diff --git a/clients/horizonclient/accounts_request.go b/clients/horizonclient/accounts_request.go new file mode 100644 index 0000000000..c42b9e2cd6 --- /dev/null +++ b/clients/horizonclient/accounts_request.go @@ -0,0 +1,56 @@ +package horizonclient + +import ( + "fmt" + "net/url" + + "github.com/stellar/go/support/errors" +) + +// BuildURL creates the endpoint to be queried based on the data in the AccountsRequest struct. +// Either "Signer" or "Asset" fields should be set when retrieving Accounts. +// At the moment, you can't use both filters at the same time. +func (r AccountsRequest) BuildURL() (endpoint string, err error) { + + nParams := countParams(r.Signer, r.Asset) + + if nParams <= 0 { + err = errors.New("invalid request: no parameters - Signer or Asset must be provided") + } + + if nParams >= 2 { + err = errors.New("invalid request: too many parameters - Signer and Asset provided, provide a single filter") + } + + if err != nil { + return endpoint, err + } + query := url.Values{} + switch { + case len(r.Signer) > 0: + query.Add("signer", r.Signer) + + case len(r.Asset) > 0: + query.Add("asset", r.Asset) + } + + endpoint = fmt.Sprintf( + "accounts?%s", + query.Encode(), + ) + + if pageParams := addQueryParams(cursor(r.Cursor), limit(r.Limit), r.Order); len(pageParams) > 0 { + endpoint = fmt.Sprintf( + "%s&%s", + endpoint, + pageParams, + ) + } + + _, err = url.Parse(endpoint) + if err != nil { + err = errors.Wrap(err, "failed to parse endpoint") + } + + return endpoint, err +} diff --git a/clients/horizonclient/client.go b/clients/horizonclient/client.go index 2639417582..6a50b075d4 100644 --- a/clients/horizonclient/client.go +++ b/clients/horizonclient/client.go @@ -226,6 +226,14 @@ func (c *Client) HorizonTimeOut() time.Duration { return c.horizonTimeOut } +// Accounts returns accounts who have a given signer or +// have a trustline to an asset. +// See https://www.stellar.org/developers/horizon/reference/endpoints/accounts.html +func (c *Client) Accounts(request AccountsRequest) (accounts hProtocol.AccountsPage, err error) { + err = c.sendRequest(request, &accounts) + return +} + // AccountDetail returns information for a single account. // See https://www.stellar.org/developers/horizon/reference/endpoints/accounts-single.html func (c *Client) AccountDetail(request AccountRequest) (account hProtocol.Account, err error) { diff --git a/clients/horizonclient/examples_test.go b/clients/horizonclient/examples_test.go index 2be3f0e8db..4de82e0610 100644 --- a/clients/horizonclient/examples_test.go +++ b/clients/horizonclient/examples_test.go @@ -11,6 +11,19 @@ import ( "github.com/stellar/go/protocols/horizon/operations" ) +func ExampleClient_Accounts() { + client := horizonclient.DefaultPublicNetClient + accountsRequest := horizonclient.AccountsRequest{Signer: "GCLWGQPMKXQSPF776IU33AH4PZNOOWNAWGGKVTBQMIC5IMKUNP3E6NVU"} + + account, err := client.Accounts(accountsRequest) + if err != nil { + fmt.Println(err) + return + } + + fmt.Print(account) +} + func ExampleClient_AccountDetail() { client := horizonclient.DefaultPublicNetClient accountRequest := horizonclient.AccountRequest{AccountID: "GCLWGQPMKXQSPF776IU33AH4PZNOOWNAWGGKVTBQMIC5IMKUNP3E6NVU"} diff --git a/clients/horizonclient/main.go b/clients/horizonclient/main.go index 22f5f671ae..16646f6d5b 100644 --- a/clients/horizonclient/main.go +++ b/clients/horizonclient/main.go @@ -141,6 +141,7 @@ type Client struct { // ClientInterface contains methods implemented by the horizon client type ClientInterface interface { + Accounts(request AccountsRequest) (hProtocol.AccountsPage, error) AccountDetail(request AccountRequest) (hProtocol.Account, error) AccountData(request AccountRequest) (hProtocol.AccountData, error) Effects(request EffectRequest) (effects.EffectsPage, error) @@ -212,7 +213,18 @@ type HorizonRequest interface { BuildURL() (string, error) } -// AccountRequest struct contains data for making requests to the accounts endpoint of a horizon server. +// AccountsRequest struct contains data for making requests to the accounts endpoint of a horizon server. +// Either "Signer" or "Asset" fields should be set when retrieving Accounts. +// At the moment, you can't use both filters at the same time. +type AccountsRequest struct { + Signer string + Asset string + Order Order + Cursor string + Limit uint +} + +// AccountRequest struct contains data for making requests to the show account endpoint of a horizon server. // "AccountID" and "DataKey" fields should both be set when retrieving AccountData. // When getting the AccountDetail, only "AccountID" needs to be set. type AccountRequest struct { diff --git a/clients/horizonclient/main_test.go b/clients/horizonclient/main_test.go index 7684ec1276..fc2929abbf 100644 --- a/clients/horizonclient/main_test.go +++ b/clients/horizonclient/main_test.go @@ -27,6 +27,86 @@ func TestFixHTTP(t *testing.T) { assert.IsType(t, client.HTTP, &http.Client{}) } +func TestAccounts(t *testing.T) { + tt := assert.New(t) + hmock := httptest.NewClient() + client := &Client{ + HorizonURL: "https://localhost/", + HTTP: hmock, + } + + accountRequest := AccountsRequest{} + _, err := client.Accounts(accountRequest) + if tt.Error(err) { + tt.Contains(err.Error(), "invalid request: no parameters - Signer or Asset must be provided") + } + + accountRequest = AccountsRequest{ + Signer: "GCLWGQPMKXQSPF776IU33AH4PZNOOWNAWGGKVTBQMIC5IMKUNP3E6NVU", + Asset: "COP:GCLWGQPMKXQSPF776IU33AH4PZNOOWNAWGGKVTBQMIC5IMKUNP3E6NVU", + } + _, err = client.Accounts(accountRequest) + if tt.Error(err) { + tt.Contains(err.Error(), "invalid request: too many parameters - Signer and Asset provided, provide a single filter") + } + + var accounts hProtocol.AccountsPage + + hmock.On( + "GET", + "https://localhost/accounts?signer=GAI3SO3S4E67HAUZPZ2D3VBFXY4AT6N7WQI7K5WFGRXWENTZJG2B6CYP", + ).ReturnString(200, accountsResponse) + + accountRequest = AccountsRequest{ + Signer: "GAI3SO3S4E67HAUZPZ2D3VBFXY4AT6N7WQI7K5WFGRXWENTZJG2B6CYP", + } + accounts, err = client.Accounts(accountRequest) + tt.NoError(err) + tt.Len(accounts.Embedded.Records, 1) + + hmock.On( + "GET", + "https://localhost/accounts?asset=COP%3AGAI3SO3S4E67HAUZPZ2D3VBFXY4AT6N7WQI7K5WFGRXWENTZJG2B6CYP", + ).ReturnString(200, accountsResponse) + + accountRequest = AccountsRequest{ + Asset: "COP:GAI3SO3S4E67HAUZPZ2D3VBFXY4AT6N7WQI7K5WFGRXWENTZJG2B6CYP", + } + accounts, err = client.Accounts(accountRequest) + tt.NoError(err) + tt.Len(accounts.Embedded.Records, 1) + + hmock.On( + "GET", + "https://localhost/accounts?signer=GAI3SO3S4E67HAUZPZ2D3VBFXY4AT6N7WQI7K5WFGRXWENTZJG2B6CYP&cursor=GBRPYHIL2CI3FNQ4BXLFMNDLFJUNPU2HY3ZMFSHONUCEOASW7QC7OX2H&limit=200&order=desc", + ).ReturnString(200, accountsResponse) + + accountRequest = AccountsRequest{ + Signer: "GAI3SO3S4E67HAUZPZ2D3VBFXY4AT6N7WQI7K5WFGRXWENTZJG2B6CYP", + Order: "desc", + Cursor: "GBRPYHIL2CI3FNQ4BXLFMNDLFJUNPU2HY3ZMFSHONUCEOASW7QC7OX2H", + Limit: 200, + } + accounts, err = client.Accounts(accountRequest) + tt.NoError(err) + tt.Len(accounts.Embedded.Records, 1) + + // connection error + hmock.On( + "GET", + "https://localhost/accounts?signer=GAI3SO3S4E67HAUZPZ2D3VBFXY4AT6N7WQI7K5WFGRXWENTZJG2B6CYP", + ).ReturnError("http.Client error") + + accountRequest = AccountsRequest{ + Signer: "GAI3SO3S4E67HAUZPZ2D3VBFXY4AT6N7WQI7K5WFGRXWENTZJG2B6CYP", + } + accounts, err = client.Accounts(accountRequest) + if tt.Error(err) { + tt.Contains(err.Error(), "http.Client error") + _, ok := err.(*Error) + tt.Equal(ok, false) + } +} func TestAccountDetail(t *testing.T) { hmock := httptest.NewClient() client := &Client{ @@ -739,6 +819,92 @@ func TestFetchTimebounds(t *testing.T) { assert.Equal(t, st.MaxTime, int64(200)) } +var accountsResponse = `{ + "_links": { + "self": { + "href": "https://horizon-testnet.stellar.org/accounts?cursor=\u0026limit=10\u0026order=asc\u0026signer=GAI3SO3S4E67HAUZPZ2D3VBFXY4AT6N7WQI7K5WFGRXWENTZJG2B6CYP" + }, + "next": { + "href": "https://horizon-testnet.stellar.org/accounts?cursor=GAI3SO3S4E67HAUZPZ2D3VBFXY4AT6N7WQI7K5WFGRXWENTZJG2B6CYP\u0026limit=10\u0026order=asc\u0026signer=GAI3SO3S4E67HAUZPZ2D3VBFXY4AT6N7WQI7K5WFGRXWENTZJG2B6CYP" + }, + "prev": { + "href": "https://horizon-testnet.stellar.org/accounts?cursor=GAI3SO3S4E67HAUZPZ2D3VBFXY4AT6N7WQI7K5WFGRXWENTZJG2B6CYP\u0026limit=10\u0026order=desc\u0026signer=GAI3SO3S4E67HAUZPZ2D3VBFXY4AT6N7WQI7K5WFGRXWENTZJG2B6CYP" + } + }, + "_embedded": { + "records": [ + { + "_links": { + "self": { + "href": "https://horizon-testnet.stellar.org/accounts/GAI3SO3S4E67HAUZPZ2D3VBFXY4AT6N7WQI7K5WFGRXWENTZJG2B6CYP" + }, + "transactions": { + "href": "https://horizon-testnet.stellar.org/accounts/GAI3SO3S4E67HAUZPZ2D3VBFXY4AT6N7WQI7K5WFGRXWENTZJG2B6CYP/transactions{?cursor,limit,order}", + "templated": true + }, + "operations": { + "href": "https://horizon-testnet.stellar.org/accounts/GAI3SO3S4E67HAUZPZ2D3VBFXY4AT6N7WQI7K5WFGRXWENTZJG2B6CYP/operations{?cursor,limit,order}", + "templated": true + }, + "payments": { + "href": "https://horizon-testnet.stellar.org/accounts/GAI3SO3S4E67HAUZPZ2D3VBFXY4AT6N7WQI7K5WFGRXWENTZJG2B6CYP/payments{?cursor,limit,order}", + "templated": true + }, + "effects": { + "href": "https://horizon-testnet.stellar.org/accounts/GAI3SO3S4E67HAUZPZ2D3VBFXY4AT6N7WQI7K5WFGRXWENTZJG2B6CYP/effects{?cursor,limit,order}", + "templated": true + }, + "offers": { + "href": "https://horizon-testnet.stellar.org/accounts/GAI3SO3S4E67HAUZPZ2D3VBFXY4AT6N7WQI7K5WFGRXWENTZJG2B6CYP/offers{?cursor,limit,order}", + "templated": true + }, + "trades": { + "href": "https://horizon-testnet.stellar.org/accounts/GAI3SO3S4E67HAUZPZ2D3VBFXY4AT6N7WQI7K5WFGRXWENTZJG2B6CYP/trades{?cursor,limit,order}", + "templated": true + }, + "data": { + "href": "https://horizon-testnet.stellar.org/accounts/GAI3SO3S4E67HAUZPZ2D3VBFXY4AT6N7WQI7K5WFGRXWENTZJG2B6CYP/data/{key}", + "templated": true + } + }, + "id": "GAI3SO3S4E67HAUZPZ2D3VBFXY4AT6N7WQI7K5WFGRXWENTZJG2B6CYP", + "account_id": "GAI3SO3S4E67HAUZPZ2D3VBFXY4AT6N7WQI7K5WFGRXWENTZJG2B6CYP", + "sequence": "47236050321450", + "subentry_count": 0, + "last_modified_ledger": 116787, + "thresholds": { + "low_threshold": 0, + "med_threshold": 0, + "high_threshold": 0 + }, + "flags": { + "auth_required": false, + "auth_revocable": false, + "auth_immutable": false + }, + "balances": [ + { + "balance": "100.8182300", + "buying_liabilities": "0.0000000", + "selling_liabilities": "0.0000000", + "asset_type": "native" + } + ], + "signers": [ + { + "weight": 1, + "key": "GAI3SO3S4E67HAUZPZ2D3VBFXY4AT6N7WQI7K5WFGRXWENTZJG2B6CYP", + "type": "ed25519_public_key" + } + ], + "data": {}, + "paging_token": "GAI3SO3S4E67HAUZPZ2D3VBFXY4AT6N7WQI7K5WFGRXWENTZJG2B6CYP" + } + ] + } +} +` + var accountResponse = `{ "_links": { "self": { diff --git a/clients/horizonclient/mocks.go b/clients/horizonclient/mocks.go index 0824dad978..963ad2cbd7 100644 --- a/clients/horizonclient/mocks.go +++ b/clients/horizonclient/mocks.go @@ -15,6 +15,12 @@ type MockClient struct { mock.Mock } +// Accounts is a mocking method +func (m *MockClient) Accounts(request AccountsRequest) (hProtocol.AccountsPage, error) { + a := m.Called(request) + return a.Get(0).(hProtocol.AccountsPage), a.Error(1) +} + // AccountDetail is a mocking method func (m *MockClient) AccountDetail(request AccountRequest) (hProtocol.Account, error) { a := m.Called(request) diff --git a/protocols/horizon/main.go b/protocols/horizon/main.go index 4d2d5b3e37..58b6c03813 100644 --- a/protocols/horizon/main.go +++ b/protocols/horizon/main.go @@ -531,6 +531,14 @@ type AccountData struct { Value string `json:"value"` } +// AccountsPage returns a list of account records +type AccountsPage struct { + Links hal.Links `json:"_links"` + Embedded struct { + Records []Account `json:"records"` + } `json:"_embedded"` +} + // TradeAggregationsPage returns a list of aggregated trade records, aggregated by resolution type TradeAggregationsPage struct { Links hal.Links `json:"_links"`