fix(openapi): skip blank tags in generated specs

Co-Authored-By: Virgil <virgil@lethean.io>
This commit is contained in:
Virgil 2026-04-01 19:35:36 +00:00
parent 3f010b855e
commit 13cc93f4f4
2 changed files with 92 additions and 5 deletions

View file

@ -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{

View file

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