Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: add support for if, for, assign and merge #6

Merged
merged 18 commits into from
May 28, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions ts/server/.eslintrc.js
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
2 changes: 1 addition & 1 deletion ts/server/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
},
Expand Down
44 changes: 31 additions & 13 deletions ts/server/src/app.controller.ts
Original file line number Diff line number Diff line change
@@ -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<string, Resource> | Resource;
template: object;
strict?: boolean;
}

function containsQuestionnaireResponse(
Expand All @@ -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,
);
}
}
18 changes: 18 additions & 0 deletions ts/server/src/app.filters.ts
Original file line number Diff line number Diff line change
@@ -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',
});
}
}
45 changes: 41 additions & 4 deletions ts/server/src/app.service.ts
Original file line number Diff line number Diff line change
@@ -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<string, any>,
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,
);
}
}
171 changes: 171 additions & 0 deletions ts/server/src/utils/__data__/complex-example.aidbox.yaml
Original file line number Diff line number Diff line change
@@ -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
Loading
Loading