Login to bookmark this video
Buy Access to Course
11.

JSON Errors in your API

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

Whenever something goes wrong in our API, we have a great setup: we always get back a descriptive JSON structure with keys that describe what went wrong:

// ... lines 1 - 6
class ProgrammerControllerTest extends ApiTestCase
{
// ... lines 9 - 255
public function test404Exception()
{
// ... lines 258 - 261
$this->assertEquals(404, $response->getStatusCode());
$this->assertEquals('application/problem+json', $response->getHeader('Content-Type')[0]);
$this->asserter()->assertResponsePropertyEquals($response, 'type', 'about:blank');
$this->asserter()->assertResponsePropertyEquals($response, 'title', 'Not Found');
$this->asserter()->assertResponsePropertyEquals($response, 'detail', 'No programmer found with nickname "fake"');
}
// ... lines 268 - 276
}

I want to do the exact same thing when something goes wrong with authentication.

Open up the TokenControllerTest:

// ... lines 1 - 6
class TokenControllerTest extends ApiTestCase
{
// ... lines 9 - 22
public function testPOSTTokenInvalidCredentials()
{
$this->createUser('weaverryan', 'I<3Pizza');
$response = $this->client->post('/api/tokens', [
'auth' => ['weaverryan', 'IH8Pizza']
]);
$this->assertEquals(401, $response->getStatusCode());
}
}

Here, we purposefully send an invalid username and password combination. This actually hits TokenController, we throw this new BadCredentialsException and that kicks us out:

// ... lines 1 - 12
class TokenController extends BaseController
{
// ... lines 15 - 18
public function newTokenAction(Request $request)
{
// ... lines 21 - 31
if (!$isValid) {
throw new BadCredentialsException();
}
// ... lines 35 - 39
}
}

It turns out that doing this this also triggers the entry point. And if you think about it, that makes sense: any time an anonymous user is able to get into your application:

// ... lines 1 - 17
class JwtTokenAuthenticator extends AbstractGuardAuthenticator
{
// ... lines 20 - 79
public function start(Request $request, AuthenticationException $authException = null)
{
// called when authentication info is missing from a
// request that requires it
return new JsonResponse([
'error' => 'auth required'
], 401);
}
}

And then you throw an exception to deny access, that will trigger the entry point. And our entry point is not yet returning the nice API problem structure.

Testing for the API Problem Response

Copy the last four lines from one of the tests in ProgrammerControllerTest:

// ... lines 1 - 6
class ProgrammerControllerTest extends ApiTestCase
{
// ... lines 9 - 255
public function test404Exception()
{
// ... lines 258 - 262
$this->assertEquals('application/problem+json', $response->getHeader('Content-Type')[0]);
$this->asserter()->assertResponsePropertyEquals($response, 'type', 'about:blank');
$this->asserter()->assertResponsePropertyEquals($response, 'title', 'Not Found');
$this->asserter()->assertResponsePropertyEquals($response, 'detail', 'No programmer found with nickname "fake"');
}
// ... lines 268 - 276
}

And add that to testPostTokenInvalidCredentials():

// ... lines 1 - 6
class TokenControllerTest extends ApiTestCase
{
// ... lines 9 - 22
public function testPOSTTokenInvalidCredentials()
{
// ... lines 25 - 29
$this->assertEquals(401, $response->getStatusCode());
$this->assertEquals('application/problem+json', $response->getHeader('Content-Type')[0]);
$this->asserter()->assertResponsePropertyEquals($response, 'type', 'about:blank');
$this->asserter()->assertResponsePropertyEquals($response, 'title', 'Unauthorized');
$this->asserter()->assertResponsePropertyEquals($response, 'detail', 'Invalid credentials.');
}
}

The header should be application/problem+json. The type should be about:blank: that's what you should use when the status code - 401 here - already fully describes what went wrong. For the title use Unauthorized - that's the standard text that always goes with a 401 status code. The ApiProblem class will actually set that for us: when we pass a null type, it sets type to about:blank and looks up the correct title.

Finally, for detail - which is an optional field for an API problem response - use Invalid Credentials. with a period. I'll show you why we're expecting that in a second.

ApiProblem in start()

Head to the JwtTokenAuthenticator. In start(), create a new $apiProblem = new ApiProblem(). Pass it a 401 status code with no type:

// ... lines 1 - 18
class JwtTokenAuthenticator extends AbstractGuardAuthenticator
{
// ... lines 21 - 80
public function start(Request $request, AuthenticationException $authException = null)
{
// called when authentication info is missing from a
// request that requires it
$apiProblem = new ApiProblem(401);
// ... lines 87 - 91
}
}

The detail key should tell the API client any other information about what went wrong. And check this out: when the start() method is called, it has an optional $authException argument. Most of the time, when Symfony calls start() its because an AuthenticationException has been thrown. And this class gives us some information about what caused this situation.

And in fact, in TokenController, we're throwing a BadCredentialsException, which is a sub-class of AuthenticationException. Hold command to look inside the class:

// ... lines 1 - 19
class BadCredentialsException extends AuthenticationException
{
// ... lines 22 - 24
public function getMessageKey()
{
return 'Invalid credentials.';
}
}

It has a getMessageKey() method set to Invalid Credentials.: make sure you test matches this string exactly:

// ... lines 1 - 6
class TokenControllerTest extends ApiTestCase
{
// ... lines 9 - 22
public function testPOSTTokenInvalidCredentials()
{
// ... lines 25 - 33
$this->asserter()->assertResponsePropertyEquals($response, 'detail', 'Invalid credentials.');
}
}

The AuthenticationException - and its sub-classes - are special: each has a getMessageKey() method that you can safely return to the user to help hint as to what went wrong.

Add $message = $authException ? $authException->getMessageKey() : 'Missing Credentials';:

// ... lines 1 - 18
class JwtTokenAuthenticator extends AbstractGuardAuthenticator
{
// ... lines 21 - 80
public function start(Request $request, AuthenticationException $authException = null)
{
// called when authentication info is missing from a
// request that requires it
$apiProblem = new ApiProblem(401);
// you could translate this
$message = $authException ? $authException->getMessageKey() : 'Missing credentials';
// ... lines 89 - 91
}
}

If no $authException is passed, this is the best message we can return to the client. Finish this with $apiProblem->set('details', $message).:

// ... lines 1 - 82
// called when authentication info is missing from a
// request that requires it
$apiProblem = new ApiProblem(401);
// you could translate this
$message = $authException ? $authException->getMessageKey() : 'Missing credentials';
$apiProblem->set('detail', $message);
// ... lines 90 - 94

Finally, return a new JsonResponse with $apiProblem->toArray() and then a 401:

// ... lines 1 - 18
class JwtTokenAuthenticator extends AbstractGuardAuthenticator
{
// ... lines 21 - 80
public function start(Request $request, AuthenticationException $authException = null)
{
// called when authentication info is missing from a
// request that requires it
$apiProblem = new ApiProblem(401);
// you could translate this
$message = $authException ? $authException->getMessageKey() : 'Missing credentials';
$apiProblem->set('detail', $message);
return new JsonResponse($apiProblem->toArray(), 401);
}
}

Perfect! Well, not actually perfect, but it's getting close.

Copy the invalid credentials test method and run:

./vendor/bin/phpunit --filter testPOSTTokenInvalidCredentials

It's close! The response looks right, but the Content-Type header is application/json instead of the more descriptive application/problem+json.

Well that's no problem! We just need to set the header inside of the start() method. But wait! Don't do that! Because we've done all of this work before.