diff --git a/doc/SUMMARY.md b/doc/SUMMARY.md index 92495a97..adf4fb29 100644 --- a/doc/SUMMARY.md +++ b/doc/SUMMARY.md @@ -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) diff --git a/doc/ring/README.md b/doc/ring/README.md index 87be88b9..cf4968f0 100644 --- a/doc/ring/README.md +++ b/doc/ring/README.md @@ -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) diff --git a/doc/ring/coercion.md b/doc/ring/coercion.md index 8b4e7d0d..436908b5 100644 --- a/doc/ring/coercion.md +++ b/doc/ring/coercion.md @@ -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: diff --git a/doc/ring/data_driven_middleware.md b/doc/ring/data_driven_middleware.md index d7c028da..0618bee0 100644 --- a/doc/ring/data_driven_middleware.md +++ b/doc/ring/data_driven_middleware.md @@ -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. diff --git a/doc/ring/route_data_validation.md b/doc/ring/route_data_validation.md new file mode 100644 index 00000000..07f8302c --- /dev/null +++ b/doc/ring/route_data_validation.md @@ -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}))) +``` diff --git a/modules/reitit-ring/src/reitit/ring/spec.cljc b/modules/reitit-ring/src/reitit/ring/spec.cljc index 3f8c6020..9039482a 100644 --- a/modules/reitit-ring/src/reitit/ring/spec.cljc +++ b/modules/reitit-ring/src/reitit/ring/spec.cljc @@ -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] diff --git a/test/cljc/reitit/ring_spec_test.cljc b/test/cljc/reitit/ring_spec_test.cljc index 726d8e0e..f730d394 100644 --- a/test/cljc/reitit/ring_spec_test.cljc +++ b/test/cljc/reitit/ring_spec_test.cljc @@ -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))))