Skip to content

Middleware for web applications: it’s not just for enterprises

Write cleaner, more maintainable code—and reuse it in many different contexts.

Artwork: Micha Huigen

Photo of Amit Saha
 logo

Amit Saha // Software Engineer,

The ReadME Project amplifies the voices of the open source community: the maintainers, developers, and teams whose contributions move the world forward every day.

When you hear the term “middleware,” you might think of big, boring, complex enterprise software and you wouldn’t be wrong. The term middleware began in computing in the 1960s, originally as a way to describe the interface between hardware and software. Its Wikipedia entry points to a book on the subject describing middleware as the “dash (-) in client-server, or the to in peer-to-peer.” 

These days, though, it’s more typically used to refer to implementing shared functional requirements in web applications. Middleware is integral to web applications for the same reason that libraries are important: They help developers avoid code duplication and promote separation of concerns.

This Guide’s focus, though, is middleware as used in the context of clients and server applications communicating over HTTP (Hypertext Transport Protocol), as discussed in this blog post. The author describes middleware as code that wraps around a web application object to do other things before and/or after the web application gets called. These other things typically include tasks such as exporting observability data, performing application-wide authentication and authorization, and caching results.

Common uses of middleware in web applications

Let’s take a typical web server application. You will likely have more than one web page or API endpoint. Let’s refer to them as views.

You want to record the latency across all your views. One way to do so would be to make each of the backend handler functions ensure that they record the data. A better way is to implement this functionality as middleware so that the latency gets recorded automatically for each of these views. If a new view is added, the latency is also automatically recorded for the new view without any work required from the implementer of the new view.

Other examples of where middleware can implement such common functionality are authentication and authorization. Say, one team in your organization is the first to need a custom authentication logic for all web applications. Once they implement this logic as middleware, and make it available as a package, any other team in the organization can integrate it into their web applications, thus avoiding a reimplementation.

The above examples are equally applicable to HTTP client applications. Typical use cases for integrating middleware in client applications include exporting observability data, client-side caching, and automatically checking for authentication data.

There are plenty of other applications for middleware, however. For example, middleware makes it easier to write more reliable tests by mocking up interactions with external servers. Instead of forwarding the request to the real server, a client middleware can be written to behave like one. Middleware can also enable you to perform chaos engineering experiments in client and server applications. Middleware can be used to simulate failures, inject latency, or just introduce arbitrary behavior, such as requests being mangled. 

In the next three sections, we will look at implementing client and server-side middleware in Go, Javascript, and Python HTTP applications.

Note: Some language ecosystems use the word “interceptor” to refer to middleware in the context we discuss. This guide uses either middleware or interceptor, depending on the language’s or specific library’s preferred term.

Middleware in Go HTTP applications

The Go developer community most commonly defaults to using the standard library’s net/http package for writing HTTP clients and servers. Thus, the middleware we implement will focus on applications using the standard library package.

HTTP clients

To make an HTTP request using the net/http package, the standard library provides functions such as http.Get() for making HTTP GET requests and http.Post() for making HTTP POST requests. These functions use a default HTTP client that is automatically created inside the standard library.

If we want to integrate middleware in our HTTP clients, we need to create an http.Client object and use it to make HTTP requests:

1
2
3
client := http.Client{}
resp, err := client.Get(...)
...

To add middleware to our client, we will create the client as follows:

1
2
3
4
5
client := http.Client{
       Transport: &latencyLogger{}
}
resp, err := client.Get(...)
...

The key is specifying a custom Transport field when creating the http.Client object. The value is set to an object of type that implements the http.RoundTripper interface.

Here we create an object of type latencyLogger and set it as the transport.

The definition of latencyLogger is as follows:

1
2
3
4
5
6
7
type latencyLogger struct{}
func (l *latencyLogger) RoundTrip(req *http.Request) (*http.Response, error) {
     start := time.Now()
     resp, err := http.DefaultTransport.RoundTrip(req)
     log.Printf("url=%s method=%s error=\"%v\" latency=%v", req.URL.String(), req.Method, err, time.Since(start))
     return resp, err
}

