Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
43 commits
Select commit Hold shift + click to select a range
22ca186
EE-283 Allow Symfony 7.4 and require every Symfony component the bund…
vinayak-iyer-paysera Sep 23, 2026
50f98a2
EE-283 Run the workflow on Symfony 7
vinayak-iyer-paysera Sep 23, 2026
9557172
EE-283 Let the test application run on Symfony 7
vinayak-iyer-paysera Sep 23, 2026
669f40a
EE-283 Declare the return types Symfony 7 and 8 require
vinayak-iyer-paysera Sep 23, 2026
0d1924a
EE-283 Pick the same locale from Accept-Language on every Symfony line
vinayak-iyer-paysera Sep 23, 2026
bc4d6ed
EE-283 Refuse the bundle's docblock annotations on Symfony 7
vinayak-iyer-paysera Sep 23, 2026
18b949a
EE-283 Declare the optional parameters nullable explicitly
vinayak-iyer-paysera Sep 23, 2026
7490f2f
EE-283 Add the 1.9.0 changelog entry
vinayak-iyer-paysera Sep 23, 2026
ed648ff
EE-283 Pass the permissions to the attribute by name in the loader test
vinayak-iyer-paysera Sep 23, 2026
aabffbe
EE-283 Cover the error mapping and the untested branches
vinayak-iyer-paysera Sep 23, 2026
5cd009c
EE-283 Clear the query validation options before reading them in the …
vinayak-iyer-paysera Sep 23, 2026
5927f2a
EE-283 Drop the finder's unreachable branch for classes without a file
vinayak-iyer-paysera Sep 23, 2026
75c78b9
EE-283 Find the bundle's docblock annotations wherever Doctrine's rea…
vinayak-iyer-paysera Sep 23, 2026
1be2b39
EE-283 Read Accept-Language the way Symfony 3.4 to 7.0 did
vinayak-iyer-paysera Sep 23, 2026
07e8bd5
EE-283 Choose the test application's Symfony 7 setup by what routing …
vinayak-iyer-paysera Sep 23, 2026
4d886f1
EE-283 Set the email validation mode Symfony 7 uses in the Symfony 7 …
vinayak-iyer-paysera Sep 23, 2026
8340227
EE-283 Make the new tests check the bundle's own configuration and re…
vinayak-iyer-paysera Sep 23, 2026
21590eb
EE-283 Say in the README how endpoints are configured on Symfony 7
vinayak-iyer-paysera Sep 23, 2026
cbfb8e3
EE-283 Find docblock annotations on Symfony 7 where Doctrine's reader…
vinayak-iyer-paysera Sep 23, 2026
c768720
EE-283 Keep the locale listener from failing on a malformed Accept-La…
vinayak-iyer-paysera Sep 23, 2026
42b4009
EE-283 Check every error code the bundle configures
vinayak-iyer-paysera Sep 23, 2026
b51b664
EE-283 Say that the locale listener keeps the rule, not every result,…
vinayak-iyer-paysera Sep 23, 2026
c2263a9
EE-283 Test the rest of the Symfony 7 check's rules against Doctrine'…
vinayak-iyer-paysera Sep 24, 2026
aaeb60d
EE-283 Name the finder's quoted-string pattern once and say when "-" …
vinayak-iyer-paysera Sep 24, 2026
30b7972
EE-283 Test an Accept-Language item that is only a semicolon
vinayak-iyer-paysera Sep 24, 2026
df9b48f
EE-283 Skip the semicolon-item test wherever Symfony's parser fails o…
vinayak-iyer-paysera Sep 24, 2026
4428688
EE-283 Keep reading where Doctrine's reader keeps reading, and test t…
vinayak-iyer-paysera Sep 24, 2026
c2f16da
EE-283 Check the configured status of internal_server_error
vinayak-iyer-paysera Sep 24, 2026
8eda65e
EE-283 Skip the semicolon-item test on Symfony 4.4 only
vinayak-iyer-paysera Sep 24, 2026
7bf10ab
EE-283 Read annotation names the way Doctrine's parser joins and reso…
vinayak-iyer-paysera Sep 24, 2026
7093f41
EE-283 Test that a name resolves in the namespace before a PHP class …
vinayak-iyer-paysera Sep 24, 2026
8c14bcd
EE-283 Read on after any name Doctrine may ignore, and test the name-…
vinayak-iyer-paysera Sep 24, 2026
16a40b5
EE-283 Key the semicolon-item test's skip on the parser, not on a ver…
vinayak-iyer-paysera Sep 24, 2026
7de29b6
EE-283 Read a docblock as doctrine/lexer 1.0 did where it reads more
vinayak-iyer-paysera Sep 24, 2026
aefcdf4
EE-283 Test the annotation preload under a directory with brackets in…
vinayak-iyer-paysera Sep 24, 2026
288add1
EE-283 Merge master with the CI workflow
vinayak-iyer-paysera Sep 25, 2026
38a3ad6
EE-283 Rework the new tests to the maintainer's review standard
vinayak-iyer-paysera Sep 25, 2026
a11829b
EE-283 Run the workflow jobs with lowest dependencies too
vinayak-iyer-paysera Sep 28, 2026
ee37629
EE-283 Expect the bundle's docblock annotations to be ignored without…
vinayak-iyer-paysera Sep 28, 2026
6552ec2
EE-283 Drop the refusal of docblock annotations on Symfony 7
vinayak-iyer-paysera Sep 28, 2026
e88c638
EE-283 Keep only the tests for the code this pull request changes
vinayak-iyer-paysera Sep 28, 2026
3e21255
EE-283 Test that Symfony 7 refuses a route configured by the bundle's…
vinayak-iyer-paysera Sep 28, 2026
9f13c4c
EE-283 Refuse the bundle's docblock annotations on Symfony 7 by name
vinayak-iyer-paysera Sep 28, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 16 additions & 2 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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.*' }
Expand All @@ -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
Expand Down
22 changes: 22 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
6 changes: 6 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
19 changes: 13 additions & 6 deletions composer.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
2 changes: 1 addition & 1 deletion src/Attribute/Body.php
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion src/Attribute/BodyContentType.php
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
4 changes: 2 additions & 2 deletions src/Attribute/PathAttribute.php
Original file line number Diff line number Diff line change
Expand Up @@ -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
) {
Expand Down
2 changes: 1 addition & 1 deletion src/Attribute/Query.php
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion src/Attribute/RequiredPermissions.php
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ class RequiredPermissions implements RestAttributeInterface

public function __construct(
array $options = [],
array $permissions = null
?array $permissions = null
) {
$this->setPermissions($options['permissions'] ?? $permissions);
}
Expand Down
2 changes: 1 addition & 1 deletion src/DependencyInjection/Configuration.php
Original file line number Diff line number Diff line change
Expand Up @@ -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')
Expand Down
2 changes: 1 addition & 1 deletion src/DependencyInjection/PayseraApiExtension.php
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
2 changes: 1 addition & 1 deletion src/Entity/QueryResolverOptions.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
2 changes: 1 addition & 1 deletion src/Exception/ApiException.php
Original file line number Diff line number Diff line change
Expand Up @@ -60,7 +60,7 @@ public function __construct(
$errorCode,
$message = null,
$statusCode = null,
Exception $previous = null,
?Exception $previous = null,
$properties = null,
$data = null,
array $violations = []
Expand Down
49 changes: 44 additions & 5 deletions src/Listener/LocaleListener.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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;
}
}
2 changes: 1 addition & 1 deletion src/Listener/RestExceptionListener.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
41 changes: 41 additions & 0 deletions src/Service/RoutingLoader/RoutingAttributeLoader.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -17,6 +18,16 @@
*/
class RoutingAttributeLoader extends AttributeRouteControllerLoader
{
private const ANNOTATION_NAMES = [
'Body',
'BodyContentType',
'PathAttribute',
'Query',
'RequiredPermissions',
'ResponseNormalization',
'Validation',
];

/**
* @var RestRequestHelper
*/
Expand Down Expand Up @@ -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;
}
Expand Down Expand Up @@ -88,6 +108,27 @@ private function loadAnnotations(Route $route, ReflectionClass $class, Reflectio
);
}

