service backend
This commit is contained in:
252
api/fx/oracle/client/client.go
Normal file
252
api/fx/oracle/client/client.go
Normal file
@@ -0,0 +1,252 @@
|
||||
package client
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/tls"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
fxv1 "github.com/tech/sendico/pkg/proto/common/fx/v1"
|
||||
moneyv1 "github.com/tech/sendico/pkg/proto/common/money/v1"
|
||||
tracev1 "github.com/tech/sendico/pkg/proto/common/trace/v1"
|
||||
oraclev1 "github.com/tech/sendico/pkg/proto/oracle/v1"
|
||||
"google.golang.org/grpc"
|
||||
"google.golang.org/grpc/credentials"
|
||||
"google.golang.org/grpc/credentials/insecure"
|
||||
)
|
||||
|
||||
// Client exposes typed helpers around the oracle gRPC API.
|
||||
type Client interface {
|
||||
LatestRate(ctx context.Context, req LatestRateParams) (*RateSnapshot, error)
|
||||
GetQuote(ctx context.Context, req GetQuoteParams) (*Quote, error)
|
||||
Close() error
|
||||
}
|
||||
|
||||
// RequestMeta carries optional multi-tenant context for oracle calls.
|
||||
type RequestMeta struct {
|
||||
TenantRef string
|
||||
OrganizationRef string
|
||||
Trace *tracev1.TraceContext
|
||||
}
|
||||
|
||||
type LatestRateParams struct {
|
||||
Meta RequestMeta
|
||||
Pair *fxv1.CurrencyPair
|
||||
Provider string
|
||||
}
|
||||
|
||||
type RateSnapshot struct {
|
||||
Pair *fxv1.CurrencyPair
|
||||
Mid string
|
||||
Bid string
|
||||
Ask string
|
||||
SpreadBps string
|
||||
Provider string
|
||||
RateRef string
|
||||
AsOf time.Time
|
||||
}
|
||||
|
||||
type GetQuoteParams struct {
|
||||
Meta RequestMeta
|
||||
Pair *fxv1.CurrencyPair
|
||||
Side fxv1.Side
|
||||
BaseAmount *moneyv1.Money
|
||||
QuoteAmount *moneyv1.Money
|
||||
Firm bool
|
||||
TTL time.Duration
|
||||
PreferredProvider string
|
||||
MaxAge time.Duration
|
||||
}
|
||||
|
||||
type Quote struct {
|
||||
QuoteRef string
|
||||
Pair *fxv1.CurrencyPair
|
||||
Side fxv1.Side
|
||||
Price string
|
||||
BaseAmount *moneyv1.Money
|
||||
QuoteAmount *moneyv1.Money
|
||||
ExpiresAt time.Time
|
||||
Provider string
|
||||
RateRef string
|
||||
Firm bool
|
||||
}
|
||||
|
||||
type grpcOracleClient interface {
|
||||
GetQuote(ctx context.Context, in *oraclev1.GetQuoteRequest, opts ...grpc.CallOption) (*oraclev1.GetQuoteResponse, error)
|
||||
LatestRate(ctx context.Context, in *oraclev1.LatestRateRequest, opts ...grpc.CallOption) (*oraclev1.LatestRateResponse, error)
|
||||
}
|
||||
|
||||
type oracleClient struct {
|
||||
cfg Config
|
||||
conn *grpc.ClientConn
|
||||
client grpcOracleClient
|
||||
}
|
||||
|
||||
// New dials the oracle endpoint and returns a ready client.
|
||||
func New(ctx context.Context, cfg Config, opts ...grpc.DialOption) (Client, error) {
|
||||
cfg.setDefaults()
|
||||
if strings.TrimSpace(cfg.Address) == "" {
|
||||
return nil, errors.New("oracle: address is required")
|
||||
}
|
||||
|
||||
dialCtx, cancel := context.WithTimeout(ctx, cfg.DialTimeout)
|
||||
defer cancel()
|
||||
|
||||
dialOpts := make([]grpc.DialOption, 0, len(opts)+1)
|
||||
dialOpts = append(dialOpts, opts...)
|
||||
|
||||
if cfg.Insecure {
|
||||
dialOpts = append(dialOpts, grpc.WithTransportCredentials(insecure.NewCredentials()))
|
||||
} else {
|
||||
dialOpts = append(dialOpts, grpc.WithTransportCredentials(credentials.NewTLS(&tls.Config{})))
|
||||
}
|
||||
|
||||
conn, err := grpc.DialContext(dialCtx, cfg.Address, dialOpts...)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("oracle: dial %s: %w", cfg.Address, err)
|
||||
}
|
||||
|
||||
return &oracleClient{
|
||||
cfg: cfg,
|
||||
conn: conn,
|
||||
client: oraclev1.NewOracleClient(conn),
|
||||
}, nil
|
||||
}
|
||||
|
||||
// NewWithClient injects a pre-built oracle client (useful for tests).
|
||||
func NewWithClient(cfg Config, oc grpcOracleClient) Client {
|
||||
cfg.setDefaults()
|
||||
return &oracleClient{
|
||||
cfg: cfg,
|
||||
client: oc,
|
||||
}
|
||||
}
|
||||
|
||||
func (c *oracleClient) Close() error {
|
||||
if c.conn != nil {
|
||||
return c.conn.Close()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *oracleClient) LatestRate(ctx context.Context, req LatestRateParams) (*RateSnapshot, error) {
|
||||
if req.Pair == nil {
|
||||
return nil, errors.New("oracle: pair is required")
|
||||
}
|
||||
|
||||
callCtx, cancel := c.callContext(ctx)
|
||||
defer cancel()
|
||||
|
||||
resp, err := c.client.LatestRate(callCtx, &oraclev1.LatestRateRequest{
|
||||
Meta: toProtoMeta(req.Meta),
|
||||
Pair: req.Pair,
|
||||
Provider: req.Provider,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("oracle: latest rate: %w", err)
|
||||
}
|
||||
if resp.GetRate() == nil {
|
||||
return nil, errors.New("oracle: latest rate: empty payload")
|
||||
}
|
||||
return fromProtoRate(resp.GetRate()), nil
|
||||
}
|
||||
|
||||
func (c *oracleClient) GetQuote(ctx context.Context, req GetQuoteParams) (*Quote, error) {
|
||||
if req.Pair == nil {
|
||||
return nil, errors.New("oracle: pair is required")
|
||||
}
|
||||
if req.Side == fxv1.Side_SIDE_UNSPECIFIED {
|
||||
return nil, errors.New("oracle: side is required")
|
||||
}
|
||||
|
||||
baseSupplied := req.BaseAmount != nil
|
||||
quoteSupplied := req.QuoteAmount != nil
|
||||
if baseSupplied == quoteSupplied {
|
||||
return nil, errors.New("oracle: exactly one of base_amount or quote_amount must be set")
|
||||
}
|
||||
|
||||
callCtx, cancel := c.callContext(ctx)
|
||||
defer cancel()
|
||||
|
||||
protoReq := &oraclev1.GetQuoteRequest{
|
||||
Meta: toProtoMeta(req.Meta),
|
||||
Pair: req.Pair,
|
||||
Side: req.Side,
|
||||
Firm: req.Firm,
|
||||
PreferredProvider: req.PreferredProvider,
|
||||
}
|
||||
if req.TTL > 0 {
|
||||
protoReq.TtlMs = req.TTL.Milliseconds()
|
||||
}
|
||||
if req.MaxAge > 0 {
|
||||
protoReq.MaxAgeMs = int32(req.MaxAge.Milliseconds())
|
||||
}
|
||||
if baseSupplied {
|
||||
protoReq.AmountInput = &oraclev1.GetQuoteRequest_BaseAmount{BaseAmount: req.BaseAmount}
|
||||
} else {
|
||||
protoReq.AmountInput = &oraclev1.GetQuoteRequest_QuoteAmount{QuoteAmount: req.QuoteAmount}
|
||||
}
|
||||
|
||||
resp, err := c.client.GetQuote(callCtx, protoReq)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("oracle: get quote: %w", err)
|
||||
}
|
||||
if resp.GetQuote() == nil {
|
||||
return nil, errors.New("oracle: get quote: empty payload")
|
||||
}
|
||||
return fromProtoQuote(resp.GetQuote()), nil
|
||||
}
|
||||
|
||||
func (c *oracleClient) callContext(ctx context.Context) (context.Context, context.CancelFunc) {
|
||||
if _, ok := ctx.Deadline(); ok {
|
||||
return context.WithCancel(ctx)
|
||||
}
|
||||
return context.WithTimeout(ctx, c.cfg.CallTimeout)
|
||||
}
|
||||
|
||||
func toProtoMeta(meta RequestMeta) *oraclev1.RequestMeta {
|
||||
if meta.TenantRef == "" && meta.OrganizationRef == "" && meta.Trace == nil {
|
||||
return nil
|
||||
}
|
||||
return &oraclev1.RequestMeta{
|
||||
TenantRef: meta.TenantRef,
|
||||
OrganizationRef: meta.OrganizationRef,
|
||||
Trace: meta.Trace,
|
||||
}
|
||||
}
|
||||
|
||||
func fromProtoRate(rate *oraclev1.RateSnapshot) *RateSnapshot {
|
||||
if rate == nil {
|
||||
return nil
|
||||
}
|
||||
return &RateSnapshot{
|
||||
Pair: rate.Pair,
|
||||
Mid: rate.GetMid().GetValue(),
|
||||
Bid: rate.GetBid().GetValue(),
|
||||
Ask: rate.GetAsk().GetValue(),
|
||||
SpreadBps: rate.GetSpreadBps().GetValue(),
|
||||
Provider: rate.GetProvider(),
|
||||
RateRef: rate.GetRateRef(),
|
||||
AsOf: time.UnixMilli(rate.GetAsofUnixMs()),
|
||||
}
|
||||
}
|
||||
|
||||
func fromProtoQuote(quote *oraclev1.Quote) *Quote {
|
||||
if quote == nil {
|
||||
return nil
|
||||
}
|
||||
return &Quote{
|
||||
QuoteRef: quote.GetQuoteRef(),
|
||||
Pair: quote.Pair,
|
||||
Side: quote.GetSide(),
|
||||
Price: quote.GetPrice().GetValue(),
|
||||
BaseAmount: quote.BaseAmount,
|
||||
QuoteAmount: quote.QuoteAmount,
|
||||
ExpiresAt: time.UnixMilli(quote.GetExpiresAtUnixMs()),
|
||||
Provider: quote.GetProvider(),
|
||||
RateRef: quote.GetRateRef(),
|
||||
Firm: quote.GetFirm(),
|
||||
}
|
||||
}
|
||||
116
api/fx/oracle/client/client_test.go
Normal file
116
api/fx/oracle/client/client_test.go
Normal file
@@ -0,0 +1,116 @@
|
||||
package client
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
fxv1 "github.com/tech/sendico/pkg/proto/common/fx/v1"
|
||||
moneyv1 "github.com/tech/sendico/pkg/proto/common/money/v1"
|
||||
oraclev1 "github.com/tech/sendico/pkg/proto/oracle/v1"
|
||||
"google.golang.org/grpc"
|
||||
)
|
||||
|
||||
type stubOracle struct {
|
||||
latestResp *oraclev1.LatestRateResponse
|
||||
latestErr error
|
||||
|
||||
quoteResp *oraclev1.GetQuoteResponse
|
||||
quoteErr error
|
||||
|
||||
lastLatest *oraclev1.LatestRateRequest
|
||||
lastQuote *oraclev1.GetQuoteRequest
|
||||
}
|
||||
|
||||
func (s *stubOracle) LatestRate(ctx context.Context, in *oraclev1.LatestRateRequest, _ ...grpc.CallOption) (*oraclev1.LatestRateResponse, error) {
|
||||
s.lastLatest = in
|
||||
return s.latestResp, s.latestErr
|
||||
}
|
||||
|
||||
func (s *stubOracle) GetQuote(ctx context.Context, in *oraclev1.GetQuoteRequest, _ ...grpc.CallOption) (*oraclev1.GetQuoteResponse, error) {
|
||||
s.lastQuote = in
|
||||
return s.quoteResp, s.quoteErr
|
||||
}
|
||||
|
||||
func TestLatestRate(t *testing.T) {
|
||||
expectedTime := time.Date(2024, 1, 1, 15, 0, 0, 0, time.UTC)
|
||||
stub := &stubOracle{
|
||||
latestResp: &oraclev1.LatestRateResponse{
|
||||
Rate: &oraclev1.RateSnapshot{
|
||||
Pair: &fxv1.CurrencyPair{Base: "USD", Quote: "EUR"},
|
||||
Mid: &moneyv1.Decimal{Value: "1.1000"},
|
||||
Bid: &moneyv1.Decimal{Value: "1.0995"},
|
||||
Ask: &moneyv1.Decimal{Value: "1.1005"},
|
||||
SpreadBps: &moneyv1.Decimal{Value: "5"},
|
||||
Provider: "ECB",
|
||||
RateRef: "ECB-20240101",
|
||||
AsofUnixMs: expectedTime.UnixMilli(),
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
client := NewWithClient(Config{}, stub)
|
||||
resp, err := client.LatestRate(context.Background(), LatestRateParams{
|
||||
Meta: RequestMeta{
|
||||
TenantRef: "tenant",
|
||||
OrganizationRef: "org",
|
||||
},
|
||||
Pair: &fxv1.CurrencyPair{Base: "USD", Quote: "EUR"},
|
||||
Provider: "ECB",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("LatestRate returned error: %v", err)
|
||||
}
|
||||
|
||||
if stub.lastLatest.GetProvider() != "ECB" {
|
||||
t.Fatalf("expected provider to propagate, got %s", stub.lastLatest.GetProvider())
|
||||
}
|
||||
if resp.Provider != "ECB" || resp.RateRef != "ECB-20240101" {
|
||||
t.Fatalf("unexpected response: %+v", resp)
|
||||
}
|
||||
if !resp.AsOf.Equal(expectedTime) {
|
||||
t.Fatalf("expected as-of %s, got %s", expectedTime, resp.AsOf)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetQuote(t *testing.T) {
|
||||
expiresAt := time.Date(2024, 2, 2, 12, 0, 0, 0, time.UTC)
|
||||
stub := &stubOracle{
|
||||
quoteResp: &oraclev1.GetQuoteResponse{
|
||||
Quote: &oraclev1.Quote{
|
||||
QuoteRef: "quote-123",
|
||||
Pair: &fxv1.CurrencyPair{Base: "GBP", Quote: "USD"},
|
||||
Side: fxv1.Side_BUY_BASE_SELL_QUOTE,
|
||||
Price: &moneyv1.Decimal{Value: "1.2500"},
|
||||
BaseAmount: &moneyv1.Money{Amount: "100.00", Currency: "GBP"},
|
||||
QuoteAmount: &moneyv1.Money{Amount: "125.00", Currency: "USD"},
|
||||
ExpiresAtUnixMs: expiresAt.UnixMilli(),
|
||||
Provider: "Test",
|
||||
RateRef: "test-ref",
|
||||
Firm: true,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
client := NewWithClient(Config{}, stub)
|
||||
resp, err := client.GetQuote(context.Background(), GetQuoteParams{
|
||||
Pair: &fxv1.CurrencyPair{Base: "GBP", Quote: "USD"},
|
||||
Side: fxv1.Side_BUY_BASE_SELL_QUOTE,
|
||||
BaseAmount: &moneyv1.Money{Amount: "100.00", Currency: "GBP"},
|
||||
Firm: true,
|
||||
TTL: 2 * time.Second,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("GetQuote returned error: %v", err)
|
||||
}
|
||||
|
||||
if stub.lastQuote.GetFirm() != true {
|
||||
t.Fatalf("expected firm flag to propagate")
|
||||
}
|
||||
if stub.lastQuote.GetTtlMs() == 0 {
|
||||
t.Fatalf("expected ttl to be populated")
|
||||
}
|
||||
if resp.QuoteRef != "quote-123" || resp.Price != "1.2500" || !resp.ExpiresAt.Equal(expiresAt) {
|
||||
t.Fatalf("unexpected quote response: %+v", resp)
|
||||
}
|
||||
}
|
||||
20
api/fx/oracle/client/config.go
Normal file
20
api/fx/oracle/client/config.go
Normal file
@@ -0,0 +1,20 @@
|
||||
package client
|
||||
|
||||
import "time"
|
||||
|
||||
// Config captures connection settings for the FX oracle gRPC service.
|
||||
type Config struct {
|
||||
Address string
|
||||
DialTimeout time.Duration
|
||||
CallTimeout time.Duration
|
||||
Insecure bool
|
||||
}
|
||||
|
||||
func (c *Config) setDefaults() {
|
||||
if c.DialTimeout <= 0 {
|
||||
c.DialTimeout = 5 * time.Second
|
||||
}
|
||||
if c.CallTimeout <= 0 {
|
||||
c.CallTimeout = 3 * time.Second
|
||||
}
|
||||
}
|
||||
60
api/fx/oracle/client/fake.go
Normal file
60
api/fx/oracle/client/fake.go
Normal file
@@ -0,0 +1,60 @@
|
||||
package client
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
fxv1 "github.com/tech/sendico/pkg/proto/common/fx/v1"
|
||||
moneyv1 "github.com/tech/sendico/pkg/proto/common/money/v1"
|
||||
)
|
||||
|
||||
// Fake implements Client for tests.
|
||||
type Fake struct {
|
||||
LatestRateFn func(ctx context.Context, req LatestRateParams) (*RateSnapshot, error)
|
||||
GetQuoteFn func(ctx context.Context, req GetQuoteParams) (*Quote, error)
|
||||
CloseFn func() error
|
||||
}
|
||||
|
||||
func (f *Fake) LatestRate(ctx context.Context, req LatestRateParams) (*RateSnapshot, error) {
|
||||
if f.LatestRateFn != nil {
|
||||
return f.LatestRateFn(ctx, req)
|
||||
}
|
||||
return &RateSnapshot{
|
||||
Pair: &fxv1.CurrencyPair{Base: "USD", Quote: "EUR"},
|
||||
Mid: "1.1000",
|
||||
Bid: "1.0995",
|
||||
Ask: "1.1005",
|
||||
SpreadBps: "5",
|
||||
Provider: "fake",
|
||||
RateRef: "fake",
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (f *Fake) GetQuote(ctx context.Context, req GetQuoteParams) (*Quote, error) {
|
||||
if f.GetQuoteFn != nil {
|
||||
return f.GetQuoteFn(ctx, req)
|
||||
}
|
||||
return &Quote{
|
||||
QuoteRef: "fake-quote",
|
||||
Pair: req.Pair,
|
||||
Side: req.Side,
|
||||
Price: "1.1000",
|
||||
BaseAmount: &moneyv1.Money{
|
||||
Amount: "100.00",
|
||||
Currency: req.Pair.GetBase(),
|
||||
},
|
||||
QuoteAmount: &moneyv1.Money{
|
||||
Amount: "110.00",
|
||||
Currency: req.Pair.GetQuote(),
|
||||
},
|
||||
Provider: "fake",
|
||||
RateRef: "fake",
|
||||
Firm: req.Firm,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (f *Fake) Close() error {
|
||||
if f.CloseFn != nil {
|
||||
return f.CloseFn()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
Reference in New Issue
Block a user