@oystehr/sdk
Version:
Oystehr SDK
402 lines (369 loc) • 12.6 kB
text/typescript
import { Address as AddressR4B, HumanName as HumanNameR4B } from 'fhir/r4b';
import { Address as AddressR5, HumanName as HumanNameR5 } from 'fhir/r5';
import {
BatchBundle,
BatchInput,
BatchInputRequest,
Binary,
Bundle,
BundleEntry,
FhirBundle,
FhirCreateParams,
FhirDeleteParams,
FhirGetParams,
FhirHistoryGetParams,
FhirHistorySearchParams,
FhirPatchParams,
FhirResource,
FhirResourceReturnValue,
FhirSearchParams,
FhirUpdateParams,
OperationOutcome,
TransactionBundle,
} from '../..';
import { addParamsToSearch, FhirFetcherResponse, OystehrClientRequest, SDKResource } from '../../client/client';
// Code adapted from https://github.com/sindresorhus/uint8array-extras
const MAX_BLOCK_SIZE = 65_535;
function stringToBase64(input: string): string {
const data: Uint8Array<ArrayBuffer> = new globalThis.TextEncoder().encode(input);
let base64 = '';
for (let index = 0; index < data.length; index += MAX_BLOCK_SIZE) {
const chunk = data.subarray(index, index + MAX_BLOCK_SIZE);
// Required as `btoa` and `atob` don't properly support Unicode: https://developer.mozilla.org/en-US/docs/Glossary/Base64#the_unicode_problem
base64 += globalThis.btoa(String.fromCodePoint.apply(undefined, chunk as unknown as number[]));
}
return base64;
}
/**
* 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'. Also support enabling optimistic locking.
*/
export interface OystehrFHIRUpdateClientRequest extends OystehrClientRequest {
/**
* Enable optimistic locking for the request. If set to a version ID, the request will
* include the 'If-Match' header with that value in the FHIR optimistic-locking format.
* If the resource has been updated since the version provided, the request
* will fail with a 412 Precondition Failed error.
*/
optimisticLockingVersionId?: string;
}
/**
* Performs a FHIR search and returns the results as a Bundle resource
*
* @param options FHIR resource type and FHIR search parameters
* @param request optional OystehrClientRequest object
* @returns FHIR Bundle resource
*/
export async function search<T extends FhirResource>(
this: SDKResource,
params: FhirSearchParams<T>,
request?: OystehrClientRequest
): Promise<FhirFetcherResponse<Bundle<T>>> {
const { resourceType, params: searchParams } = params;
let paramMap: Record<string, (string | number)[]> | undefined;
if (searchParams) {
paramMap = Object.entries(searchParams).reduce((acc, [_, param]) => {
if (!acc[param.name]) {
acc[param.name] = [];
}
acc[param.name].push(param.value);
return acc;
}, {} as Record<string, (string | number)[]>);
}
const requestBundle = await this.fhirRequest<FhirBundle<T>>(`/${resourceType}/_search`, 'POST')(paramMap, {
...request,
contentType: 'application/x-www-form-urlencoded',
});
const bundle: Bundle<T> = {
...requestBundle,
entry: requestBundle.entry as Array<BundleEntry<T>> | undefined,
unbundle: function (this: { entry?: Array<BundleEntry<T>> | undefined }) {
return (
this.entry?.map((entry) => entry.resource).filter((resource): resource is T => resource !== undefined) ?? []
);
},
};
return bundle;
}
export async function create<T extends FhirResource>(
this: SDKResource,
params: FhirCreateParams<T>,
request?: OystehrClientRequest
): Promise<FhirFetcherResponse<FhirResourceReturnValue<T>>> {
const { resourceType } = params;
return this.fhirRequest(`/${resourceType}`, 'POST')(params as unknown as Record<string, unknown>, request);
}
export async function get<T extends FhirResource>(
this: SDKResource,
{ resourceType, id }: FhirGetParams<T>,
request?: OystehrClientRequest
): Promise<FhirFetcherResponse<FhirResourceReturnValue<T>>> {
return this.fhirRequest<FhirResourceReturnValue<T>>(`/${resourceType}/${id}`, 'GET')({}, request);
}
export async function update<T extends FhirResource>(
this: SDKResource,
params: FhirUpdateParams<T>,
request?: OystehrFHIRUpdateClientRequest
): Promise<FhirFetcherResponse<FhirResourceReturnValue<T>>> {
const { id, resourceType } = params;
return this.fhirRequest(`/${resourceType}/${id}`, 'PUT')(params as unknown as Record<string, unknown>, {
...request,
ifMatch: request?.optimisticLockingVersionId ? `W/"${request.optimisticLockingVersionId}"` : undefined,
});
}
export async function patch<T extends FhirResource>(
this: SDKResource,
{ resourceType, id, operations }: FhirPatchParams<T>,
request?: OystehrFHIRUpdateClientRequest
): Promise<FhirFetcherResponse<FhirResourceReturnValue<T>>> {
return this.fhirRequest(`/${resourceType}/${id}`, 'PATCH')(operations, {
...request,
contentType: 'application/json-patch+json',
ifMatch: request?.optimisticLockingVersionId ? `W/"${request.optimisticLockingVersionId}"` : undefined,
});
}
async function del<T extends FhirResource>(
this: SDKResource,
{ resourceType, id }: FhirDeleteParams<T>,
request?: OystehrClientRequest
): Promise<FhirFetcherResponse<FhirResourceReturnValue<T>>> {
return this.fhirRequest(`/${resourceType}/${id}`, 'DELETE')({}, request);
}
export { del as delete };
export async function history<T extends FhirResource>(
this: SDKResource,
{ resourceType, id }: FhirHistorySearchParams<T>,
request?: OystehrClientRequest
): Promise<FhirFetcherResponse<Bundle<T>>>;
export async function history<T extends FhirResource>(
this: SDKResource,
{ resourceType, id, versionId }: FhirHistoryGetParams<T>,
request?: OystehrClientRequest
): Promise<FhirFetcherResponse<T>>;
export async function history<T extends FhirResource>(
this: SDKResource,
{ resourceType, id, count, offset }: FhirHistorySearchParams<T>,
request?: OystehrClientRequest
): Promise<FhirFetcherResponse<Bundle<T>>>;
export async function history<T extends FhirResource>(
this: SDKResource,
{
resourceType,
id,
versionId,
count,
offset,
}: { resourceType: string; id: string; versionId?: string; count?: number; offset?: number },
request?: OystehrClientRequest
): Promise<FhirFetcherResponse<Bundle<T>> | FhirFetcherResponse<T>> {
if (versionId) {
return this.fhirRequest(`/${resourceType}/${id}/_history/${versionId}`, 'GET')({}, request);
}
if (count) {
return this.fhirRequest(
`/${resourceType}/${id}/_history?_total=accurate&_count=${count}
${offset ? `&_offset=${offset}` : ''}`,
'GET'
)({}, request);
}
return this.fhirRequest(`/${resourceType}/${id}/_history?_total=accurate`, 'GET')({}, request);
}
function batchInputRequestToBundleEntryItem<T extends FhirResource>(
request: BatchInputRequest<T>
): BundleEntry<T | Binary<T>> {
const { method, url } = request;
const baseRequest = {
request: {
method,
url,
},
};
// Escape query string parameters in entry.request.url
if (url.split('?').length > 1) {
const [resource, query] = url.split('?');
const params = query
.split('&')
.map((param) => {
const [name, value] = param.split('=');
return { name, value };
})
.reduce((acc, { name, value }) => {
if (!name) {
return acc;
}
if (!acc[name]) {
acc[name] = [];
}
acc[name].push(value);
return acc;
}, {} as Record<string, string[]>);
const search = new URLSearchParams();
addParamsToSearch(params, search);
baseRequest.request.url = `${resource}?${search.toString()}`;
}
// GET, DELETE, and HEAD require no further parameters
if (['GET', 'DELETE', 'HEAD'].includes(method)) {
return baseRequest as BundleEntry<T>;
}
// PUT updates require a full resource
if (method === 'PUT') {
const { resource } = request;
return {
request: {
...baseRequest.request,
ifMatch: request.ifMatch,
},
resource: resource as T,
} as BundleEntry<T>;
}
// PATCH can be Binary resource or JSON patch
if (method === 'PATCH') {
if ('resource' in request) {
return {
request: {
...baseRequest.request,
ifMatch: request.ifMatch,
},
resource: request.resource,
} as BundleEntry<Binary<T>>;
}
const data = stringToBase64(JSON.stringify(request.operations));
return {
...baseRequest,
resource: {
resourceType: 'Binary',
contentType: 'application/json-patch+json',
data,
},
} as BundleEntry<Binary<T>>;
}
// POST creates require a full resource
if (method === 'POST') {
const { resource, fullUrl } = request;
return {
...baseRequest,
resource: resource as T,
fullUrl,
} as BundleEntry<T>;
}
// // Add ifMatch for the entries that support it
// if ('ifMatch' in request) {
// baseRequest.request = {
// ...baseRequest.request,
// ifMatch: request.ifMatch,
// };
// }
throw new Error('Unrecognized method');
}
export async function batch<BundleContentType extends FhirResource>(
this: SDKResource,
input: BatchInput<BundleContentType>,
request?: OystehrClientRequest
): Promise<FhirFetcherResponse<BatchBundle<BundleContentType>>> {
const resp = await this.fhirRequest('/', 'POST')(
{
resourceType: 'Bundle',
type: 'batch',
entry: input.requests.map(batchInputRequestToBundleEntryItem),
},
request
);
const bundle: BatchBundle<BundleContentType> = {
...resp,
entry: resp.entry as Array<BundleEntry<BundleContentType>> | undefined,
unbundle: function (this: { entry?: Array<BundleEntry<BundleContentType>> | undefined }) {
return (
this.entry
?.map((entry) => entry.resource)
.filter((resource): resource is BundleContentType => resource !== undefined) ?? []
);
},
errors: function (this: { entry?: Array<BundleEntry<BundleContentType>> | undefined }) {
return this.entry
?.filter((entry) => entry.response?.status?.startsWith('4') || entry.response?.status?.startsWith('5'))
.map((entry) => entry.response?.outcome)
.filter((outcome): outcome is OperationOutcome => outcome !== undefined);
},
};
return bundle;
}
export async function transaction<BundleContentType extends FhirResource>(
this: SDKResource,
input: BatchInput<BundleContentType>,
request?: OystehrClientRequest
): Promise<FhirFetcherResponse<TransactionBundle<BundleContentType>>> {
const resp = await this.fhirRequest('/', 'POST')(
{
resourceType: 'Bundle',
type: 'transaction',
entry: input.requests.map(batchInputRequestToBundleEntryItem),
},
request
);
const bundle: TransactionBundle<BundleContentType> = {
...resp,
entry: resp.entry as Array<BundleEntry<BundleContentType>> | undefined,
unbundle: function (this: { entry?: Array<BundleEntry<BundleContentType>> | undefined }) {
return (
this.entry
?.map((entry) => entry.resource)
.filter((resource): resource is BundleContentType => resource !== undefined) ?? []
);
},
};
return bundle;
}
export function formatAddress(
address: AddressR4B | AddressR5,
options?: { all?: boolean; use?: boolean; lineSeparator?: string }
): string {
const builder = [];
if (address.line) {
builder.push(...address.line);
}
if (address.city || address.state || address.postalCode) {
const cityStateZip = [];
if (address.city) {
cityStateZip.push(address.city);
}
if (address.state) {
cityStateZip.push(address.state);
}
if (address.postalCode) {
cityStateZip.push(address.postalCode);
}
builder.push(cityStateZip.join(', '));
}
if (address.use && (options?.all || options?.use)) {
builder.push('[' + address.use + ']');
}
return builder.join(options?.lineSeparator || ', ').trim();
}
export function formatHumanName(
name: HumanNameR4B | HumanNameR5,
options?: {
all?: boolean;
prefix?: boolean;
suffix?: boolean;
use?: boolean;
}
): string {
const builder = [];
if (name.prefix && options?.prefix !== false) {
builder.push(...name.prefix);
}
if (name.given) {
builder.push(...name.given);
}
if (name.family) {
builder.push(name.family);
}
if (name.suffix && options?.suffix !== false) {
builder.push(...name.suffix);
}
if (name.use && (options?.all || options?.use)) {
builder.push('[' + name.use + ']');
}
return builder.join(' ').trim();
}