A type implementing the http.RoundTripper interface must implement the RoundTrip() method with a pointer receiver. It takes as an argument, a value of type *http.Request which is the outgoing HTTP request and returns a value of type *http.Response and an error value.

The purpose of the latencyLogger middleware is to log a request’s latency. To that end, we store the current time inside it before sending the outgoing request in start. Then, we invoke a call to the RoundTrip() method of http.DefaultTransport, which is the default transport implementation. Once we get the response and error back, we log the URL and HTTP method of the request, error value, and the latency. Finally, we return the received resp and err values as returned by http.DefaultTransport.

When we have configured an HTTP client with latencyLogger as the transport, HTTP request details will be logged automatically. For example:

1
2
2022/10/04 16:58:44 url=https://github.com method=GET 
error="<nil>" latency=61.534291ms

There is a complete demo of this client middleware on my GitHub repository.

HTTP server

Let’s consider an HTTP server containing two handler functions:

1
2
3
4
5
6
7
8
9
10
11
import "net/http"
func indexHandler(w http.ResponseWriter, r *http.Request) {
     fmt.Fprintf(w, "Hello world")
}
func protectedHandler(w http.ResponseWriter, r *http.Request) {
     fmt.Fprintf(w, "This is a protected resource")
}
mux := http.NewServeMux()
mux.HandleFunc("/", indexHandler)
mux.HandleFunc("/api/protected", protectedHandler)
// rest of the server

We now want to update the application so that requests to the /api/protected path must specify a header X-API-Key with an API key. The value of the header doesn’t matter for our context.

Instead of implementing the logic inside the protectedHandler() function, we will implement a middleware to do so:

1
2
3
4
5
6
7
8
9
func AuthRequired(handler http.HandlerFunc) http.HandlerFunc {
     return func(w http.ResponseWriter, r *http.Request) {
          if len(r.Header.Get("X-API-Key")) == 0 {
               http.Error(w, "Specify X-API-Key header", http.StatusUnauthorized)
               return
          }
          handler(w, r)
     }
}

The AuthRequired() function accepts, as an argument, an HTTP handler function (type: http.HandlerFunc), and returns another handler function (type: http.HandlerFunc).

Inside the function body that is returned, we check if there is X-API-Key header present in the request. If one is not found, we return a HTTP 401 error in response. If one is found, we call the handler function, handler(), to process the request.

Once we have written the middleware, we will update how we register the handler function for /api/protected as follows:

1
2
3
mux := http.NewServeMux()
mux.HandleFunc("/", indexHandler)
mux.HandleFunc("/api/protected", middleware.AuthRequired(protectedHandler))

Now, if we run the server, a request to the root path, / will not require us to specify a X-API-Key header:

1
2
$ curl localhost:8080
Hello world

A request to /api/protected will return us an HTTP 401 response if the header is not specified:

1
2
3
4
5
curl -v localhost:8080/api/protected
..
HTTP/1.1 401 Unauthorized
..
Specify X-API-Key header

When the header is specified, we get back the response from the handler function:

1
2
$ curl --header "X-API-Key: my-key" localhost:8080/api/protected
This is a protected resource

There is a complete demo of this server middleware on my GitHub repository.

Middleware in Javascript HTTP applications

For JavaScript, we will use popular third-party client and server libraries. Thus, the middleware we implement will be specific to those libraries.

HTTP clients

Axios is an HTTP client for Node.js as well as the browser. We will focus on a client application that runs via Node.js using Axios v1.0.0.

Consider the following code, which makes an HTTP GET request to github.com and prints the HTTP response status if the request succeeds, or an error if does not:

