mirror of
https://github.com/hanzo-fi/payments.git
synced 2026-10-30 20:31:20 +00:00
- go.mod module path + all self-imports: github.com/formancehq/payments -> github.com/hanzo-fi/payments (1590 .go files / 3152 refs; 20 modules: root, pkg/client, pkg/domain, 18 ce/plugins). External dep kept: formancehq/go-libs. - plugins: each ce/plugins/* consumed a *published* pkg/domain; added local 'replace .../pkg/domain => ../../../pkg/domain' (consistent with root) so every standalone plugin module builds in-monorepo. compile-plugin.sh MOD var rebranded too. - branding: README, LICENSE (dual MIT+EE; upstream copyright retained + Hanzo added), goreleaser (release images -> registry.hanzo.ai/hanzo-fi, OCI labels, homepage), proto go_package, renovate, speakeasy gen.yaml, connector-template *.gotpl. - CI: removed Numary/Formance workflows + composite action; added canonical hanzo.yml + cicd.yml (hanzoai/ci, gated HANZO_CI_ENABLED) + secretless ci.yml (go build/vet). - kept as data/compat keys: wire capability enums ALLOW_FORMANCE_*, generated SDK type client.Formance, domain field FormanceRedirectURL, OTEL_SERVICE_NAME, upstream base image. - verified: go build ./... (default + -tags ee) + all 20 nested modules green.
504 lines
13 KiB
Go
504 lines
13 KiB
Go
package workbench
|
|
|
|
import (
|
|
"fmt"
|
|
"go/ast"
|
|
"go/parser"
|
|
"go/token"
|
|
"os"
|
|
"path/filepath"
|
|
"sort"
|
|
"strings"
|
|
|
|
"github.com/hanzo-fi/payments/internal/connectors/plugins/registry"
|
|
"github.com/hanzo-fi/payments/pkg/domain/models"
|
|
)
|
|
|
|
// Introspector provides code introspection capabilities for connectors.
|
|
type Introspector struct {
|
|
provider string
|
|
connectorID models.ConnectorID
|
|
basePath string // Path to the connector's source directory
|
|
}
|
|
|
|
// NewIntrospector creates a new introspector for a connector.
|
|
func NewIntrospector(provider string, connectorID models.ConnectorID) *Introspector {
|
|
// Try to find the connector source directory
|
|
// Look in common locations relative to working directory
|
|
possiblePaths := []string{
|
|
// CE plugins location
|
|
filepath.Join("ce", "plugins", provider),
|
|
filepath.Join("..", "ce", "plugins", provider),
|
|
filepath.Join("..", "..", "ce", "plugins", provider),
|
|
// EE plugins location
|
|
filepath.Join("ee", "plugins", provider),
|
|
filepath.Join("..", "ee", "plugins", provider),
|
|
filepath.Join("..", "..", "ee", "plugins", provider),
|
|
}
|
|
|
|
var basePath string
|
|
for _, p := range possiblePaths {
|
|
if info, err := os.Stat(p); err == nil && info.IsDir() {
|
|
basePath, _ = filepath.Abs(p)
|
|
break
|
|
}
|
|
}
|
|
|
|
return &Introspector{
|
|
provider: provider,
|
|
connectorID: connectorID,
|
|
basePath: basePath,
|
|
}
|
|
}
|
|
|
|
// FileInfo represents information about a source file.
|
|
type FileInfo struct {
|
|
Name string `json:"name"`
|
|
Path string `json:"path"` // Relative path from connector root
|
|
Size int64 `json:"size"`
|
|
IsDir bool `json:"is_dir"`
|
|
Children []FileInfo `json:"children,omitempty"`
|
|
}
|
|
|
|
// SourceFile represents the contents of a source file.
|
|
type SourceFile struct {
|
|
Path string `json:"path"`
|
|
Name string `json:"name"`
|
|
Content string `json:"content"`
|
|
Language string `json:"language"`
|
|
Lines int `json:"lines"`
|
|
Symbols []Symbol `json:"symbols,omitempty"`
|
|
}
|
|
|
|
// Symbol represents a code symbol (function, type, etc.)
|
|
type Symbol struct {
|
|
Name string `json:"name"`
|
|
Kind string `json:"kind"` // "function", "type", "const", "var", "method"
|
|
Line int `json:"line"`
|
|
Doc string `json:"doc,omitempty"`
|
|
Signature string `json:"signature,omitempty"`
|
|
}
|
|
|
|
// ConnectorInfo provides overview information about a connector.
|
|
type ConnectorInfo struct {
|
|
Provider string `json:"provider"`
|
|
ConnectorID string `json:"connector_id"`
|
|
Capabilities []CapabilityInfo `json:"capabilities"`
|
|
Config []ConfigParam `json:"config"`
|
|
SourcePath string `json:"source_path,omitempty"`
|
|
HasSource bool `json:"has_source"`
|
|
Files []FileInfo `json:"files,omitempty"`
|
|
Methods []MethodInfo `json:"methods,omitempty"`
|
|
}
|
|
|
|
// CapabilityInfo describes a connector capability.
|
|
type CapabilityInfo struct {
|
|
Name string `json:"name"`
|
|
Supported bool `json:"supported"`
|
|
Description string `json:"description,omitempty"`
|
|
}
|
|
|
|
// ConfigParam describes a configuration parameter.
|
|
type ConfigParam struct {
|
|
Name string `json:"name"`
|
|
Type string `json:"type"`
|
|
Required bool `json:"required"`
|
|
}
|
|
|
|
// MethodInfo describes a plugin method.
|
|
type MethodInfo struct {
|
|
Name string `json:"name"`
|
|
Implemented bool `json:"implemented"`
|
|
File string `json:"file,omitempty"`
|
|
Line int `json:"line,omitempty"`
|
|
}
|
|
|
|
// AllCapabilities returns all possible connector capabilities with descriptions.
|
|
var AllCapabilities = []CapabilityInfo{
|
|
{Name: "FETCH_ACCOUNTS", Description: "Fetch accounts from the PSP"},
|
|
{Name: "FETCH_BALANCES", Description: "Fetch account balances"},
|
|
{Name: "FETCH_EXTERNAL_ACCOUNTS", Description: "Fetch external/beneficiary accounts"},
|
|
{Name: "FETCH_PAYMENTS", Description: "Fetch payment transactions"},
|
|
{Name: "FETCH_OTHERS", Description: "Fetch other data types"},
|
|
{Name: "CREATE_TRANSFER", Description: "Create internal transfers"},
|
|
{Name: "CREATE_PAYOUT", Description: "Create payouts to external accounts"},
|
|
{Name: "CREATE_BANK_ACCOUNT", Description: "Create/link bank accounts"},
|
|
{Name: "CREATE_WEBHOOKS", Description: "Register webhooks with PSP"},
|
|
{Name: "TRANSLATE_WEBHOOK", Description: "Parse incoming webhooks"},
|
|
}
|
|
|
|
// GetInfo returns overview information about the connector.
|
|
func (i *Introspector) GetInfo() (*ConnectorInfo, error) {
|
|
info := &ConnectorInfo{
|
|
Provider: i.provider,
|
|
ConnectorID: i.connectorID.String(),
|
|
HasSource: i.basePath != "",
|
|
SourcePath: i.basePath,
|
|
}
|
|
|
|
// Get capabilities
|
|
caps, err := registry.GetCapabilities(i.provider)
|
|
if err == nil {
|
|
capMap := make(map[string]bool)
|
|
for _, c := range caps {
|
|
capMap[c.String()] = true
|
|
}
|
|
for _, c := range AllCapabilities {
|
|
c.Supported = capMap[c.Name]
|
|
info.Capabilities = append(info.Capabilities, c)
|
|
}
|
|
}
|
|
|
|
// Get config schema
|
|
config, err := registry.GetConfig(i.provider)
|
|
if err == nil {
|
|
for name, param := range config {
|
|
info.Config = append(info.Config, ConfigParam{
|
|
Name: name,
|
|
Type: string(param.DataType),
|
|
Required: param.Required,
|
|
})
|
|
}
|
|
// Sort by required first, then name
|
|
sort.Slice(info.Config, func(i, j int) bool {
|
|
if info.Config[i].Required != info.Config[j].Required {
|
|
return info.Config[i].Required
|
|
}
|
|
return info.Config[i].Name < info.Config[j].Name
|
|
})
|
|
}
|
|
|
|
// Get file tree if source is available
|
|
if i.basePath != "" {
|
|
files, err := i.GetFileTree()
|
|
if err == nil {
|
|
info.Files = files
|
|
}
|
|
|
|
// Try to find implemented methods
|
|
methods := i.findImplementedMethods()
|
|
info.Methods = methods
|
|
}
|
|
|
|
return info, nil
|
|
}
|
|
|
|
// GetFileTree returns the file tree for the connector source.
|
|
func (i *Introspector) GetFileTree() ([]FileInfo, error) {
|
|
if i.basePath == "" {
|
|
return nil, fmt.Errorf("source path not found")
|
|
}
|
|
|
|
return i.readDir("")
|
|
}
|
|
|
|
func (i *Introspector) readDir(relPath string) ([]FileInfo, error) {
|
|
fullPath := filepath.Join(i.basePath, relPath)
|
|
entries, err := os.ReadDir(fullPath)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
var files []FileInfo
|
|
for _, entry := range entries {
|
|
name := entry.Name()
|
|
|
|
// Skip hidden files and test files for cleaner view
|
|
if strings.HasPrefix(name, ".") {
|
|
continue
|
|
}
|
|
|
|
entryPath := filepath.Join(relPath, name)
|
|
info, err := entry.Info()
|
|
if err != nil {
|
|
continue
|
|
}
|
|
|
|
fi := FileInfo{
|
|
Name: name,
|
|
Path: entryPath,
|
|
Size: info.Size(),
|
|
IsDir: entry.IsDir(),
|
|
}
|
|
|
|
if entry.IsDir() {
|
|
children, err := i.readDir(entryPath)
|
|
if err == nil {
|
|
fi.Children = children
|
|
}
|
|
}
|
|
|
|
files = append(files, fi)
|
|
}
|
|
|
|
// Sort: directories first, then by name
|
|
sort.Slice(files, func(i, j int) bool {
|
|
if files[i].IsDir != files[j].IsDir {
|
|
return files[i].IsDir
|
|
}
|
|
return files[i].Name < files[j].Name
|
|
})
|
|
|
|
return files, nil
|
|
}
|
|
|
|
// GetFile returns the contents of a source file.
|
|
func (i *Introspector) GetFile(relPath string) (*SourceFile, error) {
|
|
if i.basePath == "" {
|
|
return nil, fmt.Errorf("source path not found")
|
|
}
|
|
|
|
// Security: ensure path doesn't escape connector directory
|
|
fullPath := filepath.Join(i.basePath, relPath)
|
|
fullPath, err := filepath.Abs(fullPath)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
// Use path separator suffix to prevent sibling directory bypass (e.g., ../stripe-extra/)
|
|
if fullPath != i.basePath && !strings.HasPrefix(fullPath, i.basePath+string(filepath.Separator)) {
|
|
return nil, fmt.Errorf("invalid path")
|
|
}
|
|
|
|
content, err := os.ReadFile(fullPath)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
sf := &SourceFile{
|
|
Path: relPath,
|
|
Name: filepath.Base(relPath),
|
|
Content: string(content),
|
|
Lines: strings.Count(string(content), "\n") + 1,
|
|
Language: detectLanguage(relPath),
|
|
}
|
|
|
|
// Extract symbols for Go files
|
|
if sf.Language == "go" {
|
|
symbols := i.extractSymbols(fullPath, string(content))
|
|
sf.Symbols = symbols
|
|
}
|
|
|
|
return sf, nil
|
|
}
|
|
|
|
func detectLanguage(path string) string {
|
|
ext := strings.ToLower(filepath.Ext(path))
|
|
switch ext {
|
|
case ".go":
|
|
return "go"
|
|
case ".json":
|
|
return "json"
|
|
case ".yaml", ".yml":
|
|
return "yaml"
|
|
case ".md":
|
|
return "markdown"
|
|
case ".sql":
|
|
return "sql"
|
|
default:
|
|
return "text"
|
|
}
|
|
}
|
|
|
|
func (i *Introspector) extractSymbols(path string, content string) []Symbol {
|
|
fset := token.NewFileSet()
|
|
f, err := parser.ParseFile(fset, path, content, parser.ParseComments)
|
|
if err != nil {
|
|
return nil
|
|
}
|
|
|
|
var symbols []Symbol
|
|
|
|
for _, decl := range f.Decls {
|
|
switch d := decl.(type) {
|
|
case *ast.FuncDecl:
|
|
sym := Symbol{
|
|
Name: d.Name.Name,
|
|
Kind: "function",
|
|
Line: fset.Position(d.Pos()).Line,
|
|
}
|
|
if d.Recv != nil {
|
|
sym.Kind = "method"
|
|
// Get receiver type
|
|
if len(d.Recv.List) > 0 {
|
|
if star, ok := d.Recv.List[0].Type.(*ast.StarExpr); ok {
|
|
if ident, ok := star.X.(*ast.Ident); ok {
|
|
sym.Signature = fmt.Sprintf("(*%s) %s", ident.Name, d.Name.Name)
|
|
}
|
|
} else if ident, ok := d.Recv.List[0].Type.(*ast.Ident); ok {
|
|
sym.Signature = fmt.Sprintf("(%s) %s", ident.Name, d.Name.Name)
|
|
}
|
|
}
|
|
}
|
|
if d.Doc != nil {
|
|
sym.Doc = strings.TrimSpace(d.Doc.Text())
|
|
}
|
|
symbols = append(symbols, sym)
|
|
|
|
case *ast.GenDecl:
|
|
for _, spec := range d.Specs {
|
|
switch s := spec.(type) {
|
|
case *ast.TypeSpec:
|
|
sym := Symbol{
|
|
Name: s.Name.Name,
|
|
Kind: "type",
|
|
Line: fset.Position(s.Pos()).Line,
|
|
}
|
|
if d.Doc != nil {
|
|
sym.Doc = strings.TrimSpace(d.Doc.Text())
|
|
}
|
|
symbols = append(symbols, sym)
|
|
|
|
case *ast.ValueSpec:
|
|
kind := "var"
|
|
if d.Tok == token.CONST {
|
|
kind = "const"
|
|
}
|
|
for _, name := range s.Names {
|
|
if name.Name == "_" {
|
|
continue
|
|
}
|
|
symbols = append(symbols, Symbol{
|
|
Name: name.Name,
|
|
Kind: kind,
|
|
Line: fset.Position(name.Pos()).Line,
|
|
})
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
return symbols
|
|
}
|
|
|
|
// findImplementedMethods scans plugin.go to find which interface methods are implemented.
|
|
func (i *Introspector) findImplementedMethods() []MethodInfo {
|
|
pluginMethods := []string{
|
|
"Install", "Uninstall",
|
|
"FetchNextAccounts", "FetchNextPayments", "FetchNextBalances",
|
|
"FetchNextExternalAccounts", "FetchNextOthers",
|
|
"CreateBankAccount", "CreateTransfer", "ReverseTransfer",
|
|
"PollTransferStatus", "CreatePayout", "ReversePayout", "PollPayoutStatus",
|
|
"CreateWebhooks", "TranslateWebhook", "VerifyWebhook", "TrimWebhook",
|
|
"CreateUser", "CreateUserLink", "CompleteUserLink", "UpdateUserLink",
|
|
"DeleteUser", "DeleteUserConnection",
|
|
}
|
|
|
|
methods := make([]MethodInfo, len(pluginMethods))
|
|
for i, m := range pluginMethods {
|
|
methods[i] = MethodInfo{
|
|
Name: m,
|
|
Implemented: false,
|
|
}
|
|
}
|
|
|
|
if i.basePath == "" {
|
|
return methods
|
|
}
|
|
|
|
// Scan all Go files for method implementations
|
|
_ = filepath.Walk(i.basePath, func(path string, info os.FileInfo, err error) error {
|
|
if err != nil || info.IsDir() || !strings.HasSuffix(path, ".go") {
|
|
return nil
|
|
}
|
|
if strings.HasSuffix(path, "_test.go") {
|
|
return nil
|
|
}
|
|
|
|
content, err := os.ReadFile(path)
|
|
if err != nil {
|
|
return nil
|
|
}
|
|
|
|
fset := token.NewFileSet()
|
|
f, err := parser.ParseFile(fset, path, content, 0)
|
|
if err != nil {
|
|
return nil
|
|
}
|
|
|
|
relPath, _ := filepath.Rel(i.basePath, path)
|
|
|
|
for _, decl := range f.Decls {
|
|
if fn, ok := decl.(*ast.FuncDecl); ok && fn.Recv != nil {
|
|
// Check if this is a method on Plugin type
|
|
isPluginMethod := false
|
|
if len(fn.Recv.List) > 0 {
|
|
if star, ok := fn.Recv.List[0].Type.(*ast.StarExpr); ok {
|
|
if ident, ok := star.X.(*ast.Ident); ok {
|
|
if ident.Name == "Plugin" {
|
|
isPluginMethod = true
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
if isPluginMethod {
|
|
for j := range methods {
|
|
if methods[j].Name == fn.Name.Name {
|
|
methods[j].Implemented = true
|
|
methods[j].File = relPath
|
|
methods[j].Line = fset.Position(fn.Pos()).Line
|
|
break
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
return nil
|
|
})
|
|
|
|
return methods
|
|
}
|
|
|
|
// SearchCode searches for a pattern in the connector source code.
|
|
func (i *Introspector) SearchCode(pattern string) ([]SearchResult, error) {
|
|
if i.basePath == "" {
|
|
return nil, fmt.Errorf("source path not found")
|
|
}
|
|
|
|
pattern = strings.ToLower(pattern)
|
|
var results []SearchResult
|
|
|
|
_ = filepath.Walk(i.basePath, func(path string, info os.FileInfo, err error) error {
|
|
if err != nil || info.IsDir() {
|
|
return nil
|
|
}
|
|
if !strings.HasSuffix(path, ".go") {
|
|
return nil
|
|
}
|
|
|
|
content, err := os.ReadFile(path)
|
|
if err != nil {
|
|
return nil
|
|
}
|
|
|
|
relPath, _ := filepath.Rel(i.basePath, path)
|
|
lines := strings.Split(string(content), "\n")
|
|
|
|
for lineNum, line := range lines {
|
|
if strings.Contains(strings.ToLower(line), pattern) {
|
|
results = append(results, SearchResult{
|
|
File: relPath,
|
|
Line: lineNum + 1,
|
|
Content: strings.TrimSpace(line),
|
|
})
|
|
if len(results) >= 100 {
|
|
return filepath.SkipAll
|
|
}
|
|
}
|
|
}
|
|
|
|
return nil
|
|
})
|
|
|
|
return results, nil
|
|
}
|
|
|
|
// SearchResult represents a code search result.
|
|
type SearchResult struct {
|
|
File string `json:"file"`
|
|
Line int `json:"line"`
|
|
Content string `json:"content"`
|
|
}
|