Middleware in Clojure
This work is licensed under a Creative Commons Attribution 3.0 Unported License (including images & stylesheets). The source is available on Github.
What is Middleware?
Middleware in Clojure is a common design pattern for threading a request through a series of functions designed to operate on it as well as threading the response through the same series of functions.
Middleware is used in many Clojure projects such as Ring, Compojure, reititclj-http, and Kit.
The client
function
The base of all middleware in Clojure is the client
function, which
takes a request object (usually a Clojure map) and returns a response
object (also usually a Clojure map).
For example, let's use a client
function that pulls some keys out of
a map request and does an HTTP GET on a site:
(ns middleware.example
(:require [clj-http.client :as http]))
(defn client [request]
(http/get (:site request) (:options request)))
To use the client method, call it like so (response shortened to fit here):
(client {:site "http://www.aoeu.com" :options {}})
;; ⇒ {:status 200, :headers {...}, :request-time 3057, :body "..."}
Now that a client function exists, middleware can be wrapped around it to change the request, the response, or both.
Let's start with a middleware function that doesn't do anything. We'll
call it the no-op
middleware:
;; It is standard convention to name middleware wrap-<something>
(defn wrap-no-op
;; the wrapping function takes a client function to be used...
[client-fn]
;; ...and returns a function that takes a request...
(fn [request]
;; ...that calls the client function with the request
(client-fn request)))
So how is this middleware used? First, it must be 'wrapped' around the existing client function:
(def new-client (wrap-no-op client))
;; Now new-client can be used just like the client function:
(new-client {:site "http://www.aoeu.com" :options {}})
;; ⇒ {:status 200, :headers {...}, :request-time 3057, :body "..."}
It works! Now it's not very exiting because it doesn't do anything yet, so let's add another middleware wrapper that does something more exiting.
Let's add a middleware function that automatically changes all "HTTP" requests into "HTTPS" requests. Again, we need a function that returns another function, so we can end up with a new method to call:
;; assume (require '[clojure.string :as str])
(defn wrap-https
[client-fn]
(fn [request]
(let [site (:site request)
new-site (str/replace site "http:" "https:")
new-request (assoc request :site new-site)]
(client-fn new-request))))
This could be written more concisely using ->
and update
, if you prefer:
;; assume (require '[clojure.string :as str])
(defn wrap-https
[client-fn]
(fn [request]
(-> request
(update :site #(str/replace % "http:" "https:"))
(client-fn))))
The wrap-https
middleware can be tested again by creating a new
client function:
(def https-client (wrap-https client))
;; Notice the :trace-redirects key shows that HTTPS was used instead
;; of HTTP
(https-client {:site "http://www.google.com" :options {}})
;; ⇒ {:trace-redirects ["https://www.google.com"],
;; :status 200,
;; :headers {...},
;; :request-time 3057,
;; :body "..."}
Middleware can be tested independently of the client function by
providing the identity function (or any other function that returns a
map). For example, we can see the wrap-https
middleware returns the
clojure map with the :site changed from 'http' to 'https':
((wrap-https identity) {:site "http://www.example.com"})
;; ⇒ {:site "https://www.example.com"}
Combining middleware
In the previous example, we showed how to create and use middleware, but what about using multiple middleware functions? Let's define one more middleware so we have a total of three to work with. Here's the source for a middleware function that adds the current data to the response map:
(defn wrap-add-date
[client]
(fn [request]
(let [response (client request)]
(assoc response :date (java.util.Date.)))))
And again, we can test it without using any other functions using
identity
as the client function:
((wrap-add-date identity) {})
;; ⇒ {:date #inst "2023-11-12T19:16:52.081-00:00"}
Middleware is useful on its own, but where it becomes truly more useful is in combining middleware together. Here's what a new client function looks like combining all the middleware:
(def my-client (wrap-add-date (wrap-https (wrap-no-op client))))
(my-client {:site "http://www.google.com"})
;; ⇒ {:date #inst "2023-11-12T19:17:19.616-00:00",
;; :cookies {...},
;; :trace-redirects ["https://www.google.com/"],
;; :request-time 1634,
;; :status 200,
;; :headers {...},
;; :body "..."}
(The response map has been edited to take less space where you see
...
)
Here we can see that the wrap-https
middleware has successfully
turned the request for http://www.google.com into one for
https://www.google.com, additionally the wrap-add-date
middleware
has added the :date key with the date the request happened. (the
wrap-no-op
middleware did execute, but since it didn't do anything,
there's no output to tell)
This is a good start, but adding middleware can be expressed in a much
cleaner and clearer way by using Clojure's threading macro, ->
. The
my-client
definition from above can be expressed like this:
(def my-client
(-> client
wrap-no-op
wrap-https
wrap-add-date))
(my-client {:site "http://www.google.com"})
;; ⇒ {:date #inst "2023-11-12T19:19:10.389-00:00",
;; :cookies {...},
;; :trace-redirects ["https://www.google.com/"],
;; :request-time 1630,
;; :status 200,
;; :headers {...},
;; :body "..."}
Something else to keep in mind is that middleware expressed in this
way will be executed from the bottom up, so in this case,
wrap-add-date
will call wrap-https
, which in turn calls
wrap-no-op
, which finally calls the client
function.
If you have a lot of middleware to combine, it can be easier to use reduce
and a vector of middleware functions. See how clj-http
does this
for its default stack of middleware:
(defn wrap-request
"Returns a batteries-included HTTP request function corresponding to the given
core client. See default-middleware for the middleware wrappers that are used
by default"
[request]
(reduce (fn wrap-request* [request middleware]
(middleware request))
request
default-middleware))
See clj-http
's client.clj
for the full source.