If you liked what you've learned so far, dive in!
Subscribe to get access to this tutorial plus
video, code and script downloads.
With a Subscription, click any sentence in the script to jump to that part of the video!
Login SubscribeIn 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 |
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
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.
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
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.
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.
Fixed it:
App\EventListener\ApiExceptionSubscriber:
autowire: false
arguments: ['%kernel.debug%', '@api.response_factory']
tags:
- { name: kernel.event_subscriber }
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!
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.
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.
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!
// 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
}
}
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);