/**
* @throws ConfigurationException
*/
private function refuseDocblockAnnotations(ReflectionClass $class, ReflectionMethod $method): void
{
$pattern = '/(?<!\w)@(?:[\w\\\\]+\\\\)?(' . implode('|', self::ANNOTATION_NAMES) . ')(?![\w\\\\])/';
preg_match_all($pattern, $class->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());
Expand Down
4 changes: 2 additions & 2 deletions src/Service/Validation/EntityValidator.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
<?php
declare(strict_types=1);

namespace Paysera\Bundle\ApiBundle\Tests\Functional\Fixtures\FixtureTestBundle\Controller\Attribute;

use Doctrine\ORM\EntityManagerInterface;
use Paysera\Bundle\ApiBundle\Attribute\Query;
use Paysera\Bundle\ApiBundle\Entity\PagedQuery;
use Paysera\Bundle\ApiBundle\Tests\Functional\Fixtures\FixtureTestBundle\Entity\PersistedEntity;
use Paysera\Bundle\ApiBundle\Tests\Functional\Fixtures\FixtureTestBundle\Entity\PersistedEntityFilter;
use Paysera\Bundle\ApiBundle\Tests\Functional\Fixtures\FixtureTestBundle\Repository\PersistedEntityRepository;
use Paysera\Pagination\Entity\Pager;
use Symfony\Component\Routing\Annotation\Route;

class AttributedPagedQueryController
{
private $entityManager;

public function __construct(EntityManagerInterface $entityManager)
{
$this->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);
}
}
Loading
Loading