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 <virgil@lethean.io>
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Snider 2026-02-21 00:51:58 +00:00
parent 3e96f9b5c2
commit e94283b06c
3 changed files with 223 additions and 0 deletions

56
export.go Normal file
View file

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

166
export_test.go Normal file
View file

@ -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")
}
}

1
go.mod
View file

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