diff --git a/src/JsonApi/JsonSchema/SchemaFactory.php b/src/JsonApi/JsonSchema/SchemaFactory.php index f940b44d7d..2baa10512e 100644 --- a/src/JsonApi/JsonSchema/SchemaFactory.php +++ b/src/JsonApi/JsonSchema/SchemaFactory.php @@ -283,6 +283,8 @@ public function setSchemaFactory(SchemaFactoryInterface $schemaFactory): void private function buildDefinitionPropertiesSchema(string $key, string $className, string $format, string $type, ?Operation $operation, Schema $schema, ?array $serializerContext): array { + // Capture the resource's operation up front; the relationship loop below reassigns $operation. + $resourceOperation = $operation; $definitions = $schema->getDefinitions(); $properties = $definitions[$key]['properties'] ?? []; @@ -371,7 +373,8 @@ private function buildDefinitionPropertiesSchema(string $key, string $className, } // https://jsonapi.org/format/#crud-creating — clients MAY supply an id when creating a resource. - $required = $operation instanceof HttpOperation && 'POST' === $operation->getMethod() ? ['type'] : ['type', 'id']; + // Only relax the requirement on the input schema; responses still always carry an `id`. + $required = Schema::TYPE_INPUT === $type && $resourceOperation instanceof HttpOperation && 'POST' === $resourceOperation->getMethod() ? ['type'] : ['type', 'id']; return [ 'data' => [ diff --git a/src/JsonApi/Serializer/ItemNormalizer.php b/src/JsonApi/Serializer/ItemNormalizer.php index d3e59b2ebe..ee2b7bfb34 100644 --- a/src/JsonApi/Serializer/ItemNormalizer.php +++ b/src/JsonApi/Serializer/ItemNormalizer.php @@ -59,6 +59,14 @@ final class ItemNormalizer extends AbstractItemNormalizer public const FORMAT = 'jsonapi'; + /** + * Denormalization context flag enabling client-generated IDs on POST per + * https://jsonapi.org/format/#crud-creating-client-ids. Off by default to + * avoid an id-spoofing footgun on public endpoints. Set in the context or + * via the bundle configuration ("api_platform.jsonapi.allow_client_generated_id"). + */ + public const ALLOW_CLIENT_GENERATED_ID = 'allow_client_generated_id'; + private array $componentsCache = []; private bool $useIriAsId; @@ -205,21 +213,27 @@ public function denormalize(mixed $data, string $type, ?string $format = null, a return parent::denormalize($data, $type, $format, $context); } + $operation = $context['operation'] ?? null; + $isPostOperation = $operation instanceof HttpOperation && 'POST' === $operation->getMethod(); + $allowClientGeneratedId = true === ($context[self::ALLOW_CLIENT_GENERATED_ID] ?? $this->defaultContext[self::ALLOW_CLIENT_GENERATED_ID] ?? false); + // Avoid issues with proxies if we populated the object if (!isset($context[self::OBJECT_TO_POPULATE]) && isset($data['data']['id'])) { - if (true !== ($context['api_allow_update'] ?? true)) { + if ($isPostOperation) { + if (!$allowClientGeneratedId) { + 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)); + } + // Fall through: client id is merged into the denormalized payload below. + } elseif (true !== ($context['api_allow_update'] ?? true)) { throw new NotNormalizableValueException('Update is not allowed for this operation.'); - } - - $context += ['fetch_data' => false]; - if ($this->useIriAsId) { - $context[self::OBJECT_TO_POPULATE] = $this->iriConverter->getResourceFromIri( - $data['data']['id'], - $context - ); } else { - $operation = $context['operation'] ?? null; - if ($operation instanceof HttpOperation) { + $context += ['fetch_data' => false]; + if ($this->useIriAsId) { + $context[self::OBJECT_TO_POPULATE] = $this->iriConverter->getResourceFromIri( + $data['data']['id'], + $context + ); + } elseif ($operation instanceof HttpOperation) { $iri = $this->reconstructIri($type, (string) $data['data']['id'], $operation); $context[self::OBJECT_TO_POPULATE] = $this->iriConverter->getResourceFromIri($iri, $context); } @@ -232,6 +246,11 @@ public function denormalize(mixed $data, string $type, ?string $format = null, a $data['data']['relationships'] ?? [] ); + // Surface the client-generated id so the entity setter receives it. + if ($isPostOperation && $allowClientGeneratedId && isset($data['data']['id'])) { + $dataToDenormalize['id'] = $data['data']['id']; + } + return parent::denormalize( $dataToDenormalize, $type, diff --git a/src/JsonApi/Tests/JsonSchema/SchemaFactoryTest.php b/src/JsonApi/Tests/JsonSchema/SchemaFactoryTest.php index 9ca0d9c1b2..d588075cd0 100644 --- a/src/JsonApi/Tests/JsonSchema/SchemaFactoryTest.php +++ b/src/JsonApi/Tests/JsonSchema/SchemaFactoryTest.php @@ -189,4 +189,14 @@ public function testPatchInputSchemaRequiresId(): void $data = $definitions[$rootDefinitionKey]['properties']['data']; $this->assertSame(['type', 'id'], $data['required']); } + + public function testPostOutputSchemaRequiresId(): void + { + $resultSchema = $this->schemaFactory->buildSchema(Dummy::class, 'jsonapi', Schema::TYPE_OUTPUT, new Post()); + $definitions = $resultSchema->getDefinitions(); + $rootDefinitionKey = $resultSchema->getRootDefinitionKey(); + + $data = $definitions[$rootDefinitionKey]['properties']['data']; + $this->assertSame(['type', 'id'], $data['required']); + } } diff --git a/src/Laravel/ApiPlatformProvider.php b/src/Laravel/ApiPlatformProvider.php index 197db3758d..2406f105da 100644 --- a/src/Laravel/ApiPlatformProvider.php +++ b/src/Laravel/ApiPlatformProvider.php @@ -977,6 +977,7 @@ public function register(): void $this->app->singleton(JsonApiItemNormalizer::class, static function (Application $app) { $config = $app['config']; $defaultContext = $config->get('api-platform.serializer', []); + $defaultContext[JsonApiItemNormalizer::ALLOW_CLIENT_GENERATED_ID] = (bool) $config->get('api-platform.jsonapi.allow_client_generated_id', false); $useIriAsId = (bool) $config->get('api-platform.jsonapi.use_iri_as_id', true); return new JsonApiItemNormalizer( diff --git a/src/Laravel/config/api-platform.php b/src/Laravel/config/api-platform.php index 79b355e0da..cb34f5d481 100644 --- a/src/Laravel/config/api-platform.php +++ b/src/Laravel/config/api-platform.php @@ -83,6 +83,11 @@ // and a `data.links.self` IRI is added. When true (default), `data.id` // is the resource IRI. 'use_iri_as_id' => true, + + // Allow client-generated IDs on JSON:API POST per + // https://jsonapi.org/format/#crud-creating-client-ids. Off by default + // to avoid id spoofing on public endpoints. + 'allow_client_generated_id' => false, ], 'graphql' => [ diff --git a/src/Symfony/Bundle/DependencyInjection/ApiPlatformExtension.php b/src/Symfony/Bundle/DependencyInjection/ApiPlatformExtension.php index 720af7599d..bca6f7107d 100644 --- a/src/Symfony/Bundle/DependencyInjection/ApiPlatformExtension.php +++ b/src/Symfony/Bundle/DependencyInjection/ApiPlatformExtension.php @@ -30,6 +30,7 @@ use ApiPlatform\GraphQl\Resolver\QueryCollectionResolverInterface; use ApiPlatform\GraphQl\Resolver\QueryItemResolverInterface; use ApiPlatform\GraphQl\Type\Definition\TypeInterface as GraphQlTypeInterface; +use ApiPlatform\JsonApi\Serializer\ItemNormalizer as JsonApiItemNormalizer; use ApiPlatform\Metadata\ApiResource; use ApiPlatform\Metadata\AsOperationMutator; use ApiPlatform\Metadata\AsResourceMutator; @@ -705,8 +706,9 @@ private function registerJsonApiConfiguration(ContainerBuilder $container, array $loader->load('jsonapi.php'); $loader->load('state/jsonapi.php'); - $container->getDefinition('api_platform.jsonapi.normalizer.item') - ->addArgument($config['jsonapi']['use_iri_as_id']); + $itemNormalizer = $container->getDefinition('api_platform.jsonapi.normalizer.item'); + $itemNormalizer->replaceArgument(7, [JsonApiItemNormalizer::ALLOW_CLIENT_GENERATED_ID => $config['jsonapi']['allow_client_generated_id'] ?? false]); + $itemNormalizer->addArgument($config['jsonapi']['use_iri_as_id']); } private function registerJsonLdHydraConfiguration(ContainerBuilder $container, array $formats, PhpFileLoader $loader, array $config): void diff --git a/src/Symfony/Bundle/DependencyInjection/Configuration.php b/src/Symfony/Bundle/DependencyInjection/Configuration.php index 911de422fd..b70544ffbe 100644 --- a/src/Symfony/Bundle/DependencyInjection/Configuration.php +++ b/src/Symfony/Bundle/DependencyInjection/Configuration.php @@ -108,6 +108,10 @@ public function getConfigTreeBuilder(): TreeBuilder ->defaultTrue() ->info('Set to false to use entity identifiers instead of IRIs as the "id" field in JSON:API responses.') ->end() + ->booleanNode('allow_client_generated_id') + ->defaultFalse() + ->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.') + ->end() ->end() ->end() ->arrayNode('eager_loading') diff --git a/tests/Fixtures/TestBundle/ApiResource/JsonApi/ClientGeneratedId.php b/tests/Fixtures/TestBundle/ApiResource/JsonApi/ClientGeneratedId.php new file mode 100644 index 0000000000..0d02156352 --- /dev/null +++ b/tests/Fixtures/TestBundle/ApiResource/JsonApi/ClientGeneratedId.php @@ -0,0 +1,64 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace ApiPlatform\Tests\Fixtures\TestBundle\ApiResource\JsonApi; + +use ApiPlatform\JsonApi\Serializer\ItemNormalizer; +use ApiPlatform\Metadata\ApiProperty; +use ApiPlatform\Metadata\ApiResource; +use ApiPlatform\Metadata\Get; +use ApiPlatform\Metadata\Post; + +#[ApiResource( + shortName: 'JsonApiClientGeneratedId', + formats: ['jsonapi' => ['application/vnd.api+json']], + operations: [ + new Get( + uriTemplate: '/jsonapi_client_generated_ids/{id}', + uriVariables: ['id'], + provider: [self::class, 'provide'], + ), + new Post( + uriTemplate: '/jsonapi_client_generated_ids_opt_in', + denormalizationContext: [ItemNormalizer::ALLOW_CLIENT_GENERATED_ID => true], + processor: [self::class, 'process'], + ), + new Post( + uriTemplate: '/jsonapi_client_generated_ids', + processor: [self::class, 'process'], + ), + ], +)] +class ClientGeneratedId +{ + #[ApiProperty(identifier: true)] + public ?string $id = null; + + public ?string $name = null; + + public static function provide(): self + { + $resource = new self(); + $resource->id = '1'; + $resource->name = 'existing'; + + return $resource; + } + + public static function process(self $data): self + { + $data->id ??= 'server-generated'; + + return $data; + } +} diff --git a/tests/Functional/JsonApi/ClientGeneratedIdTest.php b/tests/Functional/JsonApi/ClientGeneratedIdTest.php new file mode 100644 index 0000000000..e4c5cf06a4 --- /dev/null +++ b/tests/Functional/JsonApi/ClientGeneratedIdTest.php @@ -0,0 +1,95 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace ApiPlatform\Tests\Functional\JsonApi; + +use ApiPlatform\Symfony\Bundle\Test\ApiTestCase; +use ApiPlatform\Tests\Fixtures\TestBundle\ApiResource\JsonApi\ClientGeneratedId; +use ApiPlatform\Tests\SetupClassResourcesTrait; + +final class ClientGeneratedIdTest extends ApiTestCase +{ + use SetupClassResourcesTrait; + + protected static ?bool $alwaysBootKernel = false; + + /** + * @return class-string[] + */ + public static function getResources(): array + { + return [ClientGeneratedId::class]; + } + + public function testPostWithClientIdSucceedsWhenOptedIn(): void + { + $response = self::createClient()->request('POST', '/jsonapi_client_generated_ids_opt_in', [ + 'headers' => [ + 'Accept' => 'application/vnd.api+json', + 'Content-Type' => 'application/vnd.api+json', + ], + 'json' => [ + 'data' => [ + 'type' => 'JsonApiClientGeneratedId', + 'id' => 'client-uuid-42', + 'attributes' => ['name' => 'created with client id'], + ], + ], + ]); + + $this->assertResponseStatusCodeSame(201); + $body = $response->toArray(); + // Default identifier mode emits the IRI as `data.id`. The IRI must reflect the client-supplied id. + $this->assertSame('/jsonapi_client_generated_ids/client-uuid-42', $body['data']['id']); + $this->assertSame('created with client id', $body['data']['attributes']['name']); + } + + public function testPostWithClientIdRejectedByDefault(): void + { + self::createClient()->request('POST', '/jsonapi_client_generated_ids', [ + 'headers' => [ + 'Accept' => 'application/vnd.api+json', + 'Content-Type' => 'application/vnd.api+json', + ], + 'json' => [ + 'data' => [ + 'type' => 'JsonApiClientGeneratedId', + 'id' => 'client-uuid-43', + 'attributes' => ['name' => 'should fail'], + ], + ], + ]); + + $this->assertResponseStatusCodeSame(400); + } + + public function testPostWithoutClientIdStillSucceeds(): void + { + $response = self::createClient()->request('POST', '/jsonapi_client_generated_ids', [ + 'headers' => [ + 'Accept' => 'application/vnd.api+json', + 'Content-Type' => 'application/vnd.api+json', + ], + 'json' => [ + 'data' => [ + 'type' => 'JsonApiClientGeneratedId', + 'attributes' => ['name' => 'server-side id'], + ], + ], + ]); + + $this->assertResponseStatusCodeSame(201); + $body = $response->toArray(); + $this->assertSame('server-side id', $body['data']['attributes']['name']); + } +} diff --git a/tests/Symfony/Bundle/DependencyInjection/ConfigurationTest.php b/tests/Symfony/Bundle/DependencyInjection/ConfigurationTest.php index 9004ecb665..9d1f82645b 100644 --- a/tests/Symfony/Bundle/DependencyInjection/ConfigurationTest.php +++ b/tests/Symfony/Bundle/DependencyInjection/ConfigurationTest.php @@ -251,6 +251,7 @@ private function runDefaultConfigTests(array $doctrineIntegrationsToLoad = ['orm ], 'jsonapi' => [ 'use_iri_as_id' => true, + 'allow_client_generated_id' => false, ], 'enable_scalar' => true, ], $config);