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:
Joel Kaasinen 2025-10-29 09:20:54 +02:00
parent f4da07c222
commit 7fb9c27e46
No known key found for this signature in database
3 changed files with 239 additions and 93 deletions

View file

@ -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:

View file

@ -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

View file

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