Skip to content

Commit 151198b

Browse files
committed
feat(jsonapi): allow opt-in client-generated IDs on POST per spec
Per https://jsonapi.org/format/#crud-creating-client-ids a client MAY supply an id when creating a resource. The JSON:API ItemNormalizer previously treated any incoming `data.id` on POST as a hint to load an existing resource, throwing "Update is not allowed for this operation" or failing the IRI lookup, even when the application is designed for client-generated identifiers. A new opt-in `ALLOW_CLIENT_GENERATED_ID` denormalization context flag lets the client id flow through to the entity setter without querying the IriConverter. Off by default to avoid an id-spoofing footgun on public endpoints. Configurable globally via the Symfony bundle (`api_platform.jsonapi.allow_client_generated_id`) and Laravel (`api-platform.jsonapi.allow_client_generated_id`), per-operation via `denormalizationContext`. Also tightens the input-schema guard added in #8252 with a `Schema::TYPE_INPUT === $type` check so POST response schemas keep requiring `id`. Refs #6738
1 parent 5c62c1b commit 151198b

9 files changed

Lines changed: 206 additions & 14 deletions

File tree

src/JsonApi/JsonSchema/SchemaFactory.php

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -371,7 +371,8 @@ private function buildDefinitionPropertiesSchema(string $key, string $className,
371371
}
372372

373373
// https://jsonapi.org/format/#crud-creating — clients MAY supply an id when creating a resource.
374-
$required = $operation instanceof HttpOperation && 'POST' === $operation->getMethod() ? ['type'] : ['type', 'id'];
374+
// Only relax the requirement on the input schema; responses still always carry an `id`.
375+
$required = Schema::TYPE_INPUT === $type && $operation instanceof HttpOperation && 'POST' === $operation->getMethod() ? ['type'] : ['type', 'id'];
375376

