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.