From 3872bd1cfdc841437323ee39ee3a387296a3ac2e Mon Sep 17 00:00:00 2001 From: Nestor Carvantes Date: Thu, 20 May 2021 02:26:56 -0700 Subject: [PATCH] feat: enable $docref based on the compiled IGs (#83) --- src/app.ts | 11 ++- src/configHandler.ts | 39 +++++++++++ src/implementationGuides/index.test.ts | 59 +++++++++++----- src/implementationGuides/index.ts | 68 +++++++++++++++---- .../OperationDefinitionRegistry.test.ts | 68 +++++++++++++++++++ .../OperationDefinitionRegistry.ts | 38 +++++++++++ .../USCoreDocRef/index.ts | 37 ++++------ .../USCoreDocRef/parseParams.ts | 2 +- src/operationDefinitions/index.ts | 29 ++++++++ src/operationDefinitions/types.ts | 23 ++++++- src/registry/index.ts | 4 +- 11 files changed, 320 insertions(+), 58 deletions(-) create mode 100644 src/operationDefinitions/OperationDefinitionRegistry.test.ts create mode 100644 src/operationDefinitions/OperationDefinitionRegistry.ts create mode 100644 src/operationDefinitions/index.ts diff --git a/src/app.ts b/src/app.ts index 53097fb..6d8ba67 100644 --- a/src/app.ts +++ b/src/app.ts @@ -22,6 +22,7 @@ import { applicationErrorMapper, httpErrorHandler, unknownErrorHandler } from '. import ExportRoute from './router/routes/exportRoute'; import WellKnownUriRouteRoute from './router/routes/wellKnownUriRoute'; import { FHIRStructureDefinitionRegistry } from './registry'; +import { initializeOperationRegistry } from './operationDefinitions'; const configVersionSupported: ConfigVersion = 1; @@ -38,6 +39,7 @@ export function generateServerlessRouter( const serverUrl: string = fhirConfig.server.url; let hasCORSEnabled: boolean = false; const registry = new FHIRStructureDefinitionRegistry(compiledImplementationGuides); + const operationRegistry = initializeOperationRegistry(configHandler); const app = express(); app.use(express.urlencoded({ extended: true })); @@ -70,7 +72,9 @@ export function generateServerlessRouter( // AuthZ app.use(async (req: express.Request, res: express.Response, next: express.NextFunction) => { try { - const requestInformation = getRequestInformation(req.method, req.path); + const requestInformation = + operationRegistry.getOperation(req.method, req.path)?.requestInformation ?? + getRequestInformation(req.method, req.path); // Clean auth header (remove 'Bearer ') req.headers.authorization = cleanAuthHeader(req.headers.authorization); res.locals.userIdentity = await fhirConfig.auth.authorization.verifyAccessToken({ @@ -93,6 +97,11 @@ export function generateServerlessRouter( app.use('/', exportRoute.router); } + // Operations defined by OperationDefinition resources + operationRegistry.getAllRouters().forEach(router => { + app.use('/', router); + }); + // Special Resources if (fhirConfig.profile.resources) { Object.entries(fhirConfig.profile.resources).forEach(async resourceEntry => { diff --git a/src/configHandler.ts b/src/configHandler.ts index 095085a..9cbe891 100644 --- a/src/configHandler.ts +++ b/src/configHandler.ts @@ -4,6 +4,7 @@ */ import { FhirConfig, FhirVersion, TypeOperation } from 'fhir-works-on-aws-interface'; +import ResourceHandler from './router/handlers/resourceHandler'; export default class ConfigHandler { readonly config: FhirConfig; @@ -66,4 +67,42 @@ export default class ConfigHandler { return resources; } + + /** + * Get a `ResourceHandler` for a given `resourceType`. The `ResourceHandler` uses the most specific dependencies available in `FhirConfig`: + * 1. Use the dependencies specific to the given `resourceType` if they are defined. + * 2. Otherwise use the dependencies for `genericResource` if the given `resourceType` is a valid `genericResource`. + * 3. Otherwise return undefined. + */ + getResourceHandler(resourceType: string): ResourceHandler | undefined { + if (this.config.profile.resources?.[resourceType]) { + const { persistence, typeSearch, typeHistory } = this.config.profile.resources[resourceType]; + return new ResourceHandler( + persistence, + typeSearch, + typeHistory, + this.config.auth.authorization, + this.config.server.url, + this.config.validators, + ); + } + + if ( + this.getGenericResources(this.config.profile.fhirVersion).includes(resourceType) && + this.config.profile.genericResource + ) { + const { persistence, typeSearch, typeHistory } = this.config.profile.genericResource; + + return new ResourceHandler( + persistence, + typeSearch, + typeHistory, + this.config.auth.authorization, + this.config.server.url, + this.config.validators, + ); + } + + return undefined; + } } diff --git a/src/implementationGuides/index.test.ts b/src/implementationGuides/index.test.ts index 9c79fe3..7ae94bd 100644 --- a/src/implementationGuides/index.test.ts +++ b/src/implementationGuides/index.test.ts @@ -3,12 +3,12 @@ * SPDX-License-Identifier: Apache-2.0 */ -import { StructureDefinitionImplementationGuides } from './index'; +import { RoutingImplementationGuides } from './index'; -describe('StructureDefinitionImplementationGuides', () => { +describe('RoutingImplementationGuides', () => { describe(`compile`, async () => { - test(`valid StructureDefinition`, async () => { - const compiled = new StructureDefinitionImplementationGuides().compile([ + test(`valid input`, async () => { + const compiled = new RoutingImplementationGuides().compile([ { resourceType: 'StructureDefinition', id: 'CARIN-BB-Organization', @@ -28,24 +28,49 @@ describe('StructureDefinitionImplementationGuides', () => { baseDefinition: 'http://hl7.org/fhir/us/core/StructureDefinition/us-core-organization', derivation: 'constraint', }, + { + resourceType: 'OperationDefinition', + id: 'docref', + url: 'http://hl7.org/fhir/us/core/OperationDefinition/docref', + version: '3.1.1', + name: 'USCoreFetchDocumentReferences', + title: 'US Core Fetch DocumentReferences', + status: 'active', + kind: 'operation', + date: '2019-05-21', + publisher: 'US Core Project', + description: + 'This operation is used to return all the references to documents related to a patient...', + code: 'docref', + system: false, + type: true, + instance: false, + parameter: [], + }, ]); await expect(compiled).resolves.toMatchInlineSnapshot(` - Array [ - Object { - "baseDefinition": "http://hl7.org/fhir/us/core/StructureDefinition/us-core-organization", - "description": "This profile builds on the USCoreOrganization Profile. It includes additional constraints relevant for the use cases addressed by this IG.", - "name": "CARINBBOrganization", - "resourceType": "StructureDefinition", - "type": "Organization", - "url": "http://hl7.org/fhir/us/carin/StructureDefinition/carin-bb-organization", - }, - ] - `); + Array [ + Object { + "baseDefinition": "http://hl7.org/fhir/us/core/StructureDefinition/us-core-organization", + "description": "This profile builds on the USCoreOrganization Profile. It includes additional constraints relevant for the use cases addressed by this IG.", + "name": "CARINBBOrganization", + "resourceType": "StructureDefinition", + "type": "Organization", + "url": "http://hl7.org/fhir/us/carin/StructureDefinition/carin-bb-organization", + }, + Object { + "description": "This operation is used to return all the references to documents related to a patient...", + "name": "USCoreFetchDocumentReferences", + "resourceType": "OperationDefinition", + "url": "http://hl7.org/fhir/us/core/OperationDefinition/docref", + }, + ] + `); }); - test(`invalid StructureDefinition`, async () => { - const compiled = new StructureDefinitionImplementationGuides().compile([ + test(`invalid input`, async () => { + const compiled = new RoutingImplementationGuides().compile([ { foo: 'bar', }, diff --git a/src/implementationGuides/index.ts b/src/implementationGuides/index.ts index 69e7ce1..188efcb 100644 --- a/src/implementationGuides/index.ts +++ b/src/implementationGuides/index.ts @@ -18,10 +18,21 @@ export type FhirStructureDefinition = { type: string; }; +/** + * Based on the FHIR OperationDefinition. This type only includes the fields that are required for the compile process. + * See: https://www.hl7.org/fhir/operationdefinition.html + */ +export type FhirOperationDefinition = { + resourceType: 'OperationDefinition'; + url: string; + name: string; + description: string; +}; + /** * This class compiles StructuredDefinitions from IG packages */ -export class StructureDefinitionImplementationGuides implements ImplementationGuides { +export class RoutingImplementationGuides implements ImplementationGuides { /** * Compiles the contents of an Implementation Guide into an internal representation used to build the Capability Statement * @@ -29,23 +40,43 @@ export class StructureDefinitionImplementationGuides implements ImplementationGu */ // eslint-disable-next-line class-methods-use-this async compile(resources: any[]): Promise { - const validStructureDefinitions: FhirStructureDefinition[] = []; + const validDefinitions: (FhirStructureDefinition | FhirOperationDefinition)[] = []; resources.forEach(s => { - if (StructureDefinitionImplementationGuides.isFhirStructureDefinition(s)) { - validStructureDefinitions.push(s); + if ( + RoutingImplementationGuides.isFhirStructureDefinition(s) || + RoutingImplementationGuides.isFhirOperationDefinition(s) + ) { + validDefinitions.push(s); } else { - throw new Error(`The following input is not a StructureDefinition: ${s.type} ${s.name}`); + throw new Error( + `The following input is not a StructureDefinition nor a OperationDefinition: ${s.type} ${s.name}`, + ); } }); - return validStructureDefinitions.map((structureDefinition: any) => ({ - name: structureDefinition.name, - url: structureDefinition.url, - type: structureDefinition.type, - resourceType: structureDefinition.resourceType, - description: structureDefinition.description, - baseDefinition: structureDefinition.baseDefinition, - })); + return validDefinitions.map(fhirDefinition => { + switch (fhirDefinition.resourceType) { + case 'StructureDefinition': + return { + name: fhirDefinition.name, + url: fhirDefinition.url, + type: fhirDefinition.type, + resourceType: fhirDefinition.resourceType, + description: fhirDefinition.description, + baseDefinition: fhirDefinition.baseDefinition, + }; + case 'OperationDefinition': + return { + name: fhirDefinition.name, + url: fhirDefinition.url, + resourceType: fhirDefinition.resourceType, + description: fhirDefinition.description, + }; + default: + // this should never happen + throw new Error('Unexpected error'); + } + }); } private static isFhirStructureDefinition(x: any): x is FhirStructureDefinition { @@ -60,4 +91,15 @@ export class StructureDefinitionImplementationGuides implements ImplementationGu typeof x.type === 'string' ); } + + private static isFhirOperationDefinition(x: any): x is FhirOperationDefinition { + return ( + typeof x === 'object' && + x && + x.resourceType === 'OperationDefinition' && + typeof x.url === 'string' && + typeof x.name === 'string' && + typeof x.description === 'string' + ); + } } diff --git a/src/operationDefinitions/OperationDefinitionRegistry.test.ts b/src/operationDefinitions/OperationDefinitionRegistry.test.ts new file mode 100644 index 0000000..0352040 --- /dev/null +++ b/src/operationDefinitions/OperationDefinitionRegistry.test.ts @@ -0,0 +1,68 @@ +/* + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + * + */ + +import { Router } from 'express'; +import ConfigHandler from '../configHandler'; +import { OperationDefinitionRegistry } from './OperationDefinitionRegistry'; +import { OperationDefinitionImplementation } from './types'; +import ResourceHandler from '../router/handlers/resourceHandler'; + +const fakeRouter = (jest.fn() as unknown) as Router; +const fakeOperation: OperationDefinitionImplementation = { + canonicalUrl: 'https://fwoa.com/operation/fakeOperation', + httpVerbs: ['GET'], + path: '/Patient/fakeOperation', + targetResourceType: 'Patient', + requestInformation: { + operation: 'read', + resourceType: 'Patient', + }, + // eslint-disable-next-line @typescript-eslint/no-unused-vars + buildRouter: (resourceHandler: ResourceHandler) => fakeRouter, +}; +describe('OperationDefinitionRegistry', () => { + test('getAllRouters', () => { + const configHandlerMock = { + getResourceHandler: jest.fn().mockReturnValue({}), + }; + + const operationDefinitionRegistry = new OperationDefinitionRegistry( + (configHandlerMock as unknown) as ConfigHandler, + [fakeOperation], + ); + + expect(operationDefinitionRegistry.getAllRouters()).toHaveLength(1); + expect(operationDefinitionRegistry.getAllRouters()[0]).toBe(fakeRouter); + }); + + test('getOperation', () => { + const configHandlerMock = { + getResourceHandler: jest.fn().mockReturnValue({}), + }; + + const operationDefinitionRegistry = new OperationDefinitionRegistry( + (configHandlerMock as unknown) as ConfigHandler, + [fakeOperation], + ); + + expect(operationDefinitionRegistry.getOperation('PATCH', '/Patient/fakeOperation')).toBeUndefined(); + expect(operationDefinitionRegistry.getOperation('GET', '/Patient/someOtherOperation')).toBeUndefined(); + + expect(operationDefinitionRegistry.getOperation('GET', '/Patient/fakeOperation')).toBe(fakeOperation); + }); + + test('ResourceHandler not available', () => { + const configHandlerMock = { + getResourceHandler: jest.fn().mockReturnValue(undefined), + }; + + expect( + () => new OperationDefinitionRegistry((configHandlerMock as unknown) as ConfigHandler, [fakeOperation]), + ).toThrowErrorMatchingInlineSnapshot( + `"Failed to initialize operation https://fwoa.com/operation/fakeOperation. Is your FhirConfig correct?"`, + ); + }); +}); diff --git a/src/operationDefinitions/OperationDefinitionRegistry.ts b/src/operationDefinitions/OperationDefinitionRegistry.ts new file mode 100644 index 0000000..d226143 --- /dev/null +++ b/src/operationDefinitions/OperationDefinitionRegistry.ts @@ -0,0 +1,38 @@ +/* + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + * + */ + +import { Router } from 'express'; +import { OperationDefinitionImplementation } from './types'; +import ConfigHandler from '../configHandler'; + +export class OperationDefinitionRegistry { + private readonly operations: OperationDefinitionImplementation[]; + + private readonly routers: Router[]; + + constructor(configHandler: ConfigHandler, operations: OperationDefinitionImplementation[]) { + this.operations = operations; + + this.routers = operations.map(operation => { + const resourceHandler = configHandler.getResourceHandler(operation.targetResourceType); + if (!resourceHandler) { + throw new Error( + `Failed to initialize operation ${operation.canonicalUrl}. Is your FhirConfig correct?`, + ); + } + console.log(`Enabling operation ${operation.canonicalUrl} at ${operation.path}`); + return operation.buildRouter(resourceHandler); + }); + } + + getOperation(method: string, path: string): OperationDefinitionImplementation | undefined { + return this.operations.find(o => o.path === path && o.httpVerbs.includes(method)); + } + + getAllRouters(): Router[] { + return this.routers; + } +} diff --git a/src/operationDefinitions/USCoreDocRef/index.ts b/src/operationDefinitions/USCoreDocRef/index.ts index 36fbc05..05587ce 100644 --- a/src/operationDefinitions/USCoreDocRef/index.ts +++ b/src/operationDefinitions/USCoreDocRef/index.ts @@ -4,7 +4,7 @@ * */ -import express, { Router } from 'express'; +import express from 'express'; import { KeyValueMap, TypeOperation } from 'fhir-works-on-aws-interface'; import { OperationDefinitionImplementation } from '../types'; import ResourceHandler from '../../router/handlers/resourceHandler'; @@ -19,27 +19,23 @@ const docRefImpl = async (resourceHandler: ResourceHandler, userIdentity: KeyVal return resourceHandler.typeSearch('DocumentReference', searchParams, userIdentity); }; -export class USCoreDocRef implements OperationDefinitionImplementation { - readonly canonicalUrl = 'http://hl7.org/fhir/us/core/OperationDefinition/docref'; - - readonly requestInformation = { +export const USCoreDocRef: OperationDefinitionImplementation = { + canonicalUrl: 'http://hl7.org/fhir/us/core/OperationDefinition/docref', + path: '/DocumentReference/$docref', + httpVerbs: ['GET', 'POST'], + targetResourceType: 'DocumentReference', + requestInformation: { operation: searchTypeOperation, resourceType: 'DocumentReference', - }; - - private readonly resourceHandler: ResourceHandler; - - readonly router: Router; - - constructor(resourceHandler: ResourceHandler) { - this.resourceHandler = resourceHandler; + }, + buildRouter: (resourceHandler: ResourceHandler) => { const path = '/DocumentReference/\\$docref'; const router = express.Router(); router.get( path, RouteHelper.wrapAsync(async (req: express.Request, res: express.Response) => { const response = await docRefImpl( - this.resourceHandler, + resourceHandler, res.locals.userIdentity, parseQueryParams(req.query), ); @@ -50,14 +46,11 @@ export class USCoreDocRef implements OperationDefinitionImplementation { router.post( path, RouteHelper.wrapAsync(async (req: express.Request, res: express.Response) => { - const response = await docRefImpl( - this.resourceHandler, - res.locals.userIdentity, - parsePostParams(req.body), - ); + const response = await docRefImpl(resourceHandler, res.locals.userIdentity, parsePostParams(req.body)); res.send(response); }), ); - this.router = router; - } -} + + return router; + }, +}; diff --git a/src/operationDefinitions/USCoreDocRef/parseParams.ts b/src/operationDefinitions/USCoreDocRef/parseParams.ts index 91a8cbe..3614245 100644 --- a/src/operationDefinitions/USCoreDocRef/parseParams.ts +++ b/src/operationDefinitions/USCoreDocRef/parseParams.ts @@ -9,7 +9,7 @@ import createError from 'http-errors'; // @ts-ignore import ajvErrors from 'ajv-errors'; -const ajv = ajvErrors(new Ajv({ allErrors: true })); +const ajv = ajvErrors(new Ajv({ allErrors: true, jsonPointers: true })); export interface DocRefParams { patient: string; diff --git a/src/operationDefinitions/index.ts b/src/operationDefinitions/index.ts new file mode 100644 index 0000000..577a74b --- /dev/null +++ b/src/operationDefinitions/index.ts @@ -0,0 +1,29 @@ +/* + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + * + */ + +import { OperationDefinitionImplementation } from './types'; +import { USCoreDocRef } from './USCoreDocRef'; +import { OperationDefinitionRegistry } from './OperationDefinitionRegistry'; +import ConfigHandler from '../configHandler'; + +export const initializeOperationRegistry = (configHandler: ConfigHandler) => { + const { compiledImplementationGuides } = configHandler.config.profile; + const operations: OperationDefinitionImplementation[] = []; + + // Add the operations to enable on this FHIR server. + // The recommended approach is to enable operations if the corresponding `OperationDefinition` is found on the `compiledImplementationGuides`, + // but this file can be updated to use a different enablement criteria or to disable operations altogether. + if ( + compiledImplementationGuides && + compiledImplementationGuides.find( + (x: any) => x.resourceType === 'OperationDefinition' && x.url === USCoreDocRef.canonicalUrl, + ) + ) { + operations.push(USCoreDocRef); + } + + return new OperationDefinitionRegistry(configHandler, operations); +}; diff --git a/src/operationDefinitions/types.ts b/src/operationDefinitions/types.ts index 960251b..642a0f8 100644 --- a/src/operationDefinitions/types.ts +++ b/src/operationDefinitions/types.ts @@ -5,12 +5,29 @@ import { Router } from 'express'; import { SystemOperation, TypeOperation } from 'fhir-works-on-aws-interface'; +import ResourceHandler from '../router/handlers/resourceHandler'; export interface OperationDefinitionImplementation { /** * url of the corresponding OperationDefinition resource */ - canonicalUrl: string; + readonly canonicalUrl: string; + + /** + * url path used to invoke the operation + * @example '/DocumentReference/$docref' + */ + readonly path: string; + + /** + * Http verbs (methods) supported by this operation e.g GET, POST + */ + readonly httpVerbs: string[]; + + /** + * FHIR resourceType that is affected by this operation + */ + readonly targetResourceType: string; /** * Request information used to resolve AuthZ. This is applicable to OperationDefinitions that can be mapped to @@ -18,7 +35,7 @@ export interface OperationDefinitionImplementation { * * For example, the $docref operation from US Core is effectively a 'search-type' operation on 'DocumentReference'. */ - requestInformation: { + readonly requestInformation: { operation: TypeOperation | SystemOperation; resourceType?: string; id?: string; @@ -27,5 +44,5 @@ export interface OperationDefinitionImplementation { /** * express router that contains the implementation of the operation. It will be mounted on "/" */ - router: Router; + buildRouter(resourceHandler: ResourceHandler): Router; } diff --git a/src/registry/index.ts b/src/registry/index.ts index e3c231f..4386b71 100644 --- a/src/registry/index.ts +++ b/src/registry/index.ts @@ -16,7 +16,9 @@ export class FHIRStructureDefinitionRegistry { let compiledStructureDefinitions: FhirStructureDefinition[] = []; if (compiledImplementationGuides !== undefined) { - compiledStructureDefinitions = [...compiledImplementationGuides]; + compiledStructureDefinitions = [ + ...compiledImplementationGuides.filter(x => x.resourceType === 'StructureDefinition'), + ]; } this.capabilityStatement = {};