Merge pull request #27 from metosin/CompiledMiddlewareAndSpecCoercion

Data-driven middleware and spec coercion
This commit is contained in:
Tommi Reiman 2017-09-05 11:49:19 +03:00 committed by GitHub
commit df27ad526e
17 changed files with 1153 additions and 184 deletions

304
README.md
View file

@ -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))
* both 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)
@ -114,7 +116,7 @@ Route names:
; #Match{:template "/api/user/:id"
; :meta {:name :user/user}
; :path "/api/user/1"
; :handler nil
; :result nil
; :params {:id "1"}}
```
@ -124,7 +126,7 @@ Route names:
(reitit/match-by-name router ::user)
; #PartialMatch{:template "/api/user/:id",
; :meta {:name :user/user},
; :handler nil,
; :result nil,
; :params nil,
; :required #{:id}}
@ -139,7 +141,7 @@ Only a partial match. Let's provide the path-parameters:
; #Match{:template "/api/user/:id"
; :meta {:name :user/user}
; :path "/api/user/1"
; :handler nil
; :result nil
; :params {:id "1"}}
```
@ -157,57 +159,52 @@ 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"
; :handler nil
; :params {}}
(reitit/match-by-path router "/api/admin/users")
; #Match{:template "/api/admin/users"
; :meta {:interceptors [::api]
; :roles #{:admin}
; :name ::users}
; :result nil
; :params {}
; :path "/api/admin/users"}
```
On match, route meta-data is returned and can interpreted by the application.
Routers also support meta-data compilation enabling things like fast [Ring](https://github.com/ring-clojure/ring) or [Pedestal](http://pedestal.io/) -style handlers. Compilation results are found under `:handler` in the match. See [configuring routers](#configuring-routers) for details.
Routers also support meta-data compilation enabling things like fast [Ring](https://github.com/ring-clojure/ring) or [Pedestal](http://pedestal.io/) -style handlers. Compilation results are found under `:result` in the match. See [configuring routers](#configuring-routers) for details.
## Route conflicts
@ -225,10 +222,10 @@ Route trees should not have multiple routes that match to a single (request) pat
; /:user-id/orders
; -> /public/*path
; -> /bulk/:bulk-id
;
;
; /bulk/:bulk-id
; -> /:version/status
;
;
; /public/*path
; -> /:version/status
;
@ -236,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:
@ -298,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
@ -333,16 +335,58 @@ 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]}
```
### 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
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 +441,172 @@ 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-alpha19"]
[org.clojure/spec.alpha "0.1.123"]
[metosin/spec-tools "0.3.3"]
```
### 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 faster runtime processing.
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 as normal ring middleware function and as middleware record with `:gen`. The actual codes are from `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/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*
@ -405,7 +615,7 @@ Authorized access to guarded route:
*TODO*
## Schema, Spec, Swagger & Openapi
## Swagger & Openapi
*TODO*
@ -419,12 +629,12 @@ 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`)
| `:coerce` | Function of `route opts => route` to coerce resolved route, can throw or return `nil`
| `:compile` | Function of `route opts => handler` to compile a route handler
| `:compile` | Function of `route opts => result` to compile a route handler
| `:conflicts` | Function of `{route #{route}} => side-effect` to handle conflicting routes (default `reitit.core/throw-on-conflicts!`)
| `:router` | Function of `routes opts => router` to override the actual router implementation

View file

@ -0,0 +1,174 @@
(ns reitit.coercion-perf-test
(:require [clojure.test :refer [deftest testing is]]
[criterium.core :as cc]
[reitit.perf-utils :refer :all]
[clojure.spec.alpha :as s]
[spec-tools.core :as st]
[reitit.core :as reitit]
[reitit.ring :as ring]
[reitit.coercion :as coercion]
[reitit.coercion.spec :as spec]
[reitit.coercion.protocol :as protocol]
[spec-tools.data-spec :as ds]))
;;
;; start repl with `lein perf repl`
;; perf measured with the following setup:
;;
;; Model Name: MacBook Pro
;; Model Identifier: MacBookPro11,3
;; Processor Name: Intel Core i7
;; Processor Speed: 2,5 GHz
;; Number of Processors: 1
;; Total Number of Cores: 4
;; L2 Cache (per Core): 256 KB
;; L3 Cache: 6 MB
;; Memory: 16 GB
;;
(comment
(do
(s/def ::x (s/and (s/conformer #(if (string? %) (Long/parseLong %) %) identity) int?))
(s/def ::y (s/and (s/conformer #(if (string? %) (Long/parseLong %) %) identity) int?))
(s/def ::k (s/keys :req-un [::x ::y]))
(let [spec (spec/specify {:x int?, :y int?} ::jeah)
coercers (#'coercion/request-coercers spec/coercion {:body spec})
params {:x "1", :y "2"}
request {:body-params {:x "1", :y "2"}}]
;; 4600ns
(bench!
"coerce-parameters"
(#'coercion/coerce-parameters coercers request))
;; 2700ns
(bench!
"st/conform"
(st/conform
spec
params
spec/string-conforming))
;; 4100ns
(bench!
"st/conform + s/unform"
(s/unform
spec
(st/conform
spec
params
spec/string-conforming)))
;; 610ns
(bench!
"s/conform"
(s/conform
::k
params))
;; 2700ns
(bench!
"s/conform + s/unform"
(s/unform
::k
(s/conform
::k
params))))))
(defrecord NoOpCoercion []
protocol/Coercion
(get-name [_] :no-op)
(compile [_ model _] model)
(get-apidocs [_ _ {:keys [parameters responses] :as info}])
(make-open [_ spec] spec)
(encode-error [_ error] error)
(request-coercer [_ type spec] (fn [value format] value))
(response-coercer [this spec] (protocol/request-coercer this :response spec)))
(comment
(doseq [coercion [nil (->NoOpCoercion) spec/coercion]]
(suite (str (if coercion (protocol/get-name coercion))))
(let [routes ["/api"
["/ping" {:parameters {:body {:x int?, :y int?}}
:responses {200 {:schema {:total pos-int?}}}
:get {:handler (fn [request]
(let [{:keys [x y]} (-> request :parameters :body)]
{:status 200
:body {:total (+ (or x 0) (or y 0))}}))}}]]
app (ring/ring-handler
(ring/router
routes
{:meta {:middleware [coercion/wrap-coerce-parameters]
:coercion coercion}}))
app2 (ring/ring-handler
(ring/router
routes
{:meta {:middleware [coercion/gen-wrap-coerce-parameters]
:coercion coercion}}))
app3 (ring/ring-handler
(ring/router
routes
{:meta {:middleware [coercion/wrap-coerce-parameters
coercion/wrap-coerce-response]
:coercion coercion}}))
app4 (ring/ring-handler
(ring/router
routes
{:meta {:middleware [coercion/gen-wrap-coerce-parameters
coercion/gen-wrap-coerce-response]
:coercion coercion}}))
req {:request-method :get
:uri "/api/ping"
:body-params {:x 1, :y 2}}]
;; 210ns
;; 1300ns
;; 7400ns
(bench! "wrap-coerce-parameters" (app req))
;; 170ns (-19%)
;; 500ns (-62%)
;; 5400ns (-26%)
(bench! "gen-wrap-coerce-parameters" (app2 req))
;; 340ns
;; 2400ns
;; 14800ns
(bench! "wrap-coerce-parameters & responses" (app3 req))
;; 180ns (-47%)
;; 580ns (-76%)
;; 8300ns (-44%)
(bench! "gen-wrap-coerce-parameters & responses" (app4 req)))))
(comment
(do
(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}})))
(app
{:request-method :get
:uri "/api/ping"
:body-params {:x 1, :y 2}})
; {:status 200, :body {:total 3}}
(let [req {:request-method :get
:uri "/api/ping"
:body-params {:x 1, :y 2}}]
(cc/quick-bench (app req)))))

View file

@ -1,5 +1,7 @@
(ns reitit.opensensors-routing-test
(:require [clojure.test :refer [deftest testing is]]
[criterium.core :as cc]
[reitit.perf-utils :refer :all]
[cheshire.core :as json]
[clojure.string :as str]
[reitit.core :as reitit]
@ -16,13 +18,20 @@
[io.pedestal.http.route.router :as pedestal]
[io.pedestal.http.route :as route]))
(defn raw-title [color s]
(println (str color (apply str (repeat (count s) "#")) "\u001B[0m"))
(println (str color s "\u001B[0m"))
(println (str color (apply str (repeat (count s) "#")) "\u001B[0m")))
(def title (partial raw-title "\u001B[35m"))
(def suite (partial raw-title "\u001B[32m"))
;;
;; start repl with `lein perf repl`
;; perf measured with the following setup:
;;
;; Model Name: MacBook Pro
;; Model Identifier: MacBookPro11,3
;; Processor Name: Intel Core i7
;; Processor Speed: 2,5 GHz
;; Number of Processors: 1
;; Total Number of Cores: 4
;; L2 Cache (per Core): 256 KB
;; L3 Cache: 6 MB
;; Memory: 16 GB
;;
;;
;; extract sample routes
@ -77,15 +86,6 @@
avg (int (/ (reduce + times) (count times)))]
[% avg]) urls)))
(defn bench [routes no-paths?]
(let [routes (mapv (fn [[path name]]
(if no-paths?
[(str/replace path #"\:" "") name]
[path name])) routes)
router (reitit/router routes)]
(doseq [[path time] (bench-routes routes #(reitit/match-by-path router %))]
(println path "\t" time))))
(defn bench [routes no-paths?]
(let [routes (mapv (fn [[path name]]
(if no-paths?
@ -471,7 +471,7 @@
(if-not match
(println route)))))
(defn bench! [routes verbose? name f]
(defn bench!! [routes verbose? name f]
(System/gc)
(println)
(suite name)
@ -493,14 +493,23 @@
compojure-api-f #(opensensors-compojure-api-routes {:uri % :request-method :get})
pedestal-f #(pedestal/find-route opensensors-pedestal-routes {:path-info % :request-method :get})]
(bench! routes true "reitit" reitit-f) ;; 2538ns -> 2028ns
(bench! routes true "reitit-ring" reitit-ring-f) ;; 2845ns -> 2299ns
(bench! routes true "pedestal" pedestal-f) ;; 2737ns
(bench! routes true "compojure-api" compojure-api-f) ;; 9823ns
(bench! routes true "bidi" bidi-f) ;; 16716ns
(bench! routes true "ataraxy" ataraxy-f) ;; 24467ns
;; 2538ns -> 2028ns
(bench!! routes true "reitit" reitit-f)
))
;; 2845ns -> 2299ns
(bench!! routes true "reitit-ring" reitit-ring-f)
;; 2737ns
(bench!! routes true "pedestal" pedestal-f)
;; 9823ns
(bench!! routes true "compojure-api" compojure-api-f)
;; 16716ns
(bench!! routes true "bidi" bidi-f)
;; 24467ns
(bench!! routes true "ataraxy" ataraxy-f)))
(comment
(bench-rest!))
@ -548,16 +557,15 @@
;; 125ns
;; 62ns (fast-map)
(bench! routes false "reitit" reitit-f)
(bench!! routes false "reitit" reitit-f)
;; 272ns
;; 219ns (fast-assoc)
;; 171ns (fast-map)
(bench! routes false "reitit-ring" reitit-ring-f)
(bench!! routes false "reitit-ring" reitit-ring-f)
;; 172ns
(bench! routes false "pedestal" pedestal-f)))
(bench!! routes false "pedestal" pedestal-f)))
(comment
(bench-cqrs!))

View file

@ -1,6 +1,7 @@
(ns reitit.perf-test
(:require [criterium.core :as cc]
[reitit.core :as reitit]
[reitit.perf-utils :refer :all]
[bidi.bidi :as bidi]
[compojure.api.sweet :refer [api routes GET]]
@ -27,14 +28,6 @@
;; Memory: 16 GB
;;
(defn raw-title [color s]
(println (str color (apply str (repeat (count s) "#")) "\u001B[0m"))
(println (str color s "\u001B[0m"))
(println (str color (apply str (repeat (count s) "#")) "\u001B[0m")))
(def title (partial raw-title "\u001B[35m"))
(def suite (partial raw-title "\u001B[32m"))
(def bidi-routes
["/" [["auth/login" :auth/login]
[["auth/recovery/token/" :token] :auth/recovery]
@ -106,7 +99,7 @@
(call)))
;; 1.0µs (-94%)
;; 770ns (-95%, -23%)
;; 690ns (-96%)
(title "reitit")
(let [call #(reitit/match-by-path reitit-routes "/workspace/1/1")]
(assert (call))
@ -117,7 +110,7 @@
(suite "reverse routing")
;; 2.2µs (-56%)
;; 2.0µs (-59%)
(title "bidi")
(let [call #(bidi/path-for bidi-routes :workspace/page :project "1" :page "1")]
(assert (= "/workspace/1/1" (call)))
@ -126,14 +119,14 @@
(title "ataraxy doesn't support reverse routing :(")
;; 3.8µs (-25%)
;; 3.8µs (-22%)
(title "pedestal - map-tree => prefix-tree")
(let [call #(pedestal-url-for :workspace/page :path-params {:project "1" :page "1"})]
(assert (= "/workspace/1/1" (call)))
(cc/quick-bench
(call)))
;; 5.1µs
;; 4.9µs
(title "compojure-api")
(let [call #(routes/path-for* :workspace/page compojure-api-request {:project "1", :page "1"})]
(assert (= "/workspace/1/1" (call)))

View file

@ -0,0 +1,16 @@
(ns reitit.perf-utils
(:require [criterium.core :as cc]))
(defn raw-title [color s]
(println (str color (apply str (repeat (count s) "#")) "\u001B[0m"))
(println (str color s "\u001B[0m"))
(println (str color (apply str (repeat (count s) "#")) "\u001B[0m")))
(def title (partial raw-title "\u001B[35m"))
(def suite (partial raw-title "\u001B[32m"))
(defmacro bench! [name & body]
`(do
(title ~name)
(println ~@body)
(cc/quick-bench ~@body)))

View file

@ -20,8 +20,12 @@
[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.3"]
[org.clojure/spec.alpha "0.1.123"]
[criterium "0.4.4"]
[org.clojure/test.check "0.9.0"]
[org.clojure/tools.namespace "0.2.11"]

197
src/reitit/coercion.cljc Normal file
View file

@ -0,0 +1,197 @@
(ns reitit.coercion
(:require [clojure.walk :as walk]
[spec-tools.core :as st]
[reitit.coercion.protocol :as protocol]
[reitit.middleware :as middleware]
[reitit.ring :as ring]
[reitit.impl :as impl]))
#_(defn get-apidocs [coercion spec info]
(protocol/get-apidocs coercion spec info))
;;
;; coercer
;;
(defrecord ParameterCoercion [in style keywordize? open?])
(def ring-parameter-coercion
{:query (->ParameterCoercion :query-params :string true true)
:body (->ParameterCoercion :body-params :string false true)
:form (->ParameterCoercion :form-params :string true true)
:header (->ParameterCoercion :header-params :string true true)
:path (->ParameterCoercion :path-params :string true true)})
(defn request-coercion-failed! [result coercion value in request]
(throw
(ex-info
(str "Request coercion failed: " (pr-str result))
(merge
(into {} result)
{:type ::request-coercion
:coercion coercion
:value value
:in [:request in]
:request request}))))
(defn response-coercion-failed! [result coercion value request response]
(throw
(ex-info
(str "Response coercion failed: " (pr-str result))
(merge
(into {} result)
{:type ::response-coercion
:coercion coercion
:value value
:in [:response :body]
:request request
:response response}))))
(defn request-coercer [coercion type model]
(if coercion
(let [{:keys [keywordize? open? in style]} (ring-parameter-coercion type)
transform (comp (if keywordize? walk/keywordize-keys identity) in)
model (if open? (protocol/make-open coercion model) model)
coercer (protocol/request-coercer coercion style model)]
(fn [request]
(let [value (transform request)
format (some-> request :muuntaja/request :format)
result (coercer value format)]
(if (protocol/error? result)
(request-coercion-failed! result coercion value in request)
result))))))
#_(defn muuntaja-response-format [request response]
(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)}}]
(if coercion
(let [coercer (protocol/response-coercer coercion model)]
(fn [request response]
(let [format (extract-response-format request response)
value (:body response)
result (coercer value format)]
(if (protocol/error? result)
(response-coercion-failed! result coercion value request response)
result))))))
;;
;; middleware
;;
(defn- coerce-parameters [coercers request]
(reduce-kv
(fn [acc k coercer]
(impl/fast-assoc acc k (coercer request)))
{}
coercers))
(defn- coerce-response [coercers request response]
(if response
(if-let [coercer (or (coercers (:status response)) (coercers :default))]
(impl/fast-assoc response :body (coercer request response)))))
(defn ^:no-doc request-coercers [coercion parameters]
(->> (for [[k v] parameters
:when v]
[k (request-coercer coercion k v)])
(into {})))
(defn ^:no-doc response-coercers [coercion responses opts]
(->> (for [[status {:keys [schema]}] responses :when 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`
and :parameters from route meta, otherwise does not mount."
[handler]
(fn
([request]
(let [method (:request-method request)
match (ring/get-match request)
parameters (-> match :result method :meta :parameters)
coercion (-> match :meta :coercion)]
(if coercion
(let [coercers (request-coercers coercion parameters)
coerced (coerce-parameters coercers request)]
(handler (impl/fast-assoc request :parameters coerced)))
(handler request))))
([request respond raise]
(let [method (:request-method request)
match (ring/get-match request)
parameters (-> match :result method :meta :parameters)
coercion (-> match :meta :coercion)]
(if coercion
(let [coercers (request-coercers coercion parameters)
coerced (coerce-parameters coercers request)]
(handler (impl/fast-assoc request :parameters coerced) respond raise)))))))
(def gen-wrap-coerce-parameters
"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/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.
Expects a :coercion of type `reitit.coercion.protocol/Coercion`
and :responses 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 coercion
(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 coercion
(let [coercers (response-coercers coercion responses opts)
coerced (coerce-response coercers request response)]
(handler request #(respond (coerce-response coercers request %))))
(handler request respond raise))))))
(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/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)))))))}))

View file

@ -0,0 +1,17 @@
(ns reitit.coercion.protocol
(:refer-clojure :exclude [compile]))
(defprotocol Coercion
"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 [])
(defn error? [x]
(instance? CoercionError x))

View file

@ -0,0 +1,113 @@
(ns reitit.coercion.spec
(:require [clojure.spec.alpha :as s]
[spec-tools.core :as st #?@(:cljs [:refer [Spec]])]
[spec-tools.data-spec :as ds]
[spec-tools.conform :as conform]
[spec-tools.swagger.core :as swagger]
[reitit.coercion.protocol :as protocol])
#?(:clj
(:import (spec_tools.core Spec))))
(def string-conforming
(st/type-conforming
(merge
conform/string-type-conforming
conform/strip-extra-keys-type-conforming)))
(def json-conforming
(st/type-conforming
(merge
conform/json-type-conforming
conform/strip-extra-keys-type-conforming)))
(def default-conforming
::default)
(defprotocol Specify
(specify [this name]))
(extend-protocol Specify
#?(:clj clojure.lang.PersistentArrayMap
:cljs cljs.core.PersistentArrayMap)
(specify [this name]
(ds/spec name this))
#?(:clj clojure.lang.PersistentHashMap
:cljs cljs.core.PersistentHashMap)
(specify [this name]
(ds/spec name this))
Spec
(specify [this _] this)
Object
(specify [this _]
(st/create-spec {:spec this})))
;; TODO: proper name!
(def memoized-specify
(memoize #(specify %1 (gensym "spec"))))
(defmulti coerce-response? identity :default ::default)
(defmethod coerce-response? ::default [_] true)
(defrecord SpecCoercion [name conforming coerce-response?]
protocol/Coercion
(get-name [_] name)
(compile [_ model _]
(memoized-specify model))
(get-apidocs [_ _ {:keys [parameters responses] :as info}]
(cond-> (dissoc info :parameters :responses)
parameters (assoc
::swagger/parameters
(into
(empty parameters)
(for [[k v] parameters]
[k memoized-specify])))
responses (assoc
::swagger/responses
(into
(empty responses)
(for [[k response] responses]
[k (update response :schema memoized-specify)])))))
(make-open [_ spec] spec)
(encode-error [_ error]
(update error :spec (comp str s/form)))
(request-coercer [_ type spec]
(let [spec (memoized-specify spec)
{:keys [formats default]} (conforming type)]
(fn [value format]
(if-let [conforming (or (get formats format) default)]
(let [conformed (st/conform spec value conforming)]
(if (s/invalid? conformed)
(let [problems (st/explain-data spec value conforming)]
(protocol/map->CoercionError
{:spec spec
:problems (::s/problems problems)}))
(s/unform spec conformed)))
value))))
(response-coercer [this spec]
(if (coerce-response? spec)
(protocol/request-coercer this :response spec))))
(def default-options
{:coerce-response? coerce-response?
:conforming {:body {:default default-conforming
:formats {"application/json" json-conforming
"application/msgpack" json-conforming
"application/x-yaml" json-conforming}}
:string {:default string-conforming}
:response {:default default-conforming}}})
(defn create [{:keys [conforming coerce-response?]}]
(->SpecCoercion :spec conforming coerce-response?))
(def coercion (create default-options))

View file

@ -46,7 +46,7 @@
(if (seq childs)
(walk-many (str pacc path) macc childs)
[[(str pacc path) macc]]))))]
(walk-one path meta data)))
(walk-one path (mapv identity meta) data)))
(defn map-meta [f routes]
(mapv #(update % 1 f) routes))
@ -100,8 +100,8 @@
(match-by-path [this path])
(match-by-name [this name] [this name params]))
(defrecord Match [template meta handler params path])
(defrecord PartialMatch [template meta handler params required])
(defrecord Match [template meta result params path])
(defrecord PartialMatch [template meta result params required])
(defn partial-match? [x]
(instance? PartialMatch x))
@ -132,11 +132,11 @@
(let [compiled (map #(compile-route % opts) routes)
names (find-names routes opts)
[data lookup] (reduce
(fn [[data lookup] [p {:keys [name] :as meta} handler]]
(let [{:keys [params] :as route} (impl/create [p meta handler])
(fn [[data lookup] [p {:keys [name] :as meta} result]]
(let [{:keys [params] :as route} (impl/create [p meta result])
f #(if-let [path (impl/path-for route %)]
(->Match p meta handler % path)
(->PartialMatch p meta handler % params))]
(->Match p meta result % path)
(->PartialMatch p meta result % params))]
[(conj data route)
(if name (assoc lookup name f) lookup)]))
[[] {}] compiled)
@ -155,7 +155,7 @@
(reduce
(fn [acc ^Route route]
(if-let [params ((:matcher route) path)]
(reduced (->Match (:path route) (:meta route) (:handler route) params path))))
(reduced (->Match (:path route) (:meta route) (:result route) params path))))
nil data))
(match-by-name [_ name]
(if-let [match (impl/fast-get lookup name)]
@ -179,10 +179,10 @@
(let [compiled (map #(compile-route % opts) routes)
names (find-names routes opts)
[data lookup] (reduce
(fn [[data lookup] [p {:keys [name] :as meta} handler]]
[(assoc data p (->Match p meta handler {} p))
(fn [[data lookup] [p {:keys [name] :as meta} result]]
[(assoc data p (->Match p meta result {} p))
(if name
(assoc lookup name #(->Match p meta handler % p))
(assoc lookup name #(->Match p meta result % p))
lookup)]) [{} {}] compiled)
data (impl/fast-map data)
lookup (impl/fast-map lookup)]
@ -244,10 +244,10 @@
| -------------|-------------|
| `:path` | Base-path for routes (default `\"\"`)
| `:routes` | Initial resolved routes (default `[]`)
| `:meta` | Initial expanded route-meta vector (default `[]`)
| `:meta` | Initial route meta (default `{}`)
| `:expand` | Function of `arg opts => meta` to expand route arg to route meta-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 => handler` to compile a route handler
| `:compile` | Function of `route opts => result` to compile a route handler
| `:conflicts` | Function of `{route #{route}} => side-effect` to handle conflicting routes (default `reitit.core/throw-on-conflicts!`)
| `:router` | Function of `routes opts => router` to override the actual router implementation"
([data]

View file

@ -101,15 +101,15 @@
;; Routing (c) Metosin
;;
(defrecord Route [path matcher parts params meta handler])
(defrecord Route [path matcher parts params meta result])
(defn create [[path meta handler]]
(defn create [[path meta result]]
(if (contains-wilds? path)
(as-> (parse-path path) $
(assoc $ :path-re (path-regex $))
(merge $ {:path path
:matcher (path-matcher $)
:handler handler
:result result
:meta meta})
(dissoc $ :path-re :path-constraints)
(update $ :path-params set)
@ -119,7 +119,7 @@
(map->Route {:path path
:meta meta
:matcher #(if (= path %) {})
:handler handler})))
:result result})))
(defn segments [path]
(let [ss (-> (str/split path #"/") rest vec)]

View file

@ -3,22 +3,55 @@
[reitit.core :as reitit]))
(defprotocol ExpandMiddleware
(expand-middleware [this opts]))
(expand-middleware [this meta opts]))
(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
#?(:clj clojure.lang.APersistentVector
:cljs cljs.core.PersistentVector)
(expand-middleware [[f & args] _]
(fn [handler]
(apply f handler args)))
(expand-middleware [[f & args] meta opts]
(if-let [mw (expand-middleware f meta opts)]
(fn [handler]
(apply mw handler args))))
#?(:clj clojure.lang.Fn
:cljs function)
(expand-middleware [this _] this)
(expand-middleware [this _ _] this)
#?(:clj clojure.lang.PersistentArrayMap
:cljs cljs.core.PersistentArrayMap)
(expand-middleware [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 wrap handler args))))
nil
(expand-middleware [_ _]))
(expand-middleware [_ _ _]))
(defn- ensure-handler! [path meta scope]
(when-not (:handler meta)
@ -28,10 +61,11 @@
(merge {:path path, :meta meta}
(if scope {:scope scope}))))))
(defn compose-middleware [middleware opts]
(defn compose-middleware [middleware meta opts]
(->> middleware
(keep identity)
(map #(expand-middleware % opts))
(map #(expand-middleware % meta opts))
(keep identity)
(apply comp identity)))
(defn compile-handler
@ -39,7 +73,7 @@
(compile-handler route opts nil))
([[path {:keys [middleware handler] :as meta}] opts scope]
(ensure-handler! path meta scope)
((compose-middleware middleware opts) handler)))
((compose-middleware middleware meta opts) handler)))
(defn router
([data]

View file

@ -5,7 +5,8 @@
[reitit.impl :as impl]))
(def http-methods #{:get :head :patch :delete :options :post :put})
(defrecord MethodHandlers [get head patch delete options post put])
(defrecord Methods [get head post put delete trace options connect patch any])
(defrecord Endpoint [meta handler])
(defn- group-keys [meta]
(reduce-kv
@ -19,10 +20,27 @@
(fn
([request]
(if-let [match (reitit/match-by-path router (:uri request))]
((:handler match) (impl/fast-assoc request ::match match))))
(let [method (:request-method request :any)
params (:params match)
result (:result match)
handler (or (-> result method :handler)
(-> result :any :handler))]
(if handler
(handler
(cond-> (impl/fast-assoc request ::match match)
params (impl/fast-assoc :path-params params)))))))
([request respond raise]
(if-let [match (reitit/match-by-path router (:uri request))]
((:handler match) (impl/fast-assoc request ::match match) respond raise))))
(let [method (:request-method request :any)
params (:params match)
result (:result match)
handler (or (-> result method :handler)
(-> result :any :handler))]
(if handler
(handler
(cond-> (impl/fast-assoc request ::match match)
params (impl/fast-assoc :path-params params))
respond raise))))))
{::router router}))
(defn get-router [handler]
@ -41,25 +59,27 @@
(defn compile-handler [[path meta] opts]
(let [[top childs] (group-keys meta)]
(if-not (seq childs)
(middleware/compile-handler [path meta] opts)
(let [handlers (map->MethodHandlers
(reduce-kv
#(assoc %1 %2 (middleware/compile-handler
[path (meta-merge top %3)] opts %2))
{} childs))
default-handler (if (:handler top) (middleware/compile-handler [path meta] opts))]
(fn
([request]
(if-let [handler (or ((:request-method request) handlers) default-handler)]
(handler request)))
([request respond raise]
(if-let [handler (or ((:request-method request) handlers) default-handler)]
(handler request respond raise))))))))
(map->Methods
{:any (map->Endpoint
{:handler (middleware/compile-handler [path top] opts)
:meta top})})
(let [any-handler (if (:handler top) (middleware/compile-handler [path meta] opts))]
(reduce-kv
(fn [acc method meta]
(let [meta (meta-merge top meta)
handler (middleware/compile-handler [path meta] opts method)]
(assoc acc method (map->Endpoint
{:handler handler
:meta meta}))))
(map->Methods
{:any (map->Endpoint
{:handler (if (:handler top) (middleware/compile-handler [path meta] opts))
:meta top})})
childs)))))
(defn router
([data]
(router data nil))
([data opts]
(let [opts (meta-merge {:coerce coerce-handler
:compile compile-handler} opts)]
(let [opts (meta-merge {:coerce coerce-handler, :compile compile-handler} opts)]
(reitit/router data opts))))

View file

@ -0,0 +1,63 @@
(ns reitit.coercion-test
(:require [clojure.test :refer [deftest testing is]]
[reitit.ring :as ring]
[reitit.coercion :as coercion]
[reitit.coercion.spec :as spec])
#?(:clj
(:import (clojure.lang ExceptionInfo))))
(defn handler
([{:keys [::mw]}]
{:status 200 :body (conj mw :ok)})
([request respond raise]
(respond (handler request))))
(deftest coercion-test
(let [app (ring/ring-handler
(ring/router
["/api"
["/plus/:e"
{:get {:parameters {:query {:a int?}
:body {:b int?}
:form {:c int?}
:header {:d int?}
:path {:e int?}}
:responses {200 {:schema {:total pos-int?}}}
:handler (fn [{{{:keys [a]} :query
{:keys [b]} :body
{:keys [c]} :form
{:keys [d]} :header
{:keys [e]} :path} :parameters}]
{:status 200
:body {:total (+ a b c d e)}})}}]]
{:meta {:middleware [coercion/gen-wrap-coerce-parameters
coercion/gen-wrap-coerce-response]
:coercion spec/coercion}}))]
(testing "all good"
(is (= {:status 200
:body {:total 15}}
(app {:uri "/api/plus/5"
:request-method :get
:query-params {"a" "1"}
:body-params {:b 2}
:form-params {:c 3}
:header-params {:d 4}}))))
(testing "invalid request"
(is (thrown-with-msg?
ExceptionInfo
#"Request coercion failed"
(app {:uri "/api/plus/5"
:request-method :get}))))
(testing "invalid response"
(is (thrown-with-msg?
ExceptionInfo
#"Response coercion failed"
(app {:uri "/api/plus/5"
:request-method :get
:query-params {"a" "1"}
:body-params {:b 2}
:form-params {:c 3}
:header-params {:d -40}}))))))

