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:
parent
cb726000a9
commit
b0adb53dec
2 changed files with 115 additions and 18 deletions
69
openapi.go
69
openapi.go
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue