Repository: oliyh/martian
Pull request #230 (2025-06-21 22:46:03)

Refine multipart requests handling
@oliyh Hi Oliver!

This one is a bit heavy, but it's an absolute minimum required to get the job done and to get it done well.

The problem is the multipart requests (support for which was recently added by me in #225) handling didn't play well with non-trivial data/types and request params coercion against the schema. For example, if your API schema has a numeric or array parameter, good luck sending those values ​​as the content of a multipart request (in the current implementation). You will either fail schema coercion if you prematurely convert the values ​​of these parameters to strings, or you will get an error about an unsupported content value if you try to pass them as is.

Moreover, the sets of supported content value types differ from one HTTP client to another. Finally, some clients fail to handle a multipart request with existing "Content-Type" header, either by throwing an error when sending the request (upon a `response-for` call, probably due to multiple conflicting header values) or [by not including a correct header with the `boundary` value set](https://github.com/dakrone/clj-http/pull/654) at all.

All this led me to a redesign that would defer decisions about converting multipart request content values ​​until the right moment (after the parameters have been successfully coerced), do it in a centralized and automated way and in accordance with HTTP clients' support of custom content types (while serving as a "common denominator" between all target clients), and eliminate the existing "Content-Type" request header, if any.

And the cool thing is, it works. And the uncompromising test coverage I had to add gives me the confidence to say so.

Hope this one will make it into the new version of the library!

Cheers,
Mark

marksto: @oliyh I know that `+1,178 −125` may be too big of a change to dig through, so I did my best to highlight the key spots where important decisions were made. Hope this helps!
marksto: @oliyh Please, have a look now. I reduced the new tests size by ~100 lines.
marksto: @oliyh Please, have a look now. I reduced the new tests size by ~200 lines and extracted one more common helper fn, the `without-content-type?`, to have test checks more robust, specific and comprehensible. But I would still like to keep them isolated and preserve locality because of the number of client-specific discrepancies.

Patch:
diff --git a/babashka-http-client/project.clj b/babashka-http-client/project.clj
index 55ca662..2e5498e 100644
--- a/babashka-http-client/project.clj
+++ b/babashka-http-client/project.clj
@@ -17,4 +17,6 @@
 
                                   [pedestal-api "0.3.5"]
                                   [io.pedestal/pedestal.service "0.5.9"]
-                                  [io.pedestal/pedestal.jetty "0.5.9"]]}})
+                                  [io.pedestal/pedestal.jetty "0.5.9"]
+
+                                  [nubank/matcher-combinators "3.9.1"]]}})
diff --git a/babashka-http-client/src/martian/babashka/http_client.clj b/babashka-http-client/src/martian/babashka/http_client.clj
index 2c08d63..177a278 100644
--- a/babashka-http-client/src/martian/babashka/http_client.clj
+++ b/babashka-http-client/src/martian/babashka/http_client.clj
@@ -67,14 +67,13 @@
    :leave (fn [ctx]
             (update-in ctx [:response :headers] keywordize-keys))})
 
-(def encoders
+(def request-encoders
   (assoc (encoders/default-encoders)
-    "multipart/form-data" {:encode encoders/multipart-encode
-                           :as :multipart}))
+    "multipart/form-data" {:encode encoders/multipart-encode}))
 
 (def babashka-http-client-interceptors
   (conj martian/default-interceptors
-        (interceptors/encode-request encoders)
+        (interceptors/encode-request request-encoders)
         interceptors/default-coerce-response
         keywordize-headers
         default-to-http-1))
diff --git a/clj-http-lite/project.clj b/clj-http-lite/project.clj
index f45e40b..684bd40 100644
--- a/clj-http-lite/project.clj
+++ b/clj-http-lite/project.clj
@@ -16,4 +16,6 @@
 
                                   [pedestal-api "0.3.5"]
                                   [io.pedestal/pedestal.service "0.5.9"]
-                                  [io.pedestal/pedestal.jetty "0.5.9"]]}})
+                                  [io.pedestal/pedestal.jetty "0.5.9"]
+
+                                  [nubank/matcher-combinators "3.8.5"]]}})
diff --git a/clj-http/project.clj b/clj-http/project.clj
index 66c52d9..029f36e 100644
--- a/clj-http/project.clj
+++ b/clj-http/project.clj
@@ -7,7 +7,7 @@
   :parent-project {:path "../project.clj"
                    :inherit [:managed-dependencies]}
   :dependencies [[com.github.oliyh/martian]
-                 [clj-http "3.13.0"]]
+                 [clj-http "3.13.1"]]
   :profiles {:provided {:dependencies [[org.clojure/clojure]]}
              :dev {:source-paths ["../test-common"]
                    :dependencies [[org.slf4j/slf4j-simple "2.0.17"]
@@ -15,4 +15,6 @@
 
                                   [pedestal-api "0.3.5"]
                                   [io.pedestal/pedestal.service "0.5.9"]
-                                  [io.pedestal/pedestal.jetty "0.5.9"]]}})
+                                  [io.pedestal/pedestal.jetty "0.5.9"]
+
+                                  [nubank/matcher-combinators "3.9.1"]]}})
diff --git a/clj-http/src/martian/clj_http.clj b/clj-http/src/martian/clj_http.clj
index 6be5b86..13ed430 100644
--- a/clj-http/src/martian/clj_http.clj
+++ b/clj-http/src/martian/clj_http.clj
@@ -5,21 +5,24 @@
             [martian.file :as file]
             [martian.interceptors :as interceptors]
             [martian.openapi :as openapi]
-            [martian.yaml :as yaml]))
+            [martian.yaml :as yaml])
+  (:import (org.apache.http.entity.mime.content ContentBody)))
 
 (def perform-request
   {:name ::perform-request
    :leave (fn [{:keys [request] :as ctx}]
             (assoc ctx :response (http/request request)))})
 
-(def encoders
+(defn custom-type? [obj]
+  (instance? ContentBody obj))
+
+(def request-encoders
   (assoc (encoders/default-encoders)
-    "multipart/form-data" {:encode encoders/multipart-encode
-                           :as :multipart}))
+    "multipart/form-data" {:encode #(encoders/multipart-encode % custom-type?)}))
 
 (def default-interceptors
   (conj martian/default-interceptors
-        (interceptors/encode-request encoders)
+        (interceptors/encode-request request-encoders)
         interceptors/default-coerce-response
         perform-request))
 
diff --git a/core/src/martian/encoders.cljc b/core/src/martian/encoders.cljc
index 40700c5..561b4f2 100644
--- a/core/src/martian/encoders.cljc
+++ b/core/src/martian/encoders.cljc
@@ -1,12 +1,13 @@
 (ns martian.encoders
   (:require [clojure.string :as string]
-            [flatland.ordered.map :refer [ordered-map]]
-            [cognitect.transit :as transit]
             [clojure.walk :refer [keywordize-keys]]
+            [cognitect.transit :as transit]
+            [flatland.ordered.map :refer [ordered-map]]
+            #?(:clj [cheshire.core :as json])
             #?(:clj [clojure.edn :as edn]
                :cljs [cljs.reader :as edn])
-            #?(:clj [cheshire.core :as json])
             #?(:clj [clojure.java.io :as io])
+            #?(:clj [martian.multipart :as multipart])
             #?@(:bb []
                 :clj [[ring.util.codec :as codec]]))
   #?(:clj (:import [java.io ByteArrayInputStream ByteArrayOutputStream])))
@@ -36,8 +37,15 @@
        (js->clj v :keywordize-keys key-fn))))
 
 #?(:clj
