ronda-routing is part of the ronda library and offers a middleware-based approach to routing, allowing you to do several things:
- decouple your routing logic from your handlers,
- thus, choose the routing library most suited to your requirements,
- use conditional middlewares, middlewares that get triggered by route metadata or even configured by it,
- generate and parse references to other parts of your application from within a handler and without global state.
This isn't yet another routing/matching library. I promise.
Leiningen (via Clojars)
Read the sales pitch to see what problem is being solved.
(wrap-routing handler descriptor)
The ronda.routing/wrap-routing
middleware will use a RouteDescriptor
to decide on an
endpoint a request should be routed to. The endpoint ID will be injected into the request (accessible via
ronda.routing/endpoint
) before passing it on to the next middleware/handler.
(require '[ronda.routing :as routing])
(def app
(-> (fn [{:keys [route-params] :as request}]
{:status 200,
:body (case (routing/endpoint request)
:articles "there are 2 articles."
:article (str "this is article " (:id route-params) "."))})
(routing/wrap-routing routes)))
Calling the resulting handler will inject the endpoint ID into the request which can then be
resolved further down the pipeline (using a simple case
statement in the above example).
(app {:request-method :get, :uri "/articles"})
;; => {:status 200, :body "there are 2 articles!"}
(app {:request-method :get, :uri "/articles/1"})
;; => {:status 200, :body "this is article 1."}
(wrap-endpoints default-handler handlers)
The ronda.routing/wrap-endpoints
middleware has to be applied downstream of wrap-routing
since it relies on the endpoint ID injected into the request. It will match the endpoint ID
against a map of handlers, before passing it to either a matching one or further down the
pipeline.
(def app
(-> (constantly {:status 404, :body "not found."})
(routing/wrap-endpoints
{:article
#(->> % :route-params :id
(format "this is article %s.")
(hash-map :status 200 :body))})
(routing/wrap-routing routes)))
Basically, what can be intercepted will be and the rest will pass through unmodified:
(app {:request-method :get, :uri "/articles/1"})
;; => {:status 200, :body "this is article 1."}
(app {:request-method :get, :uri "/articles"})
;; => {:status 404, :body "not found."}
There is also wrap-endpoint
(which will add a single handler interception) and compile-endpoints
(which will return nil
if the default path is reached). See the auto-generated documentation
for more information.
(conditional-middleware handler p? wrap-fn)
This middleware will route requests either to the plain handler
or to
(wrap-fn handler)
, depending on whether they match the given predicate p?
or
not. For example, to only decode JSON bodies for the :article
endpoint:
(-> app
(routing/conditional-middleware
#(= (routing/endpoint %) :article)
decode-json-body)
(routing/wrap-routing routes))
There are more variants of this logic (conditional-transform
to conditionally
apply a function to the request before passing it to the handler,
endpoint-middleware
and endpoint-transform
to have predicate based on
ronda.routing/endpoint
), all of which can be found in the auto-generated
documentation.
(routed-middleware handler middleware-key wrap-fn & args)
This middleware (and its brother active-routed-middleware
) will route requests
either to the plain handler
or to (wrap-fn handler)
, depending on request
metadata provided by the RouteDescriptor
. In particular,
you can enable middlewares per-route using enable-middlewares
and
disable-middlewares
:
(def routes'
(-> (bidi/descriptor
["/" {"articles" :articles
"api" :api}])
(r/disable-middlewares :api [:tracking])
(r/enable-middlewares :api [:json])))
Middlewares are then instantiated using e.g.:
(def app
(-> handler
(r/active-routed-middleware :tracking wrap-tracking)
(r/routed-middleware :json wrap-json)
(r/wrap-routing routes')))
An active-routed-middleware
will be applied unless explicitly disabled.
(meta-middleware handler middleware-key wrap-fn)
Handlers will be dynamically created using (wrap-fn handler route-id route-metadata)
and memoized. This means that their behaviour can be adjusted
on a per-endpoint basis, e.g. a simple cache middleware:
(defn wrap-cache*
[handler route-id {:keys [max-age]}]
(let [v (format "max-age=%d" max-age)]
(fn [request]
(assoc-in
(handler request)
[:headers "cache-control"]
v))))
(defn wrap-cache
[handler]
(r/meta-middleware handler :cache wrap-cache*))
Activation is similar to routed-middleware
:
(def routes
(-> (bidi/descriptor ["/" {"a" :a, "b" :b}])
(r/enable-middlewares :a {:cache {:max-age 300}})))
(def app
(-> (constantly {:status 200})
(wrap-cache)
(r/wrap-routing routes)))
The "cache-control"
header will be set for :a
but not :b
:
(app {:request-method :get, :uri "/a"})
;; => {:headers {"cache-control" "max-age=300"}, :status 200}
(app {:request-method :get, :uri "/b"})
;; => {:status 200}
wrap-fn
can be called with nil
metadata (if the middleware was activated but
no data attached), so you should prepare default values for that case.
The wrap-routing
middleware (see above) enables the use of two additional features:
- path generation from within a handler using
ronda.routing/href
, - path matching from within a handler using
ronda.routing/match
.
Both functions use a RouteDescriptor injected into the request map which means that you can reference (and accept references to) other parts of your application in a way that avoids global state.
(defn- article
[{:keys [route-params uri] :as request}]
(let [id (-> route-params :id Long/parseLong)]
{:status 200,
:data (routing/match request uri)
:body (->> {:id (inc id)}
(routing/href request :article)
(str "next article: "))}))
(def app
(-> (constantly {:status 404, :body "not found."})
(routing/wrap-endpoints
{:article article})
(routing/wrap-routing routes)))
And, go!
(app {:request-method :get, :uri "/articles/1"})
;; => {:status 200,
;; :data {:params {:id "1"},
;; :query-params {},
;; :path "/articles/1",
;; :id :article,
;; :route-params {:id "1"}},
;; :body "next article: /articles/2"}
Note that the RouteDescriptor
decides which values are used as query
parameters. The following rules apply when passing values to href
:
- keywords will be converted to strings.
nil
values will be ignored.- seqs will be concatenated using commas.
If you want different behaviours you have to preprocess the values map.
A RouteDescriptor
is a routing-library independent representation of a series of routes. This project, however,
does not contain any concrete implementations, so you have to explicitly include one, e.g.
ronda/routing-bidi:
(require '[ronda.routing.bidi :as bidi])
(def routes
(bidi/descriptor
["/" {"articles" :articles
["articles/" :id] :article}]))
Routing Library | RouteDescriptor |
Route Format |
---|---|---|
bidi | ronda-routing-bidi | ["/" {["article/" :id] :article}] |
clout (compojure) | ronda-routing-clout | {:article "/article/:id"} |
You can create your own by implementing the ronda.routing.descriptor/RouteDescriptor
protocol -
and feel free to open a Pull Request to add it to this list!
Commonly, routing logic takes a request, analyzes it and directly calls the handler that is able to generate a response:
+-----------+
| | ----> A
Request ----> | Routing |
| | ----> B
+-----------+
Let's assume that A
accepts a POST request with a JSON body while B
expects some form
parameters. Both can be handled gracefully using middlewares but, as you can see, they are
tightly coupled with the handlers:
+--------+
+-----------+ ----> | JSON | ----> A
| | +--------+
Request ----> | Routing |
| | +--------+
+-----------+ ----> | Params | ----> B
+--------+
Adding another JSON-based handler will usually result in something like the following:
+--------+
+-----------+ ----> | JSON | ----> A
| | +--------+
Request ----> | Routing | +--------+
| | ----> | Params | ----> B
+-----------+ +--------+
| +--------+
-------------> | JSON | ----> C
+--------+
Which makes a route correspond to its own little substack of middlewares and handler, resulting in significant duplication across diverse applications. Alternatively, one could model the stack like this:
+--------+-----------+ ----> A
+-----------+ ----> | JSON | Routing 2 |
| | +--------+-----------+ ----> C
Request ----> | Routing |
| | +--------+
+-----------+ ----> | Params | ----> B
+--------+
This can work well if the subsystems can be easily identified (e.g. all JSON handlers reside under
/api
) but will fall apart very quickly if the system is more heterogenous. Also, having routing
logic in two ore more different places can make it harder to reason about it in the first place.
Instead, ronda-routing
proposes a more decoupled approach, making routing logic something that
gets injected into the application:
(optional
+-----------+ Request + middlewares)
| Routing | Routing Data +--------+--------+
Request ----> | Middle- | --------------> | JSON | Params |
| ware | +--------+--------+
+-----------+ |
^ v
| +-------------+
v | intercept |
+------------+ +-------------+
| Descriptor | | | |
+------------+ A B C
The Descriptor
contains the routing logic, basically producing
an identifier that designates the final handler and gets injected into the request.
Follow-up middlewares can then look at that identifier and decide whether they have to
do anything or not.
Multiple paradigms are then possible:
- Each middleware knows what handlers require it. This means maintaining a list of route identifiers per middleware that trigger activation if they are encountered.
- The route descriptor contains feature-specific data (akin to "feature flags") for each route that is read by middlewares, triggering them when necessary.
The second one has immense value when it comes to documentation and is the one preferred by ronda - but the possibility to use the first approach (or even fall back to a per-handler middleware stack) remains.
Contributions are very welcome.
- Clone the repository.
- Create a branch, make your changes.
- Make sure tests are passing by running
lein test
. - Submit a Github pull request.
Copyright (c) 2015 Yannick Scherer
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.