diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 4b05237..c1a8ae9 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -31,10 +31,10 @@ jobs: - '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.*' } @@ -46,6 +46,20 @@ jobs: - { 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', 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' } steps: - name: Checkout diff --git a/CHANGELOG.md b/CHANGELOG.md index 67337b9..001bb0e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,28 @@ 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). +## [1.9.0] +### Added +- Support for Symfony 7.4 +- Support for `psr/log` 3 + +### Changed +- The Symfony components the bundle uses directly are required explicitly: `symfony/config`, `symfony/dependency-injection`, + `symfony/http-foundation`, `symfony/http-kernel`, `symfony/property-access`, `symfony/routing` and `symfony/security-core` +- `Configuration::getConfigTreeBuilder()` declares its `TreeBuilder` return type and `PayseraApiExtension::load()` declares + `void`. Breaking for subclasses that override either method without the return type: add `: TreeBuilder` or `: void` to + the override +- Symfony 7 reads no docblock annotations, so on Symfony 7 loading a route whose controller still uses the bundle's + docblock annotations (`@Body`, `@Query`, `@PathAttribute`, `@ResponseNormalization`, `@RequiredPermissions`, + `@Validation`, `@BodyContentType`) fails with a `ConfigurationException` naming them: use the attributes of the same + name. Symfony 4.4 to 6.4 are unchanged +- Optional parameters are declared nullable explicitly (`?Type $parameter = null`), as PHP 8.4 expects +- CI runs the tests on Symfony 7 with PHP 8.2 and 8.3 + +### Fixed +- On Symfony 7.1 and later, `LocaleListener` picks the locale from `Accept-Language` the same way as on older Symfony + versions: a request asking for German (`de`) kept the default locale there + ## [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..0f6c466 100644 --- a/README.md +++ b/README.md @@ -645,6 +645,12 @@ cursor and iterating this way until we have `"has_previous": false` is a reliabl ## Annotations/Attributes reference +Every option below exists as a docblock annotation (`Paysera\Bundle\ApiBundle\Annotation\*`) and, since 1.8.0, as a PHP +attribute of the same name (`Paysera\Bundle\ApiBundle\Attribute\*`, read on Symfony 6.4 and later). On Symfony 7 use the +attributes: Symfony 7 reads no docblock annotations, so loading a route whose controller still uses the bundle's docblock +annotations fails with an error naming them, and the routes themselves need `#[Route]` with `type: attribute` imports. The attributes take the same options, passed by name: +`@RequiredPermissions(permissions={"ROLE_ADMIN"})` becomes `#[RequiredPermissions(permissions: ['ROLE_ADMIN'])]`. + ### `Body` Instructs to convert request body into an object and pass to the controller as an argument. diff --git a/composer.json b/composer.json index f80e408..cbcd5c2 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.4", + "symfony/dependency-injection": "^3.4.34|^4.3|^5.4|^6.0|^7.4", + "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.4", + "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.4", + "symfony/routing": "^3.4.34|^4.3|^5.4|^6.0|^7.4", + "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.4", + "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" }, "require-dev": { "phpunit/phpunit": "^7.5 || ^9.6", "mockery/mockery": "^1.3.6", - "symfony/yaml": "^3.4.34|^4.3|^5.4|^6.0", + "symfony/yaml": "^3.4.34|^4.3|^5.4|^6.0|^7.4", "doctrine/doctrine-bundle": "^1.12.0|^2.1", - "doctrine/orm": "^2.5.14" + "doctrine/orm": "^2.6.3" }, "config": { "bin-dir": "bin" 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..8faa5a7 100644 --- a/src/DependencyInjection/Configuration.php +++ b/src/DependencyInjection/Configuration.php @@ -10,7 +10,7 @@ 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/DependencyInjection/PayseraApiExtension.php b/src/DependencyInjection/PayseraApiExtension.php index 2a1db01..a60661b 100644 --- a/src/DependencyInjection/PayseraApiExtension.php +++ b/src/DependencyInjection/PayseraApiExtension.php @@ -17,7 +17,7 @@ class PayseraApiExtension extends Extension { - public function load(array $configs, ContainerBuilder $container) + public function load(array $configs, ContainerBuilder $container): void { $configuration = new Configuration(); $config = $this->processConfiguration($configuration, $configs); 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/LocaleListener.php b/src/Listener/LocaleListener.php index 241101f..aebfd9a 100644 --- a/src/Listener/LocaleListener.php +++ b/src/Listener/LocaleListener.php @@ -4,6 +4,7 @@ namespace Paysera\Bundle\ApiBundle\Listener; use Paysera\Bundle\ApiBundle\Service\RestRequestHelper; +use Symfony\Component\HttpFoundation\AcceptHeader; use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpKernel\Event\GetResponseEvent; use Symfony\Component\HttpKernel\Event\RequestEvent; @@ -42,15 +43,53 @@ 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)); + $languages = $this->readLanguages($request); + $candidates = []; + foreach ($languages as $language) { + $candidates[] = $language; + $separatorPosition = strpos($language, '_'); + if ($separatorPosition === false) { + continue; + } + $primaryLanguage = substr($language, 0, $separatorPosition); + if (!in_array($primaryLanguage, $languages, true)) { + $candidates[] = $primaryLanguage; + } + } - if ($preferredLanguage !== null && $preferredLanguage !== $defaultLocale) { - return $preferredLanguage; + foreach ($candidates as $candidate) { + if (in_array($candidate, $this->locales, true)) { + return $candidate; + } } return null; } + + /** + * @return string[] + */ + private function readLanguages(Request $request): array + { + $languages = []; + foreach (AcceptHeader::fromString($request->headers->get('Accept-Language'))->all() as $item) { + $language = (string)$item->getValue(); + if (strpos($language, '-') !== false) { + $codes = explode('-', $language); + if ($codes[0] === 'i') { + $language = $codes[1]; + } else { + $language = strtolower($codes[0]); + for ($i = 1, $count = count($codes); $i < $count; $i++) { + $language .= '_' . strtoupper($codes[$i]); + } + } + } + $languages[] = $language; + } + + return $languages; + } } 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/RoutingLoader/RoutingAttributeLoader.php b/src/Service/RoutingLoader/RoutingAttributeLoader.php index 955f27d..1774270 100644 --- a/src/Service/RoutingLoader/RoutingAttributeLoader.php +++ b/src/Service/RoutingLoader/RoutingAttributeLoader.php @@ -6,6 +6,7 @@ use Paysera\Bundle\ApiBundle\Annotation\RestAnnotationInterface; use Paysera\Bundle\ApiBundle\Attribute\RestAttributeInterface; +use Paysera\Bundle\ApiBundle\Exception\ConfigurationException; use Paysera\Bundle\ApiBundle\Service\RestRequestHelper; use ReflectionClass; use ReflectionMethod; @@ -17,6 +18,16 @@ */ class RoutingAttributeLoader extends AttributeRouteControllerLoader { + private const ANNOTATION_NAMES = [ + 'Body', + 'BodyContentType', + 'PathAttribute', + 'Query', + 'RequiredPermissions', + 'ResponseNormalization', + 'Validation', + ]; + /** * @var RestRequestHelper */ @@ -59,8 +70,17 @@ protected function configureRoute( $this->loadAttributes($route, $class, $method); } + /** + * @throws ConfigurationException + */ private function loadAnnotations(Route $route, ReflectionClass $class, ReflectionMethod $method): void { + if (!property_exists($this, 'reader')) { + $this->refuseDocblockAnnotations($class, $method); + + return; + } + if (!isset($this->reader)) { return; } @@ -88,6 +108,27 @@ private function loadAnnotations(Route $route, ReflectionClass $class, Reflectio ); } + /** + * @throws ConfigurationException + */ + private function refuseDocblockAnnotations(ReflectionClass $class, ReflectionMethod $method): void + { + $pattern = '/(?getDocComment() . $method->getDocComment(), $matches); + $names = array_values(array_unique($matches[1])); + if ($names === []) { + return; + } + + throw new ConfigurationException(sprintf( + '%s::%s() uses docblock annotations of paysera/lib-api-bundle (@%s), which Symfony 7 does not read. ' + . 'Use the attributes of the same name from Paysera\\Bundle\\ApiBundle\\Attribute instead.', + $class->getName(), + $method->getName(), + implode(', @', $names) + )); + } + private function loadAttributes(Route $route, ReflectionClass $class, ReflectionMethod $method): void { $attributes = array_merge($class->getAttributes(), $method->getAttributes()); 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/Controller/Attribute/AttributedPagedQueryController.php b/tests/Functional/Fixtures/FixtureTestBundle/Controller/Attribute/AttributedPagedQueryController.php new file mode 100644 index 0000000..5d5e77b --- /dev/null +++ b/tests/Functional/Fixtures/FixtureTestBundle/Controller/Attribute/AttributedPagedQueryController.php @@ -0,0 +1,34 @@ +entityManager = $entityManager; + } + + #[Route(path: '/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..1fcbc06 --- /dev/null +++ b/tests/Functional/Fixtures/FixtureTestBundle/Controller/Attribute/AttributedPersistedEntityController.php @@ -0,0 +1,27 @@ +getId()); + } + + #[Route(path: '/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/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/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.xml b/tests/Functional/Fixtures/FixtureTestBundle/Resources/config/explicit_routing.xml new file mode 100644 index 0000000..64b6faf --- /dev/null +++ b/tests/Functional/Fixtures/FixtureTestBundle/Resources/config/explicit_routing.xml @@ -0,0 +1,49 @@ + + + + + + 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/routing.xml b/tests/Functional/Fixtures/FixtureTestBundle/Resources/config/routing.xml index 3a06c43..3118327 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..9fe4ff7 --- /dev/null +++ b/tests/Functional/Fixtures/config/sf7_common.yml @@ -0,0 +1,17 @@ +framework: + router: + resource: '%kernel.project_dir%/tests/Functional/Fixtures/config/sf7_routing.yml' + validation: + email_validation_mode: html5 + +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..2b876f8 --- /dev/null +++ b/tests/Functional/Fixtures/config/sf7_routing.yml @@ -0,0 +1,7 @@ +paysera_fixture_test: + resource: "@PayseraFixtureTestBundle/Resources/config/explicit_routing.xml" + prefix: / + +paysera_fixture_attributed_test: + resource: '@PayseraFixtureTestBundle/Resources/config/attributed_routing.xml' + prefix: / diff --git a/tests/Functional/FunctionalAnnotationsTest.php b/tests/Functional/FunctionalAnnotationsTest.php index 41b6f7f..bd04693 100644 --- a/tests/Functional/FunctionalAnnotationsTest.php +++ b/tests/Functional/FunctionalAnnotationsTest.php @@ -24,7 +24,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,7 +38,7 @@ public function testAnnotatedRestRequestConfiguration( public function testAttributedRestRequestConfiguration( Response $expectedResponse, Request $request, - Response $extraResponseVersion = null + ?Response $extraResponseVersion = null ) { $this->makeTest('attributed', $expectedResponse, $request, $extraResponseVersion); } @@ -47,11 +47,14 @@ 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'); } + if ($pathPrefix === 'annotated' && !TestHelper::docblockRoutingSupportExists()) { + $this->markTestSkipped('Symfony 7 reads no @Route docblocks'); + } $request->server->set( 'REQUEST_URI', @@ -177,6 +180,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/FunctionalTestCase.php b/tests/Functional/FunctionalTestCase.php index 0a1f80e..58f14e2 100644 --- a/tests/Functional/FunctionalTestCase.php +++ b/tests/Functional/FunctionalTestCase.php @@ -5,6 +5,7 @@ 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; @@ -31,7 +32,12 @@ 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(); @@ -57,9 +63,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/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..b56a774 100644 --- a/tests/Unit/Listener/LocaleListenerTest.php +++ b/tests/Unit/Listener/LocaleListenerTest.php @@ -8,10 +8,12 @@ use Paysera\Bundle\ApiBundle\Listener\LocaleListener; use Paysera\Bundle\ApiBundle\Service\RestRequestHelper; use Paysera\Bundle\ApiBundle\Tests\Unit\Helper\HttpKernelHelper; +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 +26,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 string[] $locales + */ + private function resolveLocale(array $locales, string $acceptLanguage, bool $rest): string { $helper = Mockery::mock(RestRequestHelper::class); $kernel = Mockery::mock(HttpKernelInterface::class); @@ -42,7 +63,7 @@ public function testOnKernelRequest(string $expectedLocale, array $locales, stri $listener->onKernelRequest($event); - $this->assertSame($expectedLocale, $request->getLocale()); + return $request->getLocale(); } public function provider() @@ -96,6 +117,78 @@ public function provider() '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, en;q=0.5', + true, + ], + 'primary language is not added when the header lists it' => [ + 'de', + ['de', 'en'], + 'en-US, en;q=0.5, de;q=0.9', + 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 tag without a region keeps its case, as Symfony 3.4 to 7.0 read it' => [ + 'unchanged', + ['de'], + 'DE', + true, + ], + 'a wrong-case tag does not match before a matching one' => [ + 'de', + ['en', 'lt', 'de'], + 'EN,de', + true, + ], + 'a numeric tag is not a language' => [ + 'unchanged', + ['de'], + '1', + true, + ], + 'a numeric tag next to a language' => [ + 'de', + ['de'], + 'de, 1', + true, + ], + 'a language registered with the i- prefix' => [ + 'cherokee', + ['en', 'cherokee'], + 'i-cherokee', + true, + ], + 'a malformed item is not a language' => [ + 'unchanged', + ['de'], + "'", + true, + ], + 'a malformed item next to a language' => [ + 'de', + ['de'], + "de, '", + true, + ], ]; } } 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/Fixtures/DocblockOptionsOnAttributeRouteController.php b/tests/Unit/Service/RoutingLoader/Fixtures/DocblockOptionsOnAttributeRouteController.php new file mode 100644 index 0000000..384715d --- /dev/null +++ b/tests/Unit/Service/RoutingLoader/Fixtures/DocblockOptionsOnAttributeRouteController.php @@ -0,0 +1,19 @@ +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/RoutingAttributeLoaderTest.php b/tests/Unit/Service/RoutingLoader/RoutingAttributeLoaderTest.php new file mode 100644 index 0000000..8a5d1fd --- /dev/null +++ b/tests/Unit/Service/RoutingLoader/RoutingAttributeLoaderTest.php @@ -0,0 +1,32 @@ +markTestSkipped('Symfony 6.4 and older read docblock annotations through an annotation reader'); + } + + $this->expectException(ConfigurationException::class); + $this->expectExceptionMessage( + DocblockOptionsOnAttributeRouteController::class . '::show() uses docblock annotations of ' + . 'paysera/lib-api-bundle (@RequiredPermissions), which Symfony 7 does not read. Use the attributes of the ' + . 'same name from Paysera\Bundle\ApiBundle\Attribute instead.' + ); + + (new RoutingAttributeLoader())->load(DocblockOptionsOnAttributeRouteController::class); + } +}