From 210ce0edcb923d5e4e9efd78fe7e889ff5b2a27c Mon Sep 17 00:00:00 2001 From: Tobias Machein Date: Thu, 4 Sep 2025 15:39:09 +0200 Subject: [PATCH 1/2] 968: add Validation Support for Resources Closes #968 968: add Validation Support for Resources --- .github/scripts/validation-patient.sh | 150 ++++++++++ .github/test-data/Makefile | 12 + .github/validation/.gitignore | 1 + .github/validation/package-lock.json | 149 ++++++++++ .github/validation/package.json | 27 ++ .github/validation/upload-definition.sh | 15 + .../validation/upload-definitions-from-dir.sh | 8 + .../validation/upload-testdata-from-dir.sh | 7 + .github/validation/upload-testdata.sh | 20 ++ .github/workflows/build.yml | 53 +++- docs/.vitepress/config.ts | 6 + docs/api/interaction/create.md | 4 + docs/api/interaction/transaction.md | 8 +- docs/api/interaction/update.md | 4 + docs/deployment/environment-variables.md | 16 +- docs/validation.md | 33 +++ modules/admin-api/deps.edn | 52 +--- modules/admin-api/src/blaze/admin_api.clj | 90 +----- .../src/blaze/admin_api/validation.clj | 35 --- .../admin-api/test/blaze/admin_api_test.clj | 30 +- modules/db-stub/src/blaze/db/api_stub.clj | 2 +- modules/rest-api/deps.edn | 3 + modules/rest-api/src/blaze/rest_api.clj | 2 + .../rest-api/src/blaze/rest_api/routes.clj | 28 +- .../test/blaze/rest_api/routes_test.clj | 31 +- modules/rest-util/deps.edn | 3 + .../src/blaze/middleware/fhir/validate.clj | 15 + .../blaze/middleware/fhir/validate_spec.clj | 8 + .../blaze/middleware/fhir/validate_test.clj | 142 ++++++++++ modules/validator/.clj-kondo/config.edn | 7 + modules/validator/Makefile | 31 ++ modules/validator/deps.edn | 91 ++++++ modules/validator/src/blaze/validator.clj | 176 ++++++++++++ .../validator/src/blaze/validator/spec.clj | 6 + .../validator/test/blaze/validator_test.clj | 267 ++++++++++++++++++ modules/validator/tests.edn | 11 + resources/blaze.edn | 17 +- test/blaze/system_test.clj | 123 ++++++++ 38 files changed, 1494 insertions(+), 189 deletions(-) create mode 100755 .github/scripts/validation-patient.sh create mode 100644 .github/validation/.gitignore create mode 100644 .github/validation/package-lock.json create mode 100644 .github/validation/package.json create mode 100755 .github/validation/upload-definition.sh create mode 100755 .github/validation/upload-definitions-from-dir.sh create mode 100755 .github/validation/upload-testdata-from-dir.sh create mode 100755 .github/validation/upload-testdata.sh create mode 100644 docs/validation.md delete mode 100644 modules/admin-api/src/blaze/admin_api/validation.clj create mode 100644 modules/rest-util/src/blaze/middleware/fhir/validate.clj create mode 100644 modules/rest-util/src/blaze/middleware/fhir/validate_spec.clj create mode 100644 modules/rest-util/test/blaze/middleware/fhir/validate_test.clj create mode 100644 modules/validator/.clj-kondo/config.edn create mode 100644 modules/validator/Makefile create mode 100644 modules/validator/deps.edn create mode 100644 modules/validator/src/blaze/validator.clj create mode 100644 modules/validator/src/blaze/validator/spec.clj create mode 100644 modules/validator/test/blaze/validator_test.clj create mode 100644 modules/validator/tests.edn 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 < +## 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 988d7687a..3d9f4664e 100644 --- a/docs/deployment/environment-variables.md +++ b/docs/deployment/environment-variables.md @@ -464,9 +464,13 @@ Enable SNOMED CT for the Terminology Service by using the value `true`. Path of an official SNOMED CT release. -[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 d37e802f7..7f46b73d4 100644 --- a/modules/admin-api/deps.edn +++ b/modules/admin-api/deps.edn @@ -34,59 +34,13 @@ blaze/spec {:local/root "../spec"} + blaze/validator + {:local/root "../validator"} + fi.metosin/reitit-openapi {:mvn/version "0.9.1" :exclusions [javax.xml.bind/jaxb-api]} - ca.uhn.hapi.fhir/hapi-fhir-validation - {:mvn/version "8.4.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.4.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.4.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.4.0" - :exclusions - [io.opentelemetry.instrumentation/opentelemetry-instrumentation-annotations]} - com.fasterxml.jackson.datatype/jackson-datatype-jsr310 {:mvn/version "2.20.0"} diff --git a/modules/admin-api/src/blaze/admin_api.clj b/modules/admin-api/src/blaze/admin_api.clj index f4f5aab90..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) @@ -206,24 +197,14 @@ :details #fhir/CodeableConcept {:text "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)))) - (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 a24da0bee..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 (.getDiagnostics issue)))) - - CodeableConcept - (datafy [concept] - (cond-> {:fhir/type :fhir.CodeableConcept} - (.hasText concept) - (assoc :text (.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 d4ae1d29b..1f57a3727 100644 --- a/modules/admin-api/test/blaze/admin_api_test.clj +++ b/modules/admin-api/test/blaze/admin_api_test.clj @@ -205,6 +205,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) @@ -220,6 +221,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) @@ -270,11 +275,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) @@ -304,6 +310,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 97d9e6de6..f6a59f2a6 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 aa75d1b3c..eab205329 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..ab1282287 --- /dev/null +++ b/modules/validator/deps.edn @@ -0,0 +1,91 @@ +{: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.4.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.4.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.4.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.4.0" + :exclusions + [io.opentelemetry.instrumentation/opentelemetry-instrumentation-annotations]}} + + :aliases + {:test + {:extra-paths ["test"] + + :extra-deps + {blaze/db-stub + {:local/root "../db-stub"}}} + + :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..799e7d003 --- /dev/null +++ b/modules/validator/src/blaze/validator.clj @@ -0,0 +1,176 @@ +(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] + [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 + 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 (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- create-validator [node writing-context] + (let [^FhirContext fhir-context (FhirContext/forR4) + _ (.newJsonParser fhir-context) + validator (.newValidator fhir-context) + chain (doto (ValidationSupportChain.) + (.addValidationSupport (DefaultProfileValidationSupport. fhir-context)) + (.addValidationSupport (InMemoryTerminologyServerValidationSupport. fhir-context)) + (.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])) + +(defmethod ig/init-key :blaze/validator + [_ {:keys [node writing-context]}] + (log/info "Init Validator") + (let [{:keys [validation-support-chain] :as validator} (create-validator node writing-context) + 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..3f4484cae --- /dev/null +++ b/modules/validator/src/blaze/validator/spec.clj @@ -0,0 +1,6 @@ +(ns blaze.validator.spec + (:require + [clojure.spec.alpha :as s])) + +(s/def :blaze/validator + map?) diff --git a/modules/validator/test/blaze/validator_test.clj b/modules/validator/test/blaze/validator_test.clj new file mode 100644 index 000000000..f1af67bd7 --- /dev/null +++ b/modules/validator/test/blaze/validator_test.clj @@ -0,0 +1,267 @@ +(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])) + +(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 "with minimal config" + (with-system [{:blaze/keys [validator]} config] + (is (some? validator))))) + +(deftest validate-test + (testing "with existing 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 "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 2f69aeb54..f9574e547 100644 --- a/resources/blaze.edn +++ b/resources/blaze.edn @@ -283,6 +283,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 @@ -461,6 +462,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 @@ -1497,4 +1505,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 a770e470e..0f4ec0311 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] := "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"}) From b3a18bebd2381e81d0932270253674f0fe34b517 Mon Sep 17 00:00:00 2001 From: Tobias Machein Date: Thu, 18 Dec 2025 18:29:29 +0100 Subject: [PATCH 2/2] 968: add Validation Support for Resources --- .github/value-set-expand/package-lock.json | 92 +++--- .github/value-set-expand/package.json | 2 +- modules/validator/deps.edn | 17 +- modules/validator/src/blaze/validator.clj | 59 ++-- .../validator/src/blaze/validator/spec.clj | 3 + .../validator/test/blaze/validator_test.clj | 295 ++++++++++-------- resources/blaze.edn | 5 +- 7 files changed, 266 insertions(+), 207 deletions(-) diff --git a/.github/value-set-expand/package-lock.json b/.github/value-set-expand/package-lock.json index e95323f2d..b2bf082d5 100644 --- a/.github/value-set-expand/package-lock.json +++ b/.github/value-set-expand/package-lock.json @@ -5,7 +5,7 @@ "packages": { "": { "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" } }, "node_modules/@mii-termserv/cas.registry": { @@ -94,9 +94,9 @@ } }, "node_modules/@mii-termserv/de.hl7.basisprofile.terminology": { - "version": "1.5.3-suts.6", - "resolved": "https://gitlab.com/api/v4/projects/55987678/packages/npm/@mii-termserv/de.hl7.basisprofile.terminology/-/@mii-termserv/de.hl7.basisprofile.terminology-1.5.3-suts.6.tgz", - "integrity": "sha1-grJvMBfXWM95/0n/Vv4bVM8qE9M=", + "version": "1.5.3-suts.7", + "resolved": "https://gitlab.com/api/v4/projects/55987678/packages/npm/@mii-termserv/de.hl7.basisprofile.terminology/-/@mii-termserv/de.hl7.basisprofile.terminology-1.5.3-suts.7.tgz", + "integrity": "sha1-36pTloMbDh/RRrOqhIAIvIKn3KI=", "dependencies": { "@mii-termserv/de.bfarm.alpha-id-se": ">=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/modules/validator/deps.edn b/modules/validator/deps.edn index ab1282287..b53b16367 100644 --- a/modules/validator/deps.edn +++ b/modules/validator/deps.edn @@ -21,7 +21,7 @@ {:local/root "../job-re-index"} ca.uhn.hapi.fhir/hapi-fhir-validation - {:mvn/version "8.4.0" + {:mvn/version "8.6.0" :exclusions [commons-beanutils/commons-beanutils info.cqframework/cql @@ -46,7 +46,7 @@ org.commonmark/commonmark-ext-gfm-tables]} ca.uhn.hapi.fhir/hapi-fhir-structures-r4 - {:mvn/version "8.4.0" + {:mvn/version "8.6.0" :exclusions [com.google.code.findbugs/jsr305 commons-net/commons-net @@ -56,7 +56,7 @@ net.sf.saxon/Saxon-HE]} ca.uhn.hapi.fhir/hapi-fhir-validation-resources-r4 - {:mvn/version "8.4.0" + {:mvn/version "8.6.0" :exclusions [com.google.code.findbugs/jsr305 io.opentelemetry/opentelemetry-api @@ -64,9 +64,12 @@ org.slf4j/jcl-over-slf4j]} ca.uhn.hapi.fhir/hapi-fhir-caching-caffeine - {:mvn/version "8.4.0" + {:mvn/version "8.6.0" :exclusions - [io.opentelemetry.instrumentation/opentelemetry-instrumentation-annotations]}} + [io.opentelemetry.instrumentation/opentelemetry-instrumentation-annotations]} + + ca.uhn.hapi.fhir/hapi-fhir-client + {:mvn/version "8.6.0"}} :aliases {:test @@ -74,7 +77,9 @@ :extra-deps {blaze/db-stub - {:local/root "../db-stub"}}} + {:local/root "../db-stub"} + com.squareup.okhttp3/mockwebserver + {:mvn/version "5.3.2"}}} :kaocha {:extra-deps diff --git a/modules/validator/src/blaze/validator.clj b/modules/validator/src/blaze/validator.clj index 799e7d003..c13ae8396 100644 --- a/modules/validator/src/blaze/validator.clj +++ b/modules/validator/src/blaze/validator.clj @@ -10,6 +10,7 @@ [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] @@ -25,8 +26,8 @@ [org.hl7.fhir.common.hapi.validation.support BaseValidationSupport CommonCodeSystemsTerminologyService - InMemoryTerminologyServerValidationSupport - PrePopulatedValidationSupport + InMemoryTerminologyServerValidationSupport PrePopulatedValidationSupport + RemoteTerminologyServiceValidationSupport ValidationSupportChain] [org.hl7.fhir.common.hapi.validation.validator FhirInstanceValidator] [org.hl7.fhir.instance.model.api IBaseResource] @@ -95,7 +96,7 @@ (proxy [BaseValidationSupport] [context] (fetchAllStructureDefinitions [] (map #(transform-resource context writing-context %) - @(d/pull-many node (d/type-list (d/db node) "StructureDefinition")))) + @(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)))))) @@ -110,25 +111,25 @@ (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"]) + #(.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] @@ -145,13 +146,18 @@ (flow/cancel! subscription)) (onComplete [_])) -(defn- create-validator [node writing-context] +(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))) @@ -164,12 +170,13 @@ :validation-support-chain chain})) (defmethod m/pre-init-spec :blaze/validator [_] - (s/keys :req-un [:blaze.db/node :blaze.fhir/writing-context])) + (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]}] + [_ {:keys [node writing-context terminology-service-base-url]}] (log/info "Init Validator") - (let [{:keys [validation-support-chain] :as validator} (create-validator node writing-context) + (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) diff --git a/modules/validator/src/blaze/validator/spec.clj b/modules/validator/src/blaze/validator/spec.clj index 3f4484cae..bb679ea6f 100644 --- a/modules/validator/src/blaze/validator/spec.clj +++ b/modules/validator/src/blaze/validator/spec.clj @@ -4,3 +4,6 @@ (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 index f1af67bd7..59ca04d09 100644 --- a/modules/validator/test/blaze/validator_test.clj +++ b/modules/validator/test/blaze/validator_test.clj @@ -12,7 +12,8 @@ [clojure.test :as test :refer [deftest is testing]] [integrant.core :as ig] [juxt.iota :refer [given]] - [taoensso.timbre :as log])) + [taoensso.timbre :as log]) + (:import [okhttp3.mockwebserver Dispatcher MockResponse MockWebServer RecordedRequest])) (st/instrument) (log/set-min-level! :trace) @@ -52,30 +53,114 @@ [: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" - (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" + (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" @@ -83,141 +168,91 @@ :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"]))) + [: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 "in bundle" + (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/Patient - :meta #fhir/Meta{:profile [#fhir/canonical "http://example.org/url-114730"]}} + {:fhir/type :fhir/Observation + :status #fhir/code "registered"} :request {:fhir/type :fhir.Bundle.entry/request :method #fhir/code "POST" - :url #fhir/uri "/Patient"}}]})] + :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] := "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)"))))) + [: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 "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] := "Patient.communication.language: minimum required = 1, but only found 0 (from http://hl7.org/fhir/StructureDefinition/Patient|4.0.1)")))) - (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 "valid patient" + (is (nil? (validator/validate validator {:fhir/type :fhir/Patient :id "0"}))))) - (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 "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"))))))))))) + [: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" diff --git a/resources/blaze.edn b/resources/blaze.edn index 0d0253aa9..d92b4808a 100644 --- a/resources/blaze.edn +++ b/resources/blaze.edn @@ -1506,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"