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:
client := http.Client{}
resp, err := client.Get(...)
...
To add middleware to our client, we will create the client as follows:
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:
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:
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:
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:
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:
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:
$ curl localhost:8080
Hello world
A request to /api/protected
will return us an HTTP 401 response if the header is not specified:
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:
$ 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:
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:
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:
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:
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:
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:
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:
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:
/
which responds with the textHello World!
to an incoming request/api/protected
which should respond with the textThis is a protected resource
to incoming requests that specify anX-API-Key
header only. This is not currently implemented.
Here is the Express application implementing the above:
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:
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 arequest
object describing the request being processed.res
is aresponse
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:
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:
$ 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:
$ 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
:
$ 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
:
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
:
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:
s = requests.Session()
s.mount('https://', RequestLogger())
When we run the client, we will see the request and response details being logged:
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:
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:
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
:
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:
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:
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:
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:
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 beginningincluded_patterns
: Request path patterns for which we will check the presence of theX-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:
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:
$ curl localhost:8000/api/protected
Specify X-API-KEY header%
When the header is specified, we get the expected response:
$ 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:
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:
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:
app.add_middleware(AuthHeaderCheck, include_patterns=["/api"])
Now, if we run the application, requests to the /
path will return a successful response:
$ curl localhost:8000/
Hello world!
Requests to the /api/protected
path without X-API-Key
header will get an HTTP 401 error:
$ curl localhost:8000/api/protected
Specify X-API-KEY header
Once the header is specified, we get a successful response:
$ 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
Middleware patterns in Go discusses a few patterns of implementing server side middleware
Writing HTTP client middleware in Go is a blog post by the author shows how to avoid external HTTP API interactions using client-side middleware
A book by the author, Practical Go, describes in detail how to implement middleware in client and server applications
github/go-fault demonstrates how we can use server side middleware for chaos engineering
Javascript
Axios library documentation on interceptors
Writing middleware and Using middleware from the express documentation are useful to learn more about middleware in Express
Python
requests-middleware is a package which implements various middleware for the requests library
And, this talk by the author from PyCon US 2022, Implementing Shared Functionality Using Middleware, describes middleware in server side applications.