reitit/doc/ring/coercion.md

331 lines
12 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]]}
```
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
```