From cba25cf9fcb947105f64a576741ed699e5326bf5 Mon Sep 17 00:00:00 2001 From: Virgil Date: Wed, 1 Apr 2026 21:59:01 +0000 Subject: [PATCH] feat(api-docs): document sunset middleware in OpenAPI Co-Authored-By: Virgil --- .../Extensions/SunsetExtension.php | 110 ++++++++++++++++++ .../src/Api/Documentation/OpenApiBuilder.php | 2 + .../OpenApiDocumentationComprehensiveTest.php | 41 +++++++ 3 files changed, 153 insertions(+) create mode 100644 src/php/src/Api/Documentation/Extensions/SunsetExtension.php diff --git a/src/php/src/Api/Documentation/Extensions/SunsetExtension.php b/src/php/src/Api/Documentation/Extensions/SunsetExtension.php new file mode 100644 index 0000000..ae73c15 --- /dev/null +++ b/src/php/src/Api/Documentation/Extensions/SunsetExtension.php @@ -0,0 +1,110 @@ + 'Indicates that the endpoint is deprecated.', + 'schema' => [ + 'type' => 'string', + 'enum' => ['true'], + ], + ]; + + $spec['components']['headers']['sunset'] = [ + 'description' => 'The date and time after which the endpoint will no longer be supported.', + 'schema' => [ + 'type' => 'string', + 'format' => 'date-time', + ], + ]; + + $spec['components']['headers']['link'] = [ + 'description' => 'Reference to the successor endpoint, when one is provided.', + 'schema' => [ + 'type' => 'string', + ], + ]; + + $spec['components']['headers']['xapiwarn'] = [ + 'description' => 'Human-readable deprecation warning for clients.', + 'schema' => [ + 'type' => 'string', + ], + ]; + + return $spec; + } + + /** + * Extend an individual operation. + */ + public function extendOperation(array $operation, Route $route, string $method, array $config): array + { + if (! $this->hasSunsetMiddleware($route)) { + return $operation; + } + + $operation['deprecated'] = true; + + foreach ($operation['responses'] as $status => &$response) { + if (! is_numeric($status) || (int) $status < 200 || (int) $status >= 300) { + continue; + } + + $response['headers'] = $response['headers'] ?? []; + + $response['headers']['Deprecation'] = [ + '$ref' => '#/components/headers/deprecation', + ]; + $response['headers']['Sunset'] = [ + '$ref' => '#/components/headers/sunset', + ]; + $response['headers']['X-API-Warn'] = [ + '$ref' => '#/components/headers/xapiwarn', + ]; + + if (! isset($response['headers']['Link'])) { + $response['headers']['Link'] = [ + '$ref' => '#/components/headers/link', + ]; + } + } + unset($response); + + return $operation; + } + + /** + * Determine whether the route uses the sunset middleware. + */ + protected function hasSunsetMiddleware(Route $route): bool + { + foreach ($route->middleware() as $middleware) { + if (str_starts_with($middleware, 'api.sunset') || str_contains($middleware, 'ApiSunset')) { + return true; + } + } + + return false; + } +} diff --git a/src/php/src/Api/Documentation/OpenApiBuilder.php b/src/php/src/Api/Documentation/OpenApiBuilder.php index 3c7b838..7be3a2c 100644 --- a/src/php/src/Api/Documentation/OpenApiBuilder.php +++ b/src/php/src/Api/Documentation/OpenApiBuilder.php @@ -11,6 +11,7 @@ use Core\Api\Documentation\Attributes\ApiSecurity; use Core\Api\Documentation\Attributes\ApiTag; use Core\Api\Documentation\Extensions\ApiKeyAuthExtension; use Core\Api\Documentation\Extensions\RateLimitExtension; +use Core\Api\Documentation\Extensions\SunsetExtension; use Core\Api\Documentation\Extensions\WorkspaceHeaderExtension; use Illuminate\Http\Resources\Json\JsonResource; use Illuminate\Routing\Route; @@ -58,6 +59,7 @@ class OpenApiBuilder $this->extensions = [ new WorkspaceHeaderExtension, new RateLimitExtension, + new SunsetExtension, new ApiKeyAuthExtension, ]; } diff --git a/src/php/src/Api/Tests/Feature/OpenApiDocumentationComprehensiveTest.php b/src/php/src/Api/Tests/Feature/OpenApiDocumentationComprehensiveTest.php index f185b05..8c61808 100644 --- a/src/php/src/Api/Tests/Feature/OpenApiDocumentationComprehensiveTest.php +++ b/src/php/src/Api/Tests/Feature/OpenApiDocumentationComprehensiveTest.php @@ -10,6 +10,7 @@ use Core\Api\Documentation\Attributes\ApiTag; use Core\Api\Documentation\Extension; use Core\Api\Documentation\Extensions\ApiKeyAuthExtension; use Core\Api\Documentation\Extensions\RateLimitExtension; +use Core\Api\Documentation\Extensions\SunsetExtension; use Core\Api\Documentation\Extensions\WorkspaceHeaderExtension; use Core\Api\Documentation\OpenApiBuilder; use Core\Api\RateLimit\RateLimit; @@ -786,6 +787,46 @@ describe('Error Response Documentation', function () { }); }); +// ───────────────────────────────────────────────────────────────────────────── +// Sunset Documentation +// ───────────────────────────────────────────────────────────────────────────── + +describe('Sunset Documentation', function () { + it('registers deprecation headers in components', function () { + $extension = new SunsetExtension; + $spec = ['components' => []]; + + $result = $extension->extend($spec, []); + + expect($result['components']['headers'])->toHaveKey('deprecation') + ->toHaveKey('sunset') + ->toHaveKey('link') + ->toHaveKey('xapiwarn'); + }); + + it('marks sunset routes as deprecated and documents their response headers', function () { + RouteFacade::prefix('api') + ->middleware(['api', 'api.sunset:2025-06-01,/api/v2/legacy']) + ->group(function () { + RouteFacade::get('/sunset-test/legacy', fn () => response()->json(['ok' => true])) + ->name('sunset-test.legacy'); + }); + + config(['api-docs.routes.include' => ['api/*']]); + + $builder = new OpenApiBuilder; + $spec = $builder->build(); + + $operation = $spec['paths']['/api/sunset-test/legacy']['get']; + + expect($operation['deprecated'])->toBeTrue(); + expect($operation['responses']['200']['headers'])->toHaveKey('Deprecation') + ->toHaveKey('Sunset') + ->toHaveKey('X-API-Warn') + ->toHaveKey('Link'); + }); +}); + // ───────────────────────────────────────────────────────────────────────────── // Authentication Documentation // ─────────────────────────────────────────────────────────────────────────────