Skip to content

Commit

Permalink
feature: add support for binary resource download (fixes #2108)
Browse files Browse the repository at this point in the history
  • Loading branch information
allentiak committed Nov 22, 2024
1 parent 693f847 commit a2229ca
Show file tree
Hide file tree
Showing 7 changed files with 194 additions and 5 deletions.
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"
34 changes: 34 additions & 0 deletions .github/scripts/read-binary-content.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
#!/bin/bash -e

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

SCRIPT_DIR="$(dirname "$(readlink -f "$0")")"
. "$SCRIPT_DIR/util.sh"

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
ID=$(curl -s -H 'Content-Type: application/fhir+json' -d "$(binary)" "$BASE/Binary" | jq -r .id)

echo "Created Binary resource that contains the Random Data, with ID: $ID"

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

echo "Binary data retrieved. Verifying content..."

test "Base64 encoding of Resource data" "$DATA" "$BASE64_ENCODED_BINARY_RESOURCE"
6 changes: 6 additions & 0 deletions .github/workflows/build.yml
Original file line number Diff line number Diff line change
Expand Up @@ -1059,6 +1059,12 @@ jobs:
- name: Search _tag
run: .github/scripts/search-tag.sh

- name: Binary Download - not found
run: .github/scripts/read-binary-content-not-found.sh

- name: Binary Download - found
run: .github/scripts/read-binary-content.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
39 changes: 38 additions & 1 deletion 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 Down Expand Up @@ -49,6 +51,21 @@
(with-open [_ (prom/timer generate-duration-seconds "xml")]
(generate-xml* body)))

(defn- generate-binary** [body]
(when (:data body)
(.decode (Base64/getDecoder) ^String (type/value (:data body)))))

(defn- generate-binary* [body]
(try
(generate-binary** body)
(catch Throwable e
(generate-binary** (handler-util/operation-outcome (ba/anomaly e))))))

(defn- generate-binary [body]
(log/trace "generate binary")
(with-open [_ (prom/timer generate-duration-seconds "binary")]
(generate-binary* body)))

(defn- encode-response-json [{:keys [body] :as response} content-type]
(cond-> response body (-> (update :body generate-json)
(ring/content-type content-type))))
Expand All @@ -57,6 +74,14 @@
(cond-> response body (-> (update :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 (-> (update :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 @@ -96,3 +121,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)))
95 changes: 92 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,47 @@
(fn [_ respond _]
(respond (ring/response {:fhir/type :fhir/Patient :id "0"})))))

(defn- special-resource-handler [resource]
(wrap-output
(fn [_ respond _]
(respond (ring/response resource)))))

(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 @@ -140,5 +172,62 @@
[:headers "Content-Type"] := "application/fhir+xml;charset=utf-8"
[: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/fhir+xml;charset=utf-8"
[: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 a2229ca

Please sign in to comment.