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