From faa3c08bf0cd6364e004999d05501f42584c5e07 Mon Sep 17 00:00:00 2001 From: Tommi Reiman Date: Tue, 8 Aug 2017 15:31:00 +0300 Subject: [PATCH] Add stuff * router, partially from Pedestal * sample perf tests * kws expand to :name * fns expand to :handler --- README.md | 54 ++++++++++++++- perf-test/clj/reitit/perf_test.clj | 106 ++++++++++++++++++++++++++++ project.clj | 8 ++- src/reitit/core.cljc | 71 +++++++++++++------ src/reitit/regex.cljc | 108 +++++++++++++++++++++++++++++ test/cljc/reitit/core_test.cljc | 26 ++++--- 6 files changed, 338 insertions(+), 35 deletions(-) create mode 100644 perf-test/clj/reitit/perf_test.clj create mode 100644 src/reitit/regex.cljc diff --git a/README.md b/README.md index 99f61d07..a9953e88 100644 --- a/README.md +++ b/README.md @@ -2,16 +2,66 @@ Snappy data-driven router for Clojure(Script). +* Simple data-driven route syntax +* Generic, not tied to HTTP +* Extendable +* Fast + ## Latest version [![Clojars Project](http://clojars.org/metosin/reitit/latest-version.svg)](http://clojars.org/metosin/reitit) ## Usage -TODO +Named routes (example from [bide](https://github.com/funcool/bide#why-another-routing-library)). + +```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]])) + +(reitit/match-route router "/workspace/1/2") +; {:name :workspace/page +; :route-params {:project-uuid "1", :page-uuid "2"}} +``` + +Nested routes with meta-data: + +```clj +(def handler (constantly "ok")) + +(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 {}} +``` + +## Special thanks + +To all Clojure(Script) routing libs out there, expecially to +[Ataraxy](https://github.com/weavejester/ataraxy), [Bide](https://github.com/funcool/bide), [Bidi](https://github.com/juxt/bidi), [Compojure](https://github.com/weavejester/compojure) and +[Pedestal Route](https://github.com/pedestal/pedestal/tree/master/route), ## License -Copyright © 2016-2017 [Metosin Oy](http://www.metosin.fi) +Copyright © 2017 [Metosin Oy](http://www.metosin.fi) Distributed under the Eclipse Public License, the same as Clojure. diff --git a/perf-test/clj/reitit/perf_test.clj b/perf-test/clj/reitit/perf_test.clj new file mode 100644 index 00000000..b49c9a48 --- /dev/null +++ b/perf-test/clj/reitit/perf_test.clj @@ -0,0 +1,106 @@ +(ns reitit.perf-test + (:require [criterium.core :as cc] + [reitit.core :as reitit] + + [bidi.bidi :as bidi] + [compojure.api.core :refer [routes GET]] + [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])) + +;; +;; 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 +;; + +(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] + ["workspace/" [[[:project "/" :page] :workspace/page]]]]]) + +(def compojure-api-routes + (routes + (GET "/auth/login" [] (constantly "")) + (GET "/auth/recovery/token/:token" [] (constantly "")) + (GET "/workspace/:project/:page" [] (constantly "")))) + +(def ataraxy-routes + (ataraxy/compile + '{["/auth/login"] [:auth/login] + ["/auth/recovery/token/" token] [:auth/recovery token] + ["/workspace/" project "/" token] [:workspace/page project token]})) + +(def pedestal-routes + (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]]))) + +(def reitit-routes + (reitit/router + [["/auth/login" :auth/login] + ["/auth/recovery/token/:token" :auth/recovery] + ["/workspace/:project/:page" :workspace/page]])) + +(defn routing-test [] + + (suite "simple routing") + + ;; 15.4µs + (title "bidi") + (let [call #(bidi/match-route bidi-routes "/workspace/1/1")] + (assert (call)) + (cc/quick-bench + (call))) + + ;; 2.9µs (-81%) + (title "ataraxy") + (let [call #(ataraxy/matches ataraxy-routes {:uri "/workspace/1/1"})] + (assert (call)) + (cc/quick-bench + (call))) + + ;; 2.4µs (-84%) + (title "pedestal - map-tree => prefix-tree") + (let [call #(pedestal/find-route pedestal-routes {:path-info "/workspace/1/1" :request-method :get})] + (assert (call)) + (cc/quick-bench + (call))) + + ;; 3.8µs (-75%) + (title "compojure-api") + (let [call #(compojure-api-routes {:uri "/workspace/1/1", :request-method :get})] + (assert (call)) + (cc/quick-bench + (call))) + + ;; 1.0µs (-94%) + (title "reitit") + (let [call #(reitit/match-route reitit-routes "/workspace/1/1")] + (assert (call)) + (cc/quick-bench + (call)))) + +(comment + (routing-test)) diff --git a/project.clj b/project.clj index 26382202..97dbaf25 100644 --- a/project.clj +++ b/project.clj @@ -26,7 +26,13 @@ [org.clojure/test.check "0.9.0"] [org.clojure/tools.namespace "0.2.11"] [com.gfredericks/test.chuck "0.2.7"]]} - :perf {:jvm-opts ^:replace ["-server"]}} + :perf {:jvm-opts ^:replace ["-server"] + :test-paths ["perf-test/clj"] + :dependencies [[metosin/compojure-api "2.0.0-alpha7"] + [io.pedestal/pedestal.route "0.5.2"] + [org.clojure/core.async "0.3.443"] + [ataraxy "0.4.0"] + [bidi "2.0.9"]]}} :aliases {"all" ["with-profile" "dev"] "perf" ["with-profile" "default,dev,perf"] "test-clj" ["all" "do" ["test"] ["check"]] diff --git a/src/reitit/core.cljc b/src/reitit/core.cljc index 42c66c59..d6f0e4ea 100644 --- a/src/reitit/core.cljc +++ b/src/reitit/core.cljc @@ -1,14 +1,15 @@ (ns reitit.core - (:require [meta-merge.core :refer [meta-merge]])) + (:require [meta-merge.core :refer [meta-merge]] + [reitit.regex :as regex])) -(defprotocol ExpandArgs +(defprotocol Expand (expand [this])) -(extend-protocol ExpandArgs +(extend-protocol Expand #?(:clj clojure.lang.Keyword :cljs cljs.core.Keyword) - (expand [this] {:handler this}) + (expand [this] {:name this}) #?(:clj clojure.lang.PersistentArrayMap :cljs cljs.core.PersistentArrayMap) @@ -18,25 +19,30 @@ :cljs cljs.core.PersistentHashMap) (expand [this] this) + #?(:clj clojure.lang.Fn + :cljs function) + (expand [this] {:handler this}) + nil (expand [_])) -(defn walk - ([routes] - (walk ["" []] routes)) - ([[pacc macc] routes] - (letfn [(subwalk [p m r] - (reduce #(into %1 (walk [p m] %2)) [] r))] - (if (vector? (first routes)) - (subwalk pacc macc routes) - (let [[path & [maybe-meta :as args]] routes] - (let [[meta childs] (if (vector? maybe-meta) - [{} args] - [maybe-meta (rest args)]) - macc (into macc (expand meta))] - (if (seq childs) - (subwalk (str pacc path) macc childs) - [[(str pacc path) macc]]))))))) +(defn walk [data {:keys [path meta routes expand] + :or {path "", meta [], routes [], expand expand}}] + (letfn + [(walk-many [p m r] + (reduce #(into %1 (walk-one p m %2)) [] r)) + (walk-one [pacc macc routes] + (if (vector? (first routes)) + (walk-many pacc macc routes) + (let [[path & [maybe-meta :as args]] routes] + (let [[meta childs] (if (vector? maybe-meta) + [{} args] + [maybe-meta (rest args)]) + macc (into macc (expand meta))] + (if (seq childs) + (walk-many (str pacc path) macc childs) + [[(str pacc path) macc]])))))] + (walk-one path meta data))) (defn map-meta [f routes] (mapv #(update % 1 f) routes)) @@ -47,5 +53,26 @@ (meta-merge acc {k v})) {} x)) -(defn resolve-routes [x] - (->> x (walk) (map-meta merge-meta))) +(defn resolve-routes [data opts] + (->> (walk data opts) (map-meta merge-meta))) + +(defprotocol Routing + (match-route [this path]) + (path-for [this name] [this name parameters])) + +(defrecord LinearRouter [routes] + Routing + (match-route [_ path] + (reduce + (fn [acc [p m matcher]] + (if-let [params (matcher path)] + (reduced (assoc m :route-params params)))) + nil routes))) + +(defn router + ([data] + (router data {})) + ([data opts] + (->LinearRouter + (for [[p m] (resolve-routes data opts)] + [p m (regex/matcher p)])))) diff --git a/src/reitit/regex.cljc b/src/reitit/regex.cljc new file mode 100644 index 00000000..77e11318 --- /dev/null +++ b/src/reitit/regex.cljc @@ -0,0 +1,108 @@ +; Copyright 2013 Relevance, Inc. +; Copyright 2014-2016 Cognitect, Inc. + +; The use and distribution terms for this software are covered by the +; Eclipse Public License 1.0 (http://opensource.org/licenses/eclipse-1.0) +; which can be found in the file epl-v10.html at the root of this distribution. +; +; By using this software in any fashion, you are agreeing to be bound by +; the terms of this license. +; +; You must not remove this notice, or any other, from this software. + +(ns reitit.regex + (:require [clojure.string :as str]) + (:import #?(:clj (java.util.regex Pattern)))) + +;; +;; https://github.com/pedestal/pedestal/blob/master/route/src/io/pedestal/http/route/path.clj +;; + +(defn- parse-path-token [out string] + (condp re-matches string + #"^:(.+)$" :>> (fn [[_ token]] + (let [key (keyword token)] + (-> out + (update-in [:path-parts] conj key) + (update-in [:path-params] conj key) + (assoc-in [:path-constraints key] "([^/]+)")))) + #"^\*(.+)$" :>> (fn [[_ token]] + (let [key (keyword token)] + (-> out + (update-in [:path-parts] conj key) + (update-in [:path-params] conj key) + (assoc-in [:path-constraints key] "(.*)")))) + (update-in out [:path-parts] conj string))) + +(defn- parse-path + ([pattern] (parse-path {:path-parts [] :path-params [] :path-constraints {}} pattern)) + ([accumulated-info pattern] + (if-let [m (re-matches #"/(.*)" pattern)] + (let [[_ path] m] + (reduce parse-path-token + accumulated-info + (str/split path #"/"))) + (throw (ex-info "Routes must start from the root, so they must begin with a '/'" {:pattern pattern}))))) + +;; TODO: is this correct? +(defn- re-quote [x] + #?(:clj (Pattern/quote x) + :cljs (str/replace-all x #"([.?*+^$[\\]\\\\(){}|-])" "\\$1"))) + +(defn- path-regex [{:keys [path-parts path-constraints] :as route}] + (let [[pp & pps] path-parts + path-parts (if (and (seq pps) (string? pp) (empty? pp)) pps path-parts)] + (re-pattern + (apply str + (interleave (repeat "/") + (map #(or (get path-constraints %) (re-quote %)) + path-parts)))))) + +(defn- path-matcher [route] + (let [{:keys [path-re path-params]} route] + (fn [path] + (when-let [m (re-matches path-re path)] + (zipmap path-params (rest m)))))) + +;; +;; (c) https://github.com/pedestal/pedestal/blob/master/route/src/io/pedestal/http/route/prefix_tree.clj +;; + +(defn- wild? [s] + (contains? #{\: \*} (first s))) + +(defn- partition-wilds + "Given a path-spec string, return a seq of strings with wildcards + and catch-alls separated into their own strings. Eats the forward + slash following a wildcard." + [path-spec] + (let [groups (partition-by wild? (str/split path-spec #"/")) + first-groups (butlast groups) + last-group (last groups)] + (flatten + (conj (mapv #(if (wild? (first %)) + % + (str (str/join "/" %) "/")) + first-groups) + (if (wild? (first last-group)) + last-group + (str/join "/" last-group)))))) + +(defn contains-wilds? + "Return true if the given path-spec contains any wildcard params or + catch-alls." + [path-spec] + (let [parts (partition-wilds path-spec)] + (or (> (count parts) 1) + (wild? (first parts))))) + +;; +;; Routing +;; + +(defn matcher [path] + (if (contains-wilds? path) + (as-> (parse-path path) $ + (assoc $ :path-re (path-regex $)) + (path-matcher $)) + #(if (= path %) {}))) diff --git a/test/cljc/reitit/core_test.cljc b/test/cljc/reitit/core_test.cljc index f27fb7b9..1861f5a0 100644 --- a/test/cljc/reitit/core_test.cljc +++ b/test/cljc/reitit/core_test.cljc @@ -8,23 +8,29 @@ (let [routes [["/auth/login" :auth/login] ["/auth/recovery/token/:token" :auth/recovery] ["/workspace/:project-uuid/:page-uuid" :workspace/page]] - expected [["/auth/login" {:handler :auth/login}] - ["/auth/recovery/token/:token" {:handler :auth/recovery}] - ["/workspace/:project-uuid/:page-uuid" {:handler :workspace/page}]]] - (is (= expected (reitit/resolve-routes routes))))) + expected [["/auth/login" {:name :auth/login}] + ["/auth/recovery/token/:token" {:name :auth/recovery}] + ["/workspace/:project-uuid/:page-uuid" {:name :workspace/page}]]] + (is (= expected (reitit/resolve-routes routes {}))))) (testing "ring sample" - (let [routes ["/api" {:mw [:api]} + (let [pong (constantly "ok") + routes ["/api" {:mw [:api]} ["/ping" :kikka] ["/user/:id" {:parameters {:id String}} ["/:sub-id" {:parameters {:sub-id String}}]] - ["/pong"] + ["/pong" pong] ["/admin" {:mw [:admin] :roles #{:admin}} ["/user" {:roles ^:replace #{:user}}] ["/db" {:mw [:db]}]]] - expected [["/api/ping" {:mw [:api], :handler :kikka}] + expected [["/api/ping" {:mw [:api], :name :kikka}] ["/api/user/:id/:sub-id" {:mw [:api], :parameters {:id String, :sub-id String}}] - ["/api/pong" {:mw [:api]}] + ["/api/pong" {:mw [:api], :handler pong}] ["/api/admin/user" {:mw [:api :admin], :roles #{:user}}] - ["/api/admin/db" {:mw [:api :admin :db], :roles #{:admin}}]]] - (is (= expected (reitit/resolve-routes routes)))))) + ["/api/admin/db" {:mw [:api :admin :db], :roles #{:admin}}]] + router (reitit/router routes)] + (is (= expected (reitit/resolve-routes routes {}))) + (is (= {:mw [:api], :parameters {:id String, :sub-id String} + :route-params {:id "1", :sub-id "2"}} + (reitit/match-route router "/api/user/1/2")))))) +