Login to bookmark this video
Buy Access to Course
12.

Handling 404's + other Errors

Share this awesome video!

|

Keep on Learning!

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

Login Subscribe

What should the structure of a 404 response from our API look like? It's obvious: we'll want to return that same API Problem JSON response format. We want to return this whenever anything goes wrong.

Planning the Response

Start by planning out how the 404 should look with a new test method - test404Exception. Let's make a GET request to /api/programmers/fake and assert the easy part: that the status code is 404. We also know that we want the nice application/problem+json Content-Type header, so assert that too:

// ... lines 1 - 5
class ProgrammerControllerTest extends ApiTestCase
{
// ... lines 8 - 165
public function test404Exception()
{
$response = $this->client->get('/api/programmers/fake');
$this->assertEquals(404, $response->getStatusCode());
$this->assertEquals('application/problem+json', $response->getHeader('Content-Type'));
// ... lines 172 - 173
}
}

We know the JSON will at least have type and title properties. So what would be good values for those? This is a weird situation. Usually, type conveys what happened. But in this case, the 404 status code already says everything we need to. Using some type value like not_found would be fine, but totally redundant.

Look back at the Problem Details Spec. Under "Pre-Defined Problem Types", it says that if the status code is enough, you can set type to about:blank. And when you do this, it says that we should set title to whatever the standard text is for that status code. A 404 would be "Not Found".

Add this to the test: use $this->asserter()->assertResponsePropertyEquals() to assert that type is about:blank. And do this all again to assert that title is Not Found:

// ... lines 1 - 165
public function test404Exception()
{
$response = $this->client->get('/api/programmers/fake');
$this->assertEquals(404, $response->getStatusCode());
$this->assertEquals('application/problem+json', $response->getHeader('Content-Type'));
$this->asserter()->assertResponsePropertyEquals($response, 'type', 'about:blank');
$this->asserter()->assertResponsePropertyEquals($response, 'title', 'Not Found');
}
// ... lines 175 - 176

How 404's Work

A 404 happens whenever we call $this->createNotFoundException() in a controller. If you hold cmd or ctrl and click that method, you'll see that this is just a shortcut to throw a special NotFoundHttpException. And all of the other errors that might happen will ultimately just be different exceptions being thrown from different parts of our app.

The only thing that makes this exception special is that it extends that very-important HttpException class. That's why throwing this causes a 404 response. But otherwise, it's equally as exciting as any other exception.

Handling all Errors

In ApiExceptionSubscriber, we're only handling ApiException's so far. But if we handled all exceptions, we could turn everything into the nice format we want.

Reverse the logic on the if statement and set the $apiProblem variable inside:

// ... lines 1 - 12
class ApiExceptionSubscriber implements EventSubscriberInterface
{
public function onKernelException(GetResponseForExceptionEvent $event)
{
$e = $event->getException();
if ($e instanceof ApiProblemException) {
$apiProblem = $e->getApiProblem();
} else {
// ... lines 22 - 26
}
// ... lines 28 - 35
}
// ... lines 37 - 43
}

Add an else. In all other cases, we'll need to create the ApiProblem ourselves. The first thing we need to figure out is what status code this exception should have. Create a $statusCode variable. Here, check if $e is an instanceof HttpExceptionInterface: that special interface that lets an exception control its status code. So if it is, set the status code to $e->getStatusCode(). Otherwise, we have to assume that it's 500:

// ... lines 1 - 14
public function onKernelException(GetResponseForExceptionEvent $event)
{
// ... lines 17 - 18
if ($e instanceof ApiProblemException) {
// ... line 20
} else {
$statusCode = $e instanceof HttpExceptionInterface ? $e->getStatusCode() : 500;
// ... lines 23 - 26
}
// ... lines 28 - 35
}
// ... lines 37 - 45

Now use this to create an ApiProblem: $apiProblem = new ApiProblem() and pass it the $statusCode:

