diff --git a/.github/scripts/install-xq.sh b/.github/scripts/install-xq.sh new file mode 100644 index 000000000..3df4d0599 --- /dev/null +++ b/.github/scripts/install-xq.sh @@ -0,0 +1,3 @@ +#!/bin/bash -e + +sudo apt -y install xq diff --git a/.github/scripts/read-binary-content-not-found.sh b/.github/scripts/read-binary-content-not-found.sh new file mode 100755 index 000000000..903edca7d --- /dev/null +++ b/.github/scripts/read-binary-content-not-found.sh @@ -0,0 +1,20 @@ +#!/bin/bash -e + +# This script queries the server for a non-existent binary resource +# and verifies that we get the 404 error message. + +SCRIPT_DIR="$(dirname "$(readlink -f "$0")")" +. "$SCRIPT_DIR/util.sh" + +BASE="http://localhost:8080/fhir" + +RANDOM_ID="$(uuidgen | tr '[:upper:]' '[:lower:]')" + +# Attempt to retrieve the Binary resource by ID +echo "Verifying that the Binary resource with ID '$RANDOM_ID' does not exist." + +# Perform a GET request to retrieve the Binary resource by ID +STATUS_CODE=$(curl -s -H "Accept: application/pdf" -o /dev/null -w '%{response_code}' "$BASE/Binary/$RANDOM_ID") + +# Test that the response code is 404 (Not Found), indicating the resource doesn't exist +test "GET response code for Binary resource" "$STATUS_CODE" "404" diff --git a/.github/scripts/read-binary-content-via-json-found.sh b/.github/scripts/read-binary-content-via-json-found.sh new file mode 100755 index 000000000..d387db861 --- /dev/null +++ b/.github/scripts/read-binary-content-via-json-found.sh @@ -0,0 +1,39 @@ +#!/bin/bash -e + +# This script creates a binary resource and verifies that its binary content +# can be read (via JSON). + +BASE="http://localhost:8080/fhir" + +# 10 KiB of random data, base64 encoded +DATA="$(openssl rand -base64 10240 | tr -d '\n')" + +binary() { +cat < + + + +END +} + + +# Create a Binary resource that contains that data, and get its ID (via XML) +ID_VIA_XML=$(curl -s -H 'Content-Type: application/fhir+xml' -H 'Accept: application/fhir+xml' -d "$(binary)" "$BASE/Binary" | xq -x //id/@value) + +echo "Created Binary resource that contains the Random Data" +echo " - via XML, with ID: $ID_VIA_XML" + + +# Retrieve the Binary resource, and Base64 encode it so it can be safely handled by Bash (via XML) +BASE64_ENCODED_BINARY_RESOURCE_VIA_XML=$(curl -s -H 'Accept: application/pdf' "$BASE/Binary/$ID_VIA_XML" | base64 | tr -d '\n') + + +echo "Binary data retrieved. Verifying content... (XML version)" + +if [ "$DATA" = "$BASE64_ENCODED_BINARY_RESOURCE_VIA_XML" ]; then + echo "✅ Base64 encoding of both the Original Data and the Retrieved Resource Data match (XML)" +else + echo "🆘 Base64 encoding of both the Original Data and the Retrieved Resource Data are different (XML)" + exit 1 +fi diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index af34e392a..cf7f6beeb 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -565,6 +565,9 @@ jobs: - name: Install Blazectl run: .github/scripts/install-blazectl.sh + - name: Install xq + run: .github/scripts/install-xq.sh + - name: Download Blaze Image uses: actions/download-artifact@fa0a91b85d4f404e444e00e005971372dc801d16 # v4 with: @@ -1059,6 +1062,15 @@ jobs: - name: Search _tag run: .github/scripts/search-tag.sh + - name: Binary Content Download - not found + run: .github/scripts/read-binary-content-not-found.sh + + - name: Binary Content Download - found (via JSON) + run: .github/scripts/read-binary-content-via-json-found.sh + + - name: Binary Content Download - found (via XML) + run: .github/scripts/read-binary-content-via-xml-found.sh + - name: Conditional Delete - Check Referential Integrity Violated run: .github/scripts/conditional-delete-type/check-referential-integrity-violated.sh diff --git a/modules/rest-api/src/blaze/rest_api/routes.clj b/modules/rest-api/src/blaze/rest_api/routes.clj index c8b4885e4..61240f7b8 100644 --- a/modules/rest-api/src/blaze/rest_api/routes.clj +++ b/modules/rest-api/src/blaze/rest_api/routes.clj @@ -64,6 +64,7 @@ :compile (fn [{:keys [response-type] :response-type.json/keys [opts]} _] (condp = response-type :json (output/wrap-output opts) + :binary fhir-output/wrap-binary-output :none identity fhir-output/wrap-output))}) @@ -169,6 +170,8 @@ (cond-> {:name (keyword name "instance") :conflicting true} + (= name "Binary") + (assoc :response-type :binary) (contains? interactions :read) (assoc :get {:interaction "read" :middleware [[wrap-db node db-sync-timeout]] diff --git a/modules/rest-api/src/blaze/rest_api/spec.clj b/modules/rest-api/src/blaze/rest_api/spec.clj index 4caddf582..c92cf7cd9 100644 --- a/modules/rest-api/src/blaze/rest_api/spec.clj +++ b/modules/rest-api/src/blaze/rest_api/spec.clj @@ -102,7 +102,7 @@ boolean?) (s/def ::operation/response-type - #{:json}) + #{:json :binary}) (s/def ::operation/resource-types (s/coll-of string?)) diff --git a/modules/rest-util/src/blaze/middleware/fhir/output.clj b/modules/rest-util/src/blaze/middleware/fhir/output.clj index ae45c8fd7..742d15521 100644 --- a/modules/rest-util/src/blaze/middleware/fhir/output.clj +++ b/modules/rest-util/src/blaze/middleware/fhir/output.clj @@ -7,6 +7,7 @@ (:require [blaze.anomaly :as ba] [blaze.fhir.spec :as fhir-spec] + [blaze.fhir.spec.type :as type] [blaze.handler.util :as handler-util] [clojure.data.xml :as xml] [clojure.java.io :as io] @@ -15,7 +16,8 @@ [ring.util.response :as ring] [taoensso.timbre :as log]) (:import - [java.io ByteArrayOutputStream])) + [java.io ByteArrayOutputStream] + [java.util Base64])) (set! *warn-on-reflection* true) @@ -27,6 +29,12 @@ (def ^:private parse-accept (parse/fast-memoize 1000 parse/parse-accept)) +(defn- generate-error [generation-fn ex] + (-> ex + ba/anomaly + handler-util/operation-outcome + generation-fn)) + (defn- generate-json [body] (log/trace "generate JSON") (with-open [_ (prom/timer generate-duration-seconds "json")] @@ -38,18 +46,12 @@ (xml/emit (fhir-spec/unform-xml body) writer)) (.toByteArray out))) -(defn- generate-xml-error [e] - (-> e - ba/anomaly - handler-util/operation-outcome - generate-xml**)) - (defn- generate-xml* [response] (try (update response :body generate-xml**) (catch Throwable e (assoc response - :body (generate-xml-error e) + :body (generate-error generate-xml** e) :status 500)))) (defn- generate-xml [response] @@ -57,6 +59,23 @@ (with-open [_ (prom/timer generate-duration-seconds "xml")] (generate-xml* response))) +(defn- generate-binary** [body] + (when (:data body) + (.decode (Base64/getDecoder) ^String (type/value (:data body))))) + +(defn- generate-binary* [response] + (try + (update response :body generate-binary**) + (catch Throwable e + (assoc response + :body (generate-error generate-binary** e) + :status 500)))) + +(defn- generate-binary [response] + (log/trace "generate binary") + (with-open [_ (prom/timer generate-duration-seconds "binary")] + (generate-binary* response))) + (defn- encode-response-json [{:keys [body] :as response} content-type] (cond-> response body (-> (update :body generate-json) (ring/content-type content-type)))) @@ -65,6 +84,14 @@ (cond-> response body (-> generate-xml (ring/content-type content-type)))) +(defn- binary-content-type [body] + (or (-> body :contentType type/value) + "application/octet-stream")) + +(defn- encode-response-binary [{:keys [body] :as response}] + (cond-> response body (-> generate-binary + (ring/content-type (binary-content-type body))))) + (defn- format-key [format] (condp = format "application/fhir+json" :fhir+json @@ -104,3 +131,15 @@ ([handler opts] (fn [request respond raise] (handler request #(respond (handle-response opts request %)) raise)))) + +(defn handle-binary-response [request response] + (case (request-format request) + :fhir+json (encode-response-json response "application/fhir+json;charset=utf-8") + :fhir+xml (encode-response-xml response "application/fhir+xml;charset=utf-8") + (encode-response-binary response))) + +(defn wrap-binary-output + "Middleware to output binary resources." + [handler] + (fn [request respond raise] + (handler request #(respond (handle-binary-response request %)) raise))) diff --git a/modules/rest-util/test/blaze/middleware/fhir/output_test.clj b/modules/rest-util/test/blaze/middleware/fhir/output_test.clj index 9405e0996..800b4de73 100644 --- a/modules/rest-util/test/blaze/middleware/fhir/output_test.clj +++ b/modules/rest-util/test/blaze/middleware/fhir/output_test.clj @@ -1,9 +1,11 @@ (ns blaze.middleware.fhir.output-test (:require + [blaze.byte-string :as bs] [blaze.fhir.spec :as fhir-spec] [blaze.fhir.spec-spec] + [blaze.fhir.spec.type :as type] [blaze.fhir.test-util] - [blaze.middleware.fhir.output :refer [wrap-output]] + [blaze.middleware.fhir.output :refer [wrap-binary-output wrap-output]] [blaze.module.test-util.ring :refer [call]] [blaze.test-util :as tu] [clojure.data.xml :as xml] @@ -27,17 +29,42 @@ (fn [_ respond _] (respond (ring/response {:fhir/type :fhir/Patient :id "0"}))))) +(defn- binary-resource-handler-200 + "A handler which uses the binary middleware and + returns a binary resource." + [{:keys [content-type data]}] + (wrap-binary-output + (fn [_ respond _] + (respond + (ring/response + (cond-> {:fhir/type :fhir/Binary} + data (assoc :data (type/base64Binary data)) + content-type (assoc :contentType (type/code content-type)))))))) + +(def ^:private binary-resource-handler-no-body + "A handler which uses the binary middleware and + returns a response with 200 and no body." + (wrap-binary-output + (fn [_ respond _] + (respond (ring/status 200))))) + (def ^:private resource-handler-304 "A handler which returns a 304 Not Modified response." (wrap-output (fn [_ respond _] (respond (ring/status 304))))) -(defn- special-resource-handler [resource] - (wrap-output +(defn- common-handler [wrapper-middleware resource] + (wrapper-middleware (fn [_ respond _] (respond (ring/response resource))))) +(defn- special-resource-handler [resource] + (common-handler wrap-output resource)) + +(defn- binary-resource-handler [resource] + (common-handler wrap-binary-output resource)) + (defn- parse-json [body] (fhir-spec/conform-json (fhir-spec/parse-json body))) @@ -143,5 +170,62 @@ [:body parse-xml :fhir/type] := :fhir/OperationOutcome [:body parse-xml :issue 0 :diagnostics] := "Invalid white space character (0x1e) in text to output (in xml 1.1, could output as a character entity)"))) +(deftest binary-resource-test + (testing "returning the resource" + (testing "JSON" + (given (call (binary-resource-handler-200 {:content-type "text/plain" :data "MTA1NjE0Cg=="}) {:headers {"accept" "application/fhir+json"}}) + :status := 200 + [:headers "Content-Type"] := "application/fhir+json;charset=utf-8" + [:body parse-json] := {:fhir/type :fhir/Binary + :contentType #fhir/code"text/plain" + :data #fhir/base64Binary"MTA1NjE0Cg=="})) + + (testing "XML" + (given (call (binary-resource-handler-200 {:content-type "text/plain" :data "MTA1NjE0Cg=="}) {:headers {"accept" "application/fhir+xml"}}) + :status := 200 + [:headers "Content-Type"] := "application/fhir+xml;charset=utf-8" + [:body parse-xml] := {:fhir/type :fhir/Binary + :contentType #fhir/code"text/plain" + :data #fhir/base64Binary"MTA1NjE0Cg=="}))) + + (testing "returning the data" + (testing "with content type" + (given (call (binary-resource-handler-200 {:content-type "text/plain" :data "MTA1NjE0Cg=="}) {:headers {"accept" "text/plain"}}) + :status := 200 + [:headers "Content-Type"] := "text/plain" + [:body bs/from-byte-array] := #blaze/byte-string"3130353631340A")) + + (testing "without content type" + (given (call (binary-resource-handler-200 {:content-type nil :data "MTA1NjE0Cg=="}) {:headers {"accept" "text/plain"}}) + :status := 200 + [:headers "Content-Type"] := "application/octet-stream" + [:body bs/from-byte-array] := #blaze/byte-string"3130353631340A"))) + + (testing "without data" + (testing "with content type" + (given (call (binary-resource-handler-200 {:content-type "text/plain"}) {:headers {"accept" "text/plain"}}) + :status := 200 + [:headers "Content-Type"] := "text/plain" + :body := nil)) + + (testing "without content type" + (given (call (binary-resource-handler-200 {:content-type nil}) {:headers {"accept" "text/plain"}}) + :status := 200 + [:headers "Content-Type"] := "application/octet-stream" + :body := nil))) + + (testing "without body at all" + (given (call binary-resource-handler-no-body {:headers {"accept" "text/plain"}}) + :status := 200 + [:headers "Content-Type"] := nil + :body := nil)) + + (testing "failing binary emit" + (given (call (binary-resource-handler {:fhir/type :fhir/Patient :id "0" :gender #fhir/code"foo\u001Ebar"}) {:headers {"accept" "text/plain"}}) + :status := 500 + [:headers "Content-Type"] := "application/octet-stream" + [:body parse-xml :fhir/type] := :fhir/OperationOutcome + [:body parse-xml :issue 0 :diagnostics] := "Invalid white space character (0x1e) in text to output (in xml 1.1, could output as a character entity)"))) + (deftest not-acceptable-test (is (nil? (call resource-handler-200 {:headers {"accept" "text/plain"}}))))