From 6a7d09d0462e9a0f7a12d4de82b0969c296fe1b9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?K=C3=A9vin=20Dunglas?= Date: Fri, 27 Nov 2020 00:14:54 +0100 Subject: [PATCH] feat: add an ApiResource PHP 8 attribute --- .gitignore | 1 + src/Annotation/ApiResource.php | 118 +++++++++++++++++- .../AnnotationResourceMetadataFactory.php | 10 +- tests/Annotation/ApiResourceTest.php | 109 ++++++++++++++++ .../Fixtures/TestBundle/Entity/DummyPhp8.php | 26 ++++ .../AnnotationResourceMetadataFactoryTest.php | 12 ++ 6 files changed, 270 insertions(+), 6 deletions(-) create mode 100644 tests/Fixtures/TestBundle/Entity/DummyPhp8.php 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/src/Annotation/ApiResource.php b/src/Annotation/ApiResource.php index 831fd76f0cd..ef323528bf2 100644 --- a/src/Annotation/ApiResource.php +++ b/src/Annotation/ApiResource.php @@ -71,18 +71,28 @@ * @Attribute("validationGroups", type="mixed"), * ) */ +#[\Attribute(\Attribute::TARGET_CLASS)] final class ApiResource { use AttributesHydratorTrait; + private const PUBLIC_PROPERTIES = [ + 'description', + 'collectionOperations', + 'graphql', + 'iri', + 'itemOperations', + 'shortName', + 'subresourceOperations', + ]; + /** * @internal * * @see \ApiPlatform\Core\Bridge\Symfony\Bundle\DependencyInjection\Configuration::addDefaultsSection */ public const CONFIGURABLE_DEFAULTS = [ - 'accessControl', - 'accessControlMessage', + 'attributes', 'security', 'securityMessage', 'securityPostDenormalize', @@ -114,7 +124,6 @@ final class ApiResource 'paginationEnabled', 'paginationFetchJoinCollection', 'paginationItemsPerPage', - 'maximumItemsPerPage', 'paginationMaximumItemsPerPage', 'paginationPartial', 'paginationViaCursor', @@ -453,10 +462,109 @@ final class ApiResource private $urlGenerationStrategy; /** + * @param array|string $valuesOrDescription + * @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 + * * @throws InvalidArgumentException */ - public function __construct(array $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 +) { - $this->hydrateAttributes($values); + if (!is_array($description)) { + foreach (self::PUBLIC_PROPERTIES as $prop) { + $this->$prop = $$prop; + } + + $description = []; + foreach (array_diff(self::CONFIGURABLE_DEFAULTS, self::PUBLIC_PROPERTIES) as $attribute) { + $description[$attribute] = $$attribute; + } + } + + $this->hydrateAttributes($description); } } 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/tests/Annotation/ApiResourceTest.php b/tests/Annotation/ApiResourceTest.php index 48e040c0ac9..5c8340549f5 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; @@ -111,6 +112,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..563c37af068 --- /dev/null +++ b/tests/Fixtures/TestBundle/Entity/DummyPhp8.php @@ -0,0 +1,26 @@ + + * + * 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) + */ + public $id; +} 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 = [