diff --git a/src/App/src/Middleware/DeprecationMiddleware.php b/src/App/src/Middleware/DeprecationMiddleware.php index 1f47851a..5791fc2c 100644 --- a/src/App/src/Middleware/DeprecationMiddleware.php +++ b/src/App/src/Middleware/DeprecationMiddleware.php @@ -5,9 +5,10 @@ namespace Api\App\Middleware; use Api\App\Attribute\ResourceDeprecation; -use Api\App\Exception\ConflictException; +use Api\App\Exception\ExpiredException; use Api\App\Exception\RuntimeException; use Api\App\Service\HandlerService; +use DateTimeImmutable; use Dot\DependencyInjection\Attribute\Inject; use Psr\Http\Message\ResponseInterface; use Psr\Http\Message\ServerRequestInterface; @@ -20,7 +21,6 @@ use function array_merge; use function array_values; use function implode; -use function is_string; use function sprintf; class DeprecationMiddleware implements MiddlewareInterface @@ -28,7 +28,7 @@ class DeprecationMiddleware implements MiddlewareInterface public const string RESOURCE_DEPRECATION_ATTRIBUTE = ResourceDeprecation::class; /** - * @param array $config + * @param array{documentation_url?: non-empty-string} $config */ #[Inject( 'config.application.versioning', @@ -39,7 +39,7 @@ public function __construct( } /** - * @throws ConflictException + * @throws ExpiredException * @throws ReflectionException * @throws RuntimeException */ @@ -47,36 +47,61 @@ public function process( ServerRequestInterface $request, RequestHandlerInterface $handler ): ResponseInterface { - $response = $handler->handle($request); - $reflectionHandler = HandlerService::getReflectionHandler($request); if (! $reflectionHandler instanceof ReflectionClass) { - return $response; + return $handler->handle($request); } $attributes = $this->getReflectionAttributes($reflectionHandler); if (empty($attributes)) { - return $response; + return $handler->handle($request); } $attribute = $this->getAttribute($attributes); if (null === $attribute) { - return $response; + return $handler->handle($request); + } + + $documentationUrl = $this->getDocumentationUrl($attribute); + + if ($this->isSunsetReached($attribute['sunset'])) { + throw ExpiredException::create( + detail: sprintf('This resource is no longer available (sunset on %s).', $attribute['sunset']), + type: $documentationUrl ?? '', + ); } + $response = $handler->handle($request); + if (! empty($attribute['sunset'])) { $response = $response->withHeader('sunset', $attribute['sunset']); } - /** @var non-empty-string $baseUrl */ - $baseUrl = $attribute['link'] ?? $this->config['documentation_url'] ?? null; - if (is_string($baseUrl)) { - $response = $response->withHeader('link', $this->formatLink($baseUrl, $attribute)); + if (null !== $documentationUrl) { + $response = $response->withHeader('link', $this->formatLink($documentationUrl, $attribute)); } return $response; } + /** + * @param array $attribute + * @return non-empty-string|null + */ + private function getDocumentationUrl(array $attribute): ?string + { + return $attribute['link'] ?? $this->config['documentation_url'] ?? null; + } + + private function isSunsetReached(?string $sunset): bool + { + if (empty($sunset)) { + return false; + } + + return new DateTimeImmutable() >= new DateTimeImmutable($sunset); + } + /** * @param array $attributes * @return array|null diff --git a/test/Unit/App/Middleware/DeprecationMiddlewareTest.php b/test/Unit/App/Middleware/DeprecationMiddlewareTest.php index bcc47255..f60d0306 100644 --- a/test/Unit/App/Middleware/DeprecationMiddlewareTest.php +++ b/test/Unit/App/Middleware/DeprecationMiddlewareTest.php @@ -6,8 +6,10 @@ use Api\App\Attribute\ResourceDeprecation; use Api\App\Exception\ConflictException; +use Api\App\Exception\ExpiredException; use Api\App\Middleware\DeprecationMiddleware; use Fig\Http\Message\RequestMethodInterface; +use Fig\Http\Message\StatusCodeInterface; use Laminas\Diactoros\Response\EmptyResponse; use Laminas\Stratigility\MiddlewarePipe; use Mezzio\Middleware\LazyLoadingMiddleware; @@ -272,6 +274,100 @@ public function handle(ServerRequestInterface $request): ResponseInterface $this->assertSame('2038-01-01', $response->getHeader('sunset')[0]); } + /** + * @throws Exception + * @throws ReflectionException + */ + public function testPastSunsetThrowsExpiredExceptionWithoutRunningHandler(): void + { + $deprecated = new #[ResourceDeprecation(sunset: '2000-01-01', link: 'test-link')] + class implements RequestHandlerInterface { + public function handle(ServerRequestInterface $request): ResponseInterface + { + return new EmptyResponse(); + } + }; + + $this->routeRequestTo($deprecated); + + $handler = $this->createMock(RequestHandlerInterface::class); + $handler->expects($this->never())->method('handle'); + + $this->expectException(ExpiredException::class); + + $this->deprecationMiddleware->process($this->request, $handler); + } + + /** + * @throws Exception + * @throws ReflectionException + */ + public function testExpiredExceptionCarriesStatusMessageAndLink(): void + { + $deprecated = new #[ResourceDeprecation(sunset: '2000-01-01', link: 'test-link')] + class implements RequestHandlerInterface { + public function handle(ServerRequestInterface $request): ResponseInterface + { + return new EmptyResponse(); + } + }; + + $this->routeRequestTo($deprecated); + + try { + $this->deprecationMiddleware->process($this->request, $this->handler); + $this->fail('ExpiredException was not thrown'); + } catch (ExpiredException $exception) { + $this->assertSame(StatusCodeInterface::STATUS_GONE, $exception->getStatus()); + $this->assertSame('This resource is no longer available (sunset on 2000-01-01).', $exception->getDetail()); + $this->assertSame('test-link', $exception->getType()); + } + } + + /** + * @throws Exception + * @throws ReflectionException + */ + public function testExpiredExceptionFallsBackToConfiguredDocumentationUrl(): void + { + $deprecated = new #[ResourceDeprecation(sunset: '2000-01-01')] + class implements RequestHandlerInterface { + public function handle(ServerRequestInterface $request): ResponseInterface + { + return new EmptyResponse(); + } + }; + + $this->routeRequestTo($deprecated); + + try { + $this->deprecationMiddleware->process($this->request, $this->handler); + $this->fail('ExpiredException was not thrown'); + } catch (ExpiredException $exception) { + $this->assertSame(self::VERSIONING_CONFIG['documentation_url'], $exception->getType()); + } + } + + /** + * @throws Exception + */ + private function routeRequestTo(RequestHandlerInterface $deprecated): void + { + $routeResult = $this->createStub(RouteResult::class); + $route = $this->createStub(Route::class); + $lazyLoadingMiddleware = new LazyLoadingMiddleware( + $this->createStub(MiddlewareContainer::class), + $deprecated::class, + ); + + $route->method('getMiddleware')->willReturn($lazyLoadingMiddleware); + $routeResult->method('isFailure')->willReturn(false); + $routeResult->method('getMatchedRoute')->willReturn($route); + $this->request->method('getAttribute')->willReturn($routeResult); + $this->request->method('getMethod')->willReturn(RequestMethodInterface::METHOD_GET); + $this->handler->method('handle')->willReturn($this->response); + } + /** * @param non-empty-string $baseLink * @param array $attribute