Skip to content

Commit

Permalink
write client tests and re-arrange/refactor client
Browse files Browse the repository at this point in the history
  • Loading branch information
netr committed Nov 28, 2023
1 parent fbcc5f4 commit a4a2d2a
Show file tree
Hide file tree
Showing 5 changed files with 224 additions and 79 deletions.
31 changes: 29 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
# Coinbase Advanced Trades API (v3) [![Build Status](https://github.com/netr/go-coinbasev3/workflows/Build/badge.svg?branch=v1)](https://github.com/netr/go-coinbasev3/actions?query=workflow%3ABuild) [![GoDoc](https://godoc.org/github.com/netr/go-coinbasev3?status.svg)](https://godoc.org/github.com/netr/go-coinbasev3)
This is a Go client for the Coinbase Advanced Trades API (v3). Work in progress.
# Coinbase Advanced Trades API (v3) ![Tests Status](https://github.com/netr/go-coinbasev3/actions/workflows/ci.yml/badge.svg) [![GoDoc](https://godoc.org/github.com/netr/go-coinbasev3?status.svg)](https://godoc.org/github.com/netr/go-coinbasev3)
This is a Go client for the Coinbase Advanced Trades API (v3). Work in progress.

**Note:** The advanced trading API does not have a sandbox available at this time. All tests have been mocked to ensure the client works as expected. Once the sandbox is available for the advanced trading API the tests will be updated to use the sandbox.

## Installation

