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.
12 KiB
Ring Coercion
Basic coercion is explained in detail in the Coercion Guide. With Ring, both request parameters and response bodies can be coerced.
The following request parameters are currently supported:
| type | request source |
|---|---|
:query |
:query-params |
:body |
:body-params |
:request |
:body-params, allows per-content-type coercion |
:form |
:form-params |
:header |
:header-params |
:path |
:path-params |
:multipart |
:multipart-params, see Default Middleware |
To enable coercion, the following things need to be done:
- Define a
reitit.coercion/Coercionfor the routes - Define types for the parameters and/or responses
- Mount Coercion Middleware to apply to coercion
- Use the coerced parameters in a handler/middleware
Define coercion
reitit.coercion/Coercion is a protocol defining how types are defined, coerced and inventoried.
Reitit ships with the following coercion modules:
reitit.coercion.malli/coercionfor mallireitit.coercion.schema/coercionfor plumatic schemareitit.coercion.spec/coercionfor both clojure.spec and data-specs
Coercion can be attached to route data under :coercion key. There can be multiple Coercion implementations within a single router, normal scoping rules apply.
Defining parameters and responses
Parameters are defined in route data under :parameters key. It's value should be a map of parameter :type -> Coercion Schema.
Responses are defined in route data under :responses key. It's value should be a map of http status code to a map which can contain :body key with Coercion Schema as value. Additionally, the key :default specifies the coercion for other status codes.
Below is an example with Plumatic Schema. It defines schemas for :query, :body and :path parameters and for http 200 response :body.
Handlers can access the coerced parameters via the :parameters key in the request.
(require '[reitit.coercion.schema])
(require '[schema.core :as s])
(def PositiveInt (s/constrained s/Int pos? 'PositiveInt))
(def plus-endpoint
{:coercion reitit.coercion.schema/coercion
:parameters {:query {:x s/Int}
:body {:y s/Int}
:path {:z s/Int}}
:responses {200 {:body {:total PositiveInt}}
:default {:body {:error s/Str}}}
:handler (fn [{:keys [parameters]}]
(let [total (+ (-> parameters :query :x)
(-> parameters :body :y)
(-> parameters :path :z))]
{:status 200
:body {:total total}}))})
Nested parameter definitions
Parameters are accumulated recursively along the route tree, just like
other route data. There is special case
handling for merging eg. malli :map schemas.
(def router
(reitit.ring/router
["/api" {:get {:parameters {:query [:map [:api-key :string]]}}}
["/project/:project-id" {:get {:parameters {:path [:map [:project-id :int]]}}}
["/task/:task-id" {:get {:parameters {:path [:map [:task-id :int]]
:query [:map [:details :boolean]]}
:handler (fn [req] (prn req))}}]]]
{:data {:coercion reitit.coercion.malli/coercion}}))
(-> (r/match-by-path router "/api/project/1/task/2") :result :get :data :parameters)
; {:query [:map
; {:closed true}
; [:api-key :string]
; [:details :boolean]],
; :path [:map
; {:closed true}
; [:project-id :int]
; [:task-id :int]]}
Coercion Middleware
Defining a coercion for a route data doesn't do anything, as it's just data. We have to attach some code to apply the actual coercion. We can use the middleware from reitit.ring.coercion:
coerce-request-middlewareto apply the parameter coercioncoerce-response-middlewareto apply the response coercioncoerce-exceptions-middlewareto transform coercion exceptions into pretty responses
Full example
Here is a full example for applying coercion with Reitit, Ring and Schema:
(require '[reitit.ring.coercion :as rrc])
(require '[reitit.coercion.schema])
(require '[reitit.ring :as ring])
(require '[schema.core :as s])
(def PositiveInt (s/constrained s/Int pos? 'PositiveInt))
(def app
(ring/ring-handler
(ring/router
["/api"
["/ping" {:name ::ping
:get (fn [_]
{:status 200
:body "pong"})}]
["/plus/:z" {:name ::plus
:post {:coercion reitit.coercion.schema/coercion
:parameters {:query {:x s/Int}
:body {:y s/Int}
:path {:z s/Int}}
:responses {200 {:body {:total PositiveInt}}}
:handler (fn [{:keys [parameters]}]
(let [total (+ (-> parameters :query :x)
(-> parameters :body :y)
(-> parameters :path :z))]
{:status 200
:body {:total total}}))}}]]
{:data {:middleware [rrc/coerce-exceptions-middleware
rrc/coerce-request-middleware
rrc/coerce-response-middleware]}})))
Valid request:
(app {:request-method :post
:uri "/api/plus/3"
:query-params {"x" "1"}
:body-params {:y 2}})
; {:status 200, :body {:total 6}}
Invalid request:
(app {:request-method :post
:uri "/api/plus/3"
:query-params {"x" "abba"}
:body-params {:y 2}})
; {:status 400,
; :body {:schema {:x "Int", "Any" "Any"},
; :errors {:x "(not (integer? \"abba\"))"},
; :type :reitit.coercion/request-coercion,
; :coercion :schema,
; :value {:x "abba"},
; :in [:request :query-params]}}
Invalid response:
(app {:request-method :post
:uri "/api/plus/3"
:query-params {"x" "1"}
:body-params {:y -10}})
; {:status 500,
; :body {:schema {:total "(constrained Int PositiveInt)"},
; :errors {:total "(not (PositiveInt -6))"},
; :type :reitit.coercion/response-coercion,
; :coercion :schema,
; :value {:total -6},
; :in [:response :body]}}
Per-content-type coercion
You can also specify request and response body schemas per content-type. These are also read by the OpenAPI feature when generating api docs. The syntax for this is:
(def app
(ring/ring-handler
(ring/router
["/api"
["/example" {:post {:coercion reitit.coercion.schema/coercion
:request {:content {"application/json" {:schema {:y s/Int}}
"application/edn" {:schema {:z s/Int}}
;; default if no content-type matches:
:default {:schema {:yy s/Int}}}}
:responses {200 {:content {"application/json" {:schema {:w s/Int}}
"application/edn" {:schema {:x s/Int}}
:default {:schema {:ww s/Int}}}}}
:handler ...}}]]
{:data {:muuntaja muuntaja.core/instance
:middleware [reitit.ring.middleware.muuntaja/format-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:
- Get the response status, or
:defaultfrom the:responsesmap - From this map, get use the first of these to coerce:
:content <content-type> :schema:content :default :schema:body
- If nothing was found, do not coerce
To select the response content-type, you can either:
- Let muuntaja pick the content-type based on things like the request Accept header
- This is what most users want
- Set
:muuntaja/content-typein the response to pick an explicit content type - 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.
- Use the
:extract-response-formatoption to inject your own logic. Seereitit.coercion/extract-response-format-defaultfor the default.
See also the muuntaja content negotiation docs.
Pretty printing spec errors
Spec problems are exposed as is in request & response coercion errors. Pretty-printers like expound can be enabled like this:
(require '[reitit.ring :as ring])
(require '[reitit.ring.middleware.exception :as exception])
(require '[reitit.ring.coercion :as coercion])
(require '[expound.alpha :as expound])
(defn coercion-error-handler [status]
(let [printer (expound/custom-printer {:theme :figwheel-theme, :print-specs? false})
handler (exception/create-coercion-handler status)]
(fn [exception request]
(printer (-> exception ex-data :problems))
(handler exception request))))
(def app
(ring/ring-handler
(ring/router
["/plus"
{:get
{:parameters {:query {:x int?, :y int?}}
:responses {200 {:body {:total pos-int?}}}
:handler (fn [{{{:keys [x y]} :query} :parameters}]
{:status 200, :body {:total (+ x y)}})}}]
{:data {:coercion reitit.coercion.spec/coercion
:middleware [(exception/create-exception-middleware
(merge
exception/default-handlers
{:reitit.coercion/request-coercion (coercion-error-handler 400)
:reitit.coercion/response-coercion (coercion-error-handler 500)}))
coercion/coerce-request-middleware
coercion/coerce-response-middleware]}})))
(app
{:uri "/plus"
:request-method :get
:query-params {"x" "1", "y" "fail"}})
; => ...
; -- Spec failed --------------------
;
; {:x ..., :y "fail"}
; ^^^^^^
;
; should satisfy
;
; int?
(app
{:uri "/plus"
:request-method :get
:query-params {"x" "1", "y" "-2"}})
; => ...
;-- Spec failed --------------------
;
; {:total -1}
; ^^
;
; should satisfy
;
; pos-int?
Optimizations
The coercion middlewares are compiled against a route. In the middleware compilation step the actual coercer implementations are constructed for the defined models. Also, the middleware doesn't mount itself if a route doesn't have :coercion and :parameters or :responses defined.
We can query the compiled middleware chain for the routes:
(require '[reitit.core :as r])
(-> (ring/get-router app)
(r/match-by-name ::plus)
:result :post :middleware
(->> (mapv :name)))
; [::mw/coerce-exceptions
; ::mw/coerce-request
; ::mw/coerce-response]
Route without coercion defined:
(app {:request-method :get, :uri "/api/ping"})
; {:status 200, :body "pong"}
Has no mounted middleware:
(-> (ring/get-router app)
(r/match-by-name ::ping)
:result :get :middleware
(->> (mapv :name)))
; []