UNPKG

@oystehr/sdk

Version:

Oystehr SDK

284 lines (281 loc) 11.9 kB
import { v4 } from 'uuid'; import { OystehrSdkError, OystehrFHIRError } from '../errors/index.js'; const defaultProjectApiUrl = 'https://project-api.zapehr.com/v1'; const defaultFhirApiUrl = 'https://fhir-api.zapehr.com'; const STATUS_CODES_TO_RETRY = [408, 429, 500, 502, 503, 504]; const ERROR_CODES_TO_RETRY = [ 'ECONNRESET', 'ECONNREFUSED', 'EPIPE', 'ETIMEDOUT', 'UND_ERR_CONNECT_TIMEOUT', 'UND_ERR_HEADERS_TIMEOUT', 'UND_ERR_HEADERS_TIMEOUT', 'UND_ERR_SOCKET', ]; class SDKResource { config; constructor(config) { this.config = config; } request(path, method, baseUrlThunk) { return async (params, request) => { const configThunk = () => this.config; try { return await fetcher(baseUrlThunk, configThunk, path, method)(params, request); } catch (err) { const error = err; throw new OystehrSdkError({ message: error.message, code: error.code, cause: error.cause }); } }; } fhirRequest(path, method) { return async (params, request) => { try { const baseUrlThunk = () => this.config.services?.fhirApiUrl ?? defaultFhirApiUrl; const configThunk = () => this.config; // must await here to catch return await fetcher(baseUrlThunk, configThunk, path, method)(params, request); } catch (err) { // FHIR API error messages are JSON strings const fullError = err; if (typeof fullError.message === 'string') { throw new OystehrSdkError({ message: fullError.message, code: fullError.code, cause: fullError.cause, }); } throw new OystehrFHIRError({ error: fullError.message, code: fullError.code, }); } }; } } function isInternalClientRequest(request) { return 'accessToken' in request; } /** * Parse XML response in format <response><status>...</status><output>...</output></response> */ function parseXmlResponse(xmlString) { try { // Extract status const statusMatch = xmlString.match(/<status>(\d+)<\/status>/); const status = statusMatch ? parseInt(statusMatch[1], 10) : null; // Extract output - everything between <output> and </output> const outputMatch = xmlString.match(/<output>([\s\S]*?)<\/output>/); const output = outputMatch ? outputMatch[1] : null; if (status === null || output === null) { return null; } return { status, output }; } catch (_err) { return null; } } function fetcher(baseUrlThunk, configThunk, path, methodParam) { return async (params, request) => { // this function supports multiple signatures. fetcher(baseUrl, path, method)(params, request) or fetcher(baseUrl, path, method)(request) // or fetcher(baseUrl, path, method)(params) or fetcher(baseUrl, path, method)(). the types for this are handled by Client<Path, Methods> // and this is the backend implementation behind it. the heuristic we're using is that if the first param is an object with an accessToken // and there is no second param, assume the first one is the request object instead const providedParams = !!params && !request && !Array.isArray(params) && isInternalClientRequest(params) ? {} : params ?? {}; const requestCtx = !!params && !request && !Array.isArray(params) && isInternalClientRequest(params) ? params : request; const method = methodParam.toLowerCase(); const config = configThunk(); const fetchImpl = config.fetch ?? fetch; const accessToken = requestCtx?.accessToken ?? config.accessToken; const projectId = requestCtx?.projectId ?? configThunk().projectId; let finalPath = path; let finalParams = providedParams; if (!Array.isArray(providedParams)) { const [subbedPath, addlParams] = subParamsInPath(path, providedParams); finalPath = subbedPath; finalParams = addlParams; } finalPath = finalPath.replace(/^\//, ''); // remove leading slash const baseUrlEvaluated = baseUrlThunk(); const fullBaseUrl = baseUrlEvaluated.endsWith('/') ? baseUrlEvaluated : baseUrlEvaluated + '/'; const url = new URL(finalPath, fullBaseUrl); let body; if (Array.isArray(finalParams)) { body = JSON.stringify(finalParams); } else if (Object.keys(finalParams).length) { if (method === 'get') { addParamsToSearch(finalParams, url.searchParams); } else if (requestCtx?.contentType === 'application/x-www-form-urlencoded') { const search = new URLSearchParams(); addParamsToSearch(finalParams, search); body = search.toString(); } else { body = JSON.stringify(finalParams); } } else { // override for rpc call if (requestCtx?.contentType !== 'application/x-www-form-urlencoded' && method === 'post') { body = '{}'; } } const headers = Object.assign(projectId ? { 'x-zapehr-project-id': projectId, 'x-oystehr-project-id': projectId, } : {}, { 'content-type': requestCtx?.contentType ?? 'application/json', }, accessToken ? { Authorization: `Bearer ${accessToken}` } : {}, requestCtx?.ifMatch ? { 'If-Match': requestCtx.ifMatch } : {}, { 'x-oystehr-request-id': requestCtx?.requestId ?? v4() }); const retryConfig = { retries: config.retry?.retries ?? 3, jitter: config.retry?.jitter ?? 20, delay: config.retry?.delay ?? 100, onRetry: config.retry?.onRetry, // Using array instead of set because the length is too short for uniqueness to be important retryOn: [...(config.retry?.retryOn ?? []), ...STATUS_CODES_TO_RETRY], }; retryConfig.retryOn.push(...STATUS_CODES_TO_RETRY); return retry(async () => { const response = await fetchImpl(new Request(url, { method: method.toUpperCase(), body, headers, })); const responseBody = response.body ? await response.text() : null; let responseJson; const contentType = response.headers.get('content-type'); try { if (responseBody && (contentType?.includes('application/json') || contentType?.includes('application/fhir+json'))) { responseJson = JSON.parse(responseBody); } else if (responseBody && (contentType?.includes('application/xml') || contentType?.includes('text/xml'))) { // Parse XML response into { status, output } structure responseJson = parseXmlResponse(responseBody); } else { responseJson = null; } } catch (_err) { // ignore JSON.parse errors responseJson = null; } const isError = !response.ok || response.status >= 400; if (isError) { const errObj = { message: (typeof responseJson?.output === 'string' ? responseJson.output // XML error case - output is XML string : responseJson?.output?.message) ?? // official zambda output format (JSON) responseJson?.message ?? // normal endpoint output format responseJson ?? // parsable json responseBody ?? // raw response response.statusText, // fallback to status text code: responseJson?.output?.code ?? // official zambda output format responseJson?.code ?? // normal endpoint output format response.status, // fallback to status code response, }; throw errObj; } return responseJson; }, retryConfig); }; } async function retry(work, config) { let lastErr; for (const attempt of Array.from({ length: (config.retries ?? 0) + 1 }, (_, index) => index)) { try { return await work(attempt); } catch (e) { let isRetryable = false; if ('response' in e) { // error from API const err = e; isRetryable = config.retryOn.includes(err.code); // Removes response lastErr = { message: e.message, code: e.code }; } else { lastErr = e; // error from fetch if ('code' in e && typeof e.code === 'string') { const err = e; isRetryable = ERROR_CODES_TO_RETRY.includes(err.code); } } if (!isRetryable) { break; } const jitter = Math.floor(Math.random() * (config.jitter + 1)); await new Promise((resolve) => setTimeout(resolve, config.delay + jitter)); if (config.onRetry && attempt !== (config.retries ?? 0)) { config.onRetry(attempt + 1); } } } throw lastErr; } /** * Substitutes params in a path and returns the path with params substituted and any unused params. * * Uses the property names in the params object to determine the param to substitute in the path. * * @param path JSON API resource URI * @param params all params provided to the client method * @returns resource URI with params substituted and any unused params */ function subParamsInPath(path, params) { const unusedParams = { ...params }; // capture everything of the form `{paramName}` and replace with the value of `params[paramName]` const subbedPath = path.replace(/\{([^}]+)\}/g, (_, paramName) => { delete unusedParams[paramName]; // override for path params that are paths, indicated by a `+` at the end if (paramName.match(/^.*\+$/)) { return params[paramName] + ''; } // error if param value is empty if (!params[paramName] || params[paramName] === '') { throw new OystehrSdkError({ message: `Required path parameter is an empty string: ${paramName}`, code: 400 }); } // encode search params if (params[paramName]) { return encodeURIComponent(params[paramName] + ''); // coerce to string } return ''; }); const unusedKeys = Object.keys(unusedParams); const addlParams = unusedKeys.length ? unusedKeys.reduce((acc, key) => ({ ...acc, [key]: unusedParams[key] }), {}) : {}; return [subbedPath, addlParams]; } /** * Adds params to a URLSearchParams object in such a way as to preserve array values. * @param params params * @param search URLSearchParams object */ function addParamsToSearch(params, search) { for (const [key, value] of Object.entries(params)) { if (Array.isArray(value)) { value.forEach((v) => search.append(key, v)); continue; } search.append(key, value); } } export { SDKResource, addParamsToSearch, defaultProjectApiUrl }; //# sourceMappingURL=client.js.map