From 2b63c7b1788766d5a60612cca332614b80bb8150 Mon Sep 17 00:00:00 2001 From: Snider Date: Sat, 21 Feb 2026 00:44:49 +0000 Subject: [PATCH] feat: add ToolBridge for tool-to-REST endpoint conversion Co-Authored-By: Virgil Co-Authored-By: Claude Opus 4.6 --- bridge.go | 83 ++++++++++++++++++ bridge_test.go | 234 +++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 317 insertions(+) create mode 100644 bridge.go create mode 100644 bridge_test.go diff --git a/bridge.go b/bridge.go new file mode 100644 index 0000000..9b2a7e0 --- /dev/null +++ b/bridge.go @@ -0,0 +1,83 @@ +// SPDX-License-Identifier: EUPL-1.2 + +package api + +import "github.com/gin-gonic/gin" + +// ToolDescriptor describes a tool that can be exposed as a REST endpoint. +type ToolDescriptor struct { + Name string // Tool name, e.g. "file_read" (becomes POST path segment) + Description string // Human-readable description + Group string // OpenAPI tag group, e.g. "files" + InputSchema map[string]any // JSON Schema for request body + OutputSchema map[string]any // JSON Schema for response data (optional) +} + +// ToolBridge converts tool descriptors into REST endpoints and OpenAPI paths. +// It implements both RouteGroup and DescribableGroup. +type ToolBridge struct { + basePath string + name string + tools []boundTool +} + +type boundTool struct { + descriptor ToolDescriptor + handler gin.HandlerFunc +} + +// NewToolBridge creates a bridge that mounts tool endpoints at basePath. +func NewToolBridge(basePath string) *ToolBridge { + return &ToolBridge{ + basePath: basePath, + name: "tools", + } +} + +// Add registers a tool with its HTTP handler. +func (b *ToolBridge) Add(desc ToolDescriptor, handler gin.HandlerFunc) { + b.tools = append(b.tools, boundTool{descriptor: desc, handler: handler}) +} + +// Name returns the bridge identifier. +func (b *ToolBridge) Name() string { return b.name } + +// BasePath returns the URL prefix for all tool endpoints. +func (b *ToolBridge) BasePath() string { return b.basePath } + +// RegisterRoutes mounts POST /{tool_name} for each registered tool. +func (b *ToolBridge) RegisterRoutes(rg *gin.RouterGroup) { + for _, t := range b.tools { + rg.POST("/"+t.descriptor.Name, t.handler) + } +} + +// Describe returns OpenAPI route descriptions for all registered tools. +func (b *ToolBridge) Describe() []RouteDescription { + descs := make([]RouteDescription, 0, len(b.tools)) + for _, t := range b.tools { + tags := []string{t.descriptor.Group} + if t.descriptor.Group == "" { + tags = []string{b.name} + } + descs = append(descs, RouteDescription{ + Method: "POST", + Path: "/" + t.descriptor.Name, + Summary: t.descriptor.Description, + Description: t.descriptor.Description, + Tags: tags, + RequestBody: t.descriptor.InputSchema, + Response: t.descriptor.OutputSchema, + }) + } + return descs +} + +// Tools returns all registered tool descriptors. +func (b *ToolBridge) Tools() []ToolDescriptor { + descs := make([]ToolDescriptor, len(b.tools)) + for i, t := range b.tools { + descs[i] = t.descriptor + } + return descs +} diff --git a/bridge_test.go b/bridge_test.go new file mode 100644 index 0000000..75f8cae --- /dev/null +++ b/bridge_test.go @@ -0,0 +1,234 @@ +// SPDX-License-Identifier: EUPL-1.2 + +package api_test + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + + "github.com/gin-gonic/gin" + + api "forge.lthn.ai/core/go-api" +) + +// ── ToolBridge ───────────────────────────────────────────────────────── + +func TestToolBridge_Good_RegisterAndServe(t *testing.T) { + gin.SetMode(gin.TestMode) + engine := gin.New() + + bridge := api.NewToolBridge("/tools") + bridge.Add(api.ToolDescriptor{ + Name: "file_read", + Description: "Read a file", + Group: "files", + }, func(c *gin.Context) { + c.JSON(http.StatusOK, api.OK("result1")) + }) + bridge.Add(api.ToolDescriptor{ + Name: "file_write", + Description: "Write a file", + Group: "files", + }, func(c *gin.Context) { + c.JSON(http.StatusOK, api.OK("result2")) + }) + + rg := engine.Group(bridge.BasePath()) + bridge.RegisterRoutes(rg) + + // POST /tools/file_read + w1 := httptest.NewRecorder() + req1, _ := http.NewRequest(http.MethodPost, "/tools/file_read", nil) + engine.ServeHTTP(w1, req1) + + if w1.Code != http.StatusOK { + t.Fatalf("expected 200 for file_read, got %d", w1.Code) + } + var resp1 api.Response[string] + if err := json.Unmarshal(w1.Body.Bytes(), &resp1); err != nil { + t.Fatalf("unmarshal error: %v", err) + } + if resp1.Data != "result1" { + t.Fatalf("expected Data=%q, got %q", "result1", resp1.Data) + } + + // POST /tools/file_write + w2 := httptest.NewRecorder() + req2, _ := http.NewRequest(http.MethodPost, "/tools/file_write", nil) + engine.ServeHTTP(w2, req2) + + if w2.Code != http.StatusOK { + t.Fatalf("expected 200 for file_write, got %d", w2.Code) + } + var resp2 api.Response[string] + if err := json.Unmarshal(w2.Body.Bytes(), &resp2); err != nil { + t.Fatalf("unmarshal error: %v", err) + } + if resp2.Data != "result2" { + t.Fatalf("expected Data=%q, got %q", "result2", resp2.Data) + } +} + +func TestToolBridge_Good_BasePath(t *testing.T) { + bridge := api.NewToolBridge("/api/v1/tools") + + if bridge.BasePath() != "/api/v1/tools" { + t.Fatalf("expected BasePath=%q, got %q", "/api/v1/tools", bridge.BasePath()) + } + if bridge.Name() != "tools" { + t.Fatalf("expected Name=%q, got %q", "tools", bridge.Name()) + } +} + +func TestToolBridge_Good_Describe(t *testing.T) { + 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) {}) + 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) {}) + + // Verify DescribableGroup interface satisfaction. + var dg api.DescribableGroup = bridge + descs := dg.Describe() + + if len(descs) != 2 { + t.Fatalf("expected 2 descriptions, got %d", len(descs)) + } + + // First tool. + if descs[0].Method != "POST" { + t.Fatalf("expected descs[0].Method=%q, got %q", "POST", descs[0].Method) + } + if descs[0].Path != "/file_read" { + t.Fatalf("expected descs[0].Path=%q, got %q", "/file_read", descs[0].Path) + } + if descs[0].Summary != "Read a file from disk" { + t.Fatalf("expected descs[0].Summary=%q, got %q", "Read a file from disk", descs[0].Summary) + } + if len(descs[0].Tags) != 1 || descs[0].Tags[0] != "files" { + t.Fatalf("expected descs[0].Tags=[files], got %v", descs[0].Tags) + } + if descs[0].RequestBody == nil { + t.Fatal("expected descs[0].RequestBody to be non-nil") + } + if descs[0].Response == nil { + t.Fatal("expected descs[0].Response to be non-nil") + } + + // Second tool. + if descs[1].Path != "/metrics_query" { + t.Fatalf("expected descs[1].Path=%q, got %q", "/metrics_query", descs[1].Path) + } + if len(descs[1].Tags) != 1 || descs[1].Tags[0] != "metrics" { + t.Fatalf("expected descs[1].Tags=[metrics], got %v", descs[1].Tags) + } + if descs[1].Response != nil { + t.Fatalf("expected descs[1].Response to be nil, got %v", descs[1].Response) + } +} + +func TestToolBridge_Good_ToolsAccessor(t *testing.T) { + bridge := api.NewToolBridge("/tools") + bridge.Add(api.ToolDescriptor{Name: "alpha", Description: "Tool A", Group: "a"}, func(c *gin.Context) {}) + bridge.Add(api.ToolDescriptor{Name: "beta", Description: "Tool B", Group: "b"}, func(c *gin.Context) {}) + bridge.Add(api.ToolDescriptor{Name: "gamma", Description: "Tool C", Group: "c"}, func(c *gin.Context) {}) + + tools := bridge.Tools() + if len(tools) != 3 { + t.Fatalf("expected 3 tools, got %d", len(tools)) + } + + expected := []string{"alpha", "beta", "gamma"} + for i, want := range expected { + if tools[i].Name != want { + t.Fatalf("expected tools[%d].Name=%q, got %q", i, want, tools[i].Name) + } + } +} + +func TestToolBridge_Bad_EmptyBridge(t *testing.T) { + gin.SetMode(gin.TestMode) + bridge := api.NewToolBridge("/tools") + + // RegisterRoutes should not panic with no tools. + engine := gin.New() + rg := engine.Group(bridge.BasePath()) + bridge.RegisterRoutes(rg) + + // Describe should return empty slice. + descs := bridge.Describe() + if len(descs) != 0 { + t.Fatalf("expected 0 descriptions, got %d", len(descs)) + } + + // Tools should return empty slice. + tools := bridge.Tools() + if len(tools) != 0 { + t.Fatalf("expected 0 tools, got %d", len(tools)) + } +} + +func TestToolBridge_Good_IntegrationWithEngine(t *testing.T) { + gin.SetMode(gin.TestMode) + e, err := api.New() + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + bridge := api.NewToolBridge("/tools") + bridge.Add(api.ToolDescriptor{ + Name: "ping", + Description: "Ping tool", + Group: "util", + }, func(c *gin.Context) { + c.JSON(http.StatusOK, api.OK("pong")) + }) + + e.Register(bridge) + + h := e.Handler() + w := httptest.NewRecorder() + req, _ := http.NewRequest(http.MethodPost, "/tools/ping", nil) + h.ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Fatalf("expected 200, got %d", w.Code) + } + + var resp api.Response[string] + if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil { + t.Fatalf("unmarshal error: %v", err) + } + if !resp.Success { + t.Fatal("expected Success=true") + } + if resp.Data != "pong" { + t.Fatalf("expected Data=%q, got %q", "pong", resp.Data) + } +}