Skip to content

Commit

Permalink
Add support for binary resource download (fixes #2108)
Browse files Browse the repository at this point in the history
  • Loading branch information
allentiak committed Dec 3, 2024
1 parent f8aa4a2 commit 64ca712
Show file tree
Hide file tree
Showing 9 changed files with 251 additions and 12 deletions.
3 changes: 3 additions & 0 deletions .github/scripts/install-xq.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
#!/bin/bash -e

sudo apt -y install xq
20 changes: 20 additions & 0 deletions .github/scripts/read-binary-content-not-found.sh
Original file line number Diff line number Diff line change
@@ -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"
39 changes: 39 additions & 0 deletions .github/scripts/read-binary-content-via-json-found.sh
Original file line number Diff line number Diff line change
@@ -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
{
"resourceType": "Binary",
"contentType": "application/pdf",
"data": "$DATA"
}
END
}

# Create a Binary resource that contains that data, and get its ID (via JSON)
ID_VIA_JSON=$(curl -s -H 'Content-Type: application/fhir+json' -d "$(binary)" "$BASE/Binary" | jq -r '.id')

echo "Created Binary resource that contains the Random Data"
echo " - via JSON, with ID: $ID_VIA_JSON"


# Retrieve the Binary resource, and Base64 encode it so it can be safely handled by Bash (JSON)
BASE64_ENCODED_BINARY_RESOURCE_VIA_JSON=$(curl -s -H 'Accept: application/pdf' "$BASE/Binary/$ID_VIA_JSON" | base64 | tr -d '\n')


echo "Binary data retrieved. Verifying content... (JSON version)"

if [ "$DATA" = "$BASE64_ENCODED_BINARY_RESOURCE_VIA_JSON" ]; then
echo "✅ Base64 encoding of both the Original Data and the Retrieved Resource Data match (JSON)"
else
echo "🆘 Base64 encoding of both the Original Data and the Retrieved Resource Data are different (JSON)"
exit 1
fi
39 changes: 39 additions & 0 deletions .github/scripts/read-binary-content-via-xml-found.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
#!/bin/bash -e

# This script creates a binary resource and verifies that its binary content
# can be read (via XML).

BASE="http://localhost:8080/fhir"

# 10 KiB of random data, base64 encoded
DATA="$(openssl rand -base64 10240 | tr -d '\n')"

binary() {
cat <<END
<Binary xmlns="http://hl7.org/fhir">
<contentType value="application/pdf"/>
<data value="$DATA"/>
</Binary>
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
12 changes: 12 additions & 0 deletions .github/workflows/build.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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

Expand Down
3 changes: 3 additions & 0 deletions modules/rest-api/src/blaze/rest_api/routes.clj
Original file line number Diff line number Diff line change
Expand Up @@ -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))})

Expand Down Expand Up @@ -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]]
Expand Down
2 changes: 1 addition & 1 deletion modules/rest-api/src/blaze/rest_api/spec.clj
Original file line number Diff line number Diff line change
Expand Up @@ -102,7 +102,7 @@
boolean?)

(s/def ::operation/response-type
#{:json})
#{:json :binary})

(s/def ::operation/resource-types
(s/coll-of string?))
Expand Down
55 changes: 47 additions & 8 deletions modules/rest-util/src/blaze/middleware/fhir/output.clj
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand All @@ -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)

Expand All @@ -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")]
Expand All @@ -38,25 +46,36 @@
(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]
(log/trace "generate XML")
(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))))
Expand All @@ -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
Expand Down Expand Up @@ -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)))
90 changes: 87 additions & 3 deletions modules/rest-util/test/blaze/middleware/fhir/output_test.clj
Original file line number Diff line number Diff line change
@@ -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]
Expand All @@ -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)))

Expand Down Expand Up @@ -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"}}))))

0 comments on commit 64ca712

Please sign in to comment.