Skip to content

Commit

Permalink
Implement Binary Upload (Fixes #2126)
Browse files Browse the repository at this point in the history
  • Loading branch information
allentiak committed Feb 3, 2025
1 parent e0f832c commit 10a6299
Show file tree
Hide file tree
Showing 5 changed files with 139 additions and 12 deletions.
26 changes: 26 additions & 0 deletions .github/scripts/write-binary-content-via-raw-data.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
#!/bin/bash -e

# This script creates a large binary resource (8 MiB) and verifies that its binary content
# can be read correctly via both JSON/XML wrappers and direct binary upload.

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

# 8 MiB of random data (8388608 bytes), kept in memory
DATA="$(openssl rand 8388608 | tr -d '\n')"

echo "Testing direct binary upload and download..."

# Create Binary resource via direct binary upload
ID=$(curl -s -H 'Content-Type: application/octet-stream' --data-binary "$DATA" "$BASE/Binary" | jq -r '.id')

echo "Created Binary resource via direct binary upload with ID: $ID"

# Retrieve and verify via direct binary
BASE64_ENCODED_BINARY_RESOURCE_VIA_DIRECT=$(curl -s -H 'Accept: application/octet-stream' "$BASE/Binary/$ID")

if [ "$DATA" = "$BASE64_ENCODED_BINARY_RESOURCE_VIA_DIRECT" ]; then
echo "✅ Direct Binary: Successfully verified 8 MiB binary content integrity"
else
echo "🆘 Direct Binary: Content verification failed"
exit 1
fi
3 changes: 3 additions & 0 deletions .github/workflows/build.yml
Original file line number Diff line number Diff line change
Expand Up @@ -1074,6 +1074,9 @@ jobs:
- name: Binary Content Download - found (via XML)
run: .github/scripts/read-binary-content-via-xml-found.sh

- name: Binary Content Upload (via raw data)
run: .github/scripts/write-binary-content-via-raw-data.sh

- name: Conditional Delete - Check Referential Integrity Violated
run: .github/scripts/conditional-delete-type/check-referential-integrity-violated.sh

Expand Down
12 changes: 10 additions & 2 deletions modules/rest-api/src/blaze/rest_api/routes.clj
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,10 @@
{:name :auth-guard
:wrap auth-guard/wrap-auth-guard})

(def ^:private wrap-binary-data
{:name :binary-data
:wrap resource/wrap-binary-data})

(def ^:private wrap-resource
{:name :resource
:wrap resource/wrap-resource})
Expand Down Expand Up @@ -111,7 +115,9 @@
:blaze.rest-api.interaction/handler)})
(contains? interactions :create)
(assoc :post {:interaction "create"
:middleware [wrap-resource]
:middleware (if (= name "Binary")
[wrap-binary-data]
[wrap-resource])
:handler (-> interactions :create
:blaze.rest-api.interaction/handler)})
(contains? interactions :conditional-delete-type)
Expand Down Expand Up @@ -179,7 +185,9 @@
:blaze.rest-api.interaction/handler)})
(contains? interactions :update)
(assoc :put {:interaction "update"
:middleware [wrap-resource]
:middleware (if (= name "Binary")
[wrap-binary-data]
[wrap-resource])
:handler (-> interactions :update
:blaze.rest-api.interaction/handler)})
(contains? interactions :delete)
Expand Down
57 changes: 52 additions & 5 deletions modules/rest-util/src/blaze/middleware/fhir/resource.clj
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
[blaze.anomaly :as ba :refer [if-ok when-ok]]
[blaze.async.comp :as ac]
[blaze.fhir.spec :as fhir-spec]
[blaze.fhir.spec.type :as type]
[clojure.data.xml.jvm.parse :as xml-jvm]
[clojure.data.xml.tree :as xml-tree]
[clojure.java.io :as io]
Expand All @@ -16,7 +17,10 @@
[ring.util.request :as request])
(:import
[com.ctc.wstx.api WstxInputProperties]
[java.io InputStream]
[java.io Reader]
[java.util Base64$Encoder]
[java.util Base64]
[javax.xml.stream XMLInputFactory]))

(set! *warn-on-reflection* true)
Expand Down Expand Up @@ -95,6 +99,25 @@
(assoc request :body resource))
(ba/incorrect "Missing HTTP body.")))

(def ^:private ^Base64$Encoder b64-encoder
(.withoutPadding (Base64/getUrlEncoder)))

(defn- get-binary-data [body]
(with-open [_ (prom/timer parse-duration-seconds "binary")
^InputStream input body]
(.encodeToString b64-encoder input)))

(defn- resource-request-binary-data [{:keys [body headers] :as request}]
(if body
(when-ok [b64-encoded-data (get-binary-data body)]
(let [content-type (get headers "content-type")]
(assoc request :body
{:fhir/type :fhir/Binary
:resourceType "Binary"
:contentType (type/code content-type)
:data (type/base64Binary b64-encoded-data)})))
(ba/incorrect "Missing HTTP body.")))

(defn- unsupported-media-type-msg [media-type]
(format "Unsupported media type `%s` expect one of `application/fhir+json` or `application/fhir+xml`."
media-type))
Expand All @@ -109,19 +132,43 @@
:http/status 415))
(if (str/blank? (slurp (:body request)))
(assoc request :body nil)
(ba/incorrect "Content-Type header expected, but is missing."))))
(ba/incorrect "Expected Content-Type header for FHIR resources."))))

