Skip to content

Commit

Permalink
[SDP-1172] Implement GET /balances to return the Circle balance (#325)
Browse files Browse the repository at this point in the history
### What

Implement `GET /balances` to return the Circle balance

### Why

Addresses https://stellarorg.atlassian.net/browse/SDP-1172
  • Loading branch information
marcelosalloum authored Jun 13, 2024
1 parent 7ba6f43 commit 77ea816
Show file tree
Hide file tree
Showing 12 changed files with 760 additions and 27 deletions.
47 changes: 43 additions & 4 deletions internal/circle/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -10,19 +10,24 @@ import (
"net/url"

"github.com/google/uuid"

"github.com/stellar/stellar-disbursement-platform-backend/internal/serve/httpclient"
)

const (
pingPath = "/ping"
transferPath = "/v1/transfers"
walletPath = "/v1/wallets"
)

// ClientInterface defines the interface for interacting with the Circle API.
//
//go:generate mockery --name=ClientInterface --case=underscore --structname=MockClient
type ClientInterface interface {
Ping(ctx context.Context) (bool, error)
PostTransfer(ctx context.Context, transferRequest TransferRequest) (*Transfer, error)
GetTransferByID(ctx context.Context, id string) (*Transfer, error)
GetWalletByID(ctx context.Context, id string) (*Wallet, error)
}

// Client provides methods to interact with the Circle API.
Expand All @@ -32,8 +37,13 @@ type Client struct {
httpClient httpclient.HttpClientInterface
}

// ClientFactory is a function that creates a ClientInterface.
type ClientFactory func(env Environment, apiKey string) ClientInterface

var _ ClientFactory = NewClient

// NewClient creates a new instance of Circle Client.
func NewClient(env Environment, apiKey string) *Client {
func NewClient(env Environment, apiKey string) ClientInterface {
return &Client{
BasePath: string(env),
APIKey: apiKey,
Expand All @@ -42,7 +52,8 @@ func NewClient(env Environment, apiKey string) *Client {
}

// Ping checks that the service is running.
// https://developers.circle.com/circle-mint/reference/ping.
//
// Circle API documentation: https://developers.circle.com/circle-mint/reference/ping.
func (client *Client) Ping(ctx context.Context) (bool, error) {
u, err := url.JoinPath(client.BasePath, pingPath)
if err != nil {
Expand Down Expand Up @@ -74,7 +85,8 @@ func (client *Client) Ping(ctx context.Context) (bool, error) {
}

// PostTransfer creates a new transfer.
// https://developers.circle.com/circle-mint/reference/createtransfer
//
// Circle API documentation: https://developers.circle.com/circle-mint/reference/createtransfer.
func (client *Client) PostTransfer(ctx context.Context, transferReq TransferRequest) (*Transfer, error) {
err := transferReq.validate()
if err != nil {
Expand Down Expand Up @@ -109,7 +121,8 @@ func (client *Client) PostTransfer(ctx context.Context, transferReq TransferRequ
}

// GetTransferByID retrieves a transfer by its ID.
// https://developers.circle.com/circle-mint/reference/gettransfer
//
// Circle API documentation: https://developers.circle.com/circle-mint/reference/gettransfer.
func (client *Client) GetTransferByID(ctx context.Context, id string) (*Transfer, error) {
u, err := url.JoinPath(client.BasePath, transferPath, id)
if err != nil {
Expand All @@ -132,6 +145,32 @@ func (client *Client) GetTransferByID(ctx context.Context, id string) (*Transfer
return parseTransferResponse(resp)
}

// GetWalletByID retrieves a wallet by its ID.
//
// Circle API documentation: https://developers.circle.com/circle-mint/reference/getwallet.
func (client *Client) GetWalletByID(ctx context.Context, id string) (*Wallet, error) {
url, err := url.JoinPath(client.BasePath, walletPath, id)
if err != nil {
return nil, fmt.Errorf("building path: %w", err)
}

resp, err := client.request(ctx, url, http.MethodGet, true, nil)
if err != nil {
return nil, fmt.Errorf("making request: %w", err)
}
defer resp.Body.Close()

if resp.StatusCode != http.StatusOK {
apiError, parseErr := parseAPIError(resp)
if parseErr != nil {
return nil, fmt.Errorf("parsing API error: %w", parseErr)
}
return nil, fmt.Errorf("API error: %w", apiError)
}

return parseWalletResponse(resp)
}

// request makes an HTTP request to the Circle API.
func (client *Client) request(ctx context.Context, u string, method string, isAuthed bool, body io.Reader) (*http.Response, error) {
req, err := http.NewRequestWithContext(ctx, method, u, body)
Expand Down
100 changes: 97 additions & 3 deletions internal/circle/client_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -9,20 +9,25 @@ import (
"net/http"
"testing"

httpclientMocks "github.com/stellar/stellar-disbursement-platform-backend/internal/serve/httpclient/mocks"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/mock"

httpclientMocks "github.com/stellar/stellar-disbursement-platform-backend/internal/serve/httpclient/mocks"
)

func Test_NewClient(t *testing.T) {
t.Run("production environment", func(t *testing.T) {
cc := NewClient(Production, "test-key")
clientInterface := NewClient(Production, "test-key")
cc, ok := clientInterface.(*Client)
assert.True(t, ok)
assert.Equal(t, "https://api.circle.com", cc.BasePath)
assert.Equal(t, "test-key", cc.APIKey)
})

t.Run("sandbox environment", func(t *testing.T) {
cc := NewClient(Sandbox, "test-key")
clientInterface := NewClient(Sandbox, "test-key")
cc, ok := clientInterface.(*Client)
assert.True(t, ok)
assert.Equal(t, "https://api-sandbox.circle.com", cc.BasePath)
assert.Equal(t, "test-key", cc.APIKey)
})
Expand Down Expand Up @@ -193,6 +198,95 @@ func Test_Client_GetTransferByID(t *testing.T) {
})
}

func Test_Client_GetWalletByID(t *testing.T) {
ctx := context.Background()
t.Run("get wallet by id error", func(t *testing.T) {
cc, httpClientMock := newClientWithMock(t)
testError := errors.New("test error")
httpClientMock.
On("Do", mock.Anything).
Run(func(args mock.Arguments) {
req, ok := args.Get(0).(*http.Request)
assert.True(t, ok)

assert.Equal(t, "http://localhost:8080/v1/wallets/test-id", req.URL.String())
assert.Equal(t, http.MethodGet, req.Method)
assert.Equal(t, "Bearer test-key", req.Header.Get("Authorization"))
}).
Return(nil, testError).
Once()

wallet, err := cc.GetWalletByID(ctx, "test-id")
assert.EqualError(t, err, fmt.Errorf("making request: %w", testError).Error())
assert.Nil(t, wallet)
})

t.Run("get wallet by id fails auth", func(t *testing.T) {
const unauthorizedResponse = `{
"code": 401,
"message": "Malformed key. Does it contain three parts?"
}`
cc, httpClientMock := newClientWithMock(t)
httpClientMock.
On("Do", mock.Anything).
Return(&http.Response{
StatusCode: http.StatusUnauthorized,
Body: io.NopCloser(bytes.NewBufferString(unauthorizedResponse)),
}, nil).
Once()

transfer, err := cc.GetWalletByID(ctx, "test-id")
assert.EqualError(t, err, "API error: APIError: Code=401, Message=Malformed key. Does it contain three parts?, Errors=[]")
assert.Nil(t, transfer)
})

t.Run("get wallet by id successful", func(t *testing.T) {
const getWalletResponseJSON = `{
"data": {
"walletId": "test-id",
"entityId": "2f47c999-9022-4939-acea-dc3afa9ccbaf",
"type": "end_user_wallet",
"description": "Treasury Wallet",
"balances": [
{
"amount": "4790.00",
"currency": "USD"
}
]
}
}`
cc, httpClientMock := newClientWithMock(t)
httpClientMock.
On("Do", mock.Anything).
Return(&http.Response{
StatusCode: http.StatusOK,
Body: io.NopCloser(bytes.NewBufferString(getWalletResponseJSON)),
}, nil).
Run(func(args mock.Arguments) {
req, ok := args.Get(0).(*http.Request)
assert.True(t, ok)

assert.Equal(t, "http://localhost:8080/v1/wallets/test-id", req.URL.String())
assert.Equal(t, http.MethodGet, req.Method)
assert.Equal(t, "Bearer test-key", req.Header.Get("Authorization"))
}).
Once()

wallet, err := cc.GetWalletByID(ctx, "test-id")
assert.NoError(t, err)
wantWallet := &Wallet{
WalletID: "test-id",
EntityID: "2f47c999-9022-4939-acea-dc3afa9ccbaf",
Type: "end_user_wallet",
Description: "Treasury Wallet",
Balances: []Balance{
{Amount: "4790.00", Currency: "USD"},
},
}
assert.Equal(t, wantWallet, wallet)
})
}

func newClientWithMock(t *testing.T) (Client, *httpclientMocks.HttpClientMock) {
httpClientMock := httpclientMocks.NewHttpClientMock(t)

Expand Down
13 changes: 13 additions & 0 deletions internal/circle/environments.go
Original file line number Diff line number Diff line change
@@ -1,9 +1,22 @@
package circle

import (
"github.com/stellar/stellar-disbursement-platform-backend/internal/data"
"github.com/stellar/stellar-disbursement-platform-backend/internal/services/assets"
"github.com/stellar/stellar-disbursement-platform-backend/internal/utils"
)

// Environment holds the possible environments for the Circle API.
type Environment string

const (
Production Environment = "https://api.circle.com"
Sandbox Environment = "https://api-sandbox.circle.com"
)

var AllowedAssetsMap = map[string]map[utils.NetworkType]data.Asset{
"USD": {
utils.PubnetNetworkType: assets.USDCAssetPubnet,
utils.TestnetNetworkType: assets.USDCAssetTestnet,
},
}
133 changes: 133 additions & 0 deletions internal/circle/mocks/client_interface.go

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading

0 comments on commit 77ea816

Please sign in to comment.