View file

@ -96,15 +96,15 @@
(reitit/routes router))))
(testing "route match contains compiled handler"
(is (= 2 @compile-times))
(let [{:keys [handler]} (reitit/match-by-path router "/api/pong")]
(is handler)
(is (= "/api/pong" (handler)))
(let [{:keys [result]} (reitit/match-by-path router "/api/pong")]
(is result)
(is (= "/api/pong" (result)))
(is (= 2 @compile-times))))))
(testing "default compile"
(let [router (reitit/router ["/ping" (constantly "ok")])]
(let [{:keys [handler]} (reitit/match-by-path router "/ping")]
(is handler)
(is (= "ok" (handler)))))))
(let [{:keys [result]} (reitit/match-by-path router "/ping")]
(is result)
(is (= "ok" (result)))))))
(testing "custom router"
(let [router (reitit/router ["/ping"] {:router (fn [_ _]

View file

@ -0,0 +1,164 @@
(ns reitit.middleware-test
(:require [clojure.test :refer [deftest testing is are]]
[reitit.middleware :as middleware]
[clojure.set :as set]
[reitit.core :as reitit])
#?(:clj
(:import (clojure.lang ExceptionInfo))))
(defn mw [handler name]
(fn
([request]
(-> request
(update ::mw (fnil conj []) name)
(handler)
(update :body (fnil conj []) name)))
([request respond raise]
(handler
(update request ::mw (fnil conj []) name)
#(respond (update % :body (fnil conj []) name))
raise))))
(defn handler
([{:keys [::mw]}]
{:status 200 :body (conj mw :ok)})
([request respond raise]
(respond (handler request))))
(deftest expand-middleware-test
(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 [request]
[value request]))}]
(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])))}]
(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)))
(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
(testing "all paths should have a handler"
(is (thrown-with-msg?
ExceptionInfo
#"path \"/ping\" doesn't have a :handler defined"
(middleware/router ["/ping"]))))
(testing "ring-handler"
(let [api-mw #(mw % :api)
router (middleware/router
[["/ping" handler]
["/api" {:middleware [api-mw]}
["/ping" handler]
["/admin" {:middleware [[mw :admin]]}
["/ping" handler]]]])
app (fn
([{:keys [uri] :as request}]
(if-let [handler (:result (reitit/match-by-path router uri))]
(handler request)))
([{:keys [uri] :as request} respond raise]
(if-let [handler (:result (reitit/match-by-path router uri))]
(handler request respond raise))))]
(testing "not found"
(is (= nil (app {:uri "/favicon.ico"}))))
(testing "normal handler"
(is (= {:status 200, :body [:ok]}
(app {:uri "/ping"}))))
(testing "with middleware"
(is (= {:status 200, :body [:api :ok :api]}
(app {:uri "/api/ping"}))))
(testing "with nested middleware"
(is (= {:status 200, :body [:api :admin :ok :admin :api]}
(app {:uri "/api/admin/ping"}))))
(testing "3-arity"
(let [result (atom nil)
respond (partial reset! result), raise ::not-called]
(app {:uri "/api/admin/ping"} respond raise)
(is (= {:status 200, :body [:api :admin :ok :admin :api]}
@result)))))))

