Skip to main content
Glama
bundle.ts9.06 kB
// SPDX-FileCopyrightText: Copyright Orangebot, Inc. and Medplum contributors // SPDX-License-Identifier: Apache-2.0 import type { Bundle, BundleEntry, BundleEntryRequest, ExtractResource, Resource, ResourceType, } from '@medplum/fhirtypes'; import { generateId } from './crypto'; import { isReference } from './types'; import { deepClone } from './utils'; /** * More on Bundles can be found here * http://hl7.org/fhir/R4/bundle.html */ /** * Takes a bundle and creates a Transaction Type bundle * @param bundle - The Bundle object that we'll receive from the search query * @returns transaction type bundle */ export function convertToTransactionBundle(bundle: Bundle): Bundle { const idToUuid: Record<string, string> = {}; bundle = deepClone(bundle); for (const entry of bundle.entry || []) { const resource = entry.resource; if (!resource) { continue; } if (resource.meta !== undefined) { delete resource.meta.author; delete resource.meta.compartment; delete resource.meta.lastUpdated; delete resource.meta.project; delete resource.meta.versionId; if (Object.keys(resource.meta).length === 0) { delete resource.meta; } } const id = resource?.id; if (id) { idToUuid[id] = generateId(); entry.fullUrl = 'urn:uuid:' + idToUuid[id]; delete entry.resource?.id; } } const input = bundle.entry; const jsonString = JSON.stringify( { resourceType: 'Bundle', type: 'transaction', entry: input?.map((entry: BundleEntry) => ({ fullUrl: entry.fullUrl, request: { method: 'POST', url: entry.resource?.resourceType }, resource: entry.resource, })), }, (key, value) => referenceReplacer(key, value, idToUuid), 2 ); return reorderBundle(JSON.parse(jsonString) as Bundle); } function referenceReplacer(key: string, value: string, idToUuid: Record<string, string>): string { if (key === 'reference' && typeof value === 'string') { let id; if (value.includes('/')) { id = value.split('/')[1]; } else if (value.startsWith('urn:uuid:')) { id = value.slice(9); } else if (value.startsWith('#')) { id = value.slice(1); } if (id) { const replacement = idToUuid[id]; if (replacement) { return 'urn:uuid:' + replacement; } } } return value; } /** * Topologically sorts a `batch` or `transaction` bundle to improve reference resolution. * The bundle is sorted such that a resource is created _before_ references to that resource appear in the bundle. * * In the event of cycles, this function will first create a POST request for each resource in the cycle, and then will * append a PUT request to the bundle. This ensures that each resources in the cycle is visited twice, and all * references can be resolved * @param bundle - Input bundle with type `batch` or `transaction` * @returns Bundle of the same type, with Bundle.entry reordered */ export function reorderBundle(bundle: Bundle): Bundle { const adjacencyList = buildAdjacencyList(bundle); const { sorted: sortedFullUrls, cycles } = topologicalSortWithCycles(adjacencyList); const entryMap: Record<string, BundleEntry> = {}; const entriesWithoutFullUrl: BundleEntry[] = []; for (const entry of bundle.entry ?? []) { if (entry.fullUrl) { entryMap[entry.fullUrl] = entry; } else { // Preserve entries without fullUrl (e.g., PATCH operations) // These don't need topological sorting since they operate on existing resources entriesWithoutFullUrl.push(entry); } } const reorderedEntries = sortedFullUrls.map((fullUrl) => entryMap[fullUrl]); // Handle cycles by appending additional entries with a method of 'PUT' for (const cycle of cycles) { for (const fullUrl of cycle) { const originalEntry = entryMap[fullUrl]; const putEntry: BundleEntry = { ...originalEntry, request: { ...(originalEntry.request as BundleEntryRequest), method: 'PUT', }, }; reorderedEntries.push(putEntry); } } // Append entries without fullUrl at the end (e.g., PATCH operations that update existing resources) reorderedEntries.push(...entriesWithoutFullUrl); return { ...bundle, entry: reorderedEntries }; } type AdjacencyList = Record<string, string[]>; const VertexState = { NotVisited: 'NotVisited', Visiting: 'Visiting', Visited: 'Visited', } as const; type VertexState = (typeof VertexState)[keyof typeof VertexState]; function topologicalSortWithCycles(graph: AdjacencyList): { sorted: string[]; cycles: string[][] } { const sorted: string[] = []; const state: Record<string, VertexState> = {}; const cycles: string[][] = []; // Initialize all vertices to NotVisited state for (const vertex of Object.keys(graph)) { state[vertex] = VertexState.NotVisited; } function visit(vertex: string, path: string[]): boolean { // If this vertex is already visited, return true if (state[vertex] === VertexState.Visited) { return true; } // If this vertex is currently being visited, we have a cycle if (state[vertex] === VertexState.Visiting) { const cycleStartIndex = path.lastIndexOf(vertex); if (cycleStartIndex !== -1) { cycles.push(path.slice(cycleStartIndex)); } return true; // return true for vertices that are part of cycles } // Mark the vertex as visiting and add it to the path state[vertex] = VertexState.Visiting; path.push(vertex); // Visit all neighbors let hasCycle = false; for (const neighbor of graph[vertex]) { if (!visit(neighbor, path)) { hasCycle = true; } } // Mark the vertex as visited, remove it from the path, and add it to the sorted list state[vertex] = VertexState.Visited; path.pop(); sorted.unshift(vertex); return !hasCycle; } for (const vertex in graph) { if (state[vertex] === VertexState.NotVisited) { const path: string[] = []; visit(vertex, path); } } return { sorted, cycles }; } function findReferences(resource: any, callback: (reference: string) => void): void { for (const key in resource) { if (resource[key] && typeof resource[key] === 'object') { const value = resource[key]; if (isReference(value)) { const reference = value.reference; if (reference.startsWith('urn:uuid:')) { callback(reference); } } else { findReferences(value, callback); } } } } function buildAdjacencyList(bundle: Bundle): AdjacencyList { const adjacencyList: AdjacencyList = {}; // Initialize adjacency list with empty arrays for each entry's fullUrl for (const entry of bundle.entry || []) { if (entry.fullUrl) { adjacencyList[entry.fullUrl] = []; } } for (const entry of bundle.entry || []) { const fullUrl = entry.fullUrl; if (entry.resource) { findReferences(entry.resource, (reference: string) => { // Add an incoming reference to the adjacency list if (adjacencyList[reference]) { adjacencyList[reference].push(fullUrl as string); } }); } } return adjacencyList; } /** * Converts a resource with contained resources to a transaction bundle. * This function is useful when creating a resource that contains other resources. * Handles local references and topological sorting. * @param resource - The input resource which may or may not include contained resources. * @returns A bundle with the input resource and all contained resources. */ export function convertContainedResourcesToBundle(resource: Resource & { contained?: Resource[] }): Bundle { // Create a clone so we don't modify the original resource resource = deepClone(resource); // Create the simple naive bundle const simpleBundle = { resourceType: 'Bundle', type: 'transaction', entry: [{ resource }], } satisfies Bundle; // Move all contained resources to the bundle if (resource.contained) { for (const contained of resource.contained) { simpleBundle.entry.push({ resource: contained }); } resource.contained = undefined; } // Make sure that all resources have an ID // This is required for convertToTransactionBundle for (const entry of simpleBundle.entry) { if (entry.resource && !entry.resource.id) { entry.resource.id = generateId(); } } // Convert to a transaction bundle // This adds fullUrl and request properties to each entry // and reorders the bundle to ensure that contained resources are created before they are referenced. return convertToTransactionBundle(simpleBundle); } export function findResourceInBundle<K extends ResourceType>( bundle: Bundle, resourceType: K, id: string ): ExtractResource<K> { return bundle.entry?.find(({ resource }) => resource?.resourceType === resourceType && resource?.id === id) ?.resource as ExtractResource<K>; }

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