Improving our API User Experience


In this video we are going to improve the developer and end user experience of our API by adding in a helpful error message in the event that a user forgets to set, or sets an invalid Content-type header.

We really only care about the Content-type header when a user is making a POST, PUT, or PATCH. Without setting the header, I found that the API was returning a false positive - that is to say it would return the expected status code but not actually update the data. Sad panda.

This caught me out for a few hours in truth. There's nothing worse than feeling the fatigue after a few hours of solid development, only to find an issue that manifests itself strange. In my case this was that the request worked just fine in Postman client, but the Behat tests were showing a test fail for the same operation.

This turned out to be that I hadn't remember to set the Content-type to application/json in any of my requests, so I fixed that with the following:

// src/AppBundle/Features/Context/RestApiContext.php

    /**
     * @Given when consuming the endpoint I use the :header of :value
     */
    public function whenConsumingTheEndpointIUseTheOf($header, $value)
    {
        $this->client->setDefaultOption($header, $value);
    }

Which allowed me to use a Behat Background step of:

 And when consuming the endpoint I use the "headers/content-type" of "application/json"

This effectively sets the Content-type header on any request, not just POST, or PATCH etc. But honestly, for the purposes of my test suite this is good enough.

However, I wasn't happy with how long I'd -spent- wasted on this issue. And worse, if I'd wasted this time, then I owed it to my API consumers to save them the same headache. Otherwise, well, what a load of wasted effort, right ?

Listening For Mistakes

I've covered Symfony Event Listeners before here on Code Review Videos.

Simply put, through the journey between your end user's Request arriving at your server, and your code doing interesting things with that Request, and then Symfony returning a Response, there are many Events that are dispatched.

Remember, Symfony is really all about the journey of Request to Response. It makes sense that at various points throughout that journey that developers may be interested in what is happening. We, as developers, can register our own listeners that sit alongside the Symfony core listeners, and third party bundle listeners.

A listener is doing exactly that. It listens for these various events and, if configured to do so, responds to the event data that it receives.

One point to note here - listeners are listening to every single request. We want any listener to fail fast, as if we have some longwinded logic that takes place on every request, our system will be sloooow.

In our case, I wanted to make sure that any future developers or API consumers are given a very explicit error as to why things went wrong:

# src/AppBundle/Features/developer_experience.feature

Feature: To improve the Developer Experience of using this API

  In order to offer an API user a better experience
  As a client software developer
  I need to return useful information when situations may be otherwise confusing

  Background:
    Given there are users with the following details:
      | uid  | username | email          | password |
      | a1   | peter    | peter@test.com | testpass |
    And I am successfully logged in with username: "peter", and password: "testpass"
    And when consuming the endpoint I use the "headers/content-type" of "application/json"

  Scenario: User must use the right Content-type
    When I have forgotten to set the "headers/content-type"
     And I send a "PATCH" request to "/users/a1"
    Then the response code should be 415
     And the response should contain json:
      """
      {
        "code": 415,
        "message": "Invalid or missing Content-type header"
      }
      """

As a side note, there may be a way to do this with FOSRESTBundle right out of the box. If there is, please do let me know.

Almost all of this is re-usable code from our existing Behat features, with one exception:

    When I have forgotten to set the "headers/content-type"

And this one is very easy to implement:

// src/AppBundle/Features/Context/RestApiContext.php

    /**
     * @When I have forgotten to set the :header
     */
    public function iHaveForgottenToSetThe($header)
    {
        $this->client->setDefaultOption($header, null);
    }

Which effectively nulls out any header value we pass in - e.g. the header we set in our Background step.

We just need to implement the listener:

<?php

// src/AppBundle/Event/Listener/ContentTypeListener.php

namespace AppBundle\Event\Listener;

use AppBundle\Exception\HttpContentTypeException;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpKernel\Event\GetResponseEvent;
use Symfony\Component\Routing\RouterInterface;

class ContentTypeListener
{
    const MIME_TYPE_APPLICATION_JSON = 'application/json';
    const MIME_TYPE_MULTIPART_FORM_DATA = 'multipart/form-data';

    public function onKernelRequest(GetResponseEvent $event)
    {
        $request = $event->getRequest();

        if ($request->headers->contains('Content-type', self::MIME_TYPE_APPLICATION_JSON)) {
            return true;
        }

        if ($request->getMethod() === Request::METHOD_GET) {
            return true;
        }

        if ($this->isMultipartFilePost($request)) {
            return true;
        }

        throw new HttpContentTypeException();
    }

    private function isMultipartFilePost(Request $request)
    {
        $contentType = $request->headers->get('Content-type');

        $isMultipart = (strpos($contentType, self::MIME_TYPE_MULTIPART_FORM_DATA) !== false ? true : false);

        if ($isMultipart && $request->get('_route') === 'post_accounts_files') {
            return true;
        }

        return false;
    }
}

And declare the service:

# app/config/services.yml

    crv.event.listener.content_type_listener:
        class: AppBundle\Event\Listener\ContentTypeListener
        tags:
            - { name: kernel.event_listener, event: kernel.request, method: onKernelRequest }

With that, any request that comes in to our API will be checked against our logic, and either return and continue, or throw the following simple error:

<?php

// src/AppBundle/Exception/HttpContentTypeException.php

namespace AppBundle\Exception;

use Symfony\Component\HttpKernel\Exception\HttpException;

class HttpContentTypeException extends HttpException
{
    const ERROR_CODE = 415;
    const ERROR_MESSAGE = 'Invalid or missing Content-type header';

    public function __construct()
    {
        parent::__construct(self::ERROR_CODE, self::ERROR_MESSAGE);
    }
}

If you can think of, or know of a better way to do this then by all means let me know by leaving a comment below.

Code For This Course

Get the code for this course.

Episodes