Understanding Ring Middlewares and Compojure Routes

Oitihjya Sen
helpshift-engineering
5 min readNov 2, 2022

Ring Framework

Ring is a Clojure library that provides an easy-to-use interface for developing web applications in Clojure. Quoting from the official documentation:

By abstracting the details of HTTP into a simple, unified API, Ring allows web applications to be constructed of modular components that can be shared among a variety of applications, web servers, and web frameworks.

The Ring framework works on the HTTP layer and parses incoming HTTP messages into Clojure hash-maps. This enables us to work on HTTP messages seamlessly using Clojure. Handler functions can work on these request hash-maps and send a response back to Ring, which too should be a Clojure hash-map. Ring does the work of converting the response from a hash-map to a HTTP response.

What is a handler?

A handler is a function that takes a request map and returns a response. In other words:

(defn handler-fn [request]
(...)
response)

Here’s a trivial example:

(defn hello-world-handler
[_]
{:status 200
:headers {}
:body "Hello World"})

Naturally, we must pass the handler function to the server

(use 'ring.adapter.jetty)(run-jetty hello-world-handler {:port 8080})

What is a middleware?

Conceptually, a middleware function sits between the server and the handler function. It can modify the request before it reaches the handler. Alternatively (or even additionally), it can modify the response that the handler returned before handing it over to the server.

How a middleware works in practice is quite interesting. Essentially, A middleware is a higher-order function that takes one handler and returns a new handler function. In other words:

(defn middleware-fn
[handler-fn]
(fn [req]
(...)
response))

Quoting from the official documentation:

Middleware are higher-level functions that add additional functionality to handlers. The first argument of a middleware function should be a handler, and its return value should be a new handler function that will call the original handler.

Typically, a middleware function would call the handler-fn that was passed to it as an argument. In addition to this, it would either (or both):

  • Do some operations on the request map, before calling the handler function
  • Do some operations on the response map returned by the handler function, before returning it back.

Thus, the skeleton of a middleware function would look like this:

Importantly, a middleware takes a handler function and returns a new (and modified) handler function.

(def final-handler
(let [handler (fn [r] ...)
handler* (middleware-1 handler)
handler** (middleware-2 handler*)
...]
handler**...*))
(final-handler request)
;;This can be re-written using threading macro:
(def final-handler (-> (fn [request] ...)
middleware-1
middleware-2
...))
(final-handler request)

Simple example of a middleware + (Ring) handler

Let’s take an example. We want to write a handler function that can easily access the query and URL encoded parameters from an incoming request map.

Ring does not provide this functionality out of the box, i.e., the request map does not, by default, contain the keyword :params.

Thankfully, Ring provides a useful middleware, ring.middleware.params/wrap-params, that converts query and URL encoded form parameters from the request map and adds the following keys to the request body: :query-params, :form-params and :params (containing a map of both query and form parameters).

{
...
:query-string "name=otee"
:query-params {"name" "otee"}
:form-params {"id" "1234"}
:params {"id" "1234", "name" "otee"}
...
}

So, if we want this functionality, we can simply use this middleware, like so:

(require '[ring.middleware.params :as rmp])(rmp/wrap-params handler-fn)
;;=> new handler function that provides us the params keys

But this alone will not work. This middleware does not add keywords into the :params hash-map.

There is another Ring middleware, ring.middleware.keyword-params/wrap-keyword-paramsthat does this. Once wrap-keyword-params is applied on the handler, the request map looks like this:

{
...
:query-string "name=otee"
:query-params {"name" "otee"}
:form-params {"id" "1234"}
:params {:id "1234", :name "otee"}
...
}

So if we want this functionality, we can do the following:

The threading macro (->) can be used to represent the chaining of middlewares better:

(-> handler-that-needs-keyword-params
rmkp/wrap-keyword-params
rmp/wrap-params)

Note that the order of chaining middlewares is crucial. In this example, the handler function (handler-that-needs-keyword-params) is dependent on the two middlewares that appropriately prepares the request map. Similarly, the wrap-keyword-params looks for the keyword :params in the request map; hence it needs to be called after the wrap-params middleware has been called on the handler function.

More broadly, if middlewares are inter-dependent (i.e., the output of one is consumed by the other), we must ensure that the less dependent one (here, wrap-params)is called first.

In a threading macro, the least dependent one floats to the bottom:

(-> handler
most-dependent-middleware
less-dependent-middleware
least-dependent-middleware)

What is a compojure route?

There are some down-sides of using handlers, in the manner shown above. Handlers cannot differentiate between different routes. Thus, irrespective the URL path or the HTTP method, the same handler will be triggered. This can make things messy, as we need to tailor make our responses vis-a-vis the nature of the request (the route and the HTTP method, for example), as shown below.

This is where compojure comes into the scene. It is a routing library that works on top of Ring. Here’s how a single compojure route looks like:

(require '[compojure.core :refer [GET]])(GET "/" request (str "Hello World! This is your uri: " (:uri
request)))

In the above example, GET is a route macro. It gets triggered only when the URL path matches /, i.e., the first argument to that macro. The second argument is the request itself (which we can destructure in a number of ways) and the third argument is a function that returns a response and it can access the second argument, namely the request map.

We can pass a compojure macro to a server and it will work just fine:

We can even club multiple routes together using defroutes:

So how does a compojure route fit in the handler <> middleware dynamic?

Earlier we defined a handler as a function that takes a request map and returns a response map. We also proposed that any function that meets this function signature can be passed as an argument to run-jetty.

Now, we are saying that we can replace a handler with a compojure route. Clearly, a compojure route does not meet the function signature of a handler. (Where even is the handler, when we use a compojure route?)

And, what about middlewares? As we know, a middleware is a function that expects a handler function as its argument. Can we, then, not use middlewares when we use routes?

Good news is that whatever we can do with a handler, we can do with a compojure route. This is because, a compojure route is a macro. It always expands to a handler function. To quote from the official documentation:

Routes return Ring handler functions. Despite their syntax, there’s nothing magical about them. They just provide a concise way of defining functions to handle HTTP requests.

Thus, we can use the middlewares discussed above, on compojure routes.

--

--

Oitihjya Sen
helpshift-engineering

Recurse Center | Lawyer turned Programmer | Backend Engineer