Pragmatism in the real world

Returning JSON errors in a ZF2 application

If you have a standard ZF2 application and accept application/json requests in addition to application/html, then you have probably noticed that when an error happens, HTML is created, even though the client has requested JSON.

One way to fix this is to create a listener on MVC’s render event to detect that an error has occurred and substitute a JsonModel in place of the ViewModel.

The easiest way to do this in your ApplicationModule.

Firstly, attach a lister to render:

class Module
{
    public function onBootstrap(MvcEvent $e)
    {
        // attach the JSON view strategy
        $app      = $e->getTarget();
        $locator  = $app->getServiceManager();
        $view     = $locator->get('ZendViewView');
        $strategy = $locator->get('ViewJsonStrategy');
        $view->getEventManager()->attach($strategy, 100);

        // attach a listener to check for errors
        $events = $e->getTarget()->getEventManager();
        $events->attach(MvcEvent::EVENT_RENDER, array($this, 'onRenderError'));
    }

Now write the error detector:

    public function onRenderError($e)
    {
        // must be an error
        if (!$e->isError()) {
            return;
        }

        // Check the accept headers for application/json
        $request = $e->getRequest();
        if (!$request instanceof HttpRequest) {
            return;
        }

        $headers = $request->getHeaders();
        if (!$headers->has('Accept')) {
            return;
        }

        $accept = $headers->get('Accept');
        $match  = $accept->match('application/json');
        if (!$match || $match->getTypeString() == '*/*') {
            // not application/json
            return;
        }

        // make debugging easier if we're using xdebug!
        ini_set('html_errors', 0); 

        // if we have a JsonModel in the result, then do nothing
        $currentModel = $e->getResult();
        if ($currentModel instanceof JsonModel) {
            return;
        }

        // create a new JsonModel - use application/api-problem+json fields.
        $response = $e->getResponse();
        $model = new JsonModel(array(
            "httpStatus" => $response->getStatusCode(),
            "title" => $response->getReasonPhrase(),
        ));

        // Find out what the error is
        $exception  = $currentModel->getVariable('exception');

        if ($currentModel instanceof ModelInterface && $currentModel->reason) {
            switch ($currentModel->reason) {
                case 'error-controller-cannot-dispatch':
                    $model->detail = 'The requested controller was unable to dispatch the request.';
                    break;
                case 'error-controller-not-found':
                    $model->detail = 'The requested controller could not be mapped to an existing controller class.';
                    break;
                case 'error-controller-invalid':
                    $model->detail = 'The requested controller was not dispatchable.';
                    break;
                case 'error-router-no-match':
                    $model->detail = 'The requested URL could not be matched by routing.';
                    break;
                default:
                    $model->detail = $currentModel->message;
                    break;
            }
        }

        if ($exception) {
            if ($exception->getCode()) {
                $e->getResponse()->setStatusCode($exception->getCode());
            }
            $model->detail = $exception->getMessage();

            // find the previous exceptions
            $messages = array();
            while ($exception = $exception->getPrevious()) {
                $messages[] = "* " . $exception->getMessage();
            };
            if (count($messages)) {
                $exceptionString = implode("n", $messages);
                $model->messages = $exceptionString;
            }
        }

        // set our new view model
        $model->setTerminal(true);
        $e->setResult($model);
        $e->setViewModel($model);
    }

You’ll also need some use statements:

use ZendHttpRequest as HttpRequest;
use ZendViewModelJsonModel;
use ZendViewModelModelInterface;

(This code is heavily inspired from PhlyRestfully – Thanks Matthew!)

Essentially, we check that we are in an error situation and that the client wants JSON. If we are, we create a JsonModel and populate it information. I’ve used the fields from the draft Problem Details for HTTP APIs IETF spec as it seems sensible to do so.

Note though, that if you run this, you’ll see that the response’s content-type is application/json, not application/api-problem+json. You can’t set this in onRenderError though as the view’s JSON strategy will override it.

A brute-force solution to this is to override the content-type header in a listener on the finish event. Firstly we update onBootstrap():

class Module
{
    public function onBootstrap(MvcEvent $e)
    {
        // ...
        $events->attach(MvcEvent::EVENT_FINISH, array($this, 'onFinish'));
    }

Then we write the onFinish listener:

    public function onFinish($e)
    {
        $response = $e->getResponse();
        $headers = $response->getHeaders();
        $contentType = $headers->get('Content-Type');
        if (strpos($contentType->getFieldValue(), 'application/json') !== false
            && strpos($response->getContent(), 'httpStatus')) {
            // This is (almost certainly!) an api-problem
            $headers->addHeaderLine('Content-Type', 'application/api-problem+json');
        }
    }

This method simply looks at the response and tries to guess if it’s an api-problem. If it is, then it changes the content-type in the Response’s header.

Now you’re done.

You can test with curl:

$ curl -s -i -H "Accept: application/json" "http://localhost/booklist/public/invalid" 

which will return:

HTTP/1.1 404 Not Found
Date: Mon, 09 Sep 2013 08:55:01 GMT
Server: Apache/2.2.22 (Unix) DAV/2 PHP/5.4.19 mod_ssl/2.2.22 OpenSSL/0.9.8x
X-Powered-By: PHP/5.4.19
Content-Length: 100
Content-Type: application/api-problem+json

{"httpStatus":404,"title":"Not Found","detail":"The requested URL could not be matched by routing."} 

Of course, in an ideal world, someone would package this up into a module :)

4 thoughts on “Returning JSON errors in a ZF2 application

  1. These html errors bug me too. I wanted to test this out but am wondering where the best place would be to put onBootstrap(). You suggest Application/Module. The setup I've got here is a number of separate modules with admin and member controllers. All controllers inherit from a base controller (an abstract class) which itself extends Zend_Controller_Action. Should I put this code in the base controller then? Thanks!

  2. Is this still the way to return json errors in ZF2? Thinking this could be useful as a ZF2 module or simple library. Then I could add some tests around it. Will help older APIs built on ZF2 built before Apigility as Im assuming Apigility handles this eloquently. Haven't used apigility yet.

  3. There's probably other ways to do it, but I sill like this approach. I would guess you could simplify some of this by using some of Apigility's zf-campus components

  4. It does not make sense to first set the httpStatus response code and later update the response code with the exception code without updating the httpStatus.

Comments are closed.