From eceda4e5c1d01f0e1c27a69f5cbaa2be14095374 Mon Sep 17 00:00:00 2001 From: Virgil Date: Wed, 1 Apr 2026 20:55:33 +0000 Subject: [PATCH] feat(openapi): support iterator-backed route descriptions Co-Authored-By: Virgil --- group.go | 14 ++++++++++- openapi.go | 33 +++++++++++++++++++++----- openapi_test.go | 63 +++++++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 103 insertions(+), 7 deletions(-) diff --git a/group.go b/group.go index 44fc9c4..bc8f372 100644 --- a/group.go +++ b/group.go @@ -2,7 +2,11 @@ package api -import "github.com/gin-gonic/gin" +import ( + "iter" + + "github.com/gin-gonic/gin" +) // RouteGroup registers API routes onto a Gin router group. // Subsystems implement this interface to declare their endpoints. @@ -32,6 +36,14 @@ type DescribableGroup interface { Describe() []RouteDescription } +// DescribableGroupIter extends DescribableGroup with an iterator-based +// description source for callers that want to avoid slice allocation. +type DescribableGroupIter interface { + DescribableGroup + // DescribeIter returns endpoint descriptions for OpenAPI generation. + DescribeIter() iter.Seq[RouteDescription] +} + // RouteDescription describes a single endpoint for OpenAPI generation. type RouteDescription struct { Method string // HTTP method: GET, POST, PUT, DELETE, PATCH diff --git a/openapi.go b/openapi.go index 8f5fb1f..a350370 100644 --- a/openapi.go +++ b/openapi.go @@ -4,6 +4,7 @@ package api import ( "encoding/json" + "iter" "net/http" "strconv" "strings" @@ -169,11 +170,11 @@ func (sb *SpecBuilder) buildPaths(groups []RouteGroup) map[string]any { } for _, g := range groups { - dg, ok := g.(DescribableGroup) - if !ok { + descIter := routeDescriptions(g) + if descIter == nil { continue } - for _, rd := range dg.Describe() { + for rd := range descIter { fullPath := joinOpenAPIPath(g.BasePath(), rd.Path) method := strings.ToLower(rd.Method) @@ -483,12 +484,12 @@ func (sb *SpecBuilder) buildTags(groups []RouteGroup) []map[string]any { seen[name] = true } - dg, ok := g.(DescribableGroup) - if !ok { + descIter := routeDescriptions(g) + if descIter == nil { continue } - for _, rd := range dg.Describe() { + for rd := range descIter { for _, tag := range rd.Tags { tag = strings.TrimSpace(tag) if tag == "" || seen[tag] { @@ -506,6 +507,26 @@ func (sb *SpecBuilder) buildTags(groups []RouteGroup) []map[string]any { return tags } +// routeDescriptions returns OpenAPI route descriptions for a group. +// Iterator-backed implementations are preferred when available so builders +// can avoid slice allocation. +func routeDescriptions(g RouteGroup) iter.Seq[RouteDescription] { + if dg, ok := g.(DescribableGroupIter); ok { + return dg.DescribeIter() + } + if dg, ok := g.(DescribableGroup); ok { + descs := dg.Describe() + return func(yield func(RouteDescription) bool) { + for _, rd := range descs { + if !yield(rd) { + return + } + } + } + } + return nil +} + // pathParameters extracts unique OpenAPI path parameters from a path template. // Parameters are returned in the order they appear in the path. func pathParameters(path string) []map[string]any { diff --git a/openapi_test.go b/openapi_test.go index 6fe0812..d858f5f 100644 --- a/openapi_test.go +++ b/openapi_test.go @@ -4,6 +4,7 @@ package api_test import ( "encoding/json" + "iter" "net/http" "testing" @@ -31,6 +32,26 @@ func (plainStubGroup) Name() string { return "plain" } func (plainStubGroup) BasePath() string { return "/plain" } func (plainStubGroup) RegisterRoutes(rg *gin.RouterGroup) {} +type iterStubGroup struct { + name string + basePath string + descs []api.RouteDescription +} + +func (s *iterStubGroup) Name() string { return s.name } +func (s *iterStubGroup) BasePath() string { return s.basePath } +func (s *iterStubGroup) RegisterRoutes(rg *gin.RouterGroup) {} +func (s *iterStubGroup) Describe() []api.RouteDescription { return nil } +func (s *iterStubGroup) DescribeIter() iter.Seq[api.RouteDescription] { + 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) { @@ -380,6 +401,48 @@ func TestSpecBuilder_Good_WithDescribableGroup(t *testing.T) { } } +func TestSpecBuilder_Good_DescribeIterGroup(t *testing.T) { + sb := &api.SpecBuilder{ + Title: "Test", + Version: "1.0.0", + } + + group := &iterStubGroup{ + name: "iter", + basePath: "/api/iter", + descs: []api.RouteDescription{ + { + Method: "GET", + Path: "/status", + Summary: "Iter status", + Tags: []string{"iter"}, + 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) + } + + op := spec["paths"].(map[string]any)["/api/iter/status"].(map[string]any)["get"].(map[string]any) + if op["summary"] != "Iter status" { + t.Fatalf("expected summary='Iter status', got %v", op["summary"]) + } + tags, ok := op["tags"].([]any) + if !ok || len(tags) != 1 || tags[0] != "iter" { + t.Fatalf("expected tags to be populated from DescribeIter, got %v", op["tags"]) + } +} + func TestSpecBuilder_Good_SecuredResponses(t *testing.T) { sb := &api.SpecBuilder{ Title: "Test",