Global RESTful Exception Handling
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 SubscribeWhen we throw an ApiProblemException
, we need our app to automatically turn that into a nicely-formatted API Problem JSON response and return it. That code will look like what we have down here for validation, but it needs to live in a global spot.
Whenever an exception is thrown in Symfony, it dispatches an event called kernel.exception
. If we attach a listener function to that event, we can take full control of how exceptions are handled. If creating a listener is new to you, we have a chapter on that in our Journey series called Interrupt Symfony with an Event Subscriber.
In AppBundle, create an EventListener
directory. Add a new class in here called ApiExceptionSubscriber
and make sure it's in the AppBundle\EventListener
namespace:
// ... lines 1 - 2 | |
namespace AppBundle\EventListener; | |
class ApiExceptionSubscriber | |
{ | |
} |
There are two ways to hook into an event: via a listener or a subscriber. They're really the same thing, but I think subscribers are cooler. To hook one up, make this class implement EventSubscriberInterface
- the one from Symfony. Now, hit cmd+n - or go to the the Code->Generate menu - select "Implement Methods" and select getSubscribedEvents
. That's a fast way to generate the one method from EventSubscriberInterface
that we need to fill in:
// ... lines 1 - 4 | |
use Symfony\Component\EventDispatcher\EventSubscriberInterface; | |
class ApiExceptionSubscriber implements EventSubscriberInterface | |
{ | |
public static function getSubscribedEvents() | |
{ | |
// TODO: Implement getSubscribedEvents() method. | |
} | |
} |
Return an array with one just entry. The key is the event's name - use KernelEvents::EXCEPTION
- that's really just the string kernel.exception
. Assign that to the string: onKernelException
. That'll be the name of our method in this class that should be called whenever an exception is thrown. Create that method: public function onKernelException()
:
// ... lines 1 - 5 | |
use Symfony\Component\HttpKernel\KernelEvents; | |
// ... line 7 | |
class ApiExceptionSubscriber implements EventSubscriberInterface | |
{ | |
public function onKernelException() | |
{ | |
} | |
public static function getSubscribedEvents() | |
{ | |
return array( | |
KernelEvents::EXCEPTION => 'onKernelException' | |
); | |
} | |
} |
So once we tell Symfony about this class, whenever an exception is thrown, Symfony will call this method. And when it does, it'll pass us an $event
argument object. But what type of object is that? Hold cmd - or control for Windows and Linux - and click the EXCEPTION
constant. The documentation above it tells us that we'll be passed a GetResponseForExceptionEvent
object. Close that class and type-hint the event argument. Don't forget your use
statement:
// ... lines 1 - 5 | |
use Symfony\Component\HttpKernel\Event\GetResponseForExceptionEvent; | |
// ... lines 7 - 8 | |
class ApiExceptionSubscriber implements EventSubscriberInterface | |
{ | |
public function onKernelException(GetResponseForExceptionEvent $event) | |
{ | |
} | |
// ... lines 15 - 21 | |
} |
The Subscriber Logic
Listeners to kernel.exception
have one big job: to try to understand what went wrong and return a Response for the error. The big exception page we see in dev
mode is caused by a core Symfony listener to this same event. We throw an exception and it gives us the pretty exception page.
So our missing is clear: detect if an ApiProblemException
was thrown and create a nice Api Problem JSON response if it was.
First, to get access to the exception that was just thrown, call getException()
on the $event
. So far, we only want our listener to act if this is an ApiProblemException
object. Add an if statment: if !$e instanceof ApiProblemException
, then just return immediately:
// ... lines 1 - 11 | |
public function onKernelException(GetResponseForExceptionEvent $event) | |
{ | |
$e = $event->getException(); | |
if (!$e instanceof ApiProblemException) { | |
return; | |
} | |
} | |
// ... lines 19 - 27 |
For now, that'll mean that normal exceptions will still be handled via Symfony's core listener.
Now that we know this is an ApiProblemException
, let's turn it into a Response. Go steal the last few lines of the validation response code from ProgrammerController
. Put this inside onKernelException()
. You'll need to add the use
statement for JsonResponse
manually:
// ... lines 1 - 12 | |
public function onKernelException(GetResponseForExceptionEvent $event) | |
{ | |
$e = $event->getException(); | |
if (!$e instanceof ApiProblemException) { | |
return; | |
} | |
// ... lines 19 - 21 | |
$response = new JsonResponse( | |
$apiProblem->toArray(), | |
$apiProblem->getStatusCode() | |
); | |
$response->headers->set('Content-Type', 'application/problem+json'); | |
// ... lines 27 - 28 | |
} | |
// ... lines 30 - 38 |
But we don't have an $apiProblem
variable yet. There is an ApiProblem
object inside the ApiProblemException
as a property, but we don't have a way to access it yet. Go back to the Generate menu - select Getters - and choose the apiProblem
property:
// ... lines 1 - 6 | |
class ApiProblemException extends HttpException | |
{ | |
// ... lines 9 - 19 | |
public function getApiProblem() | |
{ | |
return $this->apiProblem; | |
} | |
} |
In the subscriber, we can say $apiProblem = $e->getApiProblem()
:
// ... lines 1 - 10 | |
class ApiExceptionSubscriber implements EventSubscriberInterface | |
{ | |
public function onKernelException(GetResponseForExceptionEvent $event) | |
{ | |
// ... lines 15 - 19 | |
$apiProblem = $e->getApiProblem(); | |
$response = new JsonResponse( | |
$apiProblem->toArray(), | |
$apiProblem->getStatusCode() | |
); | |
$response->headers->set('Content-Type', 'application/problem+json'); | |
// ... lines 27 - 28 | |
} | |
// ... lines 30 - 36 | |
} |
This is now exactly the Response we want to send back to the client. To tell Symfony to use this, call $event->setResponse()
and pass it the $response
:
// ... lines 1 - 12 | |
public function onKernelException(GetResponseForExceptionEvent $event) | |
{ | |
// ... lines 15 - 27 | |
$event->setResponse($response); | |
} | |
// ... lines 30 - 38 |
Registering the Event Subscriber
There's just one more step left: telling Symfony about the subscriber. Go to app/config/services.yml
. Give the service a name - how about api_problem_subscriber
. Then fill in the class
with ApiExceptionSubscriber
and give it an empty arguments
key. The secret to telling Symfony that this service is an event subscriber is with a tag named kernel.event_subscriber
:
// ... lines 1 - 5 | |
services: | |
// ... lines 7 - 19 | |
api_exception_subscriber: | |
class: AppBundle\EventListener\ApiExceptionSubscriber | |
arguments: [] | |
tags: | |
- { name: kernel.event_subscriber } |
That tag is enough to tell Symfony about our subscriber - it'll take care of the rest.
Head back to our test where we send invalid JSON and expect the 400 status code. This already worked before, but the response was HTML, so the next assert - for a JSON response with a type
property - has been failing hard. Actually, I totally messed up that assert earlier - make sure you're asserting a type
key, not test
:
// ... lines 1 - 5 | |
class ProgrammerControllerTest extends ApiTestCase | |
{ | |
// ... lines 8 - 147 | |
public function testInvalidJson() | |
{ | |
// ... lines 150 - 162 | |
$this->asserter()->assertResponsePropertyEquals($response, 'type', 'invalid_body_format'); | |
} | |
} |
So, type
should be set to invalid_body_format
because the ApiProblem
has that type set via the constant:
// ... lines 1 - 18 | |
class ProgrammerController extends BaseController | |
{ | |
// ... lines 21 - 142 | |
private function processForm(Request $request, FormInterface $form) | |
{ | |
$data = json_decode($request->getContent(), true); | |
if ($data === null) { | |
$apiProblem = new ApiProblem(400, ApiProblem::TYPE_INVALID_REQUEST_BODY_FORMAT); | |
throw new ApiProblemException($apiProblem); | |
} | |
// ... lines 151 - 153 | |
} | |
// ... lines 155 - 187 | |
} | |
} |
But with the exception subscriber in place, we should now get the JSON response we want. Ok, moment of truth:
./bin/phpunit -c app --filter testInvalidJson
It passes! This is huge! We now have a central way for triggering and handling errors. Take out the debugResponse()
call.
Celebrate by throwing an ApiProblemException
for validations errors too. Replace all the Response-creation logic in createValidationErrorResponse()
with a simple throw new ApiProblemException()
and pass it the $apiProblem
:
// ... lines 1 - 18 | |
class ProgrammerController extends BaseController | |
{ | |
// ... lines 21 - 173 | |
private function createValidationErrorResponse(FormInterface $form) | |
{ | |
// ... lines 176 - 183 | |
throw new ApiProblemException($apiProblem); | |
} | |
} |
That's all the code we need now, no matter where we are. And now, the method name - createValidationErrorResponse()
isn't really accurate. Change it to throwApiProblemValidationException()
:
// ... lines 1 - 18 | |
class ProgrammerController extends BaseController | |
{ | |
// ... lines 21 - 173 | |
private function throwApiProblemValidationException(FormInterface $form) | |
{ | |
// ... lines 176 - 183 | |
throw new ApiProblemException($apiProblem); | |
} | |
} |
Search for the 2 spots that use that and update the name. And we don't need to have a return
statement anymore: just call the function and it'll throw the exception for us:
// ... lines 1 - 24 | |
public function newAction(Request $request) | |
{ | |
// ... lines 27 - 30 | |
if (!$form->isValid()) { | |
$this->throwApiProblemValidationException($form); | |
} | |
// ... lines 34 - 48 | |
} | |
// ... lines 50 - 91 | |
public function updateAction($nickname, Request $request) | |
{ | |
// ... lines 94 - 107 | |
if (!$form->isValid()) { | |
$this->throwApiProblemValidationException($form); | |
} | |
// ... lines 111 - 118 | |
} | |
// ... lines 120 - 185 | |
} |
Re-test everything:
./bin/phpunit -c app
Now we're green and we can send back exciting error responses from anywhere in our code. But what about other exceptions, like 404 exceptions?
YAGT! Yet Another Great Tutorial.
So I am using these concepts in Symfony 4, and one of the great new features is the automatic lookup for an entity when you type hint the controller method with the Entity Class like this:
The problem is that the @ParamConverter catches the error first. That is not so bad, but I wanted the message to better than "App\Entity\VEConfig object not found by the @ParamConverter annotation."
Something like "Your VE Configuration could not be found. VEConfigUID = %s", $VEConfigUID
The only thing I could think of was to hack the apiExceptionSubscriber, and intercept the message, but that was not clean. Any Ideas?