Expand Down Expand Up @@ -38,6 +40,31 @@ Resource: [Rest API Pro Mapping](https://docs.cloud.coinbase.com/advanced-trade-
- [ ] Show a Transaction
- [ ] Send Money
- [ ] Withdraw Funds

## HTTP Client Usage

```go
// the client will automatically sign requests with the api_key and secret_key using req's OnBeforeRequest callback
client := coinbasev3.NewApiClient("api_key", "secret_key")

// product is a struct defined in the coinbasev3 package
product, err := client.GetProduct(productId)
if err != nil {
panic("Failed to get product")
}
```

### Changing base URL

The advanced trading API does not currently have a sandbox available. The sandbox is only available for the Coinbase API v2. The base URL can be changed to the sandbox URL for the Coinbase API v2 endpoints. Will need to revisit this once the sandbox is available for the advanced trading API.

```go
client := coinbasev3.NewApiClient("api_key", "secret_key")
client.SetBaseUrlV3("https://api-public.sandbox.pro.coinbase.com")
client.SetBaseUrlV2("https://api-public.sandbox.coinbase.com")
client.SetBaseExchangeUrl("https://api.exchange.coinbase.com")
```

## Websocket

The websocket client is a wrapper around the gorilla websocket with a few extra features to make it easier to use with the Coinbase Advanced Trade API.
Expand Down
153 changes: 78 additions & 75 deletions client.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,10 @@ import (
"time"
)

var (
ErrFailedToUnmarshal = fmt.Errorf("failed to unmarshal response")
)

type HttpClient interface {
Get(url string) (*req.Response, error)
GetClient() *req.Client
Expand All @@ -23,47 +27,83 @@ type ApiClient struct {
baseExchangeUrl string
}

func (c *ApiClient) GetClient() *req.Client {
return c.client
}
// NewApiClient creates a new Coinbase API client. The API key and secret key are used to sign requests. The default timeout is 10 seconds. The default retry count is 3. The default retry backoff interval is 1 second to 5 seconds.
func NewApiClient(apiKey, secretKey string, clients ...HttpClient) *ApiClient {
if clients != nil && len(clients) > 0 {
ac := &ApiClient{
apiKey: apiKey,
secretKey: secretKey,
client: newClient(apiKey, secretKey),
httpClient: clients[0],
}
ac.setBaseUrls()
return ac
}

type ReqClient struct {
client *req.Client
}
client := newClient(apiKey, secretKey)

func (c *ReqClient) GetClient() *req.Client {
return c.client
ac := &ApiClient{
apiKey: apiKey,
secretKey: secretKey,
client: client,
httpClient: &ReqClient{client: client},
}
ac.setBaseUrls()
return ac
}

var (
ErrFailedToUnmarshal = fmt.Errorf("failed to unmarshal response")
)
func newClient(apiKey, secretKey string) *req.Client {
client := req.C().
SetTimeout(time.Second * 10).
SetUserAgent("GoCoinbaseV3/1.0.0")

// NewApiClient creates a new Coinbase API client. The API key and secret key are used to sign requests. The default timeout is 10 seconds. The default retry count is 3. The default retry backoff interval is 1 second to 5 seconds.
func NewApiClient(apiKey, secretKey string, clients ...HttpClient) *ApiClient {
if clients != nil && len(clients) > 0 {
return &ApiClient{
apiKey: apiKey,
secretKey: secretKey,
client: newClient(apiKey, secretKey),
httpClient: clients[0],
baseUrlV3: "https://api.coinbase.com/api/v3",
baseUrlV2: "https://api.coinbase.com/api/v2",
baseExchangeUrl: "https://api.exchange.coinbase.com",
// TODO: figure out how to do this where we can use PathParam, QueryParam, etc.
client.OnBeforeRequest(func(client *req.Client, req *req.Request) error {
// create a secret key from: `timestamp + method + requestPath + body`
path := ""
if req.RawURL != "" {
u, err := url.Parse(req.RawURL)
if err != nil {
return err
}
path = u.Path
} else {
return fmt.Errorf("no path found")
}

sig := fmt.Sprintf("%d%s%s%s", time.Now().Unix(), req.Method, path, req.Body)
signedSig := string(SignHmacSha256(sig, secretKey))

client.Headers.Set("CB-ACCESS-KEY", apiKey)
client.Headers.Set("CB-ACCESS-SIGN", signedSig)
client.Headers.Set("CB-ACCESS-TIMESTAMP", fmt.Sprintf("%d", time.Now().Unix()))
return nil
})

return client
}

func (c *ApiClient) get(url string, out interface{}) error {
resp, err := c.httpClient.Get(url)
if err != nil {
return err
}

client := newClient(apiKey, secretKey)
if !resp.IsSuccessState() {
return ErrFailedToUnmarshal
}

return &ApiClient{
apiKey: apiKey,
secretKey: secretKey,
client: client,
httpClient: &ReqClient{client: client},
baseUrlV3: "https://api.coinbase.com/api/v3",
baseUrlV2: "https://api.coinbase.com/api/v2",
baseExchangeUrl: "https://api.exchange.coinbase.com",
err = resp.Unmarshal(&out)
if err != nil {
return err
}
return nil
}

func (c *ApiClient) setBaseUrls() {
c.baseUrlV3 = "https://api.coinbase.com/api/v3"
c.baseUrlV2 = "https://api.coinbase.com/api/v2"
c.baseExchangeUrl = "https://api.exchange.coinbase.com"
}

// SetSandboxUrls sets the base URLs to the sandbox environment. Note: The sandbox for Advanced Trading is not yet available. This method will be revisited when the sandbox is available.
Expand Down Expand Up @@ -109,54 +149,17 @@ func (c *ApiClient) makeExchangeUrl(path string) string {
return fmt.Sprintf("%s/%s", c.baseExchangeUrl, path)
}

func (c *ApiClient) get(url string, out interface{}) error {
resp, err := c.httpClient.Get(url)
if err != nil {
return err
}

if !resp.IsSuccessState() {
return ErrFailedToUnmarshal
}

err = resp.Unmarshal(&out)
if err != nil {
return err
}
return nil
// ReqClient is a wrapper around the req.Client to satisfy the HttpClient interface.
type ReqClient struct {
client *req.Client
}

func newClient(apiKey, secretKey string) *req.Client {
client := req.C().
SetTimeout(time.Second * 10).
SetUserAgent("GoCoinbaseV3/1.0.0")

// TODO: figure out how to do this where we can use PathParam, QueryParam, etc.
client.OnBeforeRequest(func(client *req.Client, req *req.Request) error {
// create a secret key from: `timestamp + method + requestPath + body`
path := ""
if req.RawURL != "" {
u, err := url.Parse(req.RawURL)
if err != nil {
return err
}
path = u.Path
} else {
return fmt.Errorf("no path found")
}

sig := fmt.Sprintf("%d%s%s%s", time.Now().Unix(), req.Method, path, req.Body)
signedSig := string(SignHmacSha256(sig, secretKey))

client.Headers.Set("CB-ACCESS-KEY", apiKey)
client.Headers.Set("CB-ACCESS-SIGN", signedSig)
client.Headers.Set("CB-ACCESS-TIMESTAMP", fmt.Sprintf("%d", time.Now().Unix()))
return nil
})

return client
// GetClient returns the underlying req.Client.
func (c *ReqClient) GetClient() *req.Client {
return c.client
}

// Get makes a GET request to the given URL.
func (c *ReqClient) Get(url string) (*req.Response, error) {
resp, err := c.client.R().Get(url)
if err != nil {
Expand Down
113 changes: 113 additions & 0 deletions client_test.go
Original file line number Diff line number Diff line change
@@ -1 +1,114 @@
package coinbasev3

import "testing"

func TestNewApiClient(t *testing.T) {
api := NewApiClient("api_key", "secret_key")
if api == nil {
t.Errorf("Expected api to be initialized")
}
if api.httpClient == nil {
t.Errorf("Expected client to be initialized")
}

// check base urls
if api.baseUrlV3 != "https://api.coinbase.com/api/v3" {
t.Errorf("Expected base url to be https://api.coinbase.com/api/v3, got %s", api.baseUrlV3)
}
if api.baseUrlV2 != "https://api.coinbase.com/api/v2" {
t.Errorf("Expected base url to be https://api.coinbase.com/api/v2, got %s", api.baseUrlV2)
}
if api.baseExchangeUrl != "https://api.exchange.coinbase.com" {
t.Errorf("Expected base url to be https://api.exchange.coinbase.com, got %s", api.baseExchangeUrl)
}
}

func TestNewApiClient_WithCustomClient(t *testing.T) {
mock := NewMockHttpClient(nil)
api := NewApiClient("api_key", "secret_key", mock)
if api == nil {
t.Errorf("Expected api to be initialized")
}
if api.httpClient.GetClient() != nil {
t.Errorf("Expected client to be nil from the mock")
}

// check base urls
if api.baseUrlV3 != "https://api.coinbase.com/api/v3" {
t.Errorf("Expected base url to be https://api.coinbase.com/api/v3, got %s", api.baseUrlV3)
}
if api.baseUrlV2 != "https://api.coinbase.com/api/v2" {
t.Errorf("Expected base url to be https://api.coinbase.com/api/v2, got %s", api.baseUrlV2)
}
if api.baseExchangeUrl != "https://api.exchange.coinbase.com" {
t.Errorf("Expected base url to be https://api.exchange.coinbase.com, got %s", api.baseExchangeUrl)
}
}

func TestApiClient_SetBaseExchangeUrl(t *testing.T) {
api := NewApiClient("api_key", "secret_key")
if api == nil {
t.Errorf("Expected api to be initialized")
}

api.SetBaseExchangeUrl("https://testbase.com")
if api.baseExchangeUrl != "https://testbase.com" {
t.Errorf("Expected base url to be https://testbase.com, got %s", api.baseExchangeUrl)
}
}

func TestApiClient_SetBaseV2Url(t *testing.T) {
api := NewApiClient("api_key", "secret_key")
if api == nil {
t.Errorf("Expected api to be initialized")
}

api.SetBaseUrlV2("https://testbase.com")
if api.baseUrlV2 != "https://testbase.com" {
t.Errorf("Expected base url to be https://testbase.com, got %s", api.baseUrlV2)
}
}

func TestApiClient_SetBaseV3Url(t *testing.T) {
api := NewApiClient("api_key", "secret_key")
if api == nil {
t.Errorf("Expected api to be initialized")
}

api.SetBaseUrlV3("https://testbase.com")
if api.baseUrlV3 != "https://testbase.com" {
t.Errorf("Expected base url to be https://testbase.com, got %s", api.baseUrlV3)
}
}

func TestApiClient_SetSandboxUrls(t *testing.T) {
api := NewApiClient("api_key", "secret_key")
if api == nil {
t.Errorf("Expected api to be initialized")
}
api.SetSandboxUrls()

// check base urls. TODO: Fix this when sandbox is available
checks := map[string]string{
"v3": "https://api-public.sandbox.pro.coinbase.com",
"v2": "https://api-public.sandbox.pro.coinbase.com",
"exchange": "https://api-public.sandbox.exchange.coinbase.com",
}

for k, v := range checks {
switch k {
case "v3":
if api.baseUrlV3 != v {
t.Errorf("Expected base url to be %s, got %s", v, api.baseUrlV3)
}
case "v2":
if api.baseUrlV2 != v {
t.Errorf("Expected base url to be %s, got %s", v, api.baseUrlV2)
}
case "exchange":
if api.baseExchangeUrl != v {
t.Errorf("Expected base url to be %s, got %s", v, api.baseExchangeUrl)
}
}
}
}
1 change: 0 additions & 1 deletion product_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,6 @@ import (

func TestApiClient_GetBestBidAsk(t *testing.T) {
api := NewApiClient("api_key", "secret_key")

productId := "BTC-USD"

httpmock.ActivateNonDefault(api.client.GetClient())
Expand Down
5 changes: 4 additions & 1 deletion test_utils.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
package coinbasev3

import "github.com/imroc/req/v3"
import (
"github.com/imroc/req/v3"
)

type MockHttpClient struct {
Response *req.Response
Expand All @@ -19,5 +21,6 @@ func (m *MockHttpClient) GetClient() *req.Client {
func NewMockHttpClient(resp *req.Response) HttpClient {
return &MockHttpClient{
Response: resp,
client: nil,
}
}

0 comments on commit a4a2d2a

Please sign in to comment.