When writing an application with a decoupled front- and backend, you'll have to start considering the requests your frontend client makes to the API. Fetching data again from the backend, even when you only want to verify that your frontend's cache is up to date can quickly add up.
To combat this, you can make use of the ETag
header and conditional requests.
In this blogpost I'll give a quick summary on what ETag
, If-None-Match
and If-Match
headers do, and then go over how I approached implementing this into our package which would be the quickest way of implementing it in your own application.
Skip straight to the implementation
What
Let's start with the header that is at the core of all of this, the ETag
header. This header is meant to be a value that represents the response body in the exact state it is in. In many cases, the ETag
value will be a hash of the content, since this is easiest to generate and guarantees a unique identifier for the response data.
To make the ETag
header useful, we'll have to use conditional requests. The first of two we'll go over is the If-None-Match
header. This is a request header, and is meant to be used on GET
requests. When your backend receives this header, it should compare it's value to the value of the current content. If these values match, nothing but a 304
status code should be returned, resulting in a response that is tiny compared to fetching the entire resource.
The implementation of this is dead easy: if your first GET
request to a resource gave you a response with the ETag
header set, your browser will automatically set the If-None-Match
header on subsequent requests to the resource.
This means that if you simply implement the ETag
and If-None-Match
on your backend, the amount of data transferred from your API to your frontend can be reduced by quite a bit.
The second conditional request uses the If-Match
header. This is used to prevent mid-air collisions. Simply put, if we want to update data in the backend, but our frontend data is outdated, the update should be halted and our frontend should be notified. This works in a similar way as If-None-Match
. After fetching a resource and obtaining the resource's ETag
value, you can make a PATCH
request to this resource and set the If-Match
value equal to the ETag
you previously received. The backend will then check if the ETag
value of the resource currently available on the server matches the one you send. If these match, your update will be allowed. If there is no match, 412
will be returned, letting the frontend know that the condition has not been met.
How
If all you want to do is use conditional requests in Laravel, you can simply run:
$ composer require werk365/etagconditionals
After which you can add the etag
middleware to your route and you'll be good to go. If you're curious about how the middlewares work or how you could implement this without using our package, keep reading!
SetEtag Middleware
As you might have guessed, implementing this one was the most simple of the middlewares. Laravel actually provides an option to set the ETag
header through the SetCacheHeaders
middleware, but it does not support HEAD
requests. The contents of the SetEtag
middleware looks something like this:
public function handle(Request $request, Closure $next)
{
// Handle request
$method = $request->getMethod();
// Support using HEAD method for checking If-None-Match
if ($request->isMethod('HEAD')) {
$request->setMethod('GET');
}
//Handle response
$response = $next($request);
// Setting etag
$etag = md5($response->getContent());
$response->setEtag($etag);
$request->setMethod($method);
return $response;
}
The first thing we do is getting the method of the request in case we want to modify it. Then if we're dealing with a HEAD
request, we'll change it to a GET
request to make sure the content is loaded and a hash can be made. After this, we skip to the response where we'll take the response body and hash it using the md5()
function. We'll set this hash as the ETag
header and make sure the original request method is set back before returning the response.
IfNoneMatch Middleware
This is another relatively straight forward one. Let's view the code first:
public function handle(Request $request, Closure $next)
{
// Handle request
$method = $request->getMethod();
// Support using HEAD method for checking If-None-Match
if ($request->isMethod('HEAD')) {
$request->setMethod('GET');
}
//Handle response
$response = $next($request);
$etag = '"'.md5($response->getContent()).'"';
$noneMatch = $request->getETags();
if (in_array($etag, $noneMatch)) {
$response->setNotModified();
}
$request->setMethod($method);
return $response;
}
The start of this looks familiar to the SetEtag
middleware, we'll ensure we can handle HEAD
requests again, and we generate a hash based on the response content. Note that in this case we'll add double quotes around the hash. ETag
headers are supposed to be wrapped in double quotes, and the setEtag()
method wrapped our hash automatically in the SetEtag
middleware. After we have the hash, we can simply compare it to the If-None-Match
header. Since this header can actually contain any number of hashes, and the getETags()
method will return them as an array, we'll simply check if our newly generated has exists in this array. If we do indeed have a match, we can use setNotModified()
to set a 304
status code on the response.
IfMatch Middleware
Handling If-Match
will be slightly more complicated. The What it all comes down to is that we have to find a way to get the current version of the content that should be updated. This can be done in multiple ways.
- You could use a HTTP client and make an external
GET
request for the same resource - You could look at the action that will be performed by the current request and instead call the
GET
request equivalent of that (for example calling theshow()
method on a controller) - Or you can make a new internal
GET
request.
When building this middleware I started off trying to use the second option. This for some reason seemed like the best option to me. I managed to create a fully working version, but could not be happy with the result. To make it work I had to make some assumptions, had some limitations and do too much work that would be done for me would I simply handle it by creating a new request.
So let's look at the code when we create a new request to fetch the current version of a resource:
public function handle(Request $request, Closure $next)
{
// Next unless method is PATCH and If-Match header is set
if (! ($request->isMethod('PATCH') && $request->hasHeader('If-Match'))) {
return $next($request);
}
// Create new GET request to same endpoint,
// copy headers and add header that allows you to ignore this request in middlewares
$getRequest = Request::create($request->getRequestUri(), 'GET');
$getRequest->headers = $request->headers;
$getRequest->headers->set('X-From-Middleware', 'IfMatch');
$getResponse = app()->handle($getRequest);
// Get content from response object and get hashes from content and etag
$getContent = $getResponse->getContent();
$getEtag = '"'.md5($getContent).'"';
$ifMatch = $request->header('If-Match');
// Compare current and request hashes
if ($getEtag !== $ifMatch) {
return response(null, 412);
}
return $next($request);
All of this middleware will run at the start of the request lifecycle. First, we'll filter out any non-PATCH
requests or ones that don't have the If-Match
header set. After this, we'll make a new GET
request to the same endpoint, and duplicate the headers from the initial request so the new one can pass through things like auth middleware and other constraints.
Using the response of this new request, we'll once again generate a hash that we can compare to the hash sent. If the hashes match, the request will be allowed through the middleware. If there is no match, a response with status code 412
will be returned.
With these 3 middlewares, you'll be able to handle etags and conditional requests easily within your Laravel application.
Package: https://github.com/365Werk/etagconditionals
Original post:
https://hergen.nl/caching-your-laravel-api-with-etag-and-conditional-requests
Top comments (0)