diff --git a/src/JsonApi/JsonSchema/SchemaFactory.php b/src/JsonApi/JsonSchema/SchemaFactory.php index 8e5b0667c59..f2809adc25e 100644 --- a/src/JsonApi/JsonSchema/SchemaFactory.php +++ b/src/JsonApi/JsonSchema/SchemaFactory.php @@ -20,6 +20,7 @@ use ApiPlatform\JsonSchema\SchemaFactoryAwareInterface; use ApiPlatform\JsonSchema\SchemaFactoryInterface; use ApiPlatform\JsonSchema\SchemaUriPrefixTrait; +use ApiPlatform\Metadata\HttpOperation; use ApiPlatform\Metadata\Operation; use ApiPlatform\Metadata\Property\Factory\PropertyMetadataFactoryInterface; use ApiPlatform\Metadata\Resource\Factory\ResourceMetadataCollectionFactoryInterface; @@ -282,6 +283,10 @@ 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 operation for the resource being built; the loop below + // reassigns $operation while resolving relationships. + $resourceOperation = $operation; + $definitions = $schema->getDefinitions(); $properties = $definitions[$key]['properties'] ?? []; @@ -369,11 +374,18 @@ private function buildDefinitionPropertiesSchema(string $key, string $className, ]; } + // Per JSON:API spec, `id` is optional in the request body of a creation: + // https://jsonapi.org/format/#crud-creating + $required = ['type', 'id']; + if (Schema::TYPE_INPUT === $type && $resourceOperation instanceof HttpOperation && 'POST' === $resourceOperation->getMethod()) { + $required = ['type']; + } + return [ 'data' => [ 'type' => 'object', 'properties' => $replacement, - 'required' => ['type', 'id'], + 'required' => $required, ], ] + $included; } diff --git a/src/JsonApi/Serializer/ItemNormalizer.php b/src/JsonApi/Serializer/ItemNormalizer.php index cd40caedcca..7d7eb460535 100644 --- a/src/JsonApi/Serializer/ItemNormalizer.php +++ b/src/JsonApi/Serializer/ItemNormalizer.php @@ -179,9 +179,6 @@ public function normalize(mixed $data, ?string $format = null, array $context = return $document; } - /** - * {@inheritdoc} - */ protected function getAttributes(object $object, ?string $format = null, array $context = []): array { return $this->getComponents($object, $format, $context)['attributes']; diff --git a/src/JsonApi/Serializer/ItemNormalizerTrait.php b/src/JsonApi/Serializer/ItemNormalizerTrait.php index 5b00aa13ce0..620aeb0510b 100644 --- a/src/JsonApi/Serializer/ItemNormalizerTrait.php +++ b/src/JsonApi/Serializer/ItemNormalizerTrait.php @@ -31,6 +31,14 @@ */ trait ItemNormalizerTrait { + /** + * Denormalization context flag enabling client-generated IDs on POST, per + * https://jsonapi.org/format/#crud-creating-client-ids + * Off by default to prevent ID spoofing on public endpoints. Can be set + * globally via the bundle configuration or per-call in the context. + */ + public const ALLOW_CLIENT_GENERATED_ID = 'allow_client_generated_id'; + public function getSupportedTypes(?string $format): array { return self::FORMAT === $format ? parent::getSupportedTypes($format) : []; @@ -52,18 +60,24 @@ 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[AbstractItemNormalizer::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 corresponding bundle configuration) to enable it.', self::ALLOW_CLIENT_GENERATED_ID)); + } + // Fall through: id flows 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[AbstractItemNormalizer::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[AbstractItemNormalizer::OBJECT_TO_POPULATE] = $this->iriConverter->getResourceFromIri($data['data']['id'], $context); + } elseif ($operation instanceof HttpOperation) { $iri = $this->reconstructIri($type, (string) $data['data']['id'], $operation); $context[AbstractItemNormalizer::OBJECT_TO_POPULATE] = $this->iriConverter->getResourceFromIri($iri, $context); } @@ -75,6 +89,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, $format, $context); } diff --git a/src/JsonApi/Tests/JsonSchema/SchemaFactoryTest.php b/src/JsonApi/Tests/JsonSchema/SchemaFactoryTest.php index 4adcb6480ce..e8ab12e9eea 100644 --- a/src/JsonApi/Tests/JsonSchema/SchemaFactoryTest.php +++ b/src/JsonApi/Tests/JsonSchema/SchemaFactoryTest.php @@ -22,6 +22,7 @@ use ApiPlatform\Metadata\Get; use ApiPlatform\Metadata\GetCollection; use ApiPlatform\Metadata\Operations; +use ApiPlatform\Metadata\Post; use ApiPlatform\Metadata\Property\Factory\PropertyMetadataFactoryInterface; use ApiPlatform\Metadata\Property\Factory\PropertyNameCollectionFactoryInterface; use ApiPlatform\Metadata\Property\PropertyNameCollection; @@ -49,6 +50,7 @@ protected function setUp(): void ); $propertyNameCollectionFactory = $this->prophesize(PropertyNameCollectionFactoryInterface::class); $propertyNameCollectionFactory->create(Dummy::class, ['enable_getter_setter_extraction' => true, 'schema_type' => Schema::TYPE_OUTPUT])->willReturn(new PropertyNameCollection()); + $propertyNameCollectionFactory->create(Dummy::class, ['enable_getter_setter_extraction' => true, 'schema_type' => Schema::TYPE_INPUT])->willReturn(new PropertyNameCollection()); $propertyMetadataFactory = $this->prophesize(PropertyMetadataFactoryInterface::class); $definitionNameFactory = new DefinitionNameFactory(null); @@ -164,4 +166,22 @@ public function testSchemaTypeBuildSchema(): void $forcedCollection = $this->schemaFactory->buildSchema(Dummy::class, 'jsonapi', Schema::TYPE_OUTPUT, forceCollection: true); $this->assertEquals($resultSchema['allOf'][0]['$ref'], $forcedCollection['allOf'][0]['$ref']); } + + public function testBuildSchemaForPostInputDoesNotRequireId(): void + { + $resultSchema = $this->schemaFactory->buildSchema(Dummy::class, 'jsonapi', Schema::TYPE_INPUT, new Post()); + $rootDefinitionKey = $resultSchema->getRootDefinitionKey(); + $properties = $resultSchema['definitions'][$rootDefinitionKey]['properties']; + + $this->assertSame(['type'], $properties['data']['required']); + } + + public function testBuildSchemaForPostOutputStillRequiresId(): void + { + $resultSchema = $this->schemaFactory->buildSchema(Dummy::class, 'jsonapi', Schema::TYPE_OUTPUT, new Post()); + $rootDefinitionKey = $resultSchema->getRootDefinitionKey(); + $properties = $resultSchema['definitions'][$rootDefinitionKey]['properties']; + + $this->assertSame(['type', 'id'], $properties['data']['required']); + } } diff --git a/src/JsonApi/Tests/Serializer/ItemNormalizerTest.php b/src/JsonApi/Tests/Serializer/ItemNormalizerTest.php index 2809e2fed9b..c07f728e9af 100644 --- a/src/JsonApi/Tests/Serializer/ItemNormalizerTest.php +++ b/src/JsonApi/Tests/Serializer/ItemNormalizerTest.php @@ -26,6 +26,7 @@ use ApiPlatform\Metadata\IriConverterInterface; use ApiPlatform\Metadata\Link; use ApiPlatform\Metadata\Operations; +use ApiPlatform\Metadata\Post; use ApiPlatform\Metadata\Property\Factory\PropertyMetadataFactoryInterface; use ApiPlatform\Metadata\Property\Factory\PropertyNameCollectionFactoryInterface; use ApiPlatform\Metadata\Property\PropertyNameCollection; @@ -1052,4 +1053,151 @@ public function testDenormalizeInputDtoDoesNotDoubleUnwrapJsonApiStructure(): vo $this->assertSame('Hello', $result->title); $this->assertSame('World', $result->body); } + + public function testDenormalizePostWithIdThrowsWithoutOptIn(): void + { + $this->expectException(NotNormalizableValueException::class); + $this->expectExceptionMessage('Client-generated IDs are not allowed on this operation.'); + + $normalizer = new ItemNormalizer( + $this->prophesize(PropertyNameCollectionFactoryInterface::class)->reveal(), + $this->prophesize(PropertyMetadataFactoryInterface::class)->reveal(), + $this->prophesize(IriConverterInterface::class)->reveal(), + $this->prophesize(ResourceClassResolverInterface::class)->reveal(), + ); + + $normalizer->denormalize( + [ + 'data' => [ + 'id' => 'b1f3e6a4-1234-4abc-9def-0123456789ab', + 'type' => 'dummy', + ], + ], + Dummy::class, + ItemNormalizer::FORMAT, + [ + 'operation' => new Post(), + ] + ); + } + + public function testDenormalizePostWithIdSucceedsWithOptIn(): void + { + $clientId = 'b1f3e6a4-1234-4abc-9def-0123456789ab'; + $data = [ + 'data' => [ + 'type' => 'dummy', + 'id' => $clientId, + 'attributes' => [ + 'name' => 'foo', + ], + ], + ]; + + $propertyNameCollectionFactoryProphecy = $this->prophesize(PropertyNameCollectionFactoryInterface::class); + $propertyNameCollectionFactoryProphecy->create(Dummy::class, Argument::any())->willReturn(new PropertyNameCollection(['id', 'name'])); + + $propertyMetadataFactoryProphecy = $this->prophesize(PropertyMetadataFactoryInterface::class); + $propertyMetadataFactoryProphecy->create(Dummy::class, 'id', Argument::any())->willReturn((new ApiProperty())->withNativeType(Type::string())->withReadable(false)->withWritable(true)->withIdentifier(true)); + $propertyMetadataFactoryProphecy->create(Dummy::class, 'name', Argument::any())->willReturn((new ApiProperty())->withNativeType(Type::string())->withReadable(false)->withWritable(true)); + + // The IRI converter MUST NOT be queried for an existing resource on POST with a client-generated id. + $iriConverterProphecy = $this->prophesize(IriConverterInterface::class); + $iriConverterProphecy->getResourceFromIri(Argument::cetera())->shouldNotBeCalled(); + + $propertyAccessorProphecy = $this->prophesize(PropertyAccessorInterface::class); + $propertyAccessorProphecy->setValue(Argument::type(Dummy::class), 'name', 'foo')->shouldBeCalled(); + $propertyAccessorProphecy->setValue(Argument::type(Dummy::class), 'id', $clientId)->shouldBeCalled(); + + $resourceClassResolverProphecy = $this->prophesize(ResourceClassResolverInterface::class); + $resourceClassResolverProphecy->getResourceClass(null, Dummy::class)->willReturn(Dummy::class); + $resourceClassResolverProphecy->isResourceClass(Dummy::class)->willReturn(true); + + $resourceMetadataCollectionFactory = $this->prophesize(ResourceMetadataCollectionFactoryInterface::class); + $resourceMetadataCollectionFactory->create(Dummy::class)->willReturn(new ResourceMetadataCollection(Dummy::class, [ + (new ApiResource())->withOperations(new Operations([new Post(name: 'post')])), + ])); + + $serializerProphecy = $this->prophesize(SerializerInterface::class); + $serializerProphecy->willImplement(NormalizerInterface::class); + + $normalizer = new ItemNormalizer( + $propertyNameCollectionFactoryProphecy->reveal(), + $propertyMetadataFactoryProphecy->reveal(), + $iriConverterProphecy->reveal(), + $resourceClassResolverProphecy->reveal(), + $propertyAccessorProphecy->reveal(), + new ReservedAttributeNameConverter(), + null, + [], + $resourceMetadataCollectionFactory->reveal(), + ); + $normalizer->setSerializer($serializerProphecy->reveal()); + + $result = $normalizer->denormalize($data, Dummy::class, ItemNormalizer::FORMAT, [ + 'operation' => new Post(), + ItemNormalizer::ALLOW_CLIENT_GENERATED_ID => true, + ]); + + $this->assertInstanceOf(Dummy::class, $result); + } + + public function testDenormalizePostWithIdSucceedsWhenEnabledViaDefaultContext(): void + { + $clientId = 'b1f3e6a4-1234-4abc-9def-0123456789ab'; + $data = [ + 'data' => [ + 'type' => 'dummy', + 'id' => $clientId, + 'attributes' => [ + 'name' => 'foo', + ], + ], + ]; + + $propertyNameCollectionFactoryProphecy = $this->prophesize(PropertyNameCollectionFactoryInterface::class); + $propertyNameCollectionFactoryProphecy->create(Dummy::class, Argument::any())->willReturn(new PropertyNameCollection(['id', 'name'])); + + $propertyMetadataFactoryProphecy = $this->prophesize(PropertyMetadataFactoryInterface::class); + $propertyMetadataFactoryProphecy->create(Dummy::class, 'id', Argument::any())->willReturn((new ApiProperty())->withNativeType(Type::string())->withReadable(false)->withWritable(true)->withIdentifier(true)); + $propertyMetadataFactoryProphecy->create(Dummy::class, 'name', Argument::any())->willReturn((new ApiProperty())->withNativeType(Type::string())->withReadable(false)->withWritable(true)); + + $iriConverterProphecy = $this->prophesize(IriConverterInterface::class); + $iriConverterProphecy->getResourceFromIri(Argument::cetera())->shouldNotBeCalled(); + + $propertyAccessorProphecy = $this->prophesize(PropertyAccessorInterface::class); + $propertyAccessorProphecy->setValue(Argument::type(Dummy::class), 'name', 'foo')->shouldBeCalled(); + $propertyAccessorProphecy->setValue(Argument::type(Dummy::class), 'id', $clientId)->shouldBeCalled(); + + $resourceClassResolverProphecy = $this->prophesize(ResourceClassResolverInterface::class); + $resourceClassResolverProphecy->getResourceClass(null, Dummy::class)->willReturn(Dummy::class); + $resourceClassResolverProphecy->isResourceClass(Dummy::class)->willReturn(true); + + $resourceMetadataCollectionFactory = $this->prophesize(ResourceMetadataCollectionFactoryInterface::class); + $resourceMetadataCollectionFactory->create(Dummy::class)->willReturn(new ResourceMetadataCollection(Dummy::class, [ + (new ApiResource())->withOperations(new Operations([new Post(name: 'post')])), + ])); + + $serializerProphecy = $this->prophesize(SerializerInterface::class); + $serializerProphecy->willImplement(NormalizerInterface::class); + + $normalizer = new ItemNormalizer( + $propertyNameCollectionFactoryProphecy->reveal(), + $propertyMetadataFactoryProphecy->reveal(), + $iriConverterProphecy->reveal(), + $resourceClassResolverProphecy->reveal(), + $propertyAccessorProphecy->reveal(), + new ReservedAttributeNameConverter(), + null, + [ItemNormalizer::ALLOW_CLIENT_GENERATED_ID => true], + $resourceMetadataCollectionFactory->reveal(), + ); + $normalizer->setSerializer($serializerProphecy->reveal()); + + $result = $normalizer->denormalize($data, Dummy::class, ItemNormalizer::FORMAT, [ + 'operation' => new Post(), + ]); + + $this->assertInstanceOf(Dummy::class, $result); + } } diff --git a/src/Laravel/ApiPlatformProvider.php b/src/Laravel/ApiPlatformProvider.php index 1ffe86f980f..16a61a4e444 100644 --- a/src/Laravel/ApiPlatformProvider.php +++ b/src/Laravel/ApiPlatformProvider.php @@ -1001,6 +1001,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] = $config->get('api-platform.jsonapi.allow_client_generated_id', false); return new JsonApiItemNormalizer( $app->make(PropertyNameCollectionFactoryInterface::class), @@ -1020,6 +1021,7 @@ public function register(): void $this->app->singleton(JsonApiItemDenormalizer::class, static function (Application $app) { $config = $app['config']; $defaultContext = $config->get('api-platform.serializer', []); + $defaultContext[JsonApiItemDenormalizer::ALLOW_CLIENT_GENERATED_ID] = $config->get('api-platform.jsonapi.allow_client_generated_id', false); return new JsonApiItemDenormalizer( $app->make(PropertyNameCollectionFactoryInterface::class), diff --git a/src/Laravel/config/api-platform.php b/src/Laravel/config/api-platform.php index 2db701be663..317228fde34 100644 --- a/src/Laravel/config/api-platform.php +++ b/src/Laravel/config/api-platform.php @@ -156,6 +156,12 @@ // 'datetime_format' => \DateTimeInterface::RFC3339, ], + 'jsonapi' => [ + // Allow clients to provide an id on POST per https://jsonapi.org/format/#crud-creating-client-ids. + // Off by default to prevent ID spoofing. + 'allow_client_generated_id' => false, + ], + // we recommend using "file" or "acpu" 'cache' => 'file', diff --git a/src/Symfony/Bundle/DependencyInjection/ApiPlatformExtension.php b/src/Symfony/Bundle/DependencyInjection/ApiPlatformExtension.php index 57b2cdfaac3..b58d6af8a42 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; @@ -701,10 +702,16 @@ private function registerJsonApiConfiguration(ContainerBuilder $container, array $loader->load('jsonapi.php'); $loader->load('state/jsonapi.php'); + $defaultContext = [ + JsonApiItemNormalizer::ALLOW_CLIENT_GENERATED_ID => $config['jsonapi']['allow_client_generated_id'], + ]; + $container->getDefinition('api_platform.jsonapi.normalizer.item') + ->replaceArgument(7, $defaultContext) ->addArgument($config['jsonapi']['use_iri_as_id']); $container->getDefinition('api_platform.jsonapi.denormalizer.item') + ->replaceArgument(7, $defaultContext) ->addArgument($config['jsonapi']['use_iri_as_id']); } diff --git a/src/Symfony/Bundle/DependencyInjection/Configuration.php b/src/Symfony/Bundle/DependencyInjection/Configuration.php index 911de422fdb..7c69ff50d87 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 clients to provide an id on POST per https://jsonapi.org/format/#crud-creating-client-ids. Off by default to prevent ID spoofing.') + ->end() ->end() ->end() ->arrayNode('eager_loading') diff --git a/tests/Symfony/Bundle/DependencyInjection/ConfigurationTest.php b/tests/Symfony/Bundle/DependencyInjection/ConfigurationTest.php index 9004ecb6654..9d1f82645bb 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);