reitit/doc/ring/coercion.md

349 lines
13 KiB
Markdown
Raw Normal View History

2019-02-20 06:04:03 +00:00
# Ring Coercion
2017-11-27 06:02:35 +00:00
2019-02-20 06:04:03 +00:00
Basic coercion is explained in detail [in the Coercion Guide](../coercion/coercion.md). 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](default_middleware.md) |
2017-11-27 06:02:35 +00:00
2017-12-15 06:20:53 +00:00
To enable coercion, the following things need to be done:
2017-11-27 06:02:35 +00:00
2017-12-15 06:20:53 +00:00
* Define a `reitit.coercion/Coercion` for 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
2017-11-27 06:02:35 +00:00
2017-12-15 06:20:53 +00:00
## Define coercion
2017-11-27 06:02:35 +00:00
2017-12-15 06:20:53 +00:00
`reitit.coercion/Coercion` is a protocol defining how types are defined, coerced and inventoried.
2017-11-27 06:02:35 +00:00
2017-12-15 06:20:53 +00:00
Reitit ships with the following coercion modules:
2017-11-27 06:02:35 +00:00
2019-12-29 09:17:20 +00:00
* `reitit.coercion.malli/coercion` for [malli](https://github.com/metosin/malli)
2017-12-15 06:20:53 +00:00
* `reitit.coercion.schema/coercion` for [plumatic schema](https://github.com/plumatic/schema)
* `reitit.coercion.spec/coercion` for both [clojure.spec](https://clojure.org/about/spec) and [data-specs](https://github.com/metosin/spec-tools#data-specs)
2017-11-27 06:02:35 +00:00
Coercion can be attached to route data under `:coercion` key. There can be multiple `Coercion` implementations within a single router, normal [scoping rules](../basics/route_data.md#nested-route-data) apply.
2017-11-27 06:02:35 +00:00
2018-02-11 17:15:25 +00:00
## Defining parameters and responses
2017-12-15 06:20:53 +00:00
2019-02-20 06:04:03 +00:00
Parameters are defined in route data under `:parameters` key. It's value should be a map of parameter `:type` -> Coercion Schema.
2025-04-11 08:45:12 +00:00
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.
2017-12-15 06:20:53 +00:00
2019-02-20 06:04:03 +00:00
Below is an example with [Plumatic Schema](https://github.com/plumatic/schema). It defines schemas for `:query`, `:body` and `:path` parameters and for http 200 response `:body`.
2018-02-11 17:15:25 +00:00
2021-07-27 16:33:17 +00:00
Handlers can access the coerced parameters via the `:parameters` key in the request.
2017-11-27 06:02:35 +00:00
```clj
2017-12-15 06:20:53 +00:00
(require '[reitit.coercion.schema])
2017-11-27 06:02:35 +00:00
(require '[schema.core :as s])
2018-02-11 17:15:25 +00:00
(def PositiveInt (s/constrained s/Int pos? 'PositiveInt))
2017-12-15 06:20:53 +00:00
(def plus-endpoint
{:coercion reitit.coercion.schema/coercion
:parameters {:query {:x s/Int}
:body {:y s/Int}
:path {:z s/Int}}
2025-04-11 08:45:12 +00:00
:responses {200 {:body {:total PositiveInt}}
:default {:body {:error s/Str}}}
2017-12-15 06:20:53 +00:00
:handler (fn [{:keys [parameters]}]
(let [total (+ (-> parameters :query :x)
(-> parameters :body :y)
(-> parameters :path :z))]
{:status 200
:body {:total total}}))})
2017-11-27 06:02:35 +00:00
```
### Nested parameter definitions
Parameters are accumulated recursively along the route tree, just like
other [route data](../basics/route_data.md). There is special case
handling for merging eg. malli `:map` schemas.
```clj
(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}}))
```
```clj
(-> (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`:
1. Allow keys outside the schema (by opening up the schema using eg. `malli.util/open-schema`)
2. Keywordize the keys (ie. header & query parameter names) of the input before coercing
In contrast, the `:body` coercion:
1. Uses the specified schema
* depending on the coercion, it can be configured as open or closed, see specific coercion docs for details
2. Does not keywordize the keys of the input before coercion
2026-01-23 12:25:19 +00:00
* 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`.
2017-12-15 06:20:53 +00:00
## Coercion Middleware
2017-11-27 06:02:35 +00:00
2017-12-31 09:29:51 +00:00
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`:
2017-11-27 06:02:35 +00:00
2018-02-11 17:15:25 +00:00
* `coerce-request-middleware` to apply the parameter coercion
* `coerce-response-middleware` to apply the response coercion
* `coerce-exceptions-middleware` to transform coercion exceptions into pretty responses
2017-11-27 06:02:35 +00:00
2017-12-15 17:37:04 +00:00
### Full example
2021-07-27 16:33:17 +00:00
Here is a full example for applying coercion with Reitit, Ring and Schema:
2017-11-27 06:02:35 +00:00
```clj
2017-12-31 09:29:51 +00:00
(require '[reitit.ring.coercion :as rrc])
2017-12-15 17:37:04 +00:00
(require '[reitit.coercion.schema])
(require '[reitit.ring :as ring])
(require '[schema.core :as s])
(def PositiveInt (s/constrained s/Int pos? 'PositiveInt))
2017-11-27 06:02:35 +00:00
(def app
(ring/ring-handler
(ring/router
["/api"
2017-12-15 06:20:53 +00:00
["/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}}
2018-02-11 19:40:48 +00:00
:responses {200 {:body {:total PositiveInt}}}
2017-12-15 06:20:53 +00:00
:handler (fn [{:keys [parameters]}]
(let [total (+ (-> parameters :query :x)
(-> parameters :body :y)
(-> parameters :path :z))]
{:status 200
:body {:total total}}))}}]]
2017-12-31 09:29:51 +00:00
{:data {:middleware [rrc/coerce-exceptions-middleware
rrc/coerce-request-middleware
rrc/coerce-response-middleware]}})))
2017-11-27 06:02:35 +00:00
```
Valid request:
```clj
2017-12-15 06:20:53 +00:00
(app {:request-method :post
:uri "/api/plus/3"
:query-params {"x" "1"}
:body-params {:y 2}})
; {:status 200, :body {:total 6}}
2017-11-27 06:02:35 +00:00
```
Invalid request:
```clj
2017-12-15 06:20:53 +00:00
(app {:request-method :post
:uri "/api/plus/3"
:query-params {"x" "abba"}
:body-params {:y 2}})
2017-11-27 06:02:35 +00:00
; {:status 400,
2017-12-15 06:20:53 +00:00
; :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]}}
2017-11-27 06:02:35 +00:00
```
2017-12-15 06:20:53 +00:00
Invalid response:
2017-11-27 06:02:35 +00:00
```clj
2017-12-15 06:20:53 +00:00
(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]}}
2017-11-27 06:02:35 +00:00
```
## Per-content-type coercion
2023-08-28 12:41:16 +00:00
You can also specify request and response body schemas per
content-type. These are also read by the [OpenAPI
feature](./openapi.md) when generating api docs. The syntax for this
is:
```clj
(def app
(ring/ring-handler
2023-05-28 13:49:08 +00:00
(ring/router
["/api"
["/example" {:post {:coercion reitit.coercion.schema/coercion
2023-08-18 12:17:01 +00:00
:request {:content {"application/json" {:schema {:y s/Int}}
2023-08-28 12:41:16 +00:00
"application/edn" {:schema {:z s/Int}}
;; default if no content-type matches:
:default {:schema {:yy s/Int}}}}
2023-08-18 12:17:01 +00:00
:responses {200 {:content {"application/json" {:schema {:w s/Int}}
2023-08-28 12:41:16 +00:00
"application/edn" {:schema {:x s/Int}}
:default {:schema {:ww s/Int}}}}}
2023-08-18 12:04:47 +00:00
: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:
1. Get the response status, or `:default` from the `:responses` map
2. From this map, get use the first of these to coerce:
1. `:content <content-type> :schema`
2. `:content :default :schema`
3. `:body`
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
2021-07-27 16:33:17 +00:00
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:
```clj
(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?
```
2017-12-15 06:20:53 +00:00
### Optimizations
2017-11-27 06:02:35 +00:00
2021-07-27 16:33:17 +00:00
The coercion middlewares are [compiled against a route](compiling_middleware.md). 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.
2017-11-27 06:02:35 +00:00
2017-12-15 06:20:53 +00:00
We can query the compiled middleware chain for the routes:
2017-11-27 06:02:35 +00:00
```clj
2017-12-15 06:20:53 +00:00
(require '[reitit.core :as r])
(-> (ring/get-router app)
(r/match-by-name ::plus)
:result :post :middleware
(->> (mapv :name)))
; [::mw/coerce-exceptions
2017-12-16 08:51:32 +00:00
; ::mw/coerce-request
2017-12-15 06:20:53 +00:00
; ::mw/coerce-response]
2017-11-27 06:02:35 +00:00
```
2017-12-15 06:20:53 +00:00
Route without coercion defined:
2017-11-27 06:02:35 +00:00
2017-12-15 06:20:53 +00:00
```clj
(app {:request-method :get, :uri "/api/ping"})
; {:status 200, :body "pong"}
```
2017-11-27 06:02:35 +00:00
2017-12-15 06:20:53 +00:00
Has no mounted middleware:
2017-11-27 06:02:35 +00:00
```clj
2017-12-15 06:20:53 +00:00
(-> (ring/get-router app)
(r/match-by-name ::ping)
:result :get :middleware
(->> (mapv :name)))
; []
2017-11-27 06:02:35 +00:00
```