1
2
3
4
5
6
7
8
9
10
import axios from "axios";
const axiosGithub = axios.create();
axiosGithub
     .get("https://github.com/")
     .then(function (response) {
          console.log("HTTP Response: %s", response.status);
     })
     .catch(function (error) {
           console.log("See error logs");
     });

It’s worth noting here that a request is considered successful by Axios if we get back a response in the 2XX range. Any other scenario is considered an unsuccessful request and will be logged as an error.

A request interceptor is a function that accepts a single argument: a request config describing the outgoing request. Let’s write one to log the outgoing request:

1
2
3
4
5
export const requestInterceptor = function(requestConfig) {
     requestConfig.startTime = Date.now();
     console.log('url=%s method=%s', requestConfig.url, requestConfig.method);
     return requestConfig;
};

We log the url and HTTP method of the outgoing request. Additionally, we create a new property, startTime, and set the value to the current Unix time in milliseconds. This will help us calculate the latency in the response interceptor.

A response interceptor is a function that accepts a single argument, a response describing the incoming HTTP response. Let’s write one now:

1
2
3
4
5
6
7
8
9
10
export const responseInterceptor = function(response) {
     console.log(
          'url=%s method=%s status=%s latency=%s',
          response.config.url,
          response.config.method,
          response.status,
          Date.now() - response.config.startTime,
     );
     return response;
};

We log the url, HTTP method of the request and the response status. Additionally, we calculate the latency of the request by subtracting the current Unix time in milliseconds from the time stored in startTime of the request config object (response.config).

As mentioned earlier in this section, if the result of making an HTTP request is anything other than an HTTP response with the status in the 2XX range, the client will get an error. Hence, we have to write an interceptor to handle this scenario as well:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
export const errorInterceptor = function(error) {
     if (error.response) {
          console.log(
               'url=%s method=%s error=%s status=%s',
               error.response.config.url,
               error.response.config.method,
               error.response.data,
               error.response.status,
          );
     } else if (error.request) {
          console.log(error.request);
     } else {
          console.log('Error', error.message);
     }
     return Promise.reject(error);
};

The above interceptor is based on an example from the Axios documentation. It handles errors when making the request and receiving the response, as well as a catch-all scenario.

Finally, we will integrate the interceptors with our Axios instance, axiosGithub, as follows:

1
2
axiosGithub.interceptors.request.use(requestInterceptor, errorInterceptor);
axiosGithub.interceptors.response.use(responseInterceptor, errorInterceptor);

Both the use() methods on the request and response objects expect to be called with two arguments: an interceptor for the successful scenario and an interceptor for the unsuccessful scenario, respectively.

For a successful request, the result of running the client will be:

1
2
3
url=https://github.com/ method=get
url=https://github.com/ method=get status=200 latency=897
HTTP Response: 200

For an unsuccessful request, we will see the error logged by the following error interceptor:

1
2
3
4
5
6
url=https://api.github.com/foo method=get
url=https://api.github.com/foo method=get error={
 message: 'Not Found',
 documentation_url: 'https://docs.github.com/rest'
} status=404
See error logs

You can find a complete example on my GitHub repository.

HTTP server applications

Next, we will look at a server application written using the Express framework.

We will define two path handlers:

  1. / which responds with the text Hello World! to an incoming request

  2. /api/protected which should respond with the text This is a protected resource to incoming requests that specify an X-API-Key header only. This is not currently implemented.

Here is the Express application implementing the above:

1
2
3
4
5
6
7
8
9
10
11
12
import express from "express";
const app = express();
const port = 3000;
aapp.get("/", (req, res) => {
     res.send("Hello World!");
});
app.get("/api/protected", (req, res) => {
     res.send("This is a protected resource");
});
app.listen(port, () => {
     console.log(`app listening on port ${port}`);
});

Let’s now define a middleware that will reject requests without the X-API-Key header:

1
2
3
4
5
6
7
const authHeaderCheck = function (req, res, next) {
if (req.get("X-API-Key") === undefined) {
          res.status(401).send("X-API-Key header not specified");
     } else {
          next();
     }
};

