Finding Joy: Middleware
May 16, 2020In 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:
- logging
- routing
- sessions
- query parsing
- cross site request forgery
- static files
- error handling
- file uploads
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:
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:
- Prints a string formatted with the current time as unix epoch time, the request’s method (GET, POST, etc), and the request’s uri path.
- Calls the passed-in handler function with the request and returns the result.
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:
- combine handler functions
- add additional behavior (logging, calling other systems, etc)
- modify the request or responses they handle
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:
- Halo will call
log-response
. log-response
will calllog-request
to set theresponse
let binding.log-request
will print out the request details.log-request
will callhello
.hello
will return{:status 200 :body "Hello, world!"}
.log-request
will return{:status 200 :body "Hello, world!}
.log-response
will setresponse
to{:status 200 :body "Hello, world!}
.log-response
will print out:status
and:body
.- 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.
#janet #joy #web #lisp