Skip to content

Commit

Permalink
Allow optional trusting of meta.profile (#29)
Browse files Browse the repository at this point in the history
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
  • Loading branch information
Matthew Gramigna authored Oct 17, 2022
1 parent 8b7fdfd commit 7afe15f
Show file tree
Hide file tree
Showing 9 changed files with 372 additions and 20 deletions.
22 changes: 20 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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)
Expand Down
74 changes: 58 additions & 16 deletions src/fhir.js
Original file line number Diff line number Diff line change
Expand Up @@ -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() {
Expand All @@ -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);
}
}

Expand Down Expand Up @@ -290,39 +291,80 @@ class FHIRObject {
}

class Patient extends FHIRObject {
constructor(bundle, modelInfo) {
constructor(bundle, modelInfo, patientSourceOptions = {}) {
const patientClass = modelInfo.patientClassIdentifier
? modelInfo.patientClassIdentifier
: modelInfo.patientClassName;
const resourceType = modelInfo.patientClassName.replace(/^FHIR\./, '');
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 [];
}
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;
}
}
Expand Down
68 changes: 68 additions & 0 deletions test/dstu2_test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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));
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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"
},
Expand All @@ -268,7 +271,7 @@
"coding": [
{
"system": "http://snomed.info/sct",
"code": "15777000",
"code": "714628002",
"display": "Prediabetes"
}
],
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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": [
{
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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": [
Expand Down
70 changes: 70 additions & 0 deletions test/r401_test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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));
Expand Down
Loading

0 comments on commit 7afe15f

Please sign in to comment.