Skip to main content
Glama
utils.ts49.3 kB
// SPDX-FileCopyrightText: Copyright Orangebot, Inc. and Medplum contributors // SPDX-License-Identifier: Apache-2.0 import type { Attachment, Bundle, CodeableConcept, Coding, Device, Extension, ExtensionValue, Identifier, Meta, ObservationDefinition, ObservationDefinitionQualifiedInterval, Patient, Practitioner, QuestionnaireResponse, QuestionnaireResponseItem, QuestionnaireResponseItemAnswer, Range, Reference, RelatedPerson, Resource, } from '@medplum/fhirtypes'; import { getTypedPropertyValue } from './fhirpath/utils'; import { formatCodeableConcept, formatHumanName } from './format'; import { OperationOutcomeError, validationError } from './outcomes'; import { isReference, isResource } from './types'; /** * QueryTypes defines the different ways to specify FHIR search parameters. * * Can be any valid input to the URLSearchParams() constructor. * * TypeScript definitions for URLSearchParams do not match runtime behavior. * The official spec only accepts string values. * Web browsers and Node.js automatically coerce values to strings. * See: https://github.com/microsoft/TypeScript/issues/32951 */ export type QueryTypes = | URLSearchParams | string[][] | Record<string, string | number | boolean | undefined> | string | undefined; export type ProfileResource = Patient | Practitioner | RelatedPerson; /** * Allowed values for `code_challenge_method` in a PKCE exchange. */ export type CodeChallengeMethod = 'plain' | 'S256'; export interface Code { code?: CodeableConcept; } export type ResourceWithCode = Resource & Code; export type WithId<T> = T & { id: string }; export function isResourceWithId<T extends Resource>( resource: unknown, resourceType?: T['resourceType'] ): resource is WithId<T> { return isResource(resource, resourceType) && 'id' in resource && typeof resource.id === 'string'; } /** * Creates a reference resource. * @param resource - The FHIR resource. * @returns A reference resource. */ export function createReference<T extends Resource>(resource: T): Reference<T> & { reference: string } { const reference = getReferenceString(resource) ?? 'undefined/undefined'; const display = getDisplayString(resource); return display === reference ? { reference } : { reference, display }; } /** * Returns a reference string for a resource. * @param input - The FHIR resource or reference. * @returns A reference string of the form resourceType/id. */ export function getReferenceString(input: (Reference & { reference: string }) | WithId<Resource>): string; export function getReferenceString(input: Reference | Resource): string | undefined; export function getReferenceString( input: Reference | Resource | (Reference & { reference: string }) | WithId<Resource> ): string | undefined { if (isReference(input)) { return input.reference; } if (isResourceWithId(input)) { return `${(input as Resource).resourceType}/${input.id}`; } return undefined; } /** * Returns the ID portion of a reference. * @param input - A FHIR reference or resource. * @returns The ID portion of a reference. */ export function resolveId(input: Reference | Resource | undefined): string | undefined { if (!input) { return undefined; } if (isReference(input)) { return input.reference.split('/')[1]; } return input.id; } /** * Parses a reference and returns a tuple of [ResourceType, ID]. * @param reference - A reference to a FHIR resource. * @returns A tuple containing the `ResourceType` and the ID of the resource. * @throws {@link OperationOutcomeError} If the reference cannot be parsed. */ export function parseReference<T extends Resource>(reference: Reference<T> | undefined): [T['resourceType'], string] { if (reference?.reference === undefined) { throw new OperationOutcomeError(validationError('Reference missing reference property.')); } const [type, id] = reference.reference.split('/') as [T['resourceType'] | '', string]; if (type === '' || id === '' || id === undefined) { throw new OperationOutcomeError(validationError('Unable to parse reference string.')); } return [type, id]; } /** * Normalizes Medplum's `meta.account` and `meta.accounts` into a singular array of FHIR references. * @param meta - The `meta` object of a FHIR resource. * @returns An array of references, or `undefined` if none. */ export function extractAccountReferences(meta: Meta | undefined): Reference[] | undefined { if (!meta) { return undefined; } if (meta.accounts && meta.account) { const accounts = meta.accounts; if (accounts.some((a) => a.reference === meta.account?.reference)) { return accounts; } return [meta.account, ...accounts]; } else { return arrayify(meta.accounts ?? meta.account); } } /** * Returns true if the resource is a "ProfileResource". * @param resource - The FHIR resource. * @returns True if the resource is a "ProfileResource". */ export function isProfileResource(resource: Resource): resource is ProfileResource { return ( resource.resourceType === 'Patient' || resource.resourceType === 'Practitioner' || resource.resourceType === 'RelatedPerson' ); } /** * Returns a display string for the resource. * @param resource - The input resource. * @returns Human friendly display string. */ export function getDisplayString(resource: Resource): string { if (isProfileResource(resource)) { const profileName = getProfileResourceDisplayString(resource); if (profileName) { return profileName; } } if (resource.resourceType === 'Device') { const deviceName = getDeviceDisplayString(resource); if (deviceName) { return deviceName; } } if (resource.resourceType === 'MedicationRequest' && resource.medicationCodeableConcept) { return formatCodeableConcept(resource.medicationCodeableConcept); } if (resource.resourceType === 'Subscription' && resource.criteria) { return resource.criteria; } if (resource.resourceType === 'User' && resource.email) { return resource.email; } if ('name' in resource && resource.name && typeof resource.name === 'string') { return resource.name; } if ('code' in resource && resource.code) { let code = resource.code; if (Array.isArray(code)) { code = code[0]; } if (isCodeableConcept(code)) { return formatCodeableConcept(code); } if (isTextObject(code)) { return code.text; } } return getReferenceString(resource) ?? ''; } /** * Returns a display string for a profile resource if one is found. * @param resource - The profile resource. * @returns The display name if one is found. */ function getProfileResourceDisplayString(resource: ProfileResource): string | undefined { const names = resource.name; if (names && names.length > 0) { return formatHumanName(names[0]); } return undefined; } /** * Returns a display string for a device resource if one is found. * @param device - The device resource. * @returns The display name if one is found. */ function getDeviceDisplayString(device: Device): string | undefined { const names = device.deviceName; if (names && names.length > 0) { return names[0].name; } return undefined; } /** * Returns an image URL for the resource, if one is available. * @param resource - The input resource. * @returns The image URL for the resource or undefined. */ export function getImageSrc(resource: Resource): string | undefined { if (!('photo' in resource)) { return undefined; } const photo = resource.photo; if (!photo) { return undefined; } if (Array.isArray(photo)) { for (const p of photo) { const url = getPhotoImageSrc(p); if (url) { return url; } } } else { return getPhotoImageSrc(photo); } return undefined; } function getPhotoImageSrc(photo: Attachment): string | undefined { if (photo.url && photo.contentType?.startsWith('image/')) { return photo.url; } return undefined; } /** * Returns a Date property as a Date. * When working with JSON objects, Dates are often serialized as ISO-8601 strings. * When that happens, we need to safely convert to a proper Date object. * @param date - The date property value, which could be a string or a Date object. * @returns A Date object. */ export function getDateProperty(date: string | undefined): Date | undefined { return date ? new Date(date) : undefined; } /** * Calculates the age in years from the birth date. * @param birthDateStr - The birth date or start date in ISO-8601 format YYYY-MM-DD. * @param endDateStr - Optional end date in ISO-8601 format YYYY-MM-DD. Default value is today. * @returns The age in years, months, and days. */ export function calculateAge( birthDateStr: string, endDateStr?: string ): { years: number; months: number; days: number } { const startDate = new Date(birthDateStr); startDate.setUTCHours(0, 0, 0, 0); const endDate = endDateStr ? new Date(endDateStr) : new Date(); endDate.setUTCHours(0, 0, 0, 0); const startYear = startDate.getUTCFullYear(); const startMonth = startDate.getUTCMonth(); const startDay = startDate.getUTCDate(); const endYear = endDate.getUTCFullYear(); const endMonth = endDate.getUTCMonth(); const endDay = endDate.getUTCDate(); let years = endYear - startYear; if (endMonth < startMonth || (endMonth === startMonth && endDay < startDay)) { years--; } let months = endYear * 12 + endMonth - (startYear * 12 + startMonth); if (endDay < startDay) { months--; } const days = Math.floor((endDate.getTime() - startDate.getTime()) / (1000 * 60 * 60 * 24)); return { years, months, days }; } /** * Calculates the age string for display using the age appropriate units. * If the age is greater than or equal to 2 years, then the age is displayed in years. * If the age is greater than or equal to 1 month, then the age is displayed in months. * Otherwise, the age is displayed in days. * @param birthDateStr - The birth date or start date in ISO-8601 format YYYY-MM-DD. * @param endDateStr - Optional end date in ISO-8601 format YYYY-MM-DD. Default value is today. * @returns The age string. */ export function calculateAgeString(birthDateStr: string, endDateStr?: string): string | undefined { const { years, months, days } = calculateAge(birthDateStr, endDateStr); if (years >= 2) { return years.toString().padStart(3, '0') + 'Y'; } else if (months >= 1) { return months.toString().padStart(3, '0') + 'M'; } else { return days.toString().padStart(3, '0') + 'D'; } } /** * Returns all questionnaire answers as a map by link ID. * @param response - The questionnaire response resource. * @returns Questionnaire answers mapped by link ID. */ export function getQuestionnaireAnswers( response: QuestionnaireResponse ): Record<string, QuestionnaireResponseItemAnswer> { const result: Record<string, QuestionnaireResponseItemAnswer> = {}; buildQuestionnaireAnswerItems(response.item, result); return result; } function buildQuestionnaireAnswerItems( items: QuestionnaireResponseItem[] | undefined, result: Record<string, QuestionnaireResponseItemAnswer> ): void { if (items) { for (const item of items) { if (item.linkId && item.answer && item.answer.length > 0) { result[item.linkId] = item.answer[0]; } buildQuestionnaireAnswerItems(item.item, result); } } } /** * Returns an array of questionnaire answers as a map by link ID. * @param response - The questionnaire response resource. * @returns Questionnaire answer arrays mapped by link ID. */ export function getAllQuestionnaireAnswers( response: QuestionnaireResponse ): Record<string, QuestionnaireResponseItemAnswer[]> { const result: Record<string, QuestionnaireResponseItemAnswer[]> = {}; buildAllQuestionnaireAnswerItems(response.item, result); return result; } /** * Recursively builds the questionnaire answer items map. * @param items - The current questionnaire response items. * @param result - The cumulative result map of answers. */ function buildAllQuestionnaireAnswerItems( items: QuestionnaireResponseItem[] | undefined, result: Record<string, QuestionnaireResponseItemAnswer[]> ): void { if (items) { for (const item of items) { if (item.linkId && item.answer && item.answer.length > 0) { if (result[item.linkId]) { result[item.linkId] = [...result[item.linkId], ...item.answer]; } else { result[item.linkId] = item.answer; } } buildAllQuestionnaireAnswerItems(item.item, result); } } } /** * Returns the resource identifier for the given system. * * If multiple identifiers exist with the same system, the first one is returned. * * If the system is not found, then returns undefined. * @param resource - The resource to check. * @param system - The identifier system. * @returns The identifier value if found; otherwise undefined. */ export function getIdentifier(resource: Resource, system: string): string | undefined { const identifiers = (resource as any).identifier; if (!identifiers) { return undefined; } const array = Array.isArray(identifiers) ? identifiers : [identifiers]; for (const identifier of array) { if (identifier.system === system) { return identifier.value; } } return undefined; } /** * Sets a resource identifier for the given system. * * Note that this method is only available on resources that have an "identifier" property, * and that property must be an array of Identifier objects, * which is not true for all FHIR resources. * * If the identifier already exists, then the value is updated. * * Otherwise a new identifier is added. * * @param resource - The resource to add the identifier to. * @param system - The identifier system. * @param value - The identifier value. */ export function setIdentifier(resource: Resource & { identifier?: Identifier[] }, system: string, value: string): void { const identifiers = resource.identifier; if (!identifiers) { resource.identifier = [{ system, value }]; return; } for (const identifier of identifiers) { if (identifier.system === system) { identifier.value = value; return; } } identifiers.push({ system, value }); } /** * Returns an extension value by extension URLs. * @param resource - The base resource. * @param urls - Array of extension URLs. Each entry represents a nested extension. * @returns The extension value if found; undefined otherwise. */ export function getExtensionValue(resource: any, ...urls: string[]): ExtensionValue | undefined { const extension = getExtension(resource, ...urls); if (!extension) { return undefined; } const typedValue = getTypedPropertyValue({ type: 'Extension', value: extension }, 'value[x]'); if (!typedValue) { return undefined; } return Array.isArray(typedValue) ? typedValue[0].value : typedValue.value; } /** * Returns an extension by extension URLs. * @param resource - The base resource. * @param urls - Array of extension URLs. Each entry represents a nested extension. * @returns The extension object if found; undefined otherwise. */ export function getExtension(resource: any, ...urls: string[]): Extension | undefined { // Let curr be the current resource or extension. Extensions can be nested. let curr: any = resource; // For each of the urls, try to find a matching nested extension. for (let i = 0; i < urls.length && curr; i++) { curr = (curr?.extension as Extension[] | undefined)?.find((e) => e.url === urls[i]); } return curr; } /** * Returns the FHIR JSON string representation of the input value. * * Removes properties with empty string values. * Removes objects with zero properties. * * Does not modify the input value. * If the input value does not contain any empty properties, then the original value is returned. * Otherwise, a new value is returned with the empty properties removed. * * See: https://www.hl7.org/fhir/json.html * * @param value - The input value. * @param pretty - Optional flag to pretty-print the JSON. * @returns The resulting JSON string. */ export function stringify(value: any, pretty?: boolean): string { const processedValue = removeEmptyFromUnknown(value); return JSON.stringify(processedValue, null, pretty ? 2 : undefined) ?? ''; } /** * Removes empty properties from an unknown value. * * Does not modify the input value. * * If the input value does not contain any empty properties, then the original value is returned. * * Otherwise, a new value is returned with the empty properties removed. * * @param value - The unknown input value. * @returns The value with empty properties removed. */ function removeEmptyFromUnknown(value: unknown): any { if (value === undefined || value === null || value === '') { // For null, undefined, and empty strings, return undefined return undefined; } if (typeof value === 'object') { if (Array.isArray(value)) { return removeEmptyFromArray(value); } return removeEmptyFromObject(value); } // Otherwise, return the primitive value return value; } /** * Removes empty elements from an array. * * If the input array is empty, then undefined is returned. * Otherwise, a new array is returned with the empty values replaced with null. * * FHIR arrays must maintain the same length, so null is used to replace empty values. * * @param inputArray - The input array value. * @returns The array with empty values removed. */ function removeEmptyFromArray(inputArray: unknown[]): any[] | undefined { const len = inputArray.length; if (len === 0) { return undefined; } let newArray = undefined; // Only create a new array if needed let count = 0; for (let i = 0; i < len; i++) { const inputElement = inputArray[i]; const processedElement = removeEmptyFromUnknown(inputElement); if (processedElement !== inputElement && !newArray) { newArray = Array.from(inputArray); // Clone only when a change is needed } if (processedElement === undefined) { if (newArray) { newArray[i] = null; } } else { if (newArray) { newArray[i] = processedElement; // Propagate changed element } count++; } } if (count === 0) { return undefined; } return newArray ?? inputArray; } /** * Removes empty properties from an object. * * If the input object is empty, then undefined is returned. * * @param inputObject - The input object value. * @returns The object with empty properties removed. */ function removeEmptyFromObject(inputObject: Record<string, any>): Record<string, any> | undefined { let newObject = undefined; let count = 0; // Use 'in' for faster key iteration // Using `Object.keys()` and `Object.entries()` is 2x+ slower // Using `Object.hasOwn` guard is about 50% slower // We can safely skip the hasOwn check, because this value will be passed to JSON.stringify, // which has its own property checking. // eslint-disable-next-line guard-for-in for (const key in inputObject) { const inputValue = inputObject[key]; const processedValue = removeEmptyFromUnknown(inputValue); // If the processed value is different than the input value, then we need to clone the object if (processedValue !== inputValue && !newObject) { newObject = { ...inputObject }; // Shallow clone only when a change is needed } if (processedValue === undefined) { if (newObject) { delete newObject[key]; } } else { if (newObject) { newObject[key] = processedValue; } count++; } } if (count === 0) { return undefined; } return newObject ?? inputObject; } /** * Returns true if the value is empty (null, undefined, empty string, or empty object). * @param v - Any value. * @returns True if the value is an empty string or an empty object. */ export function isEmpty(v: unknown): boolean { if (v === null || v === undefined) { return true; } const t = typeof v; if (t === 'string' || t === 'object') { return !isPopulated(v); } return false; } export type CanBePopulated = { length: number } | Record<string, any>; /** * Returns true if the value is a non-empty string, an object with a length property greater than zero, or a non-empty object * @param arg - Any value * @returns True if the value is a non-empty string, an object with a length property greater than zero, or a non-empty object */ export function isPopulated<T extends { length: number } | Record<string, any>>( arg: CanBePopulated | undefined | null ): arg is T { if (arg === null || arg === undefined) { return false; } const t = typeof arg; return ( (t === 'string' && arg !== '') || (t === 'object' && (('length' in arg && arg.length > 0) || Object.keys(arg).length > 0)) ); } /** * Returns an array with trailing empty elements removed. * For example, [1, 2, 3, null, undefined, ''] becomes [1, 2, 3]. * This is useful for FHIR arrays, which by default must maintain the same length, * but while editing we may want to trim trailing empty elements. * @param arr - The input array. * @returns The array with trailing empty elements removed. */ export function trimTrailingEmptyElements<T>(arr: T[] | undefined): T[] | undefined { if (!arr) { return undefined; } let i = arr.length - 1; while (i >= 0 && isEmpty(arr[i])) { i--; } return i >= 0 ? arr.slice(0, i + 1) : undefined; } /** * Resource equality. * Ignores meta.versionId and meta.lastUpdated. * @param object1 - The first object. * @param object2 - The second object. * @param path - Optional path string. * @returns True if the objects are equal. */ export function deepEquals(object1: unknown, object2: unknown, path?: string): boolean { if (object1 === object2) { return true; } if (isEmpty(object1) && isEmpty(object2)) { return true; } if (isEmpty(object1) || isEmpty(object2)) { return false; } if (Array.isArray(object1) && Array.isArray(object2)) { return deepEqualsArray(object1, object2); } if (Array.isArray(object1) || Array.isArray(object2)) { return false; } if (isObject(object1) && isObject(object2)) { return deepEqualsObject(object1, object2, path); } if (isObject(object1) || isObject(object2)) { return false; } return false; } function deepEqualsArray(array1: unknown[], array2: unknown[]): boolean { if (array1.length !== array2.length) { return false; } for (let i = 0; i < array1.length; i++) { if (!deepEquals(array1[i], array2[i])) { return false; } } return true; } function deepEqualsObject( object1: Record<string, unknown>, object2: Record<string, unknown>, path: string | undefined ): boolean { const keySet = new Set<string>(); Object.keys(object1).forEach((k) => keySet.add(k)); Object.keys(object2).forEach((k) => keySet.add(k)); if (path === 'meta') { keySet.delete('versionId'); keySet.delete('lastUpdated'); keySet.delete('author'); } for (const key of keySet) { const val1 = object1[key]; const val2 = object2[key]; if (!deepEquals(val1, val2, key)) { return false; } } return true; } /** * Checks if value includes all fields and values of pattern. * It doesn't matter if value has extra fields, values, etc. * @param value - The object being tested against pattern. * @param pattern - The object pattern/shape checked to exist within value. * @returns True if value includes all fields and values of pattern. */ export function deepIncludes(value: any, pattern: any): boolean { if (isEmpty(value)) { return true; } if (isEmpty(pattern)) { return false; } if (Array.isArray(value) && Array.isArray(pattern)) { return deepIncludesArray(value, pattern); } if (Array.isArray(value) || Array.isArray(pattern)) { return false; } if (isObject(value) && isObject(pattern)) { return deepIncludesObject(value, pattern); } else if (isObject(value) || isObject(pattern)) { return false; } return value === pattern; } function deepIncludesArray(value: any[], pattern: any[]): boolean { return pattern.every((patternVal) => value.some((valueVal) => deepIncludes(valueVal, patternVal))); } function deepIncludesObject(value: { [key: string]: unknown }, pattern: { [key: string]: unknown }): boolean { return Object.entries(pattern).every( ([patternKey, patternVal]) => patternKey in value && deepIncludes(value[patternKey], patternVal) ); } /** * Creates a deep clone of the input value. * * Limitations: * - Only supports JSON primitives and arrays. * - Does not support Functions, lambdas, etc. * - Does not support circular references. * * See: https://web.dev/structured-clone/ * See: https://stackoverflow.com/questions/40488190/how-is-structured-clone-algorithm-different-from-deep-copy * @param input - The input to clone. * @returns A deep clone of the input. */ export function deepClone<T>(input: T): T { return input === undefined ? input : (JSON.parse(JSON.stringify(input)) as T); } /** * Returns true if the input string is a UUID. * @param input - The input string. * @returns True if the input string matches the UUID format. */ export function isUUID(input: string): input is string { return /^[0-9A-F]{8}-[0-9A-F]{4}-[0-9A-F]{4}-[0-9A-F]{4}-[0-9A-F]{12}$/i.test(input); } /** * Returns true if the input is an object. * @param obj - The candidate object. * @returns True if the input is a non-null non-undefined object. */ export function isObject(obj: unknown): obj is Record<string, unknown> { return obj !== null && typeof obj === 'object'; } /** * Returns true if the input array is an array of strings. * @param arr - Input array. * @returns True if the input array is an array of strings. */ export function isStringArray(arr: any[]): arr is string[] { return arr.every(isString); } /** * Returns true if the input value is a string. * @param value - The candidate value. * @returns True if the input value is a string. */ export function isString(value: unknown): value is string { return typeof value === 'string'; } /** * Returns true if the input value is a Coding object. * This is a heuristic check based on the presence of the "code" property. * @param value - The candidate value. * @returns True if the input value is a Coding. */ export function isCoding(value: unknown): value is Coding & { code: string } { return isObject(value) && 'code' in value && typeof value.code === 'string'; } /** * Returns true if the input value is a CodeableConcept object. * This is a heuristic check based on the presence of the "coding" property. * @param value - The candidate value. * @returns True if the input value is a CodeableConcept. */ export function isCodeableConcept(value: unknown): value is CodeableConcept & { coding: Coding[] } { return isObject(value) && 'coding' in value && Array.isArray(value.coding) && value.coding.every(isCoding); } /** * Finds the code for a specific system in a list of CodeableConcepts. * @param categories - The list of CodeableConcepts to search. * @param system - The system to match. * @returns The code for the matching system, or undefined if not found. */ export function findCodeBySystem(categories: CodeableConcept[] | undefined, system: string): string | undefined { if (!categories) { return undefined; } for (const category of categories) { if (!category.coding) { continue; } for (const coding of category.coding) { if (coding.system === system) { return coding.code; } } } return undefined; } /** * Returns true if the input value is an object with a string text property. * This is a heuristic check based on the presence of the "text" property. * @param value - The candidate value. * @returns True if the input value is a text object. */ export function isTextObject(value: unknown): value is { text: string } { return isObject(value) && 'text' in value && typeof value.text === 'string'; } // Precompute hex octets // See: https://stackoverflow.com/a/55200387 const byteToHex: string[] = []; for (let n = 0; n < 256; n++) { byteToHex.push(n.toString(16).padStart(2, '0')); } /** * Converts an ArrayBuffer to hex string. * See: https://stackoverflow.com/a/55200387 * @param arrayBuffer - The input array buffer. * @returns The resulting hex string. */ export function arrayBufferToHex(arrayBuffer: ArrayBufferLike | ArrayBufferView): string { const buffer = normalizeArrayBufferView(arrayBuffer); const bytes = new Uint8Array(buffer); const result: string[] = new Array(bytes.length); for (let i = 0; i < bytes.length; i++) { result[i] = byteToHex[bytes[i]]; } return result.join(''); } /** * Converts an ArrayBuffer to a base-64 encoded string. * @param arrayBuffer - The input array buffer. * @returns The base-64 encoded string. */ export function arrayBufferToBase64(arrayBuffer: ArrayBufferLike | ArrayBufferView): string { const buffer = normalizeArrayBufferView(arrayBuffer); const bytes = new Uint8Array(buffer); const result: string[] = new Array(bytes.length); for (let i = 0; i < bytes.length; i++) { result[i] = String.fromCodePoint(bytes[i]); } return window.btoa(result.join('')); } /** * Normalizes an `ArrayBufferLike` (eg. an `ArrayBuffer`) to a raw `ArrayBufferLike` (without a view). If the passed buffer is a view, it gives the raw `ArrayBufferLike`. * * This is useful in cases where you need to operate on the raw bytes of an `ArrayBuffer` where a `TypedArray` (eg. `Uint32Array`) might be passed in. * This ensures that you will always operate on the raw bytes rather than accidentally truncating the input by operating on the elements of the view. * * @param typedArrayOrBuffer - The `ArrayBufferLike` (either `TypedArray` or raw `ArrayBuffer`) to normalize to raw `ArrayBuffer`. * @returns The raw `ArrayBuffer` without a view. */ export function normalizeArrayBufferView(typedArrayOrBuffer: ArrayBufferLike | ArrayBufferView): ArrayBufferLike { return ArrayBuffer.isView(typedArrayOrBuffer) ? typedArrayOrBuffer.buffer : typedArrayOrBuffer; } export function capitalize(word: string): string { if (!word) { return ''; } return word.charAt(0).toUpperCase() + word.substring(1); } export function isLowerCase(c: string): boolean { return c === c.toLowerCase() && c !== c.toUpperCase(); } export function isComplexTypeCode(code: string): boolean { return code.length > 0 && code.startsWith(code[0].toUpperCase()); } /** * Returns the difference between two paths which is often suitable to use as a key in a `Record<string, InternalSchemaElement>` * @param parentPath - The parent path that will be removed from `path`. * @param path - The element path that should be a child of `parentPath`. * @returns - The difference between `path` and `parentPath` or `undefined` if `path` is not a child of `parentPath`. */ export function getPathDifference(parentPath: string, path: string): string | undefined { const parentPathPrefix = parentPath + '.'; if (path.startsWith(parentPathPrefix)) { return path.slice(parentPathPrefix.length); } return undefined; } /** * Tries to find a code string for a given system within a given codeable concept. * @param concept - The codeable concept. * @param system - The system string. * @returns The code if found; otherwise undefined. */ export function getCodeBySystem(concept: CodeableConcept, system: string): string | undefined { return concept.coding?.find((coding) => coding.system === system)?.code; } /** * Sets a code for a given system within a given codeable concept. * @param concept - The codeable concept. * @param system - The system string. * @param code - The code value. */ export function setCodeBySystem(concept: CodeableConcept, system: string, code: string): void { if (!concept.coding) { concept.coding = []; } const coding = concept.coding.find((c) => c.system === system); if (coding) { coding.code = code; } else { concept.coding.push({ system, code }); } } /** * Tries to find an observation interval for the given patient and value. * @param definition - The observation definition. * @param patient - The patient. * @param value - The observation value. * @param category - Optional interval category restriction. * @returns The observation interval if found; otherwise undefined. */ export function findObservationInterval( definition: ObservationDefinition, patient: Patient, value: number, category?: 'reference' | 'critical' | 'absolute' ): ObservationDefinitionQualifiedInterval | undefined { return definition.qualifiedInterval?.find( (interval) => observationIntervalMatchesPatient(interval, patient) && observationIntervalMatchesValue(interval, value, definition.quantitativeDetails?.decimalPrecision) && (category === undefined || interval.category === category) ); } /** * Tries to find an observation reference range for the given patient and condition names. * @param definition - The observation definition. * @param patient - The patient. * @param names - Optional condition names. * @returns The observation interval if found; otherwise undefined. */ export function findObservationReferenceRange( definition: ObservationDefinition, patient: Patient, names?: string[] ): ObservationDefinitionQualifiedInterval | undefined { return findObservationReferenceRanges(definition, patient, names)[0]; } /** * Returns all matching observation reference range for the given patient and condition names. * @param definition - The observation definition. * @param patient - The patient. * @param names - Optional condition names. * @returns The observation intervals if found; otherwise an empty array. */ export function findObservationReferenceRanges( definition: ObservationDefinition, patient: Patient, names?: string[] ): ObservationDefinitionQualifiedInterval[] { return ( definition.qualifiedInterval?.filter( (interval) => observationIntervalMatchesPatient(interval, patient) && (!names || names.includes(interval.condition as string)) ) ?? [] ); } /** * Returns true if the patient matches the observation interval. * @param interval - The observation interval. * @param patient - The patient. * @returns True if the patient matches the observation interval. */ function observationIntervalMatchesPatient( interval: ObservationDefinitionQualifiedInterval, patient: Patient ): boolean { return observationIntervalMatchesGender(interval, patient) && observationIntervalMatchesAge(interval, patient); } /** * Returns true if the patient gender matches the observation interval. * @param interval - The observation interval. * @param patient - The patient. * @returns True if the patient gender matches the observation interval. */ function observationIntervalMatchesGender(interval: ObservationDefinitionQualifiedInterval, patient: Patient): boolean { return !interval.gender || interval.gender === patient.gender; } /** * Returns true if the patient age matches the observation interval. * @param interval - The observation interval. * @param patient - The patient. * @returns True if the patient age matches the observation interval. */ function observationIntervalMatchesAge(interval: ObservationDefinitionQualifiedInterval, patient: Patient): boolean { return !interval.age || matchesRange(calculateAge(patient.birthDate as string).years, interval.age); } /** * Returns true if the value matches the observation interval. * @param interval - The observation interval. * @param value - The observation value. * @param precision - Optional precision in number of digits. * @returns True if the value matches the observation interval. */ function observationIntervalMatchesValue( interval: ObservationDefinitionQualifiedInterval, value: number, precision?: number ): boolean { return !!interval.range && matchesRange(value, interval.range, precision); } /** * Returns true if the value is in the range accounting for precision. * @param value - The numeric value. * @param range - The numeric range. * @param precision - Optional precision in number of digits. * @returns True if the value is within the range. */ export function matchesRange(value: number, range: Range, precision?: number): boolean { return ( (range.low?.value === undefined || preciseGreaterThanOrEquals(value, range.low.value, precision)) && (range.high?.value === undefined || preciseLessThanOrEquals(value, range.high.value, precision)) ); } /** * Returns the input number rounded to the specified number of digits. * @param a - The input number. * @param precision - The precision in number of digits. * @returns The number rounded to the specified number of digits. */ export function preciseRound(a: number, precision: number): number { return Number.parseFloat(a.toFixed(precision)); } /** * Returns true if the two numbers are equal to the given precision. * @param a - The first number. * @param b - The second number. * @param precision - Optional precision in number of digits. * @returns True if the two numbers are equal to the given precision. */ export function preciseEquals(a: number, b: number, precision?: number): boolean { return toPreciseInteger(a, precision) === toPreciseInteger(b, precision); } /** * Returns true if the first number is less than the second number to the given precision. * @param a - The first number. * @param b - The second number. * @param precision - Optional precision in number of digits. * @returns True if the first number is less than the second number to the given precision. */ export function preciseLessThan(a: number, b: number, precision?: number): boolean { return toPreciseInteger(a, precision) < toPreciseInteger(b, precision); } /** * Returns true if the first number is greater than the second number to the given precision. * @param a - The first number. * @param b - The second number. * @param precision - Optional precision in number of digits. * @returns True if the first number is greater than the second number to the given precision. */ export function preciseGreaterThan(a: number, b: number, precision?: number): boolean { return toPreciseInteger(a, precision) > toPreciseInteger(b, precision); } /** * Returns true if the first number is less than or equal to the second number to the given precision. * @param a - The first number. * @param b - The second number. * @param precision - Optional precision in number of digits. * @returns True if the first number is less than or equal to the second number to the given precision. */ export function preciseLessThanOrEquals(a: number, b: number, precision?: number): boolean { return toPreciseInteger(a, precision) <= toPreciseInteger(b, precision); } /** * Returns true if the first number is greater than or equal to the second number to the given precision. * @param a - The first number. * @param b - The second number. * @param precision - Optional precision in number of digits. * @returns True if the first number is greater than or equal to the second number to the given precision. */ export function preciseGreaterThanOrEquals(a: number, b: number, precision?: number): boolean { return toPreciseInteger(a, precision) >= toPreciseInteger(b, precision); } /** * Returns an integer representation of the number with the given precision. * For example, if precision is 2, then 1.2345 will be returned as 123. * @param a - The number. * @param precision - Optional precision in number of digits. * @returns The integer with the given precision. */ function toPreciseInteger(a: number, precision?: number): number { if (precision === undefined) { return a; } return Math.round(a * Math.pow(10, precision)); } /** * Finds the first resource in the input array that matches the specified code and system. * @param resources - The array of resources to search. * @param code - The code to search for. * @param system - The system to search for. * @returns The first resource in the input array that matches the specified code and system, or undefined if no such resource is found. */ export function findResourceByCode( resources: ResourceWithCode[], code: CodeableConcept | string, system: string ): ResourceWithCode | undefined { return resources.find((r) => typeof code === 'string' ? getCodeBySystem(r.code || {}, system) === code : getCodeBySystem(r.code || {}, system) === getCodeBySystem(code, system) ); } export function arrayify<T>(value: NonNullable<T> | NonNullable<T>[]): T[]; export function arrayify<T>(value: T | T[] | undefined): T[] | undefined; export function arrayify<T>(value: T | T[] | undefined): T[] | undefined { if (value === undefined) { return undefined; } else if (Array.isArray(value)) { return value; } else { return [value]; } } export function singularize<T>(value: T | T[] | undefined): T | undefined { if (Array.isArray(value)) { return value[0]; } else { return value; } } /** * Sleeps for the specified number of milliseconds. * @param ms - Time delay in milliseconds * @returns A promise that resolves after the specified number of milliseconds. */ export const sleep = (ms: number): Promise<void> => new Promise((resolve) => { setTimeout(resolve, ms); }); /** * Splits a string into an array of strings using the specified delimiter. * Unlike the built-in split function, this function will split the string into a maximum of exactly n parts. * Trailing empty strings are included in the result. * @param str - The string to split. * @param delim - The delimiter. * @param n - The maximum number of parts to split the string into. * @returns The resulting array of strings. */ export function splitN(str: string, delim: string, n: number): string[] { const result: string[] = []; for (let i = 0; i < n - 1; i++) { const delimIndex = str.indexOf(delim); if (delimIndex < 0) { break; } else { result.push(str.slice(0, delimIndex)); str = str.slice(delimIndex + delim.length); } } result.push(str); return result; } /** * Memoizes the result of a parameterless function * @param fn - The function to be wrapped * @returns The result of the first invocation of the wrapped function */ export function lazy<T>(fn: () => T): () => T { let result: T; let executed = false; return function (): T { if (!executed) { result = fn(); executed = true; } return result; }; } export function append<T>(array: T[] | undefined, value: T): T[] { if (!array) { return [value]; } array.push(value); return array; } /** * Sorts an array of strings in place using the localeCompare method. * * This method will mutate the input array. * * @param array - The array of strings to sort. * @returns The sorted array of strings. */ export function sortStringArray(array: string[]): string[] { return array.sort((a, b) => a.localeCompare(b)); } /** * Ensures the given URL has a trailing slash. * @param url - The URL to ensure has a trailing slash. * @returns The URL with a trailing slash. */ export function ensureTrailingSlash(url: string): string { return url.endsWith('/') ? url : url + '/'; } /** * Ensures the given URL has no leading slash. * @param url - The URL to ensure has no leading slash. * @returns The URL string with no slash. */ export function ensureNoLeadingSlash(url: string): string { return url.startsWith('/') ? url.slice(1) : url; } /** * Concatenates the given base URL and URL. * * If the URL is absolute, it is returned as-is. * * @param baseUrl - The base URL. * @param path - The URL to concat. Can be relative or absolute. * @returns The concatenated URL. */ export function concatUrls(baseUrl: string | URL, path: string): string { return new URL(ensureNoLeadingSlash(path), ensureTrailingSlash(baseUrl.toString())).toString(); } /** * Concatenates a given base URL and path, ensuring the URL has the appropriate `ws://` or `wss://` protocol instead of `http://` or `https://`. * * @param baseUrl - The base URL. * @param path - The URL to concat. Can be relative or absolute. * @returns The concatenated WebSocket URL. */ export function getWebSocketUrl(baseUrl: URL | string, path: string): string { return concatUrls(baseUrl, path).toString().replace('http://', 'ws://').replace('https://', 'wss://'); } /** * Converts the given `query` to a string. * * @param query - The query to convert. The type can be any member of `QueryTypes`. * @returns The query as a string. */ export function getQueryString(query: QueryTypes): string { if (typeof query === 'object' && !Array.isArray(query) && !(query instanceof URLSearchParams)) { query = Object.fromEntries(Object.entries(query).filter((entry) => entry[1] !== undefined)); } // @ts-expect-error Technically `Record<string, string, number, boolean>` is not valid to pass into `URLSearchParams` constructor since `boolean` and `number` // are not considered to be valid values based on the WebIDL definition from WhatWG. The current runtime behavior relies on implementation-specific coercion to string under the hood. // Source: https://url.spec.whatwg.org/#dom-urlsearchparams-urlsearchparams:~:text=6.2.%20URLSearchParams,)%20init%20%3D%20%22%22)%3B return new URLSearchParams(query).toString(); } export const VALID_HOSTNAME_REGEX = /^(([a-zA-Z0-9]|[a-zA-Z0-9][a-zA-Z0-9\-_]*[a-zA-Z0-9])\.)*([A-Za-z0-9]|[A-Za-z0-9][A-Za-z0-9\-_]*[A-Za-z0-9])$/; /** * Tests whether a given input is a valid hostname. * * __NOTE: Does not validate that the input is a valid domain name, only a valid hostname.__ * * @param input - The input to test. * @returns True if `input` is a valid hostname, otherwise returns false. * * ### Valid matches: * - foo * - foo.com * - foo.bar.com * - foo.org * - foo.bar.co.uk * - localhost * - LOCALHOST * - foo-bar-baz * - foo_bar * - foobar123 * * ### Invalid matches: * - foo.com/bar * - https://foo.com * - foo_-bar_- * - foo | rm -rf / */ export function isValidHostname(input: string): boolean { return VALID_HOSTNAME_REGEX.test(input); } /** * Adds the supplied profileUrl to the resource.meta.profile if it is not already * specified * @param resource - A FHIR resource * @param profileUrl - The profile URL to add * @returns The resource */ export function addProfileToResource<T extends Resource = Resource>(resource: T, profileUrl: string): T { if (!resource?.meta?.profile?.includes(profileUrl)) { resource.meta = resource.meta ?? {}; resource.meta.profile = resource.meta.profile ?? []; resource.meta.profile.push(profileUrl); } return resource; } /** * Returns a Map of resources from a bundle, using the specified identifier system as the key. * @param resourceBundle - The bundle of resources. * @param identifierSystem - The identifier system to use for keys. * @returns Map of resources keyed by identifier value for the specified system. */ export function mapByIdentifier<T extends Resource = Resource>( resourceBundle: Bundle<T>, identifierSystem: string ): Map<string, T> { const resourceMap = new Map<string, T>( resourceBundle.entry ?.filter((e) => !!e.resource) .map((e) => [getIdentifier(e.resource as Resource, identifierSystem) as string, e.resource as T]) .filter(([i]) => i !== undefined) as [string, T][] ); return resourceMap; } /** * Removes the supplied profileUrl from the resource.meta.profile if it is present * @param resource - A FHIR resource * @param profileUrl - The profile URL to remove * @returns The resource */ export function removeProfileFromResource<T extends Resource = Resource>(resource: T, profileUrl: string): T { if (resource?.meta?.profile?.includes(profileUrl)) { const index = resource.meta.profile.indexOf(profileUrl); resource.meta.profile.splice(index, 1); } return resource; } export function flatMapFilter<T, U>(arr: T[] | undefined, fn: (value: T, idx: number) => U | U[] | undefined): U[] { const result: U[] = []; if (!arr) { return result; } for (let i = 0; i < arr.length; i++) { const resultValue = fn(arr[i], i); if (Array.isArray(resultValue)) { result.push(...resultValue); } else if (resultValue !== undefined) { result.push(resultValue); } } return result; } /** * Returns the escaped HTML string of the input string. * @param unsafe - The unsafe HTML string to escape. * @returns The escaped HTML string. */ export function escapeHtml(unsafe: string): string { return unsafe .replaceAll('&', '&amp;') .replaceAll('<', '&lt;') .replaceAll('>', '&gt;') .replaceAll('"', '&quot;') .replaceAll('“', '&ldquo;') .replaceAll('”', '&rdquo;') .replaceAll('‘', '&lsquo;') .replaceAll('’', '&rsquo;') .replaceAll('…', '&hellip;'); }

Latest Blog Posts

MCP directory API

We provide all the information about MCP servers via our MCP API.

curl -X GET 'https://glama.ai/api/mcp/v1/servers/medplum/medplum'

If you have feedback or need assistance with the MCP directory API, please join our Discord server