diff --git a/src/Serializer/AbstractItemNormalizer.php b/src/Serializer/AbstractItemNormalizer.php index af9797fb21..50f2984ce2 100644 --- a/src/Serializer/AbstractItemNormalizer.php +++ b/src/Serializer/AbstractItemNormalizer.php @@ -773,7 +773,26 @@ private function getResourceFromIri(string $data, array $context, string $resour : is_a($item, $resourceClass); if (!$matchesType) { - throw new NotNormalizableValueException(\sprintf('The iri "%s" does not reference the correct resource.', $data)); + // Keep this a NotNormalizableValueException so union/intersection denormalization can fall + // through to the next member (see testUnionType*), but build it through the factory so the + // deserialization path and expected type are preserved on the resulting violation. For a + // union/intersection relation, report every accepted class rather than only the one currently + // being attempted. + $expectedTypes = [$resourceClass]; + if ($relationType instanceof Type) { + $classNames = []; + foreach ($relationType instanceof CompositeTypeInterface ? $relationType->getTypes() : [$relationType] as $relationMember) { + if ($relationMember instanceof ObjectType) { + $classNames[] = $relationMember->getClassName(); + } + } + + if ($classNames) { + $expectedTypes = $classNames; + } + } + + throw NotNormalizableValueException::createForUnexpectedDataType(\sprintf('Invalid IRI "%s".', $data), $data, $expectedTypes, $context['deserialization_path'] ?? null, true); } return $item; diff --git a/src/Serializer/Tests/AbstractItemNormalizerTest.php b/src/Serializer/Tests/AbstractItemNormalizerTest.php index d556840d1e..d73fffdb75 100644 --- a/src/Serializer/Tests/AbstractItemNormalizerTest.php +++ b/src/Serializer/Tests/AbstractItemNormalizerTest.php @@ -1329,6 +1329,76 @@ public function testUnionTypeCollectionDenormalizationAcceptsAnyMember(): void $this->assertInstanceOf(Dummy::class, $actual); } + public function testTypeConfusionGuardPreservesPathAndExpectedType(): void + { + $propertyNameCollectionFactoryProphecy = $this->prophesize(PropertyNameCollectionFactoryInterface::class); + + $propertyMetadataFactoryProphecy = $this->prophesize(PropertyMetadataFactoryInterface::class); + + // The IRI resolves to a Dummy while a RelatedDummy is expected: the type-confusion guard must reject it. + $iriConverterProphecy = $this->prophesize(IriConverterInterface::class); + $iriConverterProphecy->getResourceFromIri(Argument::cetera())->willReturn(new Dummy()); + + $resourceClassResolverProphecy = $this->prophesize(ResourceClassResolverInterface::class); + $resourceClassResolverProphecy->getResourceClass(null, RelatedDummy::class)->willReturn(RelatedDummy::class); + $resourceClassResolverProphecy->isResourceClass(RelatedDummy::class)->willReturn(true); + + $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()); + + try { + $normalizer->denormalize('/dummies/1', RelatedDummy::class, null, [ + 'not_normalizable_value_exceptions' => [], + 'deserialization_path' => 'relatedDummy', + ]); + $this->fail('Expected a NotNormalizableValueException to be thrown.'); + } catch (NotNormalizableValueException $exception) { + $this->assertSame('Invalid IRI "/dummies/1".', $exception->getMessage()); + $this->assertSame('relatedDummy', $exception->getPath()); + $this->assertSame([RelatedDummy::class], $exception->getExpectedTypes()); + } + } + + public function testTypeConfusionGuardReportsAllUnionMemberTypes(): void + { + $propertyNameCollectionFactoryProphecy = $this->prophesize(PropertyNameCollectionFactoryInterface::class); + + $propertyMetadataFactoryProphecy = $this->prophesize(PropertyMetadataFactoryInterface::class); + + // The IRI resolves to a Dummy while the relation is declared as RelatedDummy|SecuredDummy. + $iriConverterProphecy = $this->prophesize(IriConverterInterface::class); + $iriConverterProphecy->getResourceFromIri(Argument::cetera())->willReturn(new Dummy()); + + $resourceClassResolverProphecy = $this->prophesize(ResourceClassResolverInterface::class); + $resourceClassResolverProphecy->getResourceClass(null, RelatedDummy::class)->willReturn(RelatedDummy::class); + $resourceClassResolverProphecy->isResourceClass(RelatedDummy::class)->willReturn(true); + + $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()); + + try { + $normalizer->denormalize('/dummies/1', RelatedDummy::class, null, [ + 'not_normalizable_value_exceptions' => [], + 'deserialization_path' => 'relation', + 'relation_native_type' => Type::union(Type::object(RelatedDummy::class), Type::object(SecuredDummy::class)), + ]); + $this->fail('Expected a NotNormalizableValueException to be thrown.'); + } catch (NotNormalizableValueException $exception) { + // A union relation must report every accepted class, not just the first one attempted. + $this->assertSame([RelatedDummy::class, SecuredDummy::class], $exception->getExpectedTypes()); + } + } + public function testDenormalizeRelationNotFoundReturnsNull(): void { $data = [