A Middleware in Express is a function that takes three arguments:

  • req is a request object describing the request being processed.

  • res is a response object representing a response to the request. The middleware can choose to send back a response itself or forward the request to the next middleware or a request handler.

  • next is a function that represents the next middleware to be called, or a request handler function.

The authHeaderCheck middleware defined above queries the req object to see if an X-API-Key header was specified in the request.

If the header is not found, an HTTP 401 status with the response body “X-API-Key header not specified” is sent in response.

If the header is found, next() is called to continue processing the request by the next middleware or a handler function.

Note: Middleware functions for error handling are written a bit differently, as shown in the documentation.

To integrate the authHeaderCheck middleware, we will call the use() attribute on the express instance, app, specifying the path we want to apply the middleware to:

1
app.use("/api", authHeaderCheck);

The above statement should be specified before defining any routes in your application.

Now, any request to a path beginning with /api will be checked for the presence of the X-API-Key header.

If we run the server and make a request to the / path, you will get back a Hello World! response:

1
2
$ curl localhost:3000
Hello World!

However, for the /api/protected path, you will see that you get back an error if the X-API-Key header is not specified:

1
2
$ curl localhost:3000/api/protected
X-API-Key header not specified

If you specify the X-API-Key header, you will get back a response This is a protected resource:

1
2
$ curl --header 'X-API-Key: foo-bar' localhost:3000/api/protected
This is a protected resource

I provide an example for our server application on my GitHub repository.

Middleware in Python HTTP applications

Similar to JavaScript, we will focus on implementing client middleware for widely used third-party libraries. For server-side middleware, we will implement middleware that is independent of the web framework library.

HTTP clients

In this section, we will look at implementing middleware for two client libraries :  requests for synchronous and aiohttp for asynchronous HTTP clients.

First, consider a client making an HTTP request using requests:

1
2
3
4
import requests
s = requests.Session()
r = s.get('https://github.com')
print("HTTP Response: ", r.status_code)

To write a middleware, we will implement a custom transport adapter and define it as a subclass of requests.adapters.HTTPAdapter:

