From e94283b06c4ebdb2861fe2286c6ec44bb19ee10f Mon Sep 17 00:00:00 2001 From: Snider Date: Sat, 21 Feb 2026 00:51:58 +0000 Subject: [PATCH] feat: add spec export helpers for JSON and YAML output ExportSpec writes the OpenAPI spec to any io.Writer in JSON or YAML format. ExportSpecToFile is a convenience wrapper that creates the parent directory and writes to a file path. Adds gopkg.in/yaml.v3 for YAML marshalling. Co-Authored-By: Virgil Co-Authored-By: Claude Opus 4.6 --- export.go | 56 +++++++++++++++++ export_test.go | 166 +++++++++++++++++++++++++++++++++++++++++++++++++ go.mod | 1 + 3 files changed, 223 insertions(+) create mode 100644 export.go create mode 100644 export_test.go diff --git a/export.go b/export.go new file mode 100644 index 0000000..bb233ce --- /dev/null +++ b/export.go @@ -0,0 +1,56 @@ +// SPDX-License-Identifier: EUPL-1.2 + +package api + +import ( + "encoding/json" + "fmt" + "io" + "os" + "path/filepath" + + "gopkg.in/yaml.v3" +) + +// ExportSpec generates the OpenAPI spec and writes it to w. +// Format must be "json" or "yaml". +func ExportSpec(w io.Writer, format string, builder *SpecBuilder, groups []RouteGroup) error { + data, err := builder.Build(groups) + if err != nil { + return fmt.Errorf("build spec: %w", err) + } + + switch format { + case "json": + _, err = w.Write(data) + return err + case "yaml": + // Unmarshal JSON then re-marshal as YAML. + var obj any + if err := json.Unmarshal(data, &obj); err != nil { + return fmt.Errorf("unmarshal spec: %w", err) + } + enc := yaml.NewEncoder(w) + enc.SetIndent(2) + if err := enc.Encode(obj); err != nil { + return fmt.Errorf("encode yaml: %w", err) + } + return enc.Close() + default: + return fmt.Errorf("unsupported format %q: use \"json\" or \"yaml\"", format) + } +} + +// ExportSpecToFile writes the spec to the given path. +// The parent directory is created if it does not exist. +func ExportSpecToFile(path, format string, builder *SpecBuilder, groups []RouteGroup) error { + if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { + return fmt.Errorf("create directory: %w", err) + } + f, err := os.Create(path) + if err != nil { + return fmt.Errorf("create file: %w", err) + } + defer f.Close() + return ExportSpec(f, format, builder, groups) +} diff --git a/export_test.go b/export_test.go new file mode 100644 index 0000000..e0bc056 --- /dev/null +++ b/export_test.go @@ -0,0 +1,166 @@ +// SPDX-License-Identifier: EUPL-1.2 + +package api_test + +import ( + "bytes" + "encoding/json" + "net/http" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/gin-gonic/gin" + "gopkg.in/yaml.v3" + + api "forge.lthn.ai/core/go-api" +) + +// ── ExportSpec tests ───────────────────────────────────────────────────── + +func TestExportSpec_Good_JSON(t *testing.T) { + builder := &api.SpecBuilder{Title: "Test", Description: "Test API", Version: "1.0.0"} + + var buf bytes.Buffer + if err := api.ExportSpec(&buf, "json", builder, nil); 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) + } + + if spec["openapi"] != "3.1.0" { + t.Fatalf("expected openapi=3.1.0, got %v", spec["openapi"]) + } + + info := spec["info"].(map[string]any) + if info["title"] != "Test" { + t.Fatalf("expected title=Test, got %v", info["title"]) + } +} + +func TestExportSpec_Good_YAML(t *testing.T) { + builder := &api.SpecBuilder{Title: "Test", Description: "Test API", Version: "1.0.0"} + + var buf bytes.Buffer + if err := api.ExportSpec(&buf, "yaml", builder, nil); err != nil { + t.Fatalf("unexpected error: %v", err) + } + + output := buf.String() + if !strings.Contains(output, "openapi:") { + t.Fatalf("expected YAML output to contain 'openapi:', got:\n%s", output) + } + + var spec map[string]any + if err := yaml.Unmarshal(buf.Bytes(), &spec); err != nil { + t.Fatalf("output is not valid YAML: %v", err) + } + + if spec["openapi"] != "3.1.0" { + t.Fatalf("expected openapi=3.1.0, got %v", spec["openapi"]) + } +} + +func TestExportSpec_Bad_InvalidFormat(t *testing.T) { + builder := &api.SpecBuilder{Title: "Test", Description: "Test API", Version: "1.0.0"} + + var buf bytes.Buffer + err := api.ExportSpec(&buf, "xml", builder, nil) + if err == nil { + t.Fatal("expected error for unsupported format, got nil") + } + if !strings.Contains(err.Error(), "unsupported format") { + t.Fatalf("expected error to contain 'unsupported format', got: %v", err) + } +} + +func TestExportSpecToFile_Good_CreatesFile(t *testing.T) { + builder := &api.SpecBuilder{Title: "Test", Description: "Test API", Version: "1.0.0"} + + dir := t.TempDir() + path := filepath.Join(dir, "subdir", "spec.json") + + if err := api.ExportSpecToFile(path, "json", builder, nil); err != nil { + t.Fatalf("unexpected error: %v", err) + } + + data, err := os.ReadFile(path) + if err != nil { + t.Fatalf("failed to read file: %v", err) + } + + var spec map[string]any + if err := json.Unmarshal(data, &spec); err != nil { + t.Fatalf("file content is not valid JSON: %v", err) + } + + if spec["openapi"] != "3.1.0" { + t.Fatalf("expected openapi=3.1.0, got %v", spec["openapi"]) + } +} + +func TestExportSpec_Good_WithToolBridge(t *testing.T) { + gin.SetMode(gin.TestMode) + + builder := &api.SpecBuilder{Title: "Test", Description: "Test API", Version: "1.0.0"} + + bridge := api.NewToolBridge("/tools") + bridge.Add(api.ToolDescriptor{ + Name: "file_read", + Description: "Read a file", + Group: "files", + InputSchema: map[string]any{ + "type": "object", + "properties": map[string]any{ + "path": map[string]any{"type": "string"}, + }, + }, + }, func(c *gin.Context) { + c.JSON(http.StatusOK, api.OK("ok")) + }) + bridge.Add(api.ToolDescriptor{ + Name: "metrics_query", + Description: "Query metrics", + Group: "metrics", + InputSchema: map[string]any{ + "type": "object", + "properties": map[string]any{ + "name": map[string]any{"type": "string"}, + }, + }, + }, func(c *gin.Context) { + c.JSON(http.StatusOK, api.OK("ok")) + }) + + var buf bytes.Buffer + if err := api.ExportSpec(&buf, "json", builder, []api.RouteGroup{bridge}); err != nil { + t.Fatalf("unexpected error: %v", err) + } + + output := buf.String() + if !strings.Contains(output, "/tools/file_read") { + t.Fatalf("expected output to contain /tools/file_read, got:\n%s", output) + } + if !strings.Contains(output, "/tools/metrics_query") { + t.Fatalf("expected output to contain /tools/metrics_query, got:\n%s", output) + } + + // Verify it's valid JSON. + var spec map[string]any + if err := json.Unmarshal(buf.Bytes(), &spec); err != nil { + t.Fatalf("output is not valid JSON: %v", err) + } + + // Verify paths exist. + paths := spec["paths"].(map[string]any) + if _, ok := paths["/tools/file_read"]; !ok { + t.Fatal("expected /tools/file_read path in spec") + } + if _, ok := paths["/tools/metrics_query"]; !ok { + t.Fatal("expected /tools/metrics_query path in spec") + } +} diff --git a/go.mod b/go.mod index 15147a2..1fc704a 100644 --- a/go.mod +++ b/go.mod @@ -90,4 +90,5 @@ require ( golang.org/x/tools v0.42.0 // indirect google.golang.org/protobuf v1.36.11 // indirect gopkg.in/yaml.v2 v2.4.0 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect )