From cac699bc7b5b019f607ba28e699a4aa54f7142e1 Mon Sep 17 00:00:00 2001 From: Salmatcre8 Date: Mon, 29 Jun 2026 15:44:33 +0100 Subject: [PATCH] feat(clients): add idiomatic Go SDK client MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add clients/go/ — a typed Go client for the Wraith API, one package per resource sharing a small transport in the root wraith package. - wraith: Client (New, WithHTTPClient, WithHeader), a shared Get that decodes JSON and maps non-2xx to *APIError, and query helpers. - transfers: Incoming, Outgoing, ForAddress, ByTx (+ ListParams, Transfer, Page). accounts: Summary, Transfers. assets: PopularAssets. nfts: Transfers. webhooks: List, Deliveries. status: Get. - Amounts are kept as strings to preserve full i128 precision. - Coverage tests for every package using httptest (request path/query shaping, response decoding, and *APIError mapping). - Go SDK workflow runs gofmt check, go vet, and go test ./... -race with a coverage summary. Closes #145 --- .github/workflows/go-sdk.yml | 33 +++++++ clients/go/.gitignore | 2 + clients/go/README.md | 91 +++++++++++++++++ clients/go/accounts/accounts.go | 57 +++++++++++ clients/go/accounts/accounts_test.go | 58 +++++++++++ clients/go/assets/assets.go | 65 ++++++++++++ clients/go/assets/assets_test.go | 43 ++++++++ clients/go/go.mod | 3 + clients/go/nfts/nfts.go | 77 +++++++++++++++ clients/go/nfts/nfts_test.go | 43 ++++++++ clients/go/status/status.go | 36 +++++++ clients/go/status/status_test.go | 31 ++++++ clients/go/transfers/transfers.go | 111 +++++++++++++++++++++ clients/go/transfers/transfers_test.go | 129 ++++++++++++++++++++++++ clients/go/webhooks/webhooks.go | 91 +++++++++++++++++ clients/go/webhooks/webhooks_test.go | 59 +++++++++++ clients/go/wraith/client.go | 132 +++++++++++++++++++++++++ clients/go/wraith/client_test.go | 84 ++++++++++++++++ clients/go/wraith/params.go | 33 +++++++ 19 files changed, 1178 insertions(+) create mode 100644 .github/workflows/go-sdk.yml create mode 100644 clients/go/.gitignore create mode 100644 clients/go/README.md create mode 100644 clients/go/accounts/accounts.go create mode 100644 clients/go/accounts/accounts_test.go create mode 100644 clients/go/assets/assets.go create mode 100644 clients/go/assets/assets_test.go create mode 100644 clients/go/go.mod create mode 100644 clients/go/nfts/nfts.go create mode 100644 clients/go/nfts/nfts_test.go create mode 100644 clients/go/status/status.go create mode 100644 clients/go/status/status_test.go create mode 100644 clients/go/transfers/transfers.go create mode 100644 clients/go/transfers/transfers_test.go create mode 100644 clients/go/webhooks/webhooks.go create mode 100644 clients/go/webhooks/webhooks_test.go create mode 100644 clients/go/wraith/client.go create mode 100644 clients/go/wraith/client_test.go create mode 100644 clients/go/wraith/params.go diff --git a/.github/workflows/go-sdk.yml b/.github/workflows/go-sdk.yml new file mode 100644 index 00000000..2a065d50 --- /dev/null +++ b/.github/workflows/go-sdk.yml @@ -0,0 +1,33 @@ +name: Go SDK + +on: + push: + branches: [main] + paths: + - 'clients/go/**' + - '.github/workflows/go-sdk.yml' + pull_request: + paths: + - 'clients/go/**' + - '.github/workflows/go-sdk.yml' + workflow_dispatch: + +defaults: + run: + working-directory: clients/go + +jobs: + test: + name: Vet & test wraith-go + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-go@v5 + with: + go-version: '1.22' + - name: Check formatting + run: test -z "$(gofmt -l .)" + - run: go vet ./... + - run: go test ./... -race -coverprofile=coverage.out + - name: Coverage summary + run: go tool cover -func=coverage.out | tail -1 diff --git a/clients/go/.gitignore b/clients/go/.gitignore new file mode 100644 index 00000000..46b889c2 --- /dev/null +++ b/clients/go/.gitignore @@ -0,0 +1,2 @@ +coverage.out +*.test diff --git a/clients/go/README.md b/clients/go/README.md new file mode 100644 index 00000000..216c25ae --- /dev/null +++ b/clients/go/README.md @@ -0,0 +1,91 @@ +# wraith-go + +Idiomatic Go client for the [Wraith](https://github.com/Miracle656/wraith) +Soroban token-transfer indexer REST API. + +The client is split into **one package per resource** — `transfers`, +`accounts`, `assets`, `nfts`, `webhooks`, `status` — all sharing a small +transport in the root `wraith` package. Amounts are returned as `string` to +preserve full i128 precision. + +## Install + +```bash +go get github.com/Miracle656/wraith/clients/go +``` + +## Usage + +```go +package main + +import ( + "context" + "fmt" + "log" + + "github.com/Miracle656/wraith/clients/go/wraith" + "github.com/Miracle656/wraith/clients/go/transfers" + "github.com/Miracle656/wraith/clients/go/assets" +) + +func main() { + c := wraith.New("https://wraith.example.com") + ctx := context.Background() + + // Transfers received by an address. + tc := transfers.New(c) + page, err := tc.Incoming(ctx, "GABC...", &transfers.ListParams{ + Limit: wraith.IntPtr(100), + }) + if err != nil { + log.Fatal(err) + } + for _, t := range page.Transfers { + fmt.Println(t.ContractID, t.Amount, t.EventType) + } + + // Most-active assets. + ac := assets.New(c) + pop, err := ac.PopularAssets(ctx, &assets.PopularParams{Window: "24h", By: "volume"}) + if err != nil { + log.Fatal(err) + } + for _, a := range pop.Assets { + fmt.Println(a.ContractID, a.TransferCount, a.Volume) + } +} +``` + +A non-2xx response is returned as a `*wraith.APIError`: + +```go +page, err := tc.Incoming(ctx, "GABC...", nil) +var apiErr *wraith.APIError +if errors.As(err, &apiErr) { + fmt.Println(apiErr.StatusCode, apiErr.Message) +} +``` + +## Packages + +| Package | Constructor | List methods | +| ----------- | ----------------------- | --------------------------------------------- | +| `wraith` | `wraith.New(baseURL)` | shared transport, `APIError`, options | +| `transfers` | `transfers.New(c)` | `Incoming`, `Outgoing`, `ForAddress`, `ByTx` | +| `accounts` | `accounts.New(c)` | `Summary`, `Transfers` | +| `assets` | `assets.New(c)` | `PopularAssets` | +| `nfts` | `nfts.New(c)` | `Transfers` | +| `webhooks` | `webhooks.New(c)` | `List`, `Deliveries` | +| `status` | `status.New(c)` | `Get` | + +Options: `wraith.WithHTTPClient(*http.Client)` and `wraith.WithHeader(k, v)`. + +## Development + +```bash +cd clients/go +go test ./... -cover +go vet ./... +gofmt -l . +``` diff --git a/clients/go/accounts/accounts.go b/clients/go/accounts/accounts.go new file mode 100644 index 00000000..59d7cd39 --- /dev/null +++ b/clients/go/accounts/accounts.go @@ -0,0 +1,57 @@ +// Package accounts is the Wraith client for account-level endpoints. +package accounts + +import ( + "context" + "net/url" + + "github.com/Miracle656/wraith/clients/go/transfers" + "github.com/Miracle656/wraith/clients/go/wraith" +) + +// Client accesses the /accounts endpoints. +type Client struct { + c *wraith.Client +} + +// New returns an accounts client backed by a shared *wraith.Client. +func New(c *wraith.Client) *Client { return &Client{c: c} } + +// AssetHolding is one row of an account's per-asset summary. Amounts are kept +// as strings to preserve full i128 precision. +type AssetHolding struct { + ContractID string `json:"contractId"` + TotalSent string `json:"totalSent"` + TotalReceived string `json:"totalReceived"` + Net string `json:"net"` + TxCount int `json:"txCount"` + LastActivityAt *string `json:"lastActivityAt"` + DisplayTotalSent string `json:"displayTotalSent,omitempty"` + DisplayTotalReceived string `json:"displayTotalReceived,omitempty"` + DisplayNet string `json:"displayNet,omitempty"` +} + +// Summary is an account's holdings across every asset it has touched. +type Summary struct { + Address string `json:"address"` + Assets []AssetHolding `json:"assets"` +} + +// Summary returns an account's per-asset holdings (GET /accounts/{address}/summary). +func (a *Client) Summary(ctx context.Context, address string) (*Summary, error) { + var summary Summary + if err := a.c.Get(ctx, "/accounts/"+url.PathEscape(address)+"/summary", nil, &summary); err != nil { + return nil, err + } + return &summary, nil +} + +// Transfers returns an account's transfers (GET /accounts/{address}/transfers). +// It reuses the shared transfers.ListParams filters and transfers.Page shape. +func (a *Client) Transfers(ctx context.Context, address string, params *transfers.ListParams) (*transfers.Page, error) { + var page transfers.Page + if err := a.c.Get(ctx, "/accounts/"+url.PathEscape(address)+"/transfers", params.Query(), &page); err != nil { + return nil, err + } + return &page, nil +} diff --git a/clients/go/accounts/accounts_test.go b/clients/go/accounts/accounts_test.go new file mode 100644 index 00000000..6b4bb8b1 --- /dev/null +++ b/clients/go/accounts/accounts_test.go @@ -0,0 +1,58 @@ +package accounts_test + +import ( + "context" + "net/http" + "net/http/httptest" + "testing" + + "github.com/Miracle656/wraith/clients/go/accounts" + "github.com/Miracle656/wraith/clients/go/transfers" + "github.com/Miracle656/wraith/clients/go/wraith" +) + +func TestSummary(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/accounts/GABC/summary" { + t.Errorf("path = %q", r.URL.Path) + } + w.Header().Set("content-type", "application/json") + _, _ = w.Write([]byte(`{"address":"GABC","assets":[{"contractId":"CABC","totalSent":"10","totalReceived":"30","net":"20","txCount":3}]}`)) + })) + defer srv.Close() + + c := accounts.New(wraith.New(srv.URL)) + sum, err := c.Summary(context.Background(), "GABC") + if err != nil { + t.Fatalf("Summary: %v", err) + } + if sum.Address != "GABC" || len(sum.Assets) != 1 { + t.Fatalf("unexpected summary: %+v", sum) + } + if sum.Assets[0].Net != "20" || sum.Assets[0].TxCount != 3 { + t.Errorf("unexpected holding: %+v", sum.Assets[0]) + } +} + +func TestTransfers(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/accounts/GABC/transfers" { + t.Errorf("path = %q", r.URL.Path) + } + if r.URL.Query().Get("offset") != "0" { + t.Errorf("offset = %q, want 0", r.URL.Query().Get("offset")) + } + w.Header().Set("content-type", "application/json") + _, _ = w.Write([]byte(`{"total":0,"limit":50,"offset":0,"nextCursor":null,"transfers":[]}`)) + })) + defer srv.Close() + + c := accounts.New(wraith.New(srv.URL)) + page, err := c.Transfers(context.Background(), "GABC", &transfers.ListParams{Offset: wraith.IntPtr(0)}) + if err != nil { + t.Fatalf("Transfers: %v", err) + } + if page.Limit != 50 { + t.Errorf("limit = %d, want 50", page.Limit) + } +} diff --git a/clients/go/assets/assets.go b/clients/go/assets/assets.go new file mode 100644 index 00000000..99a97d36 --- /dev/null +++ b/clients/go/assets/assets.go @@ -0,0 +1,65 @@ +// Package assets is the Wraith client for asset-level endpoints. +package assets + +import ( + "context" + "net/url" + + "github.com/Miracle656/wraith/clients/go/wraith" +) + +// Client accesses the /assets endpoints. +type Client struct { + c *wraith.Client +} + +// New returns an assets client backed by a shared *wraith.Client. +func New(c *wraith.Client) *Client { return &Client{c: c} } + +// PopularAsset is one asset on the /assets/popular leaderboard. Volume is kept +// as a string to preserve full i128 precision. +type PopularAsset struct { + ContractID string `json:"contractId"` + TransferCount int `json:"transferCount"` + Volume string `json:"volume"` + DisplayVolume string `json:"displayVolume,omitempty"` +} + +// Popular is the /assets/popular response: a ranked list plus the query window. +type Popular struct { + Window string `json:"window"` + By string `json:"by"` + Assets []PopularAsset `json:"assets"` + Total int `json:"total"` + Limit int `json:"limit"` + Offset int `json:"offset"` +} + +// PopularParams are the optional filters for PopularAssets. +type PopularParams struct { + Window string // e.g. "24h", "7d" + By string // e.g. "volume", "count" + Limit *int + Offset *int +} + +func (p *PopularParams) query() url.Values { + q := url.Values{} + if p == nil { + return q + } + wraith.AddString(q, "window", p.Window) + wraith.AddString(q, "by", p.By) + wraith.AddInt(q, "limit", p.Limit) + wraith.AddInt(q, "offset", p.Offset) + return q +} + +// PopularAssets returns the most-active assets (GET /assets/popular). +func (a *Client) PopularAssets(ctx context.Context, params *PopularParams) (*Popular, error) { + var popular Popular + if err := a.c.Get(ctx, "/assets/popular", params.query(), &popular); err != nil { + return nil, err + } + return &popular, nil +} diff --git a/clients/go/assets/assets_test.go b/clients/go/assets/assets_test.go new file mode 100644 index 00000000..b8ebafc4 --- /dev/null +++ b/clients/go/assets/assets_test.go @@ -0,0 +1,43 @@ +package assets_test + +import ( + "context" + "net/http" + "net/http/httptest" + "testing" + + "github.com/Miracle656/wraith/clients/go/assets" + "github.com/Miracle656/wraith/clients/go/wraith" +) + +func TestPopularAssets(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/assets/popular" { + t.Errorf("path = %q", r.URL.Path) + } + q := r.URL.Query() + if q.Get("window") != "24h" || q.Get("by") != "volume" { + t.Errorf("query = %v", q) + } + w.Header().Set("content-type", "application/json") + _, _ = w.Write([]byte(`{"window":"24h","by":"volume","total":1,"limit":10,"offset":0, + "assets":[{"contractId":"CABC","transferCount":5,"volume":"100","displayVolume":"1.0"}]}`)) + })) + defer srv.Close() + + c := assets.New(wraith.New(srv.URL)) + pop, err := c.PopularAssets(context.Background(), &assets.PopularParams{ + Window: "24h", + By: "volume", + Limit: wraith.IntPtr(10), + }) + if err != nil { + t.Fatalf("PopularAssets: %v", err) + } + if pop.Window != "24h" || len(pop.Assets) != 1 { + t.Fatalf("unexpected response: %+v", pop) + } + if pop.Assets[0].ContractID != "CABC" || pop.Assets[0].TransferCount != 5 { + t.Errorf("unexpected asset: %+v", pop.Assets[0]) + } +} diff --git a/clients/go/go.mod b/clients/go/go.mod new file mode 100644 index 00000000..df09f18a --- /dev/null +++ b/clients/go/go.mod @@ -0,0 +1,3 @@ +module github.com/Miracle656/wraith/clients/go + +go 1.22 diff --git a/clients/go/nfts/nfts.go b/clients/go/nfts/nfts.go new file mode 100644 index 00000000..854b9d49 --- /dev/null +++ b/clients/go/nfts/nfts.go @@ -0,0 +1,77 @@ +// Package nfts is the Wraith client for NFT (token-id) endpoints. +package nfts + +import ( + "context" + "net/url" + + "github.com/Miracle656/wraith/clients/go/wraith" +) + +// Client accesses the /nfts endpoints. +type Client struct { + c *wraith.Client +} + +// New returns an nfts client backed by a shared *wraith.Client. +func New(c *wraith.Client) *Client { return &Client{c: c} } + +// Transfer is a single NFT transfer event. +type Transfer struct { + ID int64 `json:"id"` + ContractID string `json:"contractId"` + TokenID string `json:"tokenId"` + FromAddress *string `json:"fromAddress"` + ToAddress *string `json:"toAddress"` + Ledger int64 `json:"ledger"` + LedgerClosedAt string `json:"ledgerClosedAt"` + TxHash string `json:"txHash"` + EventID string `json:"eventId"` + CreatedAt string `json:"createdAt"` +} + +// Page is a page of NFT transfers plus pagination metadata. +type Page struct { + Transfers []Transfer `json:"transfers"` + Total int `json:"total"` + Limit int `json:"limit"` + Offset int `json:"offset"` + NextCursor *string `json:"nextCursor"` +} + +// TransfersParams are the optional filters for Transfers. +type TransfersParams struct { + Contract string + TokenID string + Address string + FromLedger *int + ToLedger *int + Limit *int + Offset *int + Cursor string +} + +func (p *TransfersParams) query() url.Values { + q := url.Values{} + if p == nil { + return q + } + wraith.AddString(q, "contract", p.Contract) + wraith.AddString(q, "token_id", p.TokenID) + wraith.AddString(q, "address", p.Address) + wraith.AddInt(q, "fromLedger", p.FromLedger) + wraith.AddInt(q, "toLedger", p.ToLedger) + wraith.AddInt(q, "limit", p.Limit) + wraith.AddInt(q, "offset", p.Offset) + wraith.AddString(q, "cursor", p.Cursor) + return q +} + +// Transfers returns NFT transfers (GET /nfts/transfers). +func (n *Client) Transfers(ctx context.Context, params *TransfersParams) (*Page, error) { + var page Page + if err := n.c.Get(ctx, "/nfts/transfers", params.query(), &page); err != nil { + return nil, err + } + return &page, nil +} diff --git a/clients/go/nfts/nfts_test.go b/clients/go/nfts/nfts_test.go new file mode 100644 index 00000000..725de14d --- /dev/null +++ b/clients/go/nfts/nfts_test.go @@ -0,0 +1,43 @@ +package nfts_test + +import ( + "context" + "net/http" + "net/http/httptest" + "testing" + + "github.com/Miracle656/wraith/clients/go/nfts" + "github.com/Miracle656/wraith/clients/go/wraith" +) + +func TestTransfers(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/nfts/transfers" { + t.Errorf("path = %q", r.URL.Path) + } + q := r.URL.Query() + if q.Get("contract") != "CNFT" || q.Get("token_id") != "42" { + t.Errorf("query = %v", q) + } + w.Header().Set("content-type", "application/json") + _, _ = w.Write([]byte(`{"total":1,"limit":50,"offset":0,"nextCursor":null, + "transfers":[{"id":1,"contractId":"CNFT","tokenId":"42","fromAddress":null, + "toAddress":"GABC","ledger":7,"ledgerClosedAt":"t","txHash":"h","eventId":"e","createdAt":"t"}]}`)) + })) + defer srv.Close() + + c := nfts.New(wraith.New(srv.URL)) + page, err := c.Transfers(context.Background(), &nfts.TransfersParams{ + Contract: "CNFT", + TokenID: "42", + }) + if err != nil { + t.Fatalf("Transfers: %v", err) + } + if len(page.Transfers) != 1 || page.Transfers[0].TokenID != "42" { + t.Fatalf("unexpected page: %+v", page) + } + if page.Transfers[0].FromAddress != nil { + t.Errorf("fromAddress = %v, want nil", *page.Transfers[0].FromAddress) + } +} diff --git a/clients/go/status/status.go b/clients/go/status/status.go new file mode 100644 index 00000000..5fff9de9 --- /dev/null +++ b/clients/go/status/status.go @@ -0,0 +1,36 @@ +// Package status is the Wraith client for indexer status endpoints. +package status + +import ( + "context" + + "github.com/Miracle656/wraith/clients/go/wraith" +) + +// Client accesses the /status endpoint. +type Client struct { + c *wraith.Client +} + +// New returns a status client backed by a shared *wraith.Client. +func New(c *wraith.Client) *Client { return &Client{c: c} } + +// Status reports the indexer's progress and lag. +type Status struct { + OK bool `json:"ok"` + LastIndexedLedger int64 `json:"lastIndexedLedger"` + LatestLedger int64 `json:"latestLedger"` + LagLedgers int64 `json:"lagLedgers"` + StartedAt string `json:"startedAt"` + UptimeSeconds int64 `json:"uptimeSeconds"` + TotalIndexed int64 `json:"totalIndexed"` +} + +// Get returns the current indexer status (GET /status). +func (s *Client) Get(ctx context.Context) (*Status, error) { + var st Status + if err := s.c.Get(ctx, "/status", nil, &st); err != nil { + return nil, err + } + return &st, nil +} diff --git a/clients/go/status/status_test.go b/clients/go/status/status_test.go new file mode 100644 index 00000000..005d71e0 --- /dev/null +++ b/clients/go/status/status_test.go @@ -0,0 +1,31 @@ +package status_test + +import ( + "context" + "net/http" + "net/http/httptest" + "testing" + + "github.com/Miracle656/wraith/clients/go/status" + "github.com/Miracle656/wraith/clients/go/wraith" +) + +func TestGet(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/status" { + t.Errorf("path = %q", r.URL.Path) + } + w.Header().Set("content-type", "application/json") + _, _ = w.Write([]byte(`{"ok":true,"lastIndexedLedger":100,"latestLedger":102,"lagLedgers":2,"startedAt":"t","uptimeSeconds":3600,"totalIndexed":5000}`)) + })) + defer srv.Close() + + c := status.New(wraith.New(srv.URL)) + st, err := c.Get(context.Background()) + if err != nil { + t.Fatalf("Get: %v", err) + } + if !st.OK || st.LagLedgers != 2 || st.TotalIndexed != 5000 { + t.Errorf("unexpected status: %+v", st) + } +} diff --git a/clients/go/transfers/transfers.go b/clients/go/transfers/transfers.go new file mode 100644 index 00000000..f843dd40 --- /dev/null +++ b/clients/go/transfers/transfers.go @@ -0,0 +1,111 @@ +// Package transfers is the Wraith client for token-transfer endpoints. +package transfers + +import ( + "context" + "net/url" + + "github.com/Miracle656/wraith/clients/go/wraith" +) + +// Client accesses the /transfers endpoints. +type Client struct { + c *wraith.Client +} + +// New returns a transfers client backed by a shared *wraith.Client. +func New(c *wraith.Client) *Client { return &Client{c: c} } + +// Transfer is a single SEP-41/CAP-67 token event (transfer, mint, burn, +// clawback). Amounts are kept as strings to preserve full i128 precision. +type Transfer struct { + ID int64 `json:"id"` + ContractID string `json:"contractId"` + EventType string `json:"eventType"` + FromAddress *string `json:"fromAddress"` + ToAddress *string `json:"toAddress"` + Amount string `json:"amount"` + DisplayAmount string `json:"displayAmount"` + Ledger int64 `json:"ledger"` + LedgerClosedAt string `json:"ledgerClosedAt"` + TxHash string `json:"txHash"` + EventID string `json:"eventId"` + Direction string `json:"direction,omitempty"` +} + +// Page is a page of transfers plus pagination metadata. +type Page struct { + Transfers []Transfer `json:"transfers"` + Total int `json:"total"` + Limit int `json:"limit"` + Offset int `json:"offset"` + NextCursor *string `json:"nextCursor"` +} + +// ListParams holds the optional filters shared by the transfer list endpoints. +type ListParams struct { + ContractID string + Token string + EventType []string + FromLedger *int + ToLedger *int + FromDate string + ToDate string + Limit *int + Offset *int + Cursor string +} + +func (p *ListParams) Query() url.Values { + q := url.Values{} + if p == nil { + return q + } + wraith.AddString(q, "contractId", p.ContractID) + wraith.AddString(q, "token", p.Token) + wraith.AddStrings(q, "eventType", p.EventType) + wraith.AddInt(q, "fromLedger", p.FromLedger) + wraith.AddInt(q, "toLedger", p.ToLedger) + wraith.AddString(q, "fromDate", p.FromDate) + wraith.AddString(q, "toDate", p.ToDate) + wraith.AddInt(q, "limit", p.Limit) + wraith.AddInt(q, "offset", p.Offset) + wraith.AddString(q, "cursor", p.Cursor) + return q +} + +// Incoming returns transfers received by address (GET /transfers/incoming/{address}). +func (t *Client) Incoming(ctx context.Context, address string, params *ListParams) (*Page, error) { + var page Page + if err := t.c.Get(ctx, "/transfers/incoming/"+url.PathEscape(address), params.Query(), &page); err != nil { + return nil, err + } + return &page, nil +} + +// Outgoing returns transfers sent by address (GET /transfers/outgoing/{address}). +func (t *Client) Outgoing(ctx context.Context, address string, params *ListParams) (*Page, error) { + var page Page + if err := t.c.Get(ctx, "/transfers/outgoing/"+url.PathEscape(address), params.Query(), &page); err != nil { + return nil, err + } + return &page, nil +} + +// ForAddress returns all transfers touching address (GET /transfers/address/{address}). +func (t *Client) ForAddress(ctx context.Context, address string, params *ListParams) (*Page, error) { + var page Page + if err := t.c.Get(ctx, "/transfers/address/"+url.PathEscape(address), params.Query(), &page); err != nil { + return nil, err + } + return &page, nil +} + +// ByTx returns the transfers in a single transaction (GET /transfers/tx/{txHash}). +func (t *Client) ByTx(ctx context.Context, txHash string) ([]Transfer, error) { + var transfers []Transfer + if err := t.c.Get(ctx, "/transfers/tx/"+url.PathEscape(txHash), nil, &transfers); err != nil { + return nil, err + } + return transfers, nil +} diff --git a/clients/go/transfers/transfers_test.go b/clients/go/transfers/transfers_test.go new file mode 100644 index 00000000..ff7ab90e --- /dev/null +++ b/clients/go/transfers/transfers_test.go @@ -0,0 +1,129 @@ +package transfers_test + +import ( + "context" + "net/http" + "net/http/httptest" + "testing" + + "github.com/Miracle656/wraith/clients/go/transfers" + "github.com/Miracle656/wraith/clients/go/wraith" +) + +func TestIncoming(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/transfers/incoming/GABC" { + t.Errorf("path = %q", r.URL.Path) + } + q := r.URL.Query() + if q.Get("limit") != "50" { + t.Errorf("limit = %q, want 50", q.Get("limit")) + } + if q.Get("contractId") != "CABC" { + t.Errorf("contractId = %q, want CABC", q.Get("contractId")) + } + w.Header().Set("content-type", "application/json") + _, _ = w.Write([]byte(`{ + "total":1,"limit":50,"offset":0,"nextCursor":null, + "transfers":[{"id":1,"contractId":"CABC","eventType":"transfer", + "fromAddress":"GFROM","toAddress":"GABC","amount":"100", + "displayAmount":"0.0000100","ledger":10,"ledgerClosedAt":"2026-01-01T00:00:00Z", + "txHash":"deadbeef","eventId":"evt-1"}]}`)) + })) + defer srv.Close() + + c := transfers.New(wraith.New(srv.URL)) + page, err := c.Incoming(context.Background(), "GABC", &transfers.ListParams{ + ContractID: "CABC", + Limit: wraith.IntPtr(50), + }) + if err != nil { + t.Fatalf("Incoming: %v", err) + } + if page.Total != 1 || len(page.Transfers) != 1 { + t.Fatalf("unexpected page: %+v", page) + } + tr := page.Transfers[0] + if tr.ContractID != "CABC" || tr.Amount != "100" || tr.EventType != "transfer" { + t.Errorf("unexpected transfer: %+v", tr) + } +} + +func TestByTxReturnsArray(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/transfers/tx/abc123" { + t.Errorf("path = %q", r.URL.Path) + } + w.Header().Set("content-type", "application/json") + _, _ = w.Write([]byte(`[{"id":1,"contractId":"CABC","eventType":"mint","amount":"5","ledger":1,"ledgerClosedAt":"t","txHash":"abc123","eventId":"e1"}]`)) + })) + defer srv.Close() + + c := transfers.New(wraith.New(srv.URL)) + list, err := c.ByTx(context.Background(), "abc123") + if err != nil { + t.Fatalf("ByTx: %v", err) + } + if len(list) != 1 || list[0].EventType != "mint" { + t.Errorf("unexpected list: %+v", list) + } +} + +func TestOutgoingAndForAddress(t *testing.T) { + cases := []struct { + name string + wantPath string + call func(c *transfers.Client) (*transfers.Page, error) + }{ + { + name: "outgoing", + wantPath: "/transfers/outgoing/GABC", + call: func(c *transfers.Client) (*transfers.Page, error) { + return c.Outgoing(context.Background(), "GABC", nil) + }, + }, + { + name: "forAddress", + wantPath: "/transfers/address/GABC", + call: func(c *transfers.Client) (*transfers.Page, error) { + return c.ForAddress(context.Background(), "GABC", &transfers.ListParams{EventType: []string{"transfer", "mint"}}) + }, + }, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != tc.wantPath { + t.Errorf("path = %q, want %q", r.URL.Path, tc.wantPath) + } + w.Header().Set("content-type", "application/json") + _, _ = w.Write([]byte(`{"total":0,"limit":50,"offset":0,"nextCursor":null,"transfers":[]}`)) + })) + defer srv.Close() + + c := transfers.New(wraith.New(srv.URL)) + page, err := tc.call(c) + if err != nil { + t.Fatalf("%s: %v", tc.name, err) + } + if page.Limit != 50 { + t.Errorf("limit = %d, want 50", page.Limit) + } + }) + } +} + +func TestListParamsRepeatedEventType(t *testing.T) { + p := &transfers.ListParams{EventType: []string{"transfer", "mint"}} + got := p.Query()["eventType"] + if len(got) != 2 || got[0] != "transfer" || got[1] != "mint" { + t.Errorf("eventType = %v, want [transfer mint]", got) + } +} + +func TestListParamsNilProducesEmptyQuery(t *testing.T) { + var p *transfers.ListParams + if got := p.Query().Encode(); got != "" { + t.Errorf("nil params query = %q, want empty", got) + } +} diff --git a/clients/go/webhooks/webhooks.go b/clients/go/webhooks/webhooks.go new file mode 100644 index 00000000..14e64174 --- /dev/null +++ b/clients/go/webhooks/webhooks.go @@ -0,0 +1,91 @@ +// Package webhooks is the Wraith client for webhook subscription endpoints. +package webhooks + +import ( + "context" + "net/url" + "strconv" + + "github.com/Miracle656/wraith/clients/go/wraith" +) + +// Client accesses the /webhooks endpoints. +type Client struct { + c *wraith.Client +} + +// New returns a webhooks client backed by a shared *wraith.Client. +func New(c *wraith.Client) *Client { return &Client{c: c} } + +// Subscription is a registered webhook subscription. +type Subscription struct { + ID int64 `json:"id"` + URL string `json:"url"` + Filter *string `json:"filter"` + Active bool `json:"active"` + CreatedAt string `json:"createdAt"` + UpdatedAt string `json:"updatedAt"` +} + +// SubscriptionList is the /webhooks response. +type SubscriptionList struct { + Subscriptions []Subscription `json:"subscriptions"` +} + +// Delivery is one webhook delivery attempt record. +type Delivery struct { + ID int64 `json:"id"` + EventID string `json:"eventId"` + Status string `json:"status"` + Attempts int `json:"attempts"` + LastStatusCode *int `json:"lastStatusCode"` + LastError *string `json:"lastError"` + NextRetryAt *string `json:"nextRetryAt"` + DeliveredAt *string `json:"deliveredAt"` + CreatedAt string `json:"createdAt"` +} + +// DeliveryList is the /webhooks/{id}/deliveries response. +type DeliveryList struct { + Deliveries []Delivery `json:"deliveries"` + Total int `json:"total"` + Limit int `json:"limit"` + Offset int `json:"offset"` +} + +// DeliveriesParams are the optional filters for Deliveries. +type DeliveriesParams struct { + Status string + Limit *int + Offset *int +} + +func (p *DeliveriesParams) query() url.Values { + q := url.Values{} + if p == nil { + return q + } + wraith.AddString(q, "status", p.Status) + wraith.AddInt(q, "limit", p.Limit) + wraith.AddInt(q, "offset", p.Offset) + return q +} + +// List returns all webhook subscriptions (GET /webhooks). +func (w *Client) List(ctx context.Context) (*SubscriptionList, error) { + var list SubscriptionList + if err := w.c.Get(ctx, "/webhooks", nil, &list); err != nil { + return nil, err + } + return &list, nil +} + +// Deliveries returns a webhook's delivery log (GET /webhooks/{id}/deliveries). +func (w *Client) Deliveries(ctx context.Context, id int64, params *DeliveriesParams) (*DeliveryList, error) { + var list DeliveryList + path := "/webhooks/" + strconv.FormatInt(id, 10) + "/deliveries" + if err := w.c.Get(ctx, path, params.query(), &list); err != nil { + return nil, err + } + return &list, nil +} diff --git a/clients/go/webhooks/webhooks_test.go b/clients/go/webhooks/webhooks_test.go new file mode 100644 index 00000000..1e0d519b --- /dev/null +++ b/clients/go/webhooks/webhooks_test.go @@ -0,0 +1,59 @@ +package webhooks_test + +import ( + "context" + "net/http" + "net/http/httptest" + "testing" + + "github.com/Miracle656/wraith/clients/go/webhooks" + "github.com/Miracle656/wraith/clients/go/wraith" +) + +func TestList(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/webhooks" { + t.Errorf("path = %q", r.URL.Path) + } + w.Header().Set("content-type", "application/json") + _, _ = w.Write([]byte(`{"subscriptions":[{"id":1,"url":"https://h","filter":null,"active":true,"createdAt":"t","updatedAt":"t"}]}`)) + })) + defer srv.Close() + + c := webhooks.New(wraith.New(srv.URL)) + list, err := c.List(context.Background()) + if err != nil { + t.Fatalf("List: %v", err) + } + if len(list.Subscriptions) != 1 || list.Subscriptions[0].ID != 1 || !list.Subscriptions[0].Active { + t.Errorf("unexpected list: %+v", list) + } +} + +func TestDeliveries(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/webhooks/7/deliveries" { + t.Errorf("path = %q", r.URL.Path) + } + if r.URL.Query().Get("status") != "failed" { + t.Errorf("status = %q, want failed", r.URL.Query().Get("status")) + } + w.Header().Set("content-type", "application/json") + _, _ = w.Write([]byte(`{"total":1,"limit":50,"offset":0, + "deliveries":[{"id":9,"eventId":"e","status":"failed","attempts":3, + "lastStatusCode":500,"lastError":"boom","nextRetryAt":null,"deliveredAt":null,"createdAt":"t"}]}`)) + })) + defer srv.Close() + + c := webhooks.New(wraith.New(srv.URL)) + list, err := c.Deliveries(context.Background(), 7, &webhooks.DeliveriesParams{Status: "failed"}) + if err != nil { + t.Fatalf("Deliveries: %v", err) + } + if len(list.Deliveries) != 1 || list.Deliveries[0].Status != "failed" || list.Deliveries[0].Attempts != 3 { + t.Fatalf("unexpected deliveries: %+v", list) + } + if list.Deliveries[0].LastStatusCode == nil || *list.Deliveries[0].LastStatusCode != 500 { + t.Errorf("lastStatusCode = %v, want 500", list.Deliveries[0].LastStatusCode) + } +} diff --git a/clients/go/wraith/client.go b/clients/go/wraith/client.go new file mode 100644 index 00000000..091dd927 --- /dev/null +++ b/clients/go/wraith/client.go @@ -0,0 +1,132 @@ +// Package wraith provides the shared transport used by the resource-specific +// Wraith API client packages (transfers, accounts, assets, nfts, webhooks, +// status). Construct a *Client with New and hand it to a resource package: +// +// c := wraith.New("https://wraith.example.com") +// tc := transfers.New(c) +// page, err := tc.Incoming(ctx, "GABC...", nil) +package wraith + +import ( + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "net/url" + "strings" + "time" +) + +// DefaultTimeout is applied when no custom *http.Client is supplied. +const DefaultTimeout = 30 * time.Second + +// Client is a thin, shared HTTP wrapper over the Wraith REST API. It is safe +// for concurrent use. +type Client struct { + baseURL string + httpClient *http.Client + headers map[string]string +} + +// Option configures a Client. +type Option func(*Client) + +// WithHTTPClient sets a custom *http.Client (e.g. with a different timeout or +// transport). +func WithHTTPClient(h *http.Client) Option { + return func(c *Client) { + if h != nil { + c.httpClient = h + } + } +} + +// WithHeader adds a header sent with every request (e.g. an API key). +func WithHeader(key, value string) Option { + return func(c *Client) { + c.headers[key] = value + } +} + +// New creates a Client for the Wraith API rooted at baseURL. +func New(baseURL string, opts ...Option) *Client { + c := &Client{ + baseURL: strings.TrimRight(baseURL, "/"), + httpClient: &http.Client{Timeout: DefaultTimeout}, + headers: make(map[string]string), + } + for _, opt := range opts { + opt(c) + } + return c +} + +// APIError is returned when the API responds with a non-2xx status. +type APIError struct { + // StatusCode is the HTTP status code of the response. + StatusCode int + // Message is the API's "error" field, or the raw body when absent. + Message string +} + +func (e *APIError) Error() string { + if e.Message != "" { + return fmt.Sprintf("wraith: api error %d: %s", e.StatusCode, e.Message) + } + return fmt.Sprintf("wraith: api error %d", e.StatusCode) +} + +// Get performs a GET against path (e.g. "/transfers/incoming/GABC"), attaching +// the given query, and decodes the JSON response body into out. A non-2xx +// status yields an *APIError. It is exported so resource packages in this +// module can share one transport. +func (c *Client) Get(ctx context.Context, path string, query url.Values, out any) error { + u := c.baseURL + path + if enc := query.Encode(); enc != "" { + u += "?" + enc + } + + req, err := http.NewRequestWithContext(ctx, http.MethodGet, u, nil) + if err != nil { + return fmt.Errorf("wraith: build request: %w", err) + } + req.Header.Set("Accept", "application/json") + for k, v := range c.headers { + req.Header.Set(k, v) + } + + resp, err := c.httpClient.Do(req) + if err != nil { + return fmt.Errorf("wraith: request failed: %w", err) + } + defer resp.Body.Close() + + body, err := io.ReadAll(resp.Body) + if err != nil { + return fmt.Errorf("wraith: read body: %w", err) + } + + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + return &APIError{StatusCode: resp.StatusCode, Message: errorMessage(body)} + } + + if out != nil { + if err := json.Unmarshal(body, out); err != nil { + return fmt.Errorf("wraith: decode response: %w", err) + } + } + return nil +} + +// errorMessage extracts the API's {"error": "..."} field, falling back to the +// trimmed raw body. +func errorMessage(body []byte) string { + var parsed struct { + Error string `json:"error"` + } + if json.Unmarshal(body, &parsed) == nil && parsed.Error != "" { + return parsed.Error + } + return strings.TrimSpace(string(body)) +} diff --git a/clients/go/wraith/client_test.go b/clients/go/wraith/client_test.go new file mode 100644 index 00000000..6fb630e8 --- /dev/null +++ b/clients/go/wraith/client_test.go @@ -0,0 +1,84 @@ +package wraith_test + +import ( + "context" + "net/http" + "net/http/httptest" + "net/url" + "testing" + + "github.com/Miracle656/wraith/clients/go/wraith" +) + +func TestGetDecodesJSON(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/thing" { + t.Errorf("path = %q, want /thing", r.URL.Path) + } + if got := r.URL.Query().Get("a"); got != "1" { + t.Errorf("query a = %q, want 1", got) + } + if r.Header.Get("X-Api-Key") != "secret" { + t.Errorf("missing custom header") + } + w.Header().Set("content-type", "application/json") + _, _ = w.Write([]byte(`{"name":"ok"}`)) + })) + defer srv.Close() + + c := wraith.New(srv.URL, wraith.WithHeader("X-Api-Key", "secret")) + var out struct { + Name string `json:"name"` + } + q := url.Values{} + q.Set("a", "1") + if err := c.Get(context.Background(), "/thing", q, &out); err != nil { + t.Fatalf("Get: %v", err) + } + if out.Name != "ok" { + t.Errorf("name = %q, want ok", out.Name) + } +} + +func TestGetReturnsAPIError(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("content-type", "application/json") + w.WriteHeader(http.StatusInternalServerError) + _, _ = w.Write([]byte(`{"error":"boom"}`)) + })) + defer srv.Close() + + c := wraith.New(srv.URL) + err := c.Get(context.Background(), "/x", nil, nil) + if err == nil { + t.Fatal("expected error, got nil") + } + apiErr, ok := err.(*wraith.APIError) + if !ok { + t.Fatalf("error type = %T, want *wraith.APIError", err) + } + if apiErr.StatusCode != 500 { + t.Errorf("status = %d, want 500", apiErr.StatusCode) + } + if apiErr.Message != "boom" { + t.Errorf("message = %q, want boom", apiErr.Message) + } +} + +func TestBaseURLTrailingSlashTrimmed(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + // Assert inside the handler so there is no cross-goroutine shared state + // for the race detector to flag. + if r.URL.Path != "/status" { + t.Errorf("path = %q, want /status (no double slash)", r.URL.Path) + } + w.Header().Set("content-type", "application/json") + _, _ = w.Write([]byte(`{}`)) + })) + defer srv.Close() + + c := wraith.New(srv.URL+"/", wraith.WithHTTPClient(&http.Client{})) + if err := c.Get(context.Background(), "/status", nil, nil); err != nil { + t.Fatalf("Get: %v", err) + } +} diff --git a/clients/go/wraith/params.go b/clients/go/wraith/params.go new file mode 100644 index 00000000..198c1e99 --- /dev/null +++ b/clients/go/wraith/params.go @@ -0,0 +1,33 @@ +package wraith + +import ( + "net/url" + "strconv" +) + +// AddString sets key=val on q when val is non-empty. +func AddString(q url.Values, key, val string) { + if val != "" { + q.Set(key, val) + } +} + +// AddInt sets key=*val on q when val is non-nil. Pointers distinguish "unset" +// from a meaningful zero (e.g. offset=0). +func AddInt(q url.Values, key string, val *int) { + if val != nil { + q.Set(key, strconv.Itoa(*val)) + } +} + +// AddStrings appends one key=v entry per value (e.g. repeated eventType filters). +func AddStrings(q url.Values, key string, vals []string) { + for _, v := range vals { + if v != "" { + q.Add(key, v) + } + } +} + +// IntPtr is a convenience for setting optional int params: Limit: wraith.IntPtr(50). +func IntPtr(v int) *int { return &v }