Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

clients/horizonclient: Add support for Accounts endpoint. #2229

Merged
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
56 changes: 56 additions & 0 deletions clients/horizonclient/accounts_request.go
Original file line number Diff line number Diff line change
@@ -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.
// "Signer" or "Asset" fields should be set when retrieving AccountsData.
// 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
}
11 changes: 11 additions & 0 deletions clients/horizonclient/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -226,6 +226,17 @@ 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)
if err != nil {
return
}
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) {
Expand Down
13 changes: 13 additions & 0 deletions clients/horizonclient/examples_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"}
Expand Down
14 changes: 13 additions & 1 deletion clients/horizonclient/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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.
// "Signer" or "Asset" fields should be set when retrieving AccountsData.
// At the moment, you can't use both filters are 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.
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It doesn't seem great that we have two structs with very similar names AccountsRequest vs AccountRequest. Is there something we can do to name them more clearly?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

(I don't really like the plural name AccountsRequest`)

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@ire-and-curses I agree with you, initially I thought about following a similar approach for EffectRequest, but it seems to me that we are trying to do too much with those structs.

That said though, we could be consistent and try to follow a similar approach. Basically extend the AccountRequest struct to also take ForSigner and ForAsset. When called through client.Accounts they will be the only fields we look at + Page params, ignoring the rest.

I personally don't like that interface, but it is consistent with how the other *Request like structs behave.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think this is okay given that we can't make it more consistent with other functions without it being backwards compatible, and the alternatives of smooshing the two types together into a single shared types would be worse and could result in bad human errors. The compiler will make sure the caller uses the right type so whilst there might be some confusion with similar named types any errors arising from that should be prevented.

// "AccountID" and "DataKey" fields should both be set when retrieving AccountData.
// When getting the AccountDetail, only "AccountID" needs to be set.
type AccountRequest struct {
Expand Down
166 changes: 166 additions & 0 deletions clients/horizonclient/main_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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{
Expand Down Expand Up @@ -738,6 +818,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": {
Expand Down
6 changes: 6 additions & 0 deletions clients/horizonclient/mocks.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
8 changes: 8 additions & 0 deletions protocols/horizon/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -523,6 +523,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"`
Expand Down