Buy

The All-Important HttpExceptionInterface

What do you think would happen if we POST'ed some badly-formatted JSON to an endpoint? Because, I'm not really sure - but I bet the error wouldn't be too obvious to the client.

The invalidJson Test

Let's add a test for this and think about how we want our API to act if someone mucks up their JSON. Copy testValidationErrors() - it should be pretty similar. Name the new method testInvalidJSON():

166 lines src/AppBundle/Tests/Controller/Api/ProgrammerControllerTest.php
... lines 1 - 5
class ProgrammerControllerTest extends ApiTestCase
{
... lines 8 - 147
public function testInvalidJson()
{
... lines 150 - 162
}
... line 164
}

And we can't use this $data array anymore, json_encode is too good at creating valid JSON. Overachiever. Replace it with an $invalidJson variable - we'll have to create really bad JSON ourselves. Let's see here, start with one piece of valid JSON, remove a comma and liven things up with a hanging quotation mark, and that oughta do it! Now pass $invalidJson as the request body:

166 lines src/AppBundle/Tests/Controller/Api/ProgrammerControllerTest.php
... lines 1 - 147
public function testInvalidJson()
{
$invalidBody = <<<EOF
{
"avatarNumber" : "2
"tagLine": "I'm from a test!"
}
EOF;
$response = $this->client->post('/api/programmers', [
'body' => $invalidBody
]);
... lines 160 - 162
}
... lines 164 - 166

Ok, time to think about how we want the response to look. The 400 status code is good. Invalid JSON is the client's fault, and any status code starting with 4 is for when they mess up. You could also use 422 - Unprocessable Entity - if you want to enhance your nerdery. But, nobody is going to notice.

And since we're curious about how our API currently handles invalid JSON, use $this->debugResponse() right above the assert:

166 lines src/AppBundle/Tests/Controller/Api/ProgrammerControllerTest.php
... lines 1 - 147
public function testInvalidJson()
{
... lines 150 - 156
$response = $this->client->post('/api/programmers', [
'body' => $invalidBody
]);
$this->debugResponse($response);
$this->assertEquals(400, $response->getStatusCode());
}
... lines 164 - 166

Copy the test name and give it a try:

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

Cool - the test passes with a 400 response, but the error isn't about having invalid JSON. Instead, it looks like we're missing our nickname. Ok, so let's add a nickname:

167 lines src/AppBundle/Tests/Controller/Api/ProgrammerControllerTest.php
... lines 1 - 147
public function testInvalidJson()
{
$invalidBody = <<<EOF
{
"nickname": "JohnnyRobot",
"avatarNumber" : "2
"tagLine": "I'm from a test!"
}
EOF;
$response = $this->client->post('/api/programmers', [
'body' => $invalidBody
]);
... lines 161 - 163
}
... lines 165 - 167

And try the test again:

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

And we still fail because the nickname field is missing. So apparently, if we send invalid JSON, it acts like we're sending nothing. So good luck to any future API client trying to debug this.

Handling Invalid JSON

In ProgrammerController, search for json_decode - you'll find it in processForm():

187 lines src/AppBundle/Controller/Api/ProgrammerController.php
... lines 1 - 17
class ProgrammerController extends BaseController
{
... lines 20 - 141
private function processForm(Request $request, FormInterface $form)
{
$data = json_decode($request->getContent(), true);
... lines 145 - 150
}
... lines 152 - 185
}

If the $body has a bad format, then $data will be null. Add an if to test for that: if null === $data then we need to return that 400 status code:

187 lines src/AppBundle/Controller/Api/ProgrammerController.php
... lines 1 - 141
private function processForm(Request $request, FormInterface $form)
{
$data = json_decode($request->getContent(), true);
if ($data === null) {
... line 146
}
... lines 148 - 150
}
... lines 152 - 187

The HttpException(Interface)

But wait! There's a huge problem! When processForm() is called, its return value isn't used:

187 lines src/AppBundle/Controller/Api/ProgrammerController.php
... lines 1 - 17
class ProgrammerController extends BaseController
{
... lines 20 - 23
public function newAction(Request $request)
{
... lines 26 - 27
$this->processForm($request, $form);
... lines 29 - 47
}
... lines 49 - 90
public function updateAction($nickname, Request $request)
{
... lines 93 - 104
$this->processForm($request, $form);
... lines 106 - 117
}
... lines 119 - 185
}

So if we return a Response from processForm()... good for us! Nobody will actually do anything with that: newAction will continue on like normal. The only way we can break the flow from inside processForm() is by throwing an exception. But as you're probably thinking, if you throw an exception in Symfony, that turns into a 500 error. We need a 400 error.

And it turns out, that's totally possible - and it's a really important concept for API's. First, I just said that throwing an exception causes a 500 error in Symfony. That's just not the whole story. Throw a new HttpException from HttpKernel. It has 2 arguments: the status code - 400 - and a message - just "Invalid JSON" for now. Don't worry yet about returning our nice API problem JSON:

187 lines src/AppBundle/Controller/Api/ProgrammerController.php
... lines 1 - 141
private function processForm(Request $request, FormInterface $form)
{
$data = json_decode($request->getContent(), true);
if ($data === null) {
throw new HttpException(400, 'Invalid JSON body!');
}
... lines 148 - 150
}
... lines 152 - 187

So here's the truth about exceptions: any exception will turn into a 500 error, unless that exception implements the HttpExceptionInterface:

35 lines vendor/symfony/symfony/src/Symfony/Component/HttpKernel/Exception/HttpExceptionInterface.php
... lines 1 - 11
namespace Symfony\Component\HttpKernel\Exception;
... lines 13 - 18
interface HttpExceptionInterface
{
... lines 21 - 25
public function getStatusCode();
... lines 27 - 32
public function getHeaders();
}

It has two functions: getStatusCode() and getHeaders(). The HttpException class we're throwing implements this. That means we can throw this from anywhere in our code, stop the flow, but control the status code of the response. Symfony ships with a bunch of convenience sub-classes for common status codes, like ConflictHttpException, which automatically gives you a 409 status code. All of those classes are optional: you could use HttpException for everything.

Ok, back to reality. Since we're throwing this, the response should still be 400, so the test should still pass. But, instead of getting back a validation error, we should get back our simple "Invalid JSON" text message:

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

Yep! It passes and prints out "Invalid JSON". The "There was an error" part is from my test helper - but the red text below is the actual response. The point is that we are handling invalid JSON now, but we're not sending back the awesome API Problem JSON format yet.

Leave a comment!