13 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]]}
Differences in behaviour for different parameters
All parameter coercions except :body:
- Allow keys outside the schema (by opening up the schema using eg.
malli.util/open-schema) - Keywordize the keys (ie. header & query parameter names) of the input before coercing
In contrast, the :body coercion:
- Uses the specified schema
- depending on the coercion, it can be configured as open or closed, see specific coercion docs for details
- Does not keywordize the keys of the input before coercion
- however, coercions like malli might do the keywordization when coercing json bodies, depending on configuration
This admittedly confusing behaviour is retained currently due to
backwards compatibility reasons. It can be configured by passing
option :reitit.coercion/parameter-coercion to reitit.ring/router
or reitit.coercion/compile-request-coercers. See also:
reitit.coercion/default-parameter-coercion.
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)))
; []