go-api/openapi_test.go

404 lines
10 KiB
Go
Raw Normal View History

// SPDX-License-Identifier: EUPL-1.2
package api_test
import (
"encoding/json"
"net/http"
"testing"
"github.com/gin-gonic/gin"
api "forge.lthn.ai/core/go-api"
)
// ── Test helpers ──────────────────────────────────────────────────────────
type specStubGroup struct {
name string
basePath string
descs []api.RouteDescription
}
func (s *specStubGroup) Name() string { return s.name }
func (s *specStubGroup) BasePath() string { return s.basePath }
func (s *specStubGroup) RegisterRoutes(rg *gin.RouterGroup) {}
func (s *specStubGroup) Describe() []api.RouteDescription { return s.descs }
type plainStubGroup struct{}
func (plainStubGroup) Name() string { return "plain" }
func (plainStubGroup) BasePath() string { return "/plain" }
func (plainStubGroup) RegisterRoutes(rg *gin.RouterGroup) {}
// ── SpecBuilder tests ─────────────────────────────────────────────────────
func TestSpecBuilder_Good_EmptyGroups(t *testing.T) {
sb := &api.SpecBuilder{
Title: "Test",
Description: "Empty test",
Version: "0.0.1",
}
data, err := sb.Build(nil)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
var spec map[string]any
if err := json.Unmarshal(data, &spec); err != nil {
t.Fatalf("invalid JSON: %v", err)
}
// Verify OpenAPI version.
if spec["openapi"] != "3.1.0" {
t.Fatalf("expected openapi=3.1.0, got %v", spec["openapi"])
}
// Verify /health path exists.
paths := spec["paths"].(map[string]any)
if _, ok := paths["/health"]; !ok {
t.Fatal("expected /health path in spec")
}
// Verify system tag exists.
tags := spec["tags"].([]any)
found := false
for _, tag := range tags {
tm := tag.(map[string]any)
if tm["name"] == "system" {
found = true
break
}
}
if !found {
t.Fatal("expected system tag in spec")
}
}
func TestSpecBuilder_Good_WithDescribableGroup(t *testing.T) {
sb := &api.SpecBuilder{
Title: "Test",
Description: "Test API",
Version: "1.0.0",
}
group := &specStubGroup{
name: "items",
basePath: "/api/items",
descs: []api.RouteDescription{
{
Method: "GET",
Path: "/list",
Summary: "List items",
Tags: []string{"items"},
Response: map[string]any{
"type": "array",
"items": map[string]any{
"type": "string",
},
},
},
{
Method: "POST",
Path: "/create",
Summary: "Create item",
Description: "Creates a new item",
Tags: []string{"items"},
RequestBody: map[string]any{
"type": "object",
"properties": map[string]any{
"name": map[string]any{"type": "string"},
},
},
Response: map[string]any{
"type": "object",
"properties": map[string]any{
"id": map[string]any{"type": "integer"},
},
},
},
},
}
data, err := sb.Build([]api.RouteGroup{group})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
var spec map[string]any
if err := json.Unmarshal(data, &spec); err != nil {
t.Fatalf("invalid JSON: %v", err)
}
paths := spec["paths"].(map[string]any)
// Verify GET /api/items/list exists.
listPath, ok := paths["/api/items/list"]
if !ok {
t.Fatal("expected /api/items/list path in spec")
}
getOp := listPath.(map[string]any)["get"]
if getOp == nil {
t.Fatal("expected GET operation on /api/items/list")
}
if getOp.(map[string]any)["summary"] != "List items" {
t.Fatalf("expected summary='List items', got %v", getOp.(map[string]any)["summary"])
}
// Verify POST /api/items/create exists with request body.
createPath, ok := paths["/api/items/create"]
if !ok {
t.Fatal("expected /api/items/create path in spec")
}
postOp := createPath.(map[string]any)["post"]
if postOp == nil {
t.Fatal("expected POST operation on /api/items/create")
}
if postOp.(map[string]any)["summary"] != "Create item" {
t.Fatalf("expected summary='Create item', got %v", postOp.(map[string]any)["summary"])
}
if postOp.(map[string]any)["requestBody"] == nil {
t.Fatal("expected requestBody on POST /api/items/create")
}
}
func TestSpecBuilder_Good_EnvelopeWrapping(t *testing.T) {
sb := &api.SpecBuilder{
Title: "Test",
Version: "1.0.0",
}
group := &specStubGroup{
name: "data",
basePath: "/data",
descs: []api.RouteDescription{
{
Method: "GET",
Path: "/fetch",
Summary: "Fetch data",
Tags: []string{"data"},
Response: map[string]any{
"type": "object",
"properties": map[string]any{
"value": map[string]any{"type": "string"},
},
},
},
},
}
data, err := sb.Build([]api.RouteGroup{group})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
var spec map[string]any
if err := json.Unmarshal(data, &spec); err != nil {
t.Fatalf("invalid JSON: %v", err)
}
paths := spec["paths"].(map[string]any)
fetchPath := paths["/data/fetch"].(map[string]any)
getOp := fetchPath["get"].(map[string]any)
responses := getOp["responses"].(map[string]any)
resp200 := responses["200"].(map[string]any)
content := resp200["content"].(map[string]any)
appJSON := content["application/json"].(map[string]any)
schema := appJSON["schema"].(map[string]any)
// Verify envelope structure.
if schema["type"] != "object" {
t.Fatalf("expected schema type=object, got %v", schema["type"])
}
properties := schema["properties"].(map[string]any)
// Verify success field.
success := properties["success"].(map[string]any)
if success["type"] != "boolean" {
t.Fatalf("expected success.type=boolean, got %v", success["type"])
}
// Verify data field contains the original response schema.
dataField := properties["data"].(map[string]any)
if dataField["type"] != "object" {
t.Fatalf("expected data.type=object, got %v", dataField["type"])
}
dataProps := dataField["properties"].(map[string]any)
if dataProps["value"] == nil {
t.Fatal("expected data.properties.value to exist")
}
// Verify required contains "success".
required := schema["required"].([]any)
foundSuccess := false
for _, r := range required {
if r == "success" {
foundSuccess = true
break
}
}
if !foundSuccess {
t.Fatal("expected 'success' in required array")
}
}
func TestSpecBuilder_Good_NonDescribableGroup(t *testing.T) {
sb := &api.SpecBuilder{
Title: "Test",
Version: "1.0.0",
}
data, err := sb.Build([]api.RouteGroup{plainStubGroup{}})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
var spec map[string]any
if err := json.Unmarshal(data, &spec); err != nil {
t.Fatalf("invalid JSON: %v", err)
}
// Verify plainStubGroup appears in tags.
tags := spec["tags"].([]any)
foundPlain := false
for _, tag := range tags {
tm := tag.(map[string]any)
if tm["name"] == "plain" {
foundPlain = true
break
}
}
if !foundPlain {
t.Fatal("expected 'plain' tag in spec for non-describable group")
}
// Verify only /health exists in paths (plain group adds no paths).
paths := spec["paths"].(map[string]any)
if len(paths) != 1 {
t.Fatalf("expected 1 path (/health only), got %d", len(paths))
}
if _, ok := paths["/health"]; !ok {
t.Fatal("expected /health path in spec")
}
}
func TestSpecBuilder_Good_ToolBridgeIntegration(t *testing.T) {
gin.SetMode(gin.TestMode)
sb := &api.SpecBuilder{
Title: "Tool API",
Version: "1.0.0",
}
bridge := api.NewToolBridge("/tools")
bridge.Add(api.ToolDescriptor{
Name: "file_read",
Description: "Read a file from disk",
Group: "files",
InputSchema: map[string]any{
"type": "object",
"properties": map[string]any{
"path": map[string]any{"type": "string"},
},
},
OutputSchema: map[string]any{
"type": "object",
"properties": map[string]any{
"content": 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 data",
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"))
})
data, err := sb.Build([]api.RouteGroup{bridge})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
var spec map[string]any
if err := json.Unmarshal(data, &spec); err != nil {
t.Fatalf("invalid JSON: %v", err)
}
paths := spec["paths"].(map[string]any)
// Verify POST /tools/file_read exists.
fileReadPath, ok := paths["/tools/file_read"]
if !ok {
t.Fatal("expected /tools/file_read path in spec")
}
postOp := fileReadPath.(map[string]any)["post"]
if postOp == nil {
t.Fatal("expected POST operation on /tools/file_read")
}
if postOp.(map[string]any)["summary"] != "Read a file from disk" {
t.Fatalf("expected summary='Read a file from disk', got %v", postOp.(map[string]any)["summary"])
}
// Verify POST /tools/metrics_query exists.
metricsPath, ok := paths["/tools/metrics_query"]
if !ok {
t.Fatal("expected /tools/metrics_query path in spec")
}
metricsOp := metricsPath.(map[string]any)["post"]
if metricsOp == nil {
t.Fatal("expected POST operation on /tools/metrics_query")
}
if metricsOp.(map[string]any)["summary"] != "Query metrics data" {
t.Fatalf("expected summary='Query metrics data', got %v", metricsOp.(map[string]any)["summary"])
}
// Verify request body is present on both (both are POST with InputSchema).
if postOp.(map[string]any)["requestBody"] == nil {
t.Fatal("expected requestBody on POST /tools/file_read")
}
if metricsOp.(map[string]any)["requestBody"] == nil {
t.Fatal("expected requestBody on POST /tools/metrics_query")
}
}
func TestSpecBuilder_Bad_InfoFields(t *testing.T) {
sb := &api.SpecBuilder{
Title: "MyAPI",
Description: "Test API",
Version: "1.0.0",
}
data, err := sb.Build(nil)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
var spec map[string]any
if err := json.Unmarshal(data, &spec); err != nil {
t.Fatalf("invalid JSON: %v", err)
}
info := spec["info"].(map[string]any)
if info["title"] != "MyAPI" {
t.Fatalf("expected title=MyAPI, got %v", info["title"])
}
if info["description"] != "Test API" {
t.Fatalf("expected description='Test API', got %v", info["description"])
}
if info["version"] != "1.0.0" {
t.Fatalf("expected version=1.0.0, got %v", info["version"])
}
}