Skip to content

Commit

Permalink
Merge pull request #285 from dotkernel/issue-169
Browse files Browse the repository at this point in the history
Implemented API evolution pattern
  • Loading branch information
arhimede committed Jun 17, 2024
2 parents 33f5edf + a67d50b commit 7f27974
Show file tree
Hide file tree
Showing 13 changed files with 516 additions and 1 deletion.
2 changes: 2 additions & 0 deletions config/pipeline.php
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
use Api\App\Middleware\AuthenticationMiddleware;
use Api\App\Middleware\AuthorizationMiddleware;
use Api\App\Middleware\ContentNegotiationMiddleware;
use Api\App\Middleware\DeprecationMiddleware;
use Dot\ErrorHandler\ErrorHandlerInterface;
use Dot\ResponseHeader\Middleware\ResponseHeaderMiddleware;
use Mezzio\Application;

Check warning on line 12 in config/pipeline.php

View workflow job for this annotation

GitHub Actions / Qodana for PHP

Undefined class

Undefined class 'Application'
Expand Down Expand Up @@ -61,6 +62,7 @@
$app->pipe(MethodNotAllowedMiddleware::class);

$app->pipe(ContentNegotiationMiddleware::class);
$app->pipe(DeprecationMiddleware::class);

$app->pipe(ResponseHeaderMiddleware::class);

Expand Down
23 changes: 23 additions & 0 deletions src/App/src/Attribute/MethodDeprecation.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
<?php

declare(strict_types=1);

namespace Api\App\Attribute;

use Api\App\Exception\DeprecationSunsetException;
use Api\App\Message;
use Attribute;
use Laminas\Validator\Date;

use function sprintf;

#[Attribute(Attribute::TARGET_METHOD)]
final readonly class MethodDeprecation
{
public function __construct(public string $sunset, public string $link, public string $deprecationReason = '')
{
if (! (new Date())->isValid($sunset)) {

Check warning on line 19 in src/App/src/Attribute/MethodDeprecation.php

View workflow job for this annotation

GitHub Actions / Qodana for PHP

Undefined class

Undefined class 'Date'
throw new DeprecationSunsetException(sprintf(Message::INVALID_VALUE, 'sunset'));
}
}
}
23 changes: 23 additions & 0 deletions src/App/src/Attribute/ResourceDeprecation.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
<?php

declare(strict_types=1);

namespace Api\App\Attribute;

use Api\App\Exception\DeprecationSunsetException;
use Api\App\Message;
use Attribute;
use Laminas\Validator\Date;

use function sprintf;

#[Attribute(Attribute::TARGET_CLASS)]
final readonly class ResourceDeprecation
{
public function __construct(public string $sunset, public string $link, public string $deprecationReason = '')
{
if (! (new Date())->isValid($sunset)) {

Check warning on line 19 in src/App/src/Attribute/ResourceDeprecation.php

View workflow job for this annotation

GitHub Actions / Qodana for PHP

Undefined class

Undefined class 'Date'
throw new DeprecationSunsetException(sprintf(Message::INVALID_VALUE, 'sunset'));
}
}
}
2 changes: 2 additions & 0 deletions src/App/src/ConfigProvider.php
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@
use Api\App\Middleware\AuthenticationMiddleware;
use Api\App\Middleware\AuthorizationMiddleware;
use Api\App\Middleware\ContentNegotiationMiddleware;
use Api\App\Middleware\DeprecationMiddleware;
use Api\App\Middleware\ErrorResponseMiddleware;
use Api\App\Service\ErrorReportService;
use Api\App\Service\ErrorReportServiceInterface;
Expand Down Expand Up @@ -67,6 +68,7 @@ public function getDependencies(): array
AuthenticationMiddleware::class => AuthenticationMiddlewareFactory::class,
AuthorizationMiddleware::class => AttributedServiceFactory::class,

Check warning on line 69 in src/App/src/ConfigProvider.php

View workflow job for this annotation

GitHub Actions / Qodana for PHP

Undefined class

Undefined class 'AttributedServiceFactory'
ContentNegotiationMiddleware::class => AttributedServiceFactory::class,
DeprecationMiddleware::class => AttributedServiceFactory::class,
Environment::class => TwigEnvironmentFactory::class,
TwigExtension::class => TwigExtensionFactory::class,
TwigRenderer::class => TwigRendererFactory::class,

Check warning on line 74 in src/App/src/ConfigProvider.php

View workflow job for this annotation

GitHub Actions / Qodana for PHP

Undefined class

Undefined class 'TwigRenderer'
Expand Down
11 changes: 11 additions & 0 deletions src/App/src/Exception/DeprecationConflictException.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
<?php

declare(strict_types=1);

namespace Api\App\Exception;

use RuntimeException;

class DeprecationConflictException extends RuntimeException
{
}
11 changes: 11 additions & 0 deletions src/App/src/Exception/DeprecationSunsetException.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
<?php

declare(strict_types=1);

namespace Api\App\Exception;

use RuntimeException;

class DeprecationSunsetException extends RuntimeException
{
}
6 changes: 6 additions & 0 deletions src/App/src/Handler/ErrorReportHandler.php
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@

namespace Api\App\Handler;

use Api\App\Attribute\MethodDeprecation;
use Api\App\Exception\ForbiddenException;
use Api\App\Message;
use Api\App\Service\ErrorReportServiceInterface;
Expand Down Expand Up @@ -38,6 +39,11 @@ public function __construct(
* @throws ForbiddenException
* @throws RuntimeException
*/
#[MethodDeprecation(
sunset: '2038-01-01',
link: 'https://docs.dotkernel.org/api-documentation/v5/core-features/versioning',
deprecationReason: 'Method deprecation example.',
)]
public function post(ServerRequestInterface $request): ResponseInterface

Check warning on line 47 in src/App/src/Handler/ErrorReportHandler.php

View workflow job for this annotation

GitHub Actions / Qodana for PHP

Undefined class

Undefined class 'ServerRequestInterface'

Check warning on line 47 in src/App/src/Handler/ErrorReportHandler.php

View workflow job for this annotation

GitHub Actions / Qodana for PHP

Undefined class

Undefined class 'ResponseInterface'
{
$this->errorReportService
Expand Down
6 changes: 6 additions & 0 deletions src/App/src/Handler/HomeHandler.php
Original file line number Diff line number Diff line change
Expand Up @@ -4,12 +4,18 @@

namespace Api\App\Handler;

use Api\App\Attribute\ResourceDeprecation;
use Dot\DependencyInjection\Attribute\Inject;
use Mezzio\Hal\HalResponseFactory;

Check warning on line 9 in src/App/src/Handler/HomeHandler.php

View workflow job for this annotation

GitHub Actions / Qodana for PHP

Undefined class

Undefined class 'HalResponseFactory'
use Mezzio\Hal\ResourceGenerator;

Check warning on line 10 in src/App/src/Handler/HomeHandler.php

View workflow job for this annotation

GitHub Actions / Qodana for PHP

Undefined class

Undefined class 'ResourceGenerator'
use Psr\Http\Message\ResponseInterface;

Check warning on line 11 in src/App/src/Handler/HomeHandler.php

View workflow job for this annotation

GitHub Actions / Qodana for PHP

Undefined class

Undefined class 'ResponseInterface'
use Psr\Http\Server\RequestHandlerInterface;

Check warning on line 12 in src/App/src/Handler/HomeHandler.php

View workflow job for this annotation

GitHub Actions / Qodana for PHP

Undefined class

Undefined class 'RequestHandlerInterface'

#[ResourceDeprecation(
sunset: '2038-01-01',
link: 'https://docs.dotkernel.org/api-documentation/v5/core-features/versioning',
deprecationReason: 'Resource deprecation example.',
)]
class HomeHandler implements RequestHandlerInterface

