diff --git a/README.md b/README.md index f79aac63..37a87549 100644 --- a/README.md +++ b/README.md @@ -3,6 +3,7 @@ Snappy data-driven router for Clojure(Script). * Simple data-driven route syntax +* First-class route meta-data * Generic, not tied to HTTP * Extendable * Fast @@ -11,49 +12,163 @@ Snappy data-driven router for Clojure(Script). [![Clojars Project](http://clojars.org/metosin/reitit/latest-version.svg)](http://clojars.org/metosin/reitit) -## Usage +## Route Syntax -Named routes (example from [bide](https://github.com/funcool/bide#why-another-routing-library)). +Routes are defined as vectors, which String path as the first element, then optional meta-data (non-vector) and optional child routes. Routes can be wrapped in vectors. + +Simple route: + +```clj +["/ping"] +``` + +Two routes: + +```clj +[["/ping] + ["/pong]] +``` + +Routes with meta-data: + +```clj +[["/ping ::ping] + ["/pong {:name ::pong}]] +``` + +Nested routes with meta-data: + +```clj +["/api" + ["/admin" {:middleware [::admin]} + ["/user" ::user] + ["/db" ::db] + ["/ping" ::ping]] +``` + +Previous example flattened: + +```clj +[["/api/admin/user" {:middleware [::admin], :name ::user} + ["/api/admin/db" {:middleware [::admin], :name ::db} + ["/api/ping" ::ping]] +``` + +## Routers + +For actual routing, we need to create a `Router`. Reitit ships with 2 different router implementations: `LinearRouter` and `LookupRouter`, both 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 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: ```clj (require '[reitit.core :as reitit]) (def router (reitit/router - [["/auth/login" :auth/login] - ["/auth/recovery/token/:token" :auth/recovery] - ["/workspace/:project-uuid/:page-uuid" :workspace/page]])) + [["/api" + ["/ping" ::ping] + ["/user/:id ::user]])) -(reitit/match-route router "/workspace/1/2") -; {:name :workspace/page -; :route-params {:project-uuid "1", :page-uuid "2"}} +(class router) +; reitit.core.LinearRouter ``` -Nested routes with [meta-merged](https://github.com/weavejester/meta-merge) meta-data: +Get the expanded routes: ```clj -(def handler (constantly "ok")) +(reitit/routes router) +; [["/api/ping" {:name :user/ping}] +; ["/api/user/:id" {:name :user/user}]] +``` +Path-based routing: + +```clj +(reitit/match-by-path router "/hello") +; nil + +(reitit/match-by-path router "/api/user/1") +; #Match{:template "/api/user/:id" +; :meta {:name :user/user} +; :path "/api/user/1" +; :params {:id "1"}} +``` + +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} +; :path "/api/user/1" +; :params {:id "1"}} +``` + +## Route meta-data + +Routes can have arbitrary meta-data. For nested routes, the meta-data is accumulated from root towards leafs using [meta-merge](https://github.com/weavejester/meta-merge). + +A router based on nested route tree: + +```clj (def ring-router (reitit/router - ["/api" {:middleware [:api]} - ["/ping" handler] - ["/public/*path" handler] - ["/user/:id" {:parameters {:id String} - :handler handler}] - ["/admin" {:middleware [:admin] :roles #{:admin}} - ["/root" {:roles ^:replace #{:root} - :handler handler}] - ["/db" {:middleware [:db] - :handler handler}]]])) - -(reitit/match-route ring-router "/api/admin/db") -; {:middleware [:api :admin :db] -; :roles #{:admin} -; :handler #object[...] -; :route-params {}} + ["/api" {:middleware [:api-mw]} + ["/ping" ::ping] + ["/public/*path" ::resources] + ["/user/:id" {:name ::get-user + :parameters {:id String}} + ["/orders" ::user-orders]] + ["/admin" {:middleware [:admin-mw] + :roles #{:admin}} + ["/root" {:name ::root + :roles ^:replace #{:root}}] + ["/db" {:name ::db + :middleware [:db-mw]}]]])) ``` +Expanded and merged route tree: + +```clj +(reitit/routes ring-router) +; [["/api/ping" {:name :user/ping +; :middleware [:api-mw]}] +; ["/api/public/*path" {:name :user/resources +; :middleware [:api-mw]}] +; ["/api/user/:id/orders" {:name :user/user-orders +; :middleware [:api-mw] +; :parameters {:id String}}] +; ["/api/admin/root" {:name :user/root +; :middleware [:api-mw :admin-mw] +; :roles #{:root}}] +; ["/api/admin/db" {:name :user/db +; :middleware [:api-mw :admin-mw :db-mw] +; :roles #{:admin}}]] +``` + +Path-based routing: + +```clj +(reitit/match-by-path ring-router "/api/admin/root") +; #Match{:template "/api/admin/root" +; :meta {:name :user/root +; :middleware [:api-mw :admin-mw] +; :roles #{:root}} +; :path "/api/admin/root" +; :params {}} +``` + +Route meta-data is just data and the actual interpretation is left to the application. `Router` will get more options in the future to do things like [`clojure.spec`](https://clojure.org/about/spec) validation and custom route compilation (into into [Ring](https://github.com/ring-clojure/ring)-handlers or [Pedestal](pedestal.io)-style interceptors). See [Open issues](https://github.com/metosin/reitit/issues/). + ## Special thanks To all Clojure(Script) routing libs out there, expecially to diff --git a/perf-test/clj/reitit/perf_test.clj b/perf-test/clj/reitit/perf_test.clj index 12abe959..0ae01ce1 100644 --- a/perf-test/clj/reitit/perf_test.clj +++ b/perf-test/clj/reitit/perf_test.clj @@ -3,12 +3,14 @@ [reitit.core :as reitit] [bidi.bidi :as bidi] - [compojure.api.core :refer [routes GET]] + [compojure.api.sweet :refer [api routes GET]] + [compojure.api.routes :as routes] [ataraxy.core :as ataraxy] [io.pedestal.http.route.definition.table :as table] [io.pedestal.http.route.map-tree :as map-tree] - [io.pedestal.http.route.router :as pedestal])) + [io.pedestal.http.route.router :as pedestal] + [io.pedestal.http.route :as route])) ;; ;; start repl with `lein perf repl` @@ -40,9 +42,12 @@ (def compojure-api-routes (routes - (GET "/auth/login" [] (constantly "")) - (GET "/auth/recovery/token/:token" [] (constantly "")) - (GET "/workspace/:project/:page" [] (constantly "")))) + (GET "/auth/login" [] :name :auth/login (constantly "")) + (GET "/auth/recovery/token/:token" [] :name :auth/recovery (constantly "")) + (GET "/workspace/:project/:page" [] :name :workspace/page (constantly "")))) + +(def compojure-api-request + {:compojure.api.request/lookup (routes/route-lookup-table (routes/get-routes (api compojure-api-routes)))}) (def ataraxy-routes (ataraxy/compile @@ -51,11 +56,16 @@ ["/workspace/" project "/" token] [:workspace/page project token]})) (def pedestal-routes + (table/table-routes + [["/auth/login" :get (constantly "") :route-name :auth/login] + ["/auth/recovery/token/:token" :get (constantly "") :route-name :auth/recovery] + ["/workspace/:project/:page" :get (constantly "") :route-name :workspace/page]])) + +(def pedestal-router (map-tree/router - (table/table-routes - [["/auth/login" :get (constantly "") :route-name :auth/login] - ["/auth/recovery/token/:token" :get (constantly "") :route-name :auth/recovery] - ["/workspace/:project/:page" :get (constantly "") :route-name :workspace/page]]))) + pedestal-routes)) + +(def pedestal-url-for (route/url-for-routes pedestal-routes)) (def reitit-routes (reitit/router @@ -98,10 +108,45 @@ ;; 1.0µs (-94%) ;; 770ns (-95%, -23%) (title "reitit") - (let [call #(reitit/match reitit-routes "/workspace/1/1")] + (let [call #(reitit/match-by-path reitit-routes "/workspace/1/1")] + (assert (call)) + (cc/quick-bench + (call)))) + +(defn reverse-routing-test [] + + (suite "reverse routing") + + ;; 2.2µs (-56%) + (title "bidi") + (let [call #(bidi/path-for bidi-routes :workspace/page :project "1" :page "1")] + (assert (= "/workspace/1/1" (call))) + (cc/quick-bench + (call))) + + (title "ataraxy doesn't support reverse routing :(") + + ;; 3.8µs (-25%) + (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 + (title "compojure-api") + (let [call #(routes/path-for* :workspace/page compojure-api-request {:project "1", :page "1"})] + (assert (= "/workspace/1/1" (call))) + (cc/quick-bench + (call))) + + ;; 850ns (-83%) + (title "reitit") + (let [call #(reitit/match-by-name reitit-routes :workspace/page {:project "1", :page "1"})] (assert (call)) (cc/quick-bench (call)))) (comment - (routing-test)) + (routing-test) + (reverse-routing-test)) diff --git a/src/reitit/core.cljc b/src/reitit/core.cljc index 1be4a18e..6e2a05f6 100644 --- a/src/reitit/core.cljc +++ b/src/reitit/core.cljc @@ -60,8 +60,8 @@ (defprotocol Routing (routes [this]) - (match [this path]) - (by-name [this name] [this name parameters])) + (match-by-path [this path]) + (match-by-name [this name] [this name parameters])) (defrecord Match [template meta path params]) @@ -69,15 +69,15 @@ Routing (routes [_] routes) - (match [_ path] + (match-by-path [_ path] (reduce (fn [acc ^Route route] (if-let [params ((:matcher route) path)] (reduced (->Match (:path route) (:meta route) path params)))) nil data)) - (by-name [_ name] + (match-by-name [_ name] ((lookup name) nil)) - (by-name [_ name params] + (match-by-name [_ name params] ((lookup name) params))) (defn linear-router [routes] @@ -95,14 +95,20 @@ Routing (routes [_] routes) - (match [_ path] + (match-by-path [_ path] (data path)) - (by-name [_ name] + (match-by-name [_ name] ((lookup name) nil)) - (by-name [_ name params] + (match-by-name [_ name params] ((lookup name) params))) (defn lookup-router [routes] + (when-let [route (some impl/contains-wilds? (map first routes))] + (throw + (ex-info + (str "can't create LookupRouter with wildcard routes: " route) + {:route route + :routes routes}))) (->LookupRouter routes (->> (for [[p meta] routes] diff --git a/test/cljc/reitit/core_test.cljc b/test/cljc/reitit/core_test.cljc index e8ca8d02..8399de67 100644 --- a/test/cljc/reitit/core_test.cljc +++ b/test/cljc/reitit/core_test.cljc @@ -17,17 +17,18 @@ :meta {:name ::beer} :path "/api/ipa/large" :params {:size "large"}}) - (reitit/match router "/api/ipa/large"))) + (reitit/match-by-path router "/api/ipa/large"))) (is (= (reitit/map->Match {:template "/api/ipa/:size" :meta {:name ::beer} :path "/api/ipa/large" :params {:size "large"}}) - (reitit/by-name router ::beer {:size "large"}))) - (is (thrown-with-msg? - ExceptionInfo - #"^missing path-params for route '/api/ipa/:size': \#\{:size\}$" - (reitit/by-name router ::beer))))) + (reitit/match-by-name router ::beer {:size "large"}))) + (testing "name-based routing at runtime for missing parameters" + (is (thrown-with-msg? + ExceptionInfo + #"^missing path-params for route '/api/ipa/:size': \#\{:size\}$" + (reitit/match-by-name router ::beer)))))) (testing "lookup router" (let [router (reitit/router ["/api" ["/ipa" ["/large" ::beer]]])] @@ -39,13 +40,20 @@ :meta {:name ::beer} :path "/api/ipa/large" :params {}}) - (reitit/match router "/api/ipa/large"))) + (reitit/match-by-path router "/api/ipa/large"))) (is (= (reitit/map->Match {:template "/api/ipa/large" :meta {:name ::beer} :path "/api/ipa/large" :params {:size "large"}}) - (reitit/by-name router ::beer {:size "large"}))))) + (reitit/match-by-name router ::beer {:size "large"}))) + (testing "can't be created with wildcard routes" + (is (thrown-with-msg? + ExceptionInfo + #"can't create LookupRouter with wildcard routes" + (reitit/lookup-router + (reitit/resolve-routes + ["/api/:version/ping"] {}))))))) (testing "bide sample" (let [routes [["/auth/login" :auth/login] @@ -78,5 +86,5 @@ :meta {:mw [:api], :parameters {:id String, :sub-id String}} :path "/api/user/1/2" :params {:id "1", :sub-id "2"}}) - (reitit/match router "/api/user/1/2")))))) + (reitit/match-by-path router "/api/user/1/2"))))))