From 566b590f667a30b6a40c0f52afc1e4837471e304 Mon Sep 17 00:00:00 2001 From: Abderrahim GHAZALI Date: Sun, 26 Apr 2026 19:12:08 +0200 Subject: [PATCH 1/4] fix(jsonapi): allow opt-in client-generated IDs on POST per spec --- src/JsonApi/JsonSchema/SchemaFactory.php | 14 ++- src/JsonApi/Serializer/ItemNormalizer.php | 40 ++++++--- .../Tests/JsonSchema/SchemaFactoryTest.php | 20 +++++ .../Tests/Serializer/ItemNormalizerTest.php | 89 +++++++++++++++++++ 4 files changed, 151 insertions(+), 12 deletions(-) diff --git a/src/JsonApi/JsonSchema/SchemaFactory.php b/src/JsonApi/JsonSchema/SchemaFactory.php index 17f3e172304..a2588116cdd 100644 --- a/src/JsonApi/JsonSchema/SchemaFactory.php +++ b/src/JsonApi/JsonSchema/SchemaFactory.php @@ -21,6 +21,7 @@ use ApiPlatform\JsonSchema\SchemaFactoryInterface; use ApiPlatform\JsonSchema\SchemaUriPrefixTrait; use ApiPlatform\Metadata\Operation; +use ApiPlatform\Metadata\Post; use ApiPlatform\Metadata\Property\Factory\PropertyMetadataFactoryInterface; use ApiPlatform\Metadata\Resource\Factory\ResourceMetadataCollectionFactoryInterface; use ApiPlatform\Metadata\ResourceClassResolverInterface; @@ -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 Post) { + $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 4d9d6e225cf..a5c656cf54a 100644 --- a/src/JsonApi/Serializer/ItemNormalizer.php +++ b/src/JsonApi/Serializer/ItemNormalizer.php @@ -18,6 +18,7 @@ use ApiPlatform\Metadata\HttpOperation; use ApiPlatform\Metadata\IdentifiersExtractorInterface; use ApiPlatform\Metadata\IriConverterInterface; +use ApiPlatform\Metadata\Post; use ApiPlatform\Metadata\Property\Factory\PropertyMetadataFactoryInterface; use ApiPlatform\Metadata\Property\Factory\PropertyNameCollectionFactoryInterface; use ApiPlatform\Metadata\Resource\Factory\ResourceMetadataCollectionFactoryInterface; @@ -61,6 +62,13 @@ final class ItemNormalizer extends AbstractItemNormalizer public const FORMAT = 'jsonapi'; + /** + * Opt-in flag enabling client-generated IDs on a POST request, per + * https://jsonapi.org/format/#crud-creating-client-ids + * Off by default to prevent ID spoofing on public endpoints. + */ + public const ALLOW_CLIENT_GENERATED_ID = 'allow_client_generated_id'; + private array $componentsCache = []; private bool $useIriAsId; @@ -207,21 +215,26 @@ public function denormalize(mixed $data, string $type, ?string $format = null, a return parent::denormalize($data, $type, $format, $context); } + $operation = $context['operation'] ?? null; + $allowClientGeneratedId = true === ($context[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 ($operation instanceof Post) { + if (!$allowClientGeneratedId) { + throw new NotNormalizableValueException(\sprintf('Client-generated IDs are not allowed on this operation. Pass "%s" => true in the denormalization context to enable.', 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[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); } @@ -234,6 +247,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 ($operation instanceof Post && $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 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 683ed89cd31..6bac7751274 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; @@ -989,4 +990,92 @@ 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); + } } From 314826190f70f1ca522cad44476e02e6ad150682 Mon Sep 17 00:00:00 2001 From: Abderrahim GHAZALI Date: Mon, 27 Apr 2026 10:55:38 +0200 Subject: [PATCH 2/4] refactor(jsonapi): use getMethod() instead of instanceof Post in SchemaFactory Address review feedback from @soyuka. --- src/JsonApi/JsonSchema/SchemaFactory.php | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/JsonApi/JsonSchema/SchemaFactory.php b/src/JsonApi/JsonSchema/SchemaFactory.php index a2588116cdd..aa764a976fd 100644 --- a/src/JsonApi/JsonSchema/SchemaFactory.php +++ b/src/JsonApi/JsonSchema/SchemaFactory.php @@ -21,7 +21,6 @@ use ApiPlatform\JsonSchema\SchemaFactoryInterface; use ApiPlatform\JsonSchema\SchemaUriPrefixTrait; use ApiPlatform\Metadata\Operation; -use ApiPlatform\Metadata\Post; use ApiPlatform\Metadata\Property\Factory\PropertyMetadataFactoryInterface; use ApiPlatform\Metadata\Resource\Factory\ResourceMetadataCollectionFactoryInterface; use ApiPlatform\Metadata\ResourceClassResolverInterface; @@ -377,7 +376,7 @@ 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 Post) { + if (Schema::TYPE_INPUT === $type && $resourceOperation && 'POST' === $resourceOperation->getMethod()) { $required = ['type']; } From 689a1033607ab129dc47938dca02b887fb934ecb Mon Sep 17 00:00:00 2001 From: Abderrahim GHAZALI Date: Mon, 27 Apr 2026 11:00:01 +0200 Subject: [PATCH 3/4] fix(jsonapi): narrow to HttpOperation for getMethod() in SchemaFactory getMethod() is declared on HttpOperation, not on the abstract Operation, so PHPStan flagged the call. Use instanceof HttpOperation as the guard. --- src/JsonApi/JsonSchema/SchemaFactory.php | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/JsonApi/JsonSchema/SchemaFactory.php b/src/JsonApi/JsonSchema/SchemaFactory.php index aa764a976fd..3e3b40a5a82 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; @@ -376,7 +377,7 @@ 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 && 'POST' === $resourceOperation->getMethod()) { + if (Schema::TYPE_INPUT === $type && $resourceOperation instanceof HttpOperation && 'POST' === $resourceOperation->getMethod()) { $required = ['type']; } From 251eb81fcf9eab7293ffc855eb60bdb2fc9150c0 Mon Sep 17 00:00:00 2001 From: Abderrahim GHAZALI Date: Mon, 27 Apr 2026 11:21:41 +0200 Subject: [PATCH 4/4] feat(jsonapi): allow enabling client-generated IDs via extraProperties Address review feedback from @soyuka: - ItemNormalizer now uses HttpOperation::getMethod() instead of `instanceof Post`. - The ALLOW_CLIENT_GENERATED_ID flag can now be enabled declaratively on the operation via `extraProperties`, in addition to the denormalization context: `#[Post(extraProperties: [ItemNormalizer::ALLOW_CLIENT_GENERATED_ID => true])]`. --- src/JsonApi/Serializer/ItemNormalizer.php | 20 +++++-- .../Tests/Serializer/ItemNormalizerTest.php | 59 +++++++++++++++++++ 2 files changed, 74 insertions(+), 5 deletions(-) diff --git a/src/JsonApi/Serializer/ItemNormalizer.php b/src/JsonApi/Serializer/ItemNormalizer.php index a5c656cf54a..536abb43a1e 100644 --- a/src/JsonApi/Serializer/ItemNormalizer.php +++ b/src/JsonApi/Serializer/ItemNormalizer.php @@ -18,7 +18,6 @@ use ApiPlatform\Metadata\HttpOperation; use ApiPlatform\Metadata\IdentifiersExtractorInterface; use ApiPlatform\Metadata\IriConverterInterface; -use ApiPlatform\Metadata\Post; use ApiPlatform\Metadata\Property\Factory\PropertyMetadataFactoryInterface; use ApiPlatform\Metadata\Property\Factory\PropertyNameCollectionFactoryInterface; use ApiPlatform\Metadata\Resource\Factory\ResourceMetadataCollectionFactoryInterface; @@ -66,6 +65,11 @@ final class ItemNormalizer extends AbstractItemNormalizer * Opt-in flag enabling client-generated IDs on a POST request, per * https://jsonapi.org/format/#crud-creating-client-ids * Off by default to prevent ID spoofing on public endpoints. + * + * Can be enabled either via the denormalization context, or declaratively + * on the operation via `extraProperties`: + * + * #[Post(extraProperties: [ItemNormalizer::ALLOW_CLIENT_GENERATED_ID => true])] */ public const ALLOW_CLIENT_GENERATED_ID = 'allow_client_generated_id'; @@ -216,13 +220,19 @@ public function denormalize(mixed $data, string $type, ?string $format = null, a } $operation = $context['operation'] ?? null; - $allowClientGeneratedId = true === ($context[self::ALLOW_CLIENT_GENERATED_ID] ?? false); + $isPostOperation = $operation instanceof HttpOperation && 'POST' === $operation->getMethod(); + + $allowClientGeneratedId = $context[self::ALLOW_CLIENT_GENERATED_ID] ?? null; + if (null === $allowClientGeneratedId && $isPostOperation) { + $allowClientGeneratedId = $operation->getExtraProperties()[self::ALLOW_CLIENT_GENERATED_ID] ?? false; + } + $allowClientGeneratedId = true === $allowClientGeneratedId; // Avoid issues with proxies if we populated the object if (!isset($context[self::OBJECT_TO_POPULATE]) && isset($data['data']['id'])) { - if ($operation instanceof Post) { + if ($isPostOperation) { if (!$allowClientGeneratedId) { - throw new NotNormalizableValueException(\sprintf('Client-generated IDs are not allowed on this operation. Pass "%s" => true in the denormalization context to enable.', self::ALLOW_CLIENT_GENERATED_ID)); + throw new NotNormalizableValueException(\sprintf('Client-generated IDs are not allowed on this operation. Pass "%s" => true in the denormalization context (or set it via the operation\'s extraProperties) to enable.', self::ALLOW_CLIENT_GENERATED_ID)); } // Fall through: id flows into the denormalized payload below. } elseif (true !== ($context['api_allow_update'] ?? true)) { @@ -248,7 +258,7 @@ public function denormalize(mixed $data, string $type, ?string $format = null, a ); // Surface the client-generated id so the entity setter receives it. - if ($operation instanceof Post && $allowClientGeneratedId && isset($data['data']['id'])) { + if ($isPostOperation && $allowClientGeneratedId && isset($data['data']['id'])) { $dataToDenormalize['id'] = $data['data']['id']; } diff --git a/src/JsonApi/Tests/Serializer/ItemNormalizerTest.php b/src/JsonApi/Tests/Serializer/ItemNormalizerTest.php index 6bac7751274..0c7f9ab830b 100644 --- a/src/JsonApi/Tests/Serializer/ItemNormalizerTest.php +++ b/src/JsonApi/Tests/Serializer/ItemNormalizerTest.php @@ -1078,4 +1078,63 @@ public function testDenormalizePostWithIdSucceedsWithOptIn(): void $this->assertInstanceOf(Dummy::class, $result); } + + public function testDenormalizePostWithIdSucceedsWhenEnabledViaExtraProperties(): 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, + [], + $resourceMetadataCollectionFactory->reveal(), + ); + $normalizer->setSerializer($serializerProphecy->reveal()); + + $result = $normalizer->denormalize($data, Dummy::class, ItemNormalizer::FORMAT, [ + 'operation' => new Post(extraProperties: [ItemNormalizer::ALLOW_CLIENT_GENERATED_ID => true]), + ]); + + $this->assertInstanceOf(Dummy::class, $result); + } }