From c7c4013f970d6cc475b831df59b9a8182d04658c Mon Sep 17 00:00:00 2001 From: Tommi Reiman Date: Wed, 30 Aug 2017 13:24:01 +0300 Subject: [PATCH] README & small fixes --- README.md | 232 ++++++++++++++++---- perf-test/clj/reitit/coercion_perf_test.clj | 2 +- src/reitit/coercion.cljc | 39 ++-- src/reitit/coercion/protocol.cljc | 15 +- src/reitit/coercion/spec.cljc | 2 +- test/cljc/reitit/coercion_test.cljc | 2 +- 6 files changed, 227 insertions(+), 65 deletions(-) diff --git a/README.md b/README.md index c24c9bfb..6de6bb91 100644 --- a/README.md +++ b/README.md @@ -6,6 +6,8 @@ A friendly data-driven router for Clojure(Script). * First-class route meta-data * Generic, not tied to HTTP * [Route conflict resolution](#route-conflicts) +* [Pluggable coercion](#parameter-coercion) ([clojure.spec](https://clojure.org/about/spec)) +* Middleware & Interceptors * Extendable * Fast @@ -68,7 +70,7 @@ Same routes flattened: For routing, a `Router` is needed. Reitit ships with several different router implementations: `:linear-router`, `:lookup-router` and `:mixed-router`, based on the awesome [Pedestal](https://github.com/pedestal/pedestal/tree/master/route) implementation. -`Router` is created with `reitit.core/router`, which takes routes and optional options map as arguments. The route tree gets expanded, optionally coerced and compiled. The actual `Router` implementation is selected based on the route tree or can be selected with the `:router` option. `Router` support both fast path- and name-based lookups. +`Router` is created with `reitit.core/router`, which takes routes and optional options map as arguments. The route tree gets expanded, optionally coerced and compiled. Actual `Router` implementation is selected automatically but can be defined with a `:router` option. `Router` support both path- and name-based lookups. Creating a router: @@ -82,7 +84,7 @@ Creating a router: ["/user/:id" ::user]]])) ``` -`:mixed-router` is created (both static & wild routes are used): +`:mixed-router` is created (both static & wild routes are found): ```clj (reitit/router-type router) @@ -157,52 +159,47 @@ Routes can have arbitrary meta-data. For nested routes, the meta-data is accumul A router based on nested route tree: ```clj -(def router (reitit/router ["/api" {:interceptors [::api]} ["/ping" ::ping] - ["/public/*path" ::resources] - ["/user/:id" {:name ::get-user - :parameters {:id String}} - ["/orders" ::user-orders]] - ["/admin" {:interceptors [::admin] - :roles #{:admin}} - ["/root" {:name ::root - :roles ^:replace #{:root}}] - ["/db" {:name ::db - :interceptors [::db]}]]])) + ["/admin" {:roles #{:admin}} + ["/users" ::users] + ["/db" {:interceptors [::db], :roles ^:replace #{:db-admin}} + ["/:db" {:parameters {:db String}} + ["/drop" ::drop-db] + ["/stats" ::db-stats]]]]])) ``` Resolved route tree: ```clj (reitit/routes router) -; [["/api/ping" {:name :user/ping -; :interceptors [::api]}] -; ["/api/public/*path" {:name :user/resources -; :interceptors [::api]}] -; ["/api/user/:id/orders" {:name :user/user-orders -; :interceptors [::api] -; :parameters {:id String}}] -; ["/api/admin/root" {:name :user/root -; :interceptors [::api ::admin] -; :roles #{:root}}] -; ["/api/admin/db" {:name :user/db -; :interceptors [::api ::admin ::db] -; :roles #{:admin}}]] +; [["/api/ping" {:interceptors [::api] +; :name ::ping}] +; ["/api/admin/users" {:interceptors [::api] +; :roles #{:admin} +; :name ::users}] +; ["/api/admin/db/:db/drop" {:interceptors [::api ::db] +; :roles #{:db-admin} +; :parameters {:db String} +; :name ::drop-db}] +; ["/api/admin/db/:db/stats" {:interceptors [::api ::db] +; :roles #{:db-admin} +; :parameters {:db String} +; :name ::db-stats}]] ``` Path-based routing: ```clj -(reitit/match-by-path router "/api/admin/root") -; #Match{:template "/api/admin/root" -; :meta {:name :user/root -; :interceptors [::api ::admin] -; :roles #{:root}} -; :path "/api/admin/root" +(reitit/match-by-path router "/api/admin/users") +; #Match{:template "/api/admin/users" +; :meta {:interceptors [::api] +; :roles #{:admin} +; :name ::users} ; :result nil -; :params {}} +; :params {} +; :path "/api/admin/users"} ``` On match, route meta-data is returned and can interpreted by the application. @@ -333,8 +330,6 @@ Middleware is applied correctly: ; {:status 200, :body [:api :handler]} ``` -Nested middleware works too: - ```clj (app {:request-method :delete, :uri "/api/admin/db"}) ; {:status 200, :body [:api :admin :db :delete :handler]} @@ -342,7 +337,7 @@ Nested middleware works too: ### Async Ring -Ring-router supports also 3-arity [Async Ring](https://www.booleanknot.com/blog/2016/07/15/asynchronous-ring.html), so it can be used on [Node.js](https://nodejs.org/en/) too. +All built-in middleware provide both the 2 and 3-arity, so they work with [Async Ring](https://www.booleanknot.com/blog/2016/07/15/asynchronous-ring.html) too. ### Meta-data based extensions @@ -397,6 +392,169 @@ Authorized access to guarded route: ; {:status 200, :body "ok"} ``` +## Parameter coercion + +Reitit ships with pluggable parameter coercion via `reitit.coercion.protocol/Coercion` protocol. `reitit.coercion.spec/SpecCoercion` provides implements it for [clojure.spec](https://clojure.org/about/spec) & [data-specs](https://github.com/metosin/spec-tools#data-specs). + +**NOTE**: to use the spec-coercion, one needs to add the following dependencies manually to the project: + +```clj +[org.clojure/clojure "1.9.0-alpha17"] +[org.clojure/spec.alpha "0.1.123"] +[metosin/spec-tools "0.3.2"] +``` + +### Ring request and response coercion + +To use `Coercion` with Ring, one needs to do the following: + +1. Define parameters and responses as data into route meta-data, in format adopted from [ring-swagger](https://github.com/metosin/ring-swagger#more-complete-example): + * `:parameters` map, with submaps for different parameters: `:query`, `:body`, `:form`, `:header` and `:path`. Parameters are defined in the format understood by the `Coercion`. + * `:responses` map, with response status codes as keys (or `:default` for "everything else") with maps with `:schema` and optionally `:description` as values. +2. Define a `Coercion` to route meta-data under `:coercion` +3. Mount request & response coercion middleware to the routes. + +If the request coercion succeeds, the coerced parameters are injected into request under `:parameters`. + +If either request or response coercion fails, an descriptive error is thrown. + +#### Example with data-specs + +```clj +(require '[reitit.ring :as ring]) +(require '[reitit.coercion :as coercion]) +(require '[reitit.coercion.spec :as spec]) + +(def app + (ring/ring-handler + (ring/router + ["/api" + ["/ping" {:parameters {:body {:x int?, :y int?}} + :responses {200 {:schema {:total pos-int?}}} + :get {:handler (fn [{{{:keys [x y]} :body} :parameters}] + {:status 200 + :body {:total (+ x y)}})}}]] + {:meta {:middleware [coercion/gen-wrap-coerce-parameters + coercion/gen-wrap-coerce-response] + :coercion spec/coercion}}))) +``` + + +```clj +(app + {:request-method :get + :uri "/api/ping" + :body-params {:x 1, :y 2}}) +; {:status 200, :body {:total 3}} +``` + +#### Example with specs + +```clj +(require '[reitit.ring :as ring]) +(require '[reitit.coercion :as coercion]) +(require '[reitit.coercion.spec :as spec]) +(require '[clojure.spec.alpha :as s]) +(require '[spec-tools.core :as st]) + +(s/def ::x (st/spec int?)) +(s/def ::y (st/spec int?)) +(s/def ::total int?) +(s/def ::request (s/keys :req-un [::x ::y])) +(s/def ::response (s/keys :req-un [::total])) + +(def app + (ring/ring-handler + (ring/router + ["/api" + ["/ping" {:parameters {:body ::request} + :responses {200 {:schema ::response}} + :get {:handler (fn [{{{:keys [x y]} :body} :parameters}] + {:status 200 + :body {:total (+ x y)}})}}]] + {:meta {:middleware [coercion/gen-wrap-coerce-parameters + coercion/gen-wrap-coerce-response] + :coercion spec/coercion}}))) +``` + +```clj +(app + {:request-method :get + :uri "/api/ping" + :body-params {:x 1, :y 2}}) +; {:status 200, :body {:total 3}} +``` + +## Compiling Middleware + +The [meta-data extensions](#meta-data-based-extensions) are a easy way to extend the system. Routes meta-data can be trasnformed into any shape (records, functions etc.) in route compilation, enabling easy access at request-time. + +Still, we can do better. As we know the exact route interceptor/middleware is linked to, we can pass the (compiled) route information into the interceptor/middleware at creation-time. It can extract and transform relevant data just for it and pass it into the actual request-handler via a closure. We can do all the static local computations forehand, yielding much lighter runtime processing. + +For middleware, there is a helper `reitit.middleware/gen` for this. It takes a function of `route-meta router-opts => middleware` and returns a special record extending the internal middleware protocols so it can be mounted as normal middleware. The compiled middleware can also decide no to mount itsef byt returning `nil`. Why mount `wrap-enforce-roles` if there are no roles required for that route? + +To demonstrate the two approaches, below are response coercion middleware written in both ways (found in `reitit.coercion`): + +### Naive + +* Extracts the compiled route information on every request. + +```clj +(defn wrap-coerce-response + "Pluggable response coercion middleware. + Expects a :coercion of type `reitit.coercion.protocol/Coercion` + and :responeses from route meta, otherwise does not mount." + [handler] + (fn + ([request] + (let [response (handler request) + method (:request-method request) + match (ring/get-match request) + responses (-> match :result method :meta :responses) + coercion (-> match :meta :coercion) + opts (-> match :meta :opts)] + (if (and coercion responses) + (let [coercers (response-coercers coercion responses opts) + coerced (coerce-response coercers request response)] + (coerce-response coercers request (handler request))) + (handler request)))) + ([request respond raise] + (let [response (handler request) + method (:request-method request) + match (ring/get-match request) + responses (-> match :result method :meta :responses) + coercion (-> match :meta :coercion) + opts (-> match :meta :opts)] + (if (and coercion responses) + (let [coercers (response-coercers coercion responses opts) + coerced (coerce-response coercers request response)] + (handler request #(respond (coerce-response coercers request %)))) + (handler request respond raise)))))) +``` + +### Compiled + +* Route information is provided via a closure +* Pre-compiled coercers +* Mounts only if `:coercion` and `:responses` are defined for the route + +```clj +(def gen-wrap-coerce-response + "Generator for pluggable response coercion middleware. + Expects a :coercion of type `reitit.coercion.protocol/Coercion` + and :responses from route meta, otherwise does not mount." + (middleware/gen + (fn [{:keys [responses coercion opts]} _] + (if (and coercion responses) + (let [coercers (response-coercers coercion responses opts)] + (fn [handler] + (fn + ([request] + (coerce-response coercers request (handler request))) + ([request respond raise] + (handler request #(respond (coerce-response coercers request %)) raise))))))))) +``` + ## Merging route-trees *TODO* @@ -405,7 +563,7 @@ Authorized access to guarded route: *TODO* -## Schema, Spec, Swagger & Openapi +## Swagger & Openapi *TODO* diff --git a/perf-test/clj/reitit/coercion_perf_test.clj b/perf-test/clj/reitit/coercion_perf_test.clj index bd443498..61adcb1b 100644 --- a/perf-test/clj/reitit/coercion_perf_test.clj +++ b/perf-test/clj/reitit/coercion_perf_test.clj @@ -80,7 +80,7 @@ (defrecord NoOpCoercion [] protocol/Coercion (get-name [_] :no-op) - (compile [_ model] model) + (compile [_ model _] model) (get-apidocs [_ _ {:keys [parameters responses] :as info}]) (make-open [_ spec] spec) (encode-error [_ error] error) diff --git a/src/reitit/coercion.cljc b/src/reitit/coercion.cljc index 3ddfd129..a9004365 100644 --- a/src/reitit/coercion.cljc +++ b/src/reitit/coercion.cljc @@ -6,7 +6,7 @@ [reitit.ring :as ring] [reitit.impl :as impl])) -(defn get-apidocs [coercion spec info] +#_(defn get-apidocs [coercion spec info] (protocol/get-apidocs coercion spec info)) ;; @@ -61,15 +61,16 @@ (request-coercion-failed! result coercion value in request) result)))))) -(defn- response-format [request response] +#_(defn muuntaja-response-format [request response] (or (-> response :muuntaja/content-type) (some-> request :muuntaja/response :format))) -(defn response-coercer [coercion model] +(defn response-coercer [coercion model {:keys [extract-response-format] + :or {extract-response-format (constantly nil)}}] (if coercion (let [coercer (protocol/response-coercer coercion model)] (fn [request response] - (let [format (response-format request response) + (let [format (extract-response-format request response) value (:body response) result (coercer value format)] (if (protocol/error? result) @@ -98,15 +99,15 @@ [k (request-coercer coercion k v)]) (into {}))) -(defn ^:no-doc response-coercers [coercion responses] +(defn ^:no-doc response-coercers [coercion responses opts] (->> (for [[status {:keys [schema]}] responses :when schema] - [status (response-coercer coercion schema)]) + [status (response-coercer coercion schema opts)]) (into {}))) (defn wrap-coerce-parameters "Pluggable request coercion middleware. Expects a :coercion of type `reitit.coercion.protocol/Coercion` - from injected route meta, otherwise does not mount." + and :parameters from route meta, otherwise does not mount." [handler] (fn ([request] @@ -132,10 +133,10 @@ (def gen-wrap-coerce-parameters "Generator for pluggable request coercion middleware. Expects a :coercion of type `reitit.coercion.protocol/Coercion` - from injected route meta, otherwise does not mount." + and :parameters from route meta, otherwise does not mount." (middleware/gen (fn [{:keys [parameters coercion]} _] - (if coercion + (if (and coercion parameters) (let [coercers (request-coercers coercion parameters)] (fn [handler] (fn @@ -149,7 +150,7 @@ (defn wrap-coerce-response "Pluggable response coercion middleware. Expects a :coercion of type `reitit.coercion.protocol/Coercion` - from injected route meta, otherwise does not mount." + and :responses from route meta, otherwise does not mount." [handler] (fn ([request] @@ -157,9 +158,10 @@ method (:request-method request) match (ring/get-match request) responses (-> match :result method :meta :responses) - coercion (-> match :meta :coercion)] + coercion (-> match :meta :coercion) + opts (-> match :meta :opts)] (if coercion - (let [coercers (response-coercers coercion responses) + (let [coercers (response-coercers coercion responses opts) coerced (coerce-response coercers request response)] (coerce-response coercers request (handler request))) (handler request)))) @@ -168,9 +170,10 @@ method (:request-method request) match (ring/get-match request) responses (-> match :result method :meta :responses) - coercion (-> match :meta :coercion)] + coercion (-> match :meta :coercion) + opts (-> match :meta :opts)] (if coercion - (let [coercers (response-coercers coercion responses) + (let [coercers (response-coercers coercion responses opts) coerced (coerce-response coercers request response)] (handler request #(respond (coerce-response coercers request %)))) (handler request respond raise)))))) @@ -178,11 +181,11 @@ (def gen-wrap-coerce-response "Generator for pluggable response coercion middleware. Expects a :coercion of type `reitit.coercion.protocol/Coercion` - from injected route meta, otherwise does not mount." + and :responses from route meta, otherwise does not mount." (middleware/gen - (fn [{:keys [responses coercion]} _] - (if coercion - (let [coercers (response-coercers coercion responses)] + (fn [{:keys [responses coercion opts]} _] + (if (and coercion responses) + (let [coercers (response-coercers coercion responses opts)] (fn [handler] (fn ([request] diff --git a/src/reitit/coercion/protocol.cljc b/src/reitit/coercion/protocol.cljc index 9e58df3b..aaacb41a 100644 --- a/src/reitit/coercion/protocol.cljc +++ b/src/reitit/coercion/protocol.cljc @@ -2,13 +2,14 @@ (:refer-clojure :exclude [compile])) (defprotocol Coercion - (get-name [this]) - (compile [this model]) - (get-apidocs [this model data]) - (make-open [this model]) - (encode-error [this error]) - (request-coercer [this type model]) - (response-coercer [this model])) + "Pluggable coercion protocol" + (get-name [this] "Keyword name for the coercion") + (compile [this model name] "Compiles a coercion model") + (get-apidocs [this model data] "???") + (make-open [this model] "Returns a new map model which doesn't fail on extra keys") + (encode-error [this error] "Converts error in to a serializable format") + (request-coercer [this type model] "Returns a `value format => value` request coercion function") + (response-coercer [this model] "Returns a `value format => value` response coercion function")) (defrecord CoercionError []) diff --git a/src/reitit/coercion/spec.cljc b/src/reitit/coercion/spec.cljc index 461ac1a4..4d8603e3 100644 --- a/src/reitit/coercion/spec.cljc +++ b/src/reitit/coercion/spec.cljc @@ -57,7 +57,7 @@ protocol/Coercion (get-name [_] name) - (compile [_ model] + (compile [_ model _] (memoized-specify model)) (get-apidocs [_ _ {:keys [parameters responses] :as info}] diff --git a/test/cljc/reitit/coercion_test.cljc b/test/cljc/reitit/coercion_test.cljc index f194da87..20057b8c 100644 --- a/test/cljc/reitit/coercion_test.cljc +++ b/test/cljc/reitit/coercion_test.cljc @@ -1,4 +1,4 @@ -(ns reitit.middleware-test +(ns reitit.coercion-test (:require [clojure.test :refer [deftest testing is]] [reitit.ring :as ring] [reitit.coercion :as coercion]