Finding Joy: Middleware

In this series of posts, we are working our way up to understanding the Joy web framework by creating the building blocks ourselves. See part 1 and part 2.

Let's start simple again:

(import halo)

(defn hello [request]
  {:status 200 :body "Hello, world!"})

(halo/server hello 8080)

The thing I want to point out here is the call to halo/server. It seems kinda crazy that we could build a powerful, modern web app by passing one handler function to the server. How could we possibly do:

by passing just one function to the server? The answer is middleware.

Middleware are functions that take a handler function, add some behavior, then return a new handler function:

Middleware

Too abstract? Let's explore with some examples. We'll start with a middleware function that logs when a request is received.

Our First Middleware Function

(import halo)
  
(defn hello [request]
  {:status 200 :body "Hello, world!"})

(defn log-request [handler]
  (fn [request]
    (printf "%d %s %s" (os/time) (request :method) (request :uri))
    (handler request)))

(def app (log-request hello))

(halo/server app 8080)

Okay, lots to explore here. We've still got our old faithful hello greeting to the world. We've added 2 new things: a function log-request and a value app.

Let's look at the log-request function. It takes a function and returns a function. The function that it takes is expected to be a handler (request -> response) function. The function that it returns is a new handler function that does 2 things:

The app value is the result of calling the log-request function with our original hello handler function. We have “wrapped” hello with log-request. That means app is now:

(fn [request]
    (printf "%q %s %s" (os/time) (request :method) (request :uri))
    (hello request)))

Notice the last line is (hello request), not the result of calling hello. The hello function will not be called until Halo evaluates the outer function when a request is made.

Let's run the new code:

janet hello.janet 
Server listening on [localhost:8080] ...

Then visit http://localhost:8080 in a browser a few times. The formatted string with the unix time and request info should print to the terminal each time you visit:

1589076456 GET /
1589076856 GET /
1589076862 GET /

With handler functions and middleware functions we now have the following in our toolkit:

A handler function is a function that takes a request and returns a response. Request in, response out.

A middleware function is a function that takes a handler and returns a handler. Handler in, handler out.

The power of middleware comes from their ability to:

The end result is a single function composed of other functions that can be handed to the server.

Response Middleware

Our log-request middleware function has behavior that occurs before the handler function is called. We can also write middleware that executes after a handler function is called. This allows us to access or manipulate the response.

Let's add another middleware function that prints the response:

(import halo)
  
(defn hello [request]
  {:status 200 :body "Hello, world!"})

(defn log-request [handler]
  (fn [request]
    (printf "%d %s %s" (os/time) (request :method) (request :uri))
    (handler request)))

(defn log-response [handler]
  (fn [request]
    (let [response (handler request)]
      (printf "%d %s" (response :status) (response :body))
      response)))

(def app (log-response (log-request hello)))

(halo/server app 8080)

We've got one new function: log-response. Notice how log-response calls the handler it's given to set the response in the let macro, then it prints out the response info. We also return the response as the last line of the function so subsequent handler or middlware functions can use it.

Now app is hello wrapped with log-request, which is then wrapped with log-response. If we do some substitution, app looks like this:

(fn [request]
  (let [response (log-request hello)]
    (printf "%d %s" (response :status) (response :body))
    response))

When Halo runs and receives a request, the following will happen:

  1. Halo will call log-response.
  2. log-response will call log-request to set the response let binding.
  3. log-request will print out the request details.
  4. log-request will call hello.
  5. hello will return {:status 200 :body "Hello, world!"}.
  6. log-request will return {:status 200 :body "Hello, world!}.
  7. log-response will set response to {:status 200 :body "Hello, world!}.
  8. log-response will print out :status and :body.
  9. Halo will return the response to the browser.

Running the new app and checking the terminal should show something like this:

1589081245 GET /
200 Hello, world!

Functions All the Way Down

Let's reinforce what we've learned by creating another app that has several middleware functions:

(import halo)
  
(defn hello [request]
  {:status 200 :body "Hello, world!"})

(defn middleware-a [handler]
  (fn [request]
    (print "a")
    (handler request)))

(defn middleware-b [handler]
  (fn [request]
    (print "b")
    (handler request)))

(defn middleware-c [handler]
  (fn [request]
    (let [response (handler request)]
      (print "c")
      response)))

(defn middleware-d [handler]
  (fn [request]
    (let [response (handler request)]
      (print "d")
      response)))

(def app (-> hello
             middleware-a 
             middleware-b
             middleware-c
             middleware-d))

(halo/server app 8080)

Here we've introducted the threading macro -> to compose the middleware functions. The threading macro takes its first argument (our hello handler function) and passes it to the next function middleware-a. The result of calling middleware-a is then passed to middleware-b and so on. It's easier to mentally parse and maintain then the nested way of writing the function call chain:

(middleware-d 
  (middleware-c 
    (middleware-b 
      (middleware-a hello))))

The middleware-a and middleware-b functions have behavior occuring before their handlers are called. The middleware-c and middleware-d functions have behavior that occur after their handlers are called. The ordering can be important.

If we run this and create a request, we'll see the following output in the terminal:

janet hello.janet 
Server listening on [localhost:8080] ...
b
a
c
d

Notice how “b” was printed out before “a”. That's because b wrapped a so it will get called and printed before it calls a. Also notice that “c” was printed out before “d”. That's because d has wrapped c but calls it first to get a response before its own behavior.

Conclusion

So how can we create powerful, modern web apps with one function? By composing many functions into one using middleware.

comments powered by Disqus