Skip to content

Commit 7bc11b2

Browse files
authored
fix(serializer): accept union-typed IRI collections on denormalization (#8339)
fixed in #8333) on the collection path. Closes #8335
1 parent bb5ac42 commit 7bc11b2

6 files changed

Lines changed: 225 additions & 2 deletions

File tree

src/Serializer/AbstractItemNormalizer.php

Lines changed: 18 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -764,8 +764,15 @@ private function getResourceFromIri(string $data, array $context, string $resour
764764
try {
765765
$item = $this->iriConverter->getResourceFromIri($data, $context + ['fetch_data' => true]);
766766

767-
// Type-confusion guard: declared relation class must match the IRI's resource.
768-
if (!is_a($item, $resourceClass)) {
767+
// Type-confusion guard: the IRI's resource must satisfy the declared relation type.
768+
// When a union/intersection type is known, validate against it so any member is accepted;
769+
// otherwise fall back to the single declared class.
770+
$relationType = $context['relation_native_type'] ?? null;
771+
$matchesType = $relationType instanceof Type
772+
? $relationType->isSatisfiedBy(static fn (Type $t): bool => $t instanceof ObjectType && is_a($item, $t->getClassName()))
773+
: is_a($item, $resourceClass);
774+
775+
if (!$matchesType) {
769776
throw new NotNormalizableValueException(\sprintf('The iri "%s" does not reference the correct resource.', $data));
770777
}
771778

@@ -1227,6 +1234,12 @@ private function createAndValidateAttributeValue(string $attribute, mixed $value
12271234
$context['resource_class'] = $resourceClass;
12281235
unset($context['uri_variables']);
12291236

1237+
// Validate the IRI target against the declared collection value type so a union
1238+
// (array<Foo|Bar>) accepts an IRI pointing to any of its members, not just the first.
1239+
if ($collectionValueType instanceof Type) {
1240+
$context['relation_native_type'] = $collectionValueType;
1241+
}
1242+
12301243
try {
12311244
return $t instanceof Type
12321245
? $this->denormalizeObjectCollection($attribute, $propertyMetadata, $t, $resourceClass, $value, $format, $context)
@@ -1249,6 +1262,9 @@ private function createAndValidateAttributeValue(string $attribute, mixed $value
12491262
) {
12501263
$resourceClass = $this->resourceClassResolver->getResourceClass(null, $className);
12511264
$childContext = $this->createChildContext($this->createOperationContext($context, $resourceClass, $propertyMetadata), $attribute, $format);
1265+
if ($t instanceof Type) {
1266+
$childContext['relation_native_type'] = $t;
1267+
}
12521268

12531269
try {
12541270
return $this->denormalizeRelation($attribute, $propertyMetadata, $resourceClass, $value, $format, $childContext);

src/Serializer/Tests/AbstractItemNormalizerTest.php

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1285,6 +1285,50 @@ public function testUnionTypeDenormalizationFallsThroughAfterTypeConfusionGuardM
12851285
$this->assertInstanceOf(Dummy::class, $actual);
12861286
}
12871287

1288+
public function testUnionTypeCollectionDenormalizationAcceptsAnyMember(): void
1289+
{
1290+
$data = ['attachments' => ['/related_dummies/1']];
1291+
$relatedDummy = new RelatedDummy();
1292+
1293+
$propertyNameCollectionFactory = $this->createStub(PropertyNameCollectionFactoryInterface::class);
1294+
$propertyNameCollectionFactory->method('create')->willReturn(new PropertyNameCollection(['attachments']));
1295+
1296+
// array<Dummy|RelatedDummy>: the collection value type is a union; the IRI points to the second member.
1297+
$propertyMetadataFactory = $this->createStub(PropertyMetadataFactoryInterface::class);
1298+
$propertyMetadataFactory->method('create')->willReturn(
1299+
(new ApiProperty())
1300+
->withNativeType(Type::list(Type::union(Type::object(Dummy::class), Type::object(RelatedDummy::class))))
1301+
->withWritable(true)
1302+
);
1303+
1304+
$iriConverter = $this->createStub(IriConverterInterface::class);
1305+
$iriConverter->method('getResourceFromIri')->willReturn($relatedDummy);
1306+
1307+
$resourceClassResolver = $this->createStub(ResourceClassResolverInterface::class);
1308+
$resourceClassResolver->method('isResourceClass')->willReturnMap([
1309+
[Dummy::class, true],
1310+
[RelatedDummy::class, true],
1311+
]);
1312+
$resourceClassResolver->method('getResourceClass')->willReturnMap([
1313+
[null, Dummy::class, Dummy::class],
1314+
[null, RelatedDummy::class, RelatedDummy::class],
1315+
]);
1316+
1317+
$propertyAccessor = $this->createMock(PropertyAccessorInterface::class);
1318+
$propertyAccessor->expects($this->once())
1319+
->method('setValue')
1320+
->with($this->isInstanceOf(Dummy::class), 'attachments', [0 => $relatedDummy]);
1321+
1322+
$serializer = $this->createStub(SerializerInterface::class);
1323+
1324+
$normalizer = new class($propertyNameCollectionFactory, $propertyMetadataFactory, $iriConverter, $resourceClassResolver, $propertyAccessor, null, null, [], null, null) extends AbstractItemNormalizer {};
1325+
$normalizer->setSerializer($serializer);
1326+
1327+
$actual = $normalizer->denormalize($data, Dummy::class);
1328+
1329+
$this->assertInstanceOf(Dummy::class, $actual);
1330+
}
1331+
12881332
public function testDenormalizeRelationNotFoundReturnsNull(): void
12891333
{
12901334
$data = [
Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
<?php
2+
3+
/*
4+
* This file is part of the API Platform project.
5+
*
6+
* (c) Kévin Dunglas <dunglas@gmail.com>
7+
*
8+
* For the full copyright and license information, please view the LICENSE
9+
* file that was distributed with this source code.
10+
*/
11+
12+
declare(strict_types=1);
13+
14+
namespace ApiPlatform\Tests\Fixtures\TestBundle\ApiResource\UnionIriCollection;
15+
16+
use ApiPlatform\Metadata\ApiProperty;
17+
use ApiPlatform\Metadata\Get;
18+
use ApiPlatform\Metadata\Operation;
19+
20+
#[Get(
21+
shortName: 'UnionIriCollectionBar',
22+
uriTemplate: '/union_iri_collection_bars/{id}',
23+
provider: [self::class, 'provide'],
24+
)]
25+
class Bar
26+
{
27+
#[ApiProperty(identifier: true)]
28+
public int $id;
29+
30+
public static function provide(Operation $operation, array $uriVariables = [], array $context = []): self
31+
{
32+
$bar = new self();
33+
$bar->id = (int) ($uriVariables['id'] ?? 1);
34+
35+
return $bar;
36+
}
37+
}
Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
<?php
2+
3+
/*
4+
* This file is part of the API Platform project.
5+
*
6+
* (c) Kévin Dunglas <dunglas@gmail.com>
7+
*
8+
* For the full copyright and license information, please view the LICENSE
9+
* file that was distributed with this source code.
10+
*/
11+
12+
declare(strict_types=1);
13+
14+
namespace ApiPlatform\Tests\Fixtures\TestBundle\ApiResource\UnionIriCollection;
15+
16+
use ApiPlatform\Metadata\ApiProperty;
17+
use ApiPlatform\Metadata\Post;
18+
19+
#[Post(
20+
shortName: 'UnionIriCollectionContainer',
21+
uriTemplate: '/union_iri_collection_containers',
22+
processor: [self::class, 'process'],
23+
)]
24+
class Container
25+
{
26+
#[ApiProperty(identifier: true)]
27+
public int $id = 1;
28+
29+
/**
30+
* @var array<array-key, Foo|Bar>
31+
*/
32+
public array $attachments = [];
33+
34+
public static function process(mixed $data): self
35+
{
36+
return $data;
37+
}
38+
}
Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
<?php
2+
3+
/*
4+
* This file is part of the API Platform project.
5+
*
6+
* (c) Kévin Dunglas <dunglas@gmail.com>
7+
*
8+
* For the full copyright and license information, please view the LICENSE
9+
* file that was distributed with this source code.
10+
*/
11+
12+
declare(strict_types=1);
13+
14+
namespace ApiPlatform\Tests\Fixtures\TestBundle\ApiResource\UnionIriCollection;
15+
16+
use ApiPlatform\Metadata\ApiProperty;
17+
use ApiPlatform\Metadata\Get;
18+
use ApiPlatform\Metadata\Operation;
19+
20+
#[Get(
21+
shortName: 'UnionIriCollectionFoo',
22+
uriTemplate: '/union_iri_collection_foos/{id}',
23+
provider: [self::class, 'provide'],
24+
)]
25+
class Foo
26+
{
27+
#[ApiProperty(identifier: true)]
28+
public int $id;
29+
30+
public static function provide(Operation $operation, array $uriVariables = [], array $context = []): self
31+
{
32+
$foo = new self();
33+
$foo->id = (int) ($uriVariables['id'] ?? 1);
34+
35+
return $foo;
36+
}
37+
}
Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
<?php
2+
3+
/*
4+
* This file is part of the API Platform project.
5+
*
6+
* (c) Kévin Dunglas <dunglas@gmail.com>
7+
*
8+
* For the full copyright and license information, please view the LICENSE
9+
* file that was distributed with this source code.
10+
*/
11+
12+
declare(strict_types=1);
13+
14+
namespace ApiPlatform\Tests\Functional;
15+
16+
use ApiPlatform\Symfony\Bundle\Test\ApiTestCase;
17+
use ApiPlatform\Tests\Fixtures\TestBundle\ApiResource\UnionIriCollection\Bar;
18+
use ApiPlatform\Tests\Fixtures\TestBundle\ApiResource\UnionIriCollection\Container;
19+
use ApiPlatform\Tests\Fixtures\TestBundle\ApiResource\UnionIriCollection\Foo;
20+
use ApiPlatform\Tests\SetupClassResourcesTrait;
21+
22+
final class UnionIriCollectionTest extends ApiTestCase
23+
{
24+
use SetupClassResourcesTrait;
25+
26+
protected static ?bool $alwaysBootKernel = false;
27+
28+
/**
29+
* @return class-string[]
30+
*/
31+
public static function getResources(): array
32+
{
33+
return [Container::class, Foo::class, Bar::class];
34+
}
35+
36+
public function testDenormalizeCollectionAcceptsIriOfEachUnionMember(): void
37+
{
38+
$response = self::createClient()->request('POST', '/union_iri_collection_containers', [
39+
'headers' => ['Content-Type' => 'application/ld+json', 'Accept' => 'application/ld+json'],
40+
'json' => ['attachments' => ['/union_iri_collection_foos/1', '/union_iri_collection_bars/2']],
41+
]);
42+
43+
$this->assertResponseStatusCodeSame(201);
44+
$this->assertJsonContains([
45+
'attachments' => [
46+
'/union_iri_collection_foos/1',
47+
'/union_iri_collection_bars/2',
48+
],
49+
]);
50+
}
51+
}

0 commit comments

Comments
 (0)