diff options
| author | Sam Anthony <sam@samanthony.xyz> | 2026-07-09 15:36:08 -0230 |
|---|---|---|
| committer | Sam Anthony <sam@samanthony.xyz> | 2026-07-09 15:36:08 -0230 |
| commit | 4853cad7499a953deb4a18bae2c14c1482007e42 (patch) | |
| tree | b6a7eaf332d4c89ba585f88e1a345d716df59ae1 | |
| download | exchange-4853cad7499a953deb4a18bae2c14c1482007e42.zip | |
rate
| -rw-r--r-- | .gitignore | 1 | ||||
| -rw-r--r-- | err.go | 11 | ||||
| -rw-r--r-- | example_test.go | 33 | ||||
| -rw-r--r-- | exchange.go | 183 | ||||
| -rw-r--r-- | exchange_test.go | 199 | ||||
| -rw-r--r-- | go.mod | 29 | ||||
| -rw-r--r-- | go.sum | 60 | ||||
| -rw-r--r-- | log.go | 17 | ||||
| -rw-r--r-- | opt.go | 23 | ||||
| -rw-r--r-- | schema.go | 39 |
10 files changed, 595 insertions, 0 deletions
diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..355a0c3 --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +.key @@ -0,0 +1,11 @@ +package exchange + +import "fmt" + +type errBadCurrency struct { + symbol string +} + +func (e errBadCurrency) Error() string { + return fmt.Sprintf("unknown currency symbol %q", e.symbol) +} diff --git a/example_test.go b/example_test.go new file mode 100644 index 0000000..0af83e7 --- /dev/null +++ b/example_test.go @@ -0,0 +1,33 @@ +package exchange_test + +import ( + "bytes" + "fmt" + "os" + "time" + + "github.com/bojanz/currency" + + "git.samanthony.xyz/exchange" +) + +var btcDef = currency.Definition{ + "1000", + 8, + "BTC", +} + +func ExampleClient_Rate() { + key, _ := readFile(".key") + exc := exchange.NewClient(key, exchange.WithTTL(15*time.Minute)) + cadPerBtc, _ := exc.Rate("BTC", "CAD") + currency.Register("BTC", btcDef) + btc, _ := currency.NewAmount("1.23", "BTC") + cad, _ := btc.Convert("CAD", cadPerBtc.String()) + fmt.Printf("%v = %v\n", btc.Round(), cad.Round()) +} + +func readFile(path string) (string, error) { + buf, err := os.ReadFile(path) + return string(bytes.TrimSpace(buf)), err +} 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) +} diff --git a/exchange_test.go b/exchange_test.go new file mode 100644 index 0000000..dcb68b1 --- /dev/null +++ b/exchange_test.go @@ -0,0 +1,199 @@ +package exchange + +import ( + "bytes" + "encoding/json" + "log" + "net/url" + "os" + "testing" + "time" + + "github.com/google/go-querystring/query" + "github.com/shopspring/decimal" + "github.com/stretchr/testify/require" +) + +const ( + keyFile = ".key" + + // $1 CAD ≅ 70¢ USD (±20%) + // This is the rate at the time of writing. It may need to be + // changed later if something drastic occurs in either country. + usdPerCad = 0.70 + rateEpsilon = 0.20 + + floatEpsilon = 1e-7 +) + +var apiKey string + +func TestMain(m *testing.M) { + debug = true + + // Load API key + buf, err := os.ReadFile(keyFile) + if err != nil { + log.Fatalf("error loading API key: %v\n", err) + } + apiKey = string(bytes.TrimSpace(buf)) + + m.Run() +} + +func TestUnmarshalApiError(t *testing.T) { + data := ` +{ + "status": { + "timestamp": "2018-06-02T22:51:28.209Z", + "error_code": 1002, + "error_message": "API key missing.", + "elapsed": 10, + "credit_count": 0 + } +} +` + want := apiError{ + status{"API key missing."}, + } + var info apiError + err := json.Unmarshal([]byte(data), &info) + require.NoError(t, err) + require.Equal(t, want, info) +} + +func TestPriceConversionQuery(t *testing.T) { + q := priceConversionQuery{123, "USD", "CAD"} + want := url.Values{ + "amount": {"123"}, + "symbol": {"USD"}, + "convert": {"CAD"}, + } + vals, err := query.Values(q) + require.NoError(t, err) + require.Equal(t, want, vals) +} + +func TestUnmarshalPriceConversionResponse(t *testing.T) { + data := ` +{ + "data": [ + { + "id": 32134, + "symbol": "CAD", + "name": "Caduceus Protocol (new)", + "amount": 1, + "quote": { + "USD": { + "price": 0.00026617250802605987, + "last_updated": "2026-07-09T17:31:05.000Z" + } + }, + "last_updated": "2026-07-09T17:30:00.000Z" + }, + { + "id": 2784, + "symbol": "CAD", + "name": "Canadian Dollar", + "amount": 1, + "quote": { + "USD": { + "price": 0.7060965078585, + "last_updated": "2026-07-09T17:31:05.000Z" + } + }, + "last_updated": "2026-07-09T17:31:05.000Z" + } + ], + "status": { + "timestamp": "2026-07-09T17:32:29.378Z", + "error_code": 0, + "error_message": null, + "elapsed": 25, + "credit_count": 1, + "notice": null + } +} + +` + want := response[[]priceConversion]{ + Data: []priceConversion{ + { + 32134, + map[string]price{ + "USD": {decimal.RequireFromString("0.00026617250802605987")}, + }, + }, { + 2784, + map[string]price{ + "USD": {decimal.RequireFromString("0.7060965078585")}, + }, + }, + }, + } + var conv response[[]priceConversion] + err := json.Unmarshal([]byte(data), &conv) + require.NoError(t, err) + require.Equal(t, want, conv) +} + +func TestRateCache(t *testing.T) { + clnt := NewClient(apiKey) + defer clnt.Close() + from, to := "CAD", "USD" + + // Repeat to exercise cache + for i := 0; i < 5; i++ { + rate, err := clnt.Rate(from, to) + require.NoError(t, err) + t.Log(rate) + require.InEpsilon(t, usdPerCad, rate.InexactFloat64(), rateEpsilon) + } +} + +func TestRateCacheExpire(t *testing.T) { + ttl := time.Millisecond + clnt := NewClient(apiKey, WithTTL(ttl)) + defer clnt.Close() + from, to := "CAD", "USD" + check := func() { + rate, err := clnt.Rate(from, to) + require.NoError(t, err) + t.Log(rate) + require.InEpsilon(t, usdPerCad, rate.InexactFloat64(), rateEpsilon) + } + check() // retrieve + time.Sleep(2 * ttl) // expire + check() // refresh +} + +func TestRateFiatFiat(t *testing.T) { + testRate(t, "AUD", "CAD") +} + +func TestRateFiatCrypto(t *testing.T) { + testRate(t, "CAD", "XMR") +} + +func TestRateCryptoCrypto(t *testing.T) { + testRate(t, "XMR", "BTC") +} + +func testRate(t *testing.T, from, to string) { + c := NewClient(apiKey) + defer c.Close() + + aPerB := rate(t, c, from, to) + bPerA := rate(t, c, to, from) + require.NotZero(t, aPerB) + require.NotZero(t, bPerA) + require.InEpsilon(t, bPerA.InexactFloat64(), decimal.NewFromInt(1).Div(aPerB).InexactFloat64(), floatEpsilon) +} + +func rate(t *testing.T, c *Client, from, to string) decimal.Decimal { + t.Helper() + rate, err := c.Rate(from, to) + require.NoError(t, err) + t.Logf("1 %s = %v %s\n", from, rate, to) + return rate +} @@ -0,0 +1,29 @@ +module git.samanthony.xyz/exchange + +go 1.26 + +require ( + github.com/google/go-querystring v1.2.0 + github.com/jellydator/ttlcache/v3 v3.4.1 + github.com/shopspring/decimal v1.4.0 + github.com/stretchr/testify v1.11.1 + github.com/thrasher-corp/gocryptotrader v0.0.0-20260707022640-7e0257651955 +) + +require ( + github.com/bojanz/currency v1.4.4 // indirect + github.com/bytedance/gopkg v0.1.3 // indirect + github.com/bytedance/sonic v1.15.2 // indirect + github.com/bytedance/sonic/loader v0.5.1 // indirect + github.com/cloudwego/base64x v0.1.6 // indirect + github.com/cockroachdb/apd/v3 v3.2.1 // indirect + github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect + github.com/klauspost/cpuid/v2 v2.2.9 // indirect + github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect + github.com/twitchyliquid64/golang-asm v0.15.1 // indirect + golang.org/x/arch v0.13.0 // indirect + golang.org/x/sync v0.16.0 // indirect + golang.org/x/sys v0.46.0 // indirect + golang.org/x/time v0.15.0 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect +) @@ -0,0 +1,60 @@ +github.com/bojanz/currency v1.4.4 h1:EJjt1pZYR2BwBJxFQWXndAs/ZK56Lfs+ZBDLow8ISvs= +github.com/bojanz/currency v1.4.4/go.mod h1:hv7AAJ5jNRvE/SXaJe74uBPg6syV21imy4yKP7fi9GM= +github.com/bytedance/gopkg v0.1.3 h1:TPBSwH8RsouGCBcMBktLt1AymVo2TVsBVCY4b6TnZ/M= +github.com/bytedance/gopkg v0.1.3/go.mod h1:576VvJ+eJgyCzdjS+c4+77QF3p7ubbtiKARP3TxducM= +github.com/bytedance/sonic v1.15.2 h1:90H+rcF/FwLXwfB1cudOLq/je83n683Utf4Cbp0xHCo= +github.com/bytedance/sonic v1.15.2/go.mod h1:mT2NbXunuaEbnZ+mRIX/vYqKISmgEuHFDI4UzmKx2SA= +github.com/bytedance/sonic/loader v0.5.1 h1:Ygpfa9zwRCCKSlrp5bBP/b/Xzc3VxsAW+5NIYXrOOpI= +github.com/bytedance/sonic/loader v0.5.1/go.mod h1:AR4NYCk5DdzZizZ5djGqQ92eEhCCcdf5x77udYiSJRo= +github.com/cloudwego/base64x v0.1.6 h1:t11wG9AECkCDk5fMSoxmufanudBtJ+/HemLstXDLI2M= +github.com/cloudwego/base64x v0.1.6/go.mod h1:OFcloc187FXDaYHvrNIjxSe8ncn0OOM8gEHfghB2IPU= +github.com/cockroachdb/apd/v3 v3.2.1 h1:U+8j7t0axsIgvQUqthuNm82HIrYXodOV2iWLWtEaIwg= +github.com/cockroachdb/apd/v3 v3.2.1/go.mod h1:klXJcjp+FffLTHlhIG69tezTDvdP065naDsHzKhYSqc= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= +github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/gofrs/uuid v4.4.0+incompatible h1:3qXRTX8/NbyulANqlc0lchS1gqAVxRgsuW1YrTJupqA= +github.com/gofrs/uuid v4.4.0+incompatible/go.mod h1:b2aQJv3Z4Fp6yNu3cdSllBxTCLRxnplIgP/c0N/04lM= +github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= +github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= +github.com/google/go-querystring v1.2.0 h1:yhqkPbu2/OH+V9BfpCVPZkNmUXhb2gBxJArfhIxNtP0= +github.com/google/go-querystring v1.2.0/go.mod h1:8IFJqpSRITyJ8QhQ13bmbeMBDfmeEJZD5A0egEOmkqU= +github.com/jellydator/ttlcache/v3 v3.4.1 h1:bOdXmXiycyK6E6Qjyuj5vl+/vU3SCOoDs8a86NbHjAQ= +github.com/jellydator/ttlcache/v3 v3.4.1/go.mod h1:j7LO12PNghFg5+0v9budMAT4rDK4JY969jb9vOdOBBk= +github.com/klauspost/cpuid/v2 v2.2.9 h1:66ze0taIn2H33fBvCkXuv9BmCwDfafmiIVpKV9kKGuY= +github.com/klauspost/cpuid/v2 v2.2.9/go.mod h1:rqkxqrZ1EhYM9G+hXH7YdowN5R5RGN6NK4QwQ3WMXF8= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U= +github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/shopspring/decimal v1.4.0 h1:bxl37RwXBklmTi0C79JfXCEBD1cqqHt0bbgBAGFp81k= +github.com/shopspring/decimal v1.4.0/go.mod h1:gawqmDU56v4yIKSwfBSFip1HdCCXN8/+DMd9qYNcwME= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= +github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= +github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= +github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= +github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= +github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +github.com/thrasher-corp/gocryptotrader v0.0.0-20260707022640-7e0257651955 h1:xeZwIbTLOg7JN2dFAARveH+HIZDK4AyeQF1t3utz8zE= +github.com/thrasher-corp/gocryptotrader v0.0.0-20260707022640-7e0257651955/go.mod h1:+8roJWeRWhfkiGVODAw9CR1boMl/4Z4clLSOvbFDU5k= +github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI= +github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08= +go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= +go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= +golang.org/x/arch v0.13.0 h1:KCkqVVV1kGg0X87TFysjCJ8MxtZEIU4Ja/yXGeoECdA= +golang.org/x/arch v0.13.0/go.mod h1:FEVrYAQjsQXMVJ1nsMoVVXPZg6p2JE2mx8psSWTDQys= +golang.org/x/sync v0.16.0 h1:ycBJEhp9p4vXvUZNszeOq0kGTPghopOL8q0fq3vstxw= +golang.org/x/sync v0.16.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= +golang.org/x/sys v0.46.0 h1:noSf2Fq6F8DBgS+LysIkx7rIExoNHJsxOAtPp4rthXw= +golang.org/x/sys v0.46.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/time v0.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U= +golang.org/x/time v0.15.0/go.mod h1:Y4YMaQmXwGQZoFaVFk4YpCt4FLQMYKZe9oeV/f4MSno= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= @@ -0,0 +1,17 @@ +package exchange + +import ( + "log" + "os" +) + +var ( + lg = log.New(os.Stderr, "git.samanthony.xyz/exchange ", log.LstdFlags) + debug bool +) + +func debugf(format string, a ...any) { + if debug { + lg.Printf(format, a...) + } +} @@ -0,0 +1,23 @@ +package exchange + +import "time" + +var DefaultTTL = 24 * time.Hour + +type Option func(o *options) + +type options struct { + ttl time.Duration +} + +func parseOpts(opts []Option) options { + o := options{DefaultTTL} + for i := range opts { + opts[i](&o) + } + return o +} + +func WithTTL(ttl time.Duration) Option { + return func(o *options) { o.ttl = ttl } +} diff --git a/schema.go b/schema.go new file mode 100644 index 0000000..ec3dfee --- /dev/null +++ b/schema.go @@ -0,0 +1,39 @@ +package exchange + +import ( + "fmt" + + "github.com/shopspring/decimal" +) + +type response[T any] struct { + Data T + Status status +} + +type status struct { + Error string `json:"error_message"` +} + +type apiError struct { + Status status +} + +func (e apiError) Error() string { + return fmt.Sprintf("currency exchange api error: %v", e.Status.Error) +} + +type priceConversionQuery struct { + Amount uint `url:"amount"` + From string `url:"symbol"` + To string `url:"convert"` +} + +type priceConversion struct { + Id uint + Quote map[string]price +} + +type price struct { + Price decimal.Decimal +} |