Skip to content

Commit

Permalink
Add PHP 8 attributes support (#15)
Browse files Browse the repository at this point in the history
* Add PHP 8 attributes support

* Update tests

* Refactor annotations

* Refactor structure

* Refactor test controllers

* Add tests for attributes

* Modify PHPUnit version

* Configure tests

* Handle deprecations

* Update attributed test controllers

* handle BCs

* remove PHP version checks

* add legacy Paysera\Bundle\ApiBundle\Service\Annotation\ReflectionMethodWrapper to resolve the BC

* add BC-related improvements

* update README

---------

Co-authored-by: Zakhar Shokel <[email protected]>
  • Loading branch information
fiveight00 and Zakhar Shokel authored Sep 11, 2024
1 parent eaf1e1d commit dc7601c
Show file tree
Hide file tree
Showing 57 changed files with 1,686 additions and 904 deletions.
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,11 @@ 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.8.0]
### Added
- Support for PHP 8 attributes
- Support for `doctrine/annotations: ^2.0`

## [1.7.0]
### Added
- Support for Symfony 6.4
Expand Down
102 changes: 89 additions & 13 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -114,10 +114,10 @@ class UserNormalizer implements ObjectDenormalizerInterface, NormalizerInterface

In this case you'd also need to implement normalizer for `Address` class.

It's easiest to configure REST endpoints using annotations. This requires your routing to be provided in
controller annotations, too.
It's easiest to configure REST endpoints using annotations/attributes. This requires your routing to be provided in
controller annotations/attributes, too.

Controller example:
Controller example using annotations:
```php
<?php
declare(strict_types=1);
Expand Down Expand Up @@ -147,6 +147,31 @@ class ApiController
}
}
```
Controller example using attributes:
```php
<?php
declare(strict_types=1);

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

class ApiController
{
// ...

#[Route(path: '/users', methods: 'POST')]
#[Body(parameterName: 'user')]
public function register(User $user): User
{
$this->securityChecker->checkPermissions(Permissions::REGISTER_USER, $user);

$this->userManager->registerUser($user);
$this->entityManager->flush();

return $user;
}
}
```

Don't forget to also import your controller (or `Controller` directory) into routing configuration. For example:

Expand Down Expand Up @@ -199,7 +224,7 @@ Content-Type: application/json

### Fetching resource

Controller example:
Controller example using annotations:
```php
<?php
declare(strict_types=1);
Expand Down Expand Up @@ -227,6 +252,29 @@ class ApiController
}
```

Controller example using attributes:
```php
<?php
declare(strict_types=1);

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

class ApiController
{
// ...

#[Route(path: '/users/{userId}', methods: 'GET')]
#[PathAttribute(parameterName: 'user', pathPartName: 'userId')]
public function getUser(User $user): User
{
$this->securityChecker->checkPermissions(Permissions::ACCESS_USER, $user);

return $user;
}
}
```

For path attributes `PathAttributeResolverInterface` should be implemented, as in this
case we receive just a scalar type (ID), not an object.

Expand Down Expand Up @@ -308,7 +356,7 @@ Content-Type: application/json

### Fetching list of resources

Controller example:
Controller example using annotations:
```php
<?php
declare(strict_types=1);
Expand Down Expand Up @@ -342,6 +390,34 @@ class ApiController
}
```

Controller example using attributes:
```php
<?php
declare(strict_types=1);

use Symfony\Component\Routing\Annotation\Route;
use Paysera\Bundle\ApiBundle\Attribute\Query;
use Paysera\Pagination\Entity\Pager;
use Paysera\Bundle\ApiBundle\Entity\PagedQuery;

class ApiController
{
// ...

#[Route(path: '/users', methods: 'GET')]
#[Query(parameterName: 'filter')]
#[Query(parameterName: 'pager')]
public function getUsers(UserFilter $filter, Pager $pager): PagedQuery
{
$this->securityChecker->checkPermissions(Permissions::SEARCH_USERS, $filter);

$configuredQuery = $this->userRepository->buildConfiguredQuery($filter);

return new PagedQuery($configuredQuery, $pager);
}
}
```

