feat: add SpecBuilder for runtime OpenAPI 3.1 generation
Co-Authored-By: Virgil <virgil@lethean.io> Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
2b63c7b178
commit
3e96f9b5c2
2 changed files with 587 additions and 0 deletions
184
openapi.go
Normal file
184
openapi.go
Normal file
|
|
@ -0,0 +1,184 @@
|
|||
// SPDX-License-Identifier: EUPL-1.2
|
||||
|
||||
package api
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// SpecBuilder constructs an OpenAPI 3.1 specification from registered RouteGroups.
|
||||
type SpecBuilder struct {
|
||||
Title string
|
||||
Description string
|
||||
Version string
|
||||
}
|
||||
|
||||
// Build generates the complete OpenAPI 3.1 JSON spec.
|
||||
// Groups implementing DescribableGroup contribute endpoint documentation.
|
||||
// Other groups are listed as tags only.
|
||||
func (sb *SpecBuilder) Build(groups []RouteGroup) ([]byte, error) {
|
||||
spec := map[string]any{
|
||||
"openapi": "3.1.0",
|
||||
"info": map[string]any{
|
||||
"title": sb.Title,
|
||||
"description": sb.Description,
|
||||
"version": sb.Version,
|
||||
},
|
||||
"paths": sb.buildPaths(groups),
|
||||
"tags": sb.buildTags(groups),
|
||||
}
|
||||
|
||||
// Add component schemas for the response envelope.
|
||||
spec["components"] = map[string]any{
|
||||
"schemas": map[string]any{
|
||||
"Error": map[string]any{
|
||||
"type": "object",
|
||||
"properties": map[string]any{
|
||||
"code": map[string]any{"type": "string"},
|
||||
"message": map[string]any{"type": "string"},
|
||||
"details": map[string]any{},
|
||||
},
|
||||
"required": []string{"code", "message"},
|
||||
},
|
||||
"Meta": map[string]any{
|
||||
"type": "object",
|
||||
"properties": map[string]any{
|
||||
"request_id": map[string]any{"type": "string"},
|
||||
"duration": map[string]any{"type": "string"},
|
||||
"page": map[string]any{"type": "integer"},
|
||||
"per_page": map[string]any{"type": "integer"},
|
||||
"total": map[string]any{"type": "integer"},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
return json.MarshalIndent(spec, "", " ")
|
||||
}
|
||||
|
||||
// buildPaths generates the paths object from all DescribableGroups.
|
||||
func (sb *SpecBuilder) buildPaths(groups []RouteGroup) map[string]any {
|
||||
paths := map[string]any{
|
||||
// Built-in health endpoint.
|
||||
"/health": map[string]any{
|
||||
"get": map[string]any{
|
||||
"summary": "Health check",
|
||||
"description": "Returns server health status",
|
||||
"tags": []string{"system"},
|
||||
"responses": map[string]any{
|
||||
"200": map[string]any{
|
||||
"description": "Server is healthy",
|
||||
"content": map[string]any{
|
||||
"application/json": map[string]any{
|
||||
"schema": envelopeSchema(map[string]any{"type": "string"}),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, g := range groups {
|
||||
dg, ok := g.(DescribableGroup)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
for _, rd := range dg.Describe() {
|
||||
fullPath := g.BasePath() + rd.Path
|
||||
method := strings.ToLower(rd.Method)
|
||||
|
||||
operation := map[string]any{
|
||||
"summary": rd.Summary,
|
||||
"description": rd.Description,
|
||||
"tags": rd.Tags,
|
||||
"responses": map[string]any{
|
||||
"200": map[string]any{
|
||||
"description": "Successful response",
|
||||
"content": map[string]any{
|
||||
"application/json": map[string]any{
|
||||
"schema": envelopeSchema(rd.Response),
|
||||
},
|
||||
},
|
||||
},
|
||||
"400": map[string]any{
|
||||
"description": "Bad request",
|
||||
"content": map[string]any{
|
||||
"application/json": map[string]any{
|
||||
"schema": envelopeSchema(nil),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
// Add request body for methods that accept one.
|
||||
if rd.RequestBody != nil && (method == "post" || method == "put" || method == "patch") {
|
||||
operation["requestBody"] = map[string]any{
|
||||
"required": true,
|
||||
"content": map[string]any{
|
||||
"application/json": map[string]any{
|
||||
"schema": rd.RequestBody,
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// Create or extend path item.
|
||||
if existing, exists := paths[fullPath]; exists {
|
||||
existing.(map[string]any)[method] = operation
|
||||
} else {
|
||||
paths[fullPath] = map[string]any{
|
||||
method: operation,
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return paths
|
||||
}
|
||||
|
||||
// buildTags generates the tags array from all RouteGroups.
|
||||
func (sb *SpecBuilder) buildTags(groups []RouteGroup) []map[string]any {
|
||||
tags := []map[string]any{
|
||||
{"name": "system", "description": "System endpoints"},
|
||||
}
|
||||
seen := map[string]bool{"system": true}
|
||||
|
||||
for _, g := range groups {
|
||||
name := g.Name()
|
||||
if !seen[name] {
|
||||
tags = append(tags, map[string]any{
|
||||
"name": name,
|
||||
"description": name + " endpoints",
|
||||
})
|
||||
seen[name] = true
|
||||
}
|
||||
}
|
||||
|
||||
return tags
|
||||
}
|
||||
|
||||
// envelopeSchema wraps a data schema in the standard Response[T] envelope.
|
||||
func envelopeSchema(dataSchema map[string]any) map[string]any {
|
||||
properties := map[string]any{
|
||||
"success": map[string]any{"type": "boolean"},
|
||||
"error": map[string]any{
|
||||
"$ref": "#/components/schemas/Error",
|
||||
},
|
||||
"meta": map[string]any{
|
||||
"$ref": "#/components/schemas/Meta",
|
||||
},
|
||||
}
|
||||
|
||||
if dataSchema != nil {
|
||||
properties["data"] = dataSchema
|
||||
}
|
||||
|
||||
return map[string]any{
|
||||
"type": "object",
|
||||
"properties": properties,
|
||||
"required": []string{"success"},
|
||||
}
|
||||
}
|
||||
403
openapi_test.go
Normal file
403
openapi_test.go
Normal file
|
|
@ -0,0 +1,403 @@
|
|||
// 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"])
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue