feat(api-docs): document sunset middleware in OpenAPI

Co-Authored-By: Virgil <virgil@lethean.io>
This commit is contained in:
Virgil 2026-04-01 21:59:01 +00:00
parent 93cdb62dfe
commit cba25cf9fc
3 changed files with 153 additions and 0 deletions

View file

@ -0,0 +1,110 @@
<?php
declare(strict_types=1);
namespace Core\Api\Documentation\Extensions;
use Core\Api\Documentation\Extension;
use Illuminate\Routing\Route;
/**
* Sunset Extension.
*
* Documents endpoint deprecation and sunset metadata for routes using
* the `api.sunset` middleware.
*/
class SunsetExtension implements Extension
{
/**
* Extend the complete OpenAPI specification.
*/
public function extend(array $spec, array $config): array
{
$spec['components']['headers'] = $spec['components']['headers'] ?? [];
$spec['components']['headers']['deprecation'] = [
'description' => '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;
}
}

View file

@ -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,
];
}

View file

@ -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
// ─────────────────────────────────────────────────────────────────────────────