From f6490ce5342ca8ca96f543a5edea671266b7ba59 Mon Sep 17 00:00:00 2001 From: Mathias Arlaud Date: Fri, 21 Feb 2025 15:14:55 +0100 Subject: [PATCH 1/4] feat(metadata/doctrine): use `TypeInfo`'s `Type` --- composer.json | 7 +- .../create-a-custom-doctrine-filter.php | 3 +- .../Odm/PropertyInfo/DoctrineExtractor.php | 84 ++++++++- .../PropertyInfo/DoctrineExtractorTest.php | 168 +++++++++++++----- src/Doctrine/Odm/composer.json | 3 +- src/Doctrine/Orm/composer.json | 2 +- src/Elasticsearch/composer.json | 2 +- src/GraphQl/composer.json | 2 +- src/JsonSchema/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 | 15 +- .../AttributePropertyMetadataFactory.php | 19 +- .../ExtractorPropertyMetadataFactory.php | 26 ++- .../PropertyInfoPropertyMetadataFactory.php | 15 +- .../SerializerPropertyMetadataFactory.php | 63 +++---- src/Metadata/Resource/Factory/LinkFactory.php | 28 ++- .../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 | 27 ++- .../Util/PropertyInfoToTypeInfoHelper.php | 151 ++++++++++++++++ src/Metadata/Util/TypeHelper.php | 98 ++++++++++ src/Metadata/composer.json | 4 +- src/Serializer/AbstractItemNormalizer.php | 57 +++++- src/Serializer/composer.json | 2 +- src/Symfony/composer.json | 2 +- .../Command/JsonSchemaGenerateCommandTest.php | 6 +- 36 files changed, 767 insertions(+), 204 deletions(-) create mode 100644 src/Metadata/Util/TypeHelper.php diff --git a/composer.json b/composer.json index 7f46df75a2a..0e91de66f1f 100644 --- a/composer.json +++ b/composer.json @@ -111,10 +111,11 @@ "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.1", + "symfony/property-info": "^7.1", "symfony/serializer": "^6.4 || ^7.0", "symfony/translation-contracts": "^3.3", - "symfony/web-link": "^6.4 || ^7.1", + "symfony/type-info": "^7.2", + "symfony/web-link": "^6.4 || ^7.0", "willdurand/negotiation": "^3.1" }, "require-dev": { @@ -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/Odm/PropertyInfo/DoctrineExtractor.php b/src/Doctrine/Odm/PropertyInfo/DoctrineExtractor.php index 7967a6f2192..a8e6d8e090e 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(string $class, string $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.2 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.2', '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 c87a6205798..f32019e2cf6 100644 --- a/src/Doctrine/Odm/composer.json +++ b/src/Doctrine/Odm/composer.json @@ -29,7 +29,8 @@ "api-platform/metadata": "^4.1", "api-platform/state": "^4.1", "doctrine/mongodb-odm": "^2.10", - "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 14700800b87..23913e461cc 100644 --- a/src/Doctrine/Orm/composer.json +++ b/src/Doctrine/Orm/composer.json @@ -28,7 +28,7 @@ "api-platform/metadata": "^4.1", "api-platform/state": "^4.1", "doctrine/orm": "^2.17 || ^3.0", - "symfony/property-info": "^6.4 || ^7.1" + "symfony/property-info": "^7.1" }, "require-dev": { "doctrine/doctrine-bundle": "^2.11", diff --git a/src/Elasticsearch/composer.json b/src/Elasticsearch/composer.json index 3ccb6bc9eaa..59952192ec7 100644 --- a/src/Elasticsearch/composer.json +++ b/src/Elasticsearch/composer.json @@ -31,7 +31,7 @@ "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.1", + "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 1f0af796c60..17630f5f2fd 100644 --- a/src/GraphQl/composer.json +++ b/src/GraphQl/composer.json @@ -24,7 +24,7 @@ "api-platform/metadata": "^4.1", "api-platform/state": "^4.1", "api-platform/serializer": "^4.1", - "symfony/property-info": "^6.4 || ^7.1", + "symfony/property-info": "^7.1", "symfony/serializer": "^6.4 || ^7.0", "webonyx/graphql-php": "^15.0", "willdurand/negotiation": "^3.1" diff --git a/src/JsonSchema/composer.json b/src/JsonSchema/composer.json index 21a88b492cd..e8c269527bc 100644 --- a/src/JsonSchema/composer.json +++ b/src/JsonSchema/composer.json @@ -27,7 +27,7 @@ "php": ">=8.2", "api-platform/metadata": "^4.1", "symfony/console": "^6.4 || ^7.0", - "symfony/property-info": "^6.4 || ^7.1", + "symfony/property-info": "^7.1", "symfony/serializer": "^6.4 || ^7.0", "symfony/uid": "^6.4 || ^7.0" }, diff --git a/src/Metadata/ApiProperty.php b/src/Metadata/ApiProperty.php index 8d64ab0236f..1fd04f25879 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.2, 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.2', '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.2, use "getPhpType" instead. + * + * @return LegacyType[] */ public function getBuiltinTypes(): ?array { + // trigger_deprecation('api-platform/metadata', '4.2', 'The "%s()" method is deprecated, use "%s::getPhpType()" instead.', __METHOD__, self::class); + return $this->builtinTypes; } /** - * @param Type[] $builtinTypes + * deprecated since 4.2, use "withPhpType" instead. + * + * @param LegacyType[] $builtinTypes */ public function withBuiltinTypes(array $builtinTypes = []): static { + // trigger_deprecation('api-platform/metadata', '4.2', '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..b3374569025 100644 --- a/src/Metadata/IdentifiersExtractor.php +++ b/src/Metadata/IdentifiersExtractor.php @@ -20,6 +20,7 @@ use ApiPlatform\Metadata\Resource\Factory\ResourceMetadataCollectionFactoryInterface; use ApiPlatform\Metadata\Util\CompositeIdentifierParser; use ApiPlatform\Metadata\Util\ResourceClassInfoTrait; +use ApiPlatform\Metadata\Util\TypeHelper; use Symfony\Component\PropertyAccess\Exception\NoSuchPropertyException; use Symfony\Component\PropertyAccess\PropertyAccess; use Symfony\Component\PropertyAccess\PropertyAccessorInterface; @@ -107,24 +108,22 @@ private function getIdentifierValue(object $item, string $class, string $propert } $resourceClass = $this->getResourceClass($item, true); + 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; + $collectionValueType = TypeHelper::getCollectionValueType($type); - if (null !== $collectionValueType && $collectionValueType->getClassName() === $class) { - return $this->resolveIdentifierValue($this->propertyAccessor->getValue($item, \sprintf('%s[0].%s', $propertyName, $property)), $parameterName); - } + if ($collectionValueType?->isIdentifiedBy($class)) { + 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..c57cb82e8d1 100644 --- a/src/Metadata/Property/Factory/ExtractorPropertyMetadataFactory.php +++ b/src/Metadata/Property/Factory/ExtractorPropertyMetadataFactory.php @@ -16,8 +16,12 @@ use ApiPlatform\JsonSchema\Metadata\Property\Factory\SchemaPropertyMetadataFactory; use ApiPlatform\Metadata\ApiProperty; use ApiPlatform\Metadata\Exception\PropertyNotFoundException; +use ApiPlatform\Metadata\Exception\RuntimeException; use ApiPlatform\Metadata\Extractor\PropertyExtractorInterface; -use Symfony\Component\PropertyInfo\Type; +use PHPStan\PhpDocParser\Parser\PhpDocParser; +use Symfony\Component\PropertyInfo\Type as LegacyType; +use Symfony\Component\TypeInfo\Type; +use Symfony\Component\TypeInfo\TypeResolver\StringTypeResolver; /** * Creates properties's metadata using an extractor. @@ -60,7 +64,25 @@ 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); + $apiProperty = $apiProperty->withBuiltinTypes(array_map(static fn (string $builtinType): LegacyType => new LegacyType($builtinType), $value)); + + continue; + } + + if ('phpType' === $key && null !== $value) { + if (class_exists(PhpDocParser::class)) { + $apiProperty = $apiProperty->withPhpType((new StringTypeResolver())->resolve($value)); + + continue; + } + + try { + $apiProperty = $apiProperty->withPhpType(Type::builtin($value)); + } catch (\ValueError) { + throw new RuntimeException(\sprintf('Cannot create a type from "%s". Try running "composer require phpstan/phpdoc-parser" to support all types.', $value)); + } + + continue; } $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..194f4976b0f 100644 --- a/src/Metadata/Property/Factory/SerializerPropertyMetadataFactory.php +++ b/src/Metadata/Property/Factory/SerializerPropertyMetadataFactory.php @@ -17,8 +17,11 @@ use ApiPlatform\Metadata\Exception\ResourceClassNotFoundException; use ApiPlatform\Metadata\ResourceClassResolverInterface; use ApiPlatform\Metadata\Util\ResourceClassInfoTrait; +use ApiPlatform\Metadata\Util\TypeHelper; 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; /** * Populates read/write and link status using serialization groups. @@ -60,17 +63,13 @@ 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 (null !== $type && !$this->isResourceClass($resourceClass) && TypeHelper::isSatisfiedBy($type, static fn (Type $t): bool => $t instanceof CollectionType)) { + return $propertyMetadata->withReadableLink(true)->withWritableLink(true); } - return $this->transformLinkStatus($propertyMetadata, $normalizationGroups, $denormalizationGroups, $types); + return $this->transformLinkStatus($propertyMetadata, $normalizationGroups, $denormalizationGroups, $type); } /** @@ -112,45 +111,39 @@ 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 (!$type) { + return $propertyMetadata; + } - // 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; - } + $collectionValueType = TypeHelper::getCollectionValueType($type); + $className = $collectionValueType ? TypeHelper::getClassName($collectionValueType) : TypeHelper::getClassName($type); - // 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); - } + // if property is not a resource relation, don't set link status (as it would have no meaning) + if (!$className || !$this->isResourceClass($className)) { + 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..bd07909ea7a 100644 --- a/src/Metadata/Resource/Factory/LinkFactory.php +++ b/src/Metadata/Resource/Factory/LinkFactory.php @@ -19,7 +19,8 @@ use ApiPlatform\Metadata\Property\Factory\PropertyMetadataFactoryInterface; use ApiPlatform\Metadata\Property\Factory\PropertyNameCollectionFactoryInterface; use ApiPlatform\Metadata\ResourceClassResolverInterface; -use Symfony\Component\PropertyInfo\Type; +use ApiPlatform\Metadata\Util\TypeHelper; +use Symfony\Component\TypeInfo\Type; /** * @internal @@ -41,7 +42,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 +86,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 +116,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,21 +180,16 @@ 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; - } + if ($collectionValueType = TypeHelper::getCollectionValueType($type)) { + return $this->getPropertyClassType($collectionValueType); } - return null; + return TypeHelper::getClassName($type); } } 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..d9c7d74075c 100644 --- a/src/Metadata/UriVariablesConverter.php +++ b/src/Metadata/UriVariablesConverter.php @@ -16,7 +16,9 @@ use ApiPlatform\Metadata\Exception\InvalidUriVariableException; use ApiPlatform\Metadata\Property\Factory\PropertyMetadataFactoryInterface; use ApiPlatform\Metadata\Resource\Factory\ResourceMetadataCollectionFactoryInterface; -use Symfony\Component\PropertyInfo\Type; +use ApiPlatform\Metadata\Util\TypeHelper; +use Symfony\Component\TypeInfo\Type\CompositeTypeInterface; +use Symfony\Component\TypeInfo\Type\WrappingTypeInterface; /** * UriVariables converter that chains uri variables transformers. @@ -54,7 +56,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 +77,27 @@ 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 (TypeHelper::traverse($type) as $t) { + if (!$t instanceof CompositeTypeInterface && !$t instanceof WrappingTypeInterface) { + $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/Util/TypeHelper.php b/src/Metadata/Util/TypeHelper.php new file mode 100644 index 00000000000..d408be9f539 --- /dev/null +++ b/src/Metadata/Util/TypeHelper.php @@ -0,0 +1,98 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace ApiPlatform\Metadata\Util; + +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 + * + * @author Mathias Arlaud + */ +final class TypeHelper +{ + private function __construct() + { + } + + /** + * https://github.com/symfony/symfony/pull/59845. + * + * @return iterable + */ + public static function traverse(Type $type, bool $traverseComposite = true, bool $traverseWrapped = true): iterable + { + yield $type; + + if ($type instanceof CompositeTypeInterface && $traverseComposite) { + foreach ($type->getTypes() as $t) { + yield $t; + } + + // prevent yielding twice when having a type that is both composite and wrapped + return; + } + + if ($type instanceof WrappingTypeInterface && $traverseWrapped) { + yield $type->getWrappedType(); + } + } + + /** + * https://github.com/symfony/symfony/pull/59844. + * + * @param callable(Type): bool $specification + */ + public static function isSatisfiedBy(Type $type, callable $specification): bool + { + if ($type instanceof WrappingTypeInterface && $type->wrappedTypeIsSatisfiedBy($specification)) { + return true; + } + + if ($type instanceof CompositeTypeInterface && $type->composedTypesAreSatisfiedBy($specification)) { + return true; + } + + return $specification($type); + } + + public static function getCollectionValueType(Type $type): ?Type + { + foreach (self::traverse($type) as $t) { + if ($t instanceof CollectionType) { + return $t->getCollectionValueType(); + } + } + + return null; + } + + /** + * @return class-string|null + */ + public static function getClassName(Type $type): ?string + { + foreach (self::traverse($type) as $t) { + if ($t instanceof ObjectType) { + return $t->getClassName(); + } + } + + return null; + } +} diff --git a/src/Metadata/composer.json b/src/Metadata/composer.json index 1ca33e8104e..a5103cbf343 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.1", + "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": "^4.1", diff --git a/src/Serializer/AbstractItemNormalizer.php b/src/Serializer/AbstractItemNormalizer.php index 5a8a2989b47..7c12bf211d6 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 32bf0a9133d..8911f452690 100644 --- a/src/Serializer/composer.json +++ b/src/Serializer/composer.json @@ -26,7 +26,7 @@ "api-platform/metadata": "^4.1", "api-platform/state": "^4.1", "symfony/property-access": "^6.4 || ^7.0", - "symfony/property-info": "^6.4 || ^7.1", + "symfony/property-info": "^7.1", "symfony/serializer": "^6.4 || ^7.0", "symfony/validator": "^6.4 || ^7.0" }, diff --git a/src/Symfony/composer.json b/src/Symfony/composer.json index ce1446fd265..a7b6c8f32f8 100644 --- a/src/Symfony/composer.json +++ b/src/Symfony/composer.json @@ -39,7 +39,7 @@ "api-platform/state": "^4.1", "api-platform/validator": "^4.1", "api-platform/openapi": "^4.1", - "symfony/property-info": "^6.4 || ^7.1", + "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'], ]); From a05ec11e123cede548b3e490f89b4ee70247c9bf Mon Sep 17 00:00:00 2001 From: soyuka Date: Mon, 14 Apr 2025 15:26:08 +0200 Subject: [PATCH 2/4] use type-info 7.3, rename to nativeType, implement metadata leftovers uncomment deprecations --- composer.json | 14 +- docs/composer.json | 9 +- src/Doctrine/Common/composer.json | 11 +- .../Odm/PropertyInfo/DoctrineExtractor.php | 6 +- src/Doctrine/Odm/composer.json | 12 +- src/Doctrine/Orm/composer.json | 11 +- src/Documentation/composer.json | 8 +- src/Elasticsearch/composer.json | 13 +- src/GraphQl/composer.json | 11 +- src/Hal/composer.json | 9 +- src/HttpCache/composer.json | 9 +- src/Hydra/composer.json | 11 +- src/JsonApi/composer.json | 11 +- src/JsonLd/composer.json | 11 +- .../Factory/SchemaPropertyMetadataFactory.php | 303 ++++++++++++++++-- src/JsonSchema/composer.json | 11 +- src/Laravel/composer.json | 11 +- src/Metadata/ApiProperty.php | 51 ++- .../Extractor/XmlPropertyExtractor.php | 2 +- .../Extractor/YamlPropertyExtractor.php | 2 +- src/Metadata/Extractor/schema/properties.xsd | 2 +- src/Metadata/IdentifiersExtractor.php | 27 +- .../AttributePropertyMetadataFactory.php | 3 +- .../ExtractorPropertyMetadataFactory.php | 11 +- .../PropertyInfoPropertyMetadataFactory.php | 20 +- .../SerializerPropertyMetadataFactory.php | 72 ++++- src/Metadata/Resource/Factory/LinkFactory.php | 6 +- .../Extractor/Adapter/XmlPropertyAdapter.php | 2 +- .../Tests/Extractor/Adapter/properties.xml | 2 +- .../Tests/Extractor/Adapter/properties.yaml | 2 +- .../PropertyMetadataCompatibilityTest.php | 4 +- .../SerializerPropertyMetadataFactoryTest.php | 8 +- .../Resource/Factory/LinkFactoryTest.php | 6 +- ...kResourceMetadataCollectionFactoryTest.php | 8 +- src/Metadata/UriVariablesConverter.php | 5 +- src/Metadata/Util/TypeHelper.php | 47 +-- src/Metadata/composer.json | 12 +- src/OpenApi/composer.json | 11 +- src/RamseyUuid/composer.json | 11 +- src/Serializer/composer.json | 13 +- src/State/composer.json | 11 +- src/Symfony/composer.json | 13 +- src/Validator/composer.json | 11 +- 43 files changed, 650 insertions(+), 183 deletions(-) diff --git a/composer.json b/composer.json index 0e91de66f1f..adcb78f6059 100644 --- a/composer.json +++ b/composer.json @@ -111,10 +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": "^7.1", + "symfony/property-info": "^6.4 || ^7.1", "symfony/serializer": "^6.4 || ^7.0", "symfony/translation-contracts": "^3.3", - "symfony/type-info": "^7.2", + "symfony/type-info": "^7.3-dev", "symfony/web-link": "^6.4 || ^7.0", "willdurand/negotiation": "^3.1" }, @@ -165,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": "^7.1", + "symfony/doctrine-bridge": "^6.4.2 || ^7.1", "symfony/dom-crawler": "^6.4 || ^7.0", "symfony/error-handler": "^6.4 || ^7.0", "symfony/event-dispatcher": "^6.4 || ^7.0", @@ -209,5 +209,11 @@ "symfony/web-profiler-bundle": "To use the data collector.", "webonyx/graphql-php": "To support GraphQL." }, - "type": "library" + "type": "library", + "repositories": [ + { + "type": "vcs", + "url": "https://github.com/symfony/type-info" + } + ] } diff --git a/docs/composer.json b/docs/composer.json index 58f45c2fc20..bbc61158231 100644 --- a/docs/composer.json +++ b/docs/composer.json @@ -25,6 +25,7 @@ "symfony/property-info": "^7.0", "symfony/runtime": "^7.0", "symfony/security-bundle": "^7.0", + "symfony/type-info": "^7.3-dev", "symfony/serializer": "^7.0", "symfony/validator": "^7.0", "symfony/yaml": "^7.0", @@ -50,5 +51,11 @@ "name": "api-platform/api-platform", "url": "https://github.com/api-platform/api-platform" } - } + }, + "repositories": [ + { + "type": "vcs", + "url": "https://github.com/symfony/type-info" + } + ] } diff --git a/src/Doctrine/Common/composer.json b/src/Doctrine/Common/composer.json index b41f218dda3..16763ea378d 100644 --- a/src/Doctrine/Common/composer.json +++ b/src/Doctrine/Common/composer.json @@ -34,7 +34,8 @@ "doctrine/mongodb-odm": "^2.10", "doctrine/orm": "^2.17 || ^3.0", "phpspec/prophecy-phpunit": "^2.2", - "phpunit/phpunit": "^11.2" + "phpunit/phpunit": "^11.2", + "symfony/type-info": "^7.3-dev" }, "conflict": { "doctrine/persistence": "<1.3" @@ -73,5 +74,11 @@ }, "scripts": { "test": "./vendor/bin/phpunit" - } + }, + "repositories": [ + { + "type": "vcs", + "url": "https://github.com/symfony/type-info" + } + ] } diff --git a/src/Doctrine/Odm/PropertyInfo/DoctrineExtractor.php b/src/Doctrine/Odm/PropertyInfo/DoctrineExtractor.php index a8e6d8e090e..89718c177b3 100644 --- a/src/Doctrine/Odm/PropertyInfo/DoctrineExtractor.php +++ b/src/Doctrine/Odm/PropertyInfo/DoctrineExtractor.php @@ -115,7 +115,7 @@ public function getType(string $class, string $property, array $context = []): ? */ public function getTypes($class, $property, array $context = []): ?array { - // trigger_deprecation('api-platform/core', '4.2', 'The "%s()" method is deprecated, use "%s::getType()" instead.', __METHOD__, self::class); + trigger_deprecation('api-platform/core', '4.2', 'The "%s()" method is deprecated, use "%s::getType()" instead.', __METHOD__, self::class); if (null === $metadata = $this->getMetadata($class)) { return null; @@ -173,7 +173,7 @@ public function getTypes($class, $property, array $context = []): ?array } } - $builtinType = $this->getPhpTypeLegacy($typeOfField); + $builtinType = $this->getNativeTypeLegacy($typeOfField); return $builtinType ? [new LegacyType($builtinType, $nullable)] : null; } @@ -230,7 +230,7 @@ private function getTypeIdentifier(string $doctrineType): ?TypeIdentifier }; } - private function getPhpTypeLegacy(string $doctrineType): ?string + private function getNativeTypeLegacy(string $doctrineType): ?string { return match ($doctrineType) { MongoDbType::INTEGER, MongoDbType::INT, MongoDbType::INTID, MongoDbType::KEY => LegacyType::BUILTIN_TYPE_INT, diff --git a/src/Doctrine/Odm/composer.json b/src/Doctrine/Odm/composer.json index f32019e2cf6..eaddb75b79c 100644 --- a/src/Doctrine/Odm/composer.json +++ b/src/Doctrine/Odm/composer.json @@ -29,8 +29,8 @@ "api-platform/metadata": "^4.1", "api-platform/state": "^4.1", "doctrine/mongodb-odm": "^2.10", - "symfony/property-info": "^7.1", - "symfony/type-info": "^7.2" + "symfony/property-info": "^6.4 || ^7.1", + "symfony/type-info": "^7.3-dev" }, "require-dev": { "doctrine/doctrine-bundle": "^2.11", @@ -74,5 +74,11 @@ }, "scripts": { "test": "./vendor/bin/phpunit" - } + }, + "repositories": [ + { + "type": "vcs", + "url": "https://github.com/symfony/type-info" + } + ] } diff --git a/src/Doctrine/Orm/composer.json b/src/Doctrine/Orm/composer.json index 23913e461cc..05d7b99ec24 100644 --- a/src/Doctrine/Orm/composer.json +++ b/src/Doctrine/Orm/composer.json @@ -28,7 +28,7 @@ "api-platform/metadata": "^4.1", "api-platform/state": "^4.1", "doctrine/orm": "^2.17 || ^3.0", - "symfony/property-info": "^7.1" + "symfony/property-info": "^6.4 || ^7.1" }, "require-dev": { "doctrine/doctrine-bundle": "^2.11", @@ -40,6 +40,7 @@ "symfony/framework-bundle": "^6.4 || ^7.0", "symfony/property-access": "^6.4 || ^7.0", "symfony/serializer": "^6.4 || ^7.0", + "symfony/type-info": "^7.3-dev", "symfony/uid": "^6.4 || ^7.0", "symfony/validator": "^6.4 || ^7.0", "symfony/yaml": "^6.4 || ^7.0" @@ -73,5 +74,11 @@ }, "scripts": { "test": "./vendor/bin/phpunit" - } + }, + "repositories": [ + { + "type": "vcs", + "url": "https://github.com/symfony/type-info" + } + ] } diff --git a/src/Documentation/composer.json b/src/Documentation/composer.json index 9c812aadb87..1069c1be08b 100644 --- a/src/Documentation/composer.json +++ b/src/Documentation/composer.json @@ -38,5 +38,11 @@ }, "require-dev": { "phpunit/phpunit": "^11.2" - } + }, + "repositories": [ + { + "type": "vcs", + "url": "https://github.com/symfony/type-info" + } + ] } diff --git a/src/Elasticsearch/composer.json b/src/Elasticsearch/composer.json index 59952192ec7..332889c837c 100644 --- a/src/Elasticsearch/composer.json +++ b/src/Elasticsearch/composer.json @@ -31,13 +31,14 @@ "symfony/cache": "^6.4 || ^7.0", "symfony/console": "^6.4 || ^7.0", "symfony/property-access": "^6.4 || ^7.0", - "symfony/property-info": "^7.1", + "symfony/property-info": "^6.4 || ^7.1", "symfony/serializer": "^6.4 || ^7.0", "symfony/uid": "^6.4 || ^7.0" }, "require-dev": { "phpspec/prophecy-phpunit": "^2.2", - "phpunit/phpunit": "^11.2" + "phpunit/phpunit": "^11.2", + "symfony/type-info": "^7.3-dev" }, "autoload": { "psr-4": { @@ -73,5 +74,11 @@ }, "scripts": { "test": "./vendor/bin/phpunit" - } + }, + "repositories": [ + { + "type": "vcs", + "url": "https://github.com/symfony/type-info" + } + ] } diff --git a/src/GraphQl/composer.json b/src/GraphQl/composer.json index 17630f5f2fd..5b84f2dd190 100644 --- a/src/GraphQl/composer.json +++ b/src/GraphQl/composer.json @@ -24,7 +24,7 @@ "api-platform/metadata": "^4.1", "api-platform/state": "^4.1", "api-platform/serializer": "^4.1", - "symfony/property-info": "^7.1", + "symfony/property-info": "^6.4 || ^7.1", "symfony/serializer": "^6.4 || ^7.0", "webonyx/graphql-php": "^15.0", "willdurand/negotiation": "^3.1" @@ -35,6 +35,7 @@ "twig/twig": "^1.42.3 || ^2.12 || ^3.0", "symfony/mercure-bundle": "*", "symfony/routing": "^6.4 || ^7.0", + "symfony/type-info": "^7.3-dev", "phpunit/phpunit": "^11.2" }, "autoload": { @@ -76,5 +77,11 @@ }, "scripts": { "test": "./vendor/bin/phpunit" - } + }, + "repositories": [ + { + "type": "vcs", + "url": "https://github.com/symfony/type-info" + } + ] } diff --git a/src/Hal/composer.json b/src/Hal/composer.json index 64ee4ec82fc..9018d292317 100644 --- a/src/Hal/composer.json +++ b/src/Hal/composer.json @@ -61,6 +61,13 @@ "test": "./vendor/bin/phpunit" }, "require-dev": { + "symfony/type-info": "^7.3-dev", "phpunit/phpunit": "^11.2" - } + }, + "repositories": [ + { + "type": "vcs", + "url": "https://github.com/symfony/type-info" + } + ] } diff --git a/src/HttpCache/composer.json b/src/HttpCache/composer.json index a49dc0c932e..77dbf68a20f 100644 --- a/src/HttpCache/composer.json +++ b/src/HttpCache/composer.json @@ -32,6 +32,7 @@ "symfony/dependency-injection": "^6.4 || ^7.0", "phpspec/prophecy-phpunit": "^2.2", "symfony/http-client": "^6.4 || ^7.0", + "symfony/type-info": "^7.3-dev", "phpunit/phpunit": "^11.2" }, "autoload": { @@ -67,5 +68,11 @@ }, "scripts": { "test": "./vendor/bin/phpunit" - } + }, + "repositories": [ + { + "type": "vcs", + "url": "https://github.com/symfony/type-info" + } + ] } diff --git a/src/Hydra/composer.json b/src/Hydra/composer.json index 08a8493364e..f4b05fc22bd 100644 --- a/src/Hydra/composer.json +++ b/src/Hydra/composer.json @@ -39,7 +39,8 @@ "api-platform/doctrine-common": "^4.1", "phpspec/prophecy": "^1.19", "phpspec/prophecy-phpunit": "^2.2", - "phpunit/phpunit": "^11.2" + "phpunit/phpunit": "^11.2", + "symfony/type-info": "^7.3-dev" }, "autoload": { "psr-4": { @@ -74,5 +75,11 @@ }, "scripts": { "test": "./vendor/bin/phpunit" - } + }, + "repositories": [ + { + "type": "vcs", + "url": "https://github.com/symfony/type-info" + } + ] } diff --git a/src/JsonApi/composer.json b/src/JsonApi/composer.json index 371ffd82423..086e396b249 100644 --- a/src/JsonApi/composer.json +++ b/src/JsonApi/composer.json @@ -33,7 +33,8 @@ "require-dev": { "phpspec/prophecy": "^1.19", "phpspec/prophecy-phpunit": "^2.2", - "phpunit/phpunit": "^11.2" + "phpunit/phpunit": "^11.2", + "symfony/type-info": "^7.3-dev" }, "autoload": { "psr-4": { @@ -68,5 +69,11 @@ }, "scripts": { "test": "./vendor/bin/phpunit" - } + }, + "repositories": [ + { + "type": "vcs", + "url": "https://github.com/symfony/type-info" + } + ] } diff --git a/src/JsonLd/composer.json b/src/JsonLd/composer.json index c2e71f0e181..7fb1f3cd46e 100644 --- a/src/JsonLd/composer.json +++ b/src/JsonLd/composer.json @@ -66,6 +66,13 @@ "test": "./vendor/bin/phpunit" }, "require-dev": { - "phpunit/phpunit": "^11.2" - } + "phpunit/phpunit": "^11.2", + "symfony/type-info": "^7.3-dev" + }, + "repositories": [ + { + "type": "vcs", + "url": "https://github.com/symfony/type-info" + } + ] } diff --git a/src/JsonSchema/Metadata/Property/Factory/SchemaPropertyMetadataFactory.php b/src/JsonSchema/Metadata/Property/Factory/SchemaPropertyMetadataFactory.php index 5ed3bb23e6c..cc094c0f672 100644 --- a/src/JsonSchema/Metadata/Property/Factory/SchemaPropertyMetadataFactory.php +++ b/src/JsonSchema/Metadata/Property/Factory/SchemaPropertyMetadataFactory.php @@ -21,7 +21,17 @@ use ApiPlatform\Metadata\Util\ResourceClassInfoTrait; use Doctrine\Common\Collections\ArrayCollection; use Ramsey\Uuid\UuidInterface; -use Symfony\Component\PropertyInfo\Type; +use Symfony\Component\PropertyInfo\PropertyInfoExtractor; +use Symfony\Component\PropertyInfo\Type as LegacyType; +use Symfony\Component\TypeInfo\Type; +use Symfony\Component\TypeInfo\Type\BuiltinType; +use Symfony\Component\TypeInfo\Type\CollectionType; +use Symfony\Component\TypeInfo\Type\CompositeTypeInterface; +use Symfony\Component\TypeInfo\Type\IntersectionType; +use Symfony\Component\TypeInfo\Type\ObjectType; +use Symfony\Component\TypeInfo\Type\UnionType; +use Symfony\Component\TypeInfo\Type\WrappingTypeInterface; +use Symfony\Component\TypeInfo\TypeIdentifier; use Symfony\Component\Uid\Ulid; use Symfony\Component\Uid\Uuid; @@ -86,6 +96,255 @@ public function create(string $resourceClass, string $property, array $options = $propertySchema['externalDocs'] = ['url' => $iri]; } + if (!method_exists(PropertyInfoExtractor::class, 'getType')) { + return $propertyMetadata->withSchema($this->getLegacyTypeSchema($propertyMetadata, $propertySchema, $resourceClass, $property, $link)); + } + + return $propertyMetadata->withSchema($this->getTypeSchema($propertyMetadata, $propertySchema, $link)); + } + + private function getTypeSchema(ApiProperty $propertyMetadata, array $propertySchema, ?bool $link): array + { + $type = $propertyMetadata->getNativeType(); + + $typeIsResourceClass = function (Type $type) use (&$typeIsResourceClass): 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($type->getClassName()), + }; + }; + + if (!\array_key_exists('default', $propertySchema) && !empty($default = $propertyMetadata->getDefault()) && !$type?->isSatisfiedBy($typeIsResourceClass)) { + if ($default instanceof \BackedEnum) { + $default = $default->value; + } + $propertySchema['default'] = $default; + } + + if (!\array_key_exists('example', $propertySchema) && !empty($example = $propertyMetadata->getExample())) { + $propertySchema['example'] = $example; + } + + if (!\array_key_exists('example', $propertySchema) && \array_key_exists('default', $propertySchema)) { + $propertySchema['example'] = $propertySchema['default']; + } + + // never override the following keys if at least one is already set or if there's a custom openapi context + if ( + null === $type + || ($propertySchema['type'] ?? $propertySchema['$ref'] ?? $propertySchema['anyOf'] ?? $propertySchema['allOf'] ?? $propertySchema['oneOf'] ?? false) + || \array_key_exists('type', $propertyMetadata->getOpenapiContext() ?? []) + ) { + return $propertySchema; + } + + if ($type instanceof CollectionType && null !== $propertyMetadata->getUriTemplate()) { + $type = $type->getCollectionValueType(); + } + + return $propertySchema + $this->getJsonSchemaFromType($type, $link); + } + + /** + * Applies nullability rules to a generated JSON schema based on the original type's nullability. + * + * @param array $schema the base JSON schema generated for the non-null type + * @param bool $isNullable whether the original type allows null + * + * @return array the JSON schema with nullability applied + */ + private function applyNullability(array $schema, bool $isNullable): array + { + if (!$isNullable) { + return $schema; + } + + if (isset($schema['type']) && 'null' === $schema['type'] && 1 === \count($schema)) { + return $schema; + } + + if (isset($schema['anyOf']) && \is_array($schema['anyOf'])) { + $hasNull = false; + foreach ($schema['anyOf'] as $anyOfSchema) { + if (isset($anyOfSchema['type']) && 'null' === $anyOfSchema['type']) { + $hasNull = true; + break; + } + } + if (!$hasNull) { + $schema['anyOf'][] = ['type' => 'null']; + } + + return $schema; + } + + if (isset($schema['type'])) { + $currentType = $schema['type']; + $schema['type'] = \is_array($currentType) ? array_merge($currentType, ['null']) : [$currentType, 'null']; + + return $schema; + } + + return ['anyOf' => [$schema, ['type' => 'null']]]; + } + + /** + * Converts a TypeInfo Type into a JSON Schema definition array. + * + * @return array + */ + private function getJsonSchemaFromType(Type $type, ?bool $readableLink = null): array + { + $isNullable = $type->isNullable(); + + while ($type instanceof WrappingTypeInterface) { + $type = $type->getWrappedType(); + } + + if ($type instanceof UnionType) { + $subTypes = array_filter($type->getTypes(), fn ($t) => !($t instanceof BuiltinType && $t->isIdentifiedBy(TypeIdentifier::NULL))); + $schemas = array_map(fn ($t) => $this->getJsonSchemaFromType($t, $readableLink), $subTypes); + + if (0 === \count($schemas)) { + $schema = []; + } elseif (1 === \count($schemas)) { + $schema = $schemas[0]; + } else { + $schema = ['anyOf' => $schemas]; + } + + return $this->applyNullability($schema, $isNullable); + } + + if ($type instanceof IntersectionType) { + $schemas = []; + foreach ($type->getTypes() as $t) { + while ($t instanceof WrappingTypeInterface) { + $t = $t->getWrappedType(); + } + + $subSchema = $this->getJsonSchemaFromType($t, $readableLink); + if (!empty($subSchema)) { + $schemas[] = $subSchema; + } + } + + return $this->applyNullability(['allOf' => $schemas], $isNullable); + } + + if ($type instanceof CollectionType) { + $keyType = $type->getCollectionKeyType(); + $valueType = $type->getCollectionValueType(); + $schema = []; + + // Associative array (string keys) + if ($keyType->isSatisfiedBy(fn (Type $t) => $t instanceof BuiltinType && $t->isIdentifiedBy(TypeIdentifier::STRING))) { + $schema = [ + 'type' => 'object', + 'additionalProperties' => $this->getJsonSchemaFromType($valueType, $readableLink), + ]; + } else { // List (int keys) + $schema = [ + 'type' => 'array', + 'items' => $this->getJsonSchemaFromType($valueType, $readableLink), + ]; + } + + return $this->applyNullability($schema, $isNullable); + } + + if ($type instanceof ObjectType) { + $schema = $this->getClassSchemaDefinition($type->getClassName(), $readableLink); + + return $this->applyNullability($schema, $isNullable); + } + + if ($type instanceof BuiltinType) { + if ($type->isIdentifiedBy(TypeIdentifier::NULL)) { + return ['type' => 'null']; + } + + $schema = match ($type->getTypeIdentifier()) { + TypeIdentifier::INT => ['type' => 'integer'], + TypeIdentifier::FLOAT => ['type' => 'number'], + TypeIdentifier::BOOL => ['type' => 'boolean'], + TypeIdentifier::TRUE => ['type' => 'boolean', 'const' => true], + TypeIdentifier::FALSE => ['type' => 'boolean', 'const' => false], + TypeIdentifier::STRING => ['type' => 'string'], + TypeIdentifier::ARRAY => ['type' => 'array', 'items' => []], + TypeIdentifier::ITERABLE => ['type' => 'array', 'items' => []], + TypeIdentifier::OBJECT => ['type' => 'object'], + TypeIdentifier::RESOURCE => ['type' => 'string'], + TypeIdentifier::CALLABLE => ['type' => 'string'], + default => ['type' => 'null'], + }; + + return $this->applyNullability($schema, $isNullable); + } + + return $this->applyNullability(['type' => Schema::UNKNOWN_TYPE], $isNullable); + } + + /** + * Gets the JSON Schema definition for a class. + */ + private function getClassSchemaDefinition(?string $className, ?bool $readableLink): array + { + if (null === $className) { + return ['type' => 'string']; + } + + if (is_a($className, \DateTimeInterface::class, true)) { + return ['type' => 'string', 'format' => 'date-time']; + } + + if (is_a($className, \DateInterval::class, true)) { + return ['type' => 'string', 'format' => 'duration']; + } + + if (is_a($className, UuidInterface::class, true) || is_a($className, Uuid::class, true)) { + return ['type' => 'string', 'format' => 'uuid']; + } + + if (is_a($className, Ulid::class, true)) { + return ['type' => 'string', 'format' => 'ulid']; + } + + if (is_a($className, \SplFileInfo::class, true)) { + return ['type' => 'string', 'format' => 'binary']; + } + + if (is_a($className, \BackedEnum::class, true)) { + $enumCases = array_map(static fn (\BackedEnum $enum): string|int => $enum->value, $className::cases()); + $type = \is_string($enumCases[0] ?? '') ? 'string' : 'integer'; + + return ['type' => $type, 'enum' => $enumCases]; + } + + $isResource = $this->isResourceClass($className); + + // If it's a resource and links are not readable, represent as IRI string. + if ($isResource && true !== $readableLink) { + return [ + 'type' => 'string', + 'format' => 'iri-reference', + 'example' => 'https://example.com/', // Add a generic example + ]; + } + + // If it's a known resource represent it as UNKNOWN_TYPE this gets resolved at runtime by the SchemaFactory + if ($isResource) { + return ['type' => Schema::UNKNOWN_TYPE]; + } + + // For non-resource objects that aren't handled specifically, default to object. + return ['type' => 'object']; + } + + private function getLegacyTypeSchema(ApiProperty $propertyMetadata, array $propertySchema, string $resourceClass, string $property, bool $link): array + { $types = $propertyMetadata->getBuiltinTypes() ?? []; if (!\array_key_exists('default', $propertySchema) && !empty($default = $propertyMetadata->getDefault()) && (!\count($types) || null === ($className = $types[0]->getClassName()) || !$this->isResourceClass($className))) { @@ -104,18 +363,19 @@ public function create(string $resourceClass, string $property, array $options = } // never override the following keys if at least one is already set or if there's a custom openapi context - if ([] === $types + if ( + [] === $types || ($propertySchema['type'] ?? $propertySchema['$ref'] ?? $propertySchema['anyOf'] ?? $propertySchema['allOf'] ?? $propertySchema['oneOf'] ?? false) || \array_key_exists('type', $propertyMetadata->getOpenapiContext() ?? []) ) { - return $propertyMetadata->withSchema($propertySchema); + return $propertySchema; } $valueSchema = []; foreach ($types as $type) { // Temp fix for https://github.com/symfony/symfony/pull/52699 if (ArrayCollection::class === $type->getClassName()) { - $type = new Type($type->getBuiltinType(), $type->isNullable(), $type->getClassName(), true, $type->getCollectionKeyTypes(), $type->getCollectionValueTypes()); + $type = new LegacyType($type->getBuiltinType(), $type->isNullable(), $type->getClassName(), true, $type->getCollectionKeyTypes(), $type->getCollectionValueTypes()); } if ($isCollection = $type->isCollection()) { @@ -139,15 +399,14 @@ public function create(string $resourceClass, string $property, array $options = $isCollection = false; } - $propertyType = $this->getType(new Type($builtinType, $type->isNullable(), $className, $isCollection, $keyType, $valueType), $link); + $propertyType = $this->getLegacyType(new LegacyType($builtinType, $type->isNullable(), $className, $isCollection, $keyType, $valueType), $link); if (!\in_array($propertyType, $valueSchema, true)) { $valueSchema[] = $propertyType; } } - // only one builtInType detected (should be "type" or "$ref") if (1 === \count($valueSchema)) { - return $propertyMetadata->withSchema($propertySchema + $valueSchema[0]); + return $propertySchema + $valueSchema[0]; } // multiple builtInTypes detected: determine oneOf/allOf if union vs intersect types @@ -160,38 +419,38 @@ public function create(string $resourceClass, string $property, array $options = $composition = 'anyOf'; } - return $propertyMetadata->withSchema($propertySchema + [$composition => $valueSchema]); + return $propertySchema + [$composition => $valueSchema]; } - private function getType(Type $type, ?bool $readableLink = null): array + private function getLegacyType(LegacyType $type, ?bool $readableLink = null): array { if (!$type->isCollection()) { - return $this->addNullabilityToTypeDefinition($this->typeToArray($type, $readableLink), $type); + return $this->addNullabilityToTypeDefinition($this->legacyTypeToArray($type, $readableLink), $type); } $keyType = $type->getCollectionKeyTypes()[0] ?? null; - $subType = ($type->getCollectionValueTypes()[0] ?? null) ?? new Type($type->getBuiltinType(), false, $type->getClassName(), false); + $subType = ($type->getCollectionValueTypes()[0] ?? null) ?? new LegacyType($type->getBuiltinType(), false, $type->getClassName(), false); - if (null !== $keyType && Type::BUILTIN_TYPE_STRING === $keyType->getBuiltinType()) { + if (null !== $keyType && LegacyType::BUILTIN_TYPE_STRING === $keyType->getBuiltinType()) { return $this->addNullabilityToTypeDefinition([ 'type' => 'object', - 'additionalProperties' => $this->getType($subType, $readableLink), + 'additionalProperties' => $this->getLegacyType($subType, $readableLink), ], $type); } return $this->addNullabilityToTypeDefinition([ 'type' => 'array', - 'items' => $this->getType($subType, $readableLink), + 'items' => $this->getLegacyType($subType, $readableLink), ], $type); } - private function typeToArray(Type $type, ?bool $readableLink = null): array + private function legacyTypeToArray(LegacyType $type, ?bool $readableLink = null): array { return match ($type->getBuiltinType()) { - Type::BUILTIN_TYPE_INT => ['type' => 'integer'], - Type::BUILTIN_TYPE_FLOAT => ['type' => 'number'], - Type::BUILTIN_TYPE_BOOL => ['type' => 'boolean'], - Type::BUILTIN_TYPE_OBJECT => $this->getClassType($type->getClassName(), $type->isNullable(), $readableLink), + LegacyType::BUILTIN_TYPE_INT => ['type' => 'integer'], + LegacyType::BUILTIN_TYPE_FLOAT => ['type' => 'number'], + LegacyType::BUILTIN_TYPE_BOOL => ['type' => 'boolean'], + LegacyType::BUILTIN_TYPE_OBJECT => $this->getLegacyClassType($type->getClassName(), $type->isNullable(), $readableLink), default => ['type' => 'string'], }; } @@ -203,7 +462,7 @@ private function typeToArray(Type $type, ?bool $readableLink = null): array * * @throws PropertyNotFoundException */ - private function getClassType(?string $className, bool $nullable, ?bool $readableLink): array + private function getLegacyClassType(?string $className, bool $nullable, ?bool $readableLink): array { if (null === $className) { return ['type' => 'string']; @@ -276,14 +535,14 @@ private function getClassType(?string $className, bool $nullable, ?bool $readabl * * @return array */ - private function addNullabilityToTypeDefinition(array $jsonSchema, Type $type): array + private function addNullabilityToTypeDefinition(array $jsonSchema, LegacyType $type): array { if (!$type->isNullable()) { return $jsonSchema; } if (\array_key_exists('$ref', $jsonSchema)) { - return ['anyOf' => [$jsonSchema, 'type' => 'null']]; + return ['anyOf' => [$jsonSchema, ['type' => 'null']]]; } return [...$jsonSchema, ...[ diff --git a/src/JsonSchema/composer.json b/src/JsonSchema/composer.json index e8c269527bc..d8f67fbde96 100644 --- a/src/JsonSchema/composer.json +++ b/src/JsonSchema/composer.json @@ -27,8 +27,9 @@ "php": ">=8.2", "api-platform/metadata": "^4.1", "symfony/console": "^6.4 || ^7.0", - "symfony/property-info": "^7.1", + "symfony/property-info": "^6.4 || ^7.1", "symfony/serializer": "^6.4 || ^7.0", + "symfony/type-info": "^7.3-dev", "symfony/uid": "^6.4 || ^7.0" }, "require-dev": { @@ -68,5 +69,11 @@ }, "scripts": { "test": "./vendor/bin/phpunit" - } + }, + "repositories": [ + { + "type": "vcs", + "url": "https://github.com/symfony/type-info" + } + ] } diff --git a/src/Laravel/composer.json b/src/Laravel/composer.json index b042466148e..5769c797d54 100644 --- a/src/Laravel/composer.json +++ b/src/Laravel/composer.json @@ -58,7 +58,8 @@ "orchestra/testbench": "^9.1", "phpunit/phpunit": "^11.2", "api-platform/graphql": "^4.1", - "laravel/sanctum": "^4.0" + "laravel/sanctum": "^4.0", + "symfony/type-info": "^7.3-dev" }, "autoload": { "psr-4": { @@ -119,5 +120,11 @@ "lint": [ "@php vendor/bin/phpstan analyse --verbose --ansi" ] - } + }, + "repositories": [ + { + "type": "vcs", + "url": "https://github.com/symfony/type-info" + } + ] } diff --git a/src/Metadata/ApiProperty.php b/src/Metadata/ApiProperty.php index 1fd04f25879..a2c90b872b5 100644 --- a/src/Metadata/ApiProperty.php +++ b/src/Metadata/ApiProperty.php @@ -33,15 +33,7 @@ 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; + private ?Type $nativeType; /** * @param bool|null $readableLink https://api-platform.com/docs/core/serialization/#force-iri-with-relations-of-the-same-type-parentchilds-relations @@ -62,7 +54,7 @@ final class ApiProperty * @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 + * @param Type $nativeType The internal PHP type */ public function __construct( private ?string $description = null, @@ -219,7 +211,7 @@ public function __construct( /* * The related php types. * - * deprecated since 4.2, use "phpType" instead. + * deprecated since 4.2, use "nativeType" instead. */ private ?array $builtinTypes = null, private ?array $schema = null, @@ -234,23 +226,18 @@ public function __construct( * Whether to document this property as a hydra:supportedProperty. */ private ?bool $hydra = null, + ?Type $nativeType = 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; + $this->nativeType = $nativeType; if ($this->builtinTypes) { - // trigger_deprecation('api_platform/metadata', '4.2', '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) ?? []; + trigger_deprecation('api_platform/metadata', '4.2', 'The "builtinTypes" argument of "%s()" is deprecated, use "nativeType" instead.'); + $this->nativeType ??= PropertyInfoToTypeInfoHelper::convertLegacyTypesToType($this->builtinTypes); + } elseif ($this->nativeType) { + $this->builtinTypes = PropertyInfoToTypeInfoHelper::convertTypeToLegacyTypes($this->nativeType) ?? []; } } @@ -518,43 +505,43 @@ public function withTypes(array|string $types = []): static } /** - * deprecated since 4.2, use "getPhpType" instead. + * deprecated since 4.2, use "getNativeType" instead. * * @return LegacyType[] */ public function getBuiltinTypes(): ?array { - // trigger_deprecation('api-platform/metadata', '4.2', 'The "%s()" method is deprecated, use "%s::getPhpType()" instead.', __METHOD__, self::class); + trigger_deprecation('api-platform/metadata', '4.2', 'The "%s()" method is deprecated, use "%s::getNativeType()" instead.', __METHOD__, self::class); return $this->builtinTypes; } /** - * deprecated since 4.2, use "withPhpType" instead. + * deprecated since 4.2, use "withNativeType" instead. * * @param LegacyType[] $builtinTypes */ public function withBuiltinTypes(array $builtinTypes = []): static { - // trigger_deprecation('api-platform/metadata', '4.2', 'The "%s()" method is deprecated, use "%s::withPhpType()" instead.', __METHOD__, self::class); + trigger_deprecation('api-platform/metadata', '4.2', 'The "%s()" method is deprecated, use "%s::withNativeType()" instead.', __METHOD__, self::class); $self = clone $this; $self->builtinTypes = $builtinTypes; - $self->phpType = PropertyInfoToTypeInfoHelper::convertLegacyTypesToType($builtinTypes); + $self->nativeType = PropertyInfoToTypeInfoHelper::convertLegacyTypesToType($builtinTypes); return $self; } - public function getPhpType(): ?Type + public function getNativeType(): ?Type { - return $this->phpType; + return $this->nativeType; } - public function withPhpType(?Type $phpType): self + public function withNativeType(?Type $nativeType): self { $self = clone $this; - $self->phpType = $phpType; - $self->builtinTypes = PropertyInfoToTypeInfoHelper::convertTypeToLegacyTypes($phpType) ?? []; + $self->nativeType = $nativeType; + $self->builtinTypes = PropertyInfoToTypeInfoHelper::convertTypeToLegacyTypes($nativeType) ?? []; return $self; } diff --git a/src/Metadata/Extractor/XmlPropertyExtractor.php b/src/Metadata/Extractor/XmlPropertyExtractor.php index dc5b7ba47b9..8544a8aaf89 100644 --- a/src/Metadata/Extractor/XmlPropertyExtractor.php +++ b/src/Metadata/Extractor/XmlPropertyExtractor.php @@ -74,7 +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'), + 'nativeType' => $this->phpize($property, 'nativeType', 'string'), ]; } } diff --git a/src/Metadata/Extractor/YamlPropertyExtractor.php b/src/Metadata/Extractor/YamlPropertyExtractor.php index 24fdd3a1d8d..f9678169c53 100644 --- a/src/Metadata/Extractor/YamlPropertyExtractor.php +++ b/src/Metadata/Extractor/YamlPropertyExtractor.php @@ -95,7 +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'), + 'nativeType' => $this->phpize($propertyValues, 'nativeType', 'string'), ]; } } diff --git a/src/Metadata/Extractor/schema/properties.xsd b/src/Metadata/Extractor/schema/properties.xsd index 03ea88d9f54..689852a92c3 100644 --- a/src/Metadata/Extractor/schema/properties.xsd +++ b/src/Metadata/Extractor/schema/properties.xsd @@ -47,7 +47,7 @@ - + diff --git a/src/Metadata/IdentifiersExtractor.php b/src/Metadata/IdentifiersExtractor.php index b3374569025..86b1d3f1701 100644 --- a/src/Metadata/IdentifiersExtractor.php +++ b/src/Metadata/IdentifiersExtractor.php @@ -24,6 +24,7 @@ use Symfony\Component\PropertyAccess\Exception\NoSuchPropertyException; use Symfony\Component\PropertyAccess\PropertyAccess; use Symfony\Component\PropertyAccess\PropertyAccessorInterface; +use Symfony\Component\PropertyInfo\PropertyInfoExtractor; /** * {@inheritdoc} @@ -112,7 +113,31 @@ private function getIdentifierValue(object $item, string $class, string $propert foreach ($this->propertyNameCollectionFactory->create($resourceClass) as $propertyName) { $propertyMetadata = $this->propertyMetadataFactory->create($resourceClass, $propertyName); - if (null === $type = $propertyMetadata->getPhpType()) { + // TODO: remove in 5.x + if (!method_exists(PropertyInfoExtractor::class, 'getType')) { + $types = $propertyMetadata->getBuiltinTypes(); + if (null === ($type = $types[0] ?? null)) { + continue; + } + + try { + if ($type->isCollection()) { + $collectionValueType = $type->getCollectionValueTypes()[0] ?? null; + + if (null !== $collectionValueType && $collectionValueType->getClassName() === $class) { + return $this->resolveIdentifierValue($this->propertyAccessor->getValue($item, \sprintf('%s[0].%s', $propertyName, $property)), $parameterName); + } + } + + if ($type->getClassName() === $class) { + return $this->resolveIdentifierValue($this->propertyAccessor->getValue($item, "$propertyName.$property"), $parameterName); + } + } catch (NoSuchPropertyException $e) { + throw new RuntimeException('Not able to retrieve identifiers.', $e->getCode(), $e); + } + } + + if (null === $type = $propertyMetadata->getNativeType()) { continue; } diff --git a/src/Metadata/Property/Factory/AttributePropertyMetadataFactory.php b/src/Metadata/Property/Factory/AttributePropertyMetadataFactory.php index 1c4b15bfa36..cb7770df9c6 100644 --- a/src/Metadata/Property/Factory/AttributePropertyMetadataFactory.php +++ b/src/Metadata/Property/Factory/AttributePropertyMetadataFactory.php @@ -17,6 +17,7 @@ use ApiPlatform\Metadata\ApiProperty; use ApiPlatform\Metadata\Exception\PropertyNotFoundException; use ApiPlatform\Metadata\Util\Reflection; +use Symfony\Component\PropertyInfo\PropertyInfoExtractor; /** * Creates a property metadata from {@see ApiProperty} attribute. @@ -124,7 +125,7 @@ private function createMetadata(ApiProperty $attribute, ?ApiProperty $propertyMe if (preg_match('/^(?:get|is)(.*)/', (string) $method, $matches)) { // BC layer, to remove in 5.0 if ('getBuiltinTypes' === $method) { - if (!$attribute->usesLegacyType) { + if (method_exists(PropertyInfoExtractor::class, 'getType')) { continue; } diff --git a/src/Metadata/Property/Factory/ExtractorPropertyMetadataFactory.php b/src/Metadata/Property/Factory/ExtractorPropertyMetadataFactory.php index c57cb82e8d1..8c81a3c0ba4 100644 --- a/src/Metadata/Property/Factory/ExtractorPropertyMetadataFactory.php +++ b/src/Metadata/Property/Factory/ExtractorPropertyMetadataFactory.php @@ -19,6 +19,7 @@ use ApiPlatform\Metadata\Exception\RuntimeException; use ApiPlatform\Metadata\Extractor\PropertyExtractorInterface; use PHPStan\PhpDocParser\Parser\PhpDocParser; +use Symfony\Component\PropertyInfo\PropertyInfoExtractor; use Symfony\Component\PropertyInfo\Type as LegacyType; use Symfony\Component\TypeInfo\Type; use Symfony\Component\TypeInfo\TypeResolver\StringTypeResolver; @@ -64,20 +65,24 @@ public function create(string $resourceClass, string $property, array $options = foreach ($propertyMetadata as $key => $value) { if ('builtinTypes' === $key && null !== $value) { + if (method_exists(PropertyInfoExtractor::class, 'getType')) { + continue; + } + $apiProperty = $apiProperty->withBuiltinTypes(array_map(static fn (string $builtinType): LegacyType => new LegacyType($builtinType), $value)); continue; } - if ('phpType' === $key && null !== $value) { + if ('nativeType' === $key && null !== $value) { if (class_exists(PhpDocParser::class)) { - $apiProperty = $apiProperty->withPhpType((new StringTypeResolver())->resolve($value)); + $apiProperty = $apiProperty->withNativeType((new StringTypeResolver())->resolve($value)); continue; } try { - $apiProperty = $apiProperty->withPhpType(Type::builtin($value)); + $apiProperty = $apiProperty->withNativeType(Type::builtin($value)); } catch (\ValueError) { throw new RuntimeException(\sprintf('Cannot create a type from "%s". Try running "composer require phpstan/phpdoc-parser" to support all types.', $value)); } diff --git a/src/Metadata/Property/Factory/PropertyInfoPropertyMetadataFactory.php b/src/Metadata/Property/Factory/PropertyInfoPropertyMetadataFactory.php index e3c98b3de7a..aec8dcd0cf2 100644 --- a/src/Metadata/Property/Factory/PropertyInfoPropertyMetadataFactory.php +++ b/src/Metadata/Property/Factory/PropertyInfoPropertyMetadataFactory.php @@ -15,7 +15,10 @@ use ApiPlatform\Metadata\ApiProperty; use ApiPlatform\Metadata\Exception\PropertyNotFoundException; +use Doctrine\Common\Collections\ArrayCollection; +use Symfony\Component\PropertyInfo\PropertyInfoExtractor; use Symfony\Component\PropertyInfo\PropertyInfoExtractorInterface; +use Symfony\Component\PropertyInfo\Type; /** * PropertyInfo metadata loader decorator. @@ -43,8 +46,21 @@ public function create(string $resourceClass, string $property, array $options = } } - if (!$propertyMetadata->getPhpType()) { - $propertyMetadata = $propertyMetadata->withPhpType($this->propertyInfo->getType($resourceClass, $property, $options)); + if (!method_exists(PropertyInfoExtractor::class, 'getType') && !$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->getNativeType()) { + $propertyMetadata = $propertyMetadata->withNativeType($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 194f4976b0f..f79e6213961 100644 --- a/src/Metadata/Property/Factory/SerializerPropertyMetadataFactory.php +++ b/src/Metadata/Property/Factory/SerializerPropertyMetadataFactory.php @@ -18,6 +18,7 @@ use ApiPlatform\Metadata\ResourceClassResolverInterface; use ApiPlatform\Metadata\Util\ResourceClassInfoTrait; use ApiPlatform\Metadata\Util\TypeHelper; +use Symfony\Component\PropertyInfo\PropertyInfoExtractor; use Symfony\Component\Serializer\Mapping\AttributeMetadataInterface; use Symfony\Component\Serializer\Mapping\Factory\ClassMetadataFactoryInterface as SerializerClassMetadataFactoryInterface; use Symfony\Component\TypeInfo\Type; @@ -64,8 +65,22 @@ public function create(string $resourceClass, string $property, array $options = $propertyMetadata = $this->transformReadWrite($propertyMetadata, $resourceClass, $property, $normalizationGroups, $denormalizationGroups, $ignoredAttributes); - $type = $propertyMetadata->getPhpType(); - if (null !== $type && !$this->isResourceClass($resourceClass) && TypeHelper::isSatisfiedBy($type, static fn (Type $t): bool => $t instanceof CollectionType)) { + // TODO: remove in 5.x + if (!method_exists(PropertyInfoExtractor::class, 'getType')) { + $types = $propertyMetadata->getBuiltinTypes() ?? []; + + if (!$this->isResourceClass($resourceClass) && $types) { + foreach ($types as $builtinType) { + if ($builtinType->isCollection()) { + return $propertyMetadata->withReadableLink(true)->withWritableLink(true); + } + } + } + + return $this->transformLinkStatusLegacy($propertyMetadata, $normalizationGroups, $denormalizationGroups, $types); + } + $type = $propertyMetadata->getNativeType(); + if (null !== $type && !$this->isResourceClass($resourceClass) && $type->isSatisfiedBy(static fn (Type $t): bool => $t instanceof CollectionType)) { return $propertyMetadata->withReadableLink(true)->withWritableLink(true); } @@ -102,6 +117,59 @@ private function transformReadWrite(ApiProperty $propertyMetadata, string $resou return $propertyMetadata; } + /** + * Sets readableLink/writableLink based on matching normalization/denormalization groups. + * + * If normalization/denormalization groups are not specified, + * set link status to false since embedding of resource must be explicitly enabled + * + * @param string[]|null $normalizationGroups + * @param string[]|null $denormalizationGroups + */ + private function transformLinkStatusLegacy(ApiProperty $propertyMetadata, ?array $normalizationGroups = null, ?array $denormalizationGroups = null, ?array $types = 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; + } + + // 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); + } + + $relatedGroups = $this->getClassSerializerGroups($relatedClass); + + if (null === $propertyMetadata->isReadableLink()) { + $propertyMetadata = $propertyMetadata->withReadableLink(null !== $normalizationGroups && !empty(array_intersect($normalizationGroups, $relatedGroups))); + } + + if (null === $propertyMetadata->isWritableLink()) { + $propertyMetadata = $propertyMetadata->withWritableLink(null !== $denormalizationGroups && !empty(array_intersect($denormalizationGroups, $relatedGroups))); + } + + return $propertyMetadata; + } + + return $propertyMetadata; + } + /** * Sets readableLink/writableLink based on matching normalization/denormalization groups. * diff --git a/src/Metadata/Resource/Factory/LinkFactory.php b/src/Metadata/Resource/Factory/LinkFactory.php index bd07909ea7a..fb87e584b0a 100644 --- a/src/Metadata/Resource/Factory/LinkFactory.php +++ b/src/Metadata/Resource/Factory/LinkFactory.php @@ -42,7 +42,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->getPhpType()); + $relationClass = $this->getPropertyClassType($metadata->getNativeType()); if (!$relationClass) { throw new RuntimeException(\sprintf('We could not find a class matching the uriVariable "%s" on "%s".', $property, $resourceClass)); } @@ -86,7 +86,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->getPhpType())) || !$this->resourceClassResolver->isResourceClass($relationClass)) { + if (!($relationClass = $this->getPropertyClassType($metadata->getNativeType())) || !$this->resourceClassResolver->isResourceClass($relationClass)) { continue; } @@ -116,7 +116,7 @@ public function createLinksFromAttributes(Metadata $operation): array ->withFromProperty($property); if (!$attributeLink->getFromClass()) { - $attributeLink = $attributeLink->withFromClass($resourceClass)->withToClass($this->getPropertyClassType($metadata->getPhpType()) ?? $resourceClass); + $attributeLink = $attributeLink->withFromClass($resourceClass)->withToClass($this->getPropertyClassType($metadata->getNativeType()) ?? $resourceClass); } $links[] = $attributeLink; diff --git a/src/Metadata/Tests/Extractor/Adapter/XmlPropertyAdapter.php b/src/Metadata/Tests/Extractor/Adapter/XmlPropertyAdapter.php index 93cdc722292..27cea064f1b 100644 --- a/src/Metadata/Tests/Extractor/Adapter/XmlPropertyAdapter.php +++ b/src/Metadata/Tests/Extractor/Adapter/XmlPropertyAdapter.php @@ -46,7 +46,7 @@ final class XmlPropertyAdapter implements PropertyAdapterInterface 'uriTemplate', 'hydra', 'property', - 'phpType', + 'nativeType', ]; // TODO: add serialize support for XML (policy is Laravel-only) diff --git a/src/Metadata/Tests/Extractor/Adapter/properties.xml b/src/Metadata/Tests/Extractor/Adapter/properties.xml index d0b75addbb8..ff78be9f452 100644 --- a/src/Metadata/Tests/Extractor/Adapter/properties.xml +++ b/src/Metadata/Tests/Extractor/Adapter/properties.xml @@ -1,3 +1,3 @@ -bazbaripsumsomeirischemaanotheririschemahttps://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 393055682a9..369eb9d3e9c 100644 --- a/src/Metadata/Tests/Extractor/Adapter/properties.yaml +++ b/src/Metadata/Tests/Extractor/Adapter/properties.yaml @@ -39,4 +39,4 @@ properties: uriTemplate: /sub-resource-get-collection property: test hydra: false - phpType: string + nativeType: string diff --git a/src/Metadata/Tests/Extractor/PropertyMetadataCompatibilityTest.php b/src/Metadata/Tests/Extractor/PropertyMetadataCompatibilityTest.php index f12866d16c4..dabdf806ca2 100644 --- a/src/Metadata/Tests/Extractor/PropertyMetadataCompatibilityTest.php +++ b/src/Metadata/Tests/Extractor/PropertyMetadataCompatibilityTest.php @@ -76,7 +76,7 @@ final class PropertyMetadataCompatibilityTest extends TestCase 'uriTemplate' => '/sub-resource-get-collection', 'property' => 'test', 'hydra' => false, - 'phpType' => 'string', + 'nativeType' => 'string', ]; #[\PHPUnit\Framework\Attributes\DataProvider('getExtractors')] @@ -124,7 +124,7 @@ private function buildApiProperty(): ApiProperty return $property; } - private function withPhpType(string $value): Type + private function withNativeType(string $value): Type { return Type::builtin($value); } diff --git a/src/Metadata/Tests/Property/Factory/SerializerPropertyMetadataFactoryTest.php b/src/Metadata/Tests/Property/Factory/SerializerPropertyMetadataFactoryTest.php index 06a85aa32b0..7f0d50f1de4 100644 --- a/src/Metadata/Tests/Property/Factory/SerializerPropertyMetadataFactoryTest.php +++ b/src/Metadata/Tests/Property/Factory/SerializerPropertyMetadataFactoryTest.php @@ -72,15 +72,15 @@ public function testCreate($readGroups, $writeGroups): void $decoratedProphecy = $this->prophesize(PropertyMetadataFactoryInterface::class); $fooPropertyMetadata = (new ApiProperty()) - ->withPhpType(Type::nullable(Type::array())) // @phpstan-ignore-line + ->withNativeType(Type::nullable(Type::array())) // @phpstan-ignore-line ->withReadable(false) ->withWritable(true); $decoratedProphecy->create(Dummy::class, 'foo', $context)->willReturn($fooPropertyMetadata); $relatedDummyPropertyMetadata = (new ApiProperty()) - ->withPhpType(Type::nullable(Type::object(RelatedDummy::class))); + ->withNativeType(Type::nullable(Type::object(RelatedDummy::class))); $decoratedProphecy->create(Dummy::class, 'relatedDummy', $context)->willReturn($relatedDummyPropertyMetadata); $nameConvertedPropertyMetadata = (new ApiProperty()) - ->withPhpType(Type::nullable(Type::string())); // @phpstan-ignore-line + ->withNativeType(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())->withPhpType(Type::nullable(Type::string())); // @phpstan-ignore-line + $ignoredPropertyMetadata = (new ApiProperty())->withNativeType(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 449b7f44fc1..7f19204d713 100644 --- a/src/Metadata/Tests/Resource/Factory/LinkFactoryTest.php +++ b/src/Metadata/Tests/Resource/Factory/LinkFactoryTest.php @@ -93,11 +93,11 @@ public static function provideCreateLinksFromIdentifiersCases(): \Generator } #[\PHPUnit\Framework\Attributes\DataProvider('provideCreateLinksFromAttributesCases')] - public function testCreateLinksFromAttributes(?Type $phpType, array $expectedLinks): void + public function testCreateLinksFromAttributes(?Type $nativeType, array $expectedLinks): void { $propertyNameCollectionFactory = new PropertyInfoPropertyNameCollectionFactory(new PropertyInfoExtractor([new ReflectionExtractor()])); $propertyMetadataFactoryProphecy = $this->prophesize(PropertyMetadataFactoryInterface::class); - $propertyMetadataFactoryProphecy->create(AttributeResource::class, 'dummy')->willReturn((new ApiProperty())->withPhpType($phpType)); + $propertyMetadataFactoryProphecy->create(AttributeResource::class, 'dummy')->willReturn((new ApiProperty())->withNativeType($nativeType)); $resourceClassResolverProphecy = $this->prophesize(ResourceClassResolverInterface::class); $linkFactory = new LinkFactory($propertyNameCollectionFactory, $propertyMetadataFactoryProphecy->reveal(), $resourceClassResolverProphecy->reveal()); @@ -146,7 +146,7 @@ public function testCreateLinkFromProperty(): void $property = 'test'; $propertyMetadataFactoryProphecy = $this->prophesize(PropertyMetadataFactoryInterface::class); - $propertyMetadataFactoryProphecy->create(Dummy::class, 'test')->willReturn(new ApiProperty(phpType: Type::object(RelatedDummy::class))); + $propertyMetadataFactoryProphecy->create(Dummy::class, 'test')->willReturn(new ApiProperty(nativeType: 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 07a6989b761..6ccc6cba356 100644 --- a/src/Metadata/Tests/Resource/Factory/LinkResourceMetadataCollectionFactoryTest.php +++ b/src/Metadata/Tests/Resource/Factory/LinkResourceMetadataCollectionFactoryTest.php @@ -49,13 +49,13 @@ 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())->withPhpType( + $propertyMetadataFactoryProphecy->create(AttributeResource::class, 'foo')->willReturn((new ApiProperty())->withNativeType( Type::collection(Type::object(Collection::class), key: Type::int(), value: Type::object(Dummy::class)), )); - $propertyMetadataFactoryProphecy->create(AttributeResource::class, 'foo2')->willReturn((new ApiProperty())->withPhpType( + $propertyMetadataFactoryProphecy->create(AttributeResource::class, 'foo2')->willReturn((new ApiProperty())->withNativeType( Type::collection(Type::object(Collection::class), key: Type::int(), value: Type::object(Dummy::class)), )); - $propertyMetadataFactoryProphecy->create(AttributeResource::class, 'bar')->willReturn((new ApiProperty())->withPhpType( + $propertyMetadataFactoryProphecy->create(AttributeResource::class, 'bar')->willReturn((new ApiProperty())->withNativeType( Type::collection(Type::object(Collection::class), key: Type::int(), value: Type::object(RelatedDummy::class)), )); $resourceClassResolverProphecy = $this->prophesize(ResourceClassResolverInterface::class); @@ -104,7 +104,7 @@ 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())->withPhpType( + $propertyMetadataFactoryProphecy->create(AttributeResource::class, 'dummy')->willReturn((new ApiProperty())->withNativeType( Type::collection(Type::object(Collection::class), key: Type::int(), value: Type::object(Dummy::class)), )); $resourceClassResolverProphecy = $this->prophesize(ResourceClassResolverInterface::class); diff --git a/src/Metadata/UriVariablesConverter.php b/src/Metadata/UriVariablesConverter.php index d9c7d74075c..c33a00a03a6 100644 --- a/src/Metadata/UriVariablesConverter.php +++ b/src/Metadata/UriVariablesConverter.php @@ -16,7 +16,6 @@ use ApiPlatform\Metadata\Exception\InvalidUriVariableException; use ApiPlatform\Metadata\Property\Factory\PropertyMetadataFactoryInterface; use ApiPlatform\Metadata\Resource\Factory\ResourceMetadataCollectionFactoryInterface; -use ApiPlatform\Metadata\Util\TypeHelper; use Symfony\Component\TypeInfo\Type\CompositeTypeInterface; use Symfony\Component\TypeInfo\Type\WrappingTypeInterface; @@ -87,11 +86,11 @@ private function getIdentifierTypeStrings(string $resourceClass, array $properti foreach ($properties as $property) { $propertyMetadata = $this->propertyMetadataFactory->create($resourceClass, $property); - if (!$type = $propertyMetadata->getPhpType()) { + if (!$type = $propertyMetadata->getNativeType()) { continue; } - foreach (TypeHelper::traverse($type) as $t) { + foreach ($type->traverse() as $t) { if (!$t instanceof CompositeTypeInterface && !$t instanceof WrappingTypeInterface) { $typeStrings[] = (string) $t; } diff --git a/src/Metadata/Util/TypeHelper.php b/src/Metadata/Util/TypeHelper.php index d408be9f539..40c9e0b69bb 100644 --- a/src/Metadata/Util/TypeHelper.php +++ b/src/Metadata/Util/TypeHelper.php @@ -15,9 +15,7 @@ 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 @@ -30,50 +28,9 @@ private function __construct() { } - /** - * https://github.com/symfony/symfony/pull/59845. - * - * @return iterable - */ - public static function traverse(Type $type, bool $traverseComposite = true, bool $traverseWrapped = true): iterable - { - yield $type; - - if ($type instanceof CompositeTypeInterface && $traverseComposite) { - foreach ($type->getTypes() as $t) { - yield $t; - } - - // prevent yielding twice when having a type that is both composite and wrapped - return; - } - - if ($type instanceof WrappingTypeInterface && $traverseWrapped) { - yield $type->getWrappedType(); - } - } - - /** - * https://github.com/symfony/symfony/pull/59844. - * - * @param callable(Type): bool $specification - */ - public static function isSatisfiedBy(Type $type, callable $specification): bool - { - if ($type instanceof WrappingTypeInterface && $type->wrappedTypeIsSatisfiedBy($specification)) { - return true; - } - - if ($type instanceof CompositeTypeInterface && $type->composedTypesAreSatisfiedBy($specification)) { - return true; - } - - return $specification($type); - } - public static function getCollectionValueType(Type $type): ?Type { - foreach (self::traverse($type) as $t) { + foreach ($type->traverse() as $t) { if ($t instanceof CollectionType) { return $t->getCollectionValueType(); } @@ -87,7 +44,7 @@ public static function getCollectionValueType(Type $type): ?Type */ public static function getClassName(Type $type): ?string { - foreach (self::traverse($type) as $t) { + foreach ($type->traverse() as $t) { if ($t instanceof ObjectType) { return $t->getClassName(); } diff --git a/src/Metadata/composer.json b/src/Metadata/composer.json index a5103cbf343..656ba01e7bc 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": "^7.1", + "symfony/property-info": "^6.4 || ^7.1", "symfony/string": "^6.4 || ^7.0", - "symfony/type-info": "^7.2" + "symfony/type-info": "^7.3-dev" }, "require-dev": { "api-platform/json-schema": "^4.1", @@ -86,5 +86,11 @@ }, "scripts": { "test": "./vendor/bin/phpunit" - } + }, + "repositories": [ + { + "type": "vcs", + "url": "https://github.com/symfony/type-info" + } + ] } diff --git a/src/OpenApi/composer.json b/src/OpenApi/composer.json index 7a134b26f10..ae5821a2e87 100644 --- a/src/OpenApi/composer.json +++ b/src/OpenApi/composer.json @@ -41,7 +41,8 @@ "phpunit/phpunit": "^11.2", "api-platform/doctrine-common": "^4.1", "api-platform/doctrine-orm": "^4.1", - "api-platform/doctrine-odm": "^4.1" + "api-platform/doctrine-odm": "^4.1", + "symfony/type-info": "^7.3-dev" }, "autoload": { "psr-4": { @@ -76,5 +77,11 @@ }, "scripts": { "test": "./vendor/bin/phpunit" - } + }, + "repositories": [ + { + "type": "vcs", + "url": "https://github.com/symfony/type-info" + } + ] } diff --git a/src/RamseyUuid/composer.json b/src/RamseyUuid/composer.json index 74b92665a41..a54b682b703 100644 --- a/src/RamseyUuid/composer.json +++ b/src/RamseyUuid/composer.json @@ -30,7 +30,8 @@ "phpspec/prophecy-phpunit": "^2.2", "ramsey/uuid": "^4.7", "ramsey/uuid-doctrine": "^2.0", - "phpunit/phpunit": "^11.2" + "phpunit/phpunit": "^11.2", + "symfony/type-info": "^7.3-dev" }, "autoload": { "psr-4": { @@ -62,5 +63,11 @@ }, "scripts": { "test": "./vendor/bin/phpunit" - } + }, + "repositories": [ + { + "type": "vcs", + "url": "https://github.com/symfony/type-info" + } + ] } diff --git a/src/Serializer/composer.json b/src/Serializer/composer.json index 8911f452690..8da2f18e218 100644 --- a/src/Serializer/composer.json +++ b/src/Serializer/composer.json @@ -26,7 +26,7 @@ "api-platform/metadata": "^4.1", "api-platform/state": "^4.1", "symfony/property-access": "^6.4 || ^7.0", - "symfony/property-info": "^7.1", + "symfony/property-info": "^6.4 || ^7.1", "symfony/serializer": "^6.4 || ^7.0", "symfony/validator": "^6.4 || ^7.0" }, @@ -41,7 +41,8 @@ "phpunit/phpunit": "^11.2", "symfony/mercure-bundle": "*", "symfony/var-dumper": "^6.4 || ^7.0", - "symfony/yaml": "^6.4 || ^7.0" + "symfony/yaml": "^6.4 || ^7.0", + "symfony/type-info": "^7.3-dev" }, "suggest": { "api-platform/doctrine-orm": "To support Doctrine ORM state options.", @@ -80,5 +81,11 @@ }, "scripts": { "test": "./vendor/bin/phpunit" - } + }, + "repositories": [ + { + "type": "vcs", + "url": "https://github.com/symfony/type-info" + } + ] } diff --git a/src/State/composer.json b/src/State/composer.json index bb7466e1b8c..5ca6be8239a 100644 --- a/src/State/composer.json +++ b/src/State/composer.json @@ -38,7 +38,8 @@ "symfony/http-foundation": "^6.4 || ^7.0", "willdurand/negotiation": "^3.1", "api-platform/validator": "^4.1", - "api-platform/serializer": "^4.1" + "api-platform/serializer": "^4.1", + "symfony/type-info": "^7.3-dev" }, "autoload": { "psr-4": { @@ -80,5 +81,11 @@ }, "scripts": { "test": "./vendor/bin/phpunit" - } + }, + "repositories": [ + { + "type": "vcs", + "url": "https://github.com/symfony/type-info" + } + ] } diff --git a/src/Symfony/composer.json b/src/Symfony/composer.json index a7b6c8f32f8..52fd2cb52f9 100644 --- a/src/Symfony/composer.json +++ b/src/Symfony/composer.json @@ -39,7 +39,7 @@ "api-platform/state": "^4.1", "api-platform/validator": "^4.1", "api-platform/openapi": "^4.1", - "symfony/property-info": "^7.1", + "symfony/property-info": "^6.4 || ^7.1", "symfony/property-access": "^6.4 || ^7.0", "symfony/serializer": "^6.4 || ^7.0", "symfony/security-core": "^6.4 || ^7.0", @@ -58,7 +58,8 @@ "api-platform/doctrine-orm": "^4.1", "api-platform/doctrine-odm": "^4.1", "api-platform/parameter-validator": "^3.1", - "symfony/expression-language": "^6.4 || ^7.0" + "symfony/expression-language": "^6.4 || ^7.0", + "symfony/type-info": "^7.3-dev" }, "suggest": { "api-platform/doctrine-orm": "To support Doctrine ORM.", @@ -114,5 +115,11 @@ }, "scripts": { "test": "./vendor/bin/phpunit" - } + }, + "repositories": [ + { + "type": "vcs", + "url": "https://github.com/symfony/type-info" + } + ] } diff --git a/src/Validator/composer.json b/src/Validator/composer.json index e8d06bb60c6..254ae6b0784 100644 --- a/src/Validator/composer.json +++ b/src/Validator/composer.json @@ -31,7 +31,8 @@ "symfony/serializer": "^6.4 || ^7.0", "phpunit/phpunit": "^11.2", "symfony/validator": "^6.4 || ^7.0", - "symfony/http-kernel": "^6.4 || ^7.0" + "symfony/http-kernel": "^6.4 || ^7.0", + "symfony/type-info": "^7.3-dev" }, "autoload": { "psr-4": { @@ -63,5 +64,11 @@ }, "scripts": { "test": "./vendor/bin/phpunit" - } + }, + "repositories": [ + { + "type": "vcs", + "url": "https://github.com/symfony/type-info" + } + ] } From 00b75299c7ea4c419ab469add90bc7005f5aac8a Mon Sep 17 00:00:00 2001 From: soyuka Date: Wed, 16 Apr 2025 09:58:21 +0200 Subject: [PATCH 3/4] schema factory part of https://github.com/mtarld/core/pull/6 --- src/JsonSchema/SchemaFactory.php | 138 ++++++++++++++++++++++++++++++- 1 file changed, 136 insertions(+), 2 deletions(-) diff --git a/src/JsonSchema/SchemaFactory.php b/src/JsonSchema/SchemaFactory.php index 0ff047692b6..a6c4d3aeb64 100644 --- a/src/JsonSchema/SchemaFactory.php +++ b/src/JsonSchema/SchemaFactory.php @@ -22,8 +22,15 @@ use ApiPlatform\Metadata\Property\Factory\PropertyNameCollectionFactoryInterface; use ApiPlatform\Metadata\Resource\Factory\ResourceMetadataCollectionFactoryInterface; use ApiPlatform\Metadata\ResourceClassResolverInterface; +use Symfony\Component\PropertyInfo\PropertyInfoExtractor; use Symfony\Component\Serializer\NameConverter\NameConverterInterface; use Symfony\Component\Serializer\Normalizer\AbstractNormalizer; +use Symfony\Component\TypeInfo\Type\BuiltinType; +use Symfony\Component\TypeInfo\Type\CollectionType; +use Symfony\Component\TypeInfo\Type\CompositeTypeInterface; +use Symfony\Component\TypeInfo\Type\ObjectType; +use Symfony\Component\TypeInfo\Type\WrappingTypeInterface; +use Symfony\Component\TypeInfo\TypeIdentifier; /** * {@inheritdoc} @@ -136,13 +143,20 @@ public function buildSchema(string $className, string $format = 'json', string $ $definition['required'][] = $normalizedPropertyName; } - $this->buildPropertySchema($schema, $definitionName, $normalizedPropertyName, $propertyMetadata, $serializerContext, $format, $type); + if (!method_exists(PropertyInfoExtractor::class, 'getType')) { + $this->buildLegacyPropertySchema($schema, $definitionName, $normalizedPropertyName, $propertyMetadata, $serializerContext, $format, $type); + } else { + $this->buildPropertySchema($schema, $definitionName, $normalizedPropertyName, $propertyMetadata, $serializerContext, $format, $type); + } } return $schema; } - private function buildPropertySchema(Schema $schema, string $definitionName, string $normalizedPropertyName, ApiProperty $propertyMetadata, array $serializerContext, string $format, string $parentType): void + /** + * Builds the JSON Schema for a property using the legacy PropertyInfo component. + */ + private function buildLegacyPropertySchema(Schema $schema, string $definitionName, string $normalizedPropertyName, ApiProperty $propertyMetadata, array $serializerContext, string $format, string $parentType): void { $version = $schema->getVersion(); if (Schema::VERSION_SWAGGER === $version || Schema::VERSION_OPENAPI === $version) { @@ -256,6 +270,126 @@ private function buildPropertySchema(Schema $schema, string $definitionName, str $schema->getDefinitions()[$definitionName]['properties'][$normalizedPropertyName] = new \ArrayObject($propertySchema); } + private function buildPropertySchema(Schema $schema, string $definitionName, string $normalizedPropertyName, ApiProperty $propertyMetadata, array $serializerContext, string $format, string $parentType): void + { + $version = $schema->getVersion(); + if (Schema::VERSION_SWAGGER === $version || Schema::VERSION_OPENAPI === $version) { + $additionalPropertySchema = $propertyMetadata->getOpenapiContext(); + } else { + $additionalPropertySchema = $propertyMetadata->getJsonSchemaContext(); + } + + $propertySchema = array_merge( + $propertyMetadata->getSchema() ?? [], + $additionalPropertySchema ?? [] + ); + + // @see https://github.com/api-platform/core/issues/6299 + if (Schema::UNKNOWN_TYPE === ($propertySchema['type'] ?? null) && isset($propertySchema['$ref'])) { + unset($propertySchema['type']); + } + + $extraProperties = $propertyMetadata->getExtraProperties() ?? []; + // see AttributePropertyMetadataFactory + if (true === ($extraProperties[SchemaPropertyMetadataFactory::JSON_SCHEMA_USER_DEFINED] ?? false)) { + // schema seems to have been declared by the user: do not override nor complete user value + $schema->getDefinitions()[$definitionName]['properties'][$normalizedPropertyName] = new \ArrayObject($propertySchema); + + return; + } + + $type = $propertyMetadata->getNativeType(); + $propertySchemaType = $propertySchema['type'] ?? false; + $isSchemaDefined = ($propertySchema['$ref'] ?? $propertySchema['anyOf'] ?? $propertySchema['allOf'] ?? $propertySchema['oneOf'] ?? false) + || ($propertySchemaType && 'string' !== $propertySchemaType && !(\is_array($propertySchemaType) && !\in_array('string', $propertySchemaType, true))) + || (($propertySchema['format'] ?? $propertySchema['enum'] ?? false) && $propertySchemaType); + + // Check if the type is considered "unknown" by SchemaPropertyMetadataFactory + $isUnknown = Schema::UNKNOWN_TYPE === $propertySchemaType + || ('array' === $propertySchemaType && Schema::UNKNOWN_TYPE === ($propertySchema['items']['type'] ?? null)) + || ('object' === $propertySchemaType && Schema::UNKNOWN_TYPE === ($propertySchema['additionalProperties']['type'] ?? null)); + + // If schema is defined and not marked as unknown, or if no type info exists, return early + if (!$isUnknown && (null === $type || $isSchemaDefined)) { + $schema->getDefinitions()[$definitionName]['properties'][$normalizedPropertyName] = new \ArrayObject($propertySchema); + + return; + } + + // property schema is created in SchemaPropertyMetadataFactory, but it cannot build resource reference ($ref) + // complete property schema with resource reference ($ref) if it's related to an object/resource + $refs = []; + $isNullable = $type?->isNullable() ?? false; + + if ($type) { + foreach ($type instanceof CompositeTypeInterface ? $type->getTypes() : [$type] as $t) { + if ($t instanceof BuiltinType && TypeIdentifier::NULL === $t->getTypeIdentifier()) { + continue; + } + + $valueType = $t; + $isCollection = $t instanceof CollectionType; + + if ($isCollection) { + $valueType = $t->getCollectionValueType(); + } + + while ($valueType instanceof WrappingTypeInterface) { + $valueType = $valueType->getWrappedType(); + } + + if (!$valueType instanceof ObjectType) { + continue; + } + + $className = $valueType->getClassName(); + $subSchemaInstance = new Schema($version); + $subSchemaInstance->setDefinitions($schema->getDefinitions()); + $subSchemaFactory = $this->schemaFactory ?: $this; + $subSchemaResult = $subSchemaFactory->buildSchema($className, $format, $parentType, null, $subSchemaInstance, $serializerContext + [self::FORCE_SUBSCHEMA => true], false); + if (!isset($subSchemaResult['$ref'])) { + continue; + } + + if (false === $propertyMetadata->getGenId()) { + $subDefinitionName = $this->definitionNameFactory->create($className, $format, $className, null, $serializerContext); + if (isset($subSchemaResult->getDefinitions()[$subDefinitionName]['properties']['@id'])) { + unset($subSchemaResult->getDefinitions()[$subDefinitionName]['properties']['@id']); + } + } + + if ($isCollection) { + $key = ($propertySchema['type'] ?? null) === 'object' ? 'additionalProperties' : 'items'; + if (!isset($propertySchema[$key]) || !\is_array($propertySchema[$key])) { + $propertySchema[$key] = []; + } + $propertySchema[$key]['$ref'] = $subSchemaResult['$ref']; + unset($propertySchema[$key]['type']); + $refs = []; + break; + } + + $refs[] = ['$ref' => $subSchemaResult['$ref']]; + } + } + + if (!empty($refs)) { + if ($isNullable) { + $refs[] = ['type' => 'null']; + } + + if (($c = \count($refs)) > 1) { + $propertySchema['anyOf'] = $refs; + unset($propertySchema['type'], $propertySchema['$ref']); + } elseif (1 === $c) { + $propertySchema['$ref'] = $refs[0]['$ref']; + unset($propertySchema['type']); + } + } + + $schema->getDefinitions()[$definitionName]['properties'][$normalizedPropertyName] = new \ArrayObject($propertySchema); + } + private function getValidationGroups(Operation $operation): array { $groups = $operation->getValidationContext()['groups'] ?? []; From bbbae81b6bf07ec24ce3f0987ecb80860972e854 Mon Sep 17 00:00:00 2001 From: soyuka Date: Wed, 16 Apr 2025 15:00:46 +0200 Subject: [PATCH 4/4] fixes --- .../Factory/SchemaPropertyMetadataFactory.php | 58 +++++++++---------- .../PropertyInfoPropertyMetadataFactory.php | 27 +++++---- .../Command/JsonSchemaGenerateCommandTest.php | 24 +++----- 3 files changed, 53 insertions(+), 56 deletions(-) diff --git a/src/JsonSchema/Metadata/Property/Factory/SchemaPropertyMetadataFactory.php b/src/JsonSchema/Metadata/Property/Factory/SchemaPropertyMetadataFactory.php index cc094c0f672..4ca1f46fb56 100644 --- a/src/JsonSchema/Metadata/Property/Factory/SchemaPropertyMetadataFactory.php +++ b/src/JsonSchema/Metadata/Property/Factory/SchemaPropertyMetadataFactory.php @@ -184,6 +184,12 @@ private function applyNullability(array $schema, bool $isNullable): array $currentType = $schema['type']; $schema['type'] = \is_array($currentType) ? array_merge($currentType, ['null']) : [$currentType, 'null']; + if (isset($schema['enum'])) { + $schema['enum'][] = null; + + return $schema; + } + return $schema; } @@ -199,18 +205,23 @@ private function getJsonSchemaFromType(Type $type, ?bool $readableLink = null): { $isNullable = $type->isNullable(); - while ($type instanceof WrappingTypeInterface) { - $type = $type->getWrappedType(); - } - if ($type instanceof UnionType) { $subTypes = array_filter($type->getTypes(), fn ($t) => !($t instanceof BuiltinType && $t->isIdentifiedBy(TypeIdentifier::NULL))); + + foreach ($subTypes as $t) { + $s = $this->getJsonSchemaFromType($t, $readableLink); + // We can not find what type this is, let it be computed at runtime by the SchemaFactory + if (($s['type'] ?? null) === Schema::UNKNOWN_TYPE) { + return $s; + } + } + $schemas = array_map(fn ($t) => $this->getJsonSchemaFromType($t, $readableLink), $subTypes); if (0 === \count($schemas)) { $schema = []; } elseif (1 === \count($schemas)) { - $schema = $schemas[0]; + $schema = current($schemas); } else { $schema = ['anyOf' => $schemas]; } @@ -235,20 +246,20 @@ private function getJsonSchemaFromType(Type $type, ?bool $readableLink = null): } if ($type instanceof CollectionType) { - $keyType = $type->getCollectionKeyType(); $valueType = $type->getCollectionValueType(); - $schema = []; + $valueSchema = $this->getJsonSchemaFromType($valueType, $readableLink); + $keyType = $type->getCollectionKeyType(); // Associative array (string keys) - if ($keyType->isSatisfiedBy(fn (Type $t) => $t instanceof BuiltinType && $t->isIdentifiedBy(TypeIdentifier::STRING))) { + if ($keyType->isSatisfiedBy(fn (Type $t) => $t instanceof BuiltinType && $t->isIdentifiedBy(TypeIdentifier::INT))) { $schema = [ - 'type' => 'object', - 'additionalProperties' => $this->getJsonSchemaFromType($valueType, $readableLink), + 'type' => 'array', + 'items' => $valueSchema, ]; } else { // List (int keys) $schema = [ - 'type' => 'array', - 'items' => $this->getJsonSchemaFromType($valueType, $readableLink), + 'type' => 'object', + 'additionalProperties' => $valueSchema, ]; } @@ -262,10 +273,6 @@ private function getJsonSchemaFromType(Type $type, ?bool $readableLink = null): } if ($type instanceof BuiltinType) { - if ($type->isIdentifiedBy(TypeIdentifier::NULL)) { - return ['type' => 'null']; - } - $schema = match ($type->getTypeIdentifier()) { TypeIdentifier::INT => ['type' => 'integer'], TypeIdentifier::FLOAT => ['type' => 'number'], @@ -284,7 +291,7 @@ private function getJsonSchemaFromType(Type $type, ?bool $readableLink = null): return $this->applyNullability($schema, $isNullable); } - return $this->applyNullability(['type' => Schema::UNKNOWN_TYPE], $isNullable); + return ['type' => Schema::UNKNOWN_TYPE]; } /** @@ -316,17 +323,16 @@ private function getClassSchemaDefinition(?string $className, ?bool $readableLin return ['type' => 'string', 'format' => 'binary']; } - if (is_a($className, \BackedEnum::class, true)) { + $isResourceClass = $this->isResourceClass($className); + if (!$isResourceClass && is_a($className, \BackedEnum::class, true)) { $enumCases = array_map(static fn (\BackedEnum $enum): string|int => $enum->value, $className::cases()); $type = \is_string($enumCases[0] ?? '') ? 'string' : 'integer'; return ['type' => $type, 'enum' => $enumCases]; } - $isResource = $this->isResourceClass($className); - // If it's a resource and links are not readable, represent as IRI string. - if ($isResource && true !== $readableLink) { + if ($isResourceClass && true !== $readableLink) { return [ 'type' => 'string', 'format' => 'iri-reference', @@ -334,16 +340,10 @@ private function getClassSchemaDefinition(?string $className, ?bool $readableLin ]; } - // If it's a known resource represent it as UNKNOWN_TYPE this gets resolved at runtime by the SchemaFactory - if ($isResource) { - return ['type' => Schema::UNKNOWN_TYPE]; - } - - // For non-resource objects that aren't handled specifically, default to object. - return ['type' => 'object']; + return ['type' => Schema::UNKNOWN_TYPE]; } - private function getLegacyTypeSchema(ApiProperty $propertyMetadata, array $propertySchema, string $resourceClass, string $property, bool $link): array + private function getLegacyTypeSchema(ApiProperty $propertyMetadata, array $propertySchema, string $resourceClass, string $property, ?bool $link): array { $types = $propertyMetadata->getBuiltinTypes() ?? []; diff --git a/src/Metadata/Property/Factory/PropertyInfoPropertyMetadataFactory.php b/src/Metadata/Property/Factory/PropertyInfoPropertyMetadataFactory.php index aec8dcd0cf2..acde16a88ca 100644 --- a/src/Metadata/Property/Factory/PropertyInfoPropertyMetadataFactory.php +++ b/src/Metadata/Property/Factory/PropertyInfoPropertyMetadataFactory.php @@ -46,21 +46,24 @@ public function create(string $resourceClass, string $property, array $options = } } - if (!method_exists(PropertyInfoExtractor::class, 'getType') && !$propertyMetadata->getBuiltinTypes()) { - $types = $this->propertyInfo->getTypes($resourceClass, $property, $options) ?? []; + // TODO: remove in 5.x + if (!method_exists(PropertyInfoExtractor::class, 'getType')) { + 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()); + 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->getNativeType()) { - $propertyMetadata = $propertyMetadata->withNativeType($this->propertyInfo->getType($resourceClass, $property, $options)); + $propertyMetadata = $propertyMetadata->withBuiltinTypes($types); + } + } else { + if (!$propertyMetadata->getNativeType()) { + $propertyMetadata = $propertyMetadata->withNativeType($this->propertyInfo->getType($resourceClass, $property, $options)); + } } if (null === $propertyMetadata->getDescription() && null !== $description = $this->propertyInfo->getShortDescription($resourceClass, $property, $options)) { diff --git a/tests/JsonSchema/Command/JsonSchemaGenerateCommandTest.php b/tests/JsonSchema/Command/JsonSchemaGenerateCommandTest.php index d1b03048727..03fdc10396e 100644 --- a/tests/JsonSchema/Command/JsonSchemaGenerateCommandTest.php +++ b/tests/JsonSchema/Command/JsonSchemaGenerateCommandTest.php @@ -177,11 +177,9 @@ public function testArraySchemaWithMultipleUnionTypesJsonLd(): void $result = $this->tester->getDisplay(); $json = json_decode($result, associative: true); - $this->assertEquals($json['definitions']['Nest.jsonld']['properties']['owner']['anyOf'], [ - ['$ref' => '#/definitions/Robin.jsonld'], - ['$ref' => '#/definitions/Wren.jsonld'], - ['type' => 'null'], - ]); + $this->assertContains(['$ref' => '#/definitions/Robin.jsonld'], $json['definitions']['Nest.jsonld']['properties']['owner']['anyOf']); + $this->assertContains(['$ref' => '#/definitions/Wren.jsonld'], $json['definitions']['Nest.jsonld']['properties']['owner']['anyOf']); + $this->assertContains(['type' => 'null'], $json['definitions']['Nest.jsonld']['properties']['owner']['anyOf']); $this->assertArrayHasKey('Wren.jsonld', $json['definitions']); $this->assertArrayHasKey('Robin.jsonld', $json['definitions']); @@ -193,11 +191,9 @@ public function testArraySchemaWithMultipleUnionTypesJsonApi(): void $result = $this->tester->getDisplay(); $json = json_decode($result, associative: true); - $this->assertEquals($json['definitions']['Nest.jsonapi']['properties']['data']['properties']['attributes']['properties']['owner']['anyOf'], [ - ['$ref' => '#/definitions/Robin.jsonapi'], - ['$ref' => '#/definitions/Wren.jsonapi'], - ['type' => 'null'], - ]); + $this->assertContains(['$ref' => '#/definitions/Robin.jsonapi'], $json['definitions']['Nest.jsonapi']['properties']['data']['properties']['attributes']['properties']['owner']['anyOf']); + $this->assertContains(['$ref' => '#/definitions/Wren.jsonapi'], $json['definitions']['Nest.jsonapi']['properties']['data']['properties']['attributes']['properties']['owner']['anyOf']); + $this->assertContains(['type' => 'null'], $json['definitions']['Nest.jsonapi']['properties']['data']['properties']['attributes']['properties']['owner']['anyOf']); $this->assertArrayHasKey('Wren.jsonapi', $json['definitions']); $this->assertArrayHasKey('Robin.jsonapi', $json['definitions']); @@ -209,11 +205,9 @@ public function testArraySchemaWithMultipleUnionTypesJsonHal(): void $result = $this->tester->getDisplay(); $json = json_decode($result, associative: true); - $this->assertEquals($json['definitions']['Nest.jsonhal']['properties']['owner']['anyOf'], [ - ['$ref' => '#/definitions/Robin.jsonhal'], - ['$ref' => '#/definitions/Wren.jsonhal'], - ['type' => 'null'], - ]); + $this->assertContains(['$ref' => '#/definitions/Robin.jsonhal'], $json['definitions']['Nest.jsonhal']['properties']['owner']['anyOf']); + $this->assertContains(['$ref' => '#/definitions/Wren.jsonhal'], $json['definitions']['Nest.jsonhal']['properties']['owner']['anyOf']); + $this->assertContains(['type' => 'null'], $json['definitions']['Nest.jsonhal']['properties']['owner']['anyOf']); $this->assertArrayHasKey('Wren.jsonhal', $json['definitions']); $this->assertArrayHasKey('Robin.jsonhal', $json['definitions']);