Denormalizer for `UserFilter`:
```php
<?php
Expand Down Expand Up @@ -567,7 +643,7 @@ Host: api.example.com
In this case, if there would be zero results, `_metadata.cursors.before` would still be the same. Saving last
cursor and iterating this way until we have `"has_previous": false` is a reliable way to synchronize resources.

## Annotations reference
## Annotations/Attributes reference

### `Body`

Expand All @@ -591,7 +667,7 @@ to denormalizer.

If not configured, defaults to JSON-encoded body and 2 allowed Content-Type values: `""` (empty) and `"application/json"`.

For this annotation to have any effect, `Body` annotation must be present. Provide `plain` as `denormalizationType`
For this annotation/attribute to have any effect, `Body` annotation/attribute must be present. Provide `plain` as `denormalizationType`
if you want denormalization process to be skipped.

### `Validation`
Expand All @@ -601,7 +677,7 @@ By default, validation is always enabled.

You can turn it off for an action or whole controller class.

If annotation is provided on both class and action, the one on action "wins" – they are not merged together.
If annotation/attribute is provided on both class and action, the one on action "wins" – they are not merged together.

| Option name | Default value | Description |
|--------------------|---------------|------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
Expand All @@ -625,7 +701,7 @@ If nothing is returned from the method (`void`), empty response with HTTP status

Configures denormalization for some concrete part of the path. Usually used to find entities by their IDs.

Multiple such annotations can be used in a single controller's action.
Multiple such annotations/attributes can be used in a single controller's action.

| Option name | Default value | Description |
|-----------------------|------------------------------------------|--------------------------------------------------------------------------------------------------------------------------------|
Expand All @@ -638,26 +714,26 @@ Multiple such annotations can be used in a single controller's action.

Instructs to convert query string into an object and pass to the controller as an argument.

Multiple annotations can be used to map several different objects.
Multiple annotations/attributes can be used to map several different objects.

| Option name | Default value | Description |
|-----------------------|-----------------------------------|--------------------------------------------------------------------------------------------------------------------------------|
| `parameterName` | Required | Specifies parameter name (without `$` in controller action for passing denormalized data |
| `denormalizationType` | Guessed from type-hint | Allows configuring custom denormalization type to use. Registered denormalizer must implement `MixedTypeDenormalizerInterface` |
| `validation` | Enabled with `['Default']` groups | Use another `@Validation` annotation here, just like when configuring validation for request body |
| `validation` | Enabled with `['Default']` groups | Use another `@Validation` annotation/attribute here, just like when configuring validation for request body |

### `RequiredPermissions`

Instructs to check for permissions in security context for that specific action.

Could be also added in the class level.
Permissions from class and method level annotations are merged together.
Permissions from class and method level annotations/attributes are merged together.

| Option name | Default value | Description |
|---------------|---------------|--------------------------------------------------------------------------|
| `permissions` | Required | List of permissions to be checked before any denormalization takes place |

## Configuration without using annotations
## Configuration without using annotations/attributes

It's also possible to configure options defining `RestRequestOptions` as a service
and tagging it with `paysera_api.request_options`.
Expand Down
4 changes: 2 additions & 2 deletions composer.json
Original file line number Diff line number Diff line change
Expand Up @@ -27,10 +27,10 @@
"paysera/lib-dependency-injection": "^1.3.0",
"psr/log": "^1.0|^2.0",
"doctrine/persistence": "^1.3.8 || ^2.0.1 || ^3.0",
"doctrine/annotations": "^v1.14"
"doctrine/annotations": "^1.14 || ^2.0"
},
"require-dev": {
"phpunit/phpunit": "^7.5 || ^9.5",
"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",
Expand Down
110 changes: 2 additions & 108 deletions src/Annotation/Body.php
Original file line number Diff line number Diff line change
Expand Up @@ -3,122 +3,16 @@

namespace Paysera\Bundle\ApiBundle\Annotation;

use Paysera\Bundle\ApiBundle\Entity\RestRequestOptions;
use Paysera\Bundle\ApiBundle\Exception\ConfigurationException;
use Paysera\Bundle\ApiBundle\Service\Annotation\ReflectionMethodWrapper;
use Paysera\Bundle\ApiBundle\Attribute\Body as BodyAttribute;

/**
* @Annotation
* @Target({"METHOD"})
*/
class Body implements RestAnnotationInterface
class Body extends BodyAttribute implements RestAnnotationInterface
{
/**
* @var string
*/
private $parameterName;

/**
* @var string|null
*/
private $denormalizationType;

/**
* @var string|null
*/
private $denormalizationGroup;

/**
* @var bool|null
*/
private $optional;

public function __construct(array $options)
{
$this->setParameterName($options['parameterName']);
$this->setDenormalizationType($options['denormalizationType'] ?? null);
$this->setDenormalizationGroup($options['denormalizationGroup'] ?? null);
$this->setOptional($options['optional'] ?? null);
}

/**
* @param string|null $denormalizationType
* @return $this
*/
private function setDenormalizationType($denormalizationType): self
{
$this->denormalizationType = $denormalizationType;
return $this;
}

/**
* @param string|null $denormalizationGroup
* @return $this
*/
public function setDenormalizationGroup($denormalizationGroup): self
{
$this->denormalizationGroup = $denormalizationGroup;
return $this;
}

/**
* @param string $parameterName
* @return $this
*/
private function setParameterName(string $parameterName): self
{
$this->parameterName = $parameterName;
return $this;
}

/**
* @param bool|null $optional
* @return $this
*/
private function setOptional($optional): self
{
$this->optional = $optional;
return $this;
}

public function isSeveralSupported(): bool
{
return false;
}

public function apply(RestRequestOptions $options, ReflectionMethodWrapper $reflectionMethod)
{
$options->setBodyParameterName($this->parameterName);
$options->setBodyDenormalizationType($this->resolveDenormalizationType($reflectionMethod));
$options->setBodyDenormalizationGroup($this->denormalizationGroup);
$options->setBodyOptional($this->resolveIfBodyIsOptional($reflectionMethod));
}

private function resolveDenormalizationType(ReflectionMethodWrapper $reflectionMethod): string
{
if ($this->denormalizationType !== null) {
return $this->denormalizationType;
}

try {
$typeName = $reflectionMethod->getNonBuiltInTypeForParameter($this->parameterName);
} catch (ConfigurationException $exception) {
throw new ConfigurationException(sprintf(
'Denormalization type could not be guessed for %s in %s',
'$' . $this->parameterName,
$reflectionMethod->getFriendlyName()
));
}

return $typeName;
}

private function resolveIfBodyIsOptional(ReflectionMethodWrapper $reflectionMethod): bool
{
if ($this->optional !== null) {
return $this->optional;
}

return $reflectionMethod->getParameterByName($this->parameterName)->isDefaultValueAvailable();
}
}
Loading

0 comments on commit dc7601c

Please sign in to comment.