-   (defn multipart-encode [body]
-     (mapv (fn [[k v]] {:name (name k) :content v}) body)))
+   (defn multipart-encode
+     ([body]
+      (mapv (fn [[k v]]
+              {:name (name k) :content (multipart/coerce-content v)})
+            body))
+     ([body pass-pred]
+      (mapv (fn [[k v]]
+              {:name (name k) :content (multipart/coerce-content v pass-pred)})
+            body))))
 
 #?(:cljs
    (defn- form-encode [body]
diff --git a/core/src/martian/interceptors.cljc b/core/src/martian/interceptors.cljc
index fa107e3..fecabd5 100644
--- a/core/src/martian/interceptors.cljc
+++ b/core/src/martian/interceptors.cljc
@@ -1,12 +1,13 @@
 (ns martian.interceptors
-  (:require [martian.schema :as schema]
+  (:require [camel-snake-kebab.core :refer [->kebab-case-keyword]]
             [clojure.set :as set]
+            [clojure.string :as str]
             [clojure.walk :refer [keywordize-keys stringify-keys]]
-            [tripod.context :as tc]
-            [schema.core :as s]
-            [camel-snake-kebab.core :refer [->kebab-case-keyword]]
+            [martian.encoders :as encoders]
             [martian.encoding :as encoding]
-            [martian.encoders :as encoders]))
+            [martian.schema :as schema]
+            [schema.core :as s]
+            [tripod.context :as tc]))
 
 (defn remove-stack [ctx]
   (-> ctx tc/terminate (dissoc ::tc/stack)))
@@ -83,22 +84,41 @@
               (update ctx ::tc/queue #(into (into tc/queue i) %))
               ctx))})
 
+(defn- content-type? [header-key]
+  (= "content-type" (str/lower-case (name header-key))))
+
+(def memo:content-type? (memoize content-type?))
+
+(defn drop-content-type [headers]
+  (if-let [header-key (some #(when (memo:content-type? %) %) (keys headers))]
+    (dissoc headers header-key)
+    headers))
+
 (defn encode-request [encoders]
   {:name ::encode-request
    :encodes (keys encoders)
    :enter (fn [{:keys [request handler] :as ctx}]
             (let [has-body? (:body request)
-                  content-type (and has-body?
-                                    (not (get-in request [:headers "Content-Type"]))
-                                    (encoding/choose-content-type encoders (:consumes handler)))
-                  multipart? (= "multipart/form-data" content-type)
-                  {:keys [encode]} (encoding/find-encoder encoders content-type)]
-              (cond-> ctx
-                      has-body? (update-in [:request :body] encode)
-                      ;; NB: Luckily, all target HTTP clients — clj-http (but not lite), http-kit,
-                      ;;     hato, and even babashka/http-client — all support the same syntax.
-                      multipart? (update :request set/rename-keys {:body :multipart})
-                      content-type (assoc-in [:request :headers "Content-Type"] content-type))))})
+                  content-type (when (and has-body?
+                                          (not (get-in request [:headers "Content-Type"])))
+                                 (encoding/choose-content-type encoders (:consumes handler)))
+                  ;; NB: There are many possible subtypes of multipart requests.
+                  multipart? (when content-type (str/starts-with? content-type "multipart/"))
+                  {:keys [encode]} (encoding/find-encoder encoders content-type)
+                  encoded-request (cond-> request
+
+                                    has-body?
+                                    (update :body encode)
+
+                                    multipart?
+                                    ;; NB: Luckily, all target HTTP clients — clj-http (but not lite), http-kit,
+                                    ;;     even hato and org.babashka/http-client — all support the same syntax.
+                                    (-> (set/rename-keys {:body :multipart})
+                                        (update :headers drop-content-type))
+
+                                    (and content-type (not multipart?))
+                                    (assoc-in [:headers "Content-Type"] content-type))]
+              (assoc ctx :request encoded-request)))})
 
 (def default-encode-request (encode-request (encoders/default-encoders)))
 
diff --git a/core/src/martian/multipart.cljc b/core/src/martian/multipart.cljc
new file mode 100644
index 0000000..852e40b
--- /dev/null
+++ b/core/src/martian/multipart.cljc
@@ -0,0 +1,50 @@
+(ns martian.multipart
+  "An adaptor that prepares multipart content for all supported HTTP clients"
+  (:require [clojure.java.io :as io])
+  (:import (java.io File InputStream)))
+
+;; http-kit = {String | File, InputStream, byte[] | ByteBuffer! | ~Number!~}
+;; clj-http = {String | File, InputStream, byte[] | o.a.h.e.m.c.ContentBody}
+;; hato     = {String | File, InputStream, byte[] | URL, URI, Socket, Path!}
+;; bb/http  = {String | File, InputStream, byte[] | URL, URI, Socket, Path?}
+
+(defn common-binary? [obj]
+  (or (instance? File obj)
+      (instance? InputStream obj)
+      (bytes? obj)))
+
+#?(:bb nil
+   :clj
+   (def default-make-input-stream-impl
+     (:make-input-stream io/default-streams-impl)))
+
+#?(:bb nil
+   :clj
+   (defn implements-make-input-stream? [obj]
+     (not= default-make-input-stream-impl
+           (find-protocol-method io/IOFactory :make-input-stream obj))))
+
+(defn coerce-content
+  ([obj]
+   (coerce-content obj nil))
+  ([obj pass-pred]
+   (when (some? obj) ; let it fail downstream
+     (cond
+       (or (string? obj)
+           (common-binary? obj)
+           (and pass-pred (pass-pred obj)))
+       obj
+
+       #?@(:bb
+           [:else
+            (try
+              (io/input-stream obj)
+              (catch Exception _ex
+                (str obj)))]
+
+           :clj
+           [(implements-make-input-stream? obj)
+            (io/input-stream obj)
+
+            :else
+            (str obj)])))))
diff --git a/core/src/martian/schema.cljc b/core/src/martian/schema.cljc
index 7c1f6a0..2815cca 100644
--- a/core/src/martian/schema.cljc
+++ b/core/src/martian/schema.cljc
@@ -135,26 +135,29 @@
   [ref-lookup]
   (fn [obj] (resolve-ref-object obj ref-lookup)))
 
+(def Binary
+  #?(:clj  s/Any ; postpone to multipart coercion
+     :cljs js/File))
+
 (def URI
-  #?(:clj java.net.URI
+  #?(:clj  java.net.URI
      :cljs goog.Uri))
 
 (defn leaf-schema [{:keys [type enum format]}]
   (cond
     enum                 (apply s/enum enum)
     (= "string" type)    (case format
-                           "uuid" (s/cond-pre s/Str s/Uuid)
-                           "uri" (s/cond-pre s/Str URI)
+                           "binary" (s/cond-pre s/Str Binary)
                            "date-time" (s/cond-pre s/Str s/Inst)
                            "int-or-string" (s/cond-pre s/Str s/Int)
+                           "uri" (s/cond-pre s/Str URI)
+                           "uuid" (s/cond-pre s/Str s/Uuid)
                            s/Str)
     (= "integer" type)   s/Int
     (= "number" type)    s/Num
     (= "boolean" type)   s/Bool
     (= "date-time" type) s/Inst
-
-    :else
-    s/Any))
+    :else                s/Any))
 
 (defn wrap-default [{:keys [default]} schema]
   (if (some? default)
diff --git a/hato/project.clj b/hato/project.clj
index 80bc6dd..4059954 100644
--- a/hato/project.clj
+++ b/hato/project.clj
@@ -15,4 +15,6 @@
 
                                   [pedestal-api "0.3.5"]
                                   [io.pedestal/pedestal.service "0.5.9"]
-                                  [io.pedestal/pedestal.jetty "0.5.9"]]}})
+                                  [io.pedestal/pedestal.jetty "0.5.9"]
+
+                                  [nubank/matcher-combinators "3.9.1"]]}})
diff --git a/hato/src/martian/hato.clj b/hato/src/martian/hato.clj
index 34294dd..92d360a 100644
--- a/hato/src/martian/hato.clj
+++ b/hato/src/martian/hato.clj
@@ -42,14 +42,13 @@
    :leave (fn [ctx]
             (update-in ctx [:response :headers] keywordize-keys))})
 