// ... lines 1 - 14
public function onKernelException(GetResponseForExceptionEvent $event)
{
// ... lines 17 - 18
if ($e instanceof ApiProblemException) {
// ... line 20
} else {
$statusCode = $e instanceof HttpExceptionInterface ? $e->getStatusCode() : 500;
$apiProblem = new ApiProblem(
$statusCode
);
}
// ... lines 28 - 35
}
// ... lines 37 - 45

For the type argument, we could pass about:blank - that is what we want. But then in ApiProblem, we'll need a constant for this, and that constant will need to be mapped to a title. But we actually want the title to be dynamic based on whatever the status code is: 404 is "Not Found", 403 is "Forbidden", etc. So, don't pass anything for the type argument. Let's handle all of this logic inside ApiProblem itself.

In there, start by making the $type argument optional:

77 lines | src/AppBundle/Api/ApiProblem.php
// ... lines 1 - 8
class ApiProblem
{
// ... lines 11 - 26
public function __construct($statusCode, $type = null)
{
// ... lines 29 - 47
}
// ... lines 49 - 75
}

And if $type is exactly null, then set it to about:blank. Make sure the $this->type = $type assignment happens after all of this:

77 lines | src/AppBundle/Api/ApiProblem.php
// ... lines 1 - 26
public function __construct($statusCode, $type = null)
{
// ... lines 29 - 30
if ($type === null) {
// no type? The default is about:blank and the title should
// be the standard status code message
$type = 'about:blank';
// ... lines 35 - 43
}
$this->type = $type;
// ... line 47
}
// ... lines 49 - 77

For $title, we just need a map from the status code to its official description. Go to Navigate->Class - that's cmd+o on a Mac. Look for Response and open the one inside HttpFoundation. It has a really handy public $statusTexts map that's exactly what we want:

// ... lines 1 - 11
namespace Symfony\Component\HttpFoundation;
// ... lines 13 - 20
class Response
// ... lines 22 - 124
public static $statusTexts = array(
// ... lines 126 - 150
403 => 'Forbidden',
404 => 'Not Found',
// ... lines 153 - 185
);
// ... lines 187 - 1274
}

Set the $title variable - but use some if logic in case we have some weird status code for some reason. If it is in the $statusTexts array, use it. Otherwise, well, this is kind of a weird situation. Use Unknown Status Code with a frowny face:

77 lines | src/AppBundle/Api/ApiProblem.php
// ... lines 1 - 26
public function __construct($statusCode, $type = null)
{
// ... lines 29 - 30
if ($type === null) {
// ... lines 32 - 33
$type = 'about:blank';
$title = isset(Response::$statusTexts[$statusCode])
? Response::$statusTexts[$statusCode]
: 'Unknown status code :(';
// ... lines 38 - 43
}
// ... lines 45 - 47
}
// ... lines 49 - 77

If the $type is set - we're in the normal case. Move the check up there and add $title = self::$titles[$type]. After everything, assign $this->title = $title:

77 lines | src/AppBundle/Api/ApiProblem.php
// ... lines 1 - 26
public function __construct($statusCode, $type = null)
{
// ... lines 29 - 30
if ($type === null) {
// ... lines 32 - 37
} else {
if (!isset(self::$titles[$type])) {
throw new \InvalidArgumentException('No title for type '.$type);
}
$title = self::$titles[$type];
}
$this->type = $type;
$this->title = $title;
}
// ... lines 49 - 77

Now the code we wrote in ApiExceptionSubscriber should work: a missing $type tells ApiProblem to use all the about:blank stuff. Time to try this: copy the test method name, then run:

./bin/phpunit -c app --filter test404Exception

Aaaand that's green. It's so nice when things work.

What we just did is huge. If a 404 exception is thrown anywhere in the system, it'll map to the nice Api Problem format we want. In fact, if any exception is thrown it ends up with that format. So if your database blows, an exception is thrown. Sure, that'll map to a 500 status code, but the JSON format will be just like every other error.