feat(api): preserve path params in operationId

This commit is contained in:
Virgil 2026-04-01 08:11:33 +00:00
parent 16abc45efa
commit f030665566
2 changed files with 80 additions and 7 deletions

View file

@ -4,6 +4,7 @@ package api
import (
"encoding/json"
"strconv"
"strings"
"unicode"
)
@ -60,6 +61,7 @@ func (sb *SpecBuilder) Build(groups []RouteGroup) ([]byte, error) {
// buildPaths generates the paths object from all DescribableGroups.
func (sb *SpecBuilder) buildPaths(groups []RouteGroup) map[string]any {
operationIDs := map[string]int{}
paths := map[string]any{
// Built-in health endpoint.
"/health": map[string]any{
@ -67,7 +69,7 @@ func (sb *SpecBuilder) buildPaths(groups []RouteGroup) map[string]any {
"summary": "Health check",
"description": "Returns server health status",
"tags": []string{"system"},
"operationId": operationID("get", "/health"),
"operationId": operationID("get", "/health", operationIDs),
"responses": map[string]any{
"200": map[string]any{
"description": "Server is healthy",
@ -95,7 +97,7 @@ func (sb *SpecBuilder) buildPaths(groups []RouteGroup) map[string]any {
"summary": rd.Summary,
"description": rd.Description,
"tags": rd.Tags,
"operationId": operationID(method, fullPath),
"operationId": operationID(method, fullPath, operationIDs),
"responses": map[string]any{
"200": map[string]any{
"description": "Successful response",
@ -187,9 +189,8 @@ func envelopeSchema(dataSchema map[string]any) map[string]any {
}
// operationID builds a stable OpenAPI operationId from the HTTP method and path.
// The generated identifier is lower snake_case and strips path parameter braces
// so it stays friendly for downstream SDK generators.
func operationID(method, path string) string {
// The generated identifier is lower snake_case and preserves path parameter names.
func operationID(method, path string, operationIDs map[string]int) string {
var b strings.Builder
b.Grow(len(method) + len(path) + 1)
lastUnderscore := false
@ -219,7 +220,13 @@ func operationID(method, path string) string {
writeUnderscore()
for _, r := range path {
switch r {
case '/', '-', '.', '{', '}', ' ':
case '/':
writeUnderscore()
case '-':
writeUnderscore()
case '.':
writeUnderscore()
case ' ':
writeUnderscore()
default:
appendToken(r)
@ -230,5 +237,15 @@ func operationID(method, path string) string {
if out == "" {
return "operation"
}
return out
if operationIDs == nil {
return out
}
count := operationIDs[out]
operationIDs[out] = count + 1
if count == 0 {
return out
}
return out + "_" + strconv.Itoa(count+1)
}

View file

@ -253,6 +253,62 @@ func TestSpecBuilder_Good_EnvelopeWrapping(t *testing.T) {
}
}
func TestSpecBuilder_Good_OperationIDPreservesPathParams(t *testing.T) {
sb := &api.SpecBuilder{
Title: "Test",
Version: "1.0.0",
}
group := &specStubGroup{
name: "users",
basePath: "/api",
descs: []api.RouteDescription{
{
Method: "GET",
Path: "/users/{id}",
Summary: "Get user by id",
Tags: []string{"users"},
Response: map[string]any{
"type": "object",
},
},
{
Method: "GET",
Path: "/users/{name}",
Summary: "Get user by name",
Tags: []string{"users"},
Response: map[string]any{
"type": "object",
},
},
},
}
data, err := sb.Build([]api.RouteGroup{group})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
var spec map[string]any
if err := json.Unmarshal(data, &spec); err != nil {
t.Fatalf("invalid JSON: %v", err)
}
paths := spec["paths"].(map[string]any)
byID := paths["/api/users/{id}"].(map[string]any)["get"].(map[string]any)
byName := paths["/api/users/{name}"].(map[string]any)["get"].(map[string]any)
if byID["operationId"] != "get_api_users_id" {
t.Fatalf("expected operationId='get_api_users_id', got %v", byID["operationId"])
}
if byName["operationId"] != "get_api_users_name" {
t.Fatalf("expected operationId='get_api_users_name', got %v", byName["operationId"])
}
if byID["operationId"] == byName["operationId"] {
t.Fatal("expected unique operationId values for distinct path parameters")
}
}
func TestSpecBuilder_Good_NonDescribableGroup(t *testing.T) {
sb := &api.SpecBuilder{
Title: "Test",