Skip to main content
Glama
graphql.ts24.4 kB
// SPDX-FileCopyrightText: Copyright Orangebot, Inc. and Medplum contributors // SPDX-License-Identifier: Apache-2.0 import type { Logger } from '@medplum/core'; import { allOk, badRequest, ContentType, deepClone, DEFAULT_SEARCH_COUNT, forbidden, getResourceTypes, LRUCache, normalizeOperationOutcome, OperationOutcomeError, } from '@medplum/core'; import type { Bundle, Reference, Resource, ResourceType } from '@medplum/fhirtypes'; import DataLoader from 'dataloader'; import type { ArgumentNode, ASTNode, ASTVisitor, DocumentNode, ExecutionResult, FieldNode, GraphQLFieldConfigArgumentMap, GraphQLFieldConfigMap, GraphQLOutputType, GraphQLResolveInfo, OperationDefinitionNode, ValidationContext, } from 'graphql'; import { execute, GraphQLFloat, GraphQLID, GraphQLInt, GraphQLList, GraphQLNonNull, GraphQLObjectType, GraphQLSchema, GraphQLString, Kind, parse, specifiedRules, validate, } from 'graphql'; import type { FhirRequest, FhirResponse, FhirRouteOptions, FhirRouter } from '../fhirrouter'; import type { FhirRepository } from '../repo'; import { RepositoryMode } from '../repo'; import { getGraphQLInputType, getPatchOperationInputType } from './input-types'; import { buildGraphQLOutputType, getGraphQLOutputType, outputTypeCache } from './output-types'; import type { GraphQLContext } from './utils'; import { applyMaxCount, buildSearchArgs, invalidRequest, isFieldRequested, parseSearchArgs, resolveBySearch, } from './utils'; /** * Cache of "introspection" query results. * Common case is the standard schema query from GraphiQL and Insomnia. * The result is big and somewhat computationally expensive. */ const introspectionResults = new LRUCache<ExecutionResult>(); /** * Cached GraphQL schema. * This should be initialized at server startup. */ let rootSchema: GraphQLSchema | undefined; interface ConnectionResponse { count?: number; offset?: number; pageSize?: number; edges?: ConnectionEdge[]; first?: string; previous?: string; next?: string; last?: string; } interface ConnectionEdge { mode?: string; score?: number; resource?: Resource; } /** * Handles FHIR GraphQL requests. * * See: https://www.hl7.org/fhir/graphql.html * @param req - The request details. * @param repo - The current user FHIR repository. * @param router - The router for router options. * @param options - Additional route options. * @returns The response. */ export async function graphqlHandler( req: FhirRequest, repo: FhirRepository, router: FhirRouter, options?: FhirRouteOptions ): Promise<FhirResponse> { const { query, operationName, variables } = req.body; if (!query) { return [badRequest('Must provide query.')]; } let document: DocumentNode; try { document = parse(query); } catch (_err) { return [badRequest('GraphQL syntax error.')]; } const schema = getRootSchema(); const validationRules = [...specifiedRules, MaxDepthRule(router, req.config?.graphqlMaxDepth), QueryCostRule(router)]; const validationErrors = validate(schema, document, validationRules); if (validationErrors.length > 0) { return [invalidRequest(validationErrors)]; } const introspection = isIntrospectionQuery(query); if (introspection && !router.options?.introspectionEnabled) { return [forbidden]; } if (!options?.batch && !includesMutations(query)) { repo.setMode(RepositoryMode.READER); } const dataLoader = new DataLoader<Reference, Resource>((keys) => repo.readReferences(keys)); let result: any = introspection && introspectionResults.get(query); if (!result) { const contextValue: GraphQLContext = { repo, config: req.config, dataLoader, searchCount: 0, searchDataLoaders: Object.create(null), }; result = await execute({ schema, document, contextValue, operationName, variableValues: variables, }); } return [allOk, result, { contentType: ContentType.JSON }]; } /** * Returns true if the query is a GraphQL introspection query. * * Introspection queries ask for the schema, which is expensive. * * See: https://graphql.org/learn/introspection/ * @param query - The GraphQL query. * @returns True if the query is an introspection query. */ function isIntrospectionQuery(query: string): boolean { return query.includes('query IntrospectionQuery') || query.includes('__schema'); } /** * Returns true if the query includes mutations. * @param query - The GraphQL query. * @returns True if the query includes mutations. */ function includesMutations(query: string): boolean { return query.includes('mutation'); } export function getRootSchema(): GraphQLSchema { if (!rootSchema) { rootSchema = buildRootSchema(); } return rootSchema; } function buildRootSchema(): GraphQLSchema { // First, create placeholder types // We need this first for circular dependencies for (const resourceType of getResourceTypes()) { outputTypeCache[resourceType] = buildGraphQLOutputType(resourceType); } // Next, fill in all of the type properties const fields: GraphQLFieldConfigMap<any, GraphQLContext> = {}; const mutationFields: GraphQLFieldConfigMap<any, GraphQLContext> = {}; for (const resourceType of getResourceTypes()) { const graphQLOutputType = getGraphQLOutputType(resourceType); // Get resource by ID fields[resourceType] = { type: graphQLOutputType, args: { id: { type: new GraphQLNonNull(GraphQLID), description: resourceType + ' ID', }, }, resolve: resolveById, }; // Search resource by search parameters fields[resourceType + 'List'] = { type: new GraphQLList(graphQLOutputType), args: buildSearchArgs(resourceType), resolve: resolveBySearch, }; // FHIR GraphQL Connection API fields[resourceType + 'Connection'] = { type: buildConnectionType(resourceType, graphQLOutputType), args: buildSearchArgs(resourceType), resolve: resolveByConnectionApi, }; // Mutation API mutationFields[resourceType + 'Create'] = { type: graphQLOutputType, args: buildCreateArgs(resourceType), resolve: resolveByCreate, }; mutationFields[resourceType + 'Update'] = { type: graphQLOutputType, args: buildUpdateArgs(resourceType), resolve: resolveByUpdate, }; mutationFields[resourceType + 'Patch'] = { type: graphQLOutputType, args: buildPatchArgs(resourceType), resolve: resolveByPatch, }; mutationFields[resourceType + 'Delete'] = { type: graphQLOutputType, args: { id: { type: new GraphQLNonNull(GraphQLID), description: resourceType + ' ID', }, }, resolve: resolveByDelete, }; } return new GraphQLSchema({ query: new GraphQLObjectType({ name: 'QueryType', fields, }), mutation: new GraphQLObjectType({ name: 'MutationType', fields: mutationFields, }), }); } function buildCreateArgs(resourceType: string): GraphQLFieldConfigArgumentMap { const args: GraphQLFieldConfigArgumentMap = { res: { type: new GraphQLNonNull(getGraphQLInputType(resourceType, 'Create')), description: resourceType + ' Create', }, }; return args; } function buildUpdateArgs(resourceType: string): GraphQLFieldConfigArgumentMap { const args: GraphQLFieldConfigArgumentMap = { id: { type: new GraphQLNonNull(GraphQLID), description: resourceType + ' ID', }, res: { type: new GraphQLNonNull(getGraphQLInputType(resourceType, 'Update')), description: resourceType + ' Update', }, }; return args; } function buildConnectionType(resourceType: ResourceType, resourceGraphQLType: GraphQLOutputType): GraphQLOutputType { return new GraphQLObjectType({ name: resourceType + 'Connection', fields: { count: { type: GraphQLInt }, offset: { type: GraphQLInt }, pageSize: { type: GraphQLInt }, first: { type: GraphQLString }, previous: { type: GraphQLString }, next: { type: GraphQLString }, last: { type: GraphQLString }, edges: { type: new GraphQLList( new GraphQLObjectType({ name: resourceType + 'ConnectionEdge', fields: { mode: { type: GraphQLString }, score: { type: GraphQLFloat }, resource: { type: resourceGraphQLType }, }, }) ), }, }, }); } /** * GraphQL data loader for search requests. * The field name should always end with "List" (i.e., "Patient" search uses "PatientList"). * The search args should be FHIR search parameters. * @param source - The source/root. This should always be null for our top level readers. * @param args - The GraphQL search arguments. * @param ctx - The GraphQL context. * @param info - The GraphQL resolve info. This includes the schema, and additional field details. * @returns Promise to read the resoures for the query. */ async function resolveByConnectionApi( source: any, args: Record<string, string>, ctx: GraphQLContext, info: GraphQLResolveInfo ): Promise<ConnectionResponse | undefined> { const fieldName = info.fieldName; const resourceType = fieldName.substring(0, fieldName.length - 'Connection'.length) as ResourceType; const searchRequest = parseSearchArgs(resourceType, source, args); if (isFieldRequested(info, 'count')) { searchRequest.total = 'accurate'; } if (!isFieldRequested(info, 'edges')) { searchRequest.count = 0; } applyMaxCount(searchRequest, ctx.config?.graphqlMaxSearches); const bundle = await ctx.repo.search(searchRequest); return { count: bundle.total, offset: searchRequest.offset ?? 0, pageSize: searchRequest.count ?? DEFAULT_SEARCH_COUNT, edges: bundle.entry?.map((e) => ({ mode: e.search?.mode, score: e.search?.score, resource: e.resource as Resource, })), next: getNextCursor(bundle), }; } /** * GraphQL data loader for ID requests. * The field name should always by the resource type. * There should always be exactly one argument "id". * @param _source - The source/root. This should always be null for our top level readers. * @param args - The GraphQL search arguments. * @param ctx - The GraphQL context. * @param info - The GraphQL resolve info. This includes the schema, and additional field details. * @returns Promise to read the resoure for the query. */ async function resolveById( _source: any, args: any, ctx: GraphQLContext, info: GraphQLResolveInfo ): Promise<Resource | undefined> { try { return await ctx.dataLoader.load({ reference: `${info.fieldName}/${args.id}` }); } catch (err) { throw new OperationOutcomeError(normalizeOperationOutcome(err), { cause: err }); } } /** * GraphQL resolver function for create requests. * The field name should end with "Create" (i.e., "PatientCreate" for updating a Patient). * The args should include the data to be created for the specified resource type. * @param _source - The source/root object. In the case of creates, this is typically not used and is thus ignored. * @param args - The GraphQL arguments, containing the new data for the resource. * @param ctx - The GraphQL context. This includes the repository where resources are stored. * @param info - The GraphQL resolve info. This includes the schema, field details, and other query-specific information. * @returns A Promise that resolves to the created resource, or undefined if the resource could not be found or updated. */ async function resolveByCreate( _source: any, args: Record<string, any>, ctx: GraphQLContext, info: GraphQLResolveInfo ): Promise<any> { const fieldName = info.fieldName; const resourceType = fieldName.substring(0, fieldName.length - 'Create'.length) as ResourceType; const resourceArgs = args.res; if (resourceArgs.resourceType !== resourceType) { throw new OperationOutcomeError(badRequest('Invalid resourceType')); } // We have to deep clone the args before we try to make them into a resource, since the args are parsed from the request // as objects with a null prototype (via Object.create(null)), which means that tree of objects have no `valueOf` method which // we need for evalFhirPath to work properly return ctx.repo.createResource(deepClone(resourceArgs) as Resource); } /** * GraphQL resolver function for update requests. * The field name should end with "Update" (i.e., "PatientUpdate" for updating a Patient). * The args should include the data to be updated for the specified resource type. * @param _source - The source/root object. In the case of updates, this is typically not used and is thus ignored. * @param args - The GraphQL arguments, containing the new data for the resource. * @param ctx - The GraphQL context. This includes the repository where resources are stored. * @param info - The GraphQL resolve info. This includes the schema, field details, and other query-specific information. * @returns A Promise that resolves to the updated resource, or undefined if the resource could not be found or updated. */ async function resolveByUpdate( _source: any, args: Record<string, any>, ctx: GraphQLContext, info: GraphQLResolveInfo ): Promise<any> { const fieldName = info.fieldName; const resourceType = fieldName.substring(0, fieldName.length - 'Update'.length) as ResourceType; const resourceArgs = args.res; const resourceId = args.id; if (resourceArgs.resourceType !== resourceType) { throw new OperationOutcomeError(badRequest('Invalid resourceType')); } if (resourceId !== resourceArgs.id) { throw new OperationOutcomeError(badRequest('Invalid ID')); } // We have to deep clone the args before we try to make them into a resource, since the args are parsed from the request // as objects with a null prototype (via Object.create(null)), which means that tree of objects have no `valueOf` method which // we need for evalFhirPath to work properly return ctx.repo.updateResource(deepClone(resourceArgs) as Resource); } /** * GraphQL resolver function for delete requests. * The field name should end with "Delete" (e.g., "PatientDelete" for deleting a Patient). * The args should include the ID of the resource to be deleted. * @param _source - The source/root object. In the case of deletions, this is typically not used and is thus ignored. * @param args - The GraphQL arguments, containing the ID of the resource to be deleted. * @param ctx - The GraphQL context. This includes the repository where resources are stored. * @param info - The GraphQL resolve info. This includes the schema, field details, and other query-specific information. * @returns A Promise that resolves when the resource has been deleted. No value is returned. */ async function resolveByDelete( _source: any, args: Record<string, string>, ctx: GraphQLContext, info: GraphQLResolveInfo ): Promise<void> { const fieldName = info.fieldName; const resourceType = fieldName.substring(0, fieldName.length - 'Delete'.length) as ResourceType; await ctx.repo.deleteResource(resourceType, args.id); } /** * GraphQL resolver function for patch requests. * The field name should end with "Patch" (i.e., "PatientPatch" for patching a Patient). * The args should include the id and patch array for the specified resource type. * @param _source - The source/root object. In the case of patch, this is typically not used and is thus ignored. * @param args - The GraphQL arguments, containing the id and patch array. * @param ctx - The GraphQL context. This includes the repository where resources are stored. * @param info - The GraphQL resolve info. This includes the schema, field details, and other query-specific information. * @returns A Promise that resolves to the patched resource, or undefined if the resource could not be found or updated. */ async function resolveByPatch( _source: any, args: Record<string, any>, ctx: GraphQLContext, info: GraphQLResolveInfo ): Promise<any> { const fieldName = info.fieldName; const resourceType = fieldName.substring(0, fieldName.length - 'Patch'.length) as ResourceType; const resourceId = args.id; const patch = args.patch; if (!resourceType || !resourceId || !Array.isArray(patch)) { throw new OperationOutcomeError(badRequest('Invalid patch arguments')); } // Patch operation expects an array of operations return ctx.repo.patchResource(resourceType, resourceId, patch); } const DEFAULT_MAX_DEPTH = 12; /** * Custom GraphQL rule that enforces max depth constraint. * @param router - The FHIR router. * @param maxDepth - The maximum allowed depth. * @returns A function that is an ASTVisitor that validates the maximum depth rule. */ const MaxDepthRule = (router: FhirRouter, maxDepth: number = DEFAULT_MAX_DEPTH) => (context: ValidationContext): ASTVisitor => new MaxDepthVisitor(context, router, maxDepth); type DepthRecord = { depth: number; node?: FieldNode }; class MaxDepthVisitor { private readonly context: ValidationContext; private readonly maxDepth: number; private readonly fragmentDepths: Record<string, DepthRecord>; private readonly router: FhirRouter; constructor(context: ValidationContext, router: FhirRouter, maxDepth: number) { this.context = context; this.router = router; this.fragmentDepths = Object.create(null); this.maxDepth = maxDepth; } OperationDefinition(node: OperationDefinitionNode): void { const result = this.getDepth(...node.selectionSet.selections); if (result.depth > this.maxDepth) { // this.context.reportError( // new GraphQLError( // `Field "${result.node?.name.value}" exceeds max depth (depth=${result.depth}, max=${this.maxDepth})`, // { // nodes: result.node, // } // ) // ); this.router.log('warn', 'Query max depth too high', { depth: result.depth, limit: this.maxDepth, query: node.loc?.source?.body, }); } } /** * Returns the depth of the GraphQL node in a query. * We use field depth as the representation of depth: the number of concrete fields (not counting fragment expansions) * @param nodes - The AST nodes. * @returns The maximum "depth" of the nodes. */ private getDepth(...nodes: ASTNode[]): DepthRecord { let deepest: DepthRecord = { depth: -1 }; for (const node of nodes) { let current: DepthRecord = { depth: 0 }; if (node.kind === Kind.FIELD) { if (node.selectionSet?.selections) { current = this.getDepth(...node.selectionSet.selections); current.depth += 1; } else { current = { depth: 0, node }; // Leaf field node } } else if (node.kind === Kind.FRAGMENT_SPREAD) { const fragmentName = node.name.value; const fragment = this.context.getFragment(fragmentName); const cachedDepth = this.fragmentDepths[fragmentName]; if (cachedDepth) { current = cachedDepth; } else if (fragment) { current = this.getDepth(...fragment.selectionSet.selections); this.fragmentDepths[fragmentName] = current; } } else if (node.kind === Kind.INLINE_FRAGMENT) { current = this.getDepth(...node.selectionSet.selections); } if (current.depth > this.maxDepth) { return current; // Short circuit, no need to keep computing } if (current.depth > deepest.depth) { deepest = current; } } return deepest; } } const DEFAULT_MAX_COST = 10_000; type QueryCostRuleOptions = { logger?: Logger; maxCost?: number; debug?: boolean; }; const QueryCostRule = (router: FhirRouter, options?: QueryCostRuleOptions) => (context: ValidationContext): ASTVisitor => new QueryCostVisitor(context, router, options) as ASTVisitor; class QueryCostVisitor { private readonly context: ValidationContext; private readonly maxCost: number; private readonly debug: boolean; private readonly router: FhirRouter; private readonly fragmentCosts: Record<string, number>; constructor(context: ValidationContext, router: FhirRouter, options?: QueryCostRuleOptions) { this.context = context; this.maxCost = options?.maxCost ?? DEFAULT_MAX_COST; this.debug = options?.debug ?? false; this.router = router; this.fragmentCosts = Object.create(null); } OperationDefinition(node: OperationDefinitionNode): void { let cost = 0; for (const child of node.selectionSet.selections) { const startTime = performance.now(); const childCost = this.calculateCost(child); cost += childCost; this.log(child.kind, 'node has final cost', childCost, '(', performance.now() - startTime, 'ms)'); if (cost > this.maxCost) { // this.context.reportError( // new GraphQLError('Query too complex', { // extensions: { cost, limit: this.maxCost }, // }) // ); this.router.log('warn', 'GraphQL query too complex', { cost, limit: this.maxCost, query: node.loc?.source?.body, }); } } } private calculateCost(...nodes: ASTNode[]): number { let cost = 0; for (const node of nodes) { if (node.kind === Kind.FIELD && node.selectionSet) { let baseCost = 0; let branchingFactor = 1; if (isSearchField(node)) { this.log('Found search field', node.name.value); baseCost = 8; branchingFactor = this.getCount(node.arguments) ?? 20; } else if (isLinkedResource(node)) { this.log('Found linked resource'); baseCost = 1; branchingFactor = 2; } const fieldCost = baseCost + branchingFactor * this.calculateCost(...node.selectionSet.selections); if (fieldCost) { this.log('Field', node.name.value, 'costs', fieldCost); } cost += fieldCost; } else if (node.kind === Kind.FRAGMENT_SPREAD) { const fragmentName = node.name.value; const fragment = this.context.getFragment(fragmentName); const cachedCost = this.fragmentCosts[fragmentName]; if (cachedCost !== undefined) { this.log('Fragment', fragmentName, 'costs', cachedCost, '(cached)'); cost += cachedCost; } else if (fragment) { const fragmentCost = this.calculateCost(...fragment.selectionSet.selections); this.fragmentCosts[fragmentName] = fragmentCost; this.log('Fragment', fragmentName, 'costs', fragmentCost); cost += fragmentCost; } } else if (node.kind === Kind.INLINE_FRAGMENT) { const fragmentCost = this.calculateCost(...node.selectionSet.selections); this.log('Inline fragment on', node.typeCondition?.name.value, 'costs', fragmentCost); cost += fragmentCost; } if (cost > this.maxCost) { return cost; // Short circuit return, no need to keep processing } } return cost; } getCount(args?: readonly ArgumentNode[]): number | undefined { const countArg = args?.find((arg) => arg.name.value === '_count'); if (countArg?.value.kind === Kind.INT) { return Number.parseInt(countArg.value.value, 10); } return undefined; } log(...args: any[]): void { if (this.debug) { console.log(...args); } } } function isSearchField(node: FieldNode): boolean { return node.name.value.endsWith('List'); } function isLinkedResource(node: FieldNode): boolean { return node.name.value === 'resource'; } function getNextCursor(bundle: Bundle): string | undefined { const link = bundle.link?.find((l) => l.relation === 'next')?.url; if (!link) { return undefined; } return new URL(link).searchParams.get('_cursor') || undefined; } function buildPatchArgs(resourceType: string): GraphQLFieldConfigArgumentMap { return { id: { type: new GraphQLNonNull(GraphQLID), description: resourceType + ' ID', }, patch: { type: new GraphQLNonNull(new GraphQLList(new GraphQLNonNull(getPatchOperationInputType()))), description: 'Array of patch operations', }, }; }

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