From f0306655665dbe3ea985194aaa8dada736a3e530 Mon Sep 17 00:00:00 2001 From: Virgil Date: Wed, 1 Apr 2026 08:11:33 +0000 Subject: [PATCH] feat(api): preserve path params in operationId --- openapi.go | 31 ++++++++++++++++++++------- openapi_test.go | 56 +++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 80 insertions(+), 7 deletions(-) diff --git a/openapi.go b/openapi.go index 40a929e..0186c48 100644 --- a/openapi.go +++ b/openapi.go @@ -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) } diff --git a/openapi_test.go b/openapi_test.go index 058e455..695bf4d 100644 --- a/openapi_test.go +++ b/openapi_test.go @@ -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",