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:
parent
3e96f9b5c2
commit
e94283b06c
3 changed files with 223 additions and 0 deletions
56
export.go
Normal file
56
export.go
Normal 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
166
export_test.go
Normal 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
1
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
|
||||
)
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue