feat(openapi): hide undocumented routes

Co-Authored-By: Virgil <virgil@lethean.io>
This commit is contained in:
Virgil 2026-04-02 00:35:17 +00:00
parent 68f5abefd0
commit 0bb07f43f0
3 changed files with 147 additions and 5 deletions

View file

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

View file

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

View file

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