Files
7838d00f57 feat: connector workbench (#641)
* wip: connector workbench

* wip: improve workbench

* feat(workbench): improve UI with sidebar, routing, and syntax highlighting

- Replace connector dropdown with Slack-style sidebar for connector selection
- Add URL-based routing with react-router-dom for browser navigation support
- Add JSON syntax highlighting with react-syntax-highlighter
- Rework State tab layout to list/detail view with filtering
- Update terminology from "create" to "instantiate" for connectors
- Fix duplicate selection bug in lists (ID generation collision)
- Unify data table row heights across tabs
- Increase code block font sizes for readability
- Hide navigation tabs on homepage when no connector selected
- Make CW logo clickable to return to home view

Co-authored-by: Cursor <[email protected]>

* fix(workbench): use textarea for PEM/certificate config fields

Fields containing 'privateKey', 'certificate', or 'pem' in their name
now use a textarea instead of single-line input, preserving newlines
in PEM content that would otherwise be stripped.

Co-authored-by: Cursor <[email protected]>

* fix(workbench): address PR review issues

- Fix data race on idCounter using atomic.Int64
- Fix path traversal vulnerability in introspect.go
- Fix pointer comparison for DefaultAsset in baseline.go

Co-authored-by: Cursor <[email protected]>

* feat(workbench): add generic connector server for integration testing

Expose the generic connector REST API from the workbench so staging
services can install a "generic connector" pointing to the local
workbench, enabling end-to-end integration testing.

- Add /generic/* routes (accounts, balances, beneficiaries, transactions)
- Return cached data from storage instead of making fresh API calls
- Add connector selection and optional API key authentication
- Add UI panel in Debug tab for configuration

Co-authored-by: Cursor <[email protected]>

* fix(workbench): use internal registry to avoid pkg dependency

Change imports from pkg/registry to internal/connectors/plugins/registry
so the workbench can be merged before the pkg refactoring. The internal
registry will automatically delegate to pkg/registry once that refactor
lands.

Also adds light/dark mode toggle to the UI with system preference detection.

Co-authored-by: Cursor <[email protected]>

* fix(workbench): address PR review feedback

- Add ctx.Done() checks in fetch loops to exit early on client disconnect
- Refactor generic server to get connector directly instead of via context,
  providing clearer data flow as suggested in review

Co-authored-by: Cursor <[email protected]>

* fix(workbench): include built UI files for embed directive

The //go:embed ui/dist/* directive requires files to exist at build time.
Add exception to .gitignore and commit the built UI distribution files.

Co-authored-by: Cursor <[email protected]>

* fix(workbench): address golangci-lint errors

- Remove unused globalDebugTransport variable
- Remove unused parseIntParam function
- Fix printf format for models.TaskType (use %v instead of %s)
- Add error return value handling for various function calls

Co-authored-by: Cursor <[email protected]>

* fix: remove .vite cache folder from git

Co-authored-by: Cursor <[email protected]>

* fix(workbench): fix remaining errcheck issue

Co-authored-by: Cursor <[email protected]>

* fix(workbench): fix all remaining lint issues

- Add error handling for Body.Close() calls
- Remove unnecessary fmt.Sprintf wrapper
- Simplify hex character check condition

Co-authored-by: Cursor <[email protected]>

* fix(workbench): fix errcheck in cmd/workbench.go

Co-authored-by: Cursor <[email protected]>

* chore: replace ui dist build artifacts with placeholder

The //go:embed directive needs at least one file to exist.
Instead of committing the entire built UI, commit only a
.gitkeep placeholder. The server has fallback code that
displays instructions when the full UI isn't available.

Co-authored-by: Cursor <[email protected]>

---------

Co-authored-by: Cursor <[email protected]>
2026-02-12 18:29:17 +00:00

2131 lines
60 KiB
Go

package workbench
import (
"context"
"embed"
"encoding/json"
"fmt"
"io"
"io/fs"
"net/http"
"os"
"path/filepath"
"strconv"
"strings"
"time"
"github.com/formancehq/payments/internal/models"
"github.com/go-chi/chi/v5"
"github.com/go-chi/chi/v5/middleware"
"github.com/go-chi/cors"
)
//go:embed ui/dist/*
var uiFS embed.FS
// Server is the HTTP API server for the workbench.
type Server struct {
workbench *Workbench
addr string
enableUI bool
server *http.Server
}
// NewServer creates a new workbench HTTP server.
func NewServer(wb *Workbench, addr string, enableUI bool) *Server {
return &Server{
workbench: wb,
addr: addr,
enableUI: enableUI,
}
}
// Start starts the HTTP server.
func (s *Server) Start() error {
r := chi.NewRouter()
// Middleware
r.Use(middleware.Logger)
r.Use(middleware.Recoverer)
r.Use(middleware.RequestID)
r.Use(cors.Handler(cors.Options{
AllowedOrigins: []string{"*"},
AllowedMethods: []string{"GET", "POST", "PUT", "DELETE", "OPTIONS"},
AllowedHeaders: []string{"*"},
ExposedHeaders: []string{"Link"},
AllowCredentials: true,
MaxAge: 300,
}))
// API routes
r.Route("/api", func(r chi.Router) {
// Global status
r.Get("/status", s.handleGlobalStatus)
// Connector management
r.Get("/connectors/available", s.handleAvailableConnectors)
r.Get("/connectors", s.handleListConnectors)
r.Post("/connectors", s.handleCreateConnector)
// Connector-specific routes
r.Route("/connectors/{connectorID}", func(r chi.Router) {
r.Use(s.connectorCtx) // Middleware to load connector instance
r.Get("/", s.handleConnectorStatus)
r.Delete("/", s.handleDeleteConnector)
r.Post("/install", s.handleInstall)
r.Post("/uninstall", s.handleUninstall)
r.Post("/reset", s.handleReset)
// Fetch operations
r.Post("/fetch/accounts", s.handleFetchAccounts)
r.Post("/fetch/payments", s.handleFetchPayments)
r.Post("/fetch/balances", s.handleFetchBalances)
r.Post("/fetch/external-accounts", s.handleFetchExternalAccounts)
r.Post("/fetch/all", s.handleFetchAll)
// Write operations
r.Post("/transfer", s.handleCreateTransfer)
r.Post("/payout", s.handleCreatePayout)
// Data endpoints
r.Get("/data/accounts", s.handleGetAccounts)
r.Get("/data/payments", s.handleGetPayments)
r.Get("/data/balances", s.handleGetBalances)
r.Get("/data/external-accounts", s.handleGetExternalAccounts)
r.Get("/data/others", s.handleGetOthers)
r.Get("/data/states", s.handleGetStates)
r.Get("/data/tasks-tree", s.handleGetTasksTree)
r.Get("/data/export", s.handleExport)
r.Post("/data/import", s.handleImport)
// Config endpoints
r.Get("/config", s.handleGetConfig)
r.Put("/config/page-size", s.handleSetPageSize)
// Introspection endpoints
r.Get("/introspect/info", s.handleIntrospectInfo)
r.Get("/introspect/files", s.handleIntrospectFiles)
r.Get("/introspect/file", s.handleIntrospectFile)
r.Get("/introspect/search", s.handleIntrospectSearch)
// Task tracking endpoints
r.Get("/tasks", s.handleGetTasks)
r.Get("/tasks/executions", s.handleGetTaskExecutions)
r.Post("/tasks/step", s.handleTaskStep)
r.Put("/tasks/step-mode", s.handleSetStepMode)
r.Post("/tasks/reset", s.handleResetTasks)
// Snapshot endpoints
r.Get("/snapshots", s.handleListSnapshots)
r.Get("/snapshots/stats", s.handleSnapshotStats)
r.Get("/snapshots/{id}", s.handleGetSnapshot)
r.Post("/snapshots", s.handleCreateSnapshot)
r.Post("/snapshots/from-capture/{id}", s.handleCreateSnapshotFromCapture)
r.Delete("/snapshots/{id}", s.handleDeleteSnapshot)
r.Delete("/snapshots", s.handleClearSnapshots)
r.Post("/snapshots/export", s.handleExportSnapshots)
r.Post("/snapshots/import", s.handleImportSnapshots)
// Test generation endpoints
r.Get("/tests/preview", s.handlePreviewTests)
r.Post("/tests/generate", s.handleGenerateTests)
// Schema inference endpoints
r.Get("/schemas", s.handleListSchemas)
r.Get("/schemas/stats", s.handleSchemaStats)
r.Get("/schemas/{operation}", s.handleGetSchema)
r.Post("/schemas/infer", s.handleInferSchema)
r.Post("/schemas/baselines", s.handleSaveSchemaBaseline)
r.Post("/schemas/baselines/all", s.handleSaveAllSchemaBaselines)
r.Get("/schemas/baselines", s.handleListSchemaBaselines)
r.Get("/schemas/compare/{operation}", s.handleCompareSchema)
r.Delete("/schemas", s.handleClearSchemas)
// Data baseline endpoints
r.Get("/baselines", s.handleListBaselines)
r.Get("/baselines/{id}", s.handleGetBaseline)
r.Post("/baselines", s.handleSaveBaseline)
r.Delete("/baselines/{id}", s.handleDeleteBaseline)
r.Get("/baselines/{id}/compare", s.handleCompareBaseline)
r.Get("/baselines/{id}/export", s.handleExportBaseline)
r.Post("/baselines/import", s.handleImportBaseline)
})
// Global debug endpoints (shared across all connectors)
r.Get("/debug/logs", s.handleDebugLogs)
r.Get("/debug/requests", s.handleDebugRequests)
r.Get("/debug/plugin-calls", s.handleDebugPluginCalls)
r.Get("/debug/stats", s.handleDebugStats)
r.Delete("/debug/clear", s.handleDebugClear)
// HTTP capture control
r.Get("/debug/http-capture", s.handleHTTPCaptureStatus)
r.Post("/debug/http-capture/enable", s.handleHTTPCaptureEnable)
r.Post("/debug/http-capture/disable", s.handleHTTPCaptureDisable)
// Replay endpoints (global)
r.Get("/replay/history", s.handleReplayHistory)
r.Get("/replay/{id}", s.handleGetReplay)
r.Post("/replay", s.handleReplay)
r.Post("/replay/from-capture/{id}", s.handleReplayFromCapture)
r.Get("/replay/compare/{id}", s.handleReplayCompare)
r.Post("/replay/dry-run", s.handleReplayDryRun)
r.Get("/replay/curl/{id}", s.handleReplayCurl)
r.Delete("/replay/history", s.handleClearReplayHistory)
// Generic server configuration
r.Get("/generic-server/status", s.handleGenericServerStatus)
r.Post("/generic-server/connector", s.handleSetGenericConnector)
})
// Generic connector server (for remote integration testing)
// Exposes the standard generic connector API so staging services can connect
r.Route("/generic", func(r chi.Router) {
r.Use(s.genericServerMiddleware)
r.Get("/accounts", s.handleGenericAccounts)
r.Get("/accounts/{accountId}/balances", s.handleGenericBalances)
r.Get("/beneficiaries", s.handleGenericBeneficiaries)
r.Get("/transactions", s.handleGenericTransactions)
})
// Serve embedded UI
if s.enableUI {
// Try to serve from embedded filesystem
uiContent, err := fs.Sub(uiFS, "ui/dist")
if err == nil {
// Serve static assets
fileServer := http.FileServer(http.FS(uiContent))
// Handle /ui/assets/* for static files
r.Handle("/ui/assets/*", http.StripPrefix("/ui", fileServer))
// Handle /ui and /ui/ to serve index.html
r.Get("/ui", func(w http.ResponseWriter, r *http.Request) {
http.Redirect(w, r, "/ui/", http.StatusMovedPermanently)
})
r.Get("/ui/", func(w http.ResponseWriter, req *http.Request) {
indexHTML, err := fs.ReadFile(uiContent, "index.html")
if err != nil {
http.Error(w, "UI not available", http.StatusNotFound)
return
}
w.Header().Set("Content-Type", "text/html")
_, _ = w.Write(indexHTML)
})
}
// Root redirects to /ui/ if UI is enabled, otherwise fallback
r.Get("/", func(w http.ResponseWriter, req *http.Request) {
if uiContent, err := fs.Sub(uiFS, "ui/dist"); err == nil {
if _, err := fs.ReadFile(uiContent, "index.html"); err == nil {
http.Redirect(w, req, "/ui/", http.StatusTemporaryRedirect)
return
}
}
s.handleUIFallback(w, req)
})
} else {
r.Get("/", s.handleUIFallback)
}
s.server = &http.Server{
Addr: s.addr,
Handler: r,
}
return s.server.ListenAndServe()
}
// Stop stops the HTTP server.
func (s *Server) Stop(ctx context.Context) error {
if s.server != nil {
return s.server.Shutdown(ctx)
}
return nil
}
// === Context Key for Connector Instance ===
type ctxKey string
const connectorCtxKey ctxKey = "connector"
// connectorCtx middleware loads the connector instance from URL param
func (s *Server) connectorCtx(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
connectorID := chi.URLParam(r, "connectorID")
if connectorID == "" {
s.errorResponse(w, http.StatusBadRequest, "connector ID is required")
return
}
conn := s.workbench.GetConnector(connectorID)
if conn == nil {
s.errorResponse(w, http.StatusNotFound, fmt.Sprintf("connector %s not found", connectorID))
return
}
ctx := context.WithValue(r.Context(), connectorCtxKey, conn)
next.ServeHTTP(w, r.WithContext(ctx))
})
}
// getConnector retrieves the connector from request context
func (s *Server) getConnector(r *http.Request) *ConnectorInstance {
conn, _ := r.Context().Value(connectorCtxKey).(*ConnectorInstance)
return conn
}
// === Helper functions ===
func (s *Server) jsonResponse(w http.ResponseWriter, status int, data interface{}) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
_ = json.NewEncoder(w).Encode(data)
}
func (s *Server) errorResponse(w http.ResponseWriter, status int, message string) {
s.jsonResponse(w, status, map[string]string{"error": message})
}
// === Global Status ===
func (s *Server) handleGlobalStatus(w http.ResponseWriter, r *http.Request) {
connectors := s.workbench.ListConnectors()
debugStats := s.workbench.Debug().Stats()
connectorSummaries := make([]map[string]interface{}, 0, len(connectors))
for _, conn := range connectors {
summary := map[string]interface{}{
"id": conn.ID,
"provider": conn.Provider,
"name": conn.Name,
"connector_id": conn.ConnectorID.String(),
"installed": conn.Installed,
"created_at": conn.CreatedAt,
}
if conn.storage != nil {
stats := conn.storage.GetStats()
summary["accounts_count"] = stats.AccountsCount
summary["payments_count"] = stats.PaymentsCount
summary["balances_count"] = stats.BalancesCount
}
connectorSummaries = append(connectorSummaries, summary)
}
status := map[string]interface{}{
"connectors_count": len(connectors),
"connectors": connectorSummaries,
"debug": debugStats,
}
s.jsonResponse(w, http.StatusOK, status)
}
// === Connector Management ===
func (s *Server) handleAvailableConnectors(w http.ResponseWriter, r *http.Request) {
available := s.workbench.GetAvailableConnectors()
s.jsonResponse(w, http.StatusOK, map[string]interface{}{
"connectors": available,
"count": len(available),
})
}
func (s *Server) handleListConnectors(w http.ResponseWriter, r *http.Request) {
connectors := s.workbench.ListConnectors()
result := make([]map[string]interface{}, 0, len(connectors))
for _, conn := range connectors {
item := map[string]interface{}{
"id": conn.ID,
"provider": conn.Provider,
"name": conn.Name,
"connector_id": conn.ConnectorID.String(),
"installed": conn.Installed,
"created_at": conn.CreatedAt,
}
if conn.storage != nil {
stats := conn.storage.GetStats()
item["storage"] = map[string]interface{}{
"accounts_count": stats.AccountsCount,
"payments_count": stats.PaymentsCount,
"balances_count": stats.BalancesCount,
"external_accounts_count": stats.ExternalAccountsCount,
}
}
result = append(result, item)
}
s.jsonResponse(w, http.StatusOK, map[string]interface{}{
"connectors": result,
"count": len(result),
})
}
func (s *Server) handleCreateConnector(w http.ResponseWriter, r *http.Request) {
var req CreateConnectorRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
s.errorResponse(w, http.StatusBadRequest, "invalid request: "+err.Error())
return
}
conn, err := s.workbench.CreateConnector(r.Context(), req)
if err != nil {
s.errorResponse(w, http.StatusBadRequest, err.Error())
return
}
s.jsonResponse(w, http.StatusCreated, map[string]interface{}{
"id": conn.ID,
"provider": conn.Provider,
"name": conn.Name,
"connector_id": conn.ConnectorID.String(),
"installed": conn.Installed,
"created_at": conn.CreatedAt,
})
}
func (s *Server) handleDeleteConnector(w http.ResponseWriter, r *http.Request) {
conn := s.getConnector(r)
if conn == nil {
return // Error already sent by middleware
}
if err := s.workbench.DeleteConnector(r.Context(), conn.ID); err != nil {
s.errorResponse(w, http.StatusInternalServerError, err.Error())
return
}
s.jsonResponse(w, http.StatusOK, map[string]string{"status": "deleted"})
}
// === Connector Status ===
func (s *Server) handleConnectorStatus(w http.ResponseWriter, r *http.Request) {
conn := s.getConnector(r)
if conn == nil {
return
}
var fetchStatus interface{}
if conn.engine != nil {
fetchStatus = conn.engine.GetFetchStatus()
}
status := map[string]interface{}{
"id": conn.ID,
"provider": conn.Provider,
"name": conn.Name,
"connector_id": conn.ConnectorID.String(),
"installed": conn.Installed,
"created_at": conn.CreatedAt,
"fetch_status": fetchStatus,
}
if conn.storage != nil {
stats := conn.storage.GetStats()
status["storage"] = map[string]interface{}{
"accounts_count": stats.AccountsCount,
"payments_count": stats.PaymentsCount,
"balances_count": stats.BalancesCount,
"external_accounts_count": stats.ExternalAccountsCount,
"last_updated": stats.LastUpdated,
}
}
s.jsonResponse(w, http.StatusOK, status)
}
// === Install/Uninstall ===
func (s *Server) handleInstall(w http.ResponseWriter, r *http.Request) {
conn := s.getConnector(r)
if conn == nil {
return
}
if err := s.workbench.InstallConnector(r.Context(), conn.ID); err != nil {
s.errorResponse(w, http.StatusInternalServerError, err.Error())
return
}
s.jsonResponse(w, http.StatusOK, map[string]string{"status": "installed"})
}
func (s *Server) handleUninstall(w http.ResponseWriter, r *http.Request) {
conn := s.getConnector(r)
if conn == nil {
return
}
if err := s.workbench.UninstallConnector(r.Context(), conn.ID); err != nil {
s.errorResponse(w, http.StatusInternalServerError, err.Error())
return
}
s.jsonResponse(w, http.StatusOK, map[string]string{"status": "uninstalled"})
}
func (s *Server) handleReset(w http.ResponseWriter, r *http.Request) {
conn := s.getConnector(r)
if conn == nil {
return
}
if conn.engine != nil {
conn.engine.ResetFetchState()
}
if conn.storage != nil {
conn.storage.Clear()
}
s.jsonResponse(w, http.StatusOK, map[string]string{"status": "reset"})
}
// === Fetch Operations ===
type fetchRequest struct {
FromPayload json.RawMessage `json:"from_payload,omitempty"`
}
func (s *Server) handleFetchAccounts(w http.ResponseWriter, r *http.Request) {
conn := s.getConnector(r)
if conn == nil || conn.engine == nil {
s.errorResponse(w, http.StatusBadRequest, "connector not ready")
return
}
var req fetchRequest
if r.Body != nil {
_ = json.NewDecoder(r.Body).Decode(&req)
}
resp, err := conn.engine.FetchAccountsOnePage(r.Context(), req.FromPayload)
if err != nil {
s.errorResponse(w, http.StatusInternalServerError, err.Error())
return
}
s.jsonResponse(w, http.StatusOK, map[string]interface{}{
"accounts": resp.Accounts,
"has_more": resp.HasMore,
"count": len(resp.Accounts),
})
}
func (s *Server) handleFetchPayments(w http.ResponseWriter, r *http.Request) {
conn := s.getConnector(r)
if conn == nil || conn.engine == nil {
s.errorResponse(w, http.StatusBadRequest, "connector not ready")
return
}
var req fetchRequest
if r.Body != nil {
_ = json.NewDecoder(r.Body).Decode(&req)
}
resp, err := conn.engine.FetchPaymentsOnePage(r.Context(), req.FromPayload)
if err != nil {
s.errorResponse(w, http.StatusInternalServerError, err.Error())
return
}
s.jsonResponse(w, http.StatusOK, map[string]interface{}{
"payments": resp.Payments,
"has_more": resp.HasMore,
"count": len(resp.Payments),
})
}
func (s *Server) handleFetchBalances(w http.ResponseWriter, r *http.Request) {
conn := s.getConnector(r)
if conn == nil || conn.engine == nil {
s.errorResponse(w, http.StatusBadRequest, "connector not ready")
return
}
var req fetchRequest
if r.Body != nil {
_ = json.NewDecoder(r.Body).Decode(&req)
}
resp, err := conn.engine.FetchBalancesOnePage(r.Context(), req.FromPayload)
if err != nil {
s.errorResponse(w, http.StatusInternalServerError, err.Error())
return
}
s.jsonResponse(w, http.StatusOK, map[string]interface{}{
"balances": resp.Balances,
"has_more": resp.HasMore,
"count": len(resp.Balances),
})
}
func (s *Server) handleFetchExternalAccounts(w http.ResponseWriter, r *http.Request) {
conn := s.getConnector(r)
if conn == nil || conn.engine == nil {
s.errorResponse(w, http.StatusBadRequest, "connector not ready")
return
}
var req fetchRequest
if r.Body != nil {
_ = json.NewDecoder(r.Body).Decode(&req)
}
resp, err := conn.engine.FetchExternalAccountsOnePage(r.Context(), req.FromPayload)
if err != nil {
s.errorResponse(w, http.StatusInternalServerError, err.Error())
return
}
s.jsonResponse(w, http.StatusOK, map[string]interface{}{
"external_accounts": resp.ExternalAccounts,
"has_more": resp.HasMore,
"count": len(resp.ExternalAccounts),
})
}
func (s *Server) handleFetchAll(w http.ResponseWriter, r *http.Request) {
conn := s.getConnector(r)
if conn == nil || conn.engine == nil {
s.errorResponse(w, http.StatusBadRequest, "connector not ready")
return
}
if err := conn.engine.RunOneCycle(r.Context()); err != nil {
s.errorResponse(w, http.StatusInternalServerError, err.Error())
return
}
stats := conn.storage.GetStats()
s.jsonResponse(w, http.StatusOK, map[string]interface{}{
"status": "complete",
"accounts": stats.AccountsCount,
"payments": stats.PaymentsCount,
"balances": stats.BalancesCount,
})
}
// === Write Operations ===
func (s *Server) handleCreateTransfer(w http.ResponseWriter, r *http.Request) {
s.errorResponse(w, http.StatusNotImplemented, "transfer creation not yet implemented in workbench")
}
func (s *Server) handleCreatePayout(w http.ResponseWriter, r *http.Request) {
s.errorResponse(w, http.StatusNotImplemented, "payout creation not yet implemented in workbench")
}
// === Data Endpoints ===
func (s *Server) handleGetAccounts(w http.ResponseWriter, r *http.Request) {
conn := s.getConnector(r)
if conn == nil || conn.storage == nil {
s.errorResponse(w, http.StatusBadRequest, "connector not ready")
return
}
accounts := conn.storage.GetAccounts()
response := ToAccountResponses(accounts)
s.jsonResponse(w, http.StatusOK, map[string]interface{}{
"accounts": response,
"count": len(response),
})
}
func (s *Server) handleGetPayments(w http.ResponseWriter, r *http.Request) {
conn := s.getConnector(r)
if conn == nil || conn.storage == nil {
s.errorResponse(w, http.StatusBadRequest, "connector not ready")
return
}
payments := conn.storage.GetPayments()
response := ToPaymentResponses(payments)
s.jsonResponse(w, http.StatusOK, map[string]interface{}{
"payments": response,
"count": len(response),
})
}
func (s *Server) handleGetBalances(w http.ResponseWriter, r *http.Request) {
conn := s.getConnector(r)
if conn == nil || conn.storage == nil {
s.errorResponse(w, http.StatusBadRequest, "connector not ready")
return
}
accountRef := r.URL.Query().Get("account")
var response []BalanceResponse
if accountRef != "" {
response = ToBalanceResponses(conn.storage.GetBalancesForAccount(accountRef))
} else {
response = ToBalanceResponses(conn.storage.GetBalances())
}
s.jsonResponse(w, http.StatusOK, map[string]interface{}{
"balances": response,
})
}
func (s *Server) handleGetExternalAccounts(w http.ResponseWriter, r *http.Request) {
conn := s.getConnector(r)
if conn == nil || conn.storage == nil {
s.errorResponse(w, http.StatusBadRequest, "connector not ready")
return
}
accounts := conn.storage.GetExternalAccounts()
response := ToAccountResponses(accounts)
s.jsonResponse(w, http.StatusOK, map[string]interface{}{
"external_accounts": response,
"count": len(response),
})
}
func (s *Server) handleGetOthers(w http.ResponseWriter, r *http.Request) {
conn := s.getConnector(r)
if conn == nil || conn.storage == nil {
s.errorResponse(w, http.StatusBadRequest, "connector not ready")
return
}
name := r.URL.Query().Get("name")
var result interface{}
if name != "" {
result = conn.storage.GetOthers(name)
} else {
result = conn.storage.GetAllOthers()
}
s.jsonResponse(w, http.StatusOK, map[string]interface{}{
"others": result,
})
}
func (s *Server) handleGetStates(w http.ResponseWriter, r *http.Request) {
conn := s.getConnector(r)
if conn == nil || conn.storage == nil {
s.errorResponse(w, http.StatusBadRequest, "connector not ready")
return
}
states := conn.storage.GetAllStates()
s.jsonResponse(w, http.StatusOK, map[string]interface{}{
"states": states,
})
}
func (s *Server) handleGetTasksTree(w http.ResponseWriter, r *http.Request) {
conn := s.getConnector(r)
if conn == nil || conn.storage == nil {
s.errorResponse(w, http.StatusBadRequest, "connector not ready")
return
}
tree := conn.storage.GetTasksTree()
s.jsonResponse(w, http.StatusOK, map[string]interface{}{
"tasks_tree": tree,
})
}
func (s *Server) handleExport(w http.ResponseWriter, r *http.Request) {
conn := s.getConnector(r)
if conn == nil || conn.storage == nil {
s.errorResponse(w, http.StatusBadRequest, "connector not ready")
return
}
snapshot := conn.storage.Export()
w.Header().Set("Content-Type", "application/json")
w.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=workbench-export-%s-%s.json", conn.ID, time.Now().Format("20060102-150405")))
_ = json.NewEncoder(w).Encode(snapshot)
}
func (s *Server) handleImport(w http.ResponseWriter, r *http.Request) {
conn := s.getConnector(r)
if conn == nil || conn.storage == nil {
s.errorResponse(w, http.StatusBadRequest, "connector not ready")
return
}
body, err := io.ReadAll(r.Body)
if err != nil {
s.errorResponse(w, http.StatusBadRequest, "failed to read body")
return
}
var snapshot StorageSnapshot
if err := json.Unmarshal(body, &snapshot); err != nil {
s.errorResponse(w, http.StatusBadRequest, "invalid snapshot format")
return
}
conn.storage.Import(snapshot)
s.jsonResponse(w, http.StatusOK, map[string]string{"status": "imported"})
}
// === Debug Endpoints (Global) ===
func (s *Server) handleDebugLogs(w http.ResponseWriter, r *http.Request) {
limitStr := r.URL.Query().Get("limit")
limit := 100
if limitStr != "" {
if l, err := strconv.Atoi(limitStr); err == nil {
limit = l
}
}
typeStr := r.URL.Query().Get("type")
var entryType *DebugEntryType
if typeStr != "" {
t := DebugEntryType(typeStr)
entryType = &t
}
entries := s.workbench.Debug().GetEntries(entryType, limit)
s.jsonResponse(w, http.StatusOK, map[string]interface{}{
"entries": entries,
"count": len(entries),
})
}
func (s *Server) handleDebugRequests(w http.ResponseWriter, r *http.Request) {
limitStr := r.URL.Query().Get("limit")
limit := 50
if limitStr != "" {
if l, err := strconv.Atoi(limitStr); err == nil {
limit = l
}
}
requests := s.workbench.Debug().GetHTTPRequests(limit)
s.jsonResponse(w, http.StatusOK, map[string]interface{}{
"requests": requests,
"count": len(requests),
})
}
func (s *Server) handleDebugPluginCalls(w http.ResponseWriter, r *http.Request) {
limitStr := r.URL.Query().Get("limit")
limit := 50
if limitStr != "" {
if l, err := strconv.Atoi(limitStr); err == nil {
limit = l
}
}
calls := s.workbench.Debug().GetPluginCalls(limit)
s.jsonResponse(w, http.StatusOK, map[string]interface{}{
"plugin_calls": calls,
"count": len(calls),
})
}
func (s *Server) handleDebugStats(w http.ResponseWriter, r *http.Request) {
stats := s.workbench.Debug().Stats()
s.jsonResponse(w, http.StatusOK, stats)
}
func (s *Server) handleDebugClear(w http.ResponseWriter, r *http.Request) {
s.workbench.Debug().Clear()
s.jsonResponse(w, http.StatusOK, map[string]string{"status": "cleared"})
}
func (s *Server) handleHTTPCaptureStatus(w http.ResponseWriter, r *http.Request) {
transport := s.workbench.Transport()
enabled := false
if transport != nil {
enabled = transport.IsEnabled()
}
s.jsonResponse(w, http.StatusOK, map[string]interface{}{
"enabled": enabled,
"max_body_size": transport.MaxBodySize,
})
}
func (s *Server) handleHTTPCaptureEnable(w http.ResponseWriter, r *http.Request) {
transport := s.workbench.Transport()
if transport != nil {
transport.Enable()
}
s.jsonResponse(w, http.StatusOK, map[string]string{"status": "enabled"})
}
func (s *Server) handleHTTPCaptureDisable(w http.ResponseWriter, r *http.Request) {
transport := s.workbench.Transport()
if transport != nil {
transport.Disable()
}
s.jsonResponse(w, http.StatusOK, map[string]string{"status": "disabled"})
}
// === Config Endpoints ===
func (s *Server) handleGetConfig(w http.ResponseWriter, r *http.Request) {
conn := s.getConnector(r)
if conn == nil {
return
}
s.jsonResponse(w, http.StatusOK, map[string]interface{}{
"id": conn.ID,
"provider": conn.Provider,
"connector_id": conn.ConnectorID.String(),
"installed": conn.Installed,
})
}
func (s *Server) handleSetPageSize(w http.ResponseWriter, r *http.Request) {
conn := s.getConnector(r)
if conn == nil || conn.engine == nil {
s.errorResponse(w, http.StatusBadRequest, "connector not ready")
return
}
var req struct {
PageSize int `json:"page_size"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
s.errorResponse(w, http.StatusBadRequest, "invalid request")
return
}
if req.PageSize < 1 || req.PageSize > 1000 {
s.errorResponse(w, http.StatusBadRequest, "page_size must be between 1 and 1000")
return
}
conn.engine.SetPageSize(req.PageSize)
s.jsonResponse(w, http.StatusOK, map[string]interface{}{"page_size": req.PageSize})
}
// === Introspection Endpoints ===
func (s *Server) handleIntrospectInfo(w http.ResponseWriter, r *http.Request) {
conn := s.getConnector(r)
if conn == nil || conn.introspector == nil {
s.errorResponse(w, http.StatusBadRequest, "introspector not available")
return
}
info, err := conn.introspector.GetInfo()
if err != nil {
s.errorResponse(w, http.StatusInternalServerError, err.Error())
return
}
s.jsonResponse(w, http.StatusOK, info)
}
func (s *Server) handleIntrospectFiles(w http.ResponseWriter, r *http.Request) {
conn := s.getConnector(r)
if conn == nil || conn.introspector == nil {
s.errorResponse(w, http.StatusBadRequest, "introspector not available")
return
}
files, err := conn.introspector.GetFileTree()
if err != nil {
s.errorResponse(w, http.StatusInternalServerError, err.Error())
return
}
s.jsonResponse(w, http.StatusOK, map[string]interface{}{"files": files})
}
func (s *Server) handleIntrospectFile(w http.ResponseWriter, r *http.Request) {
conn := s.getConnector(r)
if conn == nil || conn.introspector == nil {
s.errorResponse(w, http.StatusBadRequest, "introspector not available")
return
}
path := r.URL.Query().Get("path")
if path == "" {
s.errorResponse(w, http.StatusBadRequest, "path parameter required")
return
}
file, err := conn.introspector.GetFile(path)
if err != nil {
s.errorResponse(w, http.StatusNotFound, err.Error())
return
}
s.jsonResponse(w, http.StatusOK, file)
}
func (s *Server) handleIntrospectSearch(w http.ResponseWriter, r *http.Request) {
conn := s.getConnector(r)
if conn == nil || conn.introspector == nil {
s.errorResponse(w, http.StatusBadRequest, "introspector not available")
return
}
query := r.URL.Query().Get("q")
if query == "" {
s.errorResponse(w, http.StatusBadRequest, "q parameter required")
return
}
results, err := conn.introspector.SearchCode(query)
if err != nil {
s.errorResponse(w, http.StatusInternalServerError, err.Error())
return
}
s.jsonResponse(w, http.StatusOK, map[string]interface{}{
"query": query,
"results": results,
"count": len(results),
})
}
// === Task Tracking Endpoints ===
func (s *Server) handleGetTasks(w http.ResponseWriter, r *http.Request) {
conn := s.getConnector(r)
if conn == nil || conn.tasks == nil {
s.errorResponse(w, http.StatusBadRequest, "task tracker not available")
return
}
summary := conn.tasks.GetSummary()
if conn.engine != nil {
summary.IsRunning = conn.engine.IsRunning()
}
s.jsonResponse(w, http.StatusOK, summary)
}
func (s *Server) handleGetTaskExecutions(w http.ResponseWriter, r *http.Request) {
conn := s.getConnector(r)
if conn == nil || conn.tasks == nil {
s.errorResponse(w, http.StatusBadRequest, "task tracker not available")
return
}
limitStr := r.URL.Query().Get("limit")
limit := 50
if limitStr != "" {
if l, err := strconv.Atoi(limitStr); err == nil {
limit = l
}
}
executions := conn.tasks.GetExecutions(limit)
s.jsonResponse(w, http.StatusOK, map[string]interface{}{
"executions": executions,
"count": len(executions),
})
}
func (s *Server) handleTaskStep(w http.ResponseWriter, r *http.Request) {
conn := s.getConnector(r)
if conn == nil || conn.tasks == nil {
s.errorResponse(w, http.StatusBadRequest, "task tracker not available")
return
}
conn.tasks.Step()
s.jsonResponse(w, http.StatusOK, map[string]string{"status": "stepped"})
}
func (s *Server) handleSetStepMode(w http.ResponseWriter, r *http.Request) {
conn := s.getConnector(r)
if conn == nil || conn.tasks == nil {
s.errorResponse(w, http.StatusBadRequest, "task tracker not available")
return
}
var req struct {
Enabled bool `json:"enabled"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
s.errorResponse(w, http.StatusBadRequest, "invalid request")
return
}
conn.tasks.SetStepMode(req.Enabled)
s.jsonResponse(w, http.StatusOK, map[string]bool{"step_mode": req.Enabled})
}
func (s *Server) handleResetTasks(w http.ResponseWriter, r *http.Request) {
conn := s.getConnector(r)
if conn == nil || conn.tasks == nil {
s.errorResponse(w, http.StatusBadRequest, "task tracker not available")
return
}
conn.tasks.Reset()
s.jsonResponse(w, http.StatusOK, map[string]string{"status": "reset"})
}
// === Replay Endpoints ===
func (s *Server) handleReplayHistory(w http.ResponseWriter, r *http.Request) {
replayer := s.workbench.Replayer()
if replayer == nil {
s.errorResponse(w, http.StatusInternalServerError, "replayer not available")
return
}
limitStr := r.URL.Query().Get("limit")
limit := 50
if limitStr != "" {
if l, err := strconv.Atoi(limitStr); err == nil {
limit = l
}
}
history := replayer.GetHistory(limit)
s.jsonResponse(w, http.StatusOK, map[string]interface{}{
"history": history,
"count": len(history),
})
}
func (s *Server) handleGetReplay(w http.ResponseWriter, r *http.Request) {
replayer := s.workbench.Replayer()
if replayer == nil {
s.errorResponse(w, http.StatusInternalServerError, "replayer not available")
return
}
id := chi.URLParam(r, "id")
replay := replayer.GetReplayByID(id)
if replay == nil {
s.errorResponse(w, http.StatusNotFound, "replay not found")
return
}
s.jsonResponse(w, http.StatusOK, replay)
}
func (s *Server) handleReplay(w http.ResponseWriter, r *http.Request) {
replayer := s.workbench.Replayer()
if replayer == nil {
s.errorResponse(w, http.StatusInternalServerError, "replayer not available")
return
}
var req ReplayRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
s.errorResponse(w, http.StatusBadRequest, "invalid request: "+err.Error())
return
}
resp, err := replayer.Replay(r.Context(), req)
if err != nil {
s.errorResponse(w, http.StatusBadRequest, err.Error())
return
}
s.jsonResponse(w, http.StatusOK, resp)
}
func (s *Server) handleReplayFromCapture(w http.ResponseWriter, r *http.Request) {
replayer := s.workbench.Replayer()
if replayer == nil {
s.errorResponse(w, http.StatusInternalServerError, "replayer not available")
return
}
captureID := chi.URLParam(r, "id")
// Parse optional modifications
var modifications *ReplayRequest
if r.ContentLength > 0 {
modifications = &ReplayRequest{}
if err := json.NewDecoder(r.Body).Decode(modifications); err != nil {
s.errorResponse(w, http.StatusBadRequest, "invalid modifications: "+err.Error())
return
}
}
resp, err := replayer.ReplayFromCapture(r.Context(), captureID, modifications)
if err != nil {
s.errorResponse(w, http.StatusBadRequest, err.Error())
return
}
s.jsonResponse(w, http.StatusOK, resp)
}
func (s *Server) handleReplayCompare(w http.ResponseWriter, r *http.Request) {
replayer := s.workbench.Replayer()
if replayer == nil {
s.errorResponse(w, http.StatusInternalServerError, "replayer not available")
return
}
replayID := chi.URLParam(r, "id")
comparison, err := replayer.Compare(replayID)
if err != nil {
s.errorResponse(w, http.StatusBadRequest, err.Error())
return
}
s.jsonResponse(w, http.StatusOK, comparison)
}
func (s *Server) handleReplayDryRun(w http.ResponseWriter, r *http.Request) {
replayer := s.workbench.Replayer()
if replayer == nil {
s.errorResponse(w, http.StatusInternalServerError, "replayer not available")
return
}
var req ReplayRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
s.errorResponse(w, http.StatusBadRequest, "invalid request: "+err.Error())
return
}
result, err := replayer.DryRun(r.Context(), req)
if err != nil {
s.errorResponse(w, http.StatusBadRequest, err.Error())
return
}
s.jsonResponse(w, http.StatusOK, result)
}
func (s *Server) handleReplayCurl(w http.ResponseWriter, r *http.Request) {
replayer := s.workbench.Replayer()
debug := s.workbench.Debug()
if replayer == nil || debug == nil {
s.errorResponse(w, http.StatusInternalServerError, "replayer not available")
return
}
id := chi.URLParam(r, "id")
// Try to find in captured requests
captured := debug.GetHTTPRequestByID(id)
if captured != nil {
req := ReplayRequest{
Method: captured.Method,
URL: captured.URL,
Headers: captured.RequestHeaders,
Body: captured.RequestBody,
}
curl := replayer.CreateCurlCommand(req)
s.jsonResponse(w, http.StatusOK, map[string]string{"curl": curl})
return
}
// Try to find in replay history
replay := replayer.GetReplayByID(id)
if replay != nil {
curl := replayer.CreateCurlCommand(replay.Request)
s.jsonResponse(w, http.StatusOK, map[string]string{"curl": curl})
return
}
s.errorResponse(w, http.StatusNotFound, "request not found")
}
func (s *Server) handleClearReplayHistory(w http.ResponseWriter, r *http.Request) {
replayer := s.workbench.Replayer()
if replayer == nil {
s.errorResponse(w, http.StatusInternalServerError, "replayer not available")
return
}
replayer.ClearHistory()
s.jsonResponse(w, http.StatusOK, map[string]string{"status": "cleared"})
}
// === Snapshot Endpoints ===
func (s *Server) handleListSnapshots(w http.ResponseWriter, r *http.Request) {
conn := s.getConnector(r)
if conn == nil || conn.snapshots == nil {
s.errorResponse(w, http.StatusBadRequest, "snapshots not available")
return
}
operation := r.URL.Query().Get("operation")
tagsParam := r.URL.Query().Get("tags")
var tags []string
if tagsParam != "" {
tags = strings.Split(tagsParam, ",")
}
list := conn.snapshots.List(operation, tags)
s.jsonResponse(w, http.StatusOK, map[string]interface{}{
"snapshots": list,
"count": len(list),
})
}
func (s *Server) handleSnapshotStats(w http.ResponseWriter, r *http.Request) {
conn := s.getConnector(r)
if conn == nil || conn.snapshots == nil {
s.errorResponse(w, http.StatusBadRequest, "snapshots not available")
return
}
stats := conn.snapshots.Stats()
s.jsonResponse(w, http.StatusOK, stats)
}
func (s *Server) handleGetSnapshot(w http.ResponseWriter, r *http.Request) {
conn := s.getConnector(r)
if conn == nil || conn.snapshots == nil {
s.errorResponse(w, http.StatusBadRequest, "snapshots not available")
return
}
id := chi.URLParam(r, "id")
snapshot := conn.snapshots.Get(id)
if snapshot == nil {
s.errorResponse(w, http.StatusNotFound, "snapshot not found")
return
}
s.jsonResponse(w, http.StatusOK, snapshot)
}
func (s *Server) handleCreateSnapshot(w http.ResponseWriter, r *http.Request) {
conn := s.getConnector(r)
if conn == nil || conn.snapshots == nil {
s.errorResponse(w, http.StatusBadRequest, "snapshots not available")
return
}
var snapshot Snapshot
if err := json.NewDecoder(r.Body).Decode(&snapshot); err != nil {
s.errorResponse(w, http.StatusBadRequest, "invalid request: "+err.Error())
return
}
if err := conn.snapshots.Save(&snapshot); err != nil {
s.errorResponse(w, http.StatusInternalServerError, err.Error())
return
}
s.jsonResponse(w, http.StatusCreated, snapshot)
}
func (s *Server) handleCreateSnapshotFromCapture(w http.ResponseWriter, r *http.Request) {
conn := s.getConnector(r)
if conn == nil || conn.snapshots == nil {
s.errorResponse(w, http.StatusBadRequest, "snapshots not available")
return
}
captureID := chi.URLParam(r, "id")
var req struct {
Name string `json:"name"`
Operation string `json:"operation"`
Description string `json:"description"`
Tags []string `json:"tags"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
s.errorResponse(w, http.StatusBadRequest, "invalid request: "+err.Error())
return
}
if req.Name == "" {
s.errorResponse(w, http.StatusBadRequest, "name is required")
return
}
if req.Operation == "" {
s.errorResponse(w, http.StatusBadRequest, "operation is required")
return
}
snapshot, err := conn.snapshots.SaveFromCapture(captureID, req.Name, req.Operation, req.Description, req.Tags)
if err != nil {
s.errorResponse(w, http.StatusBadRequest, err.Error())
return
}
s.jsonResponse(w, http.StatusCreated, snapshot)
}
func (s *Server) handleDeleteSnapshot(w http.ResponseWriter, r *http.Request) {
conn := s.getConnector(r)
if conn == nil || conn.snapshots == nil {
s.errorResponse(w, http.StatusBadRequest, "snapshots not available")
return
}
id := chi.URLParam(r, "id")
if err := conn.snapshots.Delete(id); err != nil {
s.errorResponse(w, http.StatusNotFound, err.Error())
return
}
s.jsonResponse(w, http.StatusOK, map[string]string{"status": "deleted"})
}
func (s *Server) handleClearSnapshots(w http.ResponseWriter, r *http.Request) {
conn := s.getConnector(r)
if conn == nil || conn.snapshots == nil {
s.errorResponse(w, http.StatusBadRequest, "snapshots not available")
return
}
conn.snapshots.Clear()
s.jsonResponse(w, http.StatusOK, map[string]string{"status": "cleared"})
}
func (s *Server) handleExportSnapshots(w http.ResponseWriter, r *http.Request) {
conn := s.getConnector(r)
if conn == nil || conn.snapshots == nil {
s.errorResponse(w, http.StatusBadRequest, "snapshots not available")
return
}
var req struct {
Directory string `json:"directory"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
s.errorResponse(w, http.StatusBadRequest, "invalid request: "+err.Error())
return
}
if req.Directory == "" {
s.errorResponse(w, http.StatusBadRequest, "directory is required")
return
}
if err := conn.snapshots.ExportToDir(req.Directory); err != nil {
s.errorResponse(w, http.StatusInternalServerError, err.Error())
return
}
s.jsonResponse(w, http.StatusOK, map[string]string{
"status": "exported",
"directory": req.Directory,
})
}
func (s *Server) handleImportSnapshots(w http.ResponseWriter, r *http.Request) {
conn := s.getConnector(r)
if conn == nil || conn.snapshots == nil {
s.errorResponse(w, http.StatusBadRequest, "snapshots not available")
return
}
var req struct {
Directory string `json:"directory"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
s.errorResponse(w, http.StatusBadRequest, "invalid request: "+err.Error())
return
}
if req.Directory == "" {
s.errorResponse(w, http.StatusBadRequest, "directory is required")
return
}
count, err := conn.snapshots.ImportFromDir(req.Directory)
if err != nil {
s.errorResponse(w, http.StatusInternalServerError, err.Error())
return
}
s.jsonResponse(w, http.StatusOK, map[string]interface{}{
"status": "imported",
"imported": count,
})
}
// === Test Generation Endpoints ===
func (s *Server) handlePreviewTests(w http.ResponseWriter, r *http.Request) {
conn := s.getConnector(r)
if conn == nil || conn.testGen == nil {
s.errorResponse(w, http.StatusBadRequest, "test generator not available")
return
}
result, err := conn.testGen.PreviewTest()
if err != nil {
s.errorResponse(w, http.StatusBadRequest, err.Error())
return
}
s.jsonResponse(w, http.StatusOK, result)
}
func (s *Server) handleGenerateTests(w http.ResponseWriter, r *http.Request) {
conn := s.getConnector(r)
if conn == nil || conn.testGen == nil || conn.snapshots == nil {
s.errorResponse(w, http.StatusBadRequest, "test generator not available")
return
}
var req struct {
OutputDir string `json:"output_dir"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
s.errorResponse(w, http.StatusBadRequest, "invalid request: "+err.Error())
return
}
result, err := conn.testGen.Generate()
if err != nil {
s.errorResponse(w, http.StatusBadRequest, err.Error())
return
}
// If output directory specified, write files
if req.OutputDir != "" {
if err := writeGeneratedFiles(req.OutputDir, result); err != nil {
s.errorResponse(w, http.StatusInternalServerError, err.Error())
return
}
result.Instructions = fmt.Sprintf("Files written to %s\n\n%s", req.OutputDir, result.Instructions)
}
s.jsonResponse(w, http.StatusOK, result)
}
// === Schema Inference Endpoints ===
func (s *Server) handleListSchemas(w http.ResponseWriter, r *http.Request) {
conn := s.getConnector(r)
if conn == nil || conn.schemas == nil {
s.errorResponse(w, http.StatusBadRequest, "schema manager not available")
return
}
list := conn.schemas.ListSchemas()
s.jsonResponse(w, http.StatusOK, map[string]interface{}{
"schemas": list,
"count": len(list),
})
}
func (s *Server) handleSchemaStats(w http.ResponseWriter, r *http.Request) {
conn := s.getConnector(r)
if conn == nil || conn.schemas == nil {
s.errorResponse(w, http.StatusBadRequest, "schema manager not available")
return
}
stats := conn.schemas.Stats()
s.jsonResponse(w, http.StatusOK, stats)
}
func (s *Server) handleGetSchema(w http.ResponseWriter, r *http.Request) {
conn := s.getConnector(r)
if conn == nil || conn.schemas == nil {
s.errorResponse(w, http.StatusBadRequest, "schema manager not available")
return
}
operation := chi.URLParam(r, "operation")
schema := conn.schemas.GetSchema(operation)
if schema == nil {
s.errorResponse(w, http.StatusNotFound, "schema not found")
return
}
s.jsonResponse(w, http.StatusOK, schema)
}
func (s *Server) handleInferSchema(w http.ResponseWriter, r *http.Request) {
conn := s.getConnector(r)
if conn == nil || conn.schemas == nil {
s.errorResponse(w, http.StatusBadRequest, "schema manager not available")
return
}
var req struct {
Operation string `json:"operation"`
Endpoint string `json:"endpoint"`
Method string `json:"method"`
Data json.RawMessage `json:"data"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
s.errorResponse(w, http.StatusBadRequest, "invalid request: "+err.Error())
return
}
schema, err := conn.schemas.InferFromJSON(req.Operation, req.Endpoint, req.Method, req.Data)
if err != nil {
s.errorResponse(w, http.StatusBadRequest, err.Error())
return
}
s.jsonResponse(w, http.StatusOK, schema)
}
func (s *Server) handleSaveSchemaBaseline(w http.ResponseWriter, r *http.Request) {
conn := s.getConnector(r)
if conn == nil || conn.schemas == nil {
s.errorResponse(w, http.StatusBadRequest, "schema manager not available")
return
}
var req struct {
Operation string `json:"operation"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
s.errorResponse(w, http.StatusBadRequest, "invalid request: "+err.Error())
return
}
if err := conn.schemas.SaveBaseline(req.Operation); err != nil {
s.errorResponse(w, http.StatusBadRequest, err.Error())
return
}
s.jsonResponse(w, http.StatusOK, map[string]string{"status": "saved"})
}
func (s *Server) handleSaveAllSchemaBaselines(w http.ResponseWriter, r *http.Request) {
conn := s.getConnector(r)
if conn == nil || conn.schemas == nil {
s.errorResponse(w, http.StatusBadRequest, "schema manager not available")
return
}
count := conn.schemas.SaveAllBaselines()
s.jsonResponse(w, http.StatusOK, map[string]interface{}{
"status": "saved",
"count": count,
})
}
func (s *Server) handleListSchemaBaselines(w http.ResponseWriter, r *http.Request) {
conn := s.getConnector(r)
if conn == nil || conn.schemas == nil {
s.errorResponse(w, http.StatusBadRequest, "schema manager not available")
return
}
list := conn.schemas.ListBaselines()
s.jsonResponse(w, http.StatusOK, map[string]interface{}{
"baselines": list,
"count": len(list),
})
}
func (s *Server) handleCompareSchema(w http.ResponseWriter, r *http.Request) {
conn := s.getConnector(r)
if conn == nil || conn.schemas == nil {
s.errorResponse(w, http.StatusBadRequest, "schema manager not available")
return
}
operation := chi.URLParam(r, "operation")
diff, err := conn.schemas.CompareWithBaseline(operation)
if err != nil {
s.errorResponse(w, http.StatusBadRequest, err.Error())
return
}
s.jsonResponse(w, http.StatusOK, diff)
}
func (s *Server) handleClearSchemas(w http.ResponseWriter, r *http.Request) {
conn := s.getConnector(r)
if conn == nil || conn.schemas == nil {
s.errorResponse(w, http.StatusBadRequest, "schema manager not available")
return
}
conn.schemas.Clear()
s.jsonResponse(w, http.StatusOK, map[string]string{"status": "cleared"})
}
// === Data Baseline Endpoints ===
func (s *Server) handleListBaselines(w http.ResponseWriter, r *http.Request) {
conn := s.getConnector(r)
if conn == nil || conn.baselines == nil {
s.errorResponse(w, http.StatusBadRequest, "baseline manager not available")
return
}
list := conn.baselines.ListBaselines()
s.jsonResponse(w, http.StatusOK, map[string]interface{}{
"baselines": list,
"count": len(list),
})
}
func (s *Server) handleGetBaseline(w http.ResponseWriter, r *http.Request) {
conn := s.getConnector(r)
if conn == nil || conn.baselines == nil {
s.errorResponse(w, http.StatusBadRequest, "baseline manager not available")
return
}
id := chi.URLParam(r, "id")
baseline := conn.baselines.GetBaseline(id)
if baseline == nil {
s.errorResponse(w, http.StatusNotFound, "baseline not found")
return
}
s.jsonResponse(w, http.StatusOK, baseline)
}
func (s *Server) handleSaveBaseline(w http.ResponseWriter, r *http.Request) {
conn := s.getConnector(r)
if conn == nil || conn.baselines == nil {
s.errorResponse(w, http.StatusBadRequest, "baseline manager not available")
return
}
var req struct {
Name string `json:"name"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
s.errorResponse(w, http.StatusBadRequest, "invalid request: "+err.Error())
return
}
if req.Name == "" {
req.Name = fmt.Sprintf("baseline-%s", time.Now().Format("2006-01-02-15-04"))
}
baseline, err := conn.baselines.SaveBaseline(req.Name)
if err != nil {
s.errorResponse(w, http.StatusInternalServerError, err.Error())
return
}
s.jsonResponse(w, http.StatusCreated, baseline)
}
func (s *Server) handleDeleteBaseline(w http.ResponseWriter, r *http.Request) {
conn := s.getConnector(r)
if conn == nil || conn.baselines == nil {
s.errorResponse(w, http.StatusBadRequest, "baseline manager not available")
return
}
id := chi.URLParam(r, "id")
if err := conn.baselines.DeleteBaseline(id); err != nil {
s.errorResponse(w, http.StatusNotFound, err.Error())
return
}
s.jsonResponse(w, http.StatusOK, map[string]string{"status": "deleted"})
}
func (s *Server) handleCompareBaseline(w http.ResponseWriter, r *http.Request) {
conn := s.getConnector(r)
if conn == nil || conn.baselines == nil {
s.errorResponse(w, http.StatusBadRequest, "baseline manager not available")
return
}
id := chi.URLParam(r, "id")
diff, err := conn.baselines.CompareWithCurrent(id)
if err != nil {
s.errorResponse(w, http.StatusBadRequest, err.Error())
return
}
s.jsonResponse(w, http.StatusOK, diff)
}
func (s *Server) handleExportBaseline(w http.ResponseWriter, r *http.Request) {
conn := s.getConnector(r)
if conn == nil || conn.baselines == nil {
s.errorResponse(w, http.StatusBadRequest, "baseline manager not available")
return
}
id := chi.URLParam(r, "id")
data, err := conn.baselines.Export(id)
if err != nil {
s.errorResponse(w, http.StatusNotFound, err.Error())
return
}
w.Header().Set("Content-Type", "application/json")
w.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=%s.json", id))
_, _ = w.Write(data)
}
func (s *Server) handleImportBaseline(w http.ResponseWriter, r *http.Request) {
conn := s.getConnector(r)
if conn == nil || conn.baselines == nil {
s.errorResponse(w, http.StatusBadRequest, "baseline manager not available")
return
}
data, err := io.ReadAll(r.Body)
if err != nil {
s.errorResponse(w, http.StatusBadRequest, "failed to read body")
return
}
baseline, err := conn.baselines.Import(data)
if err != nil {
s.errorResponse(w, http.StatusBadRequest, err.Error())
return
}
s.jsonResponse(w, http.StatusCreated, baseline)
}
// === Fallback UI ===
func (s *Server) handleUIFallback(w http.ResponseWriter, r *http.Request) {
html := `<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Connector Workbench</title>
<style>
* { box-sizing: border-box; margin: 0; padding: 0; }
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; background: #0d1117; color: #c9d1d9; padding: 40px; }
h1 { color: #58a6ff; margin-bottom: 20px; }
p { margin-bottom: 10px; color: #8b949e; }
a { color: #58a6ff; }
code { background: #161b22; padding: 2px 6px; border-radius: 3px; }
</style>
</head>
<body>
<h1>Connector Workbench (Multi-Connector)</h1>
<p>The full UI is not embedded. Build it with:</p>
<p><code>cd tools/workbench/ui && npm install && npm run build</code></p>
<p>API available at <a href="/api/status">/api/status</a></p>
<p>Available connectors at <a href="/api/connectors/available">/api/connectors/available</a></p>
</body>
</html>`
w.Header().Set("Content-Type", "text/html")
_, _ = w.Write([]byte(html))
}
// writeGeneratedFiles writes generated test files to the specified directory.
func writeGeneratedFiles(outputDir string, result *GenerateResult) error {
// Create output directory
if err := os.MkdirAll(outputDir, 0755); err != nil {
return fmt.Errorf("failed to create output directory: %w", err)
}
// Create testdata subdirectory
testdataDir := filepath.Join(outputDir, "testdata")
if err := os.MkdirAll(testdataDir, 0755); err != nil {
return fmt.Errorf("failed to create testdata directory: %w", err)
}
// Write test file
testPath := filepath.Join(outputDir, result.TestFile.Filename)
if err := os.WriteFile(testPath, []byte(result.TestFile.Content), 0644); err != nil {
return fmt.Errorf("failed to write test file: %w", err)
}
// Write fixture files
for _, fixture := range result.Fixtures {
fixturePath := filepath.Join(testdataDir, fixture.Filename)
if err := os.WriteFile(fixturePath, []byte(fixture.Content), 0644); err != nil {
return fmt.Errorf("failed to write fixture %s: %w", fixture.Filename, err)
}
}
return nil
}
// === Generic Connector Server ===
// These handlers expose the standard generic connector API so remote services
// can connect to locally-running connectors for integration testing.
// genericServerMiddleware validates API key for the generic server.
// Handlers get the connector directly from the workbench for clearer data flow.
func (s *Server) genericServerMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
conn, apiKey := s.workbench.GetGenericServerConnector()
if conn == nil {
s.genericError(w, http.StatusServiceUnavailable, "Generic server not configured", "No connector is configured for the generic server. Use the API to set one.")
return
}
if !conn.Installed {
s.genericError(w, http.StatusServiceUnavailable, "Connector not installed", "The configured connector is not installed.")
return
}
// Validate API key if configured
if apiKey != "" {
authHeader := r.Header.Get("Authorization")
providedKey := ""
if strings.HasPrefix(authHeader, "Bearer ") {
providedKey = strings.TrimPrefix(authHeader, "Bearer ")
} else {
// Also check X-API-Key header
providedKey = r.Header.Get("X-API-Key")
}
if providedKey != apiKey {
s.genericError(w, http.StatusUnauthorized, "Unauthorized", "Invalid or missing API key")
return
}
}
next.ServeHTTP(w, r)
})
}
// getGenericConnector returns the configured generic connector directly from the workbench.
// This avoids storing connector references in context, providing clearer data flow.
func (s *Server) getGenericConnector() *ConnectorInstance {
conn, _ := s.workbench.GetGenericServerConnector()
return conn
}
func (s *Server) genericError(w http.ResponseWriter, status int, title, detail string) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
_ = json.NewEncoder(w).Encode(map[string]string{
"Title": title,
"Detail": detail,
})
}
// handleGenericServerStatus returns the generic server configuration.
func (s *Server) handleGenericServerStatus(w http.ResponseWriter, r *http.Request) {
s.jsonResponse(w, http.StatusOK, s.workbench.GetGenericServerStatus())
}
// handleSetGenericConnector sets the connector for the generic server.
func (s *Server) handleSetGenericConnector(w http.ResponseWriter, r *http.Request) {
var req struct {
ConnectorID string `json:"connector_id"`
APIKey string `json:"api_key"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
s.errorResponse(w, http.StatusBadRequest, "invalid request: "+err.Error())
return
}
if err := s.workbench.SetGenericServerConnector(req.ConnectorID, req.APIKey); err != nil {
s.errorResponse(w, http.StatusBadRequest, err.Error())
return
}
s.jsonResponse(w, http.StatusOK, s.workbench.GetGenericServerStatus())
}
// handleGenericAccounts handles GET /generic/accounts
func (s *Server) handleGenericAccounts(w http.ResponseWriter, r *http.Request) {
conn := s.getGenericConnector()
if conn == nil {
return // Error already sent by middleware
}
// Parse query params
createdAtFrom := r.URL.Query().Get("createdAtFrom")
// Return cached accounts from storage
storedAccounts := conn.storage.GetAccounts()
// Transform to generic format
accounts := make([]map[string]interface{}, 0, len(storedAccounts))
for _, acc := range storedAccounts {
// Filter by createdAtFrom if specified
if createdAtFrom != "" {
if fromTime, err := time.Parse(time.RFC3339, createdAtFrom); err == nil {
if acc.CreatedAt.Before(fromTime) {
continue
}
}
}
account := map[string]interface{}{
"id": acc.Reference,
"createdAt": acc.CreatedAt.Format(time.RFC3339),
}
if acc.Name != nil {
account["accountName"] = *acc.Name
} else {
account["accountName"] = acc.Reference
}
if len(acc.Metadata) > 0 {
account["metadata"] = acc.Metadata
}
accounts = append(accounts, account)
}
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(accounts)
}
// handleGenericBalances handles GET /generic/accounts/{accountId}/balances
func (s *Server) handleGenericBalances(w http.ResponseWriter, r *http.Request) {
conn := s.getGenericConnector()
if conn == nil {
return
}
accountID := chi.URLParam(r, "accountId")
if accountID == "" {
s.genericError(w, http.StatusBadRequest, "Missing account ID", "accountId path parameter is required")
return
}
// Return cached balances from storage
storedBalances := conn.storage.GetBalancesForAccount(accountID)
// Transform to generic format
balances := make([]map[string]interface{}, 0, len(storedBalances))
var latestTime time.Time
for _, bal := range storedBalances {
balances = append(balances, map[string]interface{}{
"amount": bal.Amount.String(),
"currency": bal.Asset,
})
if bal.CreatedAt.After(latestTime) {
latestTime = bal.CreatedAt
}
}
// Use latest balance time, or now if no balances
if latestTime.IsZero() {
latestTime = time.Now()
}
result := map[string]interface{}{
"id": accountID + "-balance",
"accountID": accountID,
"at": latestTime.Format(time.RFC3339),
"balances": balances,
}
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(result)
}
// handleGenericBeneficiaries handles GET /generic/beneficiaries
func (s *Server) handleGenericBeneficiaries(w http.ResponseWriter, r *http.Request) {
conn := s.getGenericConnector()
if conn == nil {
return
}
// Parse query params
createdAtFrom := r.URL.Query().Get("createdAtFrom")
// Return cached external accounts from storage
storedExternalAccounts := conn.storage.GetExternalAccounts()
// Transform to generic format
beneficiaries := make([]map[string]interface{}, 0, len(storedExternalAccounts))
for _, ext := range storedExternalAccounts {
if createdAtFrom != "" {
if fromTime, err := time.Parse(time.RFC3339, createdAtFrom); err == nil {
if ext.CreatedAt.Before(fromTime) {
continue
}
}
}
beneficiary := map[string]interface{}{
"id": ext.Reference,
"createdAt": ext.CreatedAt.Format(time.RFC3339),
}
if ext.Name != nil {
beneficiary["ownerName"] = *ext.Name
} else {
beneficiary["ownerName"] = ext.Reference
}
if len(ext.Metadata) > 0 {
beneficiary["metadata"] = ext.Metadata
}
beneficiaries = append(beneficiaries, beneficiary)
}
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(beneficiaries)
}
// handleGenericTransactions handles GET /generic/transactions
func (s *Server) handleGenericTransactions(w http.ResponseWriter, r *http.Request) {
conn := s.getGenericConnector()
if conn == nil {
return
}
// Parse query params
updatedAtFrom := r.URL.Query().Get("updatedAtFrom")
// Return cached payments from storage
storedPayments := conn.storage.GetPayments()
// Transform to generic format
transactions := make([]map[string]interface{}, 0, len(storedPayments))
for _, pmt := range storedPayments {
if updatedAtFrom != "" {
if fromTime, err := time.Parse(time.RFC3339, updatedAtFrom); err == nil {
if pmt.CreatedAt.Before(fromTime) {
continue
}
}
}
tx := map[string]interface{}{
"id": pmt.Reference,
"createdAt": pmt.CreatedAt.Format(time.RFC3339),
"updatedAt": pmt.CreatedAt.Format(time.RFC3339), // Use createdAt as updatedAt if not available
"currency": pmt.Asset,
"amount": pmt.Amount.String(),
"type": mapPaymentTypeToGeneric(pmt.Type),
"status": mapPaymentStatusToGeneric(pmt.Status),
}
if pmt.Scheme != models.PAYMENT_SCHEME_UNKNOWN {
tx["scheme"] = pmt.Scheme.String()
}
if pmt.SourceAccountReference != nil {
tx["sourceAccountID"] = *pmt.SourceAccountReference
}
if pmt.DestinationAccountReference != nil {
tx["destinationAccountID"] = *pmt.DestinationAccountReference
}
if len(pmt.Metadata) > 0 {
tx["metadata"] = pmt.Metadata
}
transactions = append(transactions, tx)
}
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(transactions)
}
func mapPaymentTypeToGeneric(t models.PaymentType) string {
switch t {
case models.PAYMENT_TYPE_PAYIN:
return "PAYIN"
case models.PAYMENT_TYPE_PAYOUT:
return "PAYOUT"
case models.PAYMENT_TYPE_TRANSFER:
return "TRANSFER"
default:
return "TRANSFER"
}
}
func mapPaymentStatusToGeneric(s models.PaymentStatus) string {
switch s {
case models.PAYMENT_STATUS_PENDING:
return "PENDING"
case models.PAYMENT_STATUS_SUCCEEDED:
return "SUCCEEDED"
case models.PAYMENT_STATUS_FAILED, models.PAYMENT_STATUS_CANCELLED:
return "FAILED"
default:
return "PENDING"
}
}