Skip to content

Commit 3819b4c

Browse files
committed
Add Id field class for customizing resource ID behavior
1 parent bc3afe4 commit 3819b4c

25 files changed

+651
-161
lines changed

CHANGELOG.md

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,14 +8,27 @@ and this project adheres to
88

99
## [Unreleased]
1010

11+
### ⚠️ Breaking Changes
12+
13+
- Replace `Resource::getId(object $model, Context $context): string` method with
14+
`Resource::id(): Id` method, which returns an `Id` field instance to define ID
15+
schema, validation, and writability
16+
1117
### Added
1218

19+
- Add `Id` field class for customizing resource ID behavior, including type
20+
constraints, client-generated IDs via `writableOnCreate()`, and validation
1321
- Add `linkageMeta()` method to relationship fields for adding meta to resource
1422
identifier objects in linkage
23+
- Add static `location()` method to field classes to determine where they appear
24+
in the JSON:API document (`attributes`, `relationships`, or root level for
25+
`id`)
1526

1627
### Changed
1728

1829
- Various performance optimizations to improve serialization speed
30+
- Improve OpenAPI schema generation to properly handle ID field constraints and
31+
avoid redundant properties
1932

2033
## [1.0.0-beta.6] - 2025-10-02
2134

docs/.vitepress/config.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ export default defineConfig({
2222
items: [
2323
{ text: 'Defining Resources', link: '/resources' },
2424
{ text: 'Fields', link: '/fields' },
25+
{ text: 'ID', link: '/id' },
2526
{ text: 'Attributes', link: '/attributes' },
2627
{ text: 'Relationships', link: '/relationships' },
2728
{ text: 'Filtering', link: '/filtering' },

docs/context.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,9 @@ class Context
6262
// Get a resource by type
6363
public function resource(string $type): Resource;
6464

65+
// Get the ID for a model
66+
public function id(Resource $resource, mixed $model): string;
67+
6568
// Get the fields for the given resource, keyed by name
6669
public function fields(Resource $resource): array;
6770

docs/id.md

Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,80 @@
1+
# ID
2+
3+
Every JSON:API resource has an `id` field that uniquely identifies it. By
4+
default, the `id` is managed automatically by your resource implementation, but
5+
you can customize its behavior using the `Id` field class.
6+
7+
The `Id` field extends the base `Field` class, so all [field methods](fields.md)
8+
like `required()`, `default()`, and `validate()` are available.
9+
10+
## Customizing the ID Field
11+
12+
To customize how the ID is handled, define an `id()` method on your resource
13+
that returns an `Id` field instance:
14+
15+
```php
16+
use Tobyz\JsonApiServer\Schema\Id;
17+
18+
class PostsResource extends AbstractResource
19+
{
20+
// ...
21+
22+
public function id(): Id
23+
{
24+
return Id::make();
25+
}
26+
}
27+
```
28+
29+
## Type Constraints
30+
31+
The `Id` field uses a string type by default, but you can customize it to define
32+
additional constraints:
33+
34+
```php
35+
use Tobyz\JsonApiServer\Schema\Type;
36+
37+
Id::make()->type(Type\Str::make()->pattern('^[0-9]+$'));
38+
```
39+
40+
## Client-Generated IDs
41+
42+
By default, resource IDs are server-generated. To allow clients to provide their
43+
own IDs when creating resources, use the `writableOnCreate()` method:
44+
45+
```php
46+
Id::make()->writableOnCreate();
47+
```
48+
49+
Clients can then include an `id` in the request body when creating a resource:
50+
51+
```json
52+
{
53+
"data": {
54+
"type": "posts",
55+
"id": "custom-id-123",
56+
"attributes": {
57+
"title": "My Post"
58+
}
59+
}
60+
}
61+
```
62+
63+
You can combine this with other field methods:
64+
65+
```php
66+
Id::make()
67+
->writableOnCreate()
68+
->required() // Client MUST provide an ID
69+
->validate(function ($value, $fail) {
70+
if (!preg_match('/^[a-z0-9-]+$/', $value)) {
71+
$fail(
72+
'ID must contain only lowercase letters, numbers, and hyphens',
73+
);
74+
}
75+
});
76+
77+
Id::make()
78+
->writableOnCreate()
79+
->default(fn() => Str::uuid()->toString()); // Fallback if not provided
80+
```

docs/openapi.md

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,3 +11,13 @@ $api = new JsonApi();
1111

1212
$definition = (new OpenApiGenerator())->generate($api);
1313
```
14+
15+
## Schemas
16+
17+
The OpenAPI generator creates three schemas for each resource:
18+
19+
- `{type}` - The full resource schema including all fields
20+
- `{type}_create` - Schema for creating resources (includes only fields that are
21+
writable on creation)
22+
- `{type}_update` - Schema for updating resources (includes only writable
23+
fields)

docs/resources.md

Lines changed: 14 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -55,25 +55,32 @@ $api->resource(new PostsResource());
5555

5656
## Identifier
5757

58-
When serializing a model into a JSON:API resource object, the `getId` method on
59-
your resource class will be used to get the `id` for the model. A default
60-
implementation is provided in the `AbstractResource` class which assumes that
61-
your models have an `id` property. You may override this if needed:
58+
Every JSON:API resource has an `id` field. You can customize how the ID is
59+
handled by defining an `id()` method on your resource that returns an `Id` field
60+
instance:
6261

6362
```php
64-
use Tobyz\JsonApiServer\Context;
63+
use Tobyz\JsonApiServer\Schema\Id;
6564

6665
class PostsResource extends AbstractResource
6766
{
6867
// ...
6968

70-
public function getId(object $model, Context $context): string
69+
public function id(): Id
7170
{
72-
return $model->getKey();
71+
return Id::make()
72+
->writableOnCreate() // Allow client-generated IDs
73+
->validate(fn($value, $fail) => /* ... */);
7374
}
7475
}
7576
```
7677

78+
If you don't define an `id()` method, a default implementation will be used that
79+
reads the `id` property from your model.
80+
81+
Learn more about customizing the [Resource ID](id.md), including
82+
client-generated IDs and validation.
83+
7784
## Fields
7885

7986
A resource object's attributes and relationships are collectively called its

src/Context.php

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,12 +30,16 @@ class Context
3030
private ?string $path;
3131

3232
private WeakMap $endpoints;
33+
private WeakMap $resourceIds;
34+
private WeakMap $modelIds;
3335
private WeakMap $fields;
3436
private WeakMap $sparseFields;
3537

3638
public function __construct(public JsonApi $api, public ServerRequestInterface $request)
3739
{
3840
$this->endpoints = new WeakMap();
41+
$this->resourceIds = new WeakMap();
42+
$this->modelIds = new WeakMap();
3943
$this->fields = new WeakMap();
4044
$this->sparseFields = new WeakMap();
4145

@@ -95,6 +99,17 @@ public function endpoints(Collection $collection): array
9599
return $this->endpoints[$collection] ??= $collection->endpoints();
96100
}
97101

102+
public function id(Resource $resource, $model): string
103+
{
104+
if (isset($this->modelIds[$model])) {
105+
return $this->modelIds[$model];
106+
}
107+
108+
$id = $this->resourceIds[$resource] ??= $resource->id();
109+
110+
return $this->modelIds[$model] = $id->serializeValue($id->getValue($this), $this);
111+
}
112+
98113
/**
99114
* Get the fields for the given resource, keyed by name.
100115
*

src/Endpoint/Concerns/SavesData.php

Lines changed: 29 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -9,10 +9,11 @@
99
use Tobyz\JsonApiServer\Exception\Sourceable;
1010
use Tobyz\JsonApiServer\Exception\UnprocessableEntityException;
1111
use Tobyz\JsonApiServer\Schema\Field\Field;
12+
use Tobyz\JsonApiServer\Schema\Id;
1213

14+
use function Tobyz\JsonApiServer\field_path;
1315
use function Tobyz\JsonApiServer\get_value;
1416
use function Tobyz\JsonApiServer\has_value;
15-
use function Tobyz\JsonApiServer\location;
1617
use function Tobyz\JsonApiServer\set_value;
1718

1819
trait SavesData
@@ -47,15 +48,11 @@ private function parseData(Context $context): array
4748
]);
4849
}
4950

50-
if ($body['data']['id'] !== $context->resource->getId($context->model, $context)) {
51+
if ($body['data']['id'] !== $context->id($context->resource, $context->model)) {
5152
throw (new ConflictException('data.id does not match the resource ID'))->setSource([
5253
'pointer' => '/data/id',
5354
]);
5455
}
55-
} elseif (isset($body['data']['id'])) {
56-
throw (new ForbiddenException('Client-generated IDs are not supported'))->setSource([
57-
'pointer' => '/data/id',
58-
]);
5956
}
6057

6158
if (!in_array($body['data']['type'], $context->collection->resources())) {
@@ -85,6 +82,17 @@ private function parseData(Context $context): array
8582
return array_merge(['attributes' => [], 'relationships' => []], $body['data']);
8683
}
8784

85+
private function getFields(Context $context, bool $creating = false): array
86+
{
87+
$fields = $context->fields($context->resource);
88+
89+
if ($creating) {
90+
array_unshift($fields, $context->resource->id());
91+
}
92+
93+
return $fields;
94+
}
95+
8896
/**
8997
* Assert that the fields contained within a data object are valid.
9098
*/
@@ -101,13 +109,13 @@ private function assertFieldsValid(Context $context, array $data, bool $creating
101109
*/
102110
private function assertFieldsExist(Context $context, array $data): void
103111
{
104-
$fields = $context->fields($context->resource);
112+
$fields = $this->getFields($context);
105113

106114
foreach (['attributes', 'relationships'] as $location) {
107115
foreach ($data[$location] as $name => $value) {
108-
if (!isset($fields[$name]) || $location !== location($fields[$name])) {
116+
if (!isset($fields[$name]) || $location !== $fields[$name]->location()) {
109117
throw (new BadRequestException("Unknown field [$name]"))->setSource([
110-
'pointer' => "/data/$location/$name",
118+
'pointer' => '/data/' . implode('/', array_filter([$location, $name])),
111119
]);
112120
}
113121
}
@@ -124,17 +132,15 @@ private function assertFieldsWritable(
124132
array $data,
125133
bool $creating = false,
126134
): void {
127-
foreach ($context->fields($context->resource) as $field) {
135+
foreach ($this->getFields($context, $creating) as $field) {
128136
if (!has_value($data, $field)) {
129137
continue;
130138
}
131139

132140
try {
133141
$this->assertFieldWritable($context, $field, $creating);
134142
} catch (Sourceable $e) {
135-
throw $e->prependSource([
136-
'pointer' => '/data/' . location($field) . '/' . $field->name,
137-
]);
143+
throw $e->prependSource(['pointer' => '/data' . field_path($field)]);
138144
}
139145
}
140146
}
@@ -156,9 +162,9 @@ private function assertFieldWritable(
156162
/**
157163
*
158164
*/
159-
private function deserializeValues(Context $context, array &$data): void
165+
private function deserializeValues(Context $context, array &$data, bool $creating = false): void
160166
{
161-
foreach ($context->fields($context->resource) as $field) {
167+
foreach ($this->getFields($context, $creating) as $field) {
162168
if (!has_value($data, $field)) {
163169
continue;
164170
}
@@ -168,9 +174,7 @@ private function deserializeValues(Context $context, array &$data): void
168174
try {
169175
set_value($data, $field, $field->deserializeValue($value, $context));
170176
} catch (Sourceable $e) {
171-
throw $e->prependSource([
172-
'pointer' => '/data/' . location($field) . '/' . $field->name,
173-
]);
177+
throw $e->prependSource(['pointer' => '/data' . field_path($field)]);
174178
}
175179
}
176180
}
@@ -184,7 +188,7 @@ private function assertDataValid(Context $context, array $data, bool $validateAl
184188
{
185189
$errors = [];
186190

187-
foreach ($context->fields($context->resource) as $field) {
191+
foreach ($this->getFields($context, $validateAll) as $field) {
188192
$present = has_value($data, $field);
189193

190194
if (!$present && (!$field->required || !$validateAll)) {
@@ -201,7 +205,7 @@ private function assertDataValid(Context $context, array $data, bool $validateAl
201205
$errors,
202206
array_map(
203207
fn($error) => $error + [
204-
'source' => ['pointer' => '/data/' . location($field) . '/' . $field->name],
208+
'source' => ['pointer' => '/data' . field_path($field)],
205209
],
206210
$fieldErrors,
207211
),
@@ -213,7 +217,7 @@ private function assertDataValid(Context $context, array $data, bool $validateAl
213217
}
214218
}
215219

216-
private function validateField(Context $context, Field $field, mixed $value): array
220+
private function validateField(Context $context, Field|Id $field, mixed $value): array
217221
{
218222
$errors = [];
219223

@@ -229,9 +233,9 @@ private function validateField(Context $context, Field $field, mixed $value): ar
229233
/**
230234
* Set field values from a data object to the model instance.
231235
*/
232-
private function setValues(Context $context, array $data): void
236+
private function setValues(Context $context, array $data, bool $creating = false): void
233237
{
234-
foreach ($context->fields($context->resource) as $field) {
238+
foreach ($this->getFields($context, $creating) as $field) {
235239
if (!has_value($data, $field)) {
236240
continue;
237241
}
@@ -245,9 +249,9 @@ private function setValues(Context $context, array $data): void
245249
/**
246250
* Run any field save callbacks.
247251
*/
248-
private function saveFields(Context $context, array $data): void
252+
private function saveFields(Context $context, array $data, bool $creating = false): void
249253
{
250-
foreach ($context->fields($context->resource) as $field) {
254+
foreach ($this->getFields($context, $creating) as $field) {
251255
if (!has_value($data, $field)) {
252256
continue;
253257
}

src/Endpoint/Concerns/ShowsResources.php

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@ private function selfLink($model, Context $context): string
1616
return implode('/', [
1717
$context->api->basePath,
1818
$context->collection->name(),
19-
$context->resource->getId($model, $context),
19+
$context->id($context->resource, $model),
2020
]);
2121
}
2222
}

0 commit comments

Comments
 (0)