feat(api-docs): document versioned response headers
Co-Authored-By: Virgil <virgil@lethean.io>
This commit is contained in:
parent
93bef3ed85
commit
159f8d3b9f
3 changed files with 171 additions and 0 deletions
126
src/php/src/Api/Documentation/Extensions/VersionExtension.php
Normal file
126
src/php/src/Api/Documentation/Extensions/VersionExtension.php
Normal 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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
43
src/php/src/Api/Tests/Feature/OpenApiVersionHeadersTest.php
Normal file
43
src/php/src/Api/Tests/Feature/OpenApiVersionHeadersTest.php
Normal 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');
|
||||
}
|
||||
});
|
||||
Loading…
Add table
Reference in a new issue