ETag's and Conditional GET's in Laravel 4

I've begun integrated ETag's into a Laravel 4 API application. I've started by implementing Conditonal GETs.

For review, Conditional GET's allow a server to respond with a 304 Not Modified response if a resource has not changed since a client has last checked it. This saves bandwidth and, potentially, some server load.

The code explained below was a process; I starting with putting all code into a controller. This worked as a proof of concept for handling the retrieval and comparison of ETags. I then spent time making this a little more DRY and maintainable.

An outline of a Request.

  1. A request is made. It contains an "If-None-Match" header with an ETag.
  2. A controller method is fired. The code generates an ETag for the server's requested resource, and compares this to the ETag provided by the request headers.
  3. If the ETags match, the client has previous knowledge of the Resource and gets a 304 Not Modified response
  4. If the ETags do not match, the Resource has changed since the client last checked, and the server responds with the resource.

Setting up a library

The first thing I do is to make myself a library to work in. I outline making a Laravel library, altho there are other methods (packages) you may find better. I've skipped showing how to set up Autoloading for this library (See my link above).

This library will contain the API-specific code.

app
    lib
        API
            Facades
                Response.php
            Resource
                Eloquent
                    Collection.php
                    Resource.php
                CollectionInterface.php
                ResourceInterface.php

Extending the Response object

Let's start by going over the Api\Facades\Response object. This extends Illuminate's Facade class, and so contains all the methods available there. I added two new methods which handle setting the ETag header and returning the data from an Eloquent\Model or Eloquent\Collection object (more on that later).

public static function resourceJson(ResourceInterface $resource, $data = array(), $status = 200, array $headers = array())
{
    $data[$resource->getResourceName()] = $resource->toArray();

    $response = new \Symfony\Component\HttpFoundation\JsonResponse($data, $status, $headers);
    $response->setEtag( $resource->getEtag() );

    return $response;
}

public static function collectionJson(CollectionInterface $collection, $data = array(), $status = 200, array $headers = array())
{
    $data[$collection->getCollectionName()] = $collection->toArray();

    $response = new \Symfony\Component\HttpFoundation\JsonResponse($data, $status, $headers);
    $response->setEtag( $collection->getEtags() );

    return $response;
}

This helps keep or code DRY by moving some often-repeated controller code into one place.

Note: In order to replace Laravel's Response Facade we need to edit app/config/app.php as seen here:

'aliases' => array(
    ... more items ...
    'Request'         => 'Illuminate\Support\Facades\Request',
    //'Response'        => 'Illuminate\Support\Facades\Response',
    'Response'        => 'Api\Facades\Response',
    ... more items ...

Now, any time we call on the Response facade, we'll be using Api\Facades\Response rather than the default Illuminate\Support\Facades\Response.

Next, we'll generate the ETags.

Generating ETags

You may have noticed that the new Response object calls some methods on Eloquent\Model and Eloquent\Colection that don't (yet) exist. I've extended those two classes to aid in generating the ETags. Here's how:

First, I defined a CollectionInterface and a ResourceInterface. These simply define public methods for obtaining ETags.

<?php namespace Api\Resource;

interface CollectionInterface {

    public function getEtags();

    public function setCollectionName($name);

    public function getCollectionName();

}

<?php namespace Api\Resource;

interface ResourceInterface {

    public function getEtag();

    public function setResourceName($name);

    public function getResourceName();

}

This gives us a way to ensure that Resources and Collections have methods of generating ETags (We don't have to use Eloquent for our data models, after all).

Now, an ETag needs to be based on the resource. When a resource changes, so to must its ETag. I therefore created an md5() hash based on a resource's Table, ID and Updated Date. This ensures that a specific resource from a specific table will have a new ETag when it's last updated date changes.

protected function generateEtag()
{
    $etag = $this->getTable() . $this->getKey();

    if ( $this->usesTimestamps() )
    {
        $datetime = $this->updated_at;

        if ( $datetime instanceof \DateTime )
        {
            $datetime = $this->fromDateTime($datetime);
        }

        $etag .= $datetime;

    }

    return md5( $etag );
}

This works great for a single resource. We next need to generate ETags for a Collection. Since a Collection is essentially an array of multiple Resources, we can generate an ETag based on the resources within the collection.

Here I create an md5() hash based on a concatinated string of the each resources ETag.

public function getEtags()
{
    $etag = '';

    foreach ( $this as $resource )
    {
        $etag .= $resource->getEtag();
    }

    return md5($etag);
}

You may also notice that the ResourceInterface and CollectionInterface both have a getter/setter pair. This is for setting the resource/collection name. This determines the name of the object(s) returned in the response. Refer to the Response::resourceJson and Response::collectionJson() methods above to see how that's used. Note that a resource name defaults to the table name if none is set.

Implementation

Note: I use Laravel's Resource Controllers.

So, how does this look in the controller?

Let's view the show() method, which shows a single resource.

public function show($id)
{
    $article = Article::find($id);

    $etag = Request::getEtags();

    if ( isset($etag[0]) )
    {
        // Symfony getEtags() method returns ETags in double quotes :/
        $etag = str_replace('"', '', $etag[0]);

        if ( $etag === $article->getEtag() ) {
            App::abort(304);
        }
    }

    return Response::resourceJson($article);
}

First we get an Article. Then we get the Request's ETag (I assume one ETag for this tutorial). If the two ETags match, the Resource has not changed since the client last checked on it. Return a 304 Not Modified. Otherwise, return the resource and its ETag via the newly created Response facade.

Now, let's view the controller's index() method, which returns a Collection of multiple resources.

public function index()
{
    $articles = Article::all();

    $articles->setCollectionName('articles');

    $etag = Request::getEtags();

    if ( isset($etag[0]) )
    {
        $etag = str_replace('"', '', $etag[0]);

        if ( $etag === $articles->getEtags() ) {
            App::abort(304);
        }
    }

    return Response::collectionJson($articles);
}

This gets all Articles and returns the ETag based on the resources within the collection. Just like in the show() method, it compares the ETag of the request to that generated by the Collection object, and returns a 304 Not Modified response if appropriate.

Sample Collection response:

GET /api/v1/article

{"articles":[
    {"id":"1","user_id":"1","title":"This is my first article","content":"Heres some content for this article...","created_at":"2013-02-16 01:53:37","updated_at":"2013-02-16 01:53:37"},
    {"id":"2","user_id":"1","title":"this is my second article","content":"this is the first sentence of my second article","created_at":"2013-02-20 02:08:48","updated_at":"2013-02-20 02:08:48"},
    {"id":"3","user_id":"1","title":"this is my third article","content":"this is the first sentence of my first article","created_at":"2013-02-20 02:15:06","updated_at":"2013-02-20 02:15:06"},
    ]
}

and a sample resource response:

GET /api/v1/article/1

{"articles":
    {"id":"1","user_id":"1","title":"This is my first article","content":"Heres some content for this article...","created_at":"2013-02-16 01:53:37","updated_at":"2013-02-16 01:53:37"}
}
comments powered by Disqus