Skip to main content
Glama
index.ts25.3 kB
// SPDX-FileCopyrightText: Copyright Orangebot, Inc. and Medplum contributors // SPDX-License-Identifier: Apache-2.0 import type { Bundle, DiagnosticReport, Encounter, ImagingStudy, OperationOutcome, Patient, Reference, Resource, } from '@medplum/fhirtypes'; import { generateId } from '../crypto'; import { TypedEventTarget } from '../eventtarget'; import { OperationOutcomeError, validationError } from '../outcomes'; import { isReference } from '../types'; // We currently try to satisfy both STU2 and STU3. Where STU3 removes a resource / key from STU2, we leave it in as a valid key but don't require it. export const FHIRCAST_EVENT_NAMES = { 'Patient-open': 'Patient-open', 'Patient-close': 'Patient-close', 'ImagingStudy-open': 'ImagingStudy-open', 'ImagingStudy-close': 'ImagingStudy-close', 'Encounter-open': 'Encounter-open', 'Encounter-close': 'Encounter-close', 'DiagnosticReport-open': 'DiagnosticReport-open', 'DiagnosticReport-close': 'DiagnosticReport-close', 'DiagnosticReport-select': 'DiagnosticReport-select', 'DiagnosticReport-update': 'DiagnosticReport-update', syncerror: 'syncerror', } as const; export const FHIRCAST_RESOURCE_TYPES = [ 'Patient', 'Encounter', 'ImagingStudy', 'DiagnosticReport', 'OperationOutcome', 'Bundle', ] as const; export const FHIRCAST_EVENT_VERSION_REQUIRED = ['DiagnosticReport-update'] as const; export type FhircastEventVersionRequired = (typeof FHIRCAST_EVENT_VERSION_REQUIRED)[number]; export type FhircastEventVersionOptional = Exclude<FhircastEventName, FhircastEventVersionRequired>; export function isContextVersionRequired(event: string): event is FhircastEventVersionRequired { return (FHIRCAST_EVENT_VERSION_REQUIRED as readonly string[]).includes(event); } export function assertContextVersionOptional(event: string): asserts event is FhircastEventVersionOptional { if ((FHIRCAST_EVENT_VERSION_REQUIRED as readonly string[]).includes(event)) { throw new OperationOutcomeError(validationError(`'context.version' is required for '${event}'.`)); } } export type FhircastEventName = keyof typeof FHIRCAST_EVENT_NAMES; export type FhircastResourceEventName = Exclude<FhircastEventName, 'syncerror'>; export type FhircastResourceType = (typeof FHIRCAST_RESOURCE_TYPES)[number]; export type FhircastAnchorResourceType = 'Patient' | 'ImagingStudy' | 'Encounter' | 'DiagnosticReport'; export type FhircastEventContextDetails = { resourceType: FhircastResourceType | '*'; optional?: boolean; // NOTE: optional here is only referring to the schema, the spec often mentions that these are required if available as references for a given anchor resource manyAllowed?: boolean; array?: boolean; reference?: boolean; }; // Key value pairs of { [FhircastEventName]: [required_resource1, required_resource2] } export const FHIRCAST_EVENT_RESOURCES = { 'Patient-open': { patient: { resourceType: 'Patient' }, /* STU2 only! `encounter` key removed in STU3 */ encounter: { resourceType: 'Encounter', optional: true }, }, 'Patient-close': { patient: { resourceType: 'Patient' }, /* STU2 only! `encounter` key removed in STU3 */ encounter: { resourceType: 'Encounter', optional: true }, }, 'ImagingStudy-open': { study: { resourceType: 'ImagingStudy' }, encounter: { resourceType: 'Encounter', optional: true }, patient: { resourceType: 'Patient', optional: true }, }, 'ImagingStudy-close': { study: { resourceType: 'ImagingStudy' }, encounter: { resourceType: 'Encounter', optional: true }, patient: { resourceType: 'Patient', optional: true }, }, 'Encounter-open': { encounter: { resourceType: 'Encounter' }, patient: { resourceType: 'Patient' }, }, 'Encounter-close': { encounter: { resourceType: 'Encounter' }, patient: { resourceType: 'Patient' }, }, 'DiagnosticReport-open': { report: { resourceType: 'DiagnosticReport' }, encounter: { resourceType: 'Encounter', optional: true }, study: { resourceType: 'ImagingStudy', optional: true, manyAllowed: true }, patient: { resourceType: 'Patient' }, }, 'DiagnosticReport-close': { report: { resourceType: 'DiagnosticReport' }, encounter: { resourceType: 'Encounter', optional: true }, study: { resourceType: 'ImagingStudy', optional: true, manyAllowed: true }, patient: { resourceType: 'Patient' }, }, 'DiagnosticReport-select': { // Most event contexts contain a full resource, but `DiagnosticReport-select` context elements are actually references // See: https://build.fhir.org/ig/HL7/fhircast-docs/3-6-4-DiagnosticReport-select.html report: { resourceType: 'DiagnosticReport', reference: true }, patient: { resourceType: 'Patient', optional: true, reference: true }, select: { resourceType: '*', reference: true, manyAllowed: true }, }, 'DiagnosticReport-update': { // `report` and `patient` are also references for `DiagnosticReport-update`: // See: https://build.fhir.org/ig/HL7/fhircast-docs/3-6-3-DiagnosticReport-update.html report: { resourceType: 'DiagnosticReport', reference: true }, patient: { resourceType: 'Patient', optional: true, reference: true }, updates: { resourceType: 'Bundle' }, }, syncerror: { operationoutcome: { resourceType: 'OperationOutcome' }, }, } as const satisfies Record<FhircastEventName, Record<string, FhircastEventContextDetails>>; /** * Checks if a `ResourceType` can be used in a `FHIRcast` context. * * @param resourceType - A `ResourceType` to test. * @returns `true` if this is a resource type associated with `FHIRcast` contexts, otherwise returns `false`. */ export function isFhircastResourceType(resourceType: FhircastResourceType): boolean { return FHIRCAST_RESOURCE_TYPES.includes(resourceType); } /** * A `FHIRcast` subscription request. * * Can be passed to `MedplumClient.fhircastConnect` or `MedplumClient.fhircastUnsubscribe` to either open a `FHIRcast` connection, or unsubscribe from the subscription. */ export type SubscriptionRequest = { channelType: 'websocket'; mode: 'subscribe' | 'unsubscribe'; events: FhircastEventName[]; topic: string; endpoint: string; }; export type FhircastPatientContext = { key: 'patient'; resource: Patient }; export type FhircastEncounterContext = { key: 'encounter'; resource: Encounter }; export type FhircastStudyContext = { key: 'study'; resource: ImagingStudy }; export type FhircastReportContext = { key: 'report'; resource: DiagnosticReport }; export type FhircastReportReferenceContext = { key: 'report'; reference: Reference<DiagnosticReport> }; export type FhircastPatientReferenceContext = { key: 'patient'; reference: Reference<Patient> }; export type FhircastUpdatesContext = { key: 'updates'; resource: Bundle }; export type FhircastSelectContext = { key: 'select'; reference: Reference }; export type FhircastOperationOutcomeContext = { key: 'operationoutcome'; resource: OperationOutcome }; // These are all the contexts that contain a `resource` key export type FhircastResourceContext = | FhircastPatientContext | FhircastEncounterContext | FhircastStudyContext | FhircastReportContext | FhircastUpdatesContext | FhircastOperationOutcomeContext; // The reference contexts related to `*-select` and `*-update` events, which contain a `reference` key export type FhircastReferenceContext = | FhircastReportReferenceContext | FhircastPatientReferenceContext | FhircastSelectContext; export type FhircastPatientOpenContext = FhircastPatientContext | FhircastEncounterContext; export type FhircastPatientCloseContext = FhircastPatientOpenContext; export type FhircastImagingStudyOpenContext = FhircastStudyContext | FhircastEncounterContext | FhircastPatientContext; export type FhircastImagingStudyCloseContext = FhircastImagingStudyOpenContext; export type FhircastEncounterOpenContext = FhircastEncounterContext | FhircastPatientContext; export type FhircastEncounterCloseContext = FhircastEncounterOpenContext; export type FhircastDiagnosticReportOpenContext = | FhircastReportContext | FhircastEncounterContext | FhircastStudyContext | FhircastPatientContext; export type FhircastDiagnosticReportCloseContext = FhircastDiagnosticReportOpenContext; export type FhircastDiagnosticReportUpdateContext = | FhircastReportReferenceContext | FhircastPatientReferenceContext | FhircastUpdatesContext; export type FhircastDiagnosticReportSelectContext = | FhircastReportReferenceContext | FhircastPatientReferenceContext | FhircastSelectContext; export type FhircastSyncErrorContext = FhircastOperationOutcomeContext; // This is the one key that only exists within a GetCurrentContext // Specifically related to the `DiagnosticReport-update` event in a `DiagnosticReport` context // See the FHIRcast docs regarding content sharing: https://build.fhir.org/ig/HL7/fhircast-docs/2-10-ContentSharing.html#updating-attributes-of-context-resources-and-addingremoving-context-resources // And the GetCurrentContext page that mentions the specifications of this key: https://build.fhir.org/ig/HL7/fhircast-docs/2-9-GetCurrentContext.html#context export type FhircastHubContentContext = { key: 'content'; resource: Bundle; }; // Type utility to get keys for a specific event export type FhircastEventKeys<EventName extends FhircastEventName> = keyof (typeof FHIRCAST_EVENT_RESOURCES)[EventName]; // Type utility to extract the resource type from an event and key export type FhircastContextResourceType< EventName extends FhircastEventName, K extends FhircastEventKeys<EventName>, > = (typeof FHIRCAST_EVENT_RESOURCES)[EventName][K] extends { resourceType: infer R } ? R : never; export type FhircastEventContext<EventName extends FhircastEventName = FhircastResourceEventName> = EventName extends 'Patient-open' ? FhircastPatientOpenContext : EventName extends 'Patient-close' ? FhircastPatientCloseContext : EventName extends 'ImagingStudy-open' ? FhircastImagingStudyOpenContext : EventName extends 'ImagingStudy-close' ? FhircastImagingStudyCloseContext : EventName extends 'Encounter-open' ? FhircastEncounterOpenContext : EventName extends 'Encounter-close' ? FhircastEncounterCloseContext : EventName extends 'DiagnosticReport-open' ? FhircastDiagnosticReportOpenContext : EventName extends 'DiagnosticReport-close' ? FhircastDiagnosticReportCloseContext : EventName extends 'DiagnosticReport-update' ? FhircastDiagnosticReportUpdateContext : EventName extends 'DiagnosticReport-select' ? FhircastDiagnosticReportSelectContext : EventName extends 'syncerror' ? FhircastSyncErrorContext : never; export type AnchorResourceOpenEvent<T extends FhircastAnchorResourceType> = T extends FhircastAnchorResourceType ? `${T}-open` : never; export type CurrentContext<T extends FhircastAnchorResourceType | '' = FhircastAnchorResourceType | ''> = T extends '' ? { 'context.type': ''; context: never[] } : T extends 'DiagnosticReport' ? { 'context.type': 'DiagnosticReport'; 'context.versionId': string; context: (FhircastEventContext<'DiagnosticReport-open'> | FhircastHubContentContext)[]; } : T extends 'Patient' | 'Encounter' | 'ImagingStudy' ? { 'context.type': T; 'context.versionId': string; context: FhircastEventContext<AnchorResourceOpenEvent<T>>[]; } : never; export type PendingSubscriptionRequest = Omit<SubscriptionRequest, 'endpoint'>; export type FhircastEventPayload<EventName extends FhircastEventName = FhircastEventName> = { 'hub.topic': string; 'hub.event': EventName; context: FhircastEventContext<EventName>[]; 'context.versionId'?: string; 'context.priorVersionId'?: string; }; export type FhircastMessagePayload<EventName extends FhircastEventName = FhircastEventName> = { timestamp: string; id: string; event: FhircastEventPayload<EventName>; }; export function isCompletedSubscriptionRequest( subscriptionRequest: SubscriptionRequest | PendingSubscriptionRequest ): subscriptionRequest is SubscriptionRequest { return !!(subscriptionRequest as SubscriptionRequest).endpoint; } /** * Creates a serialized url-encoded payload for a `FHIRcast` subscription from a `SubscriptionRequest` object that can be directly used in an HTTP request to the Hub. * * @param subscriptionRequest - An object representing a subscription request. * @returns A serialized subscription in url-encoded form. */ export function serializeFhircastSubscriptionRequest( subscriptionRequest: SubscriptionRequest | PendingSubscriptionRequest ): string { if (!validateFhircastSubscriptionRequest(subscriptionRequest)) { throw new OperationOutcomeError( validationError('subscriptionRequest must be an object conforming to SubscriptionRequest type.') ); } const { channelType, mode, topic, events } = subscriptionRequest; const formattedSubRequest = { 'hub.channel.type': channelType, 'hub.mode': mode, 'hub.topic': topic, 'hub.events': events.join(','), } as Record<string, string>; if (isCompletedSubscriptionRequest(subscriptionRequest)) { formattedSubRequest.endpoint = subscriptionRequest.endpoint; } return new URLSearchParams(formattedSubRequest).toString(); } /** * Validates that a `SubscriptionRequest`. * * @param subscriptionRequest - The `SubscriptionRequest` to validate. * @returns A `boolean` indicating whether or not the `SubscriptionRequest` is valid. */ export function validateFhircastSubscriptionRequest( subscriptionRequest: SubscriptionRequest | PendingSubscriptionRequest ): boolean { if (typeof subscriptionRequest !== 'object') { return false; } const { channelType, mode, topic, events } = subscriptionRequest; if (!(channelType && mode && topic && events)) { return false; } if (typeof topic !== 'string') { return false; } if (typeof events !== 'object' || !Array.isArray(events) || events.length < 1) { return false; } if (channelType !== 'websocket') { return false; } if (mode !== 'subscribe' && mode !== 'unsubscribe') { return false; } for (const event of events) { if (!FHIRCAST_EVENT_NAMES[event]) { return false; } } if ( isCompletedSubscriptionRequest(subscriptionRequest) && !(typeof subscriptionRequest.endpoint === 'string' && subscriptionRequest.endpoint.startsWith('ws')) ) { return false; } return true; } /** * Throws if the context resource type is invalid. Intended as a helper for `validateFhircastContexts` only. * * @param event - The `FHIRcast` event name associated with the provided contexts. * @param resource - The `FHIRcast` event context resource to validate for given key. * @param i - The index of the current context in the context list. * @param keySchema - Schema for given key for FHIRcast event. */ function validateSingleResourceContext( event: FhircastEventName, resource: Resource, i: number, keySchema: FhircastEventContextDetails ): void { if (typeof resource !== 'object') { throw new OperationOutcomeError( validationError( `context[${i}] is invalid. Context must contain a single valid FHIR resource! Resource is not an object.` ) ); } if (!(resource.id && typeof resource.id === 'string')) { throw new OperationOutcomeError( validationError(`context[${i}] is invalid. Resource must contain a valid string ID.`) ); } if (!resource.resourceType) { throw new OperationOutcomeError( validationError(`context[${i}] is invalid. Resource must contain a resource type. No resource type found.`) ); } const expectedResourceType = keySchema.resourceType; // Make sure that resource is a valid type for this event if expected is not wildcard if (expectedResourceType !== '*') { if (!isFhircastResourceType(resource.resourceType as FhircastResourceType)) { throw new OperationOutcomeError( validationError( `context[${i}] is invalid. Resource must contain a valid FHIRcast resource type. Resource type is not a known resource type.` ) ); } if (expectedResourceType && resource.resourceType !== expectedResourceType) { throw new OperationOutcomeError( validationError( `context[${i}] is invalid. context[${i}] for the '${event}' event should contain resource of type ${expectedResourceType}.` ) ); } } } /** * Throws if the context is invalid. Intended as a helper for `validateFhircastContexts` only. * * @param event - The `FHIRcast` event name associated with the provided contexts. * @param context - The `FHIRcast` event contexts to validate. * @param i - The index of the current context in the context list. * @param keySchema - Schema for given key for FHIRcast event. * @param keysSeen - Set of keys seen so far. Used to prevent duplicate keys. */ function validateFhircastContext<EventName extends FhircastEventName = FhircastEventName>( event: EventName, context: FhircastEventContext<EventName>, i: number, keySchema: FhircastEventContextDetails, keysSeen: Map<FhircastEventContext<EventName>['key'], number> ): void { keysSeen.set(context.key, (keysSeen.get(context.key) ?? 0) + 1); if (keySchema.reference) { if (!isReference((context as FhircastReferenceContext).reference)) { // Validate single reference throw new OperationOutcomeError( validationError(`context[${i}] is invalid. Expected key '${context.key}' to be a reference.`) ); } } else { validateSingleResourceContext(event, (context as FhircastResourceContext).resource, i, keySchema); } } /** * Throws if any context in the given array of contexts is invalid. * * @param event - The `FHIRcast` event name associated with the provided contexts. * @param contexts - The `FHIRcast` event contexts to validate. */ function validateFhircastContexts<EventName extends FhircastEventName>( event: EventName, contexts: FhircastEventContext<EventName>[] ): void { const keysSeen = new Map<FhircastEventContext['key'], number>(); const eventSchema = FHIRCAST_EVENT_RESOURCES[event] as Record< FhircastEventContext['key'], FhircastEventContextDetails >; for (let i = 0; i < contexts.length; i++) { const key = contexts[i].key as FhircastEventContext['key']; if (!eventSchema[key]) { throw new OperationOutcomeError( validationError(`Key '${key}' not found for event '${event}'. Make sure to add only valid keys.`) ); } validateFhircastContext(event, contexts[i], i, eventSchema[key], keysSeen); } // Iterate each key, if conditions for keys are not met as confirmed by `keysSeen` map, throw an error for (const [key, details] of Object.entries(eventSchema) as [ FhircastEventContext['key'], FhircastEventContextDetails, ][]) { // If not optional and not keysSeen.has(key), throw if (!(details.optional || keysSeen.has(key))) { throw new OperationOutcomeError( validationError(`Missing required key '${key}' on context for '${event}' event.`) ); } // If not multiple allowed and keySeen.get(key) > 1, throw if (!details.manyAllowed && (keysSeen.get(key) ?? 0) > 1) { throw new OperationOutcomeError( validationError( `${keysSeen.get( key )} context entries with key '${key}' found for the '${event}' event when schema only allows for 1.` ) ); } } } /** * Creates a serializable JSON payload for the `FHIRcast` protocol * * @param topic - The topic that this message will be published on. Usually a UUID. * @param event - The event name, ie. "Patient-open" or "Patient-close". * @param context - The updated context, containing new versions of resources related to this event. * @param versionId - The current `versionId` of the anchor context. For example, in `DiagnosticReport-update`, it's the `versionId` of the `DiagnosticReport`. * @returns A serializable `FhircastMessagePayload`. */ export function createFhircastMessagePayload<EventName extends FhircastEventVersionOptional>( topic: string, event: EventName, context: FhircastEventContext<EventName> | FhircastEventContext<EventName>[], versionId?: never ): FhircastMessagePayload<EventName>; export function createFhircastMessagePayload<EventName extends FhircastEventVersionRequired>( topic: string, event: EventName, context: FhircastEventContext<EventName> | FhircastEventContext<EventName>[], versionId: string ): FhircastMessagePayload<EventName>; export function createFhircastMessagePayload< EventName extends FhircastEventVersionOptional | FhircastEventVersionRequired, >( topic: string, event: EventName, context: FhircastEventContext<EventName> | FhircastEventContext<EventName>[], versionId?: string ): FhircastMessagePayload<EventName> { if (!(topic && typeof topic === 'string')) { throw new OperationOutcomeError(validationError('Must provide a topic.')); } if (!FHIRCAST_EVENT_NAMES[event]) { throw new OperationOutcomeError( validationError( `Must provide a valid FHIRcast event name. Supported events: ${Object.keys(FHIRCAST_EVENT_NAMES).join(', ')}` ) ); } if (typeof context !== 'object') { throw new OperationOutcomeError(validationError('context must be a context object or array of context objects.')); } if ((FHIRCAST_EVENT_VERSION_REQUIRED as readonly string[]).includes(event) && !versionId) { throw new OperationOutcomeError(validationError(`The '${event}' event must contain a 'context.versionId'.`)); } const normalizedContexts = Array.isArray(context) ? context : [context]; // This will throw if any context in the array is invalid validateFhircastContexts(event, normalizedContexts); return { timestamp: new Date().toISOString(), id: generateId(), event: { 'hub.topic': topic, 'hub.event': event, context: normalizedContexts, ...(versionId ? { 'context.versionId': versionId } : {}), }, }; } export type FhircastConnectEvent = { type: 'connect' }; export type FhircastMessageEvent = { type: 'message'; payload: FhircastMessagePayload }; export type FhircastDisconnectEvent = { type: 'disconnect' }; export type FhircastSubscriptionEventMap = { connect: FhircastConnectEvent; message: FhircastMessageEvent; disconnect: FhircastDisconnectEvent; }; /** * A class representing a `FHIRcast` connection. * * `FhircastConnection` extends `EventTarget` and emits 3 lifecycle events: * 1. `connect` - An event to signal when a WebSocket connection has been opened. Fired as soon as a WebSocket emits `open`. * 2. `message` - Contains a `payload` field containing a `FHIRcast` message payload exactly as it comes in over WebSockets. * 3. `disconnect` - An event to signal when a WebSocket connection has been closed. Fired as soon as a WebSocket emits `close`. * * To close the connection, call `connection.disconnect()` and listen to the `disconnect` event to know when the connection has been disconnected. */ export class FhircastConnection extends TypedEventTarget<FhircastSubscriptionEventMap> { readonly subRequest: SubscriptionRequest; private readonly websocket: WebSocket; /** * Creates a new `FhircastConnection`. * @param subRequest - The subscription request to initialize the connection from. */ constructor(subRequest: SubscriptionRequest) { super(); this.subRequest = subRequest; if (!subRequest.endpoint) { throw new OperationOutcomeError(validationError('Subscription request should contain an endpoint.')); } if (!validateFhircastSubscriptionRequest(subRequest)) { throw new OperationOutcomeError(validationError('Subscription request failed validation.')); } const websocket = new WebSocket(subRequest.endpoint); websocket.addEventListener('open', () => { this.dispatchEvent({ type: 'connect' }); websocket.addEventListener('message', (event: MessageEvent) => { const message = JSON.parse(event.data) as Record<string, string | object>; // This is a check for `subscription request confirmations`, we just discard these for now if (message['hub.topic']) { return; } const fhircastMessage = message as unknown as FhircastMessagePayload; // Don't bubble up heartbeats, they are just noise if (fhircastMessage.event['hub.event'] === ('heartbeat' as unknown as FhircastEventName)) { return; } this.dispatchEvent({ type: 'message', payload: fhircastMessage }); websocket.send( JSON.stringify({ id: message?.id, timestamp: new Date().toISOString(), }) ); }); websocket.addEventListener('close', () => { this.dispatchEvent({ type: 'disconnect' }); }); }); this.websocket = websocket; } disconnect(): void { this.websocket.close(); } }

Latest Blog Posts

MCP directory API

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

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

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