1
2
3
4
5
6
7
8
9
10
11
12
13
from requests.adapters import HTTPAdapter
class RequestLogger(HTTPAdapter):
     def __init__(self, *args, **kwargs):
          super(RequestLogger, self).__init__(*args, **kwargs)
     def send(self, request, **kwargs):
          self.start_time = time.time()
          return super(RequestLogger, self).send(request, **kwargs)
     def build_response(self, *args):
          resp = super(RequestLogger, self).build_response(*args)
          latency = time.time() - self.start_time
          print(f"url={resp.url} method={resp.request.method} 
     status={resp.status_code} latency={latency}")
          return resp

We override two methods of HTTPAdapter, send(), and build_response().

Overriding the send() method allows us to execute custom code before the request is sent to the server.

In our implementation of send(), we store the current time in an instance attribute start_time, and then hand over the request to HTTPAdapter by calling its send() method and returning the result.

Overriding the build_response() allows us to execute custom code before the response is sent to the client.

In our implementation of build_response(), we call HTTPAdapter’s build_response() method to obtain the HTTP response that will be sent to the client, storing it in resp.

Then, we calculate the latency in seconds (by calculating the difference between the current time from the time stored in start_time), log key data from the response, and return the response to the client.

To integrate the middleware, we use the mount() method of the session object:

1
2
s = requests.Session()
s.mount('https://', RequestLogger())

When we run the client, we will see the request and response details being logged:

1
2
url=https://github.com/ method=GET status=200 latency=0.2398509979248047
HTTP Response:  200

I provide the complete code on my GitHub repository.

Next, let’s look at a client using aiohttp to make a request:

1
2
3
4
5
6
7
import aiohttp
import asyncio
async def main():
     async with aiohttp.ClientSession() as session:
          async with session.get('https://github.com') as resp:
               print('HTTP Response: ', resp.status)
asyncio.run(main())

To implement a middleware for aiohttp, we will take advantage of client tracing support offered by aiohttp.

First we define two functions:

1
2
3
4
5
6
async def start_timer(session, trace_config_ctx, params):
     trace_config_ctx.start_time = time.time()
async def log_response_metadata(session, trace_config_ctx, params):
     latency = time.time() - trace_config_ctx.start_time
     print(f"url={params.url} method={params.method} 
status={params.response.status} latency={latency}")

Both of the functions accept three arguments: session is an object of type ClientSession, trace_config_ctx is an object of type TraceConfig, and params is an object giving us access to the request and response properties respectively. For start_timer, params is an object of type TraceRequestParams, and for log_response_metadata, params is an object of type TraceRequestEndParams.

We want start_timer() to be executed before the request is sent, and log_response_metadata() to be executed before the response is sent to the client.

To do so, we create a instance of TraceConfig, append start_timer to the instance attribute, on_request_start, and append log_response_metadata to the attribute on_request_end:

1
2
3
trace_config = aiohttp.TraceConfig()
trace_config.on_request_start.append(start_timer)
trace_config.on_request_end.append(log_response_metadata)

Finally, we update how we create the session in our client:

1
2
3
async with aiohttp.ClientSession(
       trace_configs = [trace_config]) as session:
# rest of the client

When we run the client, we will see the details of the request and response being logged:

1
2
url=https://github.com/ method=GET status=200 latency=0.08323097229003906
HTTP Response:  200

You can find the code for the client with the middleware on my GitHub repository.

HTTP server applications

Let’s consider a WSGI application using Flask. The application is configured to handle requests to / and /api/protected paths:

1
2
3
4
5
6
7
8
from flask import Flask
app = Flask(__name__)
@app.route("/")
def hello():
     return "Hello world!"
@app.route("/api/protected")
def protected():
     return "This is a protected resource"

We will write a middleware that will look for a X-API-Key header in response to a request for the /api/protected path. If not found, an HTTP 401 error will be returned as response.

Instead of writing the middleware using the Flask-specific before_request function, we will write our middleware as a WSGI middleware:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
class AuthHeaderCheck:
     def __init__(self, wsgi_app, included_patterns):
          self.wsgi_app = wsgi_app
          self.included_patterns = included_patterns
     def __call__(self, environ, start_response):
          request_path = environ['PATH_INFO']
          x_api_key_header = environ.get('HTTP_X_API_KEY')
          for path in self.included_patterns:
               if request_path.startswith(path) and not x_api_key_header:
                    status = '401 Unauthorized'
                    headers = []
                    start_response(status, headers)
                    for item in [b'Specify X-API-KEY header']:
                         yield item
                    return
               yield from self.wsgi_app(environ, start_response)

We define a class, AuthHeaderCheck, consisting of two attributes:

  • wsgi_app: Reference to the Flask application we created in the beginning

  • included_patterns: Request path patterns for which we will check the presence of the X-API-Key header

The __call__() method must accept two arguments: 1) a dictionary, environ containing the details related to the current HTTP request being processed, and 2) a function, start_response which is used to send a response to the client.

Inside it, we look for a header, X-API-Key which, if specified, will be transformed into an HTTP_ variable, HTTP_X_API_KEY.

If the header is not found when expected, we return a HTTP 401 error response.

If the header is found, we forward the request to be processed by the Flask application, referenced by wsgi_app.

To integrate the middleware into our Flask application, we will overwrite the wsgi_app attribute as follows:

1
app.wsgi_app = AuthHeaderCheck(app.wsgi_app, included_patterns=["/api"])

When the server is run, you will see that requests to /api/protected will return an HTTP 401 response if the X-API-Key header is not specified:

1
2
$ curl localhost:8000/api/protected
Specify X-API-KEY header%

When the header is specified, we get the expected response:

