Handling 404's + other Errors
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 SubscribeWhat 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:
// ... 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:
// ... 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:
// ... 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
:
// ... 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.
Hi Ryan, I am not getting the 404 error code, everything seems to be fine but it returns a 500 code. I'm stuck :(
Failure! when making the following request:
GET: http://dev.dev/rest-symfony...
HTTP/1.1 500 Internal Server Error
Date: Mon, 04 Jan 2016 11:38:03 GMT
Server: Apache/2.4.12 (Ubuntu)
Cache-Control: no-cache, no-cache
Content-Length: 67
Connection: close
Content-Type: application/problem+json
{
"status": 500,
"type": "about:blank",
"title": "Internal Server Error"
}