diff --git a/README.md b/README.md index accf4970..f66dfafe 100644 --- a/README.md +++ b/README.md @@ -25,15 +25,22 @@ Simple route: Two routes: ```clj -[["/ping] - ["/pong]] +[["/ping"] + ["/pong"]] ``` Routes with meta-data: ```clj -[["/ping ::ping] - ["/pong {:name ::pong}]] +[["/ping" ::ping] + ["/pong" {:name ::pong}]] +``` + +Routes with path and catch-all parameters: + +```clj +[["/users/:user-id"] + ["/public/*path"]] ``` Nested routes with meta-data: @@ -60,7 +67,7 @@ For actual routing, we need to create a `Router`. Reitit ships with 2 different `Router` is created with `reitit.core/router`, which takes routes and optionally an options map as arguments. The route-tree gets expanded, optionally coerced and compiled to support both fast path- and name-based lookups. -Create a router: +Creating a router: ```clj (require '[reitit.core :as reitit]) @@ -70,12 +77,16 @@ Create a router: [["/api" ["/ping" ::ping] ["/user/:id" ::user]])) +``` +`LinearRouter` is created (as there are wildcard): + +```clj (class router) ; reitit.core.LinearRouter ``` -Get the expanded routes: +The expanded routes: ```clj (reitit/routes router) @@ -102,11 +113,7 @@ Name-based (reverse) routing: ```clj (reitit/match-by-name router ::user) ; ExceptionInfo missing path-params for route '/api/user/:id': #{:id} -``` -Oh, that didn't work, retry: - -```clj (reitit/match-by-name router ::user {:id "1"}) ; #Match{:template "/api/user/:id" ; :meta {:name :user/user} @@ -122,57 +129,192 @@ Routes can have arbitrary meta-data. For nested routes, the meta-data is accumul A router based on nested route tree: ```clj -(def ring-router +(def router (reitit/router - ["/api" {:middleware [:api-mw]} + ["/api" {:interceptors [::api]} ["/ping" ::ping] ["/public/*path" ::resources] ["/user/:id" {:name ::get-user :parameters {:id String}} ["/orders" ::user-orders]] - ["/admin" {:middleware [:admin-mw] + ["/admin" {:interceptors [::admin] :roles #{:admin}} ["/root" {:name ::root :roles ^:replace #{:root}}] ["/db" {:name ::db - :middleware [:db-mw]}]]])) + :interceptors [::db]}]]])) ``` -Expanded and merged route tree: +Resolved route tree: ```clj -(reitit/routes ring-router) +(reitit/routes router) ; [["/api/ping" {:name :user/ping -; :middleware [:api-mw]}] +; :interceptors [::api]}] ; ["/api/public/*path" {:name :user/resources -; :middleware [:api-mw]}] +; :interceptors [::api]}] ; ["/api/user/:id/orders" {:name :user/user-orders -; :middleware [:api-mw] +; :interceptors [::api] ; :parameters {:id String}}] ; ["/api/admin/root" {:name :user/root -; :middleware [:api-mw :admin-mw] +; :interceptors [::api ::admin] ; :roles #{:root}}] ; ["/api/admin/db" {:name :user/db -; :middleware [:api-mw :admin-mw :db-mw] +; :interceptors [::api ::admin ::db] ; :roles #{:admin}}]] ``` Path-based routing: ```clj -(reitit/match-by-path ring-router "/api/admin/root") +(reitit/match-by-path router "/api/admin/root") ; #Match{:template "/api/admin/root" ; :meta {:name :user/root -; :middleware [:api-mw :admin-mw] +; :interceptors [::api ::admin] ; :roles #{:root}} ; :path "/api/admin/root" ; :handler nil ; :params {}} ``` -Route meta-data is just data and the actual interpretation is left to the application. Custom coercion and route compilation can be defined via router options enabling things like [`clojure.spec`](https://clojure.org/about/spec) validation for route-meta data and pre-compiled route handlers ([Ring](https://github.com/ring-clojure/ring)-handlers or [Pedestal](pedestal.io)-style interceptors). +On match, route meta-data is returned and can interpreted by the application. -**TODO**: examples / implementations of different kind of routers. See [Open issues](https://github.com/metosin/reitit/issues/). +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. + +## Ring + +Simple [Ring](https://github.com/ring-clojure/ring)-based routing app: + +```clj +(require '[reitit.ring :as ring]) + +(defn handler [_] + {:status 200, :body "ok"}) + +(def app + (ring/ring-handler + (ring/router + ["/ping" handler]))) +``` + +It's backed by a `LookupRouter` (no wildcards!) + +```clj +(-> app (ring/get-router) class) +; reitit.core.LookupRouter +``` + +The expanded routes: + +```clj +(-> app (ring/get-router) (reitit/routes)) +; [["/ping" {:handler #object[...]}]] +``` + +Applying the handler: + +```clj +(app {:request-method :get, :uri "/favicon.ico"}) +; nil + +(app {:request-method :get, :uri "/ping"}) +; {:status 200, :body "ok"} +``` + +Routing based on `:request-method`: + +```clj +(def app + (ring/ring-handler + (ring/router + ["/ping" {:get handler + :post handler}]))) + +(app {:request-method :get, :uri "/ping"}) +; {:status 200, :body "ok"} + +(app {:request-method :put, :uri "/ping"}) +; nil +``` + +Define some middleware and a new handler: + +```clj +(defn wrap [handler id] + (fn [request] + (handler (update request ::acc (fnil conj []) id)))) + +(defn wrap-api [handler] + (wrap handler :api)) + +(defn handler [{:keys [::acc]}] + {:status 200, :body (conj acc :handler)}) +``` + +App with nested middleware: + +```clj +(def app + (ring/ring-handler + (ring/router + ["/api" {:middleware [wrap-api]} + ["/ping" handler] + ["/admin" {:middleware [[wrap :admin]]} + ["/db" {:middleware [[wrap :db]] + :delete {:middleware [#(wrap % :delete)] + :handler handler}}]]]))) +``` + +Middleware is applied correctly: + +```clj +(app {:request-method :delete, :uri "/api/ping"}) +; {: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]} +``` + +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. + +## Validating route-tree + +**TODO** + +## Merging route-trees + +**TODO** + +## Schema, Spec, Swagger & Openapi + +**TODO** + +## Interceptors + +**TODO** + +## Custom extensions + +**TODO** + +## Configuring Routers + +Routers can be configured via options to do things like custom coercion and compilatin of meta-data. These can be used to do things like [`clojure.spec`](https://clojure.org/about/spec) validation of meta-data and fast, compiled [Ring](https://github.com/ring-clojure/ring/wiki/Concepts) or [Pedestal](http://pedestal.io/) -style handlers. + +The following options are available for the `Router`: + + | key | description | + | -----------|-------------| + | `:path` | Base-path for routes (default `""`) + | `:routes` | Initial resolved routes (default `[]`) + | `:meta` | Initial expanded route-meta vector (default `[]`) + | `:expand` | Function of `arg => meta` to expand route arg to route meta-data (default `reitit.core/expand`) + | `:coerce` | Function of `[path meta] opts => [path meta]` to coerce resolved route, can throw or return `nil` + | `:compile` | Function of `[path meta] opts => handler` to compile a route handler ## Special thanks diff --git a/src/reitit/core.cljc b/src/reitit/core.cljc index d0056ba2..68fa3040 100644 --- a/src/reitit/core.cljc +++ b/src/reitit/core.cljc @@ -142,14 +142,14 @@ If routes contain wildcards, a [[LinearRouter]] is used, otherwise a [[LookupRouter]]. The following options are available: - | keys | description | + | key | description | | -----------|-------------| | `:path` | Base-path for routes (default `\"\"`) | `:routes` | Initial resolved routes (default `[]`) | `:meta` | Initial expanded route-meta vector (default `[]`) - | `:expand` | Function `arg => meta` to expand route arg to route meta-data (default `reitit.core/expand`) - | `:coerce` | Function `[path meta] opts => [path meta]` to coerce resolved route, can throw or return `nil` - | `:compile` | Function `[path meta] opts => handler` to compile a route handler" + | `:expand` | Function of `arg => meta` to expand route arg to route meta-data (default `reitit.core/expand`) + | `:coerce` | Function of `[path meta] opts => [path meta]` to coerce resolved route, can throw or return `nil` + | `:compile` | Function of `[path meta] opts => handler` to compile a route handler" ([data] (router data {})) ([data opts] diff --git a/src/reitit/middleware.cljc b/src/reitit/middleware.cljc new file mode 100644 index 00000000..71af5d15 --- /dev/null +++ b/src/reitit/middleware.cljc @@ -0,0 +1,44 @@ +(ns reitit.middleware + (:require [reitit.core :as reitit])) + +(defprotocol ExpandMiddleware + (expand-middleware [this])) + +(extend-protocol ExpandMiddleware + + #?(:clj clojure.lang.APersistentVector + :cljs cljs.core.PersistentVector) + (expand-middleware [[f & args]] + (fn [handler] + (apply f handler args))) + + #?(:clj clojure.lang.Fn + :cljs function) + (expand-middleware [this] this) + + nil + (expand-middleware [_])) + +(defn- ensure-handler! [path meta scope] + (when-not (:handler meta) + (throw (ex-info + (str "path \"" path "\" doesn't have a :handler defined" + (if scope (str " for " scope))) + (merge {:path path, :meta meta} + (if scope {:scope scope})))))) + +(defn compose-middleware [middleware] + (->> middleware + (keep identity) + (map expand-middleware) + (apply comp identity))) + +(defn compile-handler + ([route opts] + (compile-handler route opts nil)) + ([[path {:keys [middleware handler] :as meta}] _ scope] + (ensure-handler! path meta scope) + ((compose-middleware middleware) handler))) + +(defn router [data] + (reitit/router data {:compile compile-handler})) diff --git a/src/reitit/ring.cljc b/src/reitit/ring.cljc index 681f3fad..56c98c17 100644 --- a/src/reitit/ring.cljc +++ b/src/reitit/ring.cljc @@ -1,47 +1,17 @@ (ns reitit.ring (:require [meta-merge.core :refer [meta-merge]] + [reitit.middleware :as middleware] [reitit.core :as reitit])) -(defprotocol ExpandMiddleware - (expand-middleware [this])) +(def http-methods #{:get :head :patch :delete :options :post :put}) +(defrecord MethodHandlers [get head patch delete options post put]) -(extend-protocol ExpandMiddleware - - #?(:clj clojure.lang.APersistentVector - :cljs cljs.core.PersistentVector) - (expand-middleware [[f & args]] - (fn [handler] - (apply f handler args))) - - #?(:clj clojure.lang.Fn - :cljs function) - (expand-middleware [this] this) - - nil - (expand-middleware [_])) - -(defn- ensure-handler! [path meta method] - (when-not (:handler meta) - (throw (ex-info - (str "path \"" path "\" doesn't have a :handler defined" - (if method (str " for method " method))) - {:path path, :method method, :meta meta})))) - -(defn- compose-middleware [middleware] - (->> middleware - (keep identity) - (map expand-middleware) - (apply comp identity))) - -(defn- compile-handler - ([route opts] - (compile-handler route opts nil)) - ([[path {:keys [middleware handler] :as meta}] _ method] - (ensure-handler! path meta method) - ((compose-middleware middleware) handler))) - -(defn simple-router [data] - (reitit/router data {:compile compile-handler})) +(defn- group-keys [meta] + (reduce-kv + (fn [[top childs] k v] + (if (http-methods k) + [top (assoc childs k v)] + [(assoc top k v) childs])) [{} {}] meta)) (defn ring-handler [router] (with-meta @@ -57,33 +27,24 @@ (defn get-router [handler] (some-> handler meta ::router)) -(def http-methods #{:get :head :patch :delete :options :post :put}) -(defrecord MethodHandlers [get head patch delete options post put]) - -(defn- group-keys [meta] - (reduce-kv - (fn [[top childs] k v] - (if (http-methods k) - [top (assoc childs k v)] - [(assoc top k v) childs])) [{} {}] meta)) - -(defn coerce-method-handler [[path meta] {:keys [expand]}] +(defn coerce-handler [[path meta] {:keys [expand]}] [path (reduce (fn [acc method] (if (contains? acc method) (update acc method expand) acc)) meta http-methods)]) -(defn compile-method-handler [[path meta] opts] +(defn compile-handler [[path meta] opts] (let [[top childs] (group-keys meta)] (if-not (seq childs) - (compile-handler [path meta] opts) + (middleware/compile-handler [path meta] opts) (let [handlers (map->MethodHandlers (reduce-kv - #(assoc %1 %2 (compile-handler [path (meta-merge top %3)] opts %2)) + #(assoc %1 %2 (middleware/compile-handler + [path (meta-merge top %3)] opts %2)) {} childs)) - default-handler (if (:handler top) (compile-handler [path meta] opts)) - resolved-handler (fn [method] (or (method handlers) default-handler))] + default-handler (if (:handler top) (middleware/compile-handler [path meta] opts)) + resolved-handler #(or (% handlers) default-handler)] (fn ([request] (if-let [handler (resolved-handler (:request-method request))] @@ -92,6 +53,6 @@ (if-let [handler (resolved-handler (:request-method request))] (handler request respond raise)))))))) -(defn method-router [data] - (reitit/router data {:coerce coerce-method-handler - :compile compile-method-handler})) +(defn router [data] + (reitit/router data {:coerce coerce-handler + :compile compile-handler})) diff --git a/test/cljc/reitit/ring_test.cljc b/test/cljc/reitit/ring_test.cljc index 4a7a1783..12dddc68 100644 --- a/test/cljc/reitit/ring_test.cljc +++ b/test/cljc/reitit/ring_test.cljc @@ -1,5 +1,6 @@ (ns reitit.ring-test (:require [clojure.test :refer [deftest testing is]] + [reitit.middleware :as middleware] [reitit.ring :as ring]) #?(:clj (:import (clojure.lang ExceptionInfo)))) @@ -23,102 +24,101 @@ ([request respond raise] (respond (handler request)))) -(deftest ring-test +(deftest middleware-router-test - (testing "simple-router" + (testing "all paths should have a handler" + (is (thrown-with-msg? + ExceptionInfo + #"path \"/ping\" doesn't have a :handler defined" + (middleware/router ["/ping"])))) - (testing "all paths should have a handler" - (is (thrown-with-msg? - ExceptionInfo - #"path \"/ping\" doesn't have a :handler defined" - (ring/simple-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 "ring-handler" - (let [api-mw #(mw % :api) - router (ring/simple-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 "router can be extracted" - (is (= router (ring/get-router app)))) + (testing "not found" + (is (= nil (app {:uri "/favicon.ico"})))) - (testing "not found" - (is (= nil (app {:uri "/favicon.ico"})))) + (testing "normal handler" + (is (= {:status 200, :body [:ok]} + (app {:uri "/ping"})))) - (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 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 "with nested middleware" + (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]} - (app {:uri "/api/admin/ping"})))) + @result))))))) - (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))))))) - (testing "method-router" +(deftest ring-router-test - (testing "all paths should have a handler" - (is (thrown-with-msg? - ExceptionInfo - #"path \"/ping\" doesn't have a :handler defined for method :get" - (ring/method-router ["/ping" {:get {}}])))) + (testing "all paths should have a handler" + (is (thrown-with-msg? + ExceptionInfo + #"path \"/ping\" doesn't have a :handler defined for :get" + (ring/router ["/ping" {:get {}}])))) - (testing "ring-handler" - (let [api-mw #(mw % :api) - router (ring/method-router - [["/api" {:middleware [api-mw]} - ["/all" handler] - ["/get" {:get handler}] - ["/users" {:middleware [[mw :users]] - :get handler - :post {:handler handler - :middleware [[mw :post]]} - :handler handler}]]]) - app (ring/ring-handler router)] + (testing "ring-handler" + (let [api-mw #(mw % :api) + router (ring/router + [["/api" {:middleware [api-mw]} + ["/all" handler] + ["/get" {:get handler}] + ["/users" {:middleware [[mw :users]] + :get handler + :post {:handler handler + :middleware [[mw :post]]} + :handler handler}]]]) + app (ring/ring-handler router)] - (testing "router can be extracted" - (is (= router (ring/get-router app)))) + (testing "router can be extracted" + (is (= router (ring/get-router app)))) - (testing "not found" - (is (= nil (app {:uri "/favicon.ico"})))) + (testing "not found" + (is (= nil (app {:uri "/favicon.ico"})))) - (testing "catch all handler" - (is (= {:status 200, :body [:api :ok :api]} - (app {:uri "/api/all" :request-method :get})))) + (testing "catch all handler" + (is (= {:status 200, :body [:api :ok :api]} + (app {:uri "/api/all" :request-method :get})))) - (testing "just get handler" - (is (= {:status 200, :body [:api :ok :api]} - (app {:uri "/api/get" :request-method :get}))) - (is (= nil (app {:uri "/api/get" :request-method :post})))) + (testing "just get handler" + (is (= {:status 200, :body [:api :ok :api]} + (app {:uri "/api/get" :request-method :get}))) + (is (= nil (app {:uri "/api/get" :request-method :post})))) - (testing "expanded method handler" - (is (= {:status 200, :body [:api :users :ok :users :api]} - (app {:uri "/api/users" :request-method :get})))) + (testing "expanded method handler" + (is (= {:status 200, :body [:api :users :ok :users :api]} + (app {:uri "/api/users" :request-method :get})))) - (testing "method handler with middleware" + (testing "method handler with middleware" + (is (= {:status 200, :body [:api :users :post :ok :post :users :api]} + (app {:uri "/api/users" :request-method :post})))) + + (testing "fallback handler" + (is (= {:status 200, :body [:api :users :ok :users :api]} + (app {:uri "/api/users" :request-method :put})))) + + (testing "3-arity" + (let [result (atom nil) + respond (partial reset! result), raise ::not-called] + (app {:uri "/api/users" :request-method :post} respond raise) (is (= {:status 200, :body [:api :users :post :ok :post :users :api]} - (app {:uri "/api/users" :request-method :post})))) - - (testing "fallback handler" - (is (= {:status 200, :body [:api :users :ok :users :api]} - (app {:uri "/api/users" :request-method :put})))) - - (testing "3-arity" - (let [result (atom nil) - respond (partial reset! result), raise ::not-called] - (app {:uri "/api/users" :request-method :post} respond raise) - (is (= {:status 200, :body [:api :users :post :ok :post :users :api]} - @result)))))))) + @result)))))))