Files
sendico/api/edge/bff/internal/api/discovery_resolver.go
2026-02-28 00:39:20 +01:00

483 lines
13 KiB
Go

package apiimp
import (
"context"
"fmt"
"net"
"net/url"
"sort"
"strings"
"time"
"github.com/tech/sendico/pkg/discovery"
msg "github.com/tech/sendico/pkg/messaging"
"github.com/tech/sendico/pkg/mservice"
eapi "github.com/tech/sendico/server/interface/api"
"go.uber.org/zap"
)
const (
discoveryBootstrapTimeout = 3 * time.Second
discoveryBootstrapSender = "server_bootstrap"
defaultClientDialTimeoutSecs = 5
defaultClientCallTimeoutSecs = 5
)
var (
ledgerDiscoveryServiceNames = []string{
"LEDGER",
string(mservice.Ledger),
}
paymentOrchestratorDiscoveryServiceNames = []string{
"PAYMENTS_ORCHESTRATOR",
string(mservice.PaymentOrchestrator),
}
paymentQuotationDiscoveryServiceNames = []string{
"PAYMENTS_QUOTATION",
"PAYMENTS_QUOTE",
"PAYMENT_QUOTATION",
"payment_quotation",
}
paymentMethodsDiscoveryServiceNames = []string{
"PAYMENTS_METHODS",
"PAYMENT_METHODS",
string(mservice.PaymentMethods),
}
)
type discoveryEndpoint struct {
address string
insecure bool
raw string
}
type serviceSelection struct {
service discovery.ServiceSummary
endpoint discoveryEndpoint
opMatch bool
nameRank int
}
type gatewaySelection struct {
gateway discovery.GatewaySummary
endpoint discoveryEndpoint
networkMatch bool
opMatch bool
}
// resolveServiceAddressesFromDiscovery looks up downstream service addresses once
// during startup and applies them to the runtime config.
func (a *APIImp) resolveServiceAddressesFromDiscovery() {
if a == nil || a.config == nil || a.config.Mw == nil {
return
}
msgCfg := a.config.Mw.Messaging
if msgCfg.Driver == "" {
return
}
logger := a.logger.Named("discovery_bootstrap")
broker, err := msg.CreateMessagingBroker(logger.Named("bus"), &msgCfg)
if err != nil {
logger.Warn("Failed to create discovery bootstrap broker", zap.Error(err))
return
}
client, err := discovery.NewClient(logger, broker, nil, discoveryBootstrapSender)
if err != nil {
logger.Warn("Failed to create discovery bootstrap client", zap.Error(err))
return
}
defer client.Close()
ctx, cancel := context.WithTimeout(context.Background(), discoveryBootstrapTimeout)
defer cancel()
lookup, err := client.Lookup(ctx)
if err != nil {
logger.Warn("Failed to fetch discovery registry during startup", zap.Error(err))
return
}
a.resolveChainGatewayAddress(lookup.Gateways)
orchestratorFound, orchestratorEndpoint := a.resolvePaymentOrchestratorAddress(lookup.Services)
a.resolveLedgerAddress(lookup.Services)
a.resolvePaymentQuotationAddress(lookup.Services, orchestratorFound, orchestratorEndpoint)
a.resolvePaymentMethodsAddress(lookup.Services)
}
func (a *APIImp) resolveChainGatewayAddress(gateways []discovery.GatewaySummary) {
cfg := a.config.ChainGateway
if cfg == nil {
return
}
endpoint, selected, ok := selectGatewayEndpoint(
gateways,
cfg.DefaultAsset.Chain,
[]string{discovery.OperationBalanceRead},
)
if !ok {
return
}
cfg.Address = endpoint.address
cfg.Insecure = endpoint.insecure
ensureTimeoutsChainGateway(cfg)
a.logger.Info("Resolved chain gateway address from discovery",
zap.String("rail", selected.Rail),
zap.String("gateway_id", selected.ID),
zap.String("network", selected.Network),
zap.String("invoke_uri", endpoint.raw),
zap.String("address", endpoint.address),
zap.Bool("insecure", endpoint.insecure))
}
func (a *APIImp) resolveLedgerAddress(services []discovery.ServiceSummary) {
endpoint, selected, ok := selectServiceEndpoint(
services,
ledgerDiscoveryServiceNames,
discovery.LedgerServiceOperations(),
)
if !ok {
return
}
cfg := ensureLedgerConfig(a.config)
cfg.Address = endpoint.address
cfg.Insecure = endpoint.insecure
ensureTimeoutsLedger(cfg)
a.logger.Info("Resolved ledger address from discovery",
zap.String("service", selected.Service),
zap.String("service_id", selected.ID),
zap.String("instance_id", selected.InstanceID),
zap.String("invoke_uri", endpoint.raw),
zap.String("address", endpoint.address),
zap.Bool("insecure", endpoint.insecure))
}
func (a *APIImp) resolvePaymentOrchestratorAddress(services []discovery.ServiceSummary) (bool, discoveryEndpoint) {
endpoint, selected, ok := selectServiceEndpoint(
services,
paymentOrchestratorDiscoveryServiceNames,
[]string{discovery.OperationPaymentInitiate},
)
if !ok {
return false, discoveryEndpoint{}
}
cfg := ensurePaymentOrchestratorConfig(a.config)
cfg.Address = endpoint.address
cfg.Insecure = endpoint.insecure
ensureTimeoutsPayment(cfg)
a.logger.Info("Resolved payment orchestrator address from discovery",
zap.String("service", selected.Service),
zap.String("service_id", selected.ID),
zap.String("instance_id", selected.InstanceID),
zap.String("invoke_uri", endpoint.raw),
zap.String("address", endpoint.address),
zap.Bool("insecure", endpoint.insecure))
return true, endpoint
}
func (a *APIImp) resolvePaymentQuotationAddress(services []discovery.ServiceSummary, orchestratorFound bool, orchestratorEndpoint discoveryEndpoint) {
endpoint, selected, ok := selectServiceEndpoint(
services,
paymentQuotationDiscoveryServiceNames,
[]string{discovery.OperationPaymentQuote},
)
if !ok {
cfg := a.config.PaymentQuotation
if cfg != nil && strings.TrimSpace(cfg.Address) != "" {
return
}
if !orchestratorFound {
return
}
// Fall back to orchestrator endpoint when quotation service is not announced.
endpoint = orchestratorEndpoint
selected = discovery.ServiceSummary{Service: "PAYMENTS_ORCHESTRATOR"}
}
cfg := ensurePaymentQuotationConfig(a.config)
cfg.Address = endpoint.address
cfg.Insecure = endpoint.insecure
ensureTimeoutsPayment(cfg)
a.logger.Info("Resolved payment quotation address from discovery",
zap.String("service", selected.Service),
zap.String("service_id", selected.ID),
zap.String("instance_id", selected.InstanceID),
zap.String("invoke_uri", endpoint.raw),
zap.String("address", endpoint.address),
zap.Bool("insecure", endpoint.insecure))
}
func (a *APIImp) resolvePaymentMethodsAddress(services []discovery.ServiceSummary) {
endpoint, selected, ok := selectServiceEndpoint(
services,
paymentMethodsDiscoveryServiceNames,
[]string{discovery.OperationPaymentMethodsRead},
)
if !ok {
return
}
cfg := ensurePaymentMethodsConfig(a.config)
cfg.Address = endpoint.address
cfg.Insecure = endpoint.insecure
ensureTimeoutsPayment(cfg)
a.logger.Info("Resolved payment methods address from discovery",
zap.String("service", selected.Service),
zap.String("service_id", selected.ID),
zap.String("instance_id", selected.InstanceID),
zap.String("invoke_uri", endpoint.raw),
zap.String("address", endpoint.address),
zap.Bool("insecure", endpoint.insecure))
}
func selectServiceEndpoint(services []discovery.ServiceSummary, serviceNames []string, requiredOps []string) (discoveryEndpoint, discovery.ServiceSummary, bool) {
selections := make([]serviceSelection, 0)
for _, svc := range services {
if !svc.Healthy {
continue
}
if strings.TrimSpace(svc.InvokeURI) == "" {
continue
}
nameRank, ok := serviceRank(svc.Service, serviceNames)
if !ok {
continue
}
endpoint, err := parseDiscoveryInvokeURI(svc.InvokeURI)
if err != nil {
continue
}
selections = append(selections, serviceSelection{
service: svc,
endpoint: endpoint,
opMatch: discovery.HasAnyOperation(svc.Ops, requiredOps),
nameRank: nameRank,
})
}
if len(selections) == 0 {
return discoveryEndpoint{}, discovery.ServiceSummary{}, false
}
sort.Slice(selections, func(i, j int) bool {
if selections[i].opMatch != selections[j].opMatch {
return selections[i].opMatch
}
if selections[i].nameRank != selections[j].nameRank {
return selections[i].nameRank < selections[j].nameRank
}
if selections[i].service.ID != selections[j].service.ID {
return selections[i].service.ID < selections[j].service.ID
}
return selections[i].service.InstanceID < selections[j].service.InstanceID
})
selected := selections[0]
return selected.endpoint, selected.service, true
}
func selectGatewayEndpoint(gateways []discovery.GatewaySummary, preferredNetwork string, requiredOps []string) (discoveryEndpoint, discovery.GatewaySummary, bool) {
preferredNetwork = strings.TrimSpace(preferredNetwork)
selections := make([]gatewaySelection, 0)
for _, gateway := range gateways {
if !gateway.Healthy {
continue
}
if !strings.EqualFold(strings.TrimSpace(gateway.Rail), discovery.RailCrypto) {
continue
}
if strings.TrimSpace(gateway.InvokeURI) == "" {
continue
}
endpoint, err := parseDiscoveryInvokeURI(gateway.InvokeURI)
if err != nil {
continue
}
selections = append(selections, gatewaySelection{
gateway: gateway,
endpoint: endpoint,
networkMatch: preferredNetwork != "" && strings.EqualFold(strings.TrimSpace(gateway.Network), preferredNetwork),
opMatch: discovery.HasAnyOperation(gateway.Ops, requiredOps),
})
}
if len(selections) == 0 {
return discoveryEndpoint{}, discovery.GatewaySummary{}, false
}
sort.Slice(selections, func(i, j int) bool {
if selections[i].networkMatch != selections[j].networkMatch {
return selections[i].networkMatch
}
if selections[i].opMatch != selections[j].opMatch {
return selections[i].opMatch
}
if selections[i].gateway.RoutingPriority != selections[j].gateway.RoutingPriority {
return selections[i].gateway.RoutingPriority > selections[j].gateway.RoutingPriority
}
if selections[i].gateway.ID != selections[j].gateway.ID {
return selections[i].gateway.ID < selections[j].gateway.ID
}
return selections[i].gateway.InstanceID < selections[j].gateway.InstanceID
})
selected := selections[0]
return selected.endpoint, selected.gateway, true
}
func parseDiscoveryInvokeURI(raw string) (discoveryEndpoint, error) {
raw = strings.TrimSpace(raw)
if raw == "" {
return discoveryEndpoint{}, fmt.Errorf("Invoke uri is empty")
}
// Without a scheme we expect a plain host:port target.
if !strings.Contains(raw, "://") {
if _, _, err := net.SplitHostPort(raw); err != nil {
return discoveryEndpoint{}, fmt.Errorf("Invoke uri must include host:port: %w", err)
}
return discoveryEndpoint{
address: raw,
insecure: true,
raw: raw,
}, nil
}
parsed, err := url.Parse(raw)
if err != nil {
return discoveryEndpoint{}, err
}
switch strings.ToLower(strings.TrimSpace(parsed.Scheme)) {
case "grpc":
address := strings.TrimSpace(parsed.Host)
if _, _, splitErr := net.SplitHostPort(address); splitErr != nil {
return discoveryEndpoint{}, fmt.Errorf("Grpc invoke uri must include host:port: %w", splitErr)
}
return discoveryEndpoint{
address: address,
insecure: true,
raw: raw,
}, nil
case "grpcs":
address := strings.TrimSpace(parsed.Host)
if _, _, splitErr := net.SplitHostPort(address); splitErr != nil {
return discoveryEndpoint{}, fmt.Errorf("Grpcs invoke uri must include host:port: %w", splitErr)
}
return discoveryEndpoint{
address: address,
insecure: false,
raw: raw,
}, nil
case "dns", "passthrough":
// gRPC resolver targets such as dns:///service:port.
return discoveryEndpoint{
address: raw,
insecure: true,
raw: raw,
}, nil
default:
return discoveryEndpoint{}, fmt.Errorf("Unsupported invoke uri scheme: %s", parsed.Scheme)
}
}
func serviceRank(service string, names []string) (int, bool) {
service = strings.TrimSpace(service)
if service == "" {
return 0, false
}
for i, name := range names {
if strings.EqualFold(service, strings.TrimSpace(name)) {
return i, true
}
}
return 0, false
}
func ensureLedgerConfig(cfg *eapi.Config) *eapi.LedgerConfig {
if cfg == nil {
return nil
}
if cfg.Ledger == nil {
cfg.Ledger = &eapi.LedgerConfig{}
}
return cfg.Ledger
}
func ensurePaymentOrchestratorConfig(cfg *eapi.Config) *eapi.PaymentOrchestratorConfig {
if cfg == nil {
return nil
}
if cfg.PaymentOrchestrator == nil {
cfg.PaymentOrchestrator = &eapi.PaymentOrchestratorConfig{}
}
return cfg.PaymentOrchestrator
}
func ensurePaymentQuotationConfig(cfg *eapi.Config) *eapi.PaymentOrchestratorConfig {
if cfg == nil {
return nil
}
if cfg.PaymentQuotation == nil {
cfg.PaymentQuotation = &eapi.PaymentOrchestratorConfig{}
}
return cfg.PaymentQuotation
}
func ensurePaymentMethodsConfig(cfg *eapi.Config) *eapi.PaymentOrchestratorConfig {
if cfg == nil {
return nil
}
if cfg.PaymentMethods == nil {
cfg.PaymentMethods = &eapi.PaymentOrchestratorConfig{}
}
return cfg.PaymentMethods
}
func ensureTimeoutsLedger(cfg *eapi.LedgerConfig) {
if cfg == nil {
return
}
if cfg.DialTimeoutSeconds <= 0 {
cfg.DialTimeoutSeconds = defaultClientDialTimeoutSecs
}
if cfg.CallTimeoutSeconds <= 0 {
cfg.CallTimeoutSeconds = defaultClientCallTimeoutSecs
}
}
func ensureTimeoutsChainGateway(cfg *eapi.ChainGatewayConfig) {
if cfg == nil {
return
}
if cfg.DialTimeoutSeconds <= 0 {
cfg.DialTimeoutSeconds = defaultClientDialTimeoutSecs
}
if cfg.CallTimeoutSeconds <= 0 {
cfg.CallTimeoutSeconds = defaultClientCallTimeoutSecs
}
}
func ensureTimeoutsPayment(cfg *eapi.PaymentOrchestratorConfig) {
if cfg == nil {
return
}
if cfg.DialTimeoutSeconds <= 0 {
cfg.DialTimeoutSeconds = defaultClientDialTimeoutSecs
}
if cfg.CallTimeoutSeconds <= 0 {
cfg.CallTimeoutSeconds = defaultClientCallTimeoutSecs
}
}