Skip to main content
Glama
resourcegraph.ts11.2 kB
// SPDX-FileCopyrightText: Copyright Orangebot, Inc. and Medplum contributors // SPDX-License-Identifier: Apache-2.0 import type { TypedValue, WithId } from '@medplum/core'; import { allOk, badRequest, DEFAULT_SEARCH_COUNT, evalFhirPathTyped, getReferenceString, notFound, OperationOutcomeError, Operator, parseSearchRequest, PropertyType, toTypedValue, } from '@medplum/core'; import type { FhirRequest, FhirResponse } from '@medplum/fhir-router'; import type { GraphDefinition, GraphDefinitionLink, GraphDefinitionLinkTarget, Reference, Resource, ResourceType, } from '@medplum/fhirtypes'; import { getAuthenticatedContext } from '../../context'; import { getLogger } from '../../logger'; import type { Repository } from '../repo'; type ResourceCache = Record<string, WithId<Resource>>; /** * Handles a Resource $graph request. * * The operation fetches all the data related to this resources as defined by a GraphDefinition resource * * See: https://hl7.org/fhir/plandefinition-operation-apply.html * @param req - The FHIR request. * @returns The FHIR response. */ export async function resourceGraphHandler(req: FhirRequest): Promise<FhirResponse> { const ctx = getAuthenticatedContext(); const { resourceType, id } = req.params; const definition = await validateQueryParameters(req); if (!definition.start || definition.start !== resourceType) { throw new OperationOutcomeError(badRequest('Missing or incorrect `start` type')); } const rootResource = await ctx.repo.readResource(resourceType, id); const results = [rootResource]; const resourceCache: ResourceCache = Object.create(null); resourceCache[getReferenceString(rootResource)] = rootResource; if ('url' in rootResource) { resourceCache[(rootResource as { url: string }).url] = rootResource; } await followLinks(ctx.repo, rootResource, definition.link, results, resourceCache); return [ allOk, { resourceType: 'Bundle', entry: deduplicateResources(results).map((r) => ({ resource: r, })), type: 'collection', }, ]; } /** * * current resource is the start resource * for each link: * evaluate the path * assert we get a collection of references * filter the references to have the target resourceType * for each elem in the collection: * read the reference * add the result to the bundle * follow the subLink on the element * @param resource * @param path */ type FhirPathLink = GraphDefinitionLink & Required<Pick<GraphDefinitionLink, 'path'>>; function isFhirPathLink(link: GraphDefinitionLink): link is FhirPathLink { return link.path !== undefined; } type SearchLink = Omit<GraphDefinitionLink, 'path'> & { target: Required<Pick<GraphDefinitionLinkTarget, 'type' | 'params'>>; }; function isSearchLink(link: GraphDefinitionLink): link is SearchLink { return !!(link.path === undefined && link.target?.every((t) => t.type && t.params)); } async function followLinks( repo: Repository, resource: WithId<Resource>, links: GraphDefinitionLink[] | undefined, results: WithId<Resource>[], resourceCache: ResourceCache, depth = 0 ): Promise<void> { // Circuit Breaker if (results.length > 1000 || depth >= 5) { return; } // Recursive Base Case if (!links) { return; } for (const link of links) { for (const target of link.target || []) { let linkedResources: WithId<Resource>[]; if (isFhirPathLink(link)) { linkedResources = await followFhirPathLink(repo, link, target, resource, resourceCache); } else if (isSearchLink(link)) { linkedResources = await followSearchLink(repo, resource, link, resourceCache); } else { throw new OperationOutcomeError(badRequest(`Invalid link: ${JSON.stringify(link)}`)); } linkedResources = deduplicateResources(linkedResources); results.push(...linkedResources); for (const linkedResource of linkedResources) { await followLinks(repo, linkedResource, target.link, results, resourceCache, depth + 1); } } } } /** * If link.path: * Evaluate the FHIRPath expression to get the corresponding properties * For elem in elements: * If element is reference: follow reference * If element is canonical: search for resources with url===canonical * @param repo - The repository object for fetching data * @param link - A link element defined in the GraphDefinition * @param target - The target types. * @param resource - The resource for which this GraphDefinition is being applied * @param resourceCache - A cache of previously fetched resources. Used to prevent redundant reads * @returns The running list of all the resources found while applying this graph */ async function followFhirPathLink( repo: Repository, link: FhirPathLink, target: GraphDefinitionLinkTarget, resource: WithId<Resource>, resourceCache: ResourceCache ): Promise<WithId<Resource>[]> { const results = [] as WithId<Resource>[]; const elements = evalFhirPathTyped(link.path, [toTypedValue(resource)]); // The only kinds of links we can follow are 'reference search parameters'. This includes elements of type // Reference and type canonical if (!elements.every((elem) => ([PropertyType.Reference, PropertyType.canonical] as string[]).includes(elem.type))) { throw new OperationOutcomeError(badRequest('Invalid link path. Must return a path to a Reference type')); } // For each of the elements we found on the current resource, follow their various targets const referenceElements = elements.filter((elem) => elem.type === PropertyType.Reference); if (referenceElements.length > 0) { results.push(...(await followReferenceElements(repo, referenceElements, target, resourceCache))); } const canonicalElements = elements.filter((elem) => elem.type === PropertyType.canonical); if (canonicalElements.length > 0) { results.push(...(await followCanonicalElements(repo, canonicalElements, target, resourceCache))); } return results; } async function followReferenceElements( repo: Repository, elements: TypedValue[], target: GraphDefinitionLinkTarget, resourceCache: ResourceCache ): Promise<WithId<Resource>[]> { const targetReferences = elements .filter((elem) => elem.value.reference?.split('/')[0] === target.type) .map((elem) => elem.value as Reference); const results = [] as WithId<Resource>[]; for (const ref of targetReferences) { if (ref.reference) { if (ref.reference in resourceCache) { results.push(resourceCache[ref.reference]); } else { const linkedResource = await repo.readReference(ref); // Cache here to speed up subsequent loop iterations addToCache(linkedResource, resourceCache); results.push(linkedResource); } } } return results; } async function followCanonicalElements( repo: Repository, elements: TypedValue[], target: GraphDefinitionLinkTarget, resourceCache: ResourceCache ): Promise<WithId<Resource>[]> { // Filter out Resources where we've seen the canonical URL const targetUrls = elements.map((elem) => elem.value as string); const results = [] as WithId<Resource>[]; for (const url of targetUrls) { if (url in resourceCache) { results.push(resourceCache[url]); } else { const linkedResources = await repo.searchResources({ resourceType: target.type as ResourceType, filters: [{ code: 'url', operator: Operator.EQUALS, value: url }], }); if (linkedResources.length > 1) { getLogger().warn('Found more than 1 resource with canonical URL', { url }); } // Cache here to speed up subsequent loop iterations linkedResources.forEach((res) => addToCache(res, resourceCache)); results.push(...linkedResources); } } return results; } /** * Fetches all resources referenced by this GraphDefinition link, * where the link is specified using search parameters * @param repo - The repository object for fetching data * @param resource - The resource for which this GraphDefinition is being applied * @param link - A link element defined in the GraphDefinition * @param resourceCache - A cache of previously fetched resources. Used to prevent redundant reads * @returns The running list of all the resources found while applying this graph */ async function followSearchLink( repo: Repository, resource: WithId<Resource>, link: SearchLink, resourceCache: ResourceCache ): Promise<WithId<Resource>[]> { const results = [] as WithId<Resource>[]; for (const target of link.target) { const searchResourceType = target.type; const params = target.params as string; validateTargetParams(params); // Replace the {ref} string with a pointer to the current resource const searchParams = params.replace('{ref}', getReferenceString(resource)); // Formulate the searchURL string const searchRequest = parseSearchRequest(`${searchResourceType}?${searchParams}`); // Parse the max count from the link description, if available searchRequest.count = Math.min(parseCardinality(link.max), 5000); // Run the search and add the results to the `results` array const resources = await repo.searchResources(searchRequest); if (resources) { resources.forEach((res) => addToCache(res, resourceCache)); results.push(...resources); } } return results; } /** * Parses and validates the operation parameters. * See: https://www.hl7.org/fhir/resource-operation-graph.html * @param req - The HTTP request. * @returns The operation parameters if available; otherwise, undefined. */ async function validateQueryParameters(req: FhirRequest): Promise<GraphDefinition> { const ctx = getAuthenticatedContext(); const { graph } = req.query; if (typeof graph !== 'string') { throw new OperationOutcomeError(badRequest('Missing required parameter: graph')); } const definition = await ctx.repo.searchOne<GraphDefinition>({ resourceType: 'GraphDefinition', filters: [{ code: 'name', operator: Operator.EQUALS, value: graph }], }); if (!definition) { throw new OperationOutcomeError(notFound); } return definition; } function validateTargetParams(params: string): void { if (!params.includes('{ref}')) { throw new OperationOutcomeError(badRequest(`Link target search params must include {ref}`)); } } function parseCardinality(cardinality: string | undefined): number { if (!cardinality) { return DEFAULT_SEARCH_COUNT; } if (cardinality === '*') { return Number.POSITIVE_INFINITY; } return Number.parseInt(cardinality, 10); } function addToCache(resource: WithId<Resource>, cache: ResourceCache): void { cache[getReferenceString(resource)] = resource; const url = (resource as any).url; if (url) { cache[url] = resource; } } function deduplicateResources<T extends Resource>(resources: WithId<T>[]): WithId<T>[] { const seen = new Set<string>(); return resources.filter((item) => { const key = getReferenceString(item); if (seen.has(key)) { return false; } seen.add(key); return true; }); }

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