From 159f8d3b9f9ac9aae995308fe8124f05ee1cb704 Mon Sep 17 00:00:00 2001 From: Virgil Date: Wed, 1 Apr 2026 23:46:29 +0000 Subject: [PATCH] feat(api-docs): document versioned response headers Co-Authored-By: Virgil --- .../Extensions/VersionExtension.php | 126 ++++++++++++++++++ .../src/Api/Documentation/OpenApiBuilder.php | 2 + .../Feature/OpenApiVersionHeadersTest.php | 43 ++++++ 3 files changed, 171 insertions(+) create mode 100644 src/php/src/Api/Documentation/Extensions/VersionExtension.php create mode 100644 src/php/src/Api/Tests/Feature/OpenApiVersionHeadersTest.php diff --git a/src/php/src/Api/Documentation/Extensions/VersionExtension.php b/src/php/src/Api/Documentation/Extensions/VersionExtension.php new file mode 100644 index 0000000..04cead7 --- /dev/null +++ b/src/php/src/Api/Documentation/Extensions/VersionExtension.php @@ -0,0 +1,126 @@ + '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; + } +} diff --git a/src/php/src/Api/Documentation/OpenApiBuilder.php b/src/php/src/Api/Documentation/OpenApiBuilder.php index 249962f..209b13a 100644 --- a/src/php/src/Api/Documentation/OpenApiBuilder.php +++ b/src/php/src/Api/Documentation/OpenApiBuilder.php @@ -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, diff --git a/src/php/src/Api/Tests/Feature/OpenApiVersionHeadersTest.php b/src/php/src/Api/Tests/Feature/OpenApiVersionHeadersTest.php new file mode 100644 index 0000000..40514e2 --- /dev/null +++ b/src/php/src/Api/Tests/Feature/OpenApiVersionHeadersTest.php @@ -0,0 +1,43 @@ + '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'); + } +});