Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
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
11 changes: 9 additions & 2 deletions cmd/client_factory.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,10 +8,15 @@ import (
"github.com/coingecko/coingecko-cli/internal/ws"
)

// userAgent is the User-Agent header sent with all API and WebSocket requests.
var userAgent = "coingecko-cli/" + version

// newAPIClient is the factory used by command handlers to create API clients.
// Tests override this to inject httptest-backed clients.
var newAPIClient = func(cfg *config.Config) *api.Client {
return api.NewClient(cfg)
c := api.NewClient(cfg)
c.UserAgent = userAgent
return c
}

// loadConfig is the function used by command handlers to load configuration.
Expand All @@ -27,5 +32,7 @@ type Streamer interface {
// newStreamer is the factory used by command handlers to create WebSocket clients.
// Tests override this to inject test doubles.
var newStreamer = func(cfg *config.Config, coinIDs []string) Streamer {
return ws.NewClient(cfg, coinIDs)
c := ws.NewClient(cfg, coinIDs)
c.UserAgent = userAgent
return c
}
1 change: 1 addition & 0 deletions cmd/dryrun.go
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,7 @@ func printDryRunFull(cfg *config.Config, cmdName, opKey, endpoint string, params
headers[headerKey] = masked
}
headers["Accept"] = "application/json"
headers["User-Agent"] = userAgent

out := dryRunOutput{
Method: "GET",
Expand Down
2 changes: 2 additions & 0 deletions internal/api/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -170,6 +170,7 @@ type Client struct {
http *http.Client
baseURLVal string // override; empty = use cfg.BaseURL()
cfg *config.Config
UserAgent string // sent with every request; set by cmd layer
}

func NewClient(cfg *config.Config) *Client {
Expand Down Expand Up @@ -206,6 +207,7 @@ func (c *Client) get(ctx context.Context, path string, result any) error {
}
c.cfg.ApplyAuth(req)
req.Header.Set("Accept", "application/json")
req.Header.Set("User-Agent", c.UserAgent)

resp, err := c.http.Do(req)
if err != nil {
Expand Down
15 changes: 15 additions & 0 deletions internal/api/client_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,21 @@ func testClient(handler http.HandlerFunc) (*Client, *httptest.Server) {
return c, srv
}

func TestUserAgentHeader(t *testing.T) {
var gotUA string
c, srv := testClient(func(w http.ResponseWriter, r *http.Request) {
gotUA = r.Header.Get("User-Agent")
w.WriteHeader(200)
_, _ = w.Write([]byte("{}"))
})
defer srv.Close()

c.UserAgent = "coingecko-cli/v1.2.3"
var result map[string]any
_ = c.get(context.Background(), "/test", &result)
assert.Equal(t, "coingecko-cli/v1.2.3", gotUA)
}

func TestAuthHeadersSent(t *testing.T) {
var gotHeader string
c, srv := testClient(func(w http.ResponseWriter, r *http.Request) {
Expand Down
13 changes: 9 additions & 4 deletions internal/ws/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import (
"encoding/json"
"fmt"
"math/rand/v2"
"net/http"
"sync"
"sync/atomic"
"time"
Expand Down Expand Up @@ -41,9 +42,10 @@ type CoinUpdate struct {

// Client manages a WebSocket connection to CoinGecko's streaming API.
type Client struct {
cfg *config.Config
coinIDs []string
wsURL string
cfg *config.Config
coinIDs []string
wsURL string
UserAgent string // sent with WebSocket handshake; set by cmd layer

conn *websocket.Conn
updates chan *CoinUpdate
Expand Down Expand Up @@ -134,11 +136,14 @@ func (c *Client) Close() error {
func (c *Client) connect(ctx context.Context) error {
url := c.wsURL + "?x_cg_pro_api_key=" + c.cfg.APIKey

header := http.Header{}
header.Set("User-Agent", c.UserAgent)

dialer := websocket.Dialer{
HandshakeTimeout: 10 * time.Second,
}

conn, _, err := dialer.DialContext(ctx, url, nil)
conn, _, err := dialer.DialContext(ctx, url, header)
if err != nil {
return err
}
Expand Down
38 changes: 38 additions & 0 deletions internal/ws/client_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -379,6 +379,44 @@ func TestConnect_APIKeyInQueryParam(t *testing.T) {
require.NoError(t, client.Close())
}

func TestConnect_UserAgentHeader(t *testing.T) {
var gotUA string
var mu sync.Mutex

srv := newTestWSServer(t, func(conn *websocket.Conn) {
happyHandshake(t, conn)
for {
if _, _, err := conn.ReadMessage(); err != nil {
return
}
}
})
// Wrap handler to capture User-Agent from the handshake request.
origHandler := srv.Config.Handler
srv.Config.Handler = http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
mu.Lock()
gotUA = r.Header.Get("User-Agent")
mu.Unlock()
origHandler.ServeHTTP(w, r)
})

client := NewClient(paidCfg(), []string{"bitcoin"})
client.SetURL(wsURL(srv))
client.UserAgent = "coingecko-cli/v1.2.3"

ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()

_, err := client.Connect(ctx)
require.NoError(t, err)

mu.Lock()
assert.Equal(t, "coingecko-cli/v1.2.3", gotUA)
mu.Unlock()

require.NoError(t, client.Close())
}

func TestCloseWithoutConnect(t *testing.T) {
client := NewClient(paidCfg(), []string{"bitcoin"})
// Close() should not hang if Connect() was never called.
Expand Down