diff --git a/.github/workflows/build_test_lint.yml b/.github/workflows/build_test_lint.yml index ac6840ea..953b4685 100644 --- a/.github/workflows/build_test_lint.yml +++ b/.github/workflows/build_test_lint.yml @@ -79,3 +79,34 @@ jobs: - name: Check formatting run: npm run check-formatting + + deploy-storybook: + name: Deploy Storybook to S3 + runs-on: ubuntu-latest + permissions: + contents: read + pages: write + id-token: write + steps: + - uses: actions/checkout@v4 + + - name: Use Node.js 18.x + uses: actions/setup-node@v4 + with: + node-version: 18 + cache: npm + + - name: Configure AWS credentials + uses: aws-actions/configure-aws-credentials@v4 + with: + role-to-assume: arn:aws:iam::209248795938:role/SmartFormsReactAppDeployment + aws-region: ap-southeast-2 + + - name: Install dependencies + run: npm ci + + - name: Build application + run: npm run build-storybook -w packages/smart-forms-renderer + + - name: Upload static Storybook site to S3 + run: aws s3 sync packages/smart-forms-renderer/storybook-static s3://smart-forms-storybook/storybook diff --git a/.github/workflows/deploy_app.yml b/.github/workflows/deploy_app.yml index 3a1600fc..e48f9093 100644 --- a/.github/workflows/deploy_app.yml +++ b/.github/workflows/deploy_app.yml @@ -4,13 +4,15 @@ on: push: branches: ['main'] +permissions: + contents: read + pages: write + id-token: write + jobs: build: name: Deploy Smart Forms app to S3 runs-on: ubuntu-latest - permissions: - id-token: write - contents: read steps: - uses: actions/checkout@v4 diff --git a/.github/workflows/deploy_docs.yml b/.github/workflows/deploy_docs.yml index dafee6e6..96f0c6d3 100644 --- a/.github/workflows/deploy_docs.yml +++ b/.github/workflows/deploy_docs.yml @@ -34,31 +34,4 @@ jobs: run: npm run build -w documentation - name: Upload static Docusaurus site to S3 - run: aws s3 sync documentation/build s3://smart-forms-docs/docs - - deploy-storybook: - name: Deploy Storybook to S3 - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - - - name: Use Node.js 18.x - uses: actions/setup-node@v4 - with: - node-version: 18 - cache: npm - - - name: Configure AWS credentials - uses: aws-actions/configure-aws-credentials@v4 - with: - role-to-assume: arn:aws:iam::209248795938:role/SmartFormsReactAppDeployment - aws-region: ap-southeast-2 - - - name: Install dependencies - run: npm ci - - - name: Build application - run: npm run build-storybook -w packages/smart-forms-renderer - - - name: Upload static Storybook site to S3 - run: aws s3 sync packages/smart-forms-renderer/storybook-static s3://smart-forms-storybook/storybook + run: aws s3 sync documentation/build s3://smart-forms-docs/docs --cache-control no-cache diff --git a/README.md b/README.md index d37e6328..e876677e 100644 --- a/README.md +++ b/README.md @@ -10,6 +10,7 @@ Powered by SMART on FHIR and Structured Data Capture, Smart Forms allow you to e

Show me the app ➡️

Check out the documentation 📚

