/**
* @typedef {Object} ServiceResponse
* @property {number} status - HTTP status code returned by the service.
* @property {*} result - Parsed JSON body of the response, or `undefined` when the body is empty.
*/
import debug from 'debug'
export const METHODS = {
GET: 'GET',
PUT: 'PUT',
POST: 'POST',
DELETE: 'DELETE'
}
const dbg = debug('scenid:sdk')
const dbgVerbose = debug('scenid:sdk:verbose')
const truncateDebug = (str, limit = 200) => {
if (str.length <= limit) return str
return `${str.slice(0, limit)}… (+${str.length - limit} chars)`
}
/**
* Base class for all Scenid service clients.
*
* Handles token acquisition, request building, and response parsing.
* All service-specific classes extend this class and call `this.get`,
* `this.post`, `this.put`, and `this.delete` to communicate with their
* respective backend service.
*/
class ScenidCloudService {
/**
* @param {string|function(): Promise.<string>} tokenSource
* Either a static token string or an async function that resolves to a
* Bearer token. The function form is used in production — the SDK calls
* it before every request so tokens are always fresh.
* @param {string} serviceUrl - Base URL of the target service (e.g. `https://crm-service.example.com`).
* @param {Object} [options]
* @param {boolean} [options.throwOnError] - When `true`, a non-2xx response throws
* instead of resolving as `{ status, result }` (the default — deliberately
* fetch()-like, so most callers can inspect `status` themselves rather than
* wrap every call in try/catch). Off by default for every `sdk.as*` client;
* `sdk.asScript()` turns it on, since a sandboxed script has no sensible way
* to notice a silently-ignored `status` on its own.
*/
constructor (tokenSource, serviceUrl, { throwOnError = false } = {}) {
this._tokenSource = tokenSource
this.serviceUrl = serviceUrl
this._throwOnError = throwOnError
}
/**
* Resolves the Bearer token for the current request.
* Accepts both a static string and an async factory function.
*
* @returns {Promise<string>}
* @private
*/
async _getToken () {
if (typeof this._tokenSource === 'string') return this._tokenSource
return this._tokenSource()
}
/**
* Makes an authenticated HTTP request to the service and returns the parsed response.
*
* Acquires a token, constructs the full endpoint URL, serialises the body as
* JSON when provided, and parses the JSON response. Throws a typed error on
* network failure or non-JSON responses.
*
* @param {string} method - HTTP method (`GET`, `POST`, `PUT`, or `DELETE`).
* @param {string} call - Path to call relative to `serviceUrl` (leading `/` optional).
* @param {*} [body] - Request body. Serialised to JSON unless already a string.
* @returns {Promise<ServiceResponse>}
* @throws {Error} `sdk/fetch-failed` – network-level failure.
* @throws {Error} `sdk/endpoint-invalid` – service returned 404 with non-JSON body.
* @throws {Error} `sdk/non-json-response` – response body could not be parsed.
* @throws {Error} Only when constructed with `throwOnError: true` — a non-2xx
* response throws instead of resolving. The message is the server's own
* `result.error` when present (a bare `namespace/reason` code, same
* convention as every other error this SDK throws), falling back to
* `sdk/request-failed`. The response status is attached as `.status` on
* the thrown Error, for a caller that wants it.
*/
async makeRequest (method, call, body) {
const finalCall = call.startsWith('/') ? call : `/${call}`
const endpoint = `${this.serviceUrl}${finalCall}`
let token
try {
token = await this._getToken()
} catch (e) {
dbg('token-failed %s', e.message)
throw e
}
const request = {
method,
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${token}`
}
}
if (body !== undefined) {
request.body = typeof body !== 'string' ? JSON.stringify(body) : body
}
dbg('→ %s %s', method, endpoint)
if (body !== undefined) {
dbg(' body: %s', truncateDebug(request.body))
dbgVerbose(' body: %s', request.body)
}
let res, text, result
const t0 = Date.now()
try {
res = await fetch(endpoint, request)
} catch (e) {
dbg('fetch-failed %s', e.message)
throw new Error('sdk/fetch-failed')
}
try {
text = await res.text()
result = text ? JSON.parse(text) : undefined
} catch (e) {
if (res.status === 404) throw new Error('sdk/endpoint-invalid')
dbg('non-json-response status=%d body=%s', res.status, text)
throw new Error('sdk/non-json-response')
}
const resultJson = result !== undefined ? JSON.stringify(result) : undefined
dbg('← %d (%dms) %s', res.status, Date.now() - t0, resultJson !== undefined ? truncateDebug(resultJson) : '(empty)')
dbgVerbose('← %d (%dms) %s', res.status, Date.now() - t0, resultJson ?? '(empty)')
if (this._throwOnError && !res.ok) {
let message = result?.error || 'sdk/request-failed'
// ValidationError responses (apiEndpoint's error handler special-cases
// these) carry a separate `errors` array with the actual per-field
// reason — e.g. `doc/validation-failed` alone means nothing to a
// tenant, but appending "temperature" is not of type "number" does.
// Appended as `code: detail`, not folded into a generic sentence, so
// the code stays parseable as the first segment (see this SDK's own
// callers that match on a bare `namespace/reason` prefix).
if (Array.isArray(result?.errors) && result.errors.length) {
const detail = result.errors.map(e => e.message).filter(Boolean).join('; ')
if (detail) message = `${message}: ${detail}`
}
const error = new Error(message)
error.status = res.status
throw error
}
return { status: res.status, result }
}
/**
* Sends an authenticated GET request.
* @param {string} call - Path relative to `serviceUrl`.
* @returns {Promise<ServiceResponse>}
*/
get (call) {
return this.makeRequest(METHODS.GET, call)
}
/**
* Sends an authenticated PUT request.
* @param {string} call - Path relative to `serviceUrl`.
* @param {*} body - Request body, serialised to JSON.
* @returns {Promise<ServiceResponse>}
*/
put (call, body) {
return this.makeRequest(METHODS.PUT, call, body)
}
/**
* Sends an authenticated POST request.
* @param {string} call - Path relative to `serviceUrl`.
* @param {*} [body] - Request body, serialised to JSON.
* @returns {Promise<ServiceResponse>}
*/
post (call, body) {
return this.makeRequest(METHODS.POST, call, body)
}
/**
* Sends an authenticated DELETE request.
* @param {string} call - Path relative to `serviceUrl`.
* @param {*} [body] - Optional request body, serialised to JSON.
* @returns {Promise<ServiceResponse>}
*/
delete (call, body) {
return this.makeRequest(METHODS.DELETE, call, body)
}
}
export default ScenidCloudService
Source