From d8618a7a9b333d03e7247d17ad21e557ffd4aa5c Mon Sep 17 00:00:00 2001 From: Mathias Arlaud Date: Fri, 21 Feb 2025 15:14:55 +0100 Subject: [PATCH] feat(metadata/doctrine): use `TypeInfo`'s `Type` --- composer.json | 5 +- .../create-a-custom-doctrine-filter.php | 3 +- src/Doctrine/Common/composer.json | 2 +- .../Odm/PropertyInfo/DoctrineExtractor.php | 84 ++++++++- .../PropertyInfo/DoctrineExtractorTest.php | 168 +++++++++++++----- src/Doctrine/Odm/composer.json | 5 +- src/Doctrine/Orm/composer.json | 4 +- src/Documentation/composer.json | 2 +- src/Elasticsearch/composer.json | 4 +- src/GraphQl/composer.json | 4 +- src/Hal/composer.json | 2 +- src/HttpCache/composer.json | 2 +- src/Hydra/composer.json | 2 +- src/JsonApi/composer.json | 2 +- src/JsonLd/composer.json | 2 +- src/JsonSchema/composer.json | 4 +- src/Laravel/composer.json | 2 +- src/Metadata/ApiProperty.php | 59 +++++- .../Extractor/XmlPropertyExtractor.php | 1 + .../Extractor/YamlPropertyExtractor.php | 1 + src/Metadata/Extractor/schema/properties.xsd | 1 + src/Metadata/IdentifiersExtractor.php | 26 ++- .../AttributePropertyMetadataFactory.php | 19 +- .../ExtractorPropertyMetadataFactory.php | 7 +- .../PropertyInfoPropertyMetadataFactory.php | 15 +- .../SerializerPropertyMetadataFactory.php | 84 +++++---- src/Metadata/Resource/Factory/LinkFactory.php | 54 ++++-- .../Extractor/Adapter/XmlPropertyAdapter.php | 8 +- .../Tests/Extractor/Adapter/properties.xml | 2 +- .../Tests/Extractor/Adapter/properties.yaml | 3 +- .../PropertyMetadataCompatibilityTest.php | 8 +- .../SerializerPropertyMetadataFactoryTest.php | 10 +- .../Resource/Factory/LinkFactoryTest.php | 20 +-- ...kResourceMetadataCollectionFactoryTest.php | 26 +-- .../Util/PropertyInfoToTypeInfoHelperTest.php | 42 +++++ .../IntegerUriVariableTransformer.php | 4 +- src/Metadata/UriVariablesConverter.php | 28 ++- .../Util/PropertyInfoToTypeInfoHelper.php | 151 ++++++++++++++++ src/Metadata/composer.json | 4 +- src/OpenApi/composer.json | 2 +- src/RamseyUuid/composer.json | 2 +- src/Serializer/AbstractItemNormalizer.php | 57 +++++- src/Serializer/composer.json | 4 +- src/State/composer.json | 2 +- src/Symfony/composer.json | 4 +- .../Command/JsonSchemaGenerateCommandTest.php | 6 +- 46 files changed, 726 insertions(+), 221 deletions(-) diff --git a/composer.json b/composer.json index 545ff75c4e8..7d57449f250 100644 --- a/composer.json +++ b/composer.json @@ -111,9 +111,10 @@ "symfony/http-foundation": "^6.4 || ^7.0", "symfony/http-kernel": "^6.4 || ^7.0", "symfony/property-access": "^6.4 || ^7.0", - "symfony/property-info": "^6.4 || ^7.0", + "symfony/property-info": "^7.1", "symfony/serializer": "^6.4 || ^7.0", "symfony/translation-contracts": "^3.3", + "symfony/type-info": "^7.2", "symfony/web-link": "^6.4 || ^7.0", "willdurand/negotiation": "^3.1" }, @@ -164,7 +165,7 @@ "symfony/console": "^6.4 || ^7.0", "symfony/css-selector": "^6.4 || ^7.0", "symfony/dependency-injection": "^6.4 || ^7.0", - "symfony/doctrine-bridge": "^6.4.2 || ^7.0.2", + "symfony/doctrine-bridge": "^7.1", "symfony/dom-crawler": "^6.4 || ^7.0", "symfony/error-handler": "^6.4 || ^7.0", "symfony/event-dispatcher": "^6.4 || ^7.0", diff --git a/docs/guides/create-a-custom-doctrine-filter.php b/docs/guides/create-a-custom-doctrine-filter.php index 1c3c0192098..72a6dbde456 100644 --- a/docs/guides/create-a-custom-doctrine-filter.php +++ b/docs/guides/create-a-custom-doctrine-filter.php @@ -26,7 +26,6 @@ use ApiPlatform\Doctrine\Orm\Util\QueryNameGeneratorInterface; use ApiPlatform\Metadata\Operation; use Doctrine\ORM\QueryBuilder; - use Symfony\Component\PropertyInfo\Type; final class RegexpFilter extends AbstractFilter { @@ -67,7 +66,7 @@ public function getDescription(string $resourceClass): array foreach ($this->properties as $property => $strategy) { $description["regexp_$property"] = [ 'property' => $property, - 'type' => Type::BUILTIN_TYPE_STRING, + 'type' => 'string', 'required' => false, 'description' => 'Filter using a regex. This will appear in the OpenAPI documentation!', 'openapi' => [ diff --git a/src/Doctrine/Common/composer.json b/src/Doctrine/Common/composer.json index 8ecfb1da2a5..9ac33682376 100644 --- a/src/Doctrine/Common/composer.json +++ b/src/Doctrine/Common/composer.json @@ -24,7 +24,7 @@ ], "require": { "php": ">=8.2", - "api-platform/metadata": "^3.4 || ^4.0", + "api-platform/metadata": "^4.1", "api-platform/state": "^3.4 || ^4.0", "doctrine/collections": "^2.1", "doctrine/common": "^3.2.2", diff --git a/src/Doctrine/Odm/PropertyInfo/DoctrineExtractor.php b/src/Doctrine/Odm/PropertyInfo/DoctrineExtractor.php index 7967a6f2192..e072cf02f76 100644 --- a/src/Doctrine/Odm/PropertyInfo/DoctrineExtractor.php +++ b/src/Doctrine/Odm/PropertyInfo/DoctrineExtractor.php @@ -13,7 +13,6 @@ namespace ApiPlatform\Doctrine\Odm\PropertyInfo; -use ApiPlatform\Metadata\Util\PropertyInfoToTypeInfoHelper; use Doctrine\Common\Collections\Collection; use Doctrine\ODM\MongoDB\Mapping\ClassMetadata as MongoDbClassMetadata; use Doctrine\ODM\MongoDB\Types\Type as MongoDbType; @@ -25,6 +24,7 @@ use Symfony\Component\PropertyInfo\PropertyTypeExtractorInterface; use Symfony\Component\PropertyInfo\Type as LegacyType; use Symfony\Component\TypeInfo\Type; +use Symfony\Component\TypeInfo\TypeIdentifier; /** * Extracts data using Doctrine MongoDB ODM metadata. @@ -52,13 +52,71 @@ public function getProperties($class, array $context = []): ?array return $metadata->getFieldNames(); } + public function getType($class, $property, array $context = []): ?Type + { + if (null === $metadata = $this->getMetadata($class)) { + return null; + } + + if ($metadata->hasAssociation($property)) { + /** @var class-string|null */ + $class = $metadata->getAssociationTargetClass($property); + + if (null === $class) { + return null; + } + + if ($metadata->isSingleValuedAssociation($property)) { + $nullable = $metadata instanceof MongoDbClassMetadata && $metadata->isNullable($property); + + return $nullable ? Type::nullable(Type::object($class)) : Type::object($class); + } + + return Type::collection(Type::object(Collection::class), Type::object($class), Type::int()); + } + + if (!$metadata->hasField($property)) { + return null; + } + + $typeOfField = $metadata->getTypeOfField($property); + + if (!$typeIdentifier = $this->getTypeIdentifier($typeOfField)) { + return null; + } + + $nullable = $metadata instanceof MongoDbClassMetadata && $metadata->isNullable($property); + $enumType = null; + + if (null !== $enumClass = $metadata instanceof MongoDbClassMetadata ? $metadata->getFieldMapping($property)['enumType'] ?? null : null) { + $enumType = $nullable ? Type::nullable(Type::enum($enumClass)) : Type::enum($enumClass); + } + + $builtinType = $nullable ? Type::nullable(Type::builtin($typeIdentifier)) : Type::builtin($typeIdentifier); + + $type = match ($typeOfField) { + MongoDbType::DATE => Type::object(\DateTime::class), + MongoDbType::DATE_IMMUTABLE => Type::object(\DateTimeImmutable::class), + MongoDbType::HASH => Type::array(), + MongoDbType::COLLECTION => Type::list(), + MongoDbType::INT, MongoDbType::INTEGER, MongoDbType::STRING => $enumType ? $enumType : $builtinType, + default => $builtinType, + }; + + return $nullable ? Type::nullable($type) : $type; + } + /** * {@inheritdoc} * + * // deprecated since 4.1, use "getType" instead + * * @return LegacyType[]|null */ - public function getTypes(string $class, string $property, array $context = []): ?array + public function getTypes($class, $property, array $context = []): ?array { + // trigger_deprecation('api-platform/core', '4.1', 'The "%s()" method is deprecated, use "%s::getType()" instead.', __METHOD__, self::class); + if (null === $metadata = $this->getMetadata($class)) { return null; } @@ -115,7 +173,7 @@ public function getTypes(string $class, string $property, array $context = []): } } - $builtinType = $this->getPhpType($typeOfField); + $builtinType = $this->getPhpTypeLegacy($typeOfField); return $builtinType ? [new LegacyType($builtinType, $nullable)] : null; } @@ -156,15 +214,23 @@ private function getMetadata(string $class): ?ClassMetadata } } - public function getType(string $class, string $property, array $context = []): ?Type + /** + * Gets the corresponding built-in PHP type identifier. + */ + private function getTypeIdentifier(string $doctrineType): ?TypeIdentifier { - return PropertyInfoToTypeInfoHelper::convertLegacyTypesToType($this->getTypes($class, $property, $context)); + return match ($doctrineType) { + MongoDbType::INTEGER, MongoDbType::INT, MongoDbType::INTID, MongoDbType::KEY => TypeIdentifier::INT, + MongoDbType::FLOAT => TypeIdentifier::FLOAT, + MongoDbType::STRING, MongoDbType::ID, MongoDbType::OBJECTID, MongoDbType::TIMESTAMP, MongoDbType::BINDATA, MongoDbType::BINDATABYTEARRAY, MongoDbType::BINDATACUSTOM, MongoDbType::BINDATAFUNC, MongoDbType::BINDATAMD5, MongoDbType::BINDATAUUID, MongoDbType::BINDATAUUIDRFC4122 => TypeIdentifier::STRING, + MongoDbType::BOOLEAN, MongoDbType::BOOL => TypeIdentifier::BOOL, + MongoDbType::DATE, MongoDbType::DATE_IMMUTABLE => TypeIdentifier::OBJECT, + MongoDbType::HASH, MongoDbType::COLLECTION => TypeIdentifier::ARRAY, + default => null, + }; } - /** - * Gets the corresponding built-in PHP type. - */ - private function getPhpType(string $doctrineType): ?string + private function getPhpTypeLegacy(string $doctrineType): ?string { return match ($doctrineType) { MongoDbType::INTEGER, MongoDbType::INT, MongoDbType::INTID, MongoDbType::KEY => LegacyType::BUILTIN_TYPE_INT, diff --git a/src/Doctrine/Odm/Tests/PropertyInfo/DoctrineExtractorTest.php b/src/Doctrine/Odm/Tests/PropertyInfo/DoctrineExtractorTest.php index 440e6e0c497..78d7d70706d 100644 --- a/src/Doctrine/Odm/Tests/PropertyInfo/DoctrineExtractorTest.php +++ b/src/Doctrine/Odm/Tests/PropertyInfo/DoctrineExtractorTest.php @@ -28,7 +28,8 @@ use Doctrine\ODM\MongoDB\DocumentManager; use Doctrine\ODM\MongoDB\Types\Type as MongoDbType; use PHPUnit\Framework\TestCase; -use Symfony\Component\PropertyInfo\Type; +use Symfony\Component\PropertyInfo\Type as LegacyType; +use Symfony\Component\TypeInfo\Type; /** * @author Kévin Dunglas @@ -82,17 +83,25 @@ public function testTestGetPropertiesWithEmbedded(): void ); } - #[\PHPUnit\Framework\Attributes\DataProvider('typesProvider')] - public function testExtract(string $property, ?array $type = null): void + #[\PHPUnit\Framework\Attributes\Group('legacy')] + #[\PHPUnit\Framework\Attributes\DataProvider('legacyTypesProvider')] + public function testExtractLegacy(string $property, ?array $type = null): void { $this->assertEquals($type, $this->createExtractor()->getTypes(DoctrineDummy::class, $property)); } - public function testExtractWithEmbedOne(): void + #[\PHPUnit\Framework\Attributes\DataProvider('typesProvider')] + public function testExtract(string $property, ?Type $type): void + { + $this->assertEquals($type, $this->createExtractor()->getType(DoctrineDummy::class, $property)); + } + + #[\PHPUnit\Framework\Attributes\Group('legacy')] + public function testExtractWithEmbedOneLegacy(): void { $expectedTypes = [ - new Type( - Type::BUILTIN_TYPE_OBJECT, + new LegacyType( + LegacyType::BUILTIN_TYPE_OBJECT, false, DoctrineEmbeddable::class ), @@ -106,16 +115,25 @@ public function testExtractWithEmbedOne(): void $this->assertEquals($expectedTypes, $actualTypes); } - public function testExtractWithEmbedMany(): void + public function testExtractWithEmbedOne(): void + { + $this->assertEquals( + Type::object(DoctrineEmbeddable::class), + $this->createExtractor()->getType(DoctrineWithEmbedded::class, 'embedOne'), + ); + } + + #[\PHPUnit\Framework\Attributes\Group('legacy')] + public function testExtractWithEmbedManyLegacy(): void { $expectedTypes = [ - new Type( - Type::BUILTIN_TYPE_OBJECT, + new LegacyType( + LegacyType::BUILTIN_TYPE_OBJECT, false, Collection::class, true, - new Type(Type::BUILTIN_TYPE_INT), - new Type(Type::BUILTIN_TYPE_OBJECT, false, DoctrineEmbeddable::class) + new LegacyType(LegacyType::BUILTIN_TYPE_INT), + new LegacyType(LegacyType::BUILTIN_TYPE_OBJECT, false, DoctrineEmbeddable::class) ), ]; @@ -127,58 +145,74 @@ public function testExtractWithEmbedMany(): void $this->assertEquals($expectedTypes, $actualTypes); } - public function testExtractEnum(): void + public function testExtractWithEmbedMany(): void { - $this->assertEquals([new Type(Type::BUILTIN_TYPE_OBJECT, false, EnumString::class)], $this->createExtractor()->getTypes(DoctrineEnum::class, 'enumString')); - $this->assertEquals([new Type(Type::BUILTIN_TYPE_OBJECT, false, EnumInt::class)], $this->createExtractor()->getTypes(DoctrineEnum::class, 'enumInt')); + $this->assertEquals( + Type::collection(Type::object(Collection::class), Type::object(DoctrineEmbeddable::class), Type::int()), + $this->createExtractor()->getType(DoctrineWithEmbedded::class, 'embedMany'), + ); + } + + #[\PHPUnit\Framework\Attributes\Group('legacy')] + public function testExtractEnumLegacy(): void + { + $this->assertEquals([new LegacyType(LegacyType::BUILTIN_TYPE_OBJECT, false, EnumString::class)], $this->createExtractor()->getTypes(DoctrineEnum::class, 'enumString')); + $this->assertEquals([new LegacyType(LegacyType::BUILTIN_TYPE_OBJECT, false, EnumInt::class)], $this->createExtractor()->getTypes(DoctrineEnum::class, 'enumInt')); $this->assertNull($this->createExtractor()->getTypes(DoctrineEnum::class, 'enumCustom')); } - public static function typesProvider(): array + public function testExtractEnum(): void + { + $this->assertEquals(Type::enum(EnumString::class), $this->createExtractor()->getType(DoctrineEnum::class, 'enumString')); + $this->assertEquals(Type::enum(EnumInt::class), $this->createExtractor()->getType(DoctrineEnum::class, 'enumInt')); + $this->assertNull($this->createExtractor()->getType(DoctrineEnum::class, 'enumCustom')); + } + + public static function legacyTypesProvider(): array { return [ - ['id', [new Type(Type::BUILTIN_TYPE_STRING)]], - ['bin', [new Type(Type::BUILTIN_TYPE_STRING)]], - ['binByteArray', [new Type(Type::BUILTIN_TYPE_STRING)]], - ['binCustom', [new Type(Type::BUILTIN_TYPE_STRING)]], - ['binFunc', [new Type(Type::BUILTIN_TYPE_STRING)]], - ['binMd5', [new Type(Type::BUILTIN_TYPE_STRING)]], - ['binUuid', [new Type(Type::BUILTIN_TYPE_STRING)]], - ['binUuidRfc4122', [new Type(Type::BUILTIN_TYPE_STRING)]], - ['timestamp', [new Type(Type::BUILTIN_TYPE_STRING)]], - ['date', [new Type(Type::BUILTIN_TYPE_OBJECT, false, \DateTime::class)]], - ['dateImmutable', [new Type(Type::BUILTIN_TYPE_OBJECT, false, \DateTimeImmutable::class)]], - ['float', [new Type(Type::BUILTIN_TYPE_FLOAT)]], - ['bool', [new Type(Type::BUILTIN_TYPE_BOOL)]], - ['int', [new Type(Type::BUILTIN_TYPE_INT)]], - ['string', [new Type(Type::BUILTIN_TYPE_STRING)]], - ['key', [new Type(Type::BUILTIN_TYPE_INT)]], - ['hash', [new Type(Type::BUILTIN_TYPE_ARRAY, false, null, true)]], - ['collection', [new Type(Type::BUILTIN_TYPE_ARRAY, false, null, true, new Type(Type::BUILTIN_TYPE_INT))]], - ['objectId', [new Type(Type::BUILTIN_TYPE_STRING)]], + ['id', [new LegacyType(LegacyType::BUILTIN_TYPE_STRING)]], + ['bin', [new LegacyType(LegacyType::BUILTIN_TYPE_STRING)]], + ['binByteArray', [new LegacyType(LegacyType::BUILTIN_TYPE_STRING)]], + ['binCustom', [new LegacyType(LegacyType::BUILTIN_TYPE_STRING)]], + ['binFunc', [new LegacyType(LegacyType::BUILTIN_TYPE_STRING)]], + ['binMd5', [new LegacyType(LegacyType::BUILTIN_TYPE_STRING)]], + ['binUuid', [new LegacyType(LegacyType::BUILTIN_TYPE_STRING)]], + ['binUuidRfc4122', [new LegacyType(LegacyType::BUILTIN_TYPE_STRING)]], + ['timestamp', [new LegacyType(LegacyType::BUILTIN_TYPE_STRING)]], + ['date', [new LegacyType(LegacyType::BUILTIN_TYPE_OBJECT, false, \DateTime::class)]], + ['dateImmutable', [new LegacyType(LegacyType::BUILTIN_TYPE_OBJECT, false, \DateTimeImmutable::class)]], + ['float', [new LegacyType(LegacyType::BUILTIN_TYPE_FLOAT)]], + ['bool', [new LegacyType(LegacyType::BUILTIN_TYPE_BOOL)]], + ['int', [new LegacyType(LegacyType::BUILTIN_TYPE_INT)]], + ['string', [new LegacyType(LegacyType::BUILTIN_TYPE_STRING)]], + ['key', [new LegacyType(LegacyType::BUILTIN_TYPE_INT)]], + ['hash', [new LegacyType(LegacyType::BUILTIN_TYPE_ARRAY, false, null, true)]], + ['collection', [new LegacyType(LegacyType::BUILTIN_TYPE_ARRAY, false, null, true, new LegacyType(LegacyType::BUILTIN_TYPE_INT))]], + ['objectId', [new LegacyType(LegacyType::BUILTIN_TYPE_STRING)]], ['raw', null], - ['foo', [new Type(Type::BUILTIN_TYPE_OBJECT, false, DoctrineRelation::class)]], + ['foo', [new LegacyType(LegacyType::BUILTIN_TYPE_OBJECT, false, DoctrineRelation::class)]], ['bar', [ - new Type( - Type::BUILTIN_TYPE_OBJECT, + new LegacyType( + LegacyType::BUILTIN_TYPE_OBJECT, false, Collection::class, true, - new Type(Type::BUILTIN_TYPE_INT), - new Type(Type::BUILTIN_TYPE_OBJECT, false, DoctrineRelation::class) + new LegacyType(LegacyType::BUILTIN_TYPE_INT), + new LegacyType(LegacyType::BUILTIN_TYPE_OBJECT, false, DoctrineRelation::class) ), ], ], ['indexedFoo', [ - new Type( - Type::BUILTIN_TYPE_OBJECT, + new LegacyType( + LegacyType::BUILTIN_TYPE_OBJECT, false, Collection::class, true, - new Type(Type::BUILTIN_TYPE_INT), - new Type(Type::BUILTIN_TYPE_OBJECT, false, DoctrineRelation::class) + new LegacyType(LegacyType::BUILTIN_TYPE_INT), + new LegacyType(LegacyType::BUILTIN_TYPE_OBJECT, false, DoctrineRelation::class) ), ], ], @@ -187,16 +221,54 @@ public static function typesProvider(): array ]; } + /** + * @return iterable + */ + public static function typesProvider(): iterable + { + yield ['id', Type::string()]; + yield ['bin', Type::string()]; + yield ['binByteArray', Type::string()]; + yield ['binCustom', Type::string()]; + yield ['binFunc', Type::string()]; + yield ['binMd5', Type::string()]; + yield ['binUuid', Type::string()]; + yield ['binUuidRfc4122', Type::string()]; + yield ['timestamp', Type::string()]; + yield ['date', Type::object(\DateTime::class)]; + yield ['dateImmutable', Type::object(\DateTimeImmutable::class)]; + yield ['float', Type::float()]; + yield ['bool', Type::bool()]; + yield ['int', Type::int()]; + yield ['string', Type::string()]; + yield ['key', Type::int()]; + yield ['hash', Type::array()]; + yield ['collection', Type::list()]; + yield ['objectId', Type::string()]; + yield ['raw', null]; + yield ['foo', Type::object(DoctrineRelation::class)]; + yield ['bar', Type::collection(Type::object(Collection::class), Type::object(DoctrineRelation::class), Type::int())]; + yield ['indexedFoo', Type::collection(Type::object(Collection::class), Type::object(DoctrineRelation::class), Type::int())]; + yield ['customFoo', null]; + yield ['notMapped', null]; + } + public function testGetPropertiesCatchException(): void { $this->assertNull($this->createExtractor()->getProperties('Not\Exist')); } - public function testGetTypesCatchException(): void + #[\PHPUnit\Framework\Attributes\Group('legacy')] + public function testGetTypesCatchExceptionLegacy(): void { $this->assertNull($this->createExtractor()->getTypes('Not\Exist', 'baz')); } + public function testGetTypesCatchException(): void + { + $this->assertNull($this->createExtractor()->getType('Not\Exist', 'baz')); + } + public function testGeneratedValueNotWritable(): void { $extractor = $this->createExtractor(); @@ -206,7 +278,8 @@ public function testGeneratedValueNotWritable(): void $this->assertNull($extractor->isReadable(DoctrineGeneratedValue::class, 'foo')); } - public function testGetTypesWithEmbedManyOmittingTargetDocument(): void + #[\PHPUnit\Framework\Attributes\Group('legacy')] + public function testGetTypesWithEmbedManyOmittingTargetDocumentLegacy(): void { $actualTypes = $this->createExtractor()->getTypes( DoctrineWithEmbedded::class, @@ -216,6 +289,11 @@ public function testGetTypesWithEmbedManyOmittingTargetDocument(): void self::assertNull($actualTypes); } + public function testGetTypesWithEmbedManyOmittingTargetDocument(): void + { + $this->assertNull($this->createExtractor()->getType(DoctrineWithEmbedded::class, 'embedManyOmittingTargetDocument')); + } + private function createExtractor(): DoctrineExtractor { $config = DoctrineMongoDbOdmSetup::createAttributeMetadataConfiguration([__DIR__.\DIRECTORY_SEPARATOR], true); diff --git a/src/Doctrine/Odm/composer.json b/src/Doctrine/Odm/composer.json index 10957fd2252..0f19a35ff8c 100644 --- a/src/Doctrine/Odm/composer.json +++ b/src/Doctrine/Odm/composer.json @@ -26,10 +26,11 @@ "require": { "php": ">=8.2", "api-platform/doctrine-common": "^4.1", - "api-platform/metadata": "^3.4 || ^4.0", + "api-platform/metadata": "^4.1", "api-platform/state": "^3.4 || ^4.0", "doctrine/mongodb-odm": "^2.2", - "symfony/property-info": "^6.4 || ^7.1" + "symfony/property-info": "^7.1", + "symfony/type-info": "^7.2" }, "require-dev": { "doctrine/doctrine-bundle": "^2.11", diff --git a/src/Doctrine/Orm/composer.json b/src/Doctrine/Orm/composer.json index 8757f18d0c8..5c43140ed89 100644 --- a/src/Doctrine/Orm/composer.json +++ b/src/Doctrine/Orm/composer.json @@ -25,10 +25,10 @@ "require": { "php": ">=8.2", "api-platform/doctrine-common": "^4.1", - "api-platform/metadata": "^3.4 || ^4.0", + "api-platform/metadata": "^4.1", "api-platform/state": "^3.4 || ^4.0", "doctrine/orm": "^2.17 || ^3.0", - "symfony/property-info": "^6.4 || ^7.0" + "symfony/property-info": "^7.1" }, "require-dev": { "doctrine/doctrine-bundle": "^2.11", diff --git a/src/Documentation/composer.json b/src/Documentation/composer.json index 71c570c8c48..29d9e74fc86 100644 --- a/src/Documentation/composer.json +++ b/src/Documentation/composer.json @@ -21,7 +21,7 @@ ], "require": { "php": ">=8.2", - "api-platform/metadata": "^3.4 || ^4.0" + "api-platform/metadata": "^4.1" }, "extra": { "branch-alias": { diff --git a/src/Elasticsearch/composer.json b/src/Elasticsearch/composer.json index 3507b2f5411..8f50bdfc24d 100644 --- a/src/Elasticsearch/composer.json +++ b/src/Elasticsearch/composer.json @@ -24,14 +24,14 @@ ], "require": { "php": ">=8.2", - "api-platform/metadata": "^3.4 || ^4.0", + "api-platform/metadata": "^4.1", "api-platform/serializer": "^3.4 || ^4.0", "api-platform/state": "^3.4 || ^4.0", "elasticsearch/elasticsearch": "^7.17 || ^8.4", "symfony/cache": "^6.4 || ^7.0", "symfony/console": "^6.4 || ^7.0", "symfony/property-access": "^6.4 || ^7.0", - "symfony/property-info": "^6.4 || ^7.0", + "symfony/property-info": "^7.1", "symfony/serializer": "^6.4 || ^7.0", "symfony/uid": "^6.4 || ^7.0" }, diff --git a/src/GraphQl/composer.json b/src/GraphQl/composer.json index f676ddae850..b2e67b5d1fa 100644 --- a/src/GraphQl/composer.json +++ b/src/GraphQl/composer.json @@ -21,10 +21,10 @@ ], "require": { "php": ">=8.2", - "api-platform/metadata": "^3.4 || ^4.0", + "api-platform/metadata": "^4.1", "api-platform/state": "^3.4 || ^4.0", "api-platform/serializer": "^3.4 || ^4.0", - "symfony/property-info": "^6.4 || ^7.0", + "symfony/property-info": "^7.1", "symfony/serializer": "^6.4 || ^7.0", "webonyx/graphql-php": "^15.0", "willdurand/negotiation": "^3.1" diff --git a/src/Hal/composer.json b/src/Hal/composer.json index 2da21682e20..00007e4e931 100644 --- a/src/Hal/composer.json +++ b/src/Hal/composer.json @@ -23,7 +23,7 @@ "require": { "php": ">=8.1", "api-platform/state": "^3.4 || ^4.0", - "api-platform/metadata": "^3.4 || ^4.0", + "api-platform/metadata": "^4.1", "api-platform/serializer": "^3.4 || ^4.0" }, "autoload": { diff --git a/src/HttpCache/composer.json b/src/HttpCache/composer.json index 6b42bcabde8..53b94513fca 100644 --- a/src/HttpCache/composer.json +++ b/src/HttpCache/composer.json @@ -23,7 +23,7 @@ ], "require": { "php": ">=8.2", - "api-platform/metadata": "^3.4 || ^4.0", + "api-platform/metadata": "^4.1", "api-platform/state": "^3.4 || ^4.0", "symfony/http-foundation": "^6.4 || ^7.0" }, diff --git a/src/Hydra/composer.json b/src/Hydra/composer.json index eece6d0e331..a82f9b463df 100644 --- a/src/Hydra/composer.json +++ b/src/Hydra/composer.json @@ -27,7 +27,7 @@ "php": ">=8.2", "api-platform/state": "^3.4 || ^4.0", "api-platform/documentation": "^3.4 || ^4.0", - "api-platform/metadata": "^3.4 || ^4.0", + "api-platform/metadata": "^4.1", "api-platform/jsonld": "^3.4 || ^4.0", "api-platform/json-schema": "^3.4 || ^4.0", "api-platform/serializer": "^3.4 || ^4.0", diff --git a/src/JsonApi/composer.json b/src/JsonApi/composer.json index 60e5fd25bef..6db83b8d1d2 100644 --- a/src/JsonApi/composer.json +++ b/src/JsonApi/composer.json @@ -24,7 +24,7 @@ "php": ">=8.2", "api-platform/documentation": "^3.4 || ^4.0", "api-platform/json-schema": "^3.4 || ^4.0", - "api-platform/metadata": "^3.4 || ^4.0", + "api-platform/metadata": "^4.1", "api-platform/serializer": "^3.4 || ^4.0", "api-platform/state": "^3.4 || ^4.0", "symfony/error-handler": "^6.4 || ^7.0", diff --git a/src/JsonLd/composer.json b/src/JsonLd/composer.json index 7b27cfca087..66a373ac14f 100644 --- a/src/JsonLd/composer.json +++ b/src/JsonLd/composer.json @@ -25,7 +25,7 @@ "require": { "php": ">=8.2", "api-platform/state": "^3.4 || ^4.0", - "api-platform/metadata": "^3.4 || ^4.0", + "api-platform/metadata": "^4.1", "api-platform/serializer": "^3.4 || ^4.0" }, "autoload": { diff --git a/src/JsonSchema/composer.json b/src/JsonSchema/composer.json index 0695729b5eb..f4cf2b72c49 100644 --- a/src/JsonSchema/composer.json +++ b/src/JsonSchema/composer.json @@ -25,9 +25,9 @@ ], "require": { "php": ">=8.2", - "api-platform/metadata": "^3.4 || ^4.0", + "api-platform/metadata": "^4.1", "symfony/console": "^6.4 || ^7.0", - "symfony/property-info": "^6.4 || ^7.0", + "symfony/property-info": "^7.1", "symfony/serializer": "^6.4 || ^7.0", "symfony/uid": "^6.4 || ^7.0" }, diff --git a/src/Laravel/composer.json b/src/Laravel/composer.json index ae78d721792..41b3081f12c 100644 --- a/src/Laravel/composer.json +++ b/src/Laravel/composer.json @@ -34,7 +34,7 @@ "api-platform/json-schema": "^4.0", "api-platform/jsonld": "^4.0", "api-platform/json-api": "^4.0", - "api-platform/metadata": "^4.0", + "api-platform/metadata": "^4.1", "api-platform/openapi": "^4.0", "api-platform/serializer": "^4.0", "api-platform/state": "^4.0", diff --git a/src/Metadata/ApiProperty.php b/src/Metadata/ApiProperty.php index 8d64ab0236f..06ae9d5e110 100644 --- a/src/Metadata/ApiProperty.php +++ b/src/Metadata/ApiProperty.php @@ -13,13 +13,15 @@ namespace ApiPlatform\Metadata; -use Symfony\Component\PropertyInfo\Type; +use ApiPlatform\Metadata\Util\PropertyInfoToTypeInfoHelper; +use Symfony\Component\PropertyInfo\Type as LegacyType; use Symfony\Component\Serializer\Attribute\Context; use Symfony\Component\Serializer\Attribute\Groups; use Symfony\Component\Serializer\Attribute\Ignore; use Symfony\Component\Serializer\Attribute\MaxDepth; use Symfony\Component\Serializer\Attribute\SerializedName; use Symfony\Component\Serializer\Attribute\SerializedPath; +use Symfony\Component\TypeInfo\Type; /** * ApiProperty annotation. @@ -31,6 +33,15 @@ final class ApiProperty { private ?array $types; private ?array $serialize; + private ?Type $phpType; + + /** + * Used to know if only legacy types are defined without triggering deprecation. + * To be removed in 5.0. + * + * @internal + */ + public bool $usesLegacyType = false; /** * @param bool|null $readableLink https://api-platform.com/docs/core/serialization/#force-iri-with-relations-of-the-same-type-parentchilds-relations @@ -47,10 +58,11 @@ final class ApiProperty * @param string|\Stringable|null $securityPostDenormalize https://api-platform.com/docs/core/security/#executing-access-control-rules-after-denormalization * @param string[] $types the RDF types of this property * @param string[] $iris - * @param Type[] $builtinTypes + * @param LegacyType[] $builtinTypes * @param string|null $uriTemplate (experimental) whether to return the subRessource collection IRI instead of an iterable of IRI * @param string|null $property The property name * @param Context|Groups|Ignore|SerializedName|SerializedPath|MaxDepth|array $serialize Serializer attributes + * @param Type $phpType The internal PHP type */ public function __construct( private ?string $description = null, @@ -206,6 +218,8 @@ public function __construct( array|string|null $types = null, /* * The related php types. + * + * deprecated since 4.1, use "phpType" instead. */ private ?array $builtinTypes = null, private ?array $schema = null, @@ -221,9 +235,23 @@ public function __construct( */ private ?bool $hydra = null, private array $extraProperties = [], + ?Type $phpType = null, ) { $this->types = \is_string($types) ? (array) $types : $types; $this->serialize = \is_array($serialize) ? $serialize : [$serialize]; + $this->phpType = $phpType; + + if ($this->builtinTypes) { + // trigger_deprecation('api_platform/metadata', '4.1', 'The "builtinTypes" argument of "%s()" is deprecated, use "phpType" instead.'); + + $this->usesLegacyType = true; + + if (!$this->phpType) { + $this->phpType = PropertyInfoToTypeInfoHelper::convertLegacyTypesToType($this->builtinTypes); + } + } elseif ($this->phpType) { + $this->builtinTypes = PropertyInfoToTypeInfoHelper::convertTypeToLegacyTypes($this->phpType) ?? []; + } } public function getProperty(): ?string @@ -490,20 +518,43 @@ public function withTypes(array|string $types = []): static } /** - * @return Type[] + * deprecated since 4.1, use "getPhpType" instead. + * + * @return LegacyType[] */ public function getBuiltinTypes(): ?array { + // trigger_deprecation('api-platform/metadata', '4.1', 'The "%s()" method is deprecated, use "%s::getPhpType()" instead.', __METHOD__, self::class); + return $this->builtinTypes; } /** - * @param Type[] $builtinTypes + * deprecated since 4.1, use "withPhpType" instead. + * + * @param LegacyType[] $builtinTypes */ public function withBuiltinTypes(array $builtinTypes = []): static { + // trigger_deprecation('api-platform/metadata', '4.1', 'The "%s()" method is deprecated, use "%s::withPhpType()" instead.', __METHOD__, self::class); + $self = clone $this; $self->builtinTypes = $builtinTypes; + $self->phpType = PropertyInfoToTypeInfoHelper::convertLegacyTypesToType($builtinTypes); + + return $self; + } + + public function getPhpType(): ?Type + { + return $this->phpType; + } + + public function withPhpType(?Type $phpType): self + { + $self = clone $this; + $self->phpType = $phpType; + $self->builtinTypes = PropertyInfoToTypeInfoHelper::convertTypeToLegacyTypes($phpType) ?? []; return $self; } diff --git a/src/Metadata/Extractor/XmlPropertyExtractor.php b/src/Metadata/Extractor/XmlPropertyExtractor.php index 900d0ef99d1..dc5b7ba47b9 100644 --- a/src/Metadata/Extractor/XmlPropertyExtractor.php +++ b/src/Metadata/Extractor/XmlPropertyExtractor.php @@ -74,6 +74,7 @@ protected function extractPath(string $path): void 'genId' => $this->phpize($property, 'genId', 'bool'), 'uriTemplate' => $this->phpize($property, 'uriTemplate', 'string'), 'property' => $this->phpize($property, 'property', 'string'), + 'phpType' => $this->phpize($property, 'phpType', 'string'), ]; } } diff --git a/src/Metadata/Extractor/YamlPropertyExtractor.php b/src/Metadata/Extractor/YamlPropertyExtractor.php index 70f26becf2a..24fdd3a1d8d 100644 --- a/src/Metadata/Extractor/YamlPropertyExtractor.php +++ b/src/Metadata/Extractor/YamlPropertyExtractor.php @@ -95,6 +95,7 @@ private function buildProperties(array $resourcesYaml): void 'genId' => $this->phpize($propertyValues, 'genId', 'bool'), 'uriTemplate' => $this->phpize($propertyValues, 'uriTemplate', 'string'), 'property' => $this->phpize($propertyValues, 'property', 'string'), + 'phpType' => $this->phpize($propertyValues, 'phpType', 'string'), ]; } } diff --git a/src/Metadata/Extractor/schema/properties.xsd b/src/Metadata/Extractor/schema/properties.xsd index 5664433cf64..03ea88d9f54 100644 --- a/src/Metadata/Extractor/schema/properties.xsd +++ b/src/Metadata/Extractor/schema/properties.xsd @@ -47,6 +47,7 @@ + diff --git a/src/Metadata/IdentifiersExtractor.php b/src/Metadata/IdentifiersExtractor.php index a9f5efe801e..342481076d6 100644 --- a/src/Metadata/IdentifiersExtractor.php +++ b/src/Metadata/IdentifiersExtractor.php @@ -23,6 +23,10 @@ use Symfony\Component\PropertyAccess\Exception\NoSuchPropertyException; use Symfony\Component\PropertyAccess\PropertyAccess; use Symfony\Component\PropertyAccess\PropertyAccessorInterface; +use Symfony\Component\TypeInfo\Type; +use Symfony\Component\TypeInfo\Type\CollectionType; +use Symfony\Component\TypeInfo\Type\CompositeTypeInterface; +use Symfony\Component\TypeInfo\Type\WrappingTypeInterface; /** * {@inheritdoc} @@ -110,21 +114,25 @@ private function getIdentifierValue(object $item, string $class, string $propert foreach ($this->propertyNameCollectionFactory->create($resourceClass) as $propertyName) { $propertyMetadata = $this->propertyMetadataFactory->create($resourceClass, $propertyName); - $types = $propertyMetadata->getBuiltinTypes(); - if (null === ($type = $types[0] ?? null)) { + if (null === $type = $propertyMetadata->getPhpType()) { continue; } - try { - if ($type->isCollection()) { - $collectionValueType = $type->getCollectionValueTypes()[0] ?? null; + $collectionValueIsIdentifiedByClass = function (Type $type) use (&$collectionValueIsIdentifiedByClass, $class): bool { + return match (true) { + $type instanceof CollectionType => $type->getCollectionValueType()->isIdentifiedBy($class), + $type instanceof WrappingTypeInterface => $type->wrappedTypeIsSatisfiedBy($collectionValueIsIdentifiedByClass), + $type instanceof CompositeTypeInterface => $type->composedTypesAreSatisfiedBy($collectionValueIsIdentifiedByClass), + default => false, + }; + }; - if (null !== $collectionValueType && $collectionValueType->getClassName() === $class) { - return $this->resolveIdentifierValue($this->propertyAccessor->getValue($item, \sprintf('%s[0].%s', $propertyName, $property)), $parameterName); - } + try { + if ($type->isSatisfiedBy($collectionValueIsIdentifiedByClass)) { + return $this->resolveIdentifierValue($this->propertyAccessor->getValue($item, \sprintf('%s[0].%s', $propertyName, $property)), $parameterName); } - if ($type->getClassName() === $class) { + if ($type->isIdentifiedBy($class)) { return $this->resolveIdentifierValue($this->propertyAccessor->getValue($item, "$propertyName.$property"), $parameterName); } } catch (NoSuchPropertyException $e) { diff --git a/src/Metadata/Property/Factory/AttributePropertyMetadataFactory.php b/src/Metadata/Property/Factory/AttributePropertyMetadataFactory.php index 7aac5e0cf6d..1c4b15bfa36 100644 --- a/src/Metadata/Property/Factory/AttributePropertyMetadataFactory.php +++ b/src/Metadata/Property/Factory/AttributePropertyMetadataFactory.php @@ -121,8 +121,23 @@ private function createMetadata(ApiProperty $attribute, ?ApiProperty $propertyMe } foreach (get_class_methods(ApiProperty::class) as $method) { - if (preg_match('/^(?:get|is)(.*)/', (string) $method, $matches) && null !== $val = $attribute->{$method}()) { - $propertyMetadata = $propertyMetadata->{"with{$matches[1]}"}($val); + if (preg_match('/^(?:get|is)(.*)/', (string) $method, $matches)) { + // BC layer, to remove in 5.0 + if ('getBuiltinTypes' === $method) { + if (!$attribute->usesLegacyType) { + continue; + } + + if ($builtinTypes = $attribute->getBuiltinTypes()) { + $propertyMetadata = $propertyMetadata->withBuiltinTypes($builtinTypes); + } + + continue; + } + + if (null !== $val = $attribute->{$method}()) { + $propertyMetadata = $propertyMetadata->{"with{$matches[1]}"}($val); + } } } diff --git a/src/Metadata/Property/Factory/ExtractorPropertyMetadataFactory.php b/src/Metadata/Property/Factory/ExtractorPropertyMetadataFactory.php index 3b2c473d24c..f7ecdbbde20 100644 --- a/src/Metadata/Property/Factory/ExtractorPropertyMetadataFactory.php +++ b/src/Metadata/Property/Factory/ExtractorPropertyMetadataFactory.php @@ -17,7 +17,8 @@ use ApiPlatform\Metadata\ApiProperty; use ApiPlatform\Metadata\Exception\PropertyNotFoundException; use ApiPlatform\Metadata\Extractor\PropertyExtractorInterface; -use Symfony\Component\PropertyInfo\Type; +use Symfony\Component\PropertyInfo\Type as LegacyType; +use Symfony\Component\TypeInfo\Type; /** * Creates properties's metadata using an extractor. @@ -60,7 +61,9 @@ public function create(string $resourceClass, string $property, array $options = foreach ($propertyMetadata as $key => $value) { if ('builtinTypes' === $key && null !== $value) { - $value = array_map(fn (string $builtinType): Type => new Type($builtinType), $value); + $value = array_map(static fn (string $builtinType): LegacyType => new LegacyType($builtinType), $value); + } elseif ('phpType' === $key && null !== $value) { + $value = Type::builtin($value); } $methodName = 'with'.ucfirst($key); diff --git a/src/Metadata/Property/Factory/PropertyInfoPropertyMetadataFactory.php b/src/Metadata/Property/Factory/PropertyInfoPropertyMetadataFactory.php index c15072345d8..e3c98b3de7a 100644 --- a/src/Metadata/Property/Factory/PropertyInfoPropertyMetadataFactory.php +++ b/src/Metadata/Property/Factory/PropertyInfoPropertyMetadataFactory.php @@ -15,9 +15,7 @@ use ApiPlatform\Metadata\ApiProperty; use ApiPlatform\Metadata\Exception\PropertyNotFoundException; -use Doctrine\Common\Collections\ArrayCollection; use Symfony\Component\PropertyInfo\PropertyInfoExtractorInterface; -use Symfony\Component\PropertyInfo\Type; /** * PropertyInfo metadata loader decorator. @@ -45,17 +43,8 @@ public function create(string $resourceClass, string $property, array $options = } } - if (!$propertyMetadata->getBuiltinTypes()) { - $types = $this->propertyInfo->getTypes($resourceClass, $property, $options) ?? []; - - foreach ($types as $i => $type) { - // Temp fix for https://github.com/symfony/symfony/pull/52699 - if (ArrayCollection::class === $type->getClassName()) { - $types[$i] = new Type($type->getBuiltinType(), $type->isNullable(), $type->getClassName(), true, $type->getCollectionKeyTypes(), $type->getCollectionValueTypes()); - } - } - - $propertyMetadata = $propertyMetadata->withBuiltinTypes($types); + if (!$propertyMetadata->getPhpType()) { + $propertyMetadata = $propertyMetadata->withPhpType($this->propertyInfo->getType($resourceClass, $property, $options)); } if (null === $propertyMetadata->getDescription() && null !== $description = $this->propertyInfo->getShortDescription($resourceClass, $property, $options)) { diff --git a/src/Metadata/Property/Factory/SerializerPropertyMetadataFactory.php b/src/Metadata/Property/Factory/SerializerPropertyMetadataFactory.php index d41d1c0561e..e64d1c54ebe 100644 --- a/src/Metadata/Property/Factory/SerializerPropertyMetadataFactory.php +++ b/src/Metadata/Property/Factory/SerializerPropertyMetadataFactory.php @@ -19,6 +19,11 @@ use ApiPlatform\Metadata\Util\ResourceClassInfoTrait; use Symfony\Component\Serializer\Mapping\AttributeMetadataInterface; use Symfony\Component\Serializer\Mapping\Factory\ClassMetadataFactoryInterface as SerializerClassMetadataFactoryInterface; +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; /** * Populates read/write and link status using serialization groups. @@ -60,17 +65,24 @@ public function create(string $resourceClass, string $property, array $options = } $propertyMetadata = $this->transformReadWrite($propertyMetadata, $resourceClass, $property, $normalizationGroups, $denormalizationGroups, $ignoredAttributes); - $types = $propertyMetadata->getBuiltinTypes() ?? []; - if (!$this->isResourceClass($resourceClass) && $types) { - foreach ($types as $builtinType) { - if ($builtinType->isCollection()) { - return $propertyMetadata->withReadableLink(true)->withWritableLink(true); - } + $type = $propertyMetadata->getPhpType(); + if ($type && !$this->isResourceClass($resourceClass)) { + $typeIsCollection = static function (Type $type) use (&$typeIsCollection): bool { + return match (true) { + $type instanceof CollectionType => true, + $type instanceof WrappingTypeInterface => $type->wrappedTypeIsSatisfiedBy($typeIsCollection), + $type instanceof CompositeTypeInterface => $type->composedTypesAreSatisfiedBy($typeIsCollection), + default => false, + }; + }; + + if ($type->isSatisfiedBy($typeIsCollection)) { + return $propertyMetadata->withReadableLink(true)->withWritableLink(true); } } - return $this->transformLinkStatus($propertyMetadata, $normalizationGroups, $denormalizationGroups, $types); + return $this->transformLinkStatus($propertyMetadata, $normalizationGroups, $denormalizationGroups, $type); } /** @@ -112,45 +124,47 @@ private function transformReadWrite(ApiProperty $propertyMetadata, string $resou * @param string[]|null $normalizationGroups * @param string[]|null $denormalizationGroups */ - private function transformLinkStatus(ApiProperty $propertyMetadata, ?array $normalizationGroups = null, ?array $denormalizationGroups = null, ?array $types = null): ApiProperty + private function transformLinkStatus(ApiProperty $propertyMetadata, ?array $normalizationGroups = null, ?array $denormalizationGroups = null, ?Type $type = null): ApiProperty { // No need to check link status if property is not readable and not writable if (false === $propertyMetadata->isReadable() && false === $propertyMetadata->isWritable()) { return $propertyMetadata; } - foreach ($types as $type) { - if ( - $type->isCollection() - && $collectionValueType = $type->getCollectionValueTypes()[0] ?? null - ) { - $relatedClass = $collectionValueType->getClassName(); - } else { - $relatedClass = $type->getClassName(); - } - - // if property is not a resource relation, don't set link status (as it would have no meaning) - if (null === $relatedClass || !$this->isResourceClass($relatedClass)) { - continue; - } + if (!$type) { + return $propertyMetadata; + } - // find the resource class - // this prevents serializer groups on non-resource child class from incorrectly influencing the decision - if (null !== $this->resourceClassResolver) { - $relatedClass = $this->resourceClassResolver->getResourceClass(null, $relatedClass); - } + /** @var class-string|null $className */ + $className = null; + $typeIsResourceClass = function (Type $type) use (&$typeIsResourceClass, &$className): bool { + return match (true) { + $type instanceof CollectionType => $type->getCollectionValueType()->isSatisfiedBy($typeIsResourceClass), + $type instanceof WrappingTypeInterface => $type->wrappedTypeIsSatisfiedBy($typeIsResourceClass), + $type instanceof CompositeTypeInterface => $type->composedTypesAreSatisfiedBy($typeIsResourceClass), + default => $type instanceof ObjectType && $this->isResourceClass($className = $type->getClassName()), + }; + }; + + // if property is not a resource relation, don't set link status (as it would have no meaning) + if (!$type->isSatisfiedBy($typeIsResourceClass)) { + return $propertyMetadata; + } - $relatedGroups = $this->getClassSerializerGroups($relatedClass); + // find the resource class + // this prevents serializer groups on non-resource child class from incorrectly influencing the decision + if (null !== $this->resourceClassResolver) { + $className = $this->resourceClassResolver->getResourceClass(null, $className); + } - if (null === $propertyMetadata->isReadableLink()) { - $propertyMetadata = $propertyMetadata->withReadableLink(null !== $normalizationGroups && !empty(array_intersect($normalizationGroups, $relatedGroups))); - } + $relatedGroups = $this->getClassSerializerGroups($className); - if (null === $propertyMetadata->isWritableLink()) { - $propertyMetadata = $propertyMetadata->withWritableLink(null !== $denormalizationGroups && !empty(array_intersect($denormalizationGroups, $relatedGroups))); - } + if (null === $propertyMetadata->isReadableLink()) { + $propertyMetadata = $propertyMetadata->withReadableLink(null !== $normalizationGroups && !empty(array_intersect($normalizationGroups, $relatedGroups))); + } - return $propertyMetadata; + if (null === $propertyMetadata->isWritableLink()) { + $propertyMetadata = $propertyMetadata->withWritableLink(null !== $denormalizationGroups && !empty(array_intersect($denormalizationGroups, $relatedGroups))); } return $propertyMetadata; diff --git a/src/Metadata/Resource/Factory/LinkFactory.php b/src/Metadata/Resource/Factory/LinkFactory.php index 5c020a26976..30c0c6a7a19 100644 --- a/src/Metadata/Resource/Factory/LinkFactory.php +++ b/src/Metadata/Resource/Factory/LinkFactory.php @@ -19,7 +19,11 @@ use ApiPlatform\Metadata\Property\Factory\PropertyMetadataFactoryInterface; use ApiPlatform\Metadata\Property\Factory\PropertyNameCollectionFactoryInterface; use ApiPlatform\Metadata\ResourceClassResolverInterface; -use Symfony\Component\PropertyInfo\Type; +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; /** * @internal @@ -41,7 +45,7 @@ public function __construct(private readonly PropertyNameCollectionFactoryInterf public function createLinkFromProperty(Metadata $operation, string $property): Link { $metadata = $this->propertyMetadataFactory->create($resourceClass = $operation->getClass(), $property); - $relationClass = $this->getPropertyClassType($metadata->getBuiltinTypes()); + $relationClass = $this->getPropertyClassType($metadata->getPhpType()); if (!$relationClass) { throw new RuntimeException(\sprintf('We could not find a class matching the uriVariable "%s" on "%s".', $property, $resourceClass)); } @@ -85,7 +89,7 @@ public function createLinksFromRelations(Metadata $operation): array foreach ($this->propertyNameCollectionFactory->create($resourceClass = $operation->getClass()) as $property) { $metadata = $this->propertyMetadataFactory->create($resourceClass, $property); - if (!($relationClass = $this->getPropertyClassType($metadata->getBuiltinTypes())) || !$this->resourceClassResolver->isResourceClass($relationClass)) { + if (!($relationClass = $this->getPropertyClassType($metadata->getPhpType())) || !$this->resourceClassResolver->isResourceClass($relationClass)) { continue; } @@ -115,7 +119,7 @@ public function createLinksFromAttributes(Metadata $operation): array ->withFromProperty($property); if (!$attributeLink->getFromClass()) { - $attributeLink = $attributeLink->withFromClass($resourceClass)->withToClass($this->getPropertyClassType($metadata->getBuiltinTypes()) ?? $resourceClass); + $attributeLink = $attributeLink->withFromClass($resourceClass)->withToClass($this->getPropertyClassType($metadata->getPhpType()) ?? $resourceClass); } $links[] = $attributeLink; @@ -179,19 +183,39 @@ private function getIdentifiersFromResourceClass(string $resourceClass): array return $this->localIdentifiersPerResourceClassCache[$resourceClass] = $identifiers; } - /** - * @param Type[]|null $types - */ - private function getPropertyClassType(?array $types): ?string + private function getPropertyClassType(?Type $type): ?string { - foreach ($types ?? [] as $type) { - if ($type->isCollection()) { - return $this->getPropertyClassType($type->getCollectionValueTypes()); - } + if (!$type) { + return null; + } - if ($class = $type->getClassName()) { - return $class; - } + /** @var Type|null $collectionValueType */ + $collectionValueType = null; + $typeIsCollection = static function (Type $type) use (&$typeIsCollection, &$collectionValueType): bool { + return match (true) { + $type instanceof CollectionType => null !== $collectionValueType = $type->getCollectionValueType(), + $type instanceof WrappingTypeInterface => $type->wrappedTypeIsSatisfiedBy($typeIsCollection), + $type instanceof CompositeTypeInterface => $type->composedTypesAreSatisfiedBy($typeIsCollection), + default => false, + }; + }; + + if ($type->isSatisfiedBy($typeIsCollection)) { + return $this->getPropertyClassType($collectionValueType); + } + + /** @var class-string|null $className */ + $className = null; + $typeIsClass = static function (Type $type) use (&$typeIsClass, &$className): bool { + return match (true) { + $type instanceof WrappingTypeInterface => $type->wrappedTypeIsSatisfiedBy($typeIsClass), + $type instanceof CompositeTypeInterface => $type->composedTypesAreSatisfiedBy($typeIsClass), + default => $type instanceof ObjectType && $className = $type->getClassName(), + }; + }; + + if ($type->isSatisfiedBy($typeIsClass)) { + return $className; } return null; diff --git a/src/Metadata/Tests/Extractor/Adapter/XmlPropertyAdapter.php b/src/Metadata/Tests/Extractor/Adapter/XmlPropertyAdapter.php index cdfdcd245fd..93cdc722292 100644 --- a/src/Metadata/Tests/Extractor/Adapter/XmlPropertyAdapter.php +++ b/src/Metadata/Tests/Extractor/Adapter/XmlPropertyAdapter.php @@ -46,6 +46,7 @@ final class XmlPropertyAdapter implements PropertyAdapterInterface 'uriTemplate', 'hydra', 'property', + 'phpType', ]; // TODO: add serialize support for XML (policy is Laravel-only) @@ -97,12 +98,9 @@ public function __invoke(string $resourceClass, string $propertyName, array $par return [$filename]; } - private function buildBuiltinTypes(\SimpleXMLElement $resource, array $values): void + private function buildBuiltinTypes(\SimpleXMLElement $resource): void { - $node = $resource->addChild('builtinTypes'); - foreach ($values as $value) { - $node->addChild('builtinType', $value); - } + // deprecated, to remove in 5.0 } private function buildSchema(\SimpleXMLElement $resource, array $values): void diff --git a/src/Metadata/Tests/Extractor/Adapter/properties.xml b/src/Metadata/Tests/Extractor/Adapter/properties.xml index 1bd37ee8b11..d0b75addbb8 100644 --- a/src/Metadata/Tests/Extractor/Adapter/properties.xml +++ b/src/Metadata/Tests/Extractor/Adapter/properties.xml @@ -1,3 +1,3 @@ -bazbaripsumsomeirischemaanotheririschemastringhttps://schema.org/Thinghttps://schema.org/totalPriceLorem ipsum dolor sit amet1 +bazbaripsumsomeirischemaanotheririschemahttps://schema.org/Thinghttps://schema.org/totalPriceLorem ipsum dolor sit amet1 diff --git a/src/Metadata/Tests/Extractor/Adapter/properties.yaml b/src/Metadata/Tests/Extractor/Adapter/properties.yaml index 9ec0d1458db..393055682a9 100644 --- a/src/Metadata/Tests/Extractor/Adapter/properties.yaml +++ b/src/Metadata/Tests/Extractor/Adapter/properties.yaml @@ -29,8 +29,6 @@ properties: types: - someirischema - anotheririschema - builtinTypes: - - string initializable: true extraProperties: custom_property: 'Lorem ipsum dolor sit amet' @@ -41,3 +39,4 @@ properties: uriTemplate: /sub-resource-get-collection property: test hydra: false + phpType: string diff --git a/src/Metadata/Tests/Extractor/PropertyMetadataCompatibilityTest.php b/src/Metadata/Tests/Extractor/PropertyMetadataCompatibilityTest.php index 0119918b519..f12866d16c4 100644 --- a/src/Metadata/Tests/Extractor/PropertyMetadataCompatibilityTest.php +++ b/src/Metadata/Tests/Extractor/PropertyMetadataCompatibilityTest.php @@ -24,7 +24,7 @@ use ApiPlatform\Metadata\Tests\Fixtures\ApiResource\Comment; use PHPUnit\Framework\AssertionFailedError; use PHPUnit\Framework\TestCase; -use Symfony\Component\PropertyInfo\Type; +use Symfony\Component\TypeInfo\Type; /** * Ensures XML and YAML mappings are fully compatible with ApiPlatform\Metadata\ApiProperty. @@ -66,7 +66,6 @@ final class PropertyMetadataCompatibilityTest extends TestCase 'security' => 'is_granted(\'IS_AUTHENTICATED_ANONYMOUSLY\')', 'securityPostDenormalize' => 'is_granted(\'ROLE_CUSTOM_ADMIN\')', 'types' => ['someirischema', 'anotheririschema'], - 'builtinTypes' => ['string'], 'initializable' => true, 'extraProperties' => [ 'custom_property' => 'Lorem ipsum dolor sit amet', @@ -77,6 +76,7 @@ final class PropertyMetadataCompatibilityTest extends TestCase 'uriTemplate' => '/sub-resource-get-collection', 'property' => 'test', 'hydra' => false, + 'phpType' => 'string', ]; #[\PHPUnit\Framework\Attributes\DataProvider('getExtractors')] @@ -124,8 +124,8 @@ private function buildApiProperty(): ApiProperty return $property; } - private function withBuiltinTypes(array $values, array $fixtures): array + private function withPhpType(string $value): Type { - return array_map(fn (string $builtinType): Type => new Type($builtinType), $values); + return Type::builtin($value); } } diff --git a/src/Metadata/Tests/Property/Factory/SerializerPropertyMetadataFactoryTest.php b/src/Metadata/Tests/Property/Factory/SerializerPropertyMetadataFactoryTest.php index b08293138fb..06a85aa32b0 100644 --- a/src/Metadata/Tests/Property/Factory/SerializerPropertyMetadataFactoryTest.php +++ b/src/Metadata/Tests/Property/Factory/SerializerPropertyMetadataFactoryTest.php @@ -23,10 +23,10 @@ use ApiPlatform\Metadata\Tests\Fixtures\DummyIgnoreProperty; use PHPUnit\Framework\TestCase; use Prophecy\PhpUnit\ProphecyTrait; -use Symfony\Component\PropertyInfo\Type; use Symfony\Component\Serializer\Mapping\AttributeMetadata as SerializerAttributeMetadata; use Symfony\Component\Serializer\Mapping\ClassMetadata as SerializerClassMetadata; use Symfony\Component\Serializer\Mapping\Factory\ClassMetadataFactoryInterface as SerializerClassMetadataFactoryInterface; +use Symfony\Component\TypeInfo\Type; class SerializerPropertyMetadataFactoryTest extends TestCase { @@ -72,15 +72,15 @@ public function testCreate($readGroups, $writeGroups): void $decoratedProphecy = $this->prophesize(PropertyMetadataFactoryInterface::class); $fooPropertyMetadata = (new ApiProperty()) - ->withBuiltinTypes([new Type(Type::BUILTIN_TYPE_ARRAY, true)]) + ->withPhpType(Type::nullable(Type::array())) // @phpstan-ignore-line ->withReadable(false) ->withWritable(true); $decoratedProphecy->create(Dummy::class, 'foo', $context)->willReturn($fooPropertyMetadata); $relatedDummyPropertyMetadata = (new ApiProperty()) - ->withBuiltinTypes([new Type(Type::BUILTIN_TYPE_OBJECT, true, RelatedDummy::class)]); + ->withPhpType(Type::nullable(Type::object(RelatedDummy::class))); $decoratedProphecy->create(Dummy::class, 'relatedDummy', $context)->willReturn($relatedDummyPropertyMetadata); $nameConvertedPropertyMetadata = (new ApiProperty()) - ->withBuiltinTypes([new Type(Type::BUILTIN_TYPE_STRING, true)]); + ->withPhpType(Type::nullable(Type::string())); // @phpstan-ignore-line $decoratedProphecy->create(Dummy::class, 'nameConverted', $context)->willReturn($nameConvertedPropertyMetadata); $resourceClassResolverProphecy = $this->prophesize(ResourceClassResolverInterface::class); @@ -126,7 +126,7 @@ public function testCreateWithIgnoredProperty(): void $resourceClassResolverProphecy = $this->prophesize(ResourceClassResolverInterface::class); $resourceClassResolverProphecy->isResourceClass(DummyIgnoreProperty::class)->willReturn(true); - $ignoredPropertyMetadata = (new ApiProperty())->withBuiltinTypes([new Type(Type::BUILTIN_TYPE_STRING, true)]); + $ignoredPropertyMetadata = (new ApiProperty())->withPhpType(Type::nullable(Type::string())); // @phpstan-ignore-line $options = [ 'normalization_groups' => ['dummy'], diff --git a/src/Metadata/Tests/Resource/Factory/LinkFactoryTest.php b/src/Metadata/Tests/Resource/Factory/LinkFactoryTest.php index 62aa19001a1..449b7f44fc1 100644 --- a/src/Metadata/Tests/Resource/Factory/LinkFactoryTest.php +++ b/src/Metadata/Tests/Resource/Factory/LinkFactoryTest.php @@ -30,7 +30,7 @@ use Prophecy\PhpUnit\ProphecyTrait; use Symfony\Component\PropertyInfo\Extractor\ReflectionExtractor; use Symfony\Component\PropertyInfo\PropertyInfoExtractor; -use Symfony\Component\PropertyInfo\Type; +use Symfony\Component\TypeInfo\Type; final class LinkFactoryTest extends TestCase { @@ -93,11 +93,11 @@ public static function provideCreateLinksFromIdentifiersCases(): \Generator } #[\PHPUnit\Framework\Attributes\DataProvider('provideCreateLinksFromAttributesCases')] - public function testCreateLinksFromAttributes(array $builtinTypes, array $expectedLinks): void + public function testCreateLinksFromAttributes(?Type $phpType, array $expectedLinks): void { $propertyNameCollectionFactory = new PropertyInfoPropertyNameCollectionFactory(new PropertyInfoExtractor([new ReflectionExtractor()])); $propertyMetadataFactoryProphecy = $this->prophesize(PropertyMetadataFactoryInterface::class); - $propertyMetadataFactoryProphecy->create(AttributeResource::class, 'dummy')->willReturn((new ApiProperty())->withBuiltinTypes($builtinTypes)); + $propertyMetadataFactoryProphecy->create(AttributeResource::class, 'dummy')->willReturn((new ApiProperty())->withPhpType($phpType)); $resourceClassResolverProphecy = $this->prophesize(ResourceClassResolverInterface::class); $linkFactory = new LinkFactory($propertyNameCollectionFactory, $propertyMetadataFactoryProphecy->reveal(), $resourceClassResolverProphecy->reveal()); @@ -109,16 +109,16 @@ public function testCreateLinksFromAttributes(array $builtinTypes, array $expect public static function provideCreateLinksFromAttributesCases(): \Generator { - yield 'no builtin types' => [ - [], + yield 'no PHP type' => [ + null, [(new Link())->withFromClass(AttributeResource::class)->withFromProperty('dummy')->withToClass(AttributeResource::class)->withParameterName('dummyId')], ]; - yield 'with builtin types' => [ - [new Type(Type::BUILTIN_TYPE_OBJECT, false, Dummy::class)], + yield 'with PHP type' => [ + Type::object(Dummy::class), [(new Link())->withFromClass(AttributeResource::class)->withFromProperty('dummy')->withToClass(Dummy::class)->withParameterName('dummyId')], ]; - yield 'with collection builtin types' => [ - [new Type(Type::BUILTIN_TYPE_ARRAY, false, Dummy::class, true, null, [new Type(Type::BUILTIN_TYPE_OBJECT, false, RelatedDummy::class)])], + yield 'with collection PHP type' => [ + Type::list(Type::object(RelatedDummy::class)), [(new Link())->withFromClass(AttributeResource::class)->withFromProperty('dummy')->withToClass(RelatedDummy::class)->withParameterName('dummyId')], ]; } @@ -146,7 +146,7 @@ public function testCreateLinkFromProperty(): void $property = 'test'; $propertyMetadataFactoryProphecy = $this->prophesize(PropertyMetadataFactoryInterface::class); - $propertyMetadataFactoryProphecy->create(Dummy::class, 'test')->willReturn(new ApiProperty(builtinTypes: [new Type(builtinType: Type::BUILTIN_TYPE_OBJECT, class: RelatedDummy::class)])); + $propertyMetadataFactoryProphecy->create(Dummy::class, 'test')->willReturn(new ApiProperty(phpType: Type::object(RelatedDummy::class))); $resourceClassResolverProphecy = $this->prophesize(ResourceClassResolverInterface::class); $resourceClassResolverProphecy->isResourceClass(RelatedDummy::class)->willReturn(false); diff --git a/src/Metadata/Tests/Resource/Factory/LinkResourceMetadataCollectionFactoryTest.php b/src/Metadata/Tests/Resource/Factory/LinkResourceMetadataCollectionFactoryTest.php index 9edf2b70006..07a6989b761 100644 --- a/src/Metadata/Tests/Resource/Factory/LinkResourceMetadataCollectionFactoryTest.php +++ b/src/Metadata/Tests/Resource/Factory/LinkResourceMetadataCollectionFactoryTest.php @@ -32,7 +32,7 @@ use PHPUnit\Framework\TestCase; use Prophecy\Argument; use Prophecy\PhpUnit\ProphecyTrait; -use Symfony\Component\PropertyInfo\Type; +use Symfony\Component\TypeInfo\Type; class LinkResourceMetadataCollectionFactoryTest extends TestCase { @@ -49,15 +49,15 @@ public function testCreate(): void ])); $propertyMetadataFactoryProphecy = $this->prophesize(PropertyMetadataFactoryInterface::class); $propertyMetadataFactoryProphecy->create(AttributeResource::class, 'id')->willReturn(new ApiProperty()); - $propertyMetadataFactoryProphecy->create(AttributeResource::class, 'foo')->willReturn((new ApiProperty())->withBuiltinTypes([ - new Type(Type::BUILTIN_TYPE_OBJECT, false, Collection::class, true, new Type(Type::BUILTIN_TYPE_INT), new Type(Type::BUILTIN_TYPE_OBJECT, false, Dummy::class)), - ])); - $propertyMetadataFactoryProphecy->create(AttributeResource::class, 'foo2')->willReturn((new ApiProperty())->withBuiltinTypes([ - new Type(Type::BUILTIN_TYPE_OBJECT, false, Collection::class, true, new Type(Type::BUILTIN_TYPE_INT), new Type(Type::BUILTIN_TYPE_OBJECT, false, Dummy::class)), - ])); - $propertyMetadataFactoryProphecy->create(AttributeResource::class, 'bar')->willReturn((new ApiProperty())->withBuiltinTypes([ - new Type(Type::BUILTIN_TYPE_OBJECT, false, Collection::class, true, new Type(Type::BUILTIN_TYPE_INT), new Type(Type::BUILTIN_TYPE_OBJECT, false, RelatedDummy::class)), - ])); + $propertyMetadataFactoryProphecy->create(AttributeResource::class, 'foo')->willReturn((new ApiProperty())->withPhpType( + Type::collection(Type::object(Collection::class), key: Type::int(), value: Type::object(Dummy::class)), + )); + $propertyMetadataFactoryProphecy->create(AttributeResource::class, 'foo2')->willReturn((new ApiProperty())->withPhpType( + Type::collection(Type::object(Collection::class), key: Type::int(), value: Type::object(Dummy::class)), + )); + $propertyMetadataFactoryProphecy->create(AttributeResource::class, 'bar')->willReturn((new ApiProperty())->withPhpType( + Type::collection(Type::object(Collection::class), key: Type::int(), value: Type::object(RelatedDummy::class)), + )); $resourceClassResolverProphecy = $this->prophesize(ResourceClassResolverInterface::class); $resourceClassResolverProphecy->isResourceClass(Dummy::class)->willReturn(true); $resourceClassResolverProphecy->isResourceClass(RelatedDummy::class)->willReturn(true); @@ -104,9 +104,9 @@ public function testCreateWithLinkAttribute(): void ])); $propertyMetadataFactoryProphecy = $this->prophesize(PropertyMetadataFactoryInterface::class); $propertyMetadataFactoryProphecy->create(AttributeResource::class, 'identifier')->willReturn((new ApiProperty())->withIdentifier(true)); - $propertyMetadataFactoryProphecy->create(AttributeResource::class, 'dummy')->willReturn((new ApiProperty())->withBuiltinTypes([ - new Type(Type::BUILTIN_TYPE_OBJECT, false, Collection::class, true, new Type(Type::BUILTIN_TYPE_INT), new Type(Type::BUILTIN_TYPE_OBJECT, false, Dummy::class)), - ])); + $propertyMetadataFactoryProphecy->create(AttributeResource::class, 'dummy')->willReturn((new ApiProperty())->withPhpType( + Type::collection(Type::object(Collection::class), key: Type::int(), value: Type::object(Dummy::class)), + )); $resourceClassResolverProphecy = $this->prophesize(ResourceClassResolverInterface::class); $resourceClassResolverProphecy->isResourceClass(Dummy::class)->willReturn(true); $linkFactory = new LinkFactory($propertyNameCollectionFactoryProphecy->reveal(), $propertyMetadataFactoryProphecy->reveal(), $resourceClassResolverProphecy->reveal()); diff --git a/src/Metadata/Tests/Util/PropertyInfoToTypeInfoHelperTest.php b/src/Metadata/Tests/Util/PropertyInfoToTypeInfoHelperTest.php index 2fe0cf9d539..61f6e99b045 100644 --- a/src/Metadata/Tests/Util/PropertyInfoToTypeInfoHelperTest.php +++ b/src/Metadata/Tests/Util/PropertyInfoToTypeInfoHelperTest.php @@ -63,4 +63,46 @@ public static function convertLegacyTypesToTypeDataProvider(): iterable $type = Type::collection(Type::builtin(TypeIdentifier::ARRAY), Type::int(), Type::string()); // @phpstan-ignore-line yield [$type, [new LegacyType('array', false, null, true, [new LegacyType('string')], new LegacyType('int'))]]; } + + /** + * @param list|null $legacyTypes + */ + #[\PHPUnit\Framework\Attributes\DataProvider('convertTypeToLegacyTypesDataProvider')] + public function testConvertTypeToLegacyTypes(?array $legacyTypes, ?Type $type): void + { + $this->assertEquals($legacyTypes, PropertyInfoToTypeInfoHelper::convertTypeToLegacyTypes($type)); + } + + /** + * @return iterable|null, 1: ?Type, 2?: bool}> + */ + public static function convertTypeToLegacyTypesDataProvider(): iterable + { + yield [null, null]; + yield [null, Type::mixed()]; + yield [null, Type::never()]; + yield [[new LegacyType('null')], Type::null()]; + yield [[new LegacyType('null')], Type::void()]; + yield [[new LegacyType('int')], Type::int()]; + yield [[new LegacyType('object', false, \stdClass::class)], Type::object(\stdClass::class)]; + yield [ + [new LegacyType('object', false, \Traversable::class, true, null, new LegacyType('int'))], + Type::generic(Type::object(\Traversable::class), Type::int()), + ]; + yield [ + [new LegacyType('array', false, null, true, new LegacyType('int'), new LegacyType('string'))], + Type::generic(Type::builtin(TypeIdentifier::ARRAY), Type::int(), Type::string()), // @phpstan-ignore-line + ]; + yield [ + [new LegacyType('array', false, null, true, new LegacyType('int'), new LegacyType('string'))], + Type::collection(Type::builtin(TypeIdentifier::ARRAY), Type::string(), Type::int()), // @phpstan-ignore-line + ]; + yield [[new LegacyType('int', true)], Type::nullable(Type::int())]; // @phpstan-ignore-line + yield [[new LegacyType('int'), new LegacyType('string')], Type::union(Type::int(), Type::string())]; + yield [ + [new LegacyType('int', true), new LegacyType('string', true)], + Type::union(Type::int(), Type::string(), Type::null()), + ]; + yield [[new LegacyType('object', false, \Stringable::class), new LegacyType('object', false, \Traversable::class)], Type::intersection(Type::object(\Traversable::class), Type::object(\Stringable::class))]; + } } diff --git a/src/Metadata/UriVariableTransformer/IntegerUriVariableTransformer.php b/src/Metadata/UriVariableTransformer/IntegerUriVariableTransformer.php index 3821ad6b53f..10012c78492 100644 --- a/src/Metadata/UriVariableTransformer/IntegerUriVariableTransformer.php +++ b/src/Metadata/UriVariableTransformer/IntegerUriVariableTransformer.php @@ -14,7 +14,7 @@ namespace ApiPlatform\Metadata\UriVariableTransformer; use ApiPlatform\Metadata\UriVariableTransformerInterface; -use Symfony\Component\PropertyInfo\Type; +use Symfony\Component\TypeInfo\TypeIdentifier; final class IntegerUriVariableTransformer implements UriVariableTransformerInterface { @@ -25,6 +25,6 @@ public function transform(mixed $value, array $types, array $context = []): int public function supportsTransformation(mixed $value, array $types, array $context = []): bool { - return Type::BUILTIN_TYPE_INT === $types[0] && \is_string($value); + return TypeIdentifier::INT->value === $types[0] && \is_string($value); } } diff --git a/src/Metadata/UriVariablesConverter.php b/src/Metadata/UriVariablesConverter.php index ff4d6213f67..7acc0c5a8e3 100644 --- a/src/Metadata/UriVariablesConverter.php +++ b/src/Metadata/UriVariablesConverter.php @@ -16,7 +16,8 @@ use ApiPlatform\Metadata\Exception\InvalidUriVariableException; use ApiPlatform\Metadata\Property\Factory\PropertyMetadataFactoryInterface; use ApiPlatform\Metadata\Resource\Factory\ResourceMetadataCollectionFactoryInterface; -use Symfony\Component\PropertyInfo\Type; +use Symfony\Component\TypeInfo\Type\CompositeTypeInterface; +use Symfony\Component\TypeInfo\Type\WrappingTypeInterface; /** * UriVariables converter that chains uri variables transformers. @@ -54,7 +55,7 @@ public function convert(array $uriVariables, string $class, array $context = []) $properties = [$parameterName]; } - if (!$types = $this->getIdentifierTypes($uriVariableDefinition->getFromClass() ?? $class, $properties)) { + if (!$types = $this->getIdentifierTypeStrings($uriVariableDefinition->getFromClass() ?? $class, $properties)) { continue; } @@ -75,16 +76,29 @@ public function convert(array $uriVariables, string $class, array $context = []) return $uriVariables; } - private function getIdentifierTypes(string $resourceClass, array $properties): array + /** + * @return list + */ + private function getIdentifierTypeStrings(string $resourceClass, array $properties): array { - $types = []; + $typeStrings = []; + foreach ($properties as $property) { $propertyMetadata = $this->propertyMetadataFactory->create($resourceClass, $property); - foreach ($propertyMetadata->getBuiltinTypes() as $type) { - $types[] = Type::BUILTIN_TYPE_OBJECT === ($builtinType = $type->getBuiltinType()) ? $type->getClassName() : $builtinType; + + if (!$type = $propertyMetadata->getPhpType()) { + continue; + } + + foreach ($type instanceof CompositeTypeInterface ? $type->getTypes() : [$type] as $t) { + while ($t instanceof WrappingTypeInterface) { + $t = $t->getWrappedType(); + } + + $typeStrings[] = (string) $t; } } - return $types; + return $typeStrings; } } diff --git a/src/Metadata/Util/PropertyInfoToTypeInfoHelper.php b/src/Metadata/Util/PropertyInfoToTypeInfoHelper.php index 40be59bad86..bda642848f8 100644 --- a/src/Metadata/Util/PropertyInfoToTypeInfoHelper.php +++ b/src/Metadata/Util/PropertyInfoToTypeInfoHelper.php @@ -17,7 +17,11 @@ use Symfony\Component\TypeInfo\Exception\InvalidArgumentException; use Symfony\Component\TypeInfo\Type; use Symfony\Component\TypeInfo\Type\BuiltinType; +use Symfony\Component\TypeInfo\Type\CollectionType; +use Symfony\Component\TypeInfo\Type\GenericType; +use Symfony\Component\TypeInfo\Type\IntersectionType; use Symfony\Component\TypeInfo\Type\NullableType; +use Symfony\Component\TypeInfo\Type\ObjectType; use Symfony\Component\TypeInfo\Type\UnionType; use Symfony\Component\TypeInfo\TypeIdentifier; @@ -111,6 +115,11 @@ public static function createTypeFromLegacyValues(string $builtinType, bool $nul } if (\count($variableTypes)) { + // hack to have generic without classname + // this is required because some tests are using invalid data + if (null === $class && 'object' === $builtinType) { + $type = Type::object(\stdClass::class); + } $type = Type::generic($type, ...$variableTypes); } @@ -153,4 +162,146 @@ private static function convertLegacyTypeToType(LegacyType $legacyType): Type $legacyType->getCollectionValueTypes(), ); } + + /** + * Converts a {@see Type} to what is should have been in the "symfony/property-info" component. + * + * @return list|null + */ + public static function convertTypeToLegacyTypes(?Type $type): ?array + { + if (null === $type) { + return null; + } + + if (\in_array((string) $type, ['mixed', 'never'], true)) { + return null; + } + + if (\in_array((string) $type, ['null', 'void'], true)) { + return [new LegacyType('null')]; + } + + $legacyType = self::convertTypeToLegacy($type); + + if (!\is_array($legacyType)) { + $legacyType = [$legacyType]; + } + + return $legacyType; + } + + /** + * Recursive method that converts {@see Type} to its related {@see LegacyType} (or list of {@see @LegacyType}). + * + * @return LegacyType|list + */ + private static function convertTypeToLegacy(Type $type): LegacyType|array + { + $nullable = false; + + if ($type instanceof NullableType) { + $nullable = true; + $type = $type->getWrappedType(); + } + + if ($type instanceof UnionType) { + $unionTypes = []; + foreach ($type->getTypes() as $t) { + if ($t instanceof IntersectionType) { + throw new \LogicException(\sprintf('DNF types are not supported by "%s".', LegacyType::class)); + } + + if ($nullable) { + $t = Type::nullable($t); + } + + $unionTypes[] = $t; + } + + /** @var list $legacyTypes */ + $legacyTypes = array_map(self::convertTypeToLegacy(...), $unionTypes); + + if (1 === \count($legacyTypes)) { + return $legacyTypes[0]; + } + + return $legacyTypes; + } + + if ($type instanceof IntersectionType) { + /** @var list $legacyTypes */ + $legacyTypes = array_map(self::convertTypeToLegacy(...), $type->getTypes()); + + if (1 === \count($legacyTypes)) { + return $legacyTypes[0]; + } + + return $legacyTypes; + } + + if ($type instanceof CollectionType) { + $type = $type->getWrappedType(); + if ($nullable) { + $type = Type::nullable($type); + } + + return self::convertTypeToLegacy($type); + } + + $typeIdentifier = TypeIdentifier::MIXED; + $className = null; + $collectionKeyType = $collectionValueType = null; + + if ($type instanceof GenericType) { + $wrappedType = $type->getWrappedType(); + + if ($wrappedType instanceof BuiltinType) { + $typeIdentifier = $wrappedType->getTypeIdentifier(); + } elseif ($wrappedType instanceof ObjectType) { + $typeIdentifier = TypeIdentifier::OBJECT; + $className = $wrappedType->getClassName(); + } + + $variableTypes = $type->getVariableTypes(); + + if (2 === \count($variableTypes)) { + if ('int|string' !== (string) $variableTypes[0]) { + $collectionKeyType = self::convertTypeToLegacy($variableTypes[0]); + } + $collectionValueType = self::convertTypeToLegacy($variableTypes[1]); + } elseif (1 === \count($variableTypes)) { + $collectionValueType = self::convertTypeToLegacy($variableTypes[0]); + } + } elseif ($type instanceof ObjectType) { + $typeIdentifier = TypeIdentifier::OBJECT; + $className = $type->getClassName(); + } elseif ($type instanceof BuiltinType) { + $typeIdentifier = $type->getTypeIdentifier(); + } + + if (TypeIdentifier::MIXED === $typeIdentifier) { + return [ + new LegacyType(LegacyType::BUILTIN_TYPE_INT, true), + new LegacyType(LegacyType::BUILTIN_TYPE_FLOAT, true), + new LegacyType(LegacyType::BUILTIN_TYPE_STRING, true), + new LegacyType(LegacyType::BUILTIN_TYPE_BOOL, true), + new LegacyType(LegacyType::BUILTIN_TYPE_RESOURCE, true), + new LegacyType(LegacyType::BUILTIN_TYPE_OBJECT, true), + new LegacyType(LegacyType::BUILTIN_TYPE_ARRAY, true), + new LegacyType(LegacyType::BUILTIN_TYPE_NULL, true), + new LegacyType(LegacyType::BUILTIN_TYPE_CALLABLE, true), + new LegacyType(LegacyType::BUILTIN_TYPE_ITERABLE, true), + ]; + } + + return new LegacyType( + builtinType: $typeIdentifier->value, + nullable: $nullable, + class: $className, + collection: $type instanceof GenericType, + collectionKeyType: $collectionKeyType, + collectionValueType: $collectionValueType, + ); + } } diff --git a/src/Metadata/composer.json b/src/Metadata/composer.json index af7647e2020..a1f6108db71 100644 --- a/src/Metadata/composer.json +++ b/src/Metadata/composer.json @@ -31,9 +31,9 @@ "doctrine/inflector": "^1.0 || ^2.0", "psr/cache": "^1.0 || ^2.0 || ^3.0", "psr/log": "^1.0 || ^2.0 || ^3.0", - "symfony/property-info": "^6.4 || ^7.0", + "symfony/property-info": "^7.1", "symfony/string": "^6.4 || ^7.0", - "symfony/type-info": "^7.1" + "symfony/type-info": "^7.2" }, "require-dev": { "api-platform/json-schema": "^3.4 || ^4.0", diff --git a/src/OpenApi/composer.json b/src/OpenApi/composer.json index 3576b855da7..1c2cfb50a9b 100644 --- a/src/OpenApi/composer.json +++ b/src/OpenApi/composer.json @@ -29,7 +29,7 @@ "require": { "php": ">=8.2", "api-platform/json-schema": "^3.4 || ^4.0", - "api-platform/metadata": "^3.4 || ^4.0", + "api-platform/metadata": "^4.1", "api-platform/state": "^3.4 || ^4.0", "symfony/console": "^6.4 || ^7.0", "symfony/property-access": "^6.4 || ^7.0", diff --git a/src/RamseyUuid/composer.json b/src/RamseyUuid/composer.json index 5d31ceb4fdf..9bccd09f486 100644 --- a/src/RamseyUuid/composer.json +++ b/src/RamseyUuid/composer.json @@ -23,7 +23,7 @@ ], "require": { "php": ">=8.2", - "api-platform/metadata": "^3.4 || ^4.0", + "api-platform/metadata": "^4.1", "symfony/serializer": "^6.4 || ^7.0" }, "require-dev": { diff --git a/src/Serializer/AbstractItemNormalizer.php b/src/Serializer/AbstractItemNormalizer.php index 5a8a2989b47..57dc744461d 100644 --- a/src/Serializer/AbstractItemNormalizer.php +++ b/src/Serializer/AbstractItemNormalizer.php @@ -883,6 +883,7 @@ private function createAndValidateAttributeValue(string $attribute, mixed $value $propertyMetadata = $this->propertyMetadataFactory->create($context['resource_class'], $attribute, $this->getFactoryOptions($context)); $types = $propertyMetadata->getBuiltinTypes() ?? []; $isMultipleTypes = \count($types) > 1; + $denormalizationException = null; foreach ($types as $type) { if (null === $value && ($type->isNullable() || ($context[static::DISABLE_TYPE_ENFORCEMENT] ?? false))) { @@ -908,7 +909,18 @@ private function createAndValidateAttributeValue(string $attribute, mixed $value $context['resource_class'] = $resourceClass; unset($context['uri_variables']); - return $this->denormalizeCollection($attribute, $propertyMetadata, $type, $resourceClass, $value, $format, $context); + try { + return $this->denormalizeCollection($attribute, $propertyMetadata, $type, $resourceClass, $value, $format, $context); + } catch (NotNormalizableValueException $e) { + // union/intersect types: try the next type, if not valid, an exception will be thrown at the end + if ($isMultipleTypes) { + $denormalizationException = $e; + + continue; + } + + throw $e; + } } if ( @@ -918,7 +930,18 @@ private function createAndValidateAttributeValue(string $attribute, mixed $value $resourceClass = $this->resourceClassResolver->getResourceClass(null, $className); $childContext = $this->createChildContext($this->createOperationContext($context, $resourceClass), $attribute, $format); - return $this->denormalizeRelation($attribute, $propertyMetadata, $resourceClass, $value, $format, $childContext); + try { + return $this->denormalizeRelation($attribute, $propertyMetadata, $resourceClass, $value, $format, $childContext); + } catch (NotNormalizableValueException $e) { + // union/intersect types: try the next type, if not valid, an exception will be thrown at the end + if ($isMultipleTypes) { + $denormalizationException = $e; + + continue; + } + + throw $e; + } } if ( @@ -933,7 +956,18 @@ private function createAndValidateAttributeValue(string $attribute, mixed $value unset($context['resource_class'], $context['uri_variables']); - return $this->serializer->denormalize($value, $className.'[]', $format, $context); + try { + return $this->serializer->denormalize($value, $className.'[]', $format, $context); + } catch (NotNormalizableValueException $e) { + // union/intersect types: try the next type, if not valid, an exception will be thrown at the end + if ($isMultipleTypes) { + $denormalizationException = $e; + + continue; + } + + throw $e; + } } if (null !== $className = $type->getClassName()) { @@ -943,7 +977,18 @@ private function createAndValidateAttributeValue(string $attribute, mixed $value unset($context['resource_class'], $context['uri_variables']); - return $this->serializer->denormalize($value, $className, $format, $context); + try { + return $this->serializer->denormalize($value, $className, $format, $context); + } catch (NotNormalizableValueException $e) { + // union/intersect types: try the next type, if not valid, an exception will be thrown at the end + if ($isMultipleTypes) { + $denormalizationException = $e; + + continue; + } + + throw $e; + } } /* From @see AbstractObjectNormalizer::validateAndDenormalize() */ @@ -1019,6 +1064,10 @@ private function createAndValidateAttributeValue(string $attribute, mixed $value } } + if ($denormalizationException) { + throw $denormalizationException; + } + return $value; } diff --git a/src/Serializer/composer.json b/src/Serializer/composer.json index 51a7cbf5aec..38a58dd6ba5 100644 --- a/src/Serializer/composer.json +++ b/src/Serializer/composer.json @@ -23,10 +23,10 @@ ], "require": { "php": ">=8.2", - "api-platform/metadata": "^3.4 || ^4.0", + "api-platform/metadata": "^4.1", "api-platform/state": "^3.4 || ^4.0", "symfony/property-access": "^6.4 || ^7.0", - "symfony/property-info": "^6.4 || ^7.0", + "symfony/property-info": "^7.1", "symfony/serializer": "^6.4 || ^7.0", "symfony/validator": "^6.4 || ^7.0" }, diff --git a/src/State/composer.json b/src/State/composer.json index 72664396851..ca4fb7245a3 100644 --- a/src/State/composer.json +++ b/src/State/composer.json @@ -28,7 +28,7 @@ ], "require": { "php": ">=8.2", - "api-platform/metadata": "^3.4 || ^4.0", + "api-platform/metadata": "^4.1", "psr/container": "^1.0 || ^2.0", "symfony/http-kernel": "^6.4 || ^7.0" }, diff --git a/src/Symfony/composer.json b/src/Symfony/composer.json index 3abeb7a3baa..eb16cdeac50 100644 --- a/src/Symfony/composer.json +++ b/src/Symfony/composer.json @@ -34,12 +34,12 @@ "api-platform/json-schema": "^3.4 || ^4.0", "api-platform/jsonld": "^3.4 || ^4.0", "api-platform/hydra": "^3.4 || ^4.0", - "api-platform/metadata": "^3.4 || ^4.0", + "api-platform/metadata": "^4.1", "api-platform/serializer": "^3.4 || ^4.0", "api-platform/state": "^3.4 || ^4.0", "api-platform/validator": "^3.4 || ^4.0", "api-platform/openapi": "^4.1", - "symfony/property-info": "^6.4 || ^7.0", + "symfony/property-info": "^7.1", "symfony/property-access": "^6.4 || ^7.0", "symfony/serializer": "^6.4 || ^7.0", "symfony/security-core": "^6.4 || ^7.0", diff --git a/tests/JsonSchema/Command/JsonSchemaGenerateCommandTest.php b/tests/JsonSchema/Command/JsonSchemaGenerateCommandTest.php index 260124a1e3a..d1b03048727 100644 --- a/tests/JsonSchema/Command/JsonSchemaGenerateCommandTest.php +++ b/tests/JsonSchema/Command/JsonSchemaGenerateCommandTest.php @@ -178,8 +178,8 @@ public function testArraySchemaWithMultipleUnionTypesJsonLd(): void $json = json_decode($result, associative: true); $this->assertEquals($json['definitions']['Nest.jsonld']['properties']['owner']['anyOf'], [ - ['$ref' => '#/definitions/Wren.jsonld'], ['$ref' => '#/definitions/Robin.jsonld'], + ['$ref' => '#/definitions/Wren.jsonld'], ['type' => 'null'], ]); @@ -194,8 +194,8 @@ public function testArraySchemaWithMultipleUnionTypesJsonApi(): void $json = json_decode($result, associative: true); $this->assertEquals($json['definitions']['Nest.jsonapi']['properties']['data']['properties']['attributes']['properties']['owner']['anyOf'], [ - ['$ref' => '#/definitions/Wren.jsonapi'], ['$ref' => '#/definitions/Robin.jsonapi'], + ['$ref' => '#/definitions/Wren.jsonapi'], ['type' => 'null'], ]); @@ -210,8 +210,8 @@ public function testArraySchemaWithMultipleUnionTypesJsonHal(): void $json = json_decode($result, associative: true); $this->assertEquals($json['definitions']['Nest.jsonhal']['properties']['owner']['anyOf'], [ - ['$ref' => '#/definitions/Wren.jsonhal'], ['$ref' => '#/definitions/Robin.jsonhal'], + ['$ref' => '#/definitions/Wren.jsonhal'], ['type' => 'null'], ]);