Skip to main content
Glama
schema-crawler.ts11.9 kB
// SPDX-FileCopyrightText: Copyright Orangebot, Inc. and Medplum contributors // SPDX-License-Identifier: Apache-2.0 import type { ElementsContextType } from './elements-context'; import { buildElementsContext } from './elements-context'; import type { SliceDefinitionWithTypes } from './typeschema/slices'; import { isSliceDefinitionWithTypes } from './typeschema/slices'; import type { InternalSchemaElement, InternalTypeSchema, SliceDefinition, SlicingRules } from './typeschema/types'; import { tryGetProfile } from './typeschema/types'; import { isPopulated } from './utils'; export type VisitorSlicingRules = Omit<SlicingRules, 'slices'> & { slices: SliceDefinitionWithTypes[]; }; export interface SchemaVisitor { /** * Called when entering a schema. This is called once for the root profile and once for each * extension with a profile associated with it. * @param schema - The schema being entered. */ onEnterSchema?: (schema: InternalTypeSchema) => void; /** * Called when exiting a schema. See `onEnterSchema` for more information. * @param schema - The schema being exited. */ onExitSchema?: (schema: InternalTypeSchema) => void; /** * Called when entering an element. This is called for every element in the schema in a * tree-like fashion. If the element has slices, the slices are crawled after `onEnterElement` * but before `onExitElement`. * * @example * Example of tree-like method invocation ordering: * '''typescript * onEnterElement('Patient.name') * onEnterElement('Patient.name.given') * onExitElement('Patient.name.given') * onEnterElement('Patient.name.family') * onExitElement('Patient.name.family') * onExitElement('Patient.name') * ''' * * * @param path - The full path of the element being entered, even if within an extension. e.g The * path of the ombCategory extension within the US Core Race extension will be * 'Patient.extension.extension.value[x]' rather than 'Extension.extension.value[x]'. The latter is * accessible on the element parameter. * @param element - The element being entered. * @param elementsContext - The context of the elements currently being crawled. */ onEnterElement?: (path: string, element: InternalSchemaElement, elementsContext: ElementsContextType) => void; /** * Called when exiting an element. See `onEnterElement` for more information. * @param path - The full path of the element being exited. * @param element - The element being exited. * @param elementsContext - The context of the elements currently being crawled. */ onExitElement?: (path: string, element: InternalSchemaElement, elementsContext: ElementsContextType) => void; /** * Called when entering a slice. Called for every slice in a given sliced element. `onEnterElement` and `onExitElement` * will be called in a tree-like fashion for elements within the slice followed by `onExitSlice`. * * @example * Example of a sliced element being crawled with some elements excluded for brevity: * '''typescript * onEnterElement ('Observation.component') * * // systolic * onEnterSlice ('Observation.component', systolicSlice, slicingRules) * onEnterElement ('Observation.component.code') * onExitElement ('Observation.component.code') * onEnterElement ('Observation.component.value[x]') * onEnterElement ('Observation.component.value[x].code') * onExitElement ('Observation.component.value[x].code') * onEnterElement ('Observation.component.value[x].system') * onExitElement ('Observation.component.value[x].system') * onExitElement ('Observation.component.value[x]') * onExitSlice ('Observation.component', systolicSlice, slicingRules) * * // similar set of invocations for diastolic slice * * onExitElement ('Observation.component') * ''' * * @param path - The full path of the sliced element being entered. See `onEnterElement` for more information. * @param slice - The slice being entered. * @param slicing - The slicing rules related to the slice being entered. */ onEnterSlice?: (path: string, slice: SliceDefinitionWithTypes, slicing: VisitorSlicingRules) => void; /** * Called when exiting a slice. See `onEnterSlice` for more information. * @param path - The full path of the sliced element being exited. See `onEnterElement` for more information. * @param slice - The slice being exited. * @param slicing - The slicing rules related to the slice. */ onExitSlice?: (path: string, slice: SliceDefinitionWithTypes, slicing: VisitorSlicingRules) => void; } export class SchemaCrawler { private readonly rootSchema: InternalTypeSchema & { type: string }; private readonly visitor: SchemaVisitor; private readonly elementsContextStack: ElementsContextType[]; private sliceAllowList: SliceDefinition[] | undefined; constructor(schema: InternalTypeSchema, visitor: SchemaVisitor, elements?: InternalTypeSchema['elements']) { if (schema.type === undefined) { throw new Error('schema must include a type'); } this.rootSchema = schema as InternalTypeSchema & { type: string }; const rootContext = buildElementsContext({ parentContext: undefined, path: this.rootSchema.type, elements: elements ?? this.rootSchema.elements, profileUrl: this.rootSchema.name === this.rootSchema.type ? undefined : this.rootSchema.url, }); if (rootContext === undefined) { throw new Error('Could not create root elements context'); } this.elementsContextStack = [rootContext]; this.visitor = visitor; } private get elementsContext(): ElementsContextType { return this.elementsContextStack.at(-1) as ElementsContextType; } crawlElement(element: InternalSchemaElement, key: string, path: string): void { if (this.visitor.onEnterSchema) { this.visitor.onEnterSchema(this.rootSchema); } const allowedElements = Object.fromEntries( Object.entries(this.elementsContext.elements).filter(([elementKey]) => { return elementKey.startsWith(key); }) ); this.crawlElementsImpl(allowedElements, path); if (this.visitor.onExitSchema) { this.visitor.onExitSchema(this.rootSchema); } } crawlSlice(key: string, slice: SliceDefinition, slicing: SlicingRules): void { const visitorSlicing = this.prepareSlices(slicing.slices, slicing); if (!isPopulated(visitorSlicing.slices)) { throw new Error(`cannot crawl slice ${slice.name} since it has no type information`); } if (this.visitor.onEnterSchema) { this.visitor.onEnterSchema(this.rootSchema); } this.sliceAllowList = [slice]; this.crawlSliceImpl(visitorSlicing.slices[0], slice.path, visitorSlicing); this.sliceAllowList = undefined; if (this.visitor.onExitSchema) { this.visitor.onExitSchema(this.rootSchema); } } crawlResource(): void { if (this.visitor.onEnterSchema) { this.visitor.onEnterSchema(this.rootSchema); } this.crawlElementsImpl(this.rootSchema.elements, this.rootSchema.type); if (this.visitor.onExitSchema) { this.visitor.onExitSchema(this.rootSchema); } } private crawlElementsImpl(elements: InternalTypeSchema['elements'], path: string): void { const elementTree = createElementTree(elements); for (const node of elementTree) { this.crawlElementNode(node, path); } } private crawlElementNode(node: ElementNode, path: string): void { const nodePath = path + '.' + node.key; if (this.visitor.onEnterElement) { this.visitor.onEnterElement(nodePath, node.element, this.elementsContext); } for (const child of node.children) { this.crawlElementNode(child, path); } if (isPopulated(node.element?.slicing?.slices)) { this.crawlSlicingImpl(node.element.slicing, nodePath); } if (this.visitor.onExitElement) { this.visitor.onExitElement(nodePath, node.element, this.elementsContext); } } private prepareSlices(slices: SliceDefinition[], slicing: SlicingRules): VisitorSlicingRules { const slicesToVisit: SliceDefinitionWithTypes[] = []; for (const slice of slices) { if (!isSliceDefinitionWithTypes(slice)) { continue; } const profileUrl = slice.type.find((t) => isPopulated(t.profile))?.profile?.[0]; if (isPopulated(profileUrl)) { const schema = tryGetProfile(profileUrl); if (schema) { slice.typeSchema = schema; } } slicesToVisit.push(slice); } const visitorSlicing = { ...slicing, slices: slicesToVisit } as VisitorSlicingRules; return visitorSlicing; } private crawlSlicingImpl(slicing: SlicingRules, path: string): void { const visitorSlicing = this.prepareSlices(slicing.slices, slicing); for (const slice of visitorSlicing.slices) { if (this.sliceAllowList === undefined || this.sliceAllowList.includes(slice)) { this.crawlSliceImpl(slice, path, visitorSlicing); } } } private crawlSliceImpl(slice: SliceDefinitionWithTypes, path: string, slicing: VisitorSlicingRules): void { const sliceSchema = slice.typeSchema; if (sliceSchema) { if (this.visitor.onEnterSchema) { this.visitor.onEnterSchema(sliceSchema); } } if (this.visitor.onEnterSlice) { this.visitor.onEnterSlice(path, slice, slicing); } let elementsContext: ElementsContextType | undefined; const sliceElements = sliceSchema?.elements ?? slice.elements; if (isPopulated(sliceElements)) { elementsContext = buildElementsContext({ path, parentContext: this.elementsContext, elements: sliceElements, }); } if (elementsContext) { this.elementsContextStack.push(elementsContext); } this.crawlElementsImpl(sliceElements, path); if (elementsContext) { this.elementsContextStack.pop(); } if (this.visitor.onExitSlice) { this.visitor.onExitSlice(path, slice, slicing); } if (sliceSchema) { if (this.visitor.onExitSchema) { this.visitor.onExitSchema(sliceSchema); } } } } type ElementNode = { key: string; element: InternalSchemaElement; children: ElementNode[]; }; /** * Creates a tree of InternalSchemaElements nested by their key hierarchy: * * @param elements - * @returns The list of root nodes of the tree */ function createElementTree(elements: Record<string, InternalSchemaElement>): ElementNode[] { const rootNodes: ElementNode[] = []; function isChildKey(parentKey: string, childKey: string): boolean { return childKey.startsWith(parentKey + '.'); } function addNode(currentNode: ElementNode, newNode: ElementNode): void { for (const child of currentNode.children) { // If the new node is a child of an existing child, recurse deeper if (isChildKey(child.key, newNode.key)) { addNode(child, newNode); return; } } // Otherwise, add it here currentNode.children.push(newNode); } const elementEntries = Object.entries(elements); /* By sorting beforehand, we guarantee that no false root nodes are created. e.g. if 'a.b' were to be added to the tree before 'a', 'a.b' would be made a root node when it should be a child of 'a'. */ elementEntries.sort((a, b) => a[0].localeCompare(b[0])); for (const [key, element] of elementEntries) { const newNode: ElementNode = { key, element, children: [] }; let added = false; for (const rootNode of rootNodes) { if (isChildKey(rootNode.key, key)) { addNode(rootNode, newNode); added = true; break; } } // If the string is not a child of any existing node, add it as a new root if (!added) { rootNodes.push(newNode); } } return rootNodes; }

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