fix(openapi): snapshot describable groups once

Prepare route descriptions once per group before building paths and tags so iterator-backed DescribeIter implementations are consumed safely and deterministically.

Adds a regression test covering a one-shot iterator-backed group.

Co-Authored-By: Virgil <virgil@lethean.io>
This commit is contained in:
Virgil 2026-04-01 21:07:04 +00:00
parent cb726000a9
commit b0adb53dec
2 changed files with 115 additions and 18 deletions

View file

@ -34,6 +34,11 @@ type SpecBuilder struct {
ExternalDocsURL string
}
type preparedRouteGroup struct {
group RouteGroup
descs []RouteDescription
}
// Build generates the complete OpenAPI 3.1 JSON spec.
// Groups implementing DescribableGroup contribute endpoint documentation.
// Other groups are listed as tags only.
@ -42,6 +47,8 @@ type SpecBuilder struct {
//
// data, err := (&api.SpecBuilder{Title: "Service", Version: "1.0.0"}).Build(engine.Groups())
func (sb *SpecBuilder) Build(groups []RouteGroup) ([]byte, error) {
prepared := prepareRouteGroups(groups)
spec := map[string]any{
"openapi": "3.1.0",
"info": map[string]any{
@ -49,8 +56,8 @@ func (sb *SpecBuilder) Build(groups []RouteGroup) ([]byte, error) {
"description": sb.Description,
"version": sb.Version,
},
"paths": sb.buildPaths(groups),
"tags": sb.buildTags(groups),
"paths": sb.buildPaths(prepared),
"tags": sb.buildTags(prepared),
"security": []any{
map[string]any{
"bearerAuth": []any{},
@ -154,7 +161,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 {
func (sb *SpecBuilder) buildPaths(groups []preparedRouteGroup) map[string]any {
operationIDs := map[string]int{}
paths := map[string]any{
// Built-in health endpoint.
@ -170,12 +177,8 @@ func (sb *SpecBuilder) buildPaths(groups []RouteGroup) map[string]any {
}
for _, g := range groups {
descIter := routeDescriptions(g)
if descIter == nil {
continue
}
for rd := range descIter {
fullPath := joinOpenAPIPath(g.BasePath(), rd.Path)
for _, rd := range g.descs {
fullPath := joinOpenAPIPath(g.group.BasePath(), rd.Path)
method := strings.ToLower(rd.Method)
operation := map[string]any{
@ -196,7 +199,7 @@ func (sb *SpecBuilder) buildPaths(groups []RouteGroup) map[string]any {
},
}
}
if tags := resolvedOperationTags(g, rd); len(tags) > 0 {
if tags := resolvedOperationTags(g.group, rd); len(tags) > 0 {
operation["tags"] = tags
}
@ -468,14 +471,14 @@ func healthResponses() map[string]any {
}
// buildTags generates the tags array from all RouteGroups.
func (sb *SpecBuilder) buildTags(groups []RouteGroup) []map[string]any {
func (sb *SpecBuilder) buildTags(groups []preparedRouteGroup) []map[string]any {
tags := []map[string]any{
{"name": "system", "description": "System endpoints"},
}
seen := map[string]bool{"system": true}
for _, g := range groups {
name := strings.TrimSpace(g.Name())
name := strings.TrimSpace(g.group.Name())
if name != "" && !seen[name] {
tags = append(tags, map[string]any{
"name": name,
@ -484,12 +487,7 @@ func (sb *SpecBuilder) buildTags(groups []RouteGroup) []map[string]any {
seen[name] = true
}
descIter := routeDescriptions(g)
if descIter == nil {
continue
}
for rd := range descIter {
for _, rd := range g.descs {
for _, tag := range rd.Tags {
tag = strings.TrimSpace(tag)
if tag == "" || seen[tag] {
@ -507,6 +505,41 @@ func (sb *SpecBuilder) buildTags(groups []RouteGroup) []map[string]any {
return tags
}
// prepareRouteGroups snapshots route descriptions once per group so iterator-
// backed implementations can be consumed safely by both tag and path builders.
func prepareRouteGroups(groups []RouteGroup) []preparedRouteGroup {
if len(groups) == 0 {
return nil
}
out := make([]preparedRouteGroup, 0, len(groups))
for _, g := range groups {
if g == nil {
continue
}
out = append(out, preparedRouteGroup{
group: g,
descs: collectRouteDescriptions(g),
})
}
return out
}
func collectRouteDescriptions(g RouteGroup) []RouteDescription {
descIter := routeDescriptions(g)
if descIter == nil {
return nil
}
descs := make([]RouteDescription, 0)
for rd := range descIter {
descs = append(descs, rd)
}
return descs
}
// routeDescriptions returns OpenAPI route descriptions for a group.
// Iterator-backed implementations are preferred when available so builders
// can avoid slice allocation.

View file

@ -52,6 +52,28 @@ func (s *iterStubGroup) DescribeIter() iter.Seq[api.RouteDescription] {
}
}
type countingIterGroup struct {
name string
basePath string
descs []api.RouteDescription
describeCalls int
}
func (s *countingIterGroup) Name() string { return s.name }
func (s *countingIterGroup) BasePath() string { return s.basePath }
func (s *countingIterGroup) RegisterRoutes(rg *gin.RouterGroup) {}
func (s *countingIterGroup) Describe() []api.RouteDescription { return nil }
func (s *countingIterGroup) DescribeIter() iter.Seq[api.RouteDescription] {
s.describeCalls++
return func(yield func(api.RouteDescription) bool) {
for _, rd := range s.descs {
if !yield(rd) {
return
}
}
}
}
// ── SpecBuilder tests ─────────────────────────────────────────────────────
func TestSpecBuilder_Good_EmptyGroups(t *testing.T) {
@ -443,6 +465,48 @@ func TestSpecBuilder_Good_DescribeIterGroup(t *testing.T) {
}
}
func TestSpecBuilder_Good_DescribeIterSnapshotOnce(t *testing.T) {
sb := &api.SpecBuilder{
Title: "Test",
Version: "1.0.0",
}
group := &countingIterGroup{
name: "counted",
basePath: "/api/count",
descs: []api.RouteDescription{
{
Method: "GET",
Path: "/status",
Summary: "Counted status",
Tags: []string{"counted"},
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)
}
if group.describeCalls != 1 {
t.Fatalf("expected DescribeIter to be called once, got %d", group.describeCalls)
}
op := spec["paths"].(map[string]any)["/api/count/status"].(map[string]any)["get"].(map[string]any)
if op["summary"] != "Counted status" {
t.Fatalf("expected summary='Counted status', got %v", op["summary"])
}
}
func TestSpecBuilder_Good_SecuredResponses(t *testing.T) {
sb := &api.SpecBuilder{
Title: "Test",