376377
return [
377378
'data' => [

src/JsonApi/Serializer/ItemNormalizer.php

Lines changed: 30 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,14 @@ final class ItemNormalizer extends AbstractItemNormalizer
5959

6060
public const FORMAT = 'jsonapi';
6161

62+
/**
63+
* Denormalization context flag enabling client-generated IDs on POST per
64+
* https://jsonapi.org/format/#crud-creating-client-ids. Off by default to
65+
* avoid an id-spoofing footgun on public endpoints. Set in the context or
66+
* via the bundle configuration ("api_platform.jsonapi.allow_client_generated_id").
67+
*/
68+
public const ALLOW_CLIENT_GENERATED_ID = 'allow_client_generated_id';
69+
6270
private array $componentsCache = [];
6371
private bool $useIriAsId;
6472

@@ -205,21 +213,27 @@ public function denormalize(mixed $data, string $type, ?string $format = null, a
205213
return parent::denormalize($data, $type, $format, $context);
206214
}
207215

216+
$operation = $context['operation'] ?? null;
217+
$isPostOperation = $operation instanceof HttpOperation && 'POST' === $operation->getMethod();
218+
$allowClientGeneratedId = true === ($context[self::ALLOW_CLIENT_GENERATED_ID] ?? $this->defaultContext[self::ALLOW_CLIENT_GENERATED_ID] ?? false);
219+
208220
// Avoid issues with proxies if we populated the object
209221
if (!isset($context[self::OBJECT_TO_POPULATE]) && isset($data['data']['id'])) {
210-
if (true !== ($context['api_allow_update'] ?? true)) {
222+
if ($isPostOperation) {
223+
if (!$allowClientGeneratedId) {
224+
throw new NotNormalizableValueException(\sprintf('Client-generated IDs are not allowed on this operation. Set the "%s" denormalization context flag (or the bundle "allow_client_generated_id" configuration) to enable it.', self::ALLOW_CLIENT_GENERATED_ID));
225+
}
226+
// Fall through: client id is merged into the denormalized payload below.
227+
} elseif (true !== ($context['api_allow_update'] ?? true)) {
211228
throw new NotNormalizableValueException('Update is not allowed for this operation.');
212-
}
213-
214-
$context += ['fetch_data' => false];
215-
if ($this->useIriAsId) {
216-
$context[self::OBJECT_TO_POPULATE] = $this->iriConverter->getResourceFromIri(
217-
$data['data']['id'],
218-
$context
219-
);
220229
} else {
221-
$operation = $context['operation'] ?? null;
222-
if ($operation instanceof HttpOperation) {
230+
$context += ['fetch_data' => false];
231+
if ($this->useIriAsId) {
232+
$context[self::OBJECT_TO_POPULATE] = $this->iriConverter->getResourceFromIri(
233+
$data['data']['id'],
234+
$context
235+
);
236+
} elseif ($operation instanceof HttpOperation) {
223237
$iri = $this->reconstructIri($type, (string) $data['data']['id'], $operation);
224238
$context[self::OBJECT_TO_POPULATE] = $this->iriConverter->getResourceFromIri($iri, $context);
225239
}
@@ -232,6 +246,11 @@ public function denormalize(mixed $data, string $type, ?string $format = null, a
232246
$data['data']['relationships'] ?? []
233247
);
234248

249+
// Surface the client-generated id so the entity setter receives it.
250+
if ($isPostOperation && $allowClientGeneratedId && isset($data['data']['id'])) {
251+
$dataToDenormalize['id'] = $data['data']['id'];
252+
}
253+
235254
return parent::denormalize(
236255
$dataToDenormalize,
237256
$type,

src/Laravel/ApiPlatformProvider.php

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -977,6 +977,7 @@ public function register(): void
977977
$this->app->singleton(JsonApiItemNormalizer::class, static function (Application $app) {
978978
$config = $app['config'];
979979
$defaultContext = $config->get('api-platform.serializer', []);
980+
$defaultContext[JsonApiItemNormalizer::ALLOW_CLIENT_GENERATED_ID] = (bool) $config->get('api-platform.jsonapi.allow_client_generated_id', false);
980981
$useIriAsId = (bool) $config->get('api-platform.jsonapi.use_iri_as_id', true);
981982

982983
return new JsonApiItemNormalizer(

src/Laravel/config/api-platform.php

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -83,6 +83,11 @@
8383
// and a `data.links.self` IRI is added. When true (default), `data.id`
8484
// is the resource IRI.
8585
'use_iri_as_id' => true,
86+
87+
// Allow client-generated IDs on JSON:API POST per
88+
// https://jsonapi.org/format/#crud-creating-client-ids. Off by default
89+
// to avoid id spoofing on public endpoints.
90+
'allow_client_generated_id' => false,
8691
],
8792

8893
'graphql' => [

src/Symfony/Bundle/DependencyInjection/ApiPlatformExtension.php

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@
3030
use ApiPlatform\GraphQl\Resolver\QueryCollectionResolverInterface;
3131
use ApiPlatform\GraphQl\Resolver\QueryItemResolverInterface;
3232
use ApiPlatform\GraphQl\Type\Definition\TypeInterface as GraphQlTypeInterface;
33+
use ApiPlatform\JsonApi\Serializer\ItemNormalizer as JsonApiItemNormalizer;
3334
use ApiPlatform\Metadata\ApiResource;
3435
use ApiPlatform\Metadata\AsOperationMutator;
3536
use ApiPlatform\Metadata\AsResourceMutator;
@@ -705,8 +706,9 @@ private function registerJsonApiConfiguration(ContainerBuilder $container, array
705706
$loader->load('jsonapi.php');
706707
$loader->load('state/jsonapi.php');
707708

708-
$container->getDefinition('api_platform.jsonapi.normalizer.item')
709-
->addArgument($config['jsonapi']['use_iri_as_id']);
709+
$itemNormalizer = $container->getDefinition('api_platform.jsonapi.normalizer.item');
710+
$itemNormalizer->replaceArgument(7, [JsonApiItemNormalizer::ALLOW_CLIENT_GENERATED_ID => $config['jsonapi']['allow_client_generated_id'] ?? false]);
711+
$itemNormalizer->addArgument($config['jsonapi']['use_iri_as_id']);
710712
}
711713

712714
private function registerJsonLdHydraConfiguration(ContainerBuilder $container, array $formats, PhpFileLoader $loader, array $config): void

src/Symfony/Bundle/DependencyInjection/Configuration.php

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -108,6 +108,10 @@ public function getConfigTreeBuilder(): TreeBuilder
108108
->defaultTrue()
109109
->info('Set to false to use entity identifiers instead of IRIs as the "id" field in JSON:API responses.')
110110
->end()
111+
->booleanNode('allow_client_generated_id')
112+
->defaultFalse()
113+
->info('Allow client-generated IDs on JSON:API POST per https://jsonapi.org/format/#crud-creating-client-ids. Off by default to prevent id spoofing on public endpoints.')
114+
->end()
111115
->end()
112116
->end()
113117
->arrayNode('eager_loading')
Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
<?php
2+
3+
/*
4+
* This file is part of the API Platform project.
5+
*
6+
* (c) Kévin Dunglas <dunglas@gmail.com>
7+
*
8+
* For the full copyright and license information, please view the LICENSE
9+
* file that was distributed with this source code.
10+
*/
11+
12+
declare(strict_types=1);
13+
14+
namespace ApiPlatform\Tests\Fixtures\TestBundle\ApiResource\JsonApi;
15+
16+
use ApiPlatform\JsonApi\Serializer\ItemNormalizer;
17+
use ApiPlatform\Metadata\ApiProperty;
18+
use ApiPlatform\Metadata\ApiResource;
19+
use ApiPlatform\Metadata\Get;
20+
use ApiPlatform\Metadata\Post;
21+
22+
#[ApiResource(
23+
shortName: 'JsonApiClientGeneratedId',
24+
formats: ['jsonapi' => ['application/vnd.api+json']],
25+
operations: [
26+
new Get(
27+
uriTemplate: '/jsonapi_client_generated_ids/{id}',
28+
uriVariables: ['id'],
29+
provider: [self::class, 'provide'],
30+
),
31+
new Post(
32+
uriTemplate: '/jsonapi_client_generated_ids_opt_in',
33+
denormalizationContext: [ItemNormalizer::ALLOW_CLIENT_GENERATED_ID => true],
34+
processor: [self::class, 'process'],
35+
),
36+
new Post(
37+
uriTemplate: '/jsonapi_client_generated_ids',
38+
processor: [self::class, 'process'],
39+
),
40+
],
41+
)]
42+
class ClientGeneratedId
43+
{
44+
#[ApiProperty(identifier: true)]
45+
public ?string $id = null;
46+
47+
public ?string $name = null;
48+
49+
public static function provide(): self
50+
{
51+
$resource = new self();
52+
$resource->id = '1';
53+
$resource->name = 'existing';
54+
55+
return $resource;
56+
}
57+
58+
public static function process(self $data): self
59+
{
60+
$data->id ??= 'server-generated';
61+
62+
return $data;
63+
}
64+
}
Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,95 @@
1+
<?php
2+
3+
/*
4+
* This file is part of the API Platform project.
5+
*
6+
* (c) Kévin Dunglas <dunglas@gmail.com>
7+
*
8+
* For the full copyright and license information, please view the LICENSE
9+
* file that was distributed with this source code.
10+
*/
11+
12+
declare(strict_types=1);
13+
14+
namespace ApiPlatform\Tests\Functional\JsonApi;
15+
16+
use ApiPlatform\Symfony\Bundle\Test\ApiTestCase;
17+
use ApiPlatform\Tests\Fixtures\TestBundle\ApiResource\JsonApi\ClientGeneratedId;
18+
use ApiPlatform\Tests\SetupClassResourcesTrait;
19+
20+
final class ClientGeneratedIdTest extends ApiTestCase
21+
{
22+
use SetupClassResourcesTrait;
23+
24+
protected static ?bool $alwaysBootKernel = false;
25+
26+
/**
27+
* @return class-string[]
28+
*/
29+
public static function getResources(): array
30+
{
31+
return [ClientGeneratedId::class];
32+
}
33+
34+
public function testPostWithClientIdSucceedsWhenOptedIn(): void
35+
{
36+
$response = self::createClient()->request('POST', '/jsonapi_client_generated_ids_opt_in', [
37+
'headers' => [
38+
'Accept' => 'application/vnd.api+json',
39+
'Content-Type' => 'application/vnd.api+json',
40+
],
41+
'json' => [
42+
'data' => [
43+
'type' => 'JsonApiClientGeneratedId',
44+
'id' => 'client-uuid-42',
45+
'attributes' => ['name' => 'created with client id'],
46+
],
47+
],
48+
]);
49+
50+
$this->assertResponseStatusCodeSame(201);
51+
$body = $response->toArray();
52+
// Default identifier mode emits the IRI as `data.id`. The IRI must reflect the client-supplied id.
53+
$this->assertSame('/jsonapi_client_generated_ids/client-uuid-42', $body['data']['id']);
54+
$this->assertSame('created with client id', $body['data']['attributes']['name']);
55+
}
56+
57+
public function testPostWithClientIdRejectedByDefault(): void
58+
{
59+
self::createClient()->request('POST', '/jsonapi_client_generated_ids', [
60+
'headers' => [
61+
'Accept' => 'application/vnd.api+json',
62+
'Content-Type' => 'application/vnd.api+json',
63+
],
64+
'json' => [
65+
'data' => [
66+
'type' => 'JsonApiClientGeneratedId',
67+
'id' => 'client-uuid-43',
68+
'attributes' => ['name' => 'should fail'],
69+
],
70+
],
71+
]);
72+
73+
$this->assertResponseStatusCodeSame(400);
74+
}
75+
76+
public function testPostWithoutClientIdStillSucceeds(): void
77+
{
78+
$response = self::createClient()->request('POST', '/jsonapi_client_generated_ids', [
79+
'headers' => [
80+
'Accept' => 'application/vnd.api+json',
81+
'Content-Type' => 'application/vnd.api+json',
82+
],
83+
'json' => [
84+
'data' => [
85+
'type' => 'JsonApiClientGeneratedId',
86+
'attributes' => ['name' => 'server-side id'],
87+
],
88+
],
89+
]);
90+
91+
$this->assertResponseStatusCodeSame(201);
92+
$body = $response->toArray();
93+
$this->assertSame('server-side id', $body['data']['attributes']['name']);
94+
}
95+
}

tests/Symfony/Bundle/DependencyInjection/ConfigurationTest.php

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -251,6 +251,7 @@ private function runDefaultConfigTests(array $doctrineIntegrationsToLoad = ['orm
251251
],
252252
'jsonapi' => [
253253
'use_iri_as_id' => true,
254+
'allow_client_generated_id' => false,
254255
],
255256
'enable_scalar' => true,
256257
], $config);

0 commit comments

Comments
 (0)