Buy

ResponseFactory: Centralize Error Responses

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:

80 lines src/AppBundle/EventListener/ApiExceptionSubscriber.php
... 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:

80 lines src/AppBundle/EventListener/ApiExceptionSubscriber.php
... 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:

25 lines src/AppBundle/Api/ResponseFactory.php
... 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:

42 lines app/config/services.yml
... 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:

42 lines app/config/services.yml
... 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:

73 lines src/AppBundle/EventListener/ApiExceptionSubscriber.php
... 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:

73 lines src/AppBundle/EventListener/ApiExceptionSubscriber.php
... 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:

97 lines src/AppBundle/Security/JwtTokenAuthenticator.php
... 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:

97 lines src/AppBundle/Security/JwtTokenAuthenticator.php
... 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:

42 lines app/config/services.yml
... 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:

37 lines tests/AppBundle/Controller/Api/TokenControllerTest.php
... 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:

97 lines src/AppBundle/Security/JwtTokenAuthenticator.php
... 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!

  • 2016-08-11 weaverryan

    Yo Chuck!

    Yep, you've got it exactly - I won't even re-summarize because you described it perfectly :).

    But, btw - there's a lazy way to handle authentication with JS: if you have a traditional server-side login form that uses cookies to log you in (i.e. the way we've been logging people in for 15 years), then if you make AJAX calls from React or anything else, it will automatically be authenticated because it sends the session cookie. This would no longer be a pure, 100% client-side app, but honestly, I think sometimes when people are building an API just to support their JS frontend, they don't realize this is also an option :).

    Cheers!

  • 2016-08-10 Chuck Norris

    Maybe I think I get it.
    Tell me if I'm wrong or not.

    If I plug a REST Client, like a frontend React app and build a login page, the React app send the Users crendentials to the TokenController and then get back the token. Then, with the client can send this token inside the header to make authentication.

  • 2016-08-10 Chuck Norris

    Hi Ryan,

    Just wondering : what's the purpose of the TokenController, I mean I know its role is to return a Token, but why we need it?

    We can authenticate in ProgrammerController without it.

  • 2016-05-31 weaverryan

    Awesome! Thanks for sharing your nice (and simpler) solution!

  • 2016-05-26 Mahmood Bazdar

    Yes it works for me and all tests are passed! but because you are the author of Symfony security Guard you know the structure better.

    I'm so glad that I have found your website and courses.

  • 2016-05-25 weaverryan

    Hi Mahmood!

    Thank you :). And you know, honestly, it hadn't occurred to me - but it's really interesting! Does your code work?

    By the time start() is called, you're actually already inside of Symfony's Exception-handling logic. So, throwing *another* exception *might* cause problems... but I actually think you're right (as the security system is built to allow this).

    In other words - I wish I had thought of this! :D

  • 2016-05-24 Mahmood Bazdar

    Hey Ryan. great course, I have a question: Why not just throw new ApiProblemException?
    like this:

    $apiProblem=new ApiProblem(401);
    $message=($authException)?$authException->getMessageKey():"Missing credentials.";
    $apiProblem->set("detail",$message);
    throw new ApiProblemException($apiProblem);