From 66f5e6fd061a39552bf96f4f9af33737daec931a Mon Sep 17 00:00:00 2001 From: Vincent Langlet Date: Mon, 9 Mar 2026 16:27:52 +0100 Subject: [PATCH 1/2] fix(serializer): Try using serializer when denormalizing relation --- src/Serializer/AbstractItemNormalizer.php | 69 +++++++++---------- .../Tests/AbstractItemNormalizerTest.php | 1 - 2 files changed, 31 insertions(+), 39 deletions(-) diff --git a/src/Serializer/AbstractItemNormalizer.php b/src/Serializer/AbstractItemNormalizer.php index 7b141132aa..572c95db15 100644 --- a/src/Serializer/AbstractItemNormalizer.php +++ b/src/Serializer/AbstractItemNormalizer.php @@ -266,21 +266,7 @@ public function denormalize(mixed $data, string $type, ?string $format = null, a } if (\is_string($data)) { - try { - return $this->iriConverter->getResourceFromIri($data, $context + ['fetch_data' => true]); - } catch (ItemNotFoundException $e) { - if (!isset($context['not_normalizable_value_exceptions'])) { - throw new UnexpectedValueException($e->getMessage(), $e->getCode(), $e); - } - - throw NotNormalizableValueException::createForUnexpectedDataType($e->getMessage(), $data, [$resourceClass], $context['deserialization_path'] ?? null, true, $e->getCode(), $e); - } catch (InvalidArgumentException $e) { - if (!isset($context['not_normalizable_value_exceptions'])) { - throw new UnexpectedValueException(\sprintf('Invalid IRI "%s".', $data), $e->getCode(), $e); - } - - throw NotNormalizableValueException::createForUnexpectedDataType(\sprintf('Invalid IRI "%s".', $data), $data, [$resourceClass], $context['deserialization_path'] ?? null, true, $e->getCode(), $e); - } + return $this->getResourceFromIri($data, $context, $resourceClass); } if (!\is_array($data)) { @@ -699,32 +685,16 @@ protected function denormalizeObjectCollection(string $attribute, ApiProperty $p */ protected function denormalizeRelation(string $attributeName, ApiProperty $propertyMetadata, string $className, mixed $value, ?string $format, array $context): ?object { - if (\is_string($value)) { - try { - return $this->iriConverter->getResourceFromIri($value, $context + ['fetch_data' => true]); - } catch (ItemNotFoundException $e) { - if (false === ($context['denormalize_throw_on_relation_not_found'] ?? true)) { - return null; - } - - if (!isset($context['not_normalizable_value_exceptions'])) { - throw new UnexpectedValueException($e->getMessage(), $e->getCode(), $e); - } - - throw NotNormalizableValueException::createForUnexpectedDataType($e->getMessage(), $value, [$className], $context['deserialization_path'] ?? null, true, $e->getCode(), $e); - } catch (InvalidArgumentException $e) { - if (!isset($context['not_normalizable_value_exceptions'])) { - throw new UnexpectedValueException(\sprintf('Invalid IRI "%s".', $value), $e->getCode(), $e); - } - - throw NotNormalizableValueException::createForUnexpectedDataType(\sprintf('Invalid IRI "%s".', $value), $value, [$className], $context['deserialization_path'] ?? null, true, $e->getCode(), $e); + if (\is_string($value) || $propertyMetadata->isWritableLink()) { + if ($propertyMetadata->isWritableLink()) { + $context['api_allow_update'] = true; } - } - - if ($propertyMetadata->isWritableLink()) { - $context['api_allow_update'] = true; if (!$this->serializer instanceof DenormalizerInterface) { + if (\is_string($value)) { + return $this->getResourceFromIri($value, $context, $className); + } + throw new LogicException(\sprintf('The injected serializer must be an instance of "%s".', DenormalizerInterface::class)); } @@ -743,6 +713,29 @@ protected function denormalizeRelation(string $attributeName, ApiProperty $prope throw NotNormalizableValueException::createForUnexpectedDataType(\sprintf('Nested documents for attribute "%s" are not allowed. Use IRIs instead.', $attributeName), $value, ['array', 'string'], $context['deserialization_path'] ?? null, true); } + private function getResourceFromIri(string $data, array $context, string $resourceClass): ?object + { + try { + return $this->iriConverter->getResourceFromIri($data, $context + ['fetch_data' => true]); + } catch (ItemNotFoundException $e) { + if (false === ($context['denormalize_throw_on_relation_not_found'] ?? true)) { + return null; + } + + if (!isset($context['not_normalizable_value_exceptions'])) { + throw new UnexpectedValueException($e->getMessage(), $e->getCode(), $e); + } + + throw NotNormalizableValueException::createForUnexpectedDataType($e->getMessage(), $data, [$resourceClass], $context['deserialization_path'] ?? null, true, $e->getCode(), $e); + } catch (InvalidArgumentException $e) { + if (!isset($context['not_normalizable_value_exceptions'])) { + throw new UnexpectedValueException(\sprintf('Invalid IRI "%s".', $data), $e->getCode(), $e); + } + + throw NotNormalizableValueException::createForUnexpectedDataType(\sprintf('Invalid IRI "%s".', $data), $data, [$resourceClass], $context['deserialization_path'] ?? null, true, $e->getCode(), $e); + } + } + /** * Gets the options for the property name collection / property metadata factories. */ diff --git a/src/Serializer/Tests/AbstractItemNormalizerTest.php b/src/Serializer/Tests/AbstractItemNormalizerTest.php index 4926681433..c421de7d7f 100644 --- a/src/Serializer/Tests/AbstractItemNormalizerTest.php +++ b/src/Serializer/Tests/AbstractItemNormalizerTest.php @@ -1253,7 +1253,6 @@ public function testDeserializationPathForNotDenormalizableRelations(): void $propertyAccessorProphecy = $this->prophesize(PropertyAccessorInterface::class); $serializerProphecy = $this->prophesize(SerializerInterface::class); - $serializerProphecy->willImplement(DenormalizerInterface::class); $normalizer = new class($propertyNameCollectionFactoryProphecy->reveal(), $propertyMetadataFactoryProphecy->reveal(), $iriConverterProphecy->reveal(), $resourceClassResolverProphecy->reveal(), $propertyAccessorProphecy->reveal(), null, null, [], null, null) extends AbstractItemNormalizer {}; $normalizer->setSerializer($serializerProphecy->reveal()); From 3b9728d85e0e7a378fb2c9e8525a6e5ecc224ef7 Mon Sep 17 00:00:00 2001 From: Vincent Langlet Date: Mon, 9 Mar 2026 17:52:22 +0100 Subject: [PATCH 2/2] Add tests --- src/Serializer/AbstractItemNormalizer.php | 8 ++-- .../ApiResource/DummyDtoNameConverted.php | 3 +- .../Dto/InputDtoWithNameConverter.php | 4 ++ .../Denormalizer/InputDtoDenormalizer.php | 43 +++++++++++++++++++ tests/Fixtures/app/config/config_common.yml | 5 +++ .../InputOutputNameConverterTest.php | 33 ++++++++++++++ 6 files changed, 91 insertions(+), 5 deletions(-) create mode 100644 tests/Fixtures/TestBundle/Serializer/Denormalizer/InputDtoDenormalizer.php diff --git a/src/Serializer/AbstractItemNormalizer.php b/src/Serializer/AbstractItemNormalizer.php index 572c95db15..5927835394 100644 --- a/src/Serializer/AbstractItemNormalizer.php +++ b/src/Serializer/AbstractItemNormalizer.php @@ -686,10 +686,6 @@ protected function denormalizeObjectCollection(string $attribute, ApiProperty $p protected function denormalizeRelation(string $attributeName, ApiProperty $propertyMetadata, string $className, mixed $value, ?string $format, array $context): ?object { if (\is_string($value) || $propertyMetadata->isWritableLink()) { - if ($propertyMetadata->isWritableLink()) { - $context['api_allow_update'] = true; - } - if (!$this->serializer instanceof DenormalizerInterface) { if (\is_string($value)) { return $this->getResourceFromIri($value, $context, $className); @@ -698,6 +694,10 @@ protected function denormalizeRelation(string $attributeName, ApiProperty $prope throw new LogicException(\sprintf('The injected serializer must be an instance of "%s".', DenormalizerInterface::class)); } + if ($propertyMetadata->isWritableLink()) { + $context['api_allow_update'] = true; + } + $item = $this->serializer->denormalize($value, $className, $format, $context); if (!\is_object($item) && null !== $item) { throw new \UnexpectedValueException('Expected item to be an object or null.'); diff --git a/tests/Fixtures/TestBundle/ApiResource/DummyDtoNameConverted.php b/tests/Fixtures/TestBundle/ApiResource/DummyDtoNameConverted.php index dd9b5df8ad..e6346741ff 100644 --- a/tests/Fixtures/TestBundle/ApiResource/DummyDtoNameConverted.php +++ b/tests/Fixtures/TestBundle/ApiResource/DummyDtoNameConverted.php @@ -42,6 +42,7 @@ public function __construct( public ?int $id = null, public ?string $nameConverted = null, public ?GenderTypeEnum $gender = null, + public ?self $childRelation = null, ) { } @@ -55,6 +56,6 @@ public static function provide(Operation $operation, array $uriVariables = [], a */ public static function process(mixed $data, Operation $operation, array $uriVariables = [], array $context = []): self { - return new self(id: 1, nameConverted: $data->nameConverted); + return new self(id: 1, nameConverted: $data->nameConverted, childRelation: $data->childRelation); } } diff --git a/tests/Fixtures/TestBundle/Dto/InputDtoWithNameConverter.php b/tests/Fixtures/TestBundle/Dto/InputDtoWithNameConverter.php index 2d5017e784..e1997045c1 100644 --- a/tests/Fixtures/TestBundle/Dto/InputDtoWithNameConverter.php +++ b/tests/Fixtures/TestBundle/Dto/InputDtoWithNameConverter.php @@ -13,7 +13,11 @@ namespace ApiPlatform\Tests\Fixtures\TestBundle\Dto; +use ApiPlatform\Tests\Fixtures\TestBundle\ApiResource\DummyDtoNameConverted; + class InputDtoWithNameConverter { public ?string $nameConverted = null; + + public ?DummyDtoNameConverted $childRelation = null; } diff --git a/tests/Fixtures/TestBundle/Serializer/Denormalizer/InputDtoDenormalizer.php b/tests/Fixtures/TestBundle/Serializer/Denormalizer/InputDtoDenormalizer.php new file mode 100644 index 0000000000..52c0ad09b7 --- /dev/null +++ b/tests/Fixtures/TestBundle/Serializer/Denormalizer/InputDtoDenormalizer.php @@ -0,0 +1,43 @@ + + * + * 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\Serializer\Denormalizer; + +use ApiPlatform\Tests\Fixtures\TestBundle\ApiResource\DummyDtoNameConverted; +use Symfony\Component\Serializer\Normalizer\DenormalizerInterface; + +class InputDtoDenormalizer implements DenormalizerInterface +{ + /** + * {@inheritdoc} + */ + public function denormalize($data, $class, $format = null, array $context = []): mixed + { + return new DummyDtoNameConverted(42); + } + + /** + * {@inheritdoc} + */ + public function supportsDenormalization($data, $type, $format = null, array $context = []): bool + { + return DummyDtoNameConverted::class === $type && 'child_relation' === $data; + } + + public function getSupportedTypes($format): array + { + return [ + DummyDtoNameConverted::class => true, + ]; + } +} diff --git a/tests/Fixtures/app/config/config_common.yml b/tests/Fixtures/app/config/config_common.yml index c89718fadc..3bf4a70e12 100644 --- a/tests/Fixtures/app/config/config_common.yml +++ b/tests/Fixtures/app/config/config_common.yml @@ -182,6 +182,11 @@ services: tags: - name: 'serializer.normalizer' + app.serializer.denormalizer.input_dto: + class: 'ApiPlatform\Tests\Fixtures\TestBundle\Serializer\Denormalizer\InputDtoDenormalizer' + tags: + - name: 'serializer.normalizer' + app.name_converter: class: 'ApiPlatform\Tests\Fixtures\TestBundle\Serializer\NameConverter\CustomConverter' diff --git a/tests/Functional/InputOutputNameConverterTest.php b/tests/Functional/InputOutputNameConverterTest.php index fd3cbb460f..07dbde490f 100644 --- a/tests/Functional/InputOutputNameConverterTest.php +++ b/tests/Functional/InputOutputNameConverterTest.php @@ -52,6 +52,39 @@ public function testInputDtoNameConverterIsApplied(): void $this->assertResponseStatusCodeSame(201); $data = $response->toArray(); $this->assertSame('converted', $data['name_converted']); + $this->assertNull($data['childRelation']); + } + + public function testInputDtoDenormalizerIsApplied(): void + { + $response = self::createClient()->request('POST', '/dummy_dto_name_converted', [ + 'headers' => ['Content-Type' => 'application/ld+json'], + 'json' => [ + 'name_converted' => 'converted', + 'childRelation' => 'child_relation', + ], + ]); + + $this->assertResponseStatusCodeSame(201); + $data = $response->toArray(); + $this->assertSame('converted', $data['name_converted']); + $this->assertSame('/dummy_dto_name_converted/42', $data['childRelation']); + } + + public function testInputDtoIriConverterIsApplied(): void + { + $response = self::createClient()->request('POST', '/dummy_dto_name_converted', [ + 'headers' => ['Content-Type' => 'application/ld+json'], + 'json' => [ + 'name_converted' => 'converted', + 'childRelation' => '/dummy_dto_name_converted/child_relation', + ], + ]); + + $this->assertResponseStatusCodeSame(201); + $data = $response->toArray(); + $this->assertSame('converted', $data['name_converted']); + $this->assertSame('/dummy_dto_name_converted/1', $data['childRelation']); } public function testOutputDtoNameConverterIsApplied(): void