Check warning on line 19 in src/App/src/Handler/HomeHandler.php

View workflow job for this annotation

GitHub Actions / Qodana for PHP

Undefined class

Undefined class 'RequestHandlerInterface'
{
use HandlerTrait;
Expand Down
3 changes: 2 additions & 1 deletion src/App/src/Message.php
Original file line number Diff line number Diff line change
Expand Up @@ -18,19 +18,20 @@ class Message
public const INVALID_CLIENT_ID = 'Invalid client_id.';
public const INVALID_CONFIG = 'Invalid configuration value: \'%s\'';
public const INVALID_VALUE = 'The value specified for \'%s\' is invalid.';
public const MAIL_NOT_SENT_TO = 'Could not send mail to \'%s\'.';
public const MAIL_SENT_RECOVER_IDENTITY = 'If the provided email identifies an account in our system, '
. 'you will receive an email with your account\'s identity.';
public const MAIL_SENT_RESET_PASSWORD = 'If the provided email identifies an account in our system, '
. 'you will receive an email with further instructions on resetting your account\'s password.';
public const MAIL_SENT_USER_ACTIVATION = 'User activation mail has been successfully sent to \'%s\'';
public const MAIL_NOT_SENT_TO = 'Could not send mail to \'%s\'.';
public const MISSING_CONFIG = 'Missing configuration value: \'%s\'.';
public const RESET_PASSWORD_EXPIRED = 'Password reset request for hash: \'%s\' is invalid (expired).';
public const RESET_PASSWORD_NOT_FOUND = 'Could not find password reset request identified by hash: \'%s\'';
public const RESET_PASSWORD_OK = 'Password successfully modified.';
public const RESET_PASSWORD_USED = 'Password reset request for hash: \'%s\' is invalid (used).';
public const RESET_PASSWORD_VALID = 'Password reset request for hash: \'%s\' is valid.';
public const RESOURCE_NOT_ALLOWED = 'You are not allowed to access this resource.';
public const RESTRICTION_DEPRECATION = 'Cannot use both `%s` and `%s` attributes on the same object.';
public const RESTRICTION_IMAGE = 'File must be an image (jpg, png).';
public const RESTRICTION_ROLES = 'User accounts must have at least one role.';
public const ROLE_NOT_FOUND = 'Role not found.';
Expand Down
128 changes: 128 additions & 0 deletions src/App/src/Middleware/DeprecationMiddleware.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,128 @@
<?php

declare(strict_types=1);

namespace Api\App\Middleware;

use Api\App\Attribute\MethodDeprecation;
use Api\App\Attribute\ResourceDeprecation;
use Api\App\Exception\DeprecationConflictException;
use Api\App\Handler\ResponseTrait;
use Api\App\Message;
use Laminas\Stratigility\MiddlewarePipe;

Check warning on line 12 in src/App/src/Middleware/DeprecationMiddleware.php

View workflow job for this annotation

GitHub Actions / Qodana for PHP

Undefined class

Undefined class 'MiddlewarePipe'
use Mezzio\Middleware\LazyLoadingMiddleware;

Check warning on line 13 in src/App/src/Middleware/DeprecationMiddleware.php

View workflow job for this annotation

GitHub Actions / Qodana for PHP

Undefined class

Undefined class 'LazyLoadingMiddleware'
use Mezzio\Router\RouteResult;
use Psr\Http\Message\ResponseInterface;

Check warning on line 15 in src/App/src/Middleware/DeprecationMiddleware.php

View workflow job for this annotation

GitHub Actions / Qodana for PHP

Undefined class

Undefined class 'ResponseInterface'
use Psr\Http\Message\ServerRequestInterface;
use Psr\Http\Server\MiddlewareInterface;

Check warning on line 17 in src/App/src/Middleware/DeprecationMiddleware.php

View workflow job for this annotation

GitHub Actions / Qodana for PHP

Undefined class

Undefined class 'MiddlewareInterface'
use Psr\Http\Server\RequestHandlerInterface;

Check warning on line 18 in src/App/src/Middleware/DeprecationMiddleware.php

View workflow job for this annotation

GitHub Actions / Qodana for PHP

Undefined class

Undefined class 'RequestHandlerInterface'
use ReflectionClass;
use ReflectionException;
use ReflectionMethod;

use function array_key_exists;
use function array_keys;
use function sprintf;

class DeprecationMiddleware implements MiddlewareInterface

Check warning on line 27 in src/App/src/Middleware/DeprecationMiddleware.php

View workflow job for this annotation

GitHub Actions / Qodana for PHP

Undefined class

Undefined class 'MiddlewareInterface'
{
use ResponseTrait;

public const RESOURCE_DEPRECATION_ATTRIBUTE = ResourceDeprecation::class;
public const METHOD_DEPRECATION_ATTRIBUTE = MethodDeprecation::class;

/**
* @throws ReflectionException
*/
public function process(
ServerRequestInterface $request,
RequestHandlerInterface $handler

Check warning on line 39 in src/App/src/Middleware/DeprecationMiddleware.php

View workflow job for this annotation

GitHub Actions / Qodana for PHP

Undefined class

Undefined class 'RequestHandlerInterface'
): ResponseInterface {
$response = $handler->handle($request);
$routeResult = $request->getAttribute(RouteResult::class);

Check warning on line 42 in src/App/src/Middleware/DeprecationMiddleware.php

View workflow job for this annotation

GitHub Actions / Qodana for PHP

Undefined class

Undefined class 'RouteResult'
if (! $routeResult instanceof RouteResult || $routeResult->isFailure()) {

Check warning on line 43 in src/App/src/Middleware/DeprecationMiddleware.php

View workflow job for this annotation

GitHub Actions / Qodana for PHP

Undefined class

Undefined class 'RouteResult'
return $response;
}

$reflectionHandler = null;
$matchedRoute = $routeResult->getMatchedRoute();
if (! $matchedRoute) {
return $response;
}

$routeMiddleware = $matchedRoute->getMiddleware();
if ($routeMiddleware instanceof LazyLoadingMiddleware) {
/** @var class-string $routeMiddlewareName */
$routeMiddlewareName = $routeMiddleware->middlewareName;
$reflectionMiddlewareClass = new ReflectionClass($routeMiddlewareName);
if ($reflectionMiddlewareClass->implementsInterface(RequestHandlerInterface::class)) {

Check warning on line 58 in src/App/src/Middleware/DeprecationMiddleware.php

View workflow job for this annotation

GitHub Actions / Qodana for PHP

Undefined class

Undefined class 'RequestHandlerInterface'
$reflectionHandler = $reflectionMiddlewareClass;
}
} elseif ($routeMiddleware instanceof MiddlewarePipe) {
$reflectionClass = new ReflectionClass($routeMiddleware);
$middlewarePipeline = $reflectionClass->getProperty('pipeline')->getValue($routeMiddleware);
for ($middlewarePipeline->rewind(); $middlewarePipeline->valid(); $middlewarePipeline->next()) {
$reflectionMiddlewareClass = new ReflectionClass($middlewarePipeline->current()->middlewareName);
if ($reflectionMiddlewareClass->implementsInterface(RequestHandlerInterface::class)) {

Check warning on line 66 in src/App/src/Middleware/DeprecationMiddleware.php

View workflow job for this annotation

GitHub Actions / Qodana for PHP

Undefined class

Undefined class 'RequestHandlerInterface'
$reflectionHandler = $reflectionMiddlewareClass;
}
}
}

if (! $reflectionHandler) {
return $response;
}

$attributes = $this->getAttributes($reflectionHandler, self::RESOURCE_DEPRECATION_ATTRIBUTE);
foreach ($reflectionHandler->getMethods() as $method) {
$methodRef = new ReflectionMethod($method->class, $method->name);
$attributes += $this->getAttributes($methodRef, self::METHOD_DEPRECATION_ATTRIBUTE);
}

if ([self::RESOURCE_DEPRECATION_ATTRIBUTE, self::METHOD_DEPRECATION_ATTRIBUTE] === array_keys($attributes)) {
throw new DeprecationConflictException(
sprintf(
Message::RESTRICTION_DEPRECATION,
self::RESOURCE_DEPRECATION_ATTRIBUTE,
self::METHOD_DEPRECATION_ATTRIBUTE
)
);
}

$sunset = null;
$link = null;
if (array_key_exists(self::RESOURCE_DEPRECATION_ATTRIBUTE, $attributes)) {
$sunset = $attributes[self::RESOURCE_DEPRECATION_ATTRIBUTE]['sunset'];
$link = $attributes[self::RESOURCE_DEPRECATION_ATTRIBUTE]['link'];
}

if (array_key_exists(self::METHOD_DEPRECATION_ATTRIBUTE, $attributes)) {
$sunset = $attributes[self::METHOD_DEPRECATION_ATTRIBUTE]['sunset'];
$link = $attributes[self::METHOD_DEPRECATION_ATTRIBUTE]['link'];
}

if ($sunset !== null) {
$response = $response->withHeader('sunset', $sunset);
}

if ($link !== null) {
$response = $response->withHeader('link', $link);
}

return $response;
}

/**
* @param class-string $type
*/
public function getAttributes(ReflectionClass|ReflectionMethod $reflection, string $type): array
{
$attributes = [];
foreach ($reflection->getAttributes($type) as $attribute) {
$attribute->newInstance();
$attributes[$attribute->getName()] = $attribute->getArguments();
}

return $attributes;
}
}
63 changes: 63 additions & 0 deletions test/Unit/App/Attribute/MethodDeprecationTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
<?php

declare(strict_types=1);

namespace ApiTest\Unit\App\Attribute;

use Api\App\Attribute\MethodDeprecation;
use Api\App\Exception\DeprecationSunsetException;
use PHPUnit\Framework\TestCase;
use ReflectionClass;

class MethodDeprecationTest extends TestCase

Check warning on line 12 in test/Unit/App/Attribute/MethodDeprecationTest.php

View workflow job for this annotation

GitHub Actions / Qodana for PHP

Undefined class

Undefined class 'TestCase'
{
public function testInvalidDateThrowsException(): void
{
$class = new class {
#[MethodDeprecation(
sunset: 'invalid-01-01',
link: 'test-link',
deprecationReason: 'test-deprecation-reason',
)]
public function test(): void
{
}
};

$reflectionClass = new ReflectionClass($class);
$attributes = $this->getAttributes($reflectionClass);

$this->expectException(DeprecationSunsetException::class);

$attributes[0]->newInstance();
}

public function testValidDatePassesValidation(): void
{
$class = new class {
#[MethodDeprecation(
sunset: '2038-01-01',
link: 'test-link',
deprecationReason: 'test-deprecation-reason',
)]
public function test(): void
{
}
};

$reflectionClass = new ReflectionClass($class);
$attributes = $this->getAttributes($reflectionClass);

$attribute = $attributes[0]->newInstance();

$this->assertSame('2038-01-01', $attribute->sunset);
$this->assertSame('test-link', $attribute->link);
$this->assertSame('test-deprecation-reason', $attribute->deprecationReason);
}

private function getAttributes(ReflectionClass $reflectionClass): array
{
$methods = $reflectionClass->getMethods();
return $methods[0]->getAttributes(MethodDeprecation::class);
}
}
Loading

0 comments on commit 7f27974

Please sign in to comment.