feat(api): add iterator-backed spec export
Co-Authored-By: Virgil <virgil@lethean.io>
This commit is contained in:
parent
f0d25392a8
commit
ec7391cb06
6 changed files with 139 additions and 5 deletions
|
|
@ -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"))
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
27
cmd/api/spec_groups_iter.go
Normal file
27
cmd/api/spec_groups_iter.go
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
43
export.go
43
export.go
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
}
|
||||
}
|
||||
|
|
|
|||
20
openapi.go
20
openapi.go
|
|
@ -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 {
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue