From 4853cad7499a953deb4a18bae2c14c1482007e42 Mon Sep 17 00:00:00 2001 From: Sam Anthony Date: Thu, 9 Jul 2026 15:36:08 -0230 Subject: rate --- exchange.go | 183 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 183 insertions(+) create mode 100644 exchange.go (limited to 'exchange.go') diff --git a/exchange.go b/exchange.go new file mode 100644 index 0000000..49f0a2f --- /dev/null +++ b/exchange.go @@ -0,0 +1,183 @@ +// TODO +package exchange + +import ( + "bytes" + "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] + + // TODO: remove once debugged + buf := new(bytes.Buffer) + _, err := buf.ReadFrom(hresp.Body) + if err != nil { + return resp.Data, err + } + debugf("raw response: %s\n", buf.String()) + dec := json.NewDecoder(buf) + + 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) +} -- cgit v1.2.3