feat(api-docs): document versioned response headers

Co-Authored-By: Virgil <virgil@lethean.io>
This commit is contained in:
Virgil 2026-04-01 23:46:29 +00:00
parent 93bef3ed85
commit 159f8d3b9f
3 changed files with 171 additions and 0 deletions

View file

@ -0,0 +1,126 @@
<?php
declare(strict_types=1);
namespace Core\Api\Documentation\Extensions;
use Core\Api\Documentation\Extension;
use Illuminate\Routing\Route;
/**
* API Version Extension.
*
* Documents the X-API-Version response header and version-driven deprecation
* metadata for routes using the api.version middleware.
*/
class VersionExtension implements Extension
{
/**
* Extend the complete OpenAPI specification.
*/
public function extend(array $spec, array $config): array
{
if (! (bool) config('api.headers.include_version', true)) {
return $spec;
}
$spec['components']['headers'] = $spec['components']['headers'] ?? [];
$spec['components']['headers']['xapiversion'] = [
'description' => 'API version used to process the request.',
'schema' => [
'type' => 'string',
],
];
return $spec;
}
/**
* Extend an individual operation.
*/
public function extendOperation(array $operation, Route $route, string $method, array $config): array
{
$version = $this->versionMiddlewareVersion($route);
if ($version === null) {
return $operation;
}
$includeVersion = (bool) config('api.headers.include_version', true);
$includeDeprecation = (bool) config('api.headers.include_deprecation', true);
$deprecatedVersions = array_map('intval', config('api.versioning.deprecated', []));
$sunsetDates = config('api.versioning.sunset', []);
$isDeprecatedVersion = in_array($version, $deprecatedVersions, true);
$sunsetDate = $sunsetDates[$version] ?? null;
if ($isDeprecatedVersion) {
$operation['deprecated'] = true;
}
foreach ($operation['responses'] as $status => &$response) {
if (! is_numeric($status) || (int) $status < 200 || (int) $status >= 600) {
continue;
}
$response['headers'] = $response['headers'] ?? [];
if ($includeVersion && ! isset($response['headers']['X-API-Version'])) {
$response['headers']['X-API-Version'] = [
'$ref' => '#/components/headers/xapiversion',
];
}
if (! $includeDeprecation || ! $isDeprecatedVersion) {
continue;
}
$response['headers']['Deprecation'] = [
'$ref' => '#/components/headers/deprecation',
];
$response['headers']['X-API-Warn'] = [
'$ref' => '#/components/headers/xapiwarn',
];
if ($sunsetDate !== null && $sunsetDate !== '') {
$response['headers']['Sunset'] = [
'$ref' => '#/components/headers/sunset',
];
}
}
unset($response);
return $operation;
}
/**
* Extract the version number from api.version middleware.
*/
protected function versionMiddlewareVersion(Route $route): ?int
{
foreach ($route->middleware() as $middleware) {
if (! str_starts_with($middleware, 'api.version') && ! str_contains($middleware, 'ApiVersion')) {
continue;
}
if (! str_contains($middleware, ':')) {
return null;
}
[, $arguments] = explode(':', $middleware, 2);
$arguments = trim($arguments);
if ($arguments === '') {
return null;
}
$parts = explode(',', $arguments, 2);
$version = ltrim(trim($parts[0] ?? ''), 'vV');
if ($version === '' || ! is_numeric($version)) {
return null;
}
return (int) $version;
}
return null;
}
}

View file

@ -12,6 +12,7 @@ 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\VersionExtension;
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 VersionExtension,
new RateLimitExtension,
new SunsetExtension,
new ApiKeyAuthExtension,

View file

@ -0,0 +1,43 @@
<?php
declare(strict_types=1);
use Core\Api\Documentation\OpenApiBuilder;
use Illuminate\Support\Facades\Config;
use Illuminate\Support\Facades\Route as RouteFacade;
beforeEach(function () {
Config::set('api.headers.include_version', true);
Config::set('api.headers.include_deprecation', true);
Config::set('api.versioning.deprecated', [1]);
Config::set('api.versioning.sunset', [
1 => '2025-06-01',
]);
Config::set('api-docs.routes.include', ['api/*']);
Config::set('api-docs.routes.exclude', []);
});
it('documents version headers and version-driven deprecation on versioned routes', function () {
RouteFacade::prefix('api/v1')
->middleware(['api', 'api.version:1'])
->group(function () {
RouteFacade::get('/legacy-status', fn () => response()->json(['ok' => true]));
});
$spec = (new OpenApiBuilder)->build();
expect($spec['components']['headers']['xapiversion'] ?? null)->not->toBeNull();
$operation = $spec['paths']['/api/v1/legacy-status']['get'];
expect($operation['deprecated'] ?? null)->toBeTrue();
foreach (['200', '400', '500'] as $status) {
$headers = $operation['responses'][$status]['headers'] ?? [];
expect($headers)->toHaveKey('X-API-Version');
expect($headers)->toHaveKey('Deprecation');
expect($headers)->toHaveKey('Sunset');
expect($headers)->toHaveKey('X-API-Warn');
}
});