welcome, first class data-driven Middleware.

This commit is contained in:
Tommi Reiman 2017-09-04 08:24:42 +03:00
parent c7c4013f97
commit 76f7f28591
5 changed files with 210 additions and 112 deletions

View file

@ -7,7 +7,7 @@ A friendly data-driven router for Clojure(Script).
* Generic, not tied to HTTP * Generic, not tied to HTTP
* [Route conflict resolution](#route-conflicts) * [Route conflict resolution](#route-conflicts)
* [Pluggable coercion](#parameter-coercion) ([clojure.spec](https://clojure.org/about/spec)) * [Pluggable coercion](#parameter-coercion) ([clojure.spec](https://clojure.org/about/spec))
* Middleware & Interceptors * both Middleware & Interceptors
* Extendable * Extendable
* Fast * Fast
@ -233,7 +233,7 @@ Route trees should not have multiple routes that match to a single (request) pat
## Ring ## 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: Simple Ring app:
@ -295,6 +295,11 @@ Reverse routing:
### Middleware ### 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: Let's define some middleware and a handler:
```clj ```clj
@ -335,6 +340,50 @@ Middleware is applied correctly:
; {:status 200, :body [:api :admin :db :delete :handler]} ; {: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 ### 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. 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: **NOTE**: to use the spec-coercion, one needs to add the following dependencies manually to the project:
```clj ```clj
[org.clojure/clojure "1.9.0-alpha17"] [org.clojure/clojure "1.9.0-alpha19"]
[org.clojure/spec.alpha "0.1.123"] [org.clojure/spec.alpha "0.1.123"]
[metosin/spec-tools "0.3.2"] [metosin/spec-tools "0.3.3"]
``` ```
### Ring request and response coercion ### 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. 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 ### Naive
@ -543,18 +592,21 @@ To demonstrate the two approaches, below are response coercion middleware writte
"Generator for pluggable response coercion middleware. "Generator for pluggable response coercion middleware.
Expects a :coercion of type `reitit.coercion.protocol/Coercion` Expects a :coercion of type `reitit.coercion.protocol/Coercion`
and :responses from route meta, otherwise does not mount." and :responses from route meta, otherwise does not mount."
(middleware/gen (middleware/create
(fn [{:keys [responses coercion opts]} _] {:name ::coerce-response
(if (and coercion responses) :gen (fn [{:keys [responses coercion opts]} _]
(let [coercers (response-coercers coercion responses opts)] (if (and coercion responses)
(fn [handler] (let [coercers (response-coercers coercion responses opts)]
(fn (fn [handler]
([request] (fn
(coerce-response coercers request (handler request))) ([request]
([request respond raise] (coerce-response coercers request (handler request)))
(handler request #(respond (coerce-response coercers request %)) raise))))))))) ([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 ## Merging route-trees
*TODO* *TODO*
@ -577,7 +629,7 @@ Routers can be configured via options. Options allow things like [`clojure.spec`
| key | description | | key | description |
| -------------|-------------| | -------------|-------------|
| `:path` | Base-path for routes (default `""`) | `:path` | Base-path for routes
| `:routes` | Initial resolved routes (default `[]`) | `:routes` | Initial resolved routes (default `[]`)
| `:meta` | Initial expanded route-meta vector (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`) | `:expand` | Function of `arg opts => meta` to expand route arg to route meta-data (default `reitit.core/expand`)

View file

@ -20,10 +20,10 @@
[lein-cloverage "1.0.9"] [lein-cloverage "1.0.9"]
[lein-codox "0.10.3"]] [lein-codox "0.10.3"]]
:jvm-opts ^:replace ["-server"] :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"] [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"] [org.clojure/spec.alpha "0.1.123"]
[criterium "0.4.4"] [criterium "0.4.4"]

View file

@ -7,7 +7,7 @@
[reitit.impl :as impl])) [reitit.impl :as impl]))
#_(defn get-apidocs [coercion spec info] #_(defn get-apidocs [coercion spec info]
(protocol/get-apidocs coercion spec info)) (protocol/get-apidocs coercion spec info))
;; ;;
;; coercer ;; coercer
@ -62,8 +62,8 @@
result)))))) result))))))
#_(defn muuntaja-response-format [request response] #_(defn muuntaja-response-format [request response]
(or (-> response :muuntaja/content-type) (or (-> response :muuntaja/content-type)
(some-> request :muuntaja/response :format))) (some-> request :muuntaja/response :format)))
(defn response-coercer [coercion model {:keys [extract-response-format] (defn response-coercer [coercion model {:keys [extract-response-format]
:or {extract-response-format (constantly nil)}}] :or {extract-response-format (constantly nil)}}]
@ -134,18 +134,19 @@
"Generator for pluggable request coercion middleware. "Generator for pluggable request coercion middleware.
Expects a :coercion of type `reitit.coercion.protocol/Coercion` Expects a :coercion of type `reitit.coercion.protocol/Coercion`
and :parameters from route meta, otherwise does not mount." and :parameters from route meta, otherwise does not mount."
(middleware/gen (middleware/create
(fn [{:keys [parameters coercion]} _] {:name ::coerce-parameters
(if (and coercion parameters) :gen (fn [{:keys [parameters coercion]} _]
(let [coercers (request-coercers coercion parameters)] (if (and coercion parameters)
(fn [handler] (let [coercers (request-coercers coercion parameters)]
(fn (fn [handler]
([request] (fn
(let [coerced (coerce-parameters coercers request)] ([request]
(handler (impl/fast-assoc request :parameters coerced)))) (let [coerced (coerce-parameters coercers request)]
([request respond raise] (handler (impl/fast-assoc request :parameters coerced))))
(let [coerced (coerce-parameters coercers request)] ([request respond raise]
(handler (impl/fast-assoc request :parameters coerced) respond raise)))))))))) (let [coerced (coerce-parameters coercers request)]
(handler (impl/fast-assoc request :parameters coerced) respond raise))))))))}))
(defn wrap-coerce-response (defn wrap-coerce-response
"Pluggable response coercion middleware. "Pluggable response coercion middleware.
@ -182,14 +183,15 @@
"Generator for pluggable response coercion middleware. "Generator for pluggable response coercion middleware.
Expects a :coercion of type `reitit.coercion.protocol/Coercion` Expects a :coercion of type `reitit.coercion.protocol/Coercion`
and :responses from route meta, otherwise does not mount." and :responses from route meta, otherwise does not mount."
(middleware/gen (middleware/create
(fn [{:keys [responses coercion opts]} _] {:name ::coerce-response
(if (and coercion responses) :gen (fn [{:keys [responses coercion opts]} _]
(let [coercers (response-coercers coercion responses opts)] (if (and coercion responses)
(fn [handler] (let [coercers (response-coercers coercion responses opts)]
(fn (fn [handler]
([request] (fn
(coerce-response coercers request (handler request))) ([request]
([request respond raise] (coerce-response coercers request (handler request)))
(handler request #(respond (coerce-response coercers request %)) raise))))))))) ([request respond raise]
(handler request #(respond (coerce-response coercers request %)) raise)))))))}))

View file

@ -1,23 +1,22 @@
(ns reitit.middleware (ns reitit.middleware
(:require [meta-merge.core :refer [meta-merge]] (:require [meta-merge.core :refer [meta-merge]]
[reitit.core :as reitit]) [reitit.core :as reitit]))
#?(:clj
(:import (clojure.lang IFn AFn))))
(defprotocol ExpandMiddleware (defprotocol ExpandMiddleware
(expand-middleware [this meta opts])) (expand-middleware [this meta opts]))
(defrecord MiddlewareGenerator [f args] (defrecord Middleware [name wrap create])
IFn
(invoke [_] (defn create [{:keys [name gen wrap] :as m}]
(f nil nil)) (when-not name
(invoke [_ meta] (throw
(f meta nil)) (ex-info
(invoke [_ meta opts] (str "Middleware must have :name defined " m) m)))
(f meta opts)) (when (and gen wrap)
#?(:clj (throw
(applyTo [this args] (ex-info
(AFn/applyToHelper this args)))) (str "Middleware can't both :wrap and :gen defined " m) m)))
(map->Middleware m))
(extend-protocol ExpandMiddleware (extend-protocol ExpandMiddleware
@ -32,11 +31,24 @@
:cljs function) :cljs function)
(expand-middleware [this _ _] this) (expand-middleware [this _ _] this)
MiddlewareGenerator #?(:clj clojure.lang.PersistentArrayMap
:cljs cljs.core.PersistentArrayMap)
(expand-middleware [this meta opts] (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] (fn [handler & args]
(apply mw handler args)))) (apply wrap handler args))))
nil nil
(expand-middleware [_ _ _])) (expand-middleware [_ _ _]))
@ -56,9 +68,6 @@
(keep identity) (keep identity)
(apply comp identity))) (apply comp identity)))
(defn gen [f & args]
(->MiddlewareGenerator f args))
(defn compile-handler (defn compile-handler
([route opts] ([route opts]
(compile-handler route opts nil)) (compile-handler route opts nil))

View file

@ -1,5 +1,5 @@
(ns reitit.middleware-test (ns reitit.middleware-test
(:require [clojure.test :refer [deftest testing is]] (:require [clojure.test :refer [deftest testing is are]]
[reitit.middleware :as middleware] [reitit.middleware :as middleware]
[clojure.set :as set] [clojure.set :as set]
[reitit.core :as reitit]) [reitit.core :as reitit])
@ -26,61 +26,96 @@
(respond (handler request)))) (respond (handler request))))
(deftest expand-middleware-test (deftest expand-middleware-test
(testing "middleware generators"
(let [calls (atom 0)]
(testing "record generator" (testing "middleware records"
(reset! calls 0)
(let [syntax [(middleware/gen (testing ":name is mandatory"
(fn [meta _] (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) (swap! calls inc)
(fn [handler value] (fn [request]
(swap! calls inc) [value request]))}]
(fn [request]
[meta value request]))))]
app ((middleware/compose-middleware syntax :meta {}) identity :value)]
(dotimes [_ 10]
(is (= [:meta :value :request] (app :request)))
(is (= 2 @calls)))))
(testing "middleware generator as function" (testing "as map"
(reset! calls 0) (reset! calls 0)
(let [syntax (middleware/gen (let [app ((middleware/compose-middleware [data] :meta {}) identity :value)]
(fn [meta _] (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) (swap! calls inc)
(fn [handler value] (fn [handler value]
(swap! calls inc) (swap! calls inc)
(fn [request] (fn [request]
[meta value request])))) [meta value request])))}]
app ((syntax :meta nil) identity :value)]
(dotimes [_ 10] (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 (= [:meta :value :request] (app :request)))
(is (= 2 @calls))))) (dotimes [_ 10]
(is (= [:meta :value :request] (app :request)))
(testing "generator vector" (is (= 2 @calls)))))
(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)))))))))
(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 (deftest middleware-router-test