If you liked what you've learned so far, dive in!
Subscribe to get access to this tutorial plus
video, code and script downloads.
When 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 | |
} |
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 |
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?
// composer.json
{
"require": {
"php": ">=5.3.3",
"symfony/symfony": "2.6.*", // v2.6.11
"doctrine/orm": "~2.2,>=2.2.3,<2.5", // v2.4.7
"doctrine/dbal": "<2.5", // v2.4.4
"doctrine/doctrine-bundle": "~1.2", // v1.4.0
"twig/extensions": "~1.0", // v1.2.0
"symfony/assetic-bundle": "~2.3", // v2.6.1
"symfony/swiftmailer-bundle": "~2.3", // v2.3.8
"symfony/monolog-bundle": "~2.4", // v2.7.1
"sensio/distribution-bundle": "~3.0,>=3.0.12", // v3.0.21
"sensio/framework-extra-bundle": "~3.0,>=3.0.2", // v3.0.7
"incenteev/composer-parameter-handler": "~2.0", // v2.1.0
"hautelook/alice-bundle": "0.2.*", // 0.2
"jms/serializer-bundle": "0.13.*" // 0.13.0
},
"require-dev": {
"sensio/generator-bundle": "~2.3", // v2.5.3
"behat/behat": "~3.0", // v3.0.15
"behat/mink-extension": "~2.0.1", // v2.0.1
"behat/mink-goutte-driver": "~1.1.0", // v1.1.0
"behat/mink-selenium2-driver": "~1.2.0", // v1.2.0
"phpunit/phpunit": "~4.6.0" // 4.6.4
}
}