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

300
README.md
View file

@ -6,6 +6,8 @@ A friendly data-driven router for Clojure(Script).
* First-class route meta-data * First-class route meta-data
* 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))
* both Middleware & Interceptors
* Extendable * Extendable
* Fast * 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. 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: Creating a router:
@ -82,7 +84,7 @@ Creating a router:
["/user/:id" ::user]]])) ["/user/:id" ::user]]]))
``` ```
`:mixed-router` is created (both static & wild routes are used): `:mixed-router` is created (both static & wild routes are found):
```clj ```clj
(reitit/router-type router) (reitit/router-type router)
@ -114,7 +116,7 @@ Route names:
; #Match{:template "/api/user/:id" ; #Match{:template "/api/user/:id"
; :meta {:name :user/user} ; :meta {:name :user/user}
; :path "/api/user/1" ; :path "/api/user/1"
; :handler nil ; :result nil
; :params {:id "1"}} ; :params {:id "1"}}
``` ```
@ -124,7 +126,7 @@ Route names:
(reitit/match-by-name router ::user) (reitit/match-by-name router ::user)
; #PartialMatch{:template "/api/user/:id", ; #PartialMatch{:template "/api/user/:id",
; :meta {:name :user/user}, ; :meta {:name :user/user},
; :handler nil, ; :result nil,
; :params nil, ; :params nil,
; :required #{:id}} ; :required #{:id}}
@ -139,7 +141,7 @@ Only a partial match. Let's provide the path-parameters:
; #Match{:template "/api/user/:id" ; #Match{:template "/api/user/:id"
; :meta {:name :user/user} ; :meta {:name :user/user}
; :path "/api/user/1" ; :path "/api/user/1"
; :handler nil ; :result nil
; :params {:id "1"}} ; :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: A router based on nested route tree:
```clj ```clj
(def router
(reitit/router (reitit/router
["/api" {:interceptors [::api]} ["/api" {:interceptors [::api]}
["/ping" ::ping] ["/ping" ::ping]
["/public/*path" ::resources] ["/admin" {:roles #{:admin}}
["/user/:id" {:name ::get-user ["/users" ::users]
:parameters {:id String}} ["/db" {:interceptors [::db], :roles ^:replace #{:db-admin}}
["/orders" ::user-orders]] ["/:db" {:parameters {:db String}}
["/admin" {:interceptors [::admin] ["/drop" ::drop-db]
:roles #{:admin}} ["/stats" ::db-stats]]]]]))
["/root" {:name ::root
:roles ^:replace #{:root}}]
["/db" {:name ::db
:interceptors [::db]}]]]))
``` ```
Resolved route tree: Resolved route tree:
```clj ```clj
(reitit/routes router) (reitit/routes router)
; [["/api/ping" {:name :user/ping ; [["/api/ping" {:interceptors [::api]
; :interceptors [::api]}] ; :name ::ping}]
; ["/api/public/*path" {:name :user/resources ; ["/api/admin/users" {:interceptors [::api]
; :interceptors [::api]}] ; :roles #{:admin}
; ["/api/user/:id/orders" {:name :user/user-orders ; :name ::users}]
; :interceptors [::api] ; ["/api/admin/db/:db/drop" {:interceptors [::api ::db]
; :parameters {:id String}}] ; :roles #{:db-admin}
; ["/api/admin/root" {:name :user/root ; :parameters {:db String}
; :interceptors [::api ::admin] ; :name ::drop-db}]
; :roles #{:root}}] ; ["/api/admin/db/:db/stats" {:interceptors [::api ::db]
; ["/api/admin/db" {:name :user/db ; :roles #{:db-admin}
; :interceptors [::api ::admin ::db] ; :parameters {:db String}
; :roles #{:admin}}]] ; :name ::db-stats}]]
``` ```
Path-based routing: Path-based routing:
```clj ```clj
(reitit/match-by-path router "/api/admin/root") (reitit/match-by-path router "/api/admin/users")
; #Match{:template "/api/admin/root" ; #Match{:template "/api/admin/users"
; :meta {:name :user/root ; :meta {:interceptors [::api]
; :interceptors [::api ::admin] ; :roles #{:admin}
; :roles #{:root}} ; :name ::users}
; :path "/api/admin/root" ; :result nil
; :handler nil ; :params {}
; :params {}} ; :path "/api/admin/users"}
``` ```
On match, route meta-data is returned and can interpreted by the application. 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 ## Route conflicts
@ -236,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:
@ -298,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
@ -333,16 +335,58 @@ Middleware is applied correctly:
; {:status 200, :body [:api :handler]} ; {:status 200, :body [:api :handler]}
``` ```
Nested middleware works too:
```clj ```clj
(app {:request-method :delete, :uri "/api/admin/db"}) (app {:request-method :delete, :uri "/api/admin/db"})
; {: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
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 ### Meta-data based extensions
@ -397,6 +441,172 @@ Authorized access to guarded route:
; {:status 200, :body "ok"} ; {: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 ## Merging route-trees
*TODO* *TODO*
@ -405,7 +615,7 @@ Authorized access to guarded route:
*TODO* *TODO*
## Schema, Spec, Swagger & Openapi ## Swagger & Openapi
*TODO* *TODO*
@ -419,12 +629,12 @@ 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`)
| `:coerce` | Function of `route opts => route` to coerce resolved route, can throw or return `nil` | `: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!`) | `: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 | `: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 (ns reitit.opensensors-routing-test
(:require [clojure.test :refer [deftest testing is]] (:require [clojure.test :refer [deftest testing is]]
[criterium.core :as cc]
[reitit.perf-utils :refer :all]
[cheshire.core :as json] [cheshire.core :as json]
[clojure.string :as str] [clojure.string :as str]
[reitit.core :as reitit] [reitit.core :as reitit]
@ -16,13 +18,20 @@
[io.pedestal.http.route.router :as pedestal] [io.pedestal.http.route.router :as pedestal]
[io.pedestal.http.route :as route])) [io.pedestal.http.route :as route]))
(defn raw-title [color s] ;;
(println (str color (apply str (repeat (count s) "#")) "\u001B[0m")) ;; start repl with `lein perf repl`
(println (str color s "\u001B[0m")) ;; perf measured with the following setup:
(println (str color (apply str (repeat (count s) "#")) "\u001B[0m"))) ;;
;; Model Name: MacBook Pro
(def title (partial raw-title "\u001B[35m")) ;; Model Identifier: MacBookPro11,3
(def suite (partial raw-title "\u001B[32m")) ;; 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 ;; extract sample routes
@ -77,15 +86,6 @@
avg (int (/ (reduce + times) (count times)))] avg (int (/ (reduce + times) (count times)))]
[% avg]) urls))) [% 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?] (defn bench [routes no-paths?]
(let [routes (mapv (fn [[path name]] (let [routes (mapv (fn [[path name]]
(if no-paths? (if no-paths?
@ -471,7 +471,7 @@
(if-not match (if-not match
(println route))))) (println route)))))
(defn bench! [routes verbose? name f] (defn bench!! [routes verbose? name f]
(System/gc) (System/gc)
(println) (println)
(suite name) (suite name)
@ -493,14 +493,23 @@
compojure-api-f #(opensensors-compojure-api-routes {:uri % :request-method :get}) compojure-api-f #(opensensors-compojure-api-routes {:uri % :request-method :get})
pedestal-f #(pedestal/find-route opensensors-pedestal-routes {:path-info % :request-method :get})] pedestal-f #(pedestal/find-route opensensors-pedestal-routes {:path-info % :request-method :get})]
(bench! routes true "reitit" reitit-f) ;; 2538ns -> 2028ns ;; 2538ns -> 2028ns
(bench! routes true "reitit-ring" reitit-ring-f) ;; 2845ns -> 2299ns (bench!! routes true "reitit" reitit-f)
(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
)) ;; 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 (comment
(bench-rest!)) (bench-rest!))
@ -548,16 +557,15 @@
;; 125ns ;; 125ns
;; 62ns (fast-map) ;; 62ns (fast-map)
(bench! routes false "reitit" reitit-f) (bench!! routes false "reitit" reitit-f)
;; 272ns ;; 272ns
;; 219ns (fast-assoc) ;; 219ns (fast-assoc)
;; 171ns (fast-map) ;; 171ns (fast-map)
(bench! routes false "reitit-ring" reitit-ring-f) (bench!! routes false "reitit-ring" reitit-ring-f)
;; 172ns ;; 172ns
(bench! routes false "pedestal" pedestal-f))) (bench!! routes false "pedestal" pedestal-f)))
(comment (comment
(bench-cqrs!)) (bench-cqrs!))

View file

@ -1,6 +1,7 @@
(ns reitit.perf-test (ns reitit.perf-test
(:require [criterium.core :as cc] (:require [criterium.core :as cc]
[reitit.core :as reitit] [reitit.core :as reitit]
[reitit.perf-utils :refer :all]
[bidi.bidi :as bidi] [bidi.bidi :as bidi]
[compojure.api.sweet :refer [api routes GET]] [compojure.api.sweet :refer [api routes GET]]
@ -27,14 +28,6 @@
;; Memory: 16 GB ;; 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 (def bidi-routes
["/" [["auth/login" :auth/login] ["/" [["auth/login" :auth/login]
[["auth/recovery/token/" :token] :auth/recovery] [["auth/recovery/token/" :token] :auth/recovery]
@ -106,7 +99,7 @@
(call))) (call)))
;; 1.0µs (-94%) ;; 1.0µs (-94%)
;; 770ns (-95%, -23%) ;; 690ns (-96%)
(title "reitit") (title "reitit")
(let [call #(reitit/match-by-path reitit-routes "/workspace/1/1")] (let [call #(reitit/match-by-path reitit-routes "/workspace/1/1")]
(assert (call)) (assert (call))
@ -117,7 +110,7 @@
(suite "reverse routing") (suite "reverse routing")
;; 2.2µs (-56%) ;; 2.0µs (-59%)
(title "bidi") (title "bidi")
(let [call #(bidi/path-for bidi-routes :workspace/page :project "1" :page "1")] (let [call #(bidi/path-for bidi-routes :workspace/page :project "1" :page "1")]
(assert (= "/workspace/1/1" (call))) (assert (= "/workspace/1/1" (call)))
@ -126,14 +119,14 @@
(title "ataraxy doesn't support reverse routing :(") (title "ataraxy doesn't support reverse routing :(")
;; 3.8µs (-25%) ;; 3.8µs (-22%)
(title "pedestal - map-tree => prefix-tree") (title "pedestal - map-tree => prefix-tree")
(let [call #(pedestal-url-for :workspace/page :path-params {:project "1" :page "1"})] (let [call #(pedestal-url-for :workspace/page :path-params {:project "1" :page "1"})]
(assert (= "/workspace/1/1" (call))) (assert (= "/workspace/1/1" (call)))
(cc/quick-bench (cc/quick-bench
(call))) (call)))
;; 5.1µs ;; 4.9µs
(title "compojure-api") (title "compojure-api")
(let [call #(routes/path-for* :workspace/page compojure-api-request {:project "1", :page "1"})] (let [call #(routes/path-for* :workspace/page compojure-api-request {:project "1", :page "1"})]
(assert (= "/workspace/1/1" (call))) (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-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.3"]
[org.clojure/spec.alpha "0.1.123"]
[criterium "0.4.4"] [criterium "0.4.4"]
[org.clojure/test.check "0.9.0"] [org.clojure/test.check "0.9.0"]
[org.clojure/tools.namespace "0.2.11"] [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) (if (seq childs)
(walk-many (str pacc path) macc childs) (walk-many (str pacc path) macc childs)
[[(str pacc path) macc]]))))] [[(str pacc path) macc]]))))]
(walk-one path meta data))) (walk-one path (mapv identity meta) data)))
(defn map-meta [f routes] (defn map-meta [f routes]
(mapv #(update % 1 f) routes)) (mapv #(update % 1 f) routes))
@ -100,8 +100,8 @@
(match-by-path [this path]) (match-by-path [this path])
(match-by-name [this name] [this name params])) (match-by-name [this name] [this name params]))
(defrecord Match [template meta handler params path]) (defrecord Match [template meta result params path])
(defrecord PartialMatch [template meta handler params required]) (defrecord PartialMatch [template meta result params required])
(defn partial-match? [x] (defn partial-match? [x]
(instance? PartialMatch x)) (instance? PartialMatch x))
@ -132,11 +132,11 @@
(let [compiled (map #(compile-route % opts) routes) (let [compiled (map #(compile-route % opts) routes)
names (find-names routes opts) names (find-names routes opts)
[data lookup] (reduce [data lookup] (reduce
(fn [[data lookup] [p {:keys [name] :as meta} handler]] (fn [[data lookup] [p {:keys [name] :as meta} result]]
(let [{:keys [params] :as route} (impl/create [p meta handler]) (let [{:keys [params] :as route} (impl/create [p meta result])
f #(if-let [path (impl/path-for route %)] f #(if-let [path (impl/path-for route %)]
(->Match p meta handler % path) (->Match p meta result % path)
(->PartialMatch p meta handler % params))] (->PartialMatch p meta result % params))]
[(conj data route) [(conj data route)
(if name (assoc lookup name f) lookup)])) (if name (assoc lookup name f) lookup)]))
[[] {}] compiled) [[] {}] compiled)
@ -155,7 +155,7 @@
(reduce (reduce
(fn [acc ^Route route] (fn [acc ^Route route]
(if-let [params ((:matcher route) path)] (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)) nil data))
(match-by-name [_ name] (match-by-name [_ name]
(if-let [match (impl/fast-get lookup name)] (if-let [match (impl/fast-get lookup name)]
@ -179,10 +179,10 @@
(let [compiled (map #(compile-route % opts) routes) (let [compiled (map #(compile-route % opts) routes)
names (find-names routes opts) names (find-names routes opts)
[data lookup] (reduce [data lookup] (reduce
(fn [[data lookup] [p {:keys [name] :as meta} handler]] (fn [[data lookup] [p {:keys [name] :as meta} result]]
[(assoc data p (->Match p meta handler {} p)) [(assoc data p (->Match p meta result {} p))
(if name (if name
(assoc lookup name #(->Match p meta handler % p)) (assoc lookup name #(->Match p meta result % p))
lookup)]) [{} {}] compiled) lookup)]) [{} {}] compiled)
data (impl/fast-map data) data (impl/fast-map data)
lookup (impl/fast-map lookup)] lookup (impl/fast-map lookup)]
@ -244,10 +244,10 @@
| -------------|-------------| | -------------|-------------|
| `:path` | Base-path for routes (default `\"\"`) | `:path` | Base-path for routes (default `\"\"`)
| `:routes` | Initial resolved 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`) | `: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` | `: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!`) | `: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" | `:router` | Function of `routes opts => router` to override the actual router implementation"
([data] ([data]

View file

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

View file

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

View file

@ -5,7 +5,8 @@
[reitit.impl :as impl])) [reitit.impl :as impl]))
(def http-methods #{:get :head :patch :delete :options :post :put}) (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] (defn- group-keys [meta]
(reduce-kv (reduce-kv
@ -19,10 +20,27 @@
(fn (fn
([request] ([request]
(if-let [match (reitit/match-by-path router (:uri 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] ([request respond raise]
(if-let [match (reitit/match-by-path router (:uri request))] (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})) {::router router}))
(defn get-router [handler] (defn get-router [handler]
@ -41,25 +59,27 @@
(defn compile-handler [[path meta] opts] (defn compile-handler [[path meta] opts]
(let [[top childs] (group-keys meta)] (let [[top childs] (group-keys meta)]
(if-not (seq childs) (if-not (seq childs)
(middleware/compile-handler [path meta] opts) (map->Methods
(let [handlers (map->MethodHandlers {: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 (reduce-kv
#(assoc %1 %2 (middleware/compile-handler (fn [acc method meta]
[path (meta-merge top %3)] opts %2)) (let [meta (meta-merge top meta)
{} childs)) handler (middleware/compile-handler [path meta] opts method)]
default-handler (if (:handler top) (middleware/compile-handler [path meta] opts))] (assoc acc method (map->Endpoint
(fn {:handler handler
([request] :meta meta}))))
(if-let [handler (or ((:request-method request) handlers) default-handler)] (map->Methods
(handler request))) {:any (map->Endpoint
([request respond raise] {:handler (if (:handler top) (middleware/compile-handler [path meta] opts))
(if-let [handler (or ((:request-method request) handlers) default-handler)] :meta top})})
(handler request respond raise)))))))) childs)))))
(defn router (defn router
([data] ([data]
(router data nil)) (router data nil))
([data opts] ([data opts]
(let [opts (meta-merge {:coerce coerce-handler (let [opts (meta-merge {:coerce coerce-handler, :compile compile-handler} opts)]
:compile compile-handler} opts)]
(reitit/router data 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)))) (reitit/routes router))))
(testing "route match contains compiled handler" (testing "route match contains compiled handler"
(is (= 2 @compile-times)) (is (= 2 @compile-times))
(let [{:keys [handler]} (reitit/match-by-path router "/api/pong")] (let [{:keys [result]} (reitit/match-by-path router "/api/pong")]
(is handler) (is result)
(is (= "/api/pong" (handler))) (is (= "/api/pong" (result)))
(is (= 2 @compile-times)))))) (is (= 2 @compile-times))))))
(testing "default compile" (testing "default compile"
(let [router (reitit/router ["/ping" (constantly "ok")])] (let [router (reitit/router ["/ping" (constantly "ok")])]
(let [{:keys [handler]} (reitit/match-by-path router "/ping")] (let [{:keys [result]} (reitit/match-by-path router "/ping")]
(is handler) (is result)
(is (= "ok" (handler))))))) (is (= "ok" (result)))))))
(testing "custom router" (testing "custom router"
(let [router (reitit/router ["/ping"] {:router (fn [_ _] (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] ([request respond raise]
(respond (handler request)))) (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 (deftest ring-router-test
(testing "all paths should have a handler" (testing "all paths should have a handler"