From b676300cd7720b02ba4e2214e7b86cef39c1bc12 Mon Sep 17 00:00:00 2001 From: Mathias Arlaud Date: Sat, 22 Feb 2025 12:22:00 +0100 Subject: [PATCH] feat(hal): use `TypeInfo` type --- src/Hal/Serializer/ItemNormalizer.php | 43 ++++++++++++++++++++- src/Hal/composer.json | 3 +- tests/Hal/Serializer/ItemNormalizerTest.php | 26 +++++-------- 3 files changed, 53 insertions(+), 19 deletions(-) diff --git a/src/Hal/Serializer/ItemNormalizer.php b/src/Hal/Serializer/ItemNormalizer.php index 24748804177..d54648c0d03 100644 --- a/src/Hal/Serializer/ItemNormalizer.php +++ b/src/Hal/Serializer/ItemNormalizer.php @@ -26,6 +26,8 @@ use ApiPlatform\Serializer\ContextTrait; use ApiPlatform\Serializer\TagCollectorInterface; use Symfony\Component\PropertyAccess\PropertyAccessorInterface; +use Symfony\Component\PropertyInfo\PropertyInfoExtractor; +use Symfony\Component\PropertyInfo\Type as LegacyType; use Symfony\Component\Serializer\Exception\CircularReferenceException; use Symfony\Component\Serializer\Exception\LogicException; use Symfony\Component\Serializer\Exception\UnexpectedValueException; @@ -33,6 +35,11 @@ use Symfony\Component\Serializer\Mapping\Factory\ClassMetadataFactoryInterface; use Symfony\Component\Serializer\NameConverter\NameConverterInterface; use Symfony\Component\Serializer\Normalizer\AbstractNormalizer; +use Symfony\Component\TypeInfo\Type; +use Symfony\Component\TypeInfo\Type\CollectionType; +use Symfony\Component\TypeInfo\Type\CompositeTypeInterface; +use Symfony\Component\TypeInfo\Type\ObjectType; +use Symfony\Component\TypeInfo\Type\WrappingTypeInterface; /** * Converts between objects and array including HAL metadata. @@ -175,15 +182,32 @@ private function getComponents(object $object, ?string $format, array $context): foreach ($attributes as $attribute) { $propertyMetadata = $this->propertyMetadataFactory->create($context['resource_class'], $attribute, $options); - $types = $propertyMetadata->getBuiltinTypes() ?? []; + if (method_exists(PropertyInfoExtractor::class, 'getType')) { + $type = $propertyMetadata->getNativeType(); + $types = $type instanceof CompositeTypeInterface ? $type->getTypes() : (null === $type ? [] : [$type]); + /** @var class-string|null $className */ + $className = null; + } else { + $types = $propertyMetadata->getBuiltinTypes() ?? []; + } // prevent declaring $attribute as attribute if it's already declared as relationship $isRelationship = false; + $typeIsResourceClass = function (Type $type) use (&$typeIsResourceClass, &$className): bool { + return match (true) { + $type instanceof WrappingTypeInterface => $type->wrappedTypeIsSatisfiedBy($typeIsResourceClass), + $type instanceof CompositeTypeInterface => $type->composedTypesAreSatisfiedBy($typeIsResourceClass), + default => $type instanceof ObjectType && $this->resourceClassResolver->isResourceClass($className = $type->getClassName()), + }; + }; foreach ($types as $type) { $isOne = $isMany = false; - if (null !== $type) { + /** @var Type|LegacyType|null $valueType */ + $valueType = null; + + if ($type instanceof LegacyType) { if ($type->isCollection()) { $valueType = $type->getCollectionValueTypes()[0] ?? null; $isMany = null !== $valueType && ($className = $valueType->getClassName()) && $this->resourceClassResolver->isResourceClass($className); @@ -191,6 +215,21 @@ private function getComponents(object $object, ?string $format, array $context): $className = $type->getClassName(); $isOne = $className && $this->resourceClassResolver->isResourceClass($className); } + } elseif ($type instanceof Type) { + $typeIsCollection = function (Type $type) use (&$typeIsCollection, &$valueType): bool { + return match (true) { + $type instanceof CollectionType => null !== $valueType = $type->getCollectionValueType(), + $type instanceof WrappingTypeInterface => $type->wrappedTypeIsSatisfiedBy($typeIsCollection), + $type instanceof CompositeTypeInterface => $type->composedTypesAreSatisfiedBy($typeIsCollection), + default => false, + }; + }; + + if ($type->isSatisfiedBy($typeIsCollection)) { + $isMany = $valueType->isSatisfiedBy($typeIsResourceClass); + } else { + $isOne = $type->isSatisfiedBy($typeIsResourceClass); + } } if (!$isOne && !$isMany) { diff --git a/src/Hal/composer.json b/src/Hal/composer.json index 9018d292317..db2628f6403 100644 --- a/src/Hal/composer.json +++ b/src/Hal/composer.json @@ -24,7 +24,8 @@ "php": ">=8.2", "api-platform/state": "^4.1", "api-platform/metadata": "^4.1", - "api-platform/serializer": "^4.1" + "api-platform/serializer": "^3.4 || ^4.0", + "symfony/type-info": "^7.2" }, "autoload": { "psr-4": { diff --git a/tests/Hal/Serializer/ItemNormalizerTest.php b/tests/Hal/Serializer/ItemNormalizerTest.php index b45e6b40337..cf3a0c282af 100644 --- a/tests/Hal/Serializer/ItemNormalizerTest.php +++ b/tests/Hal/Serializer/ItemNormalizerTest.php @@ -31,7 +31,6 @@ use PHPUnit\Framework\TestCase; use Prophecy\Argument; use Prophecy\PhpUnit\ProphecyTrait; -use Symfony\Component\PropertyInfo\Type; use Symfony\Component\Serializer\Exception\LogicException; use Symfony\Component\Serializer\Mapping\Factory\ClassMetadataFactory; use Symfony\Component\Serializer\Mapping\Loader\AnnotationLoader; @@ -41,6 +40,7 @@ use Symfony\Component\Serializer\Normalizer\ObjectNormalizer; use Symfony\Component\Serializer\Serializer; use Symfony\Component\Serializer\SerializerInterface; +use Symfony\Component\TypeInfo\Type; /** * @author Kévin Dunglas @@ -119,10 +119,10 @@ public function testNormalize(): void $propertyMetadataFactoryProphecy = $this->prophesize(PropertyMetadataFactoryInterface::class); $propertyMetadataFactoryProphecy->create(Dummy::class, 'name', [])->willReturn( - (new ApiProperty())->withBuiltinTypes([new Type(Type::BUILTIN_TYPE_STRING)])->withDescription('')->withReadable(true) + (new ApiProperty())->withNativeType(Type::string())->withDescription('')->withReadable(true) ); $propertyMetadataFactoryProphecy->create(Dummy::class, 'relatedDummy', [])->willReturn( - (new ApiProperty())->withBuiltinTypes([new Type(Type::BUILTIN_TYPE_OBJECT, false, RelatedDummy::class)])->withDescription('')->withReadable(true)->withWritable(false)->withWritableLink(false) + (new ApiProperty())->withNativeType(Type::object(RelatedDummy::class))->withDescription('')->withReadable(true)->withWritable(false)->withWritableLink(false) ); $iriConverterProphecy = $this->prophesize(IriConverterInterface::class); @@ -183,16 +183,10 @@ public function testNormalizeWithUnionIntersectTypes(): void $propertyMetadataFactoryProphecy = $this->prophesize(PropertyMetadataFactoryInterface::class); $propertyMetadataFactoryProphecy->create(Book::class, 'author', [])->willReturn( - (new ApiProperty())->withBuiltinTypes([ - new Type(Type::BUILTIN_TYPE_OBJECT, false, ActivableInterface::class), - new Type(Type::BUILTIN_TYPE_OBJECT, false, TimestampableInterface::class), - ])->withReadable(true) + (new ApiProperty())->withNativeType(Type::intersection(Type::object(ActivableInterface::class), Type::object(TimestampableInterface::class)))->withReadable(true) ); $propertyMetadataFactoryProphecy->create(Book::class, 'library', [])->willReturn( - (new ApiProperty())->withBuiltinTypes([ - new Type(Type::BUILTIN_TYPE_OBJECT, false, ActivableInterface::class), - new Type(Type::BUILTIN_TYPE_OBJECT, false, TimestampableInterface::class), - ])->withReadable(true) + (new ApiProperty())->withNativeType(Type::intersection(Type::object(ActivableInterface::class), Type::object(TimestampableInterface::class)))->withReadable(true) ); $iriConverterProphecy = $this->prophesize(IriConverterInterface::class); @@ -247,10 +241,10 @@ public function testNormalizeWithoutCache(): void $propertyMetadataFactoryProphecy = $this->prophesize(PropertyMetadataFactoryInterface::class); $propertyMetadataFactoryProphecy->create(Dummy::class, 'name', [])->willReturn( - (new ApiProperty())->withBuiltinTypes([new Type(Type::BUILTIN_TYPE_STRING)])->withDescription('')->withReadable(true) + (new ApiProperty())->withNativeType(Type::string())->withDescription('')->withReadable(true) ); $propertyMetadataFactoryProphecy->create(Dummy::class, 'relatedDummy', [])->willReturn( - (new ApiProperty())->withBuiltinTypes([new Type(Type::BUILTIN_TYPE_OBJECT, false, RelatedDummy::class)])->withDescription('')->withReadable(true)->withWritable(false)->withReadableLink(false) + (new ApiProperty())->withNativeType(Type::object(RelatedDummy::class))->withDescription('')->withReadable(true)->withWritable(false)->withReadableLink(false) ); $iriConverterProphecy = $this->prophesize(IriConverterInterface::class); @@ -325,13 +319,13 @@ public function testMaxDepth(): void $propertyMetadataFactoryProphecy = $this->prophesize(PropertyMetadataFactoryInterface::class); $propertyMetadataFactoryProphecy->create(MaxDepthDummy::class, 'id', [])->willReturn( - (new ApiProperty())->withBuiltinTypes([new Type(Type::BUILTIN_TYPE_INT)])->withDescription('')->withReadable(true) + (new ApiProperty())->withNativeType(Type::int())->withDescription('')->withReadable(true) ); $propertyMetadataFactoryProphecy->create(MaxDepthDummy::class, 'name', [])->willReturn( - (new ApiProperty())->withBuiltinTypes([new Type(Type::BUILTIN_TYPE_STRING)])->withDescription('')->withReadable(true) + (new ApiProperty())->withNativeType(Type::string())->withDescription('')->withReadable(true) ); $propertyMetadataFactoryProphecy->create(MaxDepthDummy::class, 'child', [])->willReturn( - (new ApiProperty())->withBuiltinTypes([new Type(Type::BUILTIN_TYPE_OBJECT, false, MaxDepthDummy::class)])->withDescription('')->withReadable(true)->withWritable(false)->withReadableLink(true) + (new ApiProperty())->withNativeType(Type::object(MaxDepthDummy::class))->withDescription('')->withReadable(true)->withWritable(false)->withReadableLink(true) ); $iriConverterProphecy = $this->prophesize(IriConverterInterface::class);