summaryrefslogtreecommitdiffstats
path: root/exchange.go
blob: a1298894cac3d3a98a5ba2da3889f44da97b3f90 (plain) (blame)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
// Package exchange retrieves fiat- and crypto-currency exchange rates from [CoinMarketCap].
//
// Features:
//   - Cache ([ttlcache])
//   - Fixed-point numbers ([decimal])
//
// [CoinMarketCap]: https://coinmarketcap.com/api/documentation/
package exchange

import (
	"cmp"
	"encoding/json"
	"fmt"
	"net/http"
	"net/url"
	"slices"

	"github.com/google/go-querystring/query"
	"github.com/jellydator/ttlcache/v3"
	"github.com/shopspring/decimal"
)

const (
	keyHdr              = "X-CMC_PRO_API_KEY"
	priceConversionPath = "/v2/tools/price-conversion"
)

var apiUrl = mustParseUrl("https://pro-api.coinmarketcap.com")

// pair is a pair of currency codes.
type pair struct {
	from, to string
}

type rateResult struct {
	rate decimal.Decimal
	err  error
}

type Client struct {
	c      *http.Client
	apiKey string
	rates  *ttlcache.Cache[pair, rateResult]
}

func NewClient(apiKey string, o ...Option) *Client {
	opts := parseOpts(o)

	c := &Client{
		&http.Client{},
		apiKey,
		nil,
	}

	var rateLoader ttlcache.LoaderFunc[pair, rateResult] = c.loadRate
	rates := ttlcache.New[pair, rateResult](
		ttlcache.WithTTL[pair, rateResult](opts.ttl),
		ttlcache.WithLoader[pair, rateResult](rateLoader))
	go rates.Start()
	c.rates = rates

	return c
}

func (c *Client) Close() {
	c.c.CloseIdleConnections()
	c.rates.Stop()
	c.rates.DeleteAll()
}

// Rate takes two 3-letter currency codes and returns the conversion rate
// between them such that 1×from = rate×to.
func (c *Client) Rate(from, to string) (decimal.Decimal, error) {
	item := c.rates.Get(pair{from, to})
	if item.IsExpired() {
		lg.Printf("warning: cache[{%s, %s}] is expired\n", from, to)
	}
	res := item.Value()
	return res.rate, res.err
}

// loadRate retrieves an exchange rate and stores it in the cache.
func (c *Client) loadRate(cache *ttlcache.Cache[pair, rateResult], key pair) *ttlcache.Item[pair, rateResult] {
	from, to := key.from, key.to
	debugf("load rate %s->%s\n", from, to)
	query, err := query.Values(priceConversionQuery{1, from, to})
	if err != nil {
		return rateErr(cache, key, err)
	}

	var convs []priceConversion
	if err := get(c, priceConversionPath, query, &convs); err != nil {
		return rateErr(cache, key, err)
	}

	slices.SortFunc(convs, func(a, b priceConversion) int { return cmp.Compare(a.Id, b.Id) })
	if len(convs) < 1 {
		return rateErr(cache, key, errBadCurrency{to})
	}
	conv := convs[0]
	debugf("%s: using ID %d\n", to, conv.Id)

	rate, ok := conv.Quote[to]
	if !ok {
		return rateErr(cache, key, errBadCurrency{to})
	}
	if rate.Price.IsZero() {
		lg.Printf("warning: exchange rate %s->%s is zero\n", from, to)
	}
	debugf("cache rate %s->%s: %v\n", from, to, rate.Price)
	return cache.Set(key, rateResult{rate.Price, nil}, ttlcache.DefaultTTL)
}

func get[T any](c *Client, path string, query url.Values, resp *T) error {
	// Construct request
	uri := apiUrl.JoinPath(path).String() + "?" + query.Encode()
	debugf("GET %v\n", uri)
	req, err := http.NewRequest(http.MethodGet, uri, nil)
	if err != nil {
		return err
	}
	req.Header.Set(keyHdr, c.apiKey)

	// Send request
	hresp, err := c.c.Do(req)
	if err != nil {
		return err
	}
	defer hresp.Body.Close()

	// Decode response
	if hresp.StatusCode == http.StatusOK {
		var err error
		*resp, err = decOkResp[T](hresp)
		return err
	} else {
		return decErrResp(hresp)
	}
}

func decOkResp[T any](hresp *http.Response) (T, error) {
	var resp response[T]
	dec := json.NewDecoder(hresp.Body)
	err := dec.Decode(&resp)
	debugf("decoded response: %v\n", resp)
	if err == nil && resp.Status.Error != "" {
		err = fmt.Errorf("%s", resp.Status.Error)
	}
	return resp.Data, err
}

func decErrResp(hresp *http.Response) error {
	var errInfo apiError
	dec := json.NewDecoder(hresp.Body)
	if err := dec.Decode(&errInfo); err != nil {
		return err
	}
	debugf("%v\n", errInfo)
	if err := errInfo.Status.Error; err != "" {
		return fmt.Errorf("%s", err)
	} else {
		return fmt.Errorf("CoinMarketCap error: %s", hresp.Status)
	}
}

func mustParseUrl(s string) *url.URL {
	u, err := url.Parse(s)
	if err != nil {
		panic(err)
	}
	return u
}

func rateErr(cache *ttlcache.Cache[pair, rateResult], key pair, err error) *ttlcache.Item[pair, rateResult] {
	debugf("rate error: %v: %v\n", key, err)
	var res rateResult
	res.err = err
	return cache.Set(key, res, ttlcache.DefaultTTL)
}