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 2c982b5..f4cd673 100644 --- a/src/App/src/ConfigProvider.php +++ b/src/App/src/ConfigProvider.php @@ -11,10 +11,12 @@ use Api\App\Factory\EntityListenerResolverFactory; use Api\App\Factory\RouteListCommandFactory; use Api\App\Factory\TokenGenerateCommandFactory; +use Api\App\Handler\ErrorReportHandler; use Api\App\Handler\HomeHandler; 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; @@ -66,10 +68,12 @@ 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, HomeHandler::class => AttributedServiceFactory::class, + ErrorReportHandler::class => AttributedServiceFactory::class, ErrorResponseMiddleware::class => AttributedServiceFactory::class, RouteListCommand::class => RouteListCommandFactory::class, TokenGenerateCommand::class => TokenGenerateCommandFactory::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..5650beb 100644 --- a/src/App/src/Message.php +++ b/src/App/src/Message.php @@ -44,4 +44,5 @@ class Message public const VALIDATOR_REQUIRED_FIELD = 'This field is required and cannot be empty.'; public const VALIDATOR_REQUIRED_FIELD_BY_NAME = '%s is required and cannot be empty.'; public const VALIDATOR_REQUIRED_UPLOAD = 'A file must be uploaded first.'; + public const RESTRICTION_DEPRECATION = 'Cannot use both `%s` and `%s` attributes on the same object.'; } diff --git a/src/App/src/Middleware/DeprecationMiddleware.php b/src/App/src/Middleware/DeprecationMiddleware.php new file mode 100644 index 0000000..3596938 --- /dev/null +++ b/src/App/src/Middleware/DeprecationMiddleware.php @@ -0,0 +1,116 @@ +handle($request); + $routeResult = $request->getAttribute(RouteResult::class); + if (! $routeResult instanceof RouteResult || $routeResult->isFailure()) { + return $response; + } + + $reflectionHandler = null; + $routeMiddleware = $routeResult->getMatchedRoute()->getMiddleware(); + if ($routeMiddleware instanceof LazyLoadingMiddleware) { + $reflectionMiddlewareClass = new ReflectionClass($routeMiddleware->middlewareName); + if ($reflectionMiddlewareClass->implementsInterface(RequestHandlerInterface::class)) { + $reflectionHandler = $reflectionMiddlewareClass; + } + } else { + $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 = ''; + $link = ''; + if ($attributes[self::RESOURCE_DEPRECATION_ATTRIBUTE] ?? '') { + $sunset = $attributes[self::RESOURCE_DEPRECATION_ATTRIBUTE]['sunset']; + $link = $attributes[self::RESOURCE_DEPRECATION_ATTRIBUTE]['link']; + } + + if ($attributes[self::METHOD_DEPRECATION_ATTRIBUTE] ?? '') { + $sunset = $attributes[self::METHOD_DEPRECATION_ATTRIBUTE]['sunset']; + $link = $attributes[self::METHOD_DEPRECATION_ATTRIBUTE]['link']; + } + + if ($sunset) { + $response = $response->withHeader('sunset', $sunset); + } + + if ($link) { + $response = $response->withHeader('link', $link); + } + + return $response; + } + + 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..173eac5 --- /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() + { + } + }; + + $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..93bb2e5 --- /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() + { + $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]); + } +}