18 September 2012
Understanding Routing in Compojure
Compojure operates a little differently to most other routing libraries, and can seem a little “magical” unless you’re familiar with how it works behind the scenes. This blog post is designed as a introduction to Compojure from first principles; starting with Ring handlers, how do we get to Compojure?
Static routes
Let’s start with a brief refresher on Ring. A Ring handler is just a normal Clojure function that abstracts a web application:
(defn handler [request]
{:status 200
:headers {}
:body "Hello World"})
The handler takes a map representing a HTTP request as its argument, and returns a map representing a HTTP response.
We can also express this more concisely with the ring.util.response
library:
(use 'ring.util.response)
(defn handler [request]
(response "Hello World"))
In both examples, the handler will constantly return a 200 OK response. This makes for a concise demonstration of Ring, but a real world web application should return different responses depending on the values in the HTTP request it receives.
So let’s modify the handler function to return a different response depending on the request URI:
(defn handler [request]
(cond
(= (:uri request) "/a") (response "Alpha")
(= (:uri request) "/b") (response "Beta")))
Here the cond
macro is used to choose one response if the URI of the
request is “/a”, and another response if the URI is “/b”. This
effectively defines a static routing table.
Divisible routes
We could use cond
exclusively for defining routes, but this has two
major disadvantages:
- It’s very verbose.
- It’s not divisible.
Avoiding unncessary verbosity is common sense, but it’s divisibility that is the more important missing property. When writing code we divide it into smaller pieces called functions, and for the same reasons we separate code into functions, it is also beneficial to divide our routing logic into smaller pieces.
To this end, we can rewrite the above handler using only if
and
or
:
(defn handler [request]
(or
(if (= (:uri request) "/a") (response "Alpha"))
(if (= (:uri request) "/b") (response "Beta"))))
This combines the route conditions with the route responses. Each
route is tried in turn, stopping on the first route that returns a
value that is not nil
or false
.
This design makes it possible to factor out the combined condition and response into individual functions:
(defn a-route [request]
(if (= (:uri request) "/a") (response "Alpha")))
(defn b-route [request]
(if (= (:uri request) "/b") (response "Beta"))))
(defn handler [request]
(or (a-route request)
(b-route request)))
The routing functions act like normal Ring handlers, except that they
return nil
instead of a response when they don’t match the request.
Using or
, we can cascade through the routes, and use the first valid
response map that is returned.
This gives us the property of divisibility; routes can be defined
separately, and then combined using or
. But usefully, this design is
also also associative, in that the result of combining two or
more routes together is itself a route. This allows for arbitrary
nesting:
(defn ab-routes [request]
(or (a-route request)
(b-route request)))
(defn cd-routes [request]
(or (c-route request)
(d-route request)))
(defn handler [request]
(or (ab-routes request)
(cd-routes request)))
This is more verbose than we might like, but fortunately the code has many repetitve elements that can be factored out into a function:
(defn 2-routes [a b]
(fn [req]
(or (a req) (b req))))
Or more generally:
(defn routes [& rs]
(fn [req]
(some (fn [r] (r req)) rs)))
And when applied to our previous example it yields code that is much more concise:
(def ab-routes (routes a-route b-route))
(def cd-routes (routes c-route d-route))
(def handler (routes ab-route cd-route))
Because combining a collection of routes is a common operation,
Compojure also provides a defroutes
macro:
(defroutes ab-routes a-route b-route)
;; is identical to
(def ab-routes (routes a-route b-route))
Concise routes
Thus far we have been writing route functions directly:
(fn [request]
(if (and (= (:request-method request) :get)
(= (:uri request) "/a"))
(response "Alpha")))
But this is both verbose and a little unclear. Ideally we’d like to boil this expression down to its core components:
GET /a => "Alpha"
And Compojure provides macros to do exactly this:
(GET "/a" [] "Alpha")
This expression produces an anonymous route function equivalent to the
previous example, just expressed more concisely. In a future post I’ll
cover route macros like GET
in more detail, but for now it’s
sufficient to know that the macros look like:
(http-method uri bindings & body)
And expand out into a function like:
(fn [request#]
(if (and (= (:request-method request#) ~http-method)
(= (:uri request#) ~uri))
(let [~bindings request#]
~@body)))
Combined with the routes
function described in the previous section,
we can use these macros to produce a succinct description of the very
first set of routes at the beginning of this post:
(defroutes handler
(GET "/a" [] "Alpha")
(GET "/b" [] "Beta"))
You may have seen code like this before, but now it should look less
mysterious. The two GET
macros return route functions, and the
routes
function (hidden in defroutes
) combines them to return a
composite route function.
Compojure bills itself as a routing library for Ring, but more precisely it’s a toolset for creating and combining route functions. This is the essence of Compojure, and there’s really not much more to it than that.