From e18b008540e0e2a0832c10726d5c20b456c82ce1 Mon Sep 17 00:00:00 2001 From: Vadim Laletin Date: Fri, 7 Jun 2024 10:56:01 +0200 Subject: [PATCH] docs: update readme --- README.md | 458 +++++++++++++++++++++++++++++++++++++++++++++++++----- 1 file changed, 421 insertions(+), 37 deletions(-) diff --git a/README.md b/README.md index 8b63270..f74b4dd 100644 --- a/README.md +++ b/README.md @@ -22,10 +22,10 @@ Since this approach is already used in FHIR I decided to use it instead of `$` s Finally, data DSL should be LLM-friendly and there should be an easy way to generate a mapper based on the text description. ChatGPT works pretty well with JSON and FHIRPath. So, you can just copy and paste the specification into ChatGPT and try to generate mappers. -As a result, I got the following specifications: ## Specification -FHIRPath mapping language is data dsl designed to convert data from QuestionnaireResponse to any FHIR Resource. + +FHIRPath mapping language is data dsl designed to convert data from QuestionnaireResponse (and not only) to any FHIR Resource. Here is how does it work. @@ -83,55 +83,78 @@ Let's say we have a QuestionnaireResponse describing a patient: "valueString": "foo@yahoo.com" } ] + }, + { + "text": "country", + "linkId": "country", + "answer": [ + { + "valueString": "US" + } + ] } ] } ``` -You need to map it to Patient FHIR resource. The mapper define struture of the resource. +You need to map it to Patient FHIR resource. The mapper define structure of the resource. This mapper ```json -{"resourceType": "Patient"} +{ + "resourceType": "Patient" +} ``` -is a valid maper that return exacly the same structure +is a valid mapper that return exactly the same structure ```json -{"resourceType": "Patient"} +{ + "resourceType": "Patient" +} ``` -All strings are treated as constant value unless it start with `{{` and ends with `}}`. +All strings are treated as constant value unless it starts with `{{` and ends with `}}`. The text inside `{{` and `}}` is a FHIRPath expression. Let's use it to extract patient birthDate. ```json { -"resourceType": "Patient", -"birthDate": "{{ QuestionnaireResponse.repeat(item).where(linkId='2').answer.value }}" + "resourceType": "Patient", + "birthDate": "{{ QuestionnaireResponse.repeat(item).where(linkId='2').answer.value }}" } ``` The result will be ```json { -"resourceType": "Patient", -"birthDate": "2023-05-03" + "resourceType": "Patient", + "birthDate": "2023-05-03" } ``` Let's extract name, phone number and email fields: ```json { -"resourceType": "Patient", -"birthDate": "{{ QuestionnaireResponse.repeat(item).where(linkId='2').answer.value }}", -"name": [{"given": ["{{ QuestionnaireResponse.repeat(item).where(linkId='1').answer.value }}"]}], -"telecom": [ - {"value": "{{ QuestionnaireResponse.repeat(item).where(linkId='phone').answer.value }}", - "system": "phone"}, - {"value": "{{ QuestionnaireResponse.repeat(item).where(linkId='email').answer.value }}", - "system": "email"}] + "resourceType": "Patient", + "birthDate": "{{ QuestionnaireResponse.repeat(item).where(linkId='2').answer.value }}", + "name": [ + { + "given": [ + "{{ QuestionnaireResponse.repeat(item).where(linkId='1').answer.value }}" + ] + } + ], + "telecom": [ + { + "value": "{{ QuestionnaireResponse.repeat(item).where(linkId='phone').answer.value }}", + "system": "phone" + }, + { + "value": "{{ QuestionnaireResponse.repeat(item).where(linkId='email').answer.value }}", + "system": "email" + } + ] } ``` - To extract gender we need a bit more complex expression `QuestionnaireResponse.repeat(item).where(linkId='4.1').answer.value.code` @@ -141,24 +164,385 @@ because patient gender is token while question item type is Coding. The final mapper will look like this: ```json { -"resourceType": "Patient", -"birthDate": "{{ QuestionnaireResponse.repeat(item).where(linkId='2').answer.value }}", -"name": [{"given": ["{{ QuestionnaireResponse.repeat(item).where(linkId='1').answer.value }}"]}], -"telecom": [ - {"value": "{{ QuestionnaireResponse.repeat(item).where(linkId='phone').answer.value }}", - "system": "phone"}, - {"value": "{{ QuestionnaireResponse.repeat(item).where(linkId='email').answer.value }}", - "system": "email"}] -"gender": "{{ QuestionnaireResponse.repeat(item).where(linkId='4.1').answer.value.code }}" + "resourceType": "Patient", + "birthDate": "{{ QuestionnaireResponse.repeat(item).where(linkId='2').answer.value }}", + "name": [ + { + "given": [ + "{{ QuestionnaireResponse.repeat(item).where(linkId='1').answer.value }}" + ] + } + ], + "telecom": [ + { + "value": "{{ QuestionnaireResponse.repeat(item).where(linkId='phone').answer.value }}", + "system": "phone" + }, + { + "value": "{{ QuestionnaireResponse.repeat(item).where(linkId='email').answer.value }}", + "system": "email" + } + ], + "gender": "{{ QuestionnaireResponse.repeat(item).where(linkId='4.1').answer.value.code }}" +} +``` + +### Expression evaluation with empty result + +If expression is resolved to empty set `{}`, this key will be removed from the object. + +Let's imagine, if the gender field is missing in the QuestionnaireResponse from the example above + +```json +{ + "resourceType": "Patient", + "gender": "{{ QuestionnaireResponse.repeat(item).where(linkId='4.1').answer.value.code }}" +} +``` + +this template will be mapped into + +```json + { + "resourceType": "Patient" + } +``` + +### Null preservable construction + +**NOTE:** the feature is not mature enough and might be changed in the future. + +There's a special construction that allows to preserve the null value in the final result using `{{+` and `+}}` instead of `{{` and `}}`, + +```json +{ + "resourceType": "Patient", + "gender": "{{+ QuestionnaireResponse.repeat(item).where(linkId='4.1').answer.value.code +}}" +} +``` + +the result will be + +```json + { + "resourceType": "Patient", + "gender": null + } +``` + +**NOTE:** the feature is not mature enough and might be changed in the future. + + +### Automatic array flattening and null removal + +In FHIR resources the array of arrays as well as array of nulls are invalid construction. To simplify writing mappers there's an automatic array flattening. + +```json +{ + "list": [ + [ + 1, 2, null, 3 + ], + null, + [ + 4, 5, 6, null + ] + ] +} +``` + +will be mapped into + +```json +{ + "list": [ + 1, 2, 3, 4, 5, 6 + ] +} +``` + +It is especially useful if there's conditional and iteration logic used. + +### Locally scoped variables + +Here's a special construction that allows to define custom variables for the FHIRPath context of underlying expressions. + +```json +{ + "{% assign %}": [ + { + "varA": 1 + }, + { + "varB": "{{ %varA + 1 }}" + } + ] +} +``` + +Pay attention that `%varA` is accessed using the percent sign. +It means that `%varA` is from the context. Also the order is in the array is important. The context variables can be accessed only in the underlying expressions including nested arrays/objects, e.g. + + +```json +{ + "{% assign %}": [ + { + "birthDate": "{{ QuestionnaireResponse.repeat(item).where(linkId='2').answer.value }}" + } + ], + "resourceType": "Bundle", + "entry": [ + { + "resource": { + "resourceType": "Patient", + "birthDate": "{{ %birthDate }}" + } + } + ] +} +``` + +will be transformed into + +```json +{ + "resourceType": "Bundle", + "entry": [ + { + "resource": { + "resourceType": "Patient", + "birthDate": "2023-05-03" + } + } + ] +} +``` + +### Conditional logic + +FHIRPath provides conditional logic for primitive values like booleans, strings and numbers using `iif` function. +Sometimes it's not enough and we need to map some values to complex structures, let's say JSON objects. + +There's a special construction + +```json +{ + "{% if expression %}": { + "key": "value true" + }, + "{% else %}": { + "key": "value false" + } +} +``` + +where `expression` is FHIRPath expression that is evaluated in the same way as the first argument of `iif` function. + +For example, + +```json +{ + "resourceType": "Patient", + "address": { + "{% if QuestionnaireResponse.repeat(item).where(linkId='country').answer.exists() %}": { + "type": "physical", + "country": "{{ QuestionnaireResponse.repeat(item).where(linkId='country').answer.value }}" + } + } +} +``` + +will be mapped into + +```json +{ + "resourceType": "Patient", + "address": { + "type": "physical", + "country": "US" + } +} +``` + +#### Implicit merge + +It also makes implicit merge, in case when `if`/`else` blocks return JSON objects, e.g. + +```json +{ + "resourceType": "Patient", + "address": { + "type": "physical", + "{% if QuestionnaireResponse.repeat(item).where(linkId='country').answer.exists() %}": { + "country": "{{ QuestionnaireResponse.repeat(item).where(linkId='country').answer.value }}" + }, + "{% else %}": { + "text": "Unknown" + } + } +} +``` + +The final result will be either + +```json +{ + "resourceType": "Patient", + "address": { + "type": "physical", + "country": "US" + } +} +``` + +or + +```json +{ + "resourceType": "Patient", + "address": { + "type": "physical", + "text": "Unknown" + } +} +``` + +In this example, Patient address contains original `{"type": "physical"}` object and `country`/`text` implicitly merged based on condition. + +### Iteration logic + +To iterate over the array of values here's a special construction + +```json +{ + "{% for item in QuestionnaireResponse.item %}": { + "linkId": "{{ %item.linkId }}" + } +} +``` + +that will be transformed into + +``` +[ + { "linkId": "1" }, + { "linkId": "2" }, + { "linkId": "4.1" }, + { "linkId": "phone" }, + { "linkId": "email" }, + { "linkId": "country" } +] +``` + +#### Using index + +```json +{ + "{% for index, item in QuestionnaireResponse.item %}": { + "index": "{{ %index }}" + "linkId": "{{ %item.linkId }}" + } +} +``` + +that will be transformed into + +``` +[ + { "index": 0, "linkId": "1" }, + { "index": 1, "linkId": "2" }, + { "index": 2, "linkId": "4.1" }, + { "index": 3, "linkId": "phone" }, + { "index": 4, "linkId": "email" }, + { "index": 5, "linkId": "country" } +] +``` + + +### Merge logic + +To merge two or more objects, there's a special construction + +```json +{ + "{% merge %}": [ + { + "a": 1 + }, + { + "b": 2 + } + ] +} +``` + +that will be transformed into + +```json +{ + "a": 1 + "b": 2 } -``` +``` -## TODO -- [ ] Alternative to [JUTE $map](https://github.com/healthSamurai/jute.clj#map) derective. [Proposal](https://github.com/beda-software/FHIRPathMappingLanguage/issues/1) -- [ ] Alternative to [JUTE $if](https://github.com/healthSamurai/jute.clj#if) directive. -- [ ] Do we need a way to define FHIRPath variables? Like [JUTE $let](https://github.com/healthSamurai/jute.clj#let) directive. +## Examples + +See real-life examples of mappers for [FHIR](https://github.com/beda-software/FHIRPathMappingLanguage/blob/main/ts/server/src/utils/__data__/complex-example.fhir.yaml) and [Aidbox](https://github.com/beda-software/FHIRPathMappingLanguage/blob/main/ts/server/src/utils/__data__/complex-example.aidbox.yaml) + +and other usage in [unit tests](https://github.com/beda-software/FHIRPathMappingLanguage/tree/main/ts/server/src/utils). ## Reference implementation -I am going to build Python and TypeScript versions of FHIRPathMappingLanguage. -TypeScript is already available https://github.com/beda-software/FHIRPathMappingLanguage/tree/main/server -Also, it is packed into a docker image to use as a microservice https://hub.docker.com/r/bedasoftware/fhirpath-extract + +TypeScript implementation that supports all the specification is already available [in this repository](https://github.com/beda-software/FHIRPathMappingLanguage/tree/main/server). +Also, it is packed into a [docker image](https://hub.docker.com/r/bedasoftware/fhirpath-extract) to use as a microservice. + +### Usage + +```json +POST /r4/parse-template + +{ + "context": { + "QuestionnaireResponse": { + "resourceType": "QuestionnaireResponse", + "id": "foo", + "authored": "2024-01-01T10:00:00Z" + } + }, + "template": { + "id": "{{ id }}", + "authored": "{{ authored }}", + "status": "completed" + } +} +``` + +### Strict mode + +FHIRPath provides a way of accessing the `resource` variables without the percent sign. It potentially leads to the issues made by typos in the variable names. + +There's a runtime flag, called `strict` that is set to `false` by default. If it set to `true`, all accesses to the variables without the percent sign will be rejected and exception will be thrown. + + +The previous example should be re-written as + +```json +POST /r4/parse-template + +{ + "context": { + "QuestionnaireResponse": { + "resourceType": "QuestionnaireResponse", + "id": "foo", + "authored": "2024-01-01T10:00:00Z" + } + }, + "template": { + "id": "{{ %QuestionnaireResponse.id }}", + "authored": "{{ %QuestionnaireResponse.authored }}", + "status": "completed" + } + "strict": true +} +``` \ No newline at end of file