@oystehr/sdk
Version:
Oystehr SDK
352 lines (331 loc) • 13.5 kB
text/typescript
import { v4 as uuidv4 } from 'uuid';
import { OystehrConfig } from '../config';
import { OystehrFHIRError, OystehrSdkError } from '../errors';
import { FhirBundle, FhirResource, OperationOutcome } from '../resources/types';
type HttpMethod = 'get' | 'put' | 'post' | 'delete' | 'options' | 'head' | 'patch' | 'trace';
export 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',
];
/**
* Optional parameter that can be passed to the client methods. It allows
* overriding the access token or project ID, and setting various headers,
* such as 'Content-Type'.
*/
export interface OystehrClientRequest {
/**
* The access token to use for the request. If not provided, the access token from `oystehr.init()` will be used.
*/
accessToken?: string;
/**
* The project ID to use for the request. If not provided, the project ID from `oystehr.init()` will be used.
*/
projectId?: string;
/**
* The value of the 'Content-Type' header to use for the request.
*/
contentType?: string;
/**
* Unique identifier for this request.
*/
requestId?: string;
}
interface InternalClientRequest extends OystehrClientRequest {
ifMatch?: string;
}
type FhirData<T extends FhirResource> = T | T[] | FhirBundle<T>;
export type FhirFetcherResponse<T extends FhirData<FhirResource> = any> = T;
export class SDKResource {
protected readonly config: OystehrConfig;
constructor(config: OystehrConfig) {
this.config = config;
}
protected request(path: string, method: string, baseUrlThunk: () => string): FetcherFunction {
return async (params: any, request?: InternalClientRequest): Promise<FetcherResponse> => {
const configThunk = (): OystehrConfig => this.config;
try {
return await fetcher(baseUrlThunk, configThunk, path, method)(params, request);
} catch (err: any) {
const error = err as { message: string; code: number; cause?: unknown };
throw new OystehrSdkError({ message: error.message, code: error.code, cause: error.cause });
}
};
}
protected fhirRequest<T extends FhirResource = any>(path: string, method: string) {
return async (params: any, request?: InternalClientRequest): Promise<FhirFetcherResponse<T>> => {
try {
const baseUrlThunk = (): string => this.config.services?.fhirApiUrl ?? defaultFhirApiUrl;
const configThunk = (): OystehrConfig => this.config;
// must await here to catch
return await fetcher(baseUrlThunk, configThunk, path, method)(params, request);
} catch (err: unknown) {
// FHIR API error messages are JSON strings
const fullError = err as { message: string | Record<string, any>; code: number; cause?: unknown };
if (typeof fullError.message === 'string') {
throw new OystehrSdkError({
message: fullError.message,
code: fullError.code,
cause: fullError.cause,
});
}
throw new OystehrFHIRError({
error: fullError.message as OperationOutcome,
code: fullError.code,
});
}
};
}
}
export type FetcherError = { message: string; code: number };
export type FetcherResponse = any;
export type FetcherFunction = (
params?: Record<string, any> | [any] | InternalClientRequest,
request?: InternalClientRequest
) => Promise<FetcherResponse>;
function isInternalClientRequest(request: Record<string, any>): request is InternalClientRequest {
return 'accessToken' in request;
}
/**
* Parse XML response in format <response><status>...</status><output>...</output></response>
*/
function parseXmlResponse(xmlString: string): Record<string, unknown> | null {
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: () => string,
configThunk: () => OystehrConfig,
path: string,
methodParam: string
): FetcherFunction {
return async (
params?: Record<string, unknown> | [any] | InternalClientRequest,
request?: InternalClientRequest
): Promise<FetcherResponse> => {
// 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: Record<string, unknown> | [any] =
!!params && !request && !Array.isArray(params) && isInternalClientRequest(params)
? {}
: (params as Record<string, unknown>) ?? {};
const requestCtx =
!!params && !request && !Array.isArray(params) && isInternalClientRequest(params)
? (params as InternalClientRequest)
: request;
const method = methodParam.toLowerCase() as HttpMethod;
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: any;
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: Record<string, string> = 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 ?? uuidv4() }
);
const retryConfig: ConstructedRetryConfig = {
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: Record<string, unknown> | null;
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 as Record<string, unknown>)?.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 as Record<string, unknown>)?.code ?? // official zambda output format
responseJson?.code ?? // normal endpoint output format
response.status, // fallback to status code
response,
};
throw errObj;
}
return responseJson;
}, retryConfig);
};
}
type ConstructedRetryConfig = Omit<NonNullable<OystehrConfig['retry']>, 'retryOn'> & {
jitter: NonNullable<NonNullable<OystehrConfig['retry']>['jitter']>;
delay: NonNullable<NonNullable<OystehrConfig['retry']>['delay']>;
retryOn: NonNullable<NonNullable<OystehrConfig['retry']>['retryOn']>;
};
async function retry<T>(work: (attempt: number) => Promise<T>, config: ConstructedRetryConfig): Promise<T> {
let lastErr;
for (const attempt of Array.from({ length: (config.retries ?? 0) + 1 }, (_, index) => index)) {
try {
return await work(attempt);
} catch (e: any) {
let isRetryable = false;
if ('response' in e) {
// error from API
const err = e as FetcherError;
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 as { code: string };
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: string, params: Record<string, unknown>): [string, Record<string, string>] {
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
*/
export function addParamsToSearch(params: Record<string, unknown>, search: URLSearchParams): void {
for (const [key, value] of Object.entries(params)) {
if (Array.isArray(value)) {
value.forEach((v) => search.append(key, v as string));
continue;
}
search.append(key, value as string);
}
}