-(def encoders
+(def request-encoders
   (assoc (encoders/default-encoders)
-    "multipart/form-data" {:encode encoders/multipart-encode
-                           :as :multipart}))
+    "multipart/form-data" {:encode encoders/multipart-encode}))
 
 (def hato-interceptors
   (conj martian/default-interceptors
-        (interceptors/encode-request encoders)
+        (interceptors/encode-request request-encoders)
         interceptors/default-coerce-response
         keywordize-headers
         default-to-http-1))
diff --git a/httpkit/project.clj b/httpkit/project.clj
index c384e41..735e42b 100644
--- a/httpkit/project.clj
+++ b/httpkit/project.clj
@@ -16,4 +16,6 @@
 
                                   [pedestal-api "0.3.5"]
                                   [io.pedestal/pedestal.service "0.5.9"]
-                                  [io.pedestal/pedestal.jetty "0.5.9"]]}})
+                                  [io.pedestal/pedestal.jetty "0.5.9"]
+
+                                  [nubank/matcher-combinators "3.9.1"]]}})
diff --git a/httpkit/src/martian/httpkit.clj b/httpkit/src/martian/httpkit.clj
index c86dd11..540f7f2 100644
--- a/httpkit/src/martian/httpkit.clj
+++ b/httpkit/src/martian/httpkit.clj
@@ -7,7 +7,8 @@
             [martian.openapi :as openapi]
             [martian.yaml :as yaml]
             [org.httpkit.client :as http]
-            [tripod.context :as tc]))
+            [tripod.context :as tc])
+  (:import (java.nio ByteBuffer)))
 
 (def go-async interceptors/remove-stack)
 
@@ -21,14 +22,17 @@
                                      (fn [response]
                                        (:response (tc/execute (assoc ctx :response response))))))))})
 
-(def encoders
+;; NB: Although 'http-kit' has built-in support for numbers, we omit it.
+(defn custom-type? [obj]
+  (instance? ByteBuffer obj))
+
+(def request-encoders
   (assoc (encoders/default-encoders)
-    "multipart/form-data" {:encode encoders/multipart-encode
-                           :as :multipart}))
+    "multipart/form-data" {:encode #(encoders/multipart-encode % custom-type?)}))
 
 (def default-interceptors
   (conj martian/default-interceptors
-        (interceptors/encode-request encoders)
+        (interceptors/encode-request request-encoders)
         interceptors/default-coerce-response
         perform-request))
 


Test patch:
diff --git a/babashka-http-client/test/martian/babashka/http_client_test.clj b/babashka-http-client/test/martian/babashka/http_client_test.clj
index 8658952..6bbe37a 100644
--- a/babashka-http-client/test/martian/babashka/http_client_test.clj
+++ b/babashka-http-client/test/martian/babashka/http_client_test.clj
@@ -1,25 +1,42 @@
 (ns martian.babashka.http-client-test
-  (:require [babashka.process :as process]
-            [babashka.fs :as fs]
+  (:require [babashka.fs :as fs]
+            [babashka.process :as process]
+            [clojure.java.io :as io]
+            [clojure.string :as str]
+            [clojure.test :refer [deftest testing is use-fixtures]]
             [martian.babashka.http-client :as martian-http]
             [martian.core :as martian]
             [martian.encoders :as encoders]
-            [martian.test-utils :refer [input-stream->byte-array]]
-            [clojure.test :refer [deftest testing is use-fixtures]]))
-
-(defmacro if-bb [then & [else]]
-  (if (System/getProperty "babashka.version")
-    then else))
+            [martian.test-utils :refer [if-bb
+                                        binary-content
+                                        create-temp-file
+                                        input-stream?
+                                        input-stream->byte-array
+                                        without-content-type?
+                                        multipart+boundary?]]
+            [matcher-combinators.test])
+  (:import (java.io PrintWriter)
+           (java.net Socket URI)))
 
 (if-bb
-    (do
-      (def swagger-url (format "http://localhost:%s/swagger.json" 8888))
-      (def openapi-url (format "http://localhost:%s/openapi.json" 8888))
-      (def openapi-yaml-url (format "http://localhost:%s/openapi.yaml" 8888))
-      (def openapi-test-url (format "http://localhost:%s/openapi-test.json" 8888))
-      (def openapi-test-yaml-url (format "http://localhost:%s/openapi-test.yaml" 8888)))
+  (let [port 8888]
+    (def swagger-url (format "http://localhost:%s/swagger.json" port))
+    (def openapi-url (format "http://localhost:%s/openapi.json" port))
+    (def openapi-yaml-url (format "http://localhost:%s/openapi.yaml" port))
+    (def openapi-test-url (format "http://localhost:%s/openapi-test.json" port))
+    (def openapi-test-yaml-url (format "http://localhost:%s/openapi-test.yaml" port))
+    (def openapi-multipart-url (format "http://localhost:%s/openapi-multipart.json" port))
+    (def test-multipart-file-url (format "http://localhost:%s/test-multipart.txt" port)))
   (do
-    (require '[martian.server-stub :refer [with-server swagger-url openapi-url openapi-test-url openapi-yaml-url openapi-test-yaml-url]])
+    (require '[martian.server-stub :refer [swagger-url
+                                           openapi-url
+                                           openapi-yaml-url
+                                           openapi-test-url
+                                           openapi-test-yaml-url
+                                           openapi-multipart-url
+                                           test-multipart-file-url
+                                           with-server]])
+    (require '[martian.test-utils :refer [extend-io-factory-for-path]])
     (use-fixtures :once with-server)))
 
 (defn- decode-body
@@ -167,7 +184,7 @@
            (martian/explore m)))))
 
 (if-bb
-    nil
+  nil
   (deftest babashka-test
     (if (not (fs/which "bb"))
       (println "babashka not installed, skipping test")
@@ -181,3 +198,120 @@
       (require '[clojure.test] '[martian.babashka.http-client-test])
       (let [{:keys [error fail]} (clojure.test/run-tests 'martian.babashka.http-client-test)]
         (when (or (pos? error) (pos? fail)) (System/exit 1)))"))))
+
+(deftest multipart-request-test
+  (let [m (martian-http/bootstrap-openapi openapi-multipart-url)]
+    (is (= "http://localhost:8888" (:api-root m)))
+
+    (testing "common types:"
+      (testing "String"
+        (is (match?
+              {:multipart [{:name "string" :content "Howdy!"}]
+               :headers without-content-type?}
+              (martian/request-for m :upload-data {:string "Howdy!"})))
+        (is (match?
+              {:status 200
+               :body {:content-type multipart+boundary?
+                      :content-map {:string "Howdy!"}}}
+              (martian/response-for m :upload-data {:string "Howdy!"}))))
+      (testing "File"
+        (let [tmp-file (create-temp-file)]
+          (is (match?
+                {:multipart [{:name "binary" :content tmp-file}]
+                 :headers without-content-type?}
+                (martian/request-for m :upload-data {:binary tmp-file})))
+          (is (match?
+                {:status 200
+                 :body {:content-type multipart+boundary?
+                        :content-map {:binary (binary-content tmp-file)}}}
+                (martian/response-for m :upload-data {:binary tmp-file})))))
+      (testing "InputStream"
+        (let [tmp-file (create-temp-file)
+              tmp-file-is (io/input-stream tmp-file)]
+          (is (match?
+                {:multipart [{:name "binary" :content tmp-file-is}]
+                 :headers without-content-type?}
+                (martian/request-for m :upload-data {:binary tmp-file-is})))
+          (is (match?
+                {:status 200
+                 :body {:content-type multipart+boundary?
+                        :content-map {:binary (slurp tmp-file)}}}
+                (martian/response-for m :upload-data {:binary tmp-file-is})))))
+      (testing "byte array"
+        (let [byte-arr (String/.getBytes "Clojure!")]
+          (is (match?
+                {:multipart [{:name "binary" :content byte-arr}]
+                 :headers without-content-type?}
+                (martian/request-for m :upload-data {:binary byte-arr})))
+          (is (match?
+                {:status 200
+                 :body {:content-type multipart+boundary?
+                        :content-map {:binary "Clojure!"}}}
+                (martian/response-for m :upload-data {:binary byte-arr}))))))
+
+    (testing "extra types:"
+      (testing "URL"
+        (let [url (.toURL (URI. test-multipart-file-url))]
+          (is (match?
+                {:multipart [{:name "binary" :content input-stream?}]
+                 :headers without-content-type?}
+                (martian/request-for m :upload-data {:binary url})))
+          (is (match?
+                {:status 200
+                 :body {:content-type multipart+boundary?
+                        :content-map {:binary "Content retrieved via URL/URI"}}}
+                (martian/response-for m :upload-data {:binary url})))))
+      (testing "URI"
+        (let [uri (URI. test-multipart-file-url)]
+          (is (match?
+                {:multipart [{:name "binary" :content input-stream?}]
+                 :headers without-content-type?}
+                (martian/request-for m :upload-data {:binary uri})))
+          (is (match?
+                {:status 200
+                 :body {:content-type multipart+boundary?
+                        :content-map {:binary "Content retrieved via URL/URI"}}}
+                (martian/response-for m :upload-data {:binary uri})))))
+      (testing "Socket"
+        (with-open [socket (Socket. "localhost" 8888)
+                    writer (PrintWriter. (.getOutputStream socket) true)]
+          (binding [*out* writer]
+            (println "Hello, server! This is an invalid HTTP message."))
+          (is (match?
+                {:multipart [{:name "binary" :content input-stream?}]
+                 :headers without-content-type?}
+                (martian/request-for m :upload-data {:binary socket})))
+          (is (match?
+                {:status 200
+                 :body {:content-type multipart+boundary?
+                        :content-map {:binary #(str/starts-with? % "HTTP/1.1")}}}
+                (martian/response-for m :upload-data {:binary socket})))))
+      (if-bb
+        nil
+        (testing "Path"
+          (let [tmp-file (create-temp-file)
+                path (.toPath tmp-file)]
+            ;; NB: This test case requires IOFactory extension for Path.
+            (extend-io-factory-for-path)
+            (is (match?
+                  {:multipart [{:name "binary" :content input-stream?}]
+                   :headers without-content-type?}
+                  (martian/request-for m :upload-data {:binary path})))
+            (is (match?
+                  {:status 200
+                   :body {:content-type multipart+boundary?
+                          :content-map {:binary (slurp tmp-file)}}}
+                  (martian/response-for m :upload-data {:binary path})))))))
+
+    (testing "custom types:"
+      (testing "Number"
+        (let [int-num 1234567890]
+          (is (match?
+                {:multipart [{:name "custom" :content (str int-num)}]
+                 :headers without-content-type?}
+                (martian/request-for m :upload-data {:custom int-num})))
+          (is (match?
+                {:status 200
+                 :body {:content-type multipart+boundary?
+                        :content-map {:custom (str int-num)}}}
+                (martian/response-for m :upload-data {:custom int-num}))))))))
diff --git a/clj-http/test/martian/clj_http_test.clj b/clj-http/test/martian/clj_http_test.clj
index deae4d8..35fc8db 100644
--- a/clj-http/test/martian/clj_http_test.clj
+++ b/clj-http/test/martian/clj_http_test.clj
@@ -1,10 +1,31 @@
 (ns martian.clj-http-test
-  (:require [martian.clj-http :as martian-http]
+  (:require [clojure.java.io :as io]
+            [clojure.string :as str]
+            [clojure.test :refer [deftest testing is use-fixtures]]
+            [martian.clj-http :as martian-http]
             [martian.core :as martian]
             [martian.encoders :as encoders]
-            [martian.server-stub :refer [with-server swagger-url openapi-url openapi-test-url openapi-yaml-url openapi-test-yaml-url]]
-            [martian.test-utils :refer [input-stream->byte-array]]
-            [clojure.test :refer [deftest testing is use-fixtures]]))
+            [martian.server-stub :refer [swagger-url
+                                         openapi-url
+                                         openapi-yaml-url
+                                         openapi-test-url
+                                         openapi-test-yaml-url
+                                         openapi-multipart-url
+                                         test-multipart-file-url
+                                         with-server]]
+            [martian.test-utils :refer [binary-content
+                                        create-temp-file
+                                        extend-io-factory-for-path
+                                        input-stream?
+                                        input-stream->byte-array
+                                        without-content-type?
+                                        multipart+boundary?]]
+            [matcher-combinators.test])
+  (:import (java.io PrintWriter)
+           (java.net Socket URI)
+           (org.apache.http Consts)
+           (org.apache.http.entity ContentType)
+           (org.apache.http.entity.mime.content ByteArrayBody FileBody InputStreamBody StringBody)))
 
 (use-fixtures :once with-server)
 
@@ -73,3 +94,182 @@
     (is (= "https://sandbox.example.com" (:api-root m)))
     (is (= [[:list-items "Gets a list of items."]]
            (martian/explore m)))))
+
+(deftest multipart-request-test
+  (let [m (martian-http/bootstrap-openapi openapi-multipart-url)]
+    (is (= "http://localhost:8888" (:api-root m)))
+
+    (testing "common types:"
+      (testing "String"
+        (is (match?
+              {:multipart [{:name "string" :content "Howdy!"}]
+               :headers without-content-type?}
+              (martian/request-for m :upload-data {:string "Howdy!"})))
+        (is (match?
+              {:status 200
+               :body {:content-type multipart+boundary?
+                      :content-map {:string "Howdy!"}}}
+              (martian/response-for m :upload-data {:string "Howdy!"}))))
+      (testing "File"
+        (let [tmp-file (create-temp-file)]
+          (is (match?
+                {:multipart [{:name "binary" :content tmp-file}]
+                 :headers without-content-type?}
+                (martian/request-for m :upload-data {:binary tmp-file})))
+          (is (match?
+                {:status 200
+                 :body {:content-type multipart+boundary?
+                        :content-map {:binary (binary-content tmp-file)}}}
+                (martian/response-for m :upload-data {:binary tmp-file})))))
+      (testing "InputStream"
+        (let [tmp-file (create-temp-file)
+              tmp-file-is (io/input-stream tmp-file)]
+          (is (match?
+                {:multipart [{:name "binary" :content tmp-file-is}]
+                 :headers without-content-type?}
+                (martian/request-for m :upload-data {:binary tmp-file-is})))
+          (is (match?
+                {:status 200
+                 :body {:content-type multipart+boundary?
+                        :content-map {:binary (binary-content tmp-file "binary")}}}
+                (martian/response-for m :upload-data {:binary tmp-file-is})))))
+      (testing "byte array"
+        (let [byte-arr (String/.getBytes "Clojure!")]
+          (is (match?
+                {:multipart [{:name "binary" :content byte-arr}]
+                 :headers without-content-type?}
+                (martian/request-for m :upload-data {:binary byte-arr})))
+          (is (match?
+                {:status 200
+                 :body {:content-type multipart+boundary?
+                        :content-map {:binary {:filename "binary"
+                                               :content-type "application/octet-stream"
+                                               :tempfile "Clojure!"
+                                               :size 8}}}}
+                (martian/response-for m :upload-data {:binary byte-arr}))))))
+
+    (testing "extra types:"
+      (testing "URL"
+        (let [url (.toURL (URI. test-multipart-file-url))]
+          (is (match?
+                {:multipart [{:name "binary" :content input-stream?}]
+                 :headers without-content-type?}
+                (martian/request-for m :upload-data {:binary url})))
+          (is (match?
+                {:status 200
+                 :body {:content-type multipart+boundary?
+                        :content-map {:binary {:filename "binary"
+                                               :content-type "application/octet-stream"
+                                               :tempfile "Content retrieved via URL/URI"
+                                               :size 29}}}}
+                (martian/response-for m :upload-data {:binary url})))))
+      (testing "URI"
+        (let [uri (URI. test-multipart-file-url)]
+          (is (match?
+                {:multipart [{:name "binary" :content input-stream?}]
+                 :headers without-content-type?}
+                (martian/request-for m :upload-data {:binary uri})))
+          (is (match?
+                {:status 200
+                 :body {:content-type multipart+boundary?
+                        :content-map {:binary {:filename "binary"
+                                               :content-type "application/octet-stream"
+                                               :tempfile "Content retrieved via URL/URI"
+                                               :size 29}}}}
+                (martian/response-for m :upload-data {:binary uri})))))
+      (testing "Socket"
+        (with-open [socket (Socket. "localhost" 8888)
+                    writer (PrintWriter. (.getOutputStream socket) true)]
+          (binding [*out* writer]
+            (println "Hello, server! This is an invalid HTTP message."))
+          (is (match?
+                {:multipart [{:name "binary" :content input-stream?}]
+                 :headers without-content-type?}
+                (martian/request-for m :upload-data {:binary socket})))
+          (is (match?
+                {:status 200
+                 :body {:content-type multipart+boundary?
+                        :content-map {:binary {:filename "binary"
+                                               :content-type "application/octet-stream"
+                                               :tempfile #(str/starts-with? % "HTTP/1.1")}}}}
+                (martian/response-for m :upload-data {:binary socket})))))
+      (testing "Path"
+        (let [tmp-file (create-temp-file)
+              path (.toPath tmp-file)]
+          ;; NB: This test case requires IOFactory extension for Path.
+          (extend-io-factory-for-path)
+          (is (match?
+                {:multipart [{:name "binary" :content input-stream?}]
+                 :headers without-content-type?}
+                (martian/request-for m :upload-data {:binary path})))
+          (is (match?
+                {:status 200
+                 :body {:content-type multipart+boundary?
+                        :content-map {:binary (binary-content tmp-file "binary")}}}
+                (martian/response-for m :upload-data {:binary path}))))))
+
+    (testing "custom types:"
+      (testing "ContentBody > FileBody"
+        (let [tmp-file (create-temp-file)
+              file-body (FileBody. tmp-file)]
+          (is (match?
+                {:multipart [{:name "custom" :content file-body}]
+                 :headers without-content-type?}
+                (martian/request-for m :upload-data {:custom file-body})))
+          (is (match?
+                {:status 200
+                 :body {:content-type multipart+boundary?
+                        :content-map {:custom (binary-content tmp-file)}}}
+                (martian/response-for m :upload-data {:custom file-body})))))
+      (testing "ContentBody > InputStreamBody"
+        (let [tmp-file (create-temp-file)
+              tmp-file-is (io/input-stream tmp-file)
+              is-body (InputStreamBody. tmp-file-is "input-stream")]
+          (is (match?
+                {:multipart [{:name "custom" :content is-body}]
+                 :headers without-content-type?}
+                (martian/request-for m :upload-data {:custom is-body})))
+          (is (match?
+                {:status 200
+                 :body {:content-type multipart+boundary?
+                        :content-map {:custom (binary-content tmp-file "input-stream")}}}
+                (martian/response-for m :upload-data {:custom is-body})))))
+      (testing "ContentBody > ByteArrayBody"
+        (let [byte-arr (String/.getBytes "Clojure!")
+              byte-arr-body (ByteArrayBody. byte-arr "byte-array")]
+          (is (match?
+                {:multipart [{:name "custom" :content byte-arr-body}]
+                 :headers without-content-type?}
+                (martian/request-for m :upload-data {:custom byte-arr-body})))
+          (is (match?
+                {:status 200
+                 :body {:content-type multipart+boundary?
+                        :content-map {:custom {:filename "byte-array"
+                                               :content-type "application/octet-stream"
+                                               :tempfile "Clojure!"
+                                               :size 8}}}}
+                (martian/response-for m :upload-data {:custom byte-arr-body})))))
+      (testing "ContentBody > StringBody"
+        (let [content-type (ContentType/create "text/plain" Consts/UTF_8)
+              str-body (StringBody. "Hello, server! This is text." content-type)]
+          (is (match?
+                {:multipart [{:name "custom" :content str-body}]
+                 :headers without-content-type?}
+                (martian/request-for m :upload-data {:custom str-body})))
+          (is (match?
+                {:status 200
+                 :body {:content-type multipart+boundary?
+                        :content-map {:custom "Hello, server! This is text."}}}
+                (martian/response-for m :upload-data {:custom str-body})))))
+
+      (testing "Number"
+        (let [int-num 1234567890]
+          (is (match?
+                {:multipart [{:name "custom" :content (str int-num)}]
+                 :headers without-content-type?}
+                (martian/request-for m :upload-data {:custom int-num})))
+          (is (match?
+                {:status 200
+                 :body {:content-type multipart+boundary?
+                        :content-map {:custom (str int-num)}}}
+                (martian/response-for m :upload-data {:custom int-num}))))))))
diff --git a/core/test/martian/interceptors_test.cljc b/core/test/martian/interceptors_test.cljc
index 1737e0d..652dbb5 100644
--- a/core/test/martian/interceptors_test.cljc
+++ b/core/test/martian/interceptors_test.cljc
@@ -69,11 +69,10 @@
                        :omega "abc"}
                  i (i/encode-request
                      (assoc (encoders/default-encoders)
-                       "multipart/form-data" {:encode encoders/multipart-encode
-                                              :as :multipart}))]
-             (is (= {:multipart [{:name "alpha" :content 12345}
+                       "multipart/form-data" {:encode encoders/multipart-encode}))]
+             (is (= {:multipart [{:name "alpha" :content "12345"}
                                  {:name "omega" :content "abc"}]
-                     :headers {"Content-Type" "multipart/form-data"}}
+                     :headers nil}
                     (:request ((:enter i) {:request {:body body}
                                            :handler {:consumes ["multipart/form-data"]}}))))))))))
 
diff --git a/hato/test/martian/hato_test.clj b/hato/test/martian/hato_test.clj
index c7068db..79afb6a 100644
--- a/hato/test/martian/hato_test.clj
+++ b/hato/test/martian/hato_test.clj
@@ -1,10 +1,28 @@
 (ns martian.hato-test
-  (:require [martian.hato :as martian-http]
+  (:require [clojure.java.io :as io]
+            [clojure.string :as str]
+            [clojure.test :refer [deftest testing is use-fixtures]]
             [martian.core :as martian]
             [martian.encoders :as encoders]
-            [martian.server-stub :refer [with-server swagger-url openapi-url openapi-test-url openapi-yaml-url openapi-test-yaml-url]]
-            [martian.test-utils :refer [input-stream->byte-array]]
-            [clojure.test :refer [deftest testing is use-fixtures]]))
+            [martian.hato :as martian-http]
+            [martian.server-stub :refer [swagger-url
+                                         openapi-url
+                                         openapi-yaml-url
+                                         openapi-test-url
+                                         openapi-test-yaml-url
+                                         openapi-multipart-url
+                                         test-multipart-file-url
+                                         with-server]]
+            [martian.test-utils :refer [binary-content
+                                        create-temp-file
+                                        extend-io-factory-for-path
+                                        input-stream?
+                                        input-stream->byte-array
+                                        without-content-type?
+                                        multipart+boundary?]]
+            [matcher-combinators.test])
+  (:import (java.io PrintWriter)
+           (java.net Socket URI)))
 
 (use-fixtures :once with-server)
 
@@ -12,13 +30,13 @@
   (let [m (martian-http/bootstrap-swagger swagger-url)]
 
     (testing "default encoders"
-      (is (= {:method :post
+      (is (= {:version :http-1.1
+              :method :post
               :url "http://localhost:8888/pets/"
               :body {:name "Doggy McDogFace", :type "Dog", :age 3}
               :headers {"Accept" "application/transit+msgpack"
                         "Content-Type" "application/transit+msgpack"}
-              :as :byte-array
-              :version :http-1.1}
+              :as :byte-array}
              (-> (martian/request-for m :create-pet {:pet {:name "Doggy McDogFace"
                                                            :type "Dog"
                                                            :age 3}})
@@ -42,13 +60,13 @@
   (let [m (martian-http/bootstrap-swagger swagger-url {:interceptors martian-http/default-interceptors-async})]
 
     (testing "default encoders"
-      (is (= {:method :post
+      (is (= {:version :http-1.1
+              :method :post
               :url "http://localhost:8888/pets/"
               :body {:name "Doggy McDogFace", :type "Dog", :age 3}
               :headers {"Accept" "application/transit+msgpack"
                         "Content-Type" "application/transit+msgpack"}
-              :as :byte-array
-              :version :http-1.1}
+              :as :byte-array}
              (-> (martian/request-for m :create-pet {:pet {:name "Doggy McDogFace"
                                                            :type "Dog"
                                                            :age 3}})
@@ -128,3 +146,118 @@
     (is (= "https://sandbox.example.com" (:api-root m)))
     (is (= [[:list-items "Gets a list of items."]]
            (martian/explore m)))))
+
+(deftest multipart-request-test
+  (let [m (martian-http/bootstrap-openapi openapi-multipart-url)]
+    (is (= "http://localhost:8888" (:api-root m)))
+
+    (testing "common types:"
+      (testing "String"
+        (is (match?
+              {:multipart [{:name "string" :content "Howdy!"}]
+               :headers without-content-type?}
+              (martian/request-for m :upload-data {:string "Howdy!"})))
+        (is (match?
+              {:status 200
+               :body {:content-type multipart+boundary?
+                      :content-map {:string "Howdy!"}}}
+              (martian/response-for m :upload-data {:string "Howdy!"}))))
+      (testing "File"
+        (let [tmp-file (create-temp-file)]
+          (is (match?
+                {:multipart [{:name "binary" :content tmp-file}]
+                 :headers without-content-type?}
+                (martian/request-for m :upload-data {:binary tmp-file})))
+          (is (match?
+                {:status 200
+                 :body {:content-type multipart+boundary?
+                        :content-map {:binary (binary-content tmp-file)}}}
+                (martian/response-for m :upload-data {:binary tmp-file})))))
+      (testing "InputStream"
+        (let [tmp-file (create-temp-file)
+              tmp-file-is (io/input-stream tmp-file)]
+          (is (match?
+                {:multipart [{:name "binary" :content tmp-file-is}]
+                 :headers without-content-type?}
+                (martian/request-for m :upload-data {:binary tmp-file-is})))
+          (is (match?
+                {:status 200
+                 :body {:content-type multipart+boundary?
+                        :content-map {:binary (slurp tmp-file)}}}
+                (martian/response-for m :upload-data {:binary tmp-file-is})))))
+      (testing "byte array"
+        (let [byte-arr (String/.getBytes "Clojure!")]
+          (is (match?
+                {:multipart [{:name "binary" :content byte-arr}]
+                 :headers without-content-type?}
+                (martian/request-for m :upload-data {:binary byte-arr})))
+          (is (match?
+                {:status 200
+                 :body {:content-type multipart+boundary?
+                        :content-map {:binary "Clojure!"}}}
+                (martian/response-for m :upload-data {:binary byte-arr}))))))
+
+    (testing "extra types:"
+      (testing "URL"
+        (let [url (.toURL (URI. test-multipart-file-url))]
+          (is (match?
+                {:multipart [{:name "binary" :content input-stream?}]
+                 :headers without-content-type?}
+                (martian/request-for m :upload-data {:binary url})))
+          (is (match?
+                {:status 200
+                 :body {:content-type multipart+boundary?
+                        :content-map {:binary "Content retrieved via URL/URI"}}}
+                (martian/response-for m :upload-data {:binary url})))))
+      (testing "URI"
+        (let [uri (URI. test-multipart-file-url)]
+          (is (match?
+                {:multipart [{:name "binary" :content input-stream?}]
+                 :headers without-content-type?}
+                (martian/request-for m :upload-data {:binary uri})))
+          (is (match?
+                {:status 200
+                 :body {:content-type multipart+boundary?
+                        :content-map {:binary "Content retrieved via URL/URI"}}}
+                (martian/response-for m :upload-data {:binary uri})))))
+      (testing "Socket"
+        (with-open [socket (Socket. "localhost" 8888)
+                    writer (PrintWriter. (.getOutputStream socket) true)]
+          (binding [*out* writer]
+            (println "Hello, server! This is an invalid HTTP message."))
+          (is (match?
+                {:multipart [{:name "binary" :content input-stream?}]
+                 :headers without-content-type?}
+                (martian/request-for m :upload-data {:binary socket})))
+          (is (match?
+                {:status 200
+                 :body {:content-type multipart+boundary?
+                        :content-map {:binary #(str/starts-with? % "HTTP/1.1")}}}
+                (martian/response-for m :upload-data {:binary socket})))))
+      (testing "Path"
+        (let [tmp-file (create-temp-file)
+              path (.toPath tmp-file)]
+          ;; NB: This test case requires IOFactory extension for Path.
+          (extend-io-factory-for-path)
+          (is (match?
+                {:multipart [{:name "binary" :content input-stream?}]
+                 :headers without-content-type?}
+                (martian/request-for m :upload-data {:binary path})))
+          (is (match?
+                {:status 200
+                 :body {:content-type multipart+boundary?
+                        :content-map {:binary (slurp tmp-file)}}}
+                (martian/response-for m :upload-data {:binary path}))))))
+
+    (testing "custom types:"
+      (testing "Number"
+        (let [int-num 1234567890]
+          (is (match?
+                {:multipart [{:name "custom" :content (str int-num)}]
+                 :headers without-content-type?}
+                (martian/request-for m :upload-data {:custom int-num})))
+          (is (match?
+                {:status 200
+                 :body {:content-type multipart+boundary?
+                        :content-map {:custom (str int-num)}}}
+                (martian/response-for m :upload-data {:custom int-num}))))))))
diff --git a/httpkit/test/martian/httpkit_test.clj b/httpkit/test/martian/httpkit_test.clj
index dce2f88..e9bdb6c 100644
--- a/httpkit/test/martian/httpkit_test.clj
+++ b/httpkit/test/martian/httpkit_test.clj
@@ -1,10 +1,28 @@
 (ns martian.httpkit-test
-  (:require [martian.httpkit :as martian-http]
+  (:require [clojure.java.io :as io]
+            [clojure.string :as str]
+            [clojure.test :refer [deftest testing is use-fixtures]]
             [martian.core :as martian]
-            [martian.server-stub :refer [with-server swagger-url openapi-url openapi-test-url openapi-yaml-url openapi-test-yaml-url]]
             [martian.encoders :as encoders]
-            [martian.test-utils :refer [input-stream->byte-array]]
-            [clojure.test :refer [deftest testing is use-fixtures]]))
+            [martian.httpkit :as martian-http]
+            [martian.server-stub :refer [swagger-url
+                                         openapi-url
+                                         openapi-yaml-url
+                                         openapi-test-url
+                                         openapi-test-yaml-url
+                                         openapi-multipart-url
+                                         test-multipart-file-url
+                                         with-server]]
+            [martian.test-utils :refer [create-temp-file
+                                        extend-io-factory-for-path
+                                        input-stream?
+                                        input-stream->byte-array
+                                        without-content-type?
+                                        multipart+boundary?]]
+            [matcher-combinators.test])
+  (:import (java.io PrintWriter)
+           (java.net Socket URI)
+           (java.nio ByteBuffer)))
 
 (use-fixtures :once with-server)
 
@@ -73,3 +91,131 @@
     (is (= "https://sandbox.example.com" (:api-root m)))
     (is (= [[:list-items "Gets a list of items."]]
            (martian/explore m)))))
+
+(deftest multipart-request-test
+  (let [m (martian-http/bootstrap-openapi openapi-multipart-url)]
+    (is (= "http://localhost:8888" (:api-root m)))
+
+    (testing "common types:"
+      (testing "String"
+        (is (match?
+              {:multipart [{:name "string" :content "Howdy!"}]
+               :headers without-content-type?}
+              (martian/request-for m :upload-data {:string "Howdy!"})))
+        (is (match?
+              {:status 200
+               :body {:content-type multipart+boundary?
+                      :content-map {:string "Howdy!"}}}
+              @(martian/response-for m :upload-data {:string "Howdy!"}))))
+      (testing "File"
+        (let [tmp-file (create-temp-file)]
+          (is (match?
+                {:multipart [{:name "binary" :content tmp-file}]
+                 :headers without-content-type?}
+                (martian/request-for m :upload-data {:binary tmp-file})))
+          (is (match?
+                {:status 200
+                 :body {:content-type multipart+boundary?
+                        :content-map {:binary (slurp tmp-file)}}}
+                @(martian/response-for m :upload-data {:binary tmp-file})))))
+      (testing "InputStream"
+        (let [tmp-file (create-temp-file)
+              tmp-file-is (io/input-stream tmp-file)]
+          (is (match?
+                {:multipart [{:name "binary" :content tmp-file-is}]
+                 :headers without-content-type?}
+                (martian/request-for m :upload-data {:binary tmp-file-is})))
+          (is (match?
+                {:status 200
+                 :body {:content-type multipart+boundary?
+                        :content-map {:binary (slurp tmp-file)}}}
+                @(martian/response-for m :upload-data {:binary tmp-file-is})))))
+      (testing "byte array"
+        (let [byte-arr (String/.getBytes "Clojure!")]
+          (is (match?
+                {:multipart [{:name "binary" :content byte-arr}]
+                 :headers without-content-type?}
+                (martian/request-for m :upload-data {:binary byte-arr})))
+          (is (match?
+                {:status 200
+                 :body {:content-type multipart+boundary?
+                        :content-map {:binary "Clojure!"}}}
+                @(martian/response-for m :upload-data {:binary byte-arr}))))))
+
+    (testing "extra types:"
+      (testing "URL"
+        (let [url (.toURL (URI. test-multipart-file-url))]
+          (is (match?
+                {:multipart [{:name "binary" :content input-stream?}]
+                 :headers without-content-type?}
+                (martian/request-for m :upload-data {:binary url})))
+          (is (match?
+                {:status 200
+                 :body {:content-type multipart+boundary?
+                        :content-map {:binary "Content retrieved via URL/URI"}}}
+                @(martian/response-for m :upload-data {:binary url})))))
+      (testing "URI"
+        (let [uri (URI. test-multipart-file-url)]
+          (is (match?
+                {:multipart [{:name "binary" :content input-stream?}]
+                 :headers without-content-type?}
+                (martian/request-for m :upload-data {:binary uri})))
+          (is (match?
+                {:status 200
+                 :body {:content-type multipart+boundary?
+                        :content-map {:binary "Content retrieved via URL/URI"}}}
+                @(martian/response-for m :upload-data {:binary uri})))))
+      (testing "Socket"
+        (with-open [socket (Socket. "localhost" 8888)
+                    writer (PrintWriter. (.getOutputStream socket) true)]
+          (binding [*out* writer]
+            (println "Hello, server! This is an invalid HTTP message."))
+          (is (match?
+                {:multipart [{:name "binary" :content input-stream?}]
+                 :headers without-content-type?}
+                (martian/request-for m :upload-data {:binary socket})))
+          (is (match?
+                {:status 200
+                 :body {:content-type multipart+boundary?
+                        :content-map {:binary #(str/starts-with? % "HTTP/1.1")}}}
+                @(martian/response-for m :upload-data {:binary socket})))))
+      (testing "Path"
+        (let [tmp-file (create-temp-file)
+              path (.toPath tmp-file)]
+          ;; NB: This test case requires IOFactory extension for Path.
+          (extend-io-factory-for-path)
+          (is (match?
+                {:multipart [{:name "binary" :content input-stream?}]
+                 :headers without-content-type?}
+                (martian/request-for m :upload-data {:binary path})))
+          (is (match?
+                {:status 200
+                 :body {:content-type multipart+boundary?
+                        :content-map {:binary (slurp tmp-file)}}}
+                @(martian/response-for m :upload-data {:binary path}))))))
+
+    (testing "custom types:"
+      (testing "ByteBuffer"
+        (let [byte-arr (String/.getBytes "Clojure!")
+              byte-buf (ByteBuffer/wrap byte-arr)]
+          (is (match?
+                {:multipart [{:name "custom" :content byte-buf}]
+                 :headers without-content-type?}
+                (martian/request-for m :upload-data {:custom byte-buf})))
+          (is (match?
+                {:status 200
+                 :body {:content-type multipart+boundary?
+                        :content-map {:custom "Clojure!"}}}
+                @(martian/response-for m :upload-data {:custom byte-buf})))))
+      ;; NB: Although 'http-kit' has built-in support for numbers, we omit it.
+      (testing "Number"
+        (let [int-num 1234567890]
+          (is (match?
+                {:multipart [{:name "custom" :content (str int-num)}]
+                 :headers without-content-type?}
+                (martian/request-for m :upload-data {:custom int-num})))
+          (is (match?
+                {:status 200
+                 :body {:content-type multipart+boundary?
+                        :content-map {:custom (str int-num)}}}
+                @(martian/response-for m :upload-data {:custom int-num}))))))))
diff --git a/test-common/martian/server_stub.clj b/test-common/martian/server_stub.clj
index 2a35c17..6ea7926 100644
--- a/test-common/martian/server_stub.clj
+++ b/test-common/martian/server_stub.clj
@@ -1,9 +1,13 @@
 (ns martian.server-stub
-  (:require [io.pedestal.http :as bootstrap]
+  (:require [clojure.walk :as w]
+            [io.pedestal.http :as bootstrap]
+            [io.pedestal.http.ring-middlewares :as ring-mw]
             [pedestal-api
              [core :as api]
              [helpers :refer [before defbefore defhandler handler]]]
-            [schema.core :as s]))
+            [schema.core :as s])
+  (:import (java.io File)
+           (java.nio.file Path)))
 
 (defonce the-pets (atom {}))
 
@@ -13,9 +17,9 @@
    :age s/Int})
 
 (defhandler create-pet
-  {:summary     "Create a pet"
-   :parameters  {:body-params Pet}
-   :responses   {201 {:body {:id s/Int}}}}
+  {:summary    "Create a pet"
+   :parameters {:body-params Pet}
+   :responses  {201 {:body {:id s/Int}}}}
   [request]
   (let [id 123]
     (swap! the-pets assoc id (:body-params request))
@@ -34,6 +38,35 @@
     {:status 404
      :body "No pet found with this id"}))
 
+(s/defschema Upload
+  {(s/optional-key :string) s/Str
+   (s/optional-key :binary) s/Any
+   (s/optional-key :custom) s/Any})
+
+(defn- get-content-type-header [request]
+  (or (get-in request [:headers "content-type"])
+      (get-in request [:headers "Content-Type"])
+      (get-in request [:headers :content-type])))
+
+(defn- get-prepared-content-map [request]
+  (w/postwalk (fn [obj]
+                (cond
+                  (instance? File obj) (slurp obj)
+                  (instance? Path obj) (slurp (.toFile obj))
+                  (= 'org.httpkit.client.MultipartEntity (type obj)) (bean obj)
+                  :else obj))
+              (:form-params request)))
+
+(defhandler upload-data
+  {:summary    "Upload data via multipart"
+   :parameters {:form-params Upload}
+   :responses  {200 {:body {:content-type s/Str
+                            :content-map {s/Keyword s/Any}}}}}
+  [request]
+  {:status 200
+   :body {:content-type (get-content-type-header request)
+          :content-map (get-prepared-content-map request)}})
+
 (s/with-fn-validation
   (api/defroutes routes
     {}
@@ -47,6 +80,9 @@
         ["/" {:post create-pet}]
         ["/:id" {:get get-pet}]]
 
+       ;; endpoint for multipart request tests
+       ["/upload" {:post upload-data}]
+
        ["/swagger.json" {:get api/swagger-json}]]]]))
 
 (def service
@@ -67,17 +103,18 @@
 (def openapi-yaml-url (format "http://localhost:%s/openapi.yaml" (::bootstrap/port service)))
 (def openapi-test-url (format "http://localhost:%s/openapi-test.json" (::bootstrap/port service)))
 (def openapi-test-yaml-url (format "http://localhost:%s/openapi-test.yaml" (::bootstrap/port service)))
+(def openapi-multipart-url (format "http://localhost:%s/openapi-multipart.json" (::bootstrap/port service)))
+(def test-multipart-file-url (format "http://localhost:%s/test-multipart.txt" (::bootstrap/port service)))
 
-(def with-server
-  (fn [f]
-    (let [server-instance (bootstrap/create-server (bootstrap/default-interceptors service))]
-      (try
-        (bootstrap/start server-instance)
-        (f)
-        (finally (bootstrap/stop server-instance))))))
+(defn add-interceptors
+  [service-map]
+  (update service-map
+          ::bootstrap/interceptors
+          #(into [(ring-mw/multipart-params)] %)))
+
+;; For use at the REPL
 
-;; for use at the repl
-(def server-instance (atom nil))
+(defonce server-instance (atom nil))
 
 (defn stop-server! []
   (when-let [instance @server-instance]
@@ -87,6 +124,18 @@
 (defn start-server! []
   (when @server-instance
     (stop-server!))
-  (let [instance (bootstrap/create-server (bootstrap/default-interceptors service))]
+  (let [instance (-> (bootstrap/default-interceptors service)
+                     (add-interceptors)
+                     (bootstrap/create-server))]
     (bootstrap/start instance)
     (reset! server-instance instance)))
+
+;; For test fixtures
+
+(def with-server
+  (fn [f]
+    (start-server!)
+    (try
+      (f)
+      (finally
+        (stop-server!)))))
diff --git a/test-common/martian/test_utils.clj b/test-common/martian/test_utils.clj
index 32c9a1a..6da2b20 100644
--- a/test-common/martian/test_utils.clj
+++ b/test-common/martian/test_utils.clj
@@ -1,8 +1,51 @@
 (ns martian.test-utils
-  (:require [clojure.java.io :as io])
-  (:import [java.io ByteArrayOutputStream]))
+  (:require [clojure.java.io :as io]
+            [matcher-combinators.matchers :as m])
+  (:import [java.io ByteArrayOutputStream File InputStream]
+           [java.nio.file Files Path]
+           [java.nio.file.attribute FileAttribute]))
+
+(defmacro if-bb [then & [else]]
+  (if (System/getProperty "babashka.version")
+    then else))
 
 (defn input-stream->byte-array [input-stream]
   (with-open [os (ByteArrayOutputStream.)]
     (io/copy (io/input-stream input-stream) os)
     (.toByteArray os)))
+
+(def input-stream? #(instance? InputStream %))
+
+(defn create-temp-file ^File []
+  (let [tmp-file (Path/.toFile
+                   (Files/createTempFile "martian" nil (make-array FileAttribute 0)))]
+    (spit tmp-file (str "Random content: " (random-uuid)))
+    tmp-file))
+
+(defn binary-content
+  ([file]
+   (binary-content file (File/.getName file)))
+  ([file filename]
+   (let [content (slurp file)]
+     {:filename filename
+      :content-type "application/octet-stream"
+      :tempfile content
+      :size (count content)})))
+
+(if-bb
+  nil
+  (defn extend-io-factory-for-path []
+    (extend Path
+      io/IOFactory
+      (assoc
+        io/default-streams-impl
+        :make-input-stream (fn [^Path path opts]
+                             (io/make-input-stream (Path/.toFile path) opts))
+        :make-output-stream (fn [^Path path opts]
+                              (io/make-output-stream (Path/.toFile path) opts))))))
+
+(defn without-content-type? [headers]
+  (not (contains? headers "Content-Type")))
+
+(def multipart+boundary?
+  (m/via #(subs % 0 30) "multipart/form-data; boundary="))
diff --git a/test-common/public/openapi-multipart.json b/test-common/public/openapi-multipart.json
new file mode 100644
index 0000000..149ebbd
--- /dev/null
+++ b/test-common/public/openapi-multipart.json
@@ -0,0 +1,89 @@
+{
+    "openapi": "3.0.2",
+    "externalDocs": {
+        "description": "Find out more about Swagger",
+        "url": "http://swagger.io"
+    },
+    "servers": [{
+        "url": "http://localhost:8888"
+    }],
+    "paths": {
+        "/upload": {
+            "post": {
+                "summary": "Upload data via multipart",
+                "description": "Upload data via multipart",
+                "operationId": "uploadData",
+                "requestBody": {
+                    "description": "Create a new pet in the store",
+                    "content": {
+                        "multipart/form-data": {
+                            "schema": {
+                                "$ref": "#/components/schemas/Upload"
+                            }
+                        }
+                    },
+                    "required": true
+                },
+                "responses": {
+                    "200": {
+                        "description": "Successful operation",
+                        "content": {
+                            "application/json": {
+                                "schema": {
+                                    "$ref": "#/components/schemas/ApiResponse"
+                                }
+                            }
+                        }
+                    }
+                },
+                "security": [{
+                    "api_key": []
+                }]
+            }
+        }
+    },
+    "components": {
+        "schemas": {
+            "Upload": {
+                "type": "object",
+                "properties": {
+                    "string": {
+                        "type": "string"
+                    },
+                    "binary": {
+                        "type": "string",
+                        "format": "binary"
+                    },
+                    "custom": {
+                        "type": "string",
+                        "format": "binary"
+                    }
+                },
+                "xml": {
+                    "name": "upload"
+                }
+            },
+            "ApiResponse": {
+                "type": "object",
+                "properties": {
+                    "contentType": {
+                        "type": "string"
+                    },
+                    "contentMap": {
+                        "type": "object"
+                    }
+                },
+                "xml": {
+                    "name": "##default"
+                }
+            }
+        },
+        "securitySchemes": {
+            "api_key": {
+                "type": "apiKey",
+                "name": "api_key",
+                "in": "header"
+            }
+        }
+    }
+}
diff --git a/test-common/public/test-multipart.txt b/test-common/public/test-multipart.txt
new file mode 100644
index 0000000..2c8e435
--- /dev/null
+++ b/test-common/public/test-multipart.txt
@@ -0,0 +1,1 @@
+Content retrieved via URL/URI
\ No newline at end of file