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".']); + } +}