This tutorial has a new version, check it out!

ResponseFactory: Centralize Error Responses

Keep on Learning!

If you liked what you've learned so far, dive in!
Subscribe to get access to this tutorial plus
video, code and script downloads.

Start your All-Access Pass
Buy just this tutorial for $10.00

In the EventListener directory, we created an ApiExceptionSubscriber whose job is to catch all exceptions and turn them into nice API problem responses. And it already has all of the logic we need to turn an ApiProblem object into a proper response:

... lines 1 - 12
class ApiExceptionSubscriber implements EventSubscriberInterface
{
... lines 15 - 21
public function onKernelException(GetResponseForExceptionEvent $event)
{
... lines 24 - 70
}
public static function getSubscribedEvents()
{
return array(
KernelEvents::EXCEPTION => 'onKernelException'
);
}
}

Instead of re-doing this in the authenticator, let's centralize and re-use this stuff! Copy the last ten lines or so out of ApiExceptionSubscriber:

... lines 1 - 12
class ApiExceptionSubscriber implements EventSubscriberInterface
{
... lines 15 - 21
public function onKernelException(GetResponseForExceptionEvent $event)
{
... lines 24 - 57
$data = $apiProblem->toArray();
// making type a URL, to a temporarily fake page
if ($data['type'] != 'about:blank') {
$data['type'] = 'http://localhost:8000/docs/errors#'.$data['type'];
}
$response = new JsonResponse(
$data,
$apiProblem->getStatusCode()
);
$response->headers->set('Content-Type', 'application/problem+json');
... lines 69 - 70
}
... lines 72 - 78
}

And in the Api directory, create a new class called ResponseFactory. Inside, give this a public function called createResponse(). We'll pass it the ApiProblem and it will turn that into a JsonResponse:

... lines 1 - 2
namespace AppBundle\Api;
use Symfony\Component\HttpFoundation\JsonResponse;
class ResponseFactory
{
public function createResponse(ApiProblem $apiProblem)
{
$data = $apiProblem->toArray();
// making type a URL, to a temporarily fake page
if ($data['type'] != 'about:blank') {
$data['type'] = 'http://localhost:8000/docs/errors#'.$data['type'];
}
$response = new JsonResponse(
$data,
$apiProblem->getStatusCode()
);
$response->headers->set('Content-Type', 'application/problem+json');
return $response;
}
}

Perfect! Next, go into services.yml and register this: how about api.response_factory. Set the class to AppBundle\Api\ResponseFactory and leave off the arguments key:

... lines 1 - 5
services:
... lines 7 - 39
api.response_factory:
class: AppBundle\Api\ResponseFactory

Using the new ResponseFactory

We will definitely need this inside ApiExceptionSubscriber, so add it as a second argument: @api.response_factory:

... lines 1 - 5
services:
... lines 7 - 19
api_exception_subscriber:
... line 21
arguments: ['%kernel.debug%', '@api.response_factory']
... lines 23 - 42

In the class, add the second constructor argument. I'll use option+enter to quickly create that property and set it for me:

... lines 1 - 13
class ApiExceptionSubscriber implements EventSubscriberInterface
{
... line 16
private $responseFactory;
public function __construct($debug, ResponseFactory $responseFactory)
{
... line 21
$this->responseFactory = $responseFactory;
}
... lines 24 - 71
}

Below, it's very simple: $response = $this->responseFactory->createResponse() and pass it $apiProblem:

... lines 1 - 13
class ApiExceptionSubscriber implements EventSubscriberInterface
{
... lines 16 - 24
public function onKernelException(GetResponseForExceptionEvent $event)
{
... lines 27 - 60
$response = $this->responseFactory->createResponse($apiProblem);
... lines 62 - 63
}
... lines 65 - 71
}

LOVE it. Let's celebrate by doing the same in the authenticator. Add a third constructor argument and then create the property and set it:

... lines 1 - 19
class JwtTokenAuthenticator extends AbstractGuardAuthenticator
{
... lines 22 - 23
private $responseFactory;
public function __construct(JWTEncoderInterface $jwtEncoder, EntityManager $em, ResponseFactory $responseFactory)
{
... lines 28 - 29
$this->responseFactory = $responseFactory;
}
... lines 32 - 95
}

