Skip to content

Commit 1bc670c

Browse files
fix(jsonapi): allow opt-in client-generated IDs on POST per spec (#7930)
Co-authored-by: soyuka <soyuka@users.noreply.github.com>
1 parent 86a09b3 commit 1bc670c

10 files changed

Lines changed: 218 additions & 14 deletions

File tree

src/JsonApi/JsonSchema/SchemaFactory.php

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -283,6 +283,8 @@ public function setSchemaFactory(SchemaFactoryInterface $schemaFactory): void
283283

284284
private function buildDefinitionPropertiesSchema(string $key, string $className, string $format, string $type, ?Operation $operation, Schema $schema, ?array $serializerContext): array
285285
{
286+
// Capture the resource's operation up front; the relationship loop below reassigns $operation.
287+
$resourceOperation = $operation;
286288
$definitions = $schema->getDefinitions();
287289
$properties = $definitions[$key]['properties'] ?? [];
288290

@@ -371,7 +373,8 @@ private function buildDefinitionPropertiesSchema(string $key, string $className,
371373
}
372374

373375
// 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'];
376+
// Only relax the requirement on the input schema; responses still always carry an `id`.
377+
$required = Schema::TYPE_INPUT === $type && $resourceOperation instanceof HttpOperation && 'POST' === $resourceOperation->getMethod() ? ['type'] : ['type', 'id'];
375378

376379
return [
377380
'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/JsonApi/Tests/JsonSchema/SchemaFactoryTest.php

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -189,4 +189,14 @@ public function testPatchInputSchemaRequiresId(): void
189189
$data = $definitions[$rootDefinitionKey]['properties']['data'];
190190
$this->assertSame(['type', 'id'], $data['required']);
191191
}
192+
193+
public function testPostOutputSchemaRequiresId(): void
194+
{
195+
$resultSchema = $this->schemaFactory->buildSchema(Dummy::class, 'jsonapi', Schema::TYPE_OUTPUT, new Post());
196+
$definitions = $resultSchema->getDefinitions();
197+
$rootDefinitionKey = $resultSchema->getRootDefinitionKey();
198+
199+
$data = $definitions[$rootDefinitionKey]['properties']['data'];
200+
$this->assertSame(['type', 'id'], $data['required']);
201+
}
192202
}

src/Laravel/ApiPlatformProvider.php

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -984,6 +984,7 @@ public function register(): void
984984
$this->app->singleton(JsonApiItemNormalizer::class, static function (Application $app) {
985985
$config = $app['config'];
986986
$defaultContext = $config->get('api-platform.serializer', []);
987+
$defaultContext[JsonApiItemNormalizer::ALLOW_CLIENT_GENERATED_ID] = (bool) $config->get('api-platform.jsonapi.allow_client_generated_id', false);
987988
$useIriAsId = (bool) $config->get('api-platform.jsonapi.use_iri_as_id', true);
988989

989990
return new JsonApiItemNormalizer(

src/Laravel/config/api-platform.php

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

8994
'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)