@oystehr/sdk
Version:
Oystehr SDK
284 lines (281 loc) • 11.9 kB
JavaScript
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