feat(api): add iterator-backed spec export

Co-Authored-By: Virgil <virgil@lethean.io>
This commit is contained in:
Virgil 2026-04-01 22:25:07 +00:00
parent f0d25392a8
commit ec7391cb06
6 changed files with 139 additions and 5 deletions

View file

@ -5,6 +5,7 @@ package api
import (
"context"
"fmt"
"iter"
"os"
"strings"
@ -51,7 +52,7 @@ func addSDKCommand(parent *cli.Command) {
// If no spec file provided, generate one to a temp file.
if specFile == "" {
builder := sdkSpecBuilder(title, description, version, termsURL, contactName, contactURL, contactEmail, licenseName, licenseURL, externalDocsDescription, externalDocsURL, servers)
groups := sdkSpecGroups()
groups := sdkSpecGroupsIter()
tmpFile, err := os.CreateTemp("", "openapi-*.json")
if err != nil {
@ -59,7 +60,7 @@ func addSDKCommand(parent *cli.Command) {
}
defer coreio.Local.Delete(tmpFile.Name())
if err := goapi.ExportSpec(tmpFile, "json", builder, groups); err != nil {
if err := goapi.ExportSpecIter(tmpFile, "json", builder, groups); err != nil {
tmpFile.Close()
return coreerr.E("sdk.Generate", "generate spec", err)
}
@ -133,3 +134,7 @@ func sdkSpecGroups() []goapi.RouteGroup {
bridge := goapi.NewToolBridge("/tools")
return append(goapi.RegisteredSpecGroups(), bridge)
}
func sdkSpecGroupsIter() iter.Seq[goapi.RouteGroup] {
return specGroupsIter(goapi.NewToolBridge("/tools"))
}

View file

@ -47,17 +47,17 @@ func addSpecCommand(parent *cli.Command) {
}
bridge := goapi.NewToolBridge("/tools")
groups := append(goapi.RegisteredSpecGroups(), bridge)
groups := specGroupsIter(bridge)
if output != "" {
if err := goapi.ExportSpecToFile(output, format, builder, groups); err != nil {
if err := goapi.ExportSpecToFileIter(output, format, builder, groups); err != nil {
return err
}
fmt.Fprintf(os.Stderr, "Spec written to %s\n", output)
return nil
}
return goapi.ExportSpec(os.Stdout, format, builder, groups)
return goapi.ExportSpecIter(os.Stdout, format, builder, groups)
})
cli.StringFlag(cmd, &output, "output", "o", "", "Write spec to file instead of stdout")

View file

@ -0,0 +1,27 @@
// SPDX-License-Identifier: EUPL-1.2
package api
import (
"iter"
goapi "dappco.re/go/core/api"
)
// specGroupsIter snapshots the registered spec groups and appends one optional
// extra group. It keeps the command paths iterator-backed while preserving the
// existing ordering guarantees.
func specGroupsIter(extra goapi.RouteGroup) iter.Seq[goapi.RouteGroup] {
return func(yield func(goapi.RouteGroup) bool) {
for group := range goapi.RegisteredSpecGroupsIter() {
if !yield(group) {
return
}
}
if extra != nil {
if !yield(extra) {
return
}
}
}
}

View file

@ -5,6 +5,7 @@ package api
import (
"encoding/json"
"io"
"iter"
"os"
"path/filepath"
@ -47,6 +48,34 @@ func ExportSpec(w io.Writer, format string, builder *SpecBuilder, groups []Route
}
}
// ExportSpecIter generates the OpenAPI spec from an iterator and writes it to w.
// Format must be "json" or "yaml".
func ExportSpecIter(w io.Writer, format string, builder *SpecBuilder, groups iter.Seq[RouteGroup]) error {
data, err := builder.BuildIter(groups)
if err != nil {
return coreerr.E("ExportSpecIter", "build spec", err)
}
switch format {
case "json":
_, err = w.Write(data)
return err
case "yaml":
var obj any
if err := json.Unmarshal(data, &obj); err != nil {
return coreerr.E("ExportSpecIter", "unmarshal spec", err)
}
enc := yaml.NewEncoder(w)
enc.SetIndent(2)
if err := enc.Encode(obj); err != nil {
return coreerr.E("ExportSpecIter", "encode yaml", err)
}
return enc.Close()
default:
return coreerr.E("ExportSpecIter", "unsupported format "+format+": use \"json\" or \"yaml\"", nil)
}
}
// ExportSpecToFile writes the spec to the given path.
// The parent directory is created if it does not exist.
//
@ -64,3 +93,17 @@ func ExportSpecToFile(path, format string, builder *SpecBuilder, groups []RouteG
defer f.Close()
return ExportSpec(f, format, builder, groups)
}
// ExportSpecToFileIter writes the OpenAPI spec from an iterator to the given path.
// The parent directory is created if it does not exist.
func ExportSpecToFileIter(path, format string, builder *SpecBuilder, groups iter.Seq[RouteGroup]) error {
if err := coreio.Local.EnsureDir(filepath.Dir(path)); err != nil {
return coreerr.E("ExportSpecToFileIter", "create directory", err)
}
f, err := os.Create(path)
if err != nil {
return coreerr.E("ExportSpecToFileIter", "create file", err)
}
defer f.Close()
return ExportSpecIter(f, format, builder, groups)
}

View file

@ -5,6 +5,7 @@ package api_test
import (
"bytes"
"encoding/json"
"iter"
"net/http"
"os"
"path/filepath"
@ -164,3 +165,41 @@ func TestExportSpec_Good_WithToolBridge(t *testing.T) {
t.Fatal("expected /tools/metrics_query path in spec")
}
}
func TestExportSpecIter_Good_WithGroupIterator(t *testing.T) {
builder := &api.SpecBuilder{Title: "Test", Description: "Test API", Version: "1.0.0"}
group := &specStubGroup{
name: "iter",
basePath: "/iter",
descs: []api.RouteDescription{
{
Method: "GET",
Path: "/ping",
Summary: "Ping iter group",
Response: map[string]any{
"type": "string",
},
},
},
}
groups := iter.Seq[api.RouteGroup](func(yield func(api.RouteGroup) bool) {
_ = yield(group)
})
var buf bytes.Buffer
if err := api.ExportSpecIter(&buf, "json", builder, groups); err != nil {
t.Fatalf("unexpected error: %v", err)
}
var spec map[string]any
if err := json.Unmarshal(buf.Bytes(), &spec); err != nil {
t.Fatalf("output is not valid JSON: %v", err)
}
paths := spec["paths"].(map[string]any)
if _, ok := paths["/iter/ping"]; !ok {
t.Fatal("expected /iter/ping path in spec")
}
}

View file

@ -160,6 +160,13 @@ func (sb *SpecBuilder) Build(groups []RouteGroup) ([]byte, error) {
return json.MarshalIndent(spec, "", " ")
}
// BuildIter generates the complete OpenAPI 3.1 JSON spec from a route-group
// iterator. The iterator is snapshotted before building so the result stays
// stable even if the source changes during rendering.
func (sb *SpecBuilder) BuildIter(groups iter.Seq[RouteGroup]) ([]byte, error) {
return sb.Build(collectRouteGroups(groups))
}
// buildPaths generates the paths object from all DescribableGroups.
func (sb *SpecBuilder) buildPaths(groups []preparedRouteGroup) map[string]any {
operationIDs := map[string]int{}
@ -539,6 +546,19 @@ func prepareRouteGroups(groups []RouteGroup) []preparedRouteGroup {
return out
}
func collectRouteGroups(groups iter.Seq[RouteGroup]) []RouteGroup {
if groups == nil {
return nil
}
out := make([]RouteGroup, 0)
for group := range groups {
out = append(out, group)
}
return out
}
func collectRouteDescriptions(g RouteGroup) []RouteDescription {
descIter := routeDescriptions(g)
if descIter == nil {