feat(api): dedupe PHP OpenAPI operation IDs

Co-Authored-By: Virgil <virgil@lethean.io>
This commit is contained in:
Virgil 2026-04-01 20:13:28 +00:00
parent c9627729b5
commit e2935ce79e
2 changed files with 36 additions and 6 deletions

View file

@ -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);
}
/**

View file

@ -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();