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)
}
|