1
2
$ curl --header 'X-API-Key: foo-123' localhost:8000/api/protected
This is a protected resource%

You can find the complete example on my GitHub repository.

You can use the same middleware with any other WSGI framework such as Django.

Next, let’s look at an equivalent example, ASGI application using FastAPI:

1
2
3
4
5
6
7
8
9
from fastapi import FastAPI
from fastapi.responses import HTMLResponse
app = FastAPI()
@app.get("/")
async def index():
     return HTMLResponse("Hello world")
@app.get("/api/protected")
async def protected():
     return HTMLResponse("This is a protected resource")

Now, we will define an ASGI middleware that looks for the X-API-Key header for a request to the /api/protected path. If the header is not found, an HTTP 401 Unauthorized error is sent as response.

We define a class, AuthHeaderCheck, with two attributes: app and included_patterns.

app will reference the FastAPI application, and include_patterns will allow us to specify the request path patterns that we want to implement the header check for:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
class AuthHeaderCheck:
     def __init__(self, app, include_patterns):
          self.app = app
          self.include_patterns = include_patterns
     async def __call__(self, scope, receive, send):
          x_api_key_header = None
          request_path = scope['path']
          for h in scope['headers']:
               if h[0] == b'x-api-key':
                    x_api_key_header = h[1]
          for pattern in self.include_patterns:
               if request_path.startswith(pattern) and not x_api_key_header:
                    await send({
                         'type': 'http.response.start',
                         'status': 401,
                    })
                    await send({
                         'type': 'http.response.body',
                         'body': b'Specify X-API-Key header',
                    })
                    return
               await self.app(scope, receive, send)

Inside the __call__() method, we implement the logic for the middleware.

The __call__() method accepts three parameters.

scope is a dictionary containing key-value pairs describing the request being handled.

receive is a coroutine used to read the request data from the client.

send is a coroutine used to send a response to the client.

Refer to this description of the ASGI spec to learn more about the above parameters.

To integrate the middleware with the FastAPI application, call the add_middleware() method:

1
app.add_middleware(AuthHeaderCheck, include_patterns=["/api"])

Now, if we run the application, requests to the / path will return a successful response:

1
2
$ curl localhost:8000/
Hello world!

Requests to the /api/protected path without X-API-Key header will get an HTTP 401 error:

1
2
$ curl localhost:8000/api/protected
Specify X-API-KEY header

Once the header is specified, we get a successful response:

1
2
$ curl --header 'X-API-Key: foo-123' localhost:8000/api/protected
This is a protected resource

Learn more

These examples are, of course, just the beginning of what you can do with middleware. I’d suggest you take my code samples above and play around with them to get a better sense for how they work. Then check out the resources below to deepen your knowledge and apply middleware to your own applications.

Go

Javascript

Python

And, this talk by the author from PyCon US 2022, Implementing Shared Functionality Using Middleware, describes middleware in server side applications.

Amit Saha is a software engineer in Sydney, Australia. He has written Practical Go: Building Scalable Network and Non-Network Applications (Wiley, 2021), Doing Math with Python: Use Programming to Explore Algebra, Statistics, Calculus, and More! (No Starch Press, 2015) and Write Your First Program (PHI Learning, 2013). His other writings have been published in technical magazines, conference proceedings, and research journals.

About The
ReadME Project

Coding is usually seen as a solitary activity, but it’s actually the world’s largest community effort led by open source maintainers, contributors, and teams. These unsung heroes put in long hours to build software, fix issues, field questions, and manage communities.

The ReadME Project is part of GitHub’s ongoing effort to amplify the voices of the developer community. It’s an evolving space to engage with the community and explore the stories, challenges, technology, and culture that surround the world of open source.

Follow us:

Nominate a developer

Nominate inspiring developers and projects you think we should feature in The ReadME Project.

Support the community

Recognize developers working behind the scenes and help open source projects get the resources they need.

Thank you! for subscribing