Error Handling with Content Negotiation

In looking to build a RESTful API, an important consideration is how to handle errors. Most errors are displayed to users in HTML, as these are web applications. However, API's need some special care.

Laravel's standard error output is, of course, HTML, but we wouldn't necessarily want this for a JSON-based API.

Luckily, Laravel 4 gives you the freedom to handle errors in a powerful way. This gives us the ability to respond to errors appropriately.

Content Negotiation

Content Negotiation is a mechanism between a client and server which allows the client to ask the server the kind of content it wants returned.

In HTTP, this is done with the accept header. This can be used to allow the same URL to return the same content in different formats (JSON vs XML vs HTML, for instance).

Some API's don't use this header - Instead, they have developers include the return type they prefer as part of the url. For instance, an end-point to the same api call may look like example.com/api/users.json or example.com/api/users.xml.

For this article, I've decided to show how to detect the requested content-type using the Accept header.

Accept header in Laravel 4

So, let's use the Accept header to decide how to output errors within our Laravel application.

Note: This is largely an exploratory article. I believe there might be a better way to do this. However, this will be effective.

We'll go through two iterations of attempting to respond to a request with an application/json header.

First Attempt

My first thought was to edit app/start/global.php where other error filters are. I added this:

use Symfony\Component\HttpKernel\Exception\HttpExceptionInterface;

App::error(function(HttpExceptionInterface $exception, $code)
{

    if ( Request::header('accept') === 'application/json' )
    {
        return Response::json([
            'error' => true,
            'message' => $exception->getMessage(),
            'code' => $code],
            $code
        );
    }

});

Second Attempt

After some thinking, I decided to move this into a ServiceProvider. Here's how:

First, create a new class structure and include a new ApiServiceProvider.php:

app
    commands
    config
    controllers
    ... other familiar files ...
    lib
        Testapi
            Api
                ApiServiceProvider.php

Second, setup auto-loading by editing composer.json:

{
    "require": {
        "laravel/framework": "4.0.*"
    },
    "autoload": {
        "classmap": [
            "app/commands",
            "app/controllers",
            "app/models",
            "app/database/migrations",
            "app/tests/TestCase.php"
        ],
        "psr-0": {
            "Testapi": "app/lib"
        }
    },
    "minimum-stability": "dev"
}

Run a quick dump-autoload just to make sure the new class loads (This may actually not be necessary with PSR-0 autoloading defined as it is):

$ php composer.phar dump-autoload

Third, put code into ApiServiceProvider.php. Note I have some code comments as I discovered a few new things about the $app container.

<?php namespace Testapi\Api;

use Illuminate\Support\ServiceProvider;
// use Illuminate\Support\Facades\Response; # Don't need, see comments below
use Symfony\Component\HttpKernel\Exception\HttpExceptionInterface;

class ApiServiceProvider extends ServiceProvider {

    /**
     * Register
     *
     * @return void
     */
    public function register()
    {
        $app = $this->app;

        # Handle HttpException errors (Not others)
        $app->error(function(HttpExceptionInterface $exception, $code) use ($app)
        {
            if ( $app['request']->header('accept') === 'application/json' )
            {
                // I thought you must create 'Request' object manually
                // as $app['response'] doesn't exit when this is ran...
                // However we can grab its Facade (apparently)
                // via $app['Response'] - Note the capitalization

                //$response = new Response; # Don't need

                return $app['Response']::json([
                    'error' => true,
                    'message' => $exception->getMessage(),
                    'code' => $code],
                    $code
                );
            }
        });
    }
}

I chose to handle errors implementing HttpExceptionInterface, however you can handle ALL exceptions by handling Exception directly. That would look like this:

$app->error(function(\Exception $exception, $code) use ($app) ...

Last, register this Service Provider in app/config/app.php:

'providers' => array(

    'Illuminate\Foundation\Providers\ArtisanServiceProvider',
    … Many other Service Providers...

    'Testapi\Api\ApiServiceProvider'

),

Now, if we get a 404 error in the browser, we'll see an error as you'd expect (in HTML). For instance, localhost/testapi/public/index.php/asdf.

However, if we use a curl request and add a Accept: application/json header, we'll get a JSON response.

$ curl -H "Accept: application/json" localhost/testapi/public/index.php/asdf
{"error":true,"message":"","code":404}

And there we have it. JSON response for any HttpException error!

Actual Curl request I made (using -v flag):

fidmac:~ fideloper$ curl -v -H "Accept: application/json" 172.16.27.135/testapi/public/index.php/asdf
* About to connect() to 172.16.27.135 port 80 (#0)
*   Trying 172.16.27.135...
* connected
* Connected to 172.16.27.135 (172.16.27.135) port 80 (#0)
> GET /testapi/public/index.php/asdf HTTP/1.1
> User-Agent: curl/7.24.0 (x86_64-apple-darwin12.0) libcurl/7.24.0 OpenSSL/0.9.8r zlib/1.2.5
> Host: 172.16.27.135
> Accept: application/json
> 
< HTTP/1.1 404 Not Found
< Date: Tue, 05 Feb 2013 01:44:04 GMT
< Server: Apache/2.2.22 (Ubuntu)
< X-Powered-By: PHP/5.4.9-4~precise+1
< Cache-Control: no-cache
< Transfer-Encoding: chunked
< Content-Type: application/json
< 
* Connection #0 to host 172.16.27.135 left intact
{"error":true,"message":"","code":404}* Closing connection #0
comments powered by Disqus