From 0f2fa3c1e1c764544ecb7fe5c5b5f83fcc64c956 Mon Sep 17 00:00:00 2001 From: JeremyDevCode Date: Mon, 20 Apr 2026 14:46:20 -0500 Subject: [PATCH 01/17] feat(transport): add Response[T] wrapper and return metadata from Sender --- services/accountdetails/service.go | 7 +-- services/accountdetails/service_test.go | 28 +++++----- services/apikey/service.go | 7 +-- services/apikey/service_test.go | 4 +- services/assets/service.go | 14 ++--- services/assets/service_test.go | 30 +++++------ services/balance/service.go | 14 ++--- services/balance/service_test.go | 20 +++---- services/cards/service.go | 18 ++++--- services/cards/service_test.go | 36 ++++++------- services/fees/service.go | 7 +-- services/fees/service_test.go | 24 ++++----- services/operations/service.go | 11 ++-- services/operations/service_test.go | 16 +++--- services/roboadvisor/service.go | 21 ++++---- services/roboadvisor/service_test.go | 36 ++++++------- services/trades/service.go | 7 +-- services/trades/service_test.go | 8 +-- services/transactions/service.go | 7 +-- services/transactions/service_test.go | 28 +++++----- services/wallets/service.go | 7 +-- services/wallets/service_test.go | 12 ++--- transport/doc.go | 20 ++++--- transport/transport.go | 70 +++++++++++++++++++++---- wallbit/doc.go | 13 +++++ wallbit/wallbit.go | 35 ++++++++----- 26 files changed, 298 insertions(+), 202 deletions(-) diff --git a/services/accountdetails/service.go b/services/accountdetails/service.go index ebdc8a4..4c13833 100644 --- a/services/accountdetails/service.go +++ b/services/accountdetails/service.go @@ -59,7 +59,7 @@ type GetResponse struct { Data AccountDetails `json:"data"` } -func (s *Service) Get(ctx context.Context, req *GetRequest) (*GetResponse, error) { +func (s *Service) Get(ctx context.Context, req *GetRequest) (*transport.Response[GetResponse], error) { path := getPath if req != nil { q := url.Values{} @@ -75,8 +75,9 @@ func (s *Service) Get(ctx context.Context, req *GetRequest) (*GetResponse, error } out := &GetResponse{} - if err := s.sender.Send(ctx, http.MethodGet, path, nil, out); err != nil { + meta, err := s.sender.Send(ctx, http.MethodGet, path, nil, out) + if err != nil { return nil, err } - return out, nil + return transport.NewResponse(meta, out), nil } diff --git a/services/accountdetails/service_test.go b/services/accountdetails/service_test.go index 34463dc..53e8b1b 100644 --- a/services/accountdetails/service_test.go +++ b/services/accountdetails/service_test.go @@ -39,17 +39,17 @@ func TestServiceGet(t *testing.T) { if err != nil { t.Fatalf("unexpected error: %v", err) } - if out.Data.HolderName != "John Doe" { - t.Fatalf("unexpected holder_name %q", out.Data.HolderName) + if out.Payload.Data.HolderName != "John Doe" { + t.Fatalf("unexpected holder_name %q", out.Payload.Data.HolderName) } - if out.Data.AccountType != "CHECKING" { - t.Fatalf("unexpected account_type %q", out.Data.AccountType) + if out.Payload.Data.AccountType != "CHECKING" { + t.Fatalf("unexpected account_type %q", out.Payload.Data.AccountType) } - if out.Data.BankName != "Community Federal Savings Bank" { - t.Fatalf("unexpected bank_name %q", out.Data.BankName) + if out.Payload.Data.BankName != "Community Federal Savings Bank" { + t.Fatalf("unexpected bank_name %q", out.Payload.Data.BankName) } - if out.Data.Address == nil || out.Data.Address.City != "New York" { - t.Fatalf("unexpected address: %+v", out.Data.Address) + if out.Payload.Data.Address == nil || out.Payload.Data.Address.City != "New York" { + t.Fatalf("unexpected address: %+v", out.Payload.Data.Address) } } @@ -81,11 +81,11 @@ func TestServiceGetWithCountryAndCurrencyQuery(t *testing.T) { if err != nil { t.Fatalf("unexpected error: %v", err) } - if out.Data.IBAN == nil || *out.Data.IBAN != "DE89370400440532013000" { - t.Fatalf("unexpected iban: %v", out.Data.IBAN) + if out.Payload.Data.IBAN == nil || *out.Payload.Data.IBAN != "DE89370400440532013000" { + t.Fatalf("unexpected iban: %v", out.Payload.Data.IBAN) } - if out.Data.BIC == nil || *out.Data.BIC != "COBADEFFXXX" { - t.Fatalf("unexpected bic: %v", out.Data.BIC) + if out.Payload.Data.BIC == nil || *out.Payload.Data.BIC != "COBADEFFXXX" { + t.Fatalf("unexpected bic: %v", out.Payload.Data.BIC) } } @@ -111,8 +111,8 @@ func TestServiceGetForwardsArbitraryQueryValues(t *testing.T) { if err != nil { t.Fatalf("unexpected error: %v", err) } - if out.Data.HolderName != "H" { - t.Fatalf("unexpected holder_name %q", out.Data.HolderName) + if out.Payload.Data.HolderName != "H" { + t.Fatalf("unexpected holder_name %q", out.Payload.Data.HolderName) } } diff --git a/services/apikey/service.go b/services/apikey/service.go index 4c39442..dd4682e 100644 --- a/services/apikey/service.go +++ b/services/apikey/service.go @@ -21,10 +21,11 @@ type RevokeResponse struct { Message string `json:"message"` } -func (s *Service) Revoke(ctx context.Context) (*RevokeResponse, error) { +func (s *Service) Revoke(ctx context.Context) (*transport.Response[RevokeResponse], error) { out := &RevokeResponse{} - if err := s.sender.Send(ctx, http.MethodDelete, revokePath, nil, out); err != nil { + meta, err := s.sender.Send(ctx, http.MethodDelete, revokePath, nil, out) + if err != nil { return nil, err } - return out, nil + return transport.NewResponse(meta, out), nil } diff --git a/services/apikey/service_test.go b/services/apikey/service_test.go index c6f991e..7c7d864 100644 --- a/services/apikey/service_test.go +++ b/services/apikey/service_test.go @@ -35,8 +35,8 @@ func TestServiceRevoke(t *testing.T) { if err != nil { t.Fatalf("unexpected error: %v", err) } - if out.Message != "API Key revoked successfully." { - t.Fatalf("unexpected message %q", out.Message) + if out.Payload.Message != "API Key revoked successfully." { + t.Fatalf("unexpected message %q", out.Payload.Message) } } diff --git a/services/assets/service.go b/services/assets/service.go index 625d281..97cf793 100644 --- a/services/assets/service.go +++ b/services/assets/service.go @@ -67,19 +67,20 @@ type GetResponse struct { Data Asset `json:"data"` } -func (s *Service) Get(ctx context.Context, symbol string) (*GetResponse, error) { +func (s *Service) Get(ctx context.Context, symbol string) (*transport.Response[GetResponse], error) { if strings.TrimSpace(symbol) == "" { return nil, ErrEmptySymbol } path := fmt.Sprintf("%s/%s", listPath, url.PathEscape(symbol)) out := &GetResponse{} - if err := s.sender.Send(ctx, http.MethodGet, path, nil, out); err != nil { + meta, err := s.sender.Send(ctx, http.MethodGet, path, nil, out) + if err != nil { return nil, err } - return out, nil + return transport.NewResponse(meta, out), nil } -func (s *Service) List(ctx context.Context, req *ListRequest) (*ListResponse, error) { +func (s *Service) List(ctx context.Context, req *ListRequest) (*transport.Response[ListResponse], error) { path := listPath if req != nil { q := url.Values{} @@ -101,8 +102,9 @@ func (s *Service) List(ctx context.Context, req *ListRequest) (*ListResponse, er } out := &ListResponse{} - if err := s.sender.Send(ctx, http.MethodGet, path, nil, out); err != nil { + meta, err := s.sender.Send(ctx, http.MethodGet, path, nil, out) + if err != nil { return nil, err } - return out, nil + return transport.NewResponse(meta, out), nil } diff --git a/services/assets/service_test.go b/services/assets/service_test.go index e9ca117..2469306 100644 --- a/services/assets/service_test.go +++ b/services/assets/service_test.go @@ -56,11 +56,11 @@ func TestServiceGet(t *testing.T) { if err != nil { t.Fatalf("unexpected error: %v", err) } - if out.Data.Symbol != "BRK.B" { - t.Fatalf("unexpected symbol %q", out.Data.Symbol) + if out.Payload.Data.Symbol != "BRK.B" { + t.Fatalf("unexpected symbol %q", out.Payload.Data.Symbol) } - if out.Data.Name != "Berkshire Hathaway Inc." { - t.Fatalf("unexpected name %q", out.Data.Name) + if out.Payload.Data.Name != "Berkshire Hathaway Inc." { + t.Fatalf("unexpected name %q", out.Payload.Data.Name) } } @@ -133,20 +133,20 @@ func TestServiceList(t *testing.T) { if err != nil { t.Fatalf("unexpected error: %v", err) } - if out.CurrentPage != 2 { - t.Fatalf("expected current_page=2, got %d", out.CurrentPage) + if out.Payload.CurrentPage != 2 { + t.Fatalf("expected current_page=2, got %d", out.Payload.CurrentPage) } - if len(out.Data) != 1 { - t.Fatalf("expected one asset, got %d", len(out.Data)) + if len(out.Payload.Data) != 1 { + t.Fatalf("expected one asset, got %d", len(out.Payload.Data)) } - if out.Data[0].Symbol != "AAPL" { - t.Fatalf("unexpected symbol %q", out.Data[0].Symbol) + if out.Payload.Data[0].Symbol != "AAPL" { + t.Fatalf("unexpected symbol %q", out.Payload.Data[0].Symbol) } - if out.Data[0].Dividend == nil { + if out.Payload.Data[0].Dividend == nil { t.Fatal("expected dividend data") } - if out.Data[0].Dividend.Amount == nil || *out.Data[0].Dividend.Amount != 0.24 { - t.Fatalf("unexpected dividend amount: %v", out.Data[0].Dividend.Amount) + if out.Payload.Data[0].Dividend.Amount == nil || *out.Payload.Data[0].Dividend.Amount != 0.24 { + t.Fatalf("unexpected dividend amount: %v", out.Payload.Data[0].Dividend.Amount) } } @@ -172,8 +172,8 @@ func TestServiceListWithoutFilters(t *testing.T) { if err != nil { t.Fatalf("unexpected error: %v", err) } - if out.Count != 0 { - t.Fatalf("expected count=0, got %d", out.Count) + if out.Payload.Count != 0 { + t.Fatalf("expected count=0, got %d", out.Payload.Count) } } diff --git a/services/balance/service.go b/services/balance/service.go index 81e4cf7..8a91d7a 100644 --- a/services/balance/service.go +++ b/services/balance/service.go @@ -38,18 +38,20 @@ type StocksBalanceResponse struct { Data []StockPosition `json:"data"` } -func (s *Service) GetChecking(ctx context.Context) (*CheckingBalanceResponse, error) { +func (s *Service) GetChecking(ctx context.Context) (*transport.Response[CheckingBalanceResponse], error) { out := &CheckingBalanceResponse{} - if err := s.sender.Send(ctx, http.MethodGet, checkingPath, nil, out); err != nil { + meta, err := s.sender.Send(ctx, http.MethodGet, checkingPath, nil, out) + if err != nil { return nil, err } - return out, nil + return transport.NewResponse(meta, out), nil } -func (s *Service) GetStocks(ctx context.Context) (*StocksBalanceResponse, error) { +func (s *Service) GetStocks(ctx context.Context) (*transport.Response[StocksBalanceResponse], error) { out := &StocksBalanceResponse{} - if err := s.sender.Send(ctx, http.MethodGet, stocksPath, nil, out); err != nil { + meta, err := s.sender.Send(ctx, http.MethodGet, stocksPath, nil, out) + if err != nil { return nil, err } - return out, nil + return transport.NewResponse(meta, out), nil } diff --git a/services/balance/service_test.go b/services/balance/service_test.go index cfe74be..94c1dda 100644 --- a/services/balance/service_test.go +++ b/services/balance/service_test.go @@ -32,14 +32,14 @@ func TestServiceGetChecking(t *testing.T) { if err != nil { t.Fatalf("unexpected error: %v", err) } - if len(out.Data) != 1 { - t.Fatalf("expected one balance row, got %d", len(out.Data)) + if len(out.Payload.Data) != 1 { + t.Fatalf("expected one balance row, got %d", len(out.Payload.Data)) } - if out.Data[0].Currency != "USD" { - t.Fatalf("expected currency USD, got %q", out.Data[0].Currency) + if out.Payload.Data[0].Currency != "USD" { + t.Fatalf("expected currency USD, got %q", out.Payload.Data[0].Currency) } - if out.Data[0].Balance != 100.5 { - t.Fatalf("expected balance 100.5, got %v", out.Data[0].Balance) + if out.Payload.Data[0].Balance != 100.5 { + t.Fatalf("expected balance 100.5, got %v", out.Payload.Data[0].Balance) } } @@ -65,11 +65,11 @@ func TestServiceGetStocks(t *testing.T) { if err != nil { t.Fatalf("unexpected error: %v", err) } - if len(out.Data) != 1 { - t.Fatalf("expected one stock position, got %d", len(out.Data)) + if len(out.Payload.Data) != 1 { + t.Fatalf("expected one stock position, got %d", len(out.Payload.Data)) } - if out.Data[0].Symbol != "AAPL" { - t.Fatalf("expected symbol AAPL, got %q", out.Data[0].Symbol) + if out.Payload.Data[0].Symbol != "AAPL" { + t.Fatalf("expected symbol AAPL, got %q", out.Payload.Data[0].Symbol) } } diff --git a/services/cards/service.go b/services/cards/service.go index fb4b805..a56e6a1 100644 --- a/services/cards/service.go +++ b/services/cards/service.go @@ -57,23 +57,24 @@ type UpdateStatusResponse struct { Data CardStatus `json:"data"` } -func (s *Service) List(ctx context.Context) (*ListResponse, error) { +func (s *Service) List(ctx context.Context) (*transport.Response[ListResponse], error) { out := &ListResponse{} - if err := s.sender.Send(ctx, http.MethodGet, listPath, nil, out); err != nil { + meta, err := s.sender.Send(ctx, http.MethodGet, listPath, nil, out) + if err != nil { return nil, err } - return out, nil + return transport.NewResponse(meta, out), nil } -func (s *Service) Block(ctx context.Context, cardUUID string) (*UpdateStatusResponse, error) { +func (s *Service) Block(ctx context.Context, cardUUID string) (*transport.Response[UpdateStatusResponse], error) { return s.updateStatus(ctx, cardUUID, StatusSuspended) } -func (s *Service) Unblock(ctx context.Context, cardUUID string) (*UpdateStatusResponse, error) { +func (s *Service) Unblock(ctx context.Context, cardUUID string) (*transport.Response[UpdateStatusResponse], error) { return s.updateStatus(ctx, cardUUID, StatusActive) } -func (s *Service) updateStatus(ctx context.Context, cardUUID string, status string) (*UpdateStatusResponse, error) { +func (s *Service) updateStatus(ctx context.Context, cardUUID string, status string) (*transport.Response[UpdateStatusResponse], error) { if strings.TrimSpace(cardUUID) == "" { return nil, ErrEmptyCardUUID } @@ -84,8 +85,9 @@ func (s *Service) updateStatus(ctx context.Context, cardUUID string, status stri out := &UpdateStatusResponse{} path := fmt.Sprintf(updateStatusPathFormat, cardUUID) - if err := s.sender.Send(ctx, http.MethodPatch, path, bytes.NewBuffer(payload), out); err != nil { + meta, err := s.sender.Send(ctx, http.MethodPatch, path, bytes.NewBuffer(payload), out) + if err != nil { return nil, err } - return out, nil + return transport.NewResponse(meta, out), nil } diff --git a/services/cards/service_test.go b/services/cards/service_test.go index a76f5da..64d4485 100644 --- a/services/cards/service_test.go +++ b/services/cards/service_test.go @@ -58,17 +58,17 @@ func TestServiceList(t *testing.T) { if err != nil { t.Fatalf("unexpected error: %v", err) } - if len(out.Data) != 2 { - t.Fatalf("expected 2 cards, got %d", len(out.Data)) + if len(out.Payload.Data) != 2 { + t.Fatalf("expected 2 cards, got %d", len(out.Payload.Data)) } - if out.Data[0].UUID != "550e8400-e29b-41d4-a716-446655440000" || out.Data[0].CardLast4 != "1234" { - t.Fatalf("unexpected first card: %+v", out.Data[0]) + if out.Payload.Data[0].UUID != "550e8400-e29b-41d4-a716-446655440000" || out.Payload.Data[0].CardLast4 != "1234" { + t.Fatalf("unexpected first card: %+v", out.Payload.Data[0]) } - if out.Data[0].Expiration == nil || *out.Data[0].Expiration != "2029-01-01" { - t.Fatalf("unexpected expiration: %v", out.Data[0].Expiration) + if out.Payload.Data[0].Expiration == nil || *out.Payload.Data[0].Expiration != "2029-01-01" { + t.Fatalf("unexpected expiration: %v", out.Payload.Data[0].Expiration) } - if out.Data[1].Expiration != nil { - t.Fatalf("expected nil expiration, got %v", out.Data[1].Expiration) + if out.Payload.Data[1].Expiration != nil { + t.Fatalf("expected nil expiration, got %v", out.Payload.Data[1].Expiration) } } @@ -91,8 +91,8 @@ func TestServiceListEmpty(t *testing.T) { if err != nil { t.Fatalf("unexpected error: %v", err) } - if len(out.Data) != 0 { - t.Fatalf("expected empty list, got %d", len(out.Data)) + if len(out.Payload.Data) != 0 { + t.Fatalf("expected empty list, got %d", len(out.Payload.Data)) } } @@ -156,11 +156,11 @@ func TestServiceBlock(t *testing.T) { if err != nil { t.Fatalf("unexpected error: %v", err) } - if out.Data.UUID != cardUUID { - t.Fatalf("expected uuid %q, got %q", cardUUID, out.Data.UUID) + if out.Payload.Data.UUID != cardUUID { + t.Fatalf("expected uuid %q, got %q", cardUUID, out.Payload.Data.UUID) } - if out.Data.Status != cards.StatusSuspended { - t.Fatalf("expected status %q, got %q", cards.StatusSuspended, out.Data.Status) + if out.Payload.Data.Status != cards.StatusSuspended { + t.Fatalf("expected status %q, got %q", cards.StatusSuspended, out.Payload.Data.Status) } } @@ -224,11 +224,11 @@ func TestServiceUnblock(t *testing.T) { if err != nil { t.Fatalf("unexpected error: %v", err) } - if out.Data.UUID != cardUUID { - t.Fatalf("expected uuid %q, got %q", cardUUID, out.Data.UUID) + if out.Payload.Data.UUID != cardUUID { + t.Fatalf("expected uuid %q, got %q", cardUUID, out.Payload.Data.UUID) } - if out.Data.Status != cards.StatusActive { - t.Fatalf("expected status %q, got %q", cards.StatusActive, out.Data.Status) + if out.Payload.Data.Status != cards.StatusActive { + t.Fatalf("expected status %q, got %q", cards.StatusActive, out.Payload.Data.Status) } } diff --git a/services/fees/service.go b/services/fees/service.go index 2e855fd..fc4eeed 100644 --- a/services/fees/service.go +++ b/services/fees/service.go @@ -70,16 +70,17 @@ type GetResponse struct { Data GetData `json:"data"` } -func (s *Service) Get(ctx context.Context, req GetRequest) (*GetResponse, error) { +func (s *Service) Get(ctx context.Context, req GetRequest) (*transport.Response[GetResponse], error) { payload, err := json.Marshal(req) if err != nil { return nil, err } out := &GetResponse{} - if err := s.sender.Send(ctx, http.MethodPost, getPath, bytes.NewBuffer(payload), out); err != nil { + meta, err := s.sender.Send(ctx, http.MethodPost, getPath, bytes.NewBuffer(payload), out) + if err != nil { return nil, err } - return out, nil + return transport.NewResponse(meta, out), nil } diff --git a/services/fees/service_test.go b/services/fees/service_test.go index e20d2d4..39a5b25 100644 --- a/services/fees/service_test.go +++ b/services/fees/service_test.go @@ -46,17 +46,17 @@ func TestServiceGet(t *testing.T) { if err != nil { t.Fatalf("unexpected error: %v", err) } - if out.Data.Empty { + if out.Payload.Data.Empty { t.Fatal("expected fee row, got empty data") } - if out.Data.Row == nil { + if out.Payload.Data.Row == nil { t.Fatal("expected non-nil fee setting") } - if out.Data.Row.FeeType != "TRADE" { - t.Fatalf("expected fee_type TRADE, got %q", out.Data.Row.FeeType) + if out.Payload.Data.Row.FeeType != "TRADE" { + t.Fatalf("expected fee_type TRADE, got %q", out.Payload.Data.Row.FeeType) } - if out.Data.Row.Tier == nil || *out.Data.Row.Tier != "LEVEL1" { - t.Fatalf("expected tier LEVEL1, got %v", out.Data.Row.Tier) + if out.Payload.Data.Row.Tier == nil || *out.Payload.Data.Row.Tier != "LEVEL1" { + t.Fatalf("expected tier LEVEL1, got %v", out.Payload.Data.Row.Tier) } } @@ -79,8 +79,8 @@ func TestServiceGetNullTier(t *testing.T) { if err != nil { t.Fatalf("unexpected error: %v", err) } - if out.Data.Row == nil || out.Data.Row.Tier != nil { - t.Fatalf("expected nil tier, got %v", out.Data.Row.Tier) + if out.Payload.Data.Row == nil || out.Payload.Data.Row.Tier != nil { + t.Fatalf("expected nil tier, got %v", out.Payload.Data.Row.Tier) } } @@ -103,11 +103,11 @@ func TestServiceGetReturnsEmptyDataArray(t *testing.T) { if err != nil { t.Fatalf("unexpected error: %v", err) } - if !out.Data.Empty { - t.Fatalf("expected empty data flag, got %#v", out.Data) + if !out.Payload.Data.Empty { + t.Fatalf("expected empty data flag, got %#v", out.Payload.Data) } - if out.Data.Row != nil { - t.Fatalf("expected nil row for empty data, got %+v", out.Data.Row) + if out.Payload.Data.Row != nil { + t.Fatalf("expected nil row for empty data, got %+v", out.Payload.Data.Row) } } diff --git a/services/operations/service.go b/services/operations/service.go index 8e3a9b4..61fd30a 100644 --- a/services/operations/service.go +++ b/services/operations/service.go @@ -63,20 +63,21 @@ type InternalResponse struct { Data Transaction `json:"data"` } -func (s *Service) Internal(ctx context.Context, req InternalRequest) (*InternalResponse, error) { +func (s *Service) Internal(ctx context.Context, req InternalRequest) (*transport.Response[InternalResponse], error) { payload, err := json.Marshal(req) if err != nil { return nil, err } out := &InternalResponse{} - if err := s.sender.Send(ctx, http.MethodPost, internalPath, bytes.NewBuffer(payload), out); err != nil { + meta, err := s.sender.Send(ctx, http.MethodPost, internalPath, bytes.NewBuffer(payload), out) + if err != nil { return nil, err } - return out, nil + return transport.NewResponse(meta, out), nil } -func (s *Service) DepositInvestment(ctx context.Context, req InvestmentDepositRequest) (*InternalResponse, error) { +func (s *Service) DepositInvestment(ctx context.Context, req InvestmentDepositRequest) (*transport.Response[InternalResponse], error) { return s.Internal(ctx, InternalRequest{ Currency: req.Currency, From: AccountDefault, @@ -85,7 +86,7 @@ func (s *Service) DepositInvestment(ctx context.Context, req InvestmentDepositRe }) } -func (s *Service) WithdrawInvestment(ctx context.Context, req InvestmentWithdrawRequest) (*InternalResponse, error) { +func (s *Service) WithdrawInvestment(ctx context.Context, req InvestmentWithdrawRequest) (*transport.Response[InternalResponse], error) { return s.Internal(ctx, InternalRequest{ Currency: req.Currency, From: AccountInvestment, diff --git a/services/operations/service_test.go b/services/operations/service_test.go index b8e7b5e..c30ac1d 100644 --- a/services/operations/service_test.go +++ b/services/operations/service_test.go @@ -51,11 +51,11 @@ func TestServiceInternal(t *testing.T) { if err != nil { t.Fatalf("unexpected error: %v", err) } - if out.Data.UUID != "tx_123" { - t.Fatalf("expected uuid tx_123, got %q", out.Data.UUID) + if out.Payload.Data.UUID != "tx_123" { + t.Fatalf("expected uuid tx_123, got %q", out.Payload.Data.UUID) } - if out.Data.Status != "COMPLETED" { - t.Fatalf("expected status COMPLETED, got %q", out.Data.Status) + if out.Payload.Data.Status != "COMPLETED" { + t.Fatalf("expected status COMPLETED, got %q", out.Payload.Data.Status) } } @@ -92,8 +92,8 @@ func TestServiceDepositInvestment(t *testing.T) { if err != nil { t.Fatalf("unexpected error: %v", err) } - if out.Data.UUID != "tx_deposit" { - t.Fatalf("expected uuid tx_deposit, got %q", out.Data.UUID) + if out.Payload.Data.UUID != "tx_deposit" { + t.Fatalf("expected uuid tx_deposit, got %q", out.Payload.Data.UUID) } } @@ -130,8 +130,8 @@ func TestServiceWithdrawInvestment(t *testing.T) { if err != nil { t.Fatalf("unexpected error: %v", err) } - if out.Data.UUID != "tx_withdraw" { - t.Fatalf("expected uuid tx_withdraw, got %q", out.Data.UUID) + if out.Payload.Data.UUID != "tx_withdraw" { + t.Fatalf("expected uuid tx_withdraw, got %q", out.Payload.Data.UUID) } } diff --git a/services/roboadvisor/service.go b/services/roboadvisor/service.go index 1f7c553..529f8a3 100644 --- a/services/roboadvisor/service.go +++ b/services/roboadvisor/service.go @@ -105,36 +105,39 @@ type WithdrawResponse struct { Data Transaction `json:"data"` } -func (s *Service) GetBalance(ctx context.Context) (*GetBalanceResponse, error) { +func (s *Service) GetBalance(ctx context.Context) (*transport.Response[GetBalanceResponse], error) { out := &GetBalanceResponse{} - if err := s.sender.Send(ctx, http.MethodGet, balancePath, nil, out); err != nil { + meta, err := s.sender.Send(ctx, http.MethodGet, balancePath, nil, out) + if err != nil { return nil, err } - return out, nil + return transport.NewResponse(meta, out), nil } -func (s *Service) Deposit(ctx context.Context, req DepositRequest) (*DepositResponse, error) { +func (s *Service) Deposit(ctx context.Context, req DepositRequest) (*transport.Response[DepositResponse], error) { payload, err := json.Marshal(req) if err != nil { return nil, err } out := &DepositResponse{} - if err := s.sender.Send(ctx, http.MethodPost, depositPath, bytes.NewBuffer(payload), out); err != nil { + meta, err := s.sender.Send(ctx, http.MethodPost, depositPath, bytes.NewBuffer(payload), out) + if err != nil { return nil, err } - return out, nil + return transport.NewResponse(meta, out), nil } -func (s *Service) Withdraw(ctx context.Context, req WithdrawRequest) (*WithdrawResponse, error) { +func (s *Service) Withdraw(ctx context.Context, req WithdrawRequest) (*transport.Response[WithdrawResponse], error) { payload, err := json.Marshal(req) if err != nil { return nil, err } out := &WithdrawResponse{} - if err := s.sender.Send(ctx, http.MethodPost, withdrawPath, bytes.NewBuffer(payload), out); err != nil { + meta, err := s.sender.Send(ctx, http.MethodPost, withdrawPath, bytes.NewBuffer(payload), out) + if err != nil { return nil, err } - return out, nil + return transport.NewResponse(meta, out), nil } diff --git a/services/roboadvisor/service_test.go b/services/roboadvisor/service_test.go index d7eea97..0eb1184 100644 --- a/services/roboadvisor/service_test.go +++ b/services/roboadvisor/service_test.go @@ -41,14 +41,14 @@ func TestServiceGetBalance(t *testing.T) { if err != nil { t.Fatalf("unexpected error: %v", err) } - if len(out.Data) != 1 { - t.Fatalf("expected one portfolio, got %d", len(out.Data)) + if len(out.Payload.Data) != 1 { + t.Fatalf("expected one portfolio, got %d", len(out.Payload.Data)) } - if out.Data[0].PortfolioType != "ROBOADVISOR" { - t.Fatalf("unexpected portfolio_type %q", out.Data[0].PortfolioType) + if out.Payload.Data[0].PortfolioType != "ROBOADVISOR" { + t.Fatalf("unexpected portfolio_type %q", out.Payload.Data[0].PortfolioType) } - if out.Data[0].RiskProfile == nil || out.Data[0].RiskProfile.RiskLevel != 3 { - t.Fatalf("unexpected risk profile: %+v", out.Data[0].RiskProfile) + if out.Payload.Data[0].RiskProfile == nil || out.Payload.Data[0].RiskProfile.RiskLevel != 3 { + t.Fatalf("unexpected risk profile: %+v", out.Payload.Data[0].RiskProfile) } } @@ -71,11 +71,11 @@ func TestServiceGetBalanceNullRiskProfile(t *testing.T) { if err != nil { t.Fatalf("unexpected error: %v", err) } - if len(out.Data) != 1 { - t.Fatalf("expected one portfolio, got %d", len(out.Data)) + if len(out.Payload.Data) != 1 { + t.Fatalf("expected one portfolio, got %d", len(out.Payload.Data)) } - if out.Data[0].RiskProfile != nil { - t.Fatalf("expected nil risk_profile, got %+v", out.Data[0].RiskProfile) + if out.Payload.Data[0].RiskProfile != nil { + t.Fatalf("expected nil risk_profile, got %+v", out.Payload.Data[0].RiskProfile) } } @@ -145,11 +145,11 @@ func TestServiceDeposit(t *testing.T) { if err != nil { t.Fatalf("unexpected error: %v", err) } - if out.Data.Type != "ROBOADVISOR_DEPOSIT" { - t.Fatalf("unexpected transaction type %q", out.Data.Type) + if out.Payload.Data.Type != "ROBOADVISOR_DEPOSIT" { + t.Fatalf("unexpected transaction type %q", out.Payload.Data.Type) } - if out.Data.Status != "PENDING" { - t.Fatalf("unexpected status %q", out.Data.Status) + if out.Payload.Data.Status != "PENDING" { + t.Fatalf("unexpected status %q", out.Payload.Data.Status) } } @@ -223,11 +223,11 @@ func TestServiceWithdraw(t *testing.T) { if err != nil { t.Fatalf("unexpected error: %v", err) } - if out.Data.Type != "ROBOADVISOR_WITHDRAW" { - t.Fatalf("unexpected transaction type %q", out.Data.Type) + if out.Payload.Data.Type != "ROBOADVISOR_WITHDRAW" { + t.Fatalf("unexpected transaction type %q", out.Payload.Data.Type) } - if out.Data.Status != "PENDING" { - t.Fatalf("unexpected status %q", out.Data.Status) + if out.Payload.Data.Status != "PENDING" { + t.Fatalf("unexpected status %q", out.Payload.Data.Status) } } diff --git a/services/trades/service.go b/services/trades/service.go index 2f86848..0d510da 100644 --- a/services/trades/service.go +++ b/services/trades/service.go @@ -49,15 +49,16 @@ type CreateResponse struct { Data Trade `json:"data"` } -func (s *Service) Create(ctx context.Context, req CreateRequest) (*CreateResponse, error) { +func (s *Service) Create(ctx context.Context, req CreateRequest) (*transport.Response[CreateResponse], error) { payload, err := json.Marshal(req) if err != nil { return nil, err } out := &CreateResponse{} - if err := s.sender.Send(ctx, http.MethodPost, createPath, bytes.NewBuffer(payload), out); err != nil { + meta, err := s.sender.Send(ctx, http.MethodPost, createPath, bytes.NewBuffer(payload), out) + if err != nil { return nil, err } - return out, nil + return transport.NewResponse(meta, out), nil } diff --git a/services/trades/service_test.go b/services/trades/service_test.go index 2801fed..747ae78 100644 --- a/services/trades/service_test.go +++ b/services/trades/service_test.go @@ -53,11 +53,11 @@ func TestServiceCreate(t *testing.T) { if err != nil { t.Fatalf("unexpected error: %v", err) } - if out.Data.Symbol != "AAPL" { - t.Fatalf("expected symbol AAPL, got %q", out.Data.Symbol) + if out.Payload.Data.Symbol != "AAPL" { + t.Fatalf("expected symbol AAPL, got %q", out.Payload.Data.Symbol) } - if out.Data.Status != "REQUESTED" { - t.Fatalf("expected status REQUESTED, got %q", out.Data.Status) + if out.Payload.Data.Status != "REQUESTED" { + t.Fatalf("expected status REQUESTED, got %q", out.Payload.Data.Status) } } diff --git a/services/transactions/service.go b/services/transactions/service.go index 9766759..699a1a3 100644 --- a/services/transactions/service.go +++ b/services/transactions/service.go @@ -61,7 +61,7 @@ type ListResponse struct { Data ListData `json:"data"` } -func (s *Service) List(ctx context.Context, req *ListRequest) (*ListResponse, error) { +func (s *Service) List(ctx context.Context, req *ListRequest) (*transport.Response[ListResponse], error) { path := listPath if req != nil { q := url.Values{} @@ -98,9 +98,10 @@ func (s *Service) List(ctx context.Context, req *ListRequest) (*ListResponse, er } out := &ListResponse{} - if err := s.sender.Send(ctx, http.MethodGet, path, nil, out); err != nil { + meta, err := s.sender.Send(ctx, http.MethodGet, path, nil, out) + if err != nil { return nil, err } - return out, nil + return transport.NewResponse(meta, out), nil } diff --git a/services/transactions/service_test.go b/services/transactions/service_test.go index 4302302..111d3b4 100644 --- a/services/transactions/service_test.go +++ b/services/transactions/service_test.go @@ -54,23 +54,23 @@ func TestServiceList(t *testing.T) { if err != nil { t.Fatalf("unexpected error: %v", err) } - if out.Data.CurrentPage != 2 { - t.Fatalf("expected current_page=2, got %d", out.Data.CurrentPage) + if out.Payload.Data.CurrentPage != 2 { + t.Fatalf("expected current_page=2, got %d", out.Payload.Data.CurrentPage) } - if len(out.Data.Data) != 2 { - t.Fatalf("expected two transactions, got %d", len(out.Data.Data)) + if len(out.Payload.Data.Data) != 2 { + t.Fatalf("expected two transactions, got %d", len(out.Payload.Data.Data)) } - if out.Data.Data[0].UUID != "abc" { - t.Fatalf("unexpected uuid %q", out.Data.Data[0].UUID) + if out.Payload.Data.Data[0].UUID != "abc" { + t.Fatalf("unexpected uuid %q", out.Payload.Data.Data[0].UUID) } - if out.Data.Data[0].ExternalAddress == nil || *out.Data.Data[0].ExternalAddress != "Juan Perez" { - t.Fatalf("unexpected external_address in first transaction: %v", out.Data.Data[0].ExternalAddress) + if out.Payload.Data.Data[0].ExternalAddress == nil || *out.Payload.Data.Data[0].ExternalAddress != "Juan Perez" { + t.Fatalf("unexpected external_address in first transaction: %v", out.Payload.Data.Data[0].ExternalAddress) } - if out.Data.Data[1].UUID != "def" { - t.Fatalf("unexpected uuid %q", out.Data.Data[1].UUID) + if out.Payload.Data.Data[1].UUID != "def" { + t.Fatalf("unexpected uuid %q", out.Payload.Data.Data[1].UUID) } - if out.Data.Data[1].ExternalAddress != nil { - t.Fatalf("expected nil external_address in second transaction, got %v", out.Data.Data[1].ExternalAddress) + if out.Payload.Data.Data[1].ExternalAddress != nil { + t.Fatalf("expected nil external_address in second transaction, got %v", out.Payload.Data.Data[1].ExternalAddress) } } @@ -96,8 +96,8 @@ func TestServiceListWithoutFilters(t *testing.T) { if err != nil { t.Fatalf("unexpected error: %v", err) } - if out.Data.Count != 0 { - t.Fatalf("expected count=0, got %d", out.Data.Count) + if out.Payload.Data.Count != 0 { + t.Fatalf("expected count=0, got %d", out.Payload.Data.Count) } } diff --git a/services/wallets/service.go b/services/wallets/service.go index 57adc80..b253652 100644 --- a/services/wallets/service.go +++ b/services/wallets/service.go @@ -33,7 +33,7 @@ type GetResponse struct { Data []Wallet `json:"data"` } -func (s *Service) Get(ctx context.Context, req *GetRequest) (*GetResponse, error) { +func (s *Service) Get(ctx context.Context, req *GetRequest) (*transport.Response[GetResponse], error) { path := getPath if req != nil { q := url.Values{} @@ -49,8 +49,9 @@ func (s *Service) Get(ctx context.Context, req *GetRequest) (*GetResponse, error } out := &GetResponse{} - if err := s.sender.Send(ctx, http.MethodGet, path, nil, out); err != nil { + meta, err := s.sender.Send(ctx, http.MethodGet, path, nil, out) + if err != nil { return nil, err } - return out, nil + return transport.NewResponse(meta, out), nil } diff --git a/services/wallets/service_test.go b/services/wallets/service_test.go index 0929c3f..5ee7fd1 100644 --- a/services/wallets/service_test.go +++ b/services/wallets/service_test.go @@ -42,11 +42,11 @@ func TestServiceGet(t *testing.T) { if err != nil { t.Fatalf("unexpected error: %v", err) } - if len(out.Data) != 1 { - t.Fatalf("expected one wallet, got %d", len(out.Data)) + if len(out.Payload.Data) != 1 { + t.Fatalf("expected one wallet, got %d", len(out.Payload.Data)) } - if out.Data[0].CurrencyCode != "USDT" { - t.Fatalf("unexpected currency_code %q", out.Data[0].CurrencyCode) + if out.Payload.Data[0].CurrencyCode != "USDT" { + t.Fatalf("unexpected currency_code %q", out.Payload.Data[0].CurrencyCode) } } @@ -72,8 +72,8 @@ func TestServiceGetWithoutFilters(t *testing.T) { if err != nil { t.Fatalf("unexpected error: %v", err) } - if len(out.Data) != 0 { - t.Fatalf("expected no wallets, got %d", len(out.Data)) + if len(out.Payload.Data) != 0 { + t.Fatalf("expected no wallets, got %d", len(out.Payload.Data)) } } diff --git a/transport/doc.go b/transport/doc.go index ac2929e..dc30dce 100644 --- a/transport/doc.go +++ b/transport/doc.go @@ -1,10 +1,18 @@ // Package transport defines the [Sender] interface that decouples the // Wallbit [github.com/jeremyjsx/wallbit-go/wallbit] client from the -// per-resource service packages. +// per-resource service packages, together with the generic [Response] +// wrapper that every service method returns. // -// Most consumers do not need to import this package directly. Import it when -// implementing a custom [Sender] for tests, mocking, or wrapping the real -// client with middlewares (logging, metrics, distributed tracing, custom -// retries). All user-facing knobs (retry policy, hooks, request/response -// metadata) live on the wallbit package itself. +// Most consumers import this package for one of two reasons: +// +// - To reference the return type of a service method explicitly, since +// every method returns a [*Response] of its own payload type (for +// example [*transport.Response[balance.CheckingBalanceResponse]]). +// In most call sites type inference makes this import unnecessary. +// - To implement a custom [Sender] for tests, mocking, or wrapping the +// real client with middlewares (logging, metrics, distributed +// tracing, custom retries). +// +// All user-facing knobs (retry policy, hooks, base URL, timeouts) live on +// the wallbit package itself. package transport diff --git a/transport/transport.go b/transport/transport.go index 8915f8c..4ab2f73 100644 --- a/transport/transport.go +++ b/transport/transport.go @@ -3,19 +3,71 @@ package transport import ( "context" "io" + "net/http" ) +// Metadata describes the response envelope that every HTTP call returns, +// independent of the decoded payload. It is what [Sender] implementations +// report back to the per-resource services so they can expose it to end +// users through [Response]. +// +// Header is the raw response header map (owned by the HTTP response and +// safe to read; callers should not mutate it). RequestID is the +// server-assigned identifier taken from the X-Request-ID header when +// present, empty otherwise. It is useful for support/debugging round-trips. +type Metadata struct { + StatusCode int + Header http.Header + RequestID string +} + +// Response is the generic wrapper returned by every service method. It +// pairs the HTTP response metadata with the typed, decoded payload T so +// callers can inspect both without giving up type safety. +// +// Payload is nil only when the endpoint legitimately returns no body +// (for example, an HTTP 204 No Content). For the common success case +// Payload is the fully decoded response struct. +type Response[T any] struct { + StatusCode int + Header http.Header + RequestID string + Payload *T +} + +// NewResponse assembles a [*Response] from the [*Metadata] returned by +// [Sender.Send] and a typed, already-decoded payload. It is a convenience +// for service implementations and nil-safe on meta so callers may use it +// without branching. +func NewResponse[T any](meta *Metadata, payload *T) *Response[T] { + if meta == nil { + return &Response[T]{Payload: payload} + } + return &Response[T]{ + StatusCode: meta.StatusCode, + Header: meta.Header, + RequestID: meta.RequestID, + Payload: payload, + } +} + // Sender issues an authenticated HTTP request against the Wallbit API and -// decodes a JSON response into dest when non-nil. It is the seam used by the -// per-resource service packages to talk to the underlying client, and the -// extension point for tests, mocking, and middlewares (logging, metrics, -// distributed tracing, custom retries). +// decodes a JSON response into dest when non-nil. It is the seam used by +// the per-resource service packages to talk to the underlying client, and +// the extension point for tests, mocking, and middlewares (logging, +// metrics, distributed tracing, custom retries). // // Implementations must be safe for concurrent use by multiple goroutines. -// path is interpreted relative to the client's base URL. body may be nil for -// requests without a payload. dest may be nil for responses whose body is -// not consumed; otherwise it must be a non-nil pointer suitable for JSON -// unmarshalling. +// path is interpreted relative to the client's base URL. body may be nil +// for requests without a payload. dest may be nil for responses whose +// body is not consumed; otherwise it must be a non-nil pointer suitable +// for JSON unmarshalling. +// +// On success Send returns a non-nil [*Metadata] describing the final HTTP +// response. On transport failure (network error, context cancellation) +// Send returns a nil Metadata and a non-nil error. On an API error (HTTP +// status >= 400) Send returns a non-nil Metadata alongside the typed +// error so callers can surface status/headers/request-id even on failure. type Sender interface { - Send(ctx context.Context, method string, path string, body io.Reader, dest any) error + Send(ctx context.Context, method string, path string, body io.Reader, dest any) (*Metadata, error) } diff --git a/wallbit/doc.go b/wallbit/doc.go index b5adae1..5c20734 100644 --- a/wallbit/doc.go +++ b/wallbit/doc.go @@ -11,6 +11,19 @@ // // client, err := wallbit.NewClient(os.Getenv("WALLBIT_API_KEY")) // +// # Responses +// +// Every service method returns a [*transport.Response] generic wrapper +// pairing the decoded payload (Payload) with the HTTP envelope +// (StatusCode, Header, RequestID). RequestID is the X-Request-ID header +// from the server and is useful when reporting issues to Wallbit support. +// +// res, err := client.Balance.GetChecking(ctx) +// if err != nil { +// return err +// } +// fmt.Println(res.StatusCode, res.RequestID, res.Payload.Data) +// // # Configuration // // Customize the client with functional options: [WithBaseURL], diff --git a/wallbit/wallbit.go b/wallbit/wallbit.go index f618e11..66fad4b 100644 --- a/wallbit/wallbit.go +++ b/wallbit/wallbit.go @@ -151,16 +151,16 @@ func (c *Client) newRequest(ctx context.Context, method string, path string, bod return req, nil } -func (c *Client) send(ctx context.Context, method string, path string, body io.Reader, dest any) error { +func (c *Client) send(ctx context.Context, method string, path string, body io.Reader, dest any) (*transport.Metadata, error) { req, err := c.newRequest(ctx, method, path, body) if err != nil { - return err + return nil, err } return c.do(req, dest) } -func (c *Client) do(req *http.Request, dest any) error { +func (c *Client) do(req *http.Request, dest any) (*transport.Metadata, error) { ctx := req.Context() max := c.maxAttempts() @@ -190,47 +190,54 @@ func (c *Client) do(req *http.Request, dest any) error { if attempt < max-1 && isIdempotentHTTPMethod(req.Method) { wait := c.retryWaitBeforeNextAttempt(nil, nil, attempt) if err := sleepContext(ctx, wait); err != nil { - return err + return nil, err } continue } - return err + return nil, err } body, rerr := io.ReadAll(res.Body) res.Body.Close() if rerr != nil { - return rerr + return nil, rerr + } + + requestID := res.Header.Get("X-Request-ID") + meta := &transport.Metadata{ + StatusCode: statusCode, + Header: res.Header, + RequestID: requestID, } if statusCode >= 400 { - apiErr := ErrorFromHTTP(statusCode, res.Header.Get("X-Request-ID"), body) + apiErr := ErrorFromHTTP(statusCode, requestID, body) if attempt < max-1 && isIdempotentHTTPMethod(req.Method) && IsRetryable(apiErr) { wait := c.retryWaitBeforeNextAttempt(res, apiErr, attempt) if err := sleepContext(ctx, wait); err != nil { - return err + return nil, err } continue } - return apiErr + return meta, apiErr } if dest == nil || len(body) == 0 || statusCode == http.StatusNoContent { - return nil + return meta, nil } if err := json.Unmarshal(body, dest); err != nil { - return err + return meta, err } - return nil + return meta, nil } - return errors.New("wallbit client: internal error: retry loop exited without return") + return nil, errors.New("wallbit client: internal error: retry loop exited without return") } type senderAdapter struct { client *Client } -func (s senderAdapter) Send(ctx context.Context, method string, path string, body io.Reader, dest any) error { +func (s senderAdapter) Send(ctx context.Context, method string, path string, body io.Reader, dest any) (*transport.Metadata, error) { return s.client.send(ctx, method, path, body, dest) } From 25f68e38427b8590d5466f74283e38888719315c Mon Sep 17 00:00:00 2001 From: JeremyDevCode Date: Mon, 20 Apr 2026 14:54:33 -0500 Subject: [PATCH 02/17] ci: add github actions workflow and golangci lint config --- .github/workflows/ci.yml | 86 ++++++++++++++++++++++++++++++++++++++++ .golangci.yml | 58 +++++++++++++++++++++++++++ 2 files changed, 144 insertions(+) create mode 100644 .github/workflows/ci.yml create mode 100644 .golangci.yml diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..796581a --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,86 @@ +name: CI + +on: + push: + branches: [main, "release/**"] + pull_request: + branches: [main, "release/**"] + +permissions: + contents: read + +concurrency: + group: ci-${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + test: + name: Test (Go ${{ matrix.go }}) + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + go: ["1.23", "stable"] + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Set up Go + uses: actions/setup-go@v5 + with: + go-version: ${{ matrix.go }} + check-latest: true + + - name: Download modules + run: go mod download + + - name: Verify modules + run: go mod verify + + - name: go vet + run: go vet ./... + + - name: go test -race + run: go test -race -count=1 ./... + + lint: + name: golangci-lint + runs-on: ubuntu-latest + + permissions: + contents: read + pull-requests: read + checks: write + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Set up Go + uses: actions/setup-go@v5 + with: + go-version: stable + check-latest: true + + - name: Run golangci-lint + uses: golangci/golangci-lint-action@v6 + with: + version: v2.11.4 + + vuln: + name: govulncheck + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Set up Go + uses: actions/setup-go@v5 + with: + go-version: stable + check-latest: true + + - name: Install govulncheck + run: go install golang.org/x/vuln/cmd/govulncheck@latest + + - name: Run govulncheck + run: govulncheck ./... diff --git a/.golangci.yml b/.golangci.yml new file mode 100644 index 0000000..f64def6 --- /dev/null +++ b/.golangci.yml @@ -0,0 +1,58 @@ +version: "2" + +run: + go: "1.23" + +linters: + default: none + enable: + - errcheck + - govet + - staticcheck + - revive + - gosec + - ineffassign + - unused + - misspell + - unconvert + - nolintlint + settings: + errcheck: + exclude-functions: + - (io.Closer).Close + - fmt.Fprint + - fmt.Fprintln + - fmt.Fprintf + revive: + rules: + - name: exported + disabled: false + arguments: + - "disableStutteringCheck" + - name: package-comments + - name: var-naming + - name: indent-error-flow + - name: errorf + - name: context-as-argument + - name: context-keys-type + gosec: + excludes: + # G104 (errors unhandled) is already covered by errcheck with a + # better-tuned allowlist. + - G104 + exclusions: + rules: + - path: _test\.go + linters: + - errcheck + - gosec + - revive + +formatters: + enable: + - gofmt + - goimports + settings: + goimports: + local-prefixes: + - github.com/jeremyjsx/wallbit-go From 66e69364318ea376bf7d9b360dc83b4aa1f559d5 Mon Sep 17 00:00:00 2001 From: JeremyDevCode Date: Mon, 20 Apr 2026 15:14:59 -0500 Subject: [PATCH 03/17] feat(wallbit): jitter retry backoff and inject SDK version into User-Agent --- wallbit/options.go | 10 +++- wallbit/retry.go | 21 +++++++- wallbit/retry_test.go | 114 ++++++++++++++++++++++++++++++++++++++++ wallbit/version.go | 44 ++++++++++++++++ wallbit/version_test.go | 53 +++++++++++++++++++ 5 files changed, 240 insertions(+), 2 deletions(-) create mode 100644 wallbit/retry_test.go create mode 100644 wallbit/version.go create mode 100644 wallbit/version_test.go diff --git a/wallbit/options.go b/wallbit/options.go index f8e7f03..1321311 100644 --- a/wallbit/options.go +++ b/wallbit/options.go @@ -95,11 +95,19 @@ func defaultConfig() (*Config, error) { HTTPClient: &http.Client{ Timeout: 30 * time.Second, }, - UserAgent: "wallbit-go-sdk/0.1.0", + UserAgent: defaultUserAgent(), RetryPolicy: DefaultRetryPolicy(), }, nil } +// defaultUserAgent builds the canonical User-Agent used when the caller +// does not override it via [WithUserAgent]. The format is +// "wallbit-go-sdk/"; the version comes from [resolveVersion]. +// See [Version] for the resolution precedence. +func defaultUserAgent() string { + return "wallbit-go-sdk/" + resolveVersion() +} + // WithBaseURL overrides the default API base URL. By default only HTTPS is // accepted; pair with [WithInsecureHTTPForTesting] to allow HTTP for local // servers and [net/http/httptest] in tests. diff --git a/wallbit/retry.go b/wallbit/retry.go index c56cc6f..2a63c0d 100644 --- a/wallbit/retry.go +++ b/wallbit/retry.go @@ -2,6 +2,7 @@ package wallbit import ( "context" + "math/rand/v2" "net/http" "strconv" "strings" @@ -84,7 +85,25 @@ func (c *Client) retryWaitBeforeNextAttempt(res *http.Response, apiErr *Error, f } d = next } - return d + return jitter(d) +} + +// jitter applies equal jitter to a deterministic backoff duration so that +// many clients driven by the same retry schedule do not synchronize on the +// same wake-up instants (the classic thundering-herd problem when a +// dependency recovers from a 5xx incident). +// +// A zero or negative input returns zero. +func jitter(d time.Duration) time.Duration { + if d <= 0 { + return 0 + } + half := d / 2 + if half <= 0 { + return d + } + + return half + time.Duration(rand.Int64N(int64(half)+1)) } func (c *Client) maxAttempts() int { diff --git a/wallbit/retry_test.go b/wallbit/retry_test.go new file mode 100644 index 0000000..c54c702 --- /dev/null +++ b/wallbit/retry_test.go @@ -0,0 +1,114 @@ +package wallbit + +import ( + "testing" + "time" +) + +func TestJitterBounds(t *testing.T) { + t.Parallel() + + cases := []time.Duration{ + 1 * time.Millisecond, + 250 * time.Millisecond, + 2 * time.Second, + time.Hour, + } + // 1024 samples per bound is enough to exercise both extremes with very + // high probability without making the test slow or flaky. We assert the + // invariant (d/2 <= got <= d) rather than distributional properties so + // the test never flakes on a run that happens to sample the edges. + const samples = 1024 + + for _, d := range cases { + lo := d / 2 + for i := 0; i < samples; i++ { + got := jitter(d) + if got < lo || got > d { + t.Fatalf("d=%s: jitter returned %s, want within [%s, %s]", d, got, lo, d) + } + } + } +} + +func TestJitterZeroAndNegative(t *testing.T) { + t.Parallel() + + if got := jitter(0); got != 0 { + t.Fatalf("jitter(0) = %s, want 0", got) + } + if got := jitter(-5 * time.Second); got != 0 { + t.Fatalf("jitter(-5s) = %s, want 0", got) + } +} + +func TestJitterVariesAcrossCalls(t *testing.T) { + t.Parallel() + + // With d=1s and equal jitter, the theoretical range is [500ms, 1s] and + // the resolution of rand.Int64N(500_000_001) is nanoseconds; seeing the + // same value 32 times in a row would mean the jitter is effectively + // degenerate. This catches accidental regressions like swapping + // rand.Int64N for a constant or forgetting to apply jitter entirely. + const d = time.Second + seen := make(map[time.Duration]struct{}) + for i := 0; i < 32; i++ { + seen[jitter(d)] = struct{}{} + } + if len(seen) < 2 { + t.Fatalf("jitter appears degenerate: produced only %d unique values across 32 samples", len(seen)) + } +} + +func TestRetryWaitAppliesJitter(t *testing.T) { + t.Parallel() + + c := &Client{ + cfg: &Config{ + RetryPolicy: RetryPolicy{ + MaxAttempts: 4, + BaseDelay: 200 * time.Millisecond, + MaxDelay: 5 * time.Second, + }, + }, + } + + // The deterministic exponential schedule (before jitter) for these + // parameters is 200ms, 400ms, 800ms for failure indices 0, 1, 2. We + // assert each retry lives inside [d/2, d] and that MaxDelay still caps + // the upper bound on the final attempt. + expected := []time.Duration{ + 200 * time.Millisecond, + 400 * time.Millisecond, + 800 * time.Millisecond, + } + for i, d := range expected { + got := c.retryWaitBeforeNextAttempt(nil, nil, i) + lo := d / 2 + if got < lo || got > d { + t.Fatalf("attempt %d: got %s, want within [%s, %s]", i, got, lo, d) + } + } +} + +func TestRetryWaitRespectsRetryAfterWithoutJitter(t *testing.T) { + t.Parallel() + + c := &Client{ + cfg: &Config{ + RetryPolicy: RetryPolicy{ + MaxAttempts: 2, + BaseDelay: 200 * time.Millisecond, + MaxDelay: 10 * time.Second, + }, + }, + } + three := int64(3) + apiErr := &Error{RetryAfterSeconds: &three} + // Retry-After is an explicit contract from the server; jitter would + // undermine the operator's intent. We assert the value is returned + // exactly, bounded only by MaxDelay. + if got := c.retryWaitBeforeNextAttempt(nil, apiErr, 0); got != 3*time.Second { + t.Fatalf("Retry-After not honored as-is: got %s, want 3s", got) + } +} diff --git a/wallbit/version.go b/wallbit/version.go new file mode 100644 index 0000000..5e2a288 --- /dev/null +++ b/wallbit/version.go @@ -0,0 +1,44 @@ +package wallbit + +import ( + "runtime/debug" +) + +const modulePath = "github.com/jeremyjsx/wallbit-go" + +// Version identifies the SDK release advertised in the default User-Agent +// (see [defaultConfig]) and is the single source of truth that operators +// correlate with traffic in Wallbit's access logs. +// +// It is set at build time via linker flags, typically from CI at release +// time: +// +// go build -ldflags "-X github.com/jeremyjsx/wallbit-go/wallbit.Version=v1.2.3" +var Version = "" + +func resolveVersion() string { + if Version != "" { + return Version + } + info, ok := debug.ReadBuildInfo() + if !ok { + return "dev" + } + for _, dep := range info.Deps { + if dep == nil { + continue + } + if dep.Path == modulePath && dep.Version != "" { + return dep.Version + } + } + // info.Main describes the binary being built. When someone is running + // tests or `go run` inside this repository it reports "(devel)", which + // is useless for a User-Agent; only trust it when a real pseudo-version + // or tag is present (which happens for users who vendor the SDK into + // their own main module, or for release builds). + if info.Main.Path == modulePath && info.Main.Version != "" && info.Main.Version != "(devel)" { + return info.Main.Version + } + return "dev" +} diff --git a/wallbit/version_test.go b/wallbit/version_test.go new file mode 100644 index 0000000..b6f858d --- /dev/null +++ b/wallbit/version_test.go @@ -0,0 +1,53 @@ +package wallbit + +import ( + "strings" + "testing" +) + +func TestResolveVersionLdflagsOverrideWins(t *testing.T) { + prev := Version + t.Cleanup(func() { Version = prev }) + + Version = "v9.9.9" + if got := resolveVersion(); got != "v9.9.9" { + t.Fatalf("resolveVersion() = %q, want %q", got, "v9.9.9") + } +} + +func TestResolveVersionFallsBackToDevWhenUnset(t *testing.T) { + prev := Version + t.Cleanup(func() { Version = prev }) + + // When Version is empty, resolveVersion consults runtime/debug build + // info. Inside `go test` running in this repo the main module is + // modulePath with Main.Version == "(devel)", which the function + // explicitly rejects as unusable; we therefore expect the "dev" + // literal. This encodes the contract "never return an empty string", + // which matters because the value ships in an HTTP header. + Version = "" + got := resolveVersion() + if got == "" { + t.Fatal("resolveVersion() returned empty string; header would be malformed") + } + if got != "dev" { + t.Fatalf("resolveVersion() = %q in a devel build, want %q", got, "dev") + } +} + +func TestDefaultUserAgentEmbedsResolvedVersion(t *testing.T) { + prev := Version + t.Cleanup(func() { Version = prev }) + + Version = "v1.2.3" + ua := defaultUserAgent() + // Assert both the product token and the version segment so a future + // refactor that changes the format (e.g. adds a URL suffix) still + // catches a regression where the version is silently dropped. + if !strings.HasPrefix(ua, "wallbit-go-sdk/") { + t.Fatalf("User-Agent %q does not start with product token", ua) + } + if !strings.Contains(ua, "v1.2.3") { + t.Fatalf("User-Agent %q does not contain resolved version", ua) + } +} From ed84f27bc9c8a44e2c52eea1972cb7b146804e5c Mon Sep 17 00:00:00 2001 From: JeremyDevCode Date: Mon, 20 Apr 2026 15:23:56 -0500 Subject: [PATCH 04/17] feat(wallbit): bound response body size with io.LimitReader --- wallbit/doc.go | 2 +- wallbit/options.go | 24 ++++++++++++++ wallbit/retry.go | 12 +++++++ wallbit/wallbit.go | 25 ++++++++++++++- wallbit/wallbit_test.go | 70 +++++++++++++++++++++++++++++++++++++++++ 5 files changed, 131 insertions(+), 2 deletions(-) diff --git a/wallbit/doc.go b/wallbit/doc.go index 5c20734..f1ac437 100644 --- a/wallbit/doc.go +++ b/wallbit/doc.go @@ -7,7 +7,7 @@ // # Authentication // // All requests are authenticated using an API key sent in the X-API-Key -// header. Obtain one from the Wallbit dashboard under Settings → API Keys. +// header. Obtain one from the Wallbit dashboard under Agents → Create Agent. // // client, err := wallbit.NewClient(os.Getenv("WALLBIT_API_KEY")) // diff --git a/wallbit/options.go b/wallbit/options.go index 1321311..96f82f4 100644 --- a/wallbit/options.go +++ b/wallbit/options.go @@ -36,6 +36,12 @@ type Config struct { // AllowInsecureHTTPForTesting permits HTTP (non-TLS) base URLs. // Keep this false in production. AllowInsecureHTTPForTesting bool + // MaxResponseBytes caps the number of bytes the client reads from any + // HTTP response body. When a response exceeds this bound the client + // returns [ErrResponseTooLarge] instead of a partial payload, so a + // hostile or buggy upstream cannot exhaust process memory. Zero or + // negative values select the default (see [DefaultMaxResponseBytes]). + MaxResponseBytes int64 } // RetryPolicy controls how the [Client] retries idempotent requests on @@ -186,6 +192,21 @@ func WithUserAgent(userAgent string) Option { } } +// WithMaxResponseBytes overrides the default cap on HTTP response body +// size. The client reads up to n bytes from res.Body and returns +// [ErrResponseTooLarge] if the server sends more, without decoding the +// partial payload. Values <= 0 are ignored so that [DefaultMaxResponseBytes] +// remains in effect; pass a very large value if you legitimately need to +// consume multi-gigabyte responses. +func WithMaxResponseBytes(n int64) Option { + return func(cfg *Config) error { + if n > 0 { + cfg.MaxResponseBytes = n + } + return nil + } +} + // WithRetryPolicy sets automatic retries for idempotent methods (GET, HEAD, // DELETE, OPTIONS, TRACE) on transport failures and on retryable API // responses (HTTP 429 and 5xx; see [IsRetryable]). POST, PATCH and PUT are @@ -224,6 +245,9 @@ func mergeClientConfig(cfg *Config) (*Config, error) { if cfg.RetryPolicy.MaxAttempts > 0 || cfg.RetryPolicy.BaseDelay > 0 || cfg.RetryPolicy.MaxDelay > 0 { out.RetryPolicy = cfg.RetryPolicy } + if cfg.MaxResponseBytes > 0 { + out.MaxResponseBytes = cfg.MaxResponseBytes + } out.Hook = cfg.Hook out.AllowInsecureHTTPForTesting = cfg.AllowInsecureHTTPForTesting return out, nil diff --git a/wallbit/retry.go b/wallbit/retry.go index 2a63c0d..2c92262 100644 --- a/wallbit/retry.go +++ b/wallbit/retry.go @@ -113,3 +113,15 @@ func (c *Client) maxAttempts() int { } return n } + +// maxResponseBytes returns the effective body-size cap for this client, +// falling back to [DefaultMaxResponseBytes] when the configuration does +// not specify a positive value. Centralizing the default here keeps the +// read path in [Client.do] free of branching and makes it trivial for +// tests to inject a small limit via [WithMaxResponseBytes]. +func (c *Client) maxResponseBytes() int64 { + if c.cfg.MaxResponseBytes > 0 { + return c.cfg.MaxResponseBytes + } + return DefaultMaxResponseBytes +} diff --git a/wallbit/wallbit.go b/wallbit/wallbit.go index 66fad4b..faa3a36 100644 --- a/wallbit/wallbit.go +++ b/wallbit/wallbit.go @@ -4,6 +4,7 @@ import ( "context" "encoding/json" "errors" + "fmt" "io" "net/http" "strings" @@ -25,6 +26,18 @@ import ( var ErrMissingAPIKey = errors.New("wallbit client requires a non-empty api key") +// ErrResponseTooLarge is returned by the client when an HTTP response body +// exceeds the configured byte cap (see [Config.MaxResponseBytes] and +// [WithMaxResponseBytes]). The partial payload is discarded because a +// truncated body cannot be distinguished from a well-formed short one by +// the JSON decoder. +var ErrResponseTooLarge = errors.New("wallbit client: response body exceeds configured size limit") + +// DefaultMaxResponseBytes is the cap applied to HTTP response bodies when +// neither [Config.MaxResponseBytes] nor [WithMaxResponseBytes] supplies a +// positive value. +const DefaultMaxResponseBytes int64 = 10 << 20 + type Client struct { apiKey string cfg *Config @@ -197,7 +210,13 @@ func (c *Client) do(req *http.Request, dest any) (*transport.Metadata, error) { return nil, err } - body, rerr := io.ReadAll(res.Body) + limit := c.maxResponseBytes() + // Reading one byte past the limit lets us detect overflow without a + // second syscall: io.ReadAll returns cleanly when the LimitReader + // reaches EOF, and we compare lengths after the fact. A plain + // LimitReader of exactly `limit` would silently truncate instead of + // signaling overflow. + body, rerr := io.ReadAll(io.LimitReader(res.Body, limit+1)) res.Body.Close() if rerr != nil { return nil, rerr @@ -210,6 +229,10 @@ func (c *Client) do(req *http.Request, dest any) (*transport.Metadata, error) { RequestID: requestID, } + if int64(len(body)) > limit { + return meta, fmt.Errorf("%w: limit %d bytes", ErrResponseTooLarge, limit) + } + if statusCode >= 400 { apiErr := ErrorFromHTTP(statusCode, requestID, body) if attempt < max-1 && isIdempotentHTTPMethod(req.Method) && IsRetryable(apiErr) { diff --git a/wallbit/wallbit_test.go b/wallbit/wallbit_test.go index 00f5f76..e399be8 100644 --- a/wallbit/wallbit_test.go +++ b/wallbit/wallbit_test.go @@ -5,6 +5,7 @@ import ( "errors" "net/http" "net/http/httptest" + "strings" "testing" "time" ) @@ -88,3 +89,72 @@ func TestClientBlocksCrossHostRedirect(t *testing.T) { t.Fatal("expected error due to blocked cross-host redirect") } } + +func TestClientEnforcesMaxResponseBytes(t *testing.T) { + t.Parallel() + + const limit = 128 + big := `{"data":[` + strings.Repeat(`"x",`, limit) + `"x"]}` + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.Header().Set("X-Request-ID", "req-too-large") + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte(big)) + })) + defer server.Close() + + c, err := NewClient( + "test-key", + WithBaseURL(server.URL), + WithInsecureHTTPForTesting(), + WithRetryPolicy(RetryPolicy{MaxAttempts: 1, BaseDelay: time.Millisecond, MaxDelay: 10 * time.Millisecond}), + WithMaxResponseBytes(limit), + ) + if err != nil { + t.Fatalf("unexpected client construction error: %v", err) + } + + _, err = c.Balance.GetChecking(context.Background()) + if !errors.Is(err, ErrResponseTooLarge) { + t.Fatalf("expected ErrResponseTooLarge, got %v", err) + } +} + +func TestClientAcceptsResponseUpToMaxResponseBytes(t *testing.T) { + t.Parallel() + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte(`{"data":[]}`)) + })) + defer server.Close() + + c, err := NewClient( + "test-key", + WithBaseURL(server.URL), + WithInsecureHTTPForTesting(), + WithRetryPolicy(RetryPolicy{MaxAttempts: 1, BaseDelay: time.Millisecond, MaxDelay: 10 * time.Millisecond}), + WithMaxResponseBytes(64), + ) + if err != nil { + t.Fatalf("unexpected client construction error: %v", err) + } + + if _, err := c.Balance.GetChecking(context.Background()); err != nil { + t.Fatalf("unexpected error on small response: %v", err) + } +} + +func TestClientUsesDefaultMaxResponseBytesWhenUnset(t *testing.T) { + t.Parallel() + + c, err := NewClient("test-key") + if err != nil { + t.Fatalf("unexpected client construction error: %v", err) + } + if got := c.maxResponseBytes(); got != DefaultMaxResponseBytes { + t.Fatalf("maxResponseBytes() = %d, want %d", got, DefaultMaxResponseBytes) + } +} From 8acfb729a4395b68c89d00aeffcad5e2341749e9 Mon Sep 17 00:00:00 2001 From: JeremyDevCode Date: Mon, 20 Apr 2026 15:34:14 -0500 Subject: [PATCH 05/17] feat(wallbit): add Ptr helper, Attempt field on hook meta, and for-range loops --- wallbit/options.go | 16 ++++++- wallbit/ptr.go | 18 ++++++++ wallbit/ptr_test.go | 55 ++++++++++++++++++++++++ wallbit/retry.go | 2 +- wallbit/retry_test.go | 4 +- wallbit/wallbit.go | 11 +++-- wallbit/wallbit_test.go | 92 +++++++++++++++++++++++++++++++++++++++-- 7 files changed, 186 insertions(+), 12 deletions(-) create mode 100644 wallbit/ptr.go create mode 100644 wallbit/ptr_test.go diff --git a/wallbit/options.go b/wallbit/options.go index 96f82f4..c4dd033 100644 --- a/wallbit/options.go +++ b/wallbit/options.go @@ -77,17 +77,29 @@ type Hook interface { // RequestMeta is the context passed to [Hook.OnRequestStart] for a single // HTTP attempt. Path is the URL path (without the base URL or query string). +// +// Attempt is 1-indexed: Attempt == 1 is the original request, Attempt == 2 +// is the first retry, and so on up to [RetryPolicy.MaxAttempts]. Hooks use +// this to distinguish retries from first attempts when emitting metrics; +// mixing the two into a single latency histogram hides the backoff cost +// and produces misleading p99 numbers. type RequestMeta struct { - Method string - Path string + Method string + Path string + Attempt int } // ResponseMeta is the context passed to [Hook.OnRequestDone] for a single // HTTP attempt. StatusCode is 0 when the transport returned an error before // receiving a response. +// +// Attempt mirrors [RequestMeta.Attempt] so a hook holding only the +// response meta can still tag metrics with the attempt number without +// correlating callbacks. type ResponseMeta struct { StatusCode int Duration time.Duration + Attempt int } func defaultConfig() (*Config, error) { diff --git a/wallbit/ptr.go b/wallbit/ptr.go new file mode 100644 index 0000000..345411d --- /dev/null +++ b/wallbit/ptr.go @@ -0,0 +1,18 @@ +package wallbit + +// Ptr returns a pointer to its argument. It exists because request bodies +// for the Wallbit API use pointer fields to distinguish "unset" from +// "explicit zero value". +// Without a helper, callers have +// to introduce a throwaway local for every optional field: +// +// name := "Jeremy" +// req := UpdateProfile{Name: &name} +// +// which scales poorly when a request has several optional fields. With +// the generic helper the same code collapses to: +// +// req := UpdateProfile{Name: wallbit.Ptr("Jeremy"), Age: wallbit.Ptr(28)} +func Ptr[T any](v T) *T { + return &v +} diff --git a/wallbit/ptr_test.go b/wallbit/ptr_test.go new file mode 100644 index 0000000..6f97300 --- /dev/null +++ b/wallbit/ptr_test.go @@ -0,0 +1,55 @@ +package wallbit + +import ( + "testing" + "time" +) + +func TestPtrReturnsAddressOfCopy(t *testing.T) { + t.Parallel() + + src := "jeremy" + p := Ptr(src) + if p == nil || *p != "jeremy" { + t.Fatalf("Ptr(%q) dereferenced to %v", src, p) + } + src = "mutated" + if *p != "jeremy" { + t.Fatalf("Ptr captured caller's variable by alias: got %q after mutation", *p) + } +} + +func TestPtrPreservesTypeForNumericAndStruct(t *testing.T) { + t.Parallel() + + if got := *Ptr(42); got != 42 { + t.Fatalf("Ptr(int) = %d, want 42", got) + } + if got := *Ptr(3.14); got != 3.14 { + t.Fatalf("Ptr(float64) = %v, want 3.14", got) + } + + type address struct { + Street string + City string + Zip string + } + a := address{Street: "Av. Corrientes", City: "Buenos Aires", Zip: "C1043"} + p := Ptr(a) + if p == nil || *p != a { + t.Fatalf("Ptr(struct) = %+v, want %+v", p, a) + } +} + +func TestPtrWithZeroValues(t *testing.T) { + t.Parallel() + + empty := Ptr("") + if empty == nil || *empty != "" { + t.Fatalf("Ptr(\"\") returned %v, want non-nil pointer to empty string", empty) + } + zero := Ptr(time.Duration(0)) + if zero == nil || *zero != 0 { + t.Fatalf("Ptr(0) returned %v, want non-nil pointer to zero duration", zero) + } +} diff --git a/wallbit/retry.go b/wallbit/retry.go index 2c92262..480fa5b 100644 --- a/wallbit/retry.go +++ b/wallbit/retry.go @@ -77,7 +77,7 @@ func (c *Client) retryWaitBeforeNextAttempt(res *http.Response, apiErr *Error, f } d := base - for i := 0; i < failureIndex; i++ { + for range failureIndex { next := d * 2 if next > maxD { d = maxD diff --git a/wallbit/retry_test.go b/wallbit/retry_test.go index c54c702..1ee44e4 100644 --- a/wallbit/retry_test.go +++ b/wallbit/retry_test.go @@ -22,7 +22,7 @@ func TestJitterBounds(t *testing.T) { for _, d := range cases { lo := d / 2 - for i := 0; i < samples; i++ { + for range samples { got := jitter(d) if got < lo || got > d { t.Fatalf("d=%s: jitter returned %s, want within [%s, %s]", d, got, lo, d) @@ -52,7 +52,7 @@ func TestJitterVariesAcrossCalls(t *testing.T) { // rand.Int64N for a constant or forgetting to apply jitter entirely. const d = time.Second seen := make(map[time.Duration]struct{}) - for i := 0; i < 32; i++ { + for range 32 { seen[jitter(d)] = struct{}{} } if len(seen) < 2 { diff --git a/wallbit/wallbit.go b/wallbit/wallbit.go index faa3a36..e72995b 100644 --- a/wallbit/wallbit.go +++ b/wallbit/wallbit.go @@ -177,14 +177,19 @@ func (c *Client) do(req *http.Request, dest any) (*transport.Metadata, error) { ctx := req.Context() max := c.maxAttempts() - for attempt := 0; attempt < max; attempt++ { + // attempt is 0-indexed because the retry bookkeeping (failureIndex in + // retryWaitBeforeNextAttempt) expects a 0-based counter; the + // user-visible Attempt field on RequestMeta/ResponseMeta is derived by + // adding one so hooks see a natural "attempt 1, 2, 3" sequence. + for attempt := range max { + attemptNumber := attempt + 1 reqTry := req.Clone(ctx) if h := c.cfg.Hook; h != nil { path := "" if reqTry.URL != nil { path = reqTry.URL.Path } - h.OnRequestStart(&RequestMeta{Method: reqTry.Method, Path: path}) + h.OnRequestStart(&RequestMeta{Method: reqTry.Method, Path: path, Attempt: attemptNumber}) } start := time.Now() @@ -196,7 +201,7 @@ func (c *Client) do(req *http.Request, dest any) (*transport.Metadata, error) { statusCode = res.StatusCode } if h := c.cfg.Hook; h != nil { - h.OnRequestDone(&ResponseMeta{StatusCode: statusCode, Duration: dur}) + h.OnRequestDone(&ResponseMeta{StatusCode: statusCode, Duration: dur, Attempt: attemptNumber}) } if err != nil { diff --git a/wallbit/wallbit_test.go b/wallbit/wallbit_test.go index e399be8..9aece32 100644 --- a/wallbit/wallbit_test.go +++ b/wallbit/wallbit_test.go @@ -11,12 +11,22 @@ import ( ) type testHook struct { - started int - done int + started int + done int + startAttempts []int + doneAttempts []int + doneStatusCodes []int } -func (h *testHook) OnRequestStart(*RequestMeta) { h.started++ } -func (h *testHook) OnRequestDone(*ResponseMeta) { h.done++ } +func (h *testHook) OnRequestStart(m *RequestMeta) { + h.started++ + h.startAttempts = append(h.startAttempts, m.Attempt) +} +func (h *testHook) OnRequestDone(m *ResponseMeta) { + h.done++ + h.doneAttempts = append(h.doneAttempts, m.Attempt) + h.doneStatusCodes = append(h.doneStatusCodes, m.StatusCode) +} func TestNewClientAndOptions(t *testing.T) { t.Parallel() @@ -50,6 +60,80 @@ func TestNewClientAndOptions(t *testing.T) { if hook.started != 1 || hook.done != 1 { t.Fatalf("unexpected hook counters: started=%d done=%d", hook.started, hook.done) } + // A successful single-attempt request must surface Attempt=1 (1-indexed) + // on both callbacks; regressions to the legacy zero-value would be + // indistinguishable from an unset field in user code. + if len(hook.startAttempts) != 1 || hook.startAttempts[0] != 1 { + t.Fatalf("OnRequestStart attempts: got %v, want [1]", hook.startAttempts) + } + if len(hook.doneAttempts) != 1 || hook.doneAttempts[0] != 1 { + t.Fatalf("OnRequestDone attempts: got %v, want [1]", hook.doneAttempts) + } +} + +func TestHookSeesAttemptIncrementingAcrossRetries(t *testing.T) { + t.Parallel() + + // The server returns 503 on the first two calls and 200 on the third, + // driving the client through three hook cycles. This exercises the + // full retry path including the 5xx branch so we confirm the attempt + // counter is emitted on BOTH the error-response bookkeeping and the + // final success, not just the success path. + var hits int + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + hits++ + if hits < 3 { + w.WriteHeader(http.StatusServiceUnavailable) + return + } + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte(`{"data":[]}`)) + })) + defer server.Close() + + hook := &testHook{} + c, err := NewClient( + "test-key", + WithBaseURL(server.URL), + WithInsecureHTTPForTesting(), + WithRetryPolicy(RetryPolicy{MaxAttempts: 3, BaseDelay: time.Millisecond, MaxDelay: 2 * time.Millisecond}), + WithHook(hook), + ) + if err != nil { + t.Fatalf("unexpected client construction error: %v", err) + } + + if _, err := c.Balance.GetChecking(context.Background()); err != nil { + t.Fatalf("unexpected error after retry recovery: %v", err) + } + + want := []int{1, 2, 3} + if !slicesEqual(hook.startAttempts, want) { + t.Fatalf("OnRequestStart attempts: got %v, want %v", hook.startAttempts, want) + } + if !slicesEqual(hook.doneAttempts, want) { + t.Fatalf("OnRequestDone attempts: got %v, want %v", hook.doneAttempts, want) + } + // Status codes cross-check that attempt 3 is the one that observed + // 200, confirming we count attempts over the actual retry loop rather + // than emitting a static sequence. + wantCodes := []int{503, 503, 200} + if !slicesEqual(hook.doneStatusCodes, wantCodes) { + t.Fatalf("OnRequestDone status codes: got %v, want %v", hook.doneStatusCodes, wantCodes) + } +} + +func slicesEqual(a, b []int) bool { + if len(a) != len(b) { + return false + } + for i := range a { + if a[i] != b[i] { + return false + } + } + return true } func TestWithBaseURLRejectsHTTPByDefault(t *testing.T) { From 1f3f64f8f3856156d4dcdd3f6b2a51583b21ee63 Mon Sep 17 00:00:00 2001 From: JeremyDevCode Date: Mon, 20 Apr 2026 16:07:07 -0500 Subject: [PATCH 06/17] refactor(transport): centralize JSON encode/decode in SendJSON helper --- services/accountdetails/service.go | 7 +- services/apikey/service.go | 7 +- services/assets/service.go | 14 +-- services/balance/service.go | 14 +-- services/cards/service.go | 21 +---- services/fees/service.go | 13 +-- services/operations/service.go | 14 +-- services/roboadvisor/service.go | 33 +------- services/trades/service.go | 14 +-- services/transactions/service.go | 8 +- services/wallets/service.go | 7 +- transport/json.go | 27 ++++++ transport/json_test.go | 131 +++++++++++++++++++++++++++++ 13 files changed, 174 insertions(+), 136 deletions(-) create mode 100644 transport/json.go create mode 100644 transport/json_test.go diff --git a/services/accountdetails/service.go b/services/accountdetails/service.go index 4c13833..c3998e4 100644 --- a/services/accountdetails/service.go +++ b/services/accountdetails/service.go @@ -74,10 +74,5 @@ func (s *Service) Get(ctx context.Context, req *GetRequest) (*transport.Response } } - out := &GetResponse{} - meta, err := s.sender.Send(ctx, http.MethodGet, path, nil, out) - if err != nil { - return nil, err - } - return transport.NewResponse(meta, out), nil + return transport.SendJSON(ctx, s.sender, http.MethodGet, path, nil, &GetResponse{}) } diff --git a/services/apikey/service.go b/services/apikey/service.go index dd4682e..08a88db 100644 --- a/services/apikey/service.go +++ b/services/apikey/service.go @@ -22,10 +22,5 @@ type RevokeResponse struct { } func (s *Service) Revoke(ctx context.Context) (*transport.Response[RevokeResponse], error) { - out := &RevokeResponse{} - meta, err := s.sender.Send(ctx, http.MethodDelete, revokePath, nil, out) - if err != nil { - return nil, err - } - return transport.NewResponse(meta, out), nil + return transport.SendJSON(ctx, s.sender, http.MethodDelete, revokePath, nil, &RevokeResponse{}) } diff --git a/services/assets/service.go b/services/assets/service.go index 97cf793..77bb041 100644 --- a/services/assets/service.go +++ b/services/assets/service.go @@ -72,12 +72,7 @@ func (s *Service) Get(ctx context.Context, symbol string) (*transport.Response[G return nil, ErrEmptySymbol } path := fmt.Sprintf("%s/%s", listPath, url.PathEscape(symbol)) - out := &GetResponse{} - meta, err := s.sender.Send(ctx, http.MethodGet, path, nil, out) - if err != nil { - return nil, err - } - return transport.NewResponse(meta, out), nil + return transport.SendJSON(ctx, s.sender, http.MethodGet, path, nil, &GetResponse{}) } func (s *Service) List(ctx context.Context, req *ListRequest) (*transport.Response[ListResponse], error) { @@ -101,10 +96,5 @@ func (s *Service) List(ctx context.Context, req *ListRequest) (*transport.Respon } } - out := &ListResponse{} - meta, err := s.sender.Send(ctx, http.MethodGet, path, nil, out) - if err != nil { - return nil, err - } - return transport.NewResponse(meta, out), nil + return transport.SendJSON(ctx, s.sender, http.MethodGet, path, nil, &ListResponse{}) } diff --git a/services/balance/service.go b/services/balance/service.go index 8a91d7a..d039cc2 100644 --- a/services/balance/service.go +++ b/services/balance/service.go @@ -39,19 +39,9 @@ type StocksBalanceResponse struct { } func (s *Service) GetChecking(ctx context.Context) (*transport.Response[CheckingBalanceResponse], error) { - out := &CheckingBalanceResponse{} - meta, err := s.sender.Send(ctx, http.MethodGet, checkingPath, nil, out) - if err != nil { - return nil, err - } - return transport.NewResponse(meta, out), nil + return transport.SendJSON(ctx, s.sender, http.MethodGet, checkingPath, nil, &CheckingBalanceResponse{}) } func (s *Service) GetStocks(ctx context.Context) (*transport.Response[StocksBalanceResponse], error) { - out := &StocksBalanceResponse{} - meta, err := s.sender.Send(ctx, http.MethodGet, stocksPath, nil, out) - if err != nil { - return nil, err - } - return transport.NewResponse(meta, out), nil + return transport.SendJSON(ctx, s.sender, http.MethodGet, stocksPath, nil, &StocksBalanceResponse{}) } diff --git a/services/cards/service.go b/services/cards/service.go index a56e6a1..048b858 100644 --- a/services/cards/service.go +++ b/services/cards/service.go @@ -1,9 +1,7 @@ package cards import ( - "bytes" "context" - "encoding/json" "errors" "fmt" "net/http" @@ -58,12 +56,7 @@ type UpdateStatusResponse struct { } func (s *Service) List(ctx context.Context) (*transport.Response[ListResponse], error) { - out := &ListResponse{} - meta, err := s.sender.Send(ctx, http.MethodGet, listPath, nil, out) - if err != nil { - return nil, err - } - return transport.NewResponse(meta, out), nil + return transport.SendJSON(ctx, s.sender, http.MethodGet, listPath, nil, &ListResponse{}) } func (s *Service) Block(ctx context.Context, cardUUID string) (*transport.Response[UpdateStatusResponse], error) { @@ -78,16 +71,6 @@ func (s *Service) updateStatus(ctx context.Context, cardUUID string, status stri if strings.TrimSpace(cardUUID) == "" { return nil, ErrEmptyCardUUID } - payload, err := json.Marshal(updateStatusRequest{Status: status}) - if err != nil { - return nil, err - } - - out := &UpdateStatusResponse{} path := fmt.Sprintf(updateStatusPathFormat, cardUUID) - meta, err := s.sender.Send(ctx, http.MethodPatch, path, bytes.NewBuffer(payload), out) - if err != nil { - return nil, err - } - return transport.NewResponse(meta, out), nil + return transport.SendJSON(ctx, s.sender, http.MethodPatch, path, updateStatusRequest{Status: status}, &UpdateStatusResponse{}) } diff --git a/services/fees/service.go b/services/fees/service.go index fc4eeed..bd6911b 100644 --- a/services/fees/service.go +++ b/services/fees/service.go @@ -71,16 +71,5 @@ type GetResponse struct { } func (s *Service) Get(ctx context.Context, req GetRequest) (*transport.Response[GetResponse], error) { - payload, err := json.Marshal(req) - if err != nil { - return nil, err - } - - out := &GetResponse{} - meta, err := s.sender.Send(ctx, http.MethodPost, getPath, bytes.NewBuffer(payload), out) - if err != nil { - return nil, err - } - - return transport.NewResponse(meta, out), nil + return transport.SendJSON(ctx, s.sender, http.MethodPost, getPath, req, &GetResponse{}) } diff --git a/services/operations/service.go b/services/operations/service.go index 61fd30a..e1e4d42 100644 --- a/services/operations/service.go +++ b/services/operations/service.go @@ -1,9 +1,7 @@ package operations import ( - "bytes" "context" - "encoding/json" "net/http" "github.com/jeremyjsx/wallbit-go/transport" @@ -64,17 +62,7 @@ type InternalResponse struct { } func (s *Service) Internal(ctx context.Context, req InternalRequest) (*transport.Response[InternalResponse], error) { - payload, err := json.Marshal(req) - if err != nil { - return nil, err - } - - out := &InternalResponse{} - meta, err := s.sender.Send(ctx, http.MethodPost, internalPath, bytes.NewBuffer(payload), out) - if err != nil { - return nil, err - } - return transport.NewResponse(meta, out), nil + return transport.SendJSON(ctx, s.sender, http.MethodPost, internalPath, req, &InternalResponse{}) } func (s *Service) DepositInvestment(ctx context.Context, req InvestmentDepositRequest) (*transport.Response[InternalResponse], error) { diff --git a/services/roboadvisor/service.go b/services/roboadvisor/service.go index 529f8a3..3f216b5 100644 --- a/services/roboadvisor/service.go +++ b/services/roboadvisor/service.go @@ -1,9 +1,7 @@ package roboadvisor import ( - "bytes" "context" - "encoding/json" "net/http" "github.com/jeremyjsx/wallbit-go/transport" @@ -106,38 +104,13 @@ type WithdrawResponse struct { } func (s *Service) GetBalance(ctx context.Context) (*transport.Response[GetBalanceResponse], error) { - out := &GetBalanceResponse{} - meta, err := s.sender.Send(ctx, http.MethodGet, balancePath, nil, out) - if err != nil { - return nil, err - } - return transport.NewResponse(meta, out), nil + return transport.SendJSON(ctx, s.sender, http.MethodGet, balancePath, nil, &GetBalanceResponse{}) } func (s *Service) Deposit(ctx context.Context, req DepositRequest) (*transport.Response[DepositResponse], error) { - payload, err := json.Marshal(req) - if err != nil { - return nil, err - } - - out := &DepositResponse{} - meta, err := s.sender.Send(ctx, http.MethodPost, depositPath, bytes.NewBuffer(payload), out) - if err != nil { - return nil, err - } - return transport.NewResponse(meta, out), nil + return transport.SendJSON(ctx, s.sender, http.MethodPost, depositPath, req, &DepositResponse{}) } func (s *Service) Withdraw(ctx context.Context, req WithdrawRequest) (*transport.Response[WithdrawResponse], error) { - payload, err := json.Marshal(req) - if err != nil { - return nil, err - } - - out := &WithdrawResponse{} - meta, err := s.sender.Send(ctx, http.MethodPost, withdrawPath, bytes.NewBuffer(payload), out) - if err != nil { - return nil, err - } - return transport.NewResponse(meta, out), nil + return transport.SendJSON(ctx, s.sender, http.MethodPost, withdrawPath, req, &WithdrawResponse{}) } diff --git a/services/trades/service.go b/services/trades/service.go index 0d510da..7616ffc 100644 --- a/services/trades/service.go +++ b/services/trades/service.go @@ -1,9 +1,7 @@ package trades import ( - "bytes" "context" - "encoding/json" "net/http" "github.com/jeremyjsx/wallbit-go/transport" @@ -50,15 +48,5 @@ type CreateResponse struct { } func (s *Service) Create(ctx context.Context, req CreateRequest) (*transport.Response[CreateResponse], error) { - payload, err := json.Marshal(req) - if err != nil { - return nil, err - } - - out := &CreateResponse{} - meta, err := s.sender.Send(ctx, http.MethodPost, createPath, bytes.NewBuffer(payload), out) - if err != nil { - return nil, err - } - return transport.NewResponse(meta, out), nil + return transport.SendJSON(ctx, s.sender, http.MethodPost, createPath, req, &CreateResponse{}) } diff --git a/services/transactions/service.go b/services/transactions/service.go index 699a1a3..72472a8 100644 --- a/services/transactions/service.go +++ b/services/transactions/service.go @@ -97,11 +97,5 @@ func (s *Service) List(ctx context.Context, req *ListRequest) (*transport.Respon } } - out := &ListResponse{} - meta, err := s.sender.Send(ctx, http.MethodGet, path, nil, out) - if err != nil { - return nil, err - } - - return transport.NewResponse(meta, out), nil + return transport.SendJSON(ctx, s.sender, http.MethodGet, path, nil, &ListResponse{}) } diff --git a/services/wallets/service.go b/services/wallets/service.go index b253652..64f9133 100644 --- a/services/wallets/service.go +++ b/services/wallets/service.go @@ -48,10 +48,5 @@ func (s *Service) Get(ctx context.Context, req *GetRequest) (*transport.Response } } - out := &GetResponse{} - meta, err := s.sender.Send(ctx, http.MethodGet, path, nil, out) - if err != nil { - return nil, err - } - return transport.NewResponse(meta, out), nil + return transport.SendJSON(ctx, s.sender, http.MethodGet, path, nil, &GetResponse{}) } diff --git a/transport/json.go b/transport/json.go new file mode 100644 index 0000000..389d53d --- /dev/null +++ b/transport/json.go @@ -0,0 +1,27 @@ +package transport + +import ( + "bytes" + "context" + "encoding/json" + "io" +) + +// SendJSON marshals req (when non-nil) and forwards the call to sender, +// wrapping the result into a [*Response][T] with the decoded dest. Pass +// a literal nil for req on GET/DELETE calls. T is inferred from dest. +func SendJSON[T any](ctx context.Context, sender Sender, method, path string, req any, dest *T) (*Response[T], error) { + var body io.Reader + if req != nil { + payload, err := json.Marshal(req) + if err != nil { + return nil, err + } + body = bytes.NewReader(payload) + } + meta, err := sender.Send(ctx, method, path, body, dest) + if err != nil { + return nil, err + } + return NewResponse(meta, dest), nil +} diff --git a/transport/json_test.go b/transport/json_test.go new file mode 100644 index 0000000..331d056 --- /dev/null +++ b/transport/json_test.go @@ -0,0 +1,131 @@ +package transport + +import ( + "context" + "encoding/json" + "errors" + "io" + "net/http" + "testing" +) + +type fakeSender struct { + gotCtx context.Context //nolint:containedctx // test stub mirrors the interface + gotMethod string + gotPath string + gotBody []byte + + respond func(dest any) (*Metadata, error) +} + +func (f *fakeSender) Send(ctx context.Context, method, path string, body io.Reader, dest any) (*Metadata, error) { + f.gotCtx = ctx + f.gotMethod = method + f.gotPath = path + if body != nil { + b, err := io.ReadAll(body) + if err != nil { + return nil, err + } + f.gotBody = b + } + if f.respond != nil { + return f.respond(dest) + } + return &Metadata{StatusCode: http.StatusOK}, nil +} + +type pingRequest struct { + Ping string `json:"ping"` +} + +type pingResponse struct { + Pong string `json:"pong"` +} + +func TestSendJSONSkipsBodyWhenRequestIsNil(t *testing.T) { + t.Parallel() + + f := &fakeSender{} + out := &pingResponse{} + + res, err := SendJSON(context.Background(), f, http.MethodGet, "/ping", nil, out) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if f.gotBody != nil { + t.Fatalf("expected nil body, got %q", f.gotBody) + } + if res == nil || res.Payload != out { + t.Fatalf("expected response to wrap the same dest pointer, got %+v", res) + } +} + +func TestSendJSONMarshalsNonNilRequest(t *testing.T) { + t.Parallel() + + const path = "/operations/internal" + f := &fakeSender{ + respond: func(dest any) (*Metadata, error) { + *(dest.(*pingResponse)) = pingResponse{Pong: "ok"} + return &Metadata{StatusCode: http.StatusOK, RequestID: "req-123"}, nil + }, + } + out := &pingResponse{} + + res, err := SendJSON(context.Background(), f, http.MethodPost, path, pingRequest{Ping: "hi"}, out) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if f.gotMethod != http.MethodPost || f.gotPath != path { + t.Fatalf("method/path: got %s %s, want POST %s", f.gotMethod, f.gotPath, path) + } + if string(f.gotBody) != `{"ping":"hi"}` { + t.Fatalf("body: got %q, want %q", f.gotBody, `{"ping":"hi"}`) + } + if res.Payload.Pong != "ok" { + t.Fatalf("response payload: got %+v, want Pong=ok", res.Payload) + } + if res.RequestID != "req-123" || res.StatusCode != http.StatusOK { + t.Fatalf("meta not propagated: got StatusCode=%d RequestID=%q", res.StatusCode, res.RequestID) + } +} + +func TestSendJSONReturnsMarshalErrorWithoutCallingSender(t *testing.T) { + t.Parallel() + + f := &fakeSender{ + respond: func(dest any) (*Metadata, error) { + t.Fatal("sender must not be invoked when marshal fails") + return nil, nil + }, + } + + _, err := SendJSON(context.Background(), f, http.MethodPost, "/x", make(chan int), &pingResponse{}) + if err == nil { + t.Fatal("expected marshal error, got nil") + } + var utErr *json.UnsupportedTypeError + if !errors.As(err, &utErr) { + t.Fatalf("expected *json.UnsupportedTypeError, got %T: %v", err, err) + } +} + +func TestSendJSONPropagatesSenderError(t *testing.T) { + t.Parallel() + + sentinel := errors.New("simulated transport failure") + f := &fakeSender{ + respond: func(dest any) (*Metadata, error) { + return nil, sentinel + }, + } + + res, err := SendJSON(context.Background(), f, http.MethodPost, "/x", pingRequest{Ping: "x"}, &pingResponse{}) + if !errors.Is(err, sentinel) { + t.Fatalf("expected sentinel error, got %v", err) + } + if res != nil { + t.Fatalf("expected nil response on sender failure, got %+v", res) + } +} From 464337b6e0e322b40882ea85c0bb8a0eb440cef7 Mon Sep 17 00:00:00 2001 From: JeremyDevCode Date: Mon, 20 Apr 2026 16:14:54 -0500 Subject: [PATCH 07/17] refactor(wallbit): extract hook and decode helpers from Client.do --- wallbit/wallbit.go | 61 +++++++++++++++++++++++++++++----------------- 1 file changed, 38 insertions(+), 23 deletions(-) diff --git a/wallbit/wallbit.go b/wallbit/wallbit.go index e72995b..5fabb42 100644 --- a/wallbit/wallbit.go +++ b/wallbit/wallbit.go @@ -177,20 +177,11 @@ func (c *Client) do(req *http.Request, dest any) (*transport.Metadata, error) { ctx := req.Context() max := c.maxAttempts() - // attempt is 0-indexed because the retry bookkeeping (failureIndex in - // retryWaitBeforeNextAttempt) expects a 0-based counter; the - // user-visible Attempt field on RequestMeta/ResponseMeta is derived by - // adding one so hooks see a natural "attempt 1, 2, 3" sequence. + // attempt is 0-indexed; hooks see the 1-indexed value via attemptNumber. for attempt := range max { attemptNumber := attempt + 1 reqTry := req.Clone(ctx) - if h := c.cfg.Hook; h != nil { - path := "" - if reqTry.URL != nil { - path = reqTry.URL.Path - } - h.OnRequestStart(&RequestMeta{Method: reqTry.Method, Path: path, Attempt: attemptNumber}) - } + c.emitRequestStart(reqTry, attemptNumber) start := time.Now() res, err := c.cfg.HTTPClient.Do(reqTry) @@ -200,9 +191,7 @@ func (c *Client) do(req *http.Request, dest any) (*transport.Metadata, error) { if res != nil { statusCode = res.StatusCode } - if h := c.cfg.Hook; h != nil { - h.OnRequestDone(&ResponseMeta{StatusCode: statusCode, Duration: dur, Attempt: attemptNumber}) - } + c.emitRequestDone(statusCode, dur, attemptNumber) if err != nil { if attempt < max-1 && isIdempotentHTTPMethod(req.Method) { @@ -216,11 +205,6 @@ func (c *Client) do(req *http.Request, dest any) (*transport.Metadata, error) { } limit := c.maxResponseBytes() - // Reading one byte past the limit lets us detect overflow without a - // second syscall: io.ReadAll returns cleanly when the LimitReader - // reaches EOF, and we compare lengths after the fact. A plain - // LimitReader of exactly `limit` would silently truncate instead of - // signaling overflow. body, rerr := io.ReadAll(io.LimitReader(res.Body, limit+1)) res.Body.Close() if rerr != nil { @@ -250,10 +234,7 @@ func (c *Client) do(req *http.Request, dest any) (*transport.Metadata, error) { return meta, apiErr } - if dest == nil || len(body) == 0 || statusCode == http.StatusNoContent { - return meta, nil - } - if err := json.Unmarshal(body, dest); err != nil { + if err := decodeBody(body, dest, statusCode); err != nil { return meta, err } return meta, nil @@ -261,6 +242,40 @@ func (c *Client) do(req *http.Request, dest any) (*transport.Metadata, error) { return nil, errors.New("wallbit client: internal error: retry loop exited without return") } +// emitRequestStart fires the OnRequestStart hook when one is configured. +// Centralized so do() doesn't carry hook plumbing inline. +func (c *Client) emitRequestStart(req *http.Request, attempt int) { + h := c.cfg.Hook + if h == nil { + return + } + path := "" + if req.URL != nil { + path = req.URL.Path + } + h.OnRequestStart(&RequestMeta{Method: req.Method, Path: path, Attempt: attempt}) +} + +// emitRequestDone fires the OnRequestDone hook when one is configured. +func (c *Client) emitRequestDone(statusCode int, dur time.Duration, attempt int) { + h := c.cfg.Hook + if h == nil { + return + } + h.OnRequestDone(&ResponseMeta{StatusCode: statusCode, Duration: dur, Attempt: attempt}) +} + +// decodeBody unmarshals body into dest unless the response carries no +// payload to decode (nil dest, empty body, or 204 No Content). io.ReadAll +// already returned a usable slice when the LimitReader hit EOF, so a +// length check is sufficient to cover both empty bodies and 204s. +func decodeBody(body []byte, dest any, statusCode int) error { + if dest == nil || len(body) == 0 || statusCode == http.StatusNoContent { + return nil + } + return json.Unmarshal(body, dest) +} + type senderAdapter struct { client *Client } From 34d91917ccd1e049699456afd53a741b96c15ccb Mon Sep 17 00:00:00 2001 From: JeremyDevCode Date: Mon, 20 Apr 2026 16:52:15 -0500 Subject: [PATCH 08/17] test: add Example funcs per package and ErrorFromHTTP fuzz --- services/accountdetails/example_test.go | 28 ++++++++++ services/apikey/example_test.go | 25 +++++++++ services/assets/example_test.go | 44 ++++++++++++++++ services/balance/example_test.go | 44 ++++++++++++++++ services/cards/example_test.go | 42 +++++++++++++++ services/fees/example_test.go | 30 +++++++++++ services/operations/example_test.go | 30 +++++++++++ services/roboadvisor/example_test.go | 46 ++++++++++++++++ services/trades/example_test.go | 31 +++++++++++ services/transactions/example_test.go | 30 +++++++++++ services/wallets/example_test.go | 29 ++++++++++ wallbit/errors_fuzz_test.go | 70 +++++++++++++++++++++++++ wallbit/example_test.go | 59 +++++++++++++++++++++ 13 files changed, 508 insertions(+) create mode 100644 services/accountdetails/example_test.go create mode 100644 services/apikey/example_test.go create mode 100644 services/assets/example_test.go create mode 100644 services/balance/example_test.go create mode 100644 services/cards/example_test.go create mode 100644 services/fees/example_test.go create mode 100644 services/operations/example_test.go create mode 100644 services/roboadvisor/example_test.go create mode 100644 services/trades/example_test.go create mode 100644 services/transactions/example_test.go create mode 100644 services/wallets/example_test.go create mode 100644 wallbit/errors_fuzz_test.go create mode 100644 wallbit/example_test.go diff --git a/services/accountdetails/example_test.go b/services/accountdetails/example_test.go new file mode 100644 index 0000000..b03cf90 --- /dev/null +++ b/services/accountdetails/example_test.go @@ -0,0 +1,28 @@ +package accountdetails_test + +import ( + "context" + "fmt" + "log" + + "github.com/jeremyjsx/wallbit-go/services/accountdetails" + "github.com/jeremyjsx/wallbit-go/wallbit" +) + +func ExampleService_Get() { + client, err := wallbit.NewClient("YOUR_API_KEY") + if err != nil { + log.Fatal(err) + } + + var svc *accountdetails.Service = client.AccountDetails + res, err := svc.Get(context.Background(), &accountdetails.GetRequest{ + Country: accountdetails.CountryUS, + Currency: accountdetails.CurrencyUSD, + }) + if err != nil { + log.Fatal(err) + } + + fmt.Printf("Bank: %s, Holder: %s\n", res.Payload.Data.BankName, res.Payload.Data.HolderName) +} diff --git a/services/apikey/example_test.go b/services/apikey/example_test.go new file mode 100644 index 0000000..f0e8cbe --- /dev/null +++ b/services/apikey/example_test.go @@ -0,0 +1,25 @@ +package apikey_test + +import ( + "context" + "fmt" + "log" + + "github.com/jeremyjsx/wallbit-go/services/apikey" + "github.com/jeremyjsx/wallbit-go/wallbit" +) + +func ExampleService_Revoke() { + client, err := wallbit.NewClient("YOUR_API_KEY") + if err != nil { + log.Fatal(err) + } + + var svc *apikey.Service = client.APIKey + res, err := svc.Revoke(context.Background()) + if err != nil { + log.Fatal(err) + } + + fmt.Println(res.Payload.Message) +} diff --git a/services/assets/example_test.go b/services/assets/example_test.go new file mode 100644 index 0000000..796fee2 --- /dev/null +++ b/services/assets/example_test.go @@ -0,0 +1,44 @@ +package assets_test + +import ( + "context" + "fmt" + "log" + + "github.com/jeremyjsx/wallbit-go/services/assets" + "github.com/jeremyjsx/wallbit-go/wallbit" +) + +func ExampleService_Get() { + client, err := wallbit.NewClient("YOUR_API_KEY") + if err != nil { + log.Fatal(err) + } + + var svc *assets.Service = client.Assets + res, err := svc.Get(context.Background(), "AAPL") + if err != nil { + log.Fatal(err) + } + + fmt.Printf("%s — %s @ %.2f USD\n", res.Payload.Data.Symbol, res.Payload.Data.Name, res.Payload.Data.Price) +} + +func ExampleService_List() { + client, err := wallbit.NewClient("YOUR_API_KEY") + if err != nil { + log.Fatal(err) + } + + var svc *assets.Service = client.Assets + res, err := svc.List(context.Background(), &assets.ListRequest{ + Search: "tech", + Page: wallbit.Ptr(1), + Limit: wallbit.Ptr(20), + }) + if err != nil { + log.Fatal(err) + } + + fmt.Printf("found %d assets across %d pages\n", res.Payload.Count, res.Payload.Pages) +} diff --git a/services/balance/example_test.go b/services/balance/example_test.go new file mode 100644 index 0000000..3aca744 --- /dev/null +++ b/services/balance/example_test.go @@ -0,0 +1,44 @@ +package balance_test + +import ( + "context" + "fmt" + "log" + + "github.com/jeremyjsx/wallbit-go/services/balance" + "github.com/jeremyjsx/wallbit-go/wallbit" +) + +func ExampleService_GetChecking() { + client, err := wallbit.NewClient("YOUR_API_KEY") + if err != nil { + log.Fatal(err) + } + + var svc *balance.Service = client.Balance + res, err := svc.GetChecking(context.Background()) + if err != nil { + log.Fatal(err) + } + + for _, b := range res.Payload.Data { + fmt.Printf("%s: %.2f\n", b.Currency, b.Balance) + } +} + +func ExampleService_GetStocks() { + client, err := wallbit.NewClient("YOUR_API_KEY") + if err != nil { + log.Fatal(err) + } + + var svc *balance.Service = client.Balance + res, err := svc.GetStocks(context.Background()) + if err != nil { + log.Fatal(err) + } + + for _, p := range res.Payload.Data { + fmt.Printf("%s: %.4f shares\n", p.Symbol, p.Shares) + } +} diff --git a/services/cards/example_test.go b/services/cards/example_test.go new file mode 100644 index 0000000..ad031c9 --- /dev/null +++ b/services/cards/example_test.go @@ -0,0 +1,42 @@ +package cards_test + +import ( + "context" + "fmt" + "log" + + "github.com/jeremyjsx/wallbit-go/services/cards" + "github.com/jeremyjsx/wallbit-go/wallbit" +) + +func ExampleService_List() { + client, err := wallbit.NewClient("YOUR_API_KEY") + if err != nil { + log.Fatal(err) + } + + var svc *cards.Service = client.Cards + res, err := svc.List(context.Background()) + if err != nil { + log.Fatal(err) + } + + for _, c := range res.Payload.Data { + fmt.Printf("%s **** %s (%s)\n", c.CardNetwork, c.CardLast4, c.Status) + } +} + +func ExampleService_Block() { + client, err := wallbit.NewClient("YOUR_API_KEY") + if err != nil { + log.Fatal(err) + } + + var svc *cards.Service = client.Cards + res, err := svc.Block(context.Background(), "card_uuid_here") + if err != nil { + log.Fatal(err) + } + + fmt.Printf("card %s is now %s\n", res.Payload.Data.UUID, res.Payload.Data.Status) +} diff --git a/services/fees/example_test.go b/services/fees/example_test.go new file mode 100644 index 0000000..d97e112 --- /dev/null +++ b/services/fees/example_test.go @@ -0,0 +1,30 @@ +package fees_test + +import ( + "context" + "fmt" + "log" + + "github.com/jeremyjsx/wallbit-go/services/fees" + "github.com/jeremyjsx/wallbit-go/wallbit" +) + +func ExampleService_Get() { + client, err := wallbit.NewClient("YOUR_API_KEY") + if err != nil { + log.Fatal(err) + } + + var svc *fees.Service = client.Fees + res, err := svc.Get(context.Background(), fees.GetRequest{Type: "TRADE"}) + if err != nil { + log.Fatal(err) + } + + if res.Payload.Data.Empty { + fmt.Println("no fee setting configured") + return + } + fee := res.Payload.Data.Row + fmt.Printf("%s: %s%% + %s USD\n", fee.FeeType, fee.PercentageFee, fee.FixedFeeUSD) +} diff --git a/services/operations/example_test.go b/services/operations/example_test.go new file mode 100644 index 0000000..4eb19d0 --- /dev/null +++ b/services/operations/example_test.go @@ -0,0 +1,30 @@ +package operations_test + +import ( + "context" + "fmt" + "log" + + "github.com/jeremyjsx/wallbit-go/services/operations" + "github.com/jeremyjsx/wallbit-go/wallbit" +) + +func ExampleService_Internal() { + client, err := wallbit.NewClient("YOUR_API_KEY") + if err != nil { + log.Fatal(err) + } + + var svc *operations.Service = client.Operations + res, err := svc.Internal(context.Background(), operations.InternalRequest{ + Currency: "USD", + From: operations.AccountDefault, + To: operations.AccountInvestment, + Amount: 100, + }) + if err != nil { + log.Fatal(err) + } + + fmt.Printf("transfer %s status=%s\n", res.Payload.Data.UUID, res.Payload.Data.Status) +} diff --git a/services/roboadvisor/example_test.go b/services/roboadvisor/example_test.go new file mode 100644 index 0000000..eaf7ce4 --- /dev/null +++ b/services/roboadvisor/example_test.go @@ -0,0 +1,46 @@ +package roboadvisor_test + +import ( + "context" + "fmt" + "log" + + "github.com/jeremyjsx/wallbit-go/services/roboadvisor" + "github.com/jeremyjsx/wallbit-go/wallbit" +) + +func ExampleService_GetBalance() { + client, err := wallbit.NewClient("YOUR_API_KEY") + if err != nil { + log.Fatal(err) + } + + var svc *roboadvisor.Service = client.RoboAdvisor + res, err := svc.GetBalance(context.Background()) + if err != nil { + log.Fatal(err) + } + + for _, p := range res.Payload.Data { + fmt.Printf("portfolio %d: %.2f USD\n", p.ID, p.PortfolioValue) + } +} + +func ExampleService_Deposit() { + client, err := wallbit.NewClient("YOUR_API_KEY") + if err != nil { + log.Fatal(err) + } + + var svc *roboadvisor.Service = client.RoboAdvisor + res, err := svc.Deposit(context.Background(), roboadvisor.DepositRequest{ + RoboAdvisorID: 1, + Amount: 500, + From: roboadvisor.AccountTypeDefault, + }) + if err != nil { + log.Fatal(err) + } + + fmt.Printf("deposit %s status=%s\n", res.Payload.Data.UUID, res.Payload.Data.Status) +} diff --git a/services/trades/example_test.go b/services/trades/example_test.go new file mode 100644 index 0000000..d5100c7 --- /dev/null +++ b/services/trades/example_test.go @@ -0,0 +1,31 @@ +package trades_test + +import ( + "context" + "fmt" + "log" + + "github.com/jeremyjsx/wallbit-go/services/trades" + "github.com/jeremyjsx/wallbit-go/wallbit" +) + +func ExampleService_Create() { + client, err := wallbit.NewClient("YOUR_API_KEY") + if err != nil { + log.Fatal(err) + } + + var svc *trades.Service = client.Trades + res, err := svc.Create(context.Background(), trades.CreateRequest{ + Symbol: "AAPL", + Direction: "BUY", + Currency: "USD", + OrderType: "MARKET", + Amount: wallbit.Ptr(100.0), + }) + if err != nil { + log.Fatal(err) + } + + fmt.Printf("trade %s status=%s\n", res.Payload.Data.Symbol, res.Payload.Data.Status) +} diff --git a/services/transactions/example_test.go b/services/transactions/example_test.go new file mode 100644 index 0000000..4775e8c --- /dev/null +++ b/services/transactions/example_test.go @@ -0,0 +1,30 @@ +package transactions_test + +import ( + "context" + "fmt" + "log" + + "github.com/jeremyjsx/wallbit-go/services/transactions" + "github.com/jeremyjsx/wallbit-go/wallbit" +) + +func ExampleService_List() { + client, err := wallbit.NewClient("YOUR_API_KEY") + if err != nil { + log.Fatal(err) + } + + var svc *transactions.Service = client.Transactions + res, err := svc.List(context.Background(), &transactions.ListRequest{ + Status: "COMPLETED", + Limit: wallbit.Ptr(50), + }) + if err != nil { + log.Fatal(err) + } + + for _, tx := range res.Payload.Data.Data { + fmt.Printf("%s %s %.2f %s\n", tx.UUID, tx.Type, tx.SourceAmount, tx.SourceCurrency.Code) + } +} diff --git a/services/wallets/example_test.go b/services/wallets/example_test.go new file mode 100644 index 0000000..9ce5798 --- /dev/null +++ b/services/wallets/example_test.go @@ -0,0 +1,29 @@ +package wallets_test + +import ( + "context" + "fmt" + "log" + + "github.com/jeremyjsx/wallbit-go/services/wallets" + "github.com/jeremyjsx/wallbit-go/wallbit" +) + +func ExampleService_Get() { + client, err := wallbit.NewClient("YOUR_API_KEY") + if err != nil { + log.Fatal(err) + } + + var svc *wallets.Service = client.Wallets + res, err := svc.Get(context.Background(), &wallets.GetRequest{ + Currency: "USDT", + }) + if err != nil { + log.Fatal(err) + } + + for _, w := range res.Payload.Data { + fmt.Printf("[%s/%s] %s\n", w.CurrencyCode, w.Network, w.Address) + } +} diff --git a/wallbit/errors_fuzz_test.go b/wallbit/errors_fuzz_test.go new file mode 100644 index 0000000..c64f6e7 --- /dev/null +++ b/wallbit/errors_fuzz_test.go @@ -0,0 +1,70 @@ +package wallbit_test + +import ( + "strings" + "testing" + + "github.com/jeremyjsx/wallbit-go/wallbit" +) + +// FuzzErrorFromHTTP exercises the parser against arbitrary inputs to +// ensure no shape causes a panic, returns nil, or silently drops the +// verbatim fields the caller provided. The seed corpus covers the +// documented response shapes; the fuzzer mutates around them. +// +// Run extended fuzzing locally with: +// +// go test -fuzz=FuzzErrorFromHTTP -fuzztime=30s ./wallbit +func FuzzErrorFromHTTP(f *testing.F) { + bodies := [][]byte{ + nil, + []byte(``), + []byte(`{}`), + []byte(`{"message":"resource not found"}`), + []byte(`{"code":"INSUFFICIENT_PERMISSIONS","message":"forbidden"}`), + []byte(`{"retry_after":3,"message":"slow down"}`), + []byte(`{"retry_after":-7}`), + []byte(`{"details":{"field":"amount","reason":"too_small"}}`), + []byte(`{"errors":[{"field":"x"},{"field":"y"}]}`), + []byte(`{"your_permissions":["READ","WRITE"]}`), + []byte(`{"error":"trade rejected"}`), + []byte(`not json at all`), + []byte(`{"message":"` + strings.Repeat("x", 4096) + `"}`), + } + statuses := []int{200, 400, 401, 404, 422, 429, 500, 502, 0, -1} + for _, b := range bodies { + for _, s := range statuses { + f.Add(s, "req-test", b) + } + } + + f.Fuzz(func(t *testing.T, statusCode int, requestID string, rawBody []byte) { + e := wallbit.ErrorFromHTTP(statusCode, requestID, rawBody) + if e == nil { + t.Fatal("ErrorFromHTTP returned nil") + } + if e.StatusCode != statusCode { + t.Fatalf("StatusCode: got %d, want %d", e.StatusCode, statusCode) + } + if e.RequestID != requestID { + t.Fatalf("RequestID: got %q, want %q", e.RequestID, requestID) + } + if e.RawBody != string(rawBody) { + t.Fatalf("RawBody not preserved") + } + if d := e.RetryAfter(); d < 0 { + t.Fatalf("RetryAfter returned negative duration: %s", d) + } + // Methods and predicates must never panic, regardless of the + // shape of the body or the status code (including non-HTTP + // values from a buggy upstream proxy). + _ = e.Error() + _ = e.IsTemporary() + _ = wallbit.IsNotFound(e) + _ = wallbit.IsAuthError(e) + _ = wallbit.IsRateLimit(e) + _ = wallbit.IsValidationError(e) + _ = wallbit.IsServerError(e) + _ = wallbit.IsRetryable(e) + }) +} diff --git a/wallbit/example_test.go b/wallbit/example_test.go new file mode 100644 index 0000000..b7992d1 --- /dev/null +++ b/wallbit/example_test.go @@ -0,0 +1,59 @@ +package wallbit_test + +import ( + "context" + "fmt" + "log" + "time" + + "github.com/jeremyjsx/wallbit-go/wallbit" +) + +// Example shows the typical lifecycle of a Wallbit client: build it once +// at startup with the desired options, then reuse it across goroutines. +// The client is safe for concurrent use. +func Example() { + client, err := wallbit.NewClient( + "YOUR_API_KEY", + wallbit.WithTimeout(15*time.Second), + wallbit.WithRetryPolicy(wallbit.RetryPolicy{ + MaxAttempts: 3, + BaseDelay: 250 * time.Millisecond, + MaxDelay: 2 * time.Second, + }), + ) + if err != nil { + log.Fatal(err) + } + + res, err := client.Balance.GetChecking(context.Background()) + if err != nil { + log.Fatal(err) + } + + for _, b := range res.Payload.Data { + fmt.Printf("%s: %.2f\n", b.Currency, b.Balance) + } +} + +// ExampleNewClientFromConfig shows building a client from a single +// [Config] block instead of multiple [Option] calls. Use this when the +// configuration comes from a struct already populated elsewhere (env +// loader, config file, dependency injection container). +func ExampleNewClientFromConfig() { + cfg := &wallbit.Config{ + UserAgent: "my-app/1.0", + RetryPolicy: wallbit.RetryPolicy{ + MaxAttempts: 5, + BaseDelay: 500 * time.Millisecond, + MaxDelay: 5 * time.Second, + }, + } + + client, err := wallbit.NewClientFromConfig("YOUR_API_KEY", cfg) + if err != nil { + log.Fatal(err) + } + + _ = client +} From d259c970637be1709457b79d19aa764b31d3c861 Mon Sep 17 00:00:00 2001 From: JeremyDevCode Date: Mon, 20 Apr 2026 17:11:28 -0500 Subject: [PATCH 09/17] docs(wallbit): document default timeout, retry, and body-size values --- wallbit/doc.go | 29 +++++++++++++++++++++++++++-- 1 file changed, 27 insertions(+), 2 deletions(-) diff --git a/wallbit/doc.go b/wallbit/doc.go index f1ac437..af689b9 100644 --- a/wallbit/doc.go +++ b/wallbit/doc.go @@ -27,8 +27,33 @@ // # Configuration // // Customize the client with functional options: [WithBaseURL], -// [WithHTTPClient], [WithTimeout], [WithUserAgent], [WithRetryPolicy] and -// [WithHook]. Options may be passed in any order. +// [WithHTTPClient], [WithTimeout], [WithUserAgent], [WithRetryPolicy], +// [WithMaxResponseBytes] and [WithHook]. Options may be passed in any +// order. +// +// # Defaults +// +// [NewClient] applies these defaults; all are overridable via the +// matching option: +// +// - BaseURL: https://api.wallbit.io (HTTPS-only; see Security below). +// - HTTP timeout: 30s. Covers tail latency during API incidents +// without leaking goroutines when the server never replies. +// Override with [WithTimeout] or supply a pre-configured +// [*net/http.Client] via [WithHTTPClient]. +// - Retry policy: [DefaultRetryPolicy] — 3 attempts, 250ms base +// delay, 2s cap, exponential with equal-jitter. Worst-case added +// wait for a failing call is ~750ms of backoff on top of the +// server's own response times. See the Retries section below for +// which requests are eligible. +// - Max response body: [DefaultMaxResponseBytes] (10 MiB). Guards +// against runaway or hostile responses; overflow returns +// [ErrResponseTooLarge] instead of a truncated payload. Raise it +// with [WithMaxResponseBytes] if you consume deliberately large +// list endpoints. +// - User-Agent: wallbit-go-sdk/. Version resolves at build +// time via -ldflags, then via [runtime/debug.ReadBuildInfo], with +// "dev" as the final fallback. Override with [WithUserAgent]. // // # Errors // From b422bcbcc69a79c2fc9e5e3a2e2360aa298a374c Mon Sep 17 00:00:00 2001 From: JeremyDevCode Date: Mon, 20 Apr 2026 17:11:42 -0500 Subject: [PATCH 10/17] feat!: parse CreatedAt and UpdatedAt into time.Time on all services --- services/operations/service.go | 3 ++- services/operations/service_test.go | 5 +++++ services/roboadvisor/service.go | 3 ++- services/roboadvisor/service_test.go | 5 +++++ services/trades/service.go | 5 +++-- services/trades/service_test.go | 8 ++++++++ services/transactions/service.go | 2 +- services/transactions/service_test.go | 8 ++++++++ 8 files changed, 34 insertions(+), 5 deletions(-) diff --git a/services/operations/service.go b/services/operations/service.go index e1e4d42..e0d6cc5 100644 --- a/services/operations/service.go +++ b/services/operations/service.go @@ -3,6 +3,7 @@ package operations import ( "context" "net/http" + "time" "github.com/jeremyjsx/wallbit-go/transport" ) @@ -53,7 +54,7 @@ type Transaction struct { SourceAmount float64 `json:"source_amount"` DestAmount float64 `json:"dest_amount"` Status string `json:"status"` - CreatedAt string `json:"created_at"` + CreatedAt time.Time `json:"created_at"` Comment *string `json:"comment"` } diff --git a/services/operations/service_test.go b/services/operations/service_test.go index c30ac1d..04f9b25 100644 --- a/services/operations/service_test.go +++ b/services/operations/service_test.go @@ -7,6 +7,7 @@ import ( "net/http" "net/http/httptest" "testing" + "time" "github.com/jeremyjsx/wallbit-go/services/operations" "github.com/jeremyjsx/wallbit-go/wallbit" @@ -57,6 +58,10 @@ func TestServiceInternal(t *testing.T) { if out.Payload.Data.Status != "COMPLETED" { t.Fatalf("expected status COMPLETED, got %q", out.Payload.Data.Status) } + wantTime := time.Date(2024, 1, 1, 0, 0, 0, 0, time.UTC) + if !out.Payload.Data.CreatedAt.Equal(wantTime) { + t.Fatalf("CreatedAt: got %s, want %s", out.Payload.Data.CreatedAt, wantTime) + } } func TestServiceDepositInvestment(t *testing.T) { diff --git a/services/roboadvisor/service.go b/services/roboadvisor/service.go index 3f216b5..8cff598 100644 --- a/services/roboadvisor/service.go +++ b/services/roboadvisor/service.go @@ -3,6 +3,7 @@ package roboadvisor import ( "context" "net/http" + "time" "github.com/jeremyjsx/wallbit-go/transport" ) @@ -92,7 +93,7 @@ type Transaction struct { Type string `json:"type"` Amount float64 `json:"amount"` Status string `json:"status"` - CreatedAt string `json:"created_at"` + CreatedAt time.Time `json:"created_at"` } type DepositResponse struct { diff --git a/services/roboadvisor/service_test.go b/services/roboadvisor/service_test.go index 0eb1184..1635b69 100644 --- a/services/roboadvisor/service_test.go +++ b/services/roboadvisor/service_test.go @@ -7,6 +7,7 @@ import ( "net/http" "net/http/httptest" "testing" + "time" "github.com/jeremyjsx/wallbit-go/services/roboadvisor" "github.com/jeremyjsx/wallbit-go/wallbit" @@ -151,6 +152,10 @@ func TestServiceDeposit(t *testing.T) { if out.Payload.Data.Status != "PENDING" { t.Fatalf("unexpected status %q", out.Payload.Data.Status) } + wantTime := time.Date(2024, 1, 15, 10, 30, 0, 0, time.UTC) + if !out.Payload.Data.CreatedAt.Equal(wantTime) { + t.Fatalf("CreatedAt: got %s, want %s", out.Payload.Data.CreatedAt, wantTime) + } } func TestServiceDepositReturnsAPIError(t *testing.T) { diff --git a/services/trades/service.go b/services/trades/service.go index 7616ffc..a914c05 100644 --- a/services/trades/service.go +++ b/services/trades/service.go @@ -3,6 +3,7 @@ package trades import ( "context" "net/http" + "time" "github.com/jeremyjsx/wallbit-go/transport" ) @@ -39,8 +40,8 @@ type Trade struct { LimitPrice *float64 `json:"limit_price"` StopPrice *float64 `json:"stop_price"` TimeInForce *string `json:"time_in_force"` - CreatedAt string `json:"created_at"` - UpdatedAt string `json:"updated_at"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` } type CreateResponse struct { diff --git a/services/trades/service_test.go b/services/trades/service_test.go index 747ae78..265b23e 100644 --- a/services/trades/service_test.go +++ b/services/trades/service_test.go @@ -7,6 +7,7 @@ import ( "net/http" "net/http/httptest" "testing" + "time" "github.com/jeremyjsx/wallbit-go/services/trades" "github.com/jeremyjsx/wallbit-go/wallbit" @@ -59,6 +60,13 @@ func TestServiceCreate(t *testing.T) { if out.Payload.Data.Status != "REQUESTED" { t.Fatalf("expected status REQUESTED, got %q", out.Payload.Data.Status) } + wantTime := time.Date(2024, 1, 1, 0, 0, 0, 0, time.UTC) + if !out.Payload.Data.CreatedAt.Equal(wantTime) { + t.Fatalf("CreatedAt: got %s, want %s", out.Payload.Data.CreatedAt, wantTime) + } + if !out.Payload.Data.UpdatedAt.Equal(wantTime) { + t.Fatalf("UpdatedAt: got %s, want %s", out.Payload.Data.UpdatedAt, wantTime) + } } func TestServiceCreateLimitWithTimeInForce(t *testing.T) { diff --git a/services/transactions/service.go b/services/transactions/service.go index 72472a8..372bc88 100644 --- a/services/transactions/service.go +++ b/services/transactions/service.go @@ -46,7 +46,7 @@ type Transaction struct { SourceAmount float64 `json:"source_amount"` DestAmount float64 `json:"dest_amount"` Status string `json:"status"` - CreatedAt string `json:"created_at"` + CreatedAt time.Time `json:"created_at"` Comment *string `json:"comment"` } diff --git a/services/transactions/service_test.go b/services/transactions/service_test.go index 111d3b4..9098727 100644 --- a/services/transactions/service_test.go +++ b/services/transactions/service_test.go @@ -72,6 +72,14 @@ func TestServiceList(t *testing.T) { if out.Payload.Data.Data[1].ExternalAddress != nil { t.Fatalf("expected nil external_address in second transaction, got %v", out.Payload.Data.Data[1].ExternalAddress) } + wantFirst := time.Date(2024, 1, 1, 0, 0, 0, 0, time.UTC) + wantSecond := time.Date(2024, 1, 2, 0, 0, 0, 0, time.UTC) + if !out.Payload.Data.Data[0].CreatedAt.Equal(wantFirst) { + t.Fatalf("Data[0].CreatedAt: got %s, want %s", out.Payload.Data.Data[0].CreatedAt, wantFirst) + } + if !out.Payload.Data.Data[1].CreatedAt.Equal(wantSecond) { + t.Fatalf("Data[1].CreatedAt: got %s, want %s", out.Payload.Data.Data[1].CreatedAt, wantSecond) + } } func TestServiceListWithoutFilters(t *testing.T) { From f0644480672949d3164e017d6ee55418896b95da Mon Sep 17 00:00:00 2001 From: JeremyDevCode Date: Mon, 20 Apr 2026 17:46:02 -0500 Subject: [PATCH 11/17] feat: add rates service for Get Exchange Rate endpoint --- services/rates/example_test.go | 33 ++++++++ services/rates/service.go | 57 ++++++++++++++ services/rates/service_test.go | 134 +++++++++++++++++++++++++++++++++ wallbit/doc.go | 4 +- wallbit/wallbit.go | 4 + 5 files changed, 230 insertions(+), 2 deletions(-) create mode 100644 services/rates/example_test.go create mode 100644 services/rates/service.go create mode 100644 services/rates/service_test.go diff --git a/services/rates/example_test.go b/services/rates/example_test.go new file mode 100644 index 0000000..98ace47 --- /dev/null +++ b/services/rates/example_test.go @@ -0,0 +1,33 @@ +package rates_test + +import ( + "context" + "fmt" + "log" + + "github.com/jeremyjsx/wallbit-go/services/rates" + "github.com/jeremyjsx/wallbit-go/wallbit" +) + +func ExampleService_Get() { + client, err := wallbit.NewClient("YOUR_API_KEY") + if err != nil { + log.Fatal(err) + } + + var svc *rates.Service = client.Rates + res, err := svc.Get(context.Background(), rates.GetRequest{ + SourceCurrency: "ARS", + DestCurrency: "USD", + }) + if err != nil { + log.Fatal(err) + } + + fmt.Printf("%s: 1 %s = %.4f %s\n", + res.Payload.Data.Pair, + res.Payload.Data.SourceCurrency, + res.Payload.Data.Rate, + res.Payload.Data.DestCurrency, + ) +} diff --git a/services/rates/service.go b/services/rates/service.go new file mode 100644 index 0000000..45eb3b8 --- /dev/null +++ b/services/rates/service.go @@ -0,0 +1,57 @@ +package rates + +import ( + "context" + "errors" + "net/http" + "net/url" + "strings" + "time" + + "github.com/jeremyjsx/wallbit-go/transport" +) + +const getPath = "/api/public/v1/rates" + +// ErrEmptyCurrency is returned by [Service.Get] when source_currency or dest_currency is empty or whitespace-only. +var ErrEmptyCurrency = errors.New("rates: source_currency and dest_currency are required") + +type Service struct { + sender transport.Sender +} + +func NewService(sender transport.Sender) *Service { + return &Service{sender: sender} +} + +type GetRequest struct { + SourceCurrency string + DestCurrency string +} + +// ExchangeRate is the row returned by the public API. UpdatedAt is nil for +// identity pairs (e.g. USD→USD returns rate 1.0 with no stored row). +type ExchangeRate struct { + SourceCurrency string `json:"source_currency"` + DestCurrency string `json:"dest_currency"` + Pair string `json:"pair"` + Rate float64 `json:"rate"` + UpdatedAt *time.Time `json:"updated_at"` +} + +type GetResponse struct { + Data ExchangeRate `json:"data"` +} + +func (s *Service) Get(ctx context.Context, req GetRequest) (*transport.Response[GetResponse], error) { + if strings.TrimSpace(req.SourceCurrency) == "" || strings.TrimSpace(req.DestCurrency) == "" { + return nil, ErrEmptyCurrency + } + + q := url.Values{} + q.Set("source_currency", req.SourceCurrency) + q.Set("dest_currency", req.DestCurrency) + path := getPath + "?" + q.Encode() + + return transport.SendJSON(ctx, s.sender, http.MethodGet, path, nil, &GetResponse{}) +} diff --git a/services/rates/service_test.go b/services/rates/service_test.go new file mode 100644 index 0000000..46c3aa4 --- /dev/null +++ b/services/rates/service_test.go @@ -0,0 +1,134 @@ +package rates_test + +import ( + "context" + "errors" + "net/http" + "net/http/httptest" + "testing" + "time" + + "github.com/jeremyjsx/wallbit-go/services/rates" + "github.com/jeremyjsx/wallbit-go/wallbit" +) + +func TestServiceGet(t *testing.T) { + t.Parallel() + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/api/public/v1/rates" { + t.Fatalf("unexpected path %q", r.URL.Path) + } + if got := r.URL.Query().Get("source_currency"); got != "ARS" { + t.Fatalf("expected source_currency=ARS, got %q", got) + } + if got := r.URL.Query().Get("dest_currency"); got != "USD" { + t.Fatalf("expected dest_currency=USD, got %q", got) + } + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte(`{"data":{"source_currency":"ARS","dest_currency":"USD","pair":"ARSUSD","rate":1481.02,"updated_at":"2026-02-25T01:50:04+00:00"}}`)) + })) + defer server.Close() + + c, err := wallbit.NewClient("test-key", wallbit.WithBaseURL(server.URL), wallbit.WithInsecureHTTPForTesting()) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + out, err := c.Rates.Get(context.Background(), rates.GetRequest{ + SourceCurrency: "ARS", + DestCurrency: "USD", + }) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if out.Payload.Data.Pair != "ARSUSD" { + t.Fatalf("unexpected pair %q", out.Payload.Data.Pair) + } + if out.Payload.Data.Rate != 1481.02 { + t.Fatalf("unexpected rate %v", out.Payload.Data.Rate) + } + if out.Payload.Data.UpdatedAt == nil { + t.Fatal("expected UpdatedAt to be non-nil") + } + want := time.Date(2026, 2, 25, 1, 50, 4, 0, time.FixedZone("UTC", 0)) + if !out.Payload.Data.UpdatedAt.Equal(want) { + t.Fatalf("unexpected UpdatedAt %v", out.Payload.Data.UpdatedAt) + } +} + +func TestServiceGetIdentityPair(t *testing.T) { + t.Parallel() + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte(`{"data":{"source_currency":"USD","dest_currency":"USD","pair":"USDUSD","rate":1.0,"updated_at":null}}`)) + })) + defer server.Close() + + c, err := wallbit.NewClient("test-key", wallbit.WithBaseURL(server.URL), wallbit.WithInsecureHTTPForTesting()) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + out, err := c.Rates.Get(context.Background(), rates.GetRequest{ + SourceCurrency: "USD", + DestCurrency: "USD", + }) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if out.Payload.Data.Rate != 1.0 { + t.Fatalf("expected rate 1.0, got %v", out.Payload.Data.Rate) + } + if out.Payload.Data.UpdatedAt != nil { + t.Fatalf("expected UpdatedAt nil for identity pair, got %v", *out.Payload.Data.UpdatedAt) + } +} + +func TestServiceGetRejectsEmptyCurrencies(t *testing.T) { + t.Parallel() + + c, err := wallbit.NewClient("test-key", wallbit.WithBaseURL("http://localhost"), wallbit.WithInsecureHTTPForTesting()) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + cases := []rates.GetRequest{ + {SourceCurrency: "", DestCurrency: "USD"}, + {SourceCurrency: "ARS", DestCurrency: ""}, + {SourceCurrency: " ", DestCurrency: "USD"}, + } + for _, req := range cases { + if _, err := c.Rates.Get(context.Background(), req); !errors.Is(err, rates.ErrEmptyCurrency) { + t.Fatalf("expected ErrEmptyCurrency for %+v, got %v", req, err) + } + } +} + +func TestServiceGetReturnsAPIError(t *testing.T) { + t.Parallel() + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusNotFound) + _, _ = w.Write([]byte(`{"message":"Exchange rate not found for this currency pair."}`)) + })) + defer server.Close() + + c, err := wallbit.NewClient("test-key", wallbit.WithBaseURL(server.URL), wallbit.WithInsecureHTTPForTesting()) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + _, err = c.Rates.Get(context.Background(), rates.GetRequest{SourceCurrency: "XXX", DestCurrency: "USD"}) + var apiErr *wallbit.Error + if !errors.As(err, &apiErr) { + t.Fatalf("expected *wallbit.Error, got %v", err) + } + if !wallbit.IsNotFound(apiErr) { + t.Fatalf("expected IsNotFound, got %v", apiErr) + } +} diff --git a/wallbit/doc.go b/wallbit/doc.go index af689b9..2325f1f 100644 --- a/wallbit/doc.go +++ b/wallbit/doc.go @@ -1,8 +1,8 @@ // Package wallbit is a Go SDK for the Wallbit public API // (https://developer.wallbit.io). It provides a single [Client] composed of // per-resource service handles (Balance, Transactions, Trades, Fees, Wallets, -// Assets, Operations, RoboAdvisor, Cards, AccountDetails, APIKey) backed by -// a configurable HTTP transport. +// Assets, Operations, RoboAdvisor, Cards, AccountDetails, APIKey, Rates) +// backed by a configurable HTTP transport. // // # Authentication // diff --git a/wallbit/wallbit.go b/wallbit/wallbit.go index 5fabb42..4ea05da 100644 --- a/wallbit/wallbit.go +++ b/wallbit/wallbit.go @@ -17,6 +17,7 @@ import ( "github.com/jeremyjsx/wallbit-go/services/cards" "github.com/jeremyjsx/wallbit-go/services/fees" "github.com/jeremyjsx/wallbit-go/services/operations" + "github.com/jeremyjsx/wallbit-go/services/rates" "github.com/jeremyjsx/wallbit-go/services/roboadvisor" "github.com/jeremyjsx/wallbit-go/services/trades" "github.com/jeremyjsx/wallbit-go/services/transactions" @@ -64,6 +65,8 @@ type Client struct { RoboAdvisor *roboadvisor.Service Cards *cards.Service + + Rates *rates.Service } func NewClient(apiKey string, opts ...Option) (*Client, error) { @@ -138,6 +141,7 @@ func wireServices(c *Client) { c.Operations = operations.NewService(c.sender) c.RoboAdvisor = roboadvisor.NewService(c.sender) c.Cards = cards.NewService(c.sender) + c.Rates = rates.NewService(c.sender) } func (c *Client) Config() *Config { From dff2b733f2b5b56e3945e27c9dd1a83d51b37f34 Mon Sep 17 00:00:00 2001 From: JeremyDevCode Date: Mon, 20 Apr 2026 17:53:15 -0500 Subject: [PATCH 12/17] fix!: unwrap operations.Internal response to match API shape --- services/operations/example_test.go | 2 +- services/operations/service.go | 30 +++++++++++++---------------- services/operations/service_test.go | 26 ++++++++++++------------- 3 files changed, 27 insertions(+), 31 deletions(-) diff --git a/services/operations/example_test.go b/services/operations/example_test.go index 4eb19d0..f946ce8 100644 --- a/services/operations/example_test.go +++ b/services/operations/example_test.go @@ -26,5 +26,5 @@ func ExampleService_Internal() { log.Fatal(err) } - fmt.Printf("transfer %s status=%s\n", res.Payload.Data.UUID, res.Payload.Data.Status) + fmt.Printf("transfer %s status=%s\n", res.Payload.UUID, res.Payload.Status) } diff --git a/services/operations/service.go b/services/operations/service.go index e0d6cc5..194b28b 100644 --- a/services/operations/service.go +++ b/services/operations/service.go @@ -46,27 +46,23 @@ type Currency struct { } type Transaction struct { - UUID string `json:"uuid"` - Type string `json:"type"` - ExternalAddress *string `json:"external_address"` - SourceCurrency Currency `json:"source_currency"` - DestCurrency Currency `json:"dest_currency"` - SourceAmount float64 `json:"source_amount"` - DestAmount float64 `json:"dest_amount"` - Status string `json:"status"` + UUID string `json:"uuid"` + Type string `json:"type"` + ExternalAddress *string `json:"external_address"` + SourceCurrency Currency `json:"source_currency"` + DestCurrency Currency `json:"dest_currency"` + SourceAmount float64 `json:"source_amount"` + DestAmount float64 `json:"dest_amount"` + Status string `json:"status"` CreatedAt time.Time `json:"created_at"` - Comment *string `json:"comment"` + Comment *string `json:"comment"` } -type InternalResponse struct { - Data Transaction `json:"data"` +func (s *Service) Internal(ctx context.Context, req InternalRequest) (*transport.Response[Transaction], error) { + return transport.SendJSON(ctx, s.sender, http.MethodPost, internalPath, req, &Transaction{}) } -func (s *Service) Internal(ctx context.Context, req InternalRequest) (*transport.Response[InternalResponse], error) { - return transport.SendJSON(ctx, s.sender, http.MethodPost, internalPath, req, &InternalResponse{}) -} - -func (s *Service) DepositInvestment(ctx context.Context, req InvestmentDepositRequest) (*transport.Response[InternalResponse], error) { +func (s *Service) DepositInvestment(ctx context.Context, req InvestmentDepositRequest) (*transport.Response[Transaction], error) { return s.Internal(ctx, InternalRequest{ Currency: req.Currency, From: AccountDefault, @@ -75,7 +71,7 @@ func (s *Service) DepositInvestment(ctx context.Context, req InvestmentDepositRe }) } -func (s *Service) WithdrawInvestment(ctx context.Context, req InvestmentWithdrawRequest) (*transport.Response[InternalResponse], error) { +func (s *Service) WithdrawInvestment(ctx context.Context, req InvestmentWithdrawRequest) (*transport.Response[Transaction], error) { return s.Internal(ctx, InternalRequest{ Currency: req.Currency, From: AccountInvestment, diff --git a/services/operations/service_test.go b/services/operations/service_test.go index 04f9b25..7f4b2b7 100644 --- a/services/operations/service_test.go +++ b/services/operations/service_test.go @@ -34,7 +34,7 @@ func TestServiceInternal(t *testing.T) { w.Header().Set("Content-Type", "application/json") w.WriteHeader(http.StatusOK) - _, _ = w.Write([]byte(`{"data":{"uuid":"tx_123","type":"INTERNAL_OPERATION","external_address":null,"source_currency":{"code":"USD","alias":"US Dollar"},"dest_currency":{"code":"USD","alias":"US Dollar"},"source_amount":100,"dest_amount":100,"status":"COMPLETED","created_at":"2024-01-01T00:00:00Z","comment":null}}`)) + _, _ = w.Write([]byte(`{"uuid":"tx_123","type":"INTERNAL_OPERATION","external_address":null,"source_currency":{"code":"USD","alias":"US Dollar"},"dest_currency":{"code":"USD","alias":"US Dollar"},"source_amount":100,"dest_amount":100,"status":"COMPLETED","created_at":"2024-01-01T00:00:00Z","comment":null}`)) })) defer server.Close() @@ -52,15 +52,15 @@ func TestServiceInternal(t *testing.T) { if err != nil { t.Fatalf("unexpected error: %v", err) } - if out.Payload.Data.UUID != "tx_123" { - t.Fatalf("expected uuid tx_123, got %q", out.Payload.Data.UUID) + if out.Payload.UUID != "tx_123" { + t.Fatalf("expected uuid tx_123, got %q", out.Payload.UUID) } - if out.Payload.Data.Status != "COMPLETED" { - t.Fatalf("expected status COMPLETED, got %q", out.Payload.Data.Status) + if out.Payload.Status != "COMPLETED" { + t.Fatalf("expected status COMPLETED, got %q", out.Payload.Status) } wantTime := time.Date(2024, 1, 1, 0, 0, 0, 0, time.UTC) - if !out.Payload.Data.CreatedAt.Equal(wantTime) { - t.Fatalf("CreatedAt: got %s, want %s", out.Payload.Data.CreatedAt, wantTime) + if !out.Payload.CreatedAt.Equal(wantTime) { + t.Fatalf("CreatedAt: got %s, want %s", out.Payload.CreatedAt, wantTime) } } @@ -81,7 +81,7 @@ func TestServiceDepositInvestment(t *testing.T) { w.Header().Set("Content-Type", "application/json") w.WriteHeader(http.StatusOK) - _, _ = w.Write([]byte(`{"data":{"uuid":"tx_deposit","type":"INTERNAL_OPERATION","external_address":null,"source_currency":{"code":"USD","alias":"US Dollar"},"dest_currency":{"code":"USD","alias":"US Dollar"},"source_amount":25,"dest_amount":25,"status":"COMPLETED","created_at":"2024-01-01T00:00:00Z","comment":null}}`)) + _, _ = w.Write([]byte(`{"uuid":"tx_deposit","type":"INTERNAL_OPERATION","external_address":null,"source_currency":{"code":"USD","alias":"US Dollar"},"dest_currency":{"code":"USD","alias":"US Dollar"},"source_amount":25,"dest_amount":25,"status":"COMPLETED","created_at":"2024-01-01T00:00:00Z","comment":null}`)) })) defer server.Close() @@ -97,8 +97,8 @@ func TestServiceDepositInvestment(t *testing.T) { if err != nil { t.Fatalf("unexpected error: %v", err) } - if out.Payload.Data.UUID != "tx_deposit" { - t.Fatalf("expected uuid tx_deposit, got %q", out.Payload.Data.UUID) + if out.Payload.UUID != "tx_deposit" { + t.Fatalf("expected uuid tx_deposit, got %q", out.Payload.UUID) } } @@ -119,7 +119,7 @@ func TestServiceWithdrawInvestment(t *testing.T) { w.Header().Set("Content-Type", "application/json") w.WriteHeader(http.StatusOK) - _, _ = w.Write([]byte(`{"data":{"uuid":"tx_withdraw","type":"INTERNAL_OPERATION","external_address":null,"source_currency":{"code":"USD","alias":"US Dollar"},"dest_currency":{"code":"USD","alias":"US Dollar"},"source_amount":15,"dest_amount":15,"status":"COMPLETED","created_at":"2024-01-01T00:00:00Z","comment":null}}`)) + _, _ = w.Write([]byte(`{"uuid":"tx_withdraw","type":"INTERNAL_OPERATION","external_address":null,"source_currency":{"code":"USD","alias":"US Dollar"},"dest_currency":{"code":"USD","alias":"US Dollar"},"source_amount":15,"dest_amount":15,"status":"COMPLETED","created_at":"2024-01-01T00:00:00Z","comment":null}`)) })) defer server.Close() @@ -135,8 +135,8 @@ func TestServiceWithdrawInvestment(t *testing.T) { if err != nil { t.Fatalf("unexpected error: %v", err) } - if out.Payload.Data.UUID != "tx_withdraw" { - t.Fatalf("expected uuid tx_withdraw, got %q", out.Payload.Data.UUID) + if out.Payload.UUID != "tx_withdraw" { + t.Fatalf("expected uuid tx_withdraw, got %q", out.Payload.UUID) } } From 0d7ae43cd61f6ef6000ba95fd4e8b2007e6af096 Mon Sep 17 00:00:00 2001 From: JeremyDevCode Date: Mon, 20 Apr 2026 18:01:54 -0500 Subject: [PATCH 13/17] feat: add ListAll iter.Seq2 pagination to transactions and assets --- services/assets/example_test.go | 18 ++++ services/assets/service.go | 43 +++++++++ services/assets/service_test.go | 109 +++++++++++++++++++++ services/transactions/example_test.go | 18 ++++ services/transactions/service.go | 43 +++++++++ services/transactions/service_test.go | 133 ++++++++++++++++++++++++++ 6 files changed, 364 insertions(+) diff --git a/services/assets/example_test.go b/services/assets/example_test.go index 796fee2..3336d39 100644 --- a/services/assets/example_test.go +++ b/services/assets/example_test.go @@ -42,3 +42,21 @@ func ExampleService_List() { fmt.Printf("found %d assets across %d pages\n", res.Payload.Count, res.Payload.Pages) } + +func ExampleService_ListAll() { + client, err := wallbit.NewClient("YOUR_API_KEY") + if err != nil { + log.Fatal(err) + } + + var svc *assets.Service = client.Assets + for a, err := range svc.ListAll(context.Background(), &assets.ListRequest{ + Category: "TECHNOLOGY", + Limit: wallbit.Ptr(50), + }) { + if err != nil { + log.Fatal(err) + } + fmt.Printf("%s — %s @ %.2f USD\n", a.Symbol, a.Name, a.Price) + } +} diff --git a/services/assets/service.go b/services/assets/service.go index 77bb041..e3f8640 100644 --- a/services/assets/service.go +++ b/services/assets/service.go @@ -4,6 +4,7 @@ import ( "context" "errors" "fmt" + "iter" "net/http" "net/url" "strconv" @@ -98,3 +99,45 @@ func (s *Service) List(ctx context.Context, req *ListRequest) (*transport.Respon return transport.SendJSON(ctx, s.sender, http.MethodGet, path, nil, &ListResponse{}) } + +// ListAll returns an iterator that walks every page of results for the given +// filters, starting from req.Page if set (default 1) and advancing one page +// per batch until current_page >= pages. It issues one HTTP request per +// page; pass a higher Limit to reduce round trips. +// +// The iterator stops early when yield returns false, when ctx is cancelled, +// or on the first error. Errors are yielded with a zero-value Asset. The +// caller's *ListRequest is not mutated. +func (s *Service) ListAll(ctx context.Context, req *ListRequest) iter.Seq2[Asset, error] { + return func(yield func(Asset, error) bool) { + var pageReq ListRequest + if req != nil { + pageReq = *req + } + page := 1 + if pageReq.Page != nil { + page = *pageReq.Page + } + for { + if err := ctx.Err(); err != nil { + yield(Asset{}, err) + return + } + pageReq.Page = &page + out, err := s.List(ctx, &pageReq) + if err != nil { + yield(Asset{}, err) + return + } + for _, a := range out.Payload.Data { + if !yield(a, nil) { + return + } + } + if len(out.Payload.Data) == 0 || out.Payload.CurrentPage >= out.Payload.Pages { + return + } + page++ + } + } +} diff --git a/services/assets/service_test.go b/services/assets/service_test.go index 2469306..2e3d3bc 100644 --- a/services/assets/service_test.go +++ b/services/assets/service_test.go @@ -177,6 +177,115 @@ func TestServiceListWithoutFilters(t *testing.T) { } } +func TestServiceListAllWalksEveryPage(t *testing.T) { + t.Parallel() + + pages := map[string]string{ + "1": `{"data":[{"symbol":"AAPL","name":"Apple Inc.","price":175.5,"logo_url":"u"},{"symbol":"MSFT","name":"Microsoft","price":400,"logo_url":"u"}],"pages":3,"current_page":1,"count":5}`, + "2": `{"data":[{"symbol":"GOOG","name":"Alphabet","price":140,"logo_url":"u"},{"symbol":"AMZN","name":"Amazon","price":180,"logo_url":"u"}],"pages":3,"current_page":2,"count":5}`, + "3": `{"data":[{"symbol":"NVDA","name":"NVIDIA","price":900,"logo_url":"u"}],"pages":3,"current_page":3,"count":5}`, + } + var hits int + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + hits++ + page := r.URL.Query().Get("page") + body, ok := pages[page] + if !ok { + t.Fatalf("unexpected page requested: %q", page) + } + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte(body)) + })) + defer server.Close() + + c, err := wallbit.NewClient("test-key", wallbit.WithBaseURL(server.URL), wallbit.WithInsecureHTTPForTesting()) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + limit := 2 + var got []string + for a, err := range c.Assets.ListAll(context.Background(), &assets.ListRequest{Limit: &limit}) { + if err != nil { + t.Fatalf("unexpected iteration error: %v", err) + } + got = append(got, a.Symbol) + } + want := []string{"AAPL", "MSFT", "GOOG", "AMZN", "NVDA"} + if len(got) != len(want) { + t.Fatalf("expected %d assets, got %d (%v)", len(want), len(got), got) + } + for i := range want { + if got[i] != want[i] { + t.Fatalf("item %d: got %q, want %q", i, got[i], want[i]) + } + } + if hits != 3 { + t.Fatalf("expected 3 HTTP calls, got %d", hits) + } +} + +func TestServiceListAllStopsOnBreak(t *testing.T) { + t.Parallel() + + var hits int + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + hits++ + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte(`{"data":[{"symbol":"AAPL","name":"Apple","price":175,"logo_url":"u"}],"pages":10,"current_page":1,"count":10}`)) + })) + defer server.Close() + + c, err := wallbit.NewClient("test-key", wallbit.WithBaseURL(server.URL), wallbit.WithInsecureHTTPForTesting()) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + for _, err := range c.Assets.ListAll(context.Background(), nil) { + if err != nil { + t.Fatalf("unexpected iteration error: %v", err) + } + break + } + if hits != 1 { + t.Fatalf("expected iteration to stop after first page, got %d HTTP calls", hits) + } +} + +func TestServiceListAllPropagatesAPIError(t *testing.T) { + t.Parallel() + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusForbidden) + _, _ = w.Write([]byte(`{"message":"forbidden","code":"INSUFFICIENT_PERMISSIONS"}`)) + })) + defer server.Close() + + c, err := wallbit.NewClient("test-key", wallbit.WithBaseURL(server.URL), wallbit.WithInsecureHTTPForTesting()) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + var sawErr error + for _, iterErr := range c.Assets.ListAll(context.Background(), nil) { + if iterErr != nil { + sawErr = iterErr + break + } + t.Fatal("expected error on first yield") + } + var apiErr *wallbit.Error + if !errors.As(sawErr, &apiErr) { + t.Fatalf("expected *wallbit.Error, got %v", sawErr) + } + if apiErr.Code != "INSUFFICIENT_PERMISSIONS" { + t.Fatalf("unexpected error code %q", apiErr.Code) + } +} + func TestServiceListReturnsAPIError(t *testing.T) { t.Parallel() diff --git a/services/transactions/example_test.go b/services/transactions/example_test.go index 4775e8c..88ed604 100644 --- a/services/transactions/example_test.go +++ b/services/transactions/example_test.go @@ -28,3 +28,21 @@ func ExampleService_List() { fmt.Printf("%s %s %.2f %s\n", tx.UUID, tx.Type, tx.SourceAmount, tx.SourceCurrency.Code) } } + +func ExampleService_ListAll() { + client, err := wallbit.NewClient("YOUR_API_KEY") + if err != nil { + log.Fatal(err) + } + + var svc *transactions.Service = client.Transactions + for tx, err := range svc.ListAll(context.Background(), &transactions.ListRequest{ + Status: "COMPLETED", + Limit: wallbit.Ptr(50), + }) { + if err != nil { + log.Fatal(err) + } + fmt.Printf("%s %s %.2f %s\n", tx.UUID, tx.Type, tx.SourceAmount, tx.SourceCurrency.Code) + } +} diff --git a/services/transactions/service.go b/services/transactions/service.go index 372bc88..5656283 100644 --- a/services/transactions/service.go +++ b/services/transactions/service.go @@ -2,6 +2,7 @@ package transactions import ( "context" + "iter" "net/http" "net/url" "strconv" @@ -99,3 +100,45 @@ func (s *Service) List(ctx context.Context, req *ListRequest) (*transport.Respon return transport.SendJSON(ctx, s.sender, http.MethodGet, path, nil, &ListResponse{}) } + +// ListAll returns an iterator that walks every page of results for the given +// filters, starting from req.Page if set (default 1) and advancing one page +// per batch until current_page >= pages. It issues one HTTP request per +// page; pass a higher Limit to reduce round trips. +// +// The iterator stops early when yield returns false, when ctx is cancelled, +// or on the first error. Errors are yielded with a zero-value Transaction. +// The caller's *ListRequest is not mutated. +func (s *Service) ListAll(ctx context.Context, req *ListRequest) iter.Seq2[Transaction, error] { + return func(yield func(Transaction, error) bool) { + var pageReq ListRequest + if req != nil { + pageReq = *req + } + page := 1 + if pageReq.Page != nil { + page = *pageReq.Page + } + for { + if err := ctx.Err(); err != nil { + yield(Transaction{}, err) + return + } + pageReq.Page = &page + out, err := s.List(ctx, &pageReq) + if err != nil { + yield(Transaction{}, err) + return + } + for _, tx := range out.Payload.Data.Data { + if !yield(tx, nil) { + return + } + } + if len(out.Payload.Data.Data) == 0 || out.Payload.Data.CurrentPage >= out.Payload.Data.Pages { + return + } + page++ + } + } +} diff --git a/services/transactions/service_test.go b/services/transactions/service_test.go index 9098727..6f1b551 100644 --- a/services/transactions/service_test.go +++ b/services/transactions/service_test.go @@ -109,6 +109,139 @@ func TestServiceListWithoutFilters(t *testing.T) { } } +func TestServiceListAllWalksEveryPage(t *testing.T) { + t.Parallel() + + pages := map[string]string{ + "1": `{"data":{"data":[{"uuid":"a","type":"TRADE","status":"COMPLETED","created_at":"2024-01-01T00:00:00Z","source_amount":1,"dest_amount":1,"source_currency":{"code":"USD","alias":"USD"},"dest_currency":{"code":"USD","alias":"USD"}},{"uuid":"b","type":"TRADE","status":"COMPLETED","created_at":"2024-01-02T00:00:00Z","source_amount":2,"dest_amount":2,"source_currency":{"code":"USD","alias":"USD"},"dest_currency":{"code":"USD","alias":"USD"}}],"pages":3,"current_page":1,"count":5}}`, + "2": `{"data":{"data":[{"uuid":"c","type":"TRADE","status":"COMPLETED","created_at":"2024-01-03T00:00:00Z","source_amount":3,"dest_amount":3,"source_currency":{"code":"USD","alias":"USD"},"dest_currency":{"code":"USD","alias":"USD"}},{"uuid":"d","type":"TRADE","status":"COMPLETED","created_at":"2024-01-04T00:00:00Z","source_amount":4,"dest_amount":4,"source_currency":{"code":"USD","alias":"USD"},"dest_currency":{"code":"USD","alias":"USD"}}],"pages":3,"current_page":2,"count":5}}`, + "3": `{"data":{"data":[{"uuid":"e","type":"TRADE","status":"COMPLETED","created_at":"2024-01-05T00:00:00Z","source_amount":5,"dest_amount":5,"source_currency":{"code":"USD","alias":"USD"},"dest_currency":{"code":"USD","alias":"USD"}}],"pages":3,"current_page":3,"count":5}}`, + } + var hits int + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + hits++ + page := r.URL.Query().Get("page") + body, ok := pages[page] + if !ok { + t.Fatalf("unexpected page requested: %q", page) + } + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte(body)) + })) + defer server.Close() + + c, err := wallbit.NewClient("test-key", wallbit.WithBaseURL(server.URL), wallbit.WithInsecureHTTPForTesting()) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + limit := 2 + var got []string + for tx, err := range c.Transactions.ListAll(context.Background(), &transactions.ListRequest{Limit: &limit}) { + if err != nil { + t.Fatalf("unexpected iteration error: %v", err) + } + got = append(got, tx.UUID) + } + want := []string{"a", "b", "c", "d", "e"} + if len(got) != len(want) { + t.Fatalf("expected %d transactions, got %d (%v)", len(want), len(got), got) + } + for i := range want { + if got[i] != want[i] { + t.Fatalf("item %d: got %q, want %q", i, got[i], want[i]) + } + } + if hits != 3 { + t.Fatalf("expected 3 HTTP calls, got %d", hits) + } +} + +func TestServiceListAllStopsOnBreak(t *testing.T) { + t.Parallel() + + var hits int + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + hits++ + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte(`{"data":{"data":[{"uuid":"a","type":"TRADE","status":"COMPLETED","created_at":"2024-01-01T00:00:00Z","source_amount":1,"dest_amount":1,"source_currency":{"code":"USD","alias":"USD"},"dest_currency":{"code":"USD","alias":"USD"}}],"pages":10,"current_page":1,"count":10}}`)) + })) + defer server.Close() + + c, err := wallbit.NewClient("test-key", wallbit.WithBaseURL(server.URL), wallbit.WithInsecureHTTPForTesting()) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + for _, err := range c.Transactions.ListAll(context.Background(), nil) { + if err != nil { + t.Fatalf("unexpected iteration error: %v", err) + } + break + } + if hits != 1 { + t.Fatalf("expected iteration to stop after first page, got %d HTTP calls", hits) + } +} + +func TestServiceListAllPropagatesAPIError(t *testing.T) { + t.Parallel() + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusForbidden) + _, _ = w.Write([]byte(`{"message":"forbidden","code":"INSUFFICIENT_PERMISSIONS"}`)) + })) + defer server.Close() + + c, err := wallbit.NewClient("test-key", wallbit.WithBaseURL(server.URL), wallbit.WithInsecureHTTPForTesting()) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + var sawErr error + for _, iterErr := range c.Transactions.ListAll(context.Background(), nil) { + if iterErr != nil { + sawErr = iterErr + break + } + t.Fatal("expected error on first yield") + } + var apiErr *wallbit.Error + if !errors.As(sawErr, &apiErr) { + t.Fatalf("expected *wallbit.Error, got %v", sawErr) + } + if apiErr.Code != "INSUFFICIENT_PERMISSIONS" { + t.Fatalf("unexpected error code %q", apiErr.Code) + } +} + +func TestServiceListAllDoesNotMutateCallerRequest(t *testing.T) { + t.Parallel() + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte(`{"data":{"data":[{"uuid":"a","type":"TRADE","status":"COMPLETED","created_at":"2024-01-01T00:00:00Z","source_amount":1,"dest_amount":1,"source_currency":{"code":"USD","alias":"USD"},"dest_currency":{"code":"USD","alias":"USD"}}],"pages":1,"current_page":1,"count":1}}`)) + })) + defer server.Close() + + c, err := wallbit.NewClient("test-key", wallbit.WithBaseURL(server.URL), wallbit.WithInsecureHTTPForTesting()) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + origPage := 1 + req := &transactions.ListRequest{Page: &origPage, Currency: "USD"} + for range c.Transactions.ListAll(context.Background(), req) { + } + if req.Page == nil || *req.Page != 1 { + t.Fatalf("caller Page mutated: got %v", req.Page) + } +} + func TestServiceListReturnsAPIError(t *testing.T) { t.Parallel() From aec6de044b5f007c255f700e5eb67ff2e690bf15 Mon Sep 17 00:00:00 2001 From: JeremyDevCode Date: Mon, 20 Apr 2026 18:13:02 -0500 Subject: [PATCH 14/17] feat: add SlogHook adapter for structured request logging --- wallbit/doc.go | 13 ++++ wallbit/example_test.go | 21 ++++++ wallbit/options.go | 8 +- wallbit/sloghook.go | 61 ++++++++++++++++ wallbit/sloghook_test.go | 153 +++++++++++++++++++++++++++++++++++++++ wallbit/wallbit.go | 16 +++- wallbit/wallbit_test.go | 10 +++ 7 files changed, 276 insertions(+), 6 deletions(-) create mode 100644 wallbit/sloghook.go create mode 100644 wallbit/sloghook_test.go diff --git a/wallbit/doc.go b/wallbit/doc.go index 2325f1f..3c5ac18 100644 --- a/wallbit/doc.go +++ b/wallbit/doc.go @@ -70,6 +70,19 @@ // POST, PATCH and PUT are never retried automatically. Backoff is exponential // and honors Retry-After. // +// # Observability +// +// Register a [Hook] via [WithHook] to observe every HTTP attempt (including +// retries). For standard structured logging, use [SlogHook] which adapts a +// [*log/slog.Logger] to the [Hook] interface and emits one record per +// attempt with method, path, attempt, status and duration_ms. Filter volume +// by configuring the logger's level; request.start is emitted at Debug, +// request.done at Info/Warn/Error depending on status. +// +// client, _ := wallbit.NewClient(key, +// wallbit.WithHook(wallbit.SlogHook(slog.Default())), +// ) +// // # Security // // HTTPS is required by default; non-HTTPS base URLs are rejected unless diff --git a/wallbit/example_test.go b/wallbit/example_test.go index b7992d1..9635585 100644 --- a/wallbit/example_test.go +++ b/wallbit/example_test.go @@ -4,6 +4,8 @@ import ( "context" "fmt" "log" + "log/slog" + "os" "time" "github.com/jeremyjsx/wallbit-go/wallbit" @@ -57,3 +59,22 @@ func ExampleNewClientFromConfig() { _ = client } + +// ExampleSlogHook wires a [log/slog.Logger] to the client's request +// lifecycle. Every HTTP attempt emits a structured record with method, +// path, attempt, status and duration_ms. Filter volume with the logger's +// level; request.start is emitted at Debug, request.done at Info/Warn/Error +// based on status. +func ExampleSlogHook() { + logger := slog.New(slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{Level: slog.LevelInfo})) + + client, err := wallbit.NewClient( + "YOUR_API_KEY", + wallbit.WithHook(wallbit.SlogHook(logger)), + ) + if err != nil { + log.Fatal(err) + } + + _ = client +} diff --git a/wallbit/options.go b/wallbit/options.go index c4dd033..4fa3728 100644 --- a/wallbit/options.go +++ b/wallbit/options.go @@ -93,10 +93,12 @@ type RequestMeta struct { // HTTP attempt. StatusCode is 0 when the transport returned an error before // receiving a response. // -// Attempt mirrors [RequestMeta.Attempt] so a hook holding only the -// response meta can still tag metrics with the attempt number without -// correlating callbacks. +// Method, Path and Attempt mirror the values passed to [Hook.OnRequestStart] +// so a hook holding only the response meta can tag metrics or log a single +// line per attempt without correlating callbacks. type ResponseMeta struct { + Method string + Path string StatusCode int Duration time.Duration Attempt int diff --git a/wallbit/sloghook.go b/wallbit/sloghook.go new file mode 100644 index 0000000..1deadcb --- /dev/null +++ b/wallbit/sloghook.go @@ -0,0 +1,61 @@ +package wallbit + +import ( + "context" + "log/slog" +) + +// SlogHook returns a [Hook] that emits structured logs to the given +// [*slog.Logger] for every HTTP attempt the client performs. When logger is +// nil, [slog.Default] is used. +// +// Each attempt produces two records (filter with the logger's level): +// +// - "wallbit.request.start" at [slog.LevelDebug] +// - "wallbit.request.done" at [slog.LevelInfo] for 2xx/3xx +// [slog.LevelWarn] for 4xx +// [slog.LevelError] for 5xx or transport errors (status == 0) +// +// Attributes on both records: method, path, attempt. +// Additional attributes on the done record: status, duration_ms. +// +// The returned hook is safe for concurrent use. +func SlogHook(logger *slog.Logger) Hook { + if logger == nil { + logger = slog.Default() + } + return &slogHook{logger: logger} +} + +type slogHook struct { + logger *slog.Logger +} + +func (h *slogHook) OnRequestStart(m *RequestMeta) { + h.logger.LogAttrs(context.Background(), slog.LevelDebug, "wallbit.request.start", + slog.String("method", m.Method), + slog.String("path", m.Path), + slog.Int("attempt", m.Attempt), + ) +} + +func (h *slogHook) OnRequestDone(m *ResponseMeta) { + h.logger.LogAttrs(context.Background(), slogLevelForStatus(m.StatusCode), "wallbit.request.done", + slog.String("method", m.Method), + slog.String("path", m.Path), + slog.Int("attempt", m.Attempt), + slog.Int("status", m.StatusCode), + slog.Int64("duration_ms", m.Duration.Milliseconds()), + ) +} + +func slogLevelForStatus(status int) slog.Level { + switch { + case status == 0, status >= 500: + return slog.LevelError + case status >= 400: + return slog.LevelWarn + default: + return slog.LevelInfo + } +} diff --git a/wallbit/sloghook_test.go b/wallbit/sloghook_test.go new file mode 100644 index 0000000..ad514c0 --- /dev/null +++ b/wallbit/sloghook_test.go @@ -0,0 +1,153 @@ +package wallbit_test + +import ( + "context" + "log/slog" + "sync" + "testing" + "time" + + "github.com/jeremyjsx/wallbit-go/wallbit" +) + +type capturedRecord struct { + Level slog.Level + Msg string + Attrs map[string]any +} + +type captureHandler struct { + mu sync.Mutex + records []capturedRecord + level slog.Level +} + +func (h *captureHandler) Enabled(_ context.Context, l slog.Level) bool { + return l >= h.level +} + +func (h *captureHandler) Handle(_ context.Context, r slog.Record) error { + attrs := map[string]any{} + r.Attrs(func(a slog.Attr) bool { + attrs[a.Key] = a.Value.Any() + return true + }) + h.mu.Lock() + h.records = append(h.records, capturedRecord{Level: r.Level, Msg: r.Message, Attrs: attrs}) + h.mu.Unlock() + return nil +} + +func (h *captureHandler) WithAttrs(_ []slog.Attr) slog.Handler { return h } +func (h *captureHandler) WithGroup(_ string) slog.Handler { return h } + +func (h *captureHandler) snapshot() []capturedRecord { + h.mu.Lock() + defer h.mu.Unlock() + out := make([]capturedRecord, len(h.records)) + copy(out, h.records) + return out +} + +func TestSlogHookEmitsStartAndDoneWithAttrs(t *testing.T) { + t.Parallel() + + h := &captureHandler{level: slog.LevelDebug} + hook := wallbit.SlogHook(slog.New(h)) + + hook.OnRequestStart(&wallbit.RequestMeta{Method: "GET", Path: "/api/public/v1/balance", Attempt: 1}) + hook.OnRequestDone(&wallbit.ResponseMeta{ + Method: "GET", + Path: "/api/public/v1/balance", + StatusCode: 200, + Duration: 75 * time.Millisecond, + Attempt: 1, + }) + + records := h.snapshot() + if len(records) != 2 { + t.Fatalf("expected 2 records, got %d", len(records)) + } + + start := records[0] + if start.Level != slog.LevelDebug { + t.Fatalf("start level: got %v, want Debug", start.Level) + } + if start.Msg != "wallbit.request.start" { + t.Fatalf("start msg: %q", start.Msg) + } + if start.Attrs["method"] != "GET" || start.Attrs["path"] != "/api/public/v1/balance" || start.Attrs["attempt"] != int64(1) { + t.Fatalf("unexpected start attrs: %+v", start.Attrs) + } + + done := records[1] + if done.Level != slog.LevelInfo { + t.Fatalf("done level: got %v, want Info", done.Level) + } + if done.Msg != "wallbit.request.done" { + t.Fatalf("done msg: %q", done.Msg) + } + if done.Attrs["status"] != int64(200) { + t.Fatalf("done status: %v", done.Attrs["status"]) + } + if done.Attrs["duration_ms"] != int64(75) { + t.Fatalf("done duration_ms: %v", done.Attrs["duration_ms"]) + } +} + +func TestSlogHookLevelsByStatus(t *testing.T) { + t.Parallel() + + cases := []struct { + status int + want slog.Level + }{ + {status: 0, want: slog.LevelError}, + {status: 200, want: slog.LevelInfo}, + {status: 301, want: slog.LevelInfo}, + {status: 400, want: slog.LevelWarn}, + {status: 404, want: slog.LevelWarn}, + {status: 500, want: slog.LevelError}, + {status: 503, want: slog.LevelError}, + } + + for _, tc := range cases { + h := &captureHandler{level: slog.LevelDebug} + hook := wallbit.SlogHook(slog.New(h)) + hook.OnRequestDone(&wallbit.ResponseMeta{Method: "GET", Path: "/x", StatusCode: tc.status, Attempt: 1}) + got := h.snapshot() + if len(got) != 1 { + t.Fatalf("status=%d: expected 1 record, got %d", tc.status, len(got)) + } + if got[0].Level != tc.want { + t.Fatalf("status=%d: level got %v, want %v", tc.status, got[0].Level, tc.want) + } + } +} + +func TestSlogHookNilLoggerUsesDefault(t *testing.T) { + t.Parallel() + + hook := wallbit.SlogHook(nil) + // Must not panic. + hook.OnRequestStart(&wallbit.RequestMeta{Method: "GET", Path: "/", Attempt: 1}) + hook.OnRequestDone(&wallbit.ResponseMeta{Method: "GET", Path: "/", StatusCode: 200, Attempt: 1}) +} + +func TestSlogHookRespectsHandlerLevel(t *testing.T) { + t.Parallel() + + h := &captureHandler{level: slog.LevelInfo} + hook := wallbit.SlogHook(slog.New(h)) + + hook.OnRequestStart(&wallbit.RequestMeta{Method: "GET", Path: "/", Attempt: 1}) + hook.OnRequestDone(&wallbit.ResponseMeta{Method: "GET", Path: "/", StatusCode: 200, Attempt: 1}) + + records := h.snapshot() + if len(records) != 1 { + t.Fatalf("expected only done to be emitted at Info level, got %d records", len(records)) + } + if records[0].Msg != "wallbit.request.done" { + t.Fatalf("unexpected msg: %q", records[0].Msg) + } +} diff --git a/wallbit/wallbit.go b/wallbit/wallbit.go index 4ea05da..b2c49b1 100644 --- a/wallbit/wallbit.go +++ b/wallbit/wallbit.go @@ -195,7 +195,7 @@ func (c *Client) do(req *http.Request, dest any) (*transport.Metadata, error) { if res != nil { statusCode = res.StatusCode } - c.emitRequestDone(statusCode, dur, attemptNumber) + c.emitRequestDone(reqTry, statusCode, dur, attemptNumber) if err != nil { if attempt < max-1 && isIdempotentHTTPMethod(req.Method) { @@ -261,12 +261,22 @@ func (c *Client) emitRequestStart(req *http.Request, attempt int) { } // emitRequestDone fires the OnRequestDone hook when one is configured. -func (c *Client) emitRequestDone(statusCode int, dur time.Duration, attempt int) { +func (c *Client) emitRequestDone(req *http.Request, statusCode int, dur time.Duration, attempt int) { h := c.cfg.Hook if h == nil { return } - h.OnRequestDone(&ResponseMeta{StatusCode: statusCode, Duration: dur, Attempt: attempt}) + path := "" + if req.URL != nil { + path = req.URL.Path + } + h.OnRequestDone(&ResponseMeta{ + Method: req.Method, + Path: path, + StatusCode: statusCode, + Duration: dur, + Attempt: attempt, + }) } // decodeBody unmarshals body into dest unless the response carries no diff --git a/wallbit/wallbit_test.go b/wallbit/wallbit_test.go index 9aece32..bae5d7c 100644 --- a/wallbit/wallbit_test.go +++ b/wallbit/wallbit_test.go @@ -16,6 +16,8 @@ type testHook struct { startAttempts []int doneAttempts []int doneStatusCodes []int + doneMethods []string + donePaths []string } func (h *testHook) OnRequestStart(m *RequestMeta) { @@ -26,6 +28,8 @@ func (h *testHook) OnRequestDone(m *ResponseMeta) { h.done++ h.doneAttempts = append(h.doneAttempts, m.Attempt) h.doneStatusCodes = append(h.doneStatusCodes, m.StatusCode) + h.doneMethods = append(h.doneMethods, m.Method) + h.donePaths = append(h.donePaths, m.Path) } func TestNewClientAndOptions(t *testing.T) { @@ -69,6 +73,12 @@ func TestNewClientAndOptions(t *testing.T) { if len(hook.doneAttempts) != 1 || hook.doneAttempts[0] != 1 { t.Fatalf("OnRequestDone attempts: got %v, want [1]", hook.doneAttempts) } + if len(hook.doneMethods) != 1 || hook.doneMethods[0] != http.MethodGet { + t.Fatalf("OnRequestDone methods: got %v, want [GET]", hook.doneMethods) + } + if len(hook.donePaths) != 1 || !strings.HasPrefix(hook.donePaths[0], "/api/public/v1/balance") { + t.Fatalf("OnRequestDone paths: got %v", hook.donePaths) + } } func TestHookSeesAttemptIncrementingAcrossRetries(t *testing.T) { From 2cc776edd1bda1c76096ed2cda369d9c750b5e78 Mon Sep 17 00:00:00 2001 From: JeremyDevCode Date: Mon, 20 Apr 2026 18:39:01 -0500 Subject: [PATCH 15/17] docs: add CHANGELOG and Makefile, refresh README coverage --- CHANGELOG.md | 89 ++++++++++++++++++++++++++++++++++++++++++++++++++++ Makefile | 82 +++++++++++++++++++++++++++++++++++++++++++++++ README.md | 43 +++++++++++++++++++++++-- 3 files changed, 211 insertions(+), 3 deletions(-) create mode 100644 CHANGELOG.md create mode 100644 Makefile diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..0ff8da6 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,89 @@ +# Changelog + +All notable changes to this project will be documented in this file. + +The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), +and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). + +## [Unreleased] + +Nothing yet — changes land here first and graduate to a versioned +section at release time. + +## [0.1.0-beta.1] — TBD + +First public release. + +### Added + +- `wallbit.Client`: single HTTP client that exposes the Wallbit API as + per-resource service handles. Build with `wallbit.NewClient(apiKey, opts...)` + or `wallbit.NewClientFromConfig(apiKey, cfg)` when configuration + comes from a struct (env loader, DI container, etc.). +- Functional options: `WithBaseURL`, `WithHTTPClient`, `WithTimeout`, + `WithUserAgent`, `WithRetryPolicy`, `WithMaxResponseBytes`, + `WithHook`, `WithInsecureHTTPForTesting`. +- Service handles on `*wallbit.Client`: `Balance`, `Transactions`, + `Trades`, `Fees`, `Wallets`, `Assets`, `Operations`, `RoboAdvisor`, + `Cards`, `AccountDetails`, `APIKey`, `Rates`. +- `transport.Response[T]` generic wrapper pairing the decoded payload + (`Payload`) with the HTTP envelope (`StatusCode`, `Header`, + `RequestID`). +- `transport.Sender` interface for injecting custom HTTP transports + and `transport.SendJSON[T]` generic helper that every service uses + internally. +- `wallbit.Error` with typed fields (`StatusCode`, `Code`, `Message`, + `Details`, `RequestID`, `RetryAfter`) and predicates + `IsNotFound`, `IsAuthError`, `IsRateLimit`, `IsValidationError`, + `IsServerError`, `IsRetryable`. `ErrorFromHTTP` is fuzz-tested for + resilience against malformed upstream bodies. +- Retry loop with equal-jitter exponential backoff, `Retry-After` + honoring and attempt counting exposed to hooks. Default policy: + 3 attempts, 250ms base delay, 2s cap. Idempotent methods (`GET`, + `HEAD`, `DELETE`, `OPTIONS`, `TRACE`) are retried on transport + errors; `429` and `5xx` are retried regardless of method. +- SDK version injected into the `User-Agent` at build time via + `-ldflags "-X github.com/jeremyjsx/wallbit-go/wallbit.Version=..."`, + with `runtime/debug.ReadBuildInfo` and `"dev"` fallbacks. +- `wallbit.Hook` interface with `RequestMeta` / `ResponseMeta` + carrying `Method`, `Path`, `Attempt`, `StatusCode` and `Duration` + for every HTTP attempt (retries included). +- `wallbit.SlogHook`: adapter that wires the hook to a + `*log/slog.Logger`, emitting one record per attempt with + `method`, `path`, `attempt`, `status` and `duration_ms`. Levels: + Debug on start, Info/Warn/Error on done based on status. +- `wallbit.Ptr[T any](v T) *T` helper for optional fields in request + bodies so call-sites read `wallbit.Ptr("value")` alongside the + client. +- Go 1.23 pagination: `transactions.ListAll` and `assets.ListAll` + return `iter.Seq2[T, error]` and walk every page lazily. +- Timestamp fields (`CreatedAt`, `UpdatedAt`) typed as `time.Time` + across services that expose them (`trades`, `transactions`, + `operations`, `roboadvisor`, `rates`). Invalid timestamps surface + as JSON decode errors instead of silently passing through. +- Nullable API fields consistently typed as pointers (`*string`, + `*float64`, …) so `nil` is distinguishable from "explicitly zero". +- Error `details` preserved as raw JSON so callers can re-decode the + server-specific payload without losing information. +- Input validation before issuing the HTTP call for paths that take a + required segment (asset symbol, card UUID, currency pair), with + typed sentinel errors (`ErrEmptySymbol`, `ErrEmptyCurrency`, …). +- `Example*` functions in every package so `pkg.go.dev` renders + runnable usage for each service method. + +### Security + +- HTTPS is required by default. Non-HTTPS base URLs are rejected with + `ErrInsecureBaseURL` unless `WithInsecureHTTPForTesting` is set, + preventing accidental plaintext transmission of the API key. +- Cross-host redirects are blocked by a `CheckRedirect` hook on the + cloned `*http.Client` so a hostile or misconfigured redirect cannot + exfiltrate the `X-API-Key` header to a foreign host. +- Response bodies are read through `io.LimitReader` with a default + cap of 10 MiB (`DefaultMaxResponseBytes`). Over-sized responses + return `ErrResponseTooLarge` instead of a partial payload so a + hostile or buggy upstream cannot exhaust process memory. Override + with `WithMaxResponseBytes`. + +[Unreleased]: https://github.com/jeremyjsx/wallbit-go/compare/v0.1.0-beta.1...HEAD +[0.1.0-beta.1]: https://github.com/jeremyjsx/wallbit-go/releases/tag/v0.1.0-beta.1 diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..5c88bef --- /dev/null +++ b/Makefile @@ -0,0 +1,82 @@ +# Makefile for wallbit-go. +# +# Assumes GNU make and a POSIX shell. On Windows, run from Git Bash or WSL. +# Run `make` or `make help` for the list of available targets. + +GO ?= go +GOLANGCI_LINT_VERSION ?= v2.11.4 +GOVULNCHECK_VERSION ?= latest +COVERFILE ?= coverage.out + +.DEFAULT_GOAL := help + +.PHONY: help +help: ## Show this help + @echo "" + @echo "Usage: make " + @echo "" + @echo "Targets:" + @echo " test Run unit tests (fast, no race detector)" + @echo " test-race Run unit tests with the race detector (matches CI)" + @echo " vet Run go vet" + @echo " fmt Format code with gofmt" + @echo " lint Run golangci-lint" + @echo " tidy Run go mod tidy and verify" + @echo " vuln Run govulncheck against all packages" + @echo " cover Generate HTML coverage report (coverage.html)" + @echo " fuzz Run fuzz targets briefly (10s each)" + @echo " check Full pre-PR check (vet + lint + test-race + vuln)" + @echo " install-tools Install pinned golangci-lint and govulncheck" + @echo " clean Remove generated artifacts" + @echo "" + +.PHONY: test +test: ## Run unit tests (fast, no race detector) + $(GO) test -count=1 ./... + +.PHONY: test-race +test-race: ## Run unit tests with the race detector (matches CI) + $(GO) test -race -count=1 ./... + +.PHONY: vet +vet: ## Run go vet + $(GO) vet ./... + +.PHONY: fmt +fmt: ## Format code with gofmt + $(GO) fmt ./... + +.PHONY: lint +lint: ## Run golangci-lint (requires `make install-tools` first) + golangci-lint run ./... + +.PHONY: tidy +tidy: ## Run go mod tidy and verify + $(GO) mod tidy + $(GO) mod verify + +.PHONY: vuln +vuln: ## Run govulncheck against all packages + $(GO) run golang.org/x/vuln/cmd/govulncheck@$(GOVULNCHECK_VERSION) ./... + +.PHONY: cover +cover: ## Generate HTML coverage report (coverage.html) + $(GO) test -race -coverprofile=$(COVERFILE) ./... + $(GO) tool cover -html=$(COVERFILE) -o coverage.html + @echo "Coverage report written to coverage.html" + +.PHONY: fuzz +fuzz: ## Run fuzz targets briefly (10s each) + $(GO) test -run=^$$ -fuzz=FuzzErrorFromHTTP -fuzztime=10s ./wallbit/... + +.PHONY: check +check: vet lint test-race vuln ## Full pre-PR check (mirrors CI) + +.PHONY: install-tools +install-tools: ## Install pinned versions of golangci-lint and govulncheck + $(GO) install github.com/golangci/golangci-lint/v2/cmd/golangci-lint@$(GOLANGCI_LINT_VERSION) + $(GO) install golang.org/x/vuln/cmd/govulncheck@$(GOVULNCHECK_VERSION) + +.PHONY: clean +clean: ## Remove generated artifacts + @rm -f $(COVERFILE) coverage.html diff --git a/README.md b/README.md index 0a99e6c..a6cb88d 100644 --- a/README.md +++ b/README.md @@ -44,7 +44,7 @@ func main() { log.Fatal(err) } - for _, b := range balance.Data { + for _, b := range balance.Payload.Data { fmt.Printf("%s: %.2f\n", b.Currency, b.Balance) } } @@ -57,16 +57,27 @@ All endpoints documented in the Wallbit OpenAPI spec are covered. | Service | Method | API endpoint | | ---------------- | ------------------------------------- | --------------------------------------------------- | | `Balance` | `GetChecking`, `GetStocks` | `GET /balance/{checking,stocks}` | -| `Transactions` | `List` | `GET /transactions` | +| `Transactions` | `List`, `ListAll` | `GET /transactions` | | `Trades` | `Create` | `POST /trades` | | `Fees` | `Get` | `POST /fees` | | `AccountDetails` | `Get` | `GET /account-details` | | `Wallets` | `Get` | `GET /wallets` | -| `Assets` | `List`, `Get` | `GET /assets`, `GET /assets/{symbol}` | +| `Assets` | `List`, `ListAll`, `Get` | `GET /assets`, `GET /assets/{symbol}` | | `Operations` | `Internal`, `DepositInvestment`, `WithdrawInvestment` | `POST /operations/internal` | | `RoboAdvisor` | `GetBalance`, `Deposit`, `Withdraw` | `GET /roboadvisor/balance`, `POST /roboadvisor/{deposit,withdraw}` | | `Cards` | `List`, `Block`, `Unblock` | `GET /cards`, `PATCH /cards/{uuid}/status` | | `APIKey` | `Revoke` | `DELETE /api-key` | +| `Rates` | `Get` | `GET /rates?source_currency=&dest_currency=` | + +`ListAll` returns a Go 1.23 `iter.Seq2[T, error]` that lazily walks every +page: + +```go +for tx, err := range client.Transactions.ListAll(ctx, nil) { + if err != nil { /* handle */ break } + fmt.Println(tx.UUID, tx.Status) +} +``` ## Error handling @@ -130,6 +141,17 @@ client, _ := wallbit.NewClient(apiKey, wallbit.WithHook(metricsHook{})) Hooks are called on every attempt (including retries) and must be safe for concurrent use. +For structured logging, use the built-in `SlogHook` adapter: + +```go +logger := slog.New(slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{Level: slog.LevelInfo})) +client, _ := wallbit.NewClient(apiKey, wallbit.WithHook(wallbit.SlogHook(logger))) +``` + +Each attempt emits one structured record with `method`, `path`, `attempt`, +`status` and `duration_ms`. Filter volume with the logger's level +(`Debug` for start, `Info`/`Warn`/`Error` for done based on status). + ## Custom HTTP client ```go @@ -149,6 +171,21 @@ The SDK clones the provided `http.Client` and installs a `CheckRedirect` that bl - Cross-host redirects are blocked by default (see above). - Never commit your API key. Read it from an environment variable or secret manager. +## Development + +```bash +# One-time: install pinned linter and vuln scanner +make install-tools + +# Before opening a PR: runs vet + lint + race tests + govulncheck +make check +``` + +Run `make help` for the full list of targets. On Windows, use Git Bash +or WSL — the Makefile assumes a POSIX shell. + +See [CHANGELOG.md](./CHANGELOG.md) for release history. + ## License Licensed under the [Apache License, Version 2.0](./LICENSE). See [NOTICE](./NOTICE) From 2bf44c0507de91f911c9eff983076b3bda3dbb3c Mon Sep 17 00:00:00 2001 From: JeremyDevCode Date: Mon, 20 Apr 2026 18:39:22 -0500 Subject: [PATCH 16/17] chore: add CODEOWNERS --- .github/CODEOWNERS | 9 +++++++++ 1 file changed, 9 insertions(+) create mode 100644 .github/CODEOWNERS diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS new file mode 100644 index 0000000..d6e642e --- /dev/null +++ b/.github/CODEOWNERS @@ -0,0 +1,9 @@ +# CODEOWNERS for wallbit-go. +# +# Every pull request is automatically reviewed by the repository +# maintainer. Order matters: the last matching pattern wins, so +# more specific paths below would override the catch-all above. +# +# Docs: https://docs.github.com/en/repositories/managing-your-repositories-settings-and-features/customizing-your-repository/about-code-owners + +* @jeremyjsx From 23d0b04d531382abe4a8b3cc45c495fe154151e9 Mon Sep 17 00:00:00 2001 From: JeremyDevCode Date: Mon, 20 Apr 2026 18:43:10 -0500 Subject: [PATCH 17/17] chore(changelog): set release date for v0.1.0-beta.1 --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0ff8da6..7a0d146 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,7 +10,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 Nothing yet — changes land here first and graduate to a versioned section at release time. -## [0.1.0-beta.1] — TBD +## [0.1.0-beta.1] — 2026-04-20 First public release.