diff --git a/group.go b/group.go index 7084d34..00d8ddb 100644 --- a/group.go +++ b/group.go @@ -64,6 +64,8 @@ type RouteDescription struct { Summary string // Short summary Description string // Long description Tags []string // OpenAPI tags for grouping + // Hidden omits the route from generated documentation. + Hidden bool // Deprecated marks the operation as deprecated in OpenAPI. Deprecated bool // SunsetDate marks when a deprecated operation will be removed. diff --git a/openapi.go b/openapi.go index 2fbdc98..4156883 100644 --- a/openapi.go +++ b/openapi.go @@ -36,8 +36,9 @@ type SpecBuilder struct { } type preparedRouteGroup struct { - group RouteGroup - descs []RouteDescription + group RouteGroup + descs []RouteDescription + describable bool } // Build generates the complete OpenAPI 3.1 JSON spec. @@ -585,7 +586,7 @@ func (sb *SpecBuilder) buildTags(groups []preparedRouteGroup) []map[string]any { for _, g := range groups { name := strings.TrimSpace(g.group.Name()) - if name != "" && !seen[name] { + if name != "" && !seen[name] && (!g.describable || len(g.descs) > 0) { tags = append(tags, map[string]any{ "name": name, "description": name + " endpoints", @@ -759,9 +760,14 @@ func prepareRouteGroups(groups []RouteGroup) []preparedRouteGroup { if g == nil { continue } + if isHiddenRouteGroup(g) { + continue + } + describable := isDescribableRouteGroup(g) out = append(out, preparedRouteGroup{ - group: g, - descs: collectRouteDescriptions(g), + group: g, + descs: collectRouteDescriptions(g), + describable: describable, }) } @@ -789,12 +795,34 @@ func collectRouteDescriptions(g RouteGroup) []RouteDescription { descs := make([]RouteDescription, 0) for rd := range descIter { + if rd.Hidden { + continue + } descs = append(descs, rd) } return descs } +func isHiddenRouteGroup(g RouteGroup) bool { + type hiddenRouteGroup interface { + Hidden() bool + } + + hg, ok := g.(hiddenRouteGroup) + return ok && hg.Hidden() +} + +func isDescribableRouteGroup(g RouteGroup) bool { + if _, ok := g.(DescribableGroupIter); ok { + return true + } + if _, ok := g.(DescribableGroup); ok { + return true + } + return false +} + // routeDescriptions returns OpenAPI route descriptions for a group. // Iterator-backed implementations are preferred when available so builders // can avoid slice allocation. diff --git a/openapi_test.go b/openapi_test.go index 57f795a..cc50d27 100644 --- a/openapi_test.go +++ b/openapi_test.go @@ -18,6 +18,7 @@ import ( type specStubGroup struct { name string basePath string + hidden bool descs []api.RouteDescription } @@ -25,6 +26,7 @@ func (s *specStubGroup) Name() string { return s.name } func (s *specStubGroup) BasePath() string { return s.basePath } func (s *specStubGroup) RegisterRoutes(rg *gin.RouterGroup) {} func (s *specStubGroup) Describe() []api.RouteDescription { return s.descs } +func (s *specStubGroup) Hidden() bool { return s.hidden } type plainStubGroup struct{} @@ -1674,6 +1676,116 @@ func TestSpecBuilder_Good_BlankRouteTagsFallBackToGroupName(t *testing.T) { } } +func TestSpecBuilder_Good_HiddenRoutesAreOmitted(t *testing.T) { + sb := &api.SpecBuilder{ + Title: "Test", + Version: "1.0.0", + } + + visible := &specStubGroup{ + name: "visible", + basePath: "/api", + descs: []api.RouteDescription{ + { + Method: "GET", + Path: "/public", + Summary: "Public endpoint", + Tags: []string{"public"}, + Response: map[string]any{ + "type": "object", + }, + }, + { + Method: "GET", + Path: "/internal", + Summary: "Internal endpoint", + Tags: []string{"internal"}, + Hidden: true, + Response: map[string]any{ + "type": "object", + }, + }, + }, + } + + hidden := &specStubGroup{ + name: "hidden-group", + basePath: "/api/internal", + hidden: true, + descs: []api.RouteDescription{ + { + Method: "GET", + Path: "/status", + Summary: "Hidden group endpoint", + Tags: []string{"hidden"}, + Response: map[string]any{ + "type": "object", + }, + }, + }, + } + + data, err := sb.Build([]api.RouteGroup{visible, hidden}) + 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) + if _, ok := paths["/api/public"]; !ok { + t.Fatal("expected visible route to remain in the spec") + } + if _, ok := paths["/api/internal"]; ok { + t.Fatal("did not expect hidden route to appear in the spec") + } + if _, ok := paths["/api/internal/status"]; ok { + t.Fatal("did not expect hidden group routes to appear in the spec") + } + + tags := spec["tags"].([]any) + foundPublic := false + foundInternal := false + foundHidden := false + foundVisibleGroup := false + foundHiddenGroup := false + for _, raw := range tags { + tag := raw.(map[string]any) + name, _ := tag["name"].(string) + switch name { + case "public": + foundPublic = true + case "internal": + foundInternal = true + case "hidden": + foundHidden = true + case "visible": + foundVisibleGroup = true + case "hidden-group": + foundHiddenGroup = true + } + } + + if !foundPublic { + t.Fatal("expected public tag to remain in the spec") + } + if !foundVisibleGroup { + t.Fatal("expected visible group tag to remain in the spec") + } + if foundInternal { + t.Fatal("did not expect hidden route tag to appear in the spec") + } + if foundHidden { + t.Fatal("did not expect hidden group route tag to appear in the spec") + } + if foundHiddenGroup { + t.Fatal("did not expect hidden group tag to appear in the spec") + } +} + func TestSpecBuilder_Good_ToolBridgeIntegration(t *testing.T) { gin.SetMode(gin.TestMode)