diff --git a/README.md b/README.md index 6de6bb91..12090b7e 100644 --- a/README.md +++ b/README.md @@ -7,7 +7,7 @@ A friendly data-driven router for Clojure(Script). * Generic, not tied to HTTP * [Route conflict resolution](#route-conflicts) * [Pluggable coercion](#parameter-coercion) ([clojure.spec](https://clojure.org/about/spec)) -* Middleware & Interceptors +* both Middleware & Interceptors * Extendable * Fast @@ -233,7 +233,7 @@ Route trees should not have multiple routes that match to a single (request) pat ## Ring -[Ring](https://github.com/ring-clojure/ring)-router adds support for [handlers](https://github.com/ring-clojure/ring/wiki/Concepts#handlers), [middleware](https://github.com/ring-clojure/ring/wiki/Concepts#middleware) and routing based on `:request-method`. +[Ring](https://github.com/ring-clojure/ring)-router adds support for ring [handlers](https://github.com/ring-clojure/ring/wiki/Concepts#handlers), [middleware](https://github.com/ring-clojure/ring/wiki/Concepts#middleware) and routing based on `:request-method`. Ring-router is created with `reitit.ring/router` function. It validates that all paths have a `:handler` defined and expands `:middleware` to create accumulated handlers for all request-methods. `reitit.ring/ring-handler` creates an actual ring handler out of a ring-router. Simple Ring app: @@ -295,6 +295,11 @@ Reverse routing: ### Middleware +`:middleware` should be a vector of either of the following (expanded via the `reitit.middleware/ExpandMiddleware`: + +1. a ring middleware function of `handler -> request -> response` +2. a vector of middleware function (`handler args -> request -> response`) and it's args - actial middleware is created by applying function with handler and args + Let's define some middleware and a handler: ```clj @@ -335,6 +340,50 @@ Middleware is applied correctly: ; {:status 200, :body [:api :admin :db :delete :handler]} ``` +### Middleware Records + +Besides just being opaque functions, middleware can be presented as first-class data entries, `reitit.middleware/Middleware` records. They are created with `reitit.middleware/create` function and must have a `:name` and either `:wrap` or `:gen` key with the actual middleware function or a [middleware generator function](#compiling-middleware). + +When routes are compiled, middleware records are unwrapped into normal middleware functions producing no runtime performance penalty. Thanks to the `ExpandMiddleware` protocol, plain clojure(script) maps can also be used - they get expanded into middleware records. + +The previous middleware re-written as records: + +```clj +(require '[reitit.middleware :as middleware]) + +(def wrap2 + (middleware/create + {:name ::wrap + :description "a nice little mw, takes 1 arg." + :wrap wrap})) + +(def wrap2-api + {:name ::wrap-api + :description "a nice little mw, :api as arg" + :wrap (fn [handler] + (wrap handler :api))}) +``` + +Or as maps: + +```clj +(require '[reitit.middleware :as middleware]) + +(def wrap3 + {:name ::wrap + :description "a nice little mw, takes 1 arg." + :wrap wrap}) + +(def wrap3-api + {:name ::wrap-api + :description "a nice little mw, :api as arg" + :wrap (fn [handler] + (wrap handler :api))}) +``` + + + + ### Async Ring 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. @@ -399,9 +448,9 @@ Reitit ships with pluggable parameter coercion via `reitit.coercion.protocol/Coe **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/clojure "1.9.0-alpha19"] [org.clojure/spec.alpha "0.1.123"] -[metosin/spec-tools "0.3.2"] +[metosin/spec-tools "0.3.3"] ``` ### Ring request and response coercion @@ -489,11 +538,11 @@ If either request or response coercion fails, an descriptive error is thrown. 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. +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 faster 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 do this we use [middleware records](#middleware-records) `:gen` hook instead of the normal `:wrap`. `:gen` expects a function of `route-meta router-opts => wrap`. Instead of returning the actual middleware function, the middleware record can also decide no to mount itsef byt returning `nil`. Why mount `wrap-enforce-roles` for a route if there are no roles required for it? -To demonstrate the two approaches, below are response coercion middleware written in both ways (found in `reitit.coercion`): +To demonstrate the two approaches, below are response coercion middleware written as normal ring middleware function and as middleware record with `:gen`. The actual codes are from `reitit.coercion`: ### Naive @@ -543,18 +592,21 @@ To demonstrate the two approaches, below are response coercion middleware writte "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))))))))) + (middleware/create + {:name ::coerce-response + :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)))))))})) ``` +The `:gen` -version is both much easier to understand but also 2-4x faster on basic perf tests. + ## Merging route-trees *TODO* @@ -577,7 +629,7 @@ Routers can be configured via options. Options allow things like [`clojure.spec` | key | description | | -------------|-------------| - | `:path` | Base-path for routes (default `""`) + | `:path` | Base-path for routes | `:routes` | Initial resolved routes (default `[]`) | `:meta` | Initial expanded route-meta vector (default `[]`) | `:expand` | Function of `arg opts => meta` to expand route arg to route meta-data (default `reitit.core/expand`) diff --git a/project.clj b/project.clj index cf5305c2..6c4926db 100644 --- a/project.clj +++ b/project.clj @@ -20,10 +20,10 @@ [lein-cloverage "1.0.9"] [lein-codox "0.10.3"]] :jvm-opts ^:replace ["-server"] - :dependencies [[org.clojure/clojure "1.9.0-alpha17"] + :dependencies [[org.clojure/clojure "1.9.0-alpha19"] [org.clojure/clojurescript "1.9.660"] - [metosin/spec-tools "0.3.2"] + [metosin/spec-tools "0.3.3"] [org.clojure/spec.alpha "0.1.123"] [criterium "0.4.4"] diff --git a/src/reitit/coercion.cljc b/src/reitit/coercion.cljc index a9004365..c2c0c901 100644 --- a/src/reitit/coercion.cljc +++ b/src/reitit/coercion.cljc @@ -7,7 +7,7 @@ [reitit.impl :as impl])) #_(defn get-apidocs [coercion spec info] - (protocol/get-apidocs coercion spec info)) + (protocol/get-apidocs coercion spec info)) ;; ;; coercer @@ -62,8 +62,8 @@ result)))))) #_(defn muuntaja-response-format [request response] - (or (-> response :muuntaja/content-type) - (some-> request :muuntaja/response :format))) + (or (-> response :muuntaja/content-type) + (some-> request :muuntaja/response :format))) (defn response-coercer [coercion model {:keys [extract-response-format] :or {extract-response-format (constantly nil)}}] @@ -134,18 +134,19 @@ "Generator for pluggable request coercion middleware. Expects a :coercion of type `reitit.coercion.protocol/Coercion` and :parameters from route meta, otherwise does not mount." - (middleware/gen - (fn [{:keys [parameters coercion]} _] - (if (and coercion parameters) - (let [coercers (request-coercers coercion parameters)] - (fn [handler] - (fn - ([request] - (let [coerced (coerce-parameters coercers request)] - (handler (impl/fast-assoc request :parameters coerced)))) - ([request respond raise] - (let [coerced (coerce-parameters coercers request)] - (handler (impl/fast-assoc request :parameters coerced) respond raise)))))))))) + (middleware/create + {:name ::coerce-parameters + :gen (fn [{:keys [parameters coercion]} _] + (if (and coercion parameters) + (let [coercers (request-coercers coercion parameters)] + (fn [handler] + (fn + ([request] + (let [coerced (coerce-parameters coercers request)] + (handler (impl/fast-assoc request :parameters coerced)))) + ([request respond raise] + (let [coerced (coerce-parameters coercers request)] + (handler (impl/fast-assoc request :parameters coerced) respond raise))))))))})) (defn wrap-coerce-response "Pluggable response coercion middleware. @@ -182,14 +183,15 @@ "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))))))))) + (middleware/create + {:name ::coerce-response + :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)))))))})) diff --git a/src/reitit/middleware.cljc b/src/reitit/middleware.cljc index 853fc50d..a817b2f8 100644 --- a/src/reitit/middleware.cljc +++ b/src/reitit/middleware.cljc @@ -1,23 +1,22 @@ (ns reitit.middleware (:require [meta-merge.core :refer [meta-merge]] - [reitit.core :as reitit]) - #?(:clj - (:import (clojure.lang IFn AFn)))) + [reitit.core :as reitit])) (defprotocol ExpandMiddleware (expand-middleware [this meta opts])) -(defrecord MiddlewareGenerator [f args] - IFn - (invoke [_] - (f nil nil)) - (invoke [_ meta] - (f meta nil)) - (invoke [_ meta opts] - (f meta opts)) - #?(:clj - (applyTo [this args] - (AFn/applyToHelper this args)))) +(defrecord Middleware [name wrap create]) + +(defn create [{:keys [name gen wrap] :as m}] + (when-not name + (throw + (ex-info + (str "Middleware must have :name defined " m) m))) + (when (and gen wrap) + (throw + (ex-info + (str "Middleware can't both :wrap and :gen defined " m) m))) + (map->Middleware m)) (extend-protocol ExpandMiddleware @@ -32,11 +31,24 @@ :cljs function) (expand-middleware [this _ _] this) - MiddlewareGenerator + #?(:clj clojure.lang.PersistentArrayMap + :cljs cljs.core.PersistentArrayMap) (expand-middleware [this meta opts] - (if-let [mw (this meta opts)] + (expand-middleware (create this) meta opts)) + + #?(:clj clojure.lang.PersistentHashMap + :cljs cljs.core.PersistentHashMap) + (expand-middleware [this meta opts] + (expand-middleware (create this) meta opts)) + + Middleware + (expand-middleware [{:keys [wrap gen]} meta opts] + (if gen + (if-let [wrap (gen meta opts)] + (fn [handler & args] + (apply wrap handler args))) (fn [handler & args] - (apply mw handler args)))) + (apply wrap handler args)))) nil (expand-middleware [_ _ _])) @@ -56,9 +68,6 @@ (keep identity) (apply comp identity))) -(defn gen [f & args] - (->MiddlewareGenerator f args)) - (defn compile-handler ([route opts] (compile-handler route opts nil)) diff --git a/test/cljc/reitit/middleware_test.cljc b/test/cljc/reitit/middleware_test.cljc index 3c57b2aa..6c24f5c3 100644 --- a/test/cljc/reitit/middleware_test.cljc +++ b/test/cljc/reitit/middleware_test.cljc @@ -1,5 +1,5 @@ (ns reitit.middleware-test - (:require [clojure.test :refer [deftest testing is]] + (:require [clojure.test :refer [deftest testing is are]] [reitit.middleware :as middleware] [clojure.set :as set] [reitit.core :as reitit]) @@ -26,61 +26,96 @@ (respond (handler request)))) (deftest expand-middleware-test - (testing "middleware generators" - (let [calls (atom 0)] - (testing "record generator" - (reset! calls 0) - (let [syntax [(middleware/gen - (fn [meta _] + (testing "middleware records" + + (testing ":name is mandatory" + (is (thrown-with-msg? + ExceptionInfo + #"Middleware must have :name defined" + (middleware/create + {:wrap identity + :gen (constantly identity)})))) + + (testing ":wrap & :gen are exclusive" + (is (thrown-with-msg? + ExceptionInfo + #"Middleware can't both :wrap and :gen defined" + (middleware/create + {:name ::test + :wrap identity + :gen (constantly identity)})))) + + (testing ":wrap" + (let [calls (atom 0) + data {:name ::test + :wrap (fn [handler value] (swap! calls inc) - (fn [handler value] - (swap! calls inc) - (fn [request] - [meta value request]))))] - app ((middleware/compose-middleware syntax :meta {}) identity :value)] - (dotimes [_ 10] - (is (= [:meta :value :request] (app :request))) - (is (= 2 @calls))))) + (fn [request] + [value request]))}] - (testing "middleware generator as function" - (reset! calls 0) - (let [syntax (middleware/gen - (fn [meta _] + (testing "as map" + (reset! calls 0) + (let [app ((middleware/compose-middleware [data] :meta {}) identity :value)] + (dotimes [_ 10] + (is (= [:value :request] (app :request))) + (is (= 1 @calls))))) + + (testing "direct" + (reset! calls 0) + (let [app ((middleware/compose-middleware [(middleware/create data)] :meta {}) identity :value)] + (dotimes [_ 10] + (is (= [:value :request] (app :request))) + (is (= 1 @calls))))) + + (testing "vector" + (reset! calls 0) + (let [app ((middleware/compose-middleware [[(middleware/create data) :value]] :meta {}) identity)] + (dotimes [_ 10] + (is (= [:value :request] (app :request))) + (is (= 1 @calls))))))) + + (testing ":gen" + (let [calls (atom 0) + data {:name ::test + :gen (fn [meta _] (swap! calls inc) (fn [handler value] (swap! calls inc) (fn [request] - [meta value request])))) - app ((syntax :meta nil) identity :value)] - (dotimes [_ 10] + [meta value request])))}] + + (testing "as map" + (reset! calls 0) + (let [app ((middleware/compose-middleware [data] :meta {}) identity :value)] + (dotimes [_ 10] + (is (= [:meta :value :request] (app :request))) + (is (= 2 @calls))))) + + (testing "direct" + (reset! calls 0) + (let [app ((middleware/compose-middleware [(middleware/create data)] :meta {}) identity :value)] + (dotimes [_ 10] + (is (= [:meta :value :request] (app :request))) + (is (= 2 @calls))))) + + (testing "vector" + (reset! calls 0) + (let [app ((middleware/compose-middleware [[(middleware/create data) :value]] :meta {}) identity)] (is (= [:meta :value :request] (app :request))) - (is (= 2 @calls))))) - - (testing "generator vector" - (reset! calls 0) - (let [syntax [[(middleware/gen - (fn [meta _] - (swap! calls inc) - (fn [handler value] - (swap! calls inc) - (fn [request] - [meta value request])))) :value]] - app ((middleware/compose-middleware syntax :meta {}) identity)] - (is (= [:meta :value :request] (app :request))) - (dotimes [_ 10] - (is (= [:meta :value :request] (app :request))) - (is (= 2 @calls))))) - - (testing "generator can return nil" - (reset! calls 0) - (let [syntax [[(middleware/gen - (fn [meta _])) :value]] - app ((middleware/compose-middleware syntax :meta {}) identity)] - (is (= :request (app :request))) - (dotimes [_ 10] - (is (= :request (app :request))))))))) + (dotimes [_ 10] + (is (= [:meta :value :request] (app :request))) + (is (= 2 @calls))))) + (testing "nil unmounts the middleware" + (reset! calls 0) + (let [syntax [[(middleware/create + {:name ::test + :gen (fn [meta _])}) :value]] + app ((middleware/compose-middleware syntax :meta {}) identity)] + (is (= :request (app :request))) + (dotimes [_ 10] + (is (= :request (app :request)))))))))) (deftest middleware-router-test