From 7afe15ffe2f88a2a9e81a043cc1e811c3bcf4e44 Mon Sep 17 00:00:00 2001 From: Matthew Gramigna Date: Mon, 17 Oct 2022 09:36:05 -0400 Subject: [PATCH] Allow optional trusting of meta.profile (#29) This commit allows integrators to pass along a new options object with the `requireProfileTagging` option. If set to true, retrieves for profiles will only return data with a `meta.profile` value matching the URL for the profile specified in the retrieve details. The is a squash commit of previous commits with the following messages: * Allow optional configuration of a trusted environment based on meta.profile * Update docs to include trusted environment description and use FHIR 401 in examples * Exclude base FHIR from meta.profile checking and update tests * Add usage of retrieveDetails for model info lookups and throw errors for bad config * Update docs * Switch to options object for constructing patient sources * Update unit test fixture to actually be a valid us-core encounter * Add meta.profile tests for all FHIR versions * Throw error when no patient found with matching profile; add tests * Fix grammar in docs * Update README to use proper object example after structure change * Add one more test case ensuring proper handling of base FHIR structure defs --- README.md | 22 +++++- src/fhir.js | 74 +++++++++++++++---- test/dstu2_test.js | 68 +++++++++++++++++ ..._a901d2b4-30a8-41b9-b94a-f44561d8f809.json | 5 +- ..._6662f0ca-b617-4e02-8f55-7275e9f49aa0.json | 12 +++ ..._a901d2b4-30a8-41b9-b94a-f44561d8f809.json | 3 +- test/r401_test.js | 70 ++++++++++++++++++ test/r4_test.js | 70 ++++++++++++++++++ test/stu3_test.js | 68 +++++++++++++++++ 9 files changed, 372 insertions(+), 20 deletions(-) diff --git a/README.md b/README.md index 5d83cde..8fc1bab 100644 --- a/README.md +++ b/README.md @@ -25,11 +25,29 @@ const cqlfhir = require('cql-exec-fhir'); // Code setting up the CQL library, executor, etc, and getting the patient data as a bundle // ... -const patientSource = cqlfhir.PatientSource.FHIRv102(); // or .FHIRv300() or .FHIRv400() +const patientSource = cqlfhir.PatientSource.FHIRv401(); // or .FHIRv102() or .FHIRv300() or .FHIRv400() patientSource.loadBundles([patient01, patient02]); const results = executor.exec(patientSource); ``` +## (Optional) Trusted Environment with meta.profile + +**NOTE**: This feature will only work with `cql-execution` version 2.4.1 or higher. + +If desired, the FHIR Data Source can be configured to use the `meta.profile` list on FHIR resources as a source of truth for whether or not that resource should be included when looking through the Bundle of data. + +```js +const cqlfhir = require('cql-exec-fhir'); + +// Including "requireProfileTagging: true" in an object passed in to the constructor enables the trusted environment +const patientSource = cqlfhir.PatientSource.FHIRv401({ + requireProfileTagging: true, +}); // or .FHIRv102() or .FHIRv300() or .FHIRv400() +``` + +As an example, if an ELM Retrieve expression asks for a FHIR Condition Resource with profile `http://hl7.org/fhir/us/core/StructureDefinition/us-core-condition-encounter-diagnosis`, the default behavior of the FHIR Data Source is to find any FHIR Condition resource. +With the trusted environment enabled however, the FHIR Data Source will _only_ find resources with the string `'http://hl7.org/fhir/us/core/StructureDefinition/us-core-condition-encounter-diagnosis'` included in their `meta.profile` lists. + # Using the FHIRWrapper If you are passing in individual FHIR resources to the execution engine as parameters, you can use FHIRWrapper @@ -39,7 +57,7 @@ Example: ```js const cqlfhir = require('cql-exec-fhir'); -const fhirWrapper = cqlfhir.FHIRWrapper.FHIRv102(); // or .FHIRv300() or .FHIRv400() or .FHIRv401() +const fhirWrapper = cqlfhir.FHIRWrapper.FHIRv401(); // or .FHIRv102() or .FHIRv300() or .FHIRv400() const conditionRawResource = { "resourceType": "Condition", "id": "f201", "clinicalStatus": "active", ... } const conditionFhirObject = fhirWrapper.wrap(conditionResource) diff --git a/src/fhir.js b/src/fhir.js index 66fa097..56a68cb 100644 --- a/src/fhir.js +++ b/src/fhir.js @@ -50,30 +50,31 @@ class FHIRWrapper { } class PatientSource { - constructor(filePathOrXML) { + constructor(filePathOrXML, patientSourceOptions = {}) { this._index = 0; this._bundles = []; + this._patientSourceOptions = patientSourceOptions; this._modelInfo = load(filePathOrXML); } // Convenience factory method for getting a FHIR 1.0.2 (DSTU2) Patient Source - static FHIRv102() { - return new PatientSource(FHIRv102XML); + static FHIRv102(patientSourceOptions) { + return new PatientSource(FHIRv102XML, patientSourceOptions); } // Convenience factory method for getting a FHIR 3.0.0 (STU3) Patient Source - static FHIRv300() { - return new PatientSource(FHIRv300XML); + static FHIRv300(patientSourceOptions) { + return new PatientSource(FHIRv300XML, patientSourceOptions); } // Convenience factory method for getting a FHIR 4.0.0 (R4) Patient Source - static FHIRv400() { - return new PatientSource(FHIRv400XML); + static FHIRv400(patientSourceOptions) { + return new PatientSource(FHIRv400XML, patientSourceOptions); } // Convenience factory method for getting a FHIR 4.0.1 (R4) Patient Source - static FHIRv401() { - return new PatientSource(FHIRv401XML); + static FHIRv401(patientSourceOptions) { + return new PatientSource(FHIRv401XML, patientSourceOptions); } get version() { @@ -86,7 +87,7 @@ class PatientSource { currentPatient() { if (this._index < this._bundles.length) { - return new Patient(this._bundles[this._index], this._modelInfo); + return new Patient(this._bundles[this._index], this._modelInfo, this._patientSourceOptions); } } @@ -290,7 +291,7 @@ class FHIRObject { } class Patient extends FHIRObject { - constructor(bundle, modelInfo) { + constructor(bundle, modelInfo, patientSourceOptions = {}) { const patientClass = modelInfo.patientClassIdentifier ? modelInfo.patientClassIdentifier : modelInfo.patientClassName; @@ -298,19 +299,36 @@ class Patient extends FHIRObject { const ptEntry = bundle.entry.find(e => e.resource && e.resource.resourceType == resourceType); const ptClass = modelInfo.findClass(patientClass); super(ptEntry.resource, ptClass, modelInfo); + this._patientSourceOptions = patientSourceOptions; + // Define a "private" un-enumerable property to hold the bundle Object.defineProperty(this, '_bundle', { value: bundle, enumerable: false }); } - findRecord(profile) { - const records = this.findRecords(profile); + findRecord(profile, retrieveDetails) { + const records = this.findRecords(profile, retrieveDetails); if (records.length > 0) { return records[0]; } } - findRecords(profile) { - const classInfo = this._modelInfo.findClass(profile); + findRecords(profile, retrieveDetails) { + const { requireProfileTagging } = this._patientSourceOptions; + + // retrieveDetails was introduced in cql-execution v2.4.1. If it is missing from the function call + // profile checking will not work, + if (requireProfileTagging === true && retrieveDetails == null) { + throw new Error( + 'meta.profile checking is only supported using cql-execution >=2.4.1. Please upgrade or set the "requireProfileTagging" option to false when constructing a PatientSource.' + ); + } + + // Preferring the datatype on retrieveDetails allows the engine to properly identify resources where + // the ELM uses a profile from a specific IG (e.g. US Core) + const classInfo = this._modelInfo.findClass( + retrieveDetails ? retrieveDetails.datatype : profile + ); + if (classInfo == null) { console.error(`Failed to find type info for ${profile}`); return []; @@ -318,11 +336,35 @@ class Patient extends FHIRObject { const resourceType = classInfo.name.replace(/^FHIR\./, ''); const records = this._bundle.entry .filter(e => { - return e.resource && e.resource.resourceType == resourceType; + if (e.resource && e.resource.resourceType == resourceType) { + if ( + requireProfileTagging === true && + profile !== `http://hl7.org/fhir/StructureDefinition/${resourceType}` + ) { + return ( + e.resource.meta && + e.resource.meta.profile && + e.resource.meta.profile.includes(profile) + ); + } + return true; + } + return false; }) .map(e => { return new FHIRObject(e.resource, classInfo, this._modelInfo); }); + + // PatientSources have the assumption that bundles loaded in always have a Patient resources. + // When "requireProfileTagging" is true, we could encounter a case where the Patient resource is contained in the Bundle + // but does not have meta.profile set to match the Retrieve of the Patient. + // In this case, we should throw an error, as we want to ensure that the Patient can properly be retrieved before executing the rest of the ELM Retrieves + if (requireProfileTagging === true && resourceType === 'Patient' && records.length === 0) { + throw new Error( + `Patient record with meta.profile matching ${profile} was not found. Please ensure that meta.profile is properly set on the Patient resource, or set the "requireProfileTagging" option to false when constructing a PatientSource.` + ); + } + return records; } } diff --git a/test/dstu2_test.js b/test/dstu2_test.js index 80f2c95..6d2c30d 100644 --- a/test/dstu2_test.js +++ b/test/dstu2_test.js @@ -379,6 +379,74 @@ describe('#DSTU2', () => { }); }); +describe('#DSTU2 PatientSource meta.profile checking', () => { + let patientSource; + before(() => { + patientSource = cqlfhir.PatientSource.FHIRv102({ + requireProfileTagging: true + }); + }); + + beforeEach(() => { + // patientMyron has 1 Condition resource with a meta.profile set to be a Argonaut Condition + // patientShawnee does not have an Argonaut profile included in meta.profile on any resources + patientSource.loadBundles([patientMyron, patientShawnee]); + }); + + afterEach(() => patientSource.reset()); + + it('should throw error when trying to use meta.profile with no retrieveDetails', () => { + const myron = patientSource.currentPatient(); + expect(() => + myron.findRecords('http://fhir.org/guides/argonaut/StructureDefinition/argo-condition') + ).to.throw(); + }); + + it('should not find any resources without a matching meta.profile', () => { + const myron = patientSource.currentPatient(); + const conditions = myron.findRecords('http://example.com/not-a-real-profile', { + datatype: '{http://hl7.org/fhir}Condition' + }); + expect(conditions).to.have.length(0); + }); + + it('should find resources with matching meta.profile', () => { + const myron = patientSource.currentPatient(); + const conditions = myron.findRecords( + 'http://fhir.org/guides/argonaut/StructureDefinition/argo-condition', + { + datatype: '{http://hl7.org/fhir}Condition', + templateId: 'http://fhir.org/guides/argonaut/StructureDefinition/argo-condition' + } + ); + + expect(conditions).to.have.length(1); + expect(conditions.every(c => c.getTypeInfo().name === 'FHIR.Condition')).to.be.true; + expect(conditions[0].meta.profile[0].value).equal( + 'http://fhir.org/guides/argonaut/StructureDefinition/argo-condition' + ); + }); + + it('should throw error if no patient resource is found with "requireProfileTagging" enabled', () => { + const shawnee = patientSource.nextPatient(); + expect(() => { + shawnee.findRecords('http://fhir.org/guides/argonaut/StructureDefinition/argo-patient', { + datatype: '{http://hl7.org/fhir}Patient', + templateId: 'http://fhir.org/guides/argonaut/StructureDefinition/argo-patient' + }); + }).to.throw(); + }); + + it('should find FHIR core resources even when they are not tagged with the core URL', () => { + const myron = patientSource.currentPatient(); + const conditions = myron.findRecords('http://hl7.org/fhir/StructureDefinition/Condition', { + datatype: '{http://hl7.org/fhir}Condition', + templateId: 'http://hl7.org/fhir/StructureDefinition/Condition' + }); + expect(conditions).to.have.length(9); + }); +}); + function compact(obj) { if (Array.isArray(obj)) { return obj.map(o => compact(o)); diff --git a/test/fixtures/dstu2/Myron933_Ondricka197_a901d2b4-30a8-41b9-b94a-f44561d8f809.json b/test/fixtures/dstu2/Myron933_Ondricka197_a901d2b4-30a8-41b9-b94a-f44561d8f809.json index bb6b9e1..cfd5057 100644 --- a/test/fixtures/dstu2/Myron933_Ondricka197_a901d2b4-30a8-41b9-b94a-f44561d8f809.json +++ b/test/fixtures/dstu2/Myron933_Ondricka197_a901d2b4-30a8-41b9-b94a-f44561d8f809.json @@ -257,6 +257,9 @@ "resource": { "resourceType": "Condition", "id": "3c57b73b-6f28-45e7-9729-b681a1ec4156", + "meta": { + "profile": ["http://fhir.org/guides/argonaut/StructureDefinition/argo-condition"] + }, "patient": { "reference": "urn:uuid:bb5e0a4a-bcb8-448b-ab0a-69f639b7dd88" }, @@ -268,7 +271,7 @@ "coding": [ { "system": "http://snomed.info/sct", - "code": "15777000", + "code": "714628002", "display": "Prediabetes" } ], diff --git a/test/fixtures/r4/Luna60_McCullough561_6662f0ca-b617-4e02-8f55-7275e9f49aa0.json b/test/fixtures/r4/Luna60_McCullough561_6662f0ca-b617-4e02-8f55-7275e9f49aa0.json index c23665d..ed85ba5 100644 --- a/test/fixtures/r4/Luna60_McCullough561_6662f0ca-b617-4e02-8f55-7275e9f49aa0.json +++ b/test/fixtures/r4/Luna60_McCullough561_6662f0ca-b617-4e02-8f55-7275e9f49aa0.json @@ -1687,6 +1687,18 @@ "resource": { "resourceType": "Condition", "id": "9934bc4f-58af-4ecf-bb70-b7cc31987fc5", + "meta": { + "profile": ["http://hl7.org/fhir/us/core/StructureDefinition/us-core-condition-encounter-diagnosis"] + }, + "category": { + "coding": [ + { + "system": "http://terminology.hl7.org/CodeSystem/condition-category", + "code": "encounter-diagnosis", + "display": "Encounter Diagnosis" + } + ] + }, "clinicalStatus": { "coding": [ { diff --git a/test/fixtures/stu3/Myron933_Ondricka197_a901d2b4-30a8-41b9-b94a-f44561d8f809.json b/test/fixtures/stu3/Myron933_Ondricka197_a901d2b4-30a8-41b9-b94a-f44561d8f809.json index ab0234a..1b17103 100644 --- a/test/fixtures/stu3/Myron933_Ondricka197_a901d2b4-30a8-41b9-b94a-f44561d8f809.json +++ b/test/fixtures/stu3/Myron933_Ondricka197_a901d2b4-30a8-41b9-b94a-f44561d8f809.json @@ -324,7 +324,8 @@ "id": "6bf97e86-a3da-4b4b-a4e5-a290c1099719", "meta": { "profile": [ - "http://standardhealthrecord.org/fhir/StructureDefinition/shr-encounter-EncounterPerformed" + "http://standardhealthrecord.org/fhir/StructureDefinition/shr-encounter-EncounterPerformed", + "http://hl7.org/fhir/us/core/StructureDefinition/us-core-encounter" ] }, "extension": [ diff --git a/test/r401_test.js b/test/r401_test.js index a7742de..990f885 100644 --- a/test/r401_test.js +++ b/test/r401_test.js @@ -375,6 +375,76 @@ describe('#R4 v4.0.1', () => { }); }); +describe('#R4 v4.0.1 PatientSource meta.profile checking', () => { + let patientSource; + before(() => { + patientSource = cqlfhir.PatientSource.FHIRv401({ + requireProfileTagging: true + }); + }); + + beforeEach(() => { + // patientLuna has 1 Condition resource with a meta.profile set to be a US Core Condition + // patientJohnnie does not have meta.profile set on any resources + patientSource.loadBundles([patientLuna, patientJohnnie]); + }); + + afterEach(() => patientSource.reset()); + + it('should throw error when trying to use meta.profile with no retrieveDetails', () => { + const luna = patientSource.currentPatient(); + expect(() => + luna.findRecords( + 'http://hl7.org/fhir/us/core/StructureDefinition/us-core-condition-encounter-diagnosis' + ) + ).to.throw(); + }); + + it('should not find any resources without a matching meta.profile', () => { + const luna = patientSource.currentPatient(); + const conditions = luna.findRecords('http://example.com/not-a-real-profile', { + datatype: '{http://hl7.org/fhir}Condition' + }); + expect(conditions).to.have.length(0); + }); + + it('should find resources with matching meta.profile', () => { + const luna = patientSource.currentPatient(); + const conditions = luna.findRecords( + 'http://hl7.org/fhir/us/core/StructureDefinition/us-core-condition-encounter-diagnosis', + { + datatype: '{http://hl7.org/fhir}Condition', + templateId: + 'http://hl7.org/fhir/us/core/StructureDefinition/us-core-condition-encounter-diagnosis' + } + ); + expect(conditions).to.have.length(1); + expect(conditions.every(c => c.getTypeInfo().name === 'Condition')).to.be.true; + expect(conditions[0].meta.profile[0].value).equal( + 'http://hl7.org/fhir/us/core/StructureDefinition/us-core-condition-encounter-diagnosis' + ); + }); + + it('should throw error if no patient resource is found with "requireProfileTagging" enabled', () => { + const johnnie = patientSource.nextPatient(); + expect(() => { + johnnie.findRecords('http://hl7.org/fhir/us/core/StructureDefinition/us-core-patient', { + datatype: '{http://hl7.org/fhir}Patient', + templateId: 'http://hl7.org/fhir/us/core/StructureDefinition/us-core-patient' + }); + }).to.throw(); + }); + + it('should find FHIR core resources even when they are not tagged with the core URL', () => { + const luna = patientSource.currentPatient(); + const conditions = luna.findRecords('http://hl7.org/fhir/StructureDefinition/Condition', { + datatype: '{http://hl7.org/fhir}Condition', + templateId: 'http://hl7.org/fhir/StructureDefinition/Condition' + }); + expect(conditions).to.have.length(8); + }); +}); + function compact(obj) { if (Array.isArray(obj)) { return obj.map(o => compact(o)); diff --git a/test/r4_test.js b/test/r4_test.js index 88c941c..423826d 100644 --- a/test/r4_test.js +++ b/test/r4_test.js @@ -413,6 +413,76 @@ describe('#R4 v4.0.0', () => { }); }); +describe('#R4 PatientSource meta.profile checking', () => { + let patientSource; + before(() => { + patientSource = cqlfhir.PatientSource.FHIRv400({ + requireProfileTagging: true + }); + }); + + beforeEach(() => { + // patientLuna has 1 Condition resource with a meta.profile set to be a US Core Condition + // patientJohnnie does not have meta.profile set on any resources + patientSource.loadBundles([patientLuna, patientJohnnie]); + }); + + afterEach(() => patientSource.reset()); + + it('should throw error when trying to use meta.profile with no retrieveDetails', () => { + const luna = patientSource.currentPatient(); + expect(() => + luna.findRecords( + 'http://hl7.org/fhir/us/core/StructureDefinition/us-core-condition-encounter-diagnosis' + ) + ).to.throw(); + }); + + it('should not find any resources without a matching meta.profile', () => { + const luna = patientSource.currentPatient(); + const conditions = luna.findRecords('http://example.com/not-a-real-profile', { + datatype: '{http://hl7.org/fhir}Condition' + }); + expect(conditions).to.have.length(0); + }); + + it('should find resources with matching meta.profile', () => { + const luna = patientSource.currentPatient(); + const conditions = luna.findRecords( + 'http://hl7.org/fhir/us/core/StructureDefinition/us-core-condition-encounter-diagnosis', + { + datatype: '{http://hl7.org/fhir}Condition', + templateId: + 'http://hl7.org/fhir/us/core/StructureDefinition/us-core-condition-encounter-diagnosis' + } + ); + expect(conditions).to.have.length(1); + expect(conditions.every(c => c.getTypeInfo().name === 'Condition')).to.be.true; + expect(conditions[0].meta.profile[0].value).equal( + 'http://hl7.org/fhir/us/core/StructureDefinition/us-core-condition-encounter-diagnosis' + ); + }); + + it('should throw error if no patient resource is found with "requireProfileTagging" enabled', () => { + const johnnie = patientSource.nextPatient(); + expect(() => { + johnnie.findRecords('http://hl7.org/fhir/us/core/StructureDefinition/us-core-patient', { + datatype: '{http://hl7.org/fhir}Patient', + templateId: 'http://hl7.org/fhir/us/core/StructureDefinition/us-core-patient' + }); + }).to.throw(); + }); + + it('should find FHIR core resources even when they are not tagged with the core URL', () => { + const luna = patientSource.currentPatient(); + const conditions = luna.findRecords('http://hl7.org/fhir/StructureDefinition/Condition', { + datatype: '{http://hl7.org/fhir}Condition', + templateId: 'http://hl7.org/fhir/StructureDefinition/Condition' + }); + expect(conditions).to.have.length(8); + }); +}); + function compact(obj) { if (Array.isArray(obj)) { return obj.map(o => compact(o)); diff --git a/test/stu3_test.js b/test/stu3_test.js index fd63048..52de1b9 100644 --- a/test/stu3_test.js +++ b/test/stu3_test.js @@ -397,6 +397,74 @@ describe('#STU3', () => { }); }); +describe('#STU3 PatientSource meta.profile checking', () => { + let patientSource; + before(() => { + patientSource = cqlfhir.PatientSource.FHIRv300({ + requireProfileTagging: true + }); + }); + + beforeEach(() => { + // patientMyron has 1 Encounter resource with a meta.profile set to be a US Core Encounter + // patientShawnee does not have a US Core profile included in meta.profile on any resources + patientSource.loadBundles([patientMyron, patientShawnee]); + }); + + afterEach(() => patientSource.reset()); + + it('should throw error when trying to use meta.profile with no retrieveDetails', () => { + const myron = patientSource.currentPatient(); + expect(() => + myron.findRecords('http://hl7.org/fhir/us/core/StructureDefinition/us-core-encounter') + ).to.throw(); + }); + + it('should not find any resources without a matching meta.profile', () => { + const myron = patientSource.currentPatient(); + const encounters = myron.findRecords('http://example.com/not-a-real-profile', { + datatype: '{http://hl7.org/fhir}Encounter' + }); + expect(encounters).to.have.length(0); + }); + + it('should find resources with matching meta.profile', () => { + const myron = patientSource.currentPatient(); + const encounters = myron.findRecords( + 'http://hl7.org/fhir/us/core/StructureDefinition/us-core-encounter', + { + datatype: '{http://hl7.org/fhir}Encounter', + templateId: 'http://hl7.org/fhir/us/core/StructureDefinition/us-core-encounter' + } + ); + + expect(encounters).to.have.length(1); + expect(encounters.every(c => c.getTypeInfo().name === 'Encounter')).to.be.true; + expect(encounters[0].meta.profile[1].value).equal( + 'http://hl7.org/fhir/us/core/StructureDefinition/us-core-encounter' + ); + }); + + it('should throw error if no patient resource is found with "requireProfileTagging" enabled', () => { + const shawnee = patientSource.nextPatient(); + expect(() => { + shawnee.findRecords('http://hl7.org/fhir/us/core/StructureDefinition/us-core-patient', { + datatype: '{http://hl7.org/fhir}Patient', + templateId: 'http://hl7.org/fhir/us/core/StructureDefinition/us-core-patient' + }); + }).to.throw(); + }); + + it('should find FHIR core resources even when they are not tagged with the core URL', () => { + const myron = patientSource.currentPatient(); + const conditions = myron.findRecords('http://hl7.org/fhir/StructureDefinition/Condition', { + datatype: '{http://hl7.org/fhir}Condition', + templateId: 'http://hl7.org/fhir/StructureDefinition/Condition' + }); + expect(conditions).to.have.length(9); + }); +}); + function compact(obj) { if (Array.isArray(obj)) { return obj.map(o => compact(o));