+Update 11/07/2024: The documentation link isn't working. It is currently being fixed. --- Smart Forms is a Typescript-based [React](https://reactjs.org/) forms web application currently ongoing development by [CSIRO's Australian e-Health Research Centre](https://aehrc.csiro.au/) as part of the Primary Care Data Quality project funded by the Australian Government Department of Health. diff --git a/deployment/cloudfront/SmartFormsRedirectToCorrectRoute.js b/deployment/cloudfront/SmartFormsRedirectToCorrectRoute.js index c6c5cdb7..93db4651 100644 --- a/deployment/cloudfront/SmartFormsRedirectToCorrectRoute.js +++ b/deployment/cloudfront/SmartFormsRedirectToCorrectRoute.js @@ -82,21 +82,28 @@ function handler(event) { } - // Handle Docz routes - if (uri.includes('/docz')) { - // Reroute to smartforms.csiro.au/docz/index.html - if (uri === '/docz/') { + // Handle Docs routes + if (uri.includes('/docs')) { + // Reroute to smartforms.csiro.au/docs/index.html + if (uri === '/docs/') { request.uri += 'index.html'; return request; } - if (uri === '/docz') { + if (uri === '/docs') { request.uri = '/redirect.html'; return request; } if (!uri.includes('.')) { - request.uri += '/index.html'; + // For https://smartforms.csiro.au/docs/sdc/population/ cases + if (uri.endsWith('/')) { + request.uri += 'index.html'; + } + // For https://smartforms.csiro.au/docs/sdc/population cases + else { + request.uri += '/index.html'; + } return request; } diff --git a/deployment/ehr-proxy/ehr-proxy-app/lib/ehr-proxy-app-stack.ts b/deployment/ehr-proxy/ehr-proxy-app/lib/ehr-proxy-app-stack.ts index 655d1fa3..f6d6553b 100644 --- a/deployment/ehr-proxy/ehr-proxy-app/lib/ehr-proxy-app-stack.ts +++ b/deployment/ehr-proxy/ehr-proxy-app/lib/ehr-proxy-app-stack.ts @@ -73,7 +73,12 @@ export class EhrProxyAppStack extends cdk.Stack { listener.addAction('EhrProxyExtractAction', { action: ListenerAction.forward([extractTargetGroup]), priority: 1, - conditions: [ListenerCondition.pathPatterns(['/fhir/QuestionnaireResponse/$extract'])] + conditions: [ + ListenerCondition.pathPatterns([ + '/fhir/QuestionnaireResponse/$extract', + '/fhir/StructureMap/$convert' + ]) + ] }); // Create a target for the transform service diff --git a/documentation/docs/index.md b/documentation/docs/index.md index 9dd5222b..b05c1197 100644 --- a/documentation/docs/index.md +++ b/documentation/docs/index.md @@ -14,6 +14,7 @@ This documentation is intended to provide a guide on how to use Smart Forms. It - [Components](/docs/components): A showcase of supported Questionnaire form components. - [SDC](/docs/sdc): A section around the conformance and usage of functionalities defined in the SDC specification. - [Developer Usage](/docs/dev): A guide on how to use the form renderer in your own application. +- [FHIR Operations](/docs/operations): A guide on using the $populate, $assemble and $extract operations. ### Referenced FHIR Specifications diff --git a/documentation/docs/operations/assemble.mdx b/documentation/docs/operations/assemble.mdx new file mode 100644 index 00000000..b6129b29 --- /dev/null +++ b/documentation/docs/operations/assemble.mdx @@ -0,0 +1,33 @@ +--- +sidebar_position: 3 +--- + +# $assemble + +## Useful links + +**Deployed service: https://smartforms.csiro.au/api/fhir/Questionnaire/$assemble** + +FHIR Operation definition: http://hl7.org/fhir/uv/sdc/OperationDefinition/Questionnaire-assemble + +Github: https://github.com/aehrc/smart-forms/tree/main/services/assemble-express + +Dockerhub: https://hub.docker.com/r/aehrc/smart-forms-assemble + +## Usage + +A Questionnaire resource can be assembled using a **POST** request to a URL such as: + +```http request +https://smartforms.csiro.au/api/fhir/Questionnaire/$assemble (type-level) +``` + +#### Parameters + +| Name | Cardinality | Type | Documentation | +| ------------- | ----------- | ------------- | --------------------------------------------------------------------------------------------------------------------------------------- | +| questionnaire | 1..1 | Questionnaire | The [Modular Questionnaire](https://hl7.org/fhir/uv/sdc/StructureDefinition-sdc-questionnaire-modular.html) to assemble the content of. | + +#### Try it out + +[Run In Postman](https://elements.getpostman.com/redirect?entityId=22885901-2af2cfbb-3a0a-49c6-8404-105ef0751415&entityType=collection) diff --git a/documentation/docs/operations/extract.mdx b/documentation/docs/operations/extract.mdx new file mode 100644 index 00000000..4727bb69 --- /dev/null +++ b/documentation/docs/operations/extract.mdx @@ -0,0 +1,196 @@ +--- +sidebar_position: 4 +--- + +# StructureMap $extract + +This $extract **proof-of-concept** reference implementation is an abstraction on top of an existing StructureMap $transform operation. +We leveraged Brian Postlethwaite's [.NET FHIR Mapping Language engine](https://github.com/brianpos/fhir-net-mappinglanguage/tree/main/demo-map-server) to expose a StructureMap $transform operation on https://proxy.smartforms.io/fhir/StructureMap/$transform. + +:::note + +This reference implementation is a proof-of-concept. It is highly likely that the underlying implementation will change in the future. + +::: + +## Useful links + +#### Services links + +Deployed service: https://proxy.smartforms.io/fhir/QuestionnaireResponse/$extract + +Underlying $transform service: https://proxy.smartforms.io/fhir/StructureMap/$transform + +FHIR Mapping Language to StructureMap $convert service: https://proxy.smartforms.io/fhir/StructureMap/$convert + +#### Specification links + +FHIR $extract operation definition: http://hl7.org/fhir/uv/sdc/OperationDefinition/QuestionnaireResponse-extract + +FHIR $transform operation definition: https://hl7.org/fhir/r4/structuremap-operation-transform.html + +FHIR Mapping Language $convert workflow: https://confluence.hl7.org/pages/viewpage.action?pageId=76158820#UsingtheFHIRMappingLanguage-WebServices + +FHIR StructureMap-based extraction: https://hl7.org/fhir/uv/sdc/extraction.html#structuremap-based-extraction + +#### Source code links + +Github: https://github.com/aehrc/smart-forms/tree/main/services/extract-express + +Dockerhub: https://hub.docker.com/r/aehrc/smart-forms-extract + +## Usage + +Resource(s) can be extracted from a QuestionnaireResponse using a **POST** request to a URL such as: + +```http request +https://proxy.smartforms.io/fhir/QuestionnaireResponse/$extract (type-level) +``` + +#### Parameters + +| Name | Cardinality | Type | Documentation | +| ---------------------- | ----------- | -------- | ------------------------------------------------------------------------------------------------------- | +| questionnaire-response | 1..1 | Resource | The QuestionnaireResponse to extract data from. Used when the operation is invoked at the 'type' level. | + +#### Try it out + +[Run In Postman](https://elements.getpostman.com/redirect?entityId=22885901-2af2cfbb-3a0a-49c6-8404-105ef0751415&entityType=collection) + +## How it works + +The $extract operation is an abstraction on top of an existing StructureMap $transform operation. + +#### The underlying $transform + +A [$transform](https://hl7.org/fhir/r4/structuremap-operation-transform.html) operation requires two input parameters: + +1. `source` - Contains the structure map defining the mapping rules +2. `content` - Contains the data to be transformed (in terms of SDC, this is the QuestionnaireResponse) + +The output of $transform is the transformed data, a FHIR resource. + +Taking this logic, we can use a StructureMap `$transform` operation to perform the extraction of resources from a QuestionnaireResponse. Let's say we want to extract a bundle containing an Observation resource from a QuestionnaireResponse. +We need a StructureMap that maps the data from the QuestionnaireResponse to the Observation resource. Normally we would write this mapping in [FHIR Mapping Language](https://www.hl7.org/fhir/mapping-language.html) and convert it to a StructureMap. + +For more information on using the FHIR Mapping Language, refer to https://confluence.hl7.org/display/FHIR/Using+the+FHIR+Mapping+Language. Brian has a really awesome tool that can help you write and test your mappings at https://fhirpath-lab.com/FhirMapper2. + +Once we have both the `source` StructureMap and the `content` QuestionnaireResponse, our $transform request body should look roughly like this: + +```json +{ + "resourceType": "Parameters", + "parameter": [ + { + "name": "source", + "resource": // - + } + }, + { + "name": "content", + "resource": // - a filled QR from + } + ] +} +``` + +Relevant resources: + +`source`- https://smartforms.csiro.au/api/fhir/StructureMap/extract-bmi + +`content`- http://smartforms.csiro.au/fhir/Questionnaire/CalculatedExpressionBMICalculatorPrepop + +[//]: # 'turn it into a microservice' + +Running the $transform operation will return the transformed data, which in this case is a Bundle resource containing the Observation resource: + +```json +{ + "resourceType": "Bundle", + "id": "", + "type": "transaction", + "entry": [ + { + "request": { + "method": "POST", + "url": "Observation" + }, + "resource": { + "resourceType": "Observation", + "status": "final", + "code": { + "coding": [ + { + "system": "http://snomed.info/sct", + "code": "60621009", + "display": "Body mass index" + } + ] + }, + "subject": { + "reference": "Patient/pat-sf" + }, + "valueQuantity": { + "value": 29.55, + "unit": "kg/m2", + "system": "http://unitsofmeasure.org", + "code": "kg/m2" + } + } + } + ] +} +``` + +#### The $extract operation + +Using the logic above, we can abstract the $transform operation into a $extract operation. The $extract operation requires only one input parameter (or you can just provide the QuestionnaireResponse in the request body): + +`questionnaire-response` - Contains the QuestionnaireResponse to extract data from + +The provided QuestionnaireResponse should fulfill two criteria: + +1. It needs to contain a canonical reference in its `questionnaire` property. + +```json +{ + ... + "questionnaire": "https://smartforms.csiro.au/docs/sdc/population/calculated-expression-1|0.1.0", + ... +} +``` + +2. The referenced Questionnaire should have a [questionnaire-targetStructureMap](http://hl7.org/fhir/uv/sdc/StructureDefinition/sdc-questionnaire-targetStructureMap) extension. This extension needs to contain a canonical reference to a StructureMap that maps the data from the QuestionnaireResponse to the desired resource(s). + +```json +{ + ... + "extension": [ + { + "url": "http://hl7.org/fhir/uv/sdc/StructureDefinition/sdc-questionnaire-targetStructureMap", + "valueCanonical": "https://smartforms.csiro.au/docs/StructureMap/extract-bmi" + } + ], + ... +} +``` + +The $extract POC implementation defines a definitional repository to resolve Questionnaires and StructureMaps - https://smartforms.csiro.au/api/fhir. See https://hub.docker.com/r/aehrc/smart-forms-extract for more information. + +The $extract operation will resolve the referenced Questionnaire + StructureMap, and set the StructureMap as the `source` in the $transform operation. The `content` will be the provided QuestionnaireResponse. + +The underlying $transform operation will be executed, and the transformed data will be returned as the output of the $extract operation. + +## The $convert operation + +Brian's [.NET FHIR Mapping Language engine](https://github.com/brianpos/fhir-net-mappinglanguage/tree/main/demo-map-server) has a handy debug mode that can be activated by adding `debug=true` to the $transform query parameters. +The debug payload contains useful details such as warnings/errors, trace output, the converted StructureMap, and the output resource. + +Conveniently, it also accepts both a FHIR Mapping Language map and a StructureMap resource as the `source` input. +This means we can use it as a $convert operation to convert a FHIR Mapping Language map to a StructureMap resource. + +``` +https://proxy.smartforms.io/fhir/StructureMap/$convert +``` + +It expects the request headers for the `Content-Type` to be `text/plain`. The request body should contain the FHIR Mapping Language map, and the response will contain the converted StructureMap resource. diff --git a/documentation/docs/operations/index.mdx b/documentation/docs/operations/index.mdx index 7c36fa14..a0a6dfd1 100644 --- a/documentation/docs/operations/index.mdx +++ b/documentation/docs/operations/index.mdx @@ -8,18 +8,19 @@ sidebar_label: Introduction Smart Forms provides reference implementations for `$populate` and `$assemble` operations as [ExpressJS](https://expressjs.com/) services to complement the FHIR Questionnaire renderer. These operations are available as Docker images and can be deployed as microservices. -[//]: # '1. **`$populate`**' -[//]: # ' Operation definition: [SDCPopulateQuestionnaire](http://hl7.org/fhir/uv/sdc/OperationDefinition/Questionnaire-populate)' -[//]: # ' Github repository:' -[//]: # -[//]: # ' A React-based library that contains the rendering engine. It acts as a reference implementation for the [SDC Form Filler](https://hl7.org/fhir/uv/sdc/CapabilityStatement-sdc-form-filler.html).' -[//]: # -[//]: # '2. **`$assemble`** ([@aehrc/sdc-populate](https://www.npmjs.com/package/@aehrc/sdc-populate))' -[//]: # -[//]: # ' A reference implementation of the [SDC Populate Questionnaire](https://hl7.org/fhir/uv/sdc/OperationDefinition-Questionnaire-populate.html) operation, also known as $populate.' -[//]: # ' Currently, there are no written documentation available for this library. Please refer to the [API](/docs/api/sdc-populate) for more information.' -[//]: # -[//]: # '3. **SDC Assemble** ([@aehrc/sdc-assemble](https://www.npmjs.com/package/@aehrc/sdc-assemble))' -[//]: # -[//]: # ' A reference implementation of the [SDC Assemble Questionnaire](https://hl7.org/fhir/uv/sdc/OperationDefinition-Questionnaire-assemble.html) operation, also known as $assemble.' -[//]: # ' Currently, there are no written documentation available for this library. Please refer to the [API](/docs/api/sdc-assemble) for more information.' +1. **$populate** (https://smartforms.csiro.au/api/fhir/Questionnaire/$populate) + + A reference implementation of the [SDC Populate Questionnaire](https://hl7.org/fhir/uv/sdc/OperationDefinition-Questionnaire-populate.html) operation, also known as $populate. + It builds on the [@aehrc/sdc-populate](https://www.npmjs.com/package/@aehrc/sdc-populate) library. + +2. **$assemble** (https://smartforms.csiro.au/api/fhir/Questionnaire/$assemble) + + A reference implementation of the [SDC Assemble](https://hl7.org/fhir/uv/sdc/OperationDefinition-Questionnaire-assemble.html) operation, also known as $assemble. + It builds on the [@aehrc/sdc-assemble](https://www.npmjs.com/package/@aehrc/sdc-assemble) library. + +3. **$extract** (https://proxy.smartforms.io/fhir/QuestionnaireResponse/$extract) + + A **proof-of-concept** reference implementation of the [SDC QuestionnaireResponse Extract](https://hl7.org/fhir/uv/sdc/OperationDefinition-QuestionnaireResponse-extract.html) operation, also known as $extract. + + This $extract POC reference implementation is an abstraction on top of an existing StructureMap $transform operation. + We leveraged Brian Postlethwaite's [.NET FHIR Mapping Language engine](https://github.com/brianpos/fhir-net-mappinglanguage/tree/main/demo-map-server) to expose a StructureMap $transform operation on https://proxy.smartforms.io/fhir/StructureMap/$transform. diff --git a/documentation/docs/operations/populate.mdx b/documentation/docs/operations/populate.mdx new file mode 100644 index 00000000..7f4b9fca --- /dev/null +++ b/documentation/docs/operations/populate.mdx @@ -0,0 +1,47 @@ +--- +sidebar_position: 2 +--- + +# $populate + +## Useful links + +**Deployed service: https://smartforms.csiro.au/api/fhir/Questionnaire/$populate** + +FHIR Operation definition: http://hl7.org/fhir/uv/sdc/OperationDefinition/Questionnaire-populate + +Github: https://github.com/aehrc/smart-forms/tree/main/services/populate-express + +Dockerhub: https://hub.docker.com/r/aehrc/smart-forms-populate + +## Usage + +A Questionnaire resource can be populated using a **POST** request to a URL such as: + +```http request +https://smartforms.csiro.au/api/fhir/Questionnaire/$populate (type-level) +``` + +#### Parameters + +| Name | Cardinality | Type | Documentation | +| --------------- | ----------- | ------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| questionnaire | 1..1 | Questionnaire | The Questionnaire is provided directly as part of the request. | +| subject | 1..1 | Reference | The resource that is to be the QuestionnaireResponse.subject. The QuestionnaireResponse instance will reference the provided subject. | +| context | 0..\* | | Resources containing information to be used to help populate the QuestionnaireResponse. These will typically be FHIR resources. | +| context.name | 0..\* | string | The name of the launchContext or root Questionnaire variable the passed content should be used as for population purposes. The name SHALL correspond to a launchContext or variable declared at the root of the Questionnaire. | +| context.content | 0..\* | Resource | The actual resource (or resources) to use as the value of the launchContext or variable. | + +https://smartforms.csiro.au/api/fhir only stores Questionnaire definitions and does not contain any clinical data. Therefore when using this sample implementation, contextual information for pre-population should be provided as actual FHIR resources, not references. + +#### Debugging + +You can add the `debug=true` query parameter to return an additional `contextResult-custom` output parameter in the response. + +```http request +https://smartforms.csiro.au/api/fhir/Questionnaire/$populate?debug=true +``` + +#### Try it out + +[Run In Postman](https://elements.getpostman.com/redirect?entityId=22885901-2af2cfbb-3a0a-49c6-8404-105ef0751415&entityType=collection) diff --git a/documentation/docusaurus.config.ts b/documentation/docusaurus.config.ts index a7f1b34f..bc66a853 100644 --- a/documentation/docusaurus.config.ts +++ b/documentation/docusaurus.config.ts @@ -12,7 +12,7 @@ const config: Config = { url: 'https://smartforms.csiro.au', // Set the // pathname under which your site is served // For GitHub pages deployment, it is often '//' - baseUrl: '/docs', + baseUrl: '/docs/', // GitHub pages deployment config. // If you aren't using GitHub pages, you don't need these. @@ -20,7 +20,9 @@ const config: Config = { projectName: '', // Usually your repo name.\ onBrokenLinks: 'warn', - onBrokenMarkdownLinks: 'warn', + onBrokenMarkdownLinks: 'throw', + + trailingSlash: false, // Even if you don't use internationalization, you can use this field to set // useful metadata like html lang. For example, if your site is Chinese, you @@ -35,11 +37,12 @@ const config: Config = { 'classic', { docs: { + showLastUpdateTime: true, routeBasePath: '/', - sidebarPath: './sidebars.ts', + sidebarPath: './sidebars.ts' // Please change this to your repo. // Remove this to remove the "edit this page" links. - editUrl: 'https://github.com/aehrc/smart-forms/' + // editUrl: 'https://github.com/aehrc/smart-forms/' }, theme: { customCss: './src/css/custom.css' @@ -53,7 +56,6 @@ const config: Config = { // Replace with your project's social card image: 'img/logo-sf.svg', navbar: { - title: 'Smart Forms', logo: { alt: 'Smart Forms', src: 'img/logo-sf.svg', @@ -153,6 +155,28 @@ const config: Config = { copyright: `Copyright © ${new Date().getFullYear()} Commonwealth Scientific and Industrial Research - Organisation (CSIRO).` }, + // Refer to https://docusaurus.io/docs/search#connecting-algolia + algolia: { + // The application ID provided by Algolia + appId: 'SL7YXI16RH', + + // Public API key: it is safe to commit it + apiKey: 'a4c401a7bac65bc81b7dd7efe958b951', + + indexName: 'smartforms-csiro', + + // Optional: see doc section below + contextualSearch: true, + + // Optional: Algolia search parameters + searchParameters: {}, + + // Optional: path for search page that enabled by default (`false` to disable it) + searchPagePath: 'search', + + // Optional: whether the insights feature is enabled or not on Docsearch (`false` by default) + insights: false + }, prism: { theme: prismThemes.github, darkTheme: prismThemes.dracula diff --git a/package-lock.json b/package-lock.json index 8a21e486..bd9c8c7f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -42626,7 +42626,7 @@ } }, "services/extract-express": { - "version": "0.3.0", + "version": "0.4.0", "license": "Apache-2.0", "dependencies": { "cors": "^2.8.5", @@ -42642,7 +42642,7 @@ } }, "services/populate-express": { - "version": "2.2.6", + "version": "2.2.7", "license": "Apache-2.0", "dependencies": { "@aehrc/sdc-populate": "^2.2.7", diff --git a/packages/smart-forms-renderer/src/utils/calculatedExpression.ts b/packages/smart-forms-renderer/src/utils/calculatedExpression.ts index bc2a5ceb..43a5aaf5 100644 --- a/packages/smart-forms-renderer/src/utils/calculatedExpression.ts +++ b/packages/smart-forms-renderer/src/utils/calculatedExpression.ts @@ -34,7 +34,7 @@ import { getQrItemsIndex, mapQItemsIndex } from './mapItem'; import { updateQrItemsInGroup } from './qrItem'; import cloneDeep from 'lodash.clonedeep'; import dayjs from 'dayjs'; -import { qrItemHasItemsOrAnswer } from './manageForm'; +import { updateQuestionnaireResponse } from './updateQr'; interface EvaluateInitialCalculatedExpressionsParams { initialResponse: QuestionnaireResponse; @@ -165,15 +165,6 @@ export function initialiseCalculatedExpressionValues( populatedResponse: QuestionnaireResponse, calculatedExpressions: Record ): QuestionnaireResponse { - if ( - !questionnaire.item || - questionnaire.item.length === 0 || - !populatedResponse.item || - populatedResponse.item.length === 0 - ) { - return populatedResponse; - } - // Filter calculated expressions, only preserve key-value pairs with values const calculatedExpressionsWithValues: Record = {}; for (const linkId in calculatedExpressions) { @@ -186,41 +177,12 @@ export function initialiseCalculatedExpressionValues( } } - // Populate calculated expression values into QR - const qItemsIndexMap = mapQItemsIndex(questionnaire); - const topLevelQRItemsByIndex = getQrItemsIndex( - questionnaire.item, - populatedResponse.item, - qItemsIndexMap + return updateQuestionnaireResponse( + questionnaire, + populatedResponse, + initialiseItemCalculatedExpressionValueRecursive, + calculatedExpressionsWithValues ); - - const topLevelQrItems: QuestionnaireResponseItem[] = []; - for (const [index, topLevelQItem] of questionnaire.item.entries()) { - const topLevelQRItemOrItems = topLevelQRItemsByIndex[index] ?? { - linkId: topLevelQItem.linkId, - text: topLevelQItem.text, - item: [] - }; - - const updatedTopLevelQRItem = initialiseItemCalculatedExpressionValueRecursive( - topLevelQItem, - topLevelQRItemOrItems, - calculatedExpressionsWithValues - ); - - if (Array.isArray(updatedTopLevelQRItem)) { - if (updatedTopLevelQRItem.length > 0) { - topLevelQrItems.push(...updatedTopLevelQRItem); - } - continue; - } - - if (updatedTopLevelQRItem && qrItemHasItemsOrAnswer(updatedTopLevelQRItem)) { - topLevelQrItems.push(updatedTopLevelQRItem); - } - } - - return { ...populatedResponse, item: topLevelQrItems }; } function initialiseItemCalculatedExpressionValueRecursive( diff --git a/packages/smart-forms-renderer/src/utils/repopulateIntoResponse.ts b/packages/smart-forms-renderer/src/utils/repopulateIntoResponse.ts index 936d7105..57210c38 100644 --- a/packages/smart-forms-renderer/src/utils/repopulateIntoResponse.ts +++ b/packages/smart-forms-renderer/src/utils/repopulateIntoResponse.ts @@ -1,14 +1,9 @@ -import type { - Questionnaire, - QuestionnaireItem, - QuestionnaireResponse, - QuestionnaireResponseItem -} from 'fhir/r4'; +import type { QuestionnaireItem, QuestionnaireResponseItem } from 'fhir/r4'; import type { ItemToRepopulate } from './repopulateItems'; import { getQrItemsIndex, mapQItemsIndex } from './mapItem'; import { isSpecificItemControl } from './itemControl'; import { questionnaireResponseStore, questionnaireStore } from '../stores'; -import { qrItemHasItemsOrAnswer } from './manageForm'; +import { updateQuestionnaireResponse } from './updateQr'; /** * Re-populate checked items in the re-population dialog into the current QuestionnaireResponse @@ -19,63 +14,14 @@ export function repopulateResponse(checkedItemsToRepopulate: Record -): QuestionnaireResponse { - if ( - !questionnaire.item || - questionnaire.item.length === 0 || - !updatableResponse.item || - updatableResponse.item.length === 0 - ) { - return updatableResponse; - } - - const qItemsIndexMap = mapQItemsIndex(questionnaire); - const topLevelQRItemsByIndex = getQrItemsIndex( - questionnaire.item, - updatableResponse.item, - qItemsIndexMap - ); - - const topLevelQrItems: QuestionnaireResponseItem[] = []; - for (const [index, topLevelQItem] of questionnaire.item.entries()) { - const topLevelQRItemOrItems = topLevelQRItemsByIndex[index] ?? { - linkId: topLevelQItem.linkId, - text: topLevelQItem.text, - item: [] - }; - - const updatedTopLevelQRItem = repopulateItemRecursive( - topLevelQItem, - topLevelQRItemOrItems, - checkedItemsToRepopulate - ); - - if (Array.isArray(updatedTopLevelQRItem)) { - if (updatedTopLevelQRItem.length > 0) { - topLevelQrItems.push(...updatedTopLevelQRItem); - } - continue; - } - - if (updatedTopLevelQRItem && qrItemHasItemsOrAnswer(updatedTopLevelQRItem)) { - topLevelQrItems.push(updatedTopLevelQRItem); - } - } - - return { ...updatableResponse, item: topLevelQrItems }; -} - function repopulateItemRecursive( qItem: QuestionnaireItem, qrItemOrItems: QuestionnaireResponseItem | QuestionnaireResponseItem[] | null, diff --git a/packages/smart-forms-renderer/src/utils/updateQr.ts b/packages/smart-forms-renderer/src/utils/updateQr.ts new file mode 100644 index 00000000..1c3b0c39 --- /dev/null +++ b/packages/smart-forms-renderer/src/utils/updateQr.ts @@ -0,0 +1,88 @@ +/* + * Copyright 2024 Commonwealth Scientific and Industrial Research + * Organisation (CSIRO) ABN 41 687 119 230. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import type { + Questionnaire, + QuestionnaireItem, + QuestionnaireResponse, + QuestionnaireResponseItem +} from 'fhir/r4'; +import { getQrItemsIndex, mapQItemsIndex } from './mapItem'; +import { qrItemHasItemsOrAnswer } from './manageForm'; + +export type RepopulateFunction = ( + qItem: QuestionnaireItem, + qrItemOrItems: QuestionnaireResponseItem | QuestionnaireResponseItem[] | null, + extraData: T +) => QuestionnaireResponseItem | QuestionnaireResponseItem[] | null; + +/** + * A generic (and safe) way to update a QuestionnaireResponse given a recursive function and a set of data i.e. Record, Record + * This function relies heavily on mapQItemsIndex() and getQrItemsIndex() to accurately pinpoint the locations of QR items based on their positions in the Q, taking into account repeating group answers, non-filled questions, etc + * + * @author Sean Fong + */ +export function updateQuestionnaireResponse( + questionnaire: Questionnaire, + questionnaireResponse: QuestionnaireResponse, + recursiveUpdateFunction: RepopulateFunction, + extraData: T +) { + if ( + !questionnaire.item || + questionnaire.item.length === 0 || + !questionnaireResponse.item || + questionnaireResponse.item.length === 0 + ) { + return questionnaireResponse; + } + + const qItemsIndexMap = mapQItemsIndex(questionnaire); + const topLevelQRItemsByIndex = getQrItemsIndex( + questionnaire.item, + questionnaireResponse.item, + qItemsIndexMap + ); + + const topLevelQrItems = []; + for (const [index, topLevelQItem] of questionnaire.item.entries()) { + const topLevelQRItemOrItems = topLevelQRItemsByIndex[index] ?? { + linkId: topLevelQItem.linkId, + text: topLevelQItem.text, + item: [] + }; + + const updatedTopLevelQRItem = recursiveUpdateFunction( + topLevelQItem, + topLevelQRItemOrItems, + extraData + ); + + if (Array.isArray(updatedTopLevelQRItem)) { + if (updatedTopLevelQRItem.length > 0) { + topLevelQrItems.push(...updatedTopLevelQRItem); + } + continue; + } + + if (updatedTopLevelQRItem && qrItemHasItemsOrAnswer(updatedTopLevelQRItem)) { + topLevelQrItems.push(updatedTopLevelQRItem); + } + } + + return { ...questionnaireResponse, item: topLevelQrItems }; +} diff --git a/push-extract-image.sh b/push-extract-image.sh index 7a0687de..2468377a 100644 --- a/push-extract-image.sh +++ b/push-extract-image.sh @@ -24,5 +24,5 @@ cd services/extract-express && npm run compile && cd - # Build the Docker image for multiple architectures, then push to Docker Hub. docker buildx build --file ./services/extract-express/Dockerfile \ --tag aehrc/smart-forms-extract:latest \ - --tag aehrc/smart-forms-extract:v0.3.1 \ + --tag aehrc/smart-forms-extract:v0.4.0 \ --platform linux/amd64,linux/arm64/v8 --push --no-cache . diff --git a/services/extract-express/README.md b/services/extract-express/README.md index 45452def..3c7ea5e0 100644 --- a/services/extract-express/README.md +++ b/services/extract-express/README.md @@ -5,6 +5,8 @@ It is an abstraction on top of an existing StructureMap [$transform](https://hl7 A proof-of-concept StructureMap $transform is defined on https://proxy.smartforms.io/fhir/StructureMap/$transform, leveraging Brian's .NET mapping engine from https://github.com/brianpos/fhir-net-mappinglanguage/tree/main/demo-map-server. +A `StructureMap/$convert` operation is also defined in the POC implementation to convert a FHIR Mapping Language map to a StructureMap resource, using the same .NET mapping engine. + ## Configuration Create a .env file (or copy from example.env) in the root of the project with the following: ```env @@ -31,9 +33,10 @@ You can use `docker run -p 3003:3003 -e EHR_SERVER_URL=https://proxy.smartforms. Docker image: https://hub.docker.com/r/aehrc/smart-forms-extract -**By default, ```FORMS_SERVER_URL``` is set to https://smartforms.csiro.au/api/fhir in the Docker image.** +**By default, ```FORMS_SERVER_URL``` is set to https://smartforms.csiro.au/api/fhir in the Docker image.** This endpoint is used to resolve referenced FHIR Questionnaires and StructureMaps. ## Sample implementation -A sample implementation of this service is available at https://proxy.smartforms.io/fhir/QuestionnaireResponse/$extract. +A sample implementation of the `$extract` service is available at https://proxy.smartforms.io/fhir/QuestionnaireResponse/$extract. +`StructureMap/$convert` is available at https://proxy.smartforms.io/fhir/StructureMap/$convert. -Note: The $extract service on https://smartforms.csiro.au/api/fhir only performs processing - it does not persist any data. +Note: The $extract and $convert service on https://proxy.smartforms.io/fhir only performs processing - it does not persist any data. diff --git a/services/extract-express/package.json b/services/extract-express/package.json index 17a61ca5..d99674b9 100644 --- a/services/extract-express/package.json +++ b/services/extract-express/package.json @@ -1,11 +1,11 @@ { "name": "extract-express", - "version": "0.3.1", + "version": "0.4.0", "description": "", "main": "lib/index.js", "scripts": { "compile": "tsc", - "start": "node lib/index.js", + "start": "node --inspect lib/index.js", "start:watch": "node --inspect --watch lib/index.js", "test": "echo \"Error: no test specified\" && exit 1" }, diff --git a/services/extract-express/src/debug.ts b/services/extract-express/src/debug.ts new file mode 100644 index 00000000..cdc7979d --- /dev/null +++ b/services/extract-express/src/debug.ts @@ -0,0 +1,76 @@ +/* + * Copyright 2024 Commonwealth Scientific and Industrial Research + * Organisation (CSIRO) ABN 41 687 119 230. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import type { OperationOutcome, Parameters, ParametersParameter, StructureMap } from 'fhir/r4b'; + +export function responseIsParametersResource(parameters: any): parameters is DebugOutputParameters { + return ( + parameters && + parameters.resourceType === 'Parameters' && + !!parameters.parameter && + parameters.parameter.length > 0 && + parameters.parameter[2].name === 'parameters' && + parameters.parameter[2].part.length > 0 && + !!parameters.parameter[2].part[1] && + parameters.parameter[2].part[1].name === 'source' && + !!parameters.parameter[2].part[1].resource && + parameters.parameter[2].part[1].resource.resourceType === 'StructureMap' + ); +} + +export function getStructureMapFromDebugOutputParameters( + outputParameters: any +): StructureMap | null { + if (!responseIsParametersResource(outputParameters)) { + return null; + } + + return outputParameters.parameter[2].part[1].resource; +} + +export interface DebugOutputParameters extends Parameters { + parameter: [OutcomeParameter, ResultParameter, DebugParametersParameter, TraceParameter]; +} + +interface OutcomeParameter extends ParametersParameter { + name: 'outcome'; + resource: OperationOutcome; +} + +interface ResultParameter extends ParametersParameter { + name: 'result'; + valueString: string; +} + +interface DebugParametersParameter extends ParametersParameter { + name: 'parameters'; + part: [ + { + name: 'evaluator'; + valueString: string; + }, + { + name: 'source'; + resource: StructureMap; + } + ]; +} + +interface TraceParameter extends ParametersParameter { + name: 'content'; + part: any[]; +} diff --git a/services/extract-express/src/fhirMappingLanguage.ts b/services/extract-express/src/fhirMappingLanguage.ts new file mode 100644 index 00000000..315fc076 --- /dev/null +++ b/services/extract-express/src/fhirMappingLanguage.ts @@ -0,0 +1,24 @@ +/* + * Copyright 2024 Commonwealth Scientific and Industrial Research + * Organisation (CSIRO) ABN 41 687 119 230. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +export function getFhirMappingLanguageMap(body: any): string | null { + if (typeof body === 'string' && body.includes('map')) { + return body; + } + + return null; +} diff --git a/services/extract-express/src/index.ts b/services/extract-express/src/index.ts index d77ff000..6924c5e0 100644 --- a/services/extract-express/src/index.ts +++ b/services/extract-express/src/index.ts @@ -19,6 +19,8 @@ import express from 'express'; import cors from 'cors'; import { getQuestionnaireResponse } from './questionnaireResponse'; import { + createFailStructureMapConversionOutcome, + createInvalidFhirMappingLanguageMap, createInvalidParametersOutcome, createInvalidQuestionnaireCanonicalOutcome, createNoFormsServerUrlSetOutcome, @@ -29,8 +31,14 @@ import { } from './operationOutcome'; import { getQuestionnaire } from './questionnaire'; import { getTargetStructureMap, getTargetStructureMapCanonical } from './structureMap'; -import { createTransformInputParameters, invokeTransform } from './transform'; +import { + createTransformInputParametersForConvert, + createTransformInputParametersForExtract, + invokeTransform +} from './transform'; import dotenv from 'dotenv'; +import { getFhirMappingLanguageMap } from './fhirMappingLanguage'; +import { getStructureMapFromDebugOutputParameters } from './debug'; const app = express(); const port = 3003; @@ -52,6 +60,7 @@ app.use( // Allows the app to accept JSON and URL encoded data up to 50MB app.use(express.json({ limit: '50mb' })); app.use(express.urlencoded({ extended: true })); +app.use(express.text()); // Allows the app to work behind reverse proxies, forwarding the correct req.protocol to the /StructureMap/$transform call // Without this, doing a HTTPS $extract call will result in a HTTP $transform call @@ -136,7 +145,7 @@ app.post('/fhir/QuestionnaireResponse/\\$extract', async (req, res) => { return; } - const transformInputParameters = createTransformInputParameters( + const transformInputParameters = createTransformInputParametersForExtract( targetStructureMap, questionnaireResponse ); @@ -165,6 +174,76 @@ app.post('/fhir/QuestionnaireResponse/\\$extract', async (req, res) => { } }); +app.get('/fhir/\\$convert', (_, res) => { + res.send( + 'This service is healthy!\nHowever, this server only supports StructureMap/$convert.\nPerform a POST request to /fhir/StructureMap/$convert to convert a FHIR Mapping Language map to a StructureMap resource.' + ); +}); + +app.get('/fhir/StructureMap/\\$convert', (_, res) => { + res.send( + 'This service is healthy!\nPerform a POST request to the same path to convert a FHIR Mapping Language map to a StructureMap resource.' + ); +}); + +app.post('/fhir/StructureMap/\\$convert', async (req, res) => { + let ehrServerUrl = req.protocol + '://' + req.get('host') + '/fhir'; + let ehrServerAuthToken: string | null = null; + + // Set EHR server URL and auth token if provided in env variables + if (EHR_SERVER_URL) { + ehrServerUrl = EHR_SERVER_URL; + ehrServerAuthToken = EHR_SERVER_AUTH_TOKEN ?? null; + } + + try { + // Get FHIR Mapping Language map from the request body + const body = req.body; + const fhirMappingLanguageMap = getFhirMappingLanguageMap(body); + + if (!fhirMappingLanguageMap) { + const outcome = createInvalidFhirMappingLanguageMap(); + res.status(400).json(outcome); + return; + } + + const transformInputParameters = + createTransformInputParametersForConvert(fhirMappingLanguageMap); + + const outputParameters = await invokeTransform( + transformInputParameters, + ehrServerUrl, + ehrServerAuthToken ?? undefined, + true + ); + + // Get StructureMap resource from the output parameters + const structureMap = getStructureMapFromDebugOutputParameters(outputParameters); + if (!structureMap) { + const outcome = createFailStructureMapConversionOutcome(); + res.status(400).json(outcome); + return; + } + + res.json(structureMap); + } catch (error) { + console.error(error); + if (error instanceof Error) { + res.status(500).json(createOperationOutcome(error?.message)); // Sending the error message as an OperationOutcome + return; + } + + // If the error is not an instance of Error, send a generic error message + res + .status(500) + .json( + createOperationOutcome( + 'Something went wrong here. Please raise a GitHub issue at https://github.com/aehrc/smart-forms/issues/new' + ) + ); + } +}); + app.listen(port, () => { console.log(`Transform Express app listening on port ${port}`); }); diff --git a/services/extract-express/src/operationOutcome.ts b/services/extract-express/src/operationOutcome.ts index 5d130370..e58c78a2 100644 --- a/services/extract-express/src/operationOutcome.ts +++ b/services/extract-express/src/operationOutcome.ts @@ -43,6 +43,19 @@ export function createInvalidParametersOutcome(): OperationOutcome { }; } +export function createInvalidFhirMappingLanguageMap(): OperationOutcome { + return { + resourceType: 'OperationOutcome', + issue: [ + { + severity: 'error', + code: 'invalid', + details: { text: 'Input provided is not a valid FHIR Mapping Language map.' } + } + ] + }; +} + export function createInvalidQuestionnaireCanonicalOutcome(): OperationOutcome { return { resourceType: 'OperationOutcome', @@ -109,6 +122,21 @@ export function createNoTargetStructureMapFoundOutcome( }; } +export function createFailStructureMapConversionOutcome(): OperationOutcome { + return { + resourceType: 'OperationOutcome', + issue: [ + { + severity: 'error', + code: 'invalid', + details: { + text: `Failed to convert the provided FHIR Mapping Language map to a StructureMap.` + } + } + ] + }; +} + export function createOperationOutcome(errorMessage: string): OperationOutcome { return { resourceType: 'OperationOutcome', diff --git a/services/extract-express/src/transform.ts b/services/extract-express/src/transform.ts index f9595318..cf6eecf5 100644 --- a/services/extract-express/src/transform.ts +++ b/services/extract-express/src/transform.ts @@ -24,7 +24,7 @@ import type { } from 'fhir/r4b'; import { HEADERS } from './globals'; -export function createTransformInputParameters( +export function createTransformInputParametersForExtract( targetStructureMap: StructureMap, questionnaireResponse: QuestionnaireResponse ): TransformInputParameters { @@ -43,12 +43,41 @@ export function createTransformInputParameters( }; } +export function createTransformInputParametersForConvert( + fhirMappingLanguageMap: string +): TransformInputParameters { + return { + resourceType: 'Parameters', + parameter: [ + { + name: 'source', + valueString: fhirMappingLanguageMap + }, + // Hardcoded content to be empty QuestionnaireResponse since it is not used in the conversion + { + name: 'content', + resource: { + resourceType: 'QuestionnaireResponse', + status: 'in-progress' + } + } + ] + }; +} + export async function invokeTransform( transformInputParameters: TransformInputParameters, ehrServerUrl: string, - ehrServerAuthToken?: string + ehrServerAuthToken?: string, + debugMode?: boolean ): Promise { - const requestUrl = `${ehrServerUrl}/StructureMap/$transform`; + let requestUrl = `${ehrServerUrl}/StructureMap/$transform`; + + // Brian's demo-map-server by default return xml when debug=true, explicitly set it to json + if (debugMode) { + requestUrl += '?debug=true&_format=json'; + } + const headers = ehrServerAuthToken ? { ...HEADERS, Authorization: `Bearer ${ehrServerAuthToken}` } : HEADERS; @@ -59,8 +88,11 @@ export async function invokeTransform( }); if (!response.ok) { + const debugModeMessage = debugMode + ? '\nNote: The input FML map might not be valid. If it is valid but you are still getting this error, please raise a GitHub issue at https://github.com/aehrc/smart-forms/issues/new' + : ''; throw new Error( - `HTTP error when performing ${ehrServerUrl}/StructureMap/$transform. Status: ${response.status}` + `HTTP error when performing ${ehrServerUrl}/StructureMap/$transform. Status: ${response.status}${debugModeMessage}` ); } @@ -68,14 +100,21 @@ export async function invokeTransform( } export interface TransformInputParameters extends Parameters { - parameter: [SourceParameter, ContentParameter]; + parameter: + | [SourceResourceParameter, ContentParameter] + | [SourceValueStringParameter, ContentParameter]; } -interface SourceParameter extends ParametersParameter { +interface SourceResourceParameter extends ParametersParameter { name: 'source'; resource: StructureMap; } +interface SourceValueStringParameter extends ParametersParameter { + name: 'source'; + valueString: string; +} + interface ContentParameter extends ParametersParameter { name: 'content'; resource: FhirResource;