Skip to content

Commit 72f2436

Browse files
committed
Refactor errors and simplify localization system
1 parent 2ea7487 commit 72f2436

File tree

94 files changed

+1135
-716
lines changed

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

94 files changed

+1135
-716
lines changed

CHANGELOG.md

Lines changed: 27 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -13,40 +13,48 @@ and this project adheres to
1313
- Replace `Resource::getId(object $model, Context $context): string` method with
1414
`Resource::id(): Id` method, which returns an `Id` field instance to define ID
1515
schema, validation, and writability
16+
- Change `ErrorProvider::getJsonApiErrors(): array` to
17+
`ErrorProvider::getJsonApiError(): array` (singular) - exceptions now
18+
represent a single error rather than potentially multiple errors. Wrap
19+
`ErrorProviders` in `JsonApiErrorsException` to represent multiple errors.
20+
- Rename `Exception\Concerns\SingleError` trait to
21+
`Exception\Concerns\JsonApiError` and change its API:
22+
- `setSource(?array $source): static``source(array $source): static`
23+
- `setMeta(?array $meta): static``meta(array $meta): static`
24+
- `setLinks(?array $links): static``links(array $links): static`
1625
- Custom deserializers on `ToOne` relationships now receive the resolved model
1726
rather than the raw relationship object, for consistency with `ToMany`
27+
- Remove `Pagination\Concerns\BuildsUrls` trait (replaced by
28+
`Context::currentUrl()`)
29+
- Move `Extension\Atomic` to `Extension\Atomic\Atomic`
1830

1931
### Added
2032

21-
- Add localization system for customizable error messages
22-
- Add `JsonApi::messages()` method for merging custom error messages with
23-
defaults
24-
- Add `JsonApi::setTranslator()` method for providing custom translator
25-
implementations
26-
- Add `Context::translate()` method for accessing the translation system
27-
- Add `Context::currentUrl()` method for building the current URL with query
28-
parameter overrides
33+
- Add ability to override error objects by caught exception
34+
- Add specific exception classes for all errors
35+
- Add `JsonApi::errors(array $overrides)` method to register error object
36+
overrides, keyed by exception class name. `detail` values accept
37+
replacements using the `:key` syntax.
38+
- Add `Exception\JsonApiErrorsException` for representing multiple errors
39+
- Add `Context::currentUrl(array $params = []): string` method for building URLs
40+
with query parameter overrides
2941
- Add `self` link to `Index` endpoint document
30-
- Add `Id` field class for customizing resource ID behavior, including type
31-
constraints, client-generated IDs via `writableOnCreate()`, and validation
42+
- Add `Id` field class for customizing resource ID behavior, including:
43+
- Type constraints (string, integer, etc.)
44+
- Client-generated IDs via `writableOnCreate()`
45+
- Custom validation rules
46+
- Getter/setter callbacks
3247
- Add `linkageMeta()` method to relationship fields for adding meta to resource
3348
identifier objects in linkage
34-
- Add static `location()` method to field classes to determine where they appear
35-
in the JSON:API document (`attributes`, `relationships`, or root level for
36-
`id`)
49+
- Add static `Field::location(): string` method to determine where fields appear
50+
in JSON:API documents (`attributes`, `relationships`, or root level for `id`)
3751

3852
### Changed
3953

40-
- Error messages now use the localization system
4154
- Various performance optimizations to improve serialization speed
4255
- Improve OpenAPI schema generation to properly handle ID field constraints and
4356
avoid redundant properties
4457

45-
### Removed
46-
47-
- Remove `Pagination\Concerns\BuildsUrls` trait (replaced by
48-
`Context::currentUrl()`)
49-
5058
## [1.0.0-beta.6] - 2025-10-02
5159

5260
### ⚠️ Breaking Changes

docs/.vitepress/config.ts

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -51,7 +51,6 @@ export default defineConfig({
5151
text: 'Heterogeneous Collections',
5252
link: '/collections',
5353
},
54-
{ text: 'Localization', link: '/localization' },
5554
{ text: 'OpenAPI Definitions', link: '/openapi' },
5655
],
5756
},

docs/context.md

Lines changed: 0 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -56,9 +56,6 @@ class Context
5656
// Get the value of a query param
5757
public function queryParam(string $name, $default = null): mixed;
5858

59-
// Translate a message using the API's translator
60-
public function translate(string $key, array $replacements = []): string;
61-
6259
// Get the URL of the current request, optionally with query parameter overrides
6360
public function currentUrl(array $queryParams = []): string;
6461

docs/errors.md

Lines changed: 141 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -16,27 +16,25 @@ try {
1616
## Error Providers
1717

1818
Exceptions can implement the `Tobyz\JsonApiServer\Exception\ErrorProvider`
19-
interface to determine what status code will be used in the response, and any
20-
JSON:API error objects to be rendered in the document.
19+
interface to determine what status code will be used in the response, and define
20+
a JSON:API error object to be rendered in the document.
2121

2222
The interface defines two methods:
2323

24-
- `getJsonApiStatus` which must return a string.
25-
- `getJsonApiErrors` which must return an array of JSON:API error objects.
24+
- `getJsonApiStatus` which must return the HTTP status code applicable to the
25+
exception as a string.
26+
- `getJsonApiError` which must return a JSON:API error object.
2627

2728
```php
28-
use JsonApiPhp\JsonApi\Error;
2929
use Tobyz\JsonApiServer\Exception\ErrorProvider;
3030

3131
class ImATeapotException implements ErrorProvider
3232
{
33-
public function getJsonApiErrors(): array
33+
public function getJsonApiError(): array
3434
{
3535
return [
36-
[
37-
'title' => "I'm a teapot",
38-
'status' => $this->getJsonApiStatus(),
39-
],
36+
'title' => "I'm A Teapot",
37+
'status' => $this->getJsonApiStatus(),
4038
];
4139
}
4240

@@ -50,8 +48,138 @@ class ImATeapotException implements ErrorProvider
5048
Exceptions that do not implement this interface will result in a generic
5149
`500 Internal Server Error` response.
5250

51+
### Creating Custom Exceptions
52+
53+
The simplest way to create custom exceptions is to extend one of the base
54+
exception classes like `BadRequestException`, `UnprocessableEntityException`, or
55+
`ForbiddenException`. These base classes implement `ErrorProvider` and use the
56+
`JsonApiError` trait internally to provide automatic error formatting.
57+
58+
For most cases, just extend a base exception and provide a message which will be
59+
used at the error `detail`. For more control, you can set the `$this->error`
60+
array in your constructor, which will be merged with the defaults:
61+
62+
```php
63+
use Tobyz\JsonApiServer\Exception\BadRequestException;
64+
65+
class ProductOutOfStockException extends BadRequestException
66+
{
67+
public function __construct(string $productId)
68+
{
69+
parent::__construct("Product $productId is out of stock");
70+
71+
$this->error = [
72+
'meta' => ['productId' => $productId],
73+
'links' => [
74+
'type' =>
75+
'https://example.com/docs/errors#product_out_of_stock',
76+
],
77+
];
78+
}
79+
}
80+
```
81+
82+
This automatically generates:
83+
84+
- `code`: `product_out_of_stock` (derived from class name)
85+
- `title`: `Product Out Of Stock` (derived from class name)
86+
- `detail`: `Product ABC123 is out of stock` (from constructor message)
87+
- `status`: `400` (inherited from BadRequestException)
88+
89+
#### Helper Methods
90+
91+
The `JsonApiError` trait also provides fluent helper methods for modifying the
92+
error object from the context in which it is thrown:
93+
94+
```php
95+
throw (new UnknownFieldException('email'))
96+
->source(['pointer' => '/data/attributes/email'])
97+
->meta(['suggestion' => 'Did you mean "emailAddress"?'])
98+
->links(['about' => 'https://example.com/docs/fields']);
99+
```
100+
101+
## Multiple Errors
102+
103+
When multiple validation errors occur (e.g., multiple field validation
104+
failures), you can wrap them in `JsonApiErrorsException`.
105+
106+
```php
107+
use Tobyz\JsonApiServer\Exception\JsonApiErrorsException;
108+
use Tobyz\JsonApiServer\Exception\RequiredFieldException;
109+
use Tobyz\JsonApiServer\Exception\InvalidFieldValueException;
110+
111+
throw new JsonApiErrorsException([
112+
new RequiredFieldException(),
113+
new InvalidFieldValueException('Must be a valid email address'),
114+
])->prependSource(['pointer' => '/data/attributes/email']);
115+
```
116+
117+
This will return a JSON:API error response with multiple error objects:
118+
119+
```json
120+
{
121+
"errors": [
122+
{
123+
"status": "422",
124+
"code": "required_field",
125+
"title": "Required Field",
126+
"detail": "Field is required",
127+
"source": { "pointer": "/data/attributes/email" }
128+
},
129+
{
130+
"status": "422",
131+
"code": "invalid_field_value",
132+
"title": "Invalid Field Value",
133+
"detail": "Must be a valid email address",
134+
"source": { "pointer": "/data/attributes/email" }
135+
}
136+
]
137+
}
138+
```
139+
140+
When `JsonApiErrorsException` contains multiple errors with different status
141+
codes, it automatically determines the most generally applicable HTTP error code
142+
to be used in the response.
143+
53144
## Customizing Error Messages
54145

55-
Many of the built-in error messages can be customized using the
56-
[localization system](localization.md). This allows you to provide localized or
57-
custom error messages throughout your API.
146+
All built-in exceptions include sensible default English error messages, so they
147+
work out-of-the-box without any configuration. The `code` and `title` are
148+
automatically derived from the exception class name, and each exception provides
149+
a default `detail` message.
150+
151+
You can customize error messages for each exception using the `errors()` method
152+
on your `JsonApi` instance. You can override any part of the error object for
153+
any exception by providing exception class names as keys.
154+
155+
```php
156+
use Tobyz\JsonApiServer\Exception\MethodNotAllowedException;
157+
use Tobyz\JsonApiServer\Exception\ResourceNotFoundException;
158+
159+
$api->errors([
160+
MethodNotAllowedException::class => [
161+
'title' => 'Not Allowed',
162+
'detail' => 'The :method method is not allowed for this endpoint',
163+
],
164+
ResourceNotFoundException::class => [
165+
'title' => 'Not Found',
166+
'detail' => 'Could not find :type resource with ID :id',
167+
],
168+
]);
169+
```
170+
171+
### Placeholder Replacement
172+
173+
The `detail` property supports placeholder replacement using the `:placeholder`
174+
syntax. Placeholders are replaced with values found in the error object's `meta`
175+
data:
176+
177+
```php
178+
ResourceNotFoundException::class => [
179+
'detail' => 'Could not find :type resource with ID :id',
180+
]
181+
```
182+
183+
When a `ResourceNotFoundException` is thrown with `meta` containing
184+
`['type' => 'users', 'id' => '123']`, the detail becomes: "Could not find users
185+
resource with ID 123"

docs/extensions.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -69,15 +69,15 @@ and activate the specified extensions on each request.
6969
## Atomic Operations
7070

7171
An implementation of the [Atomic Operations](https://jsonapi.org/ext/atomic/)
72-
extension is available at `Tobyz\JsonApi\Extension\Atomic`.
72+
extension is available at `Tobyz\JsonApi\Extension\Atomic\Atomic`.
7373

7474
When using this extension, you are responsible for wrapping the `$api->handle`
7575
call in a transaction to ensure any database (or other) operations performed are
7676
actually atomic in nature. For example, in Laravel:
7777

7878
```php
7979
use Illuminate\Support\Facades\DB;
80-
use Tobyz\JsonApiServer\Extension\Atomic;
80+
use Tobyz\JsonApiServer\Extension\Atomic\Atomic;
8181
use Tobyz\JsonApiServer\JsonApi;
8282

8383
$api = new JsonApi();

docs/localization.md

Lines changed: 0 additions & 70 deletions
This file was deleted.

src/Context.php

Lines changed: 4 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66
use Psr\Http\Message\ServerRequestInterface;
77
use RuntimeException;
88
use Tobyz\JsonApiServer\Endpoint\Endpoint;
9-
use Tobyz\JsonApiServer\Exception\BadRequestException;
9+
use Tobyz\JsonApiServer\Exception\Request\InvalidSparseFieldsetsException;
1010
use Tobyz\JsonApiServer\Resource\Collection;
1111
use Tobyz\JsonApiServer\Resource\Resource;
1212
use Tobyz\JsonApiServer\Schema\Field\Field;
@@ -49,11 +49,6 @@ public function __construct(public JsonApi $api, public ServerRequestInterface $
4949
$this->resourceMeta = new WeakMap();
5050
}
5151

52-
public function translate(string $key, array $replacements = []): string
53-
{
54-
return $this->api->translator->translate($key, $replacements);
55-
}
56-
5752
/**
5853
* Get the value of a query param.
5954
*/
@@ -177,9 +172,9 @@ public function sparseFields(Resource $resource): array
177172
$requested = $fieldsParam[$type];
178173

179174
if (!is_string($requested)) {
180-
throw (new BadRequestException(
181-
$this->translate('request.fields_invalid'),
182-
))->setSource(['parameter' => "fields[$type]"]);
175+
throw (new InvalidSparseFieldsetsException())->source([
176+
'parameter' => "fields[$type]",
177+
]);
183178
}
184179

185180
$fields = array_intersect_key($fields, array_flip(explode(',', $requested)));

0 commit comments

Comments
 (0)