summaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorSam Anthony <sam@samanthony.xyz>2026-07-09 15:36:08 -0230
committerSam Anthony <sam@samanthony.xyz>2026-07-09 15:36:08 -0230
commit4853cad7499a953deb4a18bae2c14c1482007e42 (patch)
treeb6a7eaf332d4c89ba585f88e1a345d716df59ae1
downloadexchange-4853cad7499a953deb4a18bae2c14c1482007e42.zip
rate
-rw-r--r--.gitignore1
-rw-r--r--err.go11
-rw-r--r--example_test.go33
-rw-r--r--exchange.go183
-rw-r--r--exchange_test.go199
-rw-r--r--go.mod29
-rw-r--r--go.sum60
-rw-r--r--log.go17
-rw-r--r--opt.go23
-rw-r--r--schema.go39
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
diff --git a/err.go b/err.go
new file mode 100644
index 0000000..48c8b12
--- /dev/null
+++ b/err.go
@@ -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
+}
diff --git a/go.mod b/go.mod
new file mode 100644
index 0000000..f5f390e
--- /dev/null
+++ b/go.mod
@@ -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
+)
diff --git a/go.sum b/go.sum
new file mode 100644
index 0000000..61d96a7
--- /dev/null
+++ b/go.sum
@@ -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=
diff --git a/log.go b/log.go
new file mode 100644
index 0000000..3971126
--- /dev/null
+++ b/log.go
@@ -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...)
+ }
+}
diff --git a/opt.go b/opt.go
new file mode 100644
index 0000000..840a98f
--- /dev/null
+++ b/opt.go
@@ -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
+}