Metosin

Reitit, Data-Driven Routing with Clojure(Script)

As many things we do, it started as a small experiment but ended up as a standalone library. We are happy to introduce Reitit, a new fast data-driven router for Clojure(Script)!

Another routing library?

There are already many great routing libraries for Clojure(Script), and we at Metosin have used most of them: for frontend - Secretary, Silk and Bidi and for backend Compojure, Bidi and some Pedestal. Still, none of the existing solutions felt perfect. There should be a routing library that:

  • works with both Clojure & ClojureScript
  • has simple data-driven syntax (for humans)
  • supports first-class route data
  • supports bi-directional routing
  • has pluggable parameter coercion
  • supports middleware &/ interceptors
  • handles route conflicts
  • is modular and extendable
  • is fast

Reitit does all of those and more.

Simplest thing that works

To use just the routing core, we need a dependency to:

[metosin/reitit-core "0.1.0"]

It provides the basic routing abstractions and implementation for Clojure(Script). It works best with clojure.spec but the only mandatory dependency is meta-merge.

(require '[reitit.core :as r])

(def router
  (r/router
    [["/api/ping" ::ping]
     ["/api/orders/:id" ::order]]))

(r/match-by-path router "/api/ping")
; #Match{:template "/api/ping"
;        :data {:name ::ping}
;        :result nil
;        :path-params {}
;        :path "/api/ping"}

(r/match-by-name router ::order {:id 2})
; #Match{:template "/api/orders/:id",
;        :data {:name ::order},
;        :result nil,
;        :path-params {:id 2},
;        :path "/api/orders/2"}

The route syntax and basic usage is described in detail in the docs.

Different routers

The core abstraction in reitit is the Router Protocol:

(defprotocol Router
  (router-name [this])
  (routes [this])
  (options [this])
  (route-names [this])
  (match-by-path [this path])
  (match-by-name [this name] [this name path-params]))

Reitit ships with multiple implementations for a Router and by default, reitit.core/router chooses the best one after analysing the route tree. The implementations include:

  • :linear-router - naive, but works with all route trees
  • :segment-router - prefix-tree-based, fast for wildcards routes
  • :lookup-router - hash-lookup, fast, only for non-wildcard routes
  • :single-static-path-router - super fast, for one static route
  • :mixed-router - for route trees with both static & wildcard routes
(r/router-name
  (r/router
    [["/ping" ::ping]
     ["/api/:users" ::users]]))
; :mixed-router

(r/router-name
  (r/router
    [["/ping" ::ping]
     ["/api/:users" ::users]]
    {:router r/linear-router}))
; :linear-router

The original routing code was ported from Pedestal and thanks to that, it's really fast. Below are the average route resolution times of a mid-size example REST route tree. As all perf tests, the tests may contain errors. Please let us know.

Route data

The key feature of Reitit is first-class route data. Any map-like data can be attached to routes, either directly to leaves or to branches. When router is created, paths are flattened and route data is expanded and meta-merged into leaves. Data can optionally be validated and compiled.

The following two routers are effectively the same:

(def nested-router
  (r/router
    ["/api" {:interceptors [api-interceptor]}
     ["/ping" {:handler ping-handler}]
     ["/admin" {:roles #{:admin}}
      ["/users" {:handler user-handler}]
      ["/db" {:interceptors [db-interceptor]
              :roles ^:replace #{:db-admin}
              :handler db-handler}]]]))
(def flat-router
  (r/router
    [["/api/ping" {:interceptors [api-interceptor]
                   :handler ping-handler}]
     ["/api/admin/users" {:interceptors [api-interceptor]
                          :roles #{:admin}
                          :handler user-handler}]
     ["/api/admin/db" {:interceptors [api-interceptor db-interceptor]
                       :roles #{:db-admin}
                       :handler db-handler}]]))

Full route data can be queried via r/routes and is available per route in a successful match:

(r/match-by-path nested-router "/api/admin/users")
; #Match{:template "/api/admin/users",
;        :data {:interceptors [api-interceptor],
;               :roles #{:admin},
;               :handler user-handler},
;        :result user-handler
;        :path-params {},
;        :path "/api/admin/users"}

Interpretation of the data is left to the client application.

Validation

As route data can be everything, it's easy to forget or misspell keys. We can use clojure.spec to validate both the route syntax and the route data. Route data specs can be defined via router options. Routing components like Middleware and Interceptors can also contribute to specs, with a scope of only the routes these components are mounted to.

An example router requiring :roles for all routes, using Expound:

