Merge pull request #59 from metosin/middleware-specs

Middleware specs
This commit is contained in:
Tommi Reiman 2017-12-29 12:05:23 +02:00 committed by GitHub
commit ae9fbe08e0
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
9 changed files with 354 additions and 30 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

@ -1 +1,6 @@
# Frequently Asked Questions
### Why yet another routing library?
There are many great routing libs for Clojure, but we felt that none was perfect. We picked
best parts of existing libs, added things that were missing (like first-class route data, spec-coercion and full route conflict resolution) trying to make a library it both both fun to use and really, really fast.

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

@ -6,7 +6,7 @@
(defprotocol IntoMiddleware
(into-middleware [this data opts]))
(defrecord Middleware [name wrap])
(defrecord Middleware [name wrap spec])
(defrecord Endpoint [data handler middleware])
(def ^:dynamic *max-compile-depth* 10)

View file

@ -1,15 +1,13 @@
(ns reitit.ring.spec
(:require [clojure.spec.alpha :as s]
[reitit.middleware #?@(:cljs [:refer [Middleware]])]
[reitit.spec :as rs])
#?(:clj
(:import (reitit.middleware Middleware))))
[reitit.middleware :as middleware]
[reitit.spec :as rs]))
;;
;; Specs
;;
(s/def ::middleware (s/coll-of (partial instance? Middleware)))
(s/def ::middleware (s/coll-of #(satisfies? middleware/IntoMiddleware %)))
(s/def ::data
(s/keys :req-un [::rs/handler]
@ -19,10 +17,22 @@
;; Validator
;;
(defn merge-specs [specs]
(when-let [non-specs (seq (remove #(or (s/spec? %) (s/get-spec %)) specs))]
(throw
(ex-info
(str "Not all specs satisfy the Spec protocol: " non-specs)
{:specs specs
:non-specs non-specs})))
(s/merge-spec-impl (vec specs) (vec specs) nil))
(defn- validate-ring-route-data [routes spec]
(->> (for [[p _ c] routes
[method {:keys [data] :as endpoint}] c
:when endpoint]
[method {:keys [data middleware] :as endpoint}] c
:when endpoint
:let [mw-specs (seq (keep :spec middleware))
specs (keep identity (into [spec] mw-specs))
spec (merge-specs specs)]]
(when-let [problems (and spec (s/explain-data spec data))]
(rs/->Problem p method data spec problems)))
(keep identity) (seq)))

View file

@ -2,11 +2,13 @@
(:require [clojure.test :refer [deftest testing is]]
[reitit.ring :as ring]
[reitit.ring.spec :as rrs]
[reitit.core :as r]
[reitit.spec :as rs])
[clojure.spec.alpha :as s]
[reitit.core :as r])
#?(:clj
(:import (clojure.lang ExceptionInfo))))
(s/def ::role #{:admin :user})
(s/def ::roles (s/and (s/coll-of ::role :into #{}) set?))
(deftest route-data-validation-test
(testing "validation is turned off by default"
@ -27,13 +29,6 @@
(ring/router
["/api" {:handler identity
:name "kikka"}]
{:validate rrs/validate-spec!})))
(is (thrown-with-msg?
ExceptionInfo
#"Invalid route data"
(ring/router
["/api" {:handler identity
:middleware [{}]}]
{:validate rrs/validate-spec!}))))
(testing "all endpoints are validated"
@ -48,5 +43,36 @@
(is (true? (r/router?
(ring/router
["/api" {:handler "identity"}]
{:spec any?
:validate rrs/validate-spec!}))))))
{:spec (s/spec any?)
:validate rrs/validate-spec!}))))
(testing "predicates are not allowed"
(is (thrown-with-msg?
ExceptionInfo
#"Not all specs satisfy the Spec protocol"
(ring/router
["/api" {:handler "identity"}]
{:spec any?
:validate rrs/validate-spec!})))))
(testing "middleware can contribute to specs"
(is (true? (r/router?
(ring/router
["/api" {:get {:handler identity
:roles #{:admin}}}]
{:validate rrs/validate-spec!
:data {:middleware [{:spec (s/keys :opt-un [::roles])
:wrap (fn [handler]
(fn [request]
(handler request)))}]}}))))
(is (thrown-with-msg?
ExceptionInfo
#"Invalid route data"
(ring/router
["/api" {:get {:handler identity
:roles #{:adminz}}}]
{:validate rrs/validate-spec!
:data {:middleware [{:spec (s/keys :opt-un [::roles])
:wrap (fn [handler]
(fn [request]
(handler request)))}]}})))))