diff --git a/openapi.go b/openapi.go index 670a3ef..94e014f 100644 --- a/openapi.go +++ b/openapi.go @@ -122,7 +122,6 @@ func (sb *SpecBuilder) buildPaths(groups []RouteGroup) map[string]any { operation := map[string]any{ "summary": rd.Summary, "description": rd.Description, - "tags": resolvedOperationTags(g, rd), "operationId": operationID(method, fullPath, operationIDs), "security": []any{ map[string]any{ @@ -131,6 +130,9 @@ func (sb *SpecBuilder) buildPaths(groups []RouteGroup) map[string]any { }, "responses": operationResponses(method, rd.Response), } + if tags := resolvedOperationTags(g, rd); len(tags) > 0 { + operation["tags"] = tags + } if params := pathParameters(fullPath); len(params) > 0 { operation["parameters"] = params @@ -341,8 +343,8 @@ func (sb *SpecBuilder) buildTags(groups []RouteGroup) []map[string]any { seen := map[string]bool{"system": true} for _, g := range groups { - name := g.Name() - if !seen[name] { + name := strings.TrimSpace(g.Name()) + if name != "" && !seen[name] { tags = append(tags, map[string]any{ "name": name, "description": name + " endpoints", @@ -357,6 +359,7 @@ func (sb *SpecBuilder) buildTags(groups []RouteGroup) []map[string]any { for _, rd := range dg.Describe() { for _, tag := range rd.Tags { + tag = strings.TrimSpace(tag) if tag == "" || seen[tag] { continue } @@ -491,16 +494,42 @@ func mergeOperationParameters(existing any, explicit []map[string]any) []map[str // stable fallback derived from the group's name when the route omits tags. func resolvedOperationTags(g RouteGroup, rd RouteDescription) []string { if len(rd.Tags) > 0 { - return rd.Tags + return cleanTags(rd.Tags) } - if name := g.Name(); name != "" { + if name := strings.TrimSpace(g.Name()); name != "" { return []string{name} } return nil } +// cleanTags trims whitespace and removes empty or duplicate tags while +// preserving the first occurrence of each name. +func cleanTags(tags []string) []string { + if len(tags) == 0 { + return nil + } + + cleaned := make([]string, 0, len(tags)) + seen := make(map[string]struct{}, len(tags)) + for _, tag := range tags { + tag = strings.TrimSpace(tag) + if tag == "" { + continue + } + if _, ok := seen[tag]; ok { + continue + } + seen[tag] = struct{}{} + cleaned = append(cleaned, tag) + } + if len(cleaned) == 0 { + return nil + } + return cleaned +} + // envelopeSchema wraps a data schema in the standard Response[T] envelope. func envelopeSchema(dataSchema map[string]any) map[string]any { properties := map[string]any{ diff --git a/openapi_test.go b/openapi_test.go index 5b55ef6..b0f737b 100644 --- a/openapi_test.go +++ b/openapi_test.go @@ -833,6 +833,64 @@ func TestSpecBuilder_Good_DefaultTagsFromGroupName(t *testing.T) { } } +func TestSpecBuilder_Good_BlankTagsAreIgnored(t *testing.T) { + sb := &api.SpecBuilder{ + Title: "Test", + Version: "1.0.0", + } + + group := &specStubGroup{ + name: " ", + basePath: "/api/blank", + descs: []api.RouteDescription{ + { + Method: "GET", + Path: "/status", + Summary: "Check status", + Tags: []string{"", " ", "data", "data"}, + 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) + } + + tags := spec["tags"].([]any) + var foundData bool + for _, raw := range tags { + tag := raw.(map[string]any) + name, _ := tag["name"].(string) + if name == "" { + t.Fatal("expected blank tag names to be ignored") + } + if name == "data" { + foundData = true + } + } + if !foundData { + t.Fatal("expected data tag to be retained") + } + + op := spec["paths"].(map[string]any)["/api/blank/status"].(map[string]any)["get"].(map[string]any) + opTags, ok := op["tags"].([]any) + if !ok { + t.Fatalf("expected tags array, got %T", op["tags"]) + } + if len(opTags) != 1 || opTags[0] != "data" { + t.Fatalf("expected operation tags to be cleaned to [data], got %v", opTags) + } +} + func TestSpecBuilder_Good_ToolBridgeIntegration(t *testing.T) { gin.SetMode(gin.TestMode)