diff --git a/doc/SUMMARY.md b/doc/SUMMARY.md index 8930d756..4f70c225 100644 --- a/doc/SUMMARY.md +++ b/doc/SUMMARY.md @@ -2,22 +2,24 @@ * [Introduction](README.md) * [Basics](basics/README.md) - * [Route syntax](basics/route_syntax.md) + * [Route Syntax](basics/route_syntax.md) * [Router](basics/router.md) * [Path-based Routing](basics/path_based_routing.md) * [Name-based Routing](basics/name_based_routing.md) - * [Route data](basics/route_data.md) - * [Route conflicts](basics/route_conflicts.md) + * [Route Data](basics/route_data.md) + * [Route Conflicts](basics/route_conflicts.md) +* [Coercion](coercion/README.md) + * [Coercion Explained](coercion/coercion.md) * [Advanced](advanced/README.md) - * [Configuring routers](advanced/configuring_routers.md) + * [Configuring Routers](advanced/configuring_routers.md) * [Different Routers](advanced/different_routers.md) * [Route Validation](advanced/route_validation.md) * [Ring](ring/README.md) * [Ring-router](ring/ring.md) - * [Dynamic extensions](ring/dynamic_extensions.md) + * [Dynamic Extensions](ring/dynamic_extensions.md) * [Data-driven Middleware](ring/data_driven_middleware.md) * [Pluggable Coercion](ring/coercion.md) - * [Compiling middleware](ring/compiling_middleware.md) + * [Compiling Middleware](ring/compiling_middleware.md) * [Performance](performance.md) * [FAQ](faq.md) * TODO: Swagger & OpenAPI diff --git a/doc/advanced/README.md b/doc/advanced/README.md index d5080e77..cad41444 100644 --- a/doc/advanced/README.md +++ b/doc/advanced/README.md @@ -1,5 +1,5 @@ # Advanced -* [Configuring routers](configuring_routers.md) +* [Configuring Routers](configuring_routers.md) * [Different Routers](different_routers.md) * [Route Validation](route_validation.md) diff --git a/doc/basics/README.md b/doc/basics/README.md index 1dec603b..65fc6a69 100644 --- a/doc/basics/README.md +++ b/doc/basics/README.md @@ -1,8 +1,8 @@ # Basics -* [Route syntax](route_syntax.md) +* [Route Syntax](route_syntax.md) * [Router](router.md) * [Path-based Routing](path_based_routing.md) * [Name-based Routing](name_based_routing.md) -* [Route data](route_data.md) -* [Route conflicts](route_conflicts.md) +* [Route Data](route_data.md) +* [Route Conflicts](route_conflicts.md) diff --git a/doc/basics/name_based_routing.md b/doc/basics/name_based_routing.md index ec01783d..7b16b9ae 100644 --- a/doc/basics/name_based_routing.md +++ b/doc/basics/name_based_routing.md @@ -1,4 +1,4 @@ -## Name-based (reverse) routing +## Name-based (reverse) Routing All routes which have `:name` route data defined, can also be matched by name. diff --git a/doc/basics/path_based_routing.md b/doc/basics/path_based_routing.md index 44fa8281..f9e5eaf6 100644 --- a/doc/basics/path_based_routing.md +++ b/doc/basics/path_based_routing.md @@ -1,4 +1,4 @@ -## Path-based routing +## Path-based Routing Path-based routing is done using the `reitit.core/match-by-path` function. It takes the router and path as arguments and returns one of the following: diff --git a/doc/basics/route_conflicts.md b/doc/basics/route_conflicts.md index d38e2e51..401a2aa9 100644 --- a/doc/basics/route_conflicts.md +++ b/doc/basics/route_conflicts.md @@ -1,4 +1,4 @@ -# Route conflicts +# Route Conflicts Many routing libraries allow multiple matches for a single path lookup. Usually, the first match is used and the rest are effecively unreachanle. This is not good, especially if route tree is merged from multiple sources. diff --git a/doc/basics/route_data.md b/doc/basics/route_data.md index cdaa29a2..81d918a9 100644 --- a/doc/basics/route_data.md +++ b/doc/basics/route_data.md @@ -1,4 +1,4 @@ -# Route data +# Route Data Route data is the heart of this library. Routes can have any data attachted to them. Data is interpeted either by the client application or the `Router` via it's `:coerce` and `:compile` hooks. This enables co-existence of both [adaptive and principled](https://youtu.be/x9pxbnFC4aQ?t=1907) components. diff --git a/doc/coercion/README.md b/doc/coercion/README.md new file mode 100644 index 00000000..15fe241b --- /dev/null +++ b/doc/coercion/README.md @@ -0,0 +1,3 @@ +# Coercion + +* [Coercion Explained](coercion.md) diff --git a/doc/coercion/coercion.md b/doc/coercion/coercion.md new file mode 100644 index 00000000..0ef26c72 --- /dev/null +++ b/doc/coercion/coercion.md @@ -0,0 +1,182 @@ +# Coercion Explained + +Coercion is a process of transforming parameters (and responses) from one format into another. Reitit separates routing and coercion into separate steps. + +By default, all wildcard and catch-all parameters are parsed as Strings: + +```clj +(require '[reitit.core :as r]) + +(def router + (r/router + ["/:company/users/:user-id" ::user-view])) +``` + +Here's a match with the String `:params`: + +```clj +(r/match-by-path r "/metosin/users/123") +; #Match{:template "/:company/users/:user-id", +; :data {:name :user/user-view}, +; :result nil, +; :params {:company "metosin", :user-id "123"}, +; :path "/metosin/users/123"} +``` + +To enable parameter coercion, we need to do few things: + +1. Define a `Coercion` for the routes +2. Define types for the parameters +3. Compile coercers for the types +4. Apply the coercion + +## Define Coercion + +`reitit.coercion/Coercion` is a protocol defining how types are defined, coerced and inventoried. + +Reitit has the following coercion modules: + +* `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). + +Coercion can be attached to route data under `:coercion` key. There can be multiple `Coercion` implementation into a single router, normal [scoping rules](../basics/route_data.html#nested-route-data) apply. + +## Defining parameters + +Route parameters can be defined via route data `:parameters`. It has keys for different type of parameters: `:query`, `:body`, `:form`, `:header` and `:path`. Syntax for the actual parameters is defined by the `Coercion`. + +Here's the example with Schema path-parameters: + +```clj +(require '[reitit.coercion.schema]) +(require '[schema.core :as s]) + +(def router + (r/router + ["/:company/users/:user-id" {:name ::user-view + :coercion reitit.coercion.schema/coercion + :parameters {:path {:company s/Str + :user-id s/Int}}}])) +``` + +Routing again: + +```clj +(r/match-by-path r "/metosin/users/123") +; #Match{:template "/:company/users/:user-id", +; :data {:name :user/user-view, +; :coercion #SchemaCoercion{...} +; :parameters {:path {:company java.lang.String, +; :user-id Int}}}, +; :result nil, +; :params {:company "metosin", :user-id "123"}, +; :path "/metosin/users/123"} +``` + +Coercion was not applied. Why? All we did was just added more data to the route and the routing functions are just responsible for routing, not coercion. + +But now we should have enough data on the match to apply the coercion. + +## Compiling coercers + +Before the actual coercion, we need to compile the coercers against the route data. This is because compiled coercers yield much better performance. A separate step makes thing explicit and non-magical. Compiling could be done via a Middleware, Interceptor but we can also do it at Router-level, effecting all routes. + +There is a helper function for the coercer compiling in `reitit.coercion`: + +```clj +(require '[reitit.coercion :as coercion]) +(require '[reitit.coercion.schema]) +(require '[schema.core :as s]) + +(def router + (r/router + ["/:company/users/:user-id" {:name ::user-view + :coercion reitit.coercion.schema/coercion + :parameters {:path {:company s/Str + :user-id s/Int}}}] + {:compile coercion/compile-request-coercers})) +``` + +Routing again: + +```clj +(r/match-by-path r "/metosin/users/123") +; #Match{:template "/:company/users/:user-id", +; :data {:name :user/user-view, +; :coercion #SchemaCoercion{...} +; :parameters {:path {:company java.lang.String, +; :user-id Int}}}, +; :result {:path #object[reitit.coercion$request_coercer$]}, +; :params {:company "metosin", :user-id "123"}, +; :path "/metosin/users/123"} +``` + +The compiler added a `:result` key into the match (done just once, at router creation time), which holds the compiled coercers. We are almost done. + +## Applying coercion + +We can use a helper function to do the actual coercion, based on a `Match`: + +```clj +(coercion/coerce! + (r/match-by-path router "/metosin/users/123")) +; {:path {:company "metosin", :user-id 123}} +``` + +If a coercion fails, a typed (`:reitit.coercion/request-coercion`) ExceptionInfo is thrown, with descriptive data about the actual error: + +```clj +(coercion/coerce! + (r/match-by-path router "/metosin/users/ikitommi")) +; => ExceptionInfo Request coercion failed: +; #CoercionError{:schema {:company java.lang.String, :user-id Int, Any Any}, +; :errors {:user-id (not (integer? "ikitommi"))}} +; clojure.core/ex-info (core.clj:4739) +``` + +## Full example + +Here's an full example of routing + coercion. + +```clj +(require '[reitit.coercion.schema]) +(require '[reitit.coercion :as coercion]) +(require '[schema.core :as s]) + +(def router + (r/router + ["/:company/users/:user-id" {:name ::user-view + :coercion reitit.coercion.schema/coercion + :parameters {:path {:company s/Str + :user-id s/Int}}}] + {:compile coercion/compile-request-coercers})) + +(defn route-and-coerce! [path] + (if-let [match (r/match-by-path router path)] + (assoc match :parameters (coercion/coerce! match)))) + +(route-and-coerce! "/metosin/users/123") +; #Match{:template "/:company/users/:user-id", +; :data {:name :user/user-view, +; :coercion #SchemaCoercion{...} +; :parameters {:path {:company java.lang.String, +; :user-id Int}}}, +; :result {:path #object[reitit.coercion$request_coercer$]}, +; :params {:company "metosin", :user-id "123"}, +; :parameters {:path {:company "metosin", :user-id 123}} +; :path "/metosin/users/123"} + +(route-and-coerce! "/metosin/users/ikitommi") +; => ExceptionInfo Request coercion failed... +``` + +## Ring Coercion + +For a full-blown http-coercion, see the [ring coercion](../ring/coercion.md). + +## Thanks to + +Most of the thing are just polished version of the original implementations. Big thanks to: + +* [compojure-api](https://clojars.org/metosin/compojure-api) for the initial `Coercion` protocol +* [ring-swagger](https://github.com/metosin/ring-swagger#more-complete-example) for the syntax of the `:paramters` (and `:responses`). diff --git a/doc/coercion/coercion2.md b/doc/coercion/coercion2.md new file mode 100644 index 00000000..e1c6a40f --- /dev/null +++ b/doc/coercion/coercion2.md @@ -0,0 +1,104 @@ +# Coercion + +Coercion is a process of transforming parameters from one format into another. + +By default, all wildcard and catch-all parameters are parsed as Strings: + +```clj +(require '[reitit.core :as r]) + +(def router + (r/router + ["/:company/users/:user-id" ::user-view])) +``` + +Here's a match: + +```clj +(r/match-by-path r "/metosin/users/123") +; #Match{:template "/:company/users/:user-id", +; :data {:name :user/user-view}, +; :result nil, +; :params {:company "metosin", :user-id "123"}, +; :path "/metosin/users/123"} +``` + +To enable parameter coercion, we need to do few things: + +1. Choose a `Coercion` for the routes +2. Defined types for the parameters +3. Compile coercers for the types +4. Apply the coercion + +## Coercion + +`Coercion` is a protocol defining how types can be defined, coerced and inventoried. + +Reitit ships with the following coercion modules: + +* `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). + +Coercion can be attached to routes using a `:coercion` key in the route data. There can be multiple `Coercion` implementation into a single router, normal [scoping rules](../basics/route_data.html#nested-route-data) apply here too. + +## Defining types for parameters + +Route parameters can be defined via route data `:parameters`. It can be submaps to define different types of parameters: `:query`, `:body`, `:form`, `:header` and `:path`. Syntax for the actual parameters is defined by the `Coercion` being used. + +#### Schema + +```clj +(require '[reitit.coercion.schema]) +(require '[schema.core :as schema]) + +(def router + (r/router + ["/:company/users/:user-id" {:name ::user-view + :coercion reitit.coercion.schema/coercion + :parameters {:path {:company schema/Str + :user-id schema/Int}])) +``` + +#### Clojure.spec + +Currently, `clojure.spec` [doesn't support runtime transformations via conforming](https://dev.clojure.org/jira/browse/CLJ-2116), so one needs to wrap all specs with `spec-tools.core/spec` to get this working. + + +```clj +(require '[reitit.coercion.spec]) +(require '[spec-tools.spec :as spec]) +(require '[clojure.spec :as s]) + +(s/def ::company spec/string? +(s/def ::user-id spec/int? +(s/def ::path-params (s/keys :req-un [::company ::user-id])) + +(def router + (r/router + ["/:company/users/:user-id" {:name ::user-view + :coercion reitit.coercion.spec/coercion + :parameters {:path ::path-params])) +``` + +#### Data-specs + +```clj +(require '[reitit.coercion.spec]) + +(def router + (r/router + ["/:company/users/:user-id" {:name ::user-view + :coercion reitit.coercion.spec/coercion + :parameters {:path {:company str? + :user-id int?}])) +``` + +So, now we have our + + +### Thanks to + +Most of the thing are just redefined version of the original implementation. Big thanks to: + +* [compojure-api](https://clojars.org/metosin/compojure-api) for the initial `Coercion` protocol +* [ring-swagger](https://github.com/metosin/ring-swagger#more-complete-example) for the syntax for the `:paramters` (and `:responses`). diff --git a/doc/ring/README.md b/doc/ring/README.md index 7db18acf..7ba874eb 100644 --- a/doc/ring/README.md +++ b/doc/ring/README.md @@ -1,7 +1,7 @@ # Ring * [Ring-router](ring.md) -* [Dynamic extensions](dynamic_extensions.md) +* [Dynamic Extensions](dynamic_extensions.md) * [Data-driven Middleware](data_driven_middleware.md) * [Pluggable Coercion](coercion.md) -* [Compiling middleware](compiling_middleware.md) +* [Compiling Middleware](compiling_middleware.md) diff --git a/doc/ring/dynamic_extensions.md b/doc/ring/dynamic_extensions.md index faaa573a..28df430a 100644 --- a/doc/ring/dynamic_extensions.md +++ b/doc/ring/dynamic_extensions.md @@ -1,4 +1,4 @@ -# Dynamic extensions +# Dynamic Extensions `ring-handler` injects the `Match` into a request and it can be extracted at runtime with `reitit.ring/get-match`. This can be used to build ad-hoc extensions to the system. diff --git a/modules/reitit-core/src/reitit/coercion.cljc b/modules/reitit-core/src/reitit/coercion.cljc index b05825e9..3870b91e 100644 --- a/modules/reitit-core/src/reitit/coercion.cljc +++ b/modules/reitit-core/src/reitit/coercion.cljc @@ -125,13 +125,31 @@ [status (response-coercer coercion schema opts)]) (into {}))) +(defn- coercers-not-compiled! [match] + (throw + (ex-info + (str + "Match didn't have a compiled coercion attached.\n" + "Maybe you should have defined a router option:\n" + "{:compile reitit.coercion/compile-request-coercers}\n") + {:match match}))) + ;; ;; integration ;; -(defn compile-request-coercers [[p {:keys [parameters coercion]}] opts] +(defn compile-request-coercers + "A router :compile implementation which reads the `:parameters` + and `:coercion` data to create compiled coercers into Match under + `:result. A pre-requisite to use [[coerce!]]." + [[_ {:keys [parameters coercion]}] opts] (if (and parameters coercion) (request-coercers coercion parameters opts))) -(defn coerce! [match] - (coerce-request (:result match) {:path-params (:params match)})) +(defn coerce! + "Returns a map of coerced input parameters using pre-compiled + coercers under `:result` (provided by [[compile-request-coercers]]. + If coercion or parameters are not defined, return `nil`" + [match] + (if-let [result (:result match)] + (coerce-request result {:path-params (:params match)}))) diff --git a/test/cljc/reitit/coercion_test.cljc b/test/cljc/reitit/coercion_test.cljc index 6fac8a34..bf33b52f 100644 --- a/test/cljc/reitit/coercion_test.cljc +++ b/test/cljc/reitit/coercion_test.cljc @@ -15,6 +15,10 @@ :parameters {:path {:number s/Int :keyword s/Keyword}}}]] ["/spec" {:coercion spec/coercion} + ["/:number/:keyword" {:name ::user + :parameters {:path {:number int? + :keyword keyword?}}}]] + ["/none" ["/:number/:keyword" {:name ::user :parameters {:path {:number int? :keyword keyword?}}}]]] @@ -36,5 +40,12 @@ (coercion/coerce! m))))) (testing "throws with invalid input" (let [m (r/match-by-path r "/spec/kikka/abba")] - (is (thrown? ExceptionInfo (coercion/coerce! m)))))))) + (is (thrown? ExceptionInfo (coercion/coerce! m)))))) + + (testing "no coercion defined" + (testing "doesn't coerce" + (let [m (r/match-by-path r "/none/1/abba")] + (is (= nil (coercion/coerce! m)))) + (let [m (r/match-by-path r "/none/kikka/abba")] + (is (= nil (coercion/coerce! m))))))))