diff --git a/doc/SUMMARY.md b/doc/SUMMARY.md index 3be1ac81..92495a97 100644 --- a/doc/SUMMARY.md +++ b/doc/SUMMARY.md @@ -7,6 +7,7 @@ * [Path-based Routing](basics/path_based_routing.md) * [Name-based Routing](basics/name_based_routing.md) * [Route Data](basics/route_data.md) + * [Route Data Validation](basics/route_data_validation.md) * [Route Conflicts](basics/route_conflicts.md) * [Coercion](coercion/README.md) * [Coercion Explained](coercion/coercion.md) diff --git a/doc/advanced/route_validation.md b/doc/advanced/route_validation.md index 167bf526..7d261684 100644 --- a/doc/advanced/route_validation.md +++ b/doc/advanced/route_validation.md @@ -2,8 +2,6 @@ Namespace `reitit.spec` contains [clojure.spec](https://clojure.org/about/spec) definitions for raw-routes, routes, router and router options. -**NOTE:** Use of specs requires to use Clojure 1.9.0 or higher. - ## Example ```clj @@ -26,12 +24,12 @@ Namespace `reitit.spec` contains [clojure.spec](https://clojure.org/about/spec) ## At development time -`reitit.core/router` can be instrumented and use something like [expound](https://github.com/bhb/expound) to pretty-print the spec problems. +`reitit.core/router` can be instrumented and use a tool like [expound](https://github.com/bhb/expound) to pretty-print the spec problems. First add a `:dev` dependency to: ```clj -[expound "0.3.0"] ; or higher +[expound "0.4.0"] ; or higher ``` Some bootstrapping: @@ -162,7 +160,3 @@ And we are ready to go: ; ------------------------- ; Detected 2 errors ``` - -# Validating route data - -*TODO* diff --git a/doc/basics/README.md b/doc/basics/README.md index 65fc6a69..77fcb3d7 100644 --- a/doc/basics/README.md +++ b/doc/basics/README.md @@ -5,4 +5,5 @@ * [Path-based Routing](path_based_routing.md) * [Name-based Routing](name_based_routing.md) * [Route Data](route_data.md) +* [Route Data Validation](route_data_validation.md) * [Route Conflicts](route_conflicts.md) diff --git a/doc/basics/route_data_validation.md b/doc/basics/route_data_validation.md new file mode 100644 index 00000000..119a6ab0 --- /dev/null +++ b/doc/basics/route_data_validation.md @@ -0,0 +1,129 @@ +# Route Data Validation + +Route data can be anything, so it's easy to do mistakes. Accidentally using a `:role` key instead of `:roles` might render the whole routing app without any authorization in place. + +To fail fast, we could use the custom `:coerce` and `:compile` hooks to apply data validation and throw exceptions on first sighted problem. + +But there is a better way. Router also has a `:validation` hook to validate the whole route tree after it's successfuly compiled. It expects a 2-arity function `routes opts => ()` that can side-effect in case of validation errors. + +## clojure.spec + +Namespace `reitit.spec` contains specs for main parts of `reitit.core` and a helper function `validate-spec!` that runs spec validation for all route data and throws an exception if any errors are found. + +A Router with invalid route data: + +```clj +(require '[reitit.core :as r]) + +(r/router + ["/api" {:handler "identity"}]) +; #object[reitit.core$...] +``` + +Fails fast with `clojure.spec` validation turned on: + +```clj +(require '[reitit.spec :as rs]) + +(r/router + ["/api" {:handler "identity"}] + {:validate rs/validate-spec!}) +; CompilerException clojure.lang.ExceptionInfo: Invalid route data: +; +; -- On route ----------------------- +; +; "/api" +; +; In: [:handler] val: "identity" fails spec: :reitit.spec/handler at: [:handler] predicate: fn? +; +; {:problems (#reitit.spec.Problem{:path "/api", :scope nil, :data {:handler "identity"}, :spec :reitit.spec/default-data, :problems #:clojure.spec.alpha{:problems ({:path [:handler], :pred clojure.core/fn?, :val "identity", :via [:reitit.spec/default-data :reitit.spec/handler], :in [:handler]}), :spec :reitit.spec/default-data, :value {:handler "identity"}}})}, compiling: ... + +``` + +### Customizing spec validation + +`rs/validate-spec!` reads the following router options: + + | key | description | + | ---------------|-------------| + | `:spec` | the spec to verify the route data (default `::rs/default-data`) + | `::rs/explain` | custom explain function (default `clojure.spec.alpha/explain-str`) + +**NOTE**: `clojure.spec` implicitly validates all values with fully-qualified keys if specs exist with the same name. + +Below is an example of using [expound](https://github.com/bhb/expound) to pretty-print route data problems. + +```clj +(require '[clojure.spec.alpha :as s]) +(require '[expound.alpha :as e]) + +(s/def ::role #{:admin :manager}) +(s/def ::roles (s/coll-of ::role :into #{})) + +(r/router + ["/api" {:handler identity + ::roles #{:adminz}}] + {::rs/explain e/expound-str + :validate rs/validate-spec!}) +; CompilerException clojure.lang.ExceptionInfo: Invalid route data: +; +; -- On route ----------------------- +; +; "/api" +; +; -- Spec failed -------------------- +; +; {:handler ..., :user/roles #{:adminz}} +; ^^^^^^^ +; +; should be one of: `:admin`,`:manager` +; +; -- Relevant specs ------- +; +; :user/role: +; #{:admin :manager} +; :user/roles: +; (clojure.spec.alpha/coll-of :user/role :into #{}) +; :reitit.spec/default-data: +; (clojure.spec.alpha/keys +; :opt-un +; [:reitit.spec/name :reitit.spec/handler]) +; +; ------------------------- +; Detected 1 error +; +; {:problems (#reitit.spec.Problem{:path "/api", :scope nil, :data {:handler #object[clojure.core$identity 0x15b59b0e "clojure.core$identity@15b59b0e"], :user/roles #{:adminz}}, :spec :reitit.spec/default-data, :problems #:clojure.spec.alpha{:problems ({:path [:user/roles], :pred #{:admin :manager}, :val :adminz, :via [:reitit.spec/default-data :user/roles :user/role], :in [:user/roles 0]}), :spec :reitit.spec/default-data, :value {:handler #object[clojure.core$identity 0x15b59b0e "clojure.core$identity@15b59b0e"], :user/roles #{:adminz}}}})}, compiling: ... +``` + +Explicitly requiring a `::roles` key in a route data: + +```clj +(r/router + ["/api" {:handler identity}] + {:spec (s/merge (s/keys :req [::roles]) ::rs/default-data) + ::rs/explain e/expound-str + :validate rs/validate-spec!}) +; CompilerException clojure.lang.ExceptionInfo: Invalid route data: +; +; -- On route ----------------------- +; +; "/api" +; +; -- Spec failed -------------------- +; +; {:handler +; #object[clojure.core$identity 0x15b59b0e "clojure.core$identity@15b59b0e"]} +; +; should contain key: `:user/roles` +; +; | key | spec | +; |-------------+----------------------------------------| +; | :user/roles | (coll-of #{:admin :manager} :into #{}) | +; +; +; +; ------------------------- +; Detected 1 error +; +; {:problems (#reitit.spec.Problem{:path "/api", :scope nil, :data {:handler #object[clojure.core$identity 0x15b59b0e "clojure.core$identity@15b59b0e"]}, :spec #object[clojure.spec.alpha$merge_spec_impl$reify__2124 0x7461744b "clojure.spec.alpha$merge_spec_impl$reify__2124@7461744b"], :problems #:clojure.spec.alpha{:problems ({:path [], :pred (clojure.core/fn [%] (clojure.core/contains? % :user/roles)), :val {:handler #object[clojure.core$identity 0x15b59b0e "clojure.core$identity@15b59b0e"]}, :via [], :in []}), :spec #object[clojure.spec.alpha$merge_spec_impl$reify__2124 0x7461744b "clojure.spec.alpha$merge_spec_impl$reify__2124@7461744b"], :value {:handler #object[clojure.core$identity 0x15b59b0e "clojure.core$identity@15b59b0e"]}}})}, compiling:(/Users/tommi/projects/metosin/reitit/test/cljc/reitit/spec_test.cljc:151:1) +``` diff --git a/modules/reitit-core/src/reitit/core.cljc b/modules/reitit-core/src/reitit/core.cljc index c471d6d6..53163406 100644 --- a/modules/reitit-core/src/reitit/core.cljc +++ b/modules/reitit-core/src/reitit/core.cljc @@ -340,8 +340,8 @@ | `:expand` | Function of `arg opts => data` to expand route arg to route data (default `reitit.core/expand`) | `:coerce` | Function of `route opts => route` to coerce resolved route, can throw or return `nil` | `:compile` | Function of `route opts => result` to compile a route handler - | `:validate` | Function of `routes opts => side-effect` to validate route (data) - | `:conflicts` | Function of `{route #{route}} => side-effect` to handle conflicting routes (default `reitit.core/throw-on-conflicts!`) + | `:validate` | Function of `routes opts => ()` to validate route (data) via side-effects + | `:conflicts` | Function of `{route #{route}} => ()` to handle conflicting routes (default `reitit.core/throw-on-conflicts!`) | `:router` | Function of `routes opts => router` to override the actual router implementation" ([raw-routes] (router raw-routes {}))