From 3e96f9b5c20a202a8494b5b9aa589dbada72bf1a Mon Sep 17 00:00:00 2001 From: Snider Date: Sat, 21 Feb 2026 00:48:28 +0000 Subject: [PATCH] feat: add SpecBuilder for runtime OpenAPI 3.1 generation Co-Authored-By: Virgil Co-Authored-By: Claude Opus 4.6 --- openapi.go | 184 ++++++++++++++++++++++ openapi_test.go | 403 ++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 587 insertions(+) create mode 100644 openapi.go create mode 100644 openapi_test.go diff --git a/openapi.go b/openapi.go new file mode 100644 index 0000000..b98d8d1 --- /dev/null +++ b/openapi.go @@ -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"}, + } +} diff --git a/openapi_test.go b/openapi_test.go new file mode 100644 index 0000000..c4c58a7 --- /dev/null +++ b/openapi_test.go @@ -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"]) + } +}