mirror of
https://github.com/metosin/reitit.git
synced 2025-12-16 16:01:11 +00:00
feat: use request Content-Type or :muuntaja/content-type to coerce
Previously, `extract-response-format-default` was only looking at (-> request :muuntaja/response :format). This led to wrong behaviour when there were separate schemas for separate response content types and an explicitly picked content-type for the response.
This commit is contained in:
parent
f4da07c222
commit
7fb9c27e46
3 changed files with 239 additions and 93 deletions
|
|
@ -202,9 +202,11 @@ is:
|
||||||
"application/edn" {:schema {:x s/Int}}
|
"application/edn" {:schema {:x s/Int}}
|
||||||
:default {:schema {:ww s/Int}}}}}
|
:default {:schema {:ww s/Int}}}}}
|
||||||
:handler ...}}]]
|
:handler ...}}]]
|
||||||
{:data {:middleware [rrc/coerce-exceptions-middleware
|
{:data {:muuntaja muuntaja.core/instance
|
||||||
rrc/coerce-request-middleware
|
:middleware [reitit.ring.middleware.muuntaja/format-middleware
|
||||||
rrc/coerce-response-middleware]}})))
|
reitit.ring.coercion/coerce-exceptions-middleware
|
||||||
|
reitit.ring.coercion/coerce-request-middleware
|
||||||
|
reitit.ring.coercion/coerce-response-middleware]}})))
|
||||||
```
|
```
|
||||||
|
|
||||||
The resolution logic for response coercers is:
|
The resolution logic for response coercers is:
|
||||||
|
|
@ -215,6 +217,17 @@ The resolution logic for response coercers is:
|
||||||
3. `:body`
|
3. `:body`
|
||||||
3. If nothing was found, do not coerce
|
3. If nothing was found, do not coerce
|
||||||
|
|
||||||
|
To select the response content-type, you can either:
|
||||||
|
1. Let muuntaja pick the content-type based on things like the request Accept header
|
||||||
|
- This is what most users want
|
||||||
|
2. Set `:muuntaja/content-type` in the response to pick an explicit content type
|
||||||
|
3. Set the `"Content-Type"` header in the response
|
||||||
|
- This disables muuntaja, so you need to encode your response body in some other way!
|
||||||
|
- This is not compatible with response schema checking, since coercion won't know what to do with the already-encoded response body.
|
||||||
|
4. Use the `:extract-response-format` option to inject your own logic. See `reitit.coercion/extract-response-format-default` for the default.
|
||||||
|
|
||||||
|
See also the [muuntaja content negotiation](./content_negotiation.md) docs.
|
||||||
|
|
||||||
## Pretty printing spec errors
|
## Pretty printing spec errors
|
||||||
|
|
||||||
Spec problems are exposed as is in request & response coercion errors. Pretty-printers like [expound](https://github.com/bhb/expound) can be enabled like this:
|
Spec problems are exposed as is in request & response coercion errors. Pretty-printers like [expound](https://github.com/bhb/expound) can be enabled like this:
|
||||||
|
|
|
||||||
|
|
@ -152,8 +152,10 @@
|
||||||
rcs (request-coercers coercion parameters (cond-> opts route-request (assoc ::skip #{:body})))]
|
rcs (request-coercers coercion parameters (cond-> opts route-request (assoc ::skip #{:body})))]
|
||||||
(if (and crc rcs) (into crc (vec rcs)) (or crc rcs)))))
|
(if (and crc rcs) (into crc (vec rcs)) (or crc rcs)))))
|
||||||
|
|
||||||
(defn extract-response-format-default [request _]
|
(defn extract-response-format-default [request response]
|
||||||
(-> request :muuntaja/response :format))
|
(or (get-in response [:headers "Content-Type"])
|
||||||
|
(:muuntaja/content-type response)
|
||||||
|
(-> request :muuntaja/response :format)))
|
||||||
|
|
||||||
(defn -format->coercer [coercion {:keys [content body]} _opts]
|
(defn -format->coercer [coercion {:keys [content body]} _opts]
|
||||||
(->> (concat (when body
|
(->> (concat (when body
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,10 @@
|
||||||
(ns reitit.ring-coercion-test
|
(ns reitit.ring-coercion-test
|
||||||
(:require [clojure.test :refer [deftest is testing]]
|
(:require [clojure.test :refer [deftest is testing]]
|
||||||
[malli.experimental.lite :as l]
|
[malli.experimental.lite :as l]
|
||||||
#?@(:clj [[muuntaja.middleware]
|
#?@(:clj [[muuntaja.core]
|
||||||
[jsonista.core :as j]])
|
[muuntaja.middleware]
|
||||||
|
[jsonista.core :as j]
|
||||||
|
[reitit.ring.middleware.muuntaja]])
|
||||||
[malli.core :as m]
|
[malli.core :as m]
|
||||||
[malli.util :as mu]
|
[malli.util :as mu]
|
||||||
[meta-merge.core :refer [meta-merge]]
|
[meta-merge.core :refer [meta-merge]]
|
||||||
|
|
@ -585,99 +587,138 @@
|
||||||
|
|
||||||
#?(:clj
|
#?(:clj
|
||||||
(deftest per-content-type-test
|
(deftest per-content-type-test
|
||||||
(doseq [[coercion json-request edn-request default-request json-response edn-response default-response]
|
(let [normalize-json (fn [resp]
|
||||||
[[malli/coercion
|
(update resp :body #(-> % j/write-value-as-string (j/read-value j/keyword-keys-object-mapper))))]
|
||||||
[:map [:request [:enum :json]] [:response any?]]
|
(doseq [[coercion json-request edn-request default-request json-response edn-response default-response]
|
||||||
[:map [:request [:enum :edn]] [:response any?]]
|
[[malli/coercion
|
||||||
[:map [:request [:enum :default]] [:response any?]]
|
[:map [:request [:enum :json]] [:response any?]]
|
||||||
[:map [:request any?] [:response [:enum :json]]]
|
[:map [:request [:enum :edn]] [:response any?]]
|
||||||
[:map [:request any?] [:response [:enum :edn]]]
|
[:map [:request [:enum :default]] [:response any?]]
|
||||||
[:map [:request any?] [:response [:enum :default]]]]
|
[:map [:request any?] [:response [:enum :json]]]
|
||||||
[schema/coercion
|
[:map [:request any?] [:response [:enum :edn]]]
|
||||||
{:request (s/eq :json) :response s/Any}
|
[:map [:request any?] [:response [:enum :default]]]]
|
||||||
{:request (s/eq :edn) :response s/Any}
|
[schema/coercion
|
||||||
{:request (s/eq :default) :response s/Any}
|
{:request (s/eq :json) :response s/Any}
|
||||||
{:request s/Any :response (s/eq :json)}
|
{:request (s/eq :edn) :response s/Any}
|
||||||
{:request s/Any :response (s/eq :edn)}
|
{:request (s/eq :default) :response s/Any}
|
||||||
{:request s/Any :response (s/eq :default)}]
|
{:request s/Any :response (s/eq :json)}
|
||||||
[spec/coercion
|
{:request s/Any :response (s/eq :edn)}
|
||||||
{:request (clojure.spec.alpha/spec #{:json}) :response any?}
|
{:request s/Any :response (s/eq :default)}]
|
||||||
{:request (clojure.spec.alpha/spec #{:edn}) :response any?}
|
[spec/coercion
|
||||||
{:request (clojure.spec.alpha/spec #{:default}) :response any?}
|
{:request (clojure.spec.alpha/spec #{:json}) :response any?}
|
||||||
{:request any? :response (clojure.spec.alpha/spec #{:json})}
|
{:request (clojure.spec.alpha/spec #{:edn}) :response any?}
|
||||||
{:request any? :response (clojure.spec.alpha/spec #{:end})}
|
{:request (clojure.spec.alpha/spec #{:default}) :response any?}
|
||||||
{:request any? :response (clojure.spec.alpha/spec #{:default})}]]]
|
{:request any? :response (clojure.spec.alpha/spec #{:json})}
|
||||||
(testing (str coercion)
|
{:request any? :response (clojure.spec.alpha/spec #{:end})}
|
||||||
(doseq [{:keys [name app]}
|
{:request any? :response (clojure.spec.alpha/spec #{:default})}]]]
|
||||||
[{:name "using top-level :body"
|
(testing (str coercion)
|
||||||
:app (ring/ring-handler
|
(doseq [{:keys [name app]}
|
||||||
(ring/router
|
[{:name "using top-level :body"
|
||||||
["/foo" {:post {:request {:content {"application/json" {:schema json-request}
|
:app (ring/ring-handler
|
||||||
"application/edn" {:schema edn-request}}
|
(ring/router
|
||||||
:body default-request}
|
["/foo" {:post {:request {:content {"application/json" {:schema json-request}
|
||||||
:responses {200 {:content {"application/json" {:schema json-response}
|
"application/edn" {:schema edn-request}}
|
||||||
"application/edn" {:schema edn-response}}
|
:body default-request}
|
||||||
:body default-response}}
|
:responses {200 {:content {"application/json" {:schema json-response}
|
||||||
:handler (fn [req]
|
"application/edn" {:schema edn-response}}
|
||||||
{:status 200
|
:body default-response}}
|
||||||
:body (-> req :parameters :request)})}}]
|
:handler (fn [req]
|
||||||
{:validate reitit.ring.spec/validate
|
{:status 200
|
||||||
:data {:middleware [rrc/coerce-request-middleware
|
:body (-> req :parameters :request)})}}]
|
||||||
rrc/coerce-response-middleware]
|
{:validate reitit.ring.spec/validate
|
||||||
:coercion coercion}}))}
|
:data {:middleware [rrc/coerce-request-middleware
|
||||||
{:name "using :default content"
|
rrc/coerce-response-middleware]
|
||||||
:app (ring/ring-handler
|
:coercion coercion}}))}
|
||||||
(ring/router
|
{:name "using :default content"
|
||||||
["/foo" {:post {:request {:content {"application/json" {:schema json-request}
|
:app (ring/ring-handler
|
||||||
"application/edn" {:schema edn-request}
|
(ring/router
|
||||||
:default {:schema default-request}}
|
["/foo" {:post {:request {:content {"application/json" {:schema json-request}
|
||||||
:body json-request} ;; not applied as :default exists
|
"application/edn" {:schema edn-request}
|
||||||
:responses {200 {:content {"application/json" {:schema json-response}
|
:default {:schema default-request}}
|
||||||
"application/edn" {:schema edn-response}
|
:body json-request} ;; not applied as :default exists
|
||||||
:default {:schema default-response}}
|
:responses {200 {:content {"application/json" {:schema json-response}
|
||||||
:body json-response}} ;; not applied as :default exists
|
"application/edn" {:schema edn-response}
|
||||||
:handler (fn [req]
|
:default {:schema default-response}}
|
||||||
{:status 200
|
:body json-response}} ;; not applied as :default exists
|
||||||
:body (-> req :parameters :request)})}}]
|
:handler (fn [req]
|
||||||
{:validate reitit.ring.spec/validate
|
{:status 200
|
||||||
:data {:middleware [rrc/coerce-request-middleware
|
:body (-> req :parameters :request)})}}]
|
||||||
rrc/coerce-response-middleware]
|
{:validate reitit.ring.spec/validate
|
||||||
:coercion coercion}}))}]]
|
:data {:middleware [rrc/coerce-request-middleware
|
||||||
(testing name
|
rrc/coerce-response-middleware]
|
||||||
(let [call (fn [request]
|
:coercion coercion}}))}]]
|
||||||
|
(testing name
|
||||||
|
(let [call (fn [request]
|
||||||
|
(try
|
||||||
|
(app request)
|
||||||
|
(catch ExceptionInfo e
|
||||||
|
(select-keys (ex-data e) [:type :in]))))
|
||||||
|
request (fn [request-format response-format body]
|
||||||
|
{:request-method :post
|
||||||
|
:uri "/foo"
|
||||||
|
:muuntaja/request {:format request-format}
|
||||||
|
:muuntaja/response {:format response-format}
|
||||||
|
:body-params body})]
|
||||||
|
(testing "succesful call"
|
||||||
|
(is (= {:status 200 :body {:request "json", :response "json"}}
|
||||||
|
(normalize-json (call (request "application/json" "application/json" {:request :json :response :json})))))
|
||||||
|
(is (= {:status 200 :body {:request "edn", :response "json"}}
|
||||||
|
(normalize-json (call (request "application/edn" "application/json" {:request :edn :response :json})))))
|
||||||
|
(is (= {:status 200 :body {:request :default, :response :default}}
|
||||||
|
(call (request "application/transit" "application/transit" {:request :default :response :default})))))
|
||||||
|
(testing "request validation fails"
|
||||||
|
(is (= {:type :reitit.coercion/request-coercion :in [:request :body-params]}
|
||||||
|
(call (request "application/edn" "application/json" {:request :json :response :json}))))
|
||||||
|
(is (= {:type :reitit.coercion/request-coercion :in [:request :body-params]}
|
||||||
|
(call (request "application/json" "application/json" {:request :edn :response :json}))))
|
||||||
|
(is (= {:type :reitit.coercion/request-coercion :in [:request :body-params]}
|
||||||
|
(call (request "application/transit" "application/json" {:request :edn :response :json})))))
|
||||||
|
(testing "response validation fails"
|
||||||
|
(is (= {:type :reitit.coercion/response-coercion :in [:response :body]}
|
||||||
|
(call (request "application/json" "application/json" {:request :json :response :edn}))))
|
||||||
|
(is (= {:type :reitit.coercion/response-coercion :in [:response :body]}
|
||||||
|
(call (request "application/json" "application/edn" {:request :json :response :json}))))
|
||||||
|
(is (= {:type :reitit.coercion/response-coercion :in [:response :body]}
|
||||||
|
(call (request "application/json" "application/transit" {:request :json :response :json}))))))))
|
||||||
|
(testing "explicit response content type"
|
||||||
|
(let [response (atom nil)
|
||||||
|
app (ring/ring-handler
|
||||||
|
(ring/router
|
||||||
|
["/foo" {:post {:responses {200 {:content {"application/json" {:schema json-response}
|
||||||
|
"application/edn" {:schema edn-response}
|
||||||
|
:default {:schema default-response}}}}
|
||||||
|
:handler (fn [req]
|
||||||
|
@response)}}]
|
||||||
|
{:validate reitit.ring.spec/validate
|
||||||
|
:data {:middleware [rrc/coerce-request-middleware
|
||||||
|
rrc/coerce-response-middleware]
|
||||||
|
:coercion coercion}}))
|
||||||
|
call (fn [request]
|
||||||
(try
|
(try
|
||||||
(app request)
|
(app request)
|
||||||
(catch ExceptionInfo e
|
(catch ExceptionInfo e
|
||||||
|
#_(ex-data e)
|
||||||
(select-keys (ex-data e) [:type :in]))))
|
(select-keys (ex-data e) [:type :in]))))
|
||||||
request (fn [request-format response-format body]
|
request (fn [request-format body resp]
|
||||||
|
(reset! response resp)
|
||||||
{:request-method :post
|
{:request-method :post
|
||||||
:uri "/foo"
|
:uri "/foo"
|
||||||
:muuntaja/request {:format request-format}
|
:muuntaja/request {:format request-format}
|
||||||
:muuntaja/response {:format response-format}
|
:body-params body})]
|
||||||
:body-params body})
|
(testing "via :headers \"Content-Type\""
|
||||||
normalize-json (fn[body]
|
(is (= {:status 200 :body {:request "json" :response "json"} :headers {"Content-Type" "application/json"}}
|
||||||
(-> body j/write-value-as-string (j/read-value j/keyword-keys-object-mapper)))]
|
(normalize-json (call (request "application/json" {:request :json :response :json} {:status 200 :body {:request :json :response :json} :headers {"Content-Type" "application/json"}}))))
|
||||||
(testing "succesful call"
|
"valid reponse")
|
||||||
(is (= {:status 200 :body {:request "json", :response "json"}}
|
(is (= {:in [:response :body] :type :reitit.coercion/response-coercion}
|
||||||
(normalize-json (call (request "application/json" "application/json" {:request :json :response :json})))))
|
(call (request "application/json" {:request :json :response :json} {:status 200 :body {:request :json :response :invalid} :headers {"Content-Type" "application/json"}})))
|
||||||
(is (= {:status 200 :body {:request "edn", :response "json"}}
|
"invalid reponse"))
|
||||||
(normalize-json (call (request "application/edn" "application/json" {:request :edn :response :json})))))
|
(testing "via :muuntaja/content-type"
|
||||||
(is (= {:status 200 :body {:request :default, :response :default}}
|
(is (= {:status 200 :body {:request "json" :response "json"} :muuntaja/content-type "application/json"}
|
||||||
(call (request "application/transit" "application/transit" {:request :default :response :default})))))
|
(normalize-json (call (request "application/json" {:request :json :response :json} {:status 200 :body {:request :json :response :json} :muuntaja/content-type "application/json"}))))
|
||||||
(testing "request validation fails"
|
"valid reponse")
|
||||||
(is (= {:type :reitit.coercion/request-coercion :in [:request :body-params]}
|
(is (= {:in [:response :body] :type :reitit.coercion/response-coercion}
|
||||||
(call (request "application/edn" "application/json" {:request :json :response :json}))))
|
(call (request "application/json" {:request :json :response :json} {:status 200 :body {:request :json :response :invalid} :muuntaja/content-type "application/json"})))
|
||||||
(is (= {:type :reitit.coercion/request-coercion :in [:request :body-params]}
|
"invalid reponse")))))))))
|
||||||
(call (request "application/json" "application/json" {:request :edn :response :json}))))
|
|
||||||
(is (= {:type :reitit.coercion/request-coercion :in [:request :body-params]}
|
|
||||||
(call (request "application/transit" "application/json" {:request :edn :response :json})))))
|
|
||||||
(testing "response validation fails"
|
|
||||||
(is (= {:type :reitit.coercion/response-coercion :in [:response :body]}
|
|
||||||
(call (request "application/json" "application/json" {:request :json :response :edn}))))
|
|
||||||
(is (= {:type :reitit.coercion/response-coercion :in [:response :body]}
|
|
||||||
(call (request "application/json" "application/edn" {:request :json :response :json}))))
|
|
||||||
(is (= {:type :reitit.coercion/response-coercion :in [:response :body]}
|
|
||||||
(call (request "application/json" "application/transit" {:request :json :response :json}))))))))))))
|
|
||||||
|
|
||||||
|
|
||||||
#?(:clj
|
#?(:clj
|
||||||
|
|
@ -801,3 +842,93 @@
|
||||||
(app) :body slurp (read-string))]
|
(app) :body slurp (read-string))]
|
||||||
(is (= data-edn (e2e (assoc data-edn :EXTRA "VALUE"))))
|
(is (= data-edn (e2e (assoc data-edn :EXTRA "VALUE"))))
|
||||||
(is (thrown? ExceptionInfo (e2e data-json))))))))
|
(is (thrown? ExceptionInfo (e2e data-json))))))))
|
||||||
|
|
||||||
|
#?(:clj
|
||||||
|
(deftest muuntaja-per-content-type-coercion-test
|
||||||
|
;; Test integration between per-content-type coercion and muuntaja.
|
||||||
|
;; Malli-only for now.
|
||||||
|
(let [response (atom nil)
|
||||||
|
app (ring/ring-handler
|
||||||
|
(ring/router
|
||||||
|
["/foo" {:post {:request {:content {"application/json" {:schema [:map [:request [:enum :json]]]}
|
||||||
|
"application/edn" {:schema [:map [:request [:enum :edn]]]}
|
||||||
|
:default {:schema [:map [:request [:enum :default]]]}}}
|
||||||
|
:responses {200 {:content {"application/json" {:schema [:map [:response [:enum :json]]]}
|
||||||
|
"application/edn" {:schema [:map [:response [:enum :edn]]]}
|
||||||
|
:default {}}}}
|
||||||
|
:handler (fn [req] @response)}}]
|
||||||
|
{:data {:middleware [reitit.ring.middleware.muuntaja/format-middleware
|
||||||
|
rrc/coerce-request-middleware
|
||||||
|
rrc/coerce-response-middleware]
|
||||||
|
:muuntaja muuntaja.core/instance
|
||||||
|
:coercion malli/coercion}}))
|
||||||
|
maybe-slurp #(if (instance? java.io.InputStream %)
|
||||||
|
(slurp %)
|
||||||
|
%)
|
||||||
|
call (fn [request resp]
|
||||||
|
(reset! response resp)
|
||||||
|
(try
|
||||||
|
(-> (merge {:request-method :post :uri "/foo"} request)
|
||||||
|
(update :body #(ByteArrayInputStream. (.getBytes % "UTF-8")))
|
||||||
|
(app) :body (maybe-slurp))
|
||||||
|
(catch ExceptionInfo e
|
||||||
|
#_(ex-data e)
|
||||||
|
(select-keys (ex-data e) [:in :type]))))
|
||||||
|
read-json #(j/read-value % (j/object-mapper {:decode-key-fn true}))]
|
||||||
|
(testing "response content-type defaults to json"
|
||||||
|
(is (= {:response "json"}
|
||||||
|
(read-json
|
||||||
|
(call {:headers {"content-type" "application/json"}
|
||||||
|
:body (j/write-value-as-string {:request :json})}
|
||||||
|
{:status 200
|
||||||
|
:body {:response :json}}))))
|
||||||
|
(is (= {:response "json"}
|
||||||
|
(read-json
|
||||||
|
(call {:headers {"content-type" "application/edn"}
|
||||||
|
:body (pr-str {:request :edn})}
|
||||||
|
{:status 200
|
||||||
|
:body {:response :json}}))))
|
||||||
|
(is (= {:in [:response :body] :type :reitit.coercion/response-coercion}
|
||||||
|
(call {:headers {"content-type" "application/json"}
|
||||||
|
:body (j/write-value-as-string {:request :json})}
|
||||||
|
{:status 200
|
||||||
|
:body {:response :invalid}}))))
|
||||||
|
(testing "response content-type negotiated via accept header"
|
||||||
|
(is (= {:response "json"}
|
||||||
|
(read-json
|
||||||
|
(call {:headers {"content-type" "application/json" "accept" "application/json"}
|
||||||
|
:body (j/write-value-as-string {:request :json})}
|
||||||
|
{:status 200
|
||||||
|
:body {:response :json}}))))
|
||||||
|
(is (= {:response :edn}
|
||||||
|
(read-string
|
||||||
|
(call {:headers {"content-type" "application/json" "accept" "application/edn"}
|
||||||
|
:body (j/write-value-as-string {:request :json})}
|
||||||
|
{:status 200
|
||||||
|
:body {:response :edn}}))))
|
||||||
|
(is (= {:in [:response :body] :type :reitit.coercion/response-coercion}
|
||||||
|
(call {:headers {"content-type" "application/json" "accept" "application/edn"}
|
||||||
|
:body (j/write-value-as-string {:request :json})}
|
||||||
|
{:status 200
|
||||||
|
:body {:response :invalid}}))))
|
||||||
|
(testing "response content-type set via :muuntaja/content-type"
|
||||||
|
(is (= {:response :edn}
|
||||||
|
(read-string
|
||||||
|
(call {:headers {"content-type" "application/json" "accept" "application/json"}
|
||||||
|
:body (j/write-value-as-string {:request :json})}
|
||||||
|
{:status 200
|
||||||
|
:muuntaja/content-type "application/edn"
|
||||||
|
:body {:response :edn}}))))
|
||||||
|
(is (= {:in [:response :body] :type :reitit.coercion/response-coercion}
|
||||||
|
(call {:headers {"content-type" "application/json" "accept" "application/json"}
|
||||||
|
:body (j/write-value-as-string {:request :json})}
|
||||||
|
{:status 200
|
||||||
|
:muuntaja/content-type "application/edn"
|
||||||
|
:body {:response :invalid}}))))
|
||||||
|
(testing "response content-type set via Content-Type header. muuntaja disabled for response."
|
||||||
|
(is (= "custom data"
|
||||||
|
(call {:headers {"content-type" "application/json" "accept" "application/json"}
|
||||||
|
:body (j/write-value-as-string {:request :json})}
|
||||||
|
{:status 200
|
||||||
|
:headers {"Content-Type" "application/custom"}
|
||||||
|
:body "custom data"})))))))
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue