From 96eb2b2110776ddeee46ac0a0479d83d13f7aba8 Mon Sep 17 00:00:00 2001 From: "vinayak.iyer" Date: Tue, 20 Oct 2026 04:37:54 +0000 Subject: [PATCH 01/16] Allow Symfony 7.4 and run CI on PHP 8.4, Symfony 7 and lowest dependencies Every Symfony constraint gains the 7.x range: ^7.4, the supported Symfony 7 line, for framework-bundle, security-bundle, validator and http-kernel, and ^7.0 for the other components FrameworkBundle 6.4 already accepts at 7.x. The seven components the code uses directly are required explicitly, psr/log 3 is accepted, and doctrine/annotations starts at 1.14.4 / 2.0.2, the first releases whose reader refuses opcache.save_comments=0. The workflow adds PHP 8.4 and Symfony 7.*, and runs every line with lowest dependencies where that combination installs. Co-Authored-By: Claude Fable 5.1 Torus-Session: 925cfa36-8312-40a5-a58d-89755fd7d827 --- .github/workflows/ci.yml | 26 +++++++++++++++++++++++--- composer.json | 23 +++++++++++++++-------- 2 files changed, 38 insertions(+), 11 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 4b05237..5975bcb 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -26,26 +26,46 @@ jobs: - '8.1' - '8.2' - '8.3' + - '8.4' symfony: - '3.*' - '4.*' - '5.*' - '6.*' + - '7.*' dependency: + - 'lowest' - 'highest' - include: - - { php: '7.1', symfony: '3.*', dependency: 'lowest' } exclude: - { php: '8.0', symfony: '3.*' } - { php: '8.1', symfony: '3.*' } - { php: '8.2', symfony: '3.*' } - { php: '8.3', symfony: '3.*' } + - { php: '8.4', symfony: '3.*' } - { php: '7.1', symfony: '5.*' } - { php: '7.1', symfony: '6.*' } - { php: '7.2', symfony: '6.*' } - { php: '7.3', symfony: '6.*' } - { php: '7.4', symfony: '6.*' } - - { php: '8.0', symfony: '6.*' } + - { php: '7.1', symfony: '7.*' } + - { php: '7.2', symfony: '7.*' } + - { php: '7.3', symfony: '7.*' } + - { php: '7.4', symfony: '7.*' } + - { php: '8.0', symfony: '7.*' } + - { php: '8.1', symfony: '7.*' } + - { php: '8.0', symfony: '4.*', dependency: 'lowest' } + - { php: '8.1', symfony: '4.*', dependency: 'lowest' } + - { php: '8.1', symfony: '5.*', dependency: 'lowest' } + - { php: '8.1', symfony: '6.*', dependency: 'lowest' } + - { php: '8.2', symfony: '4.*', dependency: 'lowest' } + - { php: '8.2', symfony: '5.*', dependency: 'lowest' } + - { php: '8.2', symfony: '6.*', dependency: 'lowest' } + - { php: '8.3', symfony: '4.*', dependency: 'lowest' } + - { php: '8.3', symfony: '5.*', dependency: 'lowest' } + - { php: '8.3', symfony: '6.*', dependency: 'lowest' } + - { php: '8.4', symfony: '4.*', dependency: 'lowest' } + - { php: '8.4', symfony: '5.*', dependency: 'lowest' } + - { php: '8.4', symfony: '6.*', dependency: 'lowest' } steps: - name: Checkout diff --git a/composer.json b/composer.json index f80e408..c48dbaf 100644 --- a/composer.json +++ b/composer.json @@ -17,24 +17,31 @@ "require": { "php": "^7.1 || ^8.0", "ext-json": "*", - "symfony/framework-bundle": "^3.4.34|^4.3|^5.4|^6.0", - "symfony/security-bundle": "^3.4.34|^4.3|^5.4|^6.0", - "symfony/validator": "^3.4.34|^4.3|^5.4|^6.0", + "symfony/config": "^3.4.34|^4.3|^5.4|^6.0|^7.0", + "symfony/dependency-injection": "^3.4.34|^4.3|^5.4|^6.0|^7.0", + "symfony/framework-bundle": "^3.4.34|^4.3|^5.4|^6.0|^7.4", + "symfony/http-foundation": "^3.4.34|^4.3|^5.4|^6.0|^7.0", + "symfony/http-kernel": "^3.4.34|^4.3|^5.4|^6.0|^7.4", + "symfony/property-access": "^3.4.34|^4.3|^5.4|^6.0|^7.0", + "symfony/routing": "^3.4.34|^4.3|^5.4|^6.0|^7.0", + "symfony/security-bundle": "^3.4.34|^4.3|^5.4|^6.0|^7.4", + "symfony/security-core": "^3.4.34|^4.3|^5.4|^6.0|^7.0", + "symfony/validator": "^3.4.34|^4.3|^5.4|^6.0|^7.4", "paysera/lib-normalization-bundle": "^1.1.0", "paysera/lib-normalization": "^1.2", "paysera/lib-object-wrapper": "~0.1", "paysera/lib-pagination": "^1.0", "paysera/lib-dependency-injection": "^1.3.0", - "psr/log": "^1.0|^2.0", + "psr/log": "^1.0|^2.0|^3.0", "doctrine/persistence": "^1.3.8 || ^2.0.1 || ^3.0", - "doctrine/annotations": "^1.14 || ^2.0" + "doctrine/annotations": "^1.14.4 || ^2.0.2" }, "require-dev": { "phpunit/phpunit": "^7.5 || ^9.6", "mockery/mockery": "^1.3.6", - "symfony/yaml": "^3.4.34|^4.3|^5.4|^6.0", - "doctrine/doctrine-bundle": "^1.12.0|^2.1", - "doctrine/orm": "^2.5.14" + "symfony/yaml": "^3.4.34|^4.3|^5.4|^6.0|^7.4", + "doctrine/doctrine-bundle": "^1.12.0|^2.5", + "doctrine/orm": "^2.6.3" }, "config": { "bin-dir": "bin" From 53cb92d2904becb047c4b8330d7407223d9e5675 Mon Sep 17 00:00:00 2001 From: "vinayak.iyer" Date: Tue, 20 Oct 2026 04:37:54 +0000 Subject: [PATCH 02/16] Declare the TreeBuilder return type and explicit nullable parameters Symfony 7 declares getConfigTreeBuilder(): TreeBuilder on ConfigurationInterface, so Configuration must too; the class is marked @final since the return type breaks an override without it. The eleven optional parameters typed as `Type $x = null` become `?Type $x = null`, which PHP 8.4 deprecates otherwise. Co-Authored-By: Claude Fable 5.1 Torus-Session: 925cfa36-8312-40a5-a58d-89755fd7d827 --- src/Attribute/Body.php | 2 +- src/Attribute/BodyContentType.php | 2 +- src/Attribute/PathAttribute.php | 4 ++-- src/Attribute/Query.php | 2 +- src/Attribute/RequiredPermissions.php | 2 +- src/DependencyInjection/Configuration.php | 5 ++++- src/Entity/QueryResolverOptions.php | 2 +- src/Exception/ApiException.php | 2 +- src/Listener/RestExceptionListener.php | 2 +- src/Service/Validation/EntityValidator.php | 4 ++-- .../FixtureTestBundle/DependencyInjection/Configuration.php | 5 +---- tests/Unit/Listener/RestExceptionListenerTest.php | 2 +- tests/Unit/Normalizer/PagedQueryNormalizerTest.php | 2 +- .../Service/RoutingLoader/ReflectionMethodWrapperTest.php | 2 +- 14 files changed, 19 insertions(+), 19 deletions(-) diff --git a/src/Attribute/Body.php b/src/Attribute/Body.php index a04ded8..8dcd5f5 100644 --- a/src/Attribute/Body.php +++ b/src/Attribute/Body.php @@ -34,7 +34,7 @@ class Body implements RestAttributeInterface public function __construct( array $options = [], - string $parameterName = null, + ?string $parameterName = null, ?string $denormalizationType = null, ?string $denormalizationGroup = null, ?bool $optional = null diff --git a/src/Attribute/BodyContentType.php b/src/Attribute/BodyContentType.php index d1b0f1a..d42f9ea 100644 --- a/src/Attribute/BodyContentType.php +++ b/src/Attribute/BodyContentType.php @@ -23,7 +23,7 @@ class BodyContentType implements RestAttributeInterface public function __construct( array $options = [], - array $supportedContentTypes = null, + ?array $supportedContentTypes = null, bool $jsonEncodedBody = false ) { $this->setSupportedContentTypes($options['supportedContentTypes'] ?? $supportedContentTypes); diff --git a/src/Attribute/PathAttribute.php b/src/Attribute/PathAttribute.php index 471f9a7..fca6827 100644 --- a/src/Attribute/PathAttribute.php +++ b/src/Attribute/PathAttribute.php @@ -35,8 +35,8 @@ class PathAttribute implements RestAttributeInterface public function __construct( array $options = [], - string $parameterName = null, - string $pathPartName = null, + ?string $parameterName = null, + ?string $pathPartName = null, ?string $resolverType = null, ?bool $resolutionMandatory = null ) { diff --git a/src/Attribute/Query.php b/src/Attribute/Query.php index ea47fb8..0a23815 100644 --- a/src/Attribute/Query.php +++ b/src/Attribute/Query.php @@ -35,7 +35,7 @@ class Query implements RestAttributeInterface public function __construct( array $options = [], - string $parameterName = null, + ?string $parameterName = null, ?string $denormalizationType = null, ?string $denormalizationGroup = null, ?Validation $validation = null diff --git a/src/Attribute/RequiredPermissions.php b/src/Attribute/RequiredPermissions.php index 648d0c1..4960d7a 100644 --- a/src/Attribute/RequiredPermissions.php +++ b/src/Attribute/RequiredPermissions.php @@ -18,7 +18,7 @@ class RequiredPermissions implements RestAttributeInterface public function __construct( array $options = [], - array $permissions = null + ?array $permissions = null ) { $this->setPermissions($options['permissions'] ?? $permissions); } diff --git a/src/DependencyInjection/Configuration.php b/src/DependencyInjection/Configuration.php index 99b4c98..088fca6 100644 --- a/src/DependencyInjection/Configuration.php +++ b/src/DependencyInjection/Configuration.php @@ -8,9 +8,12 @@ use Symfony\Component\Config\Definition\Builder\TreeBuilder; use Symfony\Component\Config\Definition\ConfigurationInterface; +/** + * @final + */ class Configuration implements ConfigurationInterface { - public function getConfigTreeBuilder() + public function getConfigTreeBuilder(): TreeBuilder { $treeBuilder = new TreeBuilder('paysera_api'); $rootNode = method_exists($treeBuilder, 'getRootNode') diff --git a/src/Entity/QueryResolverOptions.php b/src/Entity/QueryResolverOptions.php index b898a53..e3701c9 100644 --- a/src/Entity/QueryResolverOptions.php +++ b/src/Entity/QueryResolverOptions.php @@ -58,7 +58,7 @@ public function setDenormalizationGroup($denormalizationGroup): self * @param ValidationOptions|null $validationOptions * @return $this */ - public function setValidationOptions(ValidationOptions $validationOptions = null): self + public function setValidationOptions(?ValidationOptions $validationOptions = null): self { $this->validationOptions = $validationOptions; return $this; diff --git a/src/Exception/ApiException.php b/src/Exception/ApiException.php index ae8d096..010c1ad 100644 --- a/src/Exception/ApiException.php +++ b/src/Exception/ApiException.php @@ -60,7 +60,7 @@ public function __construct( $errorCode, $message = null, $statusCode = null, - Exception $previous = null, + ?Exception $previous = null, $properties = null, $data = null, array $violations = [] diff --git a/src/Listener/RestExceptionListener.php b/src/Listener/RestExceptionListener.php index 3d71a9b..6b3e9e8 100644 --- a/src/Listener/RestExceptionListener.php +++ b/src/Listener/RestExceptionListener.php @@ -32,7 +32,7 @@ public function __construct( ErrorBuilderInterface $errorBuilder, CoreNormalizer $coreNormalizer, ResponseBuilder $responseBuilder, - LoggerInterface $logger = null + ?LoggerInterface $logger = null ) { $this->requestHelper = $requestHelper; $this->errorBuilder = $errorBuilder; diff --git a/src/Service/Validation/EntityValidator.php b/src/Service/Validation/EntityValidator.php index 830d215..4335bae 100644 --- a/src/Service/Validation/EntityValidator.php +++ b/src/Service/Validation/EntityValidator.php @@ -26,8 +26,8 @@ class EntityValidator protected $propertyPathConverter; public function __construct( - ValidatorInterface $validator = null, - PropertyPathConverterInterface $propertyPathConverter = null + ?ValidatorInterface $validator = null, + ?PropertyPathConverterInterface $propertyPathConverter = null ) { $this->validator = $validator; $this->propertyPathConverter = $propertyPathConverter; diff --git a/tests/Functional/Fixtures/FixtureTestBundle/DependencyInjection/Configuration.php b/tests/Functional/Fixtures/FixtureTestBundle/DependencyInjection/Configuration.php index 31ced6a..87392e8 100644 --- a/tests/Functional/Fixtures/FixtureTestBundle/DependencyInjection/Configuration.php +++ b/tests/Functional/Fixtures/FixtureTestBundle/DependencyInjection/Configuration.php @@ -13,10 +13,7 @@ */ class Configuration implements ConfigurationInterface { - /** - * {@inheritdoc} - */ - public function getConfigTreeBuilder() + public function getConfigTreeBuilder(): TreeBuilder { $treeBuilder = new TreeBuilder('paysera_fixture_test'); diff --git a/tests/Unit/Listener/RestExceptionListenerTest.php b/tests/Unit/Listener/RestExceptionListenerTest.php index 3bdc879..f042855 100644 --- a/tests/Unit/Listener/RestExceptionListenerTest.php +++ b/tests/Unit/Listener/RestExceptionListenerTest.php @@ -29,7 +29,7 @@ class RestExceptionListenerTest extends MockeryTestCase * @param int $statusCode * @param string|null $logLevel */ - public function testOnKernelException(bool $restRequest, int $statusCode = 400, string $logLevel = null) + public function testOnKernelException(bool $restRequest, int $statusCode = 400, ?string $logLevel = null) { $helper = Mockery::mock(RestRequestHelper::class); $errorBuilder = Mockery::mock(ErrorBuilderInterface::class); diff --git a/tests/Unit/Normalizer/PagedQueryNormalizerTest.php b/tests/Unit/Normalizer/PagedQueryNormalizerTest.php index 9cf702a..a912137 100644 --- a/tests/Unit/Normalizer/PagedQueryNormalizerTest.php +++ b/tests/Unit/Normalizer/PagedQueryNormalizerTest.php @@ -85,7 +85,7 @@ public function testNormalize( array $explicitlyIncluded, string $defaultStrategy, string $queryStrategy, - int $maximumOffset = null + ?int $maximumOffset = null ) { $normalizationContext = Mockery::mock(NormalizationContext::class); diff --git a/tests/Unit/Service/RoutingLoader/ReflectionMethodWrapperTest.php b/tests/Unit/Service/RoutingLoader/ReflectionMethodWrapperTest.php index 030e782..dce8753 100644 --- a/tests/Unit/Service/RoutingLoader/ReflectionMethodWrapperTest.php +++ b/tests/Unit/Service/RoutingLoader/ReflectionMethodWrapperTest.php @@ -58,7 +58,7 @@ public function testGetFriendlyName(): void ); } - public function fixtureMethod(string $param1, DateTime $param2 = null, $param3 = null): string + public function fixtureMethod(string $param1, ?DateTime $param2 = null, $param3 = null): string { return $param1; } From 804670740fc41929eef227594e6db979c023bf6b Mon Sep 17 00:00:00 2001 From: "vinayak.iyer" Date: Tue, 20 Oct 2026 04:37:54 +0000 Subject: [PATCH 03/16] Register the services in PHP instead of XML Symfony 7.4 deprecates the XML configuration format and 8.0 removes its loader. The extension now builds the same 24 definitions (23 in one container, which registers one of the two route loaders), the alias, the tags and the parameters with the Definition API, which exists unchanged from Symfony 3.4 to 7.4, and the six XML files are removed. A test dumps every definition and compares it with a snapshot of what the XML produced. The extension extends the DependencyInjection component's Extension rather than HttpKernel's, which is internal since Symfony 7.1. Co-Authored-By: Claude Fable 5.1 Torus-Session: 925cfa36-8312-40a5-a58d-89755fd7d827 --- .../PayseraApiExtension.php | 246 ++++++++++- src/Resources/config/services.xml | 105 ----- src/Resources/config/services/annotations.xml | 20 - src/Resources/config/services/attributes.xml | 23 - src/Resources/config/services/listeners.xml | 49 --- src/Resources/config/services/normalizers.xml | 37 -- src/Resources/config/services/pagination.xml | 24 - .../Fixtures/services.json | 412 ++++++++++++++++++ .../PayseraApiExtensionTest.php | 166 +++++++ 9 files changed, 803 insertions(+), 279 deletions(-) delete mode 100644 src/Resources/config/services.xml delete mode 100644 src/Resources/config/services/annotations.xml delete mode 100644 src/Resources/config/services/attributes.xml delete mode 100644 src/Resources/config/services/listeners.xml delete mode 100644 src/Resources/config/services/normalizers.xml delete mode 100644 src/Resources/config/services/pagination.xml create mode 100644 tests/Unit/DependencyInjection/Fixtures/services.json create mode 100644 tests/Unit/DependencyInjection/PayseraApiExtensionTest.php diff --git a/src/DependencyInjection/PayseraApiExtension.php b/src/DependencyInjection/PayseraApiExtension.php index 2a1db01..877f86b 100644 --- a/src/DependencyInjection/PayseraApiExtension.php +++ b/src/DependencyInjection/PayseraApiExtension.php @@ -5,43 +5,70 @@ namespace Paysera\Bundle\ApiBundle\DependencyInjection; use Doctrine\Persistence\ObjectRepository; +use Paysera\Bundle\ApiBundle\Listener\LocaleListener; +use Paysera\Bundle\ApiBundle\Listener\RestExceptionListener; +use Paysera\Bundle\ApiBundle\Listener\RestRequestListener; +use Paysera\Bundle\ApiBundle\Listener\RestResponseListener; +use Paysera\Bundle\ApiBundle\Normalizer\ErrorNormalizer; +use Paysera\Bundle\ApiBundle\Normalizer\Pagination\PagedQueryNormalizer; +use Paysera\Bundle\ApiBundle\Normalizer\Pagination\PagerDenormalizer; +use Paysera\Bundle\ApiBundle\Normalizer\Pagination\ResultNormalizer; +use Paysera\Bundle\ApiBundle\Normalizer\ViolationNormalizer; +use Paysera\Bundle\ApiBundle\Service\ContentTypeMatcher; +use Paysera\Bundle\ApiBundle\Service\ErrorBuilder; use Paysera\Bundle\ApiBundle\Service\PathAttributeResolver\DoctrinePathAttributeResolver; +use Paysera\Bundle\ApiBundle\Service\PathAttributeResolver\PathAttributeResolutionManager; +use Paysera\Bundle\ApiBundle\Service\PathAttributeResolver\PathAttributeResolverRegistry; +use Paysera\Bundle\ApiBundle\Service\ResponseBuilder; +use Paysera\Bundle\ApiBundle\Service\RestRequestHelper; +use Paysera\Bundle\ApiBundle\Service\RestRequestOptionsRegistry; +use Paysera\Bundle\ApiBundle\Service\RestRequestOptionsValidator; +use Paysera\Bundle\ApiBundle\Service\RoutingLoader\RestRequestAnnotationOptionsBuilder; +use Paysera\Bundle\ApiBundle\Service\RoutingLoader\RestRequestAttributeOptionsBuilder; +use Paysera\Bundle\ApiBundle\Service\RoutingLoader\RoutingAnnotationLoader; +use Paysera\Bundle\ApiBundle\Service\RoutingLoader\RoutingAttributeLoader; +use Paysera\Bundle\ApiBundle\Service\Validation\CamelCaseToSnakeCaseConverter; +use Paysera\Bundle\ApiBundle\Service\Validation\EntityValidator; +use Paysera\Pagination\Service\CursorBuilder; +use Paysera\Pagination\Service\Doctrine\QueryAnalyser; +use Paysera\Pagination\Service\Doctrine\ResultProvider; use RuntimeException; use Symfony\Bundle\FrameworkBundle\Routing\AttributeRouteControllerLoader; -use Symfony\Component\Config\FileLocator; +use Symfony\Component\DependencyInjection\Alias; +use Symfony\Component\DependencyInjection\ChildDefinition; use Symfony\Component\DependencyInjection\ContainerBuilder; +use Symfony\Component\DependencyInjection\ContainerInterface; use Symfony\Component\DependencyInjection\Definition; -use Symfony\Component\DependencyInjection\Loader; +use Symfony\Component\DependencyInjection\Extension\Extension; use Symfony\Component\DependencyInjection\Reference; -use Symfony\Component\HttpKernel\DependencyInjection\Extension; +use Symfony\Component\PropertyAccess\PropertyAccess; +use Symfony\Component\PropertyAccess\PropertyAccessor; class PayseraApiExtension extends Extension { + /** + * @return void + */ public function load(array $configs, ContainerBuilder $container) { $configuration = new Configuration(); $config = $this->processConfiguration($configuration, $configs); - $loader = new Loader\XmlFileLoader($container, new FileLocator(__DIR__ . '/../Resources/config')); - $loader->load('services.xml'); - - $loader = new Loader\XmlFileLoader($container, new FileLocator(__DIR__ . '/../Resources/config/services')); - class_exists(AttributeRouteControllerLoader::class) - ? $loader->load('attributes.xml') - : $loader->load('annotations.xml') - ; - - $container->setParameter('paysera_api.locales', $config['locales']); - if (count($config['locales']) === 0) { - $container->removeDefinition('paysera_api.listener.locale'); + if (count($config['locales']) > 0) { + $container->setDefinition('paysera_api.listener.locale', $this->createLocaleListenerDefinition()); } + $container->addDefinitions($this->createListenerDefinitions()); + $container->addDefinitions($this->createNormalizerDefinitions()); + $container->addDefinitions($this->createPaginationDefinitions()); + $container->addDefinitions($this->createServiceDefinitions()); + $container->setAlias( + 'paysera_api.validation.property_path_converter', + $config['validation']['property_path_converter'] + ?? new Alias('paysera_api.camel_case_to_snake_case_converter', false) + ); + $container->addDefinitions($this->createRoutingLoaderDefinitions()); - if ($config['validation']['property_path_converter'] !== null) { - $container->setAlias( - 'paysera_api.validation.property_path_converter', - $config['validation']['property_path_converter'] - ); - } + $container->setParameter('paysera_api.locales', $config['locales']); if (count($config['path_attribute_resolvers']) > 0 && !class_exists('Doctrine\ORM\EntityManager')) { throw new RuntimeException( @@ -59,6 +86,183 @@ class_exists(AttributeRouteControllerLoader::class) $this->configurePagination($container, $config['pagination']); } + private function createLocaleListenerDefinition(): Definition + { + return (new Definition(LocaleListener::class, [ + new Reference('paysera_api.rest_request_helper'), + '%paysera_api.locales%', + ]))->addTag('kernel.event_listener', ['event' => 'kernel.request', 'priority' => 20]); + } + + /** + * @return array + */ + private function createListenerDefinitions(): array + { + $restRequestHelper = new Reference('paysera_api.rest_request_helper'); + $coreNormalizer = new Reference('paysera_normalization.core_normalizer'); + $responseBuilder = new Reference('paysera_api.response_builder'); + + return [ + 'paysera_api.listener.rest_exception' => (new Definition(RestExceptionListener::class, [ + $restRequestHelper, + new Reference('paysera_api.error_builder'), + $coreNormalizer, + $responseBuilder, + new Reference('logger', ContainerInterface::NULL_ON_INVALID_REFERENCE), + ]))->addTag('kernel.event_listener', ['event' => 'kernel.exception', 'priority' => 1]), + 'paysera_api.listener.rest_request' => (new Definition(RestRequestListener::class, [ + new Reference('paysera_normalization.core_denormalizer'), + new Reference('security.authorization_checker'), + new Reference('security.token_storage'), + $restRequestHelper, + new Reference('paysera_api.entity_validator'), + new Reference('paysera_api.content_type_matcher'), + new Reference('paysera_api.path_attribute_resolution_manager'), + ])) + ->addTag('kernel.event_listener', ['event' => 'kernel.controller']) + ->addTag('kernel.event_listener', ['event' => 'kernel.request', 'priority' => 31]), + 'paysera_api.listener.rest_response' => (new Definition(RestResponseListener::class, [ + $coreNormalizer, + $restRequestHelper, + $responseBuilder, + ]))->addTag('kernel.event_listener', ['event' => 'kernel.view']), + ]; + } + + /** + * @return array + */ + private function createNormalizerDefinitions(): array + { + $tag = 'paysera_normalization.autoconfigured_normalizer'; + + return [ + 'paysera_api.normalizer.violation' => (new Definition(ViolationNormalizer::class))->addTag($tag), + 'paysera_api.normalizer.error' => (new Definition(ErrorNormalizer::class))->addTag($tag), + 'paysera_api.normalizer.result' => (new Definition(ResultNormalizer::class))->addTag($tag), + 'paysera_api.normalizer.paged_query' => (new Definition(PagedQueryNormalizer::class, [ + new Reference('paysera_api.pagination.result_provider'), + '%paysera_api.pagination.default_total_count_strategy%', + '%paysera_api.pagination.maximum_offset%', + ]))->addTag($tag), + 'paysera_api.normalizer.pager_denormalizer' => (new Definition(PagerDenormalizer::class, [ + '%paysera_api.pagination.default_limit%', + '%paysera_api.pagination.maximum_limit%', + ]))->addTag($tag), + ]; + } + + /** + * @return array + */ + private function createPaginationDefinitions(): array + { + $propertyAccessor = (new Definition(PropertyAccessor::class)) + ->setFactory([PropertyAccess::class, 'createPropertyAccessor']) + ; + + return [ + 'paysera_api.pagination.result_provider' => new Definition(ResultProvider::class, [ + new Definition(QueryAnalyser::class), + new Definition(CursorBuilder::class, [$propertyAccessor]), + ]), + ]; + } + + /** + * @return array + */ + private function createServiceDefinitions(): array + { + $optionsValidator = new Reference('paysera_api.rest_request_options_validator'); + $resolverRegistry = new Reference('paysera_api.path_attribute_resolver_registry'); + + return [ + 'paysera_api.rest_request_options_registry' => new Definition(RestRequestOptionsRegistry::class), + 'paysera_api.rest_request_helper' => new Definition(RestRequestHelper::class, [ + new Reference('paysera_api.rest_request_options_registry'), + ]), + 'paysera_api.annotations.options_builder' => new Definition( + RestRequestAnnotationOptionsBuilder::class, + [$optionsValidator] + ), + 'paysera_api.attributes.options_builder' => new Definition( + RestRequestAttributeOptionsBuilder::class, + [$optionsValidator] + ), + 'paysera_api.response_builder' => new Definition(ResponseBuilder::class), + 'paysera_api.error_builder' => $this->createErrorBuilderDefinition(), + 'paysera_api.content_type_matcher' => new Definition(ContentTypeMatcher::class), + 'paysera_api.camel_case_to_snake_case_converter' => new Definition(CamelCaseToSnakeCaseConverter::class), + 'paysera_api.entity_validator' => new Definition(EntityValidator::class, [ + new Reference('validator', ContainerInterface::NULL_ON_INVALID_REFERENCE), + new Reference('paysera_api.validation.property_path_converter'), + ]), + 'paysera_api.rest_request_options_validator' => new Definition(RestRequestOptionsValidator::class, [ + new Reference('paysera_normalization.normalizer_registry'), + $resolverRegistry, + ]), + 'paysera_api.path_attribute_resolver_registry' => new Definition(PathAttributeResolverRegistry::class), + 'paysera_api.path_attribute_resolution_manager' => new Definition( + PathAttributeResolutionManager::class, + [$resolverRegistry] + ), + ]; + } + + private function createErrorBuilderDefinition(): Definition + { + $definition = new Definition(ErrorBuilder::class); + $errors = [ + ['invalid_request', 400, 'Request content is invalid'], + ['invalid_parameters', 400, 'Some required parameter is missing or it\'s format is invalid'], + ['invalid_state', 409, 'Requested action cannot be made to the current state of resource'], + ['unauthorized', 401, 'You have not provided any credentials or they are invalid'], + ['forbidden', 403, 'You have no rights to access requested resource or make requested action'], + ['not_found', 404, 'Resource was not found'], + ['internal_server_error', 500, 'Unexpected internal system error'], + ['not_acceptable', 406, 'Unknown request or response format'], + ]; + foreach ($errors as $error) { + $definition->addMethodCall('configureError', $error); + } + + return $definition; + } + + /** + * @return array + */ + private function createRoutingLoaderDefinitions(): array + { + $restRequestHelper = new Reference('paysera_api.rest_request_helper'); + $annotationOptionsBuilder = new Reference('paysera_api.annotations.options_builder'); + + if (!class_exists(AttributeRouteControllerLoader::class)) { + return [ + 'paysera_api.annotations.loader' => (new ChildDefinition('routing.loader.annotation')) + ->setClass(RoutingAnnotationLoader::class) + ->setDecoratedService('routing.loader.annotation') + ->addMethodCall('setRequestHelper', [$restRequestHelper]) + ->addMethodCall('setRestRequestOptionsBuilder', [$annotationOptionsBuilder]), + ]; + } + + $attributeLoader = (new ChildDefinition('routing.loader.attribute')) + ->setClass(RoutingAttributeLoader::class) + ->setDecoratedService('routing.loader.attribute') + ->addMethodCall('setRequestHelper', [$restRequestHelper]) + ->addMethodCall('setRestRequestAnnotationOptionsBuilder', [$annotationOptionsBuilder]) + ->addMethodCall( + 'setRestRequestAttributeOptionsBuilder', + [new Reference('paysera_api.attributes.options_builder')] + ) + ; + + return ['paysera_api.loader.attribute' => $attributeLoader]; + } + private function buildPathAttributeResolverDefinition(string $className, string $field): Definition { $repositoryDefinition = (new Definition(ObjectRepository::class, [$className])) diff --git a/src/Resources/config/services.xml b/src/Resources/config/services.xml deleted file mode 100644 index cccb84d..0000000 --- a/src/Resources/config/services.xml +++ /dev/null @@ -1,105 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - invalid_request - 400 - Request content is invalid - - - invalid_parameters - 400 - Some required parameter is missing or it's format is invalid - - - invalid_state - 409 - Requested action cannot be made to the current state of resource - - - unauthorized - 401 - You have not provided any credentials or they are invalid - - - forbidden - 403 - You have no rights to access requested resource or make requested action - - - not_found - 404 - Resource was not found - - - internal_server_error - 500 - Unexpected internal system error - - - not_acceptable - 406 - Unknown request or response format - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/src/Resources/config/services/annotations.xml b/src/Resources/config/services/annotations.xml deleted file mode 100644 index 35b69eb..0000000 --- a/src/Resources/config/services/annotations.xml +++ /dev/null @@ -1,20 +0,0 @@ - - - - - - - - - - - - - - - diff --git a/src/Resources/config/services/attributes.xml b/src/Resources/config/services/attributes.xml deleted file mode 100644 index 8ecc323..0000000 --- a/src/Resources/config/services/attributes.xml +++ /dev/null @@ -1,23 +0,0 @@ - - - - - - - - - - - - - - - - - - diff --git a/src/Resources/config/services/listeners.xml b/src/Resources/config/services/listeners.xml deleted file mode 100644 index 2324df9..0000000 --- a/src/Resources/config/services/listeners.xml +++ /dev/null @@ -1,49 +0,0 @@ - - - - - - - - - - %paysera_api.locales% - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/src/Resources/config/services/normalizers.xml b/src/Resources/config/services/normalizers.xml deleted file mode 100644 index f2e00dd..0000000 --- a/src/Resources/config/services/normalizers.xml +++ /dev/null @@ -1,37 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - %paysera_api.pagination.default_total_count_strategy% - %paysera_api.pagination.maximum_offset% - - - - - %paysera_api.pagination.default_limit% - %paysera_api.pagination.maximum_limit% - - - diff --git a/src/Resources/config/services/pagination.xml b/src/Resources/config/services/pagination.xml deleted file mode 100644 index f4707d3..0000000 --- a/src/Resources/config/services/pagination.xml +++ /dev/null @@ -1,24 +0,0 @@ - - - - - - - - - - - - - - - - - - - - diff --git a/tests/Unit/DependencyInjection/Fixtures/services.json b/tests/Unit/DependencyInjection/Fixtures/services.json new file mode 100644 index 0000000..83f7bc1 --- /dev/null +++ b/tests/Unit/DependencyInjection/Fixtures/services.json @@ -0,0 +1,412 @@ +{ + "paysera_api.annotations.loader": { + "class": "Paysera\\Bundle\\ApiBundle\\Service\\RoutingLoader\\RoutingAnnotationLoader", + "parent": "routing.loader.annotation", + "calls": [ + [ + "setRequestHelper", + [ + { + "reference": "paysera_api.rest_request_helper", + "invalid_behavior": 1 + } + ] + ], + [ + "setRestRequestOptionsBuilder", + [ + { + "reference": "paysera_api.annotations.options_builder", + "invalid_behavior": 1 + } + ] + ] + ], + "decorated": [ + "routing.loader.annotation", + null, + 0 + ] + }, + "paysera_api.annotations.options_builder": { + "class": "Paysera\\Bundle\\ApiBundle\\Service\\RoutingLoader\\RestRequestAnnotationOptionsBuilder", + "arguments": [ + { + "reference": "paysera_api.rest_request_options_validator", + "invalid_behavior": 1 + } + ] + }, + "paysera_api.attributes.options_builder": { + "class": "Paysera\\Bundle\\ApiBundle\\Service\\RoutingLoader\\RestRequestAttributeOptionsBuilder", + "arguments": [ + { + "reference": "paysera_api.rest_request_options_validator", + "invalid_behavior": 1 + } + ] + }, + "paysera_api.camel_case_to_snake_case_converter": { + "class": "Paysera\\Bundle\\ApiBundle\\Service\\Validation\\CamelCaseToSnakeCaseConverter" + }, + "paysera_api.content_type_matcher": { + "class": "Paysera\\Bundle\\ApiBundle\\Service\\ContentTypeMatcher" + }, + "paysera_api.entity_validator": { + "class": "Paysera\\Bundle\\ApiBundle\\Service\\Validation\\EntityValidator", + "arguments": [ + { + "reference": "validator", + "invalid_behavior": 2 + }, + { + "reference": "paysera_api.validation.property_path_converter", + "invalid_behavior": 1 + } + ] + }, + "paysera_api.error_builder": { + "class": "Paysera\\Bundle\\ApiBundle\\Service\\ErrorBuilder", + "calls": [ + [ + "configureError", + [ + "invalid_request", + 400, + "Request content is invalid" + ] + ], + [ + "configureError", + [ + "invalid_parameters", + 400, + "Some required parameter is missing or it's format is invalid" + ] + ], + [ + "configureError", + [ + "invalid_state", + 409, + "Requested action cannot be made to the current state of resource" + ] + ], + [ + "configureError", + [ + "unauthorized", + 401, + "You have not provided any credentials or they are invalid" + ] + ], + [ + "configureError", + [ + "forbidden", + 403, + "You have no rights to access requested resource or make requested action" + ] + ], + [ + "configureError", + [ + "not_found", + 404, + "Resource was not found" + ] + ], + [ + "configureError", + [ + "internal_server_error", + 500, + "Unexpected internal system error" + ] + ], + [ + "configureError", + [ + "not_acceptable", + 406, + "Unknown request or response format" + ] + ] + ] + }, + "paysera_api.listener.locale": { + "class": "Paysera\\Bundle\\ApiBundle\\Listener\\LocaleListener", + "arguments": [ + { + "reference": "paysera_api.rest_request_helper", + "invalid_behavior": 1 + }, + "%paysera_api.locales%" + ], + "tags": { + "kernel.event_listener": [ + { + "event": "kernel.request", + "priority": 20 + } + ] + } + }, + "paysera_api.listener.rest_exception": { + "class": "Paysera\\Bundle\\ApiBundle\\Listener\\RestExceptionListener", + "arguments": [ + { + "reference": "paysera_api.rest_request_helper", + "invalid_behavior": 1 + }, + { + "reference": "paysera_api.error_builder", + "invalid_behavior": 1 + }, + { + "reference": "paysera_normalization.core_normalizer", + "invalid_behavior": 1 + }, + { + "reference": "paysera_api.response_builder", + "invalid_behavior": 1 + }, + { + "reference": "logger", + "invalid_behavior": 2 + } + ], + "tags": { + "kernel.event_listener": [ + { + "event": "kernel.exception", + "priority": 1 + } + ] + } + }, + "paysera_api.listener.rest_request": { + "class": "Paysera\\Bundle\\ApiBundle\\Listener\\RestRequestListener", + "arguments": [ + { + "reference": "paysera_normalization.core_denormalizer", + "invalid_behavior": 1 + }, + { + "reference": "security.authorization_checker", + "invalid_behavior": 1 + }, + { + "reference": "security.token_storage", + "invalid_behavior": 1 + }, + { + "reference": "paysera_api.rest_request_helper", + "invalid_behavior": 1 + }, + { + "reference": "paysera_api.entity_validator", + "invalid_behavior": 1 + }, + { + "reference": "paysera_api.content_type_matcher", + "invalid_behavior": 1 + }, + { + "reference": "paysera_api.path_attribute_resolution_manager", + "invalid_behavior": 1 + } + ], + "tags": { + "kernel.event_listener": [ + { + "event": "kernel.controller" + }, + { + "event": "kernel.request", + "priority": 31 + } + ] + } + }, + "paysera_api.listener.rest_response": { + "class": "Paysera\\Bundle\\ApiBundle\\Listener\\RestResponseListener", + "arguments": [ + { + "reference": "paysera_normalization.core_normalizer", + "invalid_behavior": 1 + }, + { + "reference": "paysera_api.rest_request_helper", + "invalid_behavior": 1 + }, + { + "reference": "paysera_api.response_builder", + "invalid_behavior": 1 + } + ], + "tags": { + "kernel.event_listener": [ + { + "event": "kernel.view" + } + ] + } + }, + "paysera_api.loader.attribute": { + "class": "Paysera\\Bundle\\ApiBundle\\Service\\RoutingLoader\\RoutingAttributeLoader", + "parent": "routing.loader.attribute", + "calls": [ + [ + "setRequestHelper", + [ + { + "reference": "paysera_api.rest_request_helper", + "invalid_behavior": 1 + } + ] + ], + [ + "setRestRequestAnnotationOptionsBuilder", + [ + { + "reference": "paysera_api.annotations.options_builder", + "invalid_behavior": 1 + } + ] + ], + [ + "setRestRequestAttributeOptionsBuilder", + [ + { + "reference": "paysera_api.attributes.options_builder", + "invalid_behavior": 1 + } + ] + ] + ], + "decorated": [ + "routing.loader.attribute", + null, + 0 + ] + }, + "paysera_api.normalizer.error": { + "class": "Paysera\\Bundle\\ApiBundle\\Normalizer\\ErrorNormalizer", + "tags": { + "paysera_normalization.autoconfigured_normalizer": [ + [] + ] + } + }, + "paysera_api.normalizer.paged_query": { + "class": "Paysera\\Bundle\\ApiBundle\\Normalizer\\Pagination\\PagedQueryNormalizer", + "arguments": [ + { + "reference": "paysera_api.pagination.result_provider", + "invalid_behavior": 1 + }, + "%paysera_api.pagination.default_total_count_strategy%", + "%paysera_api.pagination.maximum_offset%" + ], + "tags": { + "paysera_normalization.autoconfigured_normalizer": [ + [] + ] + } + }, + "paysera_api.normalizer.pager_denormalizer": { + "class": "Paysera\\Bundle\\ApiBundle\\Normalizer\\Pagination\\PagerDenormalizer", + "arguments": [ + "%paysera_api.pagination.default_limit%", + "%paysera_api.pagination.maximum_limit%" + ], + "tags": { + "paysera_normalization.autoconfigured_normalizer": [ + [] + ] + } + }, + "paysera_api.normalizer.result": { + "class": "Paysera\\Bundle\\ApiBundle\\Normalizer\\Pagination\\ResultNormalizer", + "tags": { + "paysera_normalization.autoconfigured_normalizer": [ + [] + ] + } + }, + "paysera_api.normalizer.violation": { + "class": "Paysera\\Bundle\\ApiBundle\\Normalizer\\ViolationNormalizer", + "tags": { + "paysera_normalization.autoconfigured_normalizer": [ + [] + ] + } + }, + "paysera_api.pagination.result_provider": { + "class": "Paysera\\Pagination\\Service\\Doctrine\\ResultProvider", + "arguments": [ + { + "inline": { + "class": "Paysera\\Pagination\\Service\\Doctrine\\QueryAnalyser" + } + }, + { + "inline": { + "class": "Paysera\\Pagination\\Service\\CursorBuilder", + "arguments": [ + { + "inline": { + "class": "Symfony\\Component\\PropertyAccess\\PropertyAccessor", + "factory": [ + "Symfony\\Component\\PropertyAccess\\PropertyAccess", + "createPropertyAccessor" + ] + } + } + ] + } + } + ] + }, + "paysera_api.path_attribute_resolution_manager": { + "class": "Paysera\\Bundle\\ApiBundle\\Service\\PathAttributeResolver\\PathAttributeResolutionManager", + "arguments": [ + { + "reference": "paysera_api.path_attribute_resolver_registry", + "invalid_behavior": 1 + } + ] + }, + "paysera_api.path_attribute_resolver_registry": { + "class": "Paysera\\Bundle\\ApiBundle\\Service\\PathAttributeResolver\\PathAttributeResolverRegistry" + }, + "paysera_api.response_builder": { + "class": "Paysera\\Bundle\\ApiBundle\\Service\\ResponseBuilder" + }, + "paysera_api.rest_request_helper": { + "class": "Paysera\\Bundle\\ApiBundle\\Service\\RestRequestHelper", + "arguments": [ + { + "reference": "paysera_api.rest_request_options_registry", + "invalid_behavior": 1 + } + ] + }, + "paysera_api.rest_request_options_registry": { + "class": "Paysera\\Bundle\\ApiBundle\\Service\\RestRequestOptionsRegistry" + }, + "paysera_api.rest_request_options_validator": { + "class": "Paysera\\Bundle\\ApiBundle\\Service\\RestRequestOptionsValidator", + "arguments": [ + { + "reference": "paysera_normalization.normalizer_registry", + "invalid_behavior": 1 + }, + { + "reference": "paysera_api.path_attribute_resolver_registry", + "invalid_behavior": 1 + } + ] + } +} diff --git a/tests/Unit/DependencyInjection/PayseraApiExtensionTest.php b/tests/Unit/DependencyInjection/PayseraApiExtensionTest.php new file mode 100644 index 0000000..bcb7a11 --- /dev/null +++ b/tests/Unit/DependencyInjection/PayseraApiExtensionTest.php @@ -0,0 +1,166 @@ +> $configs + * @param array $expected + */ + public function testLoad(array $configs, array $expected): void + { + $container = new ContainerBuilder(); + $empty = new ContainerBuilder(); + + (new PayseraApiExtension())->load($configs, $container); + + $definitions = array_diff_key($container->getDefinitions(), $empty->getDefinitions()); + ksort($definitions); + $aliases = array_diff_key($container->getAliases(), $empty->getAliases()); + ksort($aliases); + $this->assertSame($expected, [ + 'definitions' => array_map( + function (Definition $definition): array { + return $this->dumpDefinition($definition, false); + }, + $definitions + ), + 'aliases' => array_map( + static function (Alias $alias): array { + return ['id' => (string)$alias, 'public' => $alias->isPublic()]; + }, + $aliases + ), + 'parameters' => $container->getParameterBag()->all(), + ]); + } + + public static function loadDataProvider(): array + { + $definitions = json_decode((string)file_get_contents(__DIR__ . '/Fixtures/services.json'), true); + unset($definitions[ + class_exists(AttributeRouteControllerLoader::class) + ? 'paysera_api.annotations.loader' + : 'paysera_api.loader.attribute' + ]); + $withoutLocaleListener = $definitions; + unset($withoutLocaleListener['paysera_api.listener.locale']); + $pagination = [ + 'paysera_api.pagination.default_total_count_strategy' => 'optional', + 'paysera_api.pagination.maximum_offset' => 1000, + 'paysera_api.pagination.default_limit' => 100, + 'paysera_api.pagination.maximum_limit' => 1000, + ]; + + return [ + 'no configuration' => [ + [], + [ + 'definitions' => $withoutLocaleListener, + 'aliases' => [ + 'paysera_api.validation.property_path_converter' => [ + 'id' => 'paysera_api.camel_case_to_snake_case_converter', + 'public' => false, + ], + ], + 'parameters' => ['paysera_api.locales' => []] + $pagination, + ], + ], + 'locales and a property path converter' => [ + [['locales' => ['en', 'lt'], 'validation' => ['property_path_converter' => 'app.converter']]], + [ + 'definitions' => $definitions, + 'aliases' => [ + 'paysera_api.validation.property_path_converter' => [ + 'id' => 'app.converter', + 'public' => (new Alias('app.converter'))->isPublic(), + ], + ], + 'parameters' => ['paysera_api.locales' => ['en', 'lt']] + $pagination, + ], + ], + ]; + } + + public function testLoadingTriggersNoDeprecation(): void + { + $deprecations = []; + set_error_handler(static function (int $level, string $message) use (&$deprecations): bool { + $deprecations[] = $message; + + return true; + }, E_USER_DEPRECATED); + try { + (new PayseraApiExtension())->load([], new ContainerBuilder()); + } finally { + restore_error_handler(); + } + + $this->assertSame([], $deprecations); + } + + /** + * @return array + */ + private function dumpDefinition(Definition $definition, bool $inline): array + { + return array_filter( + [ + 'class' => $definition->getClass(), + 'parent' => $definition instanceof ChildDefinition ? $definition->getParent() : null, + 'public' => $inline || $definition->isPublic() === (new Definition())->isPublic() + ? null + : $definition->isPublic(), + 'shared' => $definition->isShared() ? null : false, + 'lazy' => $definition->isLazy() ?: null, + 'abstract' => $definition->isAbstract() ?: null, + 'synthetic' => $definition->isSynthetic() ?: null, + 'autowired' => $definition->isAutowired() ?: null, + 'autoconfigured' => $definition->isAutoconfigured() ?: null, + 'deprecated' => $definition->isDeprecated() ?: null, + 'factory' => $this->export($definition->getFactory()), + 'arguments' => $this->export($definition->getArguments()), + 'calls' => $this->export($definition->getMethodCalls()), + 'properties' => $this->export($definition->getProperties()), + 'configurator' => $this->export($definition->getConfigurator()), + 'tags' => $definition->getTags(), + 'decorated' => $definition->getDecoratedService(), + ], + static function ($value): bool { + return $value !== null && $value !== []; + } + ); + } + + /** + * @param mixed $value + * @return mixed + */ + private function export($value) + { + if ($value instanceof Reference) { + return ['reference' => (string)$value, 'invalid_behavior' => $value->getInvalidBehavior()]; + } + if ($value instanceof Definition) { + return ['inline' => $this->dumpDefinition($value, true)]; + } + if (is_array($value)) { + return array_map([$this, 'export'], $value); + } + + return $value; + } +} From 276e75fa75894e10a90fcd3792d2101624b09081 Mon Sep 17 00:00:00 2001 From: "vinayak.iyer" Date: Tue, 20 Oct 2026 04:37:54 +0000 Subject: [PATCH 04/16] Mark "no locale matched" with a comma placeholder in LocaleListener Symfony 7.1 rewrote Request::getPreferredLanguage() to match configured locales by prefix, so the language `de` matched the placeholder `default` first and the listener kept the default locale for de-DE (and for de from 7.3). No language starts with a comma, and no unquoted Accept-Language item contains one, as Symfony splits the header on commas, so a comma is the placeholder now. An empty one would match the item `i-`, which Symfony 3.4 to 7.0 reads as an empty language. Configured locales are compared as strings and empty ones are dropped, so a null entry neither breaks the typed Request::formatLocale() of Symfony 7.4 nor matches the item `i-`. The tests' HttpKernelHelper looked the constant up without `::`, so it always used MASTER_REQUEST, which Symfony 7 removed; it finds MAIN_REQUEST now. Co-Authored-By: Claude Fable 5.1 Co-Authored-By: Claude Opus 5.5 Torus-Session: 925cfa36-8312-40a5-a58d-89755fd7d827 --- src/Listener/LocaleListener.php | 15 +-- tests/Unit/Helper/HttpKernelHelper.php | 2 +- tests/Unit/Listener/LocaleListenerTest.php | 134 ++++++++++++++++++++- 3 files changed, 137 insertions(+), 14 deletions(-) diff --git a/src/Listener/LocaleListener.php b/src/Listener/LocaleListener.php index 241101f..7197f6d 100644 --- a/src/Listener/LocaleListener.php +++ b/src/Listener/LocaleListener.php @@ -13,6 +13,8 @@ */ class LocaleListener { + private const NO_LOCALE_MATCHED = ','; + private $requestHelper; private $locales; @@ -21,7 +23,7 @@ public function __construct( array $locales ) { $this->requestHelper = $requestHelper; - $this->locales = $locales; + $this->locales = array_values(array_diff(array_map('strval', $locales), [''])); } /** @@ -42,15 +44,10 @@ public function onKernelRequest($event) } } - private function resolveFromHeaders(Request $request) + private function resolveFromHeaders(Request $request): ?string { - $defaultLocale = 'default'; - $preferredLanguage = $request->getPreferredLanguage(array_merge([$defaultLocale], $this->locales)); - - if ($preferredLanguage !== null && $preferredLanguage !== $defaultLocale) { - return $preferredLanguage; - } + $locale = (string)$request->getPreferredLanguage(array_merge([self::NO_LOCALE_MATCHED], $this->locales)); - return null; + return $locale === self::NO_LOCALE_MATCHED ? null : $locale; } } diff --git a/tests/Unit/Helper/HttpKernelHelper.php b/tests/Unit/Helper/HttpKernelHelper.php index 67a50b0..4e3d23a 100644 --- a/tests/Unit/Helper/HttpKernelHelper.php +++ b/tests/Unit/Helper/HttpKernelHelper.php @@ -10,7 +10,7 @@ class HttpKernelHelper { public static function getMainRequestConstValue(): int { - if (defined(HttpKernelInterface::class . 'MAIN_REQUEST')) { + if (defined(HttpKernelInterface::class . '::MAIN_REQUEST')) { return HttpKernelInterface::MAIN_REQUEST; } diff --git a/tests/Unit/Listener/LocaleListenerTest.php b/tests/Unit/Listener/LocaleListenerTest.php index c404e8e..695b69a 100644 --- a/tests/Unit/Listener/LocaleListenerTest.php +++ b/tests/Unit/Listener/LocaleListenerTest.php @@ -8,10 +8,13 @@ use Paysera\Bundle\ApiBundle\Listener\LocaleListener; use Paysera\Bundle\ApiBundle\Service\RestRequestHelper; use Paysera\Bundle\ApiBundle\Tests\Unit\Helper\HttpKernelHelper; +use ReflectionClass; +use Symfony\Component\HttpFoundation\AcceptHeader; use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpKernel\Event\GetResponseEvent; use Symfony\Component\HttpKernel\Event\RequestEvent; use Symfony\Component\HttpKernel\HttpKernelInterface; +use Throwable; class LocaleListenerTest extends MockeryTestCase { @@ -24,6 +27,25 @@ class LocaleListenerTest extends MockeryTestCase * @param bool $rest */ public function testOnKernelRequest(string $expectedLocale, array $locales, string $acceptLanguage, bool $rest) + { + $this->assertSame($expectedLocale, $this->resolveLocale($locales, $acceptLanguage, $rest)); + } + + public function testAnItemOfOnlyASemicolonIsNotALanguage() + { + try { + AcceptHeader::fromString(';'); + } catch (Throwable $error) { + $this->markTestSkipped('This http-foundation fails on an empty Accept-Language item itself'); + } + + $this->assertSame('unchanged', $this->resolveLocale(['de'], ';', true)); + } + + /** + * @param array $locales + */ + private function resolveLocale(array $locales, string $acceptLanguage, bool $rest): string { $helper = Mockery::mock(RestRequestHelper::class); $kernel = Mockery::mock(HttpKernelInterface::class); @@ -42,11 +64,13 @@ public function testOnKernelRequest(string $expectedLocale, array $locales, stri $listener->onKernelRequest($event); - $this->assertSame($expectedLocale, $request->getLocale()); + return $request->getLocale(); } - public function provider() + public static function provider(): array { + $tagsFormattedBySymfony = (new ReflectionClass(Request::class))->hasMethod('formatLocale'); + return [ [ 'unchanged', @@ -90,12 +114,114 @@ public function provider() 'en, de-CH', true, ], - 'higher priority wins' => [ - 'de', + 'higher priority wins, from Symfony 7.1 through the language of the first tag' => [ + $tagsFormattedBySymfony ? 'en' : 'de', ['de', 'en'], 'en-US,en;q=0.8, de-CH;q=0.9', true, ], + 'German among other locales' => [ + 'de', + ['en', 'lt', 'de'], + 'de', + true, + ], + 'German region among other locales' => [ + 'de', + ['en', 'lt', 'de'], + 'de-DE', + true, + ], + 'a tag that a configured locale starts with, from Symfony 7.1 matched to that locale' => [ + $tagsFormattedBySymfony ? 'de' : 'unchanged', + ['de'], + 'd', + true, + ], + 'a bare language, from Symfony 7.1 matched to a configured locale with a region' => [ + $tagsFormattedBySymfony ? 'en_US' : 'unchanged', + ['en_US', 'lt_LT'], + 'en', + true, + ], + 'another region of the language, from Symfony 7.1 matched to a configured locale with a region' => [ + $tagsFormattedBySymfony ? 'en_US' : 'unchanged', + ['en_US', 'lt_LT'], + 'en-GB', + true, + ], + 'a bare language of the second configured locale' => [ + $tagsFormattedBySymfony ? 'lt_LT' : 'unchanged', + ['en_US', 'lt_LT'], + 'lt', + true, + ], + 'no match keeps the locale' => [ + 'unchanged', + ['en', 'lt', 'de'], + 'fr-FR, fr;q=0.9', + true, + ], + 'no header keeps the locale' => [ + 'unchanged', + ['en', 'lt', 'de'], + '', + true, + ], + 'a locale configured as a number' => [ + '1', + [1], + 'de, 1', + true, + ], + 'a null configured next to a locale' => [ + 'de', + [null, 'de'], + 'de', + true, + ], + 'an empty i- item with a null configured next to a locale' => [ + 'de', + [null, 'de'], + 'i-, de', + true, + ], + 'an empty i- item with only a null configured' => [ + 'unchanged', + [null], + 'i-', + true, + ], + 'a malformed item is not a language' => [ + 'unchanged', + ['de'], + "'", + true, + ], + 'an empty i- item, which Symfony 3.4 to 7.0 reads as an empty language' => [ + 'de', + ['de'], + 'i-, de', + true, + ], + 'the language default before a configured locale selects that locale' => [ + 'de', + ['de'], + 'default, de', + true, + ], + 'the language i-default before a configured locale selects that locale' => [ + 'de', + ['de'], + 'i-default, de', + true, + ], + 'a malformed item next to a language' => [ + 'de', + ['de'], + "de, '", + true, + ], ]; } } From ba4940ca5e97ff951a1641e2d8a6fb349fbe54b8 Mon Sep 17 00:00:00 2001 From: "vinayak.iyer" Date: Tue, 20 Oct 2026 04:37:54 +0000 Subject: [PATCH 05/16] Read docblock annotations with Doctrine's reader where Symfony injects none On Symfony 7, and on Symfony 5.4 with PHP 8 or Symfony 6 with framework.annotations disabled, the route loader has no annotation reader, and 1.x silently dropped the bundle's docblock annotations, @RequiredPermissions among them, or failed to load any route. DocblockAnnotationReader now reads them with Doctrine's AnnotationReader where Doctrine would find them: where a lenient Doctrine DocParser, given Doctrine's imports and namespace for the class or action docblock, finds a RestAnnotationInterface annotation or fails to parse it, or where the class, the declaring class or a trait in the action's file imports Paysera\Bundle\ApiBundle\Annotation, so a misspelt annotation there still fails. Where OPcache strips doc comments, Doctrine's reader refuses to run, so a file importing the annotation namespace fails loudly; an annotation used there without that import is not detected, a documented limit. RestRouteConfigurator, shared by both route loaders, wraps read errors in a ConfigurationException naming the action, applies the bundle's attributes on PHP 8 before Symfony 6.4 too, fails on an attribute named under Paysera\Bundle\ApiBundle\ that is none of the bundle's (a misspelt name or namespace), and refuses a route configured with both docblock annotations and attributes, where the attributes used to replace the docblock options silently, or be ignored before Symfony 6.4. The functional test application runs on Symfony 7 with attribute routes, XML entity mapping and a #[Route] action whose @RequiredPermissions must still answer 401, 403 and 200. Co-Authored-By: Claude Opus 5.5 Torus-Session: 925cfa36-8312-40a5-a58d-89755fd7d827 --- .../PayseraApiExtension.php | 22 +- .../DocblockAnnotationReader.php | 217 +++++++++++ .../RoutingLoader/RestRouteConfigurator.php | 167 +++++++++ .../RoutingLoader/RoutingAnnotationLoader.php | 42 +-- .../RoutingLoader/RoutingAttributeLoader.php | 86 +---- ...utedClassRequiredPermissionsController.php | 2 +- .../AttributedClassValidationController.php | 2 +- .../Attribute/AttributedController.php | 2 +- .../AttributedDocblockOptionsController.php | 22 ++ .../AttributedPagedQueryController.php | 34 ++ .../AttributedPersistedEntityController.php | 27 ++ .../Entity/PersistedEntity.php | 9 - .../Entity/SimplePersistedEntity.php | 8 - .../config/doctrine/PersistedEntity.orm.xml | 12 + .../doctrine/SimplePersistedEntity.orm.xml | 10 + .../Resources/config/explicit_routing.yml | 43 +++ .../Resources/config/routing.xml | 43 +-- .../Resources/config/services.xml | 4 + .../FixtureTestBundle/Service/TestHelper.php | 6 + tests/Functional/Fixtures/TestKernel.php | 2 +- .../Functional/Fixtures/config/sf7_common.yml | 19 + .../Fixtures/config/sf7_routing.yml | 8 + .../Functional/FunctionalAnnotationsTest.php | 67 +++- .../FunctionalFindDenormalizersTest.php | 23 +- tests/Functional/FunctionalPagedQueryTest.php | 18 + tests/Functional/FunctionalTestCase.php | 29 +- .../Fixtures/services.json | 59 ++- .../DocblockAnnotationReaderTest.php | 26 ++ ...sedFullyQualifiedTraitMethodController.php | 13 + .../AliasedNamespaceDocblockController.php | 19 + .../Fixtures/AliasedTraitMethodController.php | 13 + .../AnnotationClassAsAttributeController.php | 15 + .../Fixtures/AppAnnotationController.php | 19 + .../AppAnnotationFullyQualifiedController.php | 18 + .../RoutingLoader/Fixtures/AppPermission.php | 26 ++ .../Fixtures/AttributeOptionsController.php | 17 + .../AttributeOptionsWithNoteTagController.php | 20 + .../ChildImportingForParentController.php | 11 + .../ChildOfImportingParentController.php | 23 ++ .../ClassAndActionDocblockController.php | 24 ++ ...ssDocblockAndMethodAttributeController.php | 21 ++ .../ClassDocblockFullyQualifiedController.php | 18 + .../Fixtures/ClassWithMissingParent.php | 12 + ...blockOptionsOnAttributeRouteController.php | 19 + .../Fixtures/DocblockOptionsTrait.php | 19 + .../FullyQualifiedDocblockController.php | 18 + .../Fixtures/FullyQualifiedOptionsTrait.php | 18 + .../Fixtures/GroupUseDocblockController.php | 19 + .../Fixtures/ImportingParentController.php | 19 + .../Fixtures/InvalidAttributeController.php | 15 + .../LowercaseAliasAppAnnotationController.php | 19 + .../LowercaseMisspeltAttributeController.php | 15 + ...ingParentInUnrelatedDocblockController.php | 27 ++ .../Fixtures/MissingParentTagController.php | 18 + ...peltAnnotationAliasAttributeController.php | 15 + .../Fixtures/MisspeltAttributeController.php | 15 + .../MisspeltAttributeNamespaceController.php | 15 + .../MisspeltDocblockAnnotationController.php | 17 + .../NamespaceAliasAppAnnotationController.php | 19 + .../NamespaceMentionedInStringController.php | 23 ++ .../NativeRouteAttributeOptionsController.php | 17 + .../NativeRouteDocblockOptionsController.php | 19 + .../NativeRouteMixedOptionsController.php | 21 ++ .../Fixtures/NestedTraitActionController.php | 10 + .../RoutingLoader/Fixtures/NestingTrait.php | 10 + .../NonAttributeBundleClassController.php | 15 + ...wnActionNextToAnnotatedTraitController.php | 22 ++ .../OwnNamespaceAppAnnotationController.php | 18 + .../ParentWithoutImportController.php | 18 + .../RoutingLoader/Fixtures/PlainShowTrait.php | 12 + .../SplitAliasAppAnnotationController.php | 20 + .../SplitFullyQualifiedDocblockController.php | 19 + .../TagNamedLikeImportsController.php | 23 ++ .../TraitDocblockOptionsController.php | 10 + .../Fixtures/UnimportedNoteTagController.php | 20 + .../RoutingLoader/Fixtures/load-routes.php | 40 ++ ...estRequestAnnotationOptionsBuilderTest.php | 5 +- .../RestRouteConfiguratorTest.php | 126 +++++++ .../RoutingAnnotationLoaderTest.php | 104 ++++++ .../RoutingAttributeLoaderTest.php | 353 ++++++++++++++++++ 80 files changed, 2229 insertions(+), 241 deletions(-) create mode 100644 src/Service/RoutingLoader/DocblockAnnotationReader.php create mode 100644 src/Service/RoutingLoader/RestRouteConfigurator.php create mode 100644 tests/Functional/Fixtures/FixtureTestBundle/Controller/Attribute/AttributedDocblockOptionsController.php create mode 100644 tests/Functional/Fixtures/FixtureTestBundle/Controller/Attribute/AttributedPagedQueryController.php create mode 100644 tests/Functional/Fixtures/FixtureTestBundle/Controller/Attribute/AttributedPersistedEntityController.php create mode 100644 tests/Functional/Fixtures/FixtureTestBundle/Resources/config/doctrine/PersistedEntity.orm.xml create mode 100644 tests/Functional/Fixtures/FixtureTestBundle/Resources/config/doctrine/SimplePersistedEntity.orm.xml create mode 100644 tests/Functional/Fixtures/FixtureTestBundle/Resources/config/explicit_routing.yml create mode 100644 tests/Functional/Fixtures/config/sf7_common.yml create mode 100644 tests/Functional/Fixtures/config/sf7_routing.yml create mode 100644 tests/Unit/Service/RoutingLoader/DocblockAnnotationReaderTest.php create mode 100644 tests/Unit/Service/RoutingLoader/Fixtures/AliasedFullyQualifiedTraitMethodController.php create mode 100644 tests/Unit/Service/RoutingLoader/Fixtures/AliasedNamespaceDocblockController.php create mode 100644 tests/Unit/Service/RoutingLoader/Fixtures/AliasedTraitMethodController.php create mode 100644 tests/Unit/Service/RoutingLoader/Fixtures/AnnotationClassAsAttributeController.php create mode 100644 tests/Unit/Service/RoutingLoader/Fixtures/AppAnnotationController.php create mode 100644 tests/Unit/Service/RoutingLoader/Fixtures/AppAnnotationFullyQualifiedController.php create mode 100644 tests/Unit/Service/RoutingLoader/Fixtures/AppPermission.php create mode 100644 tests/Unit/Service/RoutingLoader/Fixtures/AttributeOptionsController.php create mode 100644 tests/Unit/Service/RoutingLoader/Fixtures/AttributeOptionsWithNoteTagController.php create mode 100644 tests/Unit/Service/RoutingLoader/Fixtures/ChildImportingForParentController.php create mode 100644 tests/Unit/Service/RoutingLoader/Fixtures/ChildOfImportingParentController.php create mode 100644 tests/Unit/Service/RoutingLoader/Fixtures/ClassAndActionDocblockController.php create mode 100644 tests/Unit/Service/RoutingLoader/Fixtures/ClassDocblockAndMethodAttributeController.php create mode 100644 tests/Unit/Service/RoutingLoader/Fixtures/ClassDocblockFullyQualifiedController.php create mode 100644 tests/Unit/Service/RoutingLoader/Fixtures/ClassWithMissingParent.php create mode 100644 tests/Unit/Service/RoutingLoader/Fixtures/DocblockOptionsOnAttributeRouteController.php create mode 100644 tests/Unit/Service/RoutingLoader/Fixtures/DocblockOptionsTrait.php create mode 100644 tests/Unit/Service/RoutingLoader/Fixtures/FullyQualifiedDocblockController.php create mode 100644 tests/Unit/Service/RoutingLoader/Fixtures/FullyQualifiedOptionsTrait.php create mode 100644 tests/Unit/Service/RoutingLoader/Fixtures/GroupUseDocblockController.php create mode 100644 tests/Unit/Service/RoutingLoader/Fixtures/ImportingParentController.php create mode 100644 tests/Unit/Service/RoutingLoader/Fixtures/InvalidAttributeController.php create mode 100644 tests/Unit/Service/RoutingLoader/Fixtures/LowercaseAliasAppAnnotationController.php create mode 100644 tests/Unit/Service/RoutingLoader/Fixtures/LowercaseMisspeltAttributeController.php create mode 100644 tests/Unit/Service/RoutingLoader/Fixtures/MissingParentInUnrelatedDocblockController.php create mode 100644 tests/Unit/Service/RoutingLoader/Fixtures/MissingParentTagController.php create mode 100644 tests/Unit/Service/RoutingLoader/Fixtures/MisspeltAnnotationAliasAttributeController.php create mode 100644 tests/Unit/Service/RoutingLoader/Fixtures/MisspeltAttributeController.php create mode 100644 tests/Unit/Service/RoutingLoader/Fixtures/MisspeltAttributeNamespaceController.php create mode 100644 tests/Unit/Service/RoutingLoader/Fixtures/MisspeltDocblockAnnotationController.php create mode 100644 tests/Unit/Service/RoutingLoader/Fixtures/NamespaceAliasAppAnnotationController.php create mode 100644 tests/Unit/Service/RoutingLoader/Fixtures/NamespaceMentionedInStringController.php create mode 100644 tests/Unit/Service/RoutingLoader/Fixtures/NativeRouteAttributeOptionsController.php create mode 100644 tests/Unit/Service/RoutingLoader/Fixtures/NativeRouteDocblockOptionsController.php create mode 100644 tests/Unit/Service/RoutingLoader/Fixtures/NativeRouteMixedOptionsController.php create mode 100644 tests/Unit/Service/RoutingLoader/Fixtures/NestedTraitActionController.php create mode 100644 tests/Unit/Service/RoutingLoader/Fixtures/NestingTrait.php create mode 100644 tests/Unit/Service/RoutingLoader/Fixtures/NonAttributeBundleClassController.php create mode 100644 tests/Unit/Service/RoutingLoader/Fixtures/OwnActionNextToAnnotatedTraitController.php create mode 100644 tests/Unit/Service/RoutingLoader/Fixtures/OwnNamespaceAppAnnotationController.php create mode 100644 tests/Unit/Service/RoutingLoader/Fixtures/ParentWithoutImportController.php create mode 100644 tests/Unit/Service/RoutingLoader/Fixtures/PlainShowTrait.php create mode 100644 tests/Unit/Service/RoutingLoader/Fixtures/SplitAliasAppAnnotationController.php create mode 100644 tests/Unit/Service/RoutingLoader/Fixtures/SplitFullyQualifiedDocblockController.php create mode 100644 tests/Unit/Service/RoutingLoader/Fixtures/TagNamedLikeImportsController.php create mode 100644 tests/Unit/Service/RoutingLoader/Fixtures/TraitDocblockOptionsController.php create mode 100644 tests/Unit/Service/RoutingLoader/Fixtures/UnimportedNoteTagController.php create mode 100644 tests/Unit/Service/RoutingLoader/Fixtures/load-routes.php create mode 100644 tests/Unit/Service/RoutingLoader/RestRouteConfiguratorTest.php create mode 100644 tests/Unit/Service/RoutingLoader/RoutingAnnotationLoaderTest.php create mode 100644 tests/Unit/Service/RoutingLoader/RoutingAttributeLoaderTest.php diff --git a/src/DependencyInjection/PayseraApiExtension.php b/src/DependencyInjection/PayseraApiExtension.php index 877f86b..7cf215b 100644 --- a/src/DependencyInjection/PayseraApiExtension.php +++ b/src/DependencyInjection/PayseraApiExtension.php @@ -23,8 +23,10 @@ use Paysera\Bundle\ApiBundle\Service\RestRequestHelper; use Paysera\Bundle\ApiBundle\Service\RestRequestOptionsRegistry; use Paysera\Bundle\ApiBundle\Service\RestRequestOptionsValidator; +use Paysera\Bundle\ApiBundle\Service\RoutingLoader\DocblockAnnotationReader; use Paysera\Bundle\ApiBundle\Service\RoutingLoader\RestRequestAnnotationOptionsBuilder; use Paysera\Bundle\ApiBundle\Service\RoutingLoader\RestRequestAttributeOptionsBuilder; +use Paysera\Bundle\ApiBundle\Service\RoutingLoader\RestRouteConfigurator; use Paysera\Bundle\ApiBundle\Service\RoutingLoader\RoutingAnnotationLoader; use Paysera\Bundle\ApiBundle\Service\RoutingLoader\RoutingAttributeLoader; use Paysera\Bundle\ApiBundle\Service\Validation\CamelCaseToSnakeCaseConverter; @@ -191,6 +193,13 @@ private function createServiceDefinitions(): array RestRequestAttributeOptionsBuilder::class, [$optionsValidator] ), + 'paysera_api.docblock_annotation_reader' => new Definition(DocblockAnnotationReader::class), + 'paysera_api.rest_route_configurator' => new Definition(RestRouteConfigurator::class, [ + new Reference('paysera_api.rest_request_helper'), + new Reference('paysera_api.annotations.options_builder'), + new Reference('paysera_api.attributes.options_builder'), + new Reference('paysera_api.docblock_annotation_reader'), + ]), 'paysera_api.response_builder' => new Definition(ResponseBuilder::class), 'paysera_api.error_builder' => $this->createErrorBuilderDefinition(), 'paysera_api.content_type_matcher' => new Definition(ContentTypeMatcher::class), @@ -236,28 +245,21 @@ private function createErrorBuilderDefinition(): Definition */ private function createRoutingLoaderDefinitions(): array { - $restRequestHelper = new Reference('paysera_api.rest_request_helper'); - $annotationOptionsBuilder = new Reference('paysera_api.annotations.options_builder'); + $restRouteConfigurator = new Reference('paysera_api.rest_route_configurator'); if (!class_exists(AttributeRouteControllerLoader::class)) { return [ 'paysera_api.annotations.loader' => (new ChildDefinition('routing.loader.annotation')) ->setClass(RoutingAnnotationLoader::class) ->setDecoratedService('routing.loader.annotation') - ->addMethodCall('setRequestHelper', [$restRequestHelper]) - ->addMethodCall('setRestRequestOptionsBuilder', [$annotationOptionsBuilder]), + ->addMethodCall('setRestRouteConfigurator', [$restRouteConfigurator]), ]; } $attributeLoader = (new ChildDefinition('routing.loader.attribute')) ->setClass(RoutingAttributeLoader::class) ->setDecoratedService('routing.loader.attribute') - ->addMethodCall('setRequestHelper', [$restRequestHelper]) - ->addMethodCall('setRestRequestAnnotationOptionsBuilder', [$annotationOptionsBuilder]) - ->addMethodCall( - 'setRestRequestAttributeOptionsBuilder', - [new Reference('paysera_api.attributes.options_builder')] - ) + ->addMethodCall('setRestRouteConfigurator', [$restRouteConfigurator]) ; return ['paysera_api.loader.attribute' => $attributeLoader]; diff --git a/src/Service/RoutingLoader/DocblockAnnotationReader.php b/src/Service/RoutingLoader/DocblockAnnotationReader.php new file mode 100644 index 0000000..496a5db --- /dev/null +++ b/src/Service/RoutingLoader/DocblockAnnotationReader.php @@ -0,0 +1,217 @@ +> + */ + private $imports; + + /** + * @var array + */ + private $classesUsingAnnotations; + + /** + * @var array + */ + private $classAnnotations; + + public function __construct() + { + $this->imports = []; + $this->classesUsingAnnotations = []; + $this->classAnnotations = []; + } + + /** + * @return object[] + */ + public function readAnnotations(ReflectionClass $class, ReflectionMethod $method, ?Reader $reader): array + { + if ($reader !== null) { + return array_merge($this->getClassAnnotations($reader, $class), $reader->getMethodAnnotations($method)); + } + + $readClass = $this->classUsesAnnotations($class); + if (!$readClass && !$this->methodUsesAnnotations($method)) { + return []; + } + + $this->annotationReader = $this->annotationReader ?? new AnnotationReader(); + + return array_merge( + $readClass ? $this->getClassAnnotations($this->annotationReader, $class) : [], + $this->annotationReader->getMethodAnnotations($method) + ); + } + + /** + * @return object[] + */ + private function getClassAnnotations(Reader $reader, ReflectionClass $class): array + { + $className = $class->getName(); + if (!isset($this->classAnnotations[$className])) { + $this->classAnnotations[$className] = $reader->getClassAnnotations($class); + } + + return $this->classAnnotations[$className]; + } + + private function classUsesAnnotations(ReflectionClass $class): bool + { + $className = $class->getName(); + if (!isset($this->classesUsingAnnotations[$className])) { + $imports = $this->getImports($class); + $this->classesUsingAnnotations[$className] = $this->importsAnnotationNamespace($imports) + || $this->mayHaveRestAnnotations($class->getDocComment(), $imports, $class->getNamespaceName()); + } + + return $this->classesUsingAnnotations[$className]; + } + + private function methodUsesAnnotations(ReflectionMethod $method): bool + { + $imports = []; + foreach ($this->getActionScopes($method) as $scope) { + $scopeImports = $this->getImports($scope); + if ($this->importsAnnotationNamespace($scopeImports)) { + return true; + } + + $imports = array_merge($imports, $scopeImports); + } + + return $this->mayHaveRestAnnotations( + $method->getDocComment(), + $imports, + $method->getDeclaringClass()->getNamespaceName() + ); + } + + /** + * @return ReflectionClass[] + */ + private function getActionScopes(ReflectionMethod $method): array + { + $declaringClass = $method->getDeclaringClass(); + $scopes = [$declaringClass]; + foreach ($this->getTraits($declaringClass) as $trait) { + if ($trait->getFileName() === $method->getFileName()) { + $scopes[] = $trait; + } + } + + return $scopes; + } + + /** + * @return ReflectionClass[] + */ + private function getTraits(ReflectionClass $class): array + { + $traits = []; + foreach ($class->getTraits() as $trait) { + $traits[] = $trait; + $traits = array_merge($traits, $this->getTraits($trait)); + } + + return $traits; + } + + /** + * @return array + */ + private function getImports(ReflectionClass $scope): array + { + $name = $scope->getName(); + if (!isset($this->imports[$name])) { + $this->phpParser = $this->phpParser ?? new PhpParser(); + $this->imports[$name] = $this->phpParser->parseUseStatements($scope); + } + + return $this->imports[$name]; + } + + /** + * @param array $imports + */ + private function importsAnnotationNamespace(array $imports): bool + { + foreach ($imports as $import) { + $normalizedImport = strtolower(ltrim($import, '\\')) . '\\'; + if ( + strpos($normalizedImport, self::ANNOTATION_NAMESPACE . '\\') === 0 + || strpos(self::ANNOTATION_NAMESPACE . '\\', $normalizedImport) === 0 + ) { + return true; + } + } + + return false; + } + + /** + * @param string|false $docComment + * @param array $imports + */ + private function mayHaveRestAnnotations($docComment, array $imports, string $namespace): bool + { + if ($docComment === false) { + return false; + } + + if ($this->docParser === null) { + $this->docParser = new DocParser(); + $this->docParser->setIgnoreNotImportedAnnotations(true); + } + $this->docParser->setImports($imports + ['__NAMESPACE__' => $namespace]); + try { + $annotations = $this->docParser->parse($docComment); + } catch (Throwable $exception) { + return true; + } + + foreach ($annotations as $annotation) { + if ($annotation instanceof RestAnnotationInterface) { + return true; + } + } + + return false; + } +} diff --git a/src/Service/RoutingLoader/RestRouteConfigurator.php b/src/Service/RoutingLoader/RestRouteConfigurator.php new file mode 100644 index 0000000..851bc7f --- /dev/null +++ b/src/Service/RoutingLoader/RestRouteConfigurator.php @@ -0,0 +1,167 @@ +restRequestHelper = $restRequestHelper; + $this->annotationOptionsBuilder = $annotationOptionsBuilder; + $this->attributeOptionsBuilder = $attributeOptionsBuilder; + $this->docblockAnnotationReader = $docblockAnnotationReader; + } + + public function configureRoute( + Route $route, + ReflectionClass $class, + ReflectionMethod $method, + ?Reader $reader + ): void { + $annotations = $this->getRestAnnotations($class, $method, $reader); + $attributes = $this->getRestAttributes($class, $method); + if ($annotations !== [] && $attributes !== []) { + throw new ConfigurationException(sprintf( + '%s::%s() is configured with both docblock annotations and attributes of the bundle, use one of them', + $class->getName(), + $method->getName() + )); + } + + if ($annotations !== []) { + $this->restRequestHelper->setOptionsForRoute( + $route, + $this->annotationOptionsBuilder->buildOptions($annotations, $method) + ); + } + + if ($attributes !== []) { + $this->restRequestHelper->setOptionsForRoute( + $route, + $this->attributeOptionsBuilder->buildOptions($attributes, $method) + ); + } + } + + /** + * @return RestAnnotationInterface[] + */ + private function getRestAnnotations( + ReflectionClass $class, + ReflectionMethod $method, + ?Reader $reader + ): array { + try { + $annotations = $this->docblockAnnotationReader->readAnnotations($class, $method, $reader); + } catch (Throwable $exception) { + throw $this->createReadException('docblock annotations', $class, $method, $exception); + } + + return array_values(array_filter($annotations, static function ($annotation): bool { + return $annotation instanceof RestAnnotationInterface; + })); + } + + /** + * @return RestAttributeInterface[] + */ + private function getRestAttributes(ReflectionClass $class, ReflectionMethod $method): array + { + if (PHP_VERSION_ID < 80000) { + return []; + } + + $attributes = array_merge( + $class->getAttributes(RestAttributeInterface::class, ReflectionAttribute::IS_INSTANCEOF), + $method->getAttributes(RestAttributeInterface::class, ReflectionAttribute::IS_INSTANCEOF) + ); + $this->failOnUnknownBundleAttributes($class, $method); + + $restAttributes = []; + try { + foreach ($attributes as $attribute) { + $restAttributes[] = $attribute->newInstance(); + } + } catch (Throwable $exception) { + throw $this->createReadException('attributes', $class, $method, $exception); + } + + return $restAttributes; + } + + private function failOnUnknownBundleAttributes(ReflectionClass $class, ReflectionMethod $method): void + { + foreach (array_merge($class->getAttributes(), $method->getAttributes()) as $attribute) { + $name = $attribute->getName(); + if (stripos($name, self::BUNDLE_NAMESPACE) === 0 && !is_a($name, RestAttributeInterface::class, true)) { + throw new ConfigurationException(sprintf( + '%s::%s() uses %s, which is no attribute of the bundle', + $class->getName(), + $method->getName(), + $name + )); + } + } + } + + private function createReadException( + string $source, + ReflectionClass $class, + ReflectionMethod $method, + Throwable $exception + ): ConfigurationException { + return new ConfigurationException( + sprintf( + 'Cannot read the %s of %s::%s(): %s', + $source, + $class->getName(), + $method->getName(), + $exception->getMessage() + ), + 0, + $exception + ); + } +} diff --git a/src/Service/RoutingLoader/RoutingAnnotationLoader.php b/src/Service/RoutingLoader/RoutingAnnotationLoader.php index da85461..47971ad 100644 --- a/src/Service/RoutingLoader/RoutingAnnotationLoader.php +++ b/src/Service/RoutingLoader/RoutingAnnotationLoader.php @@ -3,8 +3,6 @@ namespace Paysera\Bundle\ApiBundle\Service\RoutingLoader; -use Paysera\Bundle\ApiBundle\Annotation\RestAnnotationInterface; -use Paysera\Bundle\ApiBundle\Service\RestRequestHelper; use Symfony\Bundle\FrameworkBundle\Routing\AnnotatedRouteControllerLoader; use Symfony\Component\Routing\Route; use ReflectionClass; @@ -16,49 +14,19 @@ class RoutingAnnotationLoader extends AnnotatedRouteControllerLoader { /** - * @var RestRequestHelper + * @var RestRouteConfigurator */ - private $restRequestHelper; + private $restRouteConfigurator; - /** - * @var RestRequestAnnotationOptionsBuilder - */ - private $restRequestOptionsBuilder; - - public function setRequestHelper(RestRequestHelper $restRequestHelper) + public function setRestRouteConfigurator(RestRouteConfigurator $restRouteConfigurator) { - $this->restRequestHelper = $restRequestHelper; - } - - public function setRestRequestOptionsBuilder(RestRequestAnnotationOptionsBuilder $restRequestOptionsBuilder) - { - $this->restRequestOptionsBuilder = $restRequestOptionsBuilder; + $this->restRouteConfigurator = $restRouteConfigurator; } protected function configureRoute(Route $route, ReflectionClass $class, ReflectionMethod $method, $routeAnnotation) { parent::configureRoute($route, $class, $method, $routeAnnotation); - $annotations = []; - foreach ($this->reader->getClassAnnotations($class) as $annotation) { - if ($annotation instanceof RestAnnotationInterface) { - $annotations[] = $annotation; - } - } - - foreach ($this->reader->getMethodAnnotations($method) as $annotation) { - if ($annotation instanceof RestAnnotationInterface) { - $annotations[] = $annotation; - } - } - - if (count($annotations) === 0) { - return; - } - - $this->restRequestHelper->setOptionsForRoute( - $route, - $this->restRequestOptionsBuilder->buildOptions($annotations, $method) - ); + $this->restRouteConfigurator->configureRoute($route, $class, $method, $this->reader); } } diff --git a/src/Service/RoutingLoader/RoutingAttributeLoader.php b/src/Service/RoutingLoader/RoutingAttributeLoader.php index 955f27d..78afdcd 100644 --- a/src/Service/RoutingLoader/RoutingAttributeLoader.php +++ b/src/Service/RoutingLoader/RoutingAttributeLoader.php @@ -4,9 +4,6 @@ namespace Paysera\Bundle\ApiBundle\Service\RoutingLoader; -use Paysera\Bundle\ApiBundle\Annotation\RestAnnotationInterface; -use Paysera\Bundle\ApiBundle\Attribute\RestAttributeInterface; -use Paysera\Bundle\ApiBundle\Service\RestRequestHelper; use ReflectionClass; use ReflectionMethod; use Symfony\Bundle\FrameworkBundle\Routing\AttributeRouteControllerLoader; @@ -18,33 +15,13 @@ class RoutingAttributeLoader extends AttributeRouteControllerLoader { /** - * @var RestRequestHelper + * @var RestRouteConfigurator */ - private $restRequestHelper; + private $restRouteConfigurator; - /** - * @var RestRequestAnnotationOptionsBuilder - */ - private $annotationOptionsBuilder; - - /** - * @var RestRequestAttributeOptionsBuilder - */ - private $attributeOptionsBuilder; - - public function setRequestHelper(RestRequestHelper $restRequestHelper) + public function setRestRouteConfigurator(RestRouteConfigurator $restRouteConfigurator) { - $this->restRequestHelper = $restRequestHelper; - } - - public function setRestRequestAnnotationOptionsBuilder(RestRequestAnnotationOptionsBuilder $annotationOptionsBuilder - ) { - $this->annotationOptionsBuilder = $annotationOptionsBuilder; - } - - public function setRestRequestAttributeOptionsBuilder(RestRequestAttributeOptionsBuilder $attributeOptionsBuilder) - { - $this->attributeOptionsBuilder = $attributeOptionsBuilder; + $this->restRouteConfigurator = $restRouteConfigurator; } protected function configureRoute( @@ -55,59 +32,6 @@ protected function configureRoute( ): void { parent::configureRoute($route, $class, $method, $annot); - $this->loadAnnotations($route, $class, $method); - $this->loadAttributes($route, $class, $method); - } - - private function loadAnnotations(Route $route, ReflectionClass $class, ReflectionMethod $method): void - { - if (!isset($this->reader)) { - return; - } - - $annotations = []; - foreach ($this->reader->getClassAnnotations($class) as $annotation) { - if ($annotation instanceof RestAnnotationInterface) { - $annotations[] = $annotation; - } - } - - foreach ($this->reader->getMethodAnnotations($method) as $annotation) { - if ($annotation instanceof RestAnnotationInterface) { - $annotations[] = $annotation; - } - } - - if ($annotations === []) { - return; - } - - $this->restRequestHelper->setOptionsForRoute( - $route, - $this->annotationOptionsBuilder->buildOptions($annotations, $method) - ); - } - - private function loadAttributes(Route $route, ReflectionClass $class, ReflectionMethod $method): void - { - $attributes = array_merge($class->getAttributes(), $method->getAttributes()); - - $restAttributes = []; - foreach ($attributes as $attribute) { - if (is_subclass_of($attribute->getName(), RestAttributeInterface::class)) { - /** @var RestAttributeInterface $instance */ - $instance = $attribute->newInstance(); - $restAttributes[] = $instance; - } - } - - if ($restAttributes === []) { - return; - } - - $this->restRequestHelper->setOptionsForRoute( - $route, - $this->attributeOptionsBuilder->buildOptions($restAttributes, $method) - ); + $this->restRouteConfigurator->configureRoute($route, $class, $method, $this->reader ?? null); } } diff --git a/tests/Functional/Fixtures/FixtureTestBundle/Controller/Attribute/AttributedClassRequiredPermissionsController.php b/tests/Functional/Fixtures/FixtureTestBundle/Controller/Attribute/AttributedClassRequiredPermissionsController.php index f93ebd4..9d9cd4a 100644 --- a/tests/Functional/Fixtures/FixtureTestBundle/Controller/Attribute/AttributedClassRequiredPermissionsController.php +++ b/tests/Functional/Fixtures/FixtureTestBundle/Controller/Attribute/AttributedClassRequiredPermissionsController.php @@ -6,7 +6,7 @@ use Paysera\Bundle\ApiBundle\Attribute\RequiredPermissions; use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Response; -use Symfony\Component\Routing\Annotation\Route; +use Symfony\Component\Routing\Attribute\Route; #[RequiredPermissions(permissions: ['ROLE_USER'])] class AttributedClassRequiredPermissionsController diff --git a/tests/Functional/Fixtures/FixtureTestBundle/Controller/Attribute/AttributedClassValidationController.php b/tests/Functional/Fixtures/FixtureTestBundle/Controller/Attribute/AttributedClassValidationController.php index 63d74dd..b60d7d0 100644 --- a/tests/Functional/Fixtures/FixtureTestBundle/Controller/Attribute/AttributedClassValidationController.php +++ b/tests/Functional/Fixtures/FixtureTestBundle/Controller/Attribute/AttributedClassValidationController.php @@ -8,7 +8,7 @@ use Paysera\Bundle\ApiBundle\Tests\Functional\Fixtures\FixtureTestBundle\Entity\MyObject; use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Response; -use Symfony\Component\Routing\Annotation\Route; +use Symfony\Component\Routing\Attribute\Route; #[Validation(groups: ['internal_field1_email'], violationPathMap: ['internalField1' => 'internal.field1'])] class AttributedClassValidationController diff --git a/tests/Functional/Fixtures/FixtureTestBundle/Controller/Attribute/AttributedController.php b/tests/Functional/Fixtures/FixtureTestBundle/Controller/Attribute/AttributedController.php index 6544ab7..8b0f6c7 100644 --- a/tests/Functional/Fixtures/FixtureTestBundle/Controller/Attribute/AttributedController.php +++ b/tests/Functional/Fixtures/FixtureTestBundle/Controller/Attribute/AttributedController.php @@ -14,7 +14,7 @@ use Paysera\Pagination\Entity\Pager; use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Response; -use Symfony\Component\Routing\Annotation\Route; +use Symfony\Component\Routing\Attribute\Route; class AttributedController { diff --git a/tests/Functional/Fixtures/FixtureTestBundle/Controller/Attribute/AttributedDocblockOptionsController.php b/tests/Functional/Fixtures/FixtureTestBundle/Controller/Attribute/AttributedDocblockOptionsController.php new file mode 100644 index 0000000..be417b7 --- /dev/null +++ b/tests/Functional/Fixtures/FixtureTestBundle/Controller/Attribute/AttributedDocblockOptionsController.php @@ -0,0 +1,22 @@ +entityManager = $entityManager; + } + + #[Route(path: '/attributed/paged-query/simple', methods: ['GET'])] + #[Query(parameterName: 'pager')] + #[Query(parameterName: 'filter')] + public function findSimplePersistedEntities(Pager $pager, PersistedEntityFilter $filter): PagedQuery + { + /** @var PersistedEntityRepository $repository */ + $repository = $this->entityManager->getRepository(PersistedEntity::class); + $configuredQuery = $repository->buildConfiguredQuery($filter); + return new PagedQuery($configuredQuery, $pager); + } +} diff --git a/tests/Functional/Fixtures/FixtureTestBundle/Controller/Attribute/AttributedPersistedEntityController.php b/tests/Functional/Fixtures/FixtureTestBundle/Controller/Attribute/AttributedPersistedEntityController.php new file mode 100644 index 0000000..6fa98cb --- /dev/null +++ b/tests/Functional/Fixtures/FixtureTestBundle/Controller/Attribute/AttributedPersistedEntityController.php @@ -0,0 +1,27 @@ +getId()); + } + + #[Route(path: '/attributed/simple-persisted-entities/{identifier}', methods: ['GET'])] + #[PathAttribute(parameterName: 'entity', pathPartName: 'identifier')] + public function findSimplePersistedEntity(SimplePersistedEntity $entity): Response + { + return new Response((string)$entity->getId()); + } +} diff --git a/tests/Functional/Fixtures/FixtureTestBundle/Entity/PersistedEntity.php b/tests/Functional/Fixtures/FixtureTestBundle/Entity/PersistedEntity.php index 5864530..3246bbf 100644 --- a/tests/Functional/Fixtures/FixtureTestBundle/Entity/PersistedEntity.php +++ b/tests/Functional/Fixtures/FixtureTestBundle/Entity/PersistedEntity.php @@ -3,24 +3,15 @@ namespace Paysera\Bundle\ApiBundle\Tests\Functional\Fixtures\FixtureTestBundle\Entity; -use Doctrine\ORM\Mapping as ORM; - -/** - * @ORM\Entity(repositoryClass="Paysera\Bundle\ApiBundle\Tests\Functional\Fixtures\FixtureTestBundle\Repository\PersistedEntityRepository") - */ class PersistedEntity { /** - * @ORM\Id - * @ORM\GeneratedValue(strategy="NONE") - * @ORM\Column(type="integer") * @var int|null */ private $id; /** * @var string|null - * @ORM\Column(type="string") */ private $someField; diff --git a/tests/Functional/Fixtures/FixtureTestBundle/Entity/SimplePersistedEntity.php b/tests/Functional/Fixtures/FixtureTestBundle/Entity/SimplePersistedEntity.php index 81a8437..2bcbf96 100644 --- a/tests/Functional/Fixtures/FixtureTestBundle/Entity/SimplePersistedEntity.php +++ b/tests/Functional/Fixtures/FixtureTestBundle/Entity/SimplePersistedEntity.php @@ -3,17 +3,9 @@ namespace Paysera\Bundle\ApiBundle\Tests\Functional\Fixtures\FixtureTestBundle\Entity; -use Doctrine\ORM\Mapping as ORM; - -/** - * @ORM\Entity - */ class SimplePersistedEntity { /** - * @ORM\Id - * @ORM\GeneratedValue(strategy="NONE") - * @ORM\Column(type="integer") * @var int|null */ private $id; diff --git a/tests/Functional/Fixtures/FixtureTestBundle/Resources/config/doctrine/PersistedEntity.orm.xml b/tests/Functional/Fixtures/FixtureTestBundle/Resources/config/doctrine/PersistedEntity.orm.xml new file mode 100644 index 0000000..9587ae8 --- /dev/null +++ b/tests/Functional/Fixtures/FixtureTestBundle/Resources/config/doctrine/PersistedEntity.orm.xml @@ -0,0 +1,12 @@ + + + + + + + + + diff --git a/tests/Functional/Fixtures/FixtureTestBundle/Resources/config/doctrine/SimplePersistedEntity.orm.xml b/tests/Functional/Fixtures/FixtureTestBundle/Resources/config/doctrine/SimplePersistedEntity.orm.xml new file mode 100644 index 0000000..e3b92e0 --- /dev/null +++ b/tests/Functional/Fixtures/FixtureTestBundle/Resources/config/doctrine/SimplePersistedEntity.orm.xml @@ -0,0 +1,10 @@ + + + + + + + + diff --git a/tests/Functional/Fixtures/FixtureTestBundle/Resources/config/explicit_routing.yml b/tests/Functional/Fixtures/FixtureTestBundle/Resources/config/explicit_routing.yml new file mode 100644 index 0000000..58be1d0 --- /dev/null +++ b/tests/Functional/Fixtures/FixtureTestBundle/Resources/config/explicit_routing.yml @@ -0,0 +1,43 @@ +paysera_fixture_test_1: + path: /config/by-full-controller + controller: Paysera\Bundle\ApiBundle\Tests\Functional\Fixtures\FixtureTestBundle\Controller\DefaultController::action1Action + +paysera_fixture_test_1b: + path: /config/by-full-controller-and-class-name + controller: Paysera\Bundle\ApiBundle\Tests\Functional\Fixtures\FixtureTestBundle\Controller\DefaultController::action1bAction + +paysera_fixture_test_2: + path: /config/by-class-name + controller: Paysera\Bundle\ApiBundle\Tests\Functional\Fixtures\FixtureTestBundle\Controller\DefaultController::action2 + +paysera_fixture_test_3: + path: /config/by-service-name + controller: paysera_fixture_test.controller.default_controller::action3 + +paysera_fixture_test_4: + path: /config/routing-by-service-name-config-by-class-name + controller: paysera_fixture_test.controller.default_controller::action4 + +paysera_fixture_test_5: + path: /config/routing-by-class-name-config-by-service-name + controller: Paysera\Bundle\ApiBundle\Tests\Functional\Fixtures\FixtureTestBundle\Controller\DefaultController::action5 + +paysera_fixture_test: + path: / + controller: paysera_fixture_test.controller.default_controller::action + +paysera_fixture_test_with_return: + path: /with-return + controller: paysera_fixture_test.controller.default_controller::actionWithReturn + +paysera_fixture_test_with_url_parameter: + path: /with-url-parameter/{urlParameter} + controller: Paysera\Bundle\ApiBundle\Tests\Functional\Fixtures\FixtureTestBundle\Controller\DefaultController::action + +paysera_fixture_test_with_several_url_parameters: + path: /with-url-parameters/{urlParameter1}/{urlParameter2} + controller: paysera_fixture_test.controller.default_controller::actionWithMultipleParameters + +paysera_fixture_test_with_several_parameters: + path: /with-parameters + controller: paysera_fixture_test.controller.default_controller::actionWithMultipleParameters diff --git a/tests/Functional/Fixtures/FixtureTestBundle/Resources/config/routing.xml b/tests/Functional/Fixtures/FixtureTestBundle/Resources/config/routing.xml index 3a06c43..7b0b7dc 100644 --- a/tests/Functional/Fixtures/FixtureTestBundle/Resources/config/routing.xml +++ b/tests/Functional/Fixtures/FixtureTestBundle/Resources/config/routing.xml @@ -11,46 +11,5 @@ - - Paysera\Bundle\ApiBundle\Tests\Functional\Fixtures\FixtureTestBundle\Controller\DefaultController::action1Action - - - - Paysera\Bundle\ApiBundle\Tests\Functional\Fixtures\FixtureTestBundle\Controller\DefaultController::action1bAction - - - - - Paysera\Bundle\ApiBundle\Tests\Functional\Fixtures\FixtureTestBundle\Controller\DefaultController::action2 - - - - paysera_fixture_test.controller.default_controller::action3 - - - paysera_fixture_test.controller.default_controller::action4 - - - - Paysera\Bundle\ApiBundle\Tests\Functional\Fixtures\FixtureTestBundle\Controller\DefaultController::action5 - - - - - paysera_fixture_test.controller.default_controller::action - - - paysera_fixture_test.controller.default_controller::actionWithReturn - - - - Paysera\Bundle\ApiBundle\Tests\Functional\Fixtures\FixtureTestBundle\Controller\DefaultController::action - - - - paysera_fixture_test.controller.default_controller::actionWithMultipleParameters - - - paysera_fixture_test.controller.default_controller::actionWithMultipleParameters - + diff --git a/tests/Functional/Fixtures/FixtureTestBundle/Resources/config/services.xml b/tests/Functional/Fixtures/FixtureTestBundle/Resources/config/services.xml index 46836bd..24833de 100644 --- a/tests/Functional/Fixtures/FixtureTestBundle/Resources/config/services.xml +++ b/tests/Functional/Fixtures/FixtureTestBundle/Resources/config/services.xml @@ -92,6 +92,10 @@ public="true"> + + + diff --git a/tests/Functional/Fixtures/FixtureTestBundle/Service/TestHelper.php b/tests/Functional/Fixtures/FixtureTestBundle/Service/TestHelper.php index a3133ff..e092728 100644 --- a/tests/Functional/Fixtures/FixtureTestBundle/Service/TestHelper.php +++ b/tests/Functional/Fixtures/FixtureTestBundle/Service/TestHelper.php @@ -5,6 +5,7 @@ namespace Paysera\Bundle\ApiBundle\Tests\Functional\Fixtures\FixtureTestBundle\Service; use Symfony\Bundle\FrameworkBundle\Routing\AttributeRouteControllerLoader; +use Symfony\Component\Routing\Loader\AttributeClassLoader; class TestHelper { @@ -12,4 +13,9 @@ public static function phpAttributeSupportExists(): bool { return class_exists(AttributeRouteControllerLoader::class); } + + public static function docblockRoutingSupportExists(): bool + { + return !class_exists(AttributeClassLoader::class) || property_exists(AttributeClassLoader::class, 'reader'); + } } diff --git a/tests/Functional/Fixtures/TestKernel.php b/tests/Functional/Fixtures/TestKernel.php index 9fc2a51..e0810d4 100644 --- a/tests/Functional/Fixtures/TestKernel.php +++ b/tests/Functional/Fixtures/TestKernel.php @@ -43,7 +43,7 @@ public function registerContainerConfiguration(LoaderInterface $loader) $loader->load(__DIR__ . '/config/' . $this->configFile); $loader->load(__DIR__ . '/config/' . $this->commonFile); - if (TestHelper::phpAttributeSupportExists()) { + if (TestHelper::phpAttributeSupportExists() && TestHelper::docblockRoutingSupportExists()) { $loader->load(__DIR__ . '/config/attributed_common.yml'); } } diff --git a/tests/Functional/Fixtures/config/sf7_common.yml b/tests/Functional/Fixtures/config/sf7_common.yml new file mode 100644 index 0000000..aa76955 --- /dev/null +++ b/tests/Functional/Fixtures/config/sf7_common.yml @@ -0,0 +1,19 @@ +framework: + router: + resource: '%kernel.project_dir%/tests/Functional/Fixtures/config/sf7_routing.yml' + validation: + email_validation_mode: html5 + property_info: + with_constructor_extractor: false + +security: + firewalls: + config: + pattern: ^/(config)/ + security: false + main: + http_basic: ~ + stateless: true + password_hashers: + Symfony\Component\Security\Core\User\InMemoryUser: + algorithm: plaintext diff --git a/tests/Functional/Fixtures/config/sf7_routing.yml b/tests/Functional/Fixtures/config/sf7_routing.yml new file mode 100644 index 0000000..6146637 --- /dev/null +++ b/tests/Functional/Fixtures/config/sf7_routing.yml @@ -0,0 +1,8 @@ +paysera_fixture_test: + resource: "@PayseraFixtureTestBundle/Resources/config/explicit_routing.yml" + prefix: / + +paysera_fixture_attributed_test: + resource: '@PayseraFixtureTestBundle/Controller/Attribute/' + type: attribute + prefix: / diff --git a/tests/Functional/FunctionalAnnotationsTest.php b/tests/Functional/FunctionalAnnotationsTest.php index 41b6f7f..5ade01b 100644 --- a/tests/Functional/FunctionalAnnotationsTest.php +++ b/tests/Functional/FunctionalAnnotationsTest.php @@ -3,7 +3,6 @@ namespace Paysera\Bundle\ApiBundle\Tests\Functional; -use Paysera\Bundle\ApiBundle\Tests\Functional\Fixtures\FixtureTestBundle\Service\TestHelper; use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Response; @@ -24,7 +23,7 @@ protected function setUp(): void public function testAnnotatedRestRequestConfiguration( Response $expectedResponse, Request $request, - Response $extraResponseVersion = null + ?Response $extraResponseVersion = null ) { $this->makeTest('annotated', $expectedResponse, $request, $extraResponseVersion); } @@ -38,20 +37,51 @@ public function testAnnotatedRestRequestConfiguration( public function testAttributedRestRequestConfiguration( Response $expectedResponse, Request $request, - Response $extraResponseVersion = null + ?Response $extraResponseVersion = null ) { $this->makeTest('attributed', $expectedResponse, $request, $extraResponseVersion); } + /** + * @dataProvider docblockOptionsOnAttributeRouteProvider + */ + public function testDocblockOptionsOnAttributeRoute( + int $expectedStatusCode, + string $expectedContent, + ?string $username + ): void { + $this->makeTest( + 'attributed', + new Response($expectedContent, $expectedStatusCode), + $this->createRequest('GET', '/docblock/testRequiredPermissions', null, [], $username) + ); + } + + public static function docblockOptionsOnAttributeRouteProvider(): array + { + return [ + 'without auth' => [ + 401, + '{"error":"unauthorized","error_description":"This API endpoint requires authentication, none found"}', + null, + ], + 'without enough permissions' => [ + 403, + '{"error":"forbidden",' + . '"error_description":"Access to this API endpoint is forbidden for current client"}', + 'user', + ], + 'with all needed permissions' => [200, 'OK', 'admin'], + ]; + } + private function makeTest( string $pathPrefix, Response $expectedResponse, Request $request, - Response $extraResponseVersion = null + ?Response $extraResponseVersion = null ): void { - if ($pathPrefix === 'attributed' && !TestHelper::phpAttributeSupportExists()) { - $this->markTestSkipped('Unsupported environment'); - } + $this->skipUnlessRoutesAreLoaded($pathPrefix); $request->server->set( 'REQUEST_URI', @@ -177,6 +207,29 @@ public function restRequestsConfigurationProvider(): array ['Content-Type' => 'text/plain'] ), ], + 'testBodyNormalizationWithCustomContentType and no content type' => [ + new Response( + '{"error":"invalid_request","error_description":"Content-Type must be provided"}', + 400 + ), + $this->createRequest( + 'POST', + '/testBodyNormalizationWithCustomContentType', + 'my_text' + ), + ], + 'testBodyNormalizationWithExtractedKeyValue and a body that is not JSON' => [ + new Response( + '{"error":"invalid_request","error_description":"Cannot decode request body to JSON"}', + 400 + ), + $this->createRequest( + 'POST', + '/testBodyNormalizationWithExtractedKeyValue', + '{"key": ', + ['Content-Type' => 'application/json'] + ), + ], 'testBodyNormalizationWithCustomContentTypeAndJsonDecode and JSON content-type' => [ new Response( '{"error":"invalid_request","error_description":"This Content-Type (application/json) is not supported"}', diff --git a/tests/Functional/FunctionalFindDenormalizersTest.php b/tests/Functional/FunctionalFindDenormalizersTest.php index 56ed6ec..ae4d755 100644 --- a/tests/Functional/FunctionalFindDenormalizersTest.php +++ b/tests/Functional/FunctionalFindDenormalizersTest.php @@ -15,9 +15,14 @@ protected function setUp(): void $this->setUpDatabase(); } - public function testRestRequestWithCustomPathConverter() + /** + * @dataProvider routesProvider + */ + public function testRestRequestWithCustomPathConverter(string $routes, string $pathPrefix) { - $response = $this->makeGetRequest('/persisted-entities/someField1'); + $this->skipUnlessRoutesAreLoaded($routes); + + $response = $this->makeGetRequest($pathPrefix . '/persisted-entities/someField1'); $this->assertEquals(404, $response->getStatusCode()); $manager = $this->getEntityManager(); @@ -25,15 +30,23 @@ public function testRestRequestWithCustomPathConverter() $manager->persist((new SimplePersistedEntity())->setId(420)); $manager->flush(); - $response = $this->makeGetRequest('/persisted-entities/someField1'); + $response = $this->makeGetRequest($pathPrefix . '/persisted-entities/someField1'); $this->assertEquals(200, $response->getStatusCode()); $this->assertEquals('42', $response->getContent()); - $response = $this->makeGetRequest('/simple-persisted-entities/1'); + $response = $this->makeGetRequest($pathPrefix . '/simple-persisted-entities/1'); $this->assertEquals(404, $response->getStatusCode()); - $response = $this->makeGetRequest('/simple-persisted-entities/420'); + $response = $this->makeGetRequest($pathPrefix . '/simple-persisted-entities/420'); $this->assertEquals(200, $response->getStatusCode()); $this->assertEquals('420', $response->getContent()); } + + public static function routesProvider(): array + { + return [ + 'annotated' => ['annotated', ''], + 'attributed' => ['attributed', '/attributed'], + ]; + } } diff --git a/tests/Functional/FunctionalPagedQueryTest.php b/tests/Functional/FunctionalPagedQueryTest.php index 86983a2..0eef291 100644 --- a/tests/Functional/FunctionalPagedQueryTest.php +++ b/tests/Functional/FunctionalPagedQueryTest.php @@ -19,6 +19,24 @@ class FunctionalPagedQueryTest extends FunctionalTestCase */ public function testRequestWithPagedQuery(Response $expectedResponse, Request $request, string $testCase = 'basic') { + $this->makeTest('annotated', $expectedResponse, $request, $testCase); + } + + /** + * @dataProvider requestWithPagedQueryProvider + */ + public function testAttributedRequestWithPagedQuery( + Response $expectedResponse, + Request $request, + string $testCase = 'basic' + ) { + $request->server->set('REQUEST_URI', '/attributed' . $request->server->get('REQUEST_URI')); + $this->makeTest('attributed', $expectedResponse, $request, $testCase); + } + + private function makeTest(string $routes, Response $expectedResponse, Request $request, string $testCase): void + { + $this->skipUnlessRoutesAreLoaded($routes); $this->setUpFor($testCase); $response = $this->handleRequest($request); $this->assertEquals( diff --git a/tests/Functional/FunctionalTestCase.php b/tests/Functional/FunctionalTestCase.php index 0a1f80e..675daf2 100644 --- a/tests/Functional/FunctionalTestCase.php +++ b/tests/Functional/FunctionalTestCase.php @@ -5,9 +5,9 @@ use Doctrine\ORM\EntityManagerInterface; use Doctrine\ORM\Tools\SchemaTool; +use Paysera\Bundle\ApiBundle\Tests\Functional\Fixtures\FixtureTestBundle\Service\TestHelper; use Paysera\Bundle\ApiBundle\Tests\Functional\Fixtures\TestKernel; use PHPUnit\Framework\TestCase; -use Symfony\Bundle\FrameworkBundle\Routing\AttributeRouteControllerLoader; use Symfony\Component\DependencyInjection\ContainerInterface; use Symfony\Component\DependencyInjection\ResettableContainerInterface; use Symfony\Component\HttpKernel\Kernel; @@ -20,7 +20,7 @@ abstract class FunctionalTestCase extends TestCase { /** - * @var TestKernel + * @var TestKernel|null */ protected $kernel; @@ -31,14 +31,33 @@ abstract class FunctionalTestCase extends TestCase */ protected function setUpContainer($testCase, $commonFile = 'common.yml') { - $prefix = Kernel::MAJOR_VERSION <= 4 ? 'legacy_' : ''; + $prefix = ''; + if (Kernel::MAJOR_VERSION <= 4) { + $prefix = 'legacy_'; + } elseif (!TestHelper::docblockRoutingSupportExists()) { + $prefix = 'sf7_'; + } $this->kernel = new TestKernel($testCase, $prefix . $commonFile); $this->kernel->boot(); return $this->kernel->getContainer(); } + protected function skipUnlessRoutesAreLoaded(string $routes): void + { + if ($routes === 'attributed' && !TestHelper::phpAttributeSupportExists()) { + $this->markTestSkipped('Unsupported environment'); + } + if ($routes === 'annotated' && !TestHelper::docblockRoutingSupportExists()) { + $this->markTestSkipped('Symfony 7 reads no @Route docblocks'); + } + } + protected function tearDown(): void { + if ($this->kernel === null) { + return; + } + $container = $this->kernel->getContainer(); $this->kernel->shutdown(); if ($container instanceof ResettableContainerInterface || $container instanceof ResetInterface) { @@ -57,9 +76,9 @@ protected function makeGetRequest(string $uri): Response protected function createRequest( string $method, string $uri, - string $content = null, + ?string $content = null, array $headers = [], - string $username = null + ?string $username = null ): Request { $parts = parse_url($uri); parse_str($parts['query'] ?? '', $query); diff --git a/tests/Unit/DependencyInjection/Fixtures/services.json b/tests/Unit/DependencyInjection/Fixtures/services.json index 83f7bc1..82c6777 100644 --- a/tests/Unit/DependencyInjection/Fixtures/services.json +++ b/tests/Unit/DependencyInjection/Fixtures/services.json @@ -4,19 +4,10 @@ "parent": "routing.loader.annotation", "calls": [ [ - "setRequestHelper", + "setRestRouteConfigurator", [ { - "reference": "paysera_api.rest_request_helper", - "invalid_behavior": 1 - } - ] - ], - [ - "setRestRequestOptionsBuilder", - [ - { - "reference": "paysera_api.annotations.options_builder", + "reference": "paysera_api.rest_route_configurator", "invalid_behavior": 1 } ] @@ -52,6 +43,9 @@ "paysera_api.content_type_matcher": { "class": "Paysera\\Bundle\\ApiBundle\\Service\\ContentTypeMatcher" }, + "paysera_api.docblock_annotation_reader": { + "class": "Paysera\\Bundle\\ApiBundle\\Service\\RoutingLoader\\DocblockAnnotationReader" + }, "paysera_api.entity_validator": { "class": "Paysera\\Bundle\\ApiBundle\\Service\\Validation\\EntityValidator", "arguments": [ @@ -258,28 +252,10 @@ "parent": "routing.loader.attribute", "calls": [ [ - "setRequestHelper", - [ - { - "reference": "paysera_api.rest_request_helper", - "invalid_behavior": 1 - } - ] - ], - [ - "setRestRequestAnnotationOptionsBuilder", - [ - { - "reference": "paysera_api.annotations.options_builder", - "invalid_behavior": 1 - } - ] - ], - [ - "setRestRequestAttributeOptionsBuilder", + "setRestRouteConfigurator", [ { - "reference": "paysera_api.attributes.options_builder", + "reference": "paysera_api.rest_route_configurator", "invalid_behavior": 1 } ] @@ -408,5 +384,26 @@ "invalid_behavior": 1 } ] + }, + "paysera_api.rest_route_configurator": { + "class": "Paysera\\Bundle\\ApiBundle\\Service\\RoutingLoader\\RestRouteConfigurator", + "arguments": [ + { + "reference": "paysera_api.rest_request_helper", + "invalid_behavior": 1 + }, + { + "reference": "paysera_api.annotations.options_builder", + "invalid_behavior": 1 + }, + { + "reference": "paysera_api.attributes.options_builder", + "invalid_behavior": 1 + }, + { + "reference": "paysera_api.docblock_annotation_reader", + "invalid_behavior": 1 + } + ] } } diff --git a/tests/Unit/Service/RoutingLoader/DocblockAnnotationReaderTest.php b/tests/Unit/Service/RoutingLoader/DocblockAnnotationReaderTest.php new file mode 100644 index 0000000..677a228 --- /dev/null +++ b/tests/Unit/Service/RoutingLoader/DocblockAnnotationReaderTest.php @@ -0,0 +1,26 @@ +readAnnotations( + new ReflectionClass(TraitDocblockOptionsController::class), + new ReflectionMethod(TraitDocblockOptionsController::class, 'show'), + null + ); + + $this->assertEquals([new RequiredPermissions(['permissions' => ['ROLE_ADMIN']])], $annotations); + } +} diff --git a/tests/Unit/Service/RoutingLoader/Fixtures/AliasedFullyQualifiedTraitMethodController.php b/tests/Unit/Service/RoutingLoader/Fixtures/AliasedFullyQualifiedTraitMethodController.php new file mode 100644 index 0000000..29674cf --- /dev/null +++ b/tests/Unit/Service/RoutingLoader/Fixtures/AliasedFullyQualifiedTraitMethodController.php @@ -0,0 +1,13 @@ +setRequiredPermissions(['ROLE_AUDITOR']); + } +} diff --git a/tests/Unit/Service/RoutingLoader/Fixtures/AttributeOptionsController.php b/tests/Unit/Service/RoutingLoader/Fixtures/AttributeOptionsController.php new file mode 100644 index 0000000..1d56d6f --- /dev/null +++ b/tests/Unit/Service/RoutingLoader/Fixtures/AttributeOptionsController.php @@ -0,0 +1,17 @@ +setRestRouteConfigurator(new RestRouteConfigurator( + new RestRequestHelper(new RestRequestOptionsRegistry()), + new RestRequestAnnotationOptionsBuilder(Mockery::spy(RestRequestOptionsValidator::class)), + new RestRequestAttributeOptionsBuilder(Mockery::spy(RestRequestOptionsValidator::class)), + new DocblockAnnotationReader() +)); + +$results = ['doc_comments_stripped' => (new ReflectionClass(AppPermission::class))->getDocComment() === false]; +foreach (array_slice($argv, 1) as $controller) { + try { + $permissions = []; + foreach ($loader->load($controller)->all() as $route) { + $options = $route->getDefault(RestRequestHelper::SERIALIZED_REST_OPTIONS_KEY); + $permissions[] = $options === null ? null : unserialize($options)->getRequiredPermissions(); + } + $results[$controller] = $permissions; + } catch (ConfigurationException $exception) { + $results[$controller] = $exception->getMessage(); + } +} + +echo json_encode($results); diff --git a/tests/Unit/Service/RoutingLoader/RestRequestAnnotationOptionsBuilderTest.php b/tests/Unit/Service/RoutingLoader/RestRequestAnnotationOptionsBuilderTest.php index a36d451..b5350bd 100644 --- a/tests/Unit/Service/RoutingLoader/RestRequestAnnotationOptionsBuilderTest.php +++ b/tests/Unit/Service/RoutingLoader/RestRequestAnnotationOptionsBuilderTest.php @@ -68,10 +68,11 @@ public function testBuildOptionsWithSeveralUnsupportedAnnotations(): void $builder = new RestRequestAnnotationOptionsBuilder($optionsValidator); $this->expectException(ConfigurationException::class); + $this->expectExceptionMessage('Only one annotation of type ' . Body::class . ' is supported'); $builder->buildOptions([ - new Body(['parameterName' => 'a']), - new Body(['parameterName' => 'b']), + new Body(['parameterName' => 'a', 'denormalizationType' => 'type_a', 'optional' => false]), + new Body(['parameterName' => 'b', 'denormalizationType' => 'type_b', 'optional' => false]), ], new ReflectionMethod(self::class, 'fixtureMethod')); } diff --git a/tests/Unit/Service/RoutingLoader/RestRouteConfiguratorTest.php b/tests/Unit/Service/RoutingLoader/RestRouteConfiguratorTest.php new file mode 100644 index 0000000..4ad2614 --- /dev/null +++ b/tests/Unit/Service/RoutingLoader/RestRouteConfiguratorTest.php @@ -0,0 +1,126 @@ +configureRoute(MisspeltDocblockAnnotationController::class, $reader); + $this->fail('Expected a ConfigurationException'); + } catch (ConfigurationException $exception) { + $this->assertStringStartsWith( + sprintf( + 'Cannot read the docblock annotations of %s::show(): [Semantical Error]', + MisspeltDocblockAnnotationController::class + ), + $exception->getMessage() + ); + $this->assertInstanceOf(AnnotationException::class, $exception->getPrevious()); + } + } + + public static function readerProvider(): array + { + return [ + 'with the reader of Symfony' => [new AnnotationReader()], + 'without a reader' => [null], + ]; + } + + /** + * @dataProvider invalidAttributeProvider + */ + public function testConfigureRouteFailsOnInvalidAttributes(string $controller, string $message): void + { + if (PHP_VERSION_ID < 80000) { + $this->markTestSkipped('PHP 7 has no attributes'); + } + + $this->expectException(ConfigurationException::class); + $this->expectExceptionMessage(sprintf($message, $controller)); + + $this->configureRoute($controller, null); + } + + public static function invalidAttributeProvider(): array + { + $unknown = '%s::show() uses Paysera\Bundle\ApiBundle\%s, which is no attribute of the bundle'; + + return [ + 'constructor error' => [InvalidAttributeController::class, 'Cannot read the attributes of %s::show(): '], + 'misspelt name' => [ + MisspeltAttributeController::class, + sprintf($unknown, '%s', 'Attribute\RequiredPermission'), + ], + 'misspelt namespace' => [ + MisspeltAttributeNamespaceController::class, + sprintf($unknown, '%s', 'Attributes\RequiredPermissions'), + ], + 'misspelt namespace written in lowercase' => [ + LowercaseMisspeltAttributeController::class, + '%s::show() uses paysera\bundle\apibundle\attributes\RequiredPermissions,' + . ' which is no attribute of the bundle', + ], + 'misspelt name in the annotation namespace' => [ + MisspeltAnnotationAliasAttributeController::class, + sprintf($unknown, '%s', 'Annotation\RequiredPermission'), + ], + 'class of the bundle that is no attribute' => [ + NonAttributeBundleClassController::class, + sprintf($unknown, '%s', 'Entity\RestRequestOptions'), + ], + 'annotation class used as an attribute' => [ + AnnotationClassAsAttributeController::class, + 'Cannot read the attributes of %s::show(): Attempting to use non-attribute class', + ], + ]; + } + + private function configureRoute(string $controller, ?Reader $reader): void + { + $configurator = new RestRouteConfigurator( + new RestRequestHelper(new RestRequestOptionsRegistry()), + new RestRequestAnnotationOptionsBuilder(Mockery::spy(RestRequestOptionsValidator::class)), + new RestRequestAttributeOptionsBuilder(Mockery::spy(RestRequestOptionsValidator::class)), + new DocblockAnnotationReader() + ); + $configurator->configureRoute( + new Route('/'), + new ReflectionClass($controller), + new ReflectionMethod($controller, 'show'), + $reader + ); + } +} diff --git a/tests/Unit/Service/RoutingLoader/RoutingAnnotationLoaderTest.php b/tests/Unit/Service/RoutingLoader/RoutingAnnotationLoaderTest.php new file mode 100644 index 0000000..17cb249 --- /dev/null +++ b/tests/Unit/Service/RoutingLoader/RoutingAnnotationLoaderTest.php @@ -0,0 +1,104 @@ +markTestSkipped('PHP 7 has no attributes'); + } + + if (class_exists(AttributeRouteControllerLoader::class)) { + $this->markTestSkipped('Symfony 6.4 and later load routes through RoutingAttributeLoader'); + } + + if ((new ReflectionClass(Route::class))->getAttributes() === []) { + $this->markTestSkipped('Symfony 5.1 and older read no #[Route] attributes'); + } + } + + /** + * @dataProvider optionsProvider + */ + public function testLoadAppliesOptions(string $controller, ?Reader $reader): void + { + $routes = $this->createLoader($reader)->load($controller)->all(); + + $this->assertCount(1, $routes); + $this->assertEquals( + (new RestRequestOptions())->setRequiredPermissions(['ROLE_ADMIN']), + unserialize(reset($routes)->getDefault(RestRequestHelper::SERIALIZED_REST_OPTIONS_KEY)) + ); + } + + /** + * @dataProvider readerProvider + */ + public function testLoadFailsWithAnnotationsAndAttributes(?Reader $reader): void + { + $this->expectException(ConfigurationException::class); + $this->expectExceptionMessage(sprintf( + '%s::update() is configured with both docblock annotations and attributes of the bundle', + NativeRouteMixedOptionsController::class + )); + + $this->createLoader($reader)->load(NativeRouteMixedOptionsController::class); + } + + public static function optionsProvider(): array + { + $cases = []; + foreach (self::readerProvider() as $readerCase => [$reader]) { + $cases['attributes ' . $readerCase] = [NativeRouteAttributeOptionsController::class, $reader]; + $cases['docblock annotations ' . $readerCase] = [NativeRouteDocblockOptionsController::class, $reader]; + } + + return $cases; + } + + public static function readerProvider(): array + { + return [ + 'with the reader of Symfony' => [new AnnotationReader()], + 'with framework.annotations disabled' => [null], + ]; + } + + private function createLoader(?Reader $reader): RoutingAnnotationLoader + { + $loader = new RoutingAnnotationLoader($reader); + $loader->setRestRouteConfigurator(new RestRouteConfigurator( + new RestRequestHelper(new RestRequestOptionsRegistry()), + new RestRequestAnnotationOptionsBuilder(Mockery::spy(RestRequestOptionsValidator::class)), + new RestRequestAttributeOptionsBuilder(Mockery::spy(RestRequestOptionsValidator::class)), + new DocblockAnnotationReader() + )); + + return $loader; + } +} diff --git a/tests/Unit/Service/RoutingLoader/RoutingAttributeLoaderTest.php b/tests/Unit/Service/RoutingLoader/RoutingAttributeLoaderTest.php new file mode 100644 index 0000000..74998e1 --- /dev/null +++ b/tests/Unit/Service/RoutingLoader/RoutingAttributeLoaderTest.php @@ -0,0 +1,353 @@ +markTestSkipped('Symfony 6.3 and older load routes through RoutingAnnotationLoader'); + } + } + + public function testLoadWithAnnotationReaderUsesIt(): void + { + if (!property_exists(AttributeClassLoader::class, 'reader')) { + $this->markTestSkipped('Symfony 7 passes no annotation reader to the route loader'); + } + + $reader = Mockery::mock(Reader::class); + $reader->shouldReceive('getClassAnnotation')->andReturn(null); + $reader->shouldReceive('getClassAnnotations')->andReturn([]); + $reader + ->shouldReceive('getMethodAnnotations') + ->andReturn([new stdClass(), new RequiredPermissions(['permissions' => ['ROLE_FROM_READER']])]) + ; + + $this->assertEquals( + [ + DocblockOptionsOnAttributeRouteController::class . '::show' => (new RestRequestOptions()) + ->setRequiredPermissions(['ROLE_FROM_READER']), + ], + $this->loadOptions($this->createLoader($reader), DocblockOptionsOnAttributeRouteController::class) + ); + } + + /** + * @dataProvider loadWithoutAnnotationReaderProvider + * @param array $expectedOptions + */ + public function testLoadWithoutAnnotationReader(string $controller, array $expectedOptions): void + { + $this->assertEquals($expectedOptions, $this->loadOptions($this->createLoader(), $controller)); + } + + public static function loadWithoutAnnotationReaderProvider(): array + { + $adminOnly = (new RestRequestOptions())->setRequiredPermissions(['ROLE_ADMIN']); + + return [ + 'attribute' => [ + AttributeOptionsController::class, + [AttributeOptionsController::class . '::show' => $adminOnly], + ], + 'attribute next to a tag that is not an annotation: the docblock is not read' => [ + AttributeOptionsWithNoteTagController::class, + [AttributeOptionsWithNoteTagController::class . '::show' => $adminOnly], + ], + 'annotation on the action' => [ + DocblockOptionsOnAttributeRouteController::class, + [DocblockOptionsOnAttributeRouteController::class . '::show' => $adminOnly], + ], + 'annotations on the class and the action' => [ + ClassAndActionDocblockController::class, + [ + ClassAndActionDocblockController::class . '::show' => (new RestRequestOptions()) + ->setRequiredPermissions(['ROLE_USER', 'ROLE_ADMIN']) + ->disableBodyValidation(), + ], + ], + 'action of a trait that imports the annotation' => [ + TraitDocblockOptionsController::class, + [TraitDocblockOptionsController::class . '::show' => $adminOnly], + ], + 'annotation imported with a group use statement' => [ + GroupUseDocblockController::class, + [GroupUseDocblockController::class . '::show' => $adminOnly], + ], + 'annotation written through an alias of a parent namespace' => [ + AliasedNamespaceDocblockController::class, + [AliasedNamespaceDocblockController::class . '::show' => $adminOnly], + ], + 'fully qualified annotation without a use statement' => [ + FullyQualifiedDocblockController::class, + [FullyQualifiedDocblockController::class . '::show' => $adminOnly], + ], + 'fully qualified annotation on the class without a use statement' => [ + ClassDocblockFullyQualifiedController::class, + [ClassDocblockFullyQualifiedController::class . '::show' => $adminOnly], + ], + 'fully qualified annotation on a trait action renamed with as' => [ + AliasedFullyQualifiedTraitMethodController::class, + [AliasedFullyQualifiedTraitMethodController::class . '::index' => $adminOnly], + ], + 'fully qualified annotation split across docblock lines' => [ + SplitFullyQualifiedDocblockController::class, + [SplitFullyQualifiedDocblockController::class . '::show' => $adminOnly], + ], + 'annotation class of the application, imported' => [ + AppAnnotationController::class, + [ + AppAnnotationController::class . '::show' => (new RestRequestOptions()) + ->setRequiredPermissions(['ROLE_AUDITOR']), + ], + ], + 'annotation class of the application, fully qualified without a use statement' => [ + AppAnnotationFullyQualifiedController::class, + [ + AppAnnotationFullyQualifiedController::class . '::show' => (new RestRequestOptions()) + ->setRequiredPermissions(['ROLE_AUDITOR']), + ], + ], + 'annotation class of the application, written through an alias of its namespace' => [ + NamespaceAliasAppAnnotationController::class, + [ + NamespaceAliasAppAnnotationController::class . '::show' => (new RestRequestOptions()) + ->setRequiredPermissions(['ROLE_AUDITOR']), + ], + ], + 'annotation class of the application, through a namespace alias split across docblock lines' => [ + SplitAliasAppAnnotationController::class, + [ + SplitAliasAppAnnotationController::class . '::show' => (new RestRequestOptions()) + ->setRequiredPermissions(['ROLE_AUDITOR']), + ], + ], + 'annotation class of the application, imported with a lowercase alias' => [ + LowercaseAliasAppAnnotationController::class, + [ + LowercaseAliasAppAnnotationController::class . '::show' => (new RestRequestOptions()) + ->setRequiredPermissions(['ROLE_AUDITOR']), + ], + ], + 'annotation class of the application, in the namespace of the controller' => [ + OwnNamespaceAppAnnotationController::class, + [ + OwnNamespaceAppAnnotationController::class . '::show' => (new RestRequestOptions()) + ->setRequiredPermissions(['ROLE_AUDITOR']), + ], + ], + 'unloadable class tagged outside the route\'s docblocks: the route still loads' => [ + MissingParentInUnrelatedDocblockController::class, + [MissingParentInUnrelatedDocblockController::class . '::show' => $adminOnly], + ], + 'attribute-only child of an annotated parent: only the inherited action\'s docblock is read' => [ + ChildOfImportingParentController::class, + [ + ChildOfImportingParentController::class . '::show' => $adminOnly, + ChildOfImportingParentController::class . '::index' => (new RestRequestOptions()) + ->setRequiredPermissions(['ROLE_USER']), + ], + ], + 'own action next to an annotated trait: only the trait action\'s docblock is read' => [ + OwnActionNextToAnnotatedTraitController::class, + [ + OwnActionNextToAnnotatedTraitController::class . '::show' => $adminOnly, + OwnActionNextToAnnotatedTraitController::class . '::index' => (new RestRequestOptions()) + ->setRequiredPermissions(['ROLE_USER']), + ], + ], + 'tags named like imports that are not annotations of the application: the docblock is not read' => [ + TagNamedLikeImportsController::class, + [TagNamedLikeImportsController::class . '::show' => $adminOnly], + ], + 'tag-shaped text in a string and a comment: the docblock is not read' => [ + NamespaceMentionedInStringController::class, + [NamespaceMentionedInStringController::class . '::show' => $adminOnly], + ], + ]; + } + + /** + * @dataProvider loadWithoutAnnotationReaderFailsProvider + */ + public function testLoadWithoutAnnotationReaderFails(string $controller, string $expectedMessage): void + { + $this->expectException(ConfigurationException::class); + $this->expectExceptionMessage($expectedMessage); + + $this->createLoader()->load($controller); + } + + public static function loadWithoutAnnotationReaderFailsProvider(): array + { + return [ + 'a tag that is not an annotation next to the annotation' => [ + UnimportedNoteTagController::class, + sprintf( + 'Cannot read the docblock annotations of %s::show(): [Semantical Error] The annotation "@note"', + UnimportedNoteTagController::class + ), + ], + 'annotation on the class and attribute on the action' => [ + ClassDocblockAndMethodAttributeController::class, + sprintf( + '%s::update() is configured with both docblock annotations and attributes of the bundle', + ClassDocblockAndMethodAttributeController::class + ), + ], + 'action of a trait used by another trait, whose imports Doctrine\'s reader does not see' => [ + NestedTraitActionController::class, + sprintf( + 'Cannot read the docblock annotations of %s::show(): [Semantical Error] The annotation' + . ' "@RequiredPermissions"', + NestedTraitActionController::class + ), + ], + 'trait action renamed with as, whose imports Doctrine\'s reader does not see' => [ + AliasedTraitMethodController::class, + sprintf( + 'Cannot read the docblock annotations of %s::index(): [Semantical Error] The annotation' + . ' "@RequiredPermissions"', + AliasedTraitMethodController::class + ), + ], + 'alias imported by the routed class only, for an action of its parent, refused by Doctrine' => [ + ChildImportingForParentController::class, + sprintf( + 'Cannot read the docblock annotations of %s::show(): [Semantical Error] The annotation' + . ' "@Rest\RequiredPermissions"', + ChildImportingForParentController::class + ), + ], + 'unloadable class tagged on the action' => [ + MissingParentTagController::class, + sprintf( + 'Cannot read the docblock annotations of %s::show(): Class "%s" not found', + MissingParentTagController::class, + 'Paysera\Bundle\ApiBundle\Tests\Unit\Service\RoutingLoader\Fixtures\MissingParent' + ), + ], + ]; + } + + public function testLoadWithoutAnnotationReaderWhenOpcacheStripsDocComments(): void + { + if (!extension_loaded('Zend OPcache')) { + $this->markTestSkipped('Needs the OPcache extension'); + } + + $attributeOnlyControllers = [ + AttributeOptionsController::class, + AttributeOptionsWithNoteTagController::class, + TagNamedLikeImportsController::class, + MissingParentInUnrelatedDocblockController::class, + ]; + $importingControllers = [ + DocblockOptionsOnAttributeRouteController::class, + AliasedNamespaceDocblockController::class, + ClassAndActionDocblockController::class, + ]; + exec( + sprintf( + '%s -d opcache.enable_cli=1 -d opcache.save_comments=0 -d opcache.file_update_protection=0 %s %s', + escapeshellarg(PHP_BINARY), + escapeshellarg(__DIR__ . '/Fixtures/load-routes.php'), + implode(' ', array_map('escapeshellarg', array_merge($attributeOnlyControllers, $importingControllers))) + ), + $output + ); + $results = json_decode(implode("\n", $output), true); + + $this->assertIsArray($results, implode("\n", $output)); + $this->assertTrue($results['doc_comments_stripped']); + foreach ($attributeOnlyControllers as $controller) { + $this->assertSame([['ROLE_ADMIN']], $results[$controller], $controller); + } + foreach ($importingControllers as $controller) { + $this->assertStringContainsString( + 'save_comments=1', + (string)json_encode($results[$controller]), + $controller + ); + } + } + + private function createLoader(?Reader $reader = null): RoutingAttributeLoader + { + $loader = $reader === null ? new RoutingAttributeLoader() : new RoutingAttributeLoader($reader); + $loader->setRestRouteConfigurator(new RestRouteConfigurator( + new RestRequestHelper(new RestRequestOptionsRegistry()), + new RestRequestAnnotationOptionsBuilder(Mockery::spy(RestRequestOptionsValidator::class)), + new RestRequestAttributeOptionsBuilder(Mockery::spy(RestRequestOptionsValidator::class)), + new DocblockAnnotationReader() + )); + + return $loader; + } + + /** + * @return array + */ + private function loadOptions(RoutingAttributeLoader $loader, string $controller): array + { + $options = []; + foreach ($loader->load($controller)->all() as $route) { + $serializedOptions = $route->getDefault(RestRequestHelper::SERIALIZED_REST_OPTIONS_KEY); + $options[$route->getDefault('_controller')] = $serializedOptions === null + ? null + : unserialize($serializedOptions); + } + + return $options; + } +} From 827116a5abda7effdb3b6c8cf8e18d437d596f20 Mon Sep 17 00:00:00 2001 From: "vinayak.iyer" Date: Tue, 20 Oct 2026 04:37:54 +0000 Subject: [PATCH 06/16] Document 2.0.0 and attribute route imports for Symfony 7 Co-Authored-By: Claude Fable 5.1 Torus-Session: 925cfa36-8312-40a5-a58d-89755fd7d827 --- CHANGELOG.md | 57 ++++++++++++++++++++++++++++++++++++++++++++++++++++ README.md | 55 ++++++++++++++++++++++++++++++++++++++++++++------ 2 files changed, 106 insertions(+), 6 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 67337b9..3ead160 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,63 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [2.0.0] +### Added +- Support for Symfony 7.4 and `psr/log` 3; CI runs PHP 8.4, Symfony 7 and lowest dependencies +- Explicit requirements for the Symfony components the bundle uses directly: `symfony/config`, + `dependency-injection`, `http-foundation`, `http-kernel`, `property-access`, `routing` and `security-core` + +### Changed +- `Configuration::getConfigTreeBuilder()` declares its `TreeBuilder` return type, as Symfony 7 requires, and the class + is `@final`. Breaking for a subclass that overrides the method without the return type +- `PayseraApiExtension` extends `Symfony\Component\DependencyInjection\Extension\Extension` instead of HttpKernel's + `Extension`, which is internal since Symfony 7.1. Breaking for a subclass that calls `addAnnotatedClassesToCompile()` +- Services are registered by `PayseraApiExtension` instead of the XML files in `Resources/config`, which are removed, + because Symfony 7.4 deprecates XML configuration. Service ids, classes, arguments, calls and tags are unchanged, + except that both internal route loaders take the new `paysera_api.rest_route_configurator` service (with the new + `paysera_api.docblock_annotation_reader`) instead of the request helper and the options builders +- An error while the bundle reads a route's docblock annotations itself or builds the bundle's attributes, from an + annotation or attribute constructor too, is wrapped in a `ConfigurationException` naming the action. Where Symfony + passes a reader, its own loader reads most docblocks with it first, so their errors come from Symfony unwrapped, and a + class with a missing parent (before PHP 7.3) or trait (any version) still stops PHP. An attribute named under + `Paysera\Bundle\ApiBundle\` that is none of the bundle's attributes (a misspelt name or namespace) fails too +- Optional parameters are declared nullable explicitly (`?Type $parameter = null`), as PHP 8.4 expects +- `doctrine/annotations` is required at 1.14.4 or 2.0.2 and later, whose reader refuses to run with + `opcache.save_comments=0` instead of reading the stripped docblocks as empty +- `LocaleListener` marks "no configured locale matched" with a `,` placeholder instead of `default`, which no unquoted + `Accept-Language` item can contain. A header listing the language `default` or `i-default` before a configured locale + now selects that locale, where 1.x kept the default locale + +### Fixed +- On Symfony 7.1 and later, `LocaleListener` kept the default locale for `de-DE` (and for `de` from Symfony 7.3) with + `de` configured, because Symfony matched the language against the placeholder `default` first + +### Security +- Where Symfony passes no annotation reader to the route loader (Symfony 7, and Symfony 5.4 on PHP 8 or Symfony 6 with + `framework.annotations` disabled), 1.x ignored the bundle's docblock annotations, `@RequiredPermissions` among them, + without an error on Symfony 6.4, and failed to load any route before it. The bundle now reads them with Doctrine's + `AnnotationReader` where Doctrine would find them: where a docblock uses an annotation implementing + `RestAnnotationInterface` (the bundle's or the application's), as Doctrine's `DocParser` resolves it, from the imports + and namespace of the class for its docblock, and of the declaring class and the traits in the action's file for an + action's docblock; and where the routed class, the declaring class or a trait in the action's file imports + `Paysera\Bundle\ApiBundle\Annotation`. Code that uses only attributes is not read with that reader, unless Doctrine's + parser fails on one of its docblocks (a tag naming a class that cannot be loaded), and an unused import of the + namespace makes the file read too, so remove it. Loading the routes fails with a `ConfigurationException` naming the + action where Doctrine's reader fails, on a docblock tag it cannot resolve (import it, or list it with + `@IgnoreAnnotation` in the class docblock). Where OPcache is set to strip doc comments (`save_comments=0`), Doctrine's + reader refuses to run, so loading fails where those classes import the annotation namespace. Not detected in a file + that does not import `Paysera\Bundle\ApiBundle\Annotation`: an annotation that Doctrine cannot resolve (written with + no import, with a misspelt import such as `Annotations`, or misspelt) and, where OPcache has stripped the doc + comments, any annotation +- On PHP 8 before Symfony 6.4, 1.x ignored the bundle's attributes, `#[RequiredPermissions]` among them, without an + error. They are now applied on every supported Symfony version +- On PHP 8, a route configured with both docblock annotations and attributes of the bundle, on the action or on its + class, fails to load with a `ConfigurationException`. Before, the attributes silently replaced the docblock options + from Symfony 6.4, so `#[Body]` on an action dropped the `@RequiredPermissions` of its class, and were ignored before +- These fixes are not backported to 1.x. 1.x still drops the bundle's permission checks without an error on Symfony 6.4 + with `framework.annotations` disabled, on PHP 8 before Symfony 6.4 for the bundle's attributes, and on routes + configured with both. Upgrade to 2.0.0, which applies the checks there or fails to load the routes where it cannot + ## [1.8.2] ### Changed - CI allows packages with security advisories, so Symfony 3.4 and 4.4 jobs can install dependencies with Composer 2.10 diff --git a/README.md b/README.md index eec01c8..eab0a05 100644 --- a/README.md +++ b/README.md @@ -147,12 +147,15 @@ class ApiController } } ``` -Controller example using attributes: +Controller example using attributes. The examples import `Symfony\Component\Routing\Attribute\Route`, which exists +from Symfony 6.4. On Symfony 5.2 to 6.3, import `Symfony\Component\Routing\Annotation\Route` instead; Symfony skips +an attribute class it does not know, so the route would not exist. Symfony before 5.2 reads no `#[Route]`: use a +docblock `@Route` there. ```php ``` +From Symfony 6.1, import them with `type: attribute`, which up to 6.4 also loads docblock `@Route` controllers (with +annotations enabled); Symfony 7 reads only `#[Route]`. Symfony 6.4 deprecates `type: annotation` and Symfony 7 rejects +it (Symfony 7.4 also deprecates XML route files): + ```yaml acme_something: resource: "@AcmeSomethingBundle/Controller/" - type: annotation + type: attribute prefix: /rest/v1/ ``` +```xml + + +``` + This also requires that your controller's service ID would be the same as its FQCN. #### HTTP example @@ -257,7 +277,7 @@ Controller example using attributes: Date: Thu, 22 Oct 2026 14:25:12 +0530 Subject: [PATCH 07/16] Fail on a bundle attribute name whose class does not exist PHP resolves an attribute written without its use statement against the controller's namespace, so #[RequiredPermissions] in App\Controller names App\Controller\RequiredPermissions. getAttributes() skips that class, and the namespace check misses it, so the route loaded without its permission check. A route now fails to load when an attribute's class does not exist and its short name, compared case-insensitively, is one of the bundle's seven attributes. An existing class of that name is left alone. Co-Authored-By: Claude Opus 5.5 (1M context) --- CHANGELOG.md | 14 ++++- README.md | 5 +- .../RoutingLoader/RestRouteConfigurator.php | 45 ++++++++++++++ .../MissingAttributeClassController.php | 48 +++++++++++++++ .../MissingClassAttributeClassController.php | 13 ++++ ...braryClassNamedLikeAttributeController.php | 15 +++++ .../RestRouteConfiguratorTest.php | 60 ++++++++++++++++--- 7 files changed, 189 insertions(+), 11 deletions(-) create mode 100644 tests/Unit/Service/RoutingLoader/Fixtures/MissingAttributeClassController.php create mode 100644 tests/Unit/Service/RoutingLoader/Fixtures/MissingClassAttributeClassController.php create mode 100644 tests/Unit/Service/RoutingLoader/Fixtures/OtherLibraryClassNamedLikeAttributeController.php diff --git a/CHANGELOG.md b/CHANGELOG.md index 3ead160..9235ba5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -23,7 +23,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 annotation or attribute constructor too, is wrapped in a `ConfigurationException` naming the action. Where Symfony passes a reader, its own loader reads most docblocks with it first, so their errors come from Symfony unwrapped, and a class with a missing parent (before PHP 7.3) or trait (any version) still stops PHP. An attribute named under - `Paysera\Bundle\ApiBundle\` that is none of the bundle's attributes (a misspelt name or namespace) fails too + `Paysera\Bundle\ApiBundle\` that is none of the bundle's attributes (a misspelt name or namespace) fails too, and so + does an attribute whose class does not exist and whose name, without its namespace, is one of the bundle's attributes + (one written without its `use` statement) - Optional parameters are declared nullable explicitly (`?Type $parameter = null`), as PHP 8.4 expects - `doctrine/annotations` is required at 1.14.4 or 2.0.2 and later, whose reader refuses to run with `opcache.save_comments=0` instead of reading the stripped docblocks as empty @@ -54,12 +56,18 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 comments, any annotation - On PHP 8 before Symfony 6.4, 1.x ignored the bundle's attributes, `#[RequiredPermissions]` among them, without an error. They are now applied on every supported Symfony version +- On PHP 8, a bundle attribute written without its `use` statement, `#[RequiredPermissions]` among them, names a class + of the controller's namespace that does not exist, which PHP and 1.x skip without an error. Loading the routes now + fails with a `ConfigurationException` naming the action and the bundle's attribute of that name; an existing class of + that name, such as the application's own `Body` attribute, is left alone. Not detected: a misspelt attribute name + written without its `use` statement (`#[RequiredPermission]`) - On PHP 8, a route configured with both docblock annotations and attributes of the bundle, on the action or on its class, fails to load with a `ConfigurationException`. Before, the attributes silently replaced the docblock options from Symfony 6.4, so `#[Body]` on an action dropped the `@RequiredPermissions` of its class, and were ignored before - These fixes are not backported to 1.x. 1.x still drops the bundle's permission checks without an error on Symfony 6.4 - with `framework.annotations` disabled, on PHP 8 before Symfony 6.4 for the bundle's attributes, and on routes - configured with both. Upgrade to 2.0.0, which applies the checks there or fails to load the routes where it cannot + with `framework.annotations` disabled, on PHP 8 before Symfony 6.4 for the bundle's attributes, on routes configured + with both, and for an attribute written without its `use` statement. Upgrade to 2.0.0, which applies the checks there + or fails to load the routes where it cannot ## [1.8.2] ### Changed diff --git a/README.md b/README.md index eab0a05..1b702cc 100644 --- a/README.md +++ b/README.md @@ -672,7 +672,10 @@ The attributes need PHP 8 and are applied on every supported Symfony version sin Symfony 6.4); the `#[Route]` class to import depends on the Symfony version, see the attribute controller example. A route configured with both docblock annotations and attributes of the bundle fails to load: configure each route with one of them. So does a route with an attribute named under `Paysera\Bundle\ApiBundle\` that is none of the bundle's -attributes, such as a misspelt one. +attributes, such as a misspelt one, and a route with an attribute whose class does not exist and whose name, without its +namespace, is one of the bundle's attributes, such as `#[RequiredPermissions]` written without its `use` statement. An +existing class of that name, such as your own `Body` attribute, is left alone. Not detected: a misspelt attribute name +written without its `use` statement (`#[RequiredPermission]`). Where Symfony passes no annotation reader to the route loader (Symfony 7, and Symfony 5.4 on PHP 8 or Symfony 6 with `framework.annotations` disabled), the bundle reads the docblocks with Doctrine's `AnnotationReader` where Doctrine diff --git a/src/Service/RoutingLoader/RestRouteConfigurator.php b/src/Service/RoutingLoader/RestRouteConfigurator.php index 851bc7f..99e8808 100644 --- a/src/Service/RoutingLoader/RestRouteConfigurator.php +++ b/src/Service/RoutingLoader/RestRouteConfigurator.php @@ -6,7 +6,14 @@ use Doctrine\Common\Annotations\Reader; use Paysera\Bundle\ApiBundle\Annotation\RestAnnotationInterface; +use Paysera\Bundle\ApiBundle\Attribute\Body; +use Paysera\Bundle\ApiBundle\Attribute\BodyContentType; +use Paysera\Bundle\ApiBundle\Attribute\PathAttribute; +use Paysera\Bundle\ApiBundle\Attribute\Query; +use Paysera\Bundle\ApiBundle\Attribute\RequiredPermissions; +use Paysera\Bundle\ApiBundle\Attribute\ResponseNormalization; use Paysera\Bundle\ApiBundle\Attribute\RestAttributeInterface; +use Paysera\Bundle\ApiBundle\Attribute\Validation; use Paysera\Bundle\ApiBundle\Exception\ConfigurationException; use Paysera\Bundle\ApiBundle\Service\RestRequestHelper; use ReflectionAttribute; @@ -21,6 +28,15 @@ class RestRouteConfigurator { private const BUNDLE_NAMESPACE = 'Paysera\\Bundle\\ApiBundle\\'; + private const BUNDLE_ATTRIBUTES = [ + Body::class, + BodyContentType::class, + PathAttribute::class, + Query::class, + RequiredPermissions::class, + ResponseNormalization::class, + Validation::class, + ]; /** * @var RestRequestHelper @@ -143,7 +159,36 @@ private function failOnUnknownBundleAttributes(ReflectionClass $class, Reflectio $name )); } + + $bundleAttribute = $this->findBundleAttributeNamed($this->getShortName($name)); + if ($bundleAttribute !== null && !class_exists($name)) { + throw new ConfigurationException(sprintf( + '%s::%s() uses %s, which does not exist; did you mean %s?', + $class->getName(), + $method->getName(), + $name, + $bundleAttribute + )); + } + } + } + + private function findBundleAttributeNamed(string $shortName): ?string + { + foreach (self::BUNDLE_ATTRIBUTES as $bundleAttribute) { + if (strcasecmp($this->getShortName($bundleAttribute), $shortName) === 0) { + return $bundleAttribute; + } } + + return null; + } + + private function getShortName(string $className): string + { + $position = strrpos($className, '\\'); + + return $position === false ? $className : substr($className, $position + 1); } private function createReadException( diff --git a/tests/Unit/Service/RoutingLoader/Fixtures/MissingAttributeClassController.php b/tests/Unit/Service/RoutingLoader/Fixtures/MissingAttributeClassController.php new file mode 100644 index 0000000..99a1c74 --- /dev/null +++ b/tests/Unit/Service/RoutingLoader/Fixtures/MissingAttributeClassController.php @@ -0,0 +1,48 @@ +markTestSkipped('PHP 7 has no attributes'); } @@ -71,14 +77,27 @@ public function testConfigureRouteFailsOnInvalidAttributes(string $controller, s $this->expectException(ConfigurationException::class); $this->expectExceptionMessage(sprintf($message, $controller)); - $this->configureRoute($controller, null); + $this->configureRoute($controller, null, $method); + } + + public function testConfigureRouteIgnoresClassOfOtherLibraryNamedLikeAttribute(): void + { + if (PHP_VERSION_ID < 80000) { + $this->markTestSkipped('PHP 7 has no attributes'); + } + + $this->assertEquals( + new Route('/'), + $this->configureRoute(OtherLibraryClassNamedLikeAttributeController::class, null) + ); } public static function invalidAttributeProvider(): array { $unknown = '%s::show() uses Paysera\Bundle\ApiBundle\%s, which is no attribute of the bundle'; + $missing = '%s::%s() uses %s, which does not exist; did you mean Paysera\Bundle\ApiBundle\Attribute\%s?'; - return [ + $dataSets = [ 'constructor error' => [InvalidAttributeController::class, 'Cannot read the attributes of %s::show(): '], 'misspelt name' => [ MisspeltAttributeController::class, @@ -105,10 +124,34 @@ public static function invalidAttributeProvider(): array AnnotationClassAsAttributeController::class, 'Cannot read the attributes of %s::show(): Attempting to use non-attribute class', ], + 'missing class named like an attribute in lowercase' => [ + MissingAttributeClassController::class, + sprintf($missing, '%s', 'lowercaseName', 'requiredpermissions', 'RequiredPermissions'), + 'lowercaseName', + ], + 'missing class named like an attribute, on the class' => [ + MissingClassAttributeClassController::class, + sprintf($missing, '%s', 'show', 'RequiredPermissions', 'RequiredPermissions'), + ], ]; + + foreach (glob(__DIR__ . '/../../../../src/Attribute/*.php') as $file) { + $attribute = basename($file, '.php'); + if (interface_exists('Paysera\Bundle\ApiBundle\Attribute\\' . $attribute)) { + continue; + } + + $dataSets['missing class named ' . $attribute] = [ + MissingAttributeClassController::class, + sprintf($missing, '%s', lcfirst($attribute), $attribute, $attribute), + lcfirst($attribute), + ]; + } + + return $dataSets; } - private function configureRoute(string $controller, ?Reader $reader): void + private function configureRoute(string $controller, ?Reader $reader, string $method = 'show'): Route { $configurator = new RestRouteConfigurator( new RestRequestHelper(new RestRequestOptionsRegistry()), @@ -116,11 +159,14 @@ private function configureRoute(string $controller, ?Reader $reader): void new RestRequestAttributeOptionsBuilder(Mockery::spy(RestRequestOptionsValidator::class)), new DocblockAnnotationReader() ); + $route = new Route('/'); $configurator->configureRoute( - new Route('/'), + $route, new ReflectionClass($controller), - new ReflectionMethod($controller, 'show'), + new ReflectionMethod($controller, $method), $reader ); + + return $route; } } From 1383b209ab9a96f6a65e9ff0ca34144adf9cc6d8 Mon Sep 17 00:00:00 2001 From: Vinayak Iyer Date: Thu, 22 Oct 2026 15:19:26 +0530 Subject: [PATCH 08/16] Test namespaced attribute names and say which classes cannot be loaded The data sets now name the missing classes in App\Controller, as PHP resolves an attribute written without its use statement, so a check that skips the short name fails them, and an attribute of an uninstalled library under another name still loads. The message says the class cannot be loaded, which also holds for an interface, a trait or a package that is not installed. CHANGELOG and README name the uninstalled-package case and each name that is still not detected. Co-Authored-By: Claude Opus 5.5 (1M context) --- CHANGELOG.md | 16 +++-- README.md | 12 ++-- .../RoutingLoader/RestRouteConfigurator.php | 2 +- .../MissingAttributeClassController.php | 47 ++++++++----- .../MissingClassAttributeClassController.php | 4 +- .../OtherLibraryAttributeController.php | 21 ++++++ ...braryClassNamedLikeAttributeController.php | 15 ---- .../RestRouteConfiguratorTest.php | 69 +++++++++++++------ 8 files changed, 123 insertions(+), 63 deletions(-) create mode 100644 tests/Unit/Service/RoutingLoader/Fixtures/OtherLibraryAttributeController.php delete mode 100644 tests/Unit/Service/RoutingLoader/Fixtures/OtherLibraryClassNamedLikeAttributeController.php diff --git a/CHANGELOG.md b/CHANGELOG.md index 9235ba5..033b61e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -24,8 +24,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 passes a reader, its own loader reads most docblocks with it first, so their errors come from Symfony unwrapped, and a class with a missing parent (before PHP 7.3) or trait (any version) still stops PHP. An attribute named under `Paysera\Bundle\ApiBundle\` that is none of the bundle's attributes (a misspelt name or namespace) fails too, and so - does an attribute whose class does not exist and whose name, without its namespace, is one of the bundle's attributes - (one written without its `use` statement) + does an attribute whose class cannot be loaded and whose name, without its namespace, is one of the bundle's + attributes: one written without its `use` statement, and also one of a package that is not installed where the routes + are loaded, such as swagger-php's `#[OA\Query]` with swagger-php installed for development only (install the package + there, or remove the attribute) - Optional parameters are declared nullable explicitly (`?Type $parameter = null`), as PHP 8.4 expects - `doctrine/annotations` is required at 1.14.4 or 2.0.2 and later, whose reader refuses to run with `opcache.save_comments=0` instead of reading the stripped docblocks as empty @@ -57,10 +59,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - On PHP 8 before Symfony 6.4, 1.x ignored the bundle's attributes, `#[RequiredPermissions]` among them, without an error. They are now applied on every supported Symfony version - On PHP 8, a bundle attribute written without its `use` statement, `#[RequiredPermissions]` among them, names a class - of the controller's namespace that does not exist, which PHP and 1.x skip without an error. Loading the routes now - fails with a `ConfigurationException` naming the action and the bundle's attribute of that name; an existing class of - that name, such as the application's own `Body` attribute, is left alone. Not detected: a misspelt attribute name - written without its `use` statement (`#[RequiredPermission]`) + that does not exist in the namespace it is written in, which PHP and 1.x skip without an error. Loading the routes + now fails with a `ConfigurationException` naming the action and the bundle's attribute of that name; a class of that + name that can be loaded, such as the application's own `Body` attribute, is left alone. Not detected: an attribute + written without its `use` statement whose name is none of the bundle's seven, such as a misspelt one + (`#[RequiredPermission]`) or the application's own attribute, and an alias of a class that does not exist + (`use App\Missing as RequiredPermissions`) - On PHP 8, a route configured with both docblock annotations and attributes of the bundle, on the action or on its class, fails to load with a `ConfigurationException`. Before, the attributes silently replaced the docblock options from Symfony 6.4, so `#[Body]` on an action dropped the `@RequiredPermissions` of its class, and were ignored before diff --git a/README.md b/README.md index 1b702cc..ffb61d3 100644 --- a/README.md +++ b/README.md @@ -672,10 +672,14 @@ The attributes need PHP 8 and are applied on every supported Symfony version sin Symfony 6.4); the `#[Route]` class to import depends on the Symfony version, see the attribute controller example. A route configured with both docblock annotations and attributes of the bundle fails to load: configure each route with one of them. So does a route with an attribute named under `Paysera\Bundle\ApiBundle\` that is none of the bundle's -attributes, such as a misspelt one, and a route with an attribute whose class does not exist and whose name, without its -namespace, is one of the bundle's attributes, such as `#[RequiredPermissions]` written without its `use` statement. An -existing class of that name, such as your own `Body` attribute, is left alone. Not detected: a misspelt attribute name -written without its `use` statement (`#[RequiredPermission]`). +attributes, such as a misspelt one, and a route with an attribute whose class cannot be loaded and whose name, without +its namespace, is one of the bundle's attributes, such as `#[RequiredPermissions]` written without its `use` statement. +That includes an attribute of a package that is not installed where the routes are loaded, such as swagger-php's +`#[OA\Query]` with swagger-php installed for development only: install the package there, or remove the attribute. A +class of that name that can be loaded, such as your own `Body` attribute, is left alone. Not detected: an attribute +written without its `use` statement whose name is none of the bundle's seven, such as a misspelt one +(`#[RequiredPermission]`) or your own attribute, and an alias of a class that does not exist +(`use App\Missing as RequiredPermissions`). Where Symfony passes no annotation reader to the route loader (Symfony 7, and Symfony 5.4 on PHP 8 or Symfony 6 with `framework.annotations` disabled), the bundle reads the docblocks with Doctrine's `AnnotationReader` where Doctrine diff --git a/src/Service/RoutingLoader/RestRouteConfigurator.php b/src/Service/RoutingLoader/RestRouteConfigurator.php index 99e8808..7cdfdba 100644 --- a/src/Service/RoutingLoader/RestRouteConfigurator.php +++ b/src/Service/RoutingLoader/RestRouteConfigurator.php @@ -163,7 +163,7 @@ private function failOnUnknownBundleAttributes(ReflectionClass $class, Reflectio $bundleAttribute = $this->findBundleAttributeNamed($this->getShortName($name)); if ($bundleAttribute !== null && !class_exists($name)) { throw new ConfigurationException(sprintf( - '%s::%s() uses %s, which does not exist; did you mean %s?', + '%s::%s() uses %s, which is not a class that can be loaded; did you mean %s?', $class->getName(), $method->getName(), $name, diff --git a/tests/Unit/Service/RoutingLoader/Fixtures/MissingAttributeClassController.php b/tests/Unit/Service/RoutingLoader/Fixtures/MissingAttributeClassController.php index 99a1c74..d04656e 100644 --- a/tests/Unit/Service/RoutingLoader/Fixtures/MissingAttributeClassController.php +++ b/tests/Unit/Service/RoutingLoader/Fixtures/MissingAttributeClassController.php @@ -4,45 +4,60 @@ namespace Paysera\Bundle\ApiBundle\Tests\Unit\Service\RoutingLoader\Fixtures; +use App\Controller\Body; +use App\Controller\BodyContentType; +use App\Controller\PathAttribute; +use App\Controller\Query; +use App\Controller\RequiredPermissions; +use App\Controller\requiredpermissions as LowercaseRequiredPermissions; +use App\Controller\ResponseNormalization; +use App\Controller\Validation; +use RequiredPermissions as GlobalRequiredPermissions; + class MissingAttributeClassController { - #[\Body(parameterName: 'body')] - public function body(): void + #[Body(parameterName: 'body')] + public function showBody(): void + { + } + + #[BodyContentType(supportedContentTypes: ['application/json'])] + public function showBodyContentType(): void { } - #[\BodyContentType(supportedContentTypes: ['application/json'])] - public function bodyContentType(): void + #[PathAttribute(parameterName: 'id', pathPartName: 'id')] + public function showPathAttribute(): void { } - #[\PathAttribute(parameterName: 'id', pathPartName: 'id')] - public function pathAttribute(): void + #[Query(parameterName: 'filter')] + public function showQuery(): void { } - #[\Query(parameterName: 'filter')] - public function query(): void + #[RequiredPermissions(permissions: ['ROLE_ADMIN'])] + public function showRequiredPermissions(): void { } - #[\RequiredPermissions(permissions: ['ROLE_ADMIN'])] - public function requiredPermissions(): void + #[ResponseNormalization(normalizationType: 'user')] + public function showResponseNormalization(): void { } - #[\ResponseNormalization(normalizationType: 'user')] - public function responseNormalization(): void + #[Validation(groups: ['Default'])] + public function showValidation(): void { } - #[\Validation(groups: ['Default'])] - public function validation(): void + #[LowercaseRequiredPermissions(permissions: ['ROLE_ADMIN'])] + public function showLowercaseName(): void { } - #[\requiredpermissions(permissions: ['ROLE_ADMIN'])] - public function lowercaseName(): void + #[GlobalRequiredPermissions(permissions: ['ROLE_ADMIN'])] + public function showGlobalName(): void { } } diff --git a/tests/Unit/Service/RoutingLoader/Fixtures/MissingClassAttributeClassController.php b/tests/Unit/Service/RoutingLoader/Fixtures/MissingClassAttributeClassController.php index 398803f..076efcc 100644 --- a/tests/Unit/Service/RoutingLoader/Fixtures/MissingClassAttributeClassController.php +++ b/tests/Unit/Service/RoutingLoader/Fixtures/MissingClassAttributeClassController.php @@ -4,7 +4,9 @@ namespace Paysera\Bundle\ApiBundle\Tests\Unit\Service\RoutingLoader\Fixtures; -#[\RequiredPermissions(permissions: ['ROLE_ADMIN'])] +use App\Controller\RequiredPermissions; + +#[RequiredPermissions(permissions: ['ROLE_ADMIN'])] class MissingClassAttributeClassController { public function show(): void diff --git a/tests/Unit/Service/RoutingLoader/Fixtures/OtherLibraryAttributeController.php b/tests/Unit/Service/RoutingLoader/Fixtures/OtherLibraryAttributeController.php new file mode 100644 index 0000000..dec6450 --- /dev/null +++ b/tests/Unit/Service/RoutingLoader/Fixtures/OtherLibraryAttributeController.php @@ -0,0 +1,21 @@ +configureRoute($controller, null, $method); } - public function testConfigureRouteIgnoresClassOfOtherLibraryNamedLikeAttribute(): void - { - if (PHP_VERSION_ID < 80000) { - $this->markTestSkipped('PHP 7 has no attributes'); - } - - $this->assertEquals( - new Route('/'), - $this->configureRoute(OtherLibraryClassNamedLikeAttributeController::class, null) - ); - } - public static function invalidAttributeProvider(): array { $unknown = '%s::show() uses Paysera\Bundle\ApiBundle\%s, which is no attribute of the bundle'; - $missing = '%s::%s() uses %s, which does not exist; did you mean Paysera\Bundle\ApiBundle\Attribute\%s?'; + $missing = '%s::%s() uses %s, which is not a class that can be loaded;' + . ' did you mean Paysera\Bundle\ApiBundle\Attribute\%s?'; $dataSets = [ 'constructor error' => [InvalidAttributeController::class, 'Cannot read the attributes of %s::show(): '], @@ -126,16 +116,32 @@ public static function invalidAttributeProvider(): array ], 'missing class named like an attribute in lowercase' => [ MissingAttributeClassController::class, - sprintf($missing, '%s', 'lowercaseName', 'requiredpermissions', 'RequiredPermissions'), - 'lowercaseName', + sprintf( + $missing, + '%s', + 'showLowercaseName', + 'App\Controller\requiredpermissions', + 'RequiredPermissions' + ), + 'showLowercaseName', + ], + 'missing class in the global namespace named like an attribute' => [ + MissingAttributeClassController::class, + sprintf($missing, '%s', 'showGlobalName', 'RequiredPermissions', 'RequiredPermissions'), + 'showGlobalName', ], 'missing class named like an attribute, on the class' => [ MissingClassAttributeClassController::class, - sprintf($missing, '%s', 'show', 'RequiredPermissions', 'RequiredPermissions'), + sprintf($missing, '%s', 'show', 'App\Controller\RequiredPermissions', 'RequiredPermissions'), ], ]; - foreach (glob(__DIR__ . '/../../../../src/Attribute/*.php') as $file) { + $attributeFiles = glob(__DIR__ . '/../../../../src/Attribute/*.php'); + if ($attributeFiles === false || $attributeFiles === []) { + throw new RuntimeException('No attribute class found in src/Attribute'); + } + + foreach ($attributeFiles as $file) { $attribute = basename($file, '.php'); if (interface_exists('Paysera\Bundle\ApiBundle\Attribute\\' . $attribute)) { continue; @@ -143,14 +149,37 @@ public static function invalidAttributeProvider(): array $dataSets['missing class named ' . $attribute] = [ MissingAttributeClassController::class, - sprintf($missing, '%s', lcfirst($attribute), $attribute, $attribute), - lcfirst($attribute), + sprintf($missing, '%s', 'show' . $attribute, 'App\Controller\\' . $attribute, $attribute), + 'show' . $attribute, ]; } return $dataSets; } + /** + * @dataProvider otherLibraryAttributeProvider + */ + public function testConfigureRouteIgnoresAttributesOfOtherLibraries(string $method): void + { + if (PHP_VERSION_ID < 80000) { + $this->markTestSkipped('PHP 7 has no attributes'); + } + + $this->assertEquals( + new Route('/'), + $this->configureRoute(OtherLibraryAttributeController::class, null, $method) + ); + } + + public static function otherLibraryAttributeProvider(): array + { + return [ + 'class of another library named like an attribute' => ['showClassNamedLikeAttribute'], + 'missing class with a name that is none of the attributes' => ['showMissingClassWithOtherName'], + ]; + } + private function configureRoute(string $controller, ?Reader $reader, string $method = 'show'): Route { $configurator = new RestRouteConfigurator( From c37e156a942cb32464eefebd0a205536e7aab5b2 Mon Sep 17 00:00:00 2001 From: Vinayak Iyer Date: Thu, 22 Oct 2026 15:41:29 +0530 Subject: [PATCH 09/16] Hint at the import only when the bundle's attribute is meant The refusal no longer asks "did you mean" the bundle's attribute: for an attribute of a package that is not installed it now says to import the bundle's attribute only if that is the one meant. The docs describe the check as the message does (a name that is not a class that can be loaded, which covers interfaces and traits) and say which aliases go undetected. A class-level attribute of another library still loads, and the provider names the glob pattern when it finds no attribute. Co-Authored-By: Claude Opus 5.5 (1M context) --- CHANGELOG.md | 10 +++++----- README.md | 7 ++++--- src/Service/RoutingLoader/RestRouteConfigurator.php | 3 ++- .../Fixtures/OtherLibraryAttributeController.php | 1 + .../RoutingLoader/RestRouteConfiguratorTest.php | 7 ++++--- 5 files changed, 16 insertions(+), 12 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 033b61e..9cd8ad1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -24,9 +24,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 passes a reader, its own loader reads most docblocks with it first, so their errors come from Symfony unwrapped, and a class with a missing parent (before PHP 7.3) or trait (any version) still stops PHP. An attribute named under `Paysera\Bundle\ApiBundle\` that is none of the bundle's attributes (a misspelt name or namespace) fails too, and so - does an attribute whose class cannot be loaded and whose name, without its namespace, is one of the bundle's - attributes: one written without its `use` statement, and also one of a package that is not installed where the routes - are loaded, such as swagger-php's `#[OA\Query]` with swagger-php installed for development only (install the package + does an attribute whose name, without its namespace, is one of the bundle's attributes but is not a class that can be + loaded: one written without its `use` statement, and also one of a package that is not installed where the routes are + loaded, such as swagger-php's `#[OA\Query]` with swagger-php installed for development only (install the package there, or remove the attribute) - Optional parameters are declared nullable explicitly (`?Type $parameter = null`), as PHP 8.4 expects - `doctrine/annotations` is required at 1.14.4 or 2.0.2 and later, whose reader refuses to run with @@ -63,8 +63,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 now fails with a `ConfigurationException` naming the action and the bundle's attribute of that name; a class of that name that can be loaded, such as the application's own `Body` attribute, is left alone. Not detected: an attribute written without its `use` statement whose name is none of the bundle's seven, such as a misspelt one - (`#[RequiredPermission]`) or the application's own attribute, and an alias of a class that does not exist - (`use App\Missing as RequiredPermissions`) + (`#[RequiredPermission]`) or the application's own attribute, and a class that does not exist imported under one of + the seven names (`use App\Missing as RequiredPermissions`) - On PHP 8, a route configured with both docblock annotations and attributes of the bundle, on the action or on its class, fails to load with a `ConfigurationException`. Before, the attributes silently replaced the docblock options from Symfony 6.4, so `#[Body]` on an action dropped the `@RequiredPermissions` of its class, and were ignored before diff --git a/README.md b/README.md index ffb61d3..2e6b10a 100644 --- a/README.md +++ b/README.md @@ -672,13 +672,14 @@ The attributes need PHP 8 and are applied on every supported Symfony version sin Symfony 6.4); the `#[Route]` class to import depends on the Symfony version, see the attribute controller example. A route configured with both docblock annotations and attributes of the bundle fails to load: configure each route with one of them. So does a route with an attribute named under `Paysera\Bundle\ApiBundle\` that is none of the bundle's -attributes, such as a misspelt one, and a route with an attribute whose class cannot be loaded and whose name, without -its namespace, is one of the bundle's attributes, such as `#[RequiredPermissions]` written without its `use` statement. +attributes, such as a misspelt one, and a route with an attribute whose name, without its namespace, is one of the +bundle's attributes but is not a class that can be loaded, such as `#[RequiredPermissions]` written without its `use` +statement. That includes an attribute of a package that is not installed where the routes are loaded, such as swagger-php's `#[OA\Query]` with swagger-php installed for development only: install the package there, or remove the attribute. A class of that name that can be loaded, such as your own `Body` attribute, is left alone. Not detected: an attribute written without its `use` statement whose name is none of the bundle's seven, such as a misspelt one -(`#[RequiredPermission]`) or your own attribute, and an alias of a class that does not exist +(`#[RequiredPermission]`) or your own attribute, and a class that does not exist imported under one of the seven names (`use App\Missing as RequiredPermissions`). Where Symfony passes no annotation reader to the route loader (Symfony 7, and Symfony 5.4 on PHP 8 or Symfony 6 with diff --git a/src/Service/RoutingLoader/RestRouteConfigurator.php b/src/Service/RoutingLoader/RestRouteConfigurator.php index 7cdfdba..9bfe9b6 100644 --- a/src/Service/RoutingLoader/RestRouteConfigurator.php +++ b/src/Service/RoutingLoader/RestRouteConfigurator.php @@ -163,7 +163,8 @@ private function failOnUnknownBundleAttributes(ReflectionClass $class, Reflectio $bundleAttribute = $this->findBundleAttributeNamed($this->getShortName($name)); if ($bundleAttribute !== null && !class_exists($name)) { throw new ConfigurationException(sprintf( - '%s::%s() uses %s, which is not a class that can be loaded; did you mean %s?', + '%s::%s() uses %s, which is not a class that can be loaded;' + . ' import %s if the bundle\'s attribute is meant', $class->getName(), $method->getName(), $name, diff --git a/tests/Unit/Service/RoutingLoader/Fixtures/OtherLibraryAttributeController.php b/tests/Unit/Service/RoutingLoader/Fixtures/OtherLibraryAttributeController.php index dec6450..1100d3c 100644 --- a/tests/Unit/Service/RoutingLoader/Fixtures/OtherLibraryAttributeController.php +++ b/tests/Unit/Service/RoutingLoader/Fixtures/OtherLibraryAttributeController.php @@ -7,6 +7,7 @@ use Acme\Missing\Get; use Symfony\Component\Validator\Validation; +#[Validation] class OtherLibraryAttributeController { #[Validation] diff --git a/tests/Unit/Service/RoutingLoader/RestRouteConfiguratorTest.php b/tests/Unit/Service/RoutingLoader/RestRouteConfiguratorTest.php index 853e5dd..45b4119 100644 --- a/tests/Unit/Service/RoutingLoader/RestRouteConfiguratorTest.php +++ b/tests/Unit/Service/RoutingLoader/RestRouteConfiguratorTest.php @@ -85,7 +85,7 @@ public static function invalidAttributeProvider(): array { $unknown = '%s::show() uses Paysera\Bundle\ApiBundle\%s, which is no attribute of the bundle'; $missing = '%s::%s() uses %s, which is not a class that can be loaded;' - . ' did you mean Paysera\Bundle\ApiBundle\Attribute\%s?'; + . ' import Paysera\Bundle\ApiBundle\Attribute\%s if the bundle\'s attribute is meant'; $dataSets = [ 'constructor error' => [InvalidAttributeController::class, 'Cannot read the attributes of %s::show(): '], @@ -136,9 +136,10 @@ public static function invalidAttributeProvider(): array ], ]; - $attributeFiles = glob(__DIR__ . '/../../../../src/Attribute/*.php'); + $attributePattern = __DIR__ . '/../../../../src/Attribute/*.php'; + $attributeFiles = glob($attributePattern); if ($attributeFiles === false || $attributeFiles === []) { - throw new RuntimeException('No attribute class found in src/Attribute'); + throw new RuntimeException('No attribute class found by the pattern ' . $attributePattern); } foreach ($attributeFiles as $file) { From f2e782c72a01dd023f9e50f560befeee4a704da8 Mon Sep 17 00:00:00 2001 From: Vinayak Iyer Date: Thu, 22 Oct 2026 15:55:20 +0530 Subject: [PATCH 10/16] Say what the name must resolve to, and test the class attribute alone For a qualified name such as #[REST\RequiredPermissions] without its alias import, importing the bundle's attribute changed nothing, so the message now says what the name must resolve to. The docs state exactly what goes undetected: a class that does not exist, is not named under the bundle's namespace and whose own name is none of the seven, however it is written or imported. The class-level attribute of another library gets its own fixture, so each data set that must still load tests one input. Co-Authored-By: Claude Opus 5.5 (1M context) --- CHANGELOG.md | 6 ++--- README.md | 6 ++--- .../RoutingLoader/RestRouteConfigurator.php | 2 +- .../OtherLibraryAttributeController.php | 1 - .../OtherLibraryClassAttributeController.php | 15 ++++++++++++ .../RestRouteConfiguratorTest.php | 24 ++++++++++++------- 6 files changed, 38 insertions(+), 16 deletions(-) create mode 100644 tests/Unit/Service/RoutingLoader/Fixtures/OtherLibraryClassAttributeController.php diff --git a/CHANGELOG.md b/CHANGELOG.md index 9cd8ad1..266d53a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -62,9 +62,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 that does not exist in the namespace it is written in, which PHP and 1.x skip without an error. Loading the routes now fails with a `ConfigurationException` naming the action and the bundle's attribute of that name; a class of that name that can be loaded, such as the application's own `Body` attribute, is left alone. Not detected: an attribute - written without its `use` statement whose name is none of the bundle's seven, such as a misspelt one - (`#[RequiredPermission]`) or the application's own attribute, and a class that does not exist imported under one of - the seven names (`use App\Missing as RequiredPermissions`) + whose class does not exist, is not named under `Paysera\Bundle\ApiBundle\` and whose own name, without its namespace, + is none of the seven, however it is written or imported: a misspelt `#[RequiredPermission]`, the application's own + attribute without its `use` statement, or `use App\Missing as RequiredPermissions` - On PHP 8, a route configured with both docblock annotations and attributes of the bundle, on the action or on its class, fails to load with a `ConfigurationException`. Before, the attributes silently replaced the docblock options from Symfony 6.4, so `#[Body]` on an action dropped the `@RequiredPermissions` of its class, and were ignored before diff --git a/README.md b/README.md index 2e6b10a..26f3b34 100644 --- a/README.md +++ b/README.md @@ -678,9 +678,9 @@ statement. That includes an attribute of a package that is not installed where the routes are loaded, such as swagger-php's `#[OA\Query]` with swagger-php installed for development only: install the package there, or remove the attribute. A class of that name that can be loaded, such as your own `Body` attribute, is left alone. Not detected: an attribute -written without its `use` statement whose name is none of the bundle's seven, such as a misspelt one -(`#[RequiredPermission]`) or your own attribute, and a class that does not exist imported under one of the seven names -(`use App\Missing as RequiredPermissions`). +whose class does not exist, is not named under `Paysera\Bundle\ApiBundle\` and whose own name, without its namespace, +is none of the seven, however it is written or imported: a misspelt `#[RequiredPermission]`, your own attribute without +its `use` statement, or `use App\Missing as RequiredPermissions`. Where Symfony passes no annotation reader to the route loader (Symfony 7, and Symfony 5.4 on PHP 8 or Symfony 6 with `framework.annotations` disabled), the bundle reads the docblocks with Doctrine's `AnnotationReader` where Doctrine diff --git a/src/Service/RoutingLoader/RestRouteConfigurator.php b/src/Service/RoutingLoader/RestRouteConfigurator.php index 9bfe9b6..379c4b7 100644 --- a/src/Service/RoutingLoader/RestRouteConfigurator.php +++ b/src/Service/RoutingLoader/RestRouteConfigurator.php @@ -164,7 +164,7 @@ private function failOnUnknownBundleAttributes(ReflectionClass $class, Reflectio if ($bundleAttribute !== null && !class_exists($name)) { throw new ConfigurationException(sprintf( '%s::%s() uses %s, which is not a class that can be loaded;' - . ' import %s if the bundle\'s attribute is meant', + . ' if the bundle\'s attribute is meant, the name must resolve to %s', $class->getName(), $method->getName(), $name, diff --git a/tests/Unit/Service/RoutingLoader/Fixtures/OtherLibraryAttributeController.php b/tests/Unit/Service/RoutingLoader/Fixtures/OtherLibraryAttributeController.php index 1100d3c..dec6450 100644 --- a/tests/Unit/Service/RoutingLoader/Fixtures/OtherLibraryAttributeController.php +++ b/tests/Unit/Service/RoutingLoader/Fixtures/OtherLibraryAttributeController.php @@ -7,7 +7,6 @@ use Acme\Missing\Get; use Symfony\Component\Validator\Validation; -#[Validation] class OtherLibraryAttributeController { #[Validation] diff --git a/tests/Unit/Service/RoutingLoader/Fixtures/OtherLibraryClassAttributeController.php b/tests/Unit/Service/RoutingLoader/Fixtures/OtherLibraryClassAttributeController.php new file mode 100644 index 0000000..84d4aa6 --- /dev/null +++ b/tests/Unit/Service/RoutingLoader/Fixtures/OtherLibraryClassAttributeController.php @@ -0,0 +1,15 @@ + [InvalidAttributeController::class, 'Cannot read the attributes of %s::show(): '], @@ -161,23 +162,30 @@ public static function invalidAttributeProvider(): array /** * @dataProvider otherLibraryAttributeProvider */ - public function testConfigureRouteIgnoresAttributesOfOtherLibraries(string $method): void + public function testConfigureRouteIgnoresAttributesOfOtherLibraries(string $controller, string $method): void { if (PHP_VERSION_ID < 80000) { $this->markTestSkipped('PHP 7 has no attributes'); } - $this->assertEquals( - new Route('/'), - $this->configureRoute(OtherLibraryAttributeController::class, null, $method) - ); + $this->assertEquals(new Route('/'), $this->configureRoute($controller, null, $method)); } public static function otherLibraryAttributeProvider(): array { return [ - 'class of another library named like an attribute' => ['showClassNamedLikeAttribute'], - 'missing class with a name that is none of the attributes' => ['showMissingClassWithOtherName'], + 'class of another library named like an attribute' => [ + OtherLibraryAttributeController::class, + 'showClassNamedLikeAttribute', + ], + 'class of another library named like an attribute, on the class' => [ + OtherLibraryClassAttributeController::class, + 'show', + ], + 'missing class with a name that is none of the attributes' => [ + OtherLibraryAttributeController::class, + 'showMissingClassWithOtherName', + ], ]; } From d3c1a6ebc7bc4fab1c43ff6a8ea1971191b5c319 Mon Sep 17 00:00:00 2001 From: Vinayak Iyer Date: Thu, 22 Oct 2026 19:27:25 +0530 Subject: [PATCH 11/16] Revert the short-name attribute check This reverts 5835363, 1383b20, c37e156 and f2e782c. The check refused any attribute whose class cannot be loaded and whose name, without its namespace, is one of the bundle's seven, whatever namespace it is in. An attribute of a package that is not installed where the routes load, such as swagger-php's #[OA\Query], stopped every route from loading. The next commit replaces it with a check limited to the namespace the attribute is written in. Co-Authored-By: Claude Opus 5.5 (1M context) --- CHANGELOG.md | 18 +--- README.md | 10 +- .../RoutingLoader/RestRouteConfigurator.php | 46 --------- .../MissingAttributeClassController.php | 63 ------------ .../MissingClassAttributeClassController.php | 15 --- .../OtherLibraryAttributeController.php | 21 ---- .../OtherLibraryClassAttributeController.php | 15 --- .../RestRouteConfiguratorTest.php | 98 ++----------------- 8 files changed, 11 insertions(+), 275 deletions(-) delete mode 100644 tests/Unit/Service/RoutingLoader/Fixtures/MissingAttributeClassController.php delete mode 100644 tests/Unit/Service/RoutingLoader/Fixtures/MissingClassAttributeClassController.php delete mode 100644 tests/Unit/Service/RoutingLoader/Fixtures/OtherLibraryAttributeController.php delete mode 100644 tests/Unit/Service/RoutingLoader/Fixtures/OtherLibraryClassAttributeController.php diff --git a/CHANGELOG.md b/CHANGELOG.md index 266d53a..3ead160 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -23,11 +23,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 annotation or attribute constructor too, is wrapped in a `ConfigurationException` naming the action. Where Symfony passes a reader, its own loader reads most docblocks with it first, so their errors come from Symfony unwrapped, and a class with a missing parent (before PHP 7.3) or trait (any version) still stops PHP. An attribute named under - `Paysera\Bundle\ApiBundle\` that is none of the bundle's attributes (a misspelt name or namespace) fails too, and so - does an attribute whose name, without its namespace, is one of the bundle's attributes but is not a class that can be - loaded: one written without its `use` statement, and also one of a package that is not installed where the routes are - loaded, such as swagger-php's `#[OA\Query]` with swagger-php installed for development only (install the package - there, or remove the attribute) + `Paysera\Bundle\ApiBundle\` that is none of the bundle's attributes (a misspelt name or namespace) fails too - Optional parameters are declared nullable explicitly (`?Type $parameter = null`), as PHP 8.4 expects - `doctrine/annotations` is required at 1.14.4 or 2.0.2 and later, whose reader refuses to run with `opcache.save_comments=0` instead of reading the stripped docblocks as empty @@ -58,20 +54,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 comments, any annotation - On PHP 8 before Symfony 6.4, 1.x ignored the bundle's attributes, `#[RequiredPermissions]` among them, without an error. They are now applied on every supported Symfony version -- On PHP 8, a bundle attribute written without its `use` statement, `#[RequiredPermissions]` among them, names a class - that does not exist in the namespace it is written in, which PHP and 1.x skip without an error. Loading the routes - now fails with a `ConfigurationException` naming the action and the bundle's attribute of that name; a class of that - name that can be loaded, such as the application's own `Body` attribute, is left alone. Not detected: an attribute - whose class does not exist, is not named under `Paysera\Bundle\ApiBundle\` and whose own name, without its namespace, - is none of the seven, however it is written or imported: a misspelt `#[RequiredPermission]`, the application's own - attribute without its `use` statement, or `use App\Missing as RequiredPermissions` - On PHP 8, a route configured with both docblock annotations and attributes of the bundle, on the action or on its class, fails to load with a `ConfigurationException`. Before, the attributes silently replaced the docblock options from Symfony 6.4, so `#[Body]` on an action dropped the `@RequiredPermissions` of its class, and were ignored before - These fixes are not backported to 1.x. 1.x still drops the bundle's permission checks without an error on Symfony 6.4 - with `framework.annotations` disabled, on PHP 8 before Symfony 6.4 for the bundle's attributes, on routes configured - with both, and for an attribute written without its `use` statement. Upgrade to 2.0.0, which applies the checks there - or fails to load the routes where it cannot + with `framework.annotations` disabled, on PHP 8 before Symfony 6.4 for the bundle's attributes, and on routes + configured with both. Upgrade to 2.0.0, which applies the checks there or fails to load the routes where it cannot ## [1.8.2] ### Changed diff --git a/README.md b/README.md index 26f3b34..eab0a05 100644 --- a/README.md +++ b/README.md @@ -672,15 +672,7 @@ The attributes need PHP 8 and are applied on every supported Symfony version sin Symfony 6.4); the `#[Route]` class to import depends on the Symfony version, see the attribute controller example. A route configured with both docblock annotations and attributes of the bundle fails to load: configure each route with one of them. So does a route with an attribute named under `Paysera\Bundle\ApiBundle\` that is none of the bundle's -attributes, such as a misspelt one, and a route with an attribute whose name, without its namespace, is one of the -bundle's attributes but is not a class that can be loaded, such as `#[RequiredPermissions]` written without its `use` -statement. -That includes an attribute of a package that is not installed where the routes are loaded, such as swagger-php's -`#[OA\Query]` with swagger-php installed for development only: install the package there, or remove the attribute. A -class of that name that can be loaded, such as your own `Body` attribute, is left alone. Not detected: an attribute -whose class does not exist, is not named under `Paysera\Bundle\ApiBundle\` and whose own name, without its namespace, -is none of the seven, however it is written or imported: a misspelt `#[RequiredPermission]`, your own attribute without -its `use` statement, or `use App\Missing as RequiredPermissions`. +attributes, such as a misspelt one. Where Symfony passes no annotation reader to the route loader (Symfony 7, and Symfony 5.4 on PHP 8 or Symfony 6 with `framework.annotations` disabled), the bundle reads the docblocks with Doctrine's `AnnotationReader` where Doctrine diff --git a/src/Service/RoutingLoader/RestRouteConfigurator.php b/src/Service/RoutingLoader/RestRouteConfigurator.php index 379c4b7..851bc7f 100644 --- a/src/Service/RoutingLoader/RestRouteConfigurator.php +++ b/src/Service/RoutingLoader/RestRouteConfigurator.php @@ -6,14 +6,7 @@ use Doctrine\Common\Annotations\Reader; use Paysera\Bundle\ApiBundle\Annotation\RestAnnotationInterface; -use Paysera\Bundle\ApiBundle\Attribute\Body; -use Paysera\Bundle\ApiBundle\Attribute\BodyContentType; -use Paysera\Bundle\ApiBundle\Attribute\PathAttribute; -use Paysera\Bundle\ApiBundle\Attribute\Query; -use Paysera\Bundle\ApiBundle\Attribute\RequiredPermissions; -use Paysera\Bundle\ApiBundle\Attribute\ResponseNormalization; use Paysera\Bundle\ApiBundle\Attribute\RestAttributeInterface; -use Paysera\Bundle\ApiBundle\Attribute\Validation; use Paysera\Bundle\ApiBundle\Exception\ConfigurationException; use Paysera\Bundle\ApiBundle\Service\RestRequestHelper; use ReflectionAttribute; @@ -28,15 +21,6 @@ class RestRouteConfigurator { private const BUNDLE_NAMESPACE = 'Paysera\\Bundle\\ApiBundle\\'; - private const BUNDLE_ATTRIBUTES = [ - Body::class, - BodyContentType::class, - PathAttribute::class, - Query::class, - RequiredPermissions::class, - ResponseNormalization::class, - Validation::class, - ]; /** * @var RestRequestHelper @@ -159,37 +143,7 @@ private function failOnUnknownBundleAttributes(ReflectionClass $class, Reflectio $name )); } - - $bundleAttribute = $this->findBundleAttributeNamed($this->getShortName($name)); - if ($bundleAttribute !== null && !class_exists($name)) { - throw new ConfigurationException(sprintf( - '%s::%s() uses %s, which is not a class that can be loaded;' - . ' if the bundle\'s attribute is meant, the name must resolve to %s', - $class->getName(), - $method->getName(), - $name, - $bundleAttribute - )); - } - } - } - - private function findBundleAttributeNamed(string $shortName): ?string - { - foreach (self::BUNDLE_ATTRIBUTES as $bundleAttribute) { - if (strcasecmp($this->getShortName($bundleAttribute), $shortName) === 0) { - return $bundleAttribute; - } } - - return null; - } - - private function getShortName(string $className): string - { - $position = strrpos($className, '\\'); - - return $position === false ? $className : substr($className, $position + 1); } private function createReadException( diff --git a/tests/Unit/Service/RoutingLoader/Fixtures/MissingAttributeClassController.php b/tests/Unit/Service/RoutingLoader/Fixtures/MissingAttributeClassController.php deleted file mode 100644 index d04656e..0000000 --- a/tests/Unit/Service/RoutingLoader/Fixtures/MissingAttributeClassController.php +++ /dev/null @@ -1,63 +0,0 @@ -markTestSkipped('PHP 7 has no attributes'); } @@ -79,16 +71,14 @@ public function testConfigureRouteFailsOnInvalidAttributes( $this->expectException(ConfigurationException::class); $this->expectExceptionMessage(sprintf($message, $controller)); - $this->configureRoute($controller, null, $method); + $this->configureRoute($controller, null); } public static function invalidAttributeProvider(): array { $unknown = '%s::show() uses Paysera\Bundle\ApiBundle\%s, which is no attribute of the bundle'; - $missing = '%s::%s() uses %s, which is not a class that can be loaded;' - . ' if the bundle\'s attribute is meant, the name must resolve to Paysera\Bundle\ApiBundle\Attribute\%s'; - $dataSets = [ + return [ 'constructor error' => [InvalidAttributeController::class, 'Cannot read the attributes of %s::show(): '], 'misspelt name' => [ MisspeltAttributeController::class, @@ -115,81 +105,10 @@ public static function invalidAttributeProvider(): array AnnotationClassAsAttributeController::class, 'Cannot read the attributes of %s::show(): Attempting to use non-attribute class', ], - 'missing class named like an attribute in lowercase' => [ - MissingAttributeClassController::class, - sprintf( - $missing, - '%s', - 'showLowercaseName', - 'App\Controller\requiredpermissions', - 'RequiredPermissions' - ), - 'showLowercaseName', - ], - 'missing class in the global namespace named like an attribute' => [ - MissingAttributeClassController::class, - sprintf($missing, '%s', 'showGlobalName', 'RequiredPermissions', 'RequiredPermissions'), - 'showGlobalName', - ], - 'missing class named like an attribute, on the class' => [ - MissingClassAttributeClassController::class, - sprintf($missing, '%s', 'show', 'App\Controller\RequiredPermissions', 'RequiredPermissions'), - ], ]; - - $attributePattern = __DIR__ . '/../../../../src/Attribute/*.php'; - $attributeFiles = glob($attributePattern); - if ($attributeFiles === false || $attributeFiles === []) { - throw new RuntimeException('No attribute class found by the pattern ' . $attributePattern); - } - - foreach ($attributeFiles as $file) { - $attribute = basename($file, '.php'); - if (interface_exists('Paysera\Bundle\ApiBundle\Attribute\\' . $attribute)) { - continue; - } - - $dataSets['missing class named ' . $attribute] = [ - MissingAttributeClassController::class, - sprintf($missing, '%s', 'show' . $attribute, 'App\Controller\\' . $attribute, $attribute), - 'show' . $attribute, - ]; - } - - return $dataSets; } - /** - * @dataProvider otherLibraryAttributeProvider - */ - public function testConfigureRouteIgnoresAttributesOfOtherLibraries(string $controller, string $method): void - { - if (PHP_VERSION_ID < 80000) { - $this->markTestSkipped('PHP 7 has no attributes'); - } - - $this->assertEquals(new Route('/'), $this->configureRoute($controller, null, $method)); - } - - public static function otherLibraryAttributeProvider(): array - { - return [ - 'class of another library named like an attribute' => [ - OtherLibraryAttributeController::class, - 'showClassNamedLikeAttribute', - ], - 'class of another library named like an attribute, on the class' => [ - OtherLibraryClassAttributeController::class, - 'show', - ], - 'missing class with a name that is none of the attributes' => [ - OtherLibraryAttributeController::class, - 'showMissingClassWithOtherName', - ], - ]; - } - - private function configureRoute(string $controller, ?Reader $reader, string $method = 'show'): Route + private function configureRoute(string $controller, ?Reader $reader): void { $configurator = new RestRouteConfigurator( new RestRequestHelper(new RestRequestOptionsRegistry()), @@ -197,14 +116,11 @@ private function configureRoute(string $controller, ?Reader $reader, string $met new RestRequestAttributeOptionsBuilder(Mockery::spy(RestRequestOptionsValidator::class)), new DocblockAnnotationReader() ); - $route = new Route('/'); $configurator->configureRoute( - $route, + new Route('/'), new ReflectionClass($controller), - new ReflectionMethod($controller, $method), + new ReflectionMethod($controller, 'show'), $reader ); - - return $route; } } From 320bd5ca38acc72c43e2cfd47ad809443743e3e0 Mon Sep 17 00:00:00 2001 From: Vinayak Iyer Date: Fri, 23 Oct 2026 13:21:05 +0530 Subject: [PATCH 12/16] Fail on a bundle attribute written without its use statement PHP resolves an attribute written without its use statement in the namespace of the file it is written in, so #[RequiredPermissions] in App\Controller names App\Controller\RequiredPermissions, which getAttributes() skips, and the route loaded without its permission check. A route now fails to load when an attribute's class cannot be loaded, its name without its namespace is one of the bundle's seven attributes, and the name lies in the namespace of the class it is written in (for an action, of its declaring class or a trait in its file) or below it; in the global namespace, only a name without a namespace part. An attribute imported from a package that is not installed, such as swagger-php's #[OA\Query], is left alone. Co-Authored-By: Claude Opus 5.5 (1M context) --- CHANGELOG.md | 21 ++- README.md | 10 +- .../DocblockAnnotationReader.php | 2 +- .../RoutingLoader/RestRouteConfigurator.php | 82 +++++++++- .../RoutingLoader/Fixtures/App/Api/Body.php | 12 ++ .../App/Api/OwnAttributeController.php | 13 ++ .../Controller/MissingUseClassController.php | 13 ++ .../App/Controller/MissingUseController.php | 65 ++++++++ .../Controller/MissingUseTraitController.php | 12 ++ .../App/GlobalMissingUseController.php | 18 +++ .../Fixtures/App/Shared/MissingUseActions.php | 13 ++ .../OtherLibraryAttributeController.php | 15 ++ .../OtherLibraryClassAttributeController.php | 15 ++ .../RestRouteConfiguratorTest.php | 147 +++++++++++++++++- 14 files changed, 425 insertions(+), 13 deletions(-) create mode 100644 tests/Unit/Service/RoutingLoader/Fixtures/App/Api/Body.php create mode 100644 tests/Unit/Service/RoutingLoader/Fixtures/App/Api/OwnAttributeController.php create mode 100644 tests/Unit/Service/RoutingLoader/Fixtures/App/Controller/MissingUseClassController.php create mode 100644 tests/Unit/Service/RoutingLoader/Fixtures/App/Controller/MissingUseController.php create mode 100644 tests/Unit/Service/RoutingLoader/Fixtures/App/Controller/MissingUseTraitController.php create mode 100644 tests/Unit/Service/RoutingLoader/Fixtures/App/GlobalMissingUseController.php create mode 100644 tests/Unit/Service/RoutingLoader/Fixtures/App/Shared/MissingUseActions.php create mode 100644 tests/Unit/Service/RoutingLoader/Fixtures/OtherLibraryAttributeController.php create mode 100644 tests/Unit/Service/RoutingLoader/Fixtures/OtherLibraryClassAttributeController.php diff --git a/CHANGELOG.md b/CHANGELOG.md index 3ead160..b1a6688 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -23,7 +23,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 annotation or attribute constructor too, is wrapped in a `ConfigurationException` naming the action. Where Symfony passes a reader, its own loader reads most docblocks with it first, so their errors come from Symfony unwrapped, and a class with a missing parent (before PHP 7.3) or trait (any version) still stops PHP. An attribute named under - `Paysera\Bundle\ApiBundle\` that is none of the bundle's attributes (a misspelt name or namespace) fails too + `Paysera\Bundle\ApiBundle\` that is none of the bundle's attributes (a misspelt name or namespace) fails too, and so + does one of the bundle's attributes written without its `use` statement, except where Security says it is not + detected - Optional parameters are declared nullable explicitly (`?Type $parameter = null`), as PHP 8.4 expects - `doctrine/annotations` is required at 1.14.4 or 2.0.2 and later, whose reader refuses to run with `opcache.save_comments=0` instead of reading the stripped docblocks as empty @@ -54,12 +56,25 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 comments, any annotation - On PHP 8 before Symfony 6.4, 1.x ignored the bundle's attributes, `#[RequiredPermissions]` among them, without an error. They are now applied on every supported Symfony version +- On PHP 8, a bundle attribute written without its `use` statement, `#[RequiredPermissions]` among them, names a class + in the namespace it is written in that cannot be loaded, which PHP and 1.x skip without an error. Loading the routes + now fails with a `ConfigurationException` naming the action and the bundle's attribute, for an attribute whose class + cannot be loaded, whose name without its namespace is one of the bundle's attributes, and whose name lies in the + namespace of the class it is written in (for an action, of its declaring class or a trait in its file) or below it; in + the global namespace, it must have no namespace part. An attribute imported from a package that is not installed, such + as swagger-php's `#[OA\Query]`, and a class of that name that can be loaded, such as the application's own `Body` + attribute, are left alone. Not detected: any other attribute whose class cannot be loaded, outside + `Paysera\Bundle\ApiBundle\`, such as a misspelt `#[RequiredPermission]`, the application's own attribute of another + name without its `use` statement, one imported from a misspelt namespace such as `Paysera\ApiBundle\Attribute`, and, + in a class of the global namespace, `#[Rest\RequiredPermissions]` without its alias import, which PHP reads as a + complete name - On PHP 8, a route configured with both docblock annotations and attributes of the bundle, on the action or on its class, fails to load with a `ConfigurationException`. Before, the attributes silently replaced the docblock options from Symfony 6.4, so `#[Body]` on an action dropped the `@RequiredPermissions` of its class, and were ignored before - These fixes are not backported to 1.x. 1.x still drops the bundle's permission checks without an error on Symfony 6.4 - with `framework.annotations` disabled, on PHP 8 before Symfony 6.4 for the bundle's attributes, and on routes - configured with both. Upgrade to 2.0.0, which applies the checks there or fails to load the routes where it cannot + with `framework.annotations` disabled, on PHP 8 before Symfony 6.4 for the bundle's attributes, on routes configured + with both, and for an attribute written without its `use` statement. Upgrade to 2.0.0, which applies the checks there + or fails to load the routes where it cannot, except in the cases listed above as not detected ## [1.8.2] ### Changed diff --git a/README.md b/README.md index eab0a05..a318238 100644 --- a/README.md +++ b/README.md @@ -672,7 +672,15 @@ The attributes need PHP 8 and are applied on every supported Symfony version sin Symfony 6.4); the `#[Route]` class to import depends on the Symfony version, see the attribute controller example. A route configured with both docblock annotations and attributes of the bundle fails to load: configure each route with one of them. So does a route with an attribute named under `Paysera\Bundle\ApiBundle\` that is none of the bundle's -attributes, such as a misspelt one. +attributes, such as a misspelt one, and a route with one of the bundle's attributes written without its `use` statement: +an attribute whose class cannot be loaded, whose name without its namespace is one of the bundle's attributes, and whose +name lies in the namespace of the class it is written in (for an action, of its declaring class or a trait in its file) +or below it; in the global namespace, it must have no namespace part. An attribute imported from a package that is not +installed, such as swagger-php's `#[OA\Query]`, and a class of that name that can be loaded, such as your own `Body` +attribute, are left alone. Not detected: any other attribute whose class cannot be loaded, outside +`Paysera\Bundle\ApiBundle\`, such as a misspelt `#[RequiredPermission]`, your own attribute of another name without its +`use` statement, one imported from a misspelt namespace such as `Paysera\ApiBundle\Attribute`, and, in a class of the +global namespace, `#[Rest\RequiredPermissions]` without its alias import, which PHP reads as a complete name. Where Symfony passes no annotation reader to the route loader (Symfony 7, and Symfony 5.4 on PHP 8 or Symfony 6 with `framework.annotations` disabled), the bundle reads the docblocks with Doctrine's `AnnotationReader` where Doctrine diff --git a/src/Service/RoutingLoader/DocblockAnnotationReader.php b/src/Service/RoutingLoader/DocblockAnnotationReader.php index 496a5db..628602e 100644 --- a/src/Service/RoutingLoader/DocblockAnnotationReader.php +++ b/src/Service/RoutingLoader/DocblockAnnotationReader.php @@ -126,7 +126,7 @@ private function methodUsesAnnotations(ReflectionMethod $method): bool /** * @return ReflectionClass[] */ - private function getActionScopes(ReflectionMethod $method): array + public function getActionScopes(ReflectionMethod $method): array { $declaringClass = $method->getDeclaringClass(); $scopes = [$declaringClass]; diff --git a/src/Service/RoutingLoader/RestRouteConfigurator.php b/src/Service/RoutingLoader/RestRouteConfigurator.php index 851bc7f..f4bc28e 100644 --- a/src/Service/RoutingLoader/RestRouteConfigurator.php +++ b/src/Service/RoutingLoader/RestRouteConfigurator.php @@ -6,7 +6,14 @@ use Doctrine\Common\Annotations\Reader; use Paysera\Bundle\ApiBundle\Annotation\RestAnnotationInterface; +use Paysera\Bundle\ApiBundle\Attribute\Body; +use Paysera\Bundle\ApiBundle\Attribute\BodyContentType; +use Paysera\Bundle\ApiBundle\Attribute\PathAttribute; +use Paysera\Bundle\ApiBundle\Attribute\Query; +use Paysera\Bundle\ApiBundle\Attribute\RequiredPermissions; +use Paysera\Bundle\ApiBundle\Attribute\ResponseNormalization; use Paysera\Bundle\ApiBundle\Attribute\RestAttributeInterface; +use Paysera\Bundle\ApiBundle\Attribute\Validation; use Paysera\Bundle\ApiBundle\Exception\ConfigurationException; use Paysera\Bundle\ApiBundle\Service\RestRequestHelper; use ReflectionAttribute; @@ -21,6 +28,15 @@ class RestRouteConfigurator { private const BUNDLE_NAMESPACE = 'Paysera\\Bundle\\ApiBundle\\'; + private const BUNDLE_ATTRIBUTES = [ + Body::class, + BodyContentType::class, + PathAttribute::class, + Query::class, + RequiredPermissions::class, + ResponseNormalization::class, + Validation::class, + ]; /** * @var RestRequestHelper @@ -133,7 +149,26 @@ private function getRestAttributes(ReflectionClass $class, ReflectionMethod $met private function failOnUnknownBundleAttributes(ReflectionClass $class, ReflectionMethod $method): void { - foreach (array_merge($class->getAttributes(), $method->getAttributes()) as $attribute) { + $this->failOnUnknownAttributes($class->getAttributes(), [$class], $class, $method); + $this->failOnUnknownAttributes( + $method->getAttributes(), + $this->docblockAnnotationReader->getActionScopes($method), + $class, + $method + ); + } + + /** + * @param ReflectionAttribute[] $attributes + * @param ReflectionClass[] $scopes + */ + private function failOnUnknownAttributes( + array $attributes, + array $scopes, + ReflectionClass $class, + ReflectionMethod $method + ): void { + foreach ($attributes as $attribute) { $name = $attribute->getName(); if (stripos($name, self::BUNDLE_NAMESPACE) === 0 && !is_a($name, RestAttributeInterface::class, true)) { throw new ConfigurationException(sprintf( @@ -143,9 +178,54 @@ private function failOnUnknownBundleAttributes(ReflectionClass $class, Reflectio $name )); } + + $bundleAttribute = $this->findBundleAttributeNamed($this->getShortName($name)); + if ($bundleAttribute !== null && $this->isInNamespaceOf($name, $scopes) && !class_exists($name)) { + throw new ConfigurationException(sprintf( + '%s::%s() uses %s, which is not a class that can be loaded;' + . ' if the bundle\'s attribute is meant, the name must resolve to %s', + $class->getName(), + $method->getName(), + $name, + $bundleAttribute + )); + } } } + /** + * @param ReflectionClass[] $scopes + */ + private function isInNamespaceOf(string $name, array $scopes): bool + { + foreach ($scopes as $scope) { + $namespace = $scope->getNamespaceName(); + if ($namespace === '' ? strpos($name, '\\') === false : stripos($name, $namespace . '\\') === 0) { + return true; + } + } + + return false; + } + + private function findBundleAttributeNamed(string $shortName): ?string + { + foreach (self::BUNDLE_ATTRIBUTES as $bundleAttribute) { + if (strcasecmp($this->getShortName($bundleAttribute), $shortName) === 0) { + return $bundleAttribute; + } + } + + return null; + } + + private function getShortName(string $className): string + { + $position = strrpos($className, '\\'); + + return $position === false ? $className : substr($className, $position + 1); + } + private function createReadException( string $source, ReflectionClass $class, diff --git a/tests/Unit/Service/RoutingLoader/Fixtures/App/Api/Body.php b/tests/Unit/Service/RoutingLoader/Fixtures/App/Api/Body.php new file mode 100644 index 0000000..236f18c --- /dev/null +++ b/tests/Unit/Service/RoutingLoader/Fixtures/App/Api/Body.php @@ -0,0 +1,12 @@ +markTestSkipped('PHP 7 has no attributes'); } + $this->requireApplicationFixtures(); $this->expectException(ConfigurationException::class); $this->expectExceptionMessage(sprintf($message, $controller)); - $this->configureRoute($controller, null); + $this->configureRoute($controller, null, $method); } public static function invalidAttributeProvider(): array { $unknown = '%s::show() uses Paysera\Bundle\ApiBundle\%s, which is no attribute of the bundle'; + $missing = '%s::%s() uses %s, which is not a class that can be loaded;' + . ' if the bundle\'s attribute is meant, the name must resolve to Paysera\Bundle\ApiBundle\Attribute\%s'; - return [ + $dataSets = [ 'constructor error' => [InvalidAttributeController::class, 'Cannot read the attributes of %s::show(): '], 'misspelt name' => [ MisspeltAttributeController::class, @@ -105,10 +129,116 @@ public static function invalidAttributeProvider(): array AnnotationClassAsAttributeController::class, 'Cannot read the attributes of %s::show(): Attempting to use non-attribute class', ], + 'name of an attribute without its use statement, in lowercase' => [ + MissingUseController::class, + sprintf( + $missing, + '%s', + 'showLowercaseName', + 'App\Controller\requiredpermissions', + 'RequiredPermissions' + ), + 'showLowercaseName', + ], + 'qualified name of an attribute without the import of its alias' => [ + MissingUseController::class, + sprintf( + $missing, + '%s', + 'showQualifiedName', + 'App\Controller\Rest\RequiredPermissions', + 'RequiredPermissions' + ), + 'showQualifiedName', + ], + 'name of an attribute without its use statement, on the class' => [ + MissingUseClassController::class, + sprintf($missing, '%s', 'show', 'App\Controller\RequiredPermissions', 'RequiredPermissions'), + ], + 'name of an attribute without its use statement, in a trait of another namespace' => [ + MissingUseTraitController::class, + sprintf($missing, '%s', 'showFromTrait', 'App\Shared\RequiredPermissions', 'RequiredPermissions'), + 'showFromTrait', + ], + 'name of an attribute without its use statement, in the global namespace' => [ + GlobalMissingUseController::class, + sprintf($missing, '%s', 'show', 'RequiredPermissions', 'RequiredPermissions'), + ], ]; + + $attributePattern = __DIR__ . '/../../../../src/Attribute/*.php'; + $attributeFiles = glob($attributePattern); + if ($attributeFiles === false || $attributeFiles === []) { + throw new RuntimeException('No attribute class found by the pattern ' . $attributePattern); + } + + foreach ($attributeFiles as $file) { + $attribute = basename($file, '.php'); + if (interface_exists('Paysera\Bundle\ApiBundle\Attribute\\' . $attribute)) { + continue; + } + + $dataSets['name of an attribute without its use statement: ' . $attribute] = [ + MissingUseController::class, + sprintf($missing, '%s', 'show' . $attribute, 'App\Controller\\' . $attribute, $attribute), + 'show' . $attribute, + ]; + } + + return $dataSets; } - private function configureRoute(string $controller, ?Reader $reader): void + /** + * @dataProvider loadingAttributeProvider + */ + public function testConfigureRouteLeavesOtherAttributesAlone(string $controller, string $method): void + { + if (PHP_VERSION_ID < 80000) { + $this->markTestSkipped('PHP 7 has no attributes'); + } + $this->requireApplicationFixtures(); + + $this->assertEquals(new Route('/'), $this->configureRoute($controller, null, $method)); + } + + public static function loadingAttributeProvider(): array + { + return [ + 'class of another library named like an attribute' => [ + OtherLibraryAttributeController::class, + 'showClassNamedLikeAttribute', + ], + 'class of another library named like an attribute, on the class' => [ + OtherLibraryClassAttributeController::class, + 'show', + ], + 'class of the application named like an attribute, in the namespace of the controller' => [ + OwnAttributeController::class, + 'show', + ], + 'missing class in the namespace of the controller with a name that is none of the attributes' => [ + MissingUseController::class, + 'showMissingClassWithOtherName', + ], + 'attribute of a package that is not installed, imported' => [ + MissingUseController::class, + 'showUninstalledPackageAttribute', + ], + 'attribute of a package that is not installed, imported in the global namespace' => [ + GlobalMissingUseController::class, + 'showUninstalledPackageAttribute', + ], + ]; + } + + private function requireApplicationFixtures(): void + { + foreach (self::APPLICATION_FIXTURES as $fixture) { + require_once __DIR__ . '/Fixtures/App/' . $fixture; + } + } + + private function configureRoute(string $controller, ?Reader $reader, string $method = 'show'): Route { $configurator = new RestRouteConfigurator( new RestRequestHelper(new RestRequestOptionsRegistry()), @@ -116,11 +246,14 @@ private function configureRoute(string $controller, ?Reader $reader): void new RestRequestAttributeOptionsBuilder(Mockery::spy(RestRequestOptionsValidator::class)), new DocblockAnnotationReader() ); + $route = new Route('/'); $configurator->configureRoute( - new Route('/'), + $route, new ReflectionClass($controller), - new ReflectionMethod($controller, 'show'), + new ReflectionMethod($controller, $method), $reader ); + + return $route; } } From 0639f493e31ac2b1c55c25b709ac73e509004de3 Mon Sep 17 00:00:00 2001 From: Vinayak Iyer Date: Fri, 23 Oct 2026 22:53:20 +0530 Subject: [PATCH 13/16] Refuse docblock annotations where Symfony passes no annotation reader Where Symfony injects no annotation reader into the route loader (Symfony 7, and Symfony 5.4 on PHP 8 or Symfony 6 with framework.annotations disabled), the bundle no longer reads the docblock annotations itself: DocblockAnnotationReader, its lenient DocParser pre-scan and its own AnnotationReader go. Instead, DocblockAnnotationGuard reads the source of the routed class, of the class declaring the action, of the action and of every trait they use, in one token_get_all() pass, and stops route loading with a ConfigurationException naming the action, the file and the finding when a file imports from Paysera\Bundle\ApiBundle\Annotation or has a docblock tag named like one of the bundle's annotations, in any letter case, split across lines too. Reading the files means opcache.save_comments=0 cannot hide an annotation; a file that cannot be read or matched fails the load as well. Where Symfony injects a reader, Doctrine reads the annotations as before. Not detected: an application's own annotation class implementing RestAnnotationInterface. Co-Authored-By: Claude Fable 5.1 --- CHANGELOG.md | 42 +- README.md | 22 +- .../PayseraApiExtension.php | 6 +- .../RoutingLoader/DocblockAnnotationGuard.php | 249 ++++++++++++ .../DocblockAnnotationReader.php | 217 ----------- .../RoutingLoader/RestRouteConfigurator.php | 49 ++- .../AttributedDocblockOptionsController.php | 2 +- .../Resources/config/attributed_routing.xml | 1 + .../Fixtures/config/sf7_docblock_common.yml | 19 + .../Fixtures/config/sf7_docblock_routing.yml | 13 + .../Functional/FunctionalAnnotationsTest.php | 38 ++ .../Fixtures/services.json | 6 +- .../DocblockAnnotationGuardTest.php | 358 +++++++++++++++++ .../DocblockAnnotationReaderTest.php | 26 -- .../AliasedNamespaceAttributeController.php | 17 + .../AttributeDocblockReferencesController.php | 37 ++ .../LowercaseAnnotationImportController.php | 16 + ...ercaseFullyQualifiedDocblockController.php | 16 + .../OwnActionNextToNestedTraitController.php | 19 + .../UnusedAnnotationImportController.php | 18 + .../RoutingLoader/Fixtures/load-routes.php | 4 +- .../RestRouteConfiguratorTest.php | 27 +- .../RoutingAnnotationLoaderTest.php | 76 ++-- .../RoutingAttributeLoaderTest.php | 362 +++++++++++------- 24 files changed, 1160 insertions(+), 480 deletions(-) create mode 100644 src/Service/RoutingLoader/DocblockAnnotationGuard.php delete mode 100644 src/Service/RoutingLoader/DocblockAnnotationReader.php rename tests/Functional/Fixtures/FixtureTestBundle/Controller/{Attribute => DocblockOptions}/AttributedDocblockOptionsController.php (93%) create mode 100644 tests/Functional/Fixtures/config/sf7_docblock_common.yml create mode 100644 tests/Functional/Fixtures/config/sf7_docblock_routing.yml create mode 100644 tests/Unit/Service/RoutingLoader/DocblockAnnotationGuardTest.php delete mode 100644 tests/Unit/Service/RoutingLoader/DocblockAnnotationReaderTest.php create mode 100644 tests/Unit/Service/RoutingLoader/Fixtures/AliasedNamespaceAttributeController.php create mode 100644 tests/Unit/Service/RoutingLoader/Fixtures/AttributeDocblockReferencesController.php create mode 100644 tests/Unit/Service/RoutingLoader/Fixtures/LowercaseAnnotationImportController.php create mode 100644 tests/Unit/Service/RoutingLoader/Fixtures/LowercaseFullyQualifiedDocblockController.php create mode 100644 tests/Unit/Service/RoutingLoader/Fixtures/OwnActionNextToNestedTraitController.php create mode 100644 tests/Unit/Service/RoutingLoader/Fixtures/UnusedAnnotationImportController.php diff --git a/CHANGELOG.md b/CHANGELOG.md index b1a6688..235cebf 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -18,14 +18,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Services are registered by `PayseraApiExtension` instead of the XML files in `Resources/config`, which are removed, because Symfony 7.4 deprecates XML configuration. Service ids, classes, arguments, calls and tags are unchanged, except that both internal route loaders take the new `paysera_api.rest_route_configurator` service (with the new - `paysera_api.docblock_annotation_reader`) instead of the request helper and the options builders -- An error while the bundle reads a route's docblock annotations itself or builds the bundle's attributes, from an - annotation or attribute constructor too, is wrapped in a `ConfigurationException` naming the action. Where Symfony - passes a reader, its own loader reads most docblocks with it first, so their errors come from Symfony unwrapped, and a - class with a missing parent (before PHP 7.3) or trait (any version) still stops PHP. An attribute named under - `Paysera\Bundle\ApiBundle\` that is none of the bundle's attributes (a misspelt name or namespace) fails too, and so - does one of the bundle's attributes written without its `use` statement, except where Security says it is not - detected + `paysera_api.docblock_annotation_guard`) instead of the request helper and the options builders +- An error while Symfony's annotation reader reads a route's docblock annotations for the bundle, or while the bundle + builds its attributes, from an annotation or attribute constructor too, is wrapped in a `ConfigurationException` + naming the action. Symfony's own loader reads most docblocks with that reader first, so their errors come from Symfony + unwrapped, and a class with a missing parent (before PHP 7.3) or trait (any version) still stops PHP. An attribute + named under `Paysera\Bundle\ApiBundle\` that is none of the bundle's attributes (a misspelt name or namespace) fails + too, and so does one of the bundle's attributes written without its `use` statement, except where Security says it is + not detected - Optional parameters are declared nullable explicitly (`?Type $parameter = null`), as PHP 8.4 expects - `doctrine/annotations` is required at 1.14.4 or 2.0.2 and later, whose reader refuses to run with `opcache.save_comments=0` instead of reading the stripped docblocks as empty @@ -40,20 +40,18 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Security - Where Symfony passes no annotation reader to the route loader (Symfony 7, and Symfony 5.4 on PHP 8 or Symfony 6 with `framework.annotations` disabled), 1.x ignored the bundle's docblock annotations, `@RequiredPermissions` among them, - without an error on Symfony 6.4, and failed to load any route before it. The bundle now reads them with Doctrine's - `AnnotationReader` where Doctrine would find them: where a docblock uses an annotation implementing - `RestAnnotationInterface` (the bundle's or the application's), as Doctrine's `DocParser` resolves it, from the imports - and namespace of the class for its docblock, and of the declaring class and the traits in the action's file for an - action's docblock; and where the routed class, the declaring class or a trait in the action's file imports - `Paysera\Bundle\ApiBundle\Annotation`. Code that uses only attributes is not read with that reader, unless Doctrine's - parser fails on one of its docblocks (a tag naming a class that cannot be loaded), and an unused import of the - namespace makes the file read too, so remove it. Loading the routes fails with a `ConfigurationException` naming the - action where Doctrine's reader fails, on a docblock tag it cannot resolve (import it, or list it with - `@IgnoreAnnotation` in the class docblock). Where OPcache is set to strip doc comments (`save_comments=0`), Doctrine's - reader refuses to run, so loading fails where those classes import the annotation namespace. Not detected in a file - that does not import `Paysera\Bundle\ApiBundle\Annotation`: an annotation that Doctrine cannot resolve (written with - no import, with a misspelt import such as `Annotations`, or misspelt) and, where OPcache has stripped the doc - comments, any annotation + without an error on Symfony 6.4, and failed to load any route before it. Loading the routes now fails there with a + `ConfigurationException` naming the action, the file and what was found, when the file of the routed class, of the + class declaring the action, of the action itself or of any trait these classes use imports anything from + `Paysera\Bundle\ApiBundle\Annotation` (plain, aliased, grouped or as a namespace alias), or has a docblock tag named + like one of the bundle's annotations (`@RequiredPermissions`, `@Rest\RequiredPermissions`, + `@\Paysera\Bundle\ApiBundle\Annotation\RequiredPermissions`, split across lines too, in any letter case, with or + without arguments). The check reads the source files, so OPcache stripping the doc comments + (`opcache.save_comments=0`) does not hide them, and a file that cannot be read fails the load too. It refuses more + than Doctrine would read: an unused import of the namespace, a tag of another library named like one of the bundle's + annotations, and a trait with annotations next to the action; the message names what to remove. Write the options as + attributes, or on Symfony 6.4 keep `framework.annotations` enabled. Not detected: an annotation class of your own + implementing `RestAnnotationInterface`, which nothing in the controller's file reveals - On PHP 8 before Symfony 6.4, 1.x ignored the bundle's attributes, `#[RequiredPermissions]` among them, without an error. They are now applied on every supported Symfony version - On PHP 8, a bundle attribute written without its `use` statement, `#[RequiredPermissions]` among them, names a class diff --git a/README.md b/README.md index a318238..16eb6aa 100644 --- a/README.md +++ b/README.md @@ -683,18 +683,16 @@ attribute, are left alone. Not detected: any other attribute whose class cannot global namespace, `#[Rest\RequiredPermissions]` without its alias import, which PHP reads as a complete name. Where Symfony passes no annotation reader to the route loader (Symfony 7, and Symfony 5.4 on PHP 8 or Symfony 6 with -`framework.annotations` disabled), the bundle reads the docblocks with Doctrine's `AnnotationReader` where Doctrine -would find its annotations: where a docblock uses an annotation implementing `RestAnnotationInterface` (the bundle's or -your own) as Doctrine's `DocParser` resolves it, from the imports and namespace of the class for its docblock, and of -the declaring class and the traits in the action's file for an action's docblock; and where the routed class, the -declaring class or a trait in the action's file imports `Paysera\Bundle\ApiBundle\Annotation`. Code that uses only -attributes is not read with that reader, unless Doctrine's parser fails on one of its docblocks (a tag naming a class -that cannot be loaded). There, loading the routes fails with a `ConfigurationException` naming the action on a docblock -tag that Doctrine cannot resolve (import it, or list it with `@IgnoreAnnotation` in the class docblock). Where OPcache -is set to strip doc comments (`opcache.save_comments=0`), Doctrine's reader refuses to run, so loading fails where those -classes import the annotation namespace; remove an unused `use` of it. Not detected in a file that does not import -`Paysera\Bundle\ApiBundle\Annotation`: an annotation that Doctrine cannot resolve (written with no import, with a -misspelt import such as `Annotations`, or misspelt) and, where OPcache has stripped the doc comments, any annotation. +`framework.annotations` disabled), the bundle does not read docblock annotations and refuses them instead: loading the +routes fails with a `ConfigurationException` naming the action, the file and what was found, when the file of the routed +class, of the class declaring the action, of the action itself or of any trait these classes use imports anything from +`Paysera\Bundle\ApiBundle\Annotation`, or has a docblock tag named like one of the bundle's annotations +(`@RequiredPermissions`, `@Rest\RequiredPermissions`, `@\Paysera\Bundle\ApiBundle\Annotation\RequiredPermissions`, split +across lines too, in any letter case). The check reads the source files, so `opcache.save_comments=0` does not hide +them. It refuses more than Doctrine would read: an unused import of the namespace, a tag of another library named like +one of the bundle's annotations, and a trait with annotations next to the action; remove what the message names. Write +the options as attributes, or on Symfony 6.4 keep `framework.annotations` enabled. Not detected: an annotation class of +your own implementing `RestAnnotationInterface`, which nothing in the controller's file reveals. ### `Body` diff --git a/src/DependencyInjection/PayseraApiExtension.php b/src/DependencyInjection/PayseraApiExtension.php index 7cf215b..66434e7 100644 --- a/src/DependencyInjection/PayseraApiExtension.php +++ b/src/DependencyInjection/PayseraApiExtension.php @@ -23,7 +23,7 @@ use Paysera\Bundle\ApiBundle\Service\RestRequestHelper; use Paysera\Bundle\ApiBundle\Service\RestRequestOptionsRegistry; use Paysera\Bundle\ApiBundle\Service\RestRequestOptionsValidator; -use Paysera\Bundle\ApiBundle\Service\RoutingLoader\DocblockAnnotationReader; +use Paysera\Bundle\ApiBundle\Service\RoutingLoader\DocblockAnnotationGuard; use Paysera\Bundle\ApiBundle\Service\RoutingLoader\RestRequestAnnotationOptionsBuilder; use Paysera\Bundle\ApiBundle\Service\RoutingLoader\RestRequestAttributeOptionsBuilder; use Paysera\Bundle\ApiBundle\Service\RoutingLoader\RestRouteConfigurator; @@ -193,12 +193,12 @@ private function createServiceDefinitions(): array RestRequestAttributeOptionsBuilder::class, [$optionsValidator] ), - 'paysera_api.docblock_annotation_reader' => new Definition(DocblockAnnotationReader::class), + 'paysera_api.docblock_annotation_guard' => new Definition(DocblockAnnotationGuard::class), 'paysera_api.rest_route_configurator' => new Definition(RestRouteConfigurator::class, [ new Reference('paysera_api.rest_request_helper'), new Reference('paysera_api.annotations.options_builder'), new Reference('paysera_api.attributes.options_builder'), - new Reference('paysera_api.docblock_annotation_reader'), + new Reference('paysera_api.docblock_annotation_guard'), ]), 'paysera_api.response_builder' => new Definition(ResponseBuilder::class), 'paysera_api.error_builder' => $this->createErrorBuilderDefinition(), diff --git a/src/Service/RoutingLoader/DocblockAnnotationGuard.php b/src/Service/RoutingLoader/DocblockAnnotationGuard.php new file mode 100644 index 0000000..a96a760 --- /dev/null +++ b/src/Service/RoutingLoader/DocblockAnnotationGuard.php @@ -0,0 +1,249 @@ + + */ + private $findings; + + /** + * @var string + */ + private $tagPattern; + + public function __construct() + { + $this->findings = []; + $names = array_map(function (string $annotation): string { + return substr($annotation, strrpos($annotation, '\\') + 1); + }, self::ANNOTATIONS); + $this->tagPattern = sprintf( + '/(?getFiles($class, $method) as $file) { + $finding = $this->findAnnotationUse($class, $method, $file); + if ($finding !== null) { + throw new ConfigurationException(sprintf( + '%s::%s(): %s %s, %s', + $class->getName(), + $method->getName(), + $file, + $finding, + self::NOT_READ + )); + } + } + } + + /** + * @return ReflectionClass[] + */ + public function getActionScopes(ReflectionMethod $method): array + { + $declaringClass = $method->getDeclaringClass(); + $scopes = [$declaringClass]; + foreach ($this->getTraits($declaringClass) as $trait) { + if ($trait->getFileName() === $method->getFileName()) { + $scopes[] = $trait; + } + } + + return $scopes; + } + + /** + * @return array + */ + private function getFiles(ReflectionClass $class, ReflectionMethod $method): array + { + $declaringClass = $method->getDeclaringClass(); + $files = [$method->getFileName()]; + $scopes = array_merge( + [$class, $declaringClass], + $this->getTraits($class), + $this->getTraits($declaringClass) + ); + foreach ($scopes as $scope) { + $files[] = $scope->getFileName(); + } + + return array_unique($files); + } + + /** + * @return ReflectionClass[] + */ + private function getTraits(ReflectionClass $class): array + { + $traits = []; + foreach ($class->getTraits() as $trait) { + $traits[] = $trait; + $traits = array_merge($traits, $this->getTraits($trait)); + } + + return $traits; + } + + /** + * @param string|false $file + */ + private function findAnnotationUse(ReflectionClass $class, ReflectionMethod $method, $file): ?string + { + if ($file !== false && array_key_exists($file, $this->findings)) { + return $this->findings[$file]; + } + + $source = $file === false ? false : @file_get_contents($file); + if ($source === false) { + throw new ConfigurationException(sprintf( + '%s::%s(): %s cannot be read to check its docblocks for annotations of the bundle, %s', + $class->getName(), + $method->getName(), + $file === false ? 'the source of ' . $class->getName() : $file, + self::NOT_READ + )); + } + + $this->findings[$file] = $this->findAnnotationUseInSource($source); + + return $this->findings[$file]; + } + + private function findAnnotationUseInSource(string $source): ?string + { + $tokens = token_get_all($source); + foreach ($this->parseImports($tokens) as $import) { + $normalizedImport = strtolower(ltrim($import, '\\')); + if ( + $normalizedImport === self::ANNOTATION_NAMESPACE + || strpos($normalizedImport, self::ANNOTATION_NAMESPACE . '\\') === 0 + ) { + return 'imports ' . $import; + } + } + + foreach ($tokens as $token) { + if (is_array($token) && $token[0] === T_DOC_COMMENT) { + $tag = $this->findTag($token[1]); + if ($tag !== null) { + return $tag; + } + } + } + + return null; + } + + private function findTag(string $docComment): ?string + { + // Doctrine's lexer joins a name that continues on the next docblock line after a backslash + $joined = preg_replace('/\\\\[\s*]+/', '\\\\', $docComment); + $matched = $joined === null ? false : preg_match($this->tagPattern, $joined, $match); + if ($matched === false) { + return 'has a docblock that cannot be checked (preg error ' . preg_last_error() . ')'; + } + + return $matched === 1 ? 'has the docblock tag @' . $match[1] : null; + } + + /** + * @param array $tokens + * @return string[] + */ + private function parseImports(array $tokens): array + { + $imports = []; + $count = count($tokens); + for ($index = 0; $index < $count; $index++) { + if (!is_array($tokens[$index]) || $tokens[$index][0] !== T_USE) { + continue; + } + + $statement = ''; + while (++$index < $count) { + $token = $tokens[$index]; + if (is_array($token)) { + $isSpace = in_array($token[0], [T_WHITESPACE, T_COMMENT, T_DOC_COMMENT], true); + $statement .= $isSpace ? ' ' : $token[1]; + continue; + } + if ($token === ';') { + break; + } + if ($token === '(' || ($token === '{' && substr(rtrim($statement), -1) !== '\\')) { + // the use clause of a closure, or a trait use with a conflict resolution block + $statement = ''; + break; + } + $statement .= $token; + } + + $imports = array_merge($imports, $this->expandImports($statement)); + } + + return $imports; + } + + /** + * @return string[] + */ + private function expandImports(string $statement): array + { + $statement = trim((string)preg_replace('/\s+/', ' ', $statement)); + if ($statement === '') { + return []; + } + + $prefix = ''; + $openingBrace = strpos($statement, '{'); + if ($openingBrace !== false) { + $prefix = trim(substr($statement, 0, $openingBrace)); + $statement = substr($statement, $openingBrace + 1, (int)strrpos($statement, '}') - $openingBrace - 1); + } + + $imports = []; + foreach (explode(',', $statement) as $import) { + $import = (string)preg_replace('/\s+as\s+\S+$/i', '', trim($import)); + $imports[] = str_replace(' ', '', $prefix . $import); + } + + return $imports; + } +} diff --git a/src/Service/RoutingLoader/DocblockAnnotationReader.php b/src/Service/RoutingLoader/DocblockAnnotationReader.php deleted file mode 100644 index 628602e..0000000 --- a/src/Service/RoutingLoader/DocblockAnnotationReader.php +++ /dev/null @@ -1,217 +0,0 @@ -> - */ - private $imports; - - /** - * @var array - */ - private $classesUsingAnnotations; - - /** - * @var array - */ - private $classAnnotations; - - public function __construct() - { - $this->imports = []; - $this->classesUsingAnnotations = []; - $this->classAnnotations = []; - } - - /** - * @return object[] - */ - public function readAnnotations(ReflectionClass $class, ReflectionMethod $method, ?Reader $reader): array - { - if ($reader !== null) { - return array_merge($this->getClassAnnotations($reader, $class), $reader->getMethodAnnotations($method)); - } - - $readClass = $this->classUsesAnnotations($class); - if (!$readClass && !$this->methodUsesAnnotations($method)) { - return []; - } - - $this->annotationReader = $this->annotationReader ?? new AnnotationReader(); - - return array_merge( - $readClass ? $this->getClassAnnotations($this->annotationReader, $class) : [], - $this->annotationReader->getMethodAnnotations($method) - ); - } - - /** - * @return object[] - */ - private function getClassAnnotations(Reader $reader, ReflectionClass $class): array - { - $className = $class->getName(); - if (!isset($this->classAnnotations[$className])) { - $this->classAnnotations[$className] = $reader->getClassAnnotations($class); - } - - return $this->classAnnotations[$className]; - } - - private function classUsesAnnotations(ReflectionClass $class): bool - { - $className = $class->getName(); - if (!isset($this->classesUsingAnnotations[$className])) { - $imports = $this->getImports($class); - $this->classesUsingAnnotations[$className] = $this->importsAnnotationNamespace($imports) - || $this->mayHaveRestAnnotations($class->getDocComment(), $imports, $class->getNamespaceName()); - } - - return $this->classesUsingAnnotations[$className]; - } - - private function methodUsesAnnotations(ReflectionMethod $method): bool - { - $imports = []; - foreach ($this->getActionScopes($method) as $scope) { - $scopeImports = $this->getImports($scope); - if ($this->importsAnnotationNamespace($scopeImports)) { - return true; - } - - $imports = array_merge($imports, $scopeImports); - } - - return $this->mayHaveRestAnnotations( - $method->getDocComment(), - $imports, - $method->getDeclaringClass()->getNamespaceName() - ); - } - - /** - * @return ReflectionClass[] - */ - public function getActionScopes(ReflectionMethod $method): array - { - $declaringClass = $method->getDeclaringClass(); - $scopes = [$declaringClass]; - foreach ($this->getTraits($declaringClass) as $trait) { - if ($trait->getFileName() === $method->getFileName()) { - $scopes[] = $trait; - } - } - - return $scopes; - } - - /** - * @return ReflectionClass[] - */ - private function getTraits(ReflectionClass $class): array - { - $traits = []; - foreach ($class->getTraits() as $trait) { - $traits[] = $trait; - $traits = array_merge($traits, $this->getTraits($trait)); - } - - return $traits; - } - - /** - * @return array - */ - private function getImports(ReflectionClass $scope): array - { - $name = $scope->getName(); - if (!isset($this->imports[$name])) { - $this->phpParser = $this->phpParser ?? new PhpParser(); - $this->imports[$name] = $this->phpParser->parseUseStatements($scope); - } - - return $this->imports[$name]; - } - - /** - * @param array $imports - */ - private function importsAnnotationNamespace(array $imports): bool - { - foreach ($imports as $import) { - $normalizedImport = strtolower(ltrim($import, '\\')) . '\\'; - if ( - strpos($normalizedImport, self::ANNOTATION_NAMESPACE . '\\') === 0 - || strpos(self::ANNOTATION_NAMESPACE . '\\', $normalizedImport) === 0 - ) { - return true; - } - } - - return false; - } - - /** - * @param string|false $docComment - * @param array $imports - */ - private function mayHaveRestAnnotations($docComment, array $imports, string $namespace): bool - { - if ($docComment === false) { - return false; - } - - if ($this->docParser === null) { - $this->docParser = new DocParser(); - $this->docParser->setIgnoreNotImportedAnnotations(true); - } - $this->docParser->setImports($imports + ['__NAMESPACE__' => $namespace]); - try { - $annotations = $this->docParser->parse($docComment); - } catch (Throwable $exception) { - return true; - } - - foreach ($annotations as $annotation) { - if ($annotation instanceof RestAnnotationInterface) { - return true; - } - } - - return false; - } -} diff --git a/src/Service/RoutingLoader/RestRouteConfigurator.php b/src/Service/RoutingLoader/RestRouteConfigurator.php index f4bc28e..8680f7d 100644 --- a/src/Service/RoutingLoader/RestRouteConfigurator.php +++ b/src/Service/RoutingLoader/RestRouteConfigurator.php @@ -54,20 +54,26 @@ class RestRouteConfigurator private $attributeOptionsBuilder; /** - * @var DocblockAnnotationReader + * @var DocblockAnnotationGuard */ - private $docblockAnnotationReader; + private $docblockAnnotationGuard; + + /** + * @var array + */ + private $classAnnotations; public function __construct( RestRequestHelper $restRequestHelper, RestRequestAnnotationOptionsBuilder $annotationOptionsBuilder, RestRequestAttributeOptionsBuilder $attributeOptionsBuilder, - DocblockAnnotationReader $docblockAnnotationReader + DocblockAnnotationGuard $docblockAnnotationGuard ) { $this->restRequestHelper = $restRequestHelper; $this->annotationOptionsBuilder = $annotationOptionsBuilder; $this->attributeOptionsBuilder = $attributeOptionsBuilder; - $this->docblockAnnotationReader = $docblockAnnotationReader; + $this->docblockAnnotationGuard = $docblockAnnotationGuard; + $this->classAnnotations = []; } public function configureRoute( @@ -76,8 +82,14 @@ public function configureRoute( ReflectionMethod $method, ?Reader $reader ): void { - $annotations = $this->getRestAnnotations($class, $method, $reader); $attributes = $this->getRestAttributes($class, $method); + if ($reader === null) { + $this->docblockAnnotationGuard->failOnDocblockAnnotations($class, $method); + $annotations = []; + } else { + $annotations = $this->getRestAnnotations($class, $method, $reader); + } + if ($annotations !== [] && $attributes !== []) { throw new ConfigurationException(sprintf( '%s::%s() is configured with both docblock annotations and attributes of the bundle, use one of them', @@ -104,13 +116,13 @@ public function configureRoute( /** * @return RestAnnotationInterface[] */ - private function getRestAnnotations( - ReflectionClass $class, - ReflectionMethod $method, - ?Reader $reader - ): array { + private function getRestAnnotations(ReflectionClass $class, ReflectionMethod $method, Reader $reader): array + { try { - $annotations = $this->docblockAnnotationReader->readAnnotations($class, $method, $reader); + $annotations = array_merge( + $this->getClassAnnotations($reader, $class), + $reader->getMethodAnnotations($method) + ); } catch (Throwable $exception) { throw $this->createReadException('docblock annotations', $class, $method, $exception); } @@ -120,6 +132,19 @@ private function getRestAnnotations( })); } + /** + * @return object[] + */ + private function getClassAnnotations(Reader $reader, ReflectionClass $class): array + { + $className = $class->getName(); + if (!isset($this->classAnnotations[$className])) { + $this->classAnnotations[$className] = $reader->getClassAnnotations($class); + } + + return $this->classAnnotations[$className]; + } + /** * @return RestAttributeInterface[] */ @@ -152,7 +177,7 @@ private function failOnUnknownBundleAttributes(ReflectionClass $class, Reflectio $this->failOnUnknownAttributes($class->getAttributes(), [$class], $class, $method); $this->failOnUnknownAttributes( $method->getAttributes(), - $this->docblockAnnotationReader->getActionScopes($method), + $this->docblockAnnotationGuard->getActionScopes($method), $class, $method ); diff --git a/tests/Functional/Fixtures/FixtureTestBundle/Controller/Attribute/AttributedDocblockOptionsController.php b/tests/Functional/Fixtures/FixtureTestBundle/Controller/DocblockOptions/AttributedDocblockOptionsController.php similarity index 93% rename from tests/Functional/Fixtures/FixtureTestBundle/Controller/Attribute/AttributedDocblockOptionsController.php rename to tests/Functional/Fixtures/FixtureTestBundle/Controller/DocblockOptions/AttributedDocblockOptionsController.php index be417b7..5700514 100644 --- a/tests/Functional/Fixtures/FixtureTestBundle/Controller/Attribute/AttributedDocblockOptionsController.php +++ b/tests/Functional/Fixtures/FixtureTestBundle/Controller/DocblockOptions/AttributedDocblockOptionsController.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace Paysera\Bundle\ApiBundle\Tests\Functional\Fixtures\FixtureTestBundle\Controller\Attribute; +namespace Paysera\Bundle\ApiBundle\Tests\Functional\Fixtures\FixtureTestBundle\Controller\DocblockOptions; use Paysera\Bundle\ApiBundle\Annotation\RequiredPermissions; use Symfony\Component\HttpFoundation\Request; diff --git a/tests/Functional/Fixtures/FixtureTestBundle/Resources/config/attributed_routing.xml b/tests/Functional/Fixtures/FixtureTestBundle/Resources/config/attributed_routing.xml index 253529a..472e7bf 100644 --- a/tests/Functional/Fixtures/FixtureTestBundle/Resources/config/attributed_routing.xml +++ b/tests/Functional/Fixtures/FixtureTestBundle/Resources/config/attributed_routing.xml @@ -5,4 +5,5 @@ xsi:schemaLocation="http://symfony.com/schema/routing https://symfony.com/schema/routing/routing-1.0.xsd"> + diff --git a/tests/Functional/Fixtures/config/sf7_docblock_common.yml b/tests/Functional/Fixtures/config/sf7_docblock_common.yml new file mode 100644 index 0000000..1ac9d92 --- /dev/null +++ b/tests/Functional/Fixtures/config/sf7_docblock_common.yml @@ -0,0 +1,19 @@ +framework: + router: + resource: '%kernel.project_dir%/tests/Functional/Fixtures/config/sf7_docblock_routing.yml' + validation: + email_validation_mode: html5 + property_info: + with_constructor_extractor: false + +security: + firewalls: + config: + pattern: ^/(config)/ + security: false + main: + http_basic: ~ + stateless: true + password_hashers: + Symfony\Component\Security\Core\User\InMemoryUser: + algorithm: plaintext diff --git a/tests/Functional/Fixtures/config/sf7_docblock_routing.yml b/tests/Functional/Fixtures/config/sf7_docblock_routing.yml new file mode 100644 index 0000000..c1bd3d7 --- /dev/null +++ b/tests/Functional/Fixtures/config/sf7_docblock_routing.yml @@ -0,0 +1,13 @@ +paysera_fixture_test: + resource: "@PayseraFixtureTestBundle/Resources/config/explicit_routing.yml" + prefix: / + +paysera_fixture_attributed_test: + resource: '@PayseraFixtureTestBundle/Controller/Attribute/' + type: attribute + prefix: / + +paysera_fixture_docblock_options_test: + resource: '@PayseraFixtureTestBundle/Controller/DocblockOptions/' + type: attribute + prefix: / diff --git a/tests/Functional/FunctionalAnnotationsTest.php b/tests/Functional/FunctionalAnnotationsTest.php index 5ade01b..d405b55 100644 --- a/tests/Functional/FunctionalAnnotationsTest.php +++ b/tests/Functional/FunctionalAnnotationsTest.php @@ -3,8 +3,13 @@ namespace Paysera\Bundle\ApiBundle\Tests\Functional; +use Paysera\Bundle\ApiBundle\Exception\ConfigurationException; +use Paysera\Bundle\ApiBundle\Tests\Functional\Fixtures\FixtureTestBundle\Controller\DocblockOptions; +use Paysera\Bundle\ApiBundle\Tests\Functional\Fixtures\FixtureTestBundle\Service\TestHelper; +use ReflectionClass; use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Response; +use Throwable; class FunctionalAnnotationsTest extends FunctionalTestCase { @@ -50,6 +55,10 @@ public function testDocblockOptionsOnAttributeRoute( string $expectedContent, ?string $username ): void { + if (!TestHelper::docblockRoutingSupportExists()) { + $this->markTestSkipped('Symfony 7 passes no annotation reader: see the refusal test'); + } + $this->makeTest( 'attributed', new Response($expectedContent, $expectedStatusCode), @@ -75,6 +84,35 @@ public static function docblockOptionsOnAttributeRouteProvider(): array ]; } + public function testDocblockOptionsOnAttributeRouteAreRefusedWithoutReader(): void + { + if (!TestHelper::phpAttributeSupportExists() || TestHelper::docblockRoutingSupportExists()) { + $this->markTestSkipped('Symfony before 7 passes an annotation reader to the route loader'); + } + $this->tearDown(); + $container = $this->setUpContainer('basic', 'docblock_common.yml'); + + try { + $container->get('router')->getRouteCollection(); + $this->fail('Expected the route loading to fail'); + } catch (Throwable $exception) { + $cause = $exception; + while ($cause !== null && !$cause instanceof ConfigurationException) { + $cause = $cause->getPrevious(); + } + $this->assertNotNull($cause, get_class($exception) . ': ' . $exception->getMessage()); + $this->assertStringContainsString( + sprintf( + '%s::testRequiredPermissions(): %s imports Paysera\Bundle\ApiBundle\Annotation\RequiredPermissions,' + . ' but docblock annotations of the bundle are not read here', + DocblockOptions\AttributedDocblockOptionsController::class, + (new ReflectionClass(DocblockOptions\AttributedDocblockOptionsController::class))->getFileName() + ), + $cause->getMessage() + ); + } + } + private function makeTest( string $pathPrefix, Response $expectedResponse, diff --git a/tests/Unit/DependencyInjection/Fixtures/services.json b/tests/Unit/DependencyInjection/Fixtures/services.json index 82c6777..86fc973 100644 --- a/tests/Unit/DependencyInjection/Fixtures/services.json +++ b/tests/Unit/DependencyInjection/Fixtures/services.json @@ -43,8 +43,8 @@ "paysera_api.content_type_matcher": { "class": "Paysera\\Bundle\\ApiBundle\\Service\\ContentTypeMatcher" }, - "paysera_api.docblock_annotation_reader": { - "class": "Paysera\\Bundle\\ApiBundle\\Service\\RoutingLoader\\DocblockAnnotationReader" + "paysera_api.docblock_annotation_guard": { + "class": "Paysera\\Bundle\\ApiBundle\\Service\\RoutingLoader\\DocblockAnnotationGuard" }, "paysera_api.entity_validator": { "class": "Paysera\\Bundle\\ApiBundle\\Service\\Validation\\EntityValidator", @@ -401,7 +401,7 @@ "invalid_behavior": 1 }, { - "reference": "paysera_api.docblock_annotation_reader", + "reference": "paysera_api.docblock_annotation_guard", "invalid_behavior": 1 } ] diff --git a/tests/Unit/Service/RoutingLoader/DocblockAnnotationGuardTest.php b/tests/Unit/Service/RoutingLoader/DocblockAnnotationGuardTest.php new file mode 100644 index 0000000..39c3bc5 --- /dev/null +++ b/tests/Unit/Service/RoutingLoader/DocblockAnnotationGuardTest.php @@ -0,0 +1,358 @@ +expectException(ConfigurationException::class); + $this->expectExceptionMessage(self::refusal($controller, $method, $fileOwner, $finding)); + + $this->check($controller, $method); + } + + public static function refusedProvider(): array + { + $imports = 'imports ' . self::ANNOTATION_NAMESPACE . '\\'; + $fullyQualifiedTag = 'has the docblock tag @\\' . self::ANNOTATION_NAMESPACE . '\RequiredPermissions'; + + return [ + 'import of an annotation' => [ + DocblockOptionsOnAttributeRouteController::class, + 'show', + DocblockOptionsOnAttributeRouteController::class, + $imports . 'RequiredPermissions', + ], + 'imports of two annotations, on the class and the action' => [ + ClassAndActionDocblockController::class, + 'show', + ClassAndActionDocblockController::class, + $imports . 'RequiredPermissions', + ], + 'import with a group use statement' => [ + GroupUseDocblockController::class, + 'show', + GroupUseDocblockController::class, + $imports . 'RequiredPermissions', + ], + 'import of the annotation namespace with an alias' => [ + MisspeltDocblockAnnotationController::class, + 'show', + MisspeltDocblockAnnotationController::class, + 'imports ' . self::ANNOTATION_NAMESPACE, + ], + 'import of an annotation that no docblock uses' => [ + UnusedAnnotationImportController::class, + 'show', + UnusedAnnotationImportController::class, + $imports . 'Body', + ], + 'import of an annotation written in lowercase' => [ + LowercaseAnnotationImportController::class, + 'show', + LowercaseAnnotationImportController::class, + 'imports paysera\bundle\apibundle\annotation\RequiredPermissions', + ], + 'import of an annotation next to a tag that is not an annotation' => [ + UnimportedNoteTagController::class, + 'show', + UnimportedNoteTagController::class, + $imports . 'RequiredPermissions', + ], + 'import of an annotation on the class, next to an attribute on the action' => [ + ClassDocblockAndMethodAttributeController::class, + 'update', + ClassDocblockAndMethodAttributeController::class, + $imports . 'RequiredPermissions', + ], + 'tag through an alias of a parent namespace' => [ + AliasedNamespaceDocblockController::class, + 'show', + AliasedNamespaceDocblockController::class, + 'has the docblock tag @Api\Annotation\RequiredPermissions', + ], + 'fully qualified tag' => [ + FullyQualifiedDocblockController::class, + 'show', + FullyQualifiedDocblockController::class, + $fullyQualifiedTag, + ], + 'fully qualified tag on the class' => [ + ClassDocblockFullyQualifiedController::class, + 'show', + ClassDocblockFullyQualifiedController::class, + $fullyQualifiedTag, + ], + 'fully qualified tag split across docblock lines' => [ + SplitFullyQualifiedDocblockController::class, + 'show', + SplitFullyQualifiedDocblockController::class, + $fullyQualifiedTag, + ], + 'fully qualified tag written in lowercase, without arguments' => [ + LowercaseFullyQualifiedDocblockController::class, + 'show', + LowercaseFullyQualifiedDocblockController::class, + 'has the docblock tag @\paysera\bundle\apibundle\annotation\validation', + ], + 'tag through an alias that is not imported, in the parent declaring the action' => [ + ChildImportingForParentController::class, + 'show', + ParentWithoutImportController::class, + 'has the docblock tag @Rest\RequiredPermissions', + ], + 'import in the trait declaring the action' => [ + TraitDocblockOptionsController::class, + 'show', + DocblockOptionsTrait::class, + $imports . 'RequiredPermissions', + ], + 'import in a trait used by another trait' => [ + NestedTraitActionController::class, + 'show', + DocblockOptionsTrait::class, + $imports . 'RequiredPermissions', + ], + 'import in a nested trait, for an own action next to it' => [ + OwnActionNextToNestedTraitController::class, + 'index', + DocblockOptionsTrait::class, + $imports . 'RequiredPermissions', + ], + 'import in a trait, for an own action next to it' => [ + OwnActionNextToAnnotatedTraitController::class, + 'index', + DocblockOptionsTrait::class, + $imports . 'RequiredPermissions', + ], + 'import in the trait of an action renamed with as' => [ + AliasedTraitMethodController::class, + 'index', + DocblockOptionsTrait::class, + $imports . 'RequiredPermissions', + ], + 'fully qualified tag in the trait of an action renamed with as' => [ + AliasedFullyQualifiedTraitMethodController::class, + 'index', + FullyQualifiedOptionsTrait::class, + $fullyQualifiedTag, + ], + 'import in the parent declaring the action' => [ + ChildOfImportingParentController::class, + 'index', + ImportingParentController::class, + $imports . 'RequiredPermissions', + ], + ]; + } + + /** + * @dataProvider passingProvider + */ + public function testFailOnDocblockAnnotationsPasses(string $controller, string $method = 'show'): void + { + $this->check($controller, $method); + + $this->addToAssertionCount(1); + } + + public static function passingProvider(): array + { + return [ + 'attribute' => [AttributeOptionsController::class], + 'attribute next to a tag that is not an annotation' => [AttributeOptionsWithNoteTagController::class], + 'attribute through an alias of the bundle namespace' => [AliasedNamespaceAttributeController::class], + 'docblock references to the attribute classes, a tag of another library, a string and a closure' => [ + AttributeDocblockReferencesController::class, + ], + 'tags named like imports that are not annotations' => [TagNamedLikeImportsController::class], + 'tag-shaped text in a string and a comment' => [NamespaceMentionedInStringController::class], + 'tag of another class outside the route\'s docblocks' => [ + MissingParentInUnrelatedDocblockController::class, + ], + 'tag of another class that cannot be loaded' => [MissingParentTagController::class], + 'own action of a child whose parent imports an annotation' => [ + ChildOfImportingParentController::class, + ], + 'annotation class of the application, imported: not detected' => [AppAnnotationController::class], + 'annotation class of the application, fully qualified: not detected' => [ + AppAnnotationFullyQualifiedController::class, + ], + 'annotation class of the application, in the namespace of the controller: not detected' => [ + OwnNamespaceAppAnnotationController::class, + ], + ]; + } + + public function testFailOnDocblockAnnotationsRefusesEveryAnnotationOfTheBundle(): void + { + $annotationPattern = __DIR__ . '/../../../../src/Annotation/*.php'; + $annotationFiles = glob($annotationPattern); + if ($annotationFiles === false || $annotationFiles === []) { + throw new RuntimeException('No annotation class found by the pattern ' . $annotationPattern); + } + + $checked = 0; + foreach ($annotationFiles as $file) { + $annotation = basename($file, '.php'); + if (interface_exists(self::ANNOTATION_NAMESPACE . '\\' . $annotation)) { + continue; + } + + $tag = '@\\' . self::ANNOTATION_NAMESPACE . '\\' . $annotation; + $controller = $this->declareController('Generated' . $annotation . 'Controller', $tag); + try { + $this->check($controller); + $this->fail('Expected a ConfigurationException for ' . $tag); + } catch (ConfigurationException $exception) { + $this->assertStringContainsString('has the docblock tag ' . $tag, $exception->getMessage()); + } + $checked++; + } + + $this->assertSame(count($annotationFiles) - 1, $checked); + } + + public function testFailOnDocblockAnnotationsFailsWhenTheSourceCannotBeRead(): void + { + $controller = $this->declareController('GeneratedUnreadableController', '@note kept for older clients'); + $file = (new ReflectionClass($controller))->getFileName(); + unlink($file); + + $this->expectException(ConfigurationException::class); + $this->expectExceptionMessage(sprintf( + '%s::show(): %s cannot be read to check its docblocks for annotations of the bundle%s', + $controller, + $file, + self::NOT_READ + )); + + $this->check($controller); + } + + public function testFailOnDocblockAnnotationsFailsWhenTheClassHasNoSourceFile(): void + { + eval('namespace Paysera\Bundle\ApiBundle\Tests\Unit\Service\RoutingLoader\Generated;' + . ' class EvaluatedController { public function show(): void {} }'); + $controller = 'Paysera\Bundle\ApiBundle\Tests\Unit\Service\RoutingLoader\Generated\EvaluatedController'; + + $this->expectException(ConfigurationException::class); + $this->expectExceptionMessage($controller . '::show(): '); + $this->expectExceptionMessage('cannot be read to check its docblocks for annotations of the bundle'); + + $this->check($controller); + } + + public function testFailOnDocblockAnnotationsFailsWhenTheDocblockCannotBeMatched(): void + { + $jit = ini_get('pcre.jit'); + $backtrackLimit = ini_get('pcre.backtrack_limit'); + ini_set('pcre.jit', '0'); + ini_set('pcre.backtrack_limit', '1'); + try { + $this->check(AttributeOptionsWithNoteTagController::class); + $this->fail('Expected a ConfigurationException'); + } catch (ConfigurationException $exception) { + $this->assertStringContainsString( + 'has a docblock that cannot be checked (preg error ' . PREG_BACKTRACK_LIMIT_ERROR . ')', + $exception->getMessage() + ); + } finally { + ini_set('pcre.jit', (string)$jit); + ini_set('pcre.backtrack_limit', (string)$backtrackLimit); + } + } + + private static function refusal(string $controller, string $method, string $fileOwner, string $finding): string + { + return sprintf( + '%s::%s(): %s %s%s', + $controller, + (new ReflectionMethod($controller, $method))->getName(), + (new ReflectionClass($fileOwner))->getFileName(), + $finding, + self::NOT_READ + ); + } + + private function declareController(string $shortName, string $docblockLine): string + { + $namespace = 'Paysera\Bundle\ApiBundle\Tests\Unit\Service\RoutingLoader\Generated'; + $file = tempnam(sys_get_temp_dir(), 'guard') . '.php'; + file_put_contents($file, sprintf( + "failOnDocblockAnnotations( + new ReflectionClass($controller), + new ReflectionMethod($controller, $method) + ); + } +} diff --git a/tests/Unit/Service/RoutingLoader/DocblockAnnotationReaderTest.php b/tests/Unit/Service/RoutingLoader/DocblockAnnotationReaderTest.php deleted file mode 100644 index 677a228..0000000 --- a/tests/Unit/Service/RoutingLoader/DocblockAnnotationReaderTest.php +++ /dev/null @@ -1,26 +0,0 @@ -readAnnotations( - new ReflectionClass(TraitDocblockOptionsController::class), - new ReflectionMethod(TraitDocblockOptionsController::class, 'show'), - null - ); - - $this->assertEquals([new RequiredPermissions(['permissions' => ['ROLE_ADMIN']])], $annotations); - } -} diff --git a/tests/Unit/Service/RoutingLoader/Fixtures/AliasedNamespaceAttributeController.php b/tests/Unit/Service/RoutingLoader/Fixtures/AliasedNamespaceAttributeController.php new file mode 100644 index 0000000..cadfb3c --- /dev/null +++ b/tests/Unit/Service/RoutingLoader/Fixtures/AliasedNamespaceAttributeController.php @@ -0,0 +1,17 @@ + (new ReflectionClass(AppPermission::class))->getDocComment() === false]; diff --git a/tests/Unit/Service/RoutingLoader/RestRouteConfiguratorTest.php b/tests/Unit/Service/RoutingLoader/RestRouteConfiguratorTest.php index 293c17b..f392d50 100644 --- a/tests/Unit/Service/RoutingLoader/RestRouteConfiguratorTest.php +++ b/tests/Unit/Service/RoutingLoader/RestRouteConfiguratorTest.php @@ -18,7 +18,7 @@ use Paysera\Bundle\ApiBundle\Service\RestRequestHelper; use Paysera\Bundle\ApiBundle\Service\RestRequestOptionsRegistry; use Paysera\Bundle\ApiBundle\Service\RestRequestOptionsValidator; -use Paysera\Bundle\ApiBundle\Service\RoutingLoader\DocblockAnnotationReader; +use Paysera\Bundle\ApiBundle\Service\RoutingLoader\DocblockAnnotationGuard; use Paysera\Bundle\ApiBundle\Service\RoutingLoader\RestRequestAnnotationOptionsBuilder; use Paysera\Bundle\ApiBundle\Service\RoutingLoader\RestRequestAttributeOptionsBuilder; use Paysera\Bundle\ApiBundle\Service\RoutingLoader\RestRouteConfigurator; @@ -49,13 +49,10 @@ class RestRouteConfiguratorTest extends MockeryTestCase 'GlobalMissingUseController.php', ]; - /** - * @dataProvider readerProvider - */ - public function testConfigureRouteWrapsReadErrors(?Reader $reader): void + public function testConfigureRouteWrapsReadErrorsOfTheReader(): void { try { - $this->configureRoute(MisspeltDocblockAnnotationController::class, $reader); + $this->configureRoute(MisspeltDocblockAnnotationController::class, new AnnotationReader()); $this->fail('Expected a ConfigurationException'); } catch (ConfigurationException $exception) { $this->assertStringStartsWith( @@ -69,12 +66,18 @@ public function testConfigureRouteWrapsReadErrors(?Reader $reader): void } } - public static function readerProvider(): array + public function testConfigureRouteWithoutReaderRefusesDocblockAnnotations(): void { - return [ - 'with the reader of Symfony' => [new AnnotationReader()], - 'without a reader' => [null], - ]; + $this->expectException(ConfigurationException::class); + $this->expectExceptionMessage(sprintf( + '%s::show(): %s imports Paysera\Bundle\ApiBundle\Annotation, but docblock annotations of the bundle are' + . ' not read here, because Symfony passes no annotation reader to the route loader;' + . ' use the attributes of the bundle instead', + MisspeltDocblockAnnotationController::class, + (new ReflectionClass(MisspeltDocblockAnnotationController::class))->getFileName() + )); + + $this->configureRoute(MisspeltDocblockAnnotationController::class, null); } /** @@ -244,7 +247,7 @@ private function configureRoute(string $controller, ?Reader $reader, string $met new RestRequestHelper(new RestRequestOptionsRegistry()), new RestRequestAnnotationOptionsBuilder(Mockery::spy(RestRequestOptionsValidator::class)), new RestRequestAttributeOptionsBuilder(Mockery::spy(RestRequestOptionsValidator::class)), - new DocblockAnnotationReader() + new DocblockAnnotationGuard() ); $route = new Route('/'); $configurator->configureRoute( diff --git a/tests/Unit/Service/RoutingLoader/RoutingAnnotationLoaderTest.php b/tests/Unit/Service/RoutingLoader/RoutingAnnotationLoaderTest.php index 17cb249..560b4a0 100644 --- a/tests/Unit/Service/RoutingLoader/RoutingAnnotationLoaderTest.php +++ b/tests/Unit/Service/RoutingLoader/RoutingAnnotationLoaderTest.php @@ -13,7 +13,7 @@ use Paysera\Bundle\ApiBundle\Service\RestRequestHelper; use Paysera\Bundle\ApiBundle\Service\RestRequestOptionsRegistry; use Paysera\Bundle\ApiBundle\Service\RestRequestOptionsValidator; -use Paysera\Bundle\ApiBundle\Service\RoutingLoader\DocblockAnnotationReader; +use Paysera\Bundle\ApiBundle\Service\RoutingLoader\DocblockAnnotationGuard; use Paysera\Bundle\ApiBundle\Service\RoutingLoader\RestRequestAnnotationOptionsBuilder; use Paysera\Bundle\ApiBundle\Service\RoutingLoader\RestRequestAttributeOptionsBuilder; use Paysera\Bundle\ApiBundle\Service\RoutingLoader\RestRouteConfigurator; @@ -27,6 +27,9 @@ class RoutingAnnotationLoaderTest extends MockeryTestCase { + private const NOT_READ = ', but docblock annotations of the bundle are not read here, because Symfony passes no' + . ' annotation reader to the route loader; use the attributes of the bundle instead'; + protected function setUp(): void { if (PHP_VERSION_ID < 80000) { @@ -56,36 +59,63 @@ public function testLoadAppliesOptions(string $controller, ?Reader $reader): voi ); } - /** - * @dataProvider readerProvider - */ - public function testLoadFailsWithAnnotationsAndAttributes(?Reader $reader): void + public static function optionsProvider(): array { - $this->expectException(ConfigurationException::class); - $this->expectExceptionMessage(sprintf( - '%s::update() is configured with both docblock annotations and attributes of the bundle', - NativeRouteMixedOptionsController::class - )); - - $this->createLoader($reader)->load(NativeRouteMixedOptionsController::class); + return [ + 'attributes with the reader of Symfony' => [ + NativeRouteAttributeOptionsController::class, + new AnnotationReader(), + ], + 'attributes with framework.annotations disabled' => [NativeRouteAttributeOptionsController::class, null], + 'docblock annotations with the reader of Symfony' => [ + NativeRouteDocblockOptionsController::class, + new AnnotationReader(), + ], + ]; } - public static function optionsProvider(): array + /** + * @dataProvider failureProvider + */ + public function testLoadFails(string $controller, ?Reader $reader, string $expectedMessage): void { - $cases = []; - foreach (self::readerProvider() as $readerCase => [$reader]) { - $cases['attributes ' . $readerCase] = [NativeRouteAttributeOptionsController::class, $reader]; - $cases['docblock annotations ' . $readerCase] = [NativeRouteDocblockOptionsController::class, $reader]; - } + $this->expectException(ConfigurationException::class); + $this->expectExceptionMessage($expectedMessage); - return $cases; + $this->createLoader($reader)->load($controller); } - public static function readerProvider(): array + public static function failureProvider(): array { return [ - 'with the reader of Symfony' => [new AnnotationReader()], - 'with framework.annotations disabled' => [null], + 'docblock annotations with framework.annotations disabled' => [ + NativeRouteDocblockOptionsController::class, + null, + sprintf( + '%s::show(): %s imports Paysera\Bundle\ApiBundle\Annotation%s', + NativeRouteDocblockOptionsController::class, + (new ReflectionClass(NativeRouteDocblockOptionsController::class))->getFileName(), + self::NOT_READ + ), + ], + 'docblock annotations and attributes with the reader of Symfony' => [ + NativeRouteMixedOptionsController::class, + new AnnotationReader(), + sprintf( + '%s::update() is configured with both docblock annotations and attributes of the bundle', + NativeRouteMixedOptionsController::class + ), + ], + 'docblock annotations and attributes with framework.annotations disabled' => [ + NativeRouteMixedOptionsController::class, + null, + sprintf( + '%s::update(): %s imports Paysera\Bundle\ApiBundle\Annotation\RequiredPermissions%s', + NativeRouteMixedOptionsController::class, + (new ReflectionClass(NativeRouteMixedOptionsController::class))->getFileName(), + self::NOT_READ + ), + ], ]; } @@ -96,7 +126,7 @@ private function createLoader(?Reader $reader): RoutingAnnotationLoader new RestRequestHelper(new RestRequestOptionsRegistry()), new RestRequestAnnotationOptionsBuilder(Mockery::spy(RestRequestOptionsValidator::class)), new RestRequestAttributeOptionsBuilder(Mockery::spy(RestRequestOptionsValidator::class)), - new DocblockAnnotationReader() + new DocblockAnnotationGuard() )); return $loader; diff --git a/tests/Unit/Service/RoutingLoader/RoutingAttributeLoaderTest.php b/tests/Unit/Service/RoutingLoader/RoutingAttributeLoaderTest.php index 74998e1..6cb6bb3 100644 --- a/tests/Unit/Service/RoutingLoader/RoutingAttributeLoaderTest.php +++ b/tests/Unit/Service/RoutingLoader/RoutingAttributeLoaderTest.php @@ -13,16 +13,18 @@ use Paysera\Bundle\ApiBundle\Service\RestRequestHelper; use Paysera\Bundle\ApiBundle\Service\RestRequestOptionsRegistry; use Paysera\Bundle\ApiBundle\Service\RestRequestOptionsValidator; -use Paysera\Bundle\ApiBundle\Service\RoutingLoader\DocblockAnnotationReader; +use Paysera\Bundle\ApiBundle\Service\RoutingLoader\DocblockAnnotationGuard; use Paysera\Bundle\ApiBundle\Service\RoutingLoader\RestRequestAnnotationOptionsBuilder; use Paysera\Bundle\ApiBundle\Service\RoutingLoader\RestRequestAttributeOptionsBuilder; use Paysera\Bundle\ApiBundle\Service\RoutingLoader\RestRouteConfigurator; use Paysera\Bundle\ApiBundle\Service\RoutingLoader\RoutingAttributeLoader; use Paysera\Bundle\ApiBundle\Tests\Unit\Service\RoutingLoader\Fixtures\AliasedFullyQualifiedTraitMethodController; +use Paysera\Bundle\ApiBundle\Tests\Unit\Service\RoutingLoader\Fixtures\AliasedNamespaceAttributeController; use Paysera\Bundle\ApiBundle\Tests\Unit\Service\RoutingLoader\Fixtures\AliasedNamespaceDocblockController; use Paysera\Bundle\ApiBundle\Tests\Unit\Service\RoutingLoader\Fixtures\AliasedTraitMethodController; use Paysera\Bundle\ApiBundle\Tests\Unit\Service\RoutingLoader\Fixtures\AppAnnotationController; use Paysera\Bundle\ApiBundle\Tests\Unit\Service\RoutingLoader\Fixtures\AppAnnotationFullyQualifiedController; +use Paysera\Bundle\ApiBundle\Tests\Unit\Service\RoutingLoader\Fixtures\AttributeDocblockReferencesController; use Paysera\Bundle\ApiBundle\Tests\Unit\Service\RoutingLoader\Fixtures\AttributeOptionsController; use Paysera\Bundle\ApiBundle\Tests\Unit\Service\RoutingLoader\Fixtures\AttributeOptionsWithNoteTagController; use Paysera\Bundle\ApiBundle\Tests\Unit\Service\RoutingLoader\Fixtures\ChildImportingForParentController; @@ -31,27 +33,36 @@ use Paysera\Bundle\ApiBundle\Tests\Unit\Service\RoutingLoader\Fixtures\ClassDocblockAndMethodAttributeController; use Paysera\Bundle\ApiBundle\Tests\Unit\Service\RoutingLoader\Fixtures\ClassDocblockFullyQualifiedController; use Paysera\Bundle\ApiBundle\Tests\Unit\Service\RoutingLoader\Fixtures\DocblockOptionsOnAttributeRouteController; +use Paysera\Bundle\ApiBundle\Tests\Unit\Service\RoutingLoader\Fixtures\DocblockOptionsTrait; use Paysera\Bundle\ApiBundle\Tests\Unit\Service\RoutingLoader\Fixtures\FullyQualifiedDocblockController; +use Paysera\Bundle\ApiBundle\Tests\Unit\Service\RoutingLoader\Fixtures\FullyQualifiedOptionsTrait; use Paysera\Bundle\ApiBundle\Tests\Unit\Service\RoutingLoader\Fixtures\GroupUseDocblockController; -use Paysera\Bundle\ApiBundle\Tests\Unit\Service\RoutingLoader\Fixtures\LowercaseAliasAppAnnotationController; +use Paysera\Bundle\ApiBundle\Tests\Unit\Service\RoutingLoader\Fixtures\ImportingParentController; +use Paysera\Bundle\ApiBundle\Tests\Unit\Service\RoutingLoader\Fixtures\LowercaseAnnotationImportController; +use Paysera\Bundle\ApiBundle\Tests\Unit\Service\RoutingLoader\Fixtures\LowercaseFullyQualifiedDocblockController; use Paysera\Bundle\ApiBundle\Tests\Unit\Service\RoutingLoader\Fixtures\MissingParentInUnrelatedDocblockController; use Paysera\Bundle\ApiBundle\Tests\Unit\Service\RoutingLoader\Fixtures\MissingParentTagController; -use Paysera\Bundle\ApiBundle\Tests\Unit\Service\RoutingLoader\Fixtures\NamespaceAliasAppAnnotationController; use Paysera\Bundle\ApiBundle\Tests\Unit\Service\RoutingLoader\Fixtures\NamespaceMentionedInStringController; use Paysera\Bundle\ApiBundle\Tests\Unit\Service\RoutingLoader\Fixtures\NestedTraitActionController; use Paysera\Bundle\ApiBundle\Tests\Unit\Service\RoutingLoader\Fixtures\OwnActionNextToAnnotatedTraitController; -use Paysera\Bundle\ApiBundle\Tests\Unit\Service\RoutingLoader\Fixtures\OwnNamespaceAppAnnotationController; -use Paysera\Bundle\ApiBundle\Tests\Unit\Service\RoutingLoader\Fixtures\SplitAliasAppAnnotationController; +use Paysera\Bundle\ApiBundle\Tests\Unit\Service\RoutingLoader\Fixtures\OwnActionNextToNestedTraitController; +use Paysera\Bundle\ApiBundle\Tests\Unit\Service\RoutingLoader\Fixtures\ParentWithoutImportController; use Paysera\Bundle\ApiBundle\Tests\Unit\Service\RoutingLoader\Fixtures\SplitFullyQualifiedDocblockController; use Paysera\Bundle\ApiBundle\Tests\Unit\Service\RoutingLoader\Fixtures\TagNamedLikeImportsController; use Paysera\Bundle\ApiBundle\Tests\Unit\Service\RoutingLoader\Fixtures\TraitDocblockOptionsController; use Paysera\Bundle\ApiBundle\Tests\Unit\Service\RoutingLoader\Fixtures\UnimportedNoteTagController; +use Paysera\Bundle\ApiBundle\Tests\Unit\Service\RoutingLoader\Fixtures\UnusedAnnotationImportController; +use ReflectionClass; use stdClass; use Symfony\Bundle\FrameworkBundle\Routing\AttributeRouteControllerLoader; use Symfony\Component\Routing\Loader\AttributeClassLoader; class RoutingAttributeLoaderTest extends MockeryTestCase { + private const ANNOTATION_NAMESPACE = 'Paysera\Bundle\ApiBundle\Annotation'; + private const NOT_READ = ', but docblock annotations of the bundle are not read here, because Symfony passes no' + . ' annotation reader to the route loader; use the attributes of the bundle instead'; + protected function setUp(): void { if (!class_exists(AttributeRouteControllerLoader::class)) { @@ -100,119 +111,41 @@ public static function loadWithoutAnnotationReaderProvider(): array AttributeOptionsController::class, [AttributeOptionsController::class . '::show' => $adminOnly], ], - 'attribute next to a tag that is not an annotation: the docblock is not read' => [ + 'attribute next to a tag that is not an annotation' => [ AttributeOptionsWithNoteTagController::class, [AttributeOptionsWithNoteTagController::class . '::show' => $adminOnly], ], - 'annotation on the action' => [ - DocblockOptionsOnAttributeRouteController::class, - [DocblockOptionsOnAttributeRouteController::class . '::show' => $adminOnly], - ], - 'annotations on the class and the action' => [ - ClassAndActionDocblockController::class, - [ - ClassAndActionDocblockController::class . '::show' => (new RestRequestOptions()) - ->setRequiredPermissions(['ROLE_USER', 'ROLE_ADMIN']) - ->disableBodyValidation(), - ], - ], - 'action of a trait that imports the annotation' => [ - TraitDocblockOptionsController::class, - [TraitDocblockOptionsController::class . '::show' => $adminOnly], - ], - 'annotation imported with a group use statement' => [ - GroupUseDocblockController::class, - [GroupUseDocblockController::class . '::show' => $adminOnly], - ], - 'annotation written through an alias of a parent namespace' => [ - AliasedNamespaceDocblockController::class, - [AliasedNamespaceDocblockController::class . '::show' => $adminOnly], - ], - 'fully qualified annotation without a use statement' => [ - FullyQualifiedDocblockController::class, - [FullyQualifiedDocblockController::class . '::show' => $adminOnly], - ], - 'fully qualified annotation on the class without a use statement' => [ - ClassDocblockFullyQualifiedController::class, - [ClassDocblockFullyQualifiedController::class . '::show' => $adminOnly], - ], - 'fully qualified annotation on a trait action renamed with as' => [ - AliasedFullyQualifiedTraitMethodController::class, - [AliasedFullyQualifiedTraitMethodController::class . '::index' => $adminOnly], - ], - 'fully qualified annotation split across docblock lines' => [ - SplitFullyQualifiedDocblockController::class, - [SplitFullyQualifiedDocblockController::class . '::show' => $adminOnly], - ], - 'annotation class of the application, imported' => [ - AppAnnotationController::class, - [ - AppAnnotationController::class . '::show' => (new RestRequestOptions()) - ->setRequiredPermissions(['ROLE_AUDITOR']), - ], + 'attribute through an alias of the bundle namespace' => [ + AliasedNamespaceAttributeController::class, + [AliasedNamespaceAttributeController::class . '::show' => $adminOnly], ], - 'annotation class of the application, fully qualified without a use statement' => [ - AppAnnotationFullyQualifiedController::class, - [ - AppAnnotationFullyQualifiedController::class . '::show' => (new RestRequestOptions()) - ->setRequiredPermissions(['ROLE_AUDITOR']), - ], + 'attribute next to docblock references to the attribute classes and a tag of another library' => [ + AttributeDocblockReferencesController::class, + [AttributeDocblockReferencesController::class . '::show' => $adminOnly], ], - 'annotation class of the application, written through an alias of its namespace' => [ - NamespaceAliasAppAnnotationController::class, - [ - NamespaceAliasAppAnnotationController::class . '::show' => (new RestRequestOptions()) - ->setRequiredPermissions(['ROLE_AUDITOR']), - ], - ], - 'annotation class of the application, through a namespace alias split across docblock lines' => [ - SplitAliasAppAnnotationController::class, - [ - SplitAliasAppAnnotationController::class . '::show' => (new RestRequestOptions()) - ->setRequiredPermissions(['ROLE_AUDITOR']), - ], - ], - 'annotation class of the application, imported with a lowercase alias' => [ - LowercaseAliasAppAnnotationController::class, - [ - LowercaseAliasAppAnnotationController::class . '::show' => (new RestRequestOptions()) - ->setRequiredPermissions(['ROLE_AUDITOR']), - ], + 'tags named like imports that are not annotations of the application' => [ + TagNamedLikeImportsController::class, + [TagNamedLikeImportsController::class . '::show' => $adminOnly], ], - 'annotation class of the application, in the namespace of the controller' => [ - OwnNamespaceAppAnnotationController::class, - [ - OwnNamespaceAppAnnotationController::class . '::show' => (new RestRequestOptions()) - ->setRequiredPermissions(['ROLE_AUDITOR']), - ], + 'tag-shaped text in a string and a comment' => [ + NamespaceMentionedInStringController::class, + [NamespaceMentionedInStringController::class . '::show' => $adminOnly], ], - 'unloadable class tagged outside the route\'s docblocks: the route still loads' => [ + 'tag of another class that cannot be loaded, outside the route\'s docblocks' => [ MissingParentInUnrelatedDocblockController::class, [MissingParentInUnrelatedDocblockController::class . '::show' => $adminOnly], ], - 'attribute-only child of an annotated parent: only the inherited action\'s docblock is read' => [ - ChildOfImportingParentController::class, - [ - ChildOfImportingParentController::class . '::show' => $adminOnly, - ChildOfImportingParentController::class . '::index' => (new RestRequestOptions()) - ->setRequiredPermissions(['ROLE_USER']), - ], - ], - 'own action next to an annotated trait: only the trait action\'s docblock is read' => [ - OwnActionNextToAnnotatedTraitController::class, - [ - OwnActionNextToAnnotatedTraitController::class . '::show' => $adminOnly, - OwnActionNextToAnnotatedTraitController::class . '::index' => (new RestRequestOptions()) - ->setRequiredPermissions(['ROLE_USER']), - ], + 'tag of another class that cannot be loaded, on the action: the class is not loaded' => [ + MissingParentTagController::class, + [MissingParentTagController::class . '::show' => null], ], - 'tags named like imports that are not annotations of the application: the docblock is not read' => [ - TagNamedLikeImportsController::class, - [TagNamedLikeImportsController::class . '::show' => $adminOnly], + 'annotation class of the application, imported: not detected without a reader' => [ + AppAnnotationController::class, + [AppAnnotationController::class . '::show' => null], ], - 'tag-shaped text in a string and a comment: the docblock is not read' => [ - NamespaceMentionedInStringController::class, - [NamespaceMentionedInStringController::class . '::show' => $adminOnly], + 'annotation class of the application, fully qualified: not detected without a reader' => [ + AppAnnotationFullyQualifiedController::class, + [AppAnnotationFullyQualifiedController::class . '::show' => null], ], ]; } @@ -230,53 +163,180 @@ public function testLoadWithoutAnnotationReaderFails(string $controller, string public static function loadWithoutAnnotationReaderFailsProvider(): array { + $imports = 'imports ' . self::ANNOTATION_NAMESPACE . '\\'; + $fullyQualifiedTag = 'has the docblock tag @\\' . self::ANNOTATION_NAMESPACE . '\RequiredPermissions'; + return [ - 'a tag that is not an annotation next to the annotation' => [ - UnimportedNoteTagController::class, - sprintf( - 'Cannot read the docblock annotations of %s::show(): [Semantical Error] The annotation "@note"', - UnimportedNoteTagController::class + 'annotation on the action' => [ + DocblockOptionsOnAttributeRouteController::class, + self::refusal( + DocblockOptionsOnAttributeRouteController::class, + 'show', + DocblockOptionsOnAttributeRouteController::class, + $imports . 'RequiredPermissions' + ), + ], + 'annotations on the class and the action' => [ + ClassAndActionDocblockController::class, + self::refusal( + ClassAndActionDocblockController::class, + 'show', + ClassAndActionDocblockController::class, + $imports . 'RequiredPermissions' ), ], 'annotation on the class and attribute on the action' => [ ClassDocblockAndMethodAttributeController::class, - sprintf( - '%s::update() is configured with both docblock annotations and attributes of the bundle', - ClassDocblockAndMethodAttributeController::class + self::refusal( + ClassDocblockAndMethodAttributeController::class, + 'update', + ClassDocblockAndMethodAttributeController::class, + $imports . 'RequiredPermissions' ), ], - 'action of a trait used by another trait, whose imports Doctrine\'s reader does not see' => [ - NestedTraitActionController::class, - sprintf( - 'Cannot read the docblock annotations of %s::show(): [Semantical Error] The annotation' - . ' "@RequiredPermissions"', - NestedTraitActionController::class + 'annotation imported with a group use statement' => [ + GroupUseDocblockController::class, + self::refusal( + GroupUseDocblockController::class, + 'show', + GroupUseDocblockController::class, + $imports . 'RequiredPermissions' ), ], - 'trait action renamed with as, whose imports Doctrine\'s reader does not see' => [ - AliasedTraitMethodController::class, - sprintf( - 'Cannot read the docblock annotations of %s::index(): [Semantical Error] The annotation' - . ' "@RequiredPermissions"', - AliasedTraitMethodController::class + 'annotation next to a tag that is not an annotation' => [ + UnimportedNoteTagController::class, + self::refusal( + UnimportedNoteTagController::class, + 'show', + UnimportedNoteTagController::class, + $imports . 'RequiredPermissions' + ), + ], + 'import of an annotation that no docblock uses' => [ + UnusedAnnotationImportController::class, + self::refusal( + UnusedAnnotationImportController::class, + 'show', + UnusedAnnotationImportController::class, + $imports . 'Body' + ), + ], + 'import of an annotation written in lowercase' => [ + LowercaseAnnotationImportController::class, + self::refusal( + LowercaseAnnotationImportController::class, + 'show', + LowercaseAnnotationImportController::class, + 'imports paysera\bundle\apibundle\annotation\RequiredPermissions' + ), + ], + 'annotation written through an alias of a parent namespace' => [ + AliasedNamespaceDocblockController::class, + self::refusal( + AliasedNamespaceDocblockController::class, + 'show', + AliasedNamespaceDocblockController::class, + 'has the docblock tag @Api\Annotation\RequiredPermissions' + ), + ], + 'fully qualified annotation without a use statement' => [ + FullyQualifiedDocblockController::class, + self::refusal( + FullyQualifiedDocblockController::class, + 'show', + FullyQualifiedDocblockController::class, + $fullyQualifiedTag + ), + ], + 'fully qualified annotation on the class without a use statement' => [ + ClassDocblockFullyQualifiedController::class, + self::refusal( + ClassDocblockFullyQualifiedController::class, + 'show', + ClassDocblockFullyQualifiedController::class, + $fullyQualifiedTag + ), + ], + 'fully qualified annotation split across docblock lines' => [ + SplitFullyQualifiedDocblockController::class, + self::refusal( + SplitFullyQualifiedDocblockController::class, + 'show', + SplitFullyQualifiedDocblockController::class, + $fullyQualifiedTag + ), + ], + 'fully qualified annotation written in lowercase, without arguments' => [ + LowercaseFullyQualifiedDocblockController::class, + self::refusal( + LowercaseFullyQualifiedDocblockController::class, + 'show', + LowercaseFullyQualifiedDocblockController::class, + 'has the docblock tag @\paysera\bundle\apibundle\annotation\validation' ), ], - 'alias imported by the routed class only, for an action of its parent, refused by Doctrine' => [ + 'annotation through an alias that only the routed class imports, for an action of its parent' => [ ChildImportingForParentController::class, - sprintf( - 'Cannot read the docblock annotations of %s::show(): [Semantical Error] The annotation' - . ' "@Rest\RequiredPermissions"', - ChildImportingForParentController::class + self::refusal( + ChildImportingForParentController::class, + 'show', + ParentWithoutImportController::class, + 'has the docblock tag @Rest\RequiredPermissions' ), ], - 'unloadable class tagged on the action' => [ - MissingParentTagController::class, - sprintf( - 'Cannot read the docblock annotations of %s::show(): Class "%s" not found', - MissingParentTagController::class, - 'Paysera\Bundle\ApiBundle\Tests\Unit\Service\RoutingLoader\Fixtures\MissingParent' + 'action of a trait that imports the annotation' => [ + TraitDocblockOptionsController::class, + self::refusal( + TraitDocblockOptionsController::class, + 'show', + DocblockOptionsTrait::class, + $imports . 'RequiredPermissions' + ), + ], + 'action of a trait used by another trait' => [ + NestedTraitActionController::class, + self::refusal( + NestedTraitActionController::class, + 'show', + DocblockOptionsTrait::class, + $imports . 'RequiredPermissions' + ), + ], + 'own action next to a nested trait that imports the annotation' => [ + OwnActionNextToNestedTraitController::class, + self::refusal( + OwnActionNextToNestedTraitController::class, + 'index', + DocblockOptionsTrait::class, + $imports . 'RequiredPermissions' + ), + ], + 'own action next to a trait that imports the annotation' => [ + OwnActionNextToAnnotatedTraitController::class, + self::refusal(null, null, DocblockOptionsTrait::class, $imports . 'RequiredPermissions'), + ], + 'trait action renamed with as' => [ + AliasedTraitMethodController::class, + self::refusal( + AliasedTraitMethodController::class, + 'index', + DocblockOptionsTrait::class, + $imports . 'RequiredPermissions' + ), + ], + 'fully qualified annotation on a trait action renamed with as' => [ + AliasedFullyQualifiedTraitMethodController::class, + self::refusal( + AliasedFullyQualifiedTraitMethodController::class, + 'index', + FullyQualifiedOptionsTrait::class, + $fullyQualifiedTag ), ], + 'attribute-only child of a parent that imports the annotation for an inherited action' => [ + ChildOfImportingParentController::class, + self::refusal(null, null, ImportingParentController::class, $imports . 'RequiredPermissions'), + ], ]; } @@ -292,17 +352,18 @@ public function testLoadWithoutAnnotationReaderWhenOpcacheStripsDocComments(): v TagNamedLikeImportsController::class, MissingParentInUnrelatedDocblockController::class, ]; - $importingControllers = [ + $annotatedControllers = [ DocblockOptionsOnAttributeRouteController::class, AliasedNamespaceDocblockController::class, - ClassAndActionDocblockController::class, + FullyQualifiedDocblockController::class, + TraitDocblockOptionsController::class, ]; exec( sprintf( '%s -d opcache.enable_cli=1 -d opcache.save_comments=0 -d opcache.file_update_protection=0 %s %s', escapeshellarg(PHP_BINARY), escapeshellarg(__DIR__ . '/Fixtures/load-routes.php'), - implode(' ', array_map('escapeshellarg', array_merge($attributeOnlyControllers, $importingControllers))) + implode(' ', array_map('escapeshellarg', array_merge($attributeOnlyControllers, $annotatedControllers))) ), $output ); @@ -313,15 +374,22 @@ public function testLoadWithoutAnnotationReaderWhenOpcacheStripsDocComments(): v foreach ($attributeOnlyControllers as $controller) { $this->assertSame([['ROLE_ADMIN']], $results[$controller], $controller); } - foreach ($importingControllers as $controller) { - $this->assertStringContainsString( - 'save_comments=1', - (string)json_encode($results[$controller]), - $controller - ); + foreach ($annotatedControllers as $controller) { + $this->assertStringContainsString(self::NOT_READ, (string)json_encode($results[$controller]), $controller); } } + private static function refusal(?string $controller, ?string $method, string $fileOwner, string $finding): string + { + return sprintf( + '%s%s %s%s', + $controller === null ? '' : sprintf('%s::%s(): ', $controller, $method), + (new ReflectionClass($fileOwner))->getFileName(), + $finding, + self::NOT_READ + ); + } + private function createLoader(?Reader $reader = null): RoutingAttributeLoader { $loader = $reader === null ? new RoutingAttributeLoader() : new RoutingAttributeLoader($reader); @@ -329,7 +397,7 @@ private function createLoader(?Reader $reader = null): RoutingAttributeLoader new RestRequestHelper(new RestRequestOptionsRegistry()), new RestRequestAnnotationOptionsBuilder(Mockery::spy(RestRequestOptionsValidator::class)), new RestRequestAttributeOptionsBuilder(Mockery::spy(RestRequestOptionsValidator::class)), - new DocblockAnnotationReader() + new DocblockAnnotationGuard() )); return $loader; From 7708e60d40df55d6b0b1e89d31f7b7eaa93f2eda Mon Sep 17 00:00:00 2001 From: Vinayak Iyer Date: Fri, 23 Oct 2026 23:39:46 +0530 Subject: [PATCH 14/16] Match docblock tags as Doctrine's lexer does, and declare ext-tokenizer Doctrine strips any number of leading backslashes from a tag, and joins a name split at a backslash across docblock lines only where a name follows, with blanks as its lexer's u mode sees them. The guard allowed one backslash and joined any backslash with the blanks after it, so a tag written with two backslashes, a tag after a line of prose ending with a backslash, and a name split at a no-break space were applied by Doctrine and passed the guard. A docblock with invalid UTF-8 is matched byte by byte. The message for a tag names the remedy for another library's annotation of the same name. ext-tokenizer is required directly instead of through doctrine/annotations. Not detected, now documented with the remedy: an alias of a bundle annotation class made with class_alias(); an application's own annotation class, for which an attribute implementing RestAttributeInterface is applied on every path. Tests: the Symfony 7 docblock fixture moves to Controller/Docblock, its kernel config imports sf7_common.yml, generated files are removed after the run. Co-Authored-By: Claude Fable 5.1 --- CHANGELOG.md | 4 +- README.md | 4 +- composer.json | 1 + .../RoutingLoader/DocblockAnnotationGuard.php | 82 ++++++---- .../AttributedDocblockOptionsController.php | 2 +- .../Resources/config/attributed_routing.xml | 2 +- .../Fixtures/config/sf7_docblock_common.yml | 19 +-- .../Fixtures/config/sf7_docblock_routing.yml | 2 +- .../Functional/FunctionalAnnotationsTest.php | 24 +-- .../DocblockAnnotationGuardTest.php | 147 +++++++++++++----- .../AttributeDocblockReferencesController.php | 6 +- .../BackslashBeforeTagDocblockController.php | 19 +++ .../DoubleBackslashDocblockController.php | 18 +++ .../InvalidUtf8DocblockController.php | 19 +++ .../NoBreakSpaceSplitDocblockController.php | 19 +++ .../RoutingAttributeLoaderTest.php | 58 ++++--- 16 files changed, 306 insertions(+), 120 deletions(-) rename tests/Functional/Fixtures/FixtureTestBundle/Controller/{DocblockOptions => Docblock}/AttributedDocblockOptionsController.php (93%) create mode 100644 tests/Unit/Service/RoutingLoader/Fixtures/BackslashBeforeTagDocblockController.php create mode 100644 tests/Unit/Service/RoutingLoader/Fixtures/DoubleBackslashDocblockController.php create mode 100644 tests/Unit/Service/RoutingLoader/Fixtures/InvalidUtf8DocblockController.php create mode 100644 tests/Unit/Service/RoutingLoader/Fixtures/NoBreakSpaceSplitDocblockController.php diff --git a/CHANGELOG.md b/CHANGELOG.md index 235cebf..6d45747 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -51,7 +51,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 than Doctrine would read: an unused import of the namespace, a tag of another library named like one of the bundle's annotations, and a trait with annotations next to the action; the message names what to remove. Write the options as attributes, or on Symfony 6.4 keep `framework.annotations` enabled. Not detected: an annotation class of your own - implementing `RestAnnotationInterface`, which nothing in the controller's file reveals + implementing `RestAnnotationInterface`, which nothing in the controller's file reveals (write such an option as an + attribute implementing `RestAttributeInterface`, which is applied on every path), and an alias of a bundle annotation + class made with `class_alias()` - On PHP 8 before Symfony 6.4, 1.x ignored the bundle's attributes, `#[RequiredPermissions]` among them, without an error. They are now applied on every supported Symfony version - On PHP 8, a bundle attribute written without its `use` statement, `#[RequiredPermissions]` among them, names a class diff --git a/README.md b/README.md index 16eb6aa..4806f24 100644 --- a/README.md +++ b/README.md @@ -692,7 +692,9 @@ across lines too, in any letter case). The check reads the source files, so `opc them. It refuses more than Doctrine would read: an unused import of the namespace, a tag of another library named like one of the bundle's annotations, and a trait with annotations next to the action; remove what the message names. Write the options as attributes, or on Symfony 6.4 keep `framework.annotations` enabled. Not detected: an annotation class of -your own implementing `RestAnnotationInterface`, which nothing in the controller's file reveals. +your own implementing `RestAnnotationInterface`, which nothing in the controller's file reveals (write such an option as +an attribute implementing `RestAttributeInterface`, which is applied on every path), and an alias of a bundle annotation +class made with `class_alias()`. ### `Body` diff --git a/composer.json b/composer.json index c48dbaf..985a819 100644 --- a/composer.json +++ b/composer.json @@ -17,6 +17,7 @@ "require": { "php": "^7.1 || ^8.0", "ext-json": "*", + "ext-tokenizer": "*", "symfony/config": "^3.4.34|^4.3|^5.4|^6.0|^7.0", "symfony/dependency-injection": "^3.4.34|^4.3|^5.4|^6.0|^7.0", "symfony/framework-bundle": "^3.4.34|^4.3|^5.4|^6.0|^7.4", diff --git a/src/Service/RoutingLoader/DocblockAnnotationGuard.php b/src/Service/RoutingLoader/DocblockAnnotationGuard.php index a96a760..45385b2 100644 --- a/src/Service/RoutingLoader/DocblockAnnotationGuard.php +++ b/src/Service/RoutingLoader/DocblockAnnotationGuard.php @@ -30,8 +30,12 @@ class DocblockAnnotationGuard ResponseNormalization::class, Validation::class, ]; - private const NOT_READ = 'but docblock annotations of the bundle are not read here, because Symfony passes no' + private const NOT_READ = 'docblock annotations of the bundle are not read here, because Symfony passes no' . ' annotation reader to the route loader; use the attributes of the bundle instead'; + private const TAG_FOUND = 'has the docblock tag @'; + private const OTHER_LIBRARY = ' (for an annotation of another library named like one of the bundle\'s, import its' + . ' class under another alias)'; + private const SPLIT_NAME_PATTERN = '/\\\\[\s*]+(?=[A-Za-z_])/'; /** * @var array @@ -50,7 +54,7 @@ public function __construct() return substr($annotation, strrpos($annotation, '\\') + 1); }, self::ANNOTATIONS); $this->tagPattern = sprintf( - '/(?findAnnotationUse($class, $method, $file); if ($finding !== null) { throw new ConfigurationException(sprintf( - '%s::%s(): %s %s, %s', + '%s::%s(): %s %s, but %s%s', $class->getName(), $method->getName(), $file, $finding, - self::NOT_READ + self::NOT_READ, + strpos($finding, self::TAG_FOUND) === 0 ? self::OTHER_LIBRARY : '' )); } } @@ -89,7 +94,7 @@ public function getActionScopes(ReflectionMethod $method): array } /** - * @return array + * @return array */ private function getFiles(ReflectionClass $class, ReflectionMethod $method): array { @@ -104,7 +109,9 @@ private function getFiles(ReflectionClass $class, ReflectionMethod $method): arr $files[] = $scope->getFileName(); } - return array_unique($files); + return array_map(function ($file): ?string { + return $file === false ? null : $file; + }, array_unique($files)); } /** @@ -121,22 +128,19 @@ private function getTraits(ReflectionClass $class): array return $traits; } - /** - * @param string|false $file - */ - private function findAnnotationUse(ReflectionClass $class, ReflectionMethod $method, $file): ?string + private function findAnnotationUse(ReflectionClass $class, ReflectionMethod $method, ?string $file): ?string { - if ($file !== false && array_key_exists($file, $this->findings)) { + if ($file !== null && array_key_exists($file, $this->findings)) { return $this->findings[$file]; } - $source = $file === false ? false : @file_get_contents($file); + $source = $file === null ? false : @file_get_contents($file); if ($source === false) { throw new ConfigurationException(sprintf( - '%s::%s(): %s cannot be read to check its docblocks for annotations of the bundle, %s', + '%s::%s(): %s cannot be read, so its docblocks cannot be checked for annotations of the bundle; %s', $class->getName(), $method->getName(), - $file === false ? 'the source of ' . $class->getName() : $file, + $file === null ? 'the source of ' . $class->getName() : $file, self::NOT_READ )); } @@ -173,14 +177,23 @@ private function findAnnotationUseInSource(string $source): ?string private function findTag(string $docComment): ?string { - // Doctrine's lexer joins a name that continues on the next docblock line after a backslash - $joined = preg_replace('/\\\\[\s*]+/', '\\\\', $docComment); + $joined = $this->joinNamesSplitAcrossLines($docComment); $matched = $joined === null ? false : preg_match($this->tagPattern, $joined, $match); if ($matched === false) { return 'has a docblock that cannot be checked (preg error ' . preg_last_error() . ')'; } - return $matched === 1 ? 'has the docblock tag @' . $match[1] : null; + return $matched === 1 ? self::TAG_FOUND . $match[1] : null; + } + + private function joinNamesSplitAcrossLines(string $docComment): ?string + { + $joined = preg_replace(self::SPLIT_NAME_PATTERN . 'u', '\\\\', $docComment); + if ($joined === null && preg_last_error() === PREG_BAD_UTF8_ERROR) { + $joined = preg_replace(self::SPLIT_NAME_PATTERN, '\\\\', $docComment); + } + + return $joined; } /** @@ -207,8 +220,7 @@ private function parseImports(array $tokens): array if ($token === ';') { break; } - if ($token === '(' || ($token === '{' && substr(rtrim($statement), -1) !== '\\')) { - // the use clause of a closure, or a trait use with a conflict resolution block + if ($this->opensClosureOrTraitBlock($token, $statement)) { $statement = ''; break; } @@ -221,27 +233,43 @@ private function parseImports(array $tokens): array return $imports; } + private function opensClosureOrTraitBlock(string $token, string $statement): bool + { + return $token === '(' || ($token === '{' && substr(rtrim($statement), -1) !== '\\'); + } + /** * @return string[] */ private function expandImports(string $statement): array { - $statement = trim((string)preg_replace('/\s+/', ' ', $statement)); - if ($statement === '') { + $normalized = preg_replace('/\s+/', ' ', $statement); + if ($normalized === null) { + return [$statement]; + } + + $normalized = trim($normalized); + if ($normalized === '') { return []; } $prefix = ''; - $openingBrace = strpos($statement, '{'); + $names = $normalized; + $openingBrace = strpos($normalized, '{'); if ($openingBrace !== false) { - $prefix = trim(substr($statement, 0, $openingBrace)); - $statement = substr($statement, $openingBrace + 1, (int)strrpos($statement, '}') - $openingBrace - 1); + $closingBrace = strrpos($normalized, '}'); + $prefix = trim(substr($normalized, 0, $openingBrace)); + $names = substr( + $normalized, + $openingBrace + 1, + ($closingBrace === false ? strlen($normalized) : $closingBrace) - $openingBrace - 1 + ); } $imports = []; - foreach (explode(',', $statement) as $import) { - $import = (string)preg_replace('/\s+as\s+\S+$/i', '', trim($import)); - $imports[] = str_replace(' ', '', $prefix . $import); + foreach (explode(',', $names) as $name) { + $withoutAlias = preg_replace('/\s+as\s+\S+$/i', '', trim($name)); + $imports[] = str_replace(' ', '', $prefix . ($withoutAlias === null ? $name : $withoutAlias)); } return $imports; diff --git a/tests/Functional/Fixtures/FixtureTestBundle/Controller/DocblockOptions/AttributedDocblockOptionsController.php b/tests/Functional/Fixtures/FixtureTestBundle/Controller/Docblock/AttributedDocblockOptionsController.php similarity index 93% rename from tests/Functional/Fixtures/FixtureTestBundle/Controller/DocblockOptions/AttributedDocblockOptionsController.php rename to tests/Functional/Fixtures/FixtureTestBundle/Controller/Docblock/AttributedDocblockOptionsController.php index 5700514..cf8ff92 100644 --- a/tests/Functional/Fixtures/FixtureTestBundle/Controller/DocblockOptions/AttributedDocblockOptionsController.php +++ b/tests/Functional/Fixtures/FixtureTestBundle/Controller/Docblock/AttributedDocblockOptionsController.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace Paysera\Bundle\ApiBundle\Tests\Functional\Fixtures\FixtureTestBundle\Controller\DocblockOptions; +namespace Paysera\Bundle\ApiBundle\Tests\Functional\Fixtures\FixtureTestBundle\Controller\Docblock; use Paysera\Bundle\ApiBundle\Annotation\RequiredPermissions; use Symfony\Component\HttpFoundation\Request; diff --git a/tests/Functional/Fixtures/FixtureTestBundle/Resources/config/attributed_routing.xml b/tests/Functional/Fixtures/FixtureTestBundle/Resources/config/attributed_routing.xml index 472e7bf..5aa4f1d 100644 --- a/tests/Functional/Fixtures/FixtureTestBundle/Resources/config/attributed_routing.xml +++ b/tests/Functional/Fixtures/FixtureTestBundle/Resources/config/attributed_routing.xml @@ -5,5 +5,5 @@ xsi:schemaLocation="http://symfony.com/schema/routing https://symfony.com/schema/routing/routing-1.0.xsd"> - + diff --git a/tests/Functional/Fixtures/config/sf7_docblock_common.yml b/tests/Functional/Fixtures/config/sf7_docblock_common.yml index 1ac9d92..62b028d 100644 --- a/tests/Functional/Fixtures/config/sf7_docblock_common.yml +++ b/tests/Functional/Fixtures/config/sf7_docblock_common.yml @@ -1,19 +1,6 @@ +imports: + - { resource: sf7_common.yml } + framework: router: resource: '%kernel.project_dir%/tests/Functional/Fixtures/config/sf7_docblock_routing.yml' - validation: - email_validation_mode: html5 - property_info: - with_constructor_extractor: false - -security: - firewalls: - config: - pattern: ^/(config)/ - security: false - main: - http_basic: ~ - stateless: true - password_hashers: - Symfony\Component\Security\Core\User\InMemoryUser: - algorithm: plaintext diff --git a/tests/Functional/Fixtures/config/sf7_docblock_routing.yml b/tests/Functional/Fixtures/config/sf7_docblock_routing.yml index c1bd3d7..6eddc93 100644 --- a/tests/Functional/Fixtures/config/sf7_docblock_routing.yml +++ b/tests/Functional/Fixtures/config/sf7_docblock_routing.yml @@ -8,6 +8,6 @@ paysera_fixture_attributed_test: prefix: / paysera_fixture_docblock_options_test: - resource: '@PayseraFixtureTestBundle/Controller/DocblockOptions/' + resource: '@PayseraFixtureTestBundle/Controller/Docblock/' type: attribute prefix: / diff --git a/tests/Functional/FunctionalAnnotationsTest.php b/tests/Functional/FunctionalAnnotationsTest.php index d405b55..803969f 100644 --- a/tests/Functional/FunctionalAnnotationsTest.php +++ b/tests/Functional/FunctionalAnnotationsTest.php @@ -4,7 +4,7 @@ namespace Paysera\Bundle\ApiBundle\Tests\Functional; use Paysera\Bundle\ApiBundle\Exception\ConfigurationException; -use Paysera\Bundle\ApiBundle\Tests\Functional\Fixtures\FixtureTestBundle\Controller\DocblockOptions; +use Paysera\Bundle\ApiBundle\Tests\Functional\Fixtures\FixtureTestBundle\Controller\Docblock; use Paysera\Bundle\ApiBundle\Tests\Functional\Fixtures\FixtureTestBundle\Service\TestHelper; use ReflectionClass; use Symfony\Component\HttpFoundation\Request; @@ -92,25 +92,27 @@ public function testDocblockOptionsOnAttributeRouteAreRefusedWithoutReader(): vo $this->tearDown(); $container = $this->setUpContainer('basic', 'docblock_common.yml'); + $cause = null; try { $container->get('router')->getRouteCollection(); - $this->fail('Expected the route loading to fail'); } catch (Throwable $exception) { $cause = $exception; while ($cause !== null && !$cause instanceof ConfigurationException) { $cause = $cause->getPrevious(); } $this->assertNotNull($cause, get_class($exception) . ': ' . $exception->getMessage()); - $this->assertStringContainsString( - sprintf( - '%s::testRequiredPermissions(): %s imports Paysera\Bundle\ApiBundle\Annotation\RequiredPermissions,' - . ' but docblock annotations of the bundle are not read here', - DocblockOptions\AttributedDocblockOptionsController::class, - (new ReflectionClass(DocblockOptions\AttributedDocblockOptionsController::class))->getFileName() - ), - $cause->getMessage() - ); } + + $this->assertNotNull($cause, 'Expected the route loading to fail'); + $this->assertStringContainsString( + sprintf( + '%s::testRequiredPermissions(): %s imports Paysera\Bundle\ApiBundle\Annotation\RequiredPermissions,' + . ' but docblock annotations of the bundle are not read here', + Docblock\AttributedDocblockOptionsController::class, + (new ReflectionClass(Docblock\AttributedDocblockOptionsController::class))->getFileName() + ), + $cause->getMessage() + ); } private function makeTest( diff --git a/tests/Unit/Service/RoutingLoader/DocblockAnnotationGuardTest.php b/tests/Unit/Service/RoutingLoader/DocblockAnnotationGuardTest.php index 39c3bc5..d6aac5c 100644 --- a/tests/Unit/Service/RoutingLoader/DocblockAnnotationGuardTest.php +++ b/tests/Unit/Service/RoutingLoader/DocblockAnnotationGuardTest.php @@ -15,6 +15,7 @@ use Paysera\Bundle\ApiBundle\Tests\Unit\Service\RoutingLoader\Fixtures\AttributeDocblockReferencesController; use Paysera\Bundle\ApiBundle\Tests\Unit\Service\RoutingLoader\Fixtures\AttributeOptionsController; use Paysera\Bundle\ApiBundle\Tests\Unit\Service\RoutingLoader\Fixtures\AttributeOptionsWithNoteTagController; +use Paysera\Bundle\ApiBundle\Tests\Unit\Service\RoutingLoader\Fixtures\BackslashBeforeTagDocblockController; use Paysera\Bundle\ApiBundle\Tests\Unit\Service\RoutingLoader\Fixtures\ChildImportingForParentController; use Paysera\Bundle\ApiBundle\Tests\Unit\Service\RoutingLoader\Fixtures\ChildOfImportingParentController; use Paysera\Bundle\ApiBundle\Tests\Unit\Service\RoutingLoader\Fixtures\ClassAndActionDocblockController; @@ -22,10 +23,12 @@ use Paysera\Bundle\ApiBundle\Tests\Unit\Service\RoutingLoader\Fixtures\ClassDocblockFullyQualifiedController; use Paysera\Bundle\ApiBundle\Tests\Unit\Service\RoutingLoader\Fixtures\DocblockOptionsOnAttributeRouteController; use Paysera\Bundle\ApiBundle\Tests\Unit\Service\RoutingLoader\Fixtures\DocblockOptionsTrait; +use Paysera\Bundle\ApiBundle\Tests\Unit\Service\RoutingLoader\Fixtures\DoubleBackslashDocblockController; use Paysera\Bundle\ApiBundle\Tests\Unit\Service\RoutingLoader\Fixtures\FullyQualifiedDocblockController; use Paysera\Bundle\ApiBundle\Tests\Unit\Service\RoutingLoader\Fixtures\FullyQualifiedOptionsTrait; use Paysera\Bundle\ApiBundle\Tests\Unit\Service\RoutingLoader\Fixtures\GroupUseDocblockController; use Paysera\Bundle\ApiBundle\Tests\Unit\Service\RoutingLoader\Fixtures\ImportingParentController; +use Paysera\Bundle\ApiBundle\Tests\Unit\Service\RoutingLoader\Fixtures\InvalidUtf8DocblockController; use Paysera\Bundle\ApiBundle\Tests\Unit\Service\RoutingLoader\Fixtures\LowercaseAnnotationImportController; use Paysera\Bundle\ApiBundle\Tests\Unit\Service\RoutingLoader\Fixtures\LowercaseFullyQualifiedDocblockController; use Paysera\Bundle\ApiBundle\Tests\Unit\Service\RoutingLoader\Fixtures\MissingParentInUnrelatedDocblockController; @@ -33,6 +36,7 @@ use Paysera\Bundle\ApiBundle\Tests\Unit\Service\RoutingLoader\Fixtures\MisspeltDocblockAnnotationController; use Paysera\Bundle\ApiBundle\Tests\Unit\Service\RoutingLoader\Fixtures\NamespaceMentionedInStringController; use Paysera\Bundle\ApiBundle\Tests\Unit\Service\RoutingLoader\Fixtures\NestedTraitActionController; +use Paysera\Bundle\ApiBundle\Tests\Unit\Service\RoutingLoader\Fixtures\NoBreakSpaceSplitDocblockController; use Paysera\Bundle\ApiBundle\Tests\Unit\Service\RoutingLoader\Fixtures\OwnActionNextToAnnotatedTraitController; use Paysera\Bundle\ApiBundle\Tests\Unit\Service\RoutingLoader\Fixtures\OwnActionNextToNestedTraitController; use Paysera\Bundle\ApiBundle\Tests\Unit\Service\RoutingLoader\Fixtures\OwnNamespaceAppAnnotationController; @@ -50,22 +54,25 @@ class DocblockAnnotationGuardTest extends TestCase { private const ANNOTATION_NAMESPACE = 'Paysera\Bundle\ApiBundle\Annotation'; + private const GENERATED_NAMESPACE = 'Paysera\Bundle\ApiBundle\Tests\Unit\Service\RoutingLoader\Generated'; private const NOT_READ = ', but docblock annotations of the bundle are not read here, because Symfony passes no' . ' annotation reader to the route loader; use the attributes of the bundle instead'; + private const OTHER_LIBRARY = ' (for an annotation of another library named like one of the bundle\'s, import its' + . ' class under another alias)'; /** - * @dataProvider refusedProvider + * @var string[] */ - public function testFailOnDocblockAnnotationsRefuses( - string $controller, - string $method, - string $fileOwner, - string $finding - ): void { - $this->expectException(ConfigurationException::class); - $this->expectExceptionMessage(self::refusal($controller, $method, $fileOwner, $finding)); + private static $generatedFiles = []; - $this->check($controller, $method); + public static function tearDownAfterClass(): void + { + foreach (self::$generatedFiles as $file) { + if (file_exists($file)) { + unlink($file); + } + } + self::$generatedFiles = []; } public static function refusedProvider(): array @@ -146,6 +153,30 @@ public static function refusedProvider(): array SplitFullyQualifiedDocblockController::class, $fullyQualifiedTag, ], + 'fully qualified tag with two leading backslashes' => [ + DoubleBackslashDocblockController::class, + 'show', + DoubleBackslashDocblockController::class, + 'has the docblock tag @\\\\' . self::ANNOTATION_NAMESPACE . '\RequiredPermissions', + ], + 'fully qualified tag after a line of prose ending with a backslash' => [ + BackslashBeforeTagDocblockController::class, + 'show', + BackslashBeforeTagDocblockController::class, + $fullyQualifiedTag, + ], + 'fully qualified tag split across lines at a no-break space' => [ + NoBreakSpaceSplitDocblockController::class, + 'show', + NoBreakSpaceSplitDocblockController::class, + $fullyQualifiedTag, + ], + 'fully qualified tag in a docblock with invalid UTF-8' => [ + InvalidUtf8DocblockController::class, + 'show', + InvalidUtf8DocblockController::class, + $fullyQualifiedTag, + ], 'fully qualified tag written in lowercase, without arguments' => [ LowercaseFullyQualifiedDocblockController::class, 'show', @@ -203,16 +234,6 @@ public static function refusedProvider(): array ]; } - /** - * @dataProvider passingProvider - */ - public function testFailOnDocblockAnnotationsPasses(string $controller, string $method = 'show'): void - { - $this->check($controller, $method); - - $this->addToAssertionCount(1); - } - public static function passingProvider(): array { return [ @@ -241,6 +262,48 @@ public static function passingProvider(): array ]; } + /** + * @dataProvider refusedProvider + */ + public function testFailOnDocblockAnnotationsRefuses( + string $controller, + string $method, + string $fileOwner, + string $finding + ): void { + $this->expectException(ConfigurationException::class); + $this->expectExceptionMessage($this->buildRefusalMessage($controller, $method, $fileOwner, $finding)); + + $this->check($controller, $method); + } + + public function testFailOnDocblockAnnotationsNamesTheRemedyForAnotherLibrary(): void + { + try { + $this->check(FullyQualifiedDocblockController::class); + $this->fail('Expected a ConfigurationException'); + } catch (ConfigurationException $exception) { + $this->assertStringEndsWith(self::NOT_READ . self::OTHER_LIBRARY, $exception->getMessage()); + } + + try { + $this->check(DocblockOptionsOnAttributeRouteController::class); + $this->fail('Expected a ConfigurationException'); + } catch (ConfigurationException $exception) { + $this->assertStringEndsWith(self::NOT_READ, $exception->getMessage()); + } + } + + /** + * @dataProvider passingProvider + */ + public function testFailOnDocblockAnnotationsPasses(string $controller, string $method = 'show'): void + { + $this->check($controller, $method); + + $this->addToAssertionCount(1); + } + public function testFailOnDocblockAnnotationsRefusesEveryAnnotationOfTheBundle(): void { $annotationPattern = __DIR__ . '/../../../../src/Annotation/*.php'; @@ -278,10 +341,10 @@ public function testFailOnDocblockAnnotationsFailsWhenTheSourceCannotBeRead(): v $this->expectException(ConfigurationException::class); $this->expectExceptionMessage(sprintf( - '%s::show(): %s cannot be read to check its docblocks for annotations of the bundle%s', + '%s::show(): %s cannot be read, so its docblocks cannot be checked for annotations of the bundle;' + . ' docblock annotations of the bundle are not read here', $controller, - $file, - self::NOT_READ + $file )); $this->check($controller); @@ -289,15 +352,22 @@ public function testFailOnDocblockAnnotationsFailsWhenTheSourceCannotBeRead(): v public function testFailOnDocblockAnnotationsFailsWhenTheClassHasNoSourceFile(): void { - eval('namespace Paysera\Bundle\ApiBundle\Tests\Unit\Service\RoutingLoader\Generated;' - . ' class EvaluatedController { public function show(): void {} }'); - $controller = 'Paysera\Bundle\ApiBundle\Tests\Unit\Service\RoutingLoader\Generated\EvaluatedController'; - - $this->expectException(ConfigurationException::class); - $this->expectExceptionMessage($controller . '::show(): '); - $this->expectExceptionMessage('cannot be read to check its docblocks for annotations of the bundle'); + eval(sprintf( + 'namespace %s; class EvaluatedController { public function show(): void {} }', + self::GENERATED_NAMESPACE + )); + $controller = self::GENERATED_NAMESPACE . '\EvaluatedController'; - $this->check($controller); + try { + $this->check($controller); + $this->fail('Expected a ConfigurationException'); + } catch (ConfigurationException $exception) { + $this->assertStringStartsWith($controller . '::show(): ', $exception->getMessage()); + $this->assertStringContainsString( + 'cannot be read, so its docblocks cannot be checked for annotations of the bundle', + $exception->getMessage() + ); + } } public function testFailOnDocblockAnnotationsFailsWhenTheDocblockCannotBeMatched(): void @@ -320,7 +390,7 @@ public function testFailOnDocblockAnnotationsFailsWhenTheDocblockCannotBeMatched } } - private static function refusal(string $controller, string $method, string $fileOwner, string $finding): string + private function buildRefusalMessage(string $controller, string $method, string $fileOwner, string $finding): string { return sprintf( '%s::%s(): %s %s%s', @@ -334,18 +404,23 @@ private static function refusal(string $controller, string $method, string $file private function declareController(string $shortName, string $docblockLine): string { - $namespace = 'Paysera\Bundle\ApiBundle\Tests\Unit\Service\RoutingLoader\Generated'; - $file = tempnam(sys_get_temp_dir(), 'guard') . '.php'; + $placeholder = tempnam(sys_get_temp_dir(), 'guard'); + if ($placeholder === false) { + throw new RuntimeException('Cannot create a temporary file'); + } + $file = $placeholder . '.php'; + unlink($placeholder); + self::$generatedFiles[] = $file; file_put_contents($file, sprintf( " [ DocblockOptionsOnAttributeRouteController::class, - self::refusal( + self::buildRefusalMessage( DocblockOptionsOnAttributeRouteController::class, 'show', DocblockOptionsOnAttributeRouteController::class, @@ -178,7 +178,7 @@ public static function loadWithoutAnnotationReaderFailsProvider(): array ], 'annotations on the class and the action' => [ ClassAndActionDocblockController::class, - self::refusal( + self::buildRefusalMessage( ClassAndActionDocblockController::class, 'show', ClassAndActionDocblockController::class, @@ -187,7 +187,7 @@ public static function loadWithoutAnnotationReaderFailsProvider(): array ], 'annotation on the class and attribute on the action' => [ ClassDocblockAndMethodAttributeController::class, - self::refusal( + self::buildRefusalMessage( ClassDocblockAndMethodAttributeController::class, 'update', ClassDocblockAndMethodAttributeController::class, @@ -196,7 +196,7 @@ public static function loadWithoutAnnotationReaderFailsProvider(): array ], 'annotation imported with a group use statement' => [ GroupUseDocblockController::class, - self::refusal( + self::buildRefusalMessage( GroupUseDocblockController::class, 'show', GroupUseDocblockController::class, @@ -205,7 +205,7 @@ public static function loadWithoutAnnotationReaderFailsProvider(): array ], 'annotation next to a tag that is not an annotation' => [ UnimportedNoteTagController::class, - self::refusal( + self::buildRefusalMessage( UnimportedNoteTagController::class, 'show', UnimportedNoteTagController::class, @@ -214,7 +214,7 @@ public static function loadWithoutAnnotationReaderFailsProvider(): array ], 'import of an annotation that no docblock uses' => [ UnusedAnnotationImportController::class, - self::refusal( + self::buildRefusalMessage( UnusedAnnotationImportController::class, 'show', UnusedAnnotationImportController::class, @@ -223,7 +223,7 @@ public static function loadWithoutAnnotationReaderFailsProvider(): array ], 'import of an annotation written in lowercase' => [ LowercaseAnnotationImportController::class, - self::refusal( + self::buildRefusalMessage( LowercaseAnnotationImportController::class, 'show', LowercaseAnnotationImportController::class, @@ -232,7 +232,7 @@ public static function loadWithoutAnnotationReaderFailsProvider(): array ], 'annotation written through an alias of a parent namespace' => [ AliasedNamespaceDocblockController::class, - self::refusal( + self::buildRefusalMessage( AliasedNamespaceDocblockController::class, 'show', AliasedNamespaceDocblockController::class, @@ -241,7 +241,7 @@ public static function loadWithoutAnnotationReaderFailsProvider(): array ], 'fully qualified annotation without a use statement' => [ FullyQualifiedDocblockController::class, - self::refusal( + self::buildRefusalMessage( FullyQualifiedDocblockController::class, 'show', FullyQualifiedDocblockController::class, @@ -250,7 +250,7 @@ public static function loadWithoutAnnotationReaderFailsProvider(): array ], 'fully qualified annotation on the class without a use statement' => [ ClassDocblockFullyQualifiedController::class, - self::refusal( + self::buildRefusalMessage( ClassDocblockFullyQualifiedController::class, 'show', ClassDocblockFullyQualifiedController::class, @@ -259,7 +259,7 @@ public static function loadWithoutAnnotationReaderFailsProvider(): array ], 'fully qualified annotation split across docblock lines' => [ SplitFullyQualifiedDocblockController::class, - self::refusal( + self::buildRefusalMessage( SplitFullyQualifiedDocblockController::class, 'show', SplitFullyQualifiedDocblockController::class, @@ -268,7 +268,7 @@ public static function loadWithoutAnnotationReaderFailsProvider(): array ], 'fully qualified annotation written in lowercase, without arguments' => [ LowercaseFullyQualifiedDocblockController::class, - self::refusal( + self::buildRefusalMessage( LowercaseFullyQualifiedDocblockController::class, 'show', LowercaseFullyQualifiedDocblockController::class, @@ -277,7 +277,7 @@ public static function loadWithoutAnnotationReaderFailsProvider(): array ], 'annotation through an alias that only the routed class imports, for an action of its parent' => [ ChildImportingForParentController::class, - self::refusal( + self::buildRefusalMessage( ChildImportingForParentController::class, 'show', ParentWithoutImportController::class, @@ -286,7 +286,7 @@ public static function loadWithoutAnnotationReaderFailsProvider(): array ], 'action of a trait that imports the annotation' => [ TraitDocblockOptionsController::class, - self::refusal( + self::buildRefusalMessage( TraitDocblockOptionsController::class, 'show', DocblockOptionsTrait::class, @@ -295,7 +295,7 @@ public static function loadWithoutAnnotationReaderFailsProvider(): array ], 'action of a trait used by another trait' => [ NestedTraitActionController::class, - self::refusal( + self::buildRefusalMessage( NestedTraitActionController::class, 'show', DocblockOptionsTrait::class, @@ -304,7 +304,7 @@ public static function loadWithoutAnnotationReaderFailsProvider(): array ], 'own action next to a nested trait that imports the annotation' => [ OwnActionNextToNestedTraitController::class, - self::refusal( + self::buildRefusalMessage( OwnActionNextToNestedTraitController::class, 'index', DocblockOptionsTrait::class, @@ -313,11 +313,11 @@ public static function loadWithoutAnnotationReaderFailsProvider(): array ], 'own action next to a trait that imports the annotation' => [ OwnActionNextToAnnotatedTraitController::class, - self::refusal(null, null, DocblockOptionsTrait::class, $imports . 'RequiredPermissions'), + self::buildRefusalMessage(null, null, DocblockOptionsTrait::class, $imports . 'RequiredPermissions'), ], 'trait action renamed with as' => [ AliasedTraitMethodController::class, - self::refusal( + self::buildRefusalMessage( AliasedTraitMethodController::class, 'index', DocblockOptionsTrait::class, @@ -326,7 +326,7 @@ public static function loadWithoutAnnotationReaderFailsProvider(): array ], 'fully qualified annotation on a trait action renamed with as' => [ AliasedFullyQualifiedTraitMethodController::class, - self::refusal( + self::buildRefusalMessage( AliasedFullyQualifiedTraitMethodController::class, 'index', FullyQualifiedOptionsTrait::class, @@ -335,7 +335,12 @@ public static function loadWithoutAnnotationReaderFailsProvider(): array ], 'attribute-only child of a parent that imports the annotation for an inherited action' => [ ChildOfImportingParentController::class, - self::refusal(null, null, ImportingParentController::class, $imports . 'RequiredPermissions'), + self::buildRefusalMessage( + null, + null, + ImportingParentController::class, + $imports . 'RequiredPermissions' + ), ], ]; } @@ -375,11 +380,20 @@ public function testLoadWithoutAnnotationReaderWhenOpcacheStripsDocComments(): v $this->assertSame([['ROLE_ADMIN']], $results[$controller], $controller); } foreach ($annotatedControllers as $controller) { - $this->assertStringContainsString(self::NOT_READ, (string)json_encode($results[$controller]), $controller); + $this->assertStringContainsString( + self::NOT_READ, + (string)json_encode($results[$controller]), + $controller + ); } } - private static function refusal(?string $controller, ?string $method, string $fileOwner, string $finding): string + private static function buildRefusalMessage( + ?string $controller, + ?string $method, + string $fileOwner, + string $finding + ): string { return sprintf( '%s%s %s%s', From 2667bc9ea0c53b7c63f0452565ab6487f8b5f3b1 Mon Sep 17 00:00:00 2001 From: Vinayak Iyer Date: Mon, 26 Oct 2026 09:35:18 +0530 Subject: [PATCH 15/16] Join names split at non-ASCII bytes too, and narrow the alias remedy Doctrine lexes a docblock with invalid UTF-8 from its first tag, in u mode, so a name split across lines at a no-break space after a line of Latin-1 prose was applied by Doctrine and passed the guard, whose byte-mode join only knew ASCII blanks. The byte-mode join now treats every non-ASCII byte before a name as a blank, and so also refuses such a split in a docblock Doctrine reads nothing from. The remedy for another library's annotation of the same name was appended to every tag finding, the bundle's own fully qualified name included; it is now appended only when the tag is not written with the bundle's full name. The README and CHANGELOG list the refusals beyond Doctrine with what to do about them, the tag in a docblock Doctrine reads for no route and the tag in a docblock with invalid UTF-8 included; the CHANGELOG lists ext-tokenizer among the direct requirements. Tests: the invalid UTF-8 shapes are generated with byte escapes instead of a Latin-1 fixture file, which .editorconfig declares UTF-8; every refused row of the guard's test asserts the whole message, remedy included or not; the remedy test uses another library's tag. Co-Authored-By: Claude Fable 5.1 --- CHANGELOG.md | 16 ++-- README.md | 14 ++-- .../RoutingLoader/DocblockAnnotationGuard.php | 15 +++- .../DocblockAnnotationGuardTest.php | 82 +++++++++++++------ .../InvalidUtf8DocblockController.php | 19 ----- 5 files changed, 88 insertions(+), 58 deletions(-) delete mode 100644 tests/Unit/Service/RoutingLoader/Fixtures/InvalidUtf8DocblockController.php diff --git a/CHANGELOG.md b/CHANGELOG.md index 6d45747..9e65f0b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,7 +7,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [2.0.0] ### Added - Support for Symfony 7.4 and `psr/log` 3; CI runs PHP 8.4, Symfony 7 and lowest dependencies -- Explicit requirements for the Symfony components the bundle uses directly: `symfony/config`, +- Explicit requirements for `ext-tokenizer` and the Symfony components the bundle uses directly: `symfony/config`, `dependency-injection`, `http-foundation`, `http-kernel`, `property-access`, `routing` and `security-core` ### Changed @@ -48,12 +48,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 `@\Paysera\Bundle\ApiBundle\Annotation\RequiredPermissions`, split across lines too, in any letter case, with or without arguments). The check reads the source files, so OPcache stripping the doc comments (`opcache.save_comments=0`) does not hide them, and a file that cannot be read fails the load too. It refuses more - than Doctrine would read: an unused import of the namespace, a tag of another library named like one of the bundle's - annotations, and a trait with annotations next to the action; the message names what to remove. Write the options as - attributes, or on Symfony 6.4 keep `framework.annotations` enabled. Not detected: an annotation class of your own - implementing `RestAnnotationInterface`, which nothing in the controller's file reveals (write such an option as an - attribute implementing `RestAttributeInterface`, which is applied on every path), and an alias of a bundle annotation - class made with `class_alias()` + than Doctrine would read: an unused import of the namespace (remove it), a tag of another library named like one of + the bundle's annotations (import the other library's class under another alias), a trait with annotations next to the + action, a bundle tag in a docblock Doctrine reads for no route, such as a property's (remove it), and a bundle tag in + a docblock with invalid UTF-8 that Doctrine would read nothing from; the message names the import, the tag or the + trait. Write the options as attributes, or on Symfony 6.4 keep `framework.annotations` enabled. Not detected: an + annotation class of your own implementing `RestAnnotationInterface`, which nothing in the controller's file reveals + (write such an option as an attribute implementing `RestAttributeInterface`, which is applied on every path), and an + alias of a bundle annotation class made with `class_alias()` - On PHP 8 before Symfony 6.4, 1.x ignored the bundle's attributes, `#[RequiredPermissions]` among them, without an error. They are now applied on every supported Symfony version - On PHP 8, a bundle attribute written without its `use` statement, `#[RequiredPermissions]` among them, names a class diff --git a/README.md b/README.md index 4806f24..73b732f 100644 --- a/README.md +++ b/README.md @@ -689,12 +689,14 @@ class, of the class declaring the action, of the action itself or of any trait t `Paysera\Bundle\ApiBundle\Annotation`, or has a docblock tag named like one of the bundle's annotations (`@RequiredPermissions`, `@Rest\RequiredPermissions`, `@\Paysera\Bundle\ApiBundle\Annotation\RequiredPermissions`, split across lines too, in any letter case). The check reads the source files, so `opcache.save_comments=0` does not hide -them. It refuses more than Doctrine would read: an unused import of the namespace, a tag of another library named like -one of the bundle's annotations, and a trait with annotations next to the action; remove what the message names. Write -the options as attributes, or on Symfony 6.4 keep `framework.annotations` enabled. Not detected: an annotation class of -your own implementing `RestAnnotationInterface`, which nothing in the controller's file reveals (write such an option as -an attribute implementing `RestAttributeInterface`, which is applied on every path), and an alias of a bundle annotation -class made with `class_alias()`. +them. It refuses more than Doctrine would read: an unused import of the namespace (remove it), a tag of another library +named like one of the bundle's annotations (import the other library's class under another alias), a trait with +annotations next to the action, a bundle tag in a docblock Doctrine reads for no route, such as a property's (remove +it), and a bundle tag in a docblock with invalid UTF-8 that Doctrine would read nothing from; the message names the +import, the tag or the trait. Write the options as attributes, or on Symfony 6.4 keep `framework.annotations` enabled. +Not detected: an annotation class of your own implementing `RestAnnotationInterface`, which nothing in the controller's +file reveals (write such an option as an attribute implementing `RestAttributeInterface`, which is applied on every +path), and an alias of a bundle annotation class made with `class_alias()`. ### `Body` diff --git a/src/Service/RoutingLoader/DocblockAnnotationGuard.php b/src/Service/RoutingLoader/DocblockAnnotationGuard.php index 45385b2..84b2175 100644 --- a/src/Service/RoutingLoader/DocblockAnnotationGuard.php +++ b/src/Service/RoutingLoader/DocblockAnnotationGuard.php @@ -36,6 +36,7 @@ class DocblockAnnotationGuard private const OTHER_LIBRARY = ' (for an annotation of another library named like one of the bundle\'s, import its' . ' class under another alias)'; private const SPLIT_NAME_PATTERN = '/\\\\[\s*]+(?=[A-Za-z_])/'; + private const SPLIT_NAME_BYTE_PATTERN = '/\\\\[\s*\x80-\xFF]+(?=[A-Za-z_])/'; /** * @var array @@ -71,7 +72,7 @@ public function failOnDocblockAnnotations(ReflectionClass $class, ReflectionMeth $file, $finding, self::NOT_READ, - strpos($finding, self::TAG_FOUND) === 0 ? self::OTHER_LIBRARY : '' + $this->isTagOutsideAnnotationNamespace($finding) ? self::OTHER_LIBRARY : '' )); } } @@ -186,11 +187,21 @@ private function findTag(string $docComment): ?string return $matched === 1 ? self::TAG_FOUND . $match[1] : null; } + private function isTagOutsideAnnotationNamespace(string $finding): bool + { + if (strpos($finding, self::TAG_FOUND) !== 0) { + return false; + } + $name = ltrim(substr($finding, strlen(self::TAG_FOUND)), '\\'); + + return stripos($name, self::ANNOTATION_NAMESPACE . '\\') !== 0; + } + private function joinNamesSplitAcrossLines(string $docComment): ?string { $joined = preg_replace(self::SPLIT_NAME_PATTERN . 'u', '\\\\', $docComment); if ($joined === null && preg_last_error() === PREG_BAD_UTF8_ERROR) { - $joined = preg_replace(self::SPLIT_NAME_PATTERN, '\\\\', $docComment); + $joined = preg_replace(self::SPLIT_NAME_BYTE_PATTERN, '\\\\', $docComment); } return $joined; diff --git a/tests/Unit/Service/RoutingLoader/DocblockAnnotationGuardTest.php b/tests/Unit/Service/RoutingLoader/DocblockAnnotationGuardTest.php index d6aac5c..52920d1 100644 --- a/tests/Unit/Service/RoutingLoader/DocblockAnnotationGuardTest.php +++ b/tests/Unit/Service/RoutingLoader/DocblockAnnotationGuardTest.php @@ -28,7 +28,6 @@ use Paysera\Bundle\ApiBundle\Tests\Unit\Service\RoutingLoader\Fixtures\FullyQualifiedOptionsTrait; use Paysera\Bundle\ApiBundle\Tests\Unit\Service\RoutingLoader\Fixtures\GroupUseDocblockController; use Paysera\Bundle\ApiBundle\Tests\Unit\Service\RoutingLoader\Fixtures\ImportingParentController; -use Paysera\Bundle\ApiBundle\Tests\Unit\Service\RoutingLoader\Fixtures\InvalidUtf8DocblockController; use Paysera\Bundle\ApiBundle\Tests\Unit\Service\RoutingLoader\Fixtures\LowercaseAnnotationImportController; use Paysera\Bundle\ApiBundle\Tests\Unit\Service\RoutingLoader\Fixtures\LowercaseFullyQualifiedDocblockController; use Paysera\Bundle\ApiBundle\Tests\Unit\Service\RoutingLoader\Fixtures\MissingParentInUnrelatedDocblockController; @@ -134,6 +133,7 @@ public static function refusedProvider(): array 'show', AliasedNamespaceDocblockController::class, 'has the docblock tag @Api\Annotation\RequiredPermissions', + self::OTHER_LIBRARY, ], 'fully qualified tag' => [ FullyQualifiedDocblockController::class, @@ -171,12 +171,6 @@ public static function refusedProvider(): array NoBreakSpaceSplitDocblockController::class, $fullyQualifiedTag, ], - 'fully qualified tag in a docblock with invalid UTF-8' => [ - InvalidUtf8DocblockController::class, - 'show', - InvalidUtf8DocblockController::class, - $fullyQualifiedTag, - ], 'fully qualified tag written in lowercase, without arguments' => [ LowercaseFullyQualifiedDocblockController::class, 'show', @@ -188,6 +182,7 @@ public static function refusedProvider(): array 'show', ParentWithoutImportController::class, 'has the docblock tag @Rest\RequiredPermissions', + self::OTHER_LIBRARY, ], 'import in the trait declaring the action' => [ TraitDocblockOptionsController::class, @@ -262,6 +257,26 @@ public static function passingProvider(): array ]; } + public static function invalidUtf8DocblockProvider(): array + { + $finding = 'has the docblock tag @\\' . self::ANNOTATION_NAMESPACE . '\RequiredPermissions'; + + return [ + 'Latin-1 prose before a fully qualified tag' => [ + 'GeneratedLatin1ProseController', + "R\xE9sum\xE9 of the exports\n * @\\" . self::ANNOTATION_NAMESPACE + . '\RequiredPermissions(permissions={"ROLE_ADMIN"})', + $finding, + ], + 'Latin-1 prose before a tag split across lines at a no-break space' => [ + 'GeneratedLatin1ProseSplitTagController', + "R\xE9sum\xE9 of the exports\n * @\\Paysera\\Bundle\\\n" + . " *\xC2\xA0ApiBundle\\Annotation\\RequiredPermissions(permissions={\"ROLE_ADMIN\"})", + $finding, + ], + ]; + } + /** * @dataProvider refusedProvider */ @@ -269,29 +284,38 @@ public function testFailOnDocblockAnnotationsRefuses( string $controller, string $method, string $fileOwner, + string $finding, + string $remedy = '' + ): void { + $this->assertRefusedWith( + $this->buildRefusalMessage($controller, $method, $fileOwner, $finding) . $remedy, + $controller, + $method + ); + } + + /** + * @dataProvider invalidUtf8DocblockProvider + */ + public function testFailOnDocblockAnnotationsRefusesADocblockWithInvalidUtf8( + string $shortName, + string $docblock, string $finding ): void { - $this->expectException(ConfigurationException::class); - $this->expectExceptionMessage($this->buildRefusalMessage($controller, $method, $fileOwner, $finding)); + $controller = $this->declareController($shortName, $docblock); - $this->check($controller, $method); + $this->assertRefusedWith($this->buildRefusalMessage($controller, 'show', $controller, $finding), $controller); } public function testFailOnDocblockAnnotationsNamesTheRemedyForAnotherLibrary(): void { - try { - $this->check(FullyQualifiedDocblockController::class); - $this->fail('Expected a ConfigurationException'); - } catch (ConfigurationException $exception) { - $this->assertStringEndsWith(self::NOT_READ . self::OTHER_LIBRARY, $exception->getMessage()); - } + $controller = $this->declareController('GeneratedOtherLibraryTagController', '@GQL\Query(type="String")'); - try { - $this->check(DocblockOptionsOnAttributeRouteController::class); - $this->fail('Expected a ConfigurationException'); - } catch (ConfigurationException $exception) { - $this->assertStringEndsWith(self::NOT_READ, $exception->getMessage()); - } + $this->assertRefusedWith( + $this->buildRefusalMessage($controller, 'show', $controller, 'has the docblock tag @GQL\Query') + . self::OTHER_LIBRARY, + $controller + ); } /** @@ -402,7 +426,7 @@ private function buildRefusalMessage(string $controller, string $method, string ); } - private function declareController(string $shortName, string $docblockLine): string + private function declareController(string $shortName, string $docblock): string { $placeholder = tempnam(sys_get_temp_dir(), 'guard'); if ($placeholder === false) { @@ -416,13 +440,23 @@ private function declareController(string $shortName, string $docblockLine): str . " public function show(): void\n {\n }\n}\n", self::GENERATED_NAMESPACE, $shortName, - $docblockLine + $docblock )); require $file; return self::GENERATED_NAMESPACE . '\\' . $shortName; } + private function assertRefusedWith(string $message, string $controller, string $method = 'show'): void + { + try { + $this->check($controller, $method); + $this->fail('Expected a ConfigurationException'); + } catch (ConfigurationException $exception) { + $this->assertSame($message, $exception->getMessage()); + } + } + private function check(string $controller, string $method = 'show'): void { (new DocblockAnnotationGuard())->failOnDocblockAnnotations( diff --git a/tests/Unit/Service/RoutingLoader/Fixtures/InvalidUtf8DocblockController.php b/tests/Unit/Service/RoutingLoader/Fixtures/InvalidUtf8DocblockController.php deleted file mode 100644 index eda8c25..0000000 --- a/tests/Unit/Service/RoutingLoader/Fixtures/InvalidUtf8DocblockController.php +++ /dev/null @@ -1,19 +0,0 @@ - Date: Mon, 26 Oct 2026 13:36:52 +0530 Subject: [PATCH 16/16] Match aliases as Doctrine does, and pass prose and dashed names MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Doctrine lexes identifiers with the i and u flags, under which a-z also matches the long s (U+017F) and the Kelvin sign (U+212A). A tag written through an alias of a parent namespace spelt with one of them, such as @Apiſ\Annotation\RequiredPermissions after use Paysera\Bundle\ApiBundle as Apiſ, was applied by Doctrine and passed the guard, whose name segments allowed ASCII only. Segments now take any non-ASCII byte, so a tag path with other non-ASCII letters is refused where Doctrine reads nothing; the README names that. In the other direction the guard refused bundle names Doctrine never reads: a tag right after a backtick, a parenthesis, a brace or a quote in prose, and a name followed by a dash that no digit follows. A tag now starts only after a blank, an asterisk, a double quote or a non-ASCII byte, and a name followed by a dash is not one unless a digit follows the dash, which Doctrine reads as a number. The double quote stays because Doctrine reads "x"@Tag as a tag. A source file that reflection cannot name is mapped to null where the files are collected, instead of in a later closure; the whitespace collapse in expandImports() goes, since the import parser already writes every blank as one space. Tests: a row per alias shape (long s, Kelvin sign, an umlaut that Doctrine does not accept), the prose and dashed shapes as passing rows, the dash-and-digit shape as a refused row, the quoted-string shape, a comma-separated use statement, the two byte-mode join shapes that no row pinned, and the no-break-space split generated with an escape instead of a fixture holding the raw byte. The three application-annotation fixtures no test referenced become passing rows. The unreadable-source tests assert the whole message, and a built-in class drives the branch for a class without a source file. The loader test's data provider returns the message parts. Co-Authored-By: Claude Fable 5.1 --- CHANGELOG.md | 16 +- README.md | 14 +- .../RoutingLoader/DocblockAnnotationGuard.php | 28 +-- .../DocblockAnnotationGuardTest.php | 231 ++++++++++++++---- .../AttributeDocblockReferencesController.php | 1 + .../NoBreakSpaceSplitDocblockController.php | 19 -- .../RoutingAttributeLoaderTest.php | 211 +++++++--------- 7 files changed, 311 insertions(+), 209 deletions(-) delete mode 100644 tests/Unit/Service/RoutingLoader/Fixtures/NoBreakSpaceSplitDocblockController.php diff --git a/CHANGELOG.md b/CHANGELOG.md index 9e65f0b..ebdf875 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -50,12 +50,16 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 (`opcache.save_comments=0`) does not hide them, and a file that cannot be read fails the load too. It refuses more than Doctrine would read: an unused import of the namespace (remove it), a tag of another library named like one of the bundle's annotations (import the other library's class under another alias), a trait with annotations next to the - action, a bundle tag in a docblock Doctrine reads for no route, such as a property's (remove it), and a bundle tag in - a docblock with invalid UTF-8 that Doctrine would read nothing from; the message names the import, the tag or the - trait. Write the options as attributes, or on Symfony 6.4 keep `framework.annotations` enabled. Not detected: an - annotation class of your own implementing `RestAnnotationInterface`, which nothing in the controller's file reveals - (write such an option as an attribute implementing `RestAttributeInterface`, which is applied on every path), and an - alias of a bundle annotation class made with `class_alias()` + action, a bundle tag in a docblock Doctrine reads for no route, such as a property's (remove it), a bundle tag at the + very start of a line or right after a double quote or a non-ASCII character, which Doctrine reads in some of these + places and skips in others (move it after a space), a tag path with non-ASCII characters beyond the long s and the + Kelvin sign that Doctrine accepts inside a name (remove or reword it; for another library's tag, import its class + under another alias), and a bundle tag in a docblock with invalid UTF-8 that Doctrine would read nothing from; the + message names the import, the tag or the trait. Write the options as attributes, or on Symfony 6.4 keep + `framework.annotations` enabled. Not detected: an annotation class of your own implementing `RestAnnotationInterface`, + which nothing in the controller's file reveals (write such an option as an attribute implementing + `RestAttributeInterface`, which is applied on every path), and an alias of a bundle annotation class made with + `class_alias()` - On PHP 8 before Symfony 6.4, 1.x ignored the bundle's attributes, `#[RequiredPermissions]` among them, without an error. They are now applied on every supported Symfony version - On PHP 8, a bundle attribute written without its `use` statement, `#[RequiredPermissions]` among them, names a class diff --git a/README.md b/README.md index 73b732f..eedbc09 100644 --- a/README.md +++ b/README.md @@ -692,11 +692,15 @@ across lines too, in any letter case). The check reads the source files, so `opc them. It refuses more than Doctrine would read: an unused import of the namespace (remove it), a tag of another library named like one of the bundle's annotations (import the other library's class under another alias), a trait with annotations next to the action, a bundle tag in a docblock Doctrine reads for no route, such as a property's (remove -it), and a bundle tag in a docblock with invalid UTF-8 that Doctrine would read nothing from; the message names the -import, the tag or the trait. Write the options as attributes, or on Symfony 6.4 keep `framework.annotations` enabled. -Not detected: an annotation class of your own implementing `RestAnnotationInterface`, which nothing in the controller's -file reveals (write such an option as an attribute implementing `RestAttributeInterface`, which is applied on every -path), and an alias of a bundle annotation class made with `class_alias()`. +it), a bundle tag at the very start of a line or right after a double quote or a non-ASCII character, which Doctrine +reads in some of these places and skips in others (move it after a space), a tag path with non-ASCII characters beyond +the long s and the Kelvin sign that Doctrine accepts inside a name (remove or reword it; for another library's tag, +import its class under another alias), and a bundle tag in a docblock with invalid UTF-8 that Doctrine would read +nothing from; the message names the import, the tag or the trait. Write the options as attributes, or on Symfony 6.4 +keep `framework.annotations` enabled. Not detected: an annotation class of your own implementing +`RestAnnotationInterface`, which nothing in the controller's file reveals (write such an option as an attribute +implementing `RestAttributeInterface`, which is applied on every path), and an alias of a bundle annotation class made +with `class_alias()`. ### `Body` diff --git a/src/Service/RoutingLoader/DocblockAnnotationGuard.php b/src/Service/RoutingLoader/DocblockAnnotationGuard.php index 84b2175..dfd37b2 100644 --- a/src/Service/RoutingLoader/DocblockAnnotationGuard.php +++ b/src/Service/RoutingLoader/DocblockAnnotationGuard.php @@ -55,7 +55,8 @@ public function __construct() return substr($annotation, strrpos($annotation, '\\') + 1); }, self::ANNOTATIONS); $this->tagPattern = sprintf( - '/(?getDeclaringClass(); - $files = [$method->getFileName()]; - $scopes = array_merge( - [$class, $declaringClass], + $sources = array_merge( + [$method, $class, $declaringClass], $this->getTraits($class), $this->getTraits($declaringClass) ); - foreach ($scopes as $scope) { - $files[] = $scope->getFileName(); + $files = []; + foreach ($sources as $source) { + $files[] = $source->getFileName() ?: null; } - return array_map(function ($file): ?string { - return $file === false ? null : $file; - }, array_unique($files)); + return array_unique($files); } /** @@ -231,7 +230,7 @@ private function parseImports(array $tokens): array if ($token === ';') { break; } - if ($this->opensClosureOrTraitBlock($token, $statement)) { + if ($this->isClosureOrTraitBlockOpening($token, $statement)) { $statement = ''; break; } @@ -244,7 +243,7 @@ private function parseImports(array $tokens): array return $imports; } - private function opensClosureOrTraitBlock(string $token, string $statement): bool + private function isClosureOrTraitBlockOpening(string $token, string $statement): bool { return $token === '(' || ($token === '{' && substr(rtrim($statement), -1) !== '\\'); } @@ -254,12 +253,7 @@ private function opensClosureOrTraitBlock(string $token, string $statement): boo */ private function expandImports(string $statement): array { - $normalized = preg_replace('/\s+/', ' ', $statement); - if ($normalized === null) { - return [$statement]; - } - - $normalized = trim($normalized); + $normalized = trim($statement); if ($normalized === '') { return []; } diff --git a/tests/Unit/Service/RoutingLoader/DocblockAnnotationGuardTest.php b/tests/Unit/Service/RoutingLoader/DocblockAnnotationGuardTest.php index 52920d1..733d12a 100644 --- a/tests/Unit/Service/RoutingLoader/DocblockAnnotationGuardTest.php +++ b/tests/Unit/Service/RoutingLoader/DocblockAnnotationGuardTest.php @@ -4,6 +4,7 @@ namespace Paysera\Bundle\ApiBundle\Tests\Unit\Service\RoutingLoader; +use ArrayObject; use Paysera\Bundle\ApiBundle\Exception\ConfigurationException; use Paysera\Bundle\ApiBundle\Service\RoutingLoader\DocblockAnnotationGuard; use Paysera\Bundle\ApiBundle\Tests\Unit\Service\RoutingLoader\Fixtures\AliasedFullyQualifiedTraitMethodController; @@ -28,18 +29,20 @@ use Paysera\Bundle\ApiBundle\Tests\Unit\Service\RoutingLoader\Fixtures\FullyQualifiedOptionsTrait; use Paysera\Bundle\ApiBundle\Tests\Unit\Service\RoutingLoader\Fixtures\GroupUseDocblockController; use Paysera\Bundle\ApiBundle\Tests\Unit\Service\RoutingLoader\Fixtures\ImportingParentController; +use Paysera\Bundle\ApiBundle\Tests\Unit\Service\RoutingLoader\Fixtures\LowercaseAliasAppAnnotationController; use Paysera\Bundle\ApiBundle\Tests\Unit\Service\RoutingLoader\Fixtures\LowercaseAnnotationImportController; use Paysera\Bundle\ApiBundle\Tests\Unit\Service\RoutingLoader\Fixtures\LowercaseFullyQualifiedDocblockController; use Paysera\Bundle\ApiBundle\Tests\Unit\Service\RoutingLoader\Fixtures\MissingParentInUnrelatedDocblockController; use Paysera\Bundle\ApiBundle\Tests\Unit\Service\RoutingLoader\Fixtures\MissingParentTagController; use Paysera\Bundle\ApiBundle\Tests\Unit\Service\RoutingLoader\Fixtures\MisspeltDocblockAnnotationController; +use Paysera\Bundle\ApiBundle\Tests\Unit\Service\RoutingLoader\Fixtures\NamespaceAliasAppAnnotationController; use Paysera\Bundle\ApiBundle\Tests\Unit\Service\RoutingLoader\Fixtures\NamespaceMentionedInStringController; use Paysera\Bundle\ApiBundle\Tests\Unit\Service\RoutingLoader\Fixtures\NestedTraitActionController; -use Paysera\Bundle\ApiBundle\Tests\Unit\Service\RoutingLoader\Fixtures\NoBreakSpaceSplitDocblockController; use Paysera\Bundle\ApiBundle\Tests\Unit\Service\RoutingLoader\Fixtures\OwnActionNextToAnnotatedTraitController; use Paysera\Bundle\ApiBundle\Tests\Unit\Service\RoutingLoader\Fixtures\OwnActionNextToNestedTraitController; use Paysera\Bundle\ApiBundle\Tests\Unit\Service\RoutingLoader\Fixtures\OwnNamespaceAppAnnotationController; use Paysera\Bundle\ApiBundle\Tests\Unit\Service\RoutingLoader\Fixtures\ParentWithoutImportController; +use Paysera\Bundle\ApiBundle\Tests\Unit\Service\RoutingLoader\Fixtures\SplitAliasAppAnnotationController; use Paysera\Bundle\ApiBundle\Tests\Unit\Service\RoutingLoader\Fixtures\SplitFullyQualifiedDocblockController; use Paysera\Bundle\ApiBundle\Tests\Unit\Service\RoutingLoader\Fixtures\TagNamedLikeImportsController; use Paysera\Bundle\ApiBundle\Tests\Unit\Service\RoutingLoader\Fixtures\TraitDocblockOptionsController; @@ -54,8 +57,9 @@ class DocblockAnnotationGuardTest extends TestCase { private const ANNOTATION_NAMESPACE = 'Paysera\Bundle\ApiBundle\Annotation'; private const GENERATED_NAMESPACE = 'Paysera\Bundle\ApiBundle\Tests\Unit\Service\RoutingLoader\Generated'; - private const NOT_READ = ', but docblock annotations of the bundle are not read here, because Symfony passes no' + private const NOT_READ_REASON = 'docblock annotations of the bundle are not read here, because Symfony passes no' . ' annotation reader to the route loader; use the attributes of the bundle instead'; + private const NOT_READ = ', but ' . self::NOT_READ_REASON; private const OTHER_LIBRARY = ' (for an annotation of another library named like one of the bundle\'s, import its' . ' class under another alias)'; @@ -74,6 +78,9 @@ public static function tearDownAfterClass(): void self::$generatedFiles = []; } + /** + * @return array + */ public static function refusedProvider(): array { $imports = 'imports ' . self::ANNOTATION_NAMESPACE . '\\'; @@ -165,12 +172,6 @@ public static function refusedProvider(): array BackslashBeforeTagDocblockController::class, $fullyQualifiedTag, ], - 'fully qualified tag split across lines at a no-break space' => [ - NoBreakSpaceSplitDocblockController::class, - 'show', - NoBreakSpaceSplitDocblockController::class, - $fullyQualifiedTag, - ], 'fully qualified tag written in lowercase, without arguments' => [ LowercaseFullyQualifiedDocblockController::class, 'show', @@ -229,6 +230,9 @@ public static function refusedProvider(): array ]; } + /** + * @return array + */ public static function passingProvider(): array { return [ @@ -254,25 +258,145 @@ public static function passingProvider(): array 'annotation class of the application, in the namespace of the controller: not detected' => [ OwnNamespaceAppAnnotationController::class, ], + 'annotation class of the application, through a lowercase alias: not detected' => [ + LowercaseAliasAppAnnotationController::class, + ], + 'annotation class of the application, through an alias of a parent namespace: not detected' => [ + NamespaceAliasAppAnnotationController::class, + ], + 'annotation class of the application, split across lines through an alias: not detected' => [ + SplitAliasAppAnnotationController::class, + ], ]; } - public static function invalidUtf8DocblockProvider(): array + /** + * @return array + */ + public static function generatedRefusedProvider(): array { - $finding = 'has the docblock tag @\\' . self::ANNOTATION_NAMESPACE . '\RequiredPermissions'; + $tag = '@\\' . self::ANNOTATION_NAMESPACE . '\RequiredPermissions(permissions={"ROLE_ADMIN"})'; + $fullyQualifiedTag = 'has the docblock tag @\\' . self::ANNOTATION_NAMESPACE . '\RequiredPermissions'; return [ 'Latin-1 prose before a fully qualified tag' => [ 'GeneratedLatin1ProseController', - "R\xE9sum\xE9 of the exports\n * @\\" . self::ANNOTATION_NAMESPACE - . '\RequiredPermissions(permissions={"ROLE_ADMIN"})', - $finding, + '', + "R\xE9sum\xE9 of the exports\n * " . $tag, + $fullyQualifiedTag, + ], + 'Latin-1 prose after a fully qualified tag' => [ + 'GeneratedLatin1ProseAfterTagController', + '', + $tag . "\n * R\xE9sum\xE9 of the exports", + $fullyQualifiedTag, + ], + 'Latin-1 prose ending with a backslash before a fully qualified tag' => [ + 'GeneratedLatin1BackslashProseController', + '', + "R\xE9sum\xE9 under C:\\exports\\\n * " . $tag, + $fullyQualifiedTag, ], 'Latin-1 prose before a tag split across lines at a no-break space' => [ 'GeneratedLatin1ProseSplitTagController', + '', "R\xE9sum\xE9 of the exports\n * @\\Paysera\\Bundle\\\n" . " *\xC2\xA0ApiBundle\\Annotation\\RequiredPermissions(permissions={\"ROLE_ADMIN\"})", - $finding, + $fullyQualifiedTag, + ], + 'fully qualified tag split across lines at a no-break space' => [ + 'GeneratedNoBreakSpaceSplitController', + '', + "@\\Paysera\\Bundle\\\n" + . " *\u{00A0}ApiBundle\\Annotation\\RequiredPermissions(permissions={\"ROLE_ADMIN\"})", + $fullyQualifiedTag, + ], + 'fully qualified tag right after a quoted string of another tag' => [ + 'GeneratedTagAfterStringController', + '', + '@deprecated "x"' . $tag, + $fullyQualifiedTag, + ], + 'fully qualified tag quoted in prose' => [ + 'GeneratedQuotedTagController', + '', + '"@\\' . self::ANNOTATION_NAMESPACE . '\RequiredPermissions" is gone', + $fullyQualifiedTag, + ], + 'fully qualified tag at the start of a docblock line' => [ + 'GeneratedLineStartTagController', + '', + "\n" . $tag, + $fullyQualifiedTag, + ], + 'import of an annotation in a comma-separated use statement' => [ + 'GeneratedCommaImportController', + 'use Psr\Log\LoggerInterface, ' . self::ANNOTATION_NAMESPACE . '\RequiredPermissions as Perm;', + '@Perm(permissions={"ROLE_ADMIN"})', + 'imports ' . self::ANNOTATION_NAMESPACE . '\RequiredPermissions', + ], + 'tag through an alias of a parent namespace spelt with a long s' => [ + 'GeneratedLongSAliasController', + "use Paysera\\Bundle\\ApiBundle as Api\u{017F};", + "@Api\u{017F}\\Annotation\\RequiredPermissions(permissions={\"ROLE_ADMIN\"})", + "has the docblock tag @Api\u{017F}\\Annotation\\RequiredPermissions", + self::OTHER_LIBRARY, + ], + 'tag through an alias of a parent namespace spelt with a Kelvin sign' => [ + 'GeneratedKelvinAliasController', + "use Paysera\\Bundle\\ApiBundle as Api\u{212A};", + "@Api\u{212A}\\Annotation\\Validation", + "has the docblock tag @Api\u{212A}\\Annotation\\Validation", + self::OTHER_LIBRARY, + ], + 'fully qualified tag name followed by a dash and a digit' => [ + 'GeneratedDashDigitTagController', + '', + '@\\' . self::ANNOTATION_NAMESPACE . '\Validation-1', + 'has the docblock tag @\\' . self::ANNOTATION_NAMESPACE . '\Validation', + ], + 'tag whose alias has a non-ASCII letter Doctrine does not accept' => [ + 'GeneratedUmlautAliasController', + '', + "@\u{00DC}nicode\\Query(type=\"String\")", + "has the docblock tag @\u{00DC}nicode\\Query", + self::OTHER_LIBRARY, + ], + ]; + } + + /** + * @return array + */ + public static function generatedPassingProvider(): array + { + $name = '@\\' . self::ANNOTATION_NAMESPACE . '\RequiredPermissions'; + + return [ + 'fully qualified tag name followed by a dash' => [ + 'GeneratedDashedTagController', + '', + $name . '-only(permissions={"ROLE_ADMIN"})', + ], + 'fully qualified tag in backticks in prose' => [ + 'GeneratedBacktickProseController', + '', + 'Replaces the `' . $name . '(permissions={"ROLE_ADMIN"})` of 1.x', + ], + 'fully qualified tag in parentheses in prose' => [ + 'GeneratedParenthesisProseController', + '', + '(' . $name . ' is gone)', + ], + 'fully qualified tag in braces in prose' => [ + 'GeneratedBraceProseController', + '', + '{' . $name . '}', + ], + 'fully qualified tag right after a single-quoted string' => [ + 'GeneratedTagAfterSingleQuotedStringController', + '', + "@deprecated 'x'" . $name . '(permissions={"ROLE_ADMIN"})', ], ]; } @@ -295,16 +419,34 @@ public function testFailOnDocblockAnnotationsRefuses( } /** - * @dataProvider invalidUtf8DocblockProvider + * @dataProvider generatedRefusedProvider */ - public function testFailOnDocblockAnnotationsRefusesADocblockWithInvalidUtf8( + public function testFailOnDocblockAnnotationsRefusesAGeneratedController( string $shortName, + string $imports, string $docblock, - string $finding + string $finding, + string $remedy = '' + ): void { + $controller = $this->declareController($shortName, $docblock, $imports); + + $this->assertRefusedWith( + $this->buildRefusalMessage($controller, 'show', $controller, $finding) . $remedy, + $controller + ); + } + + /** + * @dataProvider generatedPassingProvider + */ + public function testFailOnDocblockAnnotationsPassesAGeneratedController( + string $shortName, + string $imports, + string $docblock ): void { - $controller = $this->declareController($shortName, $docblock); + $this->check($this->declareController($shortName, $docblock, $imports)); - $this->assertRefusedWith($this->buildRefusalMessage($controller, 'show', $controller, $finding), $controller); + $this->addToAssertionCount(1); } public function testFailOnDocblockAnnotationsNamesTheRemedyForAnotherLibrary(): void @@ -363,18 +505,10 @@ public function testFailOnDocblockAnnotationsFailsWhenTheSourceCannotBeRead(): v $file = (new ReflectionClass($controller))->getFileName(); unlink($file); - $this->expectException(ConfigurationException::class); - $this->expectExceptionMessage(sprintf( - '%s::show(): %s cannot be read, so its docblocks cannot be checked for annotations of the bundle;' - . ' docblock annotations of the bundle are not read here', - $controller, - $file - )); - - $this->check($controller); + $this->assertRefusedWith($this->buildUnreadableMessage($controller, 'show', $file), $controller); } - public function testFailOnDocblockAnnotationsFailsWhenTheClassHasNoSourceFile(): void + public function testFailOnDocblockAnnotationsFailsWhenTheClassIsEvaluatedCode(): void { eval(sprintf( 'namespace %s; class EvaluatedController { public function show(): void {} }', @@ -382,16 +516,19 @@ public function testFailOnDocblockAnnotationsFailsWhenTheClassHasNoSourceFile(): )); $controller = self::GENERATED_NAMESPACE . '\EvaluatedController'; - try { - $this->check($controller); - $this->fail('Expected a ConfigurationException'); - } catch (ConfigurationException $exception) { - $this->assertStringStartsWith($controller . '::show(): ', $exception->getMessage()); - $this->assertStringContainsString( - 'cannot be read, so its docblocks cannot be checked for annotations of the bundle', - $exception->getMessage() - ); - } + $this->assertRefusedWith( + $this->buildUnreadableMessage($controller, 'show', (new ReflectionClass($controller))->getFileName()), + $controller + ); + } + + public function testFailOnDocblockAnnotationsFailsWhenTheClassHasNoSourceFile(): void + { + $this->assertRefusedWith( + $this->buildUnreadableMessage(ArrayObject::class, 'count', 'the source of ' . ArrayObject::class), + ArrayObject::class, + 'count' + ); } public function testFailOnDocblockAnnotationsFailsWhenTheDocblockCannotBeMatched(): void @@ -426,7 +563,18 @@ private function buildRefusalMessage(string $controller, string $method, string ); } - private function declareController(string $shortName, string $docblock): string + private function buildUnreadableMessage(string $controller, string $method, string $source): string + { + return sprintf( + '%s::%s(): %s cannot be read, so its docblocks cannot be checked for annotations of the bundle; %s', + $controller, + $method, + $source, + self::NOT_READ_REASON + ); + } + + private function declareController(string $shortName, string $docblock, string $imports = ''): string { $placeholder = tempnam(sys_get_temp_dir(), 'guard'); if ($placeholder === false) { @@ -436,9 +584,10 @@ private function declareController(string $shortName, string $docblock): string unlink($placeholder); self::$generatedFiles[] = $file; file_put_contents($file, sprintf( - "expectException(ConfigurationException::class); - $this->expectExceptionMessage($expectedMessage); + $this->expectExceptionMessage($this->buildRefusalMessage($owner, $method, $fileOwner, $finding)); $this->createLoader()->load($controller); } @@ -169,178 +174,143 @@ public static function loadWithoutAnnotationReaderFailsProvider(): array return [ 'annotation on the action' => [ DocblockOptionsOnAttributeRouteController::class, - self::buildRefusalMessage( - DocblockOptionsOnAttributeRouteController::class, - 'show', - DocblockOptionsOnAttributeRouteController::class, - $imports . 'RequiredPermissions' - ), + DocblockOptionsOnAttributeRouteController::class, + 'show', + DocblockOptionsOnAttributeRouteController::class, + $imports . 'RequiredPermissions', ], 'annotations on the class and the action' => [ ClassAndActionDocblockController::class, - self::buildRefusalMessage( - ClassAndActionDocblockController::class, - 'show', - ClassAndActionDocblockController::class, - $imports . 'RequiredPermissions' - ), + ClassAndActionDocblockController::class, + 'show', + ClassAndActionDocblockController::class, + $imports . 'RequiredPermissions', ], 'annotation on the class and attribute on the action' => [ ClassDocblockAndMethodAttributeController::class, - self::buildRefusalMessage( - ClassDocblockAndMethodAttributeController::class, - 'update', - ClassDocblockAndMethodAttributeController::class, - $imports . 'RequiredPermissions' - ), + ClassDocblockAndMethodAttributeController::class, + 'update', + ClassDocblockAndMethodAttributeController::class, + $imports . 'RequiredPermissions', ], 'annotation imported with a group use statement' => [ GroupUseDocblockController::class, - self::buildRefusalMessage( - GroupUseDocblockController::class, - 'show', - GroupUseDocblockController::class, - $imports . 'RequiredPermissions' - ), + GroupUseDocblockController::class, + 'show', + GroupUseDocblockController::class, + $imports . 'RequiredPermissions', ], 'annotation next to a tag that is not an annotation' => [ UnimportedNoteTagController::class, - self::buildRefusalMessage( - UnimportedNoteTagController::class, - 'show', - UnimportedNoteTagController::class, - $imports . 'RequiredPermissions' - ), + UnimportedNoteTagController::class, + 'show', + UnimportedNoteTagController::class, + $imports . 'RequiredPermissions', ], 'import of an annotation that no docblock uses' => [ UnusedAnnotationImportController::class, - self::buildRefusalMessage( - UnusedAnnotationImportController::class, - 'show', - UnusedAnnotationImportController::class, - $imports . 'Body' - ), + UnusedAnnotationImportController::class, + 'show', + UnusedAnnotationImportController::class, + $imports . 'Body', ], 'import of an annotation written in lowercase' => [ LowercaseAnnotationImportController::class, - self::buildRefusalMessage( - LowercaseAnnotationImportController::class, - 'show', - LowercaseAnnotationImportController::class, - 'imports paysera\bundle\apibundle\annotation\RequiredPermissions' - ), + LowercaseAnnotationImportController::class, + 'show', + LowercaseAnnotationImportController::class, + 'imports paysera\bundle\apibundle\annotation\RequiredPermissions', ], 'annotation written through an alias of a parent namespace' => [ AliasedNamespaceDocblockController::class, - self::buildRefusalMessage( - AliasedNamespaceDocblockController::class, - 'show', - AliasedNamespaceDocblockController::class, - 'has the docblock tag @Api\Annotation\RequiredPermissions' - ), + AliasedNamespaceDocblockController::class, + 'show', + AliasedNamespaceDocblockController::class, + 'has the docblock tag @Api\Annotation\RequiredPermissions', ], 'fully qualified annotation without a use statement' => [ FullyQualifiedDocblockController::class, - self::buildRefusalMessage( - FullyQualifiedDocblockController::class, - 'show', - FullyQualifiedDocblockController::class, - $fullyQualifiedTag - ), + FullyQualifiedDocblockController::class, + 'show', + FullyQualifiedDocblockController::class, + $fullyQualifiedTag, ], 'fully qualified annotation on the class without a use statement' => [ ClassDocblockFullyQualifiedController::class, - self::buildRefusalMessage( - ClassDocblockFullyQualifiedController::class, - 'show', - ClassDocblockFullyQualifiedController::class, - $fullyQualifiedTag - ), + ClassDocblockFullyQualifiedController::class, + 'show', + ClassDocblockFullyQualifiedController::class, + $fullyQualifiedTag, ], 'fully qualified annotation split across docblock lines' => [ SplitFullyQualifiedDocblockController::class, - self::buildRefusalMessage( - SplitFullyQualifiedDocblockController::class, - 'show', - SplitFullyQualifiedDocblockController::class, - $fullyQualifiedTag - ), + SplitFullyQualifiedDocblockController::class, + 'show', + SplitFullyQualifiedDocblockController::class, + $fullyQualifiedTag, ], 'fully qualified annotation written in lowercase, without arguments' => [ LowercaseFullyQualifiedDocblockController::class, - self::buildRefusalMessage( - LowercaseFullyQualifiedDocblockController::class, - 'show', - LowercaseFullyQualifiedDocblockController::class, - 'has the docblock tag @\paysera\bundle\apibundle\annotation\validation' - ), + LowercaseFullyQualifiedDocblockController::class, + 'show', + LowercaseFullyQualifiedDocblockController::class, + 'has the docblock tag @\paysera\bundle\apibundle\annotation\validation', ], 'annotation through an alias that only the routed class imports, for an action of its parent' => [ ChildImportingForParentController::class, - self::buildRefusalMessage( - ChildImportingForParentController::class, - 'show', - ParentWithoutImportController::class, - 'has the docblock tag @Rest\RequiredPermissions' - ), + ChildImportingForParentController::class, + 'show', + ParentWithoutImportController::class, + 'has the docblock tag @Rest\RequiredPermissions', ], 'action of a trait that imports the annotation' => [ TraitDocblockOptionsController::class, - self::buildRefusalMessage( - TraitDocblockOptionsController::class, - 'show', - DocblockOptionsTrait::class, - $imports . 'RequiredPermissions' - ), + TraitDocblockOptionsController::class, + 'show', + DocblockOptionsTrait::class, + $imports . 'RequiredPermissions', ], 'action of a trait used by another trait' => [ NestedTraitActionController::class, - self::buildRefusalMessage( - NestedTraitActionController::class, - 'show', - DocblockOptionsTrait::class, - $imports . 'RequiredPermissions' - ), + NestedTraitActionController::class, + 'show', + DocblockOptionsTrait::class, + $imports . 'RequiredPermissions', ], 'own action next to a nested trait that imports the annotation' => [ OwnActionNextToNestedTraitController::class, - self::buildRefusalMessage( - OwnActionNextToNestedTraitController::class, - 'index', - DocblockOptionsTrait::class, - $imports . 'RequiredPermissions' - ), + OwnActionNextToNestedTraitController::class, + 'index', + DocblockOptionsTrait::class, + $imports . 'RequiredPermissions', ], 'own action next to a trait that imports the annotation' => [ OwnActionNextToAnnotatedTraitController::class, - self::buildRefusalMessage(null, null, DocblockOptionsTrait::class, $imports . 'RequiredPermissions'), + null, + null, + DocblockOptionsTrait::class, + $imports . 'RequiredPermissions', ], 'trait action renamed with as' => [ AliasedTraitMethodController::class, - self::buildRefusalMessage( - AliasedTraitMethodController::class, - 'index', - DocblockOptionsTrait::class, - $imports . 'RequiredPermissions' - ), + AliasedTraitMethodController::class, + 'index', + DocblockOptionsTrait::class, + $imports . 'RequiredPermissions', ], 'fully qualified annotation on a trait action renamed with as' => [ AliasedFullyQualifiedTraitMethodController::class, - self::buildRefusalMessage( - AliasedFullyQualifiedTraitMethodController::class, - 'index', - FullyQualifiedOptionsTrait::class, - $fullyQualifiedTag - ), + AliasedFullyQualifiedTraitMethodController::class, + 'index', + FullyQualifiedOptionsTrait::class, + $fullyQualifiedTag, ], 'attribute-only child of a parent that imports the annotation for an inherited action' => [ ChildOfImportingParentController::class, - self::buildRefusalMessage( - null, - null, - ImportingParentController::class, - $imports . 'RequiredPermissions' - ), + null, + null, + ImportingParentController::class, + $imports . 'RequiredPermissions', ], ]; } @@ -388,13 +358,12 @@ public function testLoadWithoutAnnotationReaderWhenOpcacheStripsDocComments(): v } } - private static function buildRefusalMessage( + private function buildRefusalMessage( ?string $controller, ?string $method, string $fileOwner, string $finding - ): string - { + ): string { return sprintf( '%s%s %s%s', $controller === null ? '' : sprintf('%s::%s(): ', $controller, $method),