feat: add ToolBridge for tool-to-REST endpoint conversion
Co-Authored-By: Virgil <virgil@lethean.io> Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
465bd60a8a
commit
2b63c7b178
2 changed files with 317 additions and 0 deletions
83
bridge.go
Normal file
83
bridge.go
Normal file
|
|
@ -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
|
||||
}
|
||||
234
bridge_test.go
Normal file
234
bridge_test.go
Normal file
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue