// SPDX-FileCopyrightText: Copyright Orangebot, Inc. and Medplum contributors
// SPDX-License-Identifier: Apache-2.0
import type {
AccessPolicy,
Agent,
Attachment,
Binary,
Bot,
BulkDataExport,
Bundle,
BundleEntry,
BundleLink,
ClientApplication,
Communication,
Device,
DocumentReference,
Encounter,
ExtractResource,
Identifier,
Media,
OperationOutcome,
Patient,
Practitioner,
Project,
ProjectMembership,
ProjectMembershipAccess,
ProjectSetting,
Reference,
RelatedPerson,
Resource,
ResourceType,
SearchParameter,
StructureDefinition,
Subscription,
UserConfiguration,
ValueSet,
} from '@medplum/fhirtypes';
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
/** @ts-ignore */
import type { CustomTableLayout, TDocumentDefinitions, TFontDictionary } from 'pdfmake/interfaces';
import { encodeBase64 } from './base64';
import { LRUCache } from './cache';
import type { CdsDiscoveryResponse, CdsRequest, CdsResponse } from './cds';
import { ContentType } from './contenttype';
import { encryptSHA256, getRandomString } from './crypto';
import { isBrowserEnvironment, locationUtils } from './environment';
import { TypedEventTarget } from './eventtarget';
import type {
CurrentContext,
FhircastEventContext,
FhircastEventName,
FhircastEventVersionOptional,
FhircastEventVersionRequired,
PendingSubscriptionRequest,
SubscriptionRequest,
} from './fhircast';
import {
FhircastConnection,
assertContextVersionOptional,
createFhircastMessagePayload,
isContextVersionRequired,
serializeFhircastSubscriptionRequest,
validateFhircastSubscriptionRequest,
} from './fhircast';
import { isJwt, isMedplumAccessToken, parseJWTPayload, tryGetJwtExpiration } from './jwt';
import { MedplumKeyValueClient } from './keyvalue';
import {
OperationOutcomeError,
badRequest,
isOk,
isOperationOutcome,
normalizeOperationOutcome,
notFound,
unauthorized,
unauthorizedTokenAudience,
unauthorizedTokenExpired,
validationError,
} from './outcomes';
import { ReadablePromise } from './readablepromise';
import type { IClientStorage } from './storage';
import { ClientStorage } from './storage';
import type { SubscriptionEmitter } from './subscriptions';
import { SubscriptionManager } from './subscriptions';
import { indexSearchParameter } from './types';
import { indexStructureDefinitionBundle, isDataTypeLoaded, isProfileLoaded, loadDataType } from './typeschema/types';
import type { CodeChallengeMethod, ProfileResource, QueryTypes, WithId } from './utils';
import {
arrayBufferToBase64,
concatUrls,
createReference,
ensureTrailingSlash,
getQueryString,
getReferenceString,
getWebSocketUrl,
isObject,
resolveId,
sleep,
sortStringArray,
} from './utils';
/**
* Log level for MedplumClient requests and responses.
* - 'none': No logging
* - 'basic': Log method, URL, and status code only (no sensitive headers)
* - 'verbose': Log all details including headers (may include sensitive data)
*/
export type ClientLogLevel = 'none' | 'basic' | 'verbose';
export const MEDPLUM_VERSION: string = import.meta.env.MEDPLUM_VERSION ?? '';
export const MEDPLUM_CLI_CLIENT_ID = 'medplum-cli';
export const DEFAULT_ACCEPT = ContentType.FHIR_JSON + ', */*; q=0.1';
const DEFAULT_BASE_URL = 'https://api.medplum.com/';
const DEFAULT_RESOURCE_CACHE_SIZE = 1000;
const DEFAULT_BROWSER_CACHE_TIME = 60000; // 60 seconds
const DEFAULT_NODE_CACHE_TIME = 0;
const DEFAULT_REFRESH_GRACE_PERIOD = 300000; // 5 minutes
const BINARY_URL_PREFIX = 'Binary/';
const system: Device = {
resourceType: 'Device',
id: 'system',
deviceName: [{ type: 'model-name', name: 'System' }],
};
/**
* The MedplumClientOptions interface defines configuration options for MedplumClient.
*
* All configuration settings are optional.
*/
export interface MedplumClientOptions {
/**
* Base server URL.
*
* Default value is `https://api.medplum.com/`
*
* Use this to point to a custom Medplum deployment.
*/
baseUrl?: string;
/**
* OAuth2 authorize URL.
*
* Default value is `baseUrl + "/oauth2/authorize"`.
*
* Can be specified as absolute URL or relative to baseUrl.
*
* Use this if you want to use a separate OAuth server.
*/
authorizeUrl?: string;
/**
* FHIR URL path.
*
* Default value is `fhir/R4/`.
*
* Can be specified as absolute URL or relative to baseUrl.
*
* Use this if you want to use a different path when connecting to a FHIR server.
*/
fhirUrlPath?: string;
/**
* OAuth2 token URL.
*
* Default value is `baseUrl + "/oauth2/token"`.
*
* Can be specified as absolute URL or relative to baseUrl.
*
* Use this if you want to use a separate OAuth server.
*/
tokenUrl?: string;
/**
* OAuth2 logout URL.
*
* Default value is `baseUrl + "/oauth2/logout"`.
*
* Can be specified as absolute URL or relative to baseUrl.
*
* Use this if you want to use a separate OAuth server.
*/
logoutUrl?: string;
/**
* FHIRcast Hub URL.
*
* Default value is `fhircast/STU3`.
*
* Can be specified as absolute URL or relative to `baseUrl`.
*
* Use this if you want to use a different path when connecting to a FHIRcast hub.
*/
fhircastHubUrl?: string;
/**
* The client ID.
*
* Client ID can be used for SMART-on-FHIR customization.
*/
clientId?: string;
/**
* The client secret.
*
* Client secret can be used for FHIR Oauth Client Credential flows
*/
clientSecret?: string;
/**
* The OAuth Access Token.
*
* Access Token used to connect to make request to FHIR servers
*/
accessToken?: string;
/**
* Specifies through which part of the HTTP request the client credentials should be sent.
*
* Body is the default for backwards compatibility, but header may be more desirable for applications.
*/
authCredentialsMethod?: 'body' | 'header';
/**
* Number of resources to store in the cache.
*
* Default value is `1000`.
*
* Consider using this for performance of displaying Patient or Practitioner resources.
*/
resourceCacheSize?: number;
/**
* The length of time in milliseconds to cache resources.
*
* Default value is `60000` (60 seconds).
*
* Cache time of zero disables all caching.
*
* For any individual request, the cache behavior can be overridden by setting the cache property on request options.
*
* See: {@link https://developer.mozilla.org/en-US/docs/Web/API/Request/cache}
*/
cacheTime?: number;
/**
* The length of time in milliseconds to delay requests for auto batching.
*
* Auto batching attempts to group multiple requests together into a single batch request.
*
* Default value is `0`, which disables auto batching.
*/
autoBatchTime?: number;
/**
* The refresh grace period in milliseconds.
*
* This is the amount of time before the access token expires that the client will attempt to refresh the token.
*
* Default value is `300000` (5 minutes).
*/
refreshGracePeriod?: number;
/**
* Fetch implementation.
*
* Default is `window.fetch` (if available).
*
* For Node.js applications, consider the 'node-fetch' package.
*/
fetch?: FetchLike;
/**
* Storage implementation.
*
* Default is `window.localStorage` (if available), this is the common implementation for use in the browser, or an in-memory storage implementation. If using Medplum on a server it may be useful to provide a custom storage implementation, for example using redis, a database or a file based storage. Medplum CLI is an an example of `FileSystemStorage`, for reference.
*/
storage?: IClientStorage;
/**
* Create PDF implementation.
*
* Default is none, and PDF generation is disabled.
*
* @example
* In browser environments, import the client-side pdfmake library.
*
* ```html
* <script src="pdfmake.min.js"></script>
* <script>
* async function createPdf(docDefinition, tableLayouts, fonts) {
* return new Promise((resolve) => {
* pdfMake.createPdf(docDefinition, tableLayouts, fonts).getBlob(resolve);
* });
* }
* </script>
* ```
*
* @example
* In Node.js applications:
*
* ```ts
* import type { CustomTableLayout, TDocumentDefinitions, TFontDictionary } from 'pdfmake/interfaces';
* function createPdf(
* docDefinition: TDocumentDefinitions,
* tableLayouts?: { [name: string]: CustomTableLayout },
* fonts?: TFontDictionary
* ): Promise<Buffer> {
* return new Promise((resolve, reject) => {
* const printer = new PdfPrinter(fonts ?? {});
* const pdfDoc = printer.createPdfKitDocument(docDefinition, { tableLayouts });
* const chunks: Uint8Array[] = [];
* pdfDoc.on('data', (chunk: Uint8Array) => chunks.push(chunk));
* pdfDoc.on('end', () => resolve(Buffer.concat(chunks)));
* pdfDoc.on('error', reject);
* pdfDoc.end();
* });
* }
* ```
*/
createPdf?: CreatePdfFunction;
/**
* Callback for when the client is unauthenticated.
*
* Default is do nothing.
*
* For client side applications, consider redirecting to a sign in page.
*/
onUnauthenticated?: () => void;
/**
* The default redirect behavior.
*
* The default behavior is to not follow redirects.
*
* Use "follow" to automatically follow redirects.
*/
redirect?: RequestRedirect;
/**
* When the verbose flag is set, the client will log all requests and responses to the console.
* @deprecated Use logLevel instead. Will be removed in a future version.
*/
verbose?: boolean;
/**
* Log level for requests and responses.
* - 'none': No logging (default)
* - 'basic': Log method, URL, and status code only (no sensitive headers)
* - 'verbose': Log all details including headers (may include sensitive data like tokens)
*
* @default 'none'
*/
logLevel?: ClientLogLevel;
/**
* Optional flag to enable or disable Medplum extended mode.
*
* Medplum extended mode includes a few non-standard FHIR properties such as meta.author and meta.project.
*
* Default is true.
*/
extendedMode?: boolean;
/**
* Default headers to include in all requests.
* This can be used to set custom headers such as Cookies or Authorization headers.
*/
defaultHeaders?: Record<string, string>;
/**
* Prefix to add to all keys when using `localStorage` as the backing store for `ClientStorage` (the default option in the browser).
*
* Default is `''` (no prefix).
*/
storagePrefix?: string;
}
export interface MedplumRequestOptions extends RequestInit {
/**
* Optional flag to follow "Location" or "Content-Location" URL on successful HTTP 200 "OK" responses.
*/
followRedirectOnOk?: boolean;
/**
* Optional flag to follow "Location" or "Content-Location" URL on successful HTTP 201 "Created" responses.
*/
followRedirectOnCreated?: boolean;
/**
* Optional flag to poll the status URL on successful HTTP 202 "Accepted" responses.
*/
pollStatusOnAccepted?: boolean;
/**
* Optional polling time interval in milliseconds.
* Default value is 1000 (1 second).
*/
pollStatusPeriod?: number;
/**
* Optional max number of retries that should be made in the case of a failed request. Default is `2`.
*/
maxRetries?: number;
/**
* Optional maximum time to wait between retries, in milliseconds; defaults to `2000` (2 s).
*/
maxRetryTime?: number;
/**
* Optional flag to disable auto-batching for this specific request.
* Only applies when the client is configured with auto-batching enabled.
*/
disableAutoBatch?: boolean;
}
export interface PushToAgentOptions extends MedplumRequestOptions {
/**
* Time to wait before request timeout in milliseconds; defaults to `10000` (10 s)
*/
waitTimeout?: number;
}
export type FetchLike = (url: string, options?: any) => Promise<any>;
/**
* ResourceArray is an array of resources with a bundle property.
* The bundle property is a FHIR Bundle containing the search results.
* This is useful for retrieving bundle metadata such as total, offset, and next link.
*/
export type ResourceArray<T extends Resource = Resource> = T[] & { bundle: Bundle<T> };
export interface CreatePdfFunction {
(
docDefinition: TDocumentDefinitions,
tableLayouts?: Record<string, CustomTableLayout>,
fonts?: TFontDictionary
): Promise<any>;
}
export interface BaseLoginRequest {
readonly projectId?: string;
readonly clientId?: string;
readonly resourceType?: string;
readonly scope?: string;
readonly nonce?: string;
readonly codeChallenge?: string;
readonly codeChallengeMethod?: CodeChallengeMethod;
readonly googleClientId?: string;
readonly launch?: string;
readonly redirectUri?: string;
}
export interface EmailPasswordLoginRequest extends BaseLoginRequest {
readonly email: string;
readonly password: string;
/** @deprecated Use scope of "offline" or "offline_access" instead. */
readonly remember?: boolean;
}
export interface NewUserRequest {
readonly firstName: string;
readonly lastName: string;
readonly email: string;
readonly password: string;
readonly recaptchaToken: string;
readonly recaptchaSiteKey?: string;
readonly remember?: boolean;
readonly projectId?: string;
readonly clientId?: string;
}
export interface NewProjectRequest {
readonly login: string;
readonly projectName: string;
}
export interface NewPatientRequest {
readonly login: string;
readonly projectId: string;
}
export interface GoogleCredentialResponse {
readonly clientId: string;
readonly credential: string;
}
export interface GoogleLoginRequest extends BaseLoginRequest {
readonly googleClientId: string;
readonly googleCredential: string;
readonly createUser?: boolean;
}
export interface LoginAuthenticationResponse {
readonly login: string;
readonly mfaEnrollRequired?: boolean;
readonly mfaRequired?: boolean;
readonly enrollQrCode?: string;
readonly code?: string;
readonly memberships?: ProjectMembership[];
}
export interface LoginProfileResponse {
readonly login: string;
readonly scope: string;
}
export interface LoginScopeResponse {
readonly login: string;
readonly code: string;
}
export interface LoginState {
readonly project: Reference<Project>;
readonly profile: Reference<ProfileResource>;
readonly accessToken: string;
readonly refreshToken: string;
}
export interface TokenResponse {
readonly token_type: string;
readonly id_token: string;
readonly access_token: string;
readonly refresh_token: string;
readonly expires_in: number;
readonly project: Reference<Project>;
readonly profile: Reference<ProfileResource>;
}
export interface BotEvent<T = unknown> {
readonly bot: Reference<Bot>;
readonly contentType: string;
readonly input: T;
readonly secrets: Record<string, ProjectSetting>;
readonly traceId?: string;
readonly requester?: Reference<Bot | ClientApplication | Patient | Practitioner | RelatedPerson>;
/** Headers from the original request, when invoked by HTTP request */
readonly headers?: Record<string, string | string[] | undefined>;
}
export interface InviteRequest {
resourceType: 'Patient' | 'Practitioner' | 'RelatedPerson';
firstName: string;
lastName: string;
email?: string;
externalId?: string;
scope?: 'project' | 'server';
password?: string;
sendEmail?: boolean;
membership?: Partial<ProjectMembership>;
upsert?: boolean;
forceNewMembership?: boolean;
mfaRequired?: boolean;
/** @deprecated Use membership.accessPolicy instead. */
accessPolicy?: Reference<AccessPolicy>;
/** @deprecated Use membership.access instead. */
access?: ProjectMembershipAccess[];
/** @deprecated Use membership.admin instead. */
admin?: boolean;
}
export type RateLimitInfo = {
/** Name of the rate limiter. */
name: string;
/** Remaining rate limit quota units. */
remainingUnits: number;
/** Number of seconds until the rate limit resets to its full quota. */
secondsUntilReset: number;
/** Timestamp (seconds from 1970-01-01T00:00:00Z) after which the rate limiter resets to its full quota. */
resetsAfter: number;
};
/**
* JSONPatch patch operation.
* Compatible with fast-json-patch and rfc6902 Operation.
*/
export interface PatchOperation {
readonly op: 'add' | 'remove' | 'replace' | 'copy' | 'move' | 'test';
readonly path: string;
readonly value?: any;
}
/**
* Source for a FHIR Binary.
*/
export type BinarySource = string | File | Blob | Uint8Array;
/**
* Binary upload options.
*/
export interface CreateBinaryOptions {
/**
* The binary data to upload.
*/
readonly data: BinarySource;
/**
* Content type for the binary.
*/
readonly contentType: string;
/**
* Optional filename for the binary.
*/
readonly filename?: string;
/**
* Optional security context for the binary.
*/
readonly securityContext?: Reference;
/**
* Optional fetch options. **NOTE:** only `requestOptions.signal` is respected when `onProgress` is also provided.
*/
readonly onProgress?: (e: ProgressEvent) => void;
}
export interface CreateMediaOptions extends CreateBinaryOptions {
/**
* Optional additional fields for the Media resource.
*/
readonly additionalFields?: Partial<Media>;
}
export interface CreateDocumentReferenceOptions extends CreateBinaryOptions {
/**
* Optional additional fields for the DocumentReference resource.
*/
readonly additionalFields?: Omit<Partial<DocumentReference>, 'content'>;
}
/**
* PDF upload options.
*/
export interface CreatePdfOptions extends Omit<CreateBinaryOptions, 'data' | 'contentType'> {
/**
* The PDF document definition. See {@link https://pdfmake.github.io/docs/0.1/document-definition-object/}
*/
readonly docDefinition: TDocumentDefinitions;
/**
* Optional pdfmake custom table layout.
*/
readonly tableLayouts?: Record<string, CustomTableLayout>;
/**
* Optional pdfmake custom font dictionary.
*/
readonly fonts?: TFontDictionary;
}
export interface ReadHistoryOptions {
readonly count?: number;
readonly offset?: number;
}
/**
* Email address definition.
* Compatible with nodemailer Mail.Address.
*/
export interface MailAddress {
readonly name: string;
readonly address: string;
}
/**
* Email destination definition.
*/
export type MailDestination = string | MailAddress | string[] | MailAddress[];
/**
* Email attachment definition.
* Compatible with nodemailer Mail.Options.
*/
export interface MailAttachment {
/** String, Buffer or a Stream contents for the attachment */
readonly content?: string;
/** path to a file or an URL (data uris are allowed as well) if you want to stream the file instead of including it (better for larger attachments) */
readonly path?: string;
/** filename to be reported as the name of the attached file, use of unicode is allowed. If you do not want to use a filename, set this value as false, otherwise a filename is generated automatically */
readonly filename?: string | false;
/** optional content type for the attachment, if not set will be derived from the filename property */
readonly contentType?: string;
}
/**
* Email message definition.
* Compatible with nodemailer Mail.Options.
*/
export interface MailOptions {
/** The e-mail address of the sender. All e-mail addresses can be plain `sender@server.com` or formatted `Sender Name <sender@server.com>` */
readonly from?: string | MailAddress;
/** An e-mail address that will appear on the Sender: field */
readonly sender?: string | MailAddress;
/** Comma separated list or an array of recipients e-mail addresses that will appear on the To: field */
readonly to?: MailDestination;
/** Comma separated list or an array of recipients e-mail addresses that will appear on the Cc: field */
readonly cc?: MailDestination;
/** Comma separated list or an array of recipients e-mail addresses that will appear on the Bcc: field */
readonly bcc?: MailDestination;
/** An e-mail address that will appear on the Reply-To: field */
readonly replyTo?: string | MailAddress;
/** The subject of the e-mail */
readonly subject?: string;
/** The plaintext version of the message */
readonly text?: string;
/** The HTML version of the message */
readonly html?: string;
/** An array of attachment objects */
readonly attachments?: MailAttachment[];
}
interface SchemaGraphQLResponse {
readonly data: {
readonly StructureDefinitionList: StructureDefinition[];
readonly SearchParameterList: SearchParameter[];
};
}
interface RequestCacheEntry {
readonly requestTime: number;
readonly value: ReadablePromise<any>;
}
interface AutoBatchEntry<T = any> {
readonly method: 'GET';
readonly url: string;
readonly options: MedplumRequestOptions;
readonly resolve: (value: T) => void;
readonly reject: (reason: any) => void;
}
interface RequestState {
statusUrl?: string;
pollCount?: number;
}
/**
* OAuth 2.0 Grant Type Identifiers
* Standard identifiers: {@link https://datatracker.ietf.org/doc/html/draft-ietf-oauth-v2-1-07#name-grant-types}
* JWT bearer extension: {@link https://datatracker.ietf.org/doc/html/rfc7523}
* Token exchange extension: {@link https://datatracker.ietf.org/doc/html/rfc8693}
*/
export const OAuthGrantType = {
ClientCredentials: 'client_credentials',
AuthorizationCode: 'authorization_code',
RefreshToken: 'refresh_token',
JwtBearer: 'urn:ietf:params:oauth:grant-type:jwt-bearer',
TokenExchange: 'urn:ietf:params:oauth:grant-type:token-exchange',
} as const;
export type OAuthGrantType = (typeof OAuthGrantType)[keyof typeof OAuthGrantType];
/**
* OAuth 2.0 Token Type Identifiers
* See {@link https://datatracker.ietf.org/doc/html/rfc8693#name-token-type-identifiers | RFC 8693 Section 3.1} for full details.
*/
export const OAuthTokenType = {
/** Indicates that the token is an OAuth 2.0 access token issued by the given authorization server. */
AccessToken: 'urn:ietf:params:oauth:token-type:access_token',
/** Indicates that the token is an OAuth 2.0 refresh token issued by the given authorization server. */
RefreshToken: 'urn:ietf:params:oauth:token-type:refresh_token',
/** Indicates that the token is an ID Token as defined in Section 2 of [OpenID.Core]. */
IdToken: 'urn:ietf:params:oauth:token-type:id_token',
/** Indicates that the token is a base64url-encoded SAML 1.1 [OASIS.saml-core-1.1] assertion. */
Saml1Token: 'urn:ietf:params:oauth:token-type:saml1',
/** Indicates that the token is a base64url-encoded SAML 2.0 [OASIS.saml-core-2.0-os] assertion. */
Saml2Token: 'urn:ietf:params:oauth:token-type:saml2',
} as const;
export type OAuthTokenType = (typeof OAuthTokenType)[keyof typeof OAuthTokenType];
/**
* OAuth 2.0 Client Authentication Methods
* See: https://openid.net/specs/openid-connect-core-1_0.html#ClientAuthentication
*/
export const OAuthTokenAuthMethod = {
ClientSecretBasic: 'client_secret_basic',
ClientSecretPost: 'client_secret_post',
ClientSecretJwt: 'client_secret_jwt',
PrivateKeyJwt: 'private_key_jwt',
None: 'none',
} as const;
export type OAuthTokenAuthMethod = (typeof OAuthTokenAuthMethod)[keyof typeof OAuthTokenAuthMethod];
/**
* OAuth 2.0 Client Authentication Methods
* See {@link https://datatracker.ietf.org/doc/html/rfc7523#section-2.2 | RFC 7523 Section 2.2} for full details.
*/
export const OAuthClientAssertionType = {
/** Using JWTs for Client Authentication */
JwtBearer: 'urn:ietf:params:oauth:client-assertion-type:jwt-bearer',
} as const;
export type OAuthClientAssertionType = (typeof OAuthClientAssertionType)[keyof typeof OAuthClientAssertionType];
/**
* OAuth Signing Algorithms
* See {@link https://datatracker.ietf.org/doc/html/rfc7519 | RFC 7519} for full details.
*/
export const OAuthSigningAlgorithm = {
ES256: 'ES256',
ES384: 'ES384',
ES512: 'ES512',
HS256: 'HS256',
RS256: 'RS256',
RS384: 'RS384',
RS512: 'RS512',
} as const;
export type OAuthSigningAlgorithm = (typeof OAuthSigningAlgorithm)[keyof typeof OAuthSigningAlgorithm];
interface SessionDetails {
project: Project;
membership: ProjectMembership;
profile: WithId<ProfileResource>;
config: WithId<UserConfiguration>;
accessPolicy: AccessPolicy;
}
/**
* ValueSet $expand operation parameters.
* See {@link https://hl7.org/fhir/r4/valueset-operation-expand.html | FHIR ValueSet $expand Operation Parameters} for full details.
*/
export interface ValueSetExpandParams {
url?: string;
filter?: string;
date?: string;
offset?: number;
count?: number;
}
export interface RequestProfileSchemaOptions {
/** (optional) Whether to include nested profiles, e.g. from extensions. Defaults to false. */
expandProfile?: boolean;
}
/**
* This map enumerates all the lifecycle events that `MedplumClient` emits and what the shape of the `Event` is.
*/
export type MedplumClientEventMap = {
change: { type: 'change' };
offline: { type: 'offline' };
profileRefreshing: { type: 'profileRefreshing' };
profileRefreshed: { type: 'profileRefreshed' };
storageInitialized: { type: 'storageInitialized' };
storageInitFailed: { type: 'storageInitFailed'; payload: { error: Error } };
};
/**
* The MedplumClient class provides a client for the Medplum FHIR server.
*
* The client can be used in the browser, in a Node.js application, or in a Medplum Bot.
*
* The client provides helpful methods for common operations such as:
* 1. Authenticating
* 2. Creating resources
* 3. Reading resources
* 4. Updating resources
* 5. Deleting resources
* 6. Searching
* 7. Making GraphQL queries
*
* The client can also be used to integrate with other FHIR servers. For an example, see the {@link https://github.com/medplum/medplum/tree/main/examples/medplum-demo-bots/src/epic | Epic Connection Demo Bot}.
*
* @example
* Here is a quick example of how to use the client:
*
* ```typescript
* import { MedplumClient } from '@medplum/core';
* const medplum = new MedplumClient();
* ```
*
* @example
* Create a `Patient`:
*
* ```typescript
* const patient = await medplum.createResource({
* resourceType: 'Patient',
* name: [{
* given: ['Alice'],
* family: 'Smith'
* }]
* });
* ```
*
* @example
* Read a `Patient` by ID:
*
* ```typescript
* const patient = await medplum.readResource('Patient', '123');
* console.log(patient.name[0].given[0]);
* ```
*
* @example
* Search for a `Patient` by name:
*
* ```typescript
* const bundle = await medplum.search('Patient', 'name=Alice');
* console.log(bundle.total);
* ```
*
* <head>
* <meta name="algolia:pageRank" content="100" />
* </head>
*/
export class MedplumClient extends TypedEventTarget<MedplumClientEventMap> {
private readonly options: MedplumClientOptions;
private readonly fetch: FetchLike;
private readonly createPdfImpl?: CreatePdfFunction;
private readonly storage: IClientStorage;
private readonly requestCache: LRUCache<RequestCacheEntry> | undefined;
private readonly cacheTime: number;
private readonly baseUrl: string;
private readonly fhirBaseUrl: string;
private readonly authorizeUrl: string;
private readonly tokenUrl: string;
private readonly logoutUrl: string;
private readonly fhircastHubUrl: string;
private readonly defaultHeaders: Record<string, string>;
private readonly onUnauthenticated?: () => void;
private readonly autoBatchTime: number;
private readonly autoBatchQueue: AutoBatchEntry[] | undefined;
private readonly refreshGracePeriod: number;
private subscriptionManager?: SubscriptionManager;
private medplumServer?: boolean;
private clientId?: string;
private clientSecret?: string;
private credentialsInHeader: boolean;
private autoBatchTimerId?: any;
private accessToken?: string;
private accessTokenExpires?: number;
private refreshToken?: string;
private refreshPromise?: Promise<any>;
private profilePromise?: Promise<any>;
private sessionDetails?: SessionDetails;
private currentRateLimits?: string;
private basicAuth?: string;
private initPromise: Promise<void>;
private initComplete = true;
private keyValueClient?: MedplumKeyValueClient;
private logLevel: ClientLogLevel;
constructor(options?: MedplumClientOptions) {
super();
if (options?.baseUrl) {
if (!options.baseUrl.startsWith('http')) {
throw new Error('Base URL must start with http or https');
}
}
this.options = options ?? {};
this.fetch = options?.fetch ?? getDefaultFetch();
this.storage = options?.storage ?? new ClientStorage(undefined, options?.storagePrefix);
this.createPdfImpl = options?.createPdf;
this.baseUrl = ensureTrailingSlash(options?.baseUrl ?? DEFAULT_BASE_URL);
this.fhirBaseUrl = concatUrls(this.baseUrl, options?.fhirUrlPath ?? 'fhir/R4');
this.authorizeUrl = concatUrls(this.baseUrl, options?.authorizeUrl ?? 'oauth2/authorize');
this.tokenUrl = concatUrls(this.baseUrl, options?.tokenUrl ?? 'oauth2/token');
this.logoutUrl = concatUrls(this.baseUrl, options?.logoutUrl ?? 'oauth2/logout');
this.fhircastHubUrl = concatUrls(this.baseUrl, options?.fhircastHubUrl ?? 'fhircast/STU3');
this.clientId = options?.clientId ?? '';
this.clientSecret = options?.clientSecret ?? '';
this.credentialsInHeader = options?.authCredentialsMethod === 'header';
this.defaultHeaders = options?.defaultHeaders ?? {};
this.onUnauthenticated = options?.onUnauthenticated;
this.refreshGracePeriod = options?.refreshGracePeriod ?? DEFAULT_REFRESH_GRACE_PERIOD;
this.logLevel = this.initializeLogLevel(options);
this.cacheTime =
options?.cacheTime ?? (!isBrowserEnvironment() ? DEFAULT_NODE_CACHE_TIME : DEFAULT_BROWSER_CACHE_TIME);
if (this.cacheTime > 0) {
this.requestCache = new LRUCache(options?.resourceCacheSize ?? DEFAULT_RESOURCE_CACHE_SIZE);
} else {
this.requestCache = undefined;
}
if (options?.autoBatchTime) {
this.autoBatchTime = options.autoBatchTime;
this.autoBatchQueue = [];
} else {
this.autoBatchTime = 0;
this.autoBatchQueue = undefined;
}
if (options?.accessToken) {
this.setAccessToken(options.accessToken);
}
if (this.storage.getInitPromise === undefined) {
if (!options?.accessToken) {
this.attemptResumeActiveLogin().catch(console.error);
}
this.initPromise = Promise.resolve();
this.dispatchEvent({ type: 'storageInitialized' });
} else {
this.initComplete = false;
this.initPromise = this.storage.getInitPromise();
this.initPromise
.then(() => {
if (!options?.accessToken) {
this.attemptResumeActiveLogin().catch(console.error);
}
this.initComplete = true;
this.dispatchEvent({ type: 'storageInitialized' });
})
.catch((err: Error) => {
console.error(err);
this.initComplete = true;
this.dispatchEvent({ type: 'storageInitFailed', payload: { error: err } });
});
}
this.setupStorageListener();
}
/**
* @returns Whether the client has been fully initialized or not. Should always be true unless a custom asynchronous `ClientStorage` was passed into the constructor.
*/
get isInitialized(): boolean {
return this.initComplete;
}
/**
* Gets a Promise that resolves when async initialization is complete. This is particularly useful for waiting for an async `ClientStorage` and/or authentication to finish.
* @returns A Promise that resolves when any async initialization of the client is finished.
*/
getInitPromise(): Promise<void> {
return this.initPromise;
}
/**
* Initializes the log level with backward compatibility for the verbose option.
* @param options - The client options.
* @returns The initialized log level.
*/
private initializeLogLevel(options?: MedplumClientOptions): ClientLogLevel {
if (options?.logLevel) {
return options.logLevel;
}
if (options?.verbose !== undefined) {
return options.verbose ? 'verbose' : 'none';
}
return 'none';
}
private async attemptResumeActiveLogin(): Promise<void> {
const activeLogin = this.getActiveLogin();
if (!activeLogin) {
return;
}
this.setAccessToken(activeLogin.accessToken, activeLogin.refreshToken);
await this.refreshProfile();
}
/**
* Returns the current base URL for all API requests.
* By default, this is set to `https://api.medplum.com/`.
* This can be overridden by setting the `baseUrl` option when creating the client.
* @category HTTP
* @returns The current base URL for all API requests.
*/
getBaseUrl(): string {
return this.baseUrl;
}
/**
* Returns the current authorize URL.
* By default, this is set to `https://api.medplum.com/oauth2/authorize`.
* This can be overridden by setting the `authorizeUrl` option when creating the client.
* @category HTTP
* @returns The current authorize URL.
*/
getAuthorizeUrl(): string {
return this.authorizeUrl;
}
/**
* Returns the current token URL.
* By default, this is set to `https://api.medplum.com/oauth2/token`.
* This can be overridden by setting the `tokenUrl` option when creating the client.
* @category HTTP
* @returns The current token URL.
*/
getTokenUrl(): string {
return this.tokenUrl;
}
/**
* Returns the current logout URL.
* By default, this is set to `https://api.medplum.com/oauth2/logout`.
* This can be overridden by setting the `logoutUrl` option when creating the client.
* @category HTTP
* @returns The current logout URL.
*/
getLogoutUrl(): string {
return this.logoutUrl;
}
/**
* Returns the current FHIRcast Hub URL.
* By default, this is set to `https://api.medplum.com/fhircast/STU3`.
* This can be overridden by setting the `logoutUrl` option when creating the client.
* @category HTTP
* @returns The current FHIRcast Hub URL.
*/
getFhircastHubUrl(): string {
return this.fhircastHubUrl;
}
/**
* Returns default headers to include in all requests.
* This can be used to set custom headers such as Cookies or Authorization headers.
* @category HTTP
* @returns Default headers to include in all requests.
*/
getDefaultHeaders(): Record<string, string> {
return this.defaultHeaders;
}
/**
* Clears all auth state including local storage and session storage.
* @category Authentication
*/
clear(): void {
this.storage.clear();
if (isBrowserEnvironment()) {
sessionStorage.clear();
}
this.clearActiveLogin();
}
/**
* Clears the active login from local storage.
* Does not clear all local storage (such as other logins).
* @category Authentication
*/
clearActiveLogin(): void {
this.storage.setString('activeLogin', undefined);
this.requestCache?.clear();
this.accessToken = undefined;
this.refreshToken = undefined;
this.refreshPromise = undefined;
this.accessTokenExpires = undefined;
this.sessionDetails = undefined;
this.medplumServer = undefined;
this.dispatchEvent({ type: 'change' });
}
/**
* Invalidates any cached values or cached requests for the given URL.
* @category Caching
* @param url - The URL to invalidate.
*/
invalidateUrl(url: URL | string): void {
url = url.toString();
this.requestCache?.delete(url);
}
/**
* Invalidates all cached values and flushes the cache.
* @category Caching
*/
invalidateAll(): void {
this.requestCache?.clear();
}
/**
* Invalidates all cached search results or cached requests for the given resourceType.
* @category Caching
* @param resourceType - The resource type to invalidate.
*/
invalidateSearches(resourceType: ResourceType): void {
const url = concatUrls(this.fhirBaseUrl, resourceType);
if (this.requestCache) {
for (const key of this.requestCache.keys()) {
if (key.endsWith(url) || key.includes(url + '?')) {
this.requestCache.delete(key);
}
}
}
}
/**
* Makes an HTTP GET request to the specified URL.
*
* This is a lower level method for custom requests.
* For common operations, we recommend using higher level methods
* such as `readResource()`, `search()`, etc.
* @category HTTP
* @param url - The target URL.
* @param options - Optional fetch options.
* @returns Promise to the response content.
*/
get<T = any>(url: URL | string, options: MedplumRequestOptions = {}): ReadablePromise<T> {
url = url.toString();
const cached = this.getCacheEntry(url, options);
if (cached) {
return cached.value;
}
let promise: Promise<T>;
if (url.startsWith(this.fhirBaseUrl) && this.autoBatchQueue && !options.disableAutoBatch) {
promise = new Promise<T>((resolve, reject) => {
(this.autoBatchQueue as AutoBatchEntry[]).push({
method: 'GET',
url: (url as string).replace(this.fhirBaseUrl, ''),
options,
resolve,
reject,
});
if (!this.autoBatchTimerId) {
this.autoBatchTimerId = setTimeout(() => this.executeAutoBatch(), this.autoBatchTime);
}
});
} else {
promise = this.request<T>('GET', url, options);
}
const readablePromise = new ReadablePromise(promise);
this.setCacheEntry(url, readablePromise);
return readablePromise;
}
/**
* Makes an HTTP POST request to the specified URL.
*
* This is a lower level method for custom requests.
* For common operations, we recommend using higher level methods
* such as `createResource()`.
* @category HTTP
* @param url - The target URL.
* @param body - The content body. Strings and `File` objects are passed directly. Other objects are converted to JSON.
* @param contentType - The content type to be included in the "Content-Type" header.
* @param options - Optional fetch options.
* @returns Promise to the response content.
*/
post(url: URL | string, body?: any, contentType?: string, options: MedplumRequestOptions = {}): Promise<any> {
url = url.toString();
this.setRequestBody(options, body);
if (contentType) {
this.setRequestContentType(options, contentType);
}
this.invalidateUrl(url);
return this.request('POST', url, options);
}
/**
* Makes an HTTP PUT request to the specified URL.
*
* This is a lower level method for custom requests.
* For common operations, we recommend using higher level methods
* such as `updateResource()`.
* @category HTTP
* @param url - The target URL.
* @param body - The content body. Strings and `File` objects are passed directly. Other objects are converted to JSON.
* @param contentType - The content type to be included in the "Content-Type" header.
* @param options - Optional fetch options.
* @returns Promise to the response content.
*/
put(url: URL | string, body: any, contentType?: string, options: MedplumRequestOptions = {}): Promise<any> {
url = url.toString();
this.setRequestBody(options, body);
if (contentType) {
this.setRequestContentType(options, contentType);
}
this.invalidateUrl(url);
return this.request('PUT', url, options);
}
/**
* Makes an HTTP PATCH request to the specified URL.
*
* This is a lower level method for custom requests.
* For common operations, we recommend using higher level methods
* such as `patchResource()`.
* @category HTTP
* @param url - The target URL.
* @param operations - Array of JSONPatch operations.
* @param options - Optional fetch options.
* @returns Promise to the response content.
*/
patch(url: URL | string, operations: PatchOperation[], options: MedplumRequestOptions = {}): Promise<any> {
url = url.toString();
this.setRequestBody(options, operations);
this.setRequestContentType(options, ContentType.JSON_PATCH);
this.invalidateUrl(url);
return this.request('PATCH', url, options);
}
/**
* Makes an HTTP DELETE request to the specified URL.
*
*
* This is a lower level method for custom requests.
* For common operations, we recommend using higher level methods
* such as `deleteResource()`.
* @category HTTP
* @param url - The target URL.
* @param options - Optional fetch options.
* @returns Promise to the response content.
*/
delete(url: URL | string, options?: MedplumRequestOptions): Promise<any> {
url = url.toString();
this.invalidateUrl(url);
return this.request('DELETE', url, options);
}
/**
* Initiates a new user flow.
*
* This method is part of the two different user registration flows:
* 1) New Practitioner and new Project
* 2) New Patient registration
* @category Authentication
* @param newUserRequest - Register request including email and password.
* @param options - Optional fetch options.
* @returns Promise to the authentication response.
*/
async startNewUser(
newUserRequest: NewUserRequest,
options?: MedplumRequestOptions
): Promise<LoginAuthenticationResponse> {
const { codeChallengeMethod, codeChallenge } = await this.startPkce();
return this.post(
'auth/newuser',
{
...newUserRequest,
clientId: newUserRequest.clientId ?? this.clientId,
codeChallengeMethod,
codeChallenge,
},
undefined,
options
) as Promise<LoginAuthenticationResponse>;
}
/**
* Initiates a new project flow.
*
* This requires a partial login from `startNewUser` or `startNewGoogleUser`.
* @param newProjectRequest - Register request including email and password.
* @param options - Optional fetch options.
* @returns Promise to the authentication response.
*/
async startNewProject(
newProjectRequest: NewProjectRequest,
options?: MedplumRequestOptions
): Promise<LoginAuthenticationResponse> {
return this.post('auth/newproject', newProjectRequest, undefined, options) as Promise<LoginAuthenticationResponse>;
}
/**
* Initiates a new patient flow.
*
* This requires a partial login from `startNewUser` or `startNewGoogleUser`.
* @param newPatientRequest - Register request including email and password.
* @param options - Optional fetch options.
* @returns Promise to the authentication response.
*/
async startNewPatient(
newPatientRequest: NewPatientRequest,
options?: MedplumRequestOptions
): Promise<LoginAuthenticationResponse> {
return this.post('auth/newpatient', newPatientRequest, undefined, options) as Promise<LoginAuthenticationResponse>;
}
/**
* Initiates a user login flow.
* @category Authentication
* @param loginRequest - Login request including email and password.
* @param options - Optional fetch options.
* @returns Promise to the authentication response.
*/
async startLogin(
loginRequest: EmailPasswordLoginRequest,
options?: MedplumRequestOptions
): Promise<LoginAuthenticationResponse> {
return this.post(
'auth/login',
{
...(await this.ensureCodeChallenge(loginRequest)),
clientId: loginRequest.clientId ?? this.clientId,
scope: loginRequest.scope,
},
undefined,
options
) as Promise<LoginAuthenticationResponse>;
}
/**
* Tries to sign in with Google authentication.
* The response parameter is the result of a Google authentication.
* See {@link https://developers.google.com/identity/gsi/web/guides/handle-credential-responses-js-functions | Google Sign-In Credential Response} for full details.
* @category Authentication
* @param loginRequest - Login request including Google credential response.
* @param options - Optional fetch options.
* @returns Promise to the authentication response.
*/
async startGoogleLogin(
loginRequest: GoogleLoginRequest,
options?: MedplumRequestOptions
): Promise<LoginAuthenticationResponse> {
return this.post(
'auth/google',
{
...(await this.ensureCodeChallenge(loginRequest)),
clientId: loginRequest.clientId ?? this.clientId,
scope: loginRequest.scope,
},
undefined,
options
) as Promise<LoginAuthenticationResponse>;
}
/**
* Returns the PKCE code challenge and method.
* If the login request already includes a code challenge, it is returned.
* Otherwise, a new PKCE code challenge is generated.
* @category Authentication
* @param loginRequest - The original login request.
* @returns The PKCE code challenge and method.
*/
async ensureCodeChallenge<T extends BaseLoginRequest>(loginRequest: T): Promise<T> {
if (loginRequest.codeChallenge) {
return loginRequest;
}
return { ...loginRequest, ...(await this.startPkce()) };
}
/**
* Signs out the client.
* This revokes the current token and clears token from the local cache.
* @category Authentication
*/
async signOut(): Promise<void> {
await this.post(this.logoutUrl, {});
this.clear();
}
/**
* Tries to sign in the user.
* Returns true if the user is signed in.
* This may result in navigating away to the sign in page.
* @category Authentication
* @param loginParams - Optional login parameters.
* @returns The user profile resource if available.
*/
async signInWithRedirect(loginParams?: Partial<BaseLoginRequest>): Promise<ProfileResource | undefined> {
const urlParams = new URLSearchParams(locationUtils.getSearch());
const code = urlParams.get('code');
if (!code) {
await this.requestAuthorization(loginParams);
return undefined;
}
return this.processCode(code);
}
/**
* Tries to sign out the user.
* See: https://docs.aws.amazon.com/cognito/latest/developerguide/logout-endpoint.html
* @category Authentication
*/
signOutWithRedirect(): void {
locationUtils.assign(this.logoutUrl);
}
/**
* Initiates sign in with an external identity provider.
* @param authorizeUrl - The external authorization URL.
* @param clientId - The external client ID.
* @param redirectUri - The external identity provider redirect URI.
* @param baseLogin - The Medplum login request.
* @param pkceEnabled - Whether `PKCE` should be enabled for this external auth request. Defaults to `true`.
* @category Authentication
*/
async signInWithExternalAuth(
authorizeUrl: string,
clientId: string,
redirectUri: string,
baseLogin: BaseLoginRequest,
pkceEnabled = true
): Promise<void> {
let loginRequest = baseLogin;
if (pkceEnabled) {
loginRequest = await this.ensureCodeChallenge(baseLogin);
}
locationUtils.assign(
this.getExternalAuthRedirectUri(authorizeUrl, clientId, redirectUri, loginRequest, pkceEnabled)
);
}
/**
* Exchange an external access token for a Medplum access token.
* @param token - The access token that was generated by the external identity provider.
* @param clientId - The ID of the `ClientApplication` in your Medplum project that will be making the exchange request.
* @returns The user profile resource.
* @category Authentication
*/
async exchangeExternalAccessToken(token: string, clientId?: string): Promise<ProfileResource> {
clientId = clientId ?? this.clientId;
if (!clientId) {
throw new Error('MedplumClient is missing clientId');
}
return this.fetchTokens({
grant_type: OAuthGrantType.TokenExchange,
subject_token_type: OAuthTokenType.AccessToken,
client_id: clientId,
subject_token: token,
});
}
/**
* Builds the external identity provider redirect URI.
* @param authorizeUrl - The external authorization URL.
* @param clientId - The external client ID.
* @param redirectUri - The external identity provider redirect URI.
* @param loginRequest - The Medplum login request.
* @param pkceEnabled - Whether `PKCE` should be enabled for this external auth request. Defaults to `true`.
* @returns The external identity provider redirect URI.
* @category Authentication
*/
getExternalAuthRedirectUri(
authorizeUrl: string,
clientId: string,
redirectUri: string,
loginRequest: BaseLoginRequest,
pkceEnabled = true
): string {
const url = new URL(authorizeUrl);
url.searchParams.set('response_type', 'code');
url.searchParams.set('client_id', clientId);
url.searchParams.set('redirect_uri', redirectUri);
url.searchParams.set('scope', loginRequest.scope ?? 'openid profile email');
url.searchParams.set('state', JSON.stringify(loginRequest));
if (pkceEnabled) {
const { codeChallenge, codeChallengeMethod } = loginRequest;
if (!codeChallengeMethod) {
throw new Error('`LoginRequest` for external auth must include a `codeChallengeMethod`.');
}
if (!codeChallenge) {
throw new Error('`LoginRequest` for external auth must include a `codeChallenge`.');
}
url.searchParams.set('code_challenge_method', codeChallengeMethod);
url.searchParams.set('code_challenge', codeChallenge);
}
return url.toString();
}
/**
* Builds a FHIR URL from a collection of URL path components.
* For example, `fhirUrl('Patient', '123')` returns `fhir/R4/Patient/123`.
* @category HTTP
* @param path - The path component of the URL.
* @returns The well-formed FHIR URL.
*/
fhirUrl(...path: string[]): URL {
return new URL(concatUrls(this.fhirBaseUrl, path.join('/')));
}
/**
* Builds a FHIR search URL from a search query or structured query object.
* @category HTTP
* @category Search
* @param resourceType - The FHIR resource type.
* @param query - The FHIR search query or structured query object. Can be any valid input to the URLSearchParams() constructor.
* @returns The well-formed FHIR URL.
*/
fhirSearchUrl(resourceType: ResourceType, query: QueryTypes): URL {
const url = this.fhirUrl(resourceType);
if (query) {
url.search = getQueryString(query);
}
return url;
}
/**
* Sends a FHIR search request.
*
* @example
* Example using a FHIR search string:
*
* ```typescript
* const bundle = await client.search('Patient', 'name=Alice');
* console.log(bundle);
* ```
*
* @example
* The return value is a FHIR bundle:
*
* ```json
* {
* "resourceType": "Bundle",
* "type": "searchset",
* "entry": [
* {
* "resource": {
* "resourceType": "Patient",
* "name": [
* {
* "given": [
* "George"
* ],
* "family": "Washington"
* }
* ],
* }
* }
* ]
* }
* ```
*
* @example
* To query the count of a search, use the summary feature like so:
*
* ```typescript
* const patients = medplum.search('Patient', '_summary=count');
* ```
*
* See {@link https://www.hl7.org/fhir/search.html | FHIR search} for full details.
* @category Search
* @param resourceType - The FHIR resource type.
* @param query - Optional FHIR search query or structured query object. Can be any valid input to the URLSearchParams() constructor.
* @param options - Optional fetch options.
* @returns Promise to the search result bundle.
*/
search<RT extends ResourceType>(
resourceType: RT,
query?: QueryTypes,
options?: MedplumRequestOptions
): ReadablePromise<Bundle<WithId<ExtractResource<RT>>>> {
const url = this.fhirSearchUrl(resourceType, query);
const cacheKey = 'search-' + url.toString();
const cached = this.getCacheEntry(cacheKey, options);
if (cached) {
return cached.value;
}
const promise = this.getBundle<WithId<ExtractResource<RT>>>(url, options);
this.setCacheEntry(cacheKey, promise);
return promise;
}
/**
* Sends a FHIR search request for a single resource.
*
* This is a convenience method for `search()` that returns the first resource rather than a `Bundle`.
*
* @example
* Example using a FHIR search string:
*
* ```typescript
* const patient = await client.searchOne('Patient', 'identifier=123');
* console.log(patient);
* ```
*
* The return value is the resource, if available; otherwise, undefined.
*
* See {@link https://www.hl7.org/fhir/search.html | FHIR search} for full details.
* @category Search
* @param resourceType - The FHIR resource type.
* @param query - Optional FHIR search query or structured query object. Can be any valid input to the URLSearchParams() constructor.
* @param options - Optional fetch options.
* @returns Promise to the first search result.
*/
searchOne<RT extends ResourceType>(
resourceType: RT,
query?: QueryTypes,
options?: MedplumRequestOptions
): ReadablePromise<WithId<ExtractResource<RT>> | undefined> {
const url = this.fhirSearchUrl(resourceType, query);
url.searchParams.set('_count', '1');
url.searchParams.sort();
const cacheKey = 'searchOne-' + url.toString();
const cached = this.getCacheEntry(cacheKey, options);
if (cached) {
return cached.value;
}
const promise = new ReadablePromise(
this.search<RT>(resourceType, url.searchParams, options).then((b) => b.entry?.[0]?.resource)
);
this.setCacheEntry(cacheKey, promise);
return promise;
}
/**
* Sends a FHIR search request for an array of resources.
*
* This is a convenience method for `search()` that returns the resources as an array rather than a `Bundle`.
*
* @example
* Example using a FHIR search string:
*
* ```typescript
* const patients = await client.searchResources('Patient', 'name=Alice');
* console.log(patients);
* ```
*
* The return value is an array of resources.
*
* See {@link https://www.hl7.org/fhir/search.html | FHIR search} for full details.
* @category Search
* @param resourceType - The FHIR resource type.
* @param query - Optional FHIR search query or structured query object. Can be any valid input to the URLSearchParams() constructor.
* @param options - Optional fetch options.
* @returns Promise to the array of search results.
*/
searchResources<RT extends ResourceType>(
resourceType: RT,
query?: QueryTypes,
options?: MedplumRequestOptions
): ReadablePromise<ResourceArray<WithId<ExtractResource<RT>>>> {
const url = this.fhirSearchUrl(resourceType, query);
const cacheKey = 'searchResources-' + url.toString();
const cached = this.getCacheEntry(cacheKey, options);
if (cached) {
return cached.value;
}
const promise = new ReadablePromise(this.search<RT>(resourceType, query, options).then(bundleToResourceArray));
this.setCacheEntry(cacheKey, promise);
return promise;
}
/**
* Creates an
* {@link https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/AsyncGenerator | async generator}
* over a series of FHIR search requests for paginated search results. Each iteration of the generator yields
* the array of resources on each page. Searches using _offset based pagination are limited to 10,000 records.
* For larger result sets, _cursor based pagination should be used instead.
*
* See {@link https://www.medplum.com/docs/search/paginated-search#cursor-based-pagination | the docs} for more information.
*
* @example
*
* ```typescript
* for await (const page of medplum.searchResourcePages('Patient', { _count: 10 })) {
* for (const patient of page) {
* console.log(`Processing Patient resource with ID: ${patient.id}`);
* }
* }
* ```
*
* @category Search
* @param resourceType - The FHIR resource type.
* @param query - Optional FHIR search query or structured query object. Can be any valid input to the URLSearchParams() constructor.
* @param options - Optional fetch options.
* @yields An async generator, where each result is an array of resources for each page.
*/
async *searchResourcePages<RT extends ResourceType>(
resourceType: RT,
query?: QueryTypes,
options?: MedplumRequestOptions
): AsyncGenerator<ResourceArray<WithId<ExtractResource<RT>>>> {
let url: URL | undefined = this.fhirSearchUrl(resourceType, query);
while (url) {
const searchParams: URLSearchParams = new URL(url).searchParams;
if (!searchParams.has('_count')) {
searchParams.set('_count', '1000'); // Force maximum page size to reduce server load
}
const bundle = await this.search(resourceType, searchParams, options);
const nextLink: BundleLink | undefined = bundle.link?.find((link) => link.relation === 'next');
if (!bundle.entry?.length && !nextLink) {
break;
}
yield bundleToResourceArray(bundle);
url = nextLink?.url ? new URL(nextLink.url) : undefined;
}
}
/**
* Searches a ValueSet resource using the "expand" operation.
* See: https://www.hl7.org/fhir/operation-valueset-expand.html
* @category Search
* @param params - The ValueSet expand parameters.
* @param options - Optional fetch options.
* @returns Promise to expanded ValueSet.
*/
valueSetExpand(params: ValueSetExpandParams, options?: MedplumRequestOptions): ReadablePromise<ValueSet> {
const url = this.fhirUrl('ValueSet', '$expand');
url.search = new URLSearchParams(params as Record<string, string>).toString();
return this.get(url.toString(), options);
}
/**
* Returns a cached resource if it is available.
* @category Caching
* @param resourceType - The FHIR resource type.
* @param id - The FHIR resource ID.
* @returns The resource if it is available in the cache; undefined otherwise.
*/
getCached<RT extends ResourceType>(resourceType: RT, id: string): WithId<ExtractResource<RT>> | undefined {
const cached = this.requestCache?.get(this.fhirUrl(resourceType, id).toString())?.value;
return cached?.isOk() ? (cached.read() as WithId<ExtractResource<RT>>) : undefined;
}
/**
* Returns a cached resource if it is available.
* @category Caching
* @param reference - The FHIR reference.
* @returns The resource if it is available in the cache; undefined otherwise.
*/
getCachedReference<T extends Resource>(reference: Reference<T>): T | undefined {
const refString = reference.reference as string;
if (!refString) {
return undefined;
}
if (refString === 'system') {
return system as T;
}
const [resourceType, id] = refString.split('/');
if (!resourceType || !id) {
return undefined;
}
return this.getCached(resourceType as ResourceType, id) as T | undefined;
}
/**
* Reads a resource by resource type and ID.
*
* @example
* Example:
*
* ```typescript
* const patient = await medplum.readResource('Patient', '123');
* console.log(patient);
* ```
*
* See the FHIR "read" operation for full details: https://www.hl7.org/fhir/http.html#read
* @category Read
* @param resourceType - The FHIR resource type.
* @param id - The resource ID.
* @param options - Optional fetch options.
* @returns The resource if available.
*/
readResource<RT extends ResourceType>(
resourceType: RT,
id: string,
options?: MedplumRequestOptions
): ReadablePromise<WithId<ExtractResource<RT>>> {
if (!id) {
throw new Error('The "id" parameter cannot be null, undefined, or an empty string.');
}
return this.get<WithId<ExtractResource<RT>>>(this.fhirUrl(resourceType, id), options);
}
/**
* Reads a resource by `Reference`.
*
* This is a convenience method for `readResource()` that accepts a `Reference` object.
*
* @example
* Example:
*
* ```typescript
* const serviceRequest = await medplum.readResource('ServiceRequest', '123');
* const patient = await medplum.readReference(serviceRequest.subject);
* console.log(patient);
* ```
*
* See the FHIR "read" operation for full details: https://www.hl7.org/fhir/http.html#read
* @category Read
* @param reference - The FHIR reference object.
* @param options - Optional fetch options.
* @returns The resource if available.
*/
readReference<T extends Resource>(
reference: Reference<T>,
options?: MedplumRequestOptions
): ReadablePromise<WithId<T>> {
const refString = reference.reference;
if (!refString) {
return new ReadablePromise(Promise.reject(new Error('Missing reference')));
}
if (refString === 'system') {
return new ReadablePromise(Promise.resolve(system as unknown as WithId<T>));
}
const [resourceType, id] = refString.split('/');
if (!resourceType || !id) {
return new ReadablePromise(Promise.reject(new Error('Invalid reference')));
}
return this.readResource(resourceType as ResourceType, id, options) as ReadablePromise<WithId<T>>;
}
readCanonical<RT extends ResourceType>(
resourceType: RT | RT[],
url: string,
options?: MedplumRequestOptions
): ReadablePromise<WithId<ExtractResource<RT>> | undefined> {
if (Array.isArray(resourceType)) {
return this.searchOne('' as RT, { _type: resourceType.join(','), url }, options);
} else {
return this.searchOne(resourceType, 'url=' + url, options);
}
}
/**
* Requests the schema for a resource type.
* If the schema is already cached, the promise is resolved immediately.
* @category Schema
* @param resourceType - The FHIR resource type.
* @returns Promise to a schema with the requested resource type.
*/
requestSchema(resourceType: string): Promise<void> {
if (isDataTypeLoaded(resourceType)) {
return Promise.resolve();
}
const cacheKey = resourceType + '-requestSchema';
const cached = this.getCacheEntry(cacheKey, undefined);
if (cached) {
return cached.value;
}
const promise = new ReadablePromise<void>(
(async () => {
const query = `{
StructureDefinitionList(_filter: "name eq ${resourceType}") {
resourceType,
name,
kind,
description,
type,
url,
snapshot {
element {
id,
path,
definition,
min,
max,
base {
path,
min,
max
},
contentReference,
type {
code,
profile,
targetProfile
},
binding {
strength,
valueSet
}
}
}
}
SearchParameterList(base: "${resourceType}", _count: 100) {
base,
code,
type,
expression,
target
}
}`.replaceAll(/\s+/g, ' ');
const response = (await this.graphql(query)) as SchemaGraphQLResponse;
indexStructureDefinitionBundle(response.data.StructureDefinitionList);
for (const searchParameter of response.data.SearchParameterList) {
indexSearchParameter(searchParameter);
}
})()
);
this.setCacheEntry(cacheKey, promise);
return promise;
}
/**
* Requests the schema for a profile.
* If the schema is already cached, the promise is resolved immediately.
* @category Schema
* @param profileUrl - The FHIR URL of the profile
* @param options - (optional) Additional options
* @returns Promise for schema request.
*/
requestProfileSchema(profileUrl: string, options?: RequestProfileSchemaOptions): Promise<void> {
if (!options?.expandProfile && isProfileLoaded(profileUrl)) {
return Promise.resolve();
}
const cacheKey = profileUrl + '-requestSchema' + (options?.expandProfile ? '-nested' : '');
const cached = this.getCacheEntry(cacheKey, undefined);
if (cached) {
return cached.value;
}
const promise = new ReadablePromise<void>(
(async () => {
if (options?.expandProfile) {
const url = this.fhirUrl('StructureDefinition', '$expand-profile');
url.search = new URLSearchParams({ url: profileUrl }).toString();
const sdBundle = (await this.post(url.toString(), {})) as Bundle<StructureDefinition>;
indexStructureDefinitionBundle(sdBundle);
} else {
// Just sort by lastUpdated. Ideally, it would also be based on a logical sort of version
// See https://hl7.org/fhir/references.html#canonical-matching for more discussion
const sd = await this.searchOne('StructureDefinition', {
url: profileUrl,
_sort: '-_lastUpdated',
});
if (!sd) {
console.warn(`No StructureDefinition found for ${profileUrl}!`);
return;
}
loadDataType(sd);
}
})()
);
this.setCacheEntry(cacheKey, promise);
return promise;
}
/**
* Reads resource history by resource type and ID.
*
* The return value is a bundle of all versions of the resource.
*
* @example
* Example:
*
* ```typescript
* const history = await medplum.readHistory('Patient', '123');
* console.log(history);
* ```
*
* See the {@link https://www.hl7.org/fhir/http.html#history | FHIR "history" operation} for full details.
* @category Read
* @param resourceType - The FHIR resource type.
* @param id - The resource ID.
* @param options - Optional history options.
* @param requestOptions - Optional fetch options.
* @returns Promise to the resource history.
*/
readHistory<RT extends ResourceType>(
resourceType: RT,
id: string,
options?: ReadHistoryOptions,
requestOptions?: MedplumRequestOptions
): ReadablePromise<Bundle<WithId<ExtractResource<RT>>>> {
const url = this.fhirUrl(resourceType, id, '_history');
if (options?.count) {
url.searchParams.set('_count', options.count.toString());
}
if (options?.offset) {
url.searchParams.set('_offset', options.offset.toString());
}
return this.get(url.toString(), requestOptions);
}
/**
* Reads a specific version of a resource by resource type, ID, and version ID.
*
* @example
* Example:
*
* ```typescript
* const version = await medplum.readVersion('Patient', '123', '456');
* console.log(version);
* ```
*
* See the {@link https://www.hl7.org/fhir/http.html#vread | FHIR "vread" operation} for full details.
* @category Read
* @param resourceType - The FHIR resource type.
* @param id - The resource ID.
* @param vid - The version ID.
* @param options - Optional fetch options.
* @returns The resource if available.
*/
readVersion<RT extends ResourceType>(
resourceType: RT,
id: string,
vid: string,
options?: MedplumRequestOptions
): ReadablePromise<WithId<ExtractResource<RT>>> {
return this.get(this.fhirUrl(resourceType, id, '_history', vid), options);
}
/**
* Executes the Patient "everything" operation for a patient.
*
* @example
* Example:
*
* ```typescript
* const bundle = await medplum.readPatientEverything('123');
* console.log(bundle);
* ```
*
* See the {@link https://hl7.org/fhir/operation-patient-everything.html | FHIR "patient-everything" operation} for full details.
* @category Read
* @param id - The Patient Id
* @param options - Optional fetch options.
* @returns A Bundle of all Resources related to the Patient
*/
readPatientEverything(id: string, options?: MedplumRequestOptions): ReadablePromise<Bundle> {
return this.getBundle(this.fhirUrl('Patient', id, '$everything'), options);
}
/**
* Executes the Patient "summary" operation for a patient.
*
* @example
* Example:
*
* ```typescript
* const bundle = await medplum.readPatientSummary('123');
* console.log(bundle);
* ```
*
* See the {@link https://build.fhir.org/ig/HL7/fhir-ips/index.html | International Patient Summary Implementation Guide} for full details.
*
* See the {@link https://build.fhir.org/ig/HL7/fhir-ips/OperationDefinition-summary.html | Patient summary operation} for full details.
*
* @param id - The Patient ID.
* @param options - Optional fetch options.
* @returns A patient summary bundle, organized into the patient summary sections.
*/
readPatientSummary(id: string, options?: MedplumRequestOptions): ReadablePromise<Bundle> {
return this.getBundle(this.fhirUrl('Patient', id, '$summary'), options);
}
/**
* Creates a new FHIR resource.
*
* The return value is the newly created resource, including the ID and meta.
*
* @example
* Example:
*
* ```typescript
* const result = await medplum.createResource({
* resourceType: 'Patient',
* name: [{
* family: 'Smith',
* given: ['John']
* }]
* });
* console.log(result.id);
* ```
*
* See the {@link https://www.hl7.org/fhir/http.html#create | FHIR "create" operation} for full details.
* @category Create
* @param resource - The FHIR resource to create.
* @param options - Optional fetch options.
* @returns The result of the create operation.
*/
createResource<T extends Resource>(resource: T, options?: MedplumRequestOptions): Promise<WithId<T>> {
if (!resource.resourceType) {
throw new Error('Missing resourceType');
}
this.invalidateSearches(resource.resourceType);
return this.post(this.fhirUrl(resource.resourceType), resource, undefined, options);
}
/**
* Conditionally create a new FHIR resource only if some equivalent resource does not already exist on the server.
*
* The return value is the existing resource or the newly created resource, including the ID and meta.
*
* @example
* Example:
*
* ```typescript
* const result = await medplum.createResourceIfNoneExist(
* {
* resourceType: 'Patient',
* identifier: [{
* system: 'http://example.com/mrn',
* value: '123'
* }]
* name: [{
* family: 'Smith',
* given: ['John']
* }]
* },
* 'identifier=123'
* );
* console.log(result.id);
* ```
*
* This method is syntactic sugar for:
*
* ```typescript
* return searchOne(resourceType, query) ?? createResource(resource);
* ```
*
* The query parameter only contains the search parameters (what would be in the URL following the "?").
*
* See the {@link https://www.hl7.org/fhir/http.html#create | FHIR "conditional create" operation} for full details.
* @category Create
* @param resource - The FHIR resource to create.
* @param query - The search query for an equivalent resource (should not include resource type or "?").
* @param options - Optional fetch options.
* @returns The result of the create operation.
*/
async createResourceIfNoneExist<T extends Resource>(
resource: T,
query: string,
options: MedplumRequestOptions = {}
): Promise<WithId<T>> {
const url = this.fhirUrl(resource.resourceType);
this.setRequestHeader(options, 'If-None-Exist', query);
const result = await this.post(url, resource, undefined, options);
this.cacheResource(result);
this.invalidateUrl(this.fhirUrl(resource.resourceType, resource.id as string, '_history'));
this.invalidateSearches(resource.resourceType);
return result;
}
/**
* Upsert a resource: update it in place if it exists, otherwise create it. This is done in a single, transactional
* request to guarantee data consistency.
* @param resource - The resource to update or create.
* @param query - A FHIR search query to uniquely identify the resource if it already exists.
* @param options - Optional fetch options.
* @returns The updated/created resource.
*/
async upsertResource<T extends Resource>(
resource: T,
query: QueryTypes,
options?: MedplumRequestOptions
): Promise<WithId<T>> {
// Build conditional update URL, e.g. `PUT /ResourceType?search-param=value`
const url = this.fhirSearchUrl(resource.resourceType, query);
let result = await this.put(url, resource, undefined, options);
if (!result) {
// On 304 not modified, result will be undefined
// Return the user input instead
result = resource;
}
this.cacheResource(result);
this.invalidateUrl(this.fhirUrl(resource.resourceType, resource.id as string, '_history'));
this.invalidateSearches(resource.resourceType);
return result;
}
/**
* Creates a FHIR `Attachment` with the provided data content.
*
* This is a convenience method for creating a `Binary` resource and then creating an `Attachment` element.
*
* The `data` parameter can be a string or a `File` object.
*
* A `File` object often comes from a `<input type="file">` element.
*
* @example
* Example:
*
* ```typescript
* const result = await medplum.createAttachment(myFile, 'test.jpg', 'image/jpeg');
* console.log(result);
* ```
*
* See the {@link https://www.hl7.org/fhir/http.html#create | FHIR "create" operation} for full details.
* @category Create
* @param createBinaryOptions -The binary options. See `CreateBinaryOptions` for full details.
* @param requestOptions - Optional fetch options. **NOTE:** only `options.signal` is respected when `onProgress` is also provided.
* @returns The result of the create operation.
*/
createAttachment(
createBinaryOptions: CreateBinaryOptions,
requestOptions?: MedplumRequestOptions
): Promise<Attachment>;
/**
* @category Create
* @param data - The binary data to upload.
* @param filename - Optional filename for the binary.
* @param contentType - Content type for the binary.
* @param onProgress - Optional callback for progress events. **NOTE:** only `options.signal` is respected when `onProgress` is also provided.
* @param options - Optional fetch options. **NOTE:** only `options.signal` is respected when `onProgress` is also provided.
* @returns The result of the create operation.
* @deprecated Use `createAttachment` with `CreateBinaryOptions` instead. To be removed in a future version.
*/
createAttachment(
data: BinarySource,
filename: string | undefined,
contentType: string,
onProgress?: (e: ProgressEvent) => void,
options?: MedplumRequestOptions
): Promise<Attachment>;
async createAttachment(
arg1: BinarySource | CreateBinaryOptions,
arg2: string | undefined | MedplumRequestOptions,
arg3?: string,
arg4?: (e: ProgressEvent) => void,
arg5?: MedplumRequestOptions
): Promise<Attachment> {
let createBinaryOptions = normalizeCreateBinaryOptions(arg1, arg2, arg3, arg4);
if (createBinaryOptions.contentType === ContentType.XML) {
const fileData = createBinaryOptions.data;
let fileStr: string;
if (fileData instanceof Blob) {
fileStr = await new Promise<string>((resolve, reject) => {
const reader = new FileReader();
reader.onload = () => {
if (!reader.result) {
reject(new Error('Failed to load file'));
return;
}
resolve(reader.result as string);
};
reader.readAsText(fileData, 'utf-8');
});
} else if (ArrayBuffer.isView(fileData)) {
fileStr = new TextDecoder().decode(fileData);
} else {
fileStr = fileData;
}
// Both of the above strings are required to be within a valid C-CDA document
// The root element in a CDA document should be a "ClinicalDocument"
// "urn:hl7-org:v3" is a required namespace to be referenced by all valid C-CDA documents as well
if (fileStr.includes('<ClinicalDocument') && fileStr.includes('urn:hl7-org:v3')) {
createBinaryOptions = { ...createBinaryOptions, contentType: ContentType.CDA_XML };
}
}
const requestOptions = arg5 ?? (typeof arg2 === 'object' ? arg2 : {});
const binary = await this.createBinary(createBinaryOptions, requestOptions);
return {
contentType: createBinaryOptions.contentType,
url: binary.url,
title: createBinaryOptions.filename,
};
}
/**
* Creates a FHIR `Binary` resource with the provided data content.
*
* The return value is the newly created resource, including the ID and meta.
*
* The `data` parameter can be a string or a `File` object.
*
* A `File` object often comes from a `<input type="file">` element.
*
* @example
* Example:
*
* ```typescript
* const result = await medplum.createBinary(myFile, 'test.jpg', 'image/jpeg');
* console.log(result.id);
* ```
*
* See the {@link https://www.hl7.org/fhir/http.html#create | FHIR "create" operation} for full details.
*
* @category Create
* @param createBinaryOptions -The binary options. See `CreateBinaryOptions` for full details.
* @param requestOptions - Optional fetch options. **NOTE:** only `options.signal` is respected when `onProgress` is also provided.
* @returns The result of the create operation.
*/
createBinary(
createBinaryOptions: CreateBinaryOptions,
requestOptions?: MedplumRequestOptions
): Promise<WithId<Binary>>;
/**
* @category Create
* @param data - The binary data to upload.
* @param filename - Optional filename for the binary.
* @param contentType - Content type for the binary.
* @param onProgress - Optional callback for progress events. **NOTE:** only `options.signal` is respected when `onProgress` is also provided.
* @param options - Optional fetch options. **NOTE:** only `options.signal` is respected when `onProgress` is also provided.
* @returns The result of the create operation.
* @deprecated Use `createBinary` with `CreateBinaryOptions` instead. To be removed in a future version.
*/
createBinary(
data: BinarySource,
filename: string | undefined,
contentType: string,
onProgress?: (e: ProgressEvent) => void,
options?: MedplumRequestOptions
): Promise<WithId<Binary>>;
createBinary(
arg1: BinarySource | CreateBinaryOptions,
arg2: string | undefined | MedplumRequestOptions,
arg3?: string,
arg4?: (e: ProgressEvent) => void,
arg5?: MedplumRequestOptions
): Promise<WithId<Binary>> {
const createBinaryOptions = normalizeCreateBinaryOptions(arg1, arg2, arg3, arg4);
const requestOptions = arg5 ?? (typeof arg2 === 'object' ? arg2 : {});
const { data, contentType, filename, securityContext, onProgress } = createBinaryOptions;
const url = this.fhirUrl('Binary');
if (filename) {
url.searchParams.set('_filename', filename);
}
if (securityContext?.reference) {
this.setRequestHeader(requestOptions, 'X-Security-Context', securityContext.reference);
}
if (onProgress) {
return this.uploadwithProgress(url, data, contentType, onProgress, requestOptions);
}
return this.post(url, data, contentType, requestOptions);
}
uploadwithProgress(
url: URL,
data: BinarySource,
contentType: string,
onProgress: (e: ProgressEvent) => void,
options?: MedplumRequestOptions
): Promise<any> {
return new Promise((resolve, reject) => {
const xhr = new XMLHttpRequest();
// Ensure the 'abort' event listener is removed from the signal to prevent memory leaks,
// especially in scenarios where there is a long-lived signal across multiple requests.
const handleSignalAbort = (): void => xhr.abort();
options?.signal?.addEventListener('abort', handleSignalAbort);
const sendResult = (result: any): void => {
options?.signal?.removeEventListener('abort', handleSignalAbort);
if (result instanceof Error) {
reject(result);
} else {
resolve(result);
}
};
xhr.responseType = 'json';
xhr.onabort = () => sendResult(new DOMException('Request aborted', 'AbortError'));
xhr.onerror = () => sendResult(new Error('Request error'));
if (onProgress) {
xhr.upload.onprogress = (e) => onProgress(e);
xhr.upload.onload = (e) => onProgress(e);
}
xhr.onload = () => {
if (xhr.status >= 200 && xhr.status < 300) {
sendResult(xhr.response);
} else {
sendResult(new OperationOutcomeError(normalizeOperationOutcome(xhr.response || xhr.statusText)));
}
};
xhr.open('POST', url);
xhr.withCredentials = true;
xhr.setRequestHeader('Authorization', 'Bearer ' + this.accessToken);
xhr.setRequestHeader('Cache-Control', 'no-cache, no-store, max-age=0');
xhr.setRequestHeader('Content-Type', contentType);
if (this.options.extendedMode !== false) {
xhr.setRequestHeader('X-Medplum', 'extended');
}
if (options?.headers) {
const headers = options.headers as Record<string, string>;
for (const [key, value] of Object.entries(headers)) {
xhr.setRequestHeader(key, value);
}
}
xhr.send(data as XMLHttpRequestBodyInit);
});
}
/**
* Creates a PDF as a FHIR `Binary` resource based on pdfmake document definition.
*
* The return value is the newly created resource, including the ID and meta.
*
* The `docDefinition` parameter is a pdfmake document definition.
*
* @example
* Example:
*
* ```typescript
* const result = await medplum.createPdf({
* content: ['Hello world']
* });
* console.log(result.id);
* ```
*
* See the {@link https://pdfmake.github.io/docs/0.1/document-definition-object/ | pdfmake document definition} for full details.
* @category Media
* @param createPdfOptions - The PDF creation options. See `CreatePdfOptions` for full details.
* @param requestOptions - Optional fetch options.
* @returns The result of the create operation.
*/
createPdf(createPdfOptions: CreatePdfOptions, requestOptions?: MedplumRequestOptions): Promise<WithId<Binary>>;
/**
* @category Media
* @param docDefinition - The PDF document definition.
* @param filename - Optional filename for the PDF binary resource.
* @param tableLayouts - Optional pdfmake custom table layout.
* @param fonts - Optional pdfmake custom font dictionary.
* @returns The result of the create operation.
* @deprecated Use `createPdf` with `CreatePdfOptions` instead. To be removed in a future version.
*/
createPdf(
docDefinition: TDocumentDefinitions,
filename: string | undefined,
tableLayouts?: Record<string, CustomTableLayout>,
fonts?: TFontDictionary
): Promise<WithId<Binary>>;
async createPdf(
arg1: TDocumentDefinitions | CreatePdfOptions,
arg2?: string | MedplumRequestOptions,
arg3?: Record<string, CustomTableLayout>,
arg4?: TFontDictionary
): Promise<WithId<Binary>> {
if (!this.createPdfImpl) {
throw new Error('PDF creation not enabled');
}
const createPdfOptions = normalizeCreatePdfOptions(arg1, arg2, arg3, arg4);
const requestOptions = typeof arg2 === 'object' ? arg2 : {};
const { docDefinition, tableLayouts, fonts, ...rest } = createPdfOptions;
const blob = await this.createPdfImpl(docDefinition, tableLayouts, fonts);
const createBinaryOptions = { ...rest, data: blob, contentType: 'application/pdf' };
return this.createBinary(createBinaryOptions, requestOptions);
}
/**
* Creates a FHIR `Communication` resource with the provided data content.
*
* This is a convenience method to handle common cases where a `Communication` resource is created with a `payload`.
* @category Create
* @param resource - The FHIR resource to comment on.
* @param text - The text of the comment.
* @param options - Optional fetch options.
* @returns The result of the create operation.
*/
createComment(resource: Resource, text: string, options?: MedplumRequestOptions): Promise<WithId<Communication>> {
const profile = this.getProfile();
let encounter: Reference<Encounter> | undefined = undefined;
let subject: Reference<Patient> | undefined = undefined;
if (resource.resourceType === 'Encounter') {
encounter = createReference(resource);
subject = resource.subject as Reference<Patient> | undefined;
}
if (resource.resourceType === 'ServiceRequest') {
encounter = resource.encounter;
subject = resource.subject as Reference<Patient> | undefined;
}
if (resource.resourceType === 'Patient') {
subject = createReference(resource);
}
return this.createResource(
{
resourceType: 'Communication',
status: 'completed',
basedOn: [createReference(resource)],
encounter,
subject,
sender: profile ? createReference(profile) : undefined,
sent: new Date().toISOString(),
payload: [{ contentString: text }],
},
options
);
}
/**
* Updates a FHIR resource.
*
* The return value is the updated resource, including the ID and meta.
*
* @example
* Example:
*
* ```typescript
* const result = await medplum.updateResource({
* resourceType: 'Patient',
* id: '123',
* name: [{
* family: 'Smith',
* given: ['John']
* }]
* });
* console.log(result.meta.versionId);
* ```
*
* See the {@link https://www.hl7.org/fhir/http.html#update | FHIR "update" operation} for full details.
* @category Write
* @param resource - The FHIR resource to update.
* @param options - Optional fetch options.
* @returns The result of the update operation.
*/
async updateResource<T extends Resource>(resource: T, options?: MedplumRequestOptions): Promise<WithId<T>> {
if (!resource.resourceType) {
throw new Error('Missing resourceType');
}
if (!resource.id) {
throw new Error('Missing id');
}
let result = await this.put(this.fhirUrl(resource.resourceType, resource.id), resource, undefined, options);
if (!result) {
// On 304 not modified, result will be undefined
// Return the user input instead
result = resource;
}
this.cacheResource(result);
this.invalidateUrl(this.fhirUrl(resource.resourceType, resource.id, '_history'));
this.invalidateSearches(resource.resourceType);
return result;
}
/**
* Updates a FHIR resource using JSONPatch operations.
*
* The return value is the updated resource, including the ID and meta.
*
* @example
* Example:
*
* ```typescript
* const result = await medplum.patchResource('Patient', '123', [
* {op: 'replace', path: '/name/0/family', value: 'Smith'},
* ]);
* console.log(result.meta.versionId);
* ```
*
* See the {@link https://www.hl7.org/fhir/http.html#patch | FHIR "update" operation} for full details.
*
* See the {@link https://tools.ietf.org/html/rfc6902 | JSONPatch specification} for full details.
* @category Write
* @param resourceType - The FHIR resource type.
* @param id - The resource ID.
* @param operations - The JSONPatch operations.
* @param options - Optional fetch options.
* @returns The result of the patch operations.
*/
async patchResource<RT extends ResourceType>(
resourceType: RT,
id: string,
operations: PatchOperation[],
options?: MedplumRequestOptions
): Promise<WithId<ExtractResource<RT>>> {
const result = await this.patch(this.fhirUrl(resourceType, id), operations, options);
this.cacheResource(result);
this.invalidateUrl(this.fhirUrl(resourceType, id, '_history'));
this.invalidateSearches(resourceType);
return result;
}
/**
* Deletes a FHIR resource by resource type and ID.
*
* @example
* Example:
*
* ```typescript
* await medplum.deleteResource('Patient', '123');
* ```
*
* See the {@link https://www.hl7.org/fhir/http.html#delete | FHIR "delete" operation} for full details.
* @category Delete
* @param resourceType - The FHIR resource type.
* @param id - The resource ID.
* @param options - Optional fetch options.
* @returns The result of the delete operation.
*/
deleteResource(resourceType: ResourceType, id: string, options?: MedplumRequestOptions): Promise<any> {
this.deleteCacheEntry(this.fhirUrl(resourceType, id).toString());
this.invalidateSearches(resourceType);
return this.delete(this.fhirUrl(resourceType, id), options);
}
/**
* Executes the validate operation with the provided resource.
*
* @example
* Example:
*
* ```typescript
* const result = await medplum.validateResource({
* resourceType: 'Patient',
* name: [{ given: ['Alice'], family: 'Smith' }],
* });
* ```
*
* See the {@link https://www.hl7.org/fhir/resource-operation-validate.html | FHIR "$validate" operation} for full details.
* @param resource - The FHIR resource.
* @param options - Optional fetch options.
* @returns The validate operation outcome.
*/
validateResource<T extends Resource>(resource: T, options?: MedplumRequestOptions): Promise<OperationOutcome> {
return this.post(this.fhirUrl(resource.resourceType, '$validate'), resource, undefined, options);
}
/**
* Executes a bot by ID or Identifier.
* @param idOrIdentifier - The Bot ID or Identifier.
* @param body - The content body. Strings and `File` objects are passed directly. Other objects are converted to JSON.
* @param contentType - The content type to be included in the "Content-Type" header.
* @param options - Optional fetch options.
* @returns The Bot return value.
*/
executeBot(
idOrIdentifier: string | Identifier,
body: any,
contentType?: string,
options?: MedplumRequestOptions
): Promise<any> {
let url: URL;
if (typeof idOrIdentifier === 'string') {
const id = idOrIdentifier;
url = this.fhirUrl('Bot', id, '$execute');
} else {
const identifier = idOrIdentifier;
url = this.fhirUrl('Bot', '$execute');
url.searchParams.set('identifier', identifier.system + '|' + identifier.value);
}
return this.post(url, body, contentType, options);
}
/**
* Executes a batch or transaction of FHIR operations.
*
* @example
* Example:
*
* ```typescript
* await medplum.executeBatch({
* "resourceType": "Bundle",
* "type": "transaction",
* "entry": [
* {
* "fullUrl": "urn:uuid:61ebe359-bfdc-4613-8bf2-c5e300945f0a",
* "resource": {
* "resourceType": "Patient",
* "name": [{ "use": "official", "given": ["Alice"], "family": "Smith" }],
* "gender": "female",
* "birthDate": "1974-12-25"
* },
* "request": {
* "method": "POST",
* "url": "Patient"
* }
* },
* {
* "fullUrl": "urn:uuid:88f151c0-a954-468a-88bd-5ae15c08e059",
* "resource": {
* "resourceType": "Patient",
* "identifier": [{ "system": "http:/example.org/fhir/ids", "value": "234234" }],
* "name": [{ "use": "official", "given": ["Bob"], "family": "Jones" }],
* "gender": "male",
* "birthDate": "1974-12-25"
* },
* "request": {
* "method": "POST",
* "url": "Patient",
* "ifNoneExist": "identifier=http:/example.org/fhir/ids|234234"
* }
* }
* ]
* });
* ```
*
* See the {@link https://hl7.org/fhir/http.html#transaction | FHIR "batch/transaction" section} for full details.
* @category Batch
* @param bundle - The FHIR batch/transaction bundle.
* @param options - Optional fetch options.
* @returns The FHIR batch/transaction response bundle.
*/
executeBatch(bundle: Bundle, options?: MedplumRequestOptions): Promise<Bundle> {
return this.post(this.fhirBaseUrl, bundle, undefined, options);
}
/**
* Sends an email using the Medplum Email API.
*
* Builds the email using nodemailer MailComposer.
*
* Examples:
*
* @example
* Send a simple text email:
*
* ```typescript
* await medplum.sendEmail({
* to: 'alice@example.com',
* cc: 'bob@example.com',
* subject: 'Hello',
* text: 'Hello Alice',
* });
* ```
*
* @example
* Send an email with a `Binary` attachment:
*
* ```typescript
* await medplum.sendEmail({
* to: 'alice@example.com',
* subject: 'Email with attachment',
* text: 'See the attached report',
* attachments: [{
* filename: 'report.pdf',
* path: "Binary/" + binary.id
* }]
* });
* ```
*
* See the {@link https://nodemailer.com/extras/mailcomposer/ | nodemailer MailComposer options} for full details.
* @category Media
* @param email - The MailComposer options.
* @param options - Optional fetch options.
* @returns Promise to the operation outcome.
*/
sendEmail(email: MailOptions, options?: MedplumRequestOptions): Promise<OperationOutcome> {
return this.post('email/v1/send', email, ContentType.JSON, options);
}
/**
* Executes a GraphQL query.
*
* @example
* Example:
*
* ```typescript
* const result = await medplum.graphql(`{
* Patient(id: "123") {
* resourceType
* id
* name {
* given
* family
* }
* }
* }`);
* ```
*
* @example
* Advanced queries such as named operations and variable substitution are supported:
*
* ```typescript
* const result = await medplum.graphql(
* `query GetPatientById($patientId: ID!) {
* Patient(id: $patientId) {
* resourceType
* id
* name {
* given
* family
* }
* }
* }`,
* 'GetPatientById',
* { patientId: '123' }
* );
* ```
*
* See the {@link https://graphql.org/learn/ | GraphQL documentation} for more details.
*
* See the {@link https://www.hl7.org/fhir/graphql.html | FHIR GraphQL documentation} for FHIR specific details.
* @category Read
* @param query - The GraphQL query.
* @param operationName - Optional GraphQL operation name.
* @param variables - Optional GraphQL variables.
* @param options - Optional fetch options.
* @returns The GraphQL result.
*/
graphql(
query: string,
operationName?: string | null,
variables?: any,
options?: MedplumRequestOptions
): Promise<any> {
return this.post(this.fhirUrl('$graphql'), { query, operationName, variables }, ContentType.JSON, options);
}
/**
* Executes the $graph operation on this resource to fetch a Bundle of resources linked to the target resource
* according to a graph definition
* @category Read
* @param resourceType - The FHIR resource type.
* @param id - The resource ID.
* @param graphName - `name` parameter of the GraphDefinition
* @param options - Optional fetch options.
* @returns A Bundle
*/
readResourceGraph(
resourceType: ResourceType,
id: string,
graphName: string,
options?: MedplumRequestOptions
): ReadablePromise<Bundle> {
return this.get<Bundle>(`${this.fhirUrl(resourceType, id)}/$graph?graph=${graphName}`, options);
}
/**
* Pushes a message to an agent.
*
* @param agent - The agent to push to.
* @param destination - The destination device.
* @param body - The message body.
* @param contentType - Optional message content type.
* @param waitForResponse - Optional wait for response flag.
* @param options - Optional fetch options.
* @returns Promise to the result. If waiting for response, the result is the response body. Otherwise, it is an operation outcome.
*/
pushToAgent(
agent: Agent | Reference<Agent>,
destination: Device | Reference<Device> | string,
body: any,
contentType?: string,
waitForResponse?: boolean,
options?: PushToAgentOptions
): Promise<any> {
const { waitTimeout, ...requestOptions } = options ?? {};
return this.post(
this.fhirUrl('Agent', resolveId(agent) as string, '$push'),
{
destination: typeof destination === 'string' ? destination : getReferenceString(destination),
body,
contentType,
waitForResponse,
...(waitTimeout !== undefined ? { waitTimeout } : undefined),
},
ContentType.FHIR_JSON,
requestOptions
);
}
/**
* Reads the list of available CDS services.
* @param options - Optional fetch options.
* @returns The list of CDS services.
*/
getCdsServices(options?: MedplumRequestOptions): Promise<CdsDiscoveryResponse> {
return this.get<CdsDiscoveryResponse>('/cds-services', options);
}
/**
* Calls a CDS service by ID.
* @param id - The CDS service ID.
* @param body - The CDS request body.
* @param options - Optional fetch options.
* @returns The CDS response.
*/
callCdsService(id: string, body: CdsRequest, options?: MedplumRequestOptions): Promise<CdsResponse> {
return this.post(`/cds-services/${id}`, body, ContentType.JSON, options);
}
/**
* @category Authentication
* @returns The Login State
*/
getActiveLogin(): LoginState | undefined {
return this.storage.getObject('activeLogin');
}
/**
* Sets the active login.
* @param login - The new active login state.
* @category Authentication
*/
async setActiveLogin(login: LoginState): Promise<void> {
if (!this.sessionDetails?.profile || getReferenceString(this.sessionDetails.profile) !== login.profile?.reference) {
this.clearActiveLogin();
}
this.setAccessToken(login.accessToken, login.refreshToken);
this.storage.setObject('activeLogin', login);
this.addLogin(login);
this.refreshPromise = undefined;
await this.refreshProfile();
}
/**
* Returns the current access token.
* @returns The current access token.
* @category Authentication
*/
getAccessToken(): string | undefined {
return this.accessToken;
}
/**
* Returns whether the client has a valid access token or not.
* @param gracePeriod - Optional grace period in milliseconds. If not specified, uses the client configured grace period (default 5 minutes).
* @returns Boolean indicating whether or not the client is authenticated.
*
* **NOTE: Does not check whether the auth token has been revoked server-side.**
*/
isAuthenticated(gracePeriod?: number): boolean {
return (
this.accessTokenExpires !== undefined &&
Date.now() < this.accessTokenExpires - (gracePeriod ?? this.refreshGracePeriod)
);
}
/**
* Sets the current access token.
* @param accessToken - The new access token.
* @param refreshToken - Optional refresh token.
* @category Authentication
*/
setAccessToken(accessToken: string, refreshToken?: string): void {
this.accessToken = accessToken;
this.refreshToken = refreshToken;
this.accessTokenExpires = tryGetJwtExpiration(accessToken);
this.medplumServer = isMedplumAccessToken(accessToken);
}
/**
* Returns the list of available logins.
* @returns The list of available logins.
* @category Authentication
*/
getLogins(): LoginState[] {
return this.storage.getObject<LoginState[]>('logins') ?? [];
}
private addLogin(newLogin: LoginState): void {
const logins = this.getLogins().filter((login) => login.profile?.reference !== newLogin.profile?.reference);
logins.push(newLogin);
this.storage.setObject('logins', logins);
}
private async refreshProfile(): Promise<WithId<ProfileResource> | undefined> {
if (!this.medplumServer) {
return undefined;
}
this.profilePromise = new Promise((resolve, reject) => {
this.get('auth/me', { cache: 'no-cache' })
.then((result: SessionDetails) => {
this.profilePromise = undefined;
const profileChanged = this.sessionDetails?.profile?.id !== result.profile.id;
this.sessionDetails = result;
if (profileChanged) {
this.dispatchEvent({ type: 'change' });
}
resolve(result.profile);
this.dispatchEvent({ type: 'profileRefreshed' });
})
.catch(reject);
});
this.dispatchEvent({ type: 'profileRefreshing' });
return this.profilePromise;
}
/**
* Returns true if the client is waiting for initial authentication.
* @returns True if the client is waiting for initial authentication.
* @category Authentication
*/
isLoading(): boolean {
return !this.isInitialized || (Boolean(this.profilePromise) && !this.sessionDetails?.profile);
}
/**
* Returns true if the current user is authenticated as a super admin.
* @returns True if the current user is authenticated as a super admin.
* @category Authentication
*/
isSuperAdmin(): boolean {
return !!this.sessionDetails?.project.superAdmin;
}
/**
* Returns true if the current user is authenticated as a project admin.
* @returns True if the current user is authenticated as a project admin.
* @category Authentication
*/
isProjectAdmin(): boolean {
return !!this.sessionDetails?.membership.admin;
}
/**
* Returns the current project if available.
* @returns The current project if available.
* @category User Profile
*/
getProject(): Project | undefined {
return this.sessionDetails?.project;
}
/**
* Returns the current project membership if available.
* @returns The current project membership if available.
* @category User Profile
*/
getProjectMembership(): ProjectMembership | undefined {
return this.sessionDetails?.membership;
}
/**
* Returns the current user profile resource if available.
* This method does not wait for loading promises.
* @returns The current user profile resource if available.
* @category User Profile
*/
getProfile(): ProfileResource | undefined {
return this.sessionDetails?.profile;
}
/**
* Returns the current user profile resource, retrieving form the server if necessary.
* This method waits for loading promises.
* @returns The current user profile resource.
* @category User Profile
*/
async getProfileAsync(): Promise<WithId<ProfileResource> | undefined> {
if (this.profilePromise) {
return this.profilePromise;
}
if (this.sessionDetails) {
return this.sessionDetails.profile;
}
return this.refreshProfile();
}
/**
* Returns the current user configuration if available.
* @returns The current user configuration if available.
* @category User Profile
*/
getUserConfiguration(): WithId<UserConfiguration> | undefined {
return this.sessionDetails?.config;
}
/**
* Returns the current user access policy if available.
* @returns The current user access policy if available.
* @category User Profile
*/
getAccessPolicy(): AccessPolicy | undefined {
return this.sessionDetails?.accessPolicy;
}
/**
* Downloads the URL as a blob. Can accept binary URLs in the form of `Binary/{id}` as well.
* @category Read
* @param url - The URL to request. Can be a standard URL or one in the form of `Binary/{id}`.
* @param options - Optional fetch request init options.
* @returns Promise to the response body as a blob.
*/
async download(url: URL | string, options: MedplumRequestOptions = {}): Promise<Blob> {
if (this.refreshPromise) {
await this.refreshPromise;
}
const urlString = url.toString();
if (urlString.startsWith(BINARY_URL_PREFIX)) {
url = this.fhirUrl(urlString);
}
let headers = options.headers as Record<string, string> | undefined;
if (!headers) {
headers = {};
options.headers = headers;
}
if (!headers['Accept']) {
headers['Accept'] = '*/*';
}
this.addFetchOptionsDefaults(options);
const response = await this.fetchWithRetry(url.toString(), options);
return response.blob();
}
/**
* Creates a FHIR Media resource with the provided data content.
*
* @category Create
* @param createMediaOptions - The media creation options. See `CreateMediaOptions` for full details.
* @param requestOptions - Optional fetch options.
* @returns The new media resource.
*/
async createMedia(createMediaOptions: CreateMediaOptions, requestOptions?: MedplumRequestOptions): Promise<Media> {
const { additionalFields, ...createBinaryOptions } = createMediaOptions;
// First, create the media:
const media = await this.createResource({
resourceType: 'Media',
status: 'preparation',
content: {
contentType: createMediaOptions.contentType,
},
...additionalFields,
});
// If the caller did not specify a security context, use the media reference:
if (!createBinaryOptions.securityContext) {
createBinaryOptions.securityContext = createReference(media);
}
// Next, upload the binary:
const content = await this.createAttachment(createBinaryOptions, requestOptions);
// Update the media with the binary content:
return this.updateResource({
...media,
status: 'completed',
content,
});
}
/**
* Upload media to the server and create a Media instance for the uploaded content.
* @param contents - The contents of the media file, as a string, Uint8Array, File, or Blob.
* @param contentType - The media type of the content.
* @param filename - Optional filename for the binary, or extended upload options (see `BinaryUploadOptions`).
* @param additionalFields - Additional fields for Media.
* @param options - Optional fetch options.
* @returns Promise that resolves to the created Media
* @deprecated Use `createMedia` with `CreateMediaOptions` instead. To be removed in a future version.
*/
async uploadMedia(
contents: string | Uint8Array | File | Blob,
contentType: string,
filename: string | undefined,
additionalFields?: Partial<Media>,
options?: MedplumRequestOptions
): Promise<Media> {
return this.createMedia(
{
data: contents,
contentType,
filename,
additionalFields,
},
options
);
}
/**
* Creates a FHIR DocumentReference resource with the provided data content.
*
* @category Create
* @param createDocumentReferenceOptions - The document reference creation options. See `CreateDocumentReferenceOptions` for full details.
* @param requestOptions - Optional fetch options.
* @returns The new document reference resource.
*/
async createDocumentReference(
createDocumentReferenceOptions: CreateDocumentReferenceOptions,
requestOptions?: MedplumRequestOptions
): Promise<DocumentReference> {
const { additionalFields, ...createBinaryOptions } = createDocumentReferenceOptions;
// First, create the document reference:
const documentReference = await this.createResource({
resourceType: 'DocumentReference',
status: 'current',
content: [
{
attachment: {
contentType: createDocumentReferenceOptions.contentType,
},
},
],
...additionalFields,
});
// If the caller did not specify a security context, use the document reference:
if (!createBinaryOptions.securityContext) {
createBinaryOptions.securityContext = createReference(documentReference);
}
// Then create the binary:
const attachment = await this.createAttachment(createBinaryOptions, requestOptions);
// Finally, update the document reference with the binary reference:
return this.updateResource({
...documentReference,
content: [{ attachment: attachment }],
});
}
/**
* Performs Bulk Data Export operation request flow. See the {@link https://build.fhir.org/ig/HL7/bulk-data/export.html#bulk-data-export | FHIR "Bulk Data Export"} for full details.
* @param exportLevel - Optional export level. Defaults to system level export. 'Group/:id' - Group of Patients, 'Patient' - All Patients.
* @param resourceTypes - A string of comma-delimited FHIR resource types.
* @param since - Resources will be included in the response if their state has changed after the supplied time (e.g. if Resource.meta.lastUpdated is later than the supplied _since time).
* @param options - Optional fetch options.
* @returns Bulk Data Response containing links to Bulk Data files. See the {@link https://build.fhir.org/ig/HL7/bulk-data/export.html#response---complete-status | "Response - Complete Status"} for full details.
*/
async bulkExport(
exportLevel = '',
resourceTypes?: string,
since?: string,
options?: MedplumRequestOptions
): Promise<Partial<BulkDataExport>> {
const fhirPath = exportLevel ? `${exportLevel}/` : exportLevel;
const url = this.fhirUrl(`${fhirPath}$export`);
if (resourceTypes) {
url.searchParams.set('_type', resourceTypes);
}
if (since) {
url.searchParams.set('_since', since);
}
return this.startAsyncRequest<Partial<BulkDataExport>>(url.toString(), options);
}
/**
* Starts an async request following the FHIR "Asynchronous Request Pattern".
*
* See the {@link https://hl7.org/fhir/r4/async.html | FHIR "Asynchronous Request Pattern"} for full details.
*
* @param url - The URL to request.
* @param options - Optional fetch options.
* @returns The response body.
*/
async startAsyncRequest<T>(url: string, options: MedplumRequestOptions = {}): Promise<T> {
this.addFetchOptionsDefaults(options);
const headers = options.headers as Record<string, string>;
headers['Prefer'] = 'respond-async';
return this.request('POST', url, options);
}
/**
* Returns the key value client.
* @returns The key value client.
*/
get keyValue(): MedplumKeyValueClient {
if (!this.keyValueClient) {
this.keyValueClient = new MedplumKeyValueClient(this);
}
return this.keyValueClient;
}
//
// Private helpers
//
/**
* Internal helper method to get a bundle from a URL.
* In addition to returning the bundle, it also caches all of the resources in the bundle.
* This should be used by any method that returns a bundle of resources to be cached.
* @param url - The bundle URL.
* @param options - Optional fetch options.
* @returns Promise to the bundle.
*/
private getBundle<T extends Resource = Resource>(
url: URL,
options?: MedplumRequestOptions
): ReadablePromise<Bundle<T>> {
return new ReadablePromise(
(async () => {
const bundle = await this.get<Bundle<T>>(url, options);
if (bundle.entry) {
for (const entry of bundle.entry) {
this.cacheResource(entry.resource);
}
}
return bundle;
})()
);
}
/**
* Returns the cache entry if available and not expired.
* @param key - The cache key to retrieve.
* @param options - Optional fetch options for cache settings.
* @returns The cached entry if found.
*/
private getCacheEntry(key: string, options: MedplumRequestOptions | undefined): RequestCacheEntry | undefined {
if (!this.requestCache || options?.cache === 'no-cache' || options?.cache === 'reload') {
return undefined;
}
const entry = this.requestCache.get(key);
if (!entry || entry.requestTime + this.cacheTime < Date.now()) {
return undefined;
}
return entry;
}
/**
* Adds a readable promise to the cache.
* @param key - The cache key to store.
* @param value - The readable promise to store.
*/
private setCacheEntry(key: string, value: ReadablePromise<any>): void {
if (this.requestCache) {
this.requestCache.set(key, { requestTime: Date.now(), value });
}
}
/**
* Adds a concrete value as the cache entry for the given resource.
* This is used in cases where the resource is loaded indirectly.
* For example, when a resource is loaded as part of a Bundle.
* @param resource - The resource to cache.
*/
private cacheResource(resource: Resource | undefined): void {
if (resource?.id && !resource.meta?.tag?.some((t) => t.code === 'SUBSETTED')) {
this.setCacheEntry(
this.fhirUrl(resource.resourceType, resource.id).toString(),
new ReadablePromise(Promise.resolve(resource))
);
}
}
/**
* Deletes a cache entry.
* @param key - The cache key to delete.
*/
private deleteCacheEntry(key: string): void {
if (this.requestCache) {
this.requestCache.delete(key);
}
}
/**
* Makes an HTTP request.
* @param method - The HTTP method (GET, POST, etc).
* @param url - The target URL.
* @param options - Optional fetch request init options.
* @param state - Optional request state.
* @returns The JSON content body if available.
*/
private async request<T>(
method: string,
url: string,
options: MedplumRequestOptions = {},
state: RequestState = {}
): Promise<T> {
await this.refreshIfExpired();
options.method = method;
this.addFetchOptionsDefaults(options);
const response = await this.fetchWithRetry(url, options);
if (response.status === 401) {
// Refresh and try again
return this.handleUnauthenticated(method, url, options);
}
if (response.status === 204 || response.status === 304) {
// No content or change
return undefined as unknown as T;
}
const contentType = response.headers.get('content-type');
const isJson = contentType?.includes('json');
if (response.status === 404 && !isJson) {
// Special case for non-JSON 404 responses
// In the common case, the 404 response will include an OperationOutcome in JSON with additional details.
// In the non-JSON case, we can't parse the response, so we'll just throw a generic "Not Found" error.
throw new OperationOutcomeError(notFound);
}
const body = await this.parseBody(response, isJson);
if (
(response.status === 200 && options.followRedirectOnOk) ||
(response.status === 201 && options.followRedirectOnCreated)
) {
const contentLocation = await tryGetContentLocation(response, body);
if (contentLocation) {
return this.request('GET', contentLocation, { ...options, body: undefined });
}
}
if (response.status === 202 && options.pollStatusOnAccepted) {
const contentLocation = await tryGetContentLocation(response, body);
const statusUrl = contentLocation ?? state.statusUrl;
if (statusUrl) {
return this.pollStatus(statusUrl, options, state);
}
}
if (response.status >= 400) {
throw new OperationOutcomeError(normalizeOperationOutcome(body));
}
return body as T;
}
private async parseBody(
response: Response,
isJson: boolean | undefined
): Promise<Record<string, any> | string | undefined> {
let body: Record<string, string> | string | undefined = undefined;
// If there is no content length, don't attempt to parse the body
if (response.headers.get('content-length') === '0') {
return undefined;
}
if (isJson) {
try {
body = await response.json();
} catch (err) {
console.error('Error parsing response', response.status, err);
throw err;
}
} else {
body = await response.text();
}
return body;
}
private async fetchWithRetry(url: string, options: MedplumRequestOptions): Promise<Response> {
if (!url.startsWith('http')) {
url = concatUrls(this.baseUrl, url);
}
// Previously default for maxRetries was 3, but we will interpret maxRetries literally and not count first attempt
// Default of 2 matches old behavior with the new semantics
const maxRetries = options?.maxRetries ?? 2;
// We use <= since we want to retry maxRetries times and first retry is when attemptNum === 1
for (let attemptNum = 0; attemptNum <= maxRetries; attemptNum++) {
try {
if (this.logLevel !== 'none') {
this.logRequest(url, options);
}
const response = (await this.fetch(url, options)) as Response;
if (this.logLevel !== 'none') {
this.logResponse(response);
}
// Ensure current rate limits are set before calculating retry delay
this.setCurrentRateLimit(response);
// Handle non-500 response and max retries exceeded
// We return immediately for non-500 or 500 that has exceeded max retries
if (attemptNum >= maxRetries || !isRetryable(response)) {
return response;
}
const delayMs = this.getRetryDelay(attemptNum);
const maxRetryTime = options.maxRetryTime ?? 2_000;
// Return to user immediately if delay would be very long
if (delayMs > maxRetryTime) {
return response;
}
await sleep(delayMs);
} catch (err) {
// This is for the 1st retry to avoid multiple notifications
if ((err as Error).message === 'Failed to fetch' && attemptNum === 0) {
this.dispatchEvent({ type: 'offline' });
}
// If we got an abort error or exceeded retries, then throw immediately
if ((err as Error).name === 'AbortError' || attemptNum === maxRetries) {
throw err;
}
}
}
throw new Error('Unreachable');
}
private logRequest(url: string, options: MedplumRequestOptions): void {
console.log(`> ${options.method} ${url}`);
if (this.logLevel === 'verbose' && options.headers) {
const headers = options.headers as Record<string, string>;
for (const key of sortStringArray(Object.keys(headers))) {
console.log(`> ${key}: ${headers[key]}`);
}
}
}
private logResponse(response: Response): void {
console.log(`< ${response.status} ${response.statusText}`);
if (this.logLevel === 'verbose' && response.headers) {
response.headers.forEach((value, key) => console.log(`< ${key}: ${value}`));
}
}
private setCurrentRateLimit(res: Response): void {
// Handle cases where response might not have headers property (e.g., in tests)
if (!res?.headers || typeof res.headers.get !== 'function') {
return;
}
const rateLimitHeader = res.headers.get('ratelimit');
if (rateLimitHeader) {
this.currentRateLimits = rateLimitHeader;
}
}
/**
* Reports the last-seen rate limit information from the server.
* @returns Array of applicable rate limits.
*/
rateLimitStatus(): RateLimitInfo[] {
if (!this.currentRateLimits) {
return [];
}
const header = this.currentRateLimits;
return header.split(',').map((str) => {
const parts = str.split(';').map((s) => s.trim());
if (parts.length !== 3) {
throw new Error('Could not parse RateLimit header: ' + header);
}
const name = parts[0].substring(1, parts[0].length - 1);
const remainingPart = parts.find((p) => p.startsWith('r='))?.substring(2);
const remainingUnits = remainingPart ? Number.parseInt(remainingPart, 10) : Number.NaN;
const timePart = parts.find((p) => p.startsWith('t='))?.substring(2);
const secondsUntilReset = timePart ? Number.parseInt(timePart, 10) : Number.NaN;
if (!name || Number.isNaN(remainingUnits) || Number.isNaN(secondsUntilReset)) {
throw new Error('Could not parse RateLimit header: ' + header);
}
return {
name,
remainingUnits,
secondsUntilReset,
resetsAfter: Math.ceil((Date.now() + 1000 * secondsUntilReset) / 1000),
};
});
}
private getRetryDelay(attemptNum: number): number {
const rateLimits = this.rateLimitStatus();
let retryDelay = 500 * Math.pow(1.5, attemptNum);
for (const limit of rateLimits) {
if (!limit.remainingUnits) {
retryDelay = Math.max(retryDelay, limit.secondsUntilReset * 1000);
}
}
return retryDelay;
}
private async pollStatus<T>(statusUrl: string, options: MedplumRequestOptions, state: RequestState): Promise<T> {
const statusOptions: MedplumRequestOptions = { ...options, method: 'GET', body: undefined, redirect: 'follow' };
if (state.pollCount === undefined) {
// First request - try request immediately
if (options.headers && typeof options.headers === 'object' && 'Prefer' in options.headers) {
statusOptions.headers = { ...options.headers };
delete statusOptions.headers.Prefer;
}
state.statusUrl = statusUrl;
state.pollCount = 1;
} else {
// Subsequent requests - wait and retry
const retryDelay = options.pollStatusPeriod ?? 1000;
await sleep(retryDelay);
state.pollCount++;
}
return this.request('GET', statusUrl, statusOptions, state);
}
/**
* Executes a batch of requests that were automatically batched together.
*/
private async executeAutoBatch(): Promise<void> {
// Get the current queue
if (this.autoBatchQueue === undefined) {
return;
}
const entries = [...this.autoBatchQueue];
// Clear the queue
this.autoBatchQueue.length = 0;
// Clear the timer
this.autoBatchTimerId = undefined;
// If there is only one request in the batch, just execute it
if (entries.length === 1) {
const entry = entries[0];
try {
entry.resolve(await this.request(entry.method, concatUrls(this.fhirBaseUrl, entry.url), entry.options));
} catch (err) {
entry.reject(new OperationOutcomeError(normalizeOperationOutcome(err)));
}
return;
}
// Build the batch request
const batch: Bundle = {
resourceType: 'Bundle',
type: 'batch',
entry: entries.map(
(e): BundleEntry => ({
request: {
method: e.method,
url: e.url,
},
resource: e.options.body ? (JSON.parse(e.options.body as string) as Resource) : undefined,
})
),
};
// Execute the batch request
const response = (await this.post(this.fhirBaseUrl, batch)) as Bundle;
// Process the response
for (let i = 0; i < entries.length; i++) {
const entry = entries[i];
const responseEntry = response.entry?.[i];
if (responseEntry?.response?.outcome && !isOk(responseEntry.response.outcome)) {
entry.reject(new OperationOutcomeError(responseEntry.response.outcome));
} else {
entry.resolve(responseEntry?.resource);
}
}
}
/**
* Adds default options to the fetch options.
* @param options - The options to add defaults to.
*/
private addFetchOptionsDefaults(options: MedplumRequestOptions): void {
// Apply default headers
Object.entries(this.defaultHeaders).forEach(([name, value]) => {
this.setRequestHeader(options, name, value);
});
this.setRequestHeader(options, 'Accept', DEFAULT_ACCEPT, true);
if (this.options.extendedMode !== false) {
this.setRequestHeader(options, 'X-Medplum', 'extended');
}
if (options.body) {
this.setRequestHeader(options, 'Content-Type', ContentType.FHIR_JSON, true);
}
if (this.accessToken) {
this.setRequestHeader(options, 'Authorization', 'Bearer ' + this.accessToken);
} else if (this.basicAuth) {
this.setRequestHeader(options, 'Authorization', 'Basic ' + this.basicAuth);
}
if (!options.cache) {
options.cache = 'no-cache';
}
if (!options.credentials) {
options.credentials = 'include';
}
}
/**
* Sets the "Content-Type" header on fetch options.
* @param options - The fetch options.
* @param contentType - The new content type to set.
*/
private setRequestContentType(options: MedplumRequestOptions, contentType: string): void {
this.setRequestHeader(options, 'Content-Type', contentType);
}
/**
* Sets a header on fetch options.
* @param options - The fetch options.
* @param key - The header key.
* @param value - The header value.
* @param ifNoneExist - Optional flag to only set the header if it doesn't already exist.
*/
private setRequestHeader(options: MedplumRequestOptions, key: string, value: string, ifNoneExist = false): void {
const headers = options.headers;
if (!headers) {
options.headers = { [key]: value };
} else if (Array.isArray(headers)) {
if (!ifNoneExist || !headers.some(([k]) => k.toLowerCase() === key.toLowerCase())) {
headers.push([key, value]);
}
} else if (headers instanceof Headers) {
if (!ifNoneExist || !headers.has(key)) {
headers.set(key, value);
}
} else if (isObject(headers)) {
if (!ifNoneExist || !headers[key]) {
headers[key] = value;
}
}
}
/**
* Sets the body on fetch options.
* @param options - The fetch options.
* @param data - The new content body.
*/
private setRequestBody(options: MedplumRequestOptions, data: any): void {
if (
typeof data === 'string' ||
(typeof Blob !== 'undefined' && (data instanceof Blob || data?.constructor.name === 'Blob')) ||
(typeof File !== 'undefined' && (data instanceof File || data?.constructor.name === 'File')) ||
(typeof Uint8Array !== 'undefined' && (data instanceof Uint8Array || data?.constructor.name === 'Uint8Array'))
) {
options.body = data;
} else if (data) {
options.body = JSON.stringify(data);
}
}
/**
* Handles an unauthenticated response from the server.
* First, tries to refresh the access token and retry the request.
* Otherwise, calls unauthenticated callbacks and rejects.
* @param method - The HTTP method of the original request.
* @param url - The URL of the original request.
* @param options - Optional fetch request init options.
* @returns The result of the retry.
*/
private async handleUnauthenticated(method: string, url: string, options: MedplumRequestOptions): Promise<any> {
if (this.refresh()) {
return this.request(method, url, options);
}
this.clear();
this.onUnauthenticated?.();
throw new OperationOutcomeError(unauthorized);
}
/**
* Starts a new PKCE flow.
* These PKCE values are stateful, and must survive redirects and page refreshes.
* @category Authentication
* @returns The PKCE code challenge details.
*/
async startPkce(): Promise<{ codeChallengeMethod: CodeChallengeMethod; codeChallenge: string }> {
const pkceState = getRandomString();
sessionStorage.setItem('pkceState', pkceState);
const codeVerifier = getRandomString().slice(0, 128);
sessionStorage.setItem('codeVerifier', codeVerifier);
try {
const arrayHash = await encryptSHA256(codeVerifier);
const codeChallenge = arrayBufferToBase64(arrayHash)
.replaceAll('+', '-')
.replaceAll('/', '_')
.replaceAll('=', '');
return { codeChallengeMethod: 'S256', codeChallenge };
} catch (err) {
console.warn("Failed to hash code verifier. Falling back to 'plain' code challenge method", err);
return { codeChallengeMethod: 'plain', codeChallenge: codeVerifier };
}
}
/**
* Redirects the user to the login screen for authorization.
* Clears all auth state including local storage and session storage.
* @param loginParams - The authorization login parameters.
* @see https://openid.net/specs/openid-connect-core-1_0.html#AuthorizationEndpoint
*/
private async requestAuthorization(loginParams?: Partial<BaseLoginRequest>): Promise<void> {
const loginRequest = await this.ensureCodeChallenge(loginParams ?? {});
const url = new URL(this.authorizeUrl);
url.searchParams.set('response_type', 'code');
url.searchParams.set('state', sessionStorage.getItem('pkceState') as string);
url.searchParams.set('client_id', loginRequest.clientId ?? (this.clientId as string));
url.searchParams.set('redirect_uri', loginRequest.redirectUri ?? locationUtils.getOrigin());
url.searchParams.set('code_challenge_method', loginRequest.codeChallengeMethod as string);
url.searchParams.set('code_challenge', loginRequest.codeChallenge as string);
url.searchParams.set('scope', loginRequest.scope ?? 'openid profile');
locationUtils.assign(url.toString());
}
/**
* Processes an OAuth authorization code.
* See: https://openid.net/specs/openid-connect-core-1_0.html#TokenRequest
* @param code - The authorization code received by URL parameter.
* @param loginParams - Optional login parameters.
* @returns The user profile resource.
* @category Authentication
*/
processCode(code: string, loginParams?: Partial<BaseLoginRequest>): Promise<ProfileResource> {
const tokenParams: Record<string, string> = {
grant_type: OAuthGrantType.AuthorizationCode,
code,
client_id: loginParams?.clientId ?? this.clientId ?? '',
redirect_uri: loginParams?.redirectUri ?? locationUtils.getOrigin(),
};
if (typeof sessionStorage !== 'undefined') {
const codeVerifier = sessionStorage.getItem('codeVerifier');
if (codeVerifier) {
tokenParams.code_verifier = codeVerifier;
}
}
return this.fetchTokens(tokenParams);
}
/**
* Refreshes the access token using the refresh token if available.
* @param gracePeriod - Optional grace period in milliseconds. If not specified, uses the client configured grace period (default 5 minutes).
* @returns Promise to refresh the access token.
*/
refreshIfExpired(gracePeriod?: number): Promise<void> {
// If (1) not already refreshing, (2) we have an access token, and (3) the access token is expired,
// then start a refresh.
if (!this.refreshPromise && this.accessTokenExpires !== undefined && !this.isAuthenticated(gracePeriod)) {
// The result of the `refresh()` function is cached in `this.refreshPromise`,
// so we can safely ignore the return value here.
// eslint-disable-next-line @typescript-eslint/no-floating-promises
this.refresh();
}
return this.refreshPromise ?? Promise.resolve();
}
/**
* Tries to refresh the auth tokens.
* @returns The refresh promise if available; otherwise undefined.
* @see https://openid.net/specs/openid-connect-core-1_0.html#RefreshTokens
*/
private refresh(): Promise<void> | undefined {
if (this.refreshPromise) {
return this.refreshPromise;
}
if (this.refreshToken) {
this.refreshPromise = this.fetchTokens({
grant_type: OAuthGrantType.RefreshToken,
client_id: this.clientId ?? '',
refresh_token: this.refreshToken,
});
return this.refreshPromise;
}
if (this.clientId && this.clientSecret) {
this.refreshPromise = this.startClientLogin(this.clientId, this.clientSecret);
return this.refreshPromise;
}
return undefined;
}
/**
* Starts a new OAuth2 client credentials flow.
*
* @example
* ```typescript
* await medplum.startClientLogin(import.meta.env.MEDPLUM_CLIENT_ID, import.meta.env.MEDPLUM_CLIENT_SECRET)
* // Example Search
* await medplum.searchResources('Patient')
* ```
*
* See {@link https://datatracker.ietf.org/doc/html/rfc6749#section-4.4 | RFC 6749 Section 4.4} for full details.
*
* @category Authentication
* @param clientId - The client ID.
* @param clientSecret - The client secret.
* @returns Promise that resolves to the client profile.
*/
async startClientLogin(clientId: string, clientSecret: string): Promise<ProfileResource> {
this.clientId = clientId;
this.clientSecret = clientSecret;
return this.fetchTokens({
grant_type: OAuthGrantType.ClientCredentials,
client_id: clientId,
client_secret: clientSecret,
});
}
/**
* Starts a new OAuth2 JWT bearer flow.
*
* @example
* ```typescript
* await medplum.startJwtBearerLogin(import.meta.env.MEDPLUM_CLIENT_ID, import.meta.env.MEDPLUM_JWT_BEARER_ASSERTION, 'openid profile');
* // Example Search
* await medplum.searchResources('Patient')
* ```
*
* See {@link https://datatracker.ietf.org/doc/html/rfc7523#section-2.1 | RFC 7523 Section 2.1} for full details.
*
* @category Authentication
* @param clientId - The client ID.
* @param assertion - The JWT assertion.
* @param scope - The OAuth scope.
* @returns Promise that resolves to the client profile.
*/
async startJwtBearerLogin(clientId: string, assertion: string, scope: string): Promise<ProfileResource> {
this.clientId = clientId;
return this.fetchTokens({
grant_type: OAuthGrantType.JwtBearer,
client_id: clientId,
assertion,
scope,
});
}
/**
* Starts a new OAuth2 JWT assertion flow.
*
* See {@link https://datatracker.ietf.org/doc/html/rfc7523#section-2.2 | RFC 7523 Section 2.2} for full details.
*
* @category Authentication
* @param jwt - The JWT assertion.
* @returns Promise that resolves to the client profile.
*/
async startJwtAssertionLogin(jwt: string): Promise<ProfileResource> {
return this.fetchTokens({
grant_type: OAuthGrantType.ClientCredentials,
client_assertion_type: OAuthClientAssertionType.JwtBearer,
client_assertion: jwt,
});
}
/**
* Sets the client ID and secret for basic auth.
*
* @example
* ```typescript
* medplum.setBasicAuth(import.meta.env.MEDPLUM_CLIENT_ID, import.meta.env.MEDPLUM_CLIENT_SECRET);
* // Example Search
* await medplum.searchResources('Patient');
* ```
*
* @category Authentication
* @param clientId - The client ID.
* @param clientSecret - The client secret.
*/
setBasicAuth(clientId: string, clientSecret: string): void {
this.clientId = clientId;
this.clientSecret = clientSecret;
this.basicAuth = encodeBase64(clientId + ':' + clientSecret);
}
/**
* Sets the log level for the client.
* - 'none': No logging
* - 'basic': Log method, URL, and status code only (no sensitive headers)
* - 'verbose': Log all details including headers (may include sensitive data)
*
* @example
* ```typescript
* // Basic logging for production
* medplum.setLogLevel('basic');
* await medplum.searchResources('Patient');
* // Output:
* // > GET https://api.medplum.com/fhir/R4/Patient
* // < 200 OK
* ```
*
* @example
* ```typescript
* // Verbose logging for debugging
* medplum.setLogLevel('verbose');
* await medplum.searchResources('Patient');
* // Output includes all headers
* ```
*
* @category HTTP
* @param level - The log level to set.
*/
setLogLevel(level: ClientLogLevel): void {
this.logLevel = level;
// Update deprecated verbose option for backward compatibility
this.options.verbose = level === 'verbose';
}
/**
* Gets the current log level.
* @category HTTP
* @returns The current log level.
*/
getLogLevel(): ClientLogLevel {
return this.logLevel;
}
/**
* Sets the verbose mode for the client.
* When verbose is enabled, the client will log all requests and responses to the console.
*
* @deprecated Use setLogLevel instead. This method will be removed in a future version.
*
* @example
* ```typescript
* medplum.setVerbose(true);
* // Now all requests and responses will be logged
* await medplum.searchResources('Patient');
* ```
*
* @category HTTP
* @param verbose - Whether to enable verbose logging.
*/
setVerbose(verbose: boolean): void {
this.logLevel = verbose ? 'verbose' : 'none';
this.options.verbose = verbose;
}
/**
* Subscribes to a specified topic, listening for a list of specified events.
*
* Once you have the `SubscriptionRequest` returned from this method, you can call `fhircastConnect(subscriptionRequest)` to connect to the subscription stream.
*
* @category FHIRcast
* @param topic - The topic to publish to. Usually a UUID.
* @param events - An array of event names to listen for.
* @returns A `Promise` that resolves once the request completes, or rejects if it fails.
*/
async fhircastSubscribe(topic: string, events: FhircastEventName[]): Promise<SubscriptionRequest> {
if (!(typeof topic === 'string' && topic !== '')) {
throw new OperationOutcomeError(validationError('Invalid topic provided. Topic must be a valid string.'));
}
if (!(typeof events === 'object' && Array.isArray(events) && events.length > 0)) {
throw new OperationOutcomeError(
validationError(
'Invalid events provided. Events must be an array of event names containing at least one event.'
)
);
}
const subRequest = {
channelType: 'websocket',
mode: 'subscribe',
topic,
events,
} as PendingSubscriptionRequest;
const body = (await this.post(
this.fhircastHubUrl,
serializeFhircastSubscriptionRequest(subRequest),
ContentType.FORM_URL_ENCODED
)) as { 'hub.channel.endpoint': string };
const endpoint = body['hub.channel.endpoint'];
if (!endpoint) {
throw new Error('Invalid response!');
}
// Add endpoint to subscription request before returning
(subRequest as SubscriptionRequest).endpoint = endpoint;
return subRequest as SubscriptionRequest;
}
/**
* Unsubscribes from the specified topic.
*
* @category FHIRcast
* @param subRequest - A `SubscriptionRequest` representing a subscription to cancel. Mode will be set to `unsubscribe` automatically.
* @returns A `Promise` that resolves when request to unsubscribe is completed.
*/
async fhircastUnsubscribe(subRequest: SubscriptionRequest): Promise<void> {
if (!validateFhircastSubscriptionRequest(subRequest)) {
throw new OperationOutcomeError(
validationError('Invalid topic or subscriptionRequest. SubscriptionRequest must be an object.')
);
}
if (!(subRequest.endpoint && typeof subRequest.endpoint === 'string' && subRequest.endpoint.startsWith('ws'))) {
throw new OperationOutcomeError(
validationError('Provided subscription request must have an endpoint in order to unsubscribe.')
);
}
// Turn subRequest -> unsubRequest
subRequest.mode = 'unsubscribe';
// Send unsub request
await this.post(
this.fhircastHubUrl,
serializeFhircastSubscriptionRequest(subRequest),
ContentType.FORM_URL_ENCODED
);
}
/**
* Connects to a `FHIRcast` session.
*
* @category FHIRcast
* @param subRequest - The `SubscriptionRequest` to use for connecting.
* @returns A `FhircastConnection` which emits lifecycle events for the `FHIRcast` WebSocket connection.
*/
fhircastConnect(subRequest: SubscriptionRequest): FhircastConnection {
return new FhircastConnection(subRequest);
}
/**
* Publishes a new context to a given topic for a specified event type.
*
* @category FHIRcast
* @param topic - The topic to publish to. Usually a UUID.
* @param event - The name of the event to publish an updated context for, ie. `Patient-open`.
* @param context - The updated context containing resources relevant to this event.
* @param versionId - The `versionId` of the `anchor context` of the given event. Used for `DiagnosticReport-update` event.
* @returns A `Promise` that resolves once the request completes, or rejects if it fails.
*/
async fhircastPublish<EventName extends FhircastEventVersionOptional>(
topic: string,
event: EventName,
context: FhircastEventContext<EventName> | FhircastEventContext<EventName>[],
versionId?: never
): Promise<Record<string, any>>;
async fhircastPublish<RequiredVersionEvent extends FhircastEventVersionRequired>(
topic: string,
event: RequiredVersionEvent,
context: FhircastEventContext<RequiredVersionEvent> | FhircastEventContext<RequiredVersionEvent>[],
versionId: string
): Promise<Record<string, any>>;
async fhircastPublish<EventName extends FhircastEventVersionRequired | FhircastEventVersionOptional>(
topic: string,
event: EventName,
context: FhircastEventContext<EventName> | FhircastEventContext<EventName>[],
versionId?: string
): Promise<Record<string, any>> {
if (isContextVersionRequired(event)) {
return this.post(
this.fhircastHubUrl,
createFhircastMessagePayload<typeof event>(topic, event, context, versionId as string),
ContentType.JSON
);
}
assertContextVersionOptional(event);
return this.post(
this.fhircastHubUrl,
createFhircastMessagePayload<typeof event>(topic, event, context),
ContentType.JSON
);
}
/**
* Gets the current context of the given FHIRcast `topic`.
*
* @category FHIRcast
* @param topic - The topic to get the current context for. Usually a UUID.
* @returns A Promise which resolves to the `CurrentContext` for the given topic.
*/
async fhircastGetContext(topic: string): Promise<CurrentContext> {
return this.get(`${this.fhircastHubUrl}/${topic}`, { cache: 'no-cache' });
}
/**
* Invite a user to a project.
* @param projectId - The project ID.
* @param body - The InviteRequest.
* @returns Promise that returns a project membership or an operation outcome.
*/
async invite(projectId: string, body: InviteRequest): Promise<ProjectMembership | OperationOutcome> {
return this.post('admin/projects/' + projectId + '/invite', body);
}
/**
* Makes a POST request to the tokens endpoint.
* See {@link https://openid.net/specs/openid-connect-core-1_0.html#TokenEndpoint | OpenID Connect Core 1.0 TokenEndpoint} for full details.
* @param params - Token parameters.
* @returns The user profile resource.
*/
private async fetchTokens(params: Record<string, string>): Promise<ProfileResource> {
const formBody = new URLSearchParams(params);
const headers: HeadersInit = { ...this.defaultHeaders, 'Content-Type': ContentType.FORM_URL_ENCODED };
if (this.basicAuth) {
headers['Authorization'] = `Basic ${this.basicAuth}`;
}
if (this.credentialsInHeader) {
formBody.delete('client_id');
formBody.delete('client_secret');
if (!this.basicAuth && params.client_id && params.client_secret) {
headers['Authorization'] = `Basic ${encodeBase64(params.client_id + ':' + params.client_secret)}`;
}
}
const options: MedplumRequestOptions = {
method: 'POST',
headers,
body: formBody.toString(),
credentials: 'include',
};
let response: Response;
try {
response = await this.fetchWithRetry(this.tokenUrl, options);
} catch (err) {
this.refreshPromise = undefined;
throw err;
}
if (!response.ok) {
this.clearActiveLogin();
this.onUnauthenticated?.();
try {
const error = await response.json();
throw new OperationOutcomeError(badRequest(error.error_description));
} catch (err) {
throw new OperationOutcomeError(badRequest('Failed to fetch tokens'), { cause: err });
}
}
const tokens = await response.json();
await this.verifyTokens(tokens);
return this.getProfile() as ProfileResource;
}
/**
* Verifies the tokens received from the auth server.
* Validates the JWT against the JWKS.
* See {@link https://openid.net/specs/openid-connect-core-1_0.html#TokenEndpoint | OpenID Connect Core 1.0 TokenEndpoint} for full details.
* @param tokens - The token response.
* @returns Promise to complete.
*/
private async verifyTokens(tokens: TokenResponse): Promise<void> {
const token = tokens.access_token;
if (isJwt(token)) {
// Verify token has not expired
const tokenPayload = parseJWTPayload(token);
if (Date.now() >= (tokenPayload.exp as number) * 1000) {
this.clearActiveLogin();
throw new OperationOutcomeError(unauthorizedTokenExpired);
}
// Verify app_client_id
if (tokenPayload.cid) {
if (tokenPayload.cid !== this.clientId) {
this.clearActiveLogin();
throw new OperationOutcomeError(unauthorizedTokenAudience);
}
} else if (this.clientId && tokenPayload.client_id !== this.clientId) {
this.clearActiveLogin();
throw new OperationOutcomeError(unauthorizedTokenAudience);
}
}
return this.setActiveLogin({
accessToken: token,
refreshToken: tokens.refresh_token,
project: tokens.project,
profile: tokens.profile,
});
}
private checkSessionDetailsMatchLogin(login?: LoginState): boolean {
// We only need to validate if we already have session details
if (!(this.sessionDetails && login)) {
return true;
}
// Make sure sessionDetails.profile.id matches the ID in the profile reference we are checking against
// Otherwise return false if no profile reference in login
return login.profile?.reference?.endsWith(this.sessionDetails.profile.id) ?? false;
}
/**
* Sets up a listener for window storage events.
* This synchronizes state across browser windows and browser tabs.
*/
private setupStorageListener(): void {
try {
window.addEventListener('storage', (e: StorageEvent) => {
// Storage events fire when different tabs make changes.
// On storage clear (key === null) or profile change (key === 'activeLogin', and profile in 'activeLogin' is different)
// Refresh the page to ensure the active login is up to date.
if (e.key === null) {
locationUtils.reload();
} else if (e.key === this.storage.makeKey('activeLogin')) {
const oldState = (e.oldValue ? JSON.parse(e.oldValue) : undefined) as LoginState | undefined;
const newState = (e.newValue ? JSON.parse(e.newValue) : undefined) as LoginState | undefined;
if (
oldState?.profile.reference !== newState?.profile.reference ||
!this.checkSessionDetailsMatchLogin(newState)
) {
locationUtils.reload();
} else if (newState) {
this.setAccessToken(newState.accessToken, newState.refreshToken);
} else {
// Theoretically this should never be called, but we might want to keep it here just in case
this.clear();
}
}
});
} catch (_err) {
// Silently ignore if this environment does not support storage events
}
}
/**
* Gets the `SubscriptionManager` for WebSocket subscriptions.
*
* @category Subscriptions
* @returns the `SubscriptionManager` for this client.
*/
getSubscriptionManager(): SubscriptionManager {
if (!this.subscriptionManager) {
this.subscriptionManager = new SubscriptionManager(this, getWebSocketUrl(this.baseUrl, '/ws/subscriptions-r4'));
}
return this.subscriptionManager;
}
/**
* Subscribes to a given criteria, listening to notifications over WebSockets.
*
* This uses Medplum's `WebSocket Subscriptions` under the hood.
*
* A `SubscriptionEmitter` is returned from this function, which can be used to listen for updates to resources described by the given criteria.
*
* When subscribing to the same criteria multiple times, the same `SubscriptionEmitter` will be returned, and a reference count will be incremented.
*
* -----
* @example
* ```ts
* const emitter = medplum.subscribeToCriteria('Communication');
*
* emitter.addEventListener('message', (bundle: Bundle) => {
* // Called when a `Communication` resource is created or modified
* console.log(bundle?.entry?.[1]?.resource); // Logs the `Communication` resource that was updated
* });
* ```
*
* @category Subscriptions
* @param criteria - The criteria to subscribe to.
* @param subscriptionProps - Optional properties to add to the created `Subscription` resource.
* @returns a `SubscriptionEmitter` that emits `Bundle` resources containing changes to resources based on the given criteria.
*/
subscribeToCriteria(criteria: string, subscriptionProps?: Partial<Subscription>): SubscriptionEmitter {
return this.getSubscriptionManager().addCriteria(criteria, subscriptionProps);
}
/**
* Unsubscribes from the given criteria.
*
* When called the same amount of times as proceeding calls to `subscribeToCriteria` on a given `criteria`,
* the criteria is fully removed from the `SubscriptionManager`.
*
* @category Subscriptions
* @param criteria - The criteria to unsubscribe from.
* @param subscriptionProps - The optional properties that `subscribeToCriteria` was called with.
*/
unsubscribeFromCriteria(criteria: string, subscriptionProps?: Partial<Subscription>): void {
if (!this.subscriptionManager) {
return;
}
this.subscriptionManager.removeCriteria(criteria, subscriptionProps);
if (this.subscriptionManager.getCriteriaCount() === 0) {
this.subscriptionManager.closeWebSocket();
}
}
/**
* Get the master `SubscriptionEmitter` for the `SubscriptionManager`.
*
* The master `SubscriptionEmitter` gets messages for all subscribed `criteria` as well as WebSocket errors, `connect` and `disconnect` events, and the `close` event.
*
* It can also be used to listen for `heartbeat` messages.
*
*------
* @example
* ### Listening for `heartbeat`:
* ```ts
* const masterEmitter = medplum.getMasterSubscriptionEmitter();
*
* masterEmitter.addEventListener('heartbeat', (bundle: Bundle<SubscriptionStatus>) => {
* console.log(bundle?.entry?.[0]?.resource); // A `SubscriptionStatus` of type `heartbeat`
* });
*
* ```
* @category Subscriptions
* @returns the master `SubscriptionEmitter` from the `SubscriptionManager`.
*/
getMasterSubscriptionEmitter(): SubscriptionEmitter {
return this.getSubscriptionManager().getMasterEmitter();
}
}
/**
* Returns the default fetch method.
* The default fetch is currently only available in browser environments.
* If you want to use SSR such as Next.js, you should pass a custom fetch function.
* @returns The default fetch function for the current environment.
*/
function getDefaultFetch(): FetchLike {
if (!globalThis.fetch) {
throw new Error('Fetch not available in this environment');
}
return globalThis.fetch.bind(globalThis);
}
/**
* Attempts to retrieve the content location from the given HTTP response.
*
* This function prioritizes the "Content-Location" HTTP header as the
* most authoritative source for the content location. If this header is
* not present, it falls back to the "Location" HTTP header.
*
* Note that the FHIR spec does not follow the traditional HTTP semantics of "Content-Location" and "Location".
* "Content-Location" is not typically used with HTTP 202 responses because the content itself isn't available at the time of the response.
* However, the FHIR spec explicitly recommends it:
*
* 3.2.6.1.2 Kick-off Request
* 3.2.6.1.2.0.3 Response - Success
* HTTP Status Code of 202 Accepted
* Content-Location header with the absolute URL of an endpoint for subsequent status requests (polling location)
*
* Source: https://hl7.org/fhir/async-bulk.html
*
* In cases where neither of these headers are available (for instance,
* due to CORS restrictions), it attempts to retrieve the content location
* from the 'diagnostics' field of the first issue in an OperationOutcome object
* present in the response body. If all attempts fail, the function returns 'undefined'.
*
* @async
* @param response - The HTTP response object from which to extract the content location.
* @param body - The response body.
* @returns A Promise that resolves to the content location string if it is found, or 'undefined' if the content location cannot be determined from the response.
*/
async function tryGetContentLocation(
response: Response,
body: Record<string, string> | string | undefined
): Promise<string | undefined> {
// Accepted content location can come from multiple sources
// The authoritative source is the "Content-Location" HTTP header.
const contentLocation = response.headers.get('content-location');
if (contentLocation) {
return contentLocation;
}
// The next best source is the "Location" HTTP header.
const location = response.headers.get('location');
if (location) {
return location;
}
// However, "Content-Location" may not be available due to CORS limitations.
// In this case, we use the OperationOutcome.diagnostics field.
if (isOperationOutcome(body) && body.issue?.[0]?.diagnostics) {
return body.issue[0].diagnostics;
}
// If all else fails, return undefined.
return undefined;
}
/**
* Converts a FHIR resource bundle to a resource array.
* The bundle is attached to the array as a property named "bundle".
* @param bundle - A FHIR resource bundle.
* @returns The resource array with the bundle attached.
*/
function bundleToResourceArray<T extends Resource>(bundle: Bundle<T>): ResourceArray<T> {
const array = bundle.entry?.map((e) => e.resource as T) ?? [];
return Object.assign(array, { bundle });
}
function isCreateBinaryOptions(input: unknown): input is CreateBinaryOptions {
return isObject(input) && 'data' in input && 'contentType' in input;
}
// This function can be deleted after Medplum 4.0 and we remove the legacy createBinary method
export function normalizeCreateBinaryOptions(
arg1: BinarySource | CreateBinaryOptions,
arg2: string | undefined | MedplumRequestOptions,
arg3?: string,
arg4?: (e: ProgressEvent) => void
): CreateBinaryOptions {
if (isCreateBinaryOptions(arg1)) {
return arg1;
}
return {
data: arg1,
filename: arg2 as string | undefined,
contentType: arg3 as string,
onProgress: arg4,
};
}
function isCreatePdfOptions(input: unknown): input is CreatePdfOptions {
return isObject(input) && 'docDefinition' in input;
}
// This function can be deleted after Medplum 4.0 and we remove the legacy createPdf method
export function normalizeCreatePdfOptions(
arg1: TDocumentDefinitions | CreatePdfOptions,
arg2: string | undefined | MedplumRequestOptions,
arg3: Record<string, CustomTableLayout> | undefined,
arg4: TFontDictionary | undefined
): CreatePdfOptions {
if (isCreatePdfOptions(arg1)) {
return arg1;
}
return {
docDefinition: arg1,
filename: arg2 as string,
tableLayouts: arg3,
fonts: arg4,
};
}
function isRetryable(response: Response): boolean {
return response.status === 429 || response.status >= 500;
}