diff --git a/docs/book/v7/core-features/exceptions.md b/docs/book/v7/core-features/exceptions.md index 12e46b1..dcddac6 100644 --- a/docs/book/v7/core-features/exceptions.md +++ b/docs/book/v7/core-features/exceptions.md @@ -2,8 +2,9 @@ ## Summary -Dotkernel API expresses error conditions through a small set of problem-specific exceptions — `BadRequestException`, `ConflictException`, `ExpiredException`, `ForbiddenException`, `MethodNotAllowedException`, `NotFoundException` and `UnauthorizedException` — each mapped to an HTTP status code. -The page lists when to throw each one and walks through adding a custom exception with its own status code. +Dotkernel API expresses error conditions through ten problem-specific exceptions under `Api\App\Exception`, each carrying the HTTP status code it maps to. +All of them implement `Mezzio\ProblemDetails\Exception\ProblemDetailsExceptionInterface`, are built through a static `::create()` factory rather than with `new`, and are rendered by `ProblemDetailsMiddleware` as an RFC 9457 problem details document. +This page lists when to throw each one, shows the response it produces, and walks through adding a custom exception with its own status code. ## What are exceptions? @@ -13,66 +14,192 @@ They provide a way to manage errors in a structured and controlled manner, separ ## How we use exceptions When it comes to handling exceptions, **Dotkernel API** relies on the usage of easy-to-understand, problem-specific exceptions. -Below we will list the available custom exceptions. +They all live in `src/App/src/Exception/` and share a single shape, shown here for `BadRequestException`: + +```php + $additional + */ + public static function create(string $detail, string $type = '', string $title = '', array $additional = []): self + { + $exception = new self(); + + $exception->type = $type; + $exception->detail = $detail; + $exception->status = StatusCodeInterface::STATUS_BAD_REQUEST; + $exception->title = $title; + $exception->additional = $additional; + + return $exception; + } +} +``` + +Two things follow from that shape, and both matter when you write code that throws: + +- **The status code belongs to the class, not to the throw site.** `create()` sets `status` itself, so choosing `BadRequestException` *is* choosing `400`. There is no way to throw one of these with a different status, and nothing in the application catches an exception in order to change its status. +- **Always construct with `::create()`.** The properties the interface exposes are declared by `CommonProblemDetailsExceptionTrait` and are not constructor arguments, so `new BadRequestException('some message')` compiles but produces an exception with no status, no detail and no title. + +`create()` takes the same four arguments in every one of the ten classes: + +| Argument | Becomes | When omitted | +| --- | --- | --- | +| `$detail` | `detail` — what went wrong on this specific request | required | +| `$type` | `type` — a URI identifying the class of problem | derived from the status code | +| `$title` | `title` — a short summary of the problem type | the HTTP reason phrase for the status | +| `$additional` | extra top-level members, such as field-level validation errors | nothing is added | + +A typical throw supplies the detail and, where there is more to say, additional members: + +```php +throw BadRequestException::create( + detail: Message::VALIDATOR_INVALID_DATA, + additional: ['errors' => $this->inputFilter->getMessages()] +); +``` + +### Available exceptions + +| Exception | Status | Raised by | +| --- | --- | --- | +| `BadRequestException` | `400 Bad Request` | handlers, on input filter failure | +| `RuntimeException` | `400 Bad Request` | `Renderer`, `HandlerService`, `ErrorReportService`, `HandlerDelegatorFactory` | +| `UnauthorizedException` | `401 Unauthorized` | `ErrorReportPermissionMiddleware`, `ErrorReportService` | +| `SunsetException` | `401 Unauthorized` | `BaseDeprecation`, on an invalid `sunset` date | +| `ForbiddenException` | `403 Forbidden` | `ErrorReportPermissionMiddleware`, `ErrorReportService` | +| `NotFoundException` | `404 Not Found` | handlers and services, for a missing resource | +| `NotAcceptableException` | `406 Not Acceptable` | `ContentNegotiationMiddleware` | +| `ConflictException` | `409 Conflict` | `UserService`, `AdminService`, `DeprecationMiddleware`, `ResourceProviderMiddleware` | +| `ExpiredException` | `410 Gone` | the password reset handlers | +| `UnsupportedMediaTypeException` | `415 Unsupported Media Type` | `ContentNegotiationMiddleware` | + +Note that `RuntimeException` and `SunsetException` do not carry the status you might expect from their names — both are listed above with the status their `create()` method actually sets. ### `BadRequestException` thrown when -* The Client tries to **create/update resource**, but the **request data is invalid/incomplete** (example: client tries to create an account, but does not send the required `identity` field) +- The client tries to **create/update a resource**, but the **request data is invalid/incomplete** (example: client tries to create an account, but does not send the required `identity` field) -### `ConflictException` thrown when +This is the exception input filter failures raise, and the one you will throw most often. -* The **resource cannot be created** because a different resource with the same identifier **already exists** (example: cannot change existing user's identity because another user with the same identity already exists) -* The **resource cannot change its state** because it is **already in the specified state** (example: user cannot be activated because it is already active) +### `RuntimeException` thrown when -### `ExpiredException` thrown when +- A **template cannot be found** by `Api\App\Template\Renderer` +- A **service or route middleware is missing or misconfigured**, such as remote error reporting being called while disabled -* The **resource cannot be accessed** - * because it has **expired** (example: account activation link) - * because it has been **consumed** (example: one-time password) +It extends PHP's own `\RuntimeException` rather than `\Exception`, and returns `400 Bad Request`. -### `ForbiddenException` thrown when +### `UnauthorizedException` thrown when + +- The **resource cannot be accessed** because the **client is not authenticated** + +### `SunsetException` thrown when -* The **resource cannot be accessed** by the authenticated client's **role** (example: client authenticated as regular user sends a `GET /admin` request) +- A `ResourceDeprecation` attribute is given a **`sunset` value that is not a valid date** -### `MethodNotAllowedException` thrown when +This is a programming error surfaced at attribute construction, not a condition a client can trigger. +See [API Evolution pattern](../tutorials/api-evolution.md). + +### `ForbiddenException` thrown when -* The client tries to interact with a resource via an **invalid HTTP request method** (example: client sends a `PATCH /avatar` request) +- The **resource cannot be accessed** by the authenticated client's **role** + +Note that this is not what a failed RBAC check produces — see [How it works](#how-it-works) below. ### `NotFoundException` thrown when -* The client tries to interact with a **resource that does not exist** on the server (example: client sends a `GET /resource-does-not-exist` request) +- The client tries to interact with a **resource that does not exist** on the server (example: client sends a `GET /resource-does-not-exist` request) -### `UnauthorizedException` thrown when +### `NotAcceptableException` thrown when -* The **resource cannot be accessed** because the **client is not authenticated** (example: unauthenticated client sends a `GET /admin` request) +- The request's **`Accept` header asks for a format the route does not support** +- The **response cannot be produced** in any of the formats the client accepts + +### `ConflictException` thrown when + +- The **resource cannot be created** because a different resource with the same identifier **already exists** (example: cannot change existing user's identity because another user with the same identity already exists) +- The **resource cannot change its state** because it is **already in the specified state** (example: user cannot be activated because it is already active) + +### `ExpiredException` thrown when + +- The **resource cannot be accessed** + - because it has **expired** (example: account activation link) + - because it has been **consumed** (example: one-time password) + +### `UnsupportedMediaTypeException` thrown when + +- The request's **`Content-Type` is not one the route accepts** ## How it works -During a request, if there is no uncaught exception, **Dotkernel API** will return a JSON response with the data provided by the handler that processed the request. +During a request, if there is no uncaught exception, **Dotkernel API** will return a response with the data provided by the handler that processed the request. + +Otherwise, the response is built by `Mezzio\ProblemDetails\ProblemDetailsMiddleware`, which `config/pipeline.php` pipes as the outermost layer precisely so that it sees every throwable the application raises. +It takes one of two paths. + +**The exception implements `ProblemDetailsExceptionInterface`.** +Its status, detail, title, type and additional members are used as they are. +All ten exceptions above take this path, so a `BadRequestException` thrown from an account creation handler produces: + +```json +{ + "errors": { + "identity": { + "isEmpty": "Value is required and can't be empty" + } + }, + "title": "Bad Request", + "type": "https://datatracker.ietf.org/doc/html/rfc9110#name-400-bad-request", + "status": 400, + "detail": "The submitted request contains invalid data." +} +``` + +The `type` URI comes from the `default_types_map` in `config/autoload/problem-details.global.php`, which maps the ten statuses the application uses to the matching section of RFC 9110. +A status absent from that map falls back to `https://httpstatus.es/{status}`. -Otherwise, it will build and send a response based on the exception thrown: +**Anything else.** +`Dot\Mail\Exception\MailException`, PHP errors and any exception of your own that does not implement the interface take this path. +The response is a `500 Internal Server Error` whose `detail` reads `An unknown error occurred.` — the real message is withheld unless debug mode or `exceptionDetailsInResponse` is enabled, so in production nobody learns anything from it beyond the status. -* `BadRequestException` will return a `400 Bad Request` response -* `UnauthorizedException` will return a `401 Unauthorized` response -* `ForbiddenException` will return a `403 Forbidden` response -* `OutOfBoundsException` and `NotFoundException` will return a `404 Not Found` response -* `MethodNotAllowedException` will return a `405 Method Not Allowed` response -* `ConflictException` will return a `409 Conflict` response -* `ExpiredException` will return a `410 Gone` response -* `MailException`, `RuntimeException` and the generic `Exception` will return a `500 Internal Server Error` response +Two error responses do not come from an application exception at all: + +- **`405 Method Not Allowed`** is produced by `Mezzio\Router\Middleware\MethodNotAllowedMiddleware` when a route exists but not for that method. There is no `MethodNotAllowedException` in the codebase. +- **`404 Not Found`** for an unmatched URL is produced by `ProblemDetailsNotFoundHandler` at the end of the pipeline, before a route is ever dispatched. + +One error response is neither of the two, and it is worth knowing about because it is the one clients hit most: + +- **A failed RBAC check** returns `403 Forbidden` from `AuthorizationMiddleware`, which builds the response itself rather than throwing `ForbiddenException`. Its body is the older `{"error": {"messages": [...]}}` envelope, not a problem details document. Clients that parse error responses need to handle both shapes. See [Authorization](authorization.md). ## How to extend In this example we will -* Create a custom exception called `CustomException` -* Place it next to the already existing custom exceptions (you can use your preferred location) -* Return a custom HTTP status code when `CustomException` is encountered. +- Create a custom exception called `TeapotException` +- Place it next to the already existing custom exceptions (you can use your preferred location) +- Return a custom HTTP status code when `TeapotException` is encountered + +Because the status travels with the exception, that is the whole job — there is no pipeline or handler change to make. -### Step 1: Create exception file +### Step 1: Create the exception -Navigate to the directory `src/App/src/Handler/Exception` and create a PHP class called `CustomException.php`. -Open `CustomException.php` and add the following content: +Navigate to the directory `src/App/src/Exception` and create a PHP class called `TeapotException.php`. +Open `TeapotException.php` and add the following content: ```php $additional + */ + public static function create(string $detail, string $type = '', string $title = '', array $additional = []): self + { + $exception = new self(); + + $exception->type = $type; + $exception->detail = $detail; + $exception->status = StatusCodeInterface::STATUS_IM_A_TEAPOT; + $exception->title = $title; + $exception->additional = $additional; + + return $exception; + } } ``` Save and close the file. -### Step 2: Use exception file +The two `use` statements from `Mezzio\ProblemDetails` are what make this exception renderable: the interface is how `ProblemDetailsMiddleware` recognises it, and the trait supplies the properties `create()` fills in. + +### Step 2: Throw the exception -Open the file `src/App/src/Handler/HomeHandler.php` and at the beginning of the `get` method, place the following code: +Open the file `src/App/src/Handler/GetIndexResourceHandler.php` and replace the body of the `handle` method with the following: ```php -throw new \Api\App\Exception\CustomException('some message'); +throw TeapotException::create('I refuse to brew coffee.'); +``` + +Add the import at the top of the file: + +```php +use Api\App\Exception\TeapotException; ``` Save and close the file. -### Step 3: Test for failure +### Step 3: Test -Access your API's home page URL and make sure it returns `500 Internal Server Error` HTTP status code and the following content: +Access your API's home page URL. +It returns a `418 I'm a teapot` HTTP status code and the following content: ```json { - "error": { - "messages": [ - "some message" - ] - } + "title": "I'm a teapot", + "type": "https://httpstatus.es/418", + "status": 418, + "detail": "I refuse to brew coffee." } ``` -### Step 4: Prepare for success - -Open the file `src/App/src/Handler/HandlerTrait.php` and locate the `handle` method. -Insert the following lines of code before the first catch statement: - -```php - } catch (\Api\App\Exception\CustomException $exception) { - return $this->errorResponse($exception->getMessage(), StatusCodeInterface::STATUS_IM_A_TEAPOT); -``` - -Save and close the file. - -### Step 5: Test for success +The `title` was filled in from the status code's reason phrase, and the `type` fell back to `httpstatus.es` because `418` is not in `default_types_map`. +To control both, pass them to `create()` at the throw site, or give `create()` its own defaults in the class. +To give every `418` response the same type, add the status to `default_types_map` in `config/autoload/problem-details.global.php`. -Access your API's home page URL, which should return the same content. -Notice that this time it returns `418 I'm a teapot` HTTP status code. +Revert the change to `GetIndexResourceHandler` when you are done. ## FAQ @@ -142,6 +287,7 @@ See [Injectable input filters](../extended-features/injectable-input-filters.md) **Q: What is the difference between `UnauthorizedException` and `ForbiddenException`?** A: `UnauthorizedException` (401) means the client is not authenticated at all; `ForbiddenException` (403) means it is authenticated but its role does not grant access. +Note that a failed RBAC check does not throw `ForbiddenException` — `AuthorizationMiddleware` builds that `403` response itself. See [Authorization](authorization.md). **Q: When do I use `ConflictException`?** @@ -154,18 +300,24 @@ It returns `409 Conflict`. A: Resources that can no longer be used because they expired, such as an activation link, or because they were already consumed, such as a one-time password. It returns `410 Gone`. -**Q: What happens to an exception I do not handle?** +**Q: Why can I not pass a message to the constructor?** -A: Generic exceptions, along with `MailException` and `RuntimeException`, produce a `500 Internal Server Error`. +A: The `detail`, `title`, `type`, `status` and `additional` properties come from `CommonProblemDetailsExceptionTrait` and are set by `create()`, not by `__construct()`. +`new NotFoundException(Message::USER_NOT_FOUND)` leaves the exception with no status, so use `NotFoundException::create(Message::USER_NOT_FOUND)` instead. **Q: How do I map a custom exception to a specific status code?** -A: Create the exception class, then add a `catch` block for it in the `handle` method of `HandlerTrait.php` that returns `errorResponse()` with your chosen status code. +A: Set the status inside the exception's own `create()` method, as [How to extend](#how-to-extend) shows. +No `catch` block is involved, and there is nowhere in the application that maps exception classes to status codes. + +**Q: Why does my custom exception return `500 Internal Server Error`?** -**Q: Why does my custom exception return 500 before I touch `HandlerTrait`?** +A: Because it does not implement `ProblemDetailsExceptionInterface`, so `ProblemDetailsMiddleware` treats it as an unexpected failure. +Implement the interface and use the trait, as in Step 1 above. + +**Q: What happens to an exception I do not handle?** -A: Because nothing catches it yet, so it falls through to the generic handler. -Adding the catch block is what changes the status code. +A: Any throwable that does not implement `ProblemDetailsExceptionInterface` — `MailException` included — produces a `500 Internal Server Error` with the detail `An unknown error occurred.`, and the original message is only shown when debug mode is enabled. **Q: How do exceptions relate to problem details responses?**