Caching your Laravel API with ETag and Conditional Requests
Tags: laravel, cache, php
Published: 2021-02-20 01:28:57

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](https://developer.mozilla.org/en-US/docs/Web/HTTP/Conditional_requests#conditional_headers).

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](https://github.com/365Werk/etagconditionals) which would be the quickest way of implementing it in your own application.


[Skip straight to the implementation](#how)


## 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:
```bash
$ 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:
```php
    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:
```php
    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 the `show()` 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:
```php
    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](https://github.com/365Werk/etagconditionals) 

Original post:
[https://hergen.nl/caching-your-laravel-api-with-etag-and-conditional-requests](https://hergen.nl/caching-your-laravel-api-with-etag-and-conditional-requests)