Docs for Ring spec validation

This commit is contained in:
Tommi Reiman 2017-12-29 11:41:12 +02:00
parent 9273f99806
commit b7b0b7c81d
7 changed files with 296 additions and 14 deletions

View file

@ -23,7 +23,8 @@
* [Ring-router](ring/ring.md)
* [Dynamic Extensions](ring/dynamic_extensions.md)
* [Data-driven Middleware](ring/data_driven_middleware.md)
* [Ring Coercion](ring/coercion.md)
* [Pluggable Coercion](ring/coercion.md)
* [Route Data Validation](ring/route_data_validation.md)
* [Compiling Middleware](ring/compiling_middleware.md)
* [Performance](performance.md)
* [FAQ](faq.md)

View file

@ -3,5 +3,6 @@
* [Ring-router](ring.md)
* [Dynamic Extensions](dynamic_extensions.md)
* [Data-driven Middleware](data_driven_middleware.md)
* [Ring Coercion](coercion.md)
* [Pluggable Coercion](coercion.md)
* [Route Data Validation](route_data_validation.md)
* [Compiling Middleware](compiling_middleware.md)

View file

@ -1,6 +1,6 @@
# Ring Coercion
# Pluggable Coercion
Coercion is explained in detail [in the Coercion Guide](../coercion/coercion.md). Both request parameters (`:query`, `:body`, `:form`, `:header` and `:path`) and response `:body` can be coerced.
Basic coercion is explained in detail [in the Coercion Guide](../coercion/coercion.md). With Ring, both request parameters (`:query`, `:body`, `:form`, `:header` and `:path`) and response `:body` can be coerced.
To enable coercion, the following things need to be done:

View file

