feat(openapi): support iterator-backed route descriptions

Co-Authored-By: Virgil <virgil@lethean.io>
This commit is contained in:
Virgil 2026-04-01 20:55:33 +00:00
parent 7e4d8eb179
commit eceda4e5c1
3 changed files with 103 additions and 7 deletions

View file

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

View file

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

View file

@ -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",