Files
payments/cmd/workbench.go
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

193 lines
5.3 KiB
Go

package cmd
import (
"context"
"encoding/json"
"fmt"
"os"
"os/signal"
"strings"
"syscall"
"github.com/formancehq/go-libs/v3/logging"
"github.com/formancehq/payments/internal/connectors/plugins/registry"
"github.com/formancehq/payments/tools/workbench"
"github.com/spf13/cobra"
)
const (
workbenchProviderFlag = "provider"
workbenchConfigFlag = "config"
workbenchConfigFileFlag = "config-file"
workbenchListenFlag = "listen"
workbenchPageSizeFlag = "page-size"
)
func newWorkbench() *cobra.Command {
cmd := &cobra.Command{
Use: "workbench",
Short: "Run the connector workbench for local development",
Long: `The connector workbench provides a lightweight development environment
for building and testing payment connectors without requiring Temporal,
PostgreSQL, or other heavy infrastructure.
Features:
- In-memory storage (no database required)
- Multi-connector support - create multiple instances from the UI
- Step-by-step execution of connector operations
- HTTP API for triggering operations
- Web UI for debugging and inspection
- No Docker required
Examples:
# Run workbench without any connector (create from UI)
payments workbench
# Run workbench with a pre-configured connector
payments workbench --provider=stripe --config='{"apiKey":"sk_test_..."}'
# Run workbench with config file
payments workbench --provider=wise --config-file=./wise-config.json
# List available providers
payments workbench --list-providers`,
RunE: runWorkbench,
}
cmd.Flags().StringP(workbenchProviderFlag, "p", "", "Connector provider name (e.g., stripe, wise, adyen)")
cmd.Flags().StringP(workbenchConfigFlag, "c", "", "Connector configuration as JSON string")
cmd.Flags().StringP(workbenchConfigFileFlag, "f", "", "Path to connector configuration JSON file")
cmd.Flags().String(workbenchListenFlag, "127.0.0.1:8080", "HTTP server listen address")
cmd.Flags().Int(workbenchPageSizeFlag, 25, "Page size for fetch operations")
cmd.Flags().Bool("list-providers", false, "List available connector providers")
return cmd
}
func runWorkbench(cmd *cobra.Command, args []string) error {
// Check if listing providers
listProviders, _ := cmd.Flags().GetBool("list-providers")
if listProviders {
return printProviders()
}
// Get optional provider and config for initial connector
provider, _ := cmd.Flags().GetString(workbenchProviderFlag)
configJSON, _ := cmd.Flags().GetString(workbenchConfigFlag)
configFile, _ := cmd.Flags().GetString(workbenchConfigFileFlag)
var connectorConfig json.RawMessage
if provider != "" {
provider = strings.ToLower(provider)
if configFile != "" {
data, err := os.ReadFile(configFile)
if err != nil {
return fmt.Errorf("failed to read config file: %w", err)
}
connectorConfig = data
} else if configJSON != "" {
connectorConfig = json.RawMessage(configJSON)
} else {
return fmt.Errorf("connector config is required when provider is specified (use --config or --config-file)")
}
// Validate JSON
var validateJSON map[string]interface{}
if err := json.Unmarshal(connectorConfig, &validateJSON); err != nil {
return fmt.Errorf("invalid config JSON: %w", err)
}
}
// Get other flags
listenAddr, _ := cmd.Flags().GetString(workbenchListenFlag)
pageSize, _ := cmd.Flags().GetInt(workbenchPageSizeFlag)
// Create logger
logger := logging.NewDefaultLogger(os.Stdout, true, false, false)
// Create workbench config
cfg := workbench.Config{
ListenAddr: listenAddr,
EnableUI: true,
DefaultPageSize: pageSize,
}
// Create workbench
wb, err := workbench.New(cfg, logger)
if err != nil {
return fmt.Errorf("failed to create workbench: %w", err)
}
// Handle signals
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
sigChan := make(chan os.Signal, 1)
signal.Notify(sigChan, syscall.SIGINT, syscall.SIGTERM)
go func() {
<-sigChan
fmt.Println("\nShutting down...")
cancel()
_ = wb.Stop(context.Background())
}()
// Start workbench
if err := wb.Start(ctx); err != nil {
return fmt.Errorf("failed to start workbench: %w", err)
}
// If provider was specified, create and install a connector
if provider != "" {
conn, err := wb.CreateConnector(ctx, workbench.CreateConnectorRequest{
Provider: provider,
Name: "default",
Config: connectorConfig,
})
if err != nil {
logger.Error(fmt.Sprintf("Failed to create connector: %v", err))
} else {
logger.Info(fmt.Sprintf("Created connector instance: %s", conn.ID))
// Install the connector
if err := wb.InstallConnector(ctx, conn.ID); err != nil {
logger.Error(fmt.Sprintf("Failed to install connector: %v", err))
} else {
logger.Info("Connector installed successfully")
}
}
}
// Wait for shutdown
wb.Wait()
return nil
}
func printProviders() error {
configs := registry.GetConfigs(true) // include debug connectors
fmt.Println("Available connector providers:")
fmt.Println()
for provider, config := range configs {
fmt.Printf(" %s\n", provider)
fmt.Printf(" Config parameters:\n")
for paramName, param := range config {
required := ""
if param.Required {
required = " (required)"
}
fmt.Printf(" - %s: %s%s\n", paramName, param.DataType, required)
}
fmt.Println()
}
return nil
}
func init() {
// This will be added to root in NewRootCommand
}