diff --git a/config/pipeline.php b/config/pipeline.php index 3d8ae36..3c5cbff 100644 --- a/config/pipeline.php +++ b/config/pipeline.php @@ -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; @@ -61,6 +62,7 @@ $app->pipe(MethodNotAllowedMiddleware::class); $app->pipe(ContentNegotiationMiddleware::class); + $app->pipe(DeprecationMiddleware::class); $app->pipe(ResponseHeaderMiddleware::class); diff --git a/src/App/src/Attribute/MethodDeprecation.php b/src/App/src/Attribute/MethodDeprecation.php new file mode 100644 index 0000000..fdcf7e9 --- /dev/null +++ b/src/App/src/Attribute/MethodDeprecation.php @@ -0,0 +1,23 @@ +isValid($sunset)) { + throw new DeprecationSunsetException(sprintf(Message::INVALID_VALUE, 'sunset')); + } + } +} diff --git a/src/App/src/Attribute/ResourceDeprecation.php b/src/App/src/Attribute/ResourceDeprecation.php new file mode 100644 index 0000000..2217fd0 --- /dev/null +++ b/src/App/src/Attribute/ResourceDeprecation.php @@ -0,0 +1,23 @@ +isValid($sunset)) { + throw new DeprecationSunsetException(sprintf(Message::INVALID_VALUE, 'sunset')); + } + } +} diff --git a/src/App/src/ConfigProvider.php b/src/App/src/ConfigProvider.php index b527a11..f4cd673 100644 --- a/src/App/src/ConfigProvider.php +++ b/src/App/src/ConfigProvider.php @@ -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; @@ -67,6 +68,7 @@ public function getDependencies(): array AuthenticationMiddleware::class => AuthenticationMiddlewareFactory::class, AuthorizationMiddleware::class => AttributedServiceFactory::class, ContentNegotiationMiddleware::class => AttributedServiceFactory::class, + DeprecationMiddleware::class => AttributedServiceFactory::class, Environment::class => TwigEnvironmentFactory::class, TwigExtension::class => TwigExtensionFactory::class, TwigRenderer::class => TwigRendererFactory::class, diff --git a/src/App/src/Exception/DeprecationConflictException.php b/src/App/src/Exception/DeprecationConflictException.php new file mode 100644 index 0000000..4edf0d5 --- /dev/null +++ b/src/App/src/Exception/DeprecationConflictException.php @@ -0,0 +1,11 @@ +errorReportService diff --git a/src/App/src/Handler/HomeHandler.php b/src/App/src/Handler/HomeHandler.php index 9e8f11d..91ddb50 100644 --- a/src/App/src/Handler/HomeHandler.php +++ b/src/App/src/Handler/HomeHandler.php @@ -4,12 +4,18 @@ namespace Api\App\Handler; +use Api\App\Attribute\ResourceDeprecation; use Dot\DependencyInjection\Attribute\Inject; use Mezzio\Hal\HalResponseFactory; use Mezzio\Hal\ResourceGenerator; use Psr\Http\Message\ResponseInterface; use Psr\Http\Server\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 { use HandlerTrait; diff --git a/src/App/src/Message.php b/src/App/src/Message.php index 95d25ec..68a6734 100644 --- a/src/App/src/Message.php +++ b/src/App/src/Message.php @@ -18,12 +18,12 @@ 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\''; @@ -31,6 +31,7 @@ class Message 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.'; diff --git a/src/App/src/Middleware/DeprecationMiddleware.php b/src/App/src/Middleware/DeprecationMiddleware.php new file mode 100644 index 0000000..a4c56d8 --- /dev/null +++ b/src/App/src/Middleware/DeprecationMiddleware.php @@ -0,0 +1,128 @@ +handle($request); + $routeResult = $request->getAttribute(RouteResult::class); + if (! $routeResult instanceof RouteResult || $routeResult->isFailure()) { + 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)) { + $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)) { + $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; + } +} diff --git a/test/Unit/App/Attribute/MethodDeprecationTest.php b/test/Unit/App/Attribute/MethodDeprecationTest.php new file mode 100644 index 0000000..ddc7288 --- /dev/null +++ b/test/Unit/App/Attribute/MethodDeprecationTest.php @@ -0,0 +1,63 @@ +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); + } +} diff --git a/test/Unit/App/Attribute/ResourceDeprecationTest.php b/test/Unit/App/Attribute/ResourceDeprecationTest.php new file mode 100644 index 0000000..c819e2f --- /dev/null +++ b/test/Unit/App/Attribute/ResourceDeprecationTest.php @@ -0,0 +1,49 @@ +getAttributes(ResourceDeprecation::class); + + $this->expectException(DeprecationSunsetException::class); + + $attributes[0]->newInstance(); + } + + public function testValidDatePassesValidation(): void + { + $class = new #[ResourceDeprecation( + sunset: '2038-01-01', + link: 'test-link', + deprecationReason: 'test-deprecation-reason', + )] class { + }; + + $reflectionClass = new ReflectionClass($class); + $attributes = $reflectionClass->getAttributes(ResourceDeprecation::class); + + $attribute = $attributes[0]->newInstance(); + + $this->assertSame('2038-01-01', $attribute->sunset); + $this->assertSame('test-link', $attribute->link); + $this->assertSame('test-deprecation-reason', $attribute->deprecationReason); + } +} diff --git a/test/Unit/App/Middleware/DeprecationMiddlewareTest.php b/test/Unit/App/Middleware/DeprecationMiddlewareTest.php new file mode 100644 index 0000000..7352a51 --- /dev/null +++ b/test/Unit/App/Middleware/DeprecationMiddlewareTest.php @@ -0,0 +1,190 @@ +handler = $this->createMock(RequestHandlerInterface::class); + $this->request = $this->createMock(ServerRequestInterface::class); + $this->response = new EmptyResponse(); + $this->subject = new Subject(); + } + + /** + * @throws ReflectionException + * @throws Exception + */ + public function testThrowsDeprecationConflictException(): void + { + $handler = new #[ResourceDeprecation( + sunset: '2038-01-01', + link: 'test-link', + deprecationReason: 'test-deprecation-reason', + )] class implements RequestHandlerInterface { + #[MethodDeprecation( + sunset: '2038-01-01', + link: 'test-link', + deprecationReason: 'test-deprecation-reason', + )] + public function handle(ServerRequestInterface $request): ResponseInterface + { + return new EmptyResponse(); + } + }; + + $routeResult = $this->createMock(RouteResult::class); + $route = $this->createMock(Route::class); + $lazyLoadingMiddleware = new LazyLoadingMiddleware( + $this->createMock(MiddlewareContainer::class), + $handler::class, + ); + + $route->method('getMiddleware')->willReturn($lazyLoadingMiddleware); + $routeResult->method('isFailure')->willReturn(false); + $routeResult->method('getMatchedRoute')->willReturn($route); + $this->request->method('getAttribute')->with(RouteResult::class)->willReturn($routeResult); + $this->handler->method('handle')->with($this->request)->willReturn($this->response); + + $this->expectException(DeprecationConflictException::class); + $this->expectExceptionMessage(sprintf( + Message::RESTRICTION_DEPRECATION, + DeprecationMiddleware::RESOURCE_DEPRECATION_ATTRIBUTE, + DeprecationMiddleware::METHOD_DEPRECATION_ATTRIBUTE + )); + + $this->subject->process($this->request, $this->handler); + } + + /** + * @throws Exception + * @throws ReflectionException + */ + public function testLazyLoadingMiddleware(): void + { + $handler = new #[ResourceDeprecation( + sunset: '2038-01-01', + link: 'test-link', + deprecationReason: 'test-deprecation-reason', + )] class implements RequestHandlerInterface { + public function handle(ServerRequestInterface $request): ResponseInterface + { + return new EmptyResponse(); + } + }; + + $routeResult = $this->createMock(RouteResult::class); + $route = $this->createMock(Route::class); + $lazyLoadingMiddleware = new LazyLoadingMiddleware( + $this->createMock(MiddlewareContainer::class), + $handler::class, + ); + + $route->method('getMiddleware')->willReturn($lazyLoadingMiddleware); + $routeResult->method('isFailure')->willReturn(false); + $routeResult->method('getMatchedRoute')->willReturn($route); + $this->request->method('getAttribute')->with(RouteResult::class)->willReturn($routeResult); + $this->handler->method('handle')->with($this->request)->willReturn($this->response); + + $response = $this->subject->process($this->request, $this->handler); + + $this->assertInstanceOf(ResponseInterface::class, $response); + $this->assertIsArray($response->getHeaders()); + $this->assertTrue($response->hasHeader('sunset')); + $this->assertTrue($response->hasHeader('link')); + $this->assertSame('2038-01-01', $response->getHeader('sunset')[0]); + $this->assertSame('test-link', $response->getHeader('link')[0]); + } + + /** + * @throws ReflectionException + * @throws Exception + */ + public function testMiddlewarePipeline(): void + { + $handler = new #[ResourceDeprecation( + sunset: '2038-01-01', + link: 'test-link', + deprecationReason: 'test-deprecation-reason', + )] class implements RequestHandlerInterface { + public function handle(ServerRequestInterface $request): ResponseInterface + { + return new EmptyResponse(); + } + }; + + $middleware = new class implements MiddlewareInterface { + public function process( + ServerRequestInterface $request, + RequestHandlerInterface $handler + ): ResponseInterface { + return $handler->handle($request); + } + }; + + $routeResult = $this->createMock(RouteResult::class); + $route = $this->createMock(Route::class); + + $lazyLoadingMiddleware = new LazyLoadingMiddleware( + $this->createMock(MiddlewareContainer::class), + $middleware::class + ); + + $lazyLoadingMiddlewareHandler = new LazyLoadingMiddleware( + $this->createMock(MiddlewareContainer::class), + $handler::class, + ); + + $middlewarePipeline = new MiddlewarePipe(); + $middlewarePipeline->pipe($lazyLoadingMiddleware); + $middlewarePipeline->pipe($lazyLoadingMiddlewareHandler); + + $route->method('getMiddleware')->willReturn($middlewarePipeline); + $routeResult->method('isFailure')->willReturn(false); + $routeResult->method('getMatchedRoute')->willReturn($route); + $this->request->method('getAttribute')->with(RouteResult::class)->willReturn($routeResult); + $this->handler->method('handle')->with($this->request)->willReturn($this->response); + + $response = $this->subject->process($this->request, $this->handler); + + $this->assertInstanceOf(ResponseInterface::class, $response); + $this->assertIsArray($response->getHeaders()); + $this->assertTrue($response->hasHeader('sunset')); + $this->assertTrue($response->hasHeader('link')); + $this->assertSame('2038-01-01', $response->getHeader('sunset')[0]); + $this->assertSame('test-link', $response->getHeader('link')[0]); + } +}