From e2935ce79e53e7f7085418cead313a4544537f4c Mon Sep 17 00:00:00 2001 From: Virgil Date: Wed, 1 Apr 2026 20:13:28 +0000 Subject: [PATCH] feat(api): dedupe PHP OpenAPI operation IDs Co-Authored-By: Virgil --- .../src/Api/Documentation/OpenApiBuilder.php | 22 ++++++++++++++----- .../OpenApiDocumentationComprehensiveTest.php | 20 +++++++++++++++++ 2 files changed, 36 insertions(+), 6 deletions(-) diff --git a/src/php/src/Api/Documentation/OpenApiBuilder.php b/src/php/src/Api/Documentation/OpenApiBuilder.php index 81a3a18..3c7b838 100644 --- a/src/php/src/Api/Documentation/OpenApiBuilder.php +++ b/src/php/src/Api/Documentation/OpenApiBuilder.php @@ -229,6 +229,7 @@ class OpenApiBuilder protected function buildPaths(array $config): array { $paths = []; + $operationIds = []; $includePatterns = $config['routes']['include'] ?? ['api/*']; $excludePatterns = $config['routes']['exclude'] ?? []; @@ -243,7 +244,7 @@ class OpenApiBuilder foreach ($methods as $method) { $method = strtolower($method); - $operation = $this->buildOperation($route, $method, $config); + $operation = $this->buildOperation($route, $method, $config, $operationIds); if ($operation !== null) { $paths[$path][$method] = $operation; @@ -297,7 +298,7 @@ class OpenApiBuilder /** * Build operation for a specific route and method. */ - protected function buildOperation(Route $route, string $method, array $config): ?array + protected function buildOperation(Route $route, string $method, array $config, array &$operationIds): ?array { $controller = $route->getController(); $action = $route->getActionMethod(); @@ -309,7 +310,7 @@ class OpenApiBuilder $operation = [ 'summary' => $this->buildSummary($route, $method), - 'operationId' => $this->buildOperationId($route, $method), + 'operationId' => $this->buildOperationId($route, $method, $operationIds), 'tags' => $this->buildOperationTags($route, $controller, $action), 'responses' => $this->buildResponses($controller, $action), ]; @@ -398,15 +399,24 @@ class OpenApiBuilder /** * Build operation ID from route name. */ - protected function buildOperationId(Route $route, string $method): string + protected function buildOperationId(Route $route, string $method, array &$operationIds): string { $name = $route->getName(); if ($name) { - return Str::camel(str_replace(['.', '-'], '_', $name)); + $base = Str::camel(str_replace(['.', '-'], '_', $name)); + } else { + $base = Str::camel($method.'_'.str_replace(['/', '-', '.'], '_', $route->uri())); } - return Str::camel($method.'_'.str_replace(['/', '-', '.'], '_', $route->uri())); + $count = $operationIds[$base] ?? 0; + $operationIds[$base] = $count + 1; + + if ($count === 0) { + return $base; + } + + return $base.'_'.($count + 1); } /** diff --git a/src/php/src/Api/Tests/Feature/OpenApiDocumentationComprehensiveTest.php b/src/php/src/Api/Tests/Feature/OpenApiDocumentationComprehensiveTest.php index 8bfc5a7..8bf742c 100644 --- a/src/php/src/Api/Tests/Feature/OpenApiDocumentationComprehensiveTest.php +++ b/src/php/src/Api/Tests/Feature/OpenApiDocumentationComprehensiveTest.php @@ -152,6 +152,26 @@ describe('OpenApiBuilder Controller Scanning', function () { expect($operation['operationId'])->toBe('testScanItemsIndex'); }); + it('makes duplicate operation IDs unique', function () { + RouteFacade::prefix('api') + ->middleware('api') + ->group(function () { + RouteFacade::get('/duplicate-id/dup-one', fn () => response()->json([])); + RouteFacade::get('/duplicate-id/dup_one', fn () => response()->json([])); + }); + + config(['api-docs.routes.include' => ['api/*']]); + + $builder = new OpenApiBuilder; + $spec = $builder->build(); + + $first = $spec['paths']['/api/duplicate-id/dup-one']['get']['operationId']; + $second = $spec['paths']['/api/duplicate-id/dup_one']['get']['operationId']; + + expect($first)->not->toBe($second); + expect($second)->toEndWith('_2'); + }); + it('generates summary from route name', function () { $builder = new OpenApiBuilder; $spec = $builder->build();