Down in start(), return $this->responseFactory->createResponse() and pass it $apiProblem:

... lines 1 - 19
class JwtTokenAuthenticator extends AbstractGuardAuthenticator
{
... lines 22 - 83
public function start(Request $request, AuthenticationException $authException = null)
{
... lines 86 - 93
return $this->responseFactory->createResponse($apiProblem);
}
}

Finally, go back to services.yml to update the arguments. Just kidding! We're using autowiring, so it will automatically add the third argument for us:

... lines 1 - 5
services:
... lines 7 - 35
jwt_token_authenticator:
... line 37
autowire: true
... lines 39 - 42

If everything went well, we should be able to re-run the test with great success:

./vendor/bin/phpunit --filter testPOSTTokenInvalidCredentials

detail(s) Make tests Fails

Oh, boy - it failed. Let's see - something is wrong with the detail field:

Error reading property detail from available keys details.

That sounds like a Ryan mistake! Open up TokenControllerTest: the test is looking for detail - with no s:

... lines 1 - 6
class TokenControllerTest extends ApiTestCase
{
... lines 9 - 22
public function testPOSTTokenInvalidCredentials()
{
... lines 25 - 33
$this->asserter()->assertResponsePropertyEquals($response, 'detail', 'Invalid credentials.');
}
}

That's correct. Inside JwtTokenAuthenticator, change that key to detail:

... lines 1 - 19
class JwtTokenAuthenticator extends AbstractGuardAuthenticator
{
... lines 22 - 83
public function start(Request $request, AuthenticationException $authException = null)
{
... lines 86 - 91
$apiProblem->set('detail', $message);
... lines 93 - 94
}
}

Ok, technically we can call this field whatever we want, but detail is kind of a standard.

Try the test again.

./vendor/bin/phpunit --filter testPOSTTokenInvalidCredentials

That looks perfect. In fact, run our entire test suite:

./vendor/bin/phpunit

Hey! We didn't break any of our existing error handling. Awesome!

But there is one more case we haven't covered: what happens if somebody sends a bad JSON web token - maybe it's expired. Let's handle that final case next.

Leave a comment!

This tutorial uses an older version of Symfony. The concepts of API tokens & JWT are still valid, but integration in newer Symfony versions may be different.

What PHP libraries does this tutorial use?

// composer.json
{
    "require": {
        "php": ">=5.5.9",
        "symfony/symfony": "3.0.*", // v3.0.3
        "doctrine/orm": "^2.5", // v2.5.4
        "doctrine/doctrine-bundle": "^1.6", // 1.6.2
        "doctrine/doctrine-cache-bundle": "^1.2", // 1.3.0
        "symfony/swiftmailer-bundle": "^2.3", // v2.3.11
        "symfony/monolog-bundle": "^2.8", // v2.10.0
        "sensio/distribution-bundle": "^5.0", // v5.0.4
        "sensio/framework-extra-bundle": "^3.0.2", // v3.0.14
        "incenteev/composer-parameter-handler": "~2.0", // v2.1.2
        "jms/serializer-bundle": "^1.1.0", // 1.1.0
        "white-october/pagerfanta-bundle": "^1.0", // v1.0.5
        "lexik/jwt-authentication-bundle": "^1.4" // v1.4.3
    },
    "require-dev": {
        "sensio/generator-bundle": "^3.0", // v3.0.6
        "symfony/phpunit-bridge": "^3.0", // v3.0.3
        "behat/behat": "~3.1@dev", // dev-master
        "behat/mink-extension": "~2.2.0", // v2.2
        "behat/mink-goutte-driver": "~1.2.0", // v1.2.1
        "behat/mink-selenium2-driver": "~1.3.0", // v1.3.1
        "phpunit/phpunit": "~4.6.0", // 4.6.10
        "doctrine/doctrine-fixtures-bundle": "^2.3" // 2.3.0
    }
}