diff --git a/ts/server/.eslintrc.js b/ts/server/.eslintrc.js index 184098b..22b0f52 100644 --- a/ts/server/.eslintrc.js +++ b/ts/server/.eslintrc.js @@ -14,6 +14,7 @@ module.exports = { }, ignorePatterns: ['.eslintrc.js'], rules: { + 'prettier/prettier': 0, '@typescript-eslint/interface-name-prefix': 'off', '@typescript-eslint/explicit-function-return-type': 'off', '@typescript-eslint/explicit-module-boundary-types': 'off', diff --git a/ts/server/package.json b/ts/server/package.json index c8205cd..c017c60 100644 --- a/ts/server/package.json +++ b/ts/server/package.json @@ -24,7 +24,7 @@ "@nestjs/core": "^10.0.0", "@nestjs/platform-express": "^10.0.0", "@types/fhir": "^0.0.37", - "fhirpath": "^3.6.1", + "fhirpath": "^3.13.2", "reflect-metadata": "^0.1.13", "rxjs": "^7.8.1" }, diff --git a/ts/server/src/app.controller.ts b/ts/server/src/app.controller.ts index dddca15..623f4d2 100644 --- a/ts/server/src/app.controller.ts +++ b/ts/server/src/app.controller.ts @@ -1,10 +1,12 @@ -import { Controller, Post, Body, HttpCode } from '@nestjs/common'; +import { Controller, Post, Body, HttpCode, UseFilters } from '@nestjs/common'; import { AppService } from './app.service'; import { Resource } from 'fhir/r4b'; - +import * as fhirpath_r4_model from 'fhirpath/fhir-context/r4'; +import { FPMLValidationErrorFilter } from './app.filters'; class Template { context: Record | Resource; template: object; + strict?: boolean; } function containsQuestionnaireResponse( @@ -13,20 +15,36 @@ function containsQuestionnaireResponse( return Object.keys(context).includes('QuestionnaireResponse'); } -@Controller('parse-template') +@Controller() +@UseFilters(FPMLValidationErrorFilter) export class AppController { constructor(private readonly appService: AppService) {} - @Post() + @Post(['parse-template', 'r4/parse-template']) + @HttpCode(200) + resolveTemplateR4(@Body() body: Template): object { + const { context, template, strict = false } = body; + + return this.appService.resolveTemplate( + containsQuestionnaireResponse(context) ? context.QuestionnaireResponse : context, + template, + context, + fhirpath_r4_model, + strict, + ); + } + + @Post('aidbox/parse-template') @HttpCode(200) - resolveTemplate(@Body() body: Template): object { - const { context, template } = body; - let result: object; - if (containsQuestionnaireResponse(context)) { - result = this.appService.resolveTemplate(context.QuestionnaireResponse, template); - } else { - result = this.appService.resolveTemplate(context, template); - } - return result; + resolveTemplateAidbox(@Body() body: Template): object { + const { context, template, strict = false } = body; + + return this.appService.resolveTemplate( + containsQuestionnaireResponse(context) ? context.QuestionnaireResponse : context, + template, + context, + null, + strict, + ); } } diff --git a/ts/server/src/app.filters.ts b/ts/server/src/app.filters.ts new file mode 100644 index 0000000..3671955 --- /dev/null +++ b/ts/server/src/app.filters.ts @@ -0,0 +1,18 @@ +import { ExceptionFilter, Catch, ArgumentsHost, HttpStatus } from '@nestjs/common'; +import { FPMLValidationError } from './utils/extract'; + +@Catch(FPMLValidationError) +export class FPMLValidationErrorFilter implements ExceptionFilter { + catch(exception: any, host: ArgumentsHost) { + const ctx = host.switchToHttp(); + const response = ctx.getResponse(); + const request = ctx.getRequest(); + + response.status(HttpStatus.BAD_REQUEST).json({ + statusCode: HttpStatus.BAD_REQUEST, + timestamp: new Date().toISOString(), + path: request.url, + message: exception.message || 'Unprocessable Entity', + }); + } +} diff --git a/ts/server/src/app.service.ts b/ts/server/src/app.service.ts index 0b02b20..aa6340c 100644 --- a/ts/server/src/app.service.ts +++ b/ts/server/src/app.service.ts @@ -1,10 +1,47 @@ import { Injectable } from '@nestjs/common'; -import { resolveTemplate } from './utils/extract'; -import { Resource } from 'fhir/r4b'; +import { FPOptions, resolveTemplate } from './utils/extract'; +import * as fhirpath from 'fhirpath'; @Injectable() export class AppService { - resolveTemplate(qr: Resource, template: object): object { - return resolveTemplate(qr, template); + resolveTemplate( + resource: Record, + template: object, + context: Context, + model?: Model, + strict?: boolean, + ): object { + const options: FPOptions = { + userInvocationTable: { + answers: { + fn: (inputs, linkId: string) => { + return fhirpath.evaluate( + inputs, + model + ? `repeat(item).where(linkId='${linkId}').answer.value` + : `repeat(item).where(linkId='${linkId}').answer.value.children()`, + null, + model, + null, + ); + }, + arity: { 0: [], 1: ['String'] }, + }, + // Get rid of toString once it's fixed https://github.com/HL7/fhirpath.js/issues/156 + toString: { + fn: (inputs) => fhirpath.evaluate({ x: inputs }, 'x.toString()'), + arity: { 0: [] }, + }, + }, + }; + + return resolveTemplate( + resource, + template, + { root: resource, ...context }, + model, + options, + strict, + ); } } diff --git a/ts/server/src/utils/__data__/complex-example.aidbox.yaml b/ts/server/src/utils/__data__/complex-example.aidbox.yaml new file mode 100644 index 0000000..c21bd4f --- /dev/null +++ b/ts/server/src/utils/__data__/complex-example.aidbox.yaml @@ -0,0 +1,171 @@ +body: + "{% assign %}": + - patientId: "{{ %Patient.id }}" + - recordedDate: "{{ %QuestionnaireResponse.authored }}" + - observationEntries: + - "{% if answers('WEIGHT').exists() and answers('HEIGHT').exists() %}": + "{% assign %}": + - observationId: >- + {{ + %Observation.where( + id in %Provenance.target.id and + category.coding.code='vital-signs' and + resourceType='Observation' and + code.coding.code='29463-7' + ).id + }} + fullUrl: "urn:uuid:observation-weight" + request: + "{% if %observationId.exists() %}": + url: "/Observation/{{ %observationId }}" + method: PUT + "{% else %}": + url: "/Observation?patient={{ %patientId }}&category=vital-signs&code=http://loinc.org|29463-7" + method: POST + resource: + resourceType: Observation + id: "{{ %observationId }}" + subject: + resourceType: Patient + id: "{{ %patientId }}" + status: final + effective: + dateTime: "{{ %recordedDate }}" + category: + - coding: + - system: http://terminology.hl7.org/CodeSystem/observation-category + code: vital-signs + code: + coding: + - system: "http://loinc.org" + code: 29463-7 + display: Body Weight + value: + Quantity: + value: + "{% assign %}": + - rawHeight: "{{ answers('HEIGHT') }}" + - rawWeight: "{{ answers('WEIGHT') }}" + "{% if %rawHeight < 90 %}": "{{ %rawWeight / 2.205 }}" + "{% else %}": "{{ %rawWeight }}" + unit: kg + system: "http://unitsofmeasure.org" + code: kg + + - "{% if answers('HEIGHT').exists() %}": + "{% assign %}": + - observationId: >- + {{ + %Observation.where( + id in %Provenance.target.id and + category.coding.code='vital-signs' and + resourceType='Observation' and + code.coding.code='29463-7' + ).id + }} + fullUrl: "urn:uuid:observation-height" + request: + "{% if %observationId.exists() %}": + url: "/Observation/{{ %observationId }}" + method: PUT + "{% else %}": + url: "/Observation?patient={{ %patientId }}&category=vital-signs&code=http://loinc.org|8302-2" + method: POST + resource: + resourceType: Observation + id: "{{ %observationId }}" + subject: + resourceType: Patient + id: "{{ %patientId }}" + status: final + effective: + dateTime: "{{ %recordedDate }}" + category: + - coding: + - system: http://terminology.hl7.org/CodeSystem/observation-category + code: vital-signs + code: + coding: + - system: "http://loinc.org" + code: 8302-2 + display: Body Height + value: + Quantity: + value: + "{% assign %}": + - rawHeight: "{{ answers('HEIGHT') }}" + # 90 inch ~ 230cm + "{% if %rawHeight < 90 %}": "{{ %rawHeight * 2.54 }}" # inches to cm + "{% else %}": "{{ %rawHeight }}" # cm + unit: kg + system: "http://unitsofmeasure.org" + code: kg + + - conditionEntries: + "{% for index, coding in answers('MEDCOND1') | answers('MEDCOND2') %}": + "{% assign %}": + - conditionId: >- + {{ + %Condition.where(resourceType='Condition').where( + id in %Provenance.target.id and + category.coding.code='medicalHistory' + and code.coding.system=%coding.system and + code.coding.code=%coding.code + ).id + }} + fullUrl: "urn:uuid:condition-medical-history-{{ %index }}" + request: + "{% if %conditionId.exists() %}": + url: "Condition/{{ %conditionId }}" + method: PUT + "{% else %}": + url: /Condition?category=medicalHistory&code={{ %coding.system }}|{{ %coding.code }}&patient={{ %patientId }} + method: POST + resource: + resourceType: Condition + id: "{{ %conditionId }}" + subject: + resourceType: Patient + id: "{{ %patientId }}" + recordedDate: "{{ %recordedDate }}" + code: + coding: + - "{{ %coding }}" + text: "{{ %coding.display }}" + category: + - coding: + - code: medicalHistory + display: Medical history + - provenanceEntries: + "{% for entry in %observationEntries | %conditionEntries %}": + request: + url: /Provenance + method: POST + resource: + resourceType: Provenance + target: + - uri: "{{ %entry.fullUrl }}" + recorded: "{{ %recordedDate }}" + agent: + - who: + resourceType: Organization + id: "{{ %Organization.id }}" + entity: + - role: source + what: + resourceType: QuestionnaireResponse + id: "{{ %QuestionnaireResponse.id }}" + resourceType: Bundle + type: transaction + entry: + - "{% for entry in %observationEntries | %conditionEntries | %provenanceEntries %}": "{{ %entry }}" + - "{% assign %}": + - resourceIdsToDelete: + "{% for id in %Provenance.target.id.exclude((%observationEntries | %conditionEntries).resource.id) %}": "{{ %id }}" + "{% for provenance in %Provenance.where(target.id in %resourceIdsToDelete) %}": + - request: + url: "/Provenance/{{ %provenance.id }}" + method: DELETE + - request: + url: "/{{ %provenance.target.resourceType }}/{{ %provenance.target.id }}" + method: DELETE diff --git a/ts/server/src/utils/__data__/complex-example.fhir.yaml b/ts/server/src/utils/__data__/complex-example.fhir.yaml new file mode 100644 index 0000000..51e7a25 --- /dev/null +++ b/ts/server/src/utils/__data__/complex-example.fhir.yaml @@ -0,0 +1,157 @@ +body: + "{% assign %}": + - patientRef: "Patient/{{ %Patient.id }}" + - recordedDate: "{{ %QuestionnaireResponse.authored }}" + - observationEntries: + - "{% if answers('WEIGHT').exists() and answers('HEIGHT').exists() %}": + "{% assign %}": + - observationId: >- + {{ + %Observation.where( + id in %Provenance.target.id and + category.coding.code='vital-signs' and + resourceType='Observation' and + code.coding.code='29463-7' + ).id + }} + fullUrl: "urn:uuid:observation-weight" + request: + "{% if %observationId.exists() %}": + url: "/Observation/{{ %observationId }}" + method: PUT + "{% else %}": + url: "/Observation?patient={{ %patientRef }}&category=vital-signs&code=http://loinc.org|29463-7" + method: POST + resource: + resourceType: Observation + id: "{{ %observationId }}" + subject: "{{ %patientRef }}" + status: final + effectiveDateTime: "{{ %recordedDate }}" + category: + - coding: + - system: http://terminology.hl7.org/CodeSystem/observation-category + code: vital-signs + code: + coding: + - system: "http://loinc.org" + code: 29463-7 + display: Body Weight + valueQuantity: + value: + "{% assign %}": + - rawHeight: "{{ answers('HEIGHT') }}" + - rawWeight: "{{ answers('WEIGHT') }}" + "{% if %rawHeight < 90 %}": "{{ %rawWeight / 2.205 }}" + "{% else %}": "{{ %rawWeight }}" + unit: kg + system: "http://unitsofmeasure.org" + code: kg + + - "{% if answers('HEIGHT').exists() %}": + "{% assign %}": + - observationId: >- + {{ + %Observation.where( + id in %Provenance.target.id and + category.coding.code='vital-signs' and + resourceType='Observation' and + code.coding.code='29463-7' + ).id + }} + fullUrl: "urn:uuid:observation-height" + request: + "{% if %observationId.exists() %}": + url: "/Observation/{{ %observationId }}" + method: PUT + "{% else %}": + url: "/Observation?patient={{ %patientRef }}&category=vital-signs&code=http://loinc.org|8302-2" + method: POST + resource: + resourceType: Observation + id: "{{ %observationId }}" + subject: "{{ %patientRef }}" + status: final + effectiveDateTime: "{{ %recordedDate }}" + category: + - coding: + - system: http://terminology.hl7.org/CodeSystem/observation-category + code: vital-signs + code: + coding: + - system: "http://loinc.org" + code: 8302-2 + display: Body Height + valueQuantity: + value: + "{% assign %}": + - rawHeight: "{{ answers('HEIGHT') }}" + # 90 inch ~ 230cm + "{% if %rawHeight < 90 %}": "{{ %rawHeight * 2.54 }}" # inches to cm + "{% else %}": "{{ %rawHeight }}" # cm + unit: kg + system: "http://unitsofmeasure.org" + code: kg + + - conditionEntries: + "{% for index, coding in answers('MEDCOND1') | answers('MEDCOND2') %}": + "{% assign %}": + - conditionId: >- + {{ + %Condition.where( + id in %Provenance.target.id and + category.coding.code='medicalHistory' + and code.coding.system=%coding.system and + code.coding.code=%coding.code + ).id + }} + fullUrl: "urn:uuid:condition-medical-history-{{ %index }}" + request: + "{% if %conditionId.exists() %}": + url: "Condition/{{ %conditionId }}" + method: PUT + "{% else %}": + url: /Condition?category=medicalHistory&code={{ %coding.system }}|{{ %coding.code }}&patient={{ %patientRef }} + method: POST + resource: + resourceType: Condition + id: "{{ %conditionId }}" + subject: "{{ %patientRef }}" + recordedDate: "{{ %recordedDate }}" + code: + coding: + - "{{ %coding }}" + text: "{{ %coding.display }}" + category: + - coding: + - code: medicalHistory + display: Medical history + - provenanceEntries: + "{% for entry in %observationEntries | %conditionEntries %}": + request: + url: /Provenance + method: POST + resource: + resourceType: Provenance + target: + - uri: "{{ %entry.fullUrl }}" + recorded: "{{ %recordedDate }}" + agent: + - who: "Organization/{{ %Organization.id }}" + entity: + - role: source + what: "QuestionnaireResponse/{{ %QuestionnaireResponse.id }}" + resourceType: Bundle + type: transaction + entry: + - "{% for entry in %observationEntries | %conditionEntries | %provenanceEntries %}": "{{ %entry }}" + - "{% assign %}": + - resourceIdsToDelete: + "{% for id in %Provenance.target.id.exclude((%observationEntries | %conditionEntries).resource.id) %}": "{{ %id }}" + "{% for provenance in %Provenance.where(target.id in %resourceIdsToDelete) %}": + - request: + url: "/Provenance/{{ %provenance.id }}" + method: DELETE + - request: + url: "/{{ %provenance.target.resourceType }}/{{ %provenance.target.id }}" + method: DELETE diff --git a/ts/server/src/utils/complex-example.aidbox.spec.ts b/ts/server/src/utils/complex-example.aidbox.spec.ts new file mode 100644 index 0000000..39dd8f4 --- /dev/null +++ b/ts/server/src/utils/complex-example.aidbox.spec.ts @@ -0,0 +1,455 @@ +import { resolveTemplate } from './extract'; +import * as fhirpath from 'fhirpath'; +import * as yaml from 'js-yaml'; +import * as fs from 'fs'; +import * as path from 'path'; + +const template = yaml.load( + fs.readFileSync(path.join(__dirname, './__data__/complex-example.aidbox.yaml'), 'utf8'), +); +const result = { + body: { + resourceType: 'Bundle', + type: 'transaction', + entry: [ + { + fullUrl: 'urn:uuid:observation-weight', + request: { + url: '/Observation?patient=pid&category=vital-signs&code=http://loinc.org|29463-7', + method: 'POST', + }, + resource: { + resourceType: 'Observation', + id: null, + subject: { resourceType: 'Patient', id: 'pid' }, + status: 'final', + effective: { dateTime: '2024-01-01' }, + category: [ + { + coding: [ + { + system: 'http://terminology.hl7.org/CodeSystem/observation-category', + code: 'vital-signs', + }, + ], + }, + ], + code: { + coding: [ + { system: 'http://loinc.org', code: '29463-7', display: 'Body Weight' }, + ], + }, + value: { + Quantity: { + value: 100, + unit: 'kg', + system: 'http://unitsofmeasure.org', + code: 'kg', + }, + }, + }, + }, + { + fullUrl: 'urn:uuid:observation-height', + request: { + url: '/Observation?patient=pid&category=vital-signs&code=http://loinc.org|8302-2', + method: 'POST', + }, + resource: { + resourceType: 'Observation', + id: null, + subject: { resourceType: 'Patient', id: 'pid' }, + status: 'final', + effective: { dateTime: '2024-01-01' }, + category: [ + { + coding: [ + { + system: 'http://terminology.hl7.org/CodeSystem/observation-category', + code: 'vital-signs', + }, + ], + }, + ], + code: { + coding: [ + { system: 'http://loinc.org', code: '8302-2', display: 'Body Height' }, + ], + }, + value: { + Quantity: { + value: 190, + unit: 'kg', + system: 'http://unitsofmeasure.org', + code: 'kg', + }, + }, + }, + }, + { + fullUrl: 'urn:uuid:condition-medical-history-0', + request: { url: 'Condition/cond1', method: 'PUT' }, + resource: { + resourceType: 'Condition', + id: 'cond1', + subject: { resourceType: 'Patient', id: 'pid' }, + recordedDate: '2024-01-01', + code: { + coding: [ + { system: 'urn:raw', code: 'hypertension', display: 'Hypertension' }, + ], + text: 'Hypertension', + }, + category: [ + { coding: [{ code: 'medicalHistory', display: 'Medical history' }] }, + ], + }, + }, + { + fullUrl: 'urn:uuid:condition-medical-history-1', + request: { + url: '/Condition?category=medicalHistory&code=urn:raw|fatty-liver&patient=pid', + method: 'POST', + }, + resource: { + resourceType: 'Condition', + id: null, + subject: { resourceType: 'Patient', id: 'pid' }, + recordedDate: '2024-01-01', + code: { + coding: [ + { system: 'urn:raw', code: 'fatty-liver', display: 'Fatty Liver' }, + ], + text: 'Fatty Liver', + }, + category: [ + { coding: [{ code: 'medicalHistory', display: 'Medical history' }] }, + ], + }, + }, + { + fullUrl: 'urn:uuid:condition-medical-history-2', + request: { + url: '/Condition?category=medicalHistory&code=urn:raw|asthma&patient=pid', + method: 'POST', + }, + resource: { + resourceType: 'Condition', + id: null, + subject: { resourceType: 'Patient', id: 'pid' }, + recordedDate: '2024-01-01', + code: { + coding: [{ system: 'urn:raw', code: 'asthma', display: 'Asthma' }], + text: 'Asthma', + }, + category: [ + { coding: [{ code: 'medicalHistory', display: 'Medical history' }] }, + ], + }, + }, + { + request: { url: '/Provenance', method: 'POST' }, + resource: { + resourceType: 'Provenance', + target: [{ uri: 'urn:uuid:observation-weight' }], + recorded: '2024-01-01', + agent: [{ who: { resourceType: 'Organization', id: 'orgid' } }], + entity: [ + { + role: 'source', + what: { resourceType: 'QuestionnaireResponse', id: 'qrid' }, + }, + ], + }, + }, + { + request: { url: '/Provenance', method: 'POST' }, + resource: { + resourceType: 'Provenance', + target: [{ uri: 'urn:uuid:observation-height' }], + recorded: '2024-01-01', + agent: [{ who: { resourceType: 'Organization', id: 'orgid' } }], + entity: [ + { + role: 'source', + what: { resourceType: 'QuestionnaireResponse', id: 'qrid' }, + }, + ], + }, + }, + { + request: { url: '/Provenance', method: 'POST' }, + resource: { + resourceType: 'Provenance', + target: [{ uri: 'urn:uuid:condition-medical-history-0' }], + recorded: '2024-01-01', + agent: [{ who: { resourceType: 'Organization', id: 'orgid' } }], + entity: [ + { + role: 'source', + what: { resourceType: 'QuestionnaireResponse', id: 'qrid' }, + }, + ], + }, + }, + { + request: { url: '/Provenance', method: 'POST' }, + resource: { + resourceType: 'Provenance', + target: [{ uri: 'urn:uuid:condition-medical-history-1' }], + recorded: '2024-01-01', + agent: [{ who: { resourceType: 'Organization', id: 'orgid' } }], + entity: [ + { + role: 'source', + what: { resourceType: 'QuestionnaireResponse', id: 'qrid' }, + }, + ], + }, + }, + { + request: { url: '/Provenance', method: 'POST' }, + resource: { + resourceType: 'Provenance', + target: [{ uri: 'urn:uuid:condition-medical-history-2' }], + recorded: '2024-01-01', + agent: [{ who: { resourceType: 'Organization', id: 'orgid' } }], + entity: [ + { + role: 'source', + what: { resourceType: 'QuestionnaireResponse', id: 'qrid' }, + }, + ], + }, + }, + { request: { url: '/Provenance/prov-hypertension', method: 'DELETE' } }, + { request: { url: '/Condition/cond-hypertension', method: 'DELETE' } }, + ], + }, +}; + +const qr = { + resourceType: 'QuestionnaireResponse', + id: 'qrid', + authored: '2024-01-01', + item: [ + { + linkid: 'root', + item: [ + { + linkId: 'WEIGHT', + answer: { + value: { + decimal: 100, + }, + }, + }, + { + linkId: 'HEIGHT', + answer: { + value: { + decimal: 190, + }, + }, + }, + { + linkId: 'MEDCOND1', + answer: [ + { + value: { + Coding: { + system: 'urn:raw', + code: 'hypertension', + display: 'Hypertension', + }, + }, + }, + { + value: { + Coding: { + system: 'urn:raw', + code: 'fatty-liver', + display: 'Fatty Liver', + }, + }, + }, + ], + }, + { + linkId: 'MEDCOND2', + answer: [ + { + value: { + Coding: { + system: 'urn:raw', + code: 'asthma', + display: 'Asthma', + }, + }, + }, + ], + }, + ], + }, + ], +}; +const provenances = [ + { + resourceType: 'Provenance', + id: 'prov-hypertension', + target: [ + { + resourceType: 'Condition', + id: 'cond-hypertension', + }, + ], + recorded: '2024-01-01', + agent: [ + { + who: { + resourceType: 'Organization', + id: 'orgid', + }, + }, + ], + entity: [ + { + role: 'source', + what: { + resourceType: 'QuestionnaireResponse', + id: 'qrid', + }, + }, + ], + }, + { + resourceType: 'Provenance', + id: 'prov-flu', + target: [ + { + resourceType: 'Condition', + id: 'cond1', + }, + ], + recorded: '2024-01-01', + agent: [ + { + who: { + resourceType: 'Organization', + id: 'orgid', + }, + }, + ], + entity: [ + { + role: 'source', + what: { + resourceType: 'QuestionnaireResponse', + id: 'qrid', + }, + }, + ], + }, +]; +const organization = { resourceType: 'Organization', id: 'orgid' }; +const patient = { + resourceType: 'Patient', + id: 'pid', +}; +const observations = []; +const conditions = [ + { + resourceType: 'Condition', + id: 'cond1', + subject: { + resourceType: 'Patient', + id: 'pid', + }, + recordedDate: '2024-01-01', + code: { + coding: [ + { + system: 'urn:raw', + code: 'hypertension', + display: 'Hypertension', + }, + ], + text: 'Hypertension', + }, + category: [ + { + coding: [ + { + code: 'medicalHistory', + display: 'Medical history', + }, + ], + }, + ], + }, + { + resourceType: 'Condition', + id: 'cond-flu', + subject: { + resourceType: 'Patient', + id: 'pid', + }, + recordedDate: '2024-01-01', + code: { + coding: [ + { + system: 'urn:raw', + code: 'Flu', + display: 'Flu', + }, + ], + text: 'Flu', + }, + category: [ + { + coding: [ + { + code: 'medicalHistory', + display: 'Medical history', + }, + ], + }, + ], + }, +]; + +const userInvocationTable: UserInvocationTable = { + answers: { + fn: (inputs, linkId: string) => { + return fhirpath.evaluate( + inputs, + `repeat(item).where(linkId='${linkId}').answer.value.children()`, + ); + }, + arity: { 0: [], 1: ['String'] }, + }, + // Get rid of toString once it's fixed https://github.com/HL7/fhirpath.js/issues/156 + toString: { + fn: (inputs) => fhirpath.evaluate({ x: inputs }, 'x.toString()'), + arity: { 0: [] }, + }, +}; + +test('Test real example', () => { + expect( + resolveTemplate( + qr as any, + template, + { + QuestionnaireResponse: qr, + Provenance: provenances, + Organization: organization, + Observation: observations, + Condition: conditions, + Patient: patient, + }, + null, + { userInvocationTable }, + ), + ).toStrictEqual(result); +}); diff --git a/ts/server/src/utils/complex-example.fhir.spec.ts b/ts/server/src/utils/complex-example.fhir.spec.ts new file mode 100644 index 0000000..6898214 --- /dev/null +++ b/ts/server/src/utils/complex-example.fhir.spec.ts @@ -0,0 +1,401 @@ +import { resolveTemplate } from './extract'; +import * as fhirpath from 'fhirpath'; +import * as fhirpath_r4_model from 'fhirpath/fhir-context/r4'; +import * as yaml from 'js-yaml'; +import * as fs from 'fs'; +import * as path from 'path'; + +const template = yaml.load( + fs.readFileSync(path.join(__dirname, './__data__/complex-example.fhir.yaml'), 'utf8'), +); +const result = { + body: { + resourceType: 'Bundle', + type: 'transaction', + entry: [ + { + fullUrl: 'urn:uuid:observation-weight', + request: { + url: '/Observation?patient=Patient/pid&category=vital-signs&code=http://loinc.org|29463-7', + method: 'POST', + }, + resource: { + resourceType: 'Observation', + id: null, + subject: 'Patient/pid', + status: 'final', + effectiveDateTime: '2024-01-01', + category: [ + { + coding: [ + { + system: 'http://terminology.hl7.org/CodeSystem/observation-category', + code: 'vital-signs', + }, + ], + }, + ], + code: { + coding: [ + { system: 'http://loinc.org', code: '29463-7', display: 'Body Weight' }, + ], + }, + valueQuantity: { + value: 100, + unit: 'kg', + system: 'http://unitsofmeasure.org', + code: 'kg', + }, + }, + }, + { + fullUrl: 'urn:uuid:observation-height', + request: { + url: '/Observation?patient=Patient/pid&category=vital-signs&code=http://loinc.org|8302-2', + method: 'POST', + }, + resource: { + resourceType: 'Observation', + id: null, + subject: 'Patient/pid', + status: 'final', + effectiveDateTime: '2024-01-01', + category: [ + { + coding: [ + { + system: 'http://terminology.hl7.org/CodeSystem/observation-category', + code: 'vital-signs', + }, + ], + }, + ], + code: { + coding: [ + { system: 'http://loinc.org', code: '8302-2', display: 'Body Height' }, + ], + }, + valueQuantity: { + value: 190, + unit: 'kg', + system: 'http://unitsofmeasure.org', + code: 'kg', + }, + }, + }, + { + fullUrl: 'urn:uuid:condition-medical-history-0', + request: { url: 'Condition/cond1', method: 'PUT' }, + resource: { + resourceType: 'Condition', + id: 'cond1', + subject: 'Patient/pid', + recordedDate: '2024-01-01', + code: { + coding: [ + { system: 'urn:raw', code: 'hypertension', display: 'Hypertension' }, + ], + text: 'Hypertension', + }, + category: [ + { coding: [{ code: 'medicalHistory', display: 'Medical history' }] }, + ], + }, + }, + { + fullUrl: 'urn:uuid:condition-medical-history-1', + request: { + url: '/Condition?category=medicalHistory&code=urn:raw|fatty-liver&patient=Patient/pid', + method: 'POST', + }, + resource: { + resourceType: 'Condition', + id: null, + subject: 'Patient/pid', + recordedDate: '2024-01-01', + code: { + coding: [ + { system: 'urn:raw', code: 'fatty-liver', display: 'Fatty Liver' }, + ], + text: 'Fatty Liver', + }, + category: [ + { coding: [{ code: 'medicalHistory', display: 'Medical history' }] }, + ], + }, + }, + { + fullUrl: 'urn:uuid:condition-medical-history-2', + request: { + url: '/Condition?category=medicalHistory&code=urn:raw|asthma&patient=Patient/pid', + method: 'POST', + }, + resource: { + resourceType: 'Condition', + id: null, + subject: 'Patient/pid', + recordedDate: '2024-01-01', + code: { + coding: [{ system: 'urn:raw', code: 'asthma', display: 'Asthma' }], + text: 'Asthma', + }, + category: [ + { coding: [{ code: 'medicalHistory', display: 'Medical history' }] }, + ], + }, + }, + { + request: { url: '/Provenance', method: 'POST' }, + resource: { + resourceType: 'Provenance', + target: [{ uri: 'urn:uuid:observation-weight' }], + recorded: '2024-01-01', + agent: [{ who: 'Organization/orgid' }], + entity: [{ role: 'source', what: 'QuestionnaireResponse/qrid' }], + }, + }, + { + request: { url: '/Provenance', method: 'POST' }, + resource: { + resourceType: 'Provenance', + target: [{ uri: 'urn:uuid:observation-height' }], + recorded: '2024-01-01', + agent: [{ who: 'Organization/orgid' }], + entity: [{ role: 'source', what: 'QuestionnaireResponse/qrid' }], + }, + }, + { + request: { url: '/Provenance', method: 'POST' }, + resource: { + resourceType: 'Provenance', + target: [{ uri: 'urn:uuid:condition-medical-history-0' }], + recorded: '2024-01-01', + agent: [{ who: 'Organization/orgid' }], + entity: [{ role: 'source', what: 'QuestionnaireResponse/qrid' }], + }, + }, + { + request: { url: '/Provenance', method: 'POST' }, + resource: { + resourceType: 'Provenance', + target: [{ uri: 'urn:uuid:condition-medical-history-1' }], + recorded: '2024-01-01', + agent: [{ who: 'Organization/orgid' }], + entity: [{ role: 'source', what: 'QuestionnaireResponse/qrid' }], + }, + }, + { + request: { url: '/Provenance', method: 'POST' }, + resource: { + resourceType: 'Provenance', + target: [{ uri: 'urn:uuid:condition-medical-history-2' }], + recorded: '2024-01-01', + agent: [{ who: 'Organization/orgid' }], + entity: [{ role: 'source', what: 'QuestionnaireResponse/qrid' }], + }, + }, + { request: { url: '/Provenance/prov-hypertension', method: 'DELETE' } }, + { request: { url: '/Condition/cond-hypertension', method: 'DELETE' } }, + ], + }, +}; + +const qr = { + resourceType: 'QuestionnaireResponse', + id: 'qrid', + authored: '2024-01-01', + item: [ + { + linkid: 'root', + item: [ + { + linkId: 'WEIGHT', + answer: { + valueDecimal: 100, + }, + }, + { + linkId: 'HEIGHT', + answer: { + valueDecimal: 190, + }, + }, + { + linkId: 'MEDCOND1', + answer: [ + { + valueCoding: { + system: 'urn:raw', + code: 'hypertension', + display: 'Hypertension', + }, + }, + { + valueCoding: { + system: 'urn:raw', + code: 'fatty-liver', + display: 'Fatty Liver', + }, + }, + ], + }, + { + linkId: 'MEDCOND2', + answer: [ + { + valueCoding: { + system: 'urn:raw', + code: 'asthma', + display: 'Asthma', + }, + }, + ], + }, + ], + }, + ], +}; +const provenances = [ + { + resourceType: 'Provenance', + id: 'prov-hypertension', + target: [ + { + resourceType: 'Condition', + id: 'cond-hypertension', + }, + ], + recorded: '2024-01-01', + agent: [ + { + who: 'Organization/orgid', + }, + ], + entity: [ + { + role: 'source', + what: 'QuestionnaireResponse/qrid', + }, + ], + }, + { + resourceType: 'Provenance', + id: 'prov-flu', + target: [ + { + resourceType: 'Condition', + id: 'cond1', + }, + ], + recorded: '2024-01-01', + agent: [ + { + who: 'Organization/orgid', + }, + ], + entity: [ + { + role: 'source', + what: 'QuestionnaireResponse/qrid', + }, + ], + }, +]; +const observations = []; +const conditions = [ + { + resourceType: 'Condition', + id: 'cond-flu', + subject: 'Patient/pid', + recordedDate: '2024-01-01', + code: { + coding: [ + { + system: 'urn:raw', + code: 'Flu', + display: 'Flu', + }, + ], + text: 'Flu', + }, + category: [ + { + coding: [ + { + code: 'medicalHistory', + display: 'Medical history', + }, + ], + }, + ], + }, + { + resourceType: 'Condition', + id: 'cond1', + subject: 'Patient/pid', + recordedDate: '2024-01-01', + code: { + coding: [ + { + system: 'urn:raw', + code: 'hypertension', + display: 'Hypertension', + }, + ], + text: 'Hypertension', + }, + category: [ + { + coding: [ + { + code: 'medicalHistory', + display: 'Medical history', + }, + ], + }, + ], + }, +]; +const organization = { resourceType: 'Organization', id: 'orgid' }; +const patient = { + resourceType: 'Patient', + id: 'pid', +}; + +const userInvocationTable: UserInvocationTable = { + answers: { + fn: (inputs, linkId: string) => { + return fhirpath.evaluate( + inputs, + `repeat(item).where(linkId='${linkId}').answer.value`, + {}, + fhirpath_r4_model, + ); + }, + arity: { 0: [], 1: ['String'] }, + }, + // Get rid of toString once it's fixed https://github.com/HL7/fhirpath.js/issues/156 + toString: { + fn: (inputs) => fhirpath.evaluate({ x: inputs }, 'x.toString()'), + arity: { 0: [] }, + }, +}; + +test('Test real example', () => { + expect( + resolveTemplate( + qr as any, + template, + { + QuestionnaireResponse: qr, + Provenance: provenances, + Condition: conditions, + Observation: observations, + Organization: organization, + Patient: patient, + }, + fhirpath_r4_model, + { userInvocationTable }, + ), + ).toStrictEqual(result); +}); diff --git a/ts/server/src/utils/example1.fhir.spec.ts b/ts/server/src/utils/example1.fhir.spec.ts new file mode 100644 index 0000000..1eb48f9 --- /dev/null +++ b/ts/server/src/utils/example1.fhir.spec.ts @@ -0,0 +1,157 @@ +import { QuestionnaireResponse } from 'fhir/r4b'; +import { resolveTemplate } from './extract'; +import * as fhirpath_r4_model from 'fhirpath/fhir-context/r4'; + +const qr: QuestionnaireResponse = { + resourceType: 'QuestionnaireResponse', + status: 'completed', + item: [ + { + linkId: 'name', + item: [ + { + linkId: 'last-name', + answer: [ + { + valueString: 'Beda', + }, + ], + }, + { + linkId: 'first-name', + answer: [ + { + valueString: 'Ilya', + }, + ], + }, + { + linkId: 'middle-name', + answer: [ + { + valueString: 'Alekseevich', + }, + ], + }, + ], + }, + { + linkId: 'birth-date', + answer: [ + { + valueDate: '2023-05-01', + }, + ], + }, + { + linkId: 'gender', + answer: [ + { + valueCoding: { code: 'male' }, + }, + ], + }, + { + linkId: 'ssn', + answer: [ + { + valueString: '123', + }, + ], + }, + { + linkId: 'mobile', + answer: [ + { + valueString: '11231231231', + }, + ], + }, + ], +}; + +const template1 = { + resourceType: 'Patient', + name: [ + { + family: "{{ QuestionnaireResponse.repeat(item).where(linkId='last-name').answer.value }}", + given: [ + "{{ QuestionnaireResponse.repeat(item).where(linkId='first-name').answer.value }}", + "{{ QuestionnaireResponse.repeat(item).where(linkId='middle-name').answer.value }}", + ], + }, + ], + birthDate: "{{ QuestionnaireResponse.item.where(linkId='birth-date').answer.value }}", + gender: "{{ QuestionnaireResponse.item.where(linkId='gender').answer.valueCoding.code }}", + telecom: [ + { + system: 'phone', + value: "{{ QuestionnaireResponse.item.where(linkId='mobile').answer.value }}", + }, + ], + identifier: [ + { + system: 'http://hl7.org/fhir/sid/us-ssn', + value: "{{ QuestionnaireResponse.item.where(linkId='ssn').answer.value }}", + }, + ], +}; + +const template2 = { + resourceType: 'Patient', + name: { + "{{ QuestionnaireResponse.item.where(linkId='name') }}": { + family: "{{ item.where(linkId='last-name').answer.valueString }}", + given: [ + "{{ item.where(linkId='first-name').answer.valueString }}", + "{{ item.where(linkId='middle-name').answer.valueString }}", + ], + }, + }, + birthDate: "{{ QuestionnaireResponse.item.where(linkId='birth-date').answer.value }}", + gender: "{{ QuestionnaireResponse.item.where(linkId='gender').answer.valueCoding.code }}", + telecom: [ + { + system: 'phone', + value: "{{ QuestionnaireResponse.item.where(linkId='mobile').answer.value }}", + }, + ], + identifier: [ + { + system: 'http://hl7.org/fhir/sid/us-ssn', + value: "{{ QuestionnaireResponse.item.where(linkId='ssn').answer.value }}", + }, + ], +}; + +const result = { + birthDate: '2023-05-01', + gender: 'male', + identifier: [ + { + system: 'http://hl7.org/fhir/sid/us-ssn', + value: '123', + }, + ], + name: [ + { + family: 'Beda', + given: ['Ilya', 'Alekseevich'], + }, + ], + resourceType: 'Patient', + telecom: [ + { + system: 'phone', + value: '11231231231', + }, + ], +}; + +test('Simple transformation', () => { + expect(resolveTemplate(qr, template1, {}, fhirpath_r4_model)).toStrictEqual(result); +}); + +test('List transformation', () => { + expect(resolveTemplate(qr, template2, {}, fhirpath_r4_model)).toStrictEqual(result); +}); diff --git a/ts/server/src/utils/extract.spec.ts b/ts/server/src/utils/extract.spec.ts index c085a0d..52f1ae5 100644 --- a/ts/server/src/utils/extract.spec.ts +++ b/ts/server/src/utils/extract.spec.ts @@ -1,184 +1,267 @@ -import { QuestionnaireResponse } from 'fhir/r4b'; -import { embededFHIRPath, resolveTemplate } from './extract'; - -const qr: QuestionnaireResponse = { - resourceType: 'QuestionnaireResponse', - status: 'completed', - item: [ - { - linkId: 'name', - item: [ - { - linkId: 'last-name', - answer: [ - { - valueString: 'Beda', - }, - ], - }, - { - linkId: 'first-name', - answer: [ - { - valueString: 'Ilya', - }, - ], - }, +import { FPMLValidationError, resolveTemplate } from './extract'; + +describe('Transformation', () => { + const resource = { list: [{ key: 1 }, { key: 2 }, { key: 3 }] } as any; + + test('fails on access props of resource in strict mode', () => { + expect(() => + resolveTemplate(resource, { key: '{{ list }}' }, {}, null, null, true), + ).toThrow(FPMLValidationError); + }); + + test('for empty object return empty object', () => { + expect(resolveTemplate(resource, {})).toStrictEqual({}); + }); + + test('for empty array return empty array', () => { + expect(resolveTemplate(resource, [])).toStrictEqual([]); + }); + + test('for array of arrays returns flattened array', () => { + expect( + resolveTemplate(resource, [ + [1, 2, 3], + [4, 5, 6], + ]), + ).toStrictEqual([1, 2, 3, 4, 5, 6]); + }); + + test('for array with nulls returns compacted array', () => { + expect(resolveTemplate(resource, [[1, null, 2, null, 3]])).toStrictEqual([1, 2, 3]); + }); + + test('for object with null keys returns null keys', () => { + expect(resolveTemplate(resource, { key: null })).toStrictEqual({ key: null }); + }); + + test('for object with non-null keys returns non-null keys', () => { + expect(resolveTemplate(resource, { key: 1 })).toStrictEqual({ key: 1 }); + }); + + test('for array of objects returns original array', () => { + expect(resolveTemplate(resource, [{ list: [1, 2, 3] }, { list: [4, 5, 6] }])).toStrictEqual( + [{ list: [1, 2, 3] }, { list: [4, 5, 6] }], + ); + }); + + test('for null returns null', () => { + expect(resolveTemplate(resource, null)).toStrictEqual(null); + }); + + test('for constant string returns constant string', () => { + expect(resolveTemplate(resource, 'string')).toStrictEqual('string'); + }); + + test('for constant number returns constant number', () => { + expect(resolveTemplate(resource, 1)).toStrictEqual(1); + }); + + test('for false returns false', () => { + expect(resolveTemplate(resource, false)).toStrictEqual(false); + }); + + test('for true returns true', () => { + expect(resolveTemplate(resource, true)).toStrictEqual(true); + }); + + test('for non-empty array expression return first element', () => { + expect(resolveTemplate(resource, '{{ list }}')).toStrictEqual({ key: 1 }); + }); + + test('for empty array expression returns null', () => { + expect(resolveTemplate(resource, '{{ list.where($this = 0) }}')).toStrictEqual(null); + }); + + test('for empty array droppable expression returns undefined', () => { + expect(resolveTemplate(resource, '{{- list.where($this = 0) -}}')).toStrictEqual(undefined); + }); + + test('for template expression returns resolved template', () => { + expect( + resolveTemplate(resource, '/{{ list[0].key }}/{{ list[1].key }}/{{ list[2].key }}'), + ).toStrictEqual('/1/2/3'); + }); + + test('for empty array template expression returns null', () => { + expect( + resolveTemplate( + resource, + '/Patient/{{ list.where($this = 0) }}/_history/{{ list.last() }}', + ), + ).toStrictEqual(null); + }); + + test('for empty array droppable template expression returns undefined', () => { + expect( + resolveTemplate( + resource, + '/Patient/{{- list.where($this = 0) -}}/_history/{{ list.last() }}', + ), + ).toStrictEqual(undefined); + }); + + test('for multiline template works properly', () => { + expect(resolveTemplate(resource, '{{\nlist.where(\n$this.key=1\n).key\n}}')).toStrictEqual( + 1, + ); + }); + + test('fails with incorrect fhirpath expression', () => { + expect(() => resolveTemplate({} as any, "{{ item.where(linkId='a) }}")).toThrowError( + FPMLValidationError, + ); + }); +}); + +describe('Context block', () => { + const resource: any = { + foo: 'bar', + list: [{ key: 'a' }, { key: 'b' }, { key: 'c' }], + }; + + test('passes result as resource', () => { + expect( + resolveTemplate( + resource, { - linkId: 'middle-name', - answer: [ - { - valueString: 'Alekseevich', + list: { + '{{ list }}': { + key: '{{ key }}', + foo: '{{ %root.foo }}', }, - ], - }, - ], - }, - { - linkId: 'birth-date', - answer: [ - { - valueDate: '2023-05-01', - }, - ], - }, - { - linkId: 'gender', - answer: [ - { - valueCoding: { code: 'male' }, - }, - ], - }, - { - linkId: 'ssn', - answer: [ - { - valueString: '123', + }, }, + { root: resource }, + ), + ).toStrictEqual({ + list: [ + { key: 'a', foo: 'bar' }, + { key: 'b', foo: 'bar' }, + { key: 'c', foo: 'bar' }, ], - }, - { - linkId: 'mobile', - answer: [ - { - valueString: '11231231231', + }); + }); +}); + +describe('Assign block', () => { + const resource = { + resourceType: 'Resource', + sourceValue: 100, + } as any; + + test('works with single var as object', () => { + expect( + resolveTemplate(resource, { + '{% assign %}': { var: 100 }, + value: '{{ %var }}', + }), + ).toStrictEqual({ + value: 100, + }); + }); + + test('works with multiple vars as array of objects', () => { + expect( + resolveTemplate(resource, { + '{% assign %}': [{ varA: 100 }, { varB: '{{ %varA + 100}}' }], + valueA: '{{ %varA }}', + valueB: '{{ %varB }}', + }), + ).toStrictEqual({ + valueA: 100, + valueB: 200, + }); + }); + + test('has isolated nested context', () => { + expect( + resolveTemplate(resource, { + '{% assign %}': { varC: 100 }, + nested: { + '{% assign %}': { varC: 200 }, + valueC: '{{ %varC }}', }, - ], - }, - ], -}; - -const template1 = { - resourceType: 'Patient', - name: [ - { - family: "{{ QuestionnaireResponse.repeat(item).where(linkId='last-name').answer.value }}", - given: [ - "{{ QuestionnaireResponse.repeat(item).where(linkId='first-name').answer.value }}", - "{{ QuestionnaireResponse.repeat(item).where(linkId='middle-name').answer.value }}", - ], - }, - ], - birthDate: "{{ QuestionnaireResponse.item.where(linkId='birth-date').answer.value }}", - gender: "{{ QuestionnaireResponse.item.where(linkId='gender').answer.valueCoding.code }}", - telecom: [ - { - system: 'phone', - value: "{{ QuestionnaireResponse.item.where(linkId='mobile').answer.value }}", - }, - ], - identifier: [ - { - system: 'http://hl7.org/fhir/sid/us-ssn', - value: "{{ QuestionnaireResponse.item.where(linkId='ssn').answer.value }}", - }, - ], -}; - -const template2 = { - resourceType: 'Patient', - name: { - "{{ QuestionnaireResponse.item.where(linkId='name') }}": { - family: "{{ item.where(linkId='last-name').answer.valueString }}", - given: [ - "{{ item.where(linkId='first-name').answer.valueString }}", - "{{ item.where(linkId='middle-name').answer.valueString }}", - ], - }, - }, - birthDate: "{{ QuestionnaireResponse.item.where(linkId='birth-date').answer.value }}", - gender: "{{ QuestionnaireResponse.item.where(linkId='gender').answer.valueCoding.code }}", - telecom: [ - { - system: 'phone', - value: "{{ QuestionnaireResponse.item.where(linkId='mobile').answer.value }}", - }, - ], - identifier: [ - { - system: 'http://hl7.org/fhir/sid/us-ssn', - value: "{{ QuestionnaireResponse.item.where(linkId='ssn').answer.value }}", - }, - ], -}; - -const result = { - birthDate: '2023-05-01', - gender: 'male', - identifier: [ - { - system: 'http://hl7.org/fhir/sid/us-ssn', - value: '123', - }, - ], - name: [ - { - family: 'Beda', - given: ['Ilya', 'Alekseevich'], - }, - ], - resourceType: 'Patient', - telecom: [ - { - system: 'phone', - value: '11231231231', - }, - ], -}; - -describe('Extraction', () => { - test('Simple transformation', () => { - expect(resolveTemplate(qr, template1)).toStrictEqual(result); - }); - test('List transformation', () => { - expect(resolveTemplate(qr, template2)).toStrictEqual(result); - }); - test('Partial strings', () => { + valueC: '{{ %varC }}', + }), + ).toStrictEqual({ + valueC: 100, + nested: { + valueC: 200, + }, + }); + }); + + test('works properly in full example', () => { expect( - resolveTemplate( - { - resourceType: 'Patient', - id: 'foo', + resolveTemplate(resource, { + '{% assign %}': [ + { + varA: { + '{% assign %}': [ + { + varX: '{{ Resource.sourceValue.first() }}', + }, + ], + + x: '{{ %varX }}', + }, + }, + { varB: '{{ %varA.x + 1 }}' }, + { varC: 0 }, + ], + nested: { + '{% assign %}': { varC: '{{ %varA.x + %varB }}' }, + valueA: '{{ %varA }}', + valueB: '{{ %varB }}', + valueC: '{{ %varC }}', }, - { reference: 'Patient/{{Patient.id}}' }, - ), - ).toStrictEqual({ reference: 'Patient/foo' }); + valueA: '{{ %varA }}', + valueB: '{{ %varB }}', + valueC: '{{ %varC }}', + }), + ).toStrictEqual({ + valueA: { x: 100 }, + valueB: 101, + valueC: 0, + + nested: { + valueA: { x: 100 }, + valueB: 101, + valueC: 201, + }, + }); }); -}); -describe('Partial expression', () => { - test('Search partial expression', () => { - const { before, after, expression } = embededFHIRPath('Patient/{{Patient.id}}'); + test('fails with multiple keys in object', () => { + expect(() => + resolveTemplate(resource, { + '{% assign %}': { varA: 100, varB: 200 }, + value: '{{ %var }}', + }), + ).toThrowError(FPMLValidationError); + }); + + test('fails with multiple keys in array of objects', () => { + expect(() => + resolveTemplate(resource, { + '{% assign %}': [{ varA: 100, varB: 200 }], + value: '{{ %var }}', + }), + ).toThrowError(FPMLValidationError); + }); - expect(before).toBe('Patient/'); - expect(expression).toBe('Patient.id'); - expect(after).toBe(''); + test('fails with non-array and non-object as value', () => { + expect(() => + resolveTemplate(resource, { + '{% assign %}': 1, + value: '{{ %var }}', + }), + ).toThrowError(FPMLValidationError); }); }); -describe('Context usage', () => { - test('use context', () => { +describe('For block', () => { + test('works properly in full example', () => { expect( resolveTemplate( { @@ -186,20 +269,329 @@ describe('Context usage', () => { list: [{ key: 'a' }, { key: 'b' }, { key: 'c' }], } as any, { - list: { - '{{ list }}': { - key: '{{ key }}', - foo: '{{ %root.foo }}', + listArr: [ + { + '{% for index, item in list %}': { + key: '{{ %item.key }}', + foo: '{{ foo }}', + index: '{{ %index }}', + }, + }, + { + '{% for item in list %}': { + key: '{{ %item.key }}', + foo: '{{ foo }}', + }, + }, + ], + listObj: { + '{% for item in list %}': { + key: '{{ %item.key }}', + foo: '{{ foo }}', }, }, }, ), ).toStrictEqual({ - list: [ + listArr: [ + { key: 'a', foo: 'bar', index: 0 }, + { key: 'b', foo: 'bar', index: 1 }, + { key: 'c', foo: 'bar', index: 2 }, + { key: 'a', foo: 'bar' }, + { key: 'b', foo: 'bar' }, + { key: 'c', foo: 'bar' }, + ], + listObj: [ { key: 'a', foo: 'bar' }, { key: 'b', foo: 'bar' }, { key: 'c', foo: 'bar' }, ], }); }); + + test('has context from local assign block', () => { + expect( + resolveTemplate({} as any, { + '{% assign %}': { + localList: [{ key: 'a' }, { key: 'b' }, { key: 'c' }], + }, + listArr: [ + { + '{% for item in %localList %}': { + key: '{{ %item.key }}', + }, + }, + ], + }), + ).toStrictEqual({ + listArr: [{ key: 'a' }, { key: 'b' }, { key: 'c' }], + }); + }); + + test('fails with other keys passed', () => { + expect(() => + resolveTemplate({ list: [1, 2, 3] } as any, { + userKey: 1, + '{% for key in %list %}': '{{ %key }}', + }), + ).toThrowError(FPMLValidationError); + }); +}); + +describe('If block', () => { + const resource: any = { + key: 'value', + }; + + test('returns if branch for truthy condition at root level', () => { + expect( + resolveTemplate(resource, { + "{% if key = 'value' %}": { nested: "{{ 'true' + key }}" }, + '{% else %}': { nested: "{{ 'false' + key }}" }, + }), + ).toStrictEqual({ + nested: 'truevalue', + }); + }); + + test('returns if branch for truthy condition', () => { + expect( + resolveTemplate(resource, { + result: { + "{% if key = 'value' %}": { nested: "{{ 'true' + key }}" }, + '{% else %}': { nested: "{{ 'false' + key }}" }, + }, + }), + ).toStrictEqual({ + result: { nested: 'truevalue' }, + }); + }); + + test('returns if branch for truthy condition without else branch', () => { + expect( + resolveTemplate(resource, { + result: { + "{% if key = 'value' %}": { nested: "{{ 'true' + key }}" }, + }, + }), + ).toStrictEqual({ + result: { nested: 'truevalue' }, + }); + }); + + test('returns else branch for falsy condition', () => { + expect( + resolveTemplate(resource, { + result: { + "{% if key != 'value' %}": { nested: "{{ 'true' + key }}" }, + '{% else %}': { nested: "{{ 'false' + key }}" }, + }, + }), + ).toStrictEqual({ + result: { nested: 'falsevalue' }, + }); + }); + + test('returns null for falsy condition without else branch', () => { + expect( + resolveTemplate(resource, { + result: { + "{% if key != 'value' %}": { nested: "{{ 'true' + key }}" }, + }, + }), + ).toStrictEqual({ + result: null, + }); + }); + + test('returns if branch for nested if', () => { + expect( + resolveTemplate(resource, { + result: { + "{% if key = 'value' %}": { + "{% if key = 'value' %}": 'value', + }, + }, + }), + ).toStrictEqual({ + result: 'value', + }); + }); + + test('returns else branch for nested else', () => { + expect( + resolveTemplate(resource, { + result: { + "{% if key != 'value' %}": null, + '{% else %}': { + "{% if key != 'value' %}": null, + '{% else %}': 'value', + }, + }, + }), + ).toStrictEqual({ + result: 'value', + }); + }); + + test('implicitly merges with null returned', () => { + expect( + resolveTemplate(resource, { + result: { + myKey: 1, + "{% if key = 'value' %}": null, + }, + }), + ).toStrictEqual({ + result: { + myKey: 1, + }, + }); + }); + + test('implicitly merges with object returned for truthy condition', () => { + expect( + resolveTemplate(resource, { + result: { + myKey: 1, + "{% if key = 'value' %}": { + anotherKey: 2, + }, + }, + }), + ).toStrictEqual({ + result: { + myKey: 1, + anotherKey: 2, + }, + }); + }); + + test('implicitly merges with object returned for falsy condition', () => { + expect( + resolveTemplate(resource, { + result: { + myKey: 1, + "{% if key != 'value' %}": { + anotherKey: 2, + }, + '{% else %}': { + anotherKey: 3, + }, + }, + }), + ).toStrictEqual({ + result: { + myKey: 1, + anotherKey: 3, + }, + }); + }); + + test('fails on implicit merge with non-object returned from if branch', () => { + expect(() => + resolveTemplate(resource, { + result: { + myKey: 1, + "{% if key = 'value' %}": [], + }, + }), + ).toThrow(FPMLValidationError); + }); + + test('fails on implicit merge with non-object returned from else branch', () => { + expect(() => + resolveTemplate(resource, { + result: { + myKey: 1, + "{% if key != 'value' %}": {}, + '{% else %}': [], + }, + }), + ).toThrow(FPMLValidationError); + }); + + test('fails on multiple if blocks', () => { + expect(() => + resolveTemplate(resource, { + result: { + myKey: 1, + "{% if key != 'value' %}": {}, + "{% if key = 'value' %}": {}, + }, + }), + ).toThrow(FPMLValidationError); + }); + + test('fails on multiple else blocks', () => { + expect(() => + resolveTemplate(resource, { + result: { + myKey: 1, + "{% if key != 'value' %}": {}, + '{% else %}': {}, + '{% else %}': {}, + }, + }), + ).toThrow(FPMLValidationError); + }); + + test('fails on else block without if block', () => { + expect(() => + resolveTemplate(resource, { + result: { + myKey: 1, + '{% else %}': {}, + }, + }), + ).toThrow(FPMLValidationError); + }); +}); + +describe('Merge block', () => { + const resource: any = { + key: 'value', + }; + + test('implicitly merges into the node', () => { + expect( + resolveTemplate(resource, { + b: 1, + '{% merge %}': { a: 1 }, + }), + ).toStrictEqual({ + b: 1, + a: 1, + }); + }); + + test('merges multiple blocks within order', () => { + expect( + resolveTemplate(resource, { + '{% merge %}': [{ a: 1 }, { b: 2 }, { a: 3 }], + }), + ).toStrictEqual({ + a: 3, + b: 2, + }); + }); + + test('merges multiple blocks containing nulls', () => { + expect( + resolveTemplate(resource, { + '{% merge %}': [{ a: 1 }, null, { b: 2 }], + }), + ).toStrictEqual({ + a: 1, + b: 2, + }); + }); + + test('fails on merge with non-object', () => { + expect(() => + resolveTemplate(resource, { + '{% merge %}': [1, 2], + }), + ).toThrow(FPMLValidationError); + }); }); diff --git a/ts/server/src/utils/extract.ts b/ts/server/src/utils/extract.ts index 1215f3e..c994d1e 100644 --- a/ts/server/src/utils/extract.ts +++ b/ts/server/src/utils/extract.ts @@ -1,101 +1,443 @@ -import { Resource } from 'fhir/r4b'; import * as fhirpath from 'fhirpath'; -import * as fhirpath_r4_model from 'fhirpath/fhir-context/r4'; -interface Embeded { - before: string; - after: string; - expression: string; -} +type Resource = Record; +type Path = Array; -export function embededFHIRPath(a: string): Embeded | undefined { - const start = a.search('{{'); - const stop = a.search('}}'); - if (start === -1 || stop === -1) { - return undefined; - } +// TODO: looks a bit hacky to use extra node here +// TODO: I believe it might be re-written without using it +const rootNodeKey = '__rootNode__'; - const before = a.slice(0, start); - const after = a.slice(stop + 2); - const expression = a.slice(start + 2, stop); - return { - before, - expression, - after, - }; +export interface FPOptions { + userInvocationTable?: UserInvocationTable; } -export function resolveTemplate(qr: Resource, template: object): object { - return resolveTemplateRecur(qr, { root: qr }, template); +export class FPMLValidationError extends Error { + constructor(message: string, path: Path) { + const pathStr = path.filter((x) => x != rootNodeKey).join('.'); + super(`${message}. Path '${pathStr}'`); + } } -function resolveTemplateRecur(qr: Resource, context: object, template: object): object { - return iterateObject(template, (a) => { - if (typeof a === 'object' && Object.keys(a).length == 1) { - const key = Object.keys(a)[0]!; - const embeded = embededFHIRPath(key); - if (embeded) { - const { expression: keyExpr } = embeded; - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const result: any[] = []; - const answers = fhirpath.evaluate(qr, keyExpr, context, fhirpath_r4_model); - for (const c of answers) { - result.push(resolveTemplateRecur(c, context, a[key])); - } - return result; - } else { - return a; + +const guardedResource = new Proxy( + {}, + { + get: (obj, prop) => { + if (prop === '__path__' || prop === 'resourceType') { + return undefined; } - } else if (typeof a === 'string') { - const embeded = embededFHIRPath(a); - if (embeded) { - const result = fhirpath.evaluate( - qr, - embeded.expression, + + throw new Error( + `Forbidden access to resource property ${String( + prop, + )} in strict mode. Use context instead`, + ); + }, + }, +); + +export function resolveTemplate( + resource: Resource, + template: any, + context?: Context, + model?: Model, + fpOptions?: FPOptions, + strict?: boolean, +): any { + return resolveTemplateRecur( + [], + strict ? guardedResource : resource, + template, + context, + model, + fpOptions, + ); +} + +function resolveTemplateRecur( + startPath: Path, + resource: Resource, + template: any, + initialContext?: Context, + model?: Model, + fpOptions?: FPOptions, +): any { + return iterateObject( + startPath, + { [rootNodeKey]: template }, + initialContext ?? {}, + (path, node, context) => { + if (isPlainObject(node)) { + const { node: newNode, context: newContext } = processAssignBlock( + path, + resource, + node, context, - fhirpath_r4_model, - )[0]; - if (embeded.before || embeded.after) { - return `${embeded.before}${result}${embeded.after}`; - } else { - return result; + model, + fpOptions, + ); + const matchers = [ + processContextBlock, + processMergeBlock, + processForBlock, + processIfBlock, + ]; + for (const matcher of matchers) { + const result = matcher(path, resource, newNode, newContext, model, fpOptions); + + if (result) { + return { node: result.node, context: newContext }; + } } - } else { - return a; + + return { node: newNode, context: newContext }; + } else if (typeof node === 'string') { + return { + node: processTemplateString(path, resource, node, context, model, fpOptions), + context, + }; } - } - return a; - }); + + return { node, context }; + }, + )[rootNodeKey]; } -// eslint-disable-next-line @typescript-eslint/no-explicit-any -type Transform = (a: any) => any; +function processTemplateString( + path: Path, + resource: Resource, + node: string, + context: Context, + model: Model, + fpOptions: FPOptions, +) { + const templateRegExp = /{{-?\s*([\s\S]+?)\s*-?}}/g; + let match: + | RegExpExecArray + | { [Symbol.replace](string: string, replaceValue: string): string }[]; + let result: any = node; -// eslint-disable-next-line @typescript-eslint/no-explicit-any -function iterateObject(obj: object, transform: Transform): any { - if (Array.isArray(obj)) { - const transformedArray = []; - for (let i = 0; i < obj.length; i++) { - const value = obj[i]; - if (typeof value === 'object') { - transformedArray.push(iterateObject(transform(value), transform)); - } else { - transformedArray.push(transform(value)); + while ((match = templateRegExp.exec(node)) !== null) { + const expr = match[1]; + const replacement = + evaluateExpression(path, resource, expr, context, model, fpOptions)[0] ?? null; + + if (replacement === null) { + if (match[0].startsWith('{{-')) { + return undefined; } + + return null; } - return transformedArray; - } else if (typeof obj === 'object') { - const transformedObject = {}; - for (const key in obj) { - // eslint-disable-next-line no-prototype-builtins - if (obj.hasOwnProperty(key)) { - const value = obj[key]; - if (typeof value === 'object') { - transformedObject[key] = iterateObject(transform(value), transform); - } else { - transformedObject[key] = transform(value); + + if (match[0] === node) { + return replacement; + } + + result = result.replace(match[0], replacement); + } + + return result; +} + +function processAssignBlock( + path: Path, + resource: Resource, + node: any, + context: Context, + model: Model, + fpOptions: FPOptions, +): { node: any; context: Context } { + const extendedContext = { ...context }; + const keys = Object.keys(node); + + const assignRegExp = /{%\s*assign\s*%}/; + const assignKey = keys.find((k) => k.match(assignRegExp)); + if (assignKey) { + if (Array.isArray(node[assignKey])) { + node[assignKey].forEach((obj) => { + if (Object.keys(obj).length !== 1) { + throw new FPMLValidationError( + 'Assign block must accept only one key per object', + path, + ); } + + Object.entries( + resolveTemplateRecur(path, resource, obj, extendedContext, model, fpOptions), + ).forEach(([key, value]) => { + extendedContext[key] = value; + }); + }); + } else if (isPlainObject(node[assignKey])) { + if (Object.keys(node[assignKey]).length !== 1) { + throw new FPMLValidationError( + 'Assign block must accept only one key per object', + path, + ); } + Object.entries( + resolveTemplateRecur( + path, + resource, + node[assignKey], + extendedContext, + model, + fpOptions, + ), + ).forEach(([key, value]) => { + extendedContext[key] = value; + }); + } else { + throw new FPMLValidationError('Assign block must accept array or object', path); } - return transformedObject; + + return { node: omitKey(node, assignKey), context: extendedContext }; + } + + return { node, context }; +} + +function processMergeBlock( + path: Path, + resource: Resource, + node: any, + context: Context, + model: Model, + fpOptions: FPOptions, +): { node: any } | undefined { + const keys = Object.keys(node); + + const mergeRegExp = /{%\s*merge\s*%}/; + const mergeKey = keys.find((k) => k.match(mergeRegExp)); + + if (mergeKey) { + return { + node: (Array.isArray(node[mergeKey]) ? node[mergeKey] : [node[mergeKey]]).reduce( + (mergeAcc, nodeValue) => { + const result = resolveTemplateRecur( + path, + resource, + nodeValue, + context, + model, + fpOptions, + ); + if (!isPlainObject(result) && result !== null) { + throw new FPMLValidationError('Merge block must contain object', path); + } + + return { ...mergeAcc, ...(result || {}) }; + }, + omitKey(node, mergeKey), + ), + }; + } +} + +function processForBlock( + path: Path, + resource: Resource, + node: any, + context: Context, + model: Model, + fpOptions: FPOptions, +): { node: any } | undefined { + const keys = Object.keys(node); + + const forRegExp = /{%\s*for\s+(?:(\w+?)\s*,\s*)?(\w+?)\s+in\s+(.+?)\s*%}/; + const forKey = keys.find((k) => k.match(forRegExp)); + + if (forKey) { + const matches = forKey.match(forRegExp); + const hasIndexKey = matches.length === 4; + const indexKey = hasIndexKey ? matches[1] : null; + const itemKey = hasIndexKey ? matches[2] : matches[1]; + const expr = hasIndexKey ? matches[3] : matches[2]; + + if (keys.length > 1) { + throw new FPMLValidationError(`For block must be presented as single key`, path); + } + + const answers = evaluateExpression(path, resource, expr, context, model, fpOptions); + return { + node: answers.map((answer, index) => + resolveTemplateRecur( + path, + resource, + node[forKey], + { + ...context, + [itemKey]: answer, + ...(hasIndexKey ? { [indexKey]: index } : {}), + }, + model, + fpOptions, + ), + ), + }; + } +} + +function processContextBlock( + path: Path, + resource: Resource, + node: any, + context: Context, + model: Model, + fpOptions: FPOptions, +): { node: any } | undefined { + const keys = Object.keys(node); + + const contextRegExp = /{{\s*(.+?)\s*}}/; + const contextKey = keys.find((k) => k.match(contextRegExp)); + if (contextKey) { + const matches = contextKey.match(contextRegExp); + const expr = matches[1]; + + if (keys.length > 1) { + throw new FPMLValidationError('Context block must be presented as single key', path); + } + + const answers = evaluateExpression(path, resource, expr, context, model, fpOptions); + const result: any[] = answers.map((answer) => + resolveTemplateRecur(path, answer, node[contextKey], context, model, fpOptions), + ); + + return { node: result }; + } +} + +function processIfBlock( + path: Path, + resource: Resource, + node: any, + context: Context, + model: Model, + fpOptions: FPOptions, +): { node: any } | undefined { + const keys = Object.keys(node); + + const ifRegExp = /{%\s*if\s+(.+?)\s*%}/; + const elseRegExp = /{%\s*else\s*%}/; + + const ifKeys = keys.filter((k) => k.match(ifRegExp)); + if (ifKeys.length > 1) { + throw new FPMLValidationError('If block must be presented once', path); + } + const ifKey = ifKeys[0]; + + const elseKeys = keys.filter((k) => k.match(elseRegExp)); + if (elseKeys.length > 1) { + throw new FPMLValidationError('Else block must be presented once', path); + } + const elseKey = elseKeys[0]; + + if (elseKey && !ifKey) { + throw new FPMLValidationError( + 'Else block must be presented only when if block is presented', + path, + ); + } + + if (ifKey) { + const matches = ifKey.match(ifRegExp); + const expr = matches[1]; + + const answer = evaluateExpression( + path, + resource, + `iif(${expr}, true, false)`, + context, + model, + fpOptions, + )[0]; + + const newNode = answer + ? resolveTemplateRecur(path, resource, node[ifKey], context, model, fpOptions) + : elseKey + ? resolveTemplateRecur(path, resource, node[elseKey], context, model, fpOptions) + : null; + + const isMergeBehavior = keys.length !== (elseKey ? 2 : 1); + if (isMergeBehavior) { + if (!isPlainObject(newNode) && newNode !== null) { + throw new FPMLValidationError( + 'If/else block must return object for implicit merge into existing node', + path, + ); + } + + return { + node: { + ...omitKey(omitKey(node, ifKey), elseKey), + ...(newNode !== null ? newNode : {}), + }, + }; + } + return { node: newNode }; + } +} + +type Transformer = (path: Path, node: any, context: Context) => { node: any; context: Context }; + +function iterateObject(startPath: Path, obj: any, context: Context, transform: Transformer): any { + if (Array.isArray(obj)) { + // Arrays are flattened and null values are removed here + return obj + .flatMap((value, index) => { + const result = transform([...startPath, index], value, context); + + return iterateObject([...startPath, index], result.node, result.context, transform); + }) + .filter((x) => x !== null); + } else if (isPlainObject(obj)) { + return mapValues(obj, (value, key) => { + const result = transform([...startPath, key], value, context); + + return iterateObject([...startPath, key], result.node, result.context, transform); + }); + } + + return transform(startPath, obj, context).node; +} + +function isPlainObject(obj: any) { + return Object.prototype.toString.call(obj) === '[object Object]'; +} + +function mapValues(obj: object, fn: (value: any, key: string) => any) { + return Object.fromEntries( + Object.entries(obj).map(([key, value]) => { + return [key, fn(value, key)]; + }), + ); +} + +function omitKey(obj: any, key: string) { + // eslint-disable-next-line @typescript-eslint/no-unused-vars + const { [key]: _, ...rest } = obj; + + return rest; +} + +export function evaluateExpression( + path: Path, + resource: any, + expression: string, + context: Context, + model: Model, + options: FPOptions, +) { + try { + return fhirpath.evaluate( + resource, + expression, + // fhirpath mutates context https://github.com/HL7/fhirpath.js/issues/155 + { ...context }, + model, + options, + ); + } catch (exc) { + throw new FPMLValidationError(`Can not evaluate '${expression}': ${exc}`, path); } } diff --git a/ts/server/test/app.e2e-spec.ts b/ts/server/test/app.e2e-spec.ts index 7043d84..016d727 100644 --- a/ts/server/test/app.e2e-spec.ts +++ b/ts/server/test/app.e2e-spec.ts @@ -1,7 +1,7 @@ import { Test, TestingModule } from '@nestjs/testing'; import { INestApplication } from '@nestjs/common'; import * as request from 'supertest'; -import { AppModule } from './../src/app.module'; +import { AppModule } from '../src/app.module'; describe('AppController (e2e)', () => { let app: INestApplication; @@ -15,7 +15,21 @@ describe('AppController (e2e)', () => { await app.init(); }); - it('Conver simple resource', () => { + it('Convert simple resource r4', () => { + return request(app.getHttpServer()) + .post('/r4/parse-template') + .send({ + context: { + resourceType: 'Patient', + id: 'foo', + }, + template: { id: '{{ Patient.id }}' }, + }) + .expect(200) + .expect({ id: 'foo' }); + }); + + it('Convert simple resource default (r4)', () => { return request(app.getHttpServer()) .post('/parse-template') .send({ @@ -29,7 +43,21 @@ describe('AppController (e2e)', () => { .expect({ id: 'foo' }); }); - it('$extract', () => { + it('Convert simple resource aidbox', () => { + return request(app.getHttpServer()) + .post('/aidbox/parse-template') + .send({ + context: { + resourceType: 'Patient', + id: 'foo', + }, + template: { id: '{{ Patient.id }}' }, + }) + .expect(200) + .expect({ id: 'foo' }); + }); + + it('$extract r4', () => { return request(app.getHttpServer()) .post('/parse-template') .send({ @@ -37,12 +65,51 @@ describe('AppController (e2e)', () => { QuestionnaireResponse: { resourceType: 'QuestionnaireResponse', id: 'foo', + item: [ + { + linkId: 'q1', + answer: [{ valueDecimal: 10 }], + }, + ], }, + extraContextVar: '1', + }, + template: { + id: '{{ QuestionnaireResponse.id }}', + value: "{{ answers('q1') }}", + extraContextVar: '{{ %extraContextVar.toInteger() }}', }, - template: { id: '{{ QuestionnaireResponse.id }}' }, }) .expect(200) - .expect({ id: 'foo' }); + .expect({ id: 'foo', value: 10, extraContextVar: 1 }); + }); + + it('$extract aidbox', () => { + return request(app.getHttpServer()) + .post('/aidbox/parse-template') + .send({ + context: { + QuestionnaireResponse: { + resourceType: 'QuestionnaireResponse', + id: 'foo', + + item: [ + { + linkId: 'q1', + answer: [{ value: { decimal: 10 } }], + }, + ], + }, + extraContextVar: '1', + }, + template: { + id: '{{ QuestionnaireResponse.id }}', + value: "{{ answers('q1') }}", + extraContextVar: '{{ %extraContextVar.toInteger() }}', + }, + }) + .expect(200) + .expect({ id: 'foo', value: 10, extraContextVar: 1 }); }); it('handle nested data', () => { @@ -64,7 +131,7 @@ describe('AppController (e2e)', () => { resourceType: 'Patient', id: { foo: { bar: { baz: 1 } } }, }, - template: { id: '{{Patient.id}}' }, + template: { id: '{{ Patient.id }}' }, }) .expect(200) .expect({ id: { foo: { bar: { baz: 1 } } } }); diff --git a/ts/server/yarn.lock b/ts/server/yarn.lock index 81d8baf..cd9206c 100644 --- a/ts/server/yarn.lock +++ b/ts/server/yarn.lock @@ -677,10 +677,10 @@ "@jridgewell/resolve-uri" "^3.1.0" "@jridgewell/sourcemap-codec" "^1.4.14" -"@lhncbc/ucum-lhc@^4.1.3": - version "4.2.0" - resolved "https://registry.yarnpkg.com/@lhncbc/ucum-lhc/-/ucum-lhc-4.2.0.tgz#522cc16fe37739d7da16c27c8b9ad039ee39c2d7" - integrity sha512-OEiWX7IHFHLTFs7+w5EvGtI5dhXhhL0341LqZ9WEBWErtoY0/9xl/vn+wwT9vnBHnjQ7Ux0o7iEUXvN8uVn4xg== +"@lhncbc/ucum-lhc@^5.0.0": + version "5.0.4" + resolved "https://registry.yarnpkg.com/@lhncbc/ucum-lhc/-/ucum-lhc-5.0.4.tgz#1357a039954e03154f89ce51f2d90c93b9c1a1f3" + integrity sha512-khuV9GV51DF80b0wJmhZTR5Bf23fhS6SSIWnyGT9X+Uvn0FsHFl2LKViQ2TTOuvwagUOUSq8/0SyoE2ZDGwrAA== dependencies: coffeescript "^2.7.0" csv-parse "^4.4.6" @@ -2455,12 +2455,12 @@ fb-watchman@^2.0.0: dependencies: bser "2.1.1" -fhirpath@^3.6.1: - version "3.6.1" - resolved "https://registry.yarnpkg.com/fhirpath/-/fhirpath-3.6.1.tgz#05843eacacb4b9d94d18154771a019c0e4d8122d" - integrity sha512-Sgneaohzc2IYun17OS4107GQXeXd6umVT9P5rn+9YD6v0JDJOvoDixqWeBEY3MokSk7Hpco5NQbTa61TWF4gtg== +fhirpath@^3.13.2: + version "3.13.2" + resolved "https://registry.yarnpkg.com/fhirpath/-/fhirpath-3.13.2.tgz#4c9e3a77267a1c080fc8d295f5aa6aabd26d1fb9" + integrity sha512-pPlMHEj1TlES+QaUdLlGmYgvSYiwecXTHbPjX4WKHj7nR1ZZr0YIj/jBo7k9ADrSKQizqvURGYCqufN7sHnnQA== dependencies: - "@lhncbc/ucum-lhc" "^4.1.3" + "@lhncbc/ucum-lhc" "^5.0.0" antlr4 "~4.9.3" commander "^2.18.0" date-fns "^1.30.1"