(defn- binary-resource-request [request]
(if-let [content-type (request/content-type request)]
(cond
(json-request? content-type) (resource-request-json request)
(xml-request? content-type) (resource-request-xml request)
:else
(resource-request-binary-data request))
(ba/incorrect "Expected Content-Type header for binary resources.")))

(defn wrap-resource
"Middleware to parse a resource from the body according the content-type
header.
Updates the :body key in the request map on successful parsing and conforming
the resource to the internal format.
If the resource is successfully parsed and conformed to the internal format,
updates the :body key in the request map.
Returns an OperationOutcome in the internal format, skipping the handler, with
an appropriate error on parsing and conforming errors."
In case on errors, returns an OperationOutcome in the internal format with the
appropriate error and skips the handler."
[handler]
(fn [request]
(if-ok [request (resource-request request)]
(handler request)
ac/completed-future)))

(defn wrap-binary-data
"Middleware to parse binary data from the body according the content-type
header.
If the resource is successfully parsed and conformed to the internal format,
updates the :body key in the request map.
In case on errors, returns an OperationOutcome in the internal format with the
appropriate error and skips the handler."
[handler]
(fn [request]
(if-ok [request (binary-resource-request request)]
(handler request)
ac/completed-future)))
53 changes: 48 additions & 5 deletions modules/rest-util/test/blaze/middleware/fhir/resource_test.clj
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,10 @@
(:require
[blaze.async.comp :as ac]
[blaze.fhir.spec :as fhir-spec]
[blaze.fhir.spec.type :as type]
[blaze.fhir.test-util]
[blaze.handler.util :as handler-util]
[blaze.middleware.fhir.resource :refer [wrap-resource]]
[blaze.middleware.fhir.resource :refer [wrap-binary-data wrap-resource]]
[blaze.test-util :as tu :refer [satisfies-prop]]
[clojure.spec.test.alpha :as st]
[clojure.string :as str]
Expand All @@ -23,18 +24,24 @@

(test/use-fixtures :each tu/fixture)

(defn wrap-error [handler]
(defn- wrap-error [handler]
(fn [request]
(-> (handler request)
(ac/exceptionally handler-util/error-response))))

(def resource-handler
"A handler which just returns the :body from the request."
(def ^:private resource-handler
"A handler which just returns the `:body` from a non-binary resource request."
(-> (comp ac/completed-future :body)
wrap-resource
wrap-error))

(defn input-stream
(def ^:private binary-resource-handler
"A handler which just returns the `:body` from a binary resource request."
(-> (comp ac/completed-future :body)
wrap-binary-data
wrap-error))

(defn- input-stream
([^String s]
(ByteArrayInputStream. (.getBytes s StandardCharsets/UTF_8)))
([^String s closed?]
Expand Down Expand Up @@ -193,6 +200,42 @@
:body (input-stream (str "<Binary xmlns=\"http://hl7.org/fhir\"><data value=\"" (apply str (repeat (* 8 1024 1024) \a)) "\"/></Binary>"))})
fhir-spec/fhir-type := :fhir/Binary)))

(deftest binary-test
(testing "when sending FHIR-wrapped Binary data"
(testing "both handlers should return the same for both wrappers (JSON and XML)"
(let [b64-encoded-binary-data "MTA1NjE0Cg==" ;; raw data: 105614
binary-resource-data (type/base64Binary b64-encoded-binary-data)
binary-resource-content-type (type/code "text/plain")]
(doseq [handler [resource-handler binary-resource-handler]
[fhir-content-type resource-string-representation]
[["application/fhir+json;charset=utf-8" (str "{\"data\" : \"" b64-encoded-binary-data "\", \"resourceType\" : \"Binary\", \"contentType\" : \"" binary-resource-content-type "\"}")]
["application/fhir+xml;charset=utf-8" (str "<Binary xmlns=\"http://hl7.org/fhir\"><data value=\"" b64-encoded-binary-data "\"/><contentType value=\"" binary-resource-content-type "\"/></Binary>")]]]
(let [closed? (atom false)]
(given @(handler
{:headers {"content-type" fhir-content-type}
:body (input-stream resource-string-representation closed?)})
{:fhir/type :fhir/Binary
:contentType binary-resource-content-type
:data binary-resource-data})
(is closed?))))))

(testing "when sending raw binary resource handling"
(testing "small raw binary data"
(let [closed? (atom false)
raw-binary-data "test-data"]
(given @(binary-resource-handler
{:headers {"content-type" "application/octet-stream"}
:body (input-stream raw-binary-data closed?)})
raw-binary-data)
(is @closed?)))

(testing "large raw binary resource"
(let [large-data (apply str (repeat (* 8 1024 1024) \a))] ; 8MB of data
(given @(binary-resource-handler
{:headers {"content-type" "application/octet-stream"}
:body (input-stream large-data)})
large-data)))))

(def ^:private whitespace
(gen/fmap str/join (gen/vector (gen/elements [" " "\n" "\r" "\t"]))))

Expand Down

0 comments on commit 10a6299

Please sign in to comment.