From 2bdcb559805eef0f950507e87b22968f5cf6fc25 Mon Sep 17 00:00:00 2001 From: Virgil Date: Wed, 1 Apr 2026 22:02:44 +0000 Subject: [PATCH] feat(api): add ApiSunset middleware Co-Authored-By: Virgil --- .../src/Front/Api/Middleware/ApiSunset.php | 99 ++++++------------- src/php/tests/Feature/ApiSunsetTest.php | 12 +++ 2 files changed, 42 insertions(+), 69 deletions(-) diff --git a/src/php/src/Front/Api/Middleware/ApiSunset.php b/src/php/src/Front/Api/Middleware/ApiSunset.php index 20f3535..b8510d7 100644 --- a/src/php/src/Front/Api/Middleware/ApiSunset.php +++ b/src/php/src/Front/Api/Middleware/ApiSunset.php @@ -12,52 +12,17 @@ declare(strict_types=1); namespace Core\Front\Api\Middleware; use Closure; +use DateTimeImmutable; +use DateTimeInterface; +use DateTimeZone; use Illuminate\Http\Request; use Symfony\Component\HttpFoundation\Response; /** * API Sunset Middleware. * - * Adds the HTTP Sunset header to responses to indicate when an endpoint - * will be deprecated or removed. - * - * The Sunset header is defined in RFC 8594 and indicates that a resource - * will become unresponsive at the specified date. - * - * ## Usage - * - * Apply to routes that will be sunset: - * - * ```php - * Route::middleware('api.sunset:2025-06-01')->group(function () { - * Route::get('/legacy-endpoint', LegacyController::class); - * }); - * ``` - * - * Or with a replacement link: - * - * ```php - * Route::middleware('api.sunset:2025-06-01,/api/v2/new-endpoint')->group(function () { - * Route::get('/old-endpoint', OldController::class); - * }); - * - * You can also mark a route as deprecated without a fixed removal date: - * - * ```php - * Route::middleware('api.sunset')->group(function () { - * Route::get('/old-endpoint', OldController::class); - * }); - * ``` - * ``` - * - * ## Response Headers - * - * The middleware adds these headers: - * - Sunset: - * - Deprecation: true - * - Link: ; rel="successor-version" (if replacement provided) - * - * @see https://datatracker.ietf.org/doc/html/rfc8594 RFC 8594: The "Sunset" HTTP Header Field + * Adds deprecation headers to a route and optionally advertises a sunset + * date and successor endpoint. */ class ApiSunset { @@ -65,58 +30,54 @@ class ApiSunset * Handle an incoming request. * * @param string $sunsetDate The sunset date (YYYY-MM-DD or RFC7231 format), or empty for deprecation-only - * @param string|null $replacement Optional replacement endpoint URL + * @param string|null $replacement Optional successor endpoint URL */ public function handle(Request $request, Closure $next, string $sunsetDate = '', ?string $replacement = null): Response { /** @var Response $response */ $response = $next($request); - if ($sunsetDate !== '') { - // Convert date to RFC7231 format if needed - $formattedDate = $this->formatSunsetDate($sunsetDate); - - // Add Sunset header - $response->headers->set('Sunset', $formattedDate); - } - - // Add Deprecation header $response->headers->set('Deprecation', 'true'); - // Add warning header + if ($sunsetDate !== '') { + $response->headers->set('Sunset', $this->formatSunsetDate($sunsetDate)); + } + + if ($replacement !== null && $replacement !== '') { + $response->headers->set('Link', sprintf('<%s>; rel="successor-version"', $replacement)); + } + $warning = 'This endpoint is deprecated.'; if ($sunsetDate !== '') { $warning = "This endpoint is deprecated and will be removed on {$sunsetDate}."; } - $response->headers->set('X-API-Warn', $warning); - // Add Link header for replacement if provided - if ($replacement !== null) { - $response->headers->set('Link', "<{$replacement}>; rel=\"successor-version\""); - } + $response->headers->set('X-API-Warn', $warning); return $response; } /** - * Format the sunset date to RFC7231 format. - * - * Accepts dates in YYYY-MM-DD format or already-formatted RFC7231 dates. + * Format the sunset date to RFC7231 format when possible. */ - protected function formatSunsetDate(string $date): string + protected function formatSunsetDate(string $sunsetDate): string { - // Check if already in RFC7231 format (contains comma, day name) - if (str_contains($date, ',')) { - return $date; + $sunsetDate = trim($sunsetDate); + if ($sunsetDate === '') { + return $sunsetDate; + } + + // Already RFC7231-style dates contain a comma, so preserve them. + if (str_contains($sunsetDate, ',')) { + return $sunsetDate; } try { - return (new \DateTimeImmutable($date)) - ->setTimezone(new \DateTimeZone('GMT')) - ->format(\DateTimeInterface::RFC7231); - } catch (\Exception) { - // If parsing fails, return as-is - return $date; + return (new DateTimeImmutable($sunsetDate)) + ->setTimezone(new DateTimeZone('GMT')) + ->format(DateTimeInterface::RFC7231); + } catch (\Throwable) { + return $sunsetDate; } } } diff --git a/src/php/tests/Feature/ApiSunsetTest.php b/src/php/tests/Feature/ApiSunsetTest.php index 1c0069a..8af518d 100644 --- a/src/php/tests/Feature/ApiSunsetTest.php +++ b/src/php/tests/Feature/ApiSunsetTest.php @@ -29,3 +29,15 @@ it('adds a replacement link without a sunset date', function () { expect($response->headers->get('Link'))->toBe('; rel="successor-version"'); expect($response->headers->get('X-API-Warn'))->toBe('This endpoint is deprecated.'); }); + +it('formats the sunset date and keeps the replacement link', function () { + $middleware = new ApiSunset(); + $request = Request::create('/legacy-endpoint', 'GET'); + + $response = $middleware->handle($request, fn () => new Response('OK'), '2025-06-01', '/api/v2/users'); + + expect($response->headers->get('Deprecation'))->toBe('true'); + expect($response->headers->get('Sunset'))->toBe('Sun, 01 Jun 2025 00:00:00 GMT'); + expect($response->headers->get('Link'))->toBe('; rel="successor-version"'); + expect($response->headers->get('X-API-Warn'))->toBe('This endpoint is deprecated and will be removed on 2025-06-01.'); +});