View file

@ -26,50 +26,6 @@
([request respond raise]
(respond (handler request))))
(deftest middleware-router-test
(testing "all paths should have a handler"
(is (thrown-with-msg?
ExceptionInfo
#"path \"/ping\" doesn't have a :handler defined"
(middleware/router ["/ping"]))))
(testing "ring-handler"
(let [api-mw #(mw % :api)
router (middleware/router
[["/ping" handler]
["/api" {:middleware [api-mw]}
["/ping" handler]
["/admin" {:middleware [[mw :admin]]}
["/ping" handler]]]])
app (ring/ring-handler router)]
(testing "router can be extracted"
(is (= router (ring/get-router app))))
(testing "not found"
(is (= nil (app {:uri "/favicon.ico"}))))
(testing "normal handler"
(is (= {:status 200, :body [:ok]}
(app {:uri "/ping"}))))
(testing "with middleware"
(is (= {:status 200, :body [:api :ok :api]}
(app {:uri "/api/ping"}))))
(testing "with nested middleware"
(is (= {:status 200, :body [:api :admin :ok :admin :api]}
(app {:uri "/api/admin/ping"}))))
(testing "3-arity"
(let [result (atom nil)
respond (partial reset! result), raise ::not-called]
(app {:uri "/api/admin/ping"} respond raise)
(is (= {:status 200, :body [:api :admin :ok :admin :api]}
@result)))))))
(deftest ring-router-test
(testing "all paths should have a handler"
@ -142,7 +98,7 @@
(testing "only top-level route names are matched"
(is (= [::all ::get ::users]
(reitit/route-names router))))
(reitit/route-names router))))
(testing "all named routes can be matched"
(doseq [name (reitit/route-names router)]