Flag of Ukraine
SymfonyCasts stands united with the people of Ukraine
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

With a Subscription, click any sentence in the script to jump to that part of the video!

Login Subscribe

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!

10
Login or Register to join the conversation
Default user avatar
Default user avatar Mahmood Bazdar | posted 5 years ago

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);

2 Reply

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

Reply
Default user avatar
Default user avatar Mahmood Bazdar | weaverryan | posted 5 years ago

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.

Reply

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

Reply
Default user avatar
Default user avatar Majkell Veizaj | posted 4 years ago

I am using Symfony 4 and this is the error I am getting:
Cannot autowire service "App\EventListener\ApiExceptionSubscriber": argument "$debug" of method "__construct()" has no type-hint, you should configure its value explicitly.

Reply
Default user avatar

Fixed it:
App\EventListener\ApiExceptionSubscriber:
autowire: false
arguments: ['%kernel.debug%', '@api.response_factory']
tags:
- { name: kernel.event_subscriber }

Reply

Hey man,

you don't have to disable autowiring, you can specify just the argument that you need to.


App\EventListener\ApiExceptionSubscriber:
    arguments:
        $debug: '%kernel.debug%' # $debug must match the name of the argument

Cheers!

Reply
Chuck norris Avatar
Chuck norris Avatar Chuck norris | posted 5 years ago

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.

Reply
Chuck norris Avatar

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.

Reply

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!

Reply
Cat in space

"Houston: no signs of life"
Start the conversation!

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
    }
}
userVoice