Skip to main content
Glama
utils.ts9.11 kB
// SPDX-FileCopyrightText: Copyright Orangebot, Inc. and Medplum contributors // SPDX-License-Identifier: Apache-2.0 import type { Filter, SearchRequest } from '@medplum/core'; import { DEFAULT_MAX_SEARCH_COUNT, DEFAULT_SEARCH_COUNT, getReferenceString, getSearchParameters, Operator, parseSearchRequest, } from '@medplum/core'; import type { OperationOutcome, Reference, Resource, ResourceType } from '@medplum/fhirtypes'; import DataLoader from 'dataloader'; import type { GraphQLError, GraphQLFieldConfigArgumentMap, GraphQLResolveInfo, GraphQLScalarType } from 'graphql'; import { GraphQLBoolean, GraphQLFloat, GraphQLID, GraphQLInt, GraphQLString, Kind } from 'graphql'; import type { FhirRequestConfig } from '../fhirrouter'; import type { FhirRepository } from '../repo'; export interface GraphQLContext { repo: FhirRepository; config?: FhirRequestConfig; dataLoader: DataLoader<Reference, Resource>; searchCount: number; searchDataLoaders: Record<string, DataLoader<Filter, Resource[]>>; } export const typeCache: Record<string, GraphQLScalarType | undefined> = { base64Binary: GraphQLString, boolean: GraphQLBoolean, canonical: GraphQLString, code: GraphQLString, date: GraphQLString, dateTime: GraphQLString, decimal: GraphQLFloat, id: GraphQLID, instant: GraphQLString, integer: GraphQLFloat, markdown: GraphQLString, number: GraphQLFloat, oid: GraphQLString, positiveInt: GraphQLFloat, string: GraphQLString, time: GraphQLString, unsignedInt: GraphQLFloat, uri: GraphQLString, url: GraphQLString, uuid: GraphQLString, xhtml: GraphQLString, 'http://hl7.org/fhirpath/System.Boolean': GraphQLBoolean, 'http://hl7.org/fhirpath/System.Date': GraphQLString, 'http://hl7.org/fhirpath/System.DateTime': GraphQLString, 'http://hl7.org/fhirpath/System.Decimal': GraphQLFloat, 'http://hl7.org/fhirpath/System.Integer': GraphQLFloat, 'http://hl7.org/fhirpath/System.String': GraphQLString, 'http://hl7.org/fhirpath/System.Time': GraphQLString, }; function parseSearchArgsWithReference( resourceType: ResourceType, source: any, args: Record<string, string> ): { searchRequest: SearchRequest; referenceFilter: Filter | undefined } { let referenceFilter: Filter | undefined = undefined; if (source) { // _reference is a required field for reverse lookup searches // The GraphQL parser will validate that it is there. const reference = args['_reference']; delete args['_reference']; referenceFilter = { code: reference, operator: Operator.EQUALS, value: getReferenceString(source), }; } // Reverse the transform of dashes to underscores, back to dashes args = Object.fromEntries(Object.entries(args).map(([key, value]) => [graphQLFieldToFhirParam(key), value])); // Parse the search request const searchRequest = parseSearchRequest(resourceType, args); return { searchRequest, referenceFilter }; } function addFilter(searchRequest: SearchRequest, filter: Filter): void { const existingFilters = searchRequest.filters || []; searchRequest.filters = [filter, ...existingFilters]; } export function parseSearchArgs(resourceType: ResourceType, source: any, args: Record<string, string>): SearchRequest { const { searchRequest, referenceFilter } = parseSearchArgsWithReference(resourceType, source, args); if (referenceFilter) { addFilter(searchRequest, referenceFilter); } return searchRequest; } export function applyMaxCount(searchRequest: SearchRequest, maxCount: number | undefined): void { searchRequest.count = Math.min(searchRequest.count ?? DEFAULT_SEARCH_COUNT, maxCount ?? DEFAULT_MAX_SEARCH_COUNT); } export function graphQLFieldToFhirParam(code: string): string { return code.startsWith('_') ? code : code.replaceAll('_', '-'); } export function fhirParamToGraphQLField(code: string): string { return code.replaceAll('-', '_'); } function sortedStringify(obj: any): string { const customReplacer = (key: any, value: any): any => { if (value && typeof value === 'object' && !Array.isArray(value)) { return Object.keys(value) .sort((a, b) => a.localeCompare(b)) .reduce((sorted: any, key: string) => { sorted[key] = value[key]; return sorted; }, {}); } return value; }; return JSON.stringify(obj, customReplacer); } /** * 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 resources for the query. */ export async function resolveBySearch( source: any, args: Record<string, string>, ctx: GraphQLContext, info: GraphQLResolveInfo ): Promise<Resource[] | undefined> { ctx.searchCount++; if (ctx.config?.graphqlMaxSearches && ctx.searchCount > ctx.config.graphqlMaxSearches) { throw new Error('Maximum number of searches exceeded'); } const fieldName = info.fieldName; const resourceType = fieldName.substring(0, fieldName.length - 'List'.length) as ResourceType; const { searchRequest, referenceFilter } = parseSearchArgsWithReference(resourceType, source, args); applyMaxCount(searchRequest, ctx.config?.graphqlMaxSearches); const maxBatchSize = ctx.config?.graphqlBatchedSearchSize ?? 0; if (maxBatchSize === 0 || !referenceFilter) { if (referenceFilter) { addFilter(searchRequest, referenceFilter); } const bundle = await ctx.repo.search(searchRequest); return bundle.entry?.map((e) => e.resource as Resource); } const hash = sortedStringify(searchRequest); const dl = (ctx.searchDataLoaders[hash] ??= buildResolveBySearchDataLoader(ctx.repo, searchRequest, maxBatchSize)); return dl.load(referenceFilter); } function buildResolveBySearchDataLoader( repo: FhirRepository, searchRequest: SearchRequest, maxBatchSize: number ): DataLoader<Filter, Resource[]> { return new DataLoader<Filter, Resource[]>( async (filters) => { const results = await repo.searchByReference( searchRequest, filters[0].code, filters.map((f) => f.value) ); return filters.map((filter) => results[filter.value]); }, { maxBatchSize } ); } export function buildSearchArgs(resourceType: string): GraphQLFieldConfigArgumentMap { const args: GraphQLFieldConfigArgumentMap = { _count: { type: GraphQLInt, description: 'Specify how many elements to return from a repeating list.', }, _offset: { type: GraphQLInt, description: 'Specify the offset to start at for a repeating element.', }, _sort: { type: GraphQLString, description: 'Specify the sort order by comma-separated list of sort rules in priority order.', }, _id: { type: GraphQLString, description: 'Select resources based on the logical id of the resource.', }, _lastUpdated: { type: GraphQLString, description: 'Select resources based on the last time they were changed.', }, _filter: { type: GraphQLString, description: ' The _filter parameter provides a syntax for expressing a set of query expressions on the underlying resources.', }, _cursor: { type: GraphQLString, description: 'The _cursor parameter is used to retrieve the next page of results from a previous search.', }, }; const searchParams = getSearchParameters(resourceType); if (searchParams) { for (const [code, searchParam] of Object.entries(searchParams)) { // GraphQL does not support dashes in argument names // So convert dashes to underscores args[fhirParamToGraphQLField(code)] = { type: GraphQLString, description: searchParam.description, }; } } return args; } /** * Returns true if the field is requested in the GraphQL query. * @param info - The GraphQL resolve info. This includes the field name. * @param fieldName - The field name to check. * @returns True if the field is requested in the GraphQL query. */ export function isFieldRequested(info: GraphQLResolveInfo, fieldName: string): boolean { return info.fieldNodes.some((fieldNode) => fieldNode.selectionSet?.selections.some((selection) => { return selection.kind === Kind.FIELD && selection.name.value === fieldName; }) ); } /** * Returns an OperationOutcome for GraphQL errors. * @param errors - Array of GraphQL errors. * @returns OperationOutcome with the GraphQL errors as OperationOutcome issues. */ export function invalidRequest(errors: readonly GraphQLError[]): OperationOutcome { return { resourceType: 'OperationOutcome', issue: errors.map((error) => ({ severity: 'error', code: 'invalid', details: { text: error.message }, })), }; }

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