summaryrefslogtreecommitdiffstats
path: root/exchange.go
diff options
context:
space:
mode:
Diffstat (limited to 'exchange.go')
-rw-r--r--exchange.go183
1 files changed, 183 insertions, 0 deletions
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)
+}