(require '[clojure.spec.alpha :as s])
(require '[expound.alpha :as e]) ; [expound "0.5.0"]
(require '[reitit.spec :as rs])

(s/def ::role #{:admin :manager})
(s/def ::roles (s/coll-of ::role :into #{}))

(defn router [routes]
  (r/router
    routes
    {:spec (s/merge (s/keys :req-un [::roles]) ::rs/default-data)
     ::rs/explain e/expound-str
     :validate rs/validate-spec!}))

(router
  ["/api" {:handler identity}])
; CompilerException clojure.lang.ExceptionInfo: Invalid route data:
;
; -- On route -----------------------
;
; "/api"
;
; -- Spec failed --------------------
;
; {:handler identity}
;
; should contain key: `:roles`
;
; |    key |                                   spec |
; |-------------+-----------------------------------|
; | :roles | (coll-of #{:admin :manager} :into #{}) |

(router
  ["/api" {:handler identity
           :roles #{:adminz}}])
; CompilerException clojure.lang.ExceptionInfo: Invalid route data:
;
; -- On route -----------------------
;
; "/api"
;
; -- Spec failed --------------------
;
; {:handler ..., :roles #{:adminz}}
;                         ^^^^^^^
;
; should be one of: `:admin`,`:manager`

By design, Spec can't report errors on extra keys, but we are working on integrating Figwheel-grade error reporting to spec-tools and with that, to Reitit.

Coercion

Coercion is the process of transforming and validating input and output parameters between external formats and clojure data. Reitit ships with full-blown pluggable http-coercion (thanks to work in compojure-api), supporting clojure.spec, data-specs and Plumatic Schema as separate coercion modules.

Reitit defines routing and coercion as two separate processes: if coercion is enabled for a route, a successful routing Match will contain enough data for the client application to apply the coercion. There is a full coercion guide, but here's a simple example on using data-specs to coerce the path-parameters.

(require '[reitit.coercion :as coercion])
(require '[reitit.coercion.spec]) ; [metosin/reitit-spec "0.1.0"]

(def router
  (r/router
    ["/:company/users/:user-id"
     {:name ::user-view
      :coercion reitit.coercion.spec/coercion
      :parameters {:path {:company string?
                          :user-id int?}}}]
    {:compile coercion/compile-request-coercers}))

(defn match-by-path-and-coerce! [path]
  (if-let [match (r/match-by-path router path)]
    (assoc match :parameters (coercion/coerce! match))))

(match-by-path-and-coerce! "/metosin/users/123")
; #Match{:template "/:company/users/:user-id",
;        :data {:name :user/user-view,
;               :coercion <<:spec>>
;               :parameters {:path {:company string?,
;                                   :user-id int?}}},
;        :result {:path #object[reitit.coercion$request_coercer$]},
;        :path-params {:company "metosin", :user-id "123"},
;        :parameters {:path {:company "metosin", :user-id 123}}
;        :path "/metosin/users/123"}

(match-by-path-and-coerce! "/metosin/users/ikitommi")
; => ExceptionInfo Request coercion failed...

Route conflicts

Last but not least, Reitit does full route conflict resolution when router is created. This is important because in real life route trees are usually merged from multiple sources and we should fail fast if there are routes masking each other.

(r/router
  [["/ping"]
   ["/:user-id/orders"]
   ["/bulk/:bulk-id"]
   ["/public/*path"]
   ["/:version/status"]])
; CompilerException clojure.lang.ExceptionInfo: Router contains conflicting routes:
;
;    /:user-id/orders
; -> /public/*path
; -> /bulk/:bulk-id
;
;    /bulk/:bulk-id
; -> /:version/status
;
;    /public/*path
; -> /:version/status
;

As mostly everything, conflict resolutions can be configured via router options.

That's all?

No. This was just a quick walkthrough of the core features. In the second part I'll walk through the optional reitit-ring module, including data-driven middleware, middleware compilation, partial route specs and full http-coercion.

We are big fans of the Pedestal and are incubating a separate full interceptor module as an alternative to the reitit-ring. Goal is to distill common abstractions for both the frontend and the backend. Still work to do here.

Swagger and OpenAPI will be supported as a separate modules. Requires still some work to get working with ClojureScript.

For the browser, we have been using Reitit successfully with Keechma-style stateful routing controllers together with Re-frame. These will too be published as small helper module(s).

Final words

Even though Reitit is a new library, the ideas have evolved over many years and the code is originally based on more proven libraries so the core api should be quite stable. We are really exited about it.

Big thanks to existing Clojure(Script) routing and web libraries for ideas and showing the way. These including Bide, Bidi, Compojure, Compojure-api, Keechma, Kekkonen, Pedestal, Ring-Swagger and Yada. Also to HttpRouter for some perf stuff.

This is just a first release and there is still a lot of things to do, so comments, ideas and contributions are welcome! Roadmap is mostly written in issues, spanning multiple repos.

For discussions, there is #reitit channel in Clojurians slack.

Pointers to get started:

Happy routing.