From 0037c802f142712b226c6a6cce219c25316bf3e3 Mon Sep 17 00:00:00 2001 From: jakub-hurda <92356379+Kamhal24@users.noreply.github.com> Date: Tue, 8 Sep 2026 12:17:58 +0200 Subject: [PATCH] fix(serializer): don't leak relation_native_type into embedded documents Since 4.3.15 the relation_native_type context key set for a relation in createAndValidateAttributeValue() was never cleared when an embedded document was denormalized. It leaked through the embedded resource and any plain DTOs below it, so a nested IRI was validated by getResourceFromIri() against the outer relation's declared type and rejected with 400 "Invalid IRI" even though it resolved to exactly the declared class. Clear the key in denormalize() once the data is an embedded document: relations inside it set their own relation_native_type before resolving their IRIs, so the union and type-confusion guards keep working. Fixes #8507 --- src/Serializer/AbstractItemNormalizer.php | 2 + .../EmbeddedRelationChild.php | 42 ++++++++++ .../EmbeddedRelationParent.php | 39 ++++++++++ .../EmbeddedRelationRow.php | 19 +++++ .../EmbeddedRelationTarget.php | 37 +++++++++ .../EmbeddedRelationNativeTypeTest.php | 76 +++++++++++++++++++ 6 files changed, 215 insertions(+) create mode 100644 tests/Fixtures/TestBundle/ApiResource/EmbeddedRelationNativeType/EmbeddedRelationChild.php create mode 100644 tests/Fixtures/TestBundle/ApiResource/EmbeddedRelationNativeType/EmbeddedRelationParent.php create mode 100644 tests/Fixtures/TestBundle/ApiResource/EmbeddedRelationNativeType/EmbeddedRelationRow.php create mode 100644 tests/Fixtures/TestBundle/ApiResource/EmbeddedRelationNativeType/EmbeddedRelationTarget.php create mode 100644 tests/Functional/Serializer/EmbeddedRelationNativeTypeTest.php diff --git a/src/Serializer/AbstractItemNormalizer.php b/src/Serializer/AbstractItemNormalizer.php index 21aa1de0fe0..16a04e5600b 100644 --- a/src/Serializer/AbstractItemNormalizer.php +++ b/src/Serializer/AbstractItemNormalizer.php @@ -295,6 +295,8 @@ public function denormalize(mixed $data, string $type, ?string $format = null, a throw NotNormalizableValueException::createForUnexpectedDataType(\sprintf('The type of the "%s" resource must be "array" (nested document) or "string" (IRI), "%s" given.', $resourceClass, \gettype($data)), $data, ['array', 'string'], $context['deserialization_path'] ?? null); } + unset($context['relation_native_type']); + $previousObject = $this->clone($objectToPopulate); $object = parent::denormalize($data, $type, $format, $context); diff --git a/tests/Fixtures/TestBundle/ApiResource/EmbeddedRelationNativeType/EmbeddedRelationChild.php b/tests/Fixtures/TestBundle/ApiResource/EmbeddedRelationNativeType/EmbeddedRelationChild.php new file mode 100644 index 00000000000..ee89574ceda --- /dev/null +++ b/tests/Fixtures/TestBundle/ApiResource/EmbeddedRelationNativeType/EmbeddedRelationChild.php @@ -0,0 +1,42 @@ + + * + * 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\EmbeddedRelationNativeType; + +use ApiPlatform\Metadata\ApiProperty; +use ApiPlatform\Metadata\Get; +use ApiPlatform\Metadata\Operation; + +#[Get( + shortName: 'EmbeddedRelationChild', + uriTemplate: '/embedded_relation_children/{id}', + provider: [self::class, 'provide'], +)] +class EmbeddedRelationChild +{ + #[ApiProperty(identifier: true)] + public int $id = 1; + + /** + * @var EmbeddedRelationRow[] + */ + public array $rows = []; + + public static function provide(Operation $operation, array $uriVariables = [], array $context = []): self + { + $child = new self(); + $child->id = (int) ($uriVariables['id'] ?? 1); + + return $child; + } +} diff --git a/tests/Fixtures/TestBundle/ApiResource/EmbeddedRelationNativeType/EmbeddedRelationParent.php b/tests/Fixtures/TestBundle/ApiResource/EmbeddedRelationNativeType/EmbeddedRelationParent.php new file mode 100644 index 00000000000..ffaebdc78f8 --- /dev/null +++ b/tests/Fixtures/TestBundle/ApiResource/EmbeddedRelationNativeType/EmbeddedRelationParent.php @@ -0,0 +1,39 @@ + + * + * 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\EmbeddedRelationNativeType; + +use ApiPlatform\Metadata\ApiProperty; +use ApiPlatform\Metadata\Post; + +#[Post( + shortName: 'EmbeddedRelationParent', + uriTemplate: '/embedded_relation_parents', + processor: [self::class, 'process'], +)] +class EmbeddedRelationParent +{ + #[ApiProperty(identifier: true)] + public int $id = 1; + + /** + * @var EmbeddedRelationChild[] + */ + #[ApiProperty(writableLink: true, readableLink: true)] + public array $items = []; + + public static function process(mixed $data): self + { + return $data; + } +} diff --git a/tests/Fixtures/TestBundle/ApiResource/EmbeddedRelationNativeType/EmbeddedRelationRow.php b/tests/Fixtures/TestBundle/ApiResource/EmbeddedRelationNativeType/EmbeddedRelationRow.php new file mode 100644 index 00000000000..0d6f2599839 --- /dev/null +++ b/tests/Fixtures/TestBundle/ApiResource/EmbeddedRelationNativeType/EmbeddedRelationRow.php @@ -0,0 +1,19 @@ + + * + * 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\EmbeddedRelationNativeType; + +final class EmbeddedRelationRow +{ + public ?EmbeddedRelationTarget $ref = null; +} diff --git a/tests/Fixtures/TestBundle/ApiResource/EmbeddedRelationNativeType/EmbeddedRelationTarget.php b/tests/Fixtures/TestBundle/ApiResource/EmbeddedRelationNativeType/EmbeddedRelationTarget.php new file mode 100644 index 00000000000..59f69ad116f --- /dev/null +++ b/tests/Fixtures/TestBundle/ApiResource/EmbeddedRelationNativeType/EmbeddedRelationTarget.php @@ -0,0 +1,37 @@ + + * + * 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\EmbeddedRelationNativeType; + +use ApiPlatform\Metadata\ApiProperty; +use ApiPlatform\Metadata\Get; +use ApiPlatform\Metadata\Operation; + +#[Get( + shortName: 'EmbeddedRelationTarget', + uriTemplate: '/embedded_relation_targets/{id}', + provider: [self::class, 'provide'], +)] +class EmbeddedRelationTarget +{ + #[ApiProperty(identifier: true)] + public int $id = 1; + + public static function provide(Operation $operation, array $uriVariables = [], array $context = []): self + { + $target = new self(); + $target->id = (int) ($uriVariables['id'] ?? 1); + + return $target; + } +} diff --git a/tests/Functional/Serializer/EmbeddedRelationNativeTypeTest.php b/tests/Functional/Serializer/EmbeddedRelationNativeTypeTest.php new file mode 100644 index 00000000000..849bca7ed06 --- /dev/null +++ b/tests/Functional/Serializer/EmbeddedRelationNativeTypeTest.php @@ -0,0 +1,76 @@ + + * + * 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\Serializer; + +use ApiPlatform\Symfony\Bundle\Test\ApiTestCase; +use ApiPlatform\Tests\Fixtures\TestBundle\ApiResource\EmbeddedRelationNativeType\EmbeddedRelationChild; +use ApiPlatform\Tests\Fixtures\TestBundle\ApiResource\EmbeddedRelationNativeType\EmbeddedRelationParent; +use ApiPlatform\Tests\Fixtures\TestBundle\ApiResource\EmbeddedRelationNativeType\EmbeddedRelationTarget; +use ApiPlatform\Tests\SetupClassResourcesTrait; +use Symfony\Component\PropertyInfo\PropertyInfoExtractor; + +/** + * The "relation_native_type" context key set for the embedded EmbeddedRelationChild collection must not + * leak into the nested plain DTO: the IRI type-confusion guard in getResourceFromIri() would otherwise + * compare the resolved EmbeddedRelationTarget against EmbeddedRelationChild and reject a valid IRI. + */ +final class EmbeddedRelationNativeTypeTest extends ApiTestCase +{ + use SetupClassResourcesTrait; + + protected static ?bool $alwaysBootKernel = false; + + /** + * @return class-string[] + */ + public static function getResources(): array + { + // EmbeddedRelationRow is a plain DTO on purpose and is not registered as a resource. + return [EmbeddedRelationParent::class, EmbeddedRelationChild::class, EmbeddedRelationTarget::class]; + } + + public function testIriInsideEmbeddedDocumentIsNotValidatedAgainstTheOuterRelationType(): void + { + // relation_native_type is only set on the native-type path; the legacy property-info path never triggers the bug. + if (!method_exists(PropertyInfoExtractor::class, 'getType')) { + $this->markTestSkipped('Requires symfony/property-info >= 7.1 (native types).'); + } + + $response = self::createClient()->request('POST', '/embedded_relation_parents', [ + 'headers' => ['Content-Type' => 'application/ld+json', 'Accept' => 'application/ld+json'], + 'json' => ['items' => [['rows' => [['ref' => '/embedded_relation_targets/1']]]]], + ]); + + $this->assertResponseStatusCodeSame(201); + // EmbeddedRelationRow is not a resource, so its $ref is rendered by the generic object normalizer as a + // nested document rather than collapsed to an IRI string; asserting on "@id" pins the resolved target. + $this->assertJsonContains(['items' => [['rows' => [['ref' => ['@id' => '/embedded_relation_targets/1']]]]]]); + } + + public function testMismatchedIriInsideEmbeddedDocumentIsStillRejected(): void + { + if (!method_exists(PropertyInfoExtractor::class, 'getType')) { + $this->markTestSkipped('Requires symfony/property-info >= 7.1 (native types).'); + } + + // An IRI of the wrong resource class in the same nested position must still hit the type guard. + self::createClient()->request('POST', '/embedded_relation_parents', [ + 'headers' => ['Content-Type' => 'application/ld+json', 'Accept' => 'application/ld+json'], + 'json' => ['items' => [['rows' => [['ref' => '/embedded_relation_children/1']]]]], + ]); + + $this->assertResponseStatusCodeSame(400); + $this->assertJsonContains(['detail' => 'Invalid IRI "/embedded_relation_children/1".']); + } +}