feat: per-content-type request/response coercions

implemented on the reitit-core level so individual coercions don't
need changes

syntax:

{:parameters {:request {:content {"application/edn" [:map ...]}}}
 :responses {200 {:content {"application/edn" [:map ...]}}}}
This commit is contained in:
Joel Kaasinen 2023-03-02 14:16:56 +02:00
parent 8f48cdc96c
commit c8d679c6b3
2 changed files with 106 additions and 14 deletions

View file

@ -37,6 +37,7 @@
(def ^:no-doc default-parameter-coercion
{:query (->ParameterCoercion :query-params :string true true)
:body (->ParameterCoercion :body-params :body false false)
:request (->ParameterCoercion :body-params :request false false)
:form (->ParameterCoercion :form-params :string true true)
:header (->ParameterCoercion :headers :string true true)
:path (->ParameterCoercion :path-params :string true true)})
@ -84,11 +85,22 @@
(if coercion
(if-let [{:keys [keywordize? open? in style]} (parameter-coercion type)]
(let [transform (comp (if keywordize? walk/keywordize-keys identity) in)
model (if open? (-open-model coercion model) model)]
(if-let [coercer (-request-coercer coercion style model)]
->open (if open? #(-open-model coercion %) identity)
format-coercer-pairs (if (= :request style)
(for [[format schema] (:content model)]
[format (-request-coercer coercion :body (->open schema))])
[[:default (-request-coercer coercion style (->open model))]])
format->coercer (some->> format-coercer-pairs
(filter second)
(seq)
(into {}))]
(when format->coercer
(fn [request]
(let [value (transform request)
format (extract-request-format request)
coercer (or (format->coercer format)
(format->coercer :default)
(fn [value _format] value))
result (coercer value format)]
(if (error? result)
(request-coercion-failed! result coercion value in request serialize-failed-result)
@ -97,17 +109,24 @@
(defn extract-response-format-default [request _]
(-> request :muuntaja/response :format))
(defn response-coercer [coercion body {:keys [extract-response-format serialize-failed-result]
:or {extract-response-format extract-response-format-default}}]
(defn response-coercer [coercion {:keys [content body]} {:keys [extract-response-format serialize-failed-result]
:or {extract-response-format extract-response-format-default}}]
(if coercion
(if-let [coercer (-response-coercer coercion body)]
(fn [request response]
(let [format (extract-response-format request response)
value (:body response)
result (coercer value format)]
(if (error? result)
(response-coercion-failed! result coercion value request response serialize-failed-result)
result))))))
(let [per-format-coercers (some->> (for [[format schema] content]
[format (-response-coercer coercion schema)])
(filter second)
(seq)
(into {}))
default (when body (-response-coercer coercion body))]
(when (or per-format-coercers default)
(fn [request response]
(let [format (extract-response-format request response)
value (:body response)
coercer (get per-format-coercers format (or default (fn [value _format] value)))
result (coercer value format)]
(if (error? result)
(response-coercion-failed! result coercion value request response serialize-failed-result)
result)))))))
(defn encode-error [data]
(-> data
@ -136,8 +155,8 @@
(into {})))
(defn response-coercers [coercion responses opts]
(some->> (for [[status {:keys [body]}] responses :when body]
[status (response-coercer coercion body opts)])
(some->> (for [[status model] responses]
[status (response-coercer coercion model opts)])
(filter second)
(seq)
(into {})))

View file

@ -13,6 +13,7 @@
[reitit.ring :as ring]
[reitit.ring.coercion :as rrc]
[schema.core :as s]
[clojure.spec.alpha]
[spec-tools.data-spec :as ds])
#?(:clj
(:import (clojure.lang ExceptionInfo)
@ -582,6 +583,78 @@
(is (= {:status 200, :body {:total "FOO: this, BAR: that"}} (call m/schema custom-meta-merge-checking-schema)))
(is (= {:status 200, :body {:total "FOO: this, BAR: that"}} (call identity custom-meta-merge-checking-parameters)))))))
(deftest per-content-type-test
(doseq [[coercion json-request edn-request default-request json-response edn-response default-response]
[[#'malli/coercion
[:map [:request [:enum :json]] [:response any?]]
[:map [:request [:enum :edn]] [:response any?]]
[:map [:request [:enum :default]] [:response any?]]
[:map [:request any?] [:response [:enum :json]]]
[:map [:request any?] [:response [:enum :edn]]]
[:map [:request any?] [:response [:enum :default]]]]
[#'schema/coercion
{:request (s/eq :json) :response s/Any}
{:request (s/eq :edn) :response s/Any}
{:request (s/eq :default) :response s/Any}
{:request s/Any :response (s/eq :json)}
{:request s/Any :response (s/eq :edn)}
{:request s/Any :response (s/eq :default)}]
[#'spec/coercion
{:request (clojure.spec.alpha/spec #{:json}) :response any?}
{:request (clojure.spec.alpha/spec #{:edn}) :response any?}
{:request (clojure.spec.alpha/spec #{:default}) :response any?}
{:request any? :response (clojure.spec.alpha/spec #{:json})}
{:request any? :response (clojure.spec.alpha/spec #{:end})}
{:request any? :response (clojure.spec.alpha/spec #{:default})}]]]
(testing coercion
(let [app (ring/ring-handler
(ring/router
[["/foo" {:post {:parameters {:request {:content {"application/json" json-request
"application/edn" edn-request
:default default-request}}}
:responses {200 {:content {"application/json" json-response
"application/edn" edn-response}
:body default-response}}
:handler (fn [req]
{:status 200
:body (-> req :parameters :request)})}}]]
{:data {:middleware [rrc/coerce-request-middleware
rrc/coerce-response-middleware]
:coercion @coercion}}))
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}}
(call (request "application/json" "application/json" {:request :json :response :json}))))
(is (= {:status 200 :body {:request :edn, :response :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})))))))))
#?(:clj
(deftest muuntaja-test
(let [app (ring/ring-handler