import { HttpMethod } from 'openapi-typescript-helpers'; import { RequiredKeys } from 'ts-essentials'; import { getConfig } from '../config'; import { ZapEHRSdkError } from '../errors'; import { paths as projectPaths, servers as projectServers } from './openapi/project-api'; /** * 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 ZapEHRClientRequest { /** * The access token to use for the request. If not provided, the access token from `zapehr.init()` will be used. */ accessToken?: string; /** * The project ID to use for the request. If not provided, the project ID from `zapehr.init()` will be used. */ projectId?: string; /** * The value of the 'Content-Type' header to use for the request. */ contentType?: string; } export interface InternalClientRequest extends ZapEHRClientRequest { ifMatch?: string; } export function projectClient< Path extends keyof projectPaths, PathMethods extends Extract >(path: Path): Client { const baseUrl = (): string => getConfig().ZAPEHR_PROJECT_API_URL ?? projectServers[0]; return new Proxy( // @ts-expect-error proxy type shenanigans // eslint-disable-next-line @typescript-eslint/no-empty-function { get() {}, post() {}, put() {}, delete() {}, options() {}, head() {}, patch() {} }, { get(_, method: string) { try { return fetcher(baseUrl, path, method); } catch (err: any) { const error = err as { message: string; code: number }; throw new ZapEHRSdkError(error); } }, } ); } type FetcherResponse = any; export function fetcher(baseUrlThunk: () => string, path: string, methodParam: string) { return async ( params?: Record | [any], request?: InternalClientRequest ): Promise => { // 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 // 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) && params['accessToken'] ? {} : params ?? {}; const requestCtx = !!params && !request && !Array.isArray(params) && params['accessToken'] ? (params as InternalClientRequest) : request; const method = methodParam.toLowerCase() as HttpMethod; const accessToken = requestCtx?.accessToken ?? getConfig().ZAPEHR_ACCESS_TOKEN; const projectId = requestCtx?.projectId ?? getConfig().ZAPEHR_PROJECT_ID; 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 (method === 'post') { body = '{}'; } } // @ts-expect-error this isn't correctly determining requestCtx.projectId is defined in the ternary expression const headers: HeadersInit = Object.assign( projectId ? { 'x-zapehr-project-id': projectId, } : {}, { Authorization: `Bearer ${accessToken}`, 'content-type': requestCtx?.contentType ?? 'application/json', }, requestCtx?.ifMatch ? { 'If-Match': requestCtx.ifMatch } : {} ); const response = await fetch( new Request(url, { method: method.toUpperCase(), body, headers, }) ); const responseBody = response.body ? await response.text() : null; const responseJson = responseBody && (response.headers.get('content-type')?.includes('application/json') || response.headers.get('content-type')?.includes('application/fhir+json')) ? JSON.parse(responseBody) : null; const isError = !response.ok || response.status >= 400; if (isError) { throw { message: responseJson?.message ?? responseJson ?? responseBody, code: responseJson?.code ?? response.status, }; } return responseJson; }; } /** * 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, Record] { 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 ZapEHRSdkError({ 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, 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); } } /** Helper Types **/ type AllPaths = projectPaths; type Client> = { [M in Method]: ClientMethod; }; type ClientMethod> = BodyParams< Path, Method > extends never ? PathParams extends never ? (request?: ZapEHRClientRequest) => Promise> : RequiredPathParams extends never ? (params?: PathParams, request?: ZapEHRClientRequest) => Promise> : (params: PathParams, request?: ZapEHRClientRequest) => Promise> : PathParams extends never ? (params: BodyParams, request?: ZapEHRClientRequest) => Promise> : ( params: Flatten & PathParams>, request?: ZapEHRClientRequest ) => Promise>; // Params for the path and query, if any. type PathParams = AllPaths[Path] extends { [M in Method]: { parameters: { path?: infer P; query?: infer Q } }; } ? P & Q : never; type RequiredPathParams = RequiredKeys< PathParams > extends never ? never : PathParams; // Params for body, if any. type BodyParams = AllPaths[Path] extends { [M in Method]: any; } ? AllPaths[Path][Method] extends { requestBody?: { content: { 'application/json': infer B } } } ? B : never : never; /* The type of the response for the path. For example: * readonly 'application/json': { * readonly email?: string; * readonly name?: string; * readonly projects?: readonly string[]; * readonly uuid?: string; * }; */ type PathResponseType = M extends { responses: infer Responses } ? { [Response in keyof Responses]: Responses[Response] extends { content: { 'application/json': infer Content }; } ? Content : Response extends 'default' ? Responses[Response] : never; } : never; // The 200 response for the path type PathResponse< Path extends keyof AllPaths, Method extends Extract > = AllPaths[Path] extends { [M in Method]: { responses: { 200: Record } }; } ? PathResponseType[200] : never; type Flatten = { [K in keyof T]: T[K]; // eslint-disable-next-line @typescript-eslint/ban-types } & {};