Skip to main content
Glama
routes.ts19.5 kB
// SPDX-FileCopyrightText: Copyright Orangebot, Inc. and Medplum contributors // SPDX-License-Identifier: Apache-2.0 import { allOk, ContentType, isNotFound, isOk, OperationOutcomeError, stringify } from '@medplum/core'; import type { BatchEvent, FhirRequest, HttpMethod } from '@medplum/fhir-router'; import { FhirRouter } from '@medplum/fhir-router'; import type { ResourceType } from '@medplum/fhirtypes'; import type { NextFunction, Request, Response } from 'express'; import { Router } from 'express'; import { awsTextractHandler } from '../cloud/aws/textract'; import { getConfig } from '../config/loader'; import { getAuthenticatedContext, tryGetRequestContext } from '../context'; import { authenticateRequest } from '../oauth/middleware'; import { recordHistogramValue } from '../otel/otel'; import { bulkDataRouter } from './bulkdata'; import { jobRouter } from './job'; import { getCapabilityStatement } from './metadata'; import { agentBulkStatusHandler } from './operations/agentbulkstatus'; import { agentFetchLogsHandler } from './operations/agentfetchlogs'; import { agentPushHandler } from './operations/agentpush'; import { agentReloadConfigHandler } from './operations/agentreloadconfig'; import { agentStatusHandler } from './operations/agentstatus'; import { agentUpgradeHandler } from './operations/agentupgrade'; import { aiOperationHandler } from './operations/ai'; import { asyncJobCancelHandler } from './operations/asyncjobcancel'; import { ccdaExportHandler } from './operations/ccdaexport'; import { chargeItemDefinitionApplyHandler } from './operations/chargeitemdefinitionapply'; import { claimExportGetHandler, claimExportPostHandler } from './operations/claimexport'; import { codeSystemImportHandler } from './operations/codesystemimport'; import { codeSystemLookupHandler } from './operations/codesystemlookup'; import { codeSystemValidateCodeHandler } from './operations/codesystemvalidatecode'; import { conceptMapImportHandler } from './operations/conceptmapimport'; import { conceptMapTranslateHandler } from './operations/conceptmaptranslate'; import { csvHandler } from './operations/csv'; import { tryCustomOperation } from './operations/custom'; import { getColumnStatisticsHandler } from './operations/db-column-statistics'; import { configureColumnStatisticsHandler } from './operations/db-configure-column-statistics'; import { dbConfigureIndexesHandler } from './operations/db-configure-indexes'; import { dbIndexesHandler } from './operations/dbindexes'; import { dbInvalidIndexesHandler } from './operations/dbinvalidindexes'; import { dbSchemaDiffHandler } from './operations/dbschemadiff'; import { dbStatsHandler } from './operations/dbstats'; import { deployHandler } from './operations/deploy'; import { evaluateMeasureHandler } from './operations/evaluatemeasure'; import { executeHandler } from './operations/execute'; import { expandOperator } from './operations/expand'; import { dbExplainHandler } from './operations/explain'; import { bulkExportHandler, patientExportHandler } from './operations/export'; import { expungeHandler } from './operations/expunge'; import { extractHandler } from './operations/extract'; import { getWsBindingTokenHandler } from './operations/getwsbindingtoken'; import { groupExportHandler } from './operations/groupexport'; import { appLaunchHandler } from './operations/launch'; import { patientEverythingHandler } from './operations/patienteverything'; import { patientSummaryHandler } from './operations/patientsummary'; import { planDefinitionApplyHandler } from './operations/plandefinitionapply'; import { projectCloneHandler } from './operations/projectclone'; import { projectInitHandler } from './operations/projectinit'; import { resourceGraphHandler } from './operations/resourcegraph'; import { rotateSecretHandler } from './operations/rotatesecret'; import { setAccountsHandler } from './operations/set-accounts'; import { structureDefinitionExpandProfileHandler } from './operations/structuredefinitionexpandprofile'; import { codeSystemSubsumesOperation } from './operations/subsumes'; import { updateUserEmailOperation } from './operations/update-user-email'; import { valueSetValidateOperation } from './operations/valuesetvalidatecode'; import { sendOutcome } from './outcomes'; import type { ResendSubscriptionsOptions } from './repo'; import { sendFhirResponse } from './response'; import { smartConfigurationHandler, smartStylingHandler } from './smart'; export const fhirRouter = Router(); let internalFhirRouter: FhirRouter; // OperationOutcome interceptor fhirRouter.use(function setupResponseInterceptors(req: Request, res: Response, next: NextFunction) { const oldJson = res.json; const oldSend = res.send; res.json = (data: any) => { // Restore the original json to avoid double response // See: https://stackoverflow.com/a/60817116 res.json = oldJson; // FHIR "Prefer" header preferences // See: https://www.hl7.org/fhir/http.html#ops // Prefer: return=minimal // Prefer: return=representation // Prefer: return=OperationOutcome const prefer = req.get('Prefer'); if (prefer === 'return=minimal') { return res.send(); } if (!res.get('Content-Type')) { res.contentType(ContentType.JSON); } const pretty = req.query._pretty === 'true'; if (res.get('Content-Type')?.startsWith(ContentType.FHIR_JSON)) { let legacyFhirJsonResponseFormat: boolean | undefined; try { const ctx = getAuthenticatedContext(); legacyFhirJsonResponseFormat = ctx.project.systemSetting?.find( (s) => s.name === 'legacyFhirJsonResponseFormat' )?.valueBoolean; } catch (_err) { // Ignore errors since unauthenticated requests also use this middleware } if (!legacyFhirJsonResponseFormat) { return res.send(stringify(data, pretty)); } } // Default JSON response return res.send(JSON.stringify(data, undefined, pretty ? 2 : undefined)); }; res.send = (...args: any[]) => { // Restore the original method to avoid double response // See: https://stackoverflow.com/a/60817116 res.send = oldSend; const ctx = tryGetRequestContext(); if (ctx?.fhirRateLimiter) { // Attach rate limit header before sending first part of response body ctx.fhirRateLimiter.attachRateLimitHeader(res); } return oldSend.call(res, ...args); }; next(); }); // Public routes do not require authentication const publicRoutes = Router(); fhirRouter.use(publicRoutes); // Metadata / CapabilityStatement publicRoutes.get('/metadata', (_req: Request, res: Response) => { res.status(200).json(getCapabilityStatement()); }); // FHIR Versions publicRoutes.get(['/$versions', '/%24versions'], (_req: Request, res: Response) => { res.status(200).json({ versions: ['4.0'], default: '4.0' }); }); // SMART-on-FHIR configuration // Medplum hosts the SMART well-known both at the root and at the /fhir/R4 paths. // See: https://build.fhir.org/ig/HL7/smart-app-launch/conformance.html#sample-request publicRoutes.get('/.well-known/smart-configuration', smartConfigurationHandler); publicRoutes.get('/.well-known/smart-styles.json', smartStylingHandler); // Protected routes require authentication const protectedRoutes = Router().use(authenticateRequest); fhirRouter.use(protectedRoutes); // CSV Export (cannot use FhirRouter due to CSV output) protectedRoutes.get(['/:resourceType/$csv', '/:resourceType/%24csv'], csvHandler); // AI $ai operation - handled directly in Express routes to support streaming protectedRoutes.post(['/$ai', '/%24ai'], aiOperationHandler); // Agent $push operation (cannot use FhirRouter due to HL7 and DICOM output) protectedRoutes.post(['/Agent/$push', '/Agent/%24push'], agentPushHandler); protectedRoutes.post(['/Agent/:id/$push', '/Agent/:id/%24push'], agentPushHandler); // Bot $execute operation // Allow extra path content after the "$execute" to support external callers who append path info const botPaths = [ '/Bot/$execute', '/Bot/%24execute', '/Bot/:id/$execute', '/Bot/:id/%24execute', '/Bot/$execute/*splat', '/Bot/%24execute/*splat', '/Bot/:id/$execute/*splat', '/Bot/:id/%24execute/*splat', ]; protectedRoutes.get(botPaths, executeHandler); protectedRoutes.post(botPaths, executeHandler); // Bulk Data protectedRoutes.use('/bulkdata', bulkDataRouter); // Async Job protectedRoutes.use('/job', jobRouter); /** * Returns the internal FHIR router. * This function will be executed on every request. * @returns The lazy initialized internal FHIR router. */ function getInternalFhirRouter(): FhirRouter { if (!internalFhirRouter) { internalFhirRouter = initInternalFhirRouter(); } return internalFhirRouter; } /** * Returns a new FHIR router. * This function should only be called once on the first request. * @returns A new FHIR router with all the internal operations. */ function initInternalFhirRouter(): FhirRouter { const router = new FhirRouter({ introspectionEnabled: getConfig().introspectionEnabled, }); // Project $export router.add('GET', '/$export', bulkExportHandler); router.add('POST', '/$export', bulkExportHandler); // Project $clone router.add('POST', '/Project/:id/$clone', projectCloneHandler); // Project $init router.add('POST', '/Project/$init', projectInitHandler); // Update User email router.add('POST', '/User/:id/$update-email', updateUserEmailOperation); // ConceptMap $translate router.add('GET', '/ConceptMap/$translate', conceptMapTranslateHandler); router.add('POST', '/ConceptMap/$translate', conceptMapTranslateHandler); router.add('GET', '/ConceptMap/:id/$translate', conceptMapTranslateHandler); router.add('POST', '/ConceptMap/:id/$translate', conceptMapTranslateHandler); // ConceptMap $import router.add('POST', '/ConceptMap/$import', conceptMapImportHandler); router.add('POST', '/ConceptMap/:id/$import', conceptMapImportHandler); // ValueSet $expand operation router.add('GET', '/ValueSet/$expand', expandOperator); router.add('POST', '/ValueSet/$expand', expandOperator); // CodeSystem $import operation router.add('POST', '/CodeSystem/$import', codeSystemImportHandler); router.add('POST', '/CodeSystem/:id/$import', codeSystemImportHandler); // CodeSystem $lookup operation router.add('GET', '/CodeSystem/$lookup', codeSystemLookupHandler); router.add('POST', '/CodeSystem/$lookup', codeSystemLookupHandler); router.add('GET', '/CodeSystem/:id/$lookup', codeSystemLookupHandler); router.add('POST', '/CodeSystem/:id/$lookup', codeSystemLookupHandler); // CodeSystem $validate-code operation router.add('GET', '/CodeSystem/$validate-code', codeSystemValidateCodeHandler); router.add('POST', '/CodeSystem/$validate-code', codeSystemValidateCodeHandler); router.add('GET', '/CodeSystem/:id/$validate-code', codeSystemValidateCodeHandler); router.add('POST', '/CodeSystem/:id/$validate-code', codeSystemValidateCodeHandler); // CodeSystem $subsumes operation router.add('GET', '/CodeSystem/$subsumes', codeSystemSubsumesOperation); router.add('POST', '/CodeSystem/$subsumes', codeSystemSubsumesOperation); router.add('GET', '/CodeSystem/:id/$subsumes', codeSystemSubsumesOperation); router.add('POST', '/CodeSystem/:id/$subsumes', codeSystemSubsumesOperation); // ValueSet $validate-code operation router.add('GET', '/ValueSet/$validate-code', valueSetValidateOperation); router.add('POST', '/ValueSet/$validate-code', valueSetValidateOperation); router.add('GET', '/ValueSet/:id/$validate-code', valueSetValidateOperation); router.add('POST', '/ValueSet/:id/$validate-code', valueSetValidateOperation); // Agent $status operation router.add('GET', '/Agent/$status', agentStatusHandler); router.add('GET', '/Agent/:id/$status', agentStatusHandler); // Agent $bulk-status operation router.add('GET', '/Agent/$bulk-status', agentBulkStatusHandler); // Agent $reload-config operation router.add('GET', '/Agent/$reload-config', agentReloadConfigHandler); router.add('GET', '/Agent/:id/$reload-config', agentReloadConfigHandler); // Agent $upgrade operation router.add('GET', '/Agent/$upgrade', agentUpgradeHandler); router.add('GET', '/Agent/:id/$upgrade', agentUpgradeHandler); // Agent $fetch-logs operation router.add('GET', '/Agent/$fetch-logs', agentFetchLogsHandler); router.add('GET', '/Agent/:id/$fetch-logs', agentFetchLogsHandler); // AsyncJob $cancel operation router.add('POST', '/AsyncJob/:id/$cancel', asyncJobCancelHandler); // Bot $deploy operation router.add('POST', '/Bot/:id/$deploy', deployHandler); // Claim $export operation router.add('POST', '/Claim/$export', claimExportPostHandler); router.add('GET', '/Claim/:id/$export', claimExportGetHandler); // Group $export operation router.add('GET', '/Group/:id/$export', groupExportHandler); router.add('POST', '/Group/:id/$export', groupExportHandler); // Patient $export operation router.add('GET', '/Patient/$export', patientExportHandler); router.add('POST', '/Patient/$export', patientExportHandler); // Measure $evaluate-measure operation router.add('POST', '/Measure/:id/$evaluate-measure', evaluateMeasureHandler); // PlanDefinition $apply operation router.add('POST', '/PlanDefinition/:id/$apply', planDefinitionApplyHandler); // ChargeItemDefinition $apply operation router.add('POST', '/ChargeItemDefinition/:id/$apply', chargeItemDefinitionApplyHandler); // Resource $graph operation router.add('GET', '/:resourceType/:id/$graph', resourceGraphHandler); // Resource $set-accounts operation router.add('POST', '/:resourceType/:id/$set-accounts', setAccountsHandler); // Patient $everything operation router.add('GET', '/Patient/:id/$everything', patientEverythingHandler); router.add('POST', '/Patient/:id/$everything', patientEverythingHandler); // Patient $summary operation router.add('GET', '/Patient/:id/$summary', patientSummaryHandler); router.add('POST', '/Patient/:id/$summary', patientSummaryHandler); // Patient $ccda-export operation router.add('GET', '/Patient/:id/$ccda-export', ccdaExportHandler); router.add('POST', '/Patient/:id/$ccda-export', ccdaExportHandler); // QuestionnaireResponse $extract operation router.add('GET', '/QuestionnaireResponse/:id/$extract', extractHandler); router.add('POST', '/QuestionnaireResponse/:id/$extract', extractHandler); router.add('POST', '/QuestionnaireResponse/$extract', extractHandler); // $expunge operation router.add('POST', '/:resourceType/:id/$expunge', expungeHandler); // $get-ws-binding-token operation router.add('GET', '/Subscription/:id/$get-ws-binding-token', getWsBindingTokenHandler); // StructureDefinition $expand-profile operation router.add('POST', '/StructureDefinition/$expand-profile', structureDefinitionExpandProfileHandler); // ClientApplication $launch router.add('GET', '/ClientApplication/:id/$smart-launch', appLaunchHandler); // Rotate client secret router.add('POST', '/ClientApplication/:id/$rotate-secret', rotateSecretHandler); // AWS operations router.add('POST', '/:resourceType/:id/$aws-textract', awsTextractHandler); // Validate create resource router.add('POST', '/:resourceType/$validate', async (req: FhirRequest) => { const ctx = getAuthenticatedContext(); await ctx.repo.validateResourceStrictly(req.body); return [allOk]; }); // Reindex resource router.add('POST', '/:resourceType/:id/$reindex', async (req: FhirRequest) => { const ctx = getAuthenticatedContext(); const { resourceType, id } = req.params as { resourceType: ResourceType; id: string }; await ctx.repo.reindexResource(resourceType, id); return [allOk]; }); // Resend subscriptions router.add('POST', '/:resourceType/:id/$resend', async (req: FhirRequest) => { const ctx = getAuthenticatedContext(); const { resourceType, id } = req.params as { resourceType: ResourceType; id: string }; const options = req.body as ResendSubscriptionsOptions | undefined; await ctx.repo.resendSubscriptions(resourceType, id, options); return [allOk]; }); // Super admin operations router.add('POST', '/$db-stats', dbStatsHandler); router.add('POST', '/$db-schema-diff', dbSchemaDiffHandler); router.add('POST', '/$db-invalid-indexes', dbInvalidIndexesHandler); router.add('POST', '/$explain', dbExplainHandler); router.add('GET', '/$db-indexes', dbIndexesHandler); router.add('POST', '/$db-configure-indexes', dbConfigureIndexesHandler); router.add('GET', '/$db-column-statistics', getColumnStatisticsHandler); router.add('POST', '/$db-configure-column-statistics', configureColumnStatisticsHandler); router.addEventListener('warn', (e: any) => { const ctx = getAuthenticatedContext(); ctx.logger.warn(e.message, { ...e.data, project: ctx.project.id }); }); router.addEventListener('batch', (event: any) => { const ctx = getAuthenticatedContext(); const projectId = ctx.project.id; const { count, errors, size, bundleType } = event as BatchEvent; const metricOpts = { attributes: { bundleType, projectId } }; if (count !== undefined) { recordHistogramValue('medplum.batch.entries', count, metricOpts); } if (errors?.length) { recordHistogramValue('medplum.batch.errors', errors.length, metricOpts); ctx.logger.warn('Error processing batch', { bundleType, count, errors, size, project: projectId }); } if (size !== undefined) { recordHistogramValue('medplum.batch.size', size, metricOpts); } }); return router; } // Default route protectedRoutes.use('{*splat}', async function routeFhirRequest(req: Request, res: Response) { const ctx = getAuthenticatedContext(); const request: FhirRequest = { method: req.method as HttpMethod, url: stripPrefix(req.originalUrl, '/fhir/R4'), pathname: '', params: req.params, query: Object.create(null), // Defer query param parsing to router for consistency // Express v5 changed the default value of `req.body` from {} to undefined. A decent number of FHIR handlers // rely on the previous behavior, so we defer handling undefined for now body: req.body ?? {}, headers: req.headers, config: { graphqlBatchedSearchSize: ctx.project.systemSetting?.find((s) => s.name === 'graphqlBatchedSearchSize') ?.valueInteger, graphqlMaxDepth: ctx.project.systemSetting?.find((s) => s.name === 'graphqlMaxDepth')?.valueInteger, graphqlMaxSearches: ctx.project.systemSetting?.find((s) => s.name === 'graphqlMaxSearches')?.valueInteger, searchOnReader: ctx.project.systemSetting?.find((s) => s.name === 'searchOnReader')?.valueBoolean, transactions: ctx.project.features?.includes('transaction-bundles'), }, }; let result = await getInternalFhirRouter().handleRequest(request, ctx.repo); if (isNotFound(result[0])) { const customOperationResponse = await tryCustomOperation(request, ctx.repo); if (customOperationResponse) { result = customOperationResponse; } } if (result.length === 1) { if (!isOk(result[0])) { throw new OperationOutcomeError(result[0]); } sendOutcome(res, result[0]); return; } await sendFhirResponse(req, res, result[0], result[1], result[2]); }); function stripPrefix(str: string, prefix: string): string { return str.substring(str.indexOf(prefix) + prefix.length); }

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