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