diff --git a/cmd/api/cmd_sdk.go b/cmd/api/cmd_sdk.go index 914dab2..5d3afe6 100644 --- a/cmd/api/cmd_sdk.go +++ b/cmd/api/cmd_sdk.go @@ -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")) +} diff --git a/cmd/api/cmd_spec.go b/cmd/api/cmd_spec.go index 0fb4da2..f22ba07 100644 --- a/cmd/api/cmd_spec.go +++ b/cmd/api/cmd_spec.go @@ -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") diff --git a/cmd/api/spec_groups_iter.go b/cmd/api/spec_groups_iter.go new file mode 100644 index 0000000..ce910e5 --- /dev/null +++ b/cmd/api/spec_groups_iter.go @@ -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 + } + } + } +} diff --git a/export.go b/export.go index 08308f2..8fe7c16 100644 --- a/export.go +++ b/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) +} diff --git a/export_test.go b/export_test.go index 1a26e33..1288381 100644 --- a/export_test.go +++ b/export_test.go @@ -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") + } +} diff --git a/openapi.go b/openapi.go index 8300606..b23d3d3 100644 --- a/openapi.go +++ b/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 {