diff --git a/.gitignore b/.gitignore index 8c62c7fc8fa..1d12eccbda9 100644 --- a/.gitignore +++ b/.gitignore @@ -11,3 +11,4 @@ /tests/Fixtures/app/var/ /tests/Fixtures/app/public/bundles/ /vendor/ +/Dockerfile diff --git a/.php_cs.dist b/.php_cs.dist index cbd76a6fc83..bdd5ffa42c0 100644 --- a/.php_cs.dist +++ b/.php_cs.dist @@ -29,6 +29,7 @@ return PhpCsFixer\Config::create() '@PHPUnit60Migration:risky' => true, '@Symfony' => true, '@Symfony:risky' => true, + 'single_line_comment_style' => false, // Temporary fix for compatibility with PHP 8 attributes, see https://github.com/FriendsOfPHP/PHP-CS-Fixer/pull/5284 'align_multiline_comment' => [ 'comment_type' => 'phpdocs_like', ], diff --git a/phpstan.neon.dist b/phpstan.neon.dist index fa05eb4d3fb..211065f002d 100644 --- a/phpstan.neon.dist +++ b/phpstan.neon.dist @@ -21,6 +21,9 @@ parameters: - tests/Bridge/NelmioApiDoc/* - src/Bridge/FosUser/* # BC layer + - tests/Annotation/ApiResourceTest.php + - tests/Annotation/ApiPropertyTest.php + - tests/Metadata/Resource/Factory/AnnotationResourceMetadataFactoryTest.php - tests/Fixtures/TestBundle/BrowserKit/Client.php # The Symfony Configuration API isn't good enough to be analysed - src/Bridge/Symfony/Bundle/DependencyInjection/Configuration.php @@ -101,6 +104,13 @@ parameters: message: '#Property Doctrine\\ORM\\Mapping\\ClassMetadataInfo::\$fieldMappings \(array.*\)>\) does not accept array\(.*\)\.#' path: tests/Bridge/Doctrine/Orm/Metadata/Property/DoctrineOrmPropertyMetadataFactoryTest.php + # Expected, due to PHP 8 attributes + - '#ReflectionProperty::getAttributes\(\)#' + - '#ReflectionMethod::getAttributes\(\)#' + - '#ReflectionClass::getAttributes\(\)#' + - '#Constructor of class ApiPlatform\\Core\\Annotation\\ApiResource has an unused parameter#' + - '#Constructor of class ApiPlatform\\Core\\Annotation\\ApiProperty has an unused parameter#' + # Expected, due to optional interfaces - '#Method ApiPlatform\\Core\\Bridge\\Doctrine\\Orm\\Extension\\QueryCollectionExtensionInterface::applyToCollection\(\) invoked with 5 parameters, 3-4 required\.#' - '#Method ApiPlatform\\Core\\Bridge\\Doctrine\\Orm\\Extension\\QueryResult(Item|Collection)ExtensionInterface::getResult\(\) invoked with 4 parameters, 1 required\.#' diff --git a/src/Annotation/ApiProperty.php b/src/Annotation/ApiProperty.php index 7fcc96cabe0..1d744926a04 100644 --- a/src/Annotation/ApiProperty.php +++ b/src/Annotation/ApiProperty.php @@ -33,10 +33,16 @@ * @Attribute("swaggerContext", type="array") * ) */ +#[\Attribute(\Attribute::TARGET_PROPERTY|\Attribute::TARGET_METHOD)] final class ApiProperty { use AttributesHydratorTrait; + /** + * @var array + */ + private static $deprecatedAttributes = []; + /** * @var string */ @@ -88,66 +94,63 @@ final class ApiProperty public $example; /** - * @see https://github.com/Haehnchen/idea-php-annotation-plugin/issues/112 - * - * @var string - */ - private $deprecationReason; - - /** - * @see https://github.com/Haehnchen/idea-php-annotation-plugin/issues/112 - * - * @var bool - */ - private $fetchable; - - /** - * @see https://github.com/Haehnchen/idea-php-annotation-plugin/issues/112 - * - * @var bool - */ - private $fetchEager; - - /** - * @see https://github.com/Haehnchen/idea-php-annotation-plugin/issues/112 + * @param string $description + * @param bool $readable + * @param bool $writable + * @param bool $readableLink + * @param bool $writableLink + * @param bool $required + * @param string $iri + * @param bool $identifier + * @param string|int|float|bool|array $default + * @param string|int|float|bool|array|null $example + * @param string $deprecationReason + * @param bool $fetchable + * @param bool $fetchEager + * @param array $jsonldContext + * @param array $openapiContext + * @param bool $push + * @param string $security + * @param array $swaggerContext * - * @var array - */ - private $jsonldContext; - - /** - * @see https://github.com/Haehnchen/idea-php-annotation-plugin/issues/112 - * - * @var array - */ - private $openapiContext; - - /** - * @see https://github.com/Haehnchen/idea-php-annotation-plugin/issues/112 - * - * @var bool - */ - private $push; - - /** - * @see https://github.com/Haehnchen/idea-php-annotation-plugin/issues/112 - * - * @var string - */ - private $security; - - /** - * @see https://github.com/Haehnchen/idea-php-annotation-plugin/issues/112 - * - * @var array - */ - private $swaggerContext; - - /** * @throws InvalidArgumentException */ - public function __construct(array $values = []) - { - $this->hydrateAttributes($values); + public function __construct( + $description = null, + ?bool $readable = null, + ?bool $writable = null, + ?bool $readableLink = null, + ?bool $writableLink = null, + ?bool $required = null, + ?string $iri = null, + ?bool $identifier = null, + $default = null, + $example = null, + + // attributes + ?array $attributes = null, + ?string $deprecationReason = null, + ?bool $fetchable = null, + ?bool $fetchEager = null, + ?array $jsonldContext = null, + ?array $openapiContext = null, + ?bool $push = null, + ?string $security = null, + ?array $swaggerContext = null + ) { + if (!\is_array($description)) { // @phpstan-ignore-line Doctrine annotations support + [$publicProperties, $configurableAttributes] = self::getConfigMetadata(); + + foreach ($publicProperties as $prop => $_) { + $this->{$prop} = ${$prop}; + } + + $description = []; + foreach ($configurableAttributes as $attribute => $_) { + $description[$attribute] = ${$attribute}; + } + } + + $this->hydrateAttributes($description); } } diff --git a/src/Annotation/ApiResource.php b/src/Annotation/ApiResource.php index 831fd76f0cd..1208080d692 100644 --- a/src/Annotation/ApiResource.php +++ b/src/Annotation/ApiResource.php @@ -71,59 +71,18 @@ * @Attribute("validationGroups", type="mixed"), * ) */ +#[\Attribute(\Attribute::TARGET_CLASS)] final class ApiResource { use AttributesHydratorTrait; /** - * @internal - * - * @see \ApiPlatform\Core\Bridge\Symfony\Bundle\DependencyInjection\Configuration::addDefaultsSection + * @var array */ - public const CONFIGURABLE_DEFAULTS = [ - 'accessControl', - 'accessControlMessage', - 'security', - 'securityMessage', - 'securityPostDenormalize', - 'securityPostDenormalizeMessage', - 'cacheHeaders', - 'collectionOperations', - 'denormalizationContext', - 'deprecationReason', - 'description', - 'elasticsearch', - 'fetchPartial', - 'forceEager', - 'formats', - 'filters', - 'graphql', - 'hydraContext', - 'input', - 'iri', - 'itemOperations', - 'mercure', - 'messenger', - 'normalizationContext', - 'openapiContext', - 'order', - 'output', - 'paginationClientEnabled', - 'paginationClientItemsPerPage', - 'paginationClientPartial', - 'paginationEnabled', - 'paginationFetchJoinCollection', - 'paginationItemsPerPage', - 'maximumItemsPerPage', - 'paginationMaximumItemsPerPage', - 'paginationPartial', - 'paginationViaCursor', - 'routePrefix', - 'stateless', - 'sunset', - 'swaggerContext', - 'urlGenerationStrategy', - 'validationGroups', + private static $deprecatedAttributes = [ + 'accessControl' => ['security', '2.5'], + 'accessControlMessage' => ['securityMessage', '2.5'], + 'maximumItemsPerPage' => ['paginationMaximumItemsPerPage', '2.6'], ]; /** @@ -170,293 +129,109 @@ final class ApiResource public $subresourceOperations; /** - * @see https://api-platform.com/docs/core/performance/#setting-custom-http-cache-headers - * @see https://github.com/Haehnchen/idea-php-annotation-plugin/issues/112 - * - * @var array - */ - private $cacheHeaders; - - /** - * @see https://api-platform.com/docs/core/serialization/#using-serialization-groups - * @see https://github.com/Haehnchen/idea-php-annotation-plugin/issues/112 - * - * @var array - */ - private $denormalizationContext; - - /** - * @see https://api-platform.com/docs/core/deprecations/#deprecating-resource-classes-operations-and-properties - * @see https://github.com/Haehnchen/idea-php-annotation-plugin/issues/112 - * - * @var string - */ - private $deprecationReason; - - /** - * @see https://api-platform.com/docs/core/elasticsearch/ - * @see https://github.com/Haehnchen/idea-php-annotation-plugin/issues/112 - * - * @var bool - */ - private $elasticsearch; - - /** - * @see https://api-platform.com/docs/core/performance/#fetch-partial - * @see https://github.com/Haehnchen/idea-php-annotation-plugin/issues/112 - * - * @var bool - */ - private $fetchPartial; - - /** - * @see https://api-platform.com/docs/core/performance/#force-eager - * @see https://github.com/Haehnchen/idea-php-annotation-plugin/issues/112 - * - * @var bool - */ - private $forceEager; - - /** - * @see https://api-platform.com/docs/core/content-negotiation/#configuring-formats-for-a-specific-resource-or-operation - * @see https://github.com/Haehnchen/idea-php-annotation-plugin/issues/112 - * - * @var array - */ - private $formats; - - /** - * @see https://api-platform.com/docs/core/filters/#doctrine-orm-and-mongodb-odm-filters - * @see https://github.com/Haehnchen/idea-php-annotation-plugin/issues/112 - * - * @var string[] - */ - private $filters; - - /** - * @see https://api-platform.com/docs/core/extending-jsonld-context/#hydra - * @see https://github.com/Haehnchen/idea-php-annotation-plugin/issues/112 - * - * @var string[] - */ - private $hydraContext; - - /** - * @see https://api-platform.com/docs/core/dto/#specifying-an-input-or-an-output-data-representation - * @see https://github.com/Haehnchen/idea-php-annotation-plugin/issues/112 - * - * @var string|false - */ - private $input; - - /** - * @see https://github.com/Haehnchen/idea-php-annotation-plugin/issues/112 - * - * @var int - * - * @deprecated - Use $paginationMaximumItemsPerPage instead - */ - private $maximumItemsPerPage; - - /** - * @see https://api-platform.com/docs/core/mercure - * @see https://github.com/Haehnchen/idea-php-annotation-plugin/issues/112 - */ - private $mercure; - - /** - * @see https://api-platform.com/docs/core/messenger/#dispatching-a-resource-through-the-message-bus - * @see https://github.com/Haehnchen/idea-php-annotation-plugin/issues/112 - * - * @var bool|string - */ - private $messenger; - - /** - * @see https://api-platform.com/docs/core/serialization/#using-serialization-groups - * @see https://github.com/Haehnchen/idea-php-annotation-plugin/issues/112 - * - * @var array - */ - private $normalizationContext; - - /** - * @see https://api-platform.com/docs/core/swagger/#using-the-openapi-and-swagger-contexts - * @see https://github.com/Haehnchen/idea-php-annotation-plugin/issues/112 - * - * @var array - */ - private $openapiContext; - - /** - * @see https://api-platform.com/docs/core/default-order/#overriding-default-order - * @see https://github.com/Haehnchen/idea-php-annotation-plugin/issues/112 - * - * @var array - */ - private $order; - - /** - * @see https://api-platform.com/docs/core/dto/#specifying-an-input-or-an-output-data-representation - * @see https://github.com/Haehnchen/idea-php-annotation-plugin/issues/112 - * - * @var string|false - */ - private $output; - - /** - * @see https://api-platform.com/docs/core/pagination/#for-a-specific-resource-1 - * @see https://github.com/Haehnchen/idea-php-annotation-plugin/issues/112 - * - * @var bool - */ - private $paginationClientEnabled; - - /** - * @see https://api-platform.com/docs/core/pagination/#for-a-specific-resource-3 - * @see https://github.com/Haehnchen/idea-php-annotation-plugin/issues/112 - * - * @var bool - */ - private $paginationClientItemsPerPage; - - /** - * @see https://api-platform.com/docs/core/pagination/#for-a-specific-resource-6 - * @see https://github.com/Haehnchen/idea-php-annotation-plugin/issues/112 - * - * @var bool - */ - private $paginationClientPartial; - - /** - * @see https://api-platform.com/docs/core/pagination/#cursor-based-pagination - * @see https://github.com/Haehnchen/idea-php-annotation-plugin/issues/112 - * - * @var array - */ - private $paginationViaCursor; - - /** - * @see https://api-platform.com/docs/core/pagination/#for-a-specific-resource - * @see https://github.com/Haehnchen/idea-php-annotation-plugin/issues/112 - * - * @var bool - */ - private $paginationEnabled; - - /** - * @see https://api-platform.com/docs/core/pagination/#controlling-the-behavior-of-the-doctrine-orm-paginator - * @see https://github.com/Haehnchen/idea-php-annotation-plugin/issues/112 - * - * @var bool - */ - private $paginationFetchJoinCollection; - - /** - * @see https://api-platform.com/docs/core/pagination/#changing-the-number-of-items-per-page - * @see https://github.com/Haehnchen/idea-php-annotation-plugin/issues/112 - * - * @var int - */ - private $paginationItemsPerPage; - - /** - * @see https://api-platform.com/docs/core/pagination/#changing-maximum-items-per-page - * @see https://github.com/Haehnchen/idea-php-annotation-plugin/issues/112 - * - * @var int - */ - private $paginationMaximumItemsPerPage; - - /** - * @see https://api-platform.com/docs/core/performance/#partial-pagination - * @see https://github.com/Haehnchen/idea-php-annotation-plugin/issues/112 + * @param string $description + * @param array $collectionOperations https://api-platform.com/docs/core/operations + * @param array $graphql https://api-platform.com/docs/core/graphql + * @param array $itemOperations https://api-platform.com/docs/core/operations + * @param array $subresourceOperations https://api-platform.com/docs/core/subresources + * @param array $cacheHeaders https://api-platform.com/docs/core/performance/#setting-custom-http-cache-headers + * @param array $denormalizationContext https://api-platform.com/docs/core/serialization/#using-serialization-groups + * @param string $deprecationReason https://api-platform.com/docs/core/deprecations/#deprecating-resource-classes-operations-and-properties + * @param bool $elasticsearch https://api-platform.com/docs/core/elasticsearch/ + * @param bool $fetchPartial https://api-platform.com/docs/core/performance/#fetch-partial + * @param bool $forceEager https://api-platform.com/docs/core/performance/#force-eager + * @param array $formats https://api-platform.com/docs/core/content-negotiation/#configuring-formats-for-a-specific-resource-or-operation + * @param string[] $filters https://api-platform.com/docs/core/filters/#doctrine-orm-and-mongodb-odm-filters + * @param string[] $hydraContext https://api-platform.com/docs/core/extending-jsonld-context/#hydra + * @param string|false $input https://api-platform.com/docs/core/dto/#specifying-an-input-or-an-output-data-representation + * @param bool|array $mercure https://api-platform.com/docs/core/mercure + * @param bool $messenger https://api-platform.com/docs/core/messenger/#dispatching-a-resource-through-the-message-bus + * @param array $normalizationContext https://api-platform.com/docs/core/serialization/#using-serialization-groups + * @param array $openapiContext https://api-platform.com/docs/core/swagger/#using-the-openapi-and-swagger-contexts + * @param array $order https://api-platform.com/docs/core/default-order/#overriding-default-order + * @param string|false $output https://api-platform.com/docs/core/dto/#specifying-an-input-or-an-output-data-representation + * @param bool $paginationClientEnabled https://api-platform.com/docs/core/pagination/#for-a-specific-resource-1 + * @param bool $paginationClientItemsPerPage https://api-platform.com/docs/core/pagination/#for-a-specific-resource-3 + * @param bool $paginationClientPartial https://api-platform.com/docs/core/pagination/#for-a-specific-resource-6 + * @param array $paginationViaCursor https://api-platform.com/docs/core/pagination/#cursor-based-pagination + * @param bool $paginationEnabled https://api-platform.com/docs/core/pagination/#for-a-specific-resource + * @param bool $paginationFetchJoinCollection https://api-platform.com/docs/core/pagination/#controlling-the-behavior-of-the-doctrine-orm-paginator + * @param int $paginationItemsPerPage https://api-platform.com/docs/core/pagination/#changing-the-number-of-items-per-page + * @param int $paginationMaximumItemsPerPage https://api-platform.com/docs/core/pagination/#changing-maximum-items-per-page + * @param bool $paginationPartial https://api-platform.com/docs/core/performance/#partial-pagination + * @param string $routePrefix https://api-platform.com/docs/core/operations/#prefixing-all-routes-of-all-operations + * @param string $security https://api-platform.com/docs/core/security + * @param string $securityMessage https://api-platform.com/docs/core/security/#configuring-the-access-control-error-message + * @param string $securityPostDenormalize https://api-platform.com/docs/core/security/#executing-access-control-rules-after-denormalization + * @param string $securityPostDenormalizeMessage https://api-platform.com/docs/core/security/#configuring-the-access-control-error-message + * @param bool $stateless + * @param string $sunset https://api-platform.com/docs/core/deprecations/#setting-the-sunset-http-header-to-indicate-when-a-resource-or-an-operation-will-be-removed + * @param array $swaggerContext https://api-platform.com/docs/core/swagger/#using-the-openapi-and-swagger-contexts + * @param array $validationGroups https://api-platform.com/docs/core/validation/#using-validation-groups + * @param int $urlGenerationStrategy * - * @var bool - */ - private $paginationPartial; - - /** - * @see https://api-platform.com/docs/core/operations/#prefixing-all-routes-of-all-operations - * @see https://github.com/Haehnchen/idea-php-annotation-plugin/issues/112 - * - * @var string - */ - private $routePrefix; - - /** - * @see https://api-platform.com/docs/core/security - * @see https://github.com/Haehnchen/idea-php-annotation-plugin/issues/112 - * - * @var string - */ - private $security; - - /** - * @see https://api-platform.com/docs/core/security/#configuring-the-access-control-error-message - * @see https://github.com/Haehnchen/idea-php-annotation-plugin/issues/112 - * - * @var string - */ - private $securityMessage; - - /** - * @see https://api-platform.com/docs/core/security/#executing-access-control-rules-after-denormalization - * @see https://github.com/Haehnchen/idea-php-annotation-plugin/issues/112 - * - * @var string - */ - private $securityPostDenormalize; - - /** - * @see https://api-platform.com/docs/core/security/#configuring-the-access-control-error-message - * @see https://github.com/Haehnchen/idea-php-annotation-plugin/issues/112 - * - * @var string - */ - private $securityPostDenormalizeMessage; - - /** - * @see https://github.com/Haehnchen/idea-php-annotation-plugin/issues/112 - * - * @var bool - */ - private $stateless; - - /** - * @see https://api-platform.com/docs/core/deprecations/#setting-the-sunset-http-header-to-indicate-when-a-resource-or-an-operation-will-be-removed - * @see https://github.com/Haehnchen/idea-php-annotation-plugin/issues/112 - * - * @var string - */ - private $sunset; - - /** - * @see https://api-platform.com/docs/core/swagger/#using-the-openapi-and-swagger-contexts - * @see https://github.com/Haehnchen/idea-php-annotation-plugin/issues/112 - * - * @var array - */ - private $swaggerContext; - - /** - * @see https://api-platform.com/docs/core/validation/#using-validation-groups - * @see https://github.com/Haehnchen/idea-php-annotation-plugin/issues/112 - */ - private $validationGroups; - - /** - * @see https://github.com/Haehnchen/idea-php-annotation-plugin/issues/112 - * - * @var int - */ - private $urlGenerationStrategy; - - /** * @throws InvalidArgumentException */ - public function __construct(array $values = []) - { - $this->hydrateAttributes($values); + public function __construct( + $description = null, + array $collectionOperations = [], + array $graphql = [], + string $iri = '', + array $itemOperations = [], + string $shortName = '', + array $subresourceOperations = [], + + // attributes + ?array $attributes = null, + ?array $cacheHeaders = null, + ?array $denormalizationContext = null, + ?string $deprecationReason = null, + ?bool $elasticsearch = null, + ?bool $fetchPartial = null, + ?bool $forceEager = null, + ?array $formats = null, + ?array $filters = null, + ?array $hydraContext = null, + $input = null, + $mercure = null, + $messenger = null, + ?array $normalizationContext = null, + ?array $openapiContext = null, + ?array $order = null, + $output = null, + ?bool $paginationClientEnabled = null, + ?bool $paginationClientItemsPerPage = null, + ?bool $paginationClientPartial = null, + ?array $paginationViaCursor = null, + ?bool $paginationEnabled = null, + ?bool $paginationFetchJoinCollection = null, + ?int $paginationItemsPerPage = null, + ?int $paginationMaximumItemsPerPage = null, + ?bool $paginationPartial = null, + ?string $routePrefix = null, + ?string $security = null, + ?string $securityMessage = null, + ?string $securityPostDenormalize = null, + ?string $securityPostDenormalizeMessage = null, + ?bool $stateless = null, + ?string $sunset = null, + ?array $swaggerContext = null, + ?array $validationGroups = null, + ?int $urlGenerationStrategy = null + ) { + if (!\is_array($description)) { // @phpstan-ignore-line Doctrine annotations support + [$publicProperties, $configurableAttributes] = self::getConfigMetadata(); + + foreach ($publicProperties as $prop => $_) { + $this->{$prop} = ${$prop}; + } + + $description = []; + foreach ($configurableAttributes as $attribute => $_) { + $description[$attribute] = ${$attribute}; + } + } + + $this->hydrateAttributes($description ?? []); } } diff --git a/src/Annotation/AttributesHydratorTrait.php b/src/Annotation/AttributesHydratorTrait.php index 1211f73b6a1..9b13ab480b7 100644 --- a/src/Annotation/AttributesHydratorTrait.php +++ b/src/Annotation/AttributesHydratorTrait.php @@ -26,6 +26,34 @@ */ trait AttributesHydratorTrait { + private static $configMetadata; + + /** + * @internal + */ + public static function getConfigMetadata(): array + { + if (null !== self::$configMetadata) { + return self::$configMetadata; + } + + $rc = new \ReflectionClass(self::class); + + $publicProperties = []; + foreach ($rc->getProperties(\ReflectionProperty::IS_PUBLIC) as $reflectionProperty) { + $publicProperties[$reflectionProperty->getName()] = true; + } + + $configurableAttributes = []; + foreach ($rc->getConstructor()->getParameters() as $param) { + if (!isset($publicProperties[$name = $param->getName()])) { + $configurableAttributes[$name] = true; + } + } + + return [$publicProperties, $configurableAttributes]; + } + /** * @var array */ @@ -41,24 +69,22 @@ private function hydrateAttributes(array $values): void unset($values['attributes']); } - if (\array_key_exists('accessControl', $values)) { - $values['security'] = $values['accessControl']; - @trigger_error('Attribute "accessControl" is deprecated in annotation since API Platform 2.5, prefer using "security" attribute instead', E_USER_DEPRECATED); - unset($values['accessControl']); - } - if (\array_key_exists('accessControlMessage', $values)) { - $values['securityMessage'] = $values['accessControlMessage']; - @trigger_error('Attribute "accessControlMessage" is deprecated in annotation since API Platform 2.5, prefer using "securityMessage" attribute instead', E_USER_DEPRECATED); - unset($values['accessControlMessage']); + foreach (self::$deprecatedAttributes as $deprecatedAttribute => $options) { + if (\array_key_exists($deprecatedAttribute, $values)) { + $values[$options[0]] = $values[$deprecatedAttribute]; + @trigger_error(sprintf('Attribute "%s" is deprecated in annotation since API Platform %s, prefer using "%s" attribute instead', $deprecatedAttribute, $options[1], $options[0]), E_USER_DEPRECATED); + unset($values[$deprecatedAttribute]); + } } + [$publicProperties, $configurableAttributes] = self::getConfigMetadata(); foreach ($values as $key => $value) { $key = (string) $key; - if (!property_exists($this, $key)) { + if (!isset($publicProperties[$key]) && !isset($configurableAttributes[$key]) && !isset(self::$deprecatedAttributes[$key])) { throw new InvalidArgumentException(sprintf('Unknown property "%s" on annotation "%s".', $key, self::class)); } - if ((new \ReflectionProperty($this, $key))->isPublic()) { + if (isset($publicProperties[$key])) { $this->{$key} = $value; continue; } diff --git a/src/Bridge/Symfony/Bundle/DependencyInjection/ApiPlatformExtension.php b/src/Bridge/Symfony/Bundle/DependencyInjection/ApiPlatformExtension.php index 04b9ff08090..15c9cebba52 100644 --- a/src/Bridge/Symfony/Bundle/DependencyInjection/ApiPlatformExtension.php +++ b/src/Bridge/Symfony/Bundle/DependencyInjection/ApiPlatformExtension.php @@ -13,6 +13,7 @@ namespace ApiPlatform\Core\Bridge\Symfony\Bundle\DependencyInjection; +use ApiPlatform\Core\Annotation\ApiResource; use ApiPlatform\Core\Api\FilterInterface; use ApiPlatform\Core\Api\UrlGeneratorInterface; use ApiPlatform\Core\Bridge\Doctrine\MongoDbOdm\Extension\AggregationCollectionExtensionInterface; @@ -242,21 +243,19 @@ private function getPaginationDefaults(array $defaults, array $collectionPaginat private function normalizeDefaults(array $defaults): array { - $normalizedDefaults = ['attributes' => []]; - $rootLevelOptions = [ - 'description', - 'iri', - 'item_operations', - 'collection_operations', - 'graphql', - ]; + $normalizedDefaults = ['attributes' => $defaults['attributes'] ?? []]; + unset($defaults['attributes']); + + [$publicProperties,] = ApiResource::getConfigMetadata(); foreach ($defaults as $option => $value) { - if (\in_array($option, $rootLevelOptions, true)) { + if (isset($publicProperties[$option])) { $normalizedDefaults[$option] = $value; - } else { - $normalizedDefaults['attributes'][$option] = $value; + + continue; } + + $normalizedDefaults['attributes'][$option] = $value; } if (!\array_key_exists('stateless', $defaults)) { diff --git a/src/Bridge/Symfony/Bundle/DependencyInjection/Configuration.php b/src/Bridge/Symfony/Bundle/DependencyInjection/Configuration.php index d46115b8c64..ee5381b10a0 100644 --- a/src/Bridge/Symfony/Bundle/DependencyInjection/Configuration.php +++ b/src/Bridge/Symfony/Bundle/DependencyInjection/Configuration.php @@ -598,7 +598,7 @@ private function addDefaultsSection(ArrayNodeDefinition $rootNode): void return $normalizedDefaults; }); - foreach (ApiResource::CONFIGURABLE_DEFAULTS as $attribute) { + foreach (ApiResource::getConfigMetadata()[1] as $attribute => $_) { $snakeCased = $nameConverter->normalize($attribute); $defaultsNode->children()->variableNode($snakeCased); } diff --git a/src/Metadata/Property/Factory/AnnotationPropertyMetadataFactory.php b/src/Metadata/Property/Factory/AnnotationPropertyMetadataFactory.php index 9362aa5d6e2..0a8efcbe4a2 100644 --- a/src/Metadata/Property/Factory/AnnotationPropertyMetadataFactory.php +++ b/src/Metadata/Property/Factory/AnnotationPropertyMetadataFactory.php @@ -29,7 +29,7 @@ final class AnnotationPropertyMetadataFactory implements PropertyMetadataFactory private $reader; private $decorated; - public function __construct(Reader $reader, PropertyMetadataFactoryInterface $decorated = null) + public function __construct(Reader $reader = null, PropertyMetadataFactoryInterface $decorated = null) { $this->reader = $reader; $this->decorated = $decorated; @@ -56,7 +56,13 @@ public function create(string $resourceClass, string $property, array $options = } if ($reflectionClass->hasProperty($property)) { - $annotation = $this->reader->getPropertyAnnotation($reflectionClass->getProperty($property), ApiProperty::class); + $annotation = null; + $reflectionProperty = $reflectionClass->getProperty($property); + if (\PHP_VERSION_ID >= 80000 && $attributes = $reflectionProperty->getAttributes(ApiProperty::class)) { + $annotation = $attributes[0]->newInstance(); + } elseif (null !== $this->reader) { + $annotation = $this->reader->getPropertyAnnotation($reflectionProperty, ApiProperty::class); + } if ($annotation instanceof ApiProperty) { return $this->createMetadata($annotation, $parentPropertyMetadata); @@ -74,7 +80,12 @@ public function create(string $resourceClass, string $property, array $options = continue; } - $annotation = $this->reader->getMethodAnnotation($reflectionMethod, ApiProperty::class); + $annotation = null; + if (\PHP_VERSION_ID >= 80000 && $attributes = $reflectionMethod->getAttributes(ApiProperty::class)) { + $annotation = $attributes[0]->newInstance(); + } elseif (null !== $this->reader) { + $annotation = $this->reader->getMethodAnnotation($reflectionMethod, ApiProperty::class); + } if ($annotation instanceof ApiProperty) { return $this->createMetadata($annotation, $parentPropertyMetadata); diff --git a/src/Metadata/Property/Factory/AnnotationPropertyNameCollectionFactory.php b/src/Metadata/Property/Factory/AnnotationPropertyNameCollectionFactory.php index a847c13e8ef..fd1aa59e67f 100644 --- a/src/Metadata/Property/Factory/AnnotationPropertyNameCollectionFactory.php +++ b/src/Metadata/Property/Factory/AnnotationPropertyNameCollectionFactory.php @@ -30,7 +30,7 @@ final class AnnotationPropertyNameCollectionFactory implements PropertyNameColle private $decorated; private $reflection; - public function __construct(Reader $reader, PropertyNameCollectionFactoryInterface $decorated = null) + public function __construct(Reader $reader = null, PropertyNameCollectionFactoryInterface $decorated = null) { $this->reader = $reader; $this->decorated = $decorated; @@ -66,7 +66,10 @@ public function create(string $resourceClass, array $options = []): PropertyName // Properties foreach ($reflectionClass->getProperties() as $reflectionProperty) { - if (null !== $this->reader->getPropertyAnnotation($reflectionProperty, ApiProperty::class)) { + if ( + (\PHP_VERSION_ID >= 80000 && $reflectionProperty->getAttributes(ApiProperty::class)) || + (null !== $this->reader && null !== $this->reader->getPropertyAnnotation($reflectionProperty, ApiProperty::class)) + ) { $propertyNames[$reflectionProperty->name] = $reflectionProperty->name; } } @@ -82,7 +85,13 @@ public function create(string $resourceClass, array $options = []): PropertyName $propertyName = lcfirst($propertyName); } - if (null !== $propertyName && null !== $this->reader->getMethodAnnotation($reflectionMethod, ApiProperty::class)) { + if ( + null !== $propertyName && + ( + (\PHP_VERSION_ID >= 80000 && $reflectionMethod->getAttributes(ApiProperty::class)) || + (null !== $this->reader && null !== $this->reader->getMethodAnnotation($reflectionMethod, ApiProperty::class)) + ) + ) { $propertyNames[$propertyName] = $propertyName; } } diff --git a/src/Metadata/Resource/Factory/AnnotationResourceMetadataFactory.php b/src/Metadata/Resource/Factory/AnnotationResourceMetadataFactory.php index e55a024519d..cc9bc054499 100644 --- a/src/Metadata/Resource/Factory/AnnotationResourceMetadataFactory.php +++ b/src/Metadata/Resource/Factory/AnnotationResourceMetadataFactory.php @@ -29,7 +29,7 @@ final class AnnotationResourceMetadataFactory implements ResourceMetadataFactory private $decorated; private $defaults; - public function __construct(Reader $reader, ResourceMetadataFactoryInterface $decorated = null, array $defaults = []) + public function __construct(Reader $reader = null, ResourceMetadataFactoryInterface $decorated = null, array $defaults = []) { $this->reader = $reader; $this->decorated = $decorated; @@ -56,6 +56,14 @@ public function create(string $resourceClass): ResourceMetadata return $this->handleNotFound($parentResourceMetadata, $resourceClass); } + if (\PHP_VERSION_ID >= 80000 && $attributes = $reflectionClass->getAttributes(ApiResource::class)) { + return $this->createMetadata($attributes[0]->newInstance(), $parentResourceMetadata); + } + + if (null === $this->reader) { + $this->handleNotFound($parentResourceMetadata, $resourceClass); + } + $resourceAnnotation = $this->reader->getClassAnnotation($reflectionClass, ApiResource::class); if (!$resourceAnnotation instanceof ApiResource) { return $this->handleNotFound($parentResourceMetadata, $resourceClass); diff --git a/src/Metadata/Resource/Factory/AnnotationResourceNameCollectionFactory.php b/src/Metadata/Resource/Factory/AnnotationResourceNameCollectionFactory.php index 102da335e0f..949e3b0a844 100644 --- a/src/Metadata/Resource/Factory/AnnotationResourceNameCollectionFactory.php +++ b/src/Metadata/Resource/Factory/AnnotationResourceNameCollectionFactory.php @@ -32,7 +32,7 @@ final class AnnotationResourceNameCollectionFactory implements ResourceNameColle /** * @param string[] $paths */ - public function __construct(Reader $reader, array $paths, ResourceNameCollectionFactoryInterface $decorated = null) + public function __construct(Reader $reader = null, array $paths, ResourceNameCollectionFactoryInterface $decorated = null) { $this->reader = $reader; $this->paths = $paths; @@ -53,7 +53,10 @@ public function create(): ResourceNameCollection } foreach (ReflectionClassRecursiveIterator::getReflectionClassesFromDirectories($this->paths) as $className => $reflectionClass) { - if ($this->reader->getClassAnnotation($reflectionClass, ApiResource::class)) { + if ( + (\PHP_VERSION_ID >= 80000 && $reflectionClass->getAttributes(ApiResource::class)) || + (null !== $this->reader && $this->reader->getClassAnnotation($reflectionClass, ApiResource::class)) + ) { $classes[$className] = true; } } diff --git a/tests/Annotation/ApiPropertyTest.php b/tests/Annotation/ApiPropertyTest.php index aa550d42e4f..f4c1ec790c0 100644 --- a/tests/Annotation/ApiPropertyTest.php +++ b/tests/Annotation/ApiPropertyTest.php @@ -70,4 +70,37 @@ public function testConstruct() 'unknown' => 'unknown', ], $property->attributes); } + + /** + * @requires PHP 8.0 + */ + public function testConstructAttribute() + { + $property = eval(<<<'PHP' +return new \ApiPlatform\Core\Annotation\ApiProperty( + deprecationReason: 'this field is deprecated', + fetchable: true, + fetchEager: false, + jsonldContext: ['foo' => 'bar'], + security: 'is_granted(\'ROLE_ADMIN\')', + swaggerContext: ['foo' => 'baz'], + openapiContext: ['foo' => 'baz'], + push: true, + attributes: ['unknown' => 'unknown', 'fetchable' => false] +); +PHP + ); + + $this->assertEquals([ + 'deprecation_reason' => 'this field is deprecated', + 'fetchable' => false, + 'fetch_eager' => false, + 'jsonld_context' => ['foo' => 'bar'], + 'security' => 'is_granted(\'ROLE_ADMIN\')', + 'swagger_context' => ['foo' => 'baz'], + 'openapi_context' => ['foo' => 'baz'], + 'push' => true, + 'unknown' => 'unknown', + ], $property->attributes); + } } diff --git a/tests/Annotation/ApiResourceTest.php b/tests/Annotation/ApiResourceTest.php index 48e040c0ac9..82d2c210ec7 100644 --- a/tests/Annotation/ApiResourceTest.php +++ b/tests/Annotation/ApiResourceTest.php @@ -17,6 +17,7 @@ use ApiPlatform\Core\Api\UrlGeneratorInterface; use ApiPlatform\Core\Exception\InvalidArgumentException; use ApiPlatform\Core\Tests\Fixtures\AnnotatedClass; +use ApiPlatform\Core\Tests\Fixtures\TestBundle\Entity\DummyPhp8; use Doctrine\Common\Annotations\AnnotationReader; use PHPUnit\Framework\TestCase; @@ -56,7 +57,6 @@ public function testConstruct() 'paginationEnabled' => true, 'paginationFetchJoinCollection' => true, 'paginationItemsPerPage' => 42, - 'maximumItemsPerPage' => 42, // deprecated, see paginationMaximumItemsPerPage 'paginationMaximumItemsPerPage' => 50, 'paginationPartial' => true, 'routePrefix' => '/foo', @@ -99,7 +99,6 @@ public function testConstruct() 'pagination_enabled' => true, 'pagination_fetch_join_collection' => true, 'pagination_items_per_page' => 42, - 'maximum_items_per_page' => 42, 'pagination_maximum_items_per_page' => 50, 'pagination_partial' => true, 'route_prefix' => '/foo', @@ -111,6 +110,114 @@ public function testConstruct() ], $resource->attributes); } + /** + * @requires PHP 8.0 + */ + public function testConstructAttribute() + { + $resource = eval(<<<'PHP' +return new \ApiPlatform\Core\Annotation\ApiResource( + security: 'is_granted("ROLE_FOO")', + securityMessage: 'You are not foo.', + securityPostDenormalize: 'is_granted("ROLE_BAR")', + securityPostDenormalizeMessage: 'You are not bar.', + attributes: ['foo' => 'bar', 'validation_groups' => ['baz', 'qux'], 'cache_headers' => ['max_age' => 0, 'shared_max_age' => 0, 'vary' => ['Custom-Vary-1', 'Custom-Vary-2']]], + collectionOperations: ['bar' => ['foo']], + denormalizationContext: ['groups' => ['foo']], + description: 'description', + fetchPartial: true, + forceEager: false, + formats: ['foo', 'bar' => ['application/bar']], + filters: ['foo', 'bar'], + graphql: ['query' => ['normalization_context' => ['groups' => ['foo', 'bar']]]], + input: 'Foo', + iri: 'http://example.com/res', + itemOperations: ['foo' => ['bar']], + mercure: ['private' => true], + messenger: true, + normalizationContext: ['groups' => ['bar']], + order: ['foo', 'bar' => 'ASC'], + openapiContext: ['description' => 'foo'], + output: 'Bar', + paginationClientEnabled: true, + paginationClientItemsPerPage: true, + paginationClientPartial: true, + paginationEnabled: true, + paginationFetchJoinCollection: true, + paginationItemsPerPage: 42, + paginationMaximumItemsPerPage: 50, + paginationPartial: true, + routePrefix: '/foo', + shortName: 'shortName', + subresourceOperations: [], + swaggerContext: ['description' => 'bar'], + validationGroups: ['foo', 'bar'], + sunset: 'Thu, 11 Oct 2018 00:00:00 +0200', + urlGenerationStrategy: \ApiPlatform\Core\Api\UrlGeneratorInterface::ABS_PATH, + deprecationReason: 'reason', + elasticsearch: true, + hydraContext: ['hydra' => 'foo'], + paginationViaCursor: ['foo'], + stateless: true, +); +PHP + ); + + $this->assertSame('shortName', $resource->shortName); + $this->assertSame('description', $resource->description); + $this->assertSame('http://example.com/res', $resource->iri); + $this->assertSame(['foo' => ['bar']], $resource->itemOperations); + $this->assertSame(['bar' => ['foo']], $resource->collectionOperations); + $this->assertSame([], $resource->subresourceOperations); + $this->assertSame(['query' => ['normalization_context' => ['groups' => ['foo', 'bar']]]], $resource->graphql); + $this->assertEquals([ + 'security' => 'is_granted("ROLE_FOO")', + 'security_message' => 'You are not foo.', + 'security_post_denormalize' => 'is_granted("ROLE_BAR")', + 'security_post_denormalize_message' => 'You are not bar.', + 'denormalization_context' => ['groups' => ['foo']], + 'fetch_partial' => true, + 'foo' => 'bar', + 'force_eager' => false, + 'formats' => ['foo', 'bar' => ['application/bar']], + 'filters' => ['foo', 'bar'], + 'input' => 'Foo', + 'mercure' => ['private' => true], + 'messenger' => true, + 'normalization_context' => ['groups' => ['bar']], + 'order' => ['foo', 'bar' => 'ASC'], + 'openapi_context' => ['description' => 'foo'], + 'output' => 'Bar', + 'pagination_client_enabled' => true, + 'pagination_client_items_per_page' => true, + 'pagination_client_partial' => true, + 'pagination_enabled' => true, + 'pagination_fetch_join_collection' => true, + 'pagination_items_per_page' => 42, + 'pagination_maximum_items_per_page' => 50, + 'pagination_partial' => true, + 'route_prefix' => '/foo', + 'swagger_context' => ['description' => 'bar'], + 'validation_groups' => ['baz', 'qux'], + 'cache_headers' => ['max_age' => 0, 'shared_max_age' => 0, 'vary' => ['Custom-Vary-1', 'Custom-Vary-2']], + 'sunset' => 'Thu, 11 Oct 2018 00:00:00 +0200', + 'url_generation_strategy' => 1, + 'deprecation_reason' => 'reason', + 'elasticsearch' => true, + 'hydra_context' => ['hydra' => 'foo'], + 'pagination_via_cursor' => ['foo'], + 'stateless' => true, + ], $resource->attributes); + } + + /** + * @requires PHP 8.0 + */ + public function testUseAttribute() + { + $this->assertSame('Hey PHP 8', (new \ReflectionClass(DummyPhp8::class))->getAttributes(ApiResource::class)[0]->getArguments()['description']); + } + public function testApiResourceAnnotation() { $reader = new AnnotationReader(); diff --git a/tests/Fixtures/TestBundle/Entity/DummyPhp8.php b/tests/Fixtures/TestBundle/Entity/DummyPhp8.php new file mode 100644 index 00000000000..28d3f91d5ae --- /dev/null +++ b/tests/Fixtures/TestBundle/Entity/DummyPhp8.php @@ -0,0 +1,30 @@ + + * + * 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\Core\Tests\Fixtures\TestBundle\Entity; + +use ApiPlatform\Core\Annotation\ApiProperty; +use ApiPlatform\Core\Annotation\ApiResource; + +#[ApiResource(description: "Hey PHP 8")] +class DummyPhp8 +{ + #[ApiProperty(identifier: true, description: 'the identifier')] + public $id; + + #[ApiProperty(description: 'a foo')] + public function getFoo(): int + { + return 0; + } +} diff --git a/tests/Metadata/Property/Factory/AnnotationPropertyMetadataFactoryTest.php b/tests/Metadata/Property/Factory/AnnotationPropertyMetadataFactoryTest.php index 6da8905aee7..aa906801559 100644 --- a/tests/Metadata/Property/Factory/AnnotationPropertyMetadataFactoryTest.php +++ b/tests/Metadata/Property/Factory/AnnotationPropertyMetadataFactoryTest.php @@ -19,6 +19,7 @@ use ApiPlatform\Core\Metadata\Property\Factory\PropertyMetadataFactoryInterface; use ApiPlatform\Core\Metadata\Property\PropertyMetadata; use ApiPlatform\Core\Tests\Fixtures\TestBundle\Entity\Dummy; +use ApiPlatform\Core\Tests\Fixtures\TestBundle\Entity\DummyPhp8; use ApiPlatform\Core\Tests\ProphecyTrait; use Doctrine\Common\Annotations\Reader; use PHPUnit\Framework\TestCase; @@ -50,6 +51,21 @@ public function testCreateProperty($reader, $decorated, string $description) $this->assertEquals(['foo' => 'bar'], $metadata->getAttributes()); } + /** + * @requires PHP 8.0 + */ + public function testCreateAttribute() + { + $factory = new AnnotationPropertyMetadataFactory(); + + $metadata = $factory->create(DummyPhp8::class, 'id'); + $this->assertTrue($metadata->isIdentifier()); + $this->assertSame('the identifier', $metadata->getDescription()); + + $metadata = $factory->create(DummyPhp8::class, 'foo'); + $this->assertSame('a foo', $metadata->getDescription()); + } + public function dependenciesProvider(): array { $annotation = new ApiProperty(); diff --git a/tests/Metadata/Property/Factory/AnnotationPropertyNameCollectionFactoryTest.php b/tests/Metadata/Property/Factory/AnnotationPropertyNameCollectionFactoryTest.php index c5e3280de4a..3aeccc4c767 100644 --- a/tests/Metadata/Property/Factory/AnnotationPropertyNameCollectionFactoryTest.php +++ b/tests/Metadata/Property/Factory/AnnotationPropertyNameCollectionFactoryTest.php @@ -19,6 +19,7 @@ use ApiPlatform\Core\Metadata\Property\Factory\PropertyNameCollectionFactoryInterface; use ApiPlatform\Core\Metadata\Property\PropertyNameCollection; use ApiPlatform\Core\Tests\Fixtures\TestBundle\Entity\Dummy; +use ApiPlatform\Core\Tests\Fixtures\TestBundle\Entity\DummyPhp8; use ApiPlatform\Core\Tests\Fixtures\TestBundle\Entity\UpperCaseIdentifierDummy; use ApiPlatform\Core\Tests\ProphecyTrait; use Doctrine\Common\Annotations\Reader; @@ -66,6 +67,17 @@ public function dependenciesProvider(): array ]; } + /** + * @requires PHP 8.0 + */ + public function testCreateAttribute() + { + $factory = new AnnotationPropertyNameCollectionFactory(); + $metadata = $factory->create(DummyPhp8::class); + + $this->assertSame(['id', 'foo'], iterator_to_array($metadata)); + } + /** * @dataProvider upperCaseDependenciesProvider */ diff --git a/tests/Metadata/Resource/Factory/AnnotationResourceMetadataFactoryTest.php b/tests/Metadata/Resource/Factory/AnnotationResourceMetadataFactoryTest.php index 6e974afc1bd..caeb6fb60c6 100644 --- a/tests/Metadata/Resource/Factory/AnnotationResourceMetadataFactoryTest.php +++ b/tests/Metadata/Resource/Factory/AnnotationResourceMetadataFactoryTest.php @@ -19,6 +19,7 @@ use ApiPlatform\Core\Metadata\Resource\Factory\ResourceMetadataFactoryInterface; use ApiPlatform\Core\Metadata\Resource\ResourceMetadata; use ApiPlatform\Core\Tests\Fixtures\TestBundle\Entity\Dummy; +use ApiPlatform\Core\Tests\Fixtures\TestBundle\Entity\DummyPhp8; use ApiPlatform\Core\Tests\ProphecyTrait; use Doctrine\Common\Annotations\Reader; use PHPUnit\Framework\TestCase; @@ -49,6 +50,17 @@ public function testCreate($reader, $decorated, string $expectedShortName, ?stri $this->assertEquals(['foo' => 'bar'], $metadata->getGraphql()); } + /** + * @requires PHP 8.0 + */ + public function testCreateAttribute() + { + $factory = new AnnotationResourceMetadataFactory(); + $metadata = $factory->create(DummyPhp8::class); + + $this->assertSame('Hey PHP 8', $metadata->getDescription()); + } + public function testCreateWithDefaults() { $defaults = [ diff --git a/tests/Metadata/Resource/Factory/AnnotationResourceNameCollectionFactoryTest.php b/tests/Metadata/Resource/Factory/AnnotationResourceNameCollectionFactoryTest.php index 6fb863ffa09..ac542c9175c 100644 --- a/tests/Metadata/Resource/Factory/AnnotationResourceNameCollectionFactoryTest.php +++ b/tests/Metadata/Resource/Factory/AnnotationResourceNameCollectionFactoryTest.php @@ -38,4 +38,16 @@ public function testCreate() $this->assertEquals(new ResourceNameCollection(['foo', 'bar']), $metadata->create()); } + + /** + * @requires PHP 8.0 + */ + public function testCreateAttribute() + { + $decorated = $this->prophesize(ResourceNameCollectionFactoryInterface::class); + $decorated->create()->willReturn(new ResourceNameCollection(['foo', 'bar']))->shouldBeCalled(); + + $metadata = new AnnotationResourceNameCollectionFactory(null, [], $decorated->reveal()); + $this->assertEquals(new ResourceNameCollection(['foo', 'bar']), $metadata->create()); + } }