From 929b6b97cabb6f29252b3665fa69ae6f2cda8807 Mon Sep 17 00:00:00 2001 From: Virgil Date: Wed, 1 Apr 2026 22:32:23 +0000 Subject: [PATCH] fix(api-docs): deduplicate explicit OpenAPI parameters Explicit ApiParameter metadata now replaces matching auto-generated path parameters instead of producing duplicates, matching the precedence used by the Go OpenAPI builder. Co-Authored-By: Virgil --- .../src/Api/Documentation/OpenApiBuilder.php | 28 +++++++++++++--- .../OpenApiDocumentationComprehensiveTest.php | 33 +++++++++++++++++++ 2 files changed, 57 insertions(+), 4 deletions(-) diff --git a/src/php/src/Api/Documentation/OpenApiBuilder.php b/src/php/src/Api/Documentation/OpenApiBuilder.php index 7be3a2c..cfb7090 100644 --- a/src/php/src/Api/Documentation/OpenApiBuilder.php +++ b/src/php/src/Api/Documentation/OpenApiBuilder.php @@ -523,16 +523,36 @@ class OpenApiBuilder protected function buildParameters(Route $route, ?object $controller, string $action, array $config): array { $parameters = []; + $parameterIndex = []; + + $addParameter = function (array $parameter) use (&$parameters, &$parameterIndex): void { + $name = $parameter['name'] ?? null; + $in = $parameter['in'] ?? null; + + if (! is_string($name) || $name === '' || ! is_string($in) || $in === '') { + return; + } + + $key = $in.':'.$name; + if (isset($parameterIndex[$key])) { + $parameters[$parameterIndex[$key]] = $parameter; + + return; + } + + $parameterIndex[$key] = count($parameters); + $parameters[] = $parameter; + }; // Add path parameters preg_match_all('/\{([^}?]+)\??}/', $route->uri(), $matches); foreach ($matches[1] as $param) { - $parameters[] = [ + $addParameter([ 'name' => $param, 'in' => 'path', 'required' => true, 'schema' => ['type' => 'string'], - ]; + ]); } // Add parameters from ApiParameter attributes @@ -544,12 +564,12 @@ class OpenApiBuilder foreach ($paramAttrs as $attr) { $param = $attr->newInstance(); - $parameters[] = $param->toOpenApi(); + $addParameter($param->toOpenApi()); } } } - return $parameters; + return array_values($parameters); } /** diff --git a/src/php/src/Api/Tests/Feature/OpenApiDocumentationComprehensiveTest.php b/src/php/src/Api/Tests/Feature/OpenApiDocumentationComprehensiveTest.php index 85518b9..d0c74f7 100644 --- a/src/php/src/Api/Tests/Feature/OpenApiDocumentationComprehensiveTest.php +++ b/src/php/src/Api/Tests/Feature/OpenApiDocumentationComprehensiveTest.php @@ -245,6 +245,29 @@ describe('Application Endpoint Parameter Docs', function () { expect($serverIncludeContent['schema']['type'])->toBe('boolean'); }); + it('lets explicit path parameter metadata override the generated entry', function () { + RouteFacade::prefix('api') + ->middleware('api') + ->group(function () { + RouteFacade::get('/test-scan/items/{id}/explicit', [TestExplicitPathParameterController::class, 'show']); + }); + + $builder = new OpenApiBuilder; + $spec = $builder->build(); + + $operation = $spec['paths']['/api/test-scan/items/{id}/explicit']['get']; + $parameters = $operation['parameters'] ?? []; + + expect($parameters)->toHaveCount(1); + + $idParam = collect($parameters)->firstWhere('name', 'id'); + + expect($idParam)->not->toBeNull(); + expect($idParam['in'])->toBe('path'); + expect($idParam['required'])->toBeTrue(); + expect($idParam['description'])->toBe('Explicit item identifier'); + }); + it('documents the MCP tool call request body shape', function () { $builder = new OpenApiBuilder; $spec = $builder->build(); @@ -1178,6 +1201,16 @@ class TestPartialHiddenController public function hiddenMethod(): void {} } +/** + * Test controller with an explicit path parameter override. + */ +class TestExplicitPathParameterController +{ + #[ApiParameter('id', 'path', 'string', 'Explicit item identifier')] + #[ApiResponse(200, TestJsonResource::class, 'Item details')] + public function show(string $id): void {} +} + /** * Test tagged controller. */