Thinking in Ring: handlers and middlewares

Back to SIG Clojure Tutorials

Author: Gary Johnson

Date: 2021-07-08

Clojure's Ring library allows us to treat web requests as essentially remote function calls.

The process is as follows:

1. A web browser sends a text HTTP request to the Jetty web server.

2. Jetty converts the text HTTP request into a Java HttpRequest object and passes it to Ring.

3. Ring converts the Java HttpRequest object into a Clojure request map and passes it to your handler function.

4. Your handler function takes in the request map, performs some computation, and returns a response map.

5. Ring converts the Clojure response map back to a Java HttpResponse object and returns it to Jetty.

6. Jetty converts the Java HttpResponse object into a text HTTP response and sends it back to the web browser.

7. The web browser renders the response.

Note, of course, that this means that the request map passed to your handler function is populated with the values coming from the web browser's HTTP request. Since Jetty and Ring take care of the data format transformations for us, we just have to worry about computing a response map from the provided request map. Data in. Data out.

Now you could write a simple "hello-world-handler" function like so:

(defn hello-world-handler [request]
  {:status  200
   :headers {"Content-Type" "text/html"}
   :body    "

Hello world!

"})

Here we are ignoring the request map and just returning a hard-coded response map for a "Hello world!" page.

It's not very exciting or dynamic, but it works.

If we wanted to make a more interesting handler, we would need to at least return different results for different URLs on our site. Here's one with a conditional:

(defn routing-handler [request]
  {:status  200
   :headers {"Content-Type" "text/html"}
   :body    (case (:uri request)
             "/"        "

Home Page

" "/about" "

About Page

" "/gallery" "

Photo Gallery

" "

Page Not Found

")})

Great! Now what if we wanted to add some logging on the request and response maps? We could add some print logging right inside "routing-handler", but it's going to start getting pretty long and messy as we keep adding more complexity to that handler function. Here is where middleware functions come in.

Instead of putting the new functionality inside "routing-handler" we'll use a middleware function to wrap it around "routing-handler" like so:

(defn wrap-logging [handler]
  (fn [request]
    (println "Request:" request)
    (let [response (handler request)]
      (println "Response:" response)
      response)))

Now we'll define our new and improved "routing-handler" like so:

(def better-routing-handler (wrap-logging routing-handler))

When we start up our web server, we will tell Ring to use "better-routing-handler" as its handler function. Now, everytime a new HTTP request comes in, the request map is printed to stdout, the inner "routing-handler" function is called to create the response map, the response map is printed to stdout, and finally the response map is returned to Ring.

We can write as many middleware functions as we would like, allowing each one to perform a single data transformation or side effect on the request and/or response maps. Then we can build up a big, nested handler stack that's a bit like a set of Russian dolls:

(def mega-handler (wrap-session (wrap-cookies (wrap-params (wrap-authentication (wrap-logging routing-handler))))))

This use two important techniques from functional programming to allow us to split our program up into many small higher order functions: closures and function composition.

A closure is a function whose free (unbound) variables capture references to their lexical bindings at function creation time. Here, our middleware functions all produce closures, since all of the anonymous functions that they return capture references to "handler", which goes out of scope (but is preserved within the closures) when the middleware functions return.

Function composition is the technique of combining multiple functions together by passing the output of one function as the input to the next one. This may be performed as many times as is needed as long as the composed functions' input and output types match.

This is, of course, what we are doing when we compose the various "wrap-*" middleware invocations together to create the "mega-handler" function.