diff --git a/.github/scripts/validation-patient.sh b/.github/scripts/validation-patient.sh new file mode 100755 index 000000000..4459c4aa1 --- /dev/null +++ b/.github/scripts/validation-patient.sh @@ -0,0 +1,150 @@ +#!/bin/bash -e + +SCRIPT_DIR="$(dirname "$(readlink -f "$0")")" +. "$SCRIPT_DIR/util.sh" + +patient-valid-no-profile() { +cat <=2025.0.0", "@mii-termserv/de.bfarm.ask": ">=2025.1.1", @@ -107,7 +107,7 @@ "@mii-termserv/de.kbv.basis.terminology": "^1.7.0-suts", "@mii-termserv/de.kbv.ita.for": "^1.2.0-suts", "@mii-termserv/de.kbv.schluesseltabellen": "^1.18.0-suts", - "@mii-termserv/de.sutermserv.placeholders": ">=1.0.2", + "@mii-termserv/de.sutermserv.placeholders": ">=1.1.0", "@mii-termserv/hl7.fhir.r4.terminology": "^4.0.1-suts", "@mii-termserv/hl7.terminology.r4": "^5.4.0-suts" } @@ -122,15 +122,15 @@ } }, "node_modules/@mii-termserv/de.kbv.basis.terminology": { - "version": "1.7.0-suts.4", - "resolved": "https://gitlab.com/api/v4/projects/59772971/packages/npm/@mii-termserv/de.kbv.basis.terminology/-/@mii-termserv/de.kbv.basis.terminology-1.7.0-suts.4.tgz", - "integrity": "sha1-+qiz4R3x4d6epPZobj2UwRfWb1k=", + "version": "1.7.0-suts.5", + "resolved": "https://gitlab.com/api/v4/projects/59772971/packages/npm/@mii-termserv/de.kbv.basis.terminology/-/@mii-termserv/de.kbv.basis.terminology-1.7.0-suts.5.tgz", + "integrity": "sha1-FBeytVHx6iMMmfTpUtsT7uf4cvI=", "dependencies": { "@mii-termserv/de.bfarm.ask": ">=2025.1.1", "@mii-termserv/de.bfarm.atc": ">=2025.0.0", "@mii-termserv/de.bfarm.ops": ">=2025.0.1", "@mii-termserv/de.ihe-d.terminology": "^3.0.1-suts", - "@mii-termserv/de.sutermserv.placeholders": ">=1.0.2", + "@mii-termserv/de.sutermserv.placeholders": ">=1.1.0", "@mii-termserv/dicom.terminology": ">=2024.5.19-suts", "@mii-termserv/eu.edqm.standardterms": ">=2025.1.6", "@mii-termserv/hl7.fhir.r4.terminology": "^4.0.1-suts", @@ -163,9 +163,9 @@ } }, "node_modules/@mii-termserv/de.medizininformatikinitiative.kerndatensatz.terminology.bill-of-materials": { - "version": "2025.8.29", - "resolved": "https://gitlab.com/api/v4/projects/61223336/packages/npm/@mii-termserv/de.medizininformatikinitiative.kerndatensatz.terminology.bill-of-materials/-/@mii-termserv/de.medizininformatikinitiative.kerndatensatz.terminology.bill-of-materials-2025.8.29.tgz", - "integrity": "sha1-ZAjFLUWfpxcEUGow96N5ENtdSq4=", + "version": "2025.12.2", + "resolved": "https://gitlab.com/api/v4/projects/61223336/packages/npm/@mii-termserv/de.medizininformatikinitiative.kerndatensatz.terminology.bill-of-materials/-/@mii-termserv/de.medizininformatikinitiative.kerndatensatz.terminology.bill-of-materials-2025.12.2.tgz", + "integrity": "sha1-LQCTsOqQFIQuX7DUpsaCvfiIp70=", "dependencies": { "@mii-termserv/de.medizininformatikinitiative.kerndatensatz.terminology.bildgebung": "2025.0.2-suts.1", "@mii-termserv/de.medizininformatikinitiative.kerndatensatz.terminology.biobank": "2025.0.4-suts.1", @@ -174,11 +174,11 @@ "@mii-termserv/de.medizininformatikinitiative.kerndatensatz.terminology.fall": "2025.0.0-suts.1", "@mii-termserv/de.medizininformatikinitiative.kerndatensatz.terminology.icu": "2025.0.4-suts.1", "@mii-termserv/de.medizininformatikinitiative.kerndatensatz.terminology.laborbefund": "2025.0.2-suts.1", - "@mii-termserv/de.medizininformatikinitiative.kerndatensatz.terminology.medikation": "2025.0.0-suts.3", + "@mii-termserv/de.medizininformatikinitiative.kerndatensatz.terminology.medikation": "2025.0.0-suts.4", "@mii-termserv/de.medizininformatikinitiative.kerndatensatz.terminology.meta": "2025.0.0-suts.1", "@mii-termserv/de.medizininformatikinitiative.kerndatensatz.terminology.mikrobiologie": "2025.0.1-suts.1", "@mii-termserv/de.medizininformatikinitiative.kerndatensatz.terminology.molgen": "2025.0.0-suts.2", - "@mii-termserv/de.medizininformatikinitiative.kerndatensatz.terminology.onkologie": "2025.1.0-suts.2", + "@mii-termserv/de.medizininformatikinitiative.kerndatensatz.terminology.onkologie": "2025.1.0-suts.3", "@mii-termserv/de.medizininformatikinitiative.kerndatensatz.terminology.patho": "2025.0.2-suts.1", "@mii-termserv/de.medizininformatikinitiative.kerndatensatz.terminology.person": "2025.0.0-suts.1", "@mii-termserv/de.medizininformatikinitiative.kerndatensatz.terminology.prozedur": "2025.0.0-suts.1" @@ -258,16 +258,16 @@ } }, "node_modules/@mii-termserv/de.medizininformatikinitiative.kerndatensatz.terminology.medikation": { - "version": "2025.0.0-suts.3", - "resolved": "https://gitlab.com/api/v4/projects/60485127/packages/npm/@mii-termserv/de.medizininformatikinitiative.kerndatensatz.terminology.medikation/-/@mii-termserv/de.medizininformatikinitiative.kerndatensatz.terminology.medikation-2025.0.0-suts.3.tgz", - "integrity": "sha1-NGCBjyxzgDJCBRhEXCk7s+nWdSc=", + "version": "2025.0.0-suts.4", + "resolved": "https://gitlab.com/api/v4/projects/60485127/packages/npm/@mii-termserv/de.medizininformatikinitiative.kerndatensatz.terminology.medikation/-/@mii-termserv/de.medizininformatikinitiative.kerndatensatz.terminology.medikation-2025.0.0-suts.4.tgz", + "integrity": "sha1-D7IO1qZHMgaeqPB2lblDf23KwRE=", "dependencies": { "@mii-termserv/cas.registry": ">=2024.7.23", "@mii-termserv/de.bfarm.ask": ">=2024.7.23", "@mii-termserv/de.bfarm.atc": ">=2025.0.0", "@mii-termserv/de.hl7.basisprofile.terminology": "^1.5.3-suts", "@mii-termserv/de.ihe-d.terminology": "^3.0.1-suts", - "@mii-termserv/de.sutermserv.placeholders": ">=1.0.1", + "@mii-termserv/de.sutermserv.placeholders": ">=1.1.0", "@mii-termserv/eu.edqm.standardterms": ">=2025.7.15", "@mii-termserv/hl7.fhir.r4.terminology": "^4.0.1-suts", "@mii-termserv/hl7.terminology.r4": "^5.4.0-suts", @@ -310,14 +310,14 @@ } }, "node_modules/@mii-termserv/de.medizininformatikinitiative.kerndatensatz.terminology.onkologie": { - "version": "2025.1.0-suts.2", - "resolved": "https://gitlab.com/api/v4/projects/67991355/packages/npm/@mii-termserv/de.medizininformatikinitiative.kerndatensatz.terminology.onkologie/-/@mii-termserv/de.medizininformatikinitiative.kerndatensatz.terminology.onkologie-2025.1.0-suts.2.tgz", - "integrity": "sha1-ukQVjyNkacR7ELHHU8vnFxRjSSw=", + "version": "2025.1.0-suts.3", + "resolved": "https://gitlab.com/api/v4/projects/67991355/packages/npm/@mii-termserv/de.medizininformatikinitiative.kerndatensatz.terminology.onkologie/-/@mii-termserv/de.medizininformatikinitiative.kerndatensatz.terminology.onkologie-2025.1.0-suts.3.tgz", + "integrity": "sha1-m8Wb0jCzMiPPWRg2k6ZjH3h7NBg=", "dependencies": { "@mii-termserv/de.bfarm.icd-10-gm": ">=2025.0.0", "@mii-termserv/de.bfarm.icd-o-3": ">=2.0.0", "@mii-termserv/de.bfarm.ops": ">=2025.0.1", - "@mii-termserv/de.sutermserv.placeholders": ">=1.0.2", + "@mii-termserv/de.sutermserv.placeholders": ">=1.1.0", "@mii-termserv/hl7.fhir.r4.terminology": "^4.0.1-suts", "@mii-termserv/hl7.terminology.r4": "^5.4.0-suts", "@mii-termserv/loinc": ">=2025.3.11", @@ -365,9 +365,9 @@ "integrity": "sha1-Es6V2dDWURfHM9Q69ZRYaf0rPso=" }, "node_modules/@mii-termserv/de.sutermserv.placeholders": { - "version": "1.0.2", - "resolved": "https://gitlab.com/api/v4/projects/59462661/packages/npm/@mii-termserv/de.sutermserv.placeholders/-/@mii-termserv/de.sutermserv.placeholders-1.0.2.tgz", - "integrity": "sha1-radPaKoWuz1KkotFUHMjE6pQQZE=" + "version": "1.1.0", + "resolved": "https://gitlab.com/api/v4/projects/59462661/packages/npm/@mii-termserv/de.sutermserv.placeholders/-/@mii-termserv/de.sutermserv.placeholders-1.1.0.tgz", + "integrity": "sha1-TCsZme1Q6X9zazGYKpqIwQEMuGQ=" }, "node_modules/@mii-termserv/dicom.terminology": { "version": "2024.5.19-suts.2", @@ -401,30 +401,31 @@ } }, "node_modules/@mii-termserv/hl7.terminology.r4": { - "version": "5.4.0-suts.4", - "resolved": "https://gitlab.com/api/v4/projects/61093356/packages/npm/@mii-termserv/hl7.terminology.r4/-/@mii-termserv/hl7.terminology.r4-5.4.0-suts.4.tgz", - "integrity": "sha1-yNgZMjtJS+HO/XAWWxcT8TInKaQ=", + "version": "5.4.0-suts.5", + "resolved": "https://gitlab.com/api/v4/projects/61093356/packages/npm/@mii-termserv/hl7.terminology.r4/-/@mii-termserv/hl7.terminology.r4-5.4.0-suts.5.tgz", + "integrity": "sha1-hpn+IwoPEwUjZWHlIXvywSTXmys=", "dependencies": { - "@mii-termserv/de.sutermserv.misc-resources": ">=1.0.0" + "@mii-termserv/de.sutermserv.misc-resources": ">=1.0.0", + "@mii-termserv/iso.country-codes": ">=2024.7.31" } }, "node_modules/@mii-termserv/hl7.uv.genomics-reporting.terminology": { - "version": "2.0.0-suts.4", - "resolved": "https://gitlab.com/api/v4/projects/60223793/packages/npm/@mii-termserv/hl7.uv.genomics-reporting.terminology/-/@mii-termserv/hl7.uv.genomics-reporting.terminology-2.0.0-suts.4.tgz", - "integrity": "sha1-YYzQOMMHHn+4jGb2F/dDNrAnRUQ=", + "version": "2.0.0-suts.5", + "resolved": "https://gitlab.com/api/v4/projects/60223793/packages/npm/@mii-termserv/hl7.uv.genomics-reporting.terminology/-/@mii-termserv/hl7.uv.genomics-reporting.terminology-2.0.0-suts.5.tgz", + "integrity": "sha1-f4C+ppYFLekrEQKLBY17lsREnQU=", "dependencies": { - "@mii-termserv/de.sutermserv.placeholders": ">=1.0.0", + "@mii-termserv/de.sutermserv.placeholders": ">=1.1.0", "@mii-termserv/hl7.fhir.r4.terminology": "^4.0.1-suts", "@mii-termserv/ontologies": ">=2025.9.9", "@mii-termserv/org.genenames": ">=2025.7.4" } }, "node_modules/@mii-termserv/hl7.uv.ips.terminology": { - "version": "1.1.0-suts.4", - "resolved": "https://gitlab.com/api/v4/projects/59292188/packages/npm/@mii-termserv/hl7.uv.ips.terminology/-/@mii-termserv/hl7.uv.ips.terminology-1.1.0-suts.4.tgz", - "integrity": "sha1-cmJau3WpZKq5FsmrclZNRsod7ko=", + "version": "1.1.0-suts.5", + "resolved": "https://gitlab.com/api/v4/projects/59292188/packages/npm/@mii-termserv/hl7.uv.ips.terminology/-/@mii-termserv/hl7.uv.ips.terminology-1.1.0-suts.5.tgz", + "integrity": "sha1-GmyGsW6mXFg3YjEIDFmyvBprGDw=", "dependencies": { - "@mii-termserv/de.sutermserv.placeholders": ">=1.0.0", + "@mii-termserv/de.sutermserv.placeholders": ">=1.1.0", "@mii-termserv/dicom.terminology": ">=2024.5.19-suts", "@mii-termserv/hl7.fhir.r4.terminology": "^4.0.1-suts", "@mii-termserv/hl7.terminology.r4": "^5.4.0-suts", @@ -450,10 +451,15 @@ "resolved": "https://gitlab.com/api/v4/projects/57866495/packages/npm/@mii-termserv/iso-ieee.11073-10101/-/@mii-termserv/iso-ieee.11073-10101-2024.12.5.tgz", "integrity": "sha1-V/xVLrIwdhHKrjikIsCXg6D6UvY=" }, + "node_modules/@mii-termserv/iso.country-codes": { + "version": "2024.7.31", + "resolved": "https://gitlab.com/api/v4/projects/66405148/packages/npm/@mii-termserv/iso.country-codes/-/@mii-termserv/iso.country-codes-2024.7.31.tgz", + "integrity": "sha1-hAxgt5brpJIJe0ObnTTvX98XtRg=" + }, "node_modules/@mii-termserv/loinc": { - "version": "2025.3.11", - "resolved": "https://gitlab.com/api/v4/projects/57841883/packages/npm/@mii-termserv/loinc/-/@mii-termserv/loinc-2025.3.11.tgz", - "integrity": "sha1-kTYPpjlGnsKNNWe//pRBNTcsYqo=" + "version": "2025.12.5", + "resolved": "https://gitlab.com/api/v4/projects/57841883/packages/npm/@mii-termserv/loinc/-/@mii-termserv/loinc-2025.12.5.tgz", + "integrity": "sha1-ejoP7q7iLAN+U0DrUt8Wr8ZnLKc=" }, "node_modules/@mii-termserv/ontologies": { "version": "2025.9.9", @@ -471,9 +477,9 @@ "integrity": "sha1-172DlVbschzk+3vmme3SqQRl32k=" }, "node_modules/@mii-termserv/snomed-ct": { - "version": "2025.7.1", - "resolved": "https://gitlab.com/api/v4/projects/57841874/packages/npm/@mii-termserv/snomed-ct/-/@mii-termserv/snomed-ct-2025.7.1.tgz", - "integrity": "sha1-m0op4OjKuRFsVcRhmAuyP12rHjc=" + "version": "2025.11.15", + "resolved": "https://gitlab.com/api/v4/projects/57841874/packages/npm/@mii-termserv/snomed-ct/-/@mii-termserv/snomed-ct-2025.11.15.tgz", + "integrity": "sha1-BRk06N19npjOH2RMYH1VyqIMhyY=" }, "node_modules/@mii-termserv/us.fda.unii": { "version": "2025.7.2", diff --git a/.github/value-set-expand/package.json b/.github/value-set-expand/package.json index 00ba89d73..6d6dd57e0 100644 --- a/.github/value-set-expand/package.json +++ b/.github/value-set-expand/package.json @@ -1,5 +1,5 @@ { "dependencies": { - "@mii-termserv/de.medizininformatikinitiative.kerndatensatz.terminology.bill-of-materials": "^2025.8.29" + "@mii-termserv/de.medizininformatikinitiative.kerndatensatz.terminology.bill-of-materials": "^2025.12.2" } } diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 068eb010c..2deef5efe 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -144,6 +144,7 @@ jobs: - server - terminology-service - thread-pool-executor-collector + - validator java-version: - '21' @@ -1774,7 +1775,7 @@ jobs: - name: Expand Most KDS Terminology Resources run: .github/value-set-expand/expand-most.sh - integration-test-validation: + integration-test-terminology-service-validation: needs: build runs-on: ubuntu-24.04 @@ -1849,6 +1850,55 @@ jobs: - name: SNOMED CT Validate Code run: .github/scripts/snomed-ct-validate-code.sh + + integration-test-validation: + needs: build + runs-on: ubuntu-24.04 + + steps: + - name: Setup Java + uses: actions/setup-java@dded0888837ed1f317902acf8a20df0ad188d165 # v5 + with: + distribution: 'temurin' + java-version: '21' + + - name: Check out Git repository + uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5 + + - name: Setup Node + uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4 + with: + node-version-file: .nvmrc + + - name: Install Blazectl + env: + GH_TOKEN: ${{ github.token }} + run: .github/scripts/install-blazectl.sh + + - name: Download Blaze Image + uses: actions/download-artifact@634f93cb2916e3fdff6788551b99b062d0335ce0 # v5 + with: + name: blaze-image + path: /tmp + + - name: Load Blaze Image + run: docker load --input /tmp/blaze.tar + + - name: Run Blaze + run: docker run --name blaze -d -e JAVA_TOOL_OPTIONS=-Xmx8g -e ENABLE_VALIDATION_ON_INGEST=true -p 8080:8080 --read-only --tmpfs /tmp:exec -v blaze-data:/app/data blaze:latest + + - name: Wait for Blaze + run: .github/scripts/wait-for-url.sh http://localhost:8080/health + + - name: Docker Logs + run: docker logs blaze + + - name: Run Validation tests + run: .github/scripts/validation-patient.sh + + - name: Load Data + run: blazectl --no-progress --server http://localhost:8080/fhir upload .github/test-data/synthea + terminology-tests: needs: build runs-on: ubuntu-24.04 @@ -2731,6 +2781,7 @@ jobs: - integration-test-kds - integration-test-patient-purge - integration-test-value-set-expand + - integration-test-terminology-service-validation - integration-test-validation - terminology-tests - not-enforcing-referential-integrity-test diff --git a/docs/.vitepress/config.ts b/docs/.vitepress/config.ts index 59f2971f4..e3d035188 100644 --- a/docs/.vitepress/config.ts +++ b/docs/.vitepress/config.ts @@ -160,6 +160,12 @@ export default defineConfig({ { text: 'Validation', link: '/terminology-service/validation' } ] }, + { + text: 'Validation', + items: [ + { text: 'Overview', link: '/validation' } + ] + }, { text: 'Usage', items: [ diff --git a/docs/api/interaction/create.md b/docs/api/interaction/create.md index 32d76acd8..6d3bcd560 100644 --- a/docs/api/interaction/create.md +++ b/docs/api/interaction/create.md @@ -9,3 +9,7 @@ POST [base]/[type] ## Conditional Create It's possible to use conditional create in transaction or batch requests. However references to already existing resources, currently can't be resolved. If you need this feature, please vote on the issue [Implement Conditional References](https://github.com/samply/blaze/issues/433). + +## Validation Support + +See [Validation](../../validation.md). diff --git a/docs/api/interaction/transaction.md b/docs/api/interaction/transaction.md index 8bea3932a..e8cc9a8d9 100644 --- a/docs/api/interaction/transaction.md +++ b/docs/api/interaction/transaction.md @@ -6,7 +6,7 @@ The transaction interaction allows to submit a set of actions to be performed in POST [base] ``` -## Processing Rules +## Processing Rules References in transaction bundles are resolved according to [Resolving references in Bundles][1]. Especially absolute URIs like URNs and URLs can be used as well as relative references in entries with absolute RESTful fullUrls. @@ -14,4 +14,8 @@ References in transaction bundles are resolved according to [Resolving reference It's possible to use conditional create in transaction requests. However references to already existing resources, currently can't be resolved. If you need this feature, please vote on the issue [Implement Conditional References](https://github.com/samply/blaze/issues/433). -[1]: +## Validation Support + +See [Validation](../../validation.md). + +[1]: https://hl7.org/fhir/bundle.html#references diff --git a/docs/api/interaction/update.md b/docs/api/interaction/update.md index ef666a22c..f8ca63fe0 100644 --- a/docs/api/interaction/update.md +++ b/docs/api/interaction/update.md @@ -15,3 +15,7 @@ Some resources like base FHIR StructureDefinition resources are read-only and ca ## Conditional Update Conditional update interaction will be implemented in the future. Please see issue [#361](https://github.com/samply/blaze/issues/361) for more information. + +## Validation Support + +See [Validation](../../validation.md). diff --git a/docs/deployment/environment-variables.md b/docs/deployment/environment-variables.md index 5ff19c8cc..c3c4ddd63 100644 --- a/docs/deployment/environment-variables.md +++ b/docs/deployment/environment-variables.md @@ -42,7 +42,7 @@ The name of a file with additional CA certificates needed to access especially t ## Backend -Blaze backend is configured solely through environment variables. There is a default for every variable. So all variables are optional. +Blaze backend is configured solely through environment variables. There is a default for every variable. So all variables are optional. Some of the environment variables depend on the storage variant chosen. The storage variant can be set through the `STORAGE` env var. The default is `standalone,` with `in-memory` and `distributed` as the other options. The following sections list the relevant environment variables by storage variant. @@ -52,7 +52,7 @@ The three database directories must not exist on the first start of Blaze and wi #### `INDEX_DB_DIR` -The directory were the index database files are stored. | +The directory were the index database files are stored. | **Default:** `/app/data/index` @@ -138,7 +138,7 @@ The number of resources which are indexed in a batch. (Deprecated) ### Distributed -The distributed storage variant only uses the index database locally. +The distributed storage variant only uses the index database locally. #### `INDEX_DB_DIR` @@ -268,7 +268,7 @@ Timeout in milliseconds for all requests to the Cassandra cluster. **Default:** 2000 -More information about distributed deployment are available [here](distributed-backend.md). +More information about distributed deployment are available [here](distributed-backend.md). ### Common Environment Variables @@ -335,7 +335,7 @@ one of trace, debug, info, warn or error #### `JAVA_TOOL_OPTIONS` | Name | Default | Since | Description | -|:--------------------------|:--------|:------|:-------------------------------------------------------------| +| :------------------------ | :------ | :---- | :----------------------------------------------------------- | | -⁠Xmx4g | - | | The maximum amount of heap memory. | | -⁠Dhttp.proxyHost | - | v0.11 | The hostname of the proxy server for outbound HTTP requests. | | -⁠Dhttp.proxyPort | 80 | v0.11 | The port of the proxy server. | @@ -348,7 +348,7 @@ The number threads used for [$evaluate-measure](../api/operation/measure-evaluat #### `FHIR_OPERATION_EVALUATE_MEASURE_TIMEOUT` -Timeout in milliseconds for synchronous [$evaluate-measure](../api/operation/measure-evaluate-measure.md) executions. It's recommended to set this as short as possible in order to prevent bad designed CQL queries to impede other CQL queries and the overall performance of the server. +Timeout in milliseconds for synchronous [$evaluate-measure](../api/operation/measure-evaluate-measure.md) executions. It's recommended to set this as short as possible in order to prevent bad designed CQL queries to impede other CQL queries and the overall performance of the server. **Default:** 3600000 (1h) @@ -394,10 +394,10 @@ services: environment: DB_SEARCH_PARAM_BUNDLE: "/app/custom-search-parameters.json" ports: - - "8080:8080" + - "8080:8080" volumes: - - "custom-search-parameters.json:/app/custom-search-parameters.json:ro" - - "blaze-data:/app/data" + - "custom-search-parameters.json:/app/custom-search-parameters.json:ro" + - "blaze-data:/app/data" volumes: blaze-data: ``` @@ -450,7 +450,7 @@ The duration after page store entries expire. Lower that value if the size of th #### `ENABLE_TERMINOLOGY_SERVICE` -Enable the [Terminology Service](../terminology-service.md). This enables terminology operations in [CQL Queries](../cql-queries.md), but it's recommended to separate the terminology server from a data server were CQL queries are run. Please use the env var `EXTERN_TERMINOLOGY_SERVICE_URL` to connect to an external terminology service for data servers. +Enable the [Terminology Service](../terminology-service.md). This enables terminology operations in [CQL Queries](../cql-queries.md), but it's recommended to separate the terminology server from a data server were CQL queries are run. Please use the env var `EXTERN_TERMINOLOGY_SERVICE_URL` to connect to an external terminology service for data servers. **Default:** `false` @@ -493,9 +493,13 @@ keytool -importcert -storetype PKCS12 -keystore "trust-store.p12" \ The password for the PKCS #12 trust store. -[1]: -[2]: -[3]: -[4]: -[5]: <../authentication.md> -[6]: +#### `ENABLE_VALIDATION_ON_INGEST` + +Enable [Validation](../validation.md). + +[1]: https://docs.oracle.com/en/java/javase/11/docs/api/java.base/java/net/doc-files/net-properties.html#Proxies +[2]: https://github.com/facebook/rocksdb/wiki/Setup-Options-and-Basic-Tuning#block-cache-size +[3]: https://github.com/facebook/rocksdb/wiki/Thread-Pool +[4]: https://openid.net/connect/ +[5]: ../authentication.md +[6]: http://tx.fhir.org/r4 diff --git a/docs/validation.md b/docs/validation.md new file mode 100644 index 000000000..e41d64b14 --- /dev/null +++ b/docs/validation.md @@ -0,0 +1,33 @@ +# Validation + +Blaze supports validation based on profiles (`StructureDefinition`) upon resource ingest (create and update). +To enable validation, set the environment variable `ENABLE_VALIDATION_ON_INGEST` to true. + +The profile, which a resource is validated against, has to exist on the server and needs to be named in `meta.profile` of the resource. +Validation also works if they are inserted as part of a [transaction/batch](./api/interaction/transaction.md) request. + +Example: + +```json +{ + "resourceType": "Patient", + ... + "meta": { + "profile": "http://example.org/url-114730" + } +} +``` + +**Note:** Validation skips empty resources in a `Bundle`, as they are not a valid use case. + +## Caching + +Validation profiles are cached upon initialization of the `validator`. If a new profiles is created or an existing profile is modified or deleted this cache gets invalidated and rebuilt automatically. + +## Limitations + +Validation is performed without terminology support. + +## Performance + +Validation of resources comes at a performance cost when creating or updating resources. diff --git a/modules/admin-api/deps.edn b/modules/admin-api/deps.edn index faf7fc3a8..7f46b73d4 100644 --- a/modules/admin-api/deps.edn +++ b/modules/admin-api/deps.edn @@ -34,61 +34,15 @@ blaze/spec {:local/root "../spec"} + blaze/validator + {:local/root "../validator"} + fi.metosin/reitit-openapi - {:mvn/version "0.9.2" + {:mvn/version "0.9.1" :exclusions [javax.xml.bind/jaxb-api]} - ca.uhn.hapi.fhir/hapi-fhir-validation - {:mvn/version "8.6.0" - :exclusions - [com.nimbusds/nimbus-jose-jwt - commons-beanutils/commons-beanutils - info.cqframework/cql - info.cqframework/qdm - info.cqframework/quick - info.cqframework/cql-to-elm - info.cqframework/elm - info.cqframework/model - io.opentelemetry.instrumentation/opentelemetry-instrumentation-annotations - net.sf.saxon/Saxon-HE - net.sourceforge.plantuml/plantuml-mit - org.ogce/xpp3 - ognl/ognl - org.attoparser/attoparser - org.unbescape/unbescape - org.xerial/sqlite-jdbc - org.apache.commons/commons-collections4 - org.apache.httpcomponents/httpclient - com.google.errorprone/error_prone_annotations - org.apache.santuario/xmlsec - org.commonmark/commonmark - org.commonmark/commonmark-ext-gfm-tables]} - - ca.uhn.hapi.fhir/hapi-fhir-structures-r4 - {:mvn/version "8.6.0" - :exclusions - [com.google.code.findbugs/jsr305 - commons-net/commons-net - io.opentelemetry.instrumentation/opentelemetry-instrumentation-annotations - ;; Remove after https://github.com/hapifhir/hapi-fhir/issues/7005 is fixed - org.apache.jena/jena-shex - net.sf.saxon/Saxon-HE]} - - ca.uhn.hapi.fhir/hapi-fhir-validation-resources-r4 - {:mvn/version "8.6.0" - :exclusions - [com.google.code.findbugs/jsr305 - io.opentelemetry/opentelemetry-api - io.opentelemetry.instrumentation/opentelemetry-instrumentation-annotations - org.slf4j/jcl-over-slf4j]} - - ca.uhn.hapi.fhir/hapi-fhir-caching-caffeine - {:mvn/version "8.6.0" - :exclusions - [io.opentelemetry.instrumentation/opentelemetry-instrumentation-annotations]} - com.fasterxml.jackson.datatype/jackson-datatype-jsr310 - {:mvn/version "2.20.1"} + {:mvn/version "2.20.0"} org.fhir/ucum {:mvn/version "1.0.10" diff --git a/modules/admin-api/src/blaze/admin_api.clj b/modules/admin-api/src/blaze/admin_api.clj index d9b15d6f1..d6eec589f 100644 --- a/modules/admin-api/src/blaze/admin_api.clj +++ b/modules/admin-api/src/blaze/admin_api.clj @@ -2,7 +2,6 @@ (:refer-clojure :exclude [str]) (:require [blaze.admin-api.spec] - [blaze.admin-api.validation] [blaze.anomaly :as ba :refer [if-ok]] [blaze.async.comp :as ac :refer [do-sync]] [blaze.db.impl.index.patient-last-change :as plc] @@ -14,7 +13,6 @@ [blaze.elm.expression.spec] [blaze.fhir.parsing-context.spec] [blaze.fhir.response.create :as create-response] - [blaze.fhir.spec :as fhir-spec] [blaze.fhir.writing-context.spec] [blaze.handler.fhir.util :as fhir-util] [blaze.handler.util :as handler-util] @@ -30,7 +28,8 @@ [blaze.module :as m] [blaze.spec] [blaze.util :refer [str]] - [clojure.datafy :as datafy] + [blaze.validator :as validator] + [blaze.validator.spec] [clojure.spec.alpha :as s] [integrant.core :as ig] [jsonista.core :as j] @@ -40,17 +39,9 @@ [ring.util.response :as ring] [taoensso.timbre :as log]) (:import - [ca.uhn.fhir.context FhirContext] - [ca.uhn.fhir.context.support DefaultProfileValidationSupport] - [ca.uhn.fhir.validation FhirValidator] [com.google.common.base CaseFormat] [java.io File] - [java.nio.file Files] - [org.hl7.fhir.common.hapi.validation.support - CommonCodeSystemsTerminologyService - InMemoryTerminologyServerValidationSupport PrePopulatedValidationSupport - ValidationSupportChain] - [org.hl7.fhir.common.hapi.validation.validator FhirInstanceValidator])) + [java.nio.file Files])) (set! *warn-on-reflection* true) @@ -188,9 +179,9 @@ :wrap link-headers/wrap-link-headers}) (def ^:private allowed-profiles - #{#fhir/canonical "https://samply.github.io/blaze/fhir/StructureDefinition/AsyncInteractionJob" - #fhir/canonical "https://samply.github.io/blaze/fhir/StructureDefinition/CompactJob" - #fhir/canonical "https://samply.github.io/blaze/fhir/StructureDefinition/ReIndexJob"}) + #{#fhir/canonical"https://samply.github.io/blaze/fhir/StructureDefinition/AsyncInteractionJob" + #fhir/canonical"https://samply.github.io/blaze/fhir/StructureDefinition/CompactJob" + #fhir/canonical"https://samply.github.io/blaze/fhir/StructureDefinition/ReIndexJob"}) (defn- check-profile [resource] (if (some allowed-profiles (-> resource :meta :profile)) @@ -201,29 +192,19 @@ {:fhir/type :fhir/OperationOutcome :issue [{:fhir/type :fhir.OperationOutcome/issue - :severity #fhir/code "error" - :code #fhir/code "value" + :severity #fhir/code"error" + :code #fhir/code"value" :details #fhir/CodeableConcept - {:text #fhir/string "No allowed profile found."}}]}))) - -(defn- validate [^FhirValidator validator writing-context resource] - (->> ^String (fhir-spec/write-json-as-string writing-context resource) - (.validateWithResult validator) - (.toOperationOutcome) - (datafy/datafy))) - -(defn- error-issues [outcome] - (update outcome :issue (partial filterv (comp #{#fhir/code "error"} :severity)))) + {:text "No allowed profile found."}}]}))) (def ^:private wrap-validate-job {:name :wrap-validate-job - :wrap (fn [handler validator writing-context] + :wrap (fn [handler validator] (fn [{:keys [body] :as request}] (if-ok [body (check-profile body)] - (let [outcome (error-issues (validate validator writing-context body))] - (if (seq (:issue outcome)) - (ac/completed-future (ring/bad-request outcome)) - (handler request))) + (if-let [outcome (validator/validate validator body)] + (ac/completed-future (ring/bad-request outcome)) + (handler request)) #(ac/completed-future (ring/bad-request (:outcome %))))))}) (defn wrap-error* [handler] @@ -402,7 +383,7 @@ :handler search-type-job-handler} :post {:middleware [[wrap-resource parsing-context "Task"] - [wrap-validate-job validator writing-context]] + [wrap-validate-job validator]] :handler create-job-handler}}] ["/{id}" ["" @@ -455,57 +436,6 @@ {:path (str context-path "/__admin") :syntax :bracket})) -(defn- load-profile [context name] - (log/debug "Load profile" name) - (let [parser (.newJsonParser ^FhirContext context) - classloader (.getContextClassLoader (Thread/currentThread))] - (with-open [source (.getResourceAsStream classloader name)] - (.parseResource parser source)))) - -(defn- profile-validation-support [context] - (let [s (PrePopulatedValidationSupport. context)] - (run! - #(.addResource s (load-profile context %)) - ["blaze/db/CodeSystem-ColumnFamily.json" - "blaze/db/CodeSystem-Database.json" - "blaze/db/ValueSet-ColumnFamily.json" - "blaze/db/ValueSet-Database.json" - "blaze/job_scheduler/StructureDefinition-Job.json" - "blaze/job_scheduler/CodeSystem-JobType.json" - "blaze/job_scheduler/CodeSystem-JobOutput.json" - "blaze/job/async_interaction/StructureDefinition-AsyncInteractionJob.json" - "blaze/job/async_interaction/StructureDefinition-AsyncInteractionRequestBundle.json" - "blaze/job/async_interaction/StructureDefinition-AsyncInteractionResponseBundle.json" - "blaze/job/async_interaction/CodeSystem-AsyncInteractionJobOutput.json" - "blaze/job/async_interaction/CodeSystem-AsyncInteractionJobParameter.json" - "blaze/job/compact/CodeSystem-CompactJobOutput.json" - "blaze/job/compact/CodeSystem-CompactJobParameter.json" - "blaze/job/compact/StructureDefinition-CompactJob.json" - "blaze/job/re_index/StructureDefinition-ReIndexJob.json" - "blaze/job/re_index/CodeSystem-ReIndexJobOutput.json" - "blaze/job/re_index/CodeSystem-ReIndexJobParameter.json"]) - s)) - -(defn- create-validator* [] - (let [context (FhirContext/forR4) - _ (.newJsonParser context) - validator (.newValidator context) - chain (doto (ValidationSupportChain.) - (.addValidationSupport (DefaultProfileValidationSupport. context)) - (.addValidationSupport (InMemoryTerminologyServerValidationSupport. context)) - (.addValidationSupport (CommonCodeSystemsTerminologyService. context)) - (.addValidationSupport (profile-validation-support context))) - instanceValidator (FhirInstanceValidator. chain)] - (.registerValidatorModule validator instanceValidator) - validator)) - -(defn- create-validator [] - (try - (create-validator*) - (catch Exception e - (log/error e) - (throw e)))) - (defn- create-job-handler [job-scheduler] (fn [{:keys [body] :as request}] (do-sync [job (js/create-job job-scheduler (iu/strip-meta body))] @@ -553,7 +483,7 @@ (defmethod m/pre-init-spec :blaze/admin-api [_] (s/keys :req-un [:blaze/context-path ::admin-node :blaze.fhir/parsing-context - :blaze.fhir/writing-context :blaze/job-scheduler + :blaze.fhir/writing-context :blaze/job-scheduler :blaze/validator ::read-job-handler ::history-job-handler ::search-type-job-handler ::settings ::features] :opt [::dbs ::expr/cache ::db-sync-timeout])) @@ -562,7 +492,7 @@ [_ {:keys [job-scheduler] :as config}] (log/info "Init Admin endpoint") (reitit.ring/ring-handler - (router (assoc config :validator (create-validator) + (router (assoc config :create-job-handler (create-job-handler job-scheduler) :pause-job-handler (job-action-handler job-scheduler js/pause-job) :resume-job-handler (job-action-handler job-scheduler js/resume-job) diff --git a/modules/admin-api/src/blaze/admin_api/validation.clj b/modules/admin-api/src/blaze/admin_api/validation.clj deleted file mode 100644 index 191bef39a..000000000 --- a/modules/admin-api/src/blaze/admin_api/validation.clj +++ /dev/null @@ -1,35 +0,0 @@ -(ns blaze.admin-api.validation - (:require - [blaze.fhir.spec.type :as type] - [clojure.core.protocols :as p] - [clojure.datafy :as datafy]) - (:import - [org.hl7.fhir.r4.model - CodeableConcept OperationOutcome - OperationOutcome$OperationOutcomeIssueComponent])) - -(set! *warn-on-reflection* true) - -(extend-protocol p/Datafiable - OperationOutcome - (datafy [outcome] - {:fhir/type :fhir/OperationOutcome - :issue (mapv datafy/datafy (.getIssue outcome))}) - - OperationOutcome$OperationOutcomeIssueComponent - (datafy [issue] - (cond-> {:fhir/type :fhir.OperationOutcome/issue} - (.hasSeverity issue) - (assoc :severity (type/code (.toCode (.getSeverity issue)))) - (.hasCode issue) - (assoc :code (type/code (.toCode (.getCode issue)))) - (.hasDetails issue) - (assoc :details (datafy/datafy (.getDetails issue))) - (.hasDiagnostics issue) - (assoc :diagnostics (type/string (.getDiagnostics issue))))) - - CodeableConcept - (datafy [concept] - (cond-> #fhir/CodeableConcept{} - (.hasText concept) - (assoc :text (type/string (.getText concept)))))) diff --git a/modules/admin-api/test/blaze/admin_api_test.clj b/modules/admin-api/test/blaze/admin_api_test.clj index ddf828d7b..fa7363443 100644 --- a/modules/admin-api/test/blaze/admin_api_test.clj +++ b/modules/admin-api/test/blaze/admin_api_test.clj @@ -211,6 +211,7 @@ :parsing-context (ig/ref :blaze.fhir.parsing-context/default) :writing-context (ig/ref :blaze.fhir/writing-context) :job-scheduler (ig/ref :blaze/job-scheduler) + :validator (ig/ref :blaze/validator) :read-job-handler (ig/ref :blaze.interaction/read) :history-job-handler (ig/ref :blaze.interaction.history/instance) :search-type-job-handler (ig/ref :blaze.interaction/search-type) @@ -226,6 +227,10 @@ :clock (ig/ref :blaze.test/fixed-clock) :rng-fn (ig/ref :blaze.test/fixed-rng-fn)} + :blaze/validator + {:node (ig/ref :blaze.db.main/node) + :writing-context (ig/ref :blaze.fhir/writing-context)} + :blaze.interaction/create {:node (ig/ref :blaze.db.admin/node) :clock (ig/ref :blaze.test/fixed-clock) @@ -276,11 +281,12 @@ [:cause-data ::s/problems 2 :pred] := `(fn ~'[%] (contains? ~'% :parsing-context)) [:cause-data ::s/problems 3 :pred] := `(fn ~'[%] (contains? ~'% :writing-context)) [:cause-data ::s/problems 4 :pred] := `(fn ~'[%] (contains? ~'% :job-scheduler)) - [:cause-data ::s/problems 5 :pred] := `(fn ~'[%] (contains? ~'% :read-job-handler)) - [:cause-data ::s/problems 6 :pred] := `(fn ~'[%] (contains? ~'% :history-job-handler)) - [:cause-data ::s/problems 7 :pred] := `(fn ~'[%] (contains? ~'% :search-type-job-handler)) - [:cause-data ::s/problems 8 :pred] := `(fn ~'[%] (contains? ~'% :settings)) - [:cause-data ::s/problems 9 :pred] := `(fn ~'[%] (contains? ~'% :features)))) + [:cause-data ::s/problems 5 :pred] := `(fn ~'[%] (contains? ~'% :validator)) + [:cause-data ::s/problems 6 :pred] := `(fn ~'[%] (contains? ~'% :read-job-handler)) + [:cause-data ::s/problems 7 :pred] := `(fn ~'[%] (contains? ~'% :history-job-handler)) + [:cause-data ::s/problems 8 :pred] := `(fn ~'[%] (contains? ~'% :search-type-job-handler)) + [:cause-data ::s/problems 9 :pred] := `(fn ~'[%] (contains? ~'% :settings)) + [:cause-data ::s/problems 10 :pred] := `(fn ~'[%] (contains? ~'% :features)))) (testing "invalid context path" (given-failed-system (assoc-in (config!) [:blaze/admin-api :context-path] ::invalid) @@ -310,6 +316,20 @@ [:cause-data ::s/problems 0 :via] := [:blaze.fhir/writing-context] [:cause-data ::s/problems 0 :val] := ::invalid)) + (testing "invalid job-scheduler" + (given-failed-system (assoc-in (config!) [:blaze/admin-api :job-scheduler] ::invalid) + :key := :blaze/admin-api + :reason := ::ig/build-failed-spec + [:cause-data ::s/problems 0 :via] := [:blaze/job-scheduler] + [:cause-data ::s/problems 0 :val] := ::invalid)) + + (testing "invalid validator" + (given-failed-system (assoc-in (config!) [:blaze/admin-api :validator] ::invalid) + :key := :blaze/admin-api + :reason := ::ig/build-failed-spec + [:cause-data ::s/problems 0 :via] := [:blaze/validator] + [:cause-data ::s/problems 0 :val] := ::invalid)) + (testing "invalid settings" (given-failed-system (assoc-in (config!) [:blaze/admin-api :settings] ::invalid) :key := :blaze/admin-api diff --git a/modules/db-stub/src/blaze/db/api_stub.clj b/modules/db-stub/src/blaze/db/api_stub.clj index 0a54b62af..a3ad028f7 100644 --- a/modules/db-stub/src/blaze/db/api_stub.clj +++ b/modules/db-stub/src/blaze/db/api_stub.clj @@ -20,7 +20,7 @@ [integrant.core :as ig] [java-time.api :as time])) -(def ^:private root-system +(def root-system "Root part of the system initialized for performance reasons." (ig/init {:blaze.fhir/parsing-context diff --git a/modules/rest-api/deps.edn b/modules/rest-api/deps.edn index 9da6d4630..2a38ebf20 100644 --- a/modules/rest-api/deps.edn +++ b/modules/rest-api/deps.edn @@ -17,6 +17,9 @@ blaze/terminology-service {:local/root "../terminology-service"} + blaze/validator + {:local/root "../validator"} + buddy/buddy-auth {:mvn/version "3.0.323" :exclusions [buddy/buddy-sign]} diff --git a/modules/rest-api/src/blaze/rest_api.clj b/modules/rest-api/src/blaze/rest_api.clj index 912abec20..b8fb3e661 100644 --- a/modules/rest-api/src/blaze/rest_api.clj +++ b/modules/rest-api/src/blaze/rest_api.clj @@ -15,6 +15,7 @@ [blaze.rest-api.spec] [blaze.rest-api.structure-definitions :as structure-definitions] [blaze.spec] + [blaze.validator.spec] [buddy.auth.middleware :refer [wrap-authentication]] [clojure.spec.alpha :as s] [integrant.core :as ig] @@ -67,6 +68,7 @@ :blaze/page-id-cipher] :opt-un [:blaze/context-path + :blaze/validator ::auth-backends ::search-system-handler ::transaction-handler diff --git a/modules/rest-api/src/blaze/rest_api/routes.clj b/modules/rest-api/src/blaze/rest_api/routes.clj index ce63f3d9d..be36cf824 100644 --- a/modules/rest-api/src/blaze/rest_api/routes.clj +++ b/modules/rest-api/src/blaze/rest_api/routes.clj @@ -7,6 +7,7 @@ [blaze.middleware.fhir.error :as error] [blaze.middleware.fhir.output :as fhir-output] [blaze.middleware.fhir.resource :as resource] + [blaze.middleware.fhir.validate :as validate] [blaze.middleware.link-headers :as link-headers] [blaze.middleware.output :as output] [blaze.rest-api.middleware.auth-guard :as auth-guard] @@ -41,6 +42,10 @@ {:name :resource :wrap resource/wrap-resource}) +(def ^:private wrap-validate + {:name :validate + :wrap validate/wrap-validate}) + (def ^:private wrap-binary-data {:name :binary-data :wrap resource/wrap-binary-data}) @@ -100,7 +105,7 @@ Route data contains the resource type under :fhir.resource/type." {:arglists '([config resource-patterns structure-definition])} - [{:keys [node db-sync-timeout batch? page-id-cipher parsing-context]} + [{:keys [node db-sync-timeout batch? validator page-id-cipher parsing-context]} resource-patterns {:keys [name] :as structure-definition}] (when-let [{:blaze.rest-api.resource-pattern/keys [interactions]} @@ -118,9 +123,11 @@ :blaze.rest-api.interaction/handler)}) (contains? interactions :create) (assoc :post {:interaction "create" - :middleware (if (= name "Binary") - [[wrap-binary-data parsing-context]] - [[wrap-resource parsing-context name]]) + :middleware (cond-> + [(if (= name "Binary") + [wrap-binary-data parsing-context] + [wrap-resource parsing-context name])] + (some? validator) (conj [wrap-validate validator])) :handler (-> interactions :create :blaze.rest-api.interaction/handler)}) (contains? interactions :conditional-delete-type) @@ -188,9 +195,11 @@ :blaze.rest-api.interaction/handler)}) (contains? interactions :update) (assoc :put {:interaction "update" - :middleware (if (= name "Binary") - [[wrap-binary-data parsing-context]] - [[wrap-resource parsing-context name]]) + :middleware (cond-> + [(if (= name "Binary") + [wrap-binary-data parsing-context] + [wrap-resource parsing-context name])] + (some? validator) (conj [wrap-validate validator])) :handler (-> interactions :update :blaze.rest-api.interaction/handler)}) (contains? interactions :delete) @@ -349,6 +358,7 @@ async-status-cancel-handler capabilities-handler admin-handler + validator page-id-cipher parsing-context writing-context] @@ -372,7 +382,9 @@ :handler search-system-handler}) (some? transaction-handler) (assoc :post {:interaction "transaction" - :middleware [[wrap-resource parsing-context "Bundle"]] + :middleware (cond-> + [[wrap-resource parsing-context "Bundle"]] + (some? validator) (conj [wrap-validate validator])) :handler transaction-handler}))] ["/metadata" {:interaction "capabilities" diff --git a/modules/rest-api/test/blaze/rest_api/routes_test.clj b/modules/rest-api/test/blaze/rest_api/routes_test.clj index 301f4047e..d0f842894 100644 --- a/modules/rest-api/test/blaze/rest_api/routes_test.clj +++ b/modules/rest-api/test/blaze/rest_api/routes_test.clj @@ -8,11 +8,13 @@ [blaze.job-scheduler] [blaze.middleware.fhir.output-spec] [blaze.middleware.fhir.resource-spec] + [blaze.middleware.fhir.validate-spec] [blaze.module.test-util :refer [with-system]] [blaze.rest-api.middleware.metrics :as metrics] [blaze.rest-api.routes :as routes] [blaze.rest-api.routes-spec] [blaze.test-util :as tu] + [blaze.validator] [clojure.spec.test.alpha :as st] [clojure.test :as test :refer [are deftest testing]] [integrant.core :as ig] @@ -88,6 +90,9 @@ {:node (ig/ref :blaze.db/node) :clock (ig/ref :blaze.test/fixed-clock) :rng-fn (ig/ref :blaze.test/fixed-rng-fn)} + :blaze/validator + {:node (ig/ref :blaze.db/node) + :writing-context (ig/ref :blaze.fhir/writing-context)} :blaze.test/fixed-rng-fn {} [:blaze.fhir/parsing-context :blaze.fhir.parsing-context/default] {:structure-definition-repo structure-definition-repo} @@ -348,6 +353,18 @@ "/Measure/0/$evaluate-measure" :post [:observe-request-duration :params :output :error :forwarded :sync :db :resource] "/Measure/0" :get [:observe-request-duration :params :output :error :forwarded :sync :db])) + (testing "with validator" + (let [config (assoc config :validator (:blaze/validator system)) + router (router config system)] + (are [path request-method middleware] + (= middleware + (->> (get-in + (reitit/match-by-path router path) + [:result request-method :data :middleware]) + (mapv (comp :name #(if (sequential? %) (first %) %))))) + "/Patient" :post [:observe-request-duration :params :output :error :forwarded :sync :resource :validate] + "/Patient/0" :put [:observe-request-duration :params :output :error :forwarded :sync :resource :validate]))) + (testing "as batch" (let [router (router (assoc config :batch? true) system)] (are [path request-method middleware] @@ -384,7 +401,19 @@ "/Measure/$evaluate-measure" :post [:observe-request-duration :params :output :error :forwarded :sync :db :resource] "/Measure/0/$evaluate-measure" :get [:observe-request-duration :params :output :error :forwarded :sync :db] "/Measure/0/$evaluate-measure" :post [:observe-request-duration :params :output :error :forwarded :sync :db :resource] - "/Measure/0" :get [:observe-request-duration :params :output :error :forwarded :sync :db]))))) + "/Measure/0" :get [:observe-request-duration :params :output :error :forwarded :sync :db])) + + (testing "with validator" + (let [config (assoc config :validator (:blaze/validator system)) + router (router (assoc config :batch? true) system)] + (are [path request-method middleware] + (= middleware + (->> (get-in + (reitit/match-by-path router path) + [:result request-method :data :middleware]) + (mapv (comp :name #(if (sequential? %) (first %) %))))) + "/Patient" :post [:observe-request-duration :params :output :error :forwarded :sync :resource :validate] + "/Patient/0" :put [:observe-request-duration :params :output :error :forwarded :sync :resource :validate])))))) (deftest middleware-with-auth-backends-test (with-system [system system-config] diff --git a/modules/rest-util/deps.edn b/modules/rest-util/deps.edn index 5d94429ec..23bbd1377 100644 --- a/modules/rest-util/deps.edn +++ b/modules/rest-util/deps.edn @@ -11,6 +11,9 @@ blaze/page-id-cipher {:local/root "../page-id-cipher"} + blaze/validator + {:local/root "../validator"} + buddy/buddy-core {:mvn/version "1.12.0-430"} diff --git a/modules/rest-util/src/blaze/middleware/fhir/validate.clj b/modules/rest-util/src/blaze/middleware/fhir/validate.clj new file mode 100644 index 000000000..50e514c6d --- /dev/null +++ b/modules/rest-util/src/blaze/middleware/fhir/validate.clj @@ -0,0 +1,15 @@ +(ns blaze.middleware.fhir.validate + "FHIR Resource profile validation middleware." + + (:require + [blaze.async.comp :as ac] + [blaze.validator :as validator] + [ring.util.response :as ring])) + +(defn wrap-validate [handler validator] + (fn [{:keys [body] :as request}] + (if (and (:fhir/type body) (not (#{:fhir/StructureDefinition :fhir/CodeSystem :fhir/ValueSet} (:fhir/type body)))) + (if-let [outcome (validator/validate validator body)] + (ac/completed-future (ring/bad-request outcome)) + (handler request)) + (handler request)))) diff --git a/modules/rest-util/src/blaze/middleware/fhir/validate_spec.clj b/modules/rest-util/src/blaze/middleware/fhir/validate_spec.clj new file mode 100644 index 000000000..4b9c8f436 --- /dev/null +++ b/modules/rest-util/src/blaze/middleware/fhir/validate_spec.clj @@ -0,0 +1,8 @@ +(ns blaze.middleware.fhir.validate-spec + (:require + [blaze.middleware.fhir.validate :as validate] + [blaze.validator.spec] + [clojure.spec.alpha :as s])) + +(s/fdef validate/wrap-validate + :args (s/cat :handler ifn? :validator :blaze/validator)) diff --git a/modules/rest-util/test/blaze/middleware/fhir/validate_test.clj b/modules/rest-util/test/blaze/middleware/fhir/validate_test.clj new file mode 100644 index 000000000..e72c50b47 --- /dev/null +++ b/modules/rest-util/test/blaze/middleware/fhir/validate_test.clj @@ -0,0 +1,142 @@ +(ns blaze.middleware.fhir.validate-test + (:require + [blaze.async.comp :as ac] + [blaze.db.api-spec] + [blaze.db.api-stub :as api-stub :refer [root-system with-system-data]] + [blaze.handler.fhir.util-spec] + [blaze.middleware.fhir.db-spec] + [blaze.middleware.fhir.validate :refer [wrap-validate]] + [blaze.middleware.fhir.validate-spec] + [blaze.module.test-util :refer [with-system]] + [blaze.test-util :as tu] + [clojure.spec.test.alpha :as st] + [clojure.test :as test :refer [deftest is testing]] + [integrant.core :as ig] + [juxt.iota :refer [given]] + [ring.util.response :as ring])) + +(st/instrument) + +(test/use-fixtures :each tu/fixture) + +(def config + (assoc api-stub/mem-node-config + :blaze/validator {:node (ig/ref :blaze.db/node) + :writing-context (:blaze.fhir/writing-context root-system)})) + +(deftest wrap-validate-test + (testing "on empty body" + (with-system [{:blaze/keys [validator]} config] + (let [{:keys [status]} + @((wrap-validate (fn [_] (ac/completed-future (ring/response {}))) validator) {})] + + (testing "shortcuts to handler" + (is (= 200 status)))))) + + (testing "on body without resource type" + (with-system [{:blaze/keys [validator]} config] + (let [{:keys [status]} + @((wrap-validate (fn [_] (ac/completed-future (ring/response {}))) validator) + {:body {:id "0"}})] + + (testing "shortcuts to handler" + (is (= 200 status)))))) + + (testing "without defined profile" + (with-system [{:blaze/keys [validator]} config] + (let [{:keys [status]} + @((wrap-validate (fn [_] (ac/completed-future (ring/response {}))) validator) + {:body {:fhir/type :fhir/Patient + :id "0"}})] + + (testing "shortcuts to handler" + (is (= 200 status)))))) + + (testing "on non-matching single profile" + (doseq [tx-data [[] + [[[:put {:fhir/type :fhir/StructureDefinition :id "0" + :url #fhir/uri "url-110950" + :type #fhir/uri "Patient"}]]] + [[[:put {:fhir/type :fhir/StructureDefinition :id "0" + :url #fhir/uri "url-114730" + :type #fhir/uri "Observation"}]]]]] + (with-system-data [{:blaze/keys [validator]} config] + tx-data + + (let [{:keys [status body]} + @((wrap-validate (fn [_] :foo) validator) + {:body {:fhir/type :fhir/Patient :id "0" + :meta #fhir/Meta{:profile [#fhir/canonical "http://example.org/url-114730"]}}})] + + (testing "returns error" + (is (= 400 status)) + + (given body + :fhir/type := :fhir/OperationOutcome + [:issue 0 :severity] := #fhir/code"error" + [:issue 0 :code] := #fhir/code"processing" + [:issue 0 :diagnostics] := "Profile reference 'http://example.org/url-114730' has not been checked because it could not be found")))))) + + (testing "on non-matching multiple profiles" + (with-system-data [{:blaze/keys [validator]} config] + [[[:put {:fhir/type :fhir/StructureDefinition :id "0" + :url #fhir/uri "http://example.org/url-110950" + :type #fhir/uri "Patient" + :baseDefinition #fhir/canonical "http://hl7.org/fhir/StructureDefinition/Patient" + :derivation #fhir/code "constraint"}]]] + + (let [{:keys [status body]} + @((wrap-validate (fn [_] :foo) validator) + {:body {:fhir/type :fhir/Patient :id "0" + :meta #fhir/Meta{:profile [#fhir/canonical "http://example.org/url-110950" + #fhir/canonical "http://example.org/url-121830"]}}})] + + (testing "returns error" + (is (= 400 status)) + + (given body + :fhir/type := :fhir/OperationOutcome + [:issue 0 :severity] := #fhir/code"error" + [:issue 0 :code] := #fhir/code"processing" + [:issue 0 :diagnostics] := "Profile reference 'http://example.org/url-121830' has not been checked because it could not be found"))))) + + (testing "on matching single profile" + (with-system-data [{:blaze/keys [validator]} config] + [[[:put {:fhir/type :fhir/StructureDefinition :id "0" + :url #fhir/uri "http://example.org/url-114730" + :type #fhir/uri "Patient" + :baseDefinition #fhir/canonical "http://hl7.org/fhir/StructureDefinition/Patient" + :derivation #fhir/code "constraint" + :differential + {:fhir/type :fhir.StructureDefinition/differential + :element + [{:fhir/type :fhir/ElementDefinition + :id "Patient.active" + :path #fhir/string "Patient.active" + :mustSupport #fhir/boolean true + :min #fhir/unsignedInt 1}]}}]]] + + (testing "invalid patient" + (let [{:keys [status body]} + @((wrap-validate (fn [_] (ac/completed-future (ring/response {}))) validator) + {:body {:fhir/type :fhir/Patient :id "0" + :meta #fhir/Meta{:profile [#fhir/canonical "http://example.org/url-114730"]}}})] + + (testing "returns error" + (is (= 400 status)) + + (given body + :fhir/type := :fhir/OperationOutcome + [:issue 0 :severity] := #fhir/code"error" + [:issue 0 :code] := #fhir/code"processing" + [:issue 0 :diagnostics] := "Patient.active: minimum required = 1, but only found 0 (from http://example.org/url-114730)")))) + + (testing "valid patient" + (let [{:keys [status]} + @((wrap-validate (fn [_] (ac/completed-future (ring/response {}))) validator) + {:body {:fhir/type :fhir/Patient :id "0" + :meta #fhir/Meta{:profile [#fhir/canonical "http://example.org/url-114730"]} + :active #fhir/boolean true}})] + + (testing "continues to handler" + (is (= 200 status)))))))) diff --git a/modules/validator/.clj-kondo/config.edn b/modules/validator/.clj-kondo/config.edn new file mode 100644 index 000000000..029cc37f6 --- /dev/null +++ b/modules/validator/.clj-kondo/config.edn @@ -0,0 +1,7 @@ +{:config-paths + ["../../../.clj-kondo/root" + "../../anomaly/resources/clj-kondo.exports/blaze/anomaly" + "../../async/resources/clj-kondo.exports/blaze/async" + "../../db-stub/resources/clj-kondo.exports/blaze/db-stub" + "../../module-base/resources/clj-kondo.exports/prom-metrics/prom-metrics" + "../../module-test-util/resources/clj-kondo.exports/blaze/module-test-util"]} diff --git a/modules/validator/Makefile b/modules/validator/Makefile new file mode 100644 index 000000000..e96bcfb5b --- /dev/null +++ b/modules/validator/Makefile @@ -0,0 +1,31 @@ +fmt: + cljfmt check src test deps.edn tests.edn + +lint: + clj-kondo --lint src test deps.edn + +prep: + clojure -X:deps prep + +test: prep + clojure -M:test:kaocha --profile :ci + +test-coverage: prep + clojure -M:test:coverage + +deps-tree: + clojure -X:deps tree + +deps-list: + clojure -X:deps list + +cloc-prod: + cloc src + +cloc-test: + cloc test + +clean: + rm -rf .clj-kondo/.cache .cpcache target + +.PHONY: fmt lint prep test test-coverage deps-tree deps-list cloc-prod cloc-test clean diff --git a/modules/validator/deps.edn b/modules/validator/deps.edn new file mode 100644 index 000000000..b53b16367 --- /dev/null +++ b/modules/validator/deps.edn @@ -0,0 +1,96 @@ +{:deps + {blaze/async + {:local/root "../async"} + + blaze/db + {:local/root "../db"} + + blaze/fhir-structure + {:local/root "../fhir-structure"} + + blaze/job-scheduler + {:local/root "../job-scheduler"} + + blaze/job-async-interaction + {:local/root "../job-async-interaction"} + + blaze/job-compact + {:local/root "../job-compact"} + + blaze/job-re-index + {:local/root "../job-re-index"} + + ca.uhn.hapi.fhir/hapi-fhir-validation + {:mvn/version "8.6.0" + :exclusions + [commons-beanutils/commons-beanutils + info.cqframework/cql + info.cqframework/qdm + info.cqframework/quick + info.cqframework/cql-to-elm + info.cqframework/elm + info.cqframework/model + io.opentelemetry.instrumentation/opentelemetry-instrumentation-annotations + net.sf.saxon/Saxon-HE + net.sourceforge.plantuml/plantuml-mit + org.ogce/xpp3 + ognl/ognl + org.attoparser/attoparser + org.unbescape/unbescape + org.xerial/sqlite-jdbc + org.apache.commons/commons-collections4 + org.apache.httpcomponents/httpclient + com.google.errorprone/error_prone_annotations + org.apache.santuario/xmlsec + org.commonmark/commonmark + org.commonmark/commonmark-ext-gfm-tables]} + + ca.uhn.hapi.fhir/hapi-fhir-structures-r4 + {:mvn/version "8.6.0" + :exclusions + [com.google.code.findbugs/jsr305 + commons-net/commons-net + io.opentelemetry.instrumentation/opentelemetry-instrumentation-annotations + ;; Remove after https://github.com/hapifhir/hapi-fhir/issues/7005 is fixed + org.apache.jena/jena-shex + net.sf.saxon/Saxon-HE]} + + ca.uhn.hapi.fhir/hapi-fhir-validation-resources-r4 + {:mvn/version "8.6.0" + :exclusions + [com.google.code.findbugs/jsr305 + io.opentelemetry/opentelemetry-api + io.opentelemetry.instrumentation/opentelemetry-instrumentation-annotations + org.slf4j/jcl-over-slf4j]} + + ca.uhn.hapi.fhir/hapi-fhir-caching-caffeine + {:mvn/version "8.6.0" + :exclusions + [io.opentelemetry.instrumentation/opentelemetry-instrumentation-annotations]} + + ca.uhn.hapi.fhir/hapi-fhir-client + {:mvn/version "8.6.0"}} + + :aliases + {:test + {:extra-paths ["test"] + + :extra-deps + {blaze/db-stub + {:local/root "../db-stub"} + com.squareup.okhttp3/mockwebserver + {:mvn/version "5.3.2"}}} + + :kaocha + {:extra-deps + {lambdaisland/kaocha + {:mvn/version "1.91.1392"}} + + :main-opts ["-m" "kaocha.runner"]} + + :coverage + {:extra-deps + {lambdaisland/kaocha-cloverage + {:mvn/version "1.1.89"}} + + :main-opts ["-m" "kaocha.runner" "--profile" "coverage"]}}} diff --git a/modules/validator/src/blaze/validator.clj b/modules/validator/src/blaze/validator.clj new file mode 100644 index 000000000..c13ae8396 --- /dev/null +++ b/modules/validator/src/blaze/validator.clj @@ -0,0 +1,183 @@ +(ns blaze.validator + "FHIR Resource profile validation middleware." + + (:require + [blaze.async.flow :as flow] + [blaze.coll.core :as coll] + [blaze.db.api :as d] + [blaze.db.spec] + [blaze.fhir.spec :as fhir-spec] + [blaze.fhir.spec.type :as type] + [blaze.fhir.writing-context.spec] + [blaze.module :as m] + [blaze.validator.spec] + [clojure.core.protocols :as p] + [clojure.datafy :as datafy] + [clojure.spec.alpha :as s] + [integrant.core :as ig] + [taoensso.timbre :as log]) + + (:import + [ca.uhn.fhir.context FhirContext] + [ca.uhn.fhir.context.support DefaultProfileValidationSupport] + [ca.uhn.fhir.validation FhirValidator] + [java.io ByteArrayInputStream] + [java.util.concurrent Flow$Subscriber] + [org.hl7.fhir.common.hapi.validation.support + BaseValidationSupport + CommonCodeSystemsTerminologyService + InMemoryTerminologyServerValidationSupport PrePopulatedValidationSupport + RemoteTerminologyServiceValidationSupport + ValidationSupportChain] + [org.hl7.fhir.common.hapi.validation.validator FhirInstanceValidator] + [org.hl7.fhir.instance.model.api IBaseResource] + [org.hl7.fhir.r4.model + CodeableConcept + OperationOutcome + OperationOutcome$OperationOutcomeIssueComponent + StringType])) + +(set! *warn-on-reflection* true) + +(extend-protocol p/Datafiable + OperationOutcome + (datafy [outcome] + {:fhir/type :fhir/OperationOutcome + :issue (mapv datafy/datafy (.getIssue outcome))}) + + OperationOutcome$OperationOutcomeIssueComponent + (datafy [issue] + (cond-> {:fhir/type :fhir.OperationOutcome/issue} + (.hasSeverity issue) + (assoc :severity (type/code (.toCode (.getSeverity issue)))) + (.hasCode issue) + (assoc :code (type/code (.toCode (.getCode issue)))) + (.hasDetails issue) + (assoc :details (datafy/datafy (.getDetails issue))) + (.hasDiagnostics issue) + (assoc :diagnostics (.getDiagnostics issue)) + (.hasExpression issue) + (assoc :expression (mapv datafy/datafy (.getExpression issue))))) + + CodeableConcept + (datafy [concept] + (cond-> {:fhir/type :fhir.CodeableConcept} + (.hasText concept) + (assoc :text (.getText concept)))) + + StringType + (datafy [string] + (.toString string))) + +(defn- error-issues [outcome] + (update outcome :issue (partial filterv (comp #{#fhir/code"error"} :severity)))) + +(defn- drop-empty-operation-outcome [operation-outcome] + (if (empty? (:issue operation-outcome)) + nil + operation-outcome)) + +(defn- transform-resource + "Transforms `resource` from the internal FHIR representation to a HAPI resource." + [context writing-context resource] + (let [parser (.newJsonParser ^FhirContext context) + source (fhir-spec/write-json-as-bytes writing-context resource)] + (.parseResource parser (ByteArrayInputStream. source)))) + +(defn validate [{:keys [validator fhir-context writing-context]} resource] + (->> ^IBaseResource (transform-resource fhir-context writing-context resource) + (.validateWithResult ^FhirValidator validator) + (.toOperationOutcome) + (datafy/datafy) + (error-issues) + (drop-empty-operation-outcome))) + +(defn- db-profile-validation-support [context writing-context node] + (proxy [BaseValidationSupport] [context] + (fetchAllStructureDefinitions [] + (map #(transform-resource context writing-context %) + @(d/pull-many node (vec (d/type-list (d/db node) "StructureDefinition"))))) + (fetchStructureDefinition [url] + (when-let [handle (coll/first (d/type-query (d/db node) "StructureDefinition" [["url" url]]))] + (transform-resource context writing-context @(d/pull node handle)))))) + +(defn- load-profile [context name] + (log/debug "Load profile" name) + (let [parser (.newJsonParser ^FhirContext context) + classloader (.getContextClassLoader (Thread/currentThread))] + (with-open [source (.getResourceAsStream classloader name)] + (.parseResource parser source)))) + +(defn- admin-profile-validation-support [context] + (let [s (PrePopulatedValidationSupport. context)] + (run! + #(.addResource s (load-profile context %)) + ["blaze/db/CodeSystem-ColumnFamily.json" + "blaze/db/CodeSystem-Database.json" + "blaze/db/ValueSet-ColumnFamily.json" + "blaze/db/ValueSet-Database.json" + "blaze/job_scheduler/StructureDefinition-Job.json" + "blaze/job_scheduler/CodeSystem-JobType.json" + "blaze/job_scheduler/CodeSystem-JobOutput.json" + "blaze/job/async_interaction/StructureDefinition-AsyncInteractionJob.json" + "blaze/job/async_interaction/StructureDefinition-AsyncInteractionRequestBundle.json" + "blaze/job/async_interaction/StructureDefinition-AsyncInteractionResponseBundle.json" + "blaze/job/async_interaction/CodeSystem-AsyncInteractionJobOutput.json" + "blaze/job/async_interaction/CodeSystem-AsyncInteractionJobParameter.json" + "blaze/job/compact/CodeSystem-CompactJobOutput.json" + "blaze/job/compact/CodeSystem-CompactJobParameter.json" + "blaze/job/compact/StructureDefinition-CompactJob.json" + "blaze/job/re_index/StructureDefinition-ReIndexJob.json" + "blaze/job/re_index/CodeSystem-ReIndexJobOutput.json" + "blaze/job/re_index/CodeSystem-ReIndexJobParameter.json"]) + s)) + +(deftype StructureDefinitionSubscriber [validation-support-chain ^:volatile-mutable subscription] + Flow$Subscriber + (onSubscribe [_ s] + (set! subscription s) + (flow/request! subscription 1)) + (onNext [_ structure-definition-handles] + (log/trace "Got" (count structure-definition-handles) "changed StructureDefinition(s)") + (.invalidateCaches ^ValidationSupportChain validation-support-chain) + (flow/request! subscription 1)) + (onError [_ e] + (log/fatal "Validator cache invalidation failed. Please restart Blaze. Cause:" (ex-message e)) + (flow/cancel! subscription)) + (onComplete [_])) + +(defn add-validation-support! [^ValidationSupportChain chain test validation-support] + (when test + (.addValidationSupport chain validation-support))) + +(defn- create-validator [node writing-context terminology-service-base-url] + (let [^FhirContext fhir-context (FhirContext/forR4) + _ (.newJsonParser fhir-context) + validator (.newValidator fhir-context) + chain (doto (ValidationSupportChain.) + (.addValidationSupport (DefaultProfileValidationSupport. fhir-context)) + (.addValidationSupport (InMemoryTerminologyServerValidationSupport. fhir-context)) + (add-validation-support! terminology-service-base-url (RemoteTerminologyServiceValidationSupport. fhir-context terminology-service-base-url)) + (.addValidationSupport (CommonCodeSystemsTerminologyService. fhir-context)) + (.addValidationSupport (db-profile-validation-support fhir-context writing-context node)) + (.addValidationSupport (admin-profile-validation-support fhir-context))) + instanceValidator (FhirInstanceValidator. chain)] + (.registerValidatorModule validator instanceValidator) + + {:validator validator + :fhir-context fhir-context + :writing-context writing-context + :validation-support-chain chain})) + +(defmethod m/pre-init-spec :blaze/validator [_] + (s/keys :req-un [:blaze.db/node :blaze.fhir/writing-context] + :opt-un [::terminology-service-base-url])) + +(defmethod ig/init-key :blaze/validator + [_ {:keys [node writing-context terminology-service-base-url]}] + (log/info "Init Validator") + (let [{:keys [validation-support-chain] :as validator} (create-validator node writing-context terminology-service-base-url) + publisher (d/changed-resources-publisher node "StructureDefinition") + subscriber (->StructureDefinitionSubscriber validation-support-chain nil)] + (flow/subscribe! publisher subscriber) + validator)) diff --git a/modules/validator/src/blaze/validator/spec.clj b/modules/validator/src/blaze/validator/spec.clj new file mode 100644 index 000000000..bb679ea6f --- /dev/null +++ b/modules/validator/src/blaze/validator/spec.clj @@ -0,0 +1,9 @@ +(ns blaze.validator.spec + (:require + [clojure.spec.alpha :as s])) + +(s/def :blaze/validator + map?) + +(s/def :blaze.validator/terminology-service-base-url + string?) diff --git a/modules/validator/test/blaze/validator_test.clj b/modules/validator/test/blaze/validator_test.clj new file mode 100644 index 000000000..59ca04d09 --- /dev/null +++ b/modules/validator/test/blaze/validator_test.clj @@ -0,0 +1,302 @@ +(ns blaze.validator-test + (:require + [blaze.db.api :as d] + [blaze.db.api-spec] + [blaze.db.api-stub :as api-stub :refer [root-system with-system-data]] + [blaze.module.test-util :refer [given-failed-system with-system]] + [blaze.test-util :as tu] + [blaze.validator :as validator] + [blaze.validator.spec] + [clojure.spec.alpha :as s] + [clojure.spec.test.alpha :as st] + [clojure.test :as test :refer [deftest is testing]] + [integrant.core :as ig] + [juxt.iota :refer [given]] + [taoensso.timbre :as log]) + (:import [okhttp3.mockwebserver Dispatcher MockResponse MockWebServer RecordedRequest])) + +(st/instrument) +(log/set-min-level! :trace) + +(test/use-fixtures :each tu/fixture) + +(def config + (assoc api-stub/mem-node-config + :blaze/validator {:node (ig/ref :blaze.db/node) + :writing-context (:blaze.fhir/writing-context root-system)})) + +(deftest init-test + (testing "nil config" + (given-failed-system {:blaze/validator nil} + :key := :blaze/validator + :reason := ::ig/build-failed-spec + [:cause-data ::s/problems 0 :pred] := `map?)) + + (testing "missing config" + (given-failed-system {:blaze/validator {}} + :key := :blaze/validator + :reason := ::ig/build-failed-spec + [:cause-data ::s/problems 0 :pred] := `(fn ~'[%] (contains? ~'% :node)) + [:cause-data ::s/problems 1 :pred] := `(fn ~'[%] (contains? ~'% :writing-context)))) + + (testing "invalid node" + (given-failed-system (assoc-in config [:blaze/validator :node] ::invalid) + :key := :blaze/validator + :reason := ::ig/build-failed-spec + [:cause-data ::s/problems 0 :via] := [:blaze.db/node] + [:cause-data ::s/problems 0 :val] := ::invalid)) + + (testing "invalid writing context" + (given-failed-system (assoc-in config [:blaze/validator :writing-context] ::invalid) + :key := :blaze/validator + :reason := ::ig/build-failed-spec + [:cause-data ::s/problems 0 :via] := [:blaze.fhir/writing-context] + [:cause-data ::s/problems 0 :val] := ::invalid)) + + (testing "invalid terminology-service-base-url" + (given-failed-system (assoc-in config [:blaze/validator :terminology-service-base-url] ::invalid) + :key := :blaze/validator + :reason := ::ig/build-failed-spec + [:cause-data ::s/problems 0 :via] := [:blaze.validator/terminology-service-base-url] + [:cause-data ::s/problems 0 :val] := ::invalid)) + + (testing "with minimal config" + (with-system [{:blaze/keys [validator]} config] + (is (some? validator))))) + +(deftest validate-test + (log/set-min-level! :debug) + (testing "with existing profile" + (let [mock-web-server (MockWebServer.) + mock-base-url (str (.url mock-web-server "fhir")) + dispatcher (proxy [Dispatcher] + [] + (dispatch [^RecordedRequest request] + (.. request getRequestUrl encodedPath) + (.. request getRequestUrl encodedQuery) + (condp = (.. request getRequestUrl encodedPath) + "/fhir/ValueSet" + (-> (MockResponse.) + (.setBody "{\"resourceType\": \"Bundle\"}") + (.setHeader "Content-Type" "application/fhir+json")) + + "/fhir/metadata" + (-> (MockResponse.) + (.setBody "{\"resourceType\": \"CapabilityStatement\", \"fhirVersion\": \"4.0.1\"}") + (.setHeader "Content-Type" "application/fhir+json")) + + (-> (MockResponse.) + (.setBody "{}") + (.setHeader "Content-Type" "application/fhir+json"))))) + + config (assoc-in config [:blaze/validator :terminology-service-base-url] mock-base-url)] + + (.setDispatcher mock-web-server dispatcher) + + (with-system-data [{:blaze/keys [validator]} config] + [[[:put {:fhir/type :fhir/StructureDefinition :id "0" + :url #fhir/uri "http://example.org/url-114730" + :type #fhir/uri "Patient" + :baseDefinition #fhir/canonical "http://hl7.org/fhir/StructureDefinition/Patient" + :derivation #fhir/code "constraint" + :differential + {:fhir/type :fhir.StructureDefinition/differential + :element + [{:fhir/type :fhir/ElementDefinition + :id "Patient.active" + :path #fhir/string "Patient.active" + :mustSupport #fhir/boolean true + :min #fhir/unsignedInt 1}]}}]]] + + (testing "on matching profile for resource type" + (testing "invalid patient" + (let [result (validator/validate validator {:fhir/type :fhir/Patient :id "0" + :meta #fhir/Meta{:profile [#fhir/canonical "http://example.org/url-114730"]}})] + + (testing "returns error" + (given result + :fhir/type := :fhir/OperationOutcome + [:issue 0 :severity] := #fhir/code"error" + [:issue 0 :code] := #fhir/code"processing" + [:issue 0 :diagnostics] := "Patient.active: minimum required = 1, but only found 0 (from http://example.org/url-114730)" + [:issue 0 :expression] := ["Patient"]))) + + (testing "in bundle" + (let [result (validator/validate validator {:fhir/type :fhir/Bundle + :type #fhir/code "transaction" + :entry + [{:fhir/type :fhir.Bundle/entry + :resource + {:fhir/type :fhir/Patient + :meta #fhir/Meta{:profile [#fhir/canonical "http://example.org/url-114730"]}} + :request + {:fhir/type :fhir.Bundle.entry/request + :method #fhir/code "POST" + :url #fhir/uri "/Patient"}}]})] + (testing "returns error" + (given result + :fhir/type := :fhir/OperationOutcome + [:issue 0 :severity] := #fhir/code"error" + [:issue 0 :code] := #fhir/code"processing" + [:issue 0 :diagnostics] := "Patient.active: minimum required = 1, but only found 0 (from http://example.org/url-114730)")))))) + + (testing "valid patient" + (is (nil? (validator/validate validator {:fhir/type :fhir/Patient :id "0" + :meta #fhir/Meta{:profile [#fhir/canonical "http://example.org/url-114730"]} + :active #fhir/boolean true}))) + + (testing "in bundle" + (is (nil? (validator/validate validator {:fhir/type :fhir/Bundle + :type #fhir/code "transaction" + :entry + [{:fhir/type :fhir.Bundle/entry + :resource + {:fhir/type :fhir/Patient + :meta #fhir/Meta{:profile [#fhir/canonical "http://example.org/url-114730"]} + :active #fhir/boolean true} + :request + {:fhir/type :fhir.Bundle.entry/request + :method #fhir/code "POST" + :url #fhir/uri "/Patient"}}]}))))) + + (testing "on non-matching profile of other resource type" + (let [result (validator/validate validator {:fhir/type :fhir/Observation :id "0" + :meta #fhir/Meta{:profile [#fhir/canonical "http://example.org/url-114730"]}})] + + (testing "returns error" + (given result + :fhir/type := :fhir/OperationOutcome + [:issue 0 :severity] := #fhir/code"error" + [:issue 0 :code] := #fhir/code"processing" + [:issue 0 :diagnostics] := "Specified profile type was 'Patient' in profile 'http://example.org/url-114730', but found type 'Observation'")))) + + (testing "on defined profile of other resource type in db" + (testing "valid observation" + (is (nil? (validator/validate validator {:fhir/type :fhir/Observation + :id "0" + :status #fhir/code "registered" + :code #fhir/CodeableConcept{:text #fhir/string "loinc"}})))) + + (testing "invalid observation" + (let [result (validator/validate validator {:fhir/type :fhir/Observation + :id "0" + :status #fhir/code "registered"})] + (testing "returns error" + (given result + :fhir/type := :fhir/OperationOutcome + [:issue 0 :severity] := #fhir/code"error" + [:issue 0 :code] := #fhir/code"processing" + [:issue 0 :diagnostics] := "Observation.code: minimum required = 1, but only found 0 (from http://hl7.org/fhir/StructureDefinition/Observation|4.0.1)")))) + + (testing "invalid observation in bundle" + (let [result (validator/validate validator {:fhir/type :fhir/Bundle + :type #fhir/code "transaction" + :entry + [{:fhir/type :fhir.Bundle/entry + :resource + {:fhir/type :fhir/Observation + :status #fhir/code "registered"} + :request + {:fhir/type :fhir.Bundle.entry/request + :method #fhir/code "POST" + :url #fhir/uri "/Observation"}}]})] + (testing "returns error" + (given result + :fhir/type := :fhir/OperationOutcome + [:issue 0 :severity] := #fhir/code"error" + [:issue 0 :code] := #fhir/code"processing" + [:issue 0 :diagnostics] := "Observation.code: minimum required = 1, but only found 0 (from http://hl7.org/fhir/StructureDefinition/Observation|4.0.1)"))))) + + (testing "without defined profile" + (with-system [{:blaze/keys [validator]} config] + (testing "without meta profile" + (testing "invalid patient" + (let [result (validator/validate validator {:fhir/type :fhir/Patient :id "0" + :communication + [{:fhir/type :fhir.Patient/communication + :preferred true}]})] + + (testing "returns error" + (given result + :fhir/type := :fhir/OperationOutcome + [:issue 0 :severity] := #fhir/code"error" + [:issue 0 :code] := #fhir/code"processing" + [:issue 0 :diagnostics] := "Patient.communication.language: minimum required = 1, but only found 0 (from http://hl7.org/fhir/StructureDefinition/Patient|4.0.1)")))) + + (testing "valid patient" + (is (nil? (validator/validate validator {:fhir/type :fhir/Patient :id "0"}))))) + + (testing "with meta profile" + (testing "invalid patient" + (let [result (validator/validate validator {:fhir/type :fhir/Patient :id "0" + :meta #fhir/Meta{:profile [#fhir/canonical "http://example.org/url-114730"]}})] + + (testing "returns error" + (given result + :fhir/type := :fhir/OperationOutcome + [:issue 0 :severity] := #fhir/code"error" + [:issue 0 :code] := #fhir/code"processing" + [:issue 0 :diagnostics] := "Profile reference 'http://example.org/url-114730' has not been checked because it could not be found"))) + + (testing "in bundle" + (let [result (validator/validate validator {:fhir/type :fhir/Bundle + :type #fhir/code "transaction" + :entry + [{:fhir/type :fhir.Bundle/entry + :resource + {:fhir/type :fhir/Patient + :meta #fhir/Meta{:profile [#fhir/canonical "http://example.org/url-114730"]}}}]})] + + (testing "returns error" + (given result + :fhir/type := :fhir/OperationOutcome + [:issue 0 :severity] := #fhir/code"error" + [:issue 0 :code] := #fhir/code"processing" + [:issue 0 :diagnostics] := "Profile reference 'http://example.org/url-114730' has not been checked because it could not be found")))))))))))) + +(deftest invalidate-validator-caches-test + (testing "on ingesting new StructureDefinition" + + (with-system [{:blaze/keys [validator] :blaze.db/keys [node]} config] + (testing "with meta profile" + (testing "valid patient" + (let [result (validator/validate validator {:fhir/type :fhir/Patient :id "0" + :meta #fhir/Meta{:profile [#fhir/canonical "http://example.org/url-114730"]}})] + + (testing "returns error" + (given result + :fhir/type := :fhir/OperationOutcome + [:issue 0 :severity] := #fhir/code"error" + [:issue 0 :code] := #fhir/code"processing" + [:issue 0 :diagnostics] := "Profile reference 'http://example.org/url-114730' has not been checked because it could not be found"))))) + + (testing "on ingesting new profile" + @(d/transact node [[:put {:fhir/type :fhir/StructureDefinition :id "0" + :url #fhir/uri "http://example.org/url-114730" + :type #fhir/uri "Patient" + :baseDefinition #fhir/canonical "http://hl7.org/fhir/StructureDefinition/Patient" + :derivation #fhir/code "constraint" + :differential + {:fhir/type :fhir.StructureDefinition/differential + :element + [{:fhir/type :fhir/ElementDefinition + :id "Patient.active" + :path #fhir/string "Patient.active" + :mustSupport #fhir/boolean true + :min #fhir/unsignedInt 1}]}}]]) + + (testing "with invalid patient" + (let [result (validator/validate validator {:fhir/type :fhir/Patient :id "0" + :meta #fhir/Meta{:profile [#fhir/canonical "http://example.org/url-114730"]}})] + + (testing "returns error" + (given result + :fhir/type := :fhir/OperationOutcome + [:issue 0 :severity] := #fhir/code"error" + [:issue 0 :code] := #fhir/code"processing" + [:issue 0 :diagnostics] := "Patient.active: minimum required = 1, but only found 0 (from http://example.org/url-114730)")))) + + (testing "with valid patient" + (is (nil? (validator/validate validator {:fhir/type :fhir/Patient :id "0" + :meta #fhir/Meta{:profile [#fhir/canonical "http://example.org/url-114730"]} + :active #fhir/boolean true})))))))) \ No newline at end of file diff --git a/modules/validator/tests.edn b/modules/validator/tests.edn new file mode 100644 index 000000000..0b8674ba5 --- /dev/null +++ b/modules/validator/tests.edn @@ -0,0 +1,11 @@ +#kaocha/v1 + #merge + [{} + #profile {:ci {:reporter kaocha.report/documentation + :color? false} + :coverage {:plugins [:kaocha.plugin/cloverage] + :cloverage/opts + {:ns-exclude-regex [".+\\.spec"], + :codecov? true} + :reporter kaocha.report/documentation + :color? false}}] diff --git a/resources/blaze.edn b/resources/blaze.edn index 0b9fac4b9..d92b4808a 100644 --- a/resources/blaze.edn +++ b/resources/blaze.edn @@ -285,6 +285,7 @@ :parsing-context #blaze/ref :blaze.fhir.parsing-context/default :writing-context #blaze/ref :blaze.fhir/writing-context :job-scheduler #blaze/ref :blaze/job-scheduler + :validator #blaze/ref :blaze/validator :db-sync-timeout #blaze/cfg ["DB_SYNC_TIMEOUT" pos-int? 10000] :read-job-handler #blaze/ref :blaze.interaction/read :history-job-handler #blaze/ref :blaze.interaction.history/instance @@ -465,6 +466,13 @@ :blaze/rng-fn {} + ;; + ;; Validator + ;; + :blaze/validator + {:node #blaze/ref :blaze.db.main/node + :writing-context #blaze/ref :blaze.fhir/writing-context} + :blaze/page-id-cipher {:node #blaze/ref :blaze.db.admin/node :scheduler #blaze/ref :blaze/scheduler @@ -1498,7 +1506,10 @@ :trust-store-pass #blaze/cfg ["EXTERN_TERMINOLOGY_SERVICE_CLIENT_TRUST_STORE_PASS" string?]} :blaze.fhir.operation.evaluate-measure/handler - {:terminology-service #blaze/ref :blaze/terminology-service}}} + {:terminology-service #blaze/ref :blaze/terminology-service} + + :blaze/validator + {:terminology-service-base-url #blaze/cfg ["EXTERN_TERMINOLOGY_SERVICE_URL" string?]}}} {:key :graph :name "Operation $graph on Resource" @@ -1523,4 +1534,11 @@ :blaze/cache-collector {:caches - {"operation-graph-compiled-graph-cache" #blaze/ref :blaze.operation.graph/compiled-graph-cache}}}}]} + {"operation-graph-compiled-graph-cache" #blaze/ref :blaze.operation.graph/compiled-graph-cache}}}} + + {:key :validation + :name "Validation" + :toggle "ENABLE_VALIDATION_ON_INGEST" + :config + {:blaze/rest-api + {:validator #blaze/ref :blaze/validator}}}]} diff --git a/test/blaze/system_test.clj b/test/blaze/system_test.clj index ff7ce6ec4..2588f9a35 100644 --- a/test/blaze/system_test.clj +++ b/test/blaze/system_test.clj @@ -3,9 +3,11 @@ [blaze.async.comp :as ac] [blaze.db.api-stub :refer [mem-node-config with-system-data]] [blaze.fhir.parsing-context] + [blaze.fhir.spec.type :as type] [blaze.fhir.test-util :refer [structure-definition-repo]] [blaze.fhir.writing-context] [blaze.interaction.conditional-delete-type] + [blaze.interaction.create] [blaze.interaction.delete] [blaze.interaction.delete-history] [blaze.interaction.history.type] @@ -31,6 +33,7 @@ [blaze.terminology-service :as-alias ts] [blaze.terminology-service.local :as ts-local] [blaze.test-util :as tu] + [blaze.validator] [buddy.auth.protocols :as ap] [clojure.spec.alpha :as s] [clojure.spec.test.alpha :as st] @@ -159,6 +162,7 @@ {:code "Patient" :search-handler (ig/ref :blaze.interaction/search-compartment)}] :job-scheduler (ig/ref :blaze/job-scheduler) + :validator (ig/ref :blaze/validator) :clock (ig/ref :blaze.test/fixed-clock) :rng-fn (ig/ref :blaze.test/fixed-rng-fn)} :blaze.db/search-param-registry @@ -170,6 +174,10 @@ :clock (ig/ref :blaze.test/fixed-clock) :rng-fn (ig/ref :blaze.test/fixed-rng-fn) :db-sync-timeout 10000} + :blaze.interaction/create + {:node (ig/ref :blaze.db/node) + :clock (ig/ref :blaze.test/fixed-clock) + :rng-fn (ig/ref :blaze.test/fixed-rng-fn)} :blaze.interaction/read {} :blaze.interaction/vread {} :blaze.interaction/delete @@ -222,6 +230,9 @@ {:node (ig/ref :blaze.db/node) :clock (ig/ref :blaze.test/fixed-clock) :rng-fn (ig/ref :blaze.test/fixed-rng-fn)} + :blaze/validator + {:node (ig/ref :blaze.db/node) + :writing-context (ig/ref :blaze.fhir/writing-context)} ::rest-api/resource-patterns {:default {:read @@ -230,6 +241,9 @@ :vread #:blaze.rest-api.interaction {:handler (ig/ref :blaze.interaction/vread)} + :create + #:blaze.rest-api.interaction + {:handler (ig/ref :blaze.interaction/create)} :delete #:blaze.rest-api.interaction {:handler (ig/ref :blaze.interaction/delete)} @@ -427,6 +441,115 @@ [:body json-parser :entry 0 :resource :fhir/type] := :fhir/CapabilityStatement [:body json-parser :entry 0 :response :status] := #fhir/string "200"))) +(defn- transaction-bundle [{:fhir/keys [type] :as resource}] + {:fhir/type :fhir/Bundle + :type #fhir/code"transaction" + :entry + [{:fhir/type :fhir.Bundle/entry + :resource resource + :request + {:fhir/type :fhir.Bundle.entry/request + :method #fhir/code"POST" + :url (type/uri (str "/" (name type)))}}]}) + +(deftest transaction-test + (testing "with validator" + (testing "with valid Patient" + (with-system [{:blaze/keys [rest-api] :blaze.test/keys [json-parser json-writer]} config] + (given (call rest-api {:request-method :post :uri "" + :headers {"content-type" "application/fhir+json"} + :body (input-stream (json-writer (transaction-bundle {:fhir/type :fhir/Patient})))}) + :status := 200 + [:body json-parser :fhir/type] := :fhir/Bundle))) + + (testing "with invalid Observation" + (with-system [{:blaze/keys [rest-api] :blaze.test/keys [json-parser json-writer]} config] + (given (call rest-api {:request-method :post :uri "" + :headers {"content-type" "application/fhir+json"} + :body (input-stream (json-writer (transaction-bundle {:fhir/type :fhir/Observation + :status #fhir/code "registered"})))}) + :status := 400 + [:body json-parser :fhir/type] := :fhir/OperationOutcome + [:body json-parser :issue 0 :diagnostics] := "Observation.code: minimum required = 1, but only found 0 (from http://hl7.org/fhir/StructureDefinition/Observation|4.0.1)"))) + + (testing "with valid Observation" + (with-system [{:blaze/keys [rest-api] :blaze.test/keys [json-parser json-writer]} config] + (given (call rest-api {:request-method :post :uri "" + :headers {"content-type" "application/fhir+json"} + :body (input-stream (json-writer (transaction-bundle {:fhir/type :fhir/Observation + :status #fhir/code "registered" + :code #fhir/CodeableConcept{:text #fhir/string "loinc"}})))}) + :status := 200 + [:body json-parser :fhir/type] := :fhir/Bundle)))) + + (testing "without validator" + (let [config (update config :blaze/rest-api dissoc :validator)] + (testing "with valid Patient" + (with-system [{:blaze/keys [rest-api] :blaze.test/keys [json-parser json-writer]} config] + (given (call rest-api {:request-method :post :uri "" + :headers {"content-type" "application/fhir+json"} + :body (input-stream (json-writer (transaction-bundle {:fhir/type :fhir/Patient})))}) + :status := 200 + [:body json-parser :fhir/type] := :fhir/Bundle))) + + (testing "with invalid Observation" + (with-system [{:blaze/keys [rest-api] :blaze.test/keys [json-parser json-writer]} config] + (given (call rest-api {:request-method :post :uri "" + :headers {"content-type" "application/fhir+json"} + :body (input-stream (json-writer (transaction-bundle {:fhir/type :fhir/Observation + :status #fhir/code "registered"})))}) + :status := 200 + [:body json-parser :fhir/type] := :fhir/Bundle)))))) + +(deftest create-test + (testing "with validator" + (testing "with valid Patient" + (with-system [{:blaze/keys [rest-api] :blaze.test/keys [json-parser json-writer]} config] + (given (call rest-api {:request-method :post :uri "/Patient" + :headers {"content-type" "application/fhir+json"} + :body (input-stream (json-writer {:fhir/type :fhir/Patient}))}) + :status := 201 + [:body json-parser :fhir/type] := :fhir/Patient))) + + (testing "with invalid Observation" + (with-system [{:blaze/keys [rest-api] :blaze.test/keys [json-parser json-writer]} config] + (given (call rest-api {:request-method :post :uri "/Observation" + :headers {"content-type" "application/fhir+json"} + :body (input-stream (json-writer {:fhir/type :fhir/Observation + :status #fhir/code "registered"}))}) + :status := 400 + [:body json-parser :fhir/type] := :fhir/OperationOutcome + [:body json-parser :issue 0 :diagnostics] := "Observation.code: minimum required = 1, but only found 0 (from http://hl7.org/fhir/StructureDefinition/Observation|4.0.1)"))) + + (testing "with valid Observation" + (with-system [{:blaze/keys [rest-api] :blaze.test/keys [json-parser json-writer]} config] + (given (call rest-api {:request-method :post :uri "/Observation" + :headers {"content-type" "application/fhir+json"} + :body (input-stream (json-writer {:fhir/type :fhir/Observation + :status #fhir/code "registered" + :code #fhir/CodeableConcept{:text #fhir/string "loinc"}}))}) + :status := 201 + [:body json-parser :fhir/type] := :fhir/Observation)))) + + (testing "without validator" + (let [config (update config :blaze/rest-api dissoc :validator)] + (testing "with valid Patient" + (with-system [{:blaze/keys [rest-api] :blaze.test/keys [json-parser json-writer]} config] + (given (call rest-api {:request-method :post :uri "/Patient" + :headers {"content-type" "application/fhir+json"} + :body (input-stream (json-writer {:fhir/type :fhir/Patient}))}) + :status := 201 + [:body json-parser :fhir/type] := :fhir/Patient))) + + (testing "with invalid Observation" + (with-system [{:blaze/keys [rest-api] :blaze.test/keys [json-parser json-writer]} config] + (given (call rest-api {:request-method :post :uri "/Observation" + :headers {"content-type" "application/fhir+json"} + :body (input-stream (json-writer {:fhir/type :fhir/Observation + :status #fhir/code "registered"}))}) + :status := 201 + [:body json-parser :fhir/type] := :fhir/Observation)))))) + (deftest delete-test (with-system [{:blaze/keys [rest-api] :blaze.test/keys [json-parser]} config] (given (call rest-api {:request-method :delete :uri "/Patient/0"})