API-Problem Specification Compatible Domain Exceptions
Making your domain exceptions work with your api problem library
(RFC7807 Problem Details for HTTP APIs)
Article in Joy of Coding, English
June 12, 2021
RFC7807 Problem Details Specification “defines a ‘problem detail’ as a way to carry machine-readable details of errors in a HTTP response to avoid the need to define new error response formats for HTTP APIs.” [1]
If you don’t know the API-Problem Specification and/or want to learn more about it, you can read this article by Guy Levin: REST API Error Handling - Problem Details Response
In this article, I try to cover how I handle domain exceptions and return api problem formatted http response in a Mezzio app, but you will get the general idea.
Mezzio enables you to create middleware applications. A middleware is executed between request and response, and accepts a request object and returns a response object.
Typical middleware stack with handling api-problem details is shown below.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
<?php
declare(strict_types=1);
use Mezzio\Application;
use Mezzio\MiddlewareFactory;
use Psr\Container\ContainerInterface;
use Mezzio\ProblemDetails\ProblemDetailsMiddleware;
use Mezzio\Router\Middleware\DispatchMiddleware;
use Mezzio\Router\Middleware\RouteMiddleware;
use Mezzio\Handler\NotFoundHandler;
return static function (Application $app, MiddlewareFactory $factory, ContainerInterface $container) : void {
$app->pipe(ProblemDetailsMiddleware::class);
...
$app->pipe(RouteMiddleware::class);
...
$app->pipe(DispatchMiddleware::class);
...
$app->pipe(NotFoundHandler::class);
};
In this stack, ProblemDetailsMiddleware provides a middleware that catches the last thrown exception and returns JSON error response that implements API-Problem Specification.
Let’s say an endpoint accepts an email and stores it, but email is not a valid email address, then throws InvalidArgumentException. With ProblemDetailsMiddleware, response is like this.
1
throw new \InvalidArgumentException(sprintf('Email is not a valid email address: %s', $payload['email']), 400);
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
{
"exception": {
"class": "InvalidArgumentException",
"code": 400,
"message": "Email is not a valid email address: [email protected]",
"file": "/opt/app/src/Infrastructure/Ui/PrivateApi/IdentityAndAccess/Handler/RegisterAccount.php",
"line": 47,
"trace": [
...
]
},
"title": "Bad Request",
"type": "https://httpstatus.es/400",
"status": 400,
"detail": "Email is not a valid email address: [email protected]"
But I have problems with this output.
- HTTP status code is set using thrown exception’s code, but I need different status code and error code. i.e. I want to return “404” as HTTP status and “users/user-not-found” as error code, so an API client can use the “users/user-not-found” to implement multilingual warning messages.
- I want to set custom url for “type”. For example: https://my-awesome-api-url.com/docs/errors/users/user-not-found
- I want to set “title”. For example “User Not Found” instead of “Not Found”.
- I want to provide “additional” data with it. For example {“rateLimit”: 300, “totalRequestsLastHour”: 128 }
- Since this response is for clients, I don’t want to share some internal information in “trace” and other places. I can use an error handler to log this internal information, then return the api problem error response.
Since in my “domain code” I throw lots of informative exceptions, I want my error responses use these informations.
ProblemDetailsMiddleware provides a trait named CommonProblemDetailsExceptionTrait but it only handles exception’s internal data to api problem error response data transformation. I need an another trait that helps to create an exception and use its internal data for api problem error response.
Then I created a trait named DomainException.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
<?php
declare(strict_types=1);
namespace MyApplication\Domain\Shared\Exception;
use Mezzio\ProblemDetails\Exception\CommonProblemDetailsExceptionTrait;
use function array_merge;
use function defined;
trait DomainException
{
use CommonProblemDetailsExceptionTrait;
private function __construct(int $status, string $detail, string $title, string $type, array $additional)
{
$this->status = $status;
$this->detail = $detail;
$this->title = $title;
$this->type = $type;
$this->additional = $additional;
parent::__construct($detail, $status);
}
public static function create(string $details, ?array $additional = []): self
{
return new static(
self::STATUS,
$details,
self::TITLE,
defined('static::TYPE') ? self::TYPE : 'https://httpstatus.es/' . self::STATUS,
array_merge($additional, ['code' => self::CODE])
);
}
}
Then I am enabled to define my domain exceptions.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
<?php
declare(strict_types=1);
namespace MyApplication\Domain\Administrators\Exception;
use Mezzio\ProblemDetails\Exception\ProblemDetailsExceptionInterface;
use Exception;
use MyApplication\Domain\Shared\Exception\DomainException;
class UserNotFound extends Exception implements ProblemDetailsExceptionInterface
{
use DomainException;
private const STATUS = 404;
private const CODE = 'users/user-not-found';
private const TYPE = 'https://my-awesome-api-url.com/docs/errors/users/user-not-found';
private const TITLE = 'User Not Found';
}
Using this exception, my hypothetical final code is completed.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
<?php
...
use MyApplication\Domain\Administrators\Exception\UserNotFound;
...
$message = new AuthenticateUserWithEmail($payload['email'], $payload['password']);
$user = $this->queryBus->handle($message);
if ($user === null) {
$message = sprintf(
'Invalid username and/or password for: %s',
$payload['email']
);
$additionalData = [
'rateLimit' => 10,
'totalRequestsLast15Minutes' => 7
];
throw UserNotFound::create($message, $additionalData);
}
...
And the result is:
1
2
3
4
5
6
7
8
9
{
"rateLimit": 10,
"totalRequestsLast15Minutes": 7,
"code": "users/user-not-found",
"title": "User Not Found",
"type": "https://my-awesome-api-url.com/docs/errors/users/user-not-found",
"status": 404,
"detail": "Invalid username and/or password for [email protected]"
}
If you have questions and suggestions, or find bugs in code and logic, please contact me.