UNPKG

@oystehr/sdk

Version:

Oystehr SDK

352 lines (331 loc) 13.5 kB
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); } }