Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
16 commits
Select commit Hold shift + click to select a range
96eb2b2
Allow Symfony 7.4 and run CI on PHP 8.4, Symfony 7 and lowest depende…
vinayak-iyer-paysera Oct 20, 2026
53cb92d
Declare the TreeBuilder return type and explicit nullable parameters
vinayak-iyer-paysera Oct 20, 2026
8046707
Register the services in PHP instead of XML
vinayak-iyer-paysera Oct 20, 2026
276e75f
Mark "no locale matched" with a comma placeholder in LocaleListener
vinayak-iyer-paysera Oct 20, 2026
ba4940c
Read docblock annotations with Doctrine's reader where Symfony inject…
vinayak-iyer-paysera Oct 20, 2026
827116a
Document 2.0.0 and attribute route imports for Symfony 7
vinayak-iyer-paysera Oct 20, 2026
5835363
Fail on a bundle attribute name whose class does not exist
vinayak-iyer-paysera Oct 22, 2026
1383b20
Test namespaced attribute names and say which classes cannot be loaded
vinayak-iyer-paysera Oct 22, 2026
c37e156
Hint at the import only when the bundle's attribute is meant
vinayak-iyer-paysera Oct 22, 2026
f2e782c
Say what the name must resolve to, and test the class attribute alone
vinayak-iyer-paysera Oct 22, 2026
d3c1a6e
Revert the short-name attribute check
vinayak-iyer-paysera Oct 22, 2026
320bd5c
Fail on a bundle attribute written without its use statement
vinayak-iyer-paysera Oct 23, 2026
0639f49
Refuse docblock annotations where Symfony passes no annotation reader
vinayak-iyer-paysera Oct 23, 2026
7708e60
Match docblock tags as Doctrine's lexer does, and declare ext-tokenizer
vinayak-iyer-paysera Oct 23, 2026
2667bc9
Join names split at non-ASCII bytes too, and narrow the alias remedy
vinayak-iyer-paysera Oct 26, 2026
2877c09
Match aliases as Doctrine does, and pass prose and dashed names
vinayak-iyer-paysera Oct 26, 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
26 changes: 23 additions & 3 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
78 changes: 78 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,84 @@ 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 `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
- `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_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
- `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. 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 (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), 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
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, 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
- CI allows packages with security advisories, so Symfony 3.4 and 4.4 jobs can install dependencies with Composer 2.10
Expand Down
69 changes: 63 additions & 6 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
<?php
declare(strict_types=1);

use Symfony\Component\Routing\Annotation\Route;
use Symfony\Component\Routing\Attribute\Route;
use Paysera\Bundle\ApiBundle\Attribute\Body;

class ApiController
Expand All @@ -173,20 +176,37 @@ class ApiController
}
```

Don't forget to also import your controller (or `Controller` directory) into routing configuration. For example:
Don't forget to also import your controller (or `Controller` directory) into routing configuration, once: an import of
each type below loads the same controllers twice. Up to Symfony 6.0, for example:

```yaml
acme_something:
resource: "@AcmeSomethingBundle/Controller/"
type: annotation
prefix: /rest/v1/
```

```xml
<!-- Resources/config/routing.xml -->
<import resource="../../Controller/" type="annotation" prefix="/rest/v1/"/>
```

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
<!-- Resources/config/routing.xml -->
<import resource="../../Controller/" type="attribute" prefix="/rest/v1/"/>
```

This also requires that your controller's service ID would be the same as its FQCN.

#### HTTP example
Expand Down Expand Up @@ -257,7 +277,7 @@ Controller example using attributes:
<?php
declare(strict_types=1);

use Symfony\Component\Routing\Annotation\Route;
use Symfony\Component\Routing\Attribute\Route;
use Paysera\Bundle\ApiBundle\Attribute\PathAttribute;

class ApiController
Expand Down Expand Up @@ -395,7 +415,7 @@ Controller example using attributes:
<?php
declare(strict_types=1);

use Symfony\Component\Routing\Annotation\Route;
use Symfony\Component\Routing\Attribute\Route;
use Paysera\Bundle\ApiBundle\Attribute\Query;
use Paysera\Pagination\Entity\Pager;
use Paysera\Bundle\ApiBundle\Entity\PagedQuery;
Expand Down Expand Up @@ -645,6 +665,43 @@ 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 as a PHP attribute of
the same name (`Paysera\Bundle\ApiBundle\Attribute\*`) taking the same options by name:
`@RequiredPermissions(permissions={"ROLE_ADMIN"})` becomes `#[RequiredPermissions(permissions: ['ROLE_ADMIN'])]`.
The attributes need PHP 8 and are applied on every supported Symfony version since 2.0.0 (1.8 applies them only from
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 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 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 (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), 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`

Instructs to convert request body into an object and pass to the controller as an argument.
Expand Down
24 changes: 16 additions & 8 deletions composer.json
Original file line number Diff line number Diff line change
Expand Up @@ -17,24 +17,32 @@
"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",
"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",
"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"
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
Loading
Loading