@ -17,7 +17,8 @@ Records can have arbitrary keys, but the following keys have a special purpose:
| key | description |
| ---------------|-------------|
| `:name` | Name of the middleware as a qualified keyword (optional)
| `:name` | Name of the middleware as a qualified keyword
| `:spec` | `clojure.spec` definition for the route data, see [route data validation](route_data_validation.md) (optional)
| `:wrap` | The actual middleware function of `handler & args => request => response`
| `:compile` | Middleware compilation function, see [compiling middleware](compiling_middleware.md).
@ -135,11 +136,5 @@ Some things bubblin' under:
* Support Middleware dependency resolution with new keys `:requires` and `:provides`. Values are set of top-level keys of the request. e.g.
* `InjectUserIntoRequestMiddleware` requires `#{:session}` and provides `#{:user}`
* `AuthorizationMiddleware` requires `#{:user}`
* Support partial `s/keys` route data specs with Middleware (and Router). Merged together to define sound spec for the route data and/or route data for a given route.
* e.g. `AuthrorizationMiddleware` has a spec defining `:roles` key (a set of keywords)
* Documentation for the route data
* Route data is validated against the spec:
* Complain of keywords that are not handled by anything
* Propose fixes for typos (Figwheel-style)
Ideas welcome & see [issues](https://github.com/metosin/reitit/issues) for details.

View file

@ -0,0 +1,286 @@
# Route Data Validation
Ring route validation works [just like with core router](../basics/route_data_validation.md), with few differences:
* `reitit.ring.spec/validate-spec!` should be used instead of `reitit.spec/validate-spec!` - to support validating all endpoints (`:get`, `:post` etc.)
* With `clojure.spec` validation, Middleware can contribute to route spec via `:specs` key. The effective route data spec is router spec merged with middleware specs.
## Example
Let's build a ring app with with both explicit (via middleware) and implicit (fully-qualified keys) spec validation.
A simple app with spec-validation turned on:
```clj
(require '[clojure.spec.alpha :as s])
(require '[reitit.ring :as ring])
(require '[reitit.ring.spec :as rrs])
(require '[reitit.spec :as rs])
(require '[expound.alpha :as e])
(defn handler [_]
{:status 200, :body "ok"})
(def app
(ring/ring-handler
(ring/router
["/api"
["/public"
["/ping" {:get handler}]]
["/internal"
["/users" {:get {:handler handler}
:delete {:handler handler}}]]]
{:validate rrs/validate-spec!
::rs/explain e/expound-str})))
```
All good:
```clj
(app {:request-method :get
:uri "/api/internal/users"})
; {:status 200, :body "ok"}
```
### Explicit specs via middleware
Middleware that requires `:zone` to be present in route data:
```clj
(s/def ::zone #{:public :internal})
(def zone-middleware
{:name ::zone-middleware
:spec (s/keys :req-un [::zone])
:wrap (fn [handler]
(fn [request]
(let [zone (-> request (ring/get-match) :data :zone)]
(println zone)
(handler request))))})
```
Missing route data fails fast at router creation:
```clj
(def app
(ring/ring-handler
(ring/router
["/api" {:middleware [zone-middleware]} ;; <--- added
["/public"
["/ping" {:get handler}]]
["/internal"
["/users" {:get {:handler handler}
:delete {:handler handler}}]]]
{:validate rrs/validate-spec!
::rs/explain e/expound-str})))
; CompilerException clojure.lang.ExceptionInfo: Invalid route data:
;
; -- On route -----------------------
;
; "/api/public/ping" :get
;
; -- Spec failed --------------------
;
; {:middleware ...,
; :handler ...}
;
; should contain key: `:zone`
;
; | key | spec |
; |-------+-------|
; | :zone | :zone |
;
;
; -- On route -----------------------
;
; "/api/internal/users" :get
;
; -- Spec failed --------------------
;
; {:middleware ...,
; :handler ...}
;
; should contain key: `:zone`
;
; | key | spec |
; |-------+-------|
; | :zone | :zone |
;
;
; -- On route -----------------------
;
; "/api/internal/users" :delete
;
; -- Spec failed --------------------
;
; {:middleware ...,
; :handler ...}
;
; should contain key: `:zone`
;
; | key | spec |
; |-------+-------|
; | :zone | :zone |
```
Adding the `:zone` to route data fixes the problem:
```clj
(def app
(ring/ring-handler
(ring/router
["/api" {:middleware [zone-middleware]}
["/public" {:zone :public} ;; <--- added
["/ping" {:get handler}]]
["/internal" {:zone :internal} ;; <--- added
["/users" {:get {:handler handler}
:delete {:handler handler}}]]]
{:validate rrs/validate-spec!
::rs/explain e/expound-str})))
(app {:request-method :get
:uri "/api/internal/users"})
; in zone :internal
; => {:status 200, :body "ok"}
```
### Implicit specs
By design, clojure.spec validates all fully-qualified keys with `s/keys` specs even if they are not defined in that keyset. Validation in implicit but powerful.
Let's reuse the `wrap-enforce-roles` from [Dynamic extensions](dynamic_extensions.md) and define specs for the data:
```clj
(require '[clojure.set :as set])
(s/def ::role #{:admin :manager})
(s/def ::roles (s/coll-of ::role :into #{}))
(defn wrap-enforce-roles [handler]
(fn [{:keys [::roles] :as request}]
(let [required (some-> request (ring/get-match) :data ::roles)]
(if (and (seq required) (not (set/subset? required roles)))
{:status 403, :body "forbidden"}
(handler request)))))
```
`wrap-enforce-roles` silently ignores if the `::roles` is not present:
```clj
(def app
(ring/ring-handler
(ring/router
["/api" {:middleware [zone-middleware
wrap-enforce-roles]} ;; <--- added
["/public" {:zone :public}
["/ping" {:get handler}]]
["/internal" {:zone :internal}
["/users" {:get {:handler handler}
:delete {:handler handler}}]]]
{:validate rrs/validate-spec!
::rs/explain e/expound-str})))
(app {:request-method :get
:uri "/api/zones/admin/ping"})
; in zone :internal
; => {:status 200, :body "ok"}
```
But fails if they are present and invalid:
```clj
(def app
(ring/ring-handler
(ring/router
["/api" {:middleware [zone-middleware
wrap-enforce-roles]}
["/public" {:zone :public}
["/ping" {:get handler}]]
["/internal" {:zone :internal}
["/users" {:get {:handler handler
::roles #{:manager} ;; <--- added
:delete {:handler handler
::roles #{:adminz}}}]]] ;; <--- added
{:validate rrs/validate-spec!
::rs/explain e/expound-str})))
; CompilerException clojure.lang.ExceptionInfo: Invalid route data:
;
; -- On route -----------------------
;
; "/api/internal/users" :delete
;
; -- Spec failed --------------------
;
; {:middleware ...,
; :zone ...,
; :handler ...,
; :user/roles #{:adminz}}
; ^^^^^^^
;
; should be one of: `:admin`,`:manager`
```
### Pushing the data to the endpoints
Ability to define (and reuse) route-data in sub-paths is a powerful feature, but having data scattered all around might be harder to reason about. There is always an option to push all data to the endpoints.
```clj
(def app
(ring/ring-handler
(ring/router
["/api"
["/public"
["/ping" {:zone :public
:get handler
:middleware [zone-middleware
wrap-enforce-roles]}]]
["/internal"
["/users" {:zone :internal
:middleware [zone-middleware
wrap-enforce-roles]
:get {:handler handler
::roles #{:manager}}
:delete {:handler handler
::roles #{:admin}}}]]]
{:validate rrs/validate-spec!
::rs/explain e/expound-str})))
```
Or even flatten the routes:
```clj
(def app
(ring/ring-handler
(ring/router
[["/api/public/ping" {:zone :public
:get handler
:middleware [zone-middleware
wrap-enforce-roles]}]
["/api/internal/users" {:zone :internal
:middleware [zone-middleware
wrap-enforce-roles]
:get {:handler handler
::roles #{:manager}}
:delete {:handler handler
::roles #{:admin}}}]]
{:validate rrs/validate-spec!
::rs/explain e/expound-str})))
```
The common Middleware can also be pushed to the router, here cleanly separing behavior and data:
```clj
(def app
(ring/ring-handler
(ring/router
[["/api/public/ping" {:zone :public
:get handler}]
["/api/internal/users" {:zone :internal
:get {:handler handler
::roles #{:manager}}
:delete {:handler handler
::roles #{:admin}}}]]
{:middleware [zone-middleware wrap-enforce-roles]
:validate rrs/validate-spec!
::rs/explain e/expound-str})))
```

View file

@ -7,7 +7,7 @@
;; Specs
;;
(s/def ::middleware (s/coll-of (partial satisfies? middleware/IntoMiddleware)))
(s/def ::middleware (s/coll-of #(satisfies? middleware/IntoMiddleware %)))
(s/def ::data
(s/keys :req-un [::rs/handler]

View file

@ -3,8 +3,7 @@
[reitit.ring :as ring]
[reitit.ring.spec :as rrs]
[clojure.spec.alpha :as s]
[reitit.core :as r]
[expound.alpha :as e])
[reitit.core :as r])
#?(:clj
(:import (clojure.lang ExceptionInfo))))