From aca54d90e2c92ef4669be70a452d11fd11cc3f79 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Aleksa=20Siri=C5=A1ki?= <31509435+aleksasiriski@users.noreply.github.com> Date: Thu, 11 Jul 2024 03:38:51 +0200 Subject: [PATCH 01/13] feat(exchange): added 3 engines and unfinished route --- src/exchange/currency/currency.go | 26 ++++ src/exchange/currency/map.go | 40 ++++++ src/exchange/engines.go | 28 +++++ src/exchange/engines/currencyapi/exchange.go | 57 +++++++++ src/exchange/engines/currencyapi/info.go | 6 + src/exchange/engines/currencyapi/json.go | 6 + src/exchange/engines/currencyapi/new.go | 17 +++ src/exchange/engines/currencyapi/note.md | 1 + src/exchange/engines/exchanger.go | 9 ++ .../engines/exchangerateapi/exchange.go | 57 +++++++++ src/exchange/engines/exchangerateapi/info.go | 6 + src/exchange/engines/exchangerateapi/json.go | 5 + src/exchange/engines/exchangerateapi/new.go | 17 +++ src/exchange/engines/frankfurter/exchange.go | 57 +++++++++ src/exchange/engines/frankfurter/info.go | 6 + src/exchange/engines/frankfurter/json.go | 6 + src/exchange/engines/frankfurter/new.go | 17 +++ src/exchange/engines/name.go | 23 ++++ src/exchange/exchange.go | 57 +++++++++ src/router/routes/responses.go | 11 ++ src/router/routes/route_exchange.go | 117 ++++++++++++++++++ src/router/routes/setup.go | 22 ++++ 22 files changed, 591 insertions(+) create mode 100644 src/exchange/currency/currency.go create mode 100644 src/exchange/currency/map.go create mode 100644 src/exchange/engines.go create mode 100644 src/exchange/engines/currencyapi/exchange.go create mode 100644 src/exchange/engines/currencyapi/info.go create mode 100644 src/exchange/engines/currencyapi/json.go create mode 100644 src/exchange/engines/currencyapi/new.go create mode 100644 src/exchange/engines/currencyapi/note.md create mode 100644 src/exchange/engines/exchanger.go create mode 100644 src/exchange/engines/exchangerateapi/exchange.go create mode 100644 src/exchange/engines/exchangerateapi/info.go create mode 100644 src/exchange/engines/exchangerateapi/json.go create mode 100644 src/exchange/engines/exchangerateapi/new.go create mode 100644 src/exchange/engines/frankfurter/exchange.go create mode 100644 src/exchange/engines/frankfurter/info.go create mode 100644 src/exchange/engines/frankfurter/json.go create mode 100644 src/exchange/engines/frankfurter/new.go create mode 100644 src/exchange/engines/name.go create mode 100644 src/exchange/exchange.go create mode 100644 src/router/routes/route_exchange.go diff --git a/src/exchange/currency/currency.go b/src/exchange/currency/currency.go new file mode 100644 index 00000000..712ffce2 --- /dev/null +++ b/src/exchange/currency/currency.go @@ -0,0 +1,26 @@ +package currency + +import ( + "fmt" + "strings" +) + +// Format: ISO 4217 (3-letter code) e.g. USD, EUR, GBP +type Currency string + +func (c Currency) String() string { + return string(c) +} + +func (c Currency) Lower() string { + return strings.ToLower(c.String()) +} + +func Convert(curr string) (Currency, error) { + if len(curr) != 3 { + return "", fmt.Errorf("currency code must be 3 characters long") + } + + upperCurr := strings.ToUpper(curr) + return Currency(upperCurr), nil +} diff --git a/src/exchange/currency/map.go b/src/exchange/currency/map.go new file mode 100644 index 00000000..16496e7a --- /dev/null +++ b/src/exchange/currency/map.go @@ -0,0 +1,40 @@ +package currency + +import ( + "sync" +) + +type CurrencyMap struct { + currs map[Currency][]float64 + lock sync.RWMutex +} + +func NewCurrencyMap() CurrencyMap { + return CurrencyMap{ + currs: make(map[Currency][]float64), + } +} + +func (c *CurrencyMap) Append(currs map[Currency]float64) { + c.lock.Lock() + defer c.lock.Unlock() + + for curr, rate := range currs { + c.currs[curr] = append(c.currs[curr], rate) + } +} + +func (c *CurrencyMap) Extract() map[Currency]float64 { + c.lock.RLock() + defer c.lock.RUnlock() + + avg := make(map[Currency]float64) + for curr, rates := range c.currs { + var sum float64 + for _, rate := range rates { + sum += rate + } + avg[curr] = sum / float64(len(rates)) + } + return avg +} diff --git a/src/exchange/engines.go b/src/exchange/engines.go new file mode 100644 index 00000000..d82d7c7e --- /dev/null +++ b/src/exchange/engines.go @@ -0,0 +1,28 @@ +package exchange + +import ( + "github.com/hearchco/agent/src/exchange/engines" + "github.com/hearchco/agent/src/exchange/engines/currencyapi" + "github.com/hearchco/agent/src/exchange/engines/exchangerateapi" + "github.com/hearchco/agent/src/exchange/engines/frankfurter" +) + +func _() { + // An "invalid array index" compiler error signifies that the constant values have changed. + // Re-run the enginer command to generate them again. + var x [1]struct{} + _ = x[engines.UNDEFINED-(0)] + _ = x[engines.CURRENCYAPI-(1)] + _ = x[engines.EXCHANGERATEAPI-(2)] + _ = x[engines.FRANKFURTER-(3)] +} + +const enginerLen = 4 + +func exchangerArray() [enginerLen]engines.Exchanger { + var engineArray [enginerLen]engines.Exchanger + engineArray[engines.CURRENCYAPI] = currencyapi.New() + engineArray[engines.EXCHANGERATEAPI] = exchangerateapi.New() + engineArray[engines.FRANKFURTER] = frankfurter.New() + return engineArray +} diff --git a/src/exchange/engines/currencyapi/exchange.go b/src/exchange/engines/currencyapi/exchange.go new file mode 100644 index 00000000..025a180b --- /dev/null +++ b/src/exchange/engines/currencyapi/exchange.go @@ -0,0 +1,57 @@ +package currencyapi + +import ( + "encoding/json" + "fmt" + "io" + "net/http" + + "github.com/rs/zerolog/log" + + "github.com/hearchco/agent/src/exchange/currency" +) + +func (e Exchange) Exchange(base currency.Currency) (map[currency.Currency]float64, error) { + // Get data from the API. + api := e.apiUrlWithBaseCurrency(base) + resp, err := http.Get(api) + if err != nil { + return nil, fmt.Errorf("failed to get data from %s: %w", api, err) + } + + // Read the response body. + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, fmt.Errorf("failed to read response body: %w", err) + } + + // Unmarshal the response. + var data response + if err := json.Unmarshal(body, &data); err != nil { + return nil, fmt.Errorf("failed to unmarshal response: %w", err) + } + + // Check if no rates were found. + if len(data.Rates) == 0 { + return nil, fmt.Errorf("no rates found for %s", base) + } + + // Convert the rates to proper currency types with their rates. + rates := make(map[currency.Currency]float64, len(data.Rates)) + for currS, rate := range data.Rates { + curr, err := currency.Convert(currS) + if err != nil { + log.Error(). + Err(err). + Str("currency", currS). + Msg("failed to convert currency") + continue + } + rates[curr] = rate + } + + // Set the base currency rate to 1. + rates[base] = 1 + + return rates, nil +} diff --git a/src/exchange/engines/currencyapi/info.go b/src/exchange/engines/currencyapi/info.go new file mode 100644 index 00000000..dd6fb83c --- /dev/null +++ b/src/exchange/engines/currencyapi/info.go @@ -0,0 +1,6 @@ +package currencyapi + +const ( + // Needs to have /.json at the end + apiUrl = "https://cdn.jsdelivr.net/npm/@fawazahmed0/currency-api@2024-03-06/v1/currencies" +) diff --git a/src/exchange/engines/currencyapi/json.go b/src/exchange/engines/currencyapi/json.go new file mode 100644 index 00000000..4f44ce6d --- /dev/null +++ b/src/exchange/engines/currencyapi/json.go @@ -0,0 +1,6 @@ +package currencyapi + +// Rates field is named the same as base currency. +type response struct { + Rates map[string]float64 `json:"eur"` +} diff --git a/src/exchange/engines/currencyapi/new.go b/src/exchange/engines/currencyapi/new.go new file mode 100644 index 00000000..dd7b7a3a --- /dev/null +++ b/src/exchange/engines/currencyapi/new.go @@ -0,0 +1,17 @@ +package currencyapi + +import ( + "github.com/hearchco/agent/src/exchange/currency" +) + +type Exchange struct { + apiUrl string +} + +func New() Exchange { + return Exchange{apiUrl} +} + +func (e Exchange) apiUrlWithBaseCurrency(base currency.Currency) string { + return e.apiUrl + "/" + base.Lower() + ".json" +} diff --git a/src/exchange/engines/currencyapi/note.md b/src/exchange/engines/currencyapi/note.md new file mode 100644 index 00000000..b6897ebb --- /dev/null +++ b/src/exchange/engines/currencyapi/note.md @@ -0,0 +1 @@ +Includes a lot of currencies (and crypto) that aren's in ISO format so errors in logs are to be expected. diff --git a/src/exchange/engines/exchanger.go b/src/exchange/engines/exchanger.go new file mode 100644 index 00000000..5f810ad7 --- /dev/null +++ b/src/exchange/engines/exchanger.go @@ -0,0 +1,9 @@ +package engines + +import ( + "github.com/hearchco/agent/src/exchange/currency" +) + +type Exchanger interface { + Exchange(base currency.Currency) (map[currency.Currency]float64, error) +} diff --git a/src/exchange/engines/exchangerateapi/exchange.go b/src/exchange/engines/exchangerateapi/exchange.go new file mode 100644 index 00000000..fcd2484e --- /dev/null +++ b/src/exchange/engines/exchangerateapi/exchange.go @@ -0,0 +1,57 @@ +package exchangerateapi + +import ( + "encoding/json" + "fmt" + "io" + "net/http" + + "github.com/rs/zerolog/log" + + "github.com/hearchco/agent/src/exchange/currency" +) + +func (e Exchange) Exchange(base currency.Currency) (map[currency.Currency]float64, error) { + // Get data from the API. + api := e.apiUrlWithBaseCurrency(base) + resp, err := http.Get(api) + if err != nil { + return nil, fmt.Errorf("failed to get data from %s: %w", api, err) + } + + // Read the response body. + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, fmt.Errorf("failed to read response body: %w", err) + } + + // Unmarshal the response. + var data response + if err := json.Unmarshal(body, &data); err != nil { + return nil, fmt.Errorf("failed to unmarshal response: %w", err) + } + + // Check if no rates were found. + if len(data.Rates) == 0 { + return nil, fmt.Errorf("no rates found for %s", base) + } + + // Convert the rates to proper currency types with their rates. + rates := make(map[currency.Currency]float64, len(data.Rates)) + for currS, rate := range data.Rates { + curr, err := currency.Convert(currS) + if err != nil { + log.Error(). + Err(err). + Str("currency", currS). + Msg("failed to convert currency") + continue + } + rates[curr] = rate + } + + // Set the base currency rate to 1. + rates[base] = 1 + + return rates, nil +} diff --git a/src/exchange/engines/exchangerateapi/info.go b/src/exchange/engines/exchangerateapi/info.go new file mode 100644 index 00000000..50a01ac4 --- /dev/null +++ b/src/exchange/engines/exchangerateapi/info.go @@ -0,0 +1,6 @@ +package exchangerateapi + +const ( + // Needs to have / at the end + apiUrl = "https://open.er-api.com/v6/latest" +) diff --git a/src/exchange/engines/exchangerateapi/json.go b/src/exchange/engines/exchangerateapi/json.go new file mode 100644 index 00000000..bc3fce0b --- /dev/null +++ b/src/exchange/engines/exchangerateapi/json.go @@ -0,0 +1,5 @@ +package exchangerateapi + +type response struct { + Rates map[string]float64 `json:"rates"` +} diff --git a/src/exchange/engines/exchangerateapi/new.go b/src/exchange/engines/exchangerateapi/new.go new file mode 100644 index 00000000..585abb25 --- /dev/null +++ b/src/exchange/engines/exchangerateapi/new.go @@ -0,0 +1,17 @@ +package exchangerateapi + +import ( + "github.com/hearchco/agent/src/exchange/currency" +) + +type Exchange struct { + apiUrl string +} + +func New() Exchange { + return Exchange{apiUrl} +} + +func (e Exchange) apiUrlWithBaseCurrency(base currency.Currency) string { + return e.apiUrl + "/" + base.String() +} diff --git a/src/exchange/engines/frankfurter/exchange.go b/src/exchange/engines/frankfurter/exchange.go new file mode 100644 index 00000000..93115af8 --- /dev/null +++ b/src/exchange/engines/frankfurter/exchange.go @@ -0,0 +1,57 @@ +package frankfurter + +import ( + "encoding/json" + "fmt" + "io" + "net/http" + + "github.com/rs/zerolog/log" + + "github.com/hearchco/agent/src/exchange/currency" +) + +func (e Exchange) Exchange(base currency.Currency) (map[currency.Currency]float64, error) { + // Get data from the API. + api := e.apiUrlWithBaseCurrency(base) + resp, err := http.Get(api) + if err != nil { + return nil, fmt.Errorf("failed to get data from %s: %w", api, err) + } + + // Read the response body. + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, fmt.Errorf("failed to read response body: %w", err) + } + + // Unmarshal the response. + var data response + if err := json.Unmarshal(body, &data); err != nil { + return nil, fmt.Errorf("failed to unmarshal response: %w", err) + } + + // Check if no rates were found. + if len(data.Rates) == 0 { + return nil, fmt.Errorf("no rates found for %s", base) + } + + // Convert the rates to proper currency types with their rates. + rates := make(map[currency.Currency]float64, len(data.Rates)) + for currS, rate := range data.Rates { + curr, err := currency.Convert(currS) + if err != nil { + log.Error(). + Err(err). + Str("currency", currS). + Msg("failed to convert currency") + continue + } + rates[curr] = rate + } + + // Set the base currency rate to 1. + rates[base] = 1 + + return rates, nil +} diff --git a/src/exchange/engines/frankfurter/info.go b/src/exchange/engines/frankfurter/info.go new file mode 100644 index 00000000..c4ada286 --- /dev/null +++ b/src/exchange/engines/frankfurter/info.go @@ -0,0 +1,6 @@ +package frankfurter + +const ( + // Needs to have ?from= at the end + apiUrl = "https://api.frankfurter.app/latest" +) diff --git a/src/exchange/engines/frankfurter/json.go b/src/exchange/engines/frankfurter/json.go new file mode 100644 index 00000000..c9bbb49c --- /dev/null +++ b/src/exchange/engines/frankfurter/json.go @@ -0,0 +1,6 @@ +package frankfurter + +// Rates doesn't include the base currency. +type response struct { + Rates map[string]float64 `json:"rates"` +} diff --git a/src/exchange/engines/frankfurter/new.go b/src/exchange/engines/frankfurter/new.go new file mode 100644 index 00000000..3fb51268 --- /dev/null +++ b/src/exchange/engines/frankfurter/new.go @@ -0,0 +1,17 @@ +package frankfurter + +import ( + "github.com/hearchco/agent/src/exchange/currency" +) + +type Exchange struct { + apiUrl string +} + +func New() Exchange { + return Exchange{apiUrl} +} + +func (e Exchange) apiUrlWithBaseCurrency(base currency.Currency) string { + return apiUrl + "?from=" + base.String() +} diff --git a/src/exchange/engines/name.go b/src/exchange/engines/name.go new file mode 100644 index 00000000..f20842c4 --- /dev/null +++ b/src/exchange/engines/name.go @@ -0,0 +1,23 @@ +package engines + +type Name int + +const ( + UNDEFINED Name = iota + CURRENCYAPI + EXCHANGERATEAPI + FRANKFURTER +) + +func (n Name) String() string { + switch n { + case CURRENCYAPI: + return "CurrencyAPI" + case EXCHANGERATEAPI: + return "ExchangeRateAPI" + case FRANKFURTER: + return "Frankfurter" + default: + return "Undefined" + } +} diff --git a/src/exchange/exchange.go b/src/exchange/exchange.go new file mode 100644 index 00000000..a88460d2 --- /dev/null +++ b/src/exchange/exchange.go @@ -0,0 +1,57 @@ +package exchange + +import ( + "fmt" + "sync" + + "github.com/hearchco/agent/src/exchange/currency" + "github.com/hearchco/agent/src/exchange/engines" + "github.com/rs/zerolog/log" +) + +func Exchange(base currency.Currency, from currency.Currency, to currency.Currency, amount float64) (float64, error) { + // TODO: Load currency map from cache if available for the given engines (they should be part of the PK). + + enabledEngines := []engines.Name{engines.CURRENCYAPI, engines.EXCHANGERATEAPI, engines.FRANKFURTER} + exchangers := exchangerArray() + currencyArrayMap := currency.NewCurrencyMap() + + var wg sync.WaitGroup + wg.Add(enginerLen - 1) // -1 because of UNDEFINED + for _, eng := range enabledEngines { + exch := exchangers[eng] + go func() { + defer wg.Done() + currs, err := exch.Exchange(base) + if err != nil { + log.Error(). + Err(err). + Str("engine", eng.String()). + Msg("Error while exchanging") + return + } + currencyArrayMap.Append(currs) + }() + } + wg.Wait() + + // TODO: Cache the currency map. + // Extract the averaged currency map. + currencyMap := currencyArrayMap.Extract() + + // Check if FROM and TO currencies are supported. + if _, ok := currencyMap[from]; !ok { + return -1, fmt.Errorf("unsupported FROM currency: %s", from) + } + if _, ok := currencyMap[to]; !ok { + return -1, fmt.Errorf("unsupported TO currency: %s", to) + } + + // Convert the amount in FROM currency to base currency. + basedAmount := amount / currencyMap[from] + + // Convert the amount in base currency to TO currency. + convertedAmount := basedAmount * currencyMap[to] + + return convertedAmount, nil +} diff --git a/src/router/routes/responses.go b/src/router/routes/responses.go index 4fead5d9..2a3e239c 100644 --- a/src/router/routes/responses.go +++ b/src/router/routes/responses.go @@ -1,6 +1,7 @@ package routes import ( + "github.com/hearchco/agent/src/exchange/currency" "github.com/hearchco/agent/src/search/result" ) @@ -25,3 +26,13 @@ type SuggestionsResponse struct { Suggestions []result.Suggestion `json:"suggestions"` } + +type ExchangeResponse struct { + responseBase + + Base currency.Currency `json:"base"` + From currency.Currency `json:"from"` + To currency.Currency `json:"to"` + Amount float64 `json:"amount"` + Result float64 `json:"result"` +} diff --git a/src/router/routes/route_exchange.go b/src/router/routes/route_exchange.go new file mode 100644 index 00000000..5093129f --- /dev/null +++ b/src/router/routes/route_exchange.go @@ -0,0 +1,117 @@ +package routes + +import ( + "fmt" + "net/http" + "strconv" + "strings" + "time" + + "github.com/hearchco/agent/src/exchange" + "github.com/hearchco/agent/src/exchange/currency" +) + +func routeExchange(w http.ResponseWriter, r *http.Request, ver string) error { + // Capture start time. + startTime := time.Now() + + // Parse form data (including query params). + if err := r.ParseForm(); err != nil { + // Server error. + werr := writeResponseJSON(w, http.StatusInternalServerError, ErrorResponse{ + Message: "failed to parse form", + Value: fmt.Sprintf("%v", err), + }) + if werr != nil { + return fmt.Errorf("%w: %w", werr, err) + } + return err + } + + // FROM is required. + fromS := strings.TrimSpace(getParamOrDefault(r.Form, "from")) + if fromS == "" { + // User error. + return writeResponseJSON(w, http.StatusBadRequest, ErrorResponse{ + Message: "from cannot be empty or whitespace", + Value: "empty from", + }) + } + + // Parse FROM currency. + from, err := currency.Convert(fromS) + if err != nil { + // User error. + return writeResponseJSON(w, http.StatusBadRequest, ErrorResponse{ + Message: "invalid from currency", + Value: fmt.Sprintf("%v", err), + }) + } + + // TO is required. + toS := strings.TrimSpace(getParamOrDefault(r.Form, "to")) + if toS == "" { + // User error. + return writeResponseJSON(w, http.StatusBadRequest, ErrorResponse{ + Message: "to cannot be empty or whitespace", + Value: "empty to", + }) + } + + // Parse TO currency. + to, err := currency.Convert(toS) + if err != nil { + // User error. + return writeResponseJSON(w, http.StatusBadRequest, ErrorResponse{ + Message: "invalid to currency", + Value: fmt.Sprintf("%v", err), + }) + } + + // AMOUNT is required. + amountS := strings.TrimSpace(getParamOrDefault(r.Form, "amount")) + if amountS == "" { + // User error. + return writeResponseJSON(w, http.StatusBadRequest, ErrorResponse{ + Message: "amount cannot be empty or whitespace", + Value: "empty amount", + }) + } + + // Parse amount. + amount, err := strconv.ParseFloat(amountS, 64) + if err != nil { + // User error. + return writeResponseJSON(w, http.StatusBadRequest, ErrorResponse{ + Message: "invalid amount value", + Value: fmt.Sprintf("%v", err), + }) + } + + // TODO: Make base currency configurable. + const base currency.Currency = "EUR" + convertedAmount, err := exchange.Exchange(base, from, to, amount) + if err != nil { + // Server error. + werr := writeResponseJSON(w, http.StatusInternalServerError, ErrorResponse{ + Message: "failed to exchange", + Value: fmt.Sprintf("%v", err), + }) + if werr != nil { + return fmt.Errorf("%w: %w", werr, err) + } + return err + } + + return writeResponseJSON(w, http.StatusOK, ExchangeResponse{ + responseBase{ + ver, + time.Since(startTime).Milliseconds(), + }, + base, + from, + to, + amount, + convertedAmount, + }) +} diff --git a/src/router/routes/setup.go b/src/router/routes/setup.go index 05f8e1b3..7a238bbd 100644 --- a/src/router/routes/setup.go +++ b/src/router/routes/setup.go @@ -80,6 +80,28 @@ func Setup(mux *chi.Mux, ver string, db cache.DB, conf config.Config) { } }) + // /exchange + mux.Get("/exchange", func(w http.ResponseWriter, r *http.Request) { + err := routeExchange(w, r, ver) + if err != nil { + log.Error(). + Err(err). + Str("path", r.URL.Path). + Str("method", r.Method). + Msg("Failed to send response") + } + }) + mux.Post("/exchange", func(w http.ResponseWriter, r *http.Request) { + err := routeExchange(w, r, ver) + if err != nil { + log.Error(). + Err(err). + Str("path", r.URL.Path). + Str("method", r.Method). + Msg("Failed to send response") + } + }) + // /proxy mux.Get("/proxy", func(w http.ResponseWriter, r *http.Request) { err := routeProxy(w, r, conf.Server.ImageProxy.Salt, conf.Server.ImageProxy.Timeout) From 90c8ed4bf6643ed836c9cf9571ff1bb1ed9a2b1f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Aleksa=20Siri=C5=A1ki?= <31509435+aleksasiriski@users.noreply.github.com> Date: Thu, 11 Jul 2024 14:41:44 +0200 Subject: [PATCH 02/13] feat(cache): exchange --- src/cache/actions_currencies.go | 38 +++++++++++++++++++++++++++++++++ 1 file changed, 38 insertions(+) create mode 100644 src/cache/actions_currencies.go diff --git a/src/cache/actions_currencies.go b/src/cache/actions_currencies.go new file mode 100644 index 00000000..921bfc4b --- /dev/null +++ b/src/cache/actions_currencies.go @@ -0,0 +1,38 @@ +package cache + +import ( + "fmt" + "time" + + "github.com/hearchco/agent/src/exchange/currency" + "github.com/hearchco/agent/src/exchange/engines" +) + +func (db DB) SetCurrencies(engs []engines.Name, currencies map[currency.Currency]float64, ttl ...time.Duration) error { + key := combineExchangeEnginesNames(engs) + return db.driver.Set(key, currencies, ttl...) +} + +func (db DB) GetCurrencies(engs []engines.Name) (map[currency.Currency]float64, error) { + key := combineExchangeEnginesNames(engs) + var currencies map[currency.Currency]float64 + err := db.driver.Get(key, ¤cies) + return currencies, err +} + +func (db DB) GetCurrenciesTTL(engs []engines.Name) (time.Duration, error) { + key := combineExchangeEnginesNames(engs) + return db.driver.GetTTL(key) +} + +func combineExchangeEnginesNames(engs []engines.Name) string { + var key string + for i, eng := range engs { + if i == 0 { + key = fmt.Sprintf("%v", eng.String()) + } else { + key = fmt.Sprintf("%v_%v", key, eng.String()) + } + } + return key +} From 7c0df671c540465cae208c092d26abf30884ab6c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Aleksa=20Siri=C5=A1ki?= <31509435+aleksasiriski@users.noreply.github.com> Date: Thu, 11 Jul 2024 14:44:35 +0200 Subject: [PATCH 03/13] fix: currency map as type --- src/cache/actions_currencies.go | 6 +++--- src/exchange/currency/map.go | 8 +++++--- src/exchange/engines/currencyapi/exchange.go | 4 ++-- src/exchange/engines/exchanger.go | 2 +- src/exchange/engines/exchangerateapi/exchange.go | 4 ++-- src/exchange/engines/frankfurter/exchange.go | 4 ++-- 6 files changed, 15 insertions(+), 13 deletions(-) diff --git a/src/cache/actions_currencies.go b/src/cache/actions_currencies.go index 921bfc4b..5cd50dc1 100644 --- a/src/cache/actions_currencies.go +++ b/src/cache/actions_currencies.go @@ -8,14 +8,14 @@ import ( "github.com/hearchco/agent/src/exchange/engines" ) -func (db DB) SetCurrencies(engs []engines.Name, currencies map[currency.Currency]float64, ttl ...time.Duration) error { +func (db DB) SetCurrencies(engs []engines.Name, currencies currency.Currencies, ttl ...time.Duration) error { key := combineExchangeEnginesNames(engs) return db.driver.Set(key, currencies, ttl...) } -func (db DB) GetCurrencies(engs []engines.Name) (map[currency.Currency]float64, error) { +func (db DB) GetCurrencies(engs []engines.Name) (currency.Currencies, error) { key := combineExchangeEnginesNames(engs) - var currencies map[currency.Currency]float64 + var currencies currency.Currencies err := db.driver.Get(key, ¤cies) return currencies, err } diff --git a/src/exchange/currency/map.go b/src/exchange/currency/map.go index 16496e7a..866092cb 100644 --- a/src/exchange/currency/map.go +++ b/src/exchange/currency/map.go @@ -4,6 +4,8 @@ import ( "sync" ) +type Currencies map[Currency]float64 + type CurrencyMap struct { currs map[Currency][]float64 lock sync.RWMutex @@ -15,7 +17,7 @@ func NewCurrencyMap() CurrencyMap { } } -func (c *CurrencyMap) Append(currs map[Currency]float64) { +func (c *CurrencyMap) Append(currs Currencies) { c.lock.Lock() defer c.lock.Unlock() @@ -24,11 +26,11 @@ func (c *CurrencyMap) Append(currs map[Currency]float64) { } } -func (c *CurrencyMap) Extract() map[Currency]float64 { +func (c *CurrencyMap) Extract() Currencies { c.lock.RLock() defer c.lock.RUnlock() - avg := make(map[Currency]float64) + avg := make(Currencies) for curr, rates := range c.currs { var sum float64 for _, rate := range rates { diff --git a/src/exchange/engines/currencyapi/exchange.go b/src/exchange/engines/currencyapi/exchange.go index 025a180b..14694a6a 100644 --- a/src/exchange/engines/currencyapi/exchange.go +++ b/src/exchange/engines/currencyapi/exchange.go @@ -11,7 +11,7 @@ import ( "github.com/hearchco/agent/src/exchange/currency" ) -func (e Exchange) Exchange(base currency.Currency) (map[currency.Currency]float64, error) { +func (e Exchange) Exchange(base currency.Currency) (currency.Currencies, error) { // Get data from the API. api := e.apiUrlWithBaseCurrency(base) resp, err := http.Get(api) @@ -37,7 +37,7 @@ func (e Exchange) Exchange(base currency.Currency) (map[currency.Currency]float6 } // Convert the rates to proper currency types with their rates. - rates := make(map[currency.Currency]float64, len(data.Rates)) + rates := make(currency.Currencies, len(data.Rates)) for currS, rate := range data.Rates { curr, err := currency.Convert(currS) if err != nil { diff --git a/src/exchange/engines/exchanger.go b/src/exchange/engines/exchanger.go index 5f810ad7..8a9f83eb 100644 --- a/src/exchange/engines/exchanger.go +++ b/src/exchange/engines/exchanger.go @@ -5,5 +5,5 @@ import ( ) type Exchanger interface { - Exchange(base currency.Currency) (map[currency.Currency]float64, error) + Exchange(base currency.Currency) (currency.Currencies, error) } diff --git a/src/exchange/engines/exchangerateapi/exchange.go b/src/exchange/engines/exchangerateapi/exchange.go index fcd2484e..d9b1afc2 100644 --- a/src/exchange/engines/exchangerateapi/exchange.go +++ b/src/exchange/engines/exchangerateapi/exchange.go @@ -11,7 +11,7 @@ import ( "github.com/hearchco/agent/src/exchange/currency" ) -func (e Exchange) Exchange(base currency.Currency) (map[currency.Currency]float64, error) { +func (e Exchange) Exchange(base currency.Currency) (currency.Currencies, error) { // Get data from the API. api := e.apiUrlWithBaseCurrency(base) resp, err := http.Get(api) @@ -37,7 +37,7 @@ func (e Exchange) Exchange(base currency.Currency) (map[currency.Currency]float6 } // Convert the rates to proper currency types with their rates. - rates := make(map[currency.Currency]float64, len(data.Rates)) + rates := make(currency.Currencies, len(data.Rates)) for currS, rate := range data.Rates { curr, err := currency.Convert(currS) if err != nil { diff --git a/src/exchange/engines/frankfurter/exchange.go b/src/exchange/engines/frankfurter/exchange.go index 93115af8..5362cabe 100644 --- a/src/exchange/engines/frankfurter/exchange.go +++ b/src/exchange/engines/frankfurter/exchange.go @@ -11,7 +11,7 @@ import ( "github.com/hearchco/agent/src/exchange/currency" ) -func (e Exchange) Exchange(base currency.Currency) (map[currency.Currency]float64, error) { +func (e Exchange) Exchange(base currency.Currency) (currency.Currencies, error) { // Get data from the API. api := e.apiUrlWithBaseCurrency(base) resp, err := http.Get(api) @@ -37,7 +37,7 @@ func (e Exchange) Exchange(base currency.Currency) (map[currency.Currency]float6 } // Convert the rates to proper currency types with their rates. - rates := make(map[currency.Currency]float64, len(data.Rates)) + rates := make(currency.Currencies, len(data.Rates)) for currS, rate := range data.Rates { curr, err := currency.Convert(currS) if err != nil { From 66f47f9d05137a85afc0aff6a6bc2247af112490 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Aleksa=20Siri=C5=A1ki?= <31509435+aleksasiriski@users.noreply.github.com> Date: Thu, 11 Jul 2024 16:19:25 +0200 Subject: [PATCH 04/13] fix(exchange,cache): proper caching and small refactor --- src/cache/actions_currencies.go | 16 ++++++---- src/exchange/exchange.go | 43 +++++++++++++++++++-------- src/router/routes/route_exchange.go | 46 ++++++++++++++++++++++++++--- src/router/routes/setup.go | 8 +++-- 4 files changed, 89 insertions(+), 24 deletions(-) diff --git a/src/cache/actions_currencies.go b/src/cache/actions_currencies.go index 5cd50dc1..01e69d8d 100644 --- a/src/cache/actions_currencies.go +++ b/src/cache/actions_currencies.go @@ -8,23 +8,27 @@ import ( "github.com/hearchco/agent/src/exchange/engines" ) -func (db DB) SetCurrencies(engs []engines.Name, currencies currency.Currencies, ttl ...time.Duration) error { - key := combineExchangeEnginesNames(engs) +func (db DB) SetCurrencies(base currency.Currency, engs []engines.Name, currencies currency.Currencies, ttl ...time.Duration) error { + key := combineBaseWithExchangeEnginesNames(base, engs) return db.driver.Set(key, currencies, ttl...) } -func (db DB) GetCurrencies(engs []engines.Name) (currency.Currencies, error) { - key := combineExchangeEnginesNames(engs) +func (db DB) GetCurrencies(base currency.Currency, engs []engines.Name) (currency.Currencies, error) { + key := combineBaseWithExchangeEnginesNames(base, engs) var currencies currency.Currencies err := db.driver.Get(key, ¤cies) return currencies, err } -func (db DB) GetCurrenciesTTL(engs []engines.Name) (time.Duration, error) { - key := combineExchangeEnginesNames(engs) +func (db DB) GetCurrenciesTTL(base currency.Currency, engs []engines.Name) (time.Duration, error) { + key := combineBaseWithExchangeEnginesNames(base, engs) return db.driver.GetTTL(key) } +func combineBaseWithExchangeEnginesNames(base currency.Currency, engs []engines.Name) string { + return fmt.Sprintf("%v_%v", base.String(), combineExchangeEnginesNames(engs)) +} + func combineExchangeEnginesNames(engs []engines.Name) string { var key string for i, eng := range engs { diff --git a/src/exchange/exchange.go b/src/exchange/exchange.go index a88460d2..89825a0e 100644 --- a/src/exchange/exchange.go +++ b/src/exchange/exchange.go @@ -9,12 +9,24 @@ import ( "github.com/rs/zerolog/log" ) -func Exchange(base currency.Currency, from currency.Currency, to currency.Currency, amount float64) (float64, error) { - // TODO: Load currency map from cache if available for the given engines (they should be part of the PK). +// TODO: Test caching with private fields. +type Exchange struct { + base currency.Currency + currencies currency.Currencies +} + +func NewExchange(base currency.Currency, enabledEngines []engines.Name, currencies ...currency.Currencies) Exchange { + // If currencies are provided, use them. + if len(currencies) > 0 { + return Exchange{ + base, + currencies[0], + } + } - enabledEngines := []engines.Name{engines.CURRENCYAPI, engines.EXCHANGERATEAPI, engines.FRANKFURTER} + // Otherwise, fetch the currencies from the enabled engines. exchangers := exchangerArray() - currencyArrayMap := currency.NewCurrencyMap() + currencyMap := currency.NewCurrencyMap() var wg sync.WaitGroup wg.Add(enginerLen - 1) // -1 because of UNDEFINED @@ -30,28 +42,35 @@ func Exchange(base currency.Currency, from currency.Currency, to currency.Curren Msg("Error while exchanging") return } - currencyArrayMap.Append(currs) + currencyMap.Append(currs) }() } wg.Wait() - // TODO: Cache the currency map. - // Extract the averaged currency map. - currencyMap := currencyArrayMap.Extract() + return Exchange{ + base, + currencyMap.Extract(), + } +} + +func (e Exchange) Currencies() currency.Currencies { + return e.currencies +} +func (e Exchange) Convert(from currency.Currency, to currency.Currency, amount float64) (float64, error) { // Check if FROM and TO currencies are supported. - if _, ok := currencyMap[from]; !ok { + if _, ok := e.currencies[from]; !ok { return -1, fmt.Errorf("unsupported FROM currency: %s", from) } - if _, ok := currencyMap[to]; !ok { + if _, ok := e.currencies[to]; !ok { return -1, fmt.Errorf("unsupported TO currency: %s", to) } // Convert the amount in FROM currency to base currency. - basedAmount := amount / currencyMap[from] + basedAmount := amount / e.currencies[from] // Convert the amount in base currency to TO currency. - convertedAmount := basedAmount * currencyMap[to] + convertedAmount := basedAmount * e.currencies[to] return convertedAmount, nil } diff --git a/src/router/routes/route_exchange.go b/src/router/routes/route_exchange.go index 5093129f..e8547e41 100644 --- a/src/router/routes/route_exchange.go +++ b/src/router/routes/route_exchange.go @@ -7,11 +7,14 @@ import ( "strings" "time" + "github.com/hearchco/agent/src/cache" "github.com/hearchco/agent/src/exchange" "github.com/hearchco/agent/src/exchange/currency" + "github.com/hearchco/agent/src/exchange/engines" + "github.com/rs/zerolog/log" ) -func routeExchange(w http.ResponseWriter, r *http.Request, ver string) error { +func routeExchange(w http.ResponseWriter, r *http.Request, ver string, db cache.DB, ttl time.Duration) error { // Capture start time. startTime := time.Now() @@ -88,9 +91,32 @@ func routeExchange(w http.ResponseWriter, r *http.Request, ver string) error { }) } - // TODO: Make base currency configurable. + // TODO: Make base currency and enabled engines configurable. const base currency.Currency = "EUR" - convertedAmount, err := exchange.Exchange(base, from, to, amount) + enabledEngines := [...]engines.Name{engines.CURRENCYAPI, engines.EXCHANGERATEAPI, engines.FRANKFURTER} + + // Get the cached currencies. + currencies, err := db.GetCurrencies(base, enabledEngines[:]) + if err != nil { + log.Error(). + Err(err). + Str("base", base.String()). + Str("engines", fmt.Sprintf("%v", enabledEngines)). + Msg("Error while getting currencies from cache") + } + + // Create the exchange. + var exch exchange.Exchange + if currencies == nil { + // Fetch the currencies from the enabled engines. + exch = exchange.NewExchange(base, enabledEngines[:]) + } else { + // Use the cached currencies. + exch = exchange.NewExchange(base, enabledEngines[:], currencies) + } + + // Convert the amount. + convAmount, err := exch.Convert(from, to, amount) if err != nil { // Server error. werr := writeResponseJSON(w, http.StatusInternalServerError, ErrorResponse{ @@ -103,6 +129,18 @@ func routeExchange(w http.ResponseWriter, r *http.Request, ver string) error { return err } + // Cache the currencies. + if currencies == nil { + err := db.SetCurrencies(base, enabledEngines[:], exch.Currencies(), ttl) + if err != nil { + log.Error(). + Err(err). + Str("base", base.String()). + Str("engines", fmt.Sprintf("%v", enabledEngines)). + Msg("Error while setting currencies in cache") + } + } + return writeResponseJSON(w, http.StatusOK, ExchangeResponse{ responseBase{ ver, @@ -112,6 +150,6 @@ func routeExchange(w http.ResponseWriter, r *http.Request, ver string) error { from, to, amount, - convertedAmount, + convAmount, }) } diff --git a/src/router/routes/setup.go b/src/router/routes/setup.go index 7a238bbd..ba5095fb 100644 --- a/src/router/routes/setup.go +++ b/src/router/routes/setup.go @@ -9,6 +9,7 @@ import ( "github.com/hearchco/agent/src/cache" "github.com/hearchco/agent/src/config" "github.com/hearchco/agent/src/search/category" + "github.com/hearchco/agent/src/utils/moretime" ) func Setup(mux *chi.Mux, ver string, db cache.DB, conf config.Config) { @@ -80,9 +81,12 @@ func Setup(mux *chi.Mux, ver string, db cache.DB, conf config.Config) { } }) + // TODO: Make exchange TTL configurable. + exchTTL := moretime.Day + // /exchange mux.Get("/exchange", func(w http.ResponseWriter, r *http.Request) { - err := routeExchange(w, r, ver) + err := routeExchange(w, r, ver, db, exchTTL) if err != nil { log.Error(). Err(err). @@ -92,7 +96,7 @@ func Setup(mux *chi.Mux, ver string, db cache.DB, conf config.Config) { } }) mux.Post("/exchange", func(w http.ResponseWriter, r *http.Request) { - err := routeExchange(w, r, ver) + err := routeExchange(w, r, ver, db, exchTTL) if err != nil { log.Error(). Err(err). From b54a490e0b377420ce9a3cdbc4bc59c07f752109 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Aleksa=20Siri=C5=A1ki?= <31509435+aleksasiriski@users.noreply.github.com> Date: Thu, 11 Jul 2024 16:30:00 +0200 Subject: [PATCH 05/13] fix(exchange): better cache timing and proper checking of currency --- src/exchange/exchange.go | 24 +++++++++------- src/router/routes/route_exchange.go | 44 +++++++++++++++-------------- 2 files changed, 37 insertions(+), 31 deletions(-) diff --git a/src/exchange/exchange.go b/src/exchange/exchange.go index 89825a0e..4d8caf60 100644 --- a/src/exchange/exchange.go +++ b/src/exchange/exchange.go @@ -1,7 +1,6 @@ package exchange import ( - "fmt" "sync" "github.com/hearchco/agent/src/exchange/currency" @@ -9,7 +8,6 @@ import ( "github.com/rs/zerolog/log" ) -// TODO: Test caching with private fields. type Exchange struct { base currency.Currency currencies currency.Currencies @@ -57,13 +55,19 @@ func (e Exchange) Currencies() currency.Currencies { return e.currencies } -func (e Exchange) Convert(from currency.Currency, to currency.Currency, amount float64) (float64, error) { - // Check if FROM and TO currencies are supported. - if _, ok := e.currencies[from]; !ok { - return -1, fmt.Errorf("unsupported FROM currency: %s", from) - } - if _, ok := e.currencies[to]; !ok { - return -1, fmt.Errorf("unsupported TO currency: %s", to) +func (e Exchange) SupportsCurrency(curr currency.Currency) bool { + _, ok := e.currencies[curr] + return ok +} + +func (e Exchange) Convert(from currency.Currency, to currency.Currency, amount float64) float64 { + // Check if FROM and TO are supported currencies. + if !e.SupportsCurrency(from) || !e.SupportsCurrency(to) { + log.Panic(). + Str("from", from.String()). + Str("to", to.String()). + Msg("Unsupported currencies") + // ^PANIC - This should never happen. } // Convert the amount in FROM currency to base currency. @@ -72,5 +76,5 @@ func (e Exchange) Convert(from currency.Currency, to currency.Currency, amount f // Convert the amount in base currency to TO currency. convertedAmount := basedAmount * e.currencies[to] - return convertedAmount, nil + return convertedAmount } diff --git a/src/router/routes/route_exchange.go b/src/router/routes/route_exchange.go index e8547e41..7c37abf3 100644 --- a/src/router/routes/route_exchange.go +++ b/src/router/routes/route_exchange.go @@ -110,27 +110,7 @@ func routeExchange(w http.ResponseWriter, r *http.Request, ver string, db cache. if currencies == nil { // Fetch the currencies from the enabled engines. exch = exchange.NewExchange(base, enabledEngines[:]) - } else { - // Use the cached currencies. - exch = exchange.NewExchange(base, enabledEngines[:], currencies) - } - - // Convert the amount. - convAmount, err := exch.Convert(from, to, amount) - if err != nil { - // Server error. - werr := writeResponseJSON(w, http.StatusInternalServerError, ErrorResponse{ - Message: "failed to exchange", - Value: fmt.Sprintf("%v", err), - }) - if werr != nil { - return fmt.Errorf("%w: %w", werr, err) - } - return err - } - - // Cache the currencies. - if currencies == nil { + // Cache the currencies. err := db.SetCurrencies(base, enabledEngines[:], exch.Currencies(), ttl) if err != nil { log.Error(). @@ -139,8 +119,30 @@ func routeExchange(w http.ResponseWriter, r *http.Request, ver string, db cache. Str("engines", fmt.Sprintf("%v", enabledEngines)). Msg("Error while setting currencies in cache") } + } else { + // Use the cached currencies. + exch = exchange.NewExchange(base, enabledEngines[:], currencies) + } + + // Check if FROM and TO are supported currencies. + if !exch.SupportsCurrency(from) { + // User error. + return writeResponseJSON(w, http.StatusBadRequest, ErrorResponse{ + Message: "unsupported from currency", + Value: from.String(), + }) + } + if !exch.SupportsCurrency(to) { + // User error. + return writeResponseJSON(w, http.StatusBadRequest, ErrorResponse{ + Message: "unsupported to currency", + Value: to.String(), + }) } + // Convert the amount. + convAmount := exch.Convert(from, to, amount) + return writeResponseJSON(w, http.StatusOK, ExchangeResponse{ responseBase{ ver, From 0f7326773307130fb83c4201763b260f7fd24dcd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Aleksa=20Siri=C5=A1ki?= <31509435+aleksasiriski@users.noreply.github.com> Date: Fri, 12 Jul 2024 01:45:40 +0200 Subject: [PATCH 06/13] fix(exchange): config --- src/config/defaults.go | 7 ++++- src/config/defaults_exchange.go | 17 ++++++++++++ src/config/load.go | 43 +++++++++++++++++++++++++++-- src/config/structs_config.go | 2 ++ src/config/structs_exchange.go | 38 +++++++++++++++++++++++++ src/config/structs_server.go | 10 +++++-- src/exchange/currency/currency.go | 5 +++- src/exchange/engines/name.go | 22 +++++++++++++++ src/exchange/exchange.go | 39 ++++++++++++++++++++++---- src/router/middlewares/setup.go | 1 - src/router/routes/route_exchange.go | 38 ++++++++++++------------- src/router/routes/route_search.go | 4 +-- src/router/routes/setup.go | 12 +++----- 13 files changed, 196 insertions(+), 42 deletions(-) create mode 100644 src/config/defaults_exchange.go create mode 100644 src/config/structs_exchange.go diff --git a/src/config/defaults.go b/src/config/defaults.go index b7342610..0cccccfd 100644 --- a/src/config/defaults.go +++ b/src/config/defaults.go @@ -17,7 +17,8 @@ func New() Config { Type: "none", KeyPrefix: "HEARCHCO_", TTL: TTL{ - Time: moretime.Week, + Results: moretime.Week, + Currencies: moretime.Day, }, Redis: Redis{ Host: "localhost", @@ -78,5 +79,9 @@ func New() Config { Timings: thoroughTimings, }, }, + Exchange: Exchange{ + Engines: exchangeEngines, + Timings: exchangeTimings, + }, } } diff --git a/src/config/defaults_exchange.go b/src/config/defaults_exchange.go new file mode 100644 index 00000000..8e6dd4d8 --- /dev/null +++ b/src/config/defaults_exchange.go @@ -0,0 +1,17 @@ +package config + +import ( + "time" + + "github.com/hearchco/agent/src/exchange/engines" +) + +var exchangeEngines = []engines.Name{ + engines.CURRENCYAPI, + engines.EXCHANGERATEAPI, + engines.FRANKFURTER, +} + +var exchangeTimings = ExchangeTimings{ + HardTimeout: 500 * time.Millisecond, +} diff --git a/src/config/load.go b/src/config/load.go index fc3fad91..2526e86b 100644 --- a/src/config/load.go +++ b/src/config/load.go @@ -12,6 +12,7 @@ import ( "github.com/knadh/koanf/v2" "github.com/rs/zerolog/log" + exchengines "github.com/hearchco/agent/src/exchange/engines" "github.com/hearchco/agent/src/search/category" "github.com/hearchco/agent/src/search/engines" "github.com/hearchco/agent/src/utils/moretime" @@ -80,7 +81,8 @@ func (c Config) getReader() ReaderConfig { Cache: ReaderCache{ Type: c.Server.Cache.Type, TTL: ReaderTTL{ - Time: moretime.ConvertToFancyTime(c.Server.Cache.TTL.Time), + Results: moretime.ConvertToFancyTime(c.Server.Cache.TTL.Results), + Currencies: moretime.ConvertToFancyTime(c.Server.Cache.TTL.Currencies), }, Redis: c.Server.Cache.Redis, }, @@ -91,6 +93,13 @@ func (c Config) getReader() ReaderConfig { }, // Initialize the categories map. RCategories: map[category.Name]ReaderCategory{}, + // Exchange config. + RExchange: ReaderExchange{ + REngines: map[string]ReaderExchangeEngine{}, + RTimings: ReaderExchangeTimings{ + HardTimeout: moretime.ConvertToFancyTime(c.Exchange.Timings.HardTimeout), + }, + }, } // Set the categories map config. @@ -124,6 +133,13 @@ func (c Config) getReader() ReaderConfig { } } + // Set the exchange engines. + for _, eng := range c.Exchange.Engines { + rc.RExchange.REngines[eng.ToLower()] = ReaderExchangeEngine{ + Enabled: true, + } + } + return rc } @@ -144,7 +160,8 @@ func (c *Config) fromReader(rc ReaderConfig) { Cache: Cache{ Type: rc.Server.Cache.Type, TTL: TTL{ - Time: moretime.ConvertFromFancyTime(rc.Server.Cache.TTL.Time), + Results: moretime.ConvertFromFancyTime(rc.Server.Cache.TTL.Results), + Currencies: moretime.ConvertFromFancyTime(rc.Server.Cache.TTL.Currencies), }, Redis: rc.Server.Cache.Redis, }, @@ -155,6 +172,13 @@ func (c *Config) fromReader(rc ReaderConfig) { }, // Initialize the categories map. Categories: map[category.Name]Category{}, + // Exchange config. + Exchange: Exchange{ + Engines: []exchengines.Name{}, + Timings: ExchangeTimings{ + HardTimeout: moretime.ConvertFromFancyTime(rc.RExchange.RTimings.HardTimeout), + }, + }, } // Set the categories map config. @@ -213,6 +237,21 @@ func (c *Config) fromReader(rc ReaderConfig) { } } + // Set the exchange engines. + for engS, engRConf := range rc.RExchange.REngines { + engName, err := exchengines.NameString(engS) + if err != nil { + log.Panic(). + Caller(). + Err(err). + Msg("Failed converting string to engine name") + // ^PANIC + } + if engRConf.Enabled { + nc.Exchange.Engines = append(nc.Exchange.Engines, engName) + } + } + // Set the new config. *c = nc } diff --git a/src/config/structs_config.go b/src/config/structs_config.go index a7d69ac9..21f511c9 100644 --- a/src/config/structs_config.go +++ b/src/config/structs_config.go @@ -8,8 +8,10 @@ import ( type ReaderConfig struct { Server ReaderServer `koanf:"server"` RCategories map[category.Name]ReaderCategory `koanf:"categories"` + RExchange ReaderExchange `koanf:"exchange"` } type Config struct { Server Server Categories map[category.Name]Category + Exchange Exchange } diff --git a/src/config/structs_exchange.go b/src/config/structs_exchange.go new file mode 100644 index 00000000..ebbec88a --- /dev/null +++ b/src/config/structs_exchange.go @@ -0,0 +1,38 @@ +package config + +import ( + "time" + + "github.com/hearchco/agent/src/exchange/engines" +) + +// ReaderCategory is format in which the config is read from the config file and environment variables. +type ReaderExchange struct { + REngines map[string]ReaderExchangeEngine `koanf:"engines"` + RTimings ReaderExchangeTimings `koanf:"timings"` +} +type Exchange struct { + Engines []engines.Name + Timings ExchangeTimings +} + +// ReaderEngine is format in which the config is read from the config file and environment variables. +type ReaderExchangeEngine struct { + // If false, the engine will not be used. + Enabled bool `koanf:"enabled"` +} + +// ReaderTimings is format in which the config is read from the config file and environment variables. +// In format. +// Example: 1s, 1m, 1h, 1d, 1w, 1M, 1y. +// If unit is not specified, it is assumed to be milliseconds. +type ReaderExchangeTimings struct { + // Hard timeout after which the search is forcefully stopped (even if the engines didn't respond). + HardTimeout string `koanf:"hardtimeout"` +} + +// Delegates Delay, RandomDelay, Parallelism to colly.Collector.Limit(). +type ExchangeTimings struct { + // Hard timeout after which the search is forcefully stopped (even if the engines didn't respond). + HardTimeout time.Duration +} diff --git a/src/config/structs_server.go b/src/config/structs_server.go index 1282197f..27ad9b88 100644 --- a/src/config/structs_server.go +++ b/src/config/structs_server.go @@ -62,12 +62,18 @@ type Cache struct { type ReaderTTL struct { // How long to store the results in cache. // Setting this to 0 caches the results forever. - Time string `koanf:"time"` + Results string `koanf:"time"` + // How long to store the currencies in cache. + // Setting this to 0 caches the currencies forever. + Currencies string `koanf:"currencies"` } type TTL struct { // How long to store the results in cache. // Setting this to 0 caches the results forever. - Time time.Duration + Results time.Duration + // How long to store the currencies in cache. + // Setting this to 0 caches the currencies forever. + Currencies time.Duration } type Redis struct { diff --git a/src/exchange/currency/currency.go b/src/exchange/currency/currency.go index 712ffce2..60078f90 100644 --- a/src/exchange/currency/currency.go +++ b/src/exchange/currency/currency.go @@ -5,9 +5,12 @@ import ( "strings" ) -// Format: ISO 4217 (3-letter code) e.g. USD, EUR, GBP +// Format: ISO 4217 (3-letter code) e.g. USD, EUR, GBP. type Currency string +// TODO: Make base currency configurable. +const Base Currency = "EUR" + func (c Currency) String() string { return string(c) } diff --git a/src/exchange/engines/name.go b/src/exchange/engines/name.go index f20842c4..258ed707 100644 --- a/src/exchange/engines/name.go +++ b/src/exchange/engines/name.go @@ -1,5 +1,10 @@ package engines +import ( + "fmt" + "strings" +) + type Name int const ( @@ -21,3 +26,20 @@ func (n Name) String() string { return "Undefined" } } + +func (n Name) ToLower() string { + return strings.ToLower(n.String()) +} + +func NameString(s string) (Name, error) { + switch strings.ToLower(s) { + case CURRENCYAPI.ToLower(): + return CURRENCYAPI, nil + case EXCHANGERATEAPI.ToLower(): + return EXCHANGERATEAPI, nil + case FRANKFURTER.ToLower(): + return FRANKFURTER, nil + default: + return UNDEFINED, fmt.Errorf("%s does not belong to Name values", s) + } +} diff --git a/src/exchange/exchange.go b/src/exchange/exchange.go index 4d8caf60..7aa8b5e4 100644 --- a/src/exchange/exchange.go +++ b/src/exchange/exchange.go @@ -1,10 +1,12 @@ package exchange import ( + "context" + "fmt" "sync" + "github.com/hearchco/agent/src/config" "github.com/hearchco/agent/src/exchange/currency" - "github.com/hearchco/agent/src/exchange/engines" "github.com/rs/zerolog/log" ) @@ -13,7 +15,7 @@ type Exchange struct { currencies currency.Currencies } -func NewExchange(base currency.Currency, enabledEngines []engines.Name, currencies ...currency.Currencies) Exchange { +func NewExchange(base currency.Currency, conf config.Exchange, currencies ...currency.Currencies) Exchange { // If currencies are provided, use them. if len(currencies) > 0 { return Exchange{ @@ -26,9 +28,23 @@ func NewExchange(base currency.Currency, enabledEngines []engines.Name, currenci exchangers := exchangerArray() currencyMap := currency.NewCurrencyMap() + // Create context with HardTimeout. + ctxHardTimeout, cancelHardTimeoutFunc := context.WithTimeout(context.Background(), conf.Timings.HardTimeout) + defer cancelHardTimeoutFunc() + + // Create a WaitGroup for all engines. var wg sync.WaitGroup - wg.Add(enginerLen - 1) // -1 because of UNDEFINED - for _, eng := range enabledEngines { + wg.Add(len(conf.Engines)) + + // Create a context that cancels when the WaitGroup is done. + exchangeCtx, cancelExchange := context.WithCancel(context.Background()) + defer cancelExchange() + go func() { + wg.Wait() + cancelExchange() + }() + + for _, eng := range conf.Engines { exch := exchangers[eng] go func() { defer wg.Done() @@ -43,7 +59,20 @@ func NewExchange(base currency.Currency, enabledEngines []engines.Name, currenci currencyMap.Append(currs) }() } - wg.Wait() + + // Wait for either all engines to finish or the HardTimeout. + select { + case <-exchangeCtx.Done(): + log.Trace(). + Dur("timeout", conf.Timings.HardTimeout). + Str("engines", fmt.Sprintf("%v", conf.Engines)). + Msg("All engines finished") + case <-ctxHardTimeout.Done(): + log.Trace(). + Dur("timeout", conf.Timings.HardTimeout). + Str("engines", fmt.Sprintf("%v", conf.Engines)). + Msg("HardTimeout reached") + } return Exchange{ base, diff --git a/src/router/middlewares/setup.go b/src/router/middlewares/setup.go index eef7fd33..29180ba5 100644 --- a/src/router/middlewares/setup.go +++ b/src/router/middlewares/setup.go @@ -13,7 +13,6 @@ import ( func Setup(mux *chi.Mux, lgr zerolog.Logger, frontendUrls []string, serveProfiler bool) { // Use custom zerolog middleware. - // TODO: Make skipped paths configurable. skipPaths := []string{"/healthz", "/versionz"} mux.Use(zerologMiddleware(lgr, skipPaths)...) diff --git a/src/router/routes/route_exchange.go b/src/router/routes/route_exchange.go index 7c37abf3..495890c3 100644 --- a/src/router/routes/route_exchange.go +++ b/src/router/routes/route_exchange.go @@ -8,13 +8,13 @@ import ( "time" "github.com/hearchco/agent/src/cache" + "github.com/hearchco/agent/src/config" "github.com/hearchco/agent/src/exchange" "github.com/hearchco/agent/src/exchange/currency" - "github.com/hearchco/agent/src/exchange/engines" "github.com/rs/zerolog/log" ) -func routeExchange(w http.ResponseWriter, r *http.Request, ver string, db cache.DB, ttl time.Duration) error { +func routeExchange(w http.ResponseWriter, r *http.Request, ver string, conf config.Exchange, db cache.DB, ttl time.Duration) error { // Capture start time. startTime := time.Now() @@ -91,17 +91,13 @@ func routeExchange(w http.ResponseWriter, r *http.Request, ver string, db cache. }) } - // TODO: Make base currency and enabled engines configurable. - const base currency.Currency = "EUR" - enabledEngines := [...]engines.Name{engines.CURRENCYAPI, engines.EXCHANGERATEAPI, engines.FRANKFURTER} - // Get the cached currencies. - currencies, err := db.GetCurrencies(base, enabledEngines[:]) + currencies, err := db.GetCurrencies(currency.Base, conf.Engines) if err != nil { log.Error(). Err(err). - Str("base", base.String()). - Str("engines", fmt.Sprintf("%v", enabledEngines)). + Str("base", currency.Base.String()). + Str("engines", fmt.Sprintf("%v", conf.Engines)). Msg("Error while getting currencies from cache") } @@ -109,19 +105,21 @@ func routeExchange(w http.ResponseWriter, r *http.Request, ver string, db cache. var exch exchange.Exchange if currencies == nil { // Fetch the currencies from the enabled engines. - exch = exchange.NewExchange(base, enabledEngines[:]) - // Cache the currencies. - err := db.SetCurrencies(base, enabledEngines[:], exch.Currencies(), ttl) - if err != nil { - log.Error(). - Err(err). - Str("base", base.String()). - Str("engines", fmt.Sprintf("%v", enabledEngines)). - Msg("Error while setting currencies in cache") + exch = exchange.NewExchange(currency.Base, conf) + // Cache the currencies if any have been fetched. + if len(exch.Currencies()) > 0 { + err := db.SetCurrencies(currency.Base, conf.Engines, exch.Currencies(), ttl) + if err != nil { + log.Error(). + Err(err). + Str("base", currency.Base.String()). + Str("engines", fmt.Sprintf("%v", conf.Engines)). + Msg("Error while setting currencies in cache") + } } } else { // Use the cached currencies. - exch = exchange.NewExchange(base, enabledEngines[:], currencies) + exch = exchange.NewExchange(currency.Base, conf, currencies) } // Check if FROM and TO are supported currencies. @@ -148,7 +146,7 @@ func routeExchange(w http.ResponseWriter, r *http.Request, ver string, db cache. ver, time.Since(startTime).Milliseconds(), }, - base, + currency.Base, from, to, amount, diff --git a/src/router/routes/route_search.go b/src/router/routes/route_search.go index d6a80f7a..aec30d77 100644 --- a/src/router/routes/route_search.go +++ b/src/router/routes/route_search.go @@ -21,7 +21,7 @@ import ( "github.com/hearchco/agent/src/utils/gotypelimits" ) -func routeSearch(w http.ResponseWriter, r *http.Request, ver string, catsConf map[category.Name]config.Category, ttlConf config.TTL, db cache.DB, salt string) error { +func routeSearch(w http.ResponseWriter, r *http.Request, ver string, catsConf map[category.Name]config.Category, db cache.DB, ttl time.Duration, salt string) error { // Capture start time. startTime := time.Now() @@ -173,7 +173,7 @@ func routeSearch(w http.ResponseWriter, r *http.Request, ver string, catsConf ma rankedRes.Rank(catsConf[categoryName].Ranking) // Store the results in cache. - if err := db.SetResults(query, categoryName, opts, rankedRes, ttlConf.Time); err != nil { + if err := db.SetResults(query, categoryName, opts, rankedRes, ttl); err != nil { log.Error(). Err(err). Str("query", anonymize.String(query)). diff --git a/src/router/routes/setup.go b/src/router/routes/setup.go index ba5095fb..02edc03c 100644 --- a/src/router/routes/setup.go +++ b/src/router/routes/setup.go @@ -9,7 +9,6 @@ import ( "github.com/hearchco/agent/src/cache" "github.com/hearchco/agent/src/config" "github.com/hearchco/agent/src/search/category" - "github.com/hearchco/agent/src/utils/moretime" ) func Setup(mux *chi.Mux, ver string, db cache.DB, conf config.Config) { @@ -39,7 +38,7 @@ func Setup(mux *chi.Mux, ver string, db cache.DB, conf config.Config) { // /search mux.Get("/search", func(w http.ResponseWriter, r *http.Request) { - err := routeSearch(w, r, ver, conf.Categories, conf.Server.Cache.TTL, db, conf.Server.ImageProxy.Salt) + err := routeSearch(w, r, ver, conf.Categories, db, conf.Server.Cache.TTL.Results, conf.Server.ImageProxy.Salt) if err != nil { log.Error(). Err(err). @@ -49,7 +48,7 @@ func Setup(mux *chi.Mux, ver string, db cache.DB, conf config.Config) { } }) mux.Post("/search", func(w http.ResponseWriter, r *http.Request) { - err := routeSearch(w, r, ver, conf.Categories, conf.Server.Cache.TTL, db, conf.Server.ImageProxy.Salt) + err := routeSearch(w, r, ver, conf.Categories, db, conf.Server.Cache.TTL.Results, conf.Server.ImageProxy.Salt) if err != nil { log.Error(). Err(err). @@ -81,12 +80,9 @@ func Setup(mux *chi.Mux, ver string, db cache.DB, conf config.Config) { } }) - // TODO: Make exchange TTL configurable. - exchTTL := moretime.Day - // /exchange mux.Get("/exchange", func(w http.ResponseWriter, r *http.Request) { - err := routeExchange(w, r, ver, db, exchTTL) + err := routeExchange(w, r, ver, conf.Exchange, db, conf.Server.Cache.TTL.Currencies) if err != nil { log.Error(). Err(err). @@ -96,7 +92,7 @@ func Setup(mux *chi.Mux, ver string, db cache.DB, conf config.Config) { } }) mux.Post("/exchange", func(w http.ResponseWriter, r *http.Request) { - err := routeExchange(w, r, ver, db, exchTTL) + err := routeExchange(w, r, ver, conf.Exchange, db, conf.Server.Cache.TTL.Currencies) if err != nil { log.Error(). Err(err). From a96dd340a9a03a446f161d343d83a84fd628606d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Aleksa=20Siri=C5=A1ki?= <31509435+aleksasiriski@users.noreply.github.com> Date: Fri, 12 Jul 2024 01:48:08 +0200 Subject: [PATCH 07/13] fix(config): panic log --- src/config/load.go | 1 + 1 file changed, 1 insertion(+) diff --git a/src/config/load.go b/src/config/load.go index 2526e86b..611f9207 100644 --- a/src/config/load.go +++ b/src/config/load.go @@ -244,6 +244,7 @@ func (c *Config) fromReader(rc ReaderConfig) { log.Panic(). Caller(). Err(err). + Str("engine", engS). Msg("Failed converting string to engine name") // ^PANIC } From faae7cb036ac244c85379e43ae422a7442d89374 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Aleksa=20Siri=C5=A1ki?= <31509435+aleksasiriski@users.noreply.github.com> Date: Fri, 12 Jul 2024 01:49:28 +0200 Subject: [PATCH 08/13] chore(config): comment --- src/config/structs_exchange.go | 2 -- 1 file changed, 2 deletions(-) diff --git a/src/config/structs_exchange.go b/src/config/structs_exchange.go index ebbec88a..e4ed8087 100644 --- a/src/config/structs_exchange.go +++ b/src/config/structs_exchange.go @@ -30,8 +30,6 @@ type ReaderExchangeTimings struct { // Hard timeout after which the search is forcefully stopped (even if the engines didn't respond). HardTimeout string `koanf:"hardtimeout"` } - -// Delegates Delay, RandomDelay, Parallelism to colly.Collector.Limit(). type ExchangeTimings struct { // Hard timeout after which the search is forcefully stopped (even if the engines didn't respond). HardTimeout time.Duration From 88741e312a7d82c7e9c92424c26f74a55df269e1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Aleksa=20Siri=C5=A1ki?= <31509435+aleksasiriski@users.noreply.github.com> Date: Fri, 12 Jul 2024 02:17:17 +0200 Subject: [PATCH 09/13] fix(exchange): base currency configu --- src/config/defaults.go | 5 ++-- src/config/load.go | 7 ++++-- src/config/structs_exchange.go | 11 +++++---- src/exchange/currency/currency.go | 23 +++++++++++++++---- src/exchange/engines/currencyapi/exchange.go | 16 ++++++------- src/exchange/engines/currencyapi/json.go | 24 ++++++++++++++++++-- src/exchange/exchange.go | 8 +++---- src/router/routes/route_exchange.go | 18 +++++++-------- 8 files changed, 77 insertions(+), 35 deletions(-) diff --git a/src/config/defaults.go b/src/config/defaults.go index 0cccccfd..14becfbf 100644 --- a/src/config/defaults.go +++ b/src/config/defaults.go @@ -80,8 +80,9 @@ func New() Config { }, }, Exchange: Exchange{ - Engines: exchangeEngines, - Timings: exchangeTimings, + BaseCurrency: "EUR", + Engines: exchangeEngines, + Timings: exchangeTimings, }, } } diff --git a/src/config/load.go b/src/config/load.go index 611f9207..c2770382 100644 --- a/src/config/load.go +++ b/src/config/load.go @@ -12,6 +12,7 @@ import ( "github.com/knadh/koanf/v2" "github.com/rs/zerolog/log" + "github.com/hearchco/agent/src/exchange/currency" exchengines "github.com/hearchco/agent/src/exchange/engines" "github.com/hearchco/agent/src/search/category" "github.com/hearchco/agent/src/search/engines" @@ -95,7 +96,8 @@ func (c Config) getReader() ReaderConfig { RCategories: map[category.Name]ReaderCategory{}, // Exchange config. RExchange: ReaderExchange{ - REngines: map[string]ReaderExchangeEngine{}, + BaseCurrency: c.Exchange.BaseCurrency.String(), + REngines: map[string]ReaderExchangeEngine{}, RTimings: ReaderExchangeTimings{ HardTimeout: moretime.ConvertToFancyTime(c.Exchange.Timings.HardTimeout), }, @@ -174,7 +176,8 @@ func (c *Config) fromReader(rc ReaderConfig) { Categories: map[category.Name]Category{}, // Exchange config. Exchange: Exchange{ - Engines: []exchengines.Name{}, + BaseCurrency: currency.ConvertBase(rc.RExchange.BaseCurrency), + Engines: []exchengines.Name{}, Timings: ExchangeTimings{ HardTimeout: moretime.ConvertFromFancyTime(rc.RExchange.RTimings.HardTimeout), }, diff --git a/src/config/structs_exchange.go b/src/config/structs_exchange.go index e4ed8087..e4a09dbc 100644 --- a/src/config/structs_exchange.go +++ b/src/config/structs_exchange.go @@ -3,17 +3,20 @@ package config import ( "time" + "github.com/hearchco/agent/src/exchange/currency" "github.com/hearchco/agent/src/exchange/engines" ) // ReaderCategory is format in which the config is read from the config file and environment variables. type ReaderExchange struct { - REngines map[string]ReaderExchangeEngine `koanf:"engines"` - RTimings ReaderExchangeTimings `koanf:"timings"` + BaseCurrency string `koanf:"basecurrency"` + REngines map[string]ReaderExchangeEngine `koanf:"engines"` + RTimings ReaderExchangeTimings `koanf:"timings"` } type Exchange struct { - Engines []engines.Name - Timings ExchangeTimings + BaseCurrency currency.Currency + Engines []engines.Name + Timings ExchangeTimings } // ReaderEngine is format in which the config is read from the config file and environment variables. diff --git a/src/exchange/currency/currency.go b/src/exchange/currency/currency.go index 60078f90..4415b360 100644 --- a/src/exchange/currency/currency.go +++ b/src/exchange/currency/currency.go @@ -2,15 +2,15 @@ package currency import ( "fmt" + "slices" "strings" + + "github.com/rs/zerolog/log" ) -// Format: ISO 4217 (3-letter code) e.g. USD, EUR, GBP. +// Format: ISO 4217 (3-letter code) e.g. CHF, EUR, GBP, USD. type Currency string -// TODO: Make base currency configurable. -const Base Currency = "EUR" - func (c Currency) String() string { return string(c) } @@ -27,3 +27,18 @@ func Convert(curr string) (Currency, error) { upperCurr := strings.ToUpper(curr) return Currency(upperCurr), nil } + +func ConvertBase(curr string) Currency { + // Hardcoded to ensure all APIs include these currencies and therefore work as expected. + supportedBaseCurrencies := [...]string{"CHF", "EUR", "GBP", "USD"} + + upperCurr := strings.ToUpper(curr) + if !slices.Contains(supportedBaseCurrencies[:], upperCurr) { + log.Panic(). + Str("currency", upperCurr). + Msg("unsupported base currency") + // ^PANIC + } + + return Currency(upperCurr) +} diff --git a/src/exchange/engines/currencyapi/exchange.go b/src/exchange/engines/currencyapi/exchange.go index 14694a6a..af0fc246 100644 --- a/src/exchange/engines/currencyapi/exchange.go +++ b/src/exchange/engines/currencyapi/exchange.go @@ -1,7 +1,6 @@ package currencyapi import ( - "encoding/json" "fmt" "io" "net/http" @@ -26,22 +25,23 @@ func (e Exchange) Exchange(base currency.Currency) (currency.Currencies, error) } // Unmarshal the response. - var data response - if err := json.Unmarshal(body, &data); err != nil { - return nil, fmt.Errorf("failed to unmarshal response: %w", err) + dataRates, err := extractRatesFromResp(string(body), base) + if err != nil { + return nil, fmt.Errorf("failed to extract rates from response: %w", err) } // Check if no rates were found. - if len(data.Rates) == 0 { + if len(dataRates) == 0 { return nil, fmt.Errorf("no rates found for %s", base) } // Convert the rates to proper currency types with their rates. - rates := make(currency.Currencies, len(data.Rates)) - for currS, rate := range data.Rates { + rates := make(currency.Currencies, len(dataRates)) + for currS, rate := range dataRates { curr, err := currency.Convert(currS) if err != nil { - log.Error(). + // Non-ISO currencies are expected from this engine. + log.Trace(). Err(err). Str("currency", currS). Msg("failed to convert currency") diff --git a/src/exchange/engines/currencyapi/json.go b/src/exchange/engines/currencyapi/json.go index 4f44ce6d..92b0c16c 100644 --- a/src/exchange/engines/currencyapi/json.go +++ b/src/exchange/engines/currencyapi/json.go @@ -1,6 +1,26 @@ package currencyapi +import ( + "encoding/json" + "fmt" + "regexp" + + "github.com/hearchco/agent/src/exchange/currency" +) + // Rates field is named the same as base currency. -type response struct { - Rates map[string]float64 `json:"eur"` +func extractRatesFromResp(resp string, base currency.Currency) (map[string]float64, error) { + pattern := `"` + base.Lower() + `":\s*{[^}]*}` + regexp := regexp.MustCompile(pattern) + match := regexp.FindString(resp) + if match == "" { + return nil, fmt.Errorf("could not find JSON field for base currency %s", base) + } + + var rates map[string]float64 + if err := json.Unmarshal([]byte(match), &rates); err != nil { + return nil, fmt.Errorf("could not unmarshal JSON field for base currency %s: %w", base, err) + } + + return rates, nil } diff --git a/src/exchange/exchange.go b/src/exchange/exchange.go index 7aa8b5e4..6ee79b5e 100644 --- a/src/exchange/exchange.go +++ b/src/exchange/exchange.go @@ -15,11 +15,11 @@ type Exchange struct { currencies currency.Currencies } -func NewExchange(base currency.Currency, conf config.Exchange, currencies ...currency.Currencies) Exchange { +func NewExchange(conf config.Exchange, currencies ...currency.Currencies) Exchange { // If currencies are provided, use them. if len(currencies) > 0 { return Exchange{ - base, + conf.BaseCurrency, currencies[0], } } @@ -48,7 +48,7 @@ func NewExchange(base currency.Currency, conf config.Exchange, currencies ...cur exch := exchangers[eng] go func() { defer wg.Done() - currs, err := exch.Exchange(base) + currs, err := exch.Exchange(conf.BaseCurrency) if err != nil { log.Error(). Err(err). @@ -75,7 +75,7 @@ func NewExchange(base currency.Currency, conf config.Exchange, currencies ...cur } return Exchange{ - base, + conf.BaseCurrency, currencyMap.Extract(), } } diff --git a/src/router/routes/route_exchange.go b/src/router/routes/route_exchange.go index 495890c3..74c9f2e6 100644 --- a/src/router/routes/route_exchange.go +++ b/src/router/routes/route_exchange.go @@ -92,11 +92,11 @@ func routeExchange(w http.ResponseWriter, r *http.Request, ver string, conf conf } // Get the cached currencies. - currencies, err := db.GetCurrencies(currency.Base, conf.Engines) + currencies, err := db.GetCurrencies(conf.BaseCurrency, conf.Engines) if err != nil { log.Error(). Err(err). - Str("base", currency.Base.String()). + Str("base", conf.BaseCurrency.String()). Str("engines", fmt.Sprintf("%v", conf.Engines)). Msg("Error while getting currencies from cache") } @@ -105,35 +105,35 @@ func routeExchange(w http.ResponseWriter, r *http.Request, ver string, conf conf var exch exchange.Exchange if currencies == nil { // Fetch the currencies from the enabled engines. - exch = exchange.NewExchange(currency.Base, conf) + exch = exchange.NewExchange(conf) // Cache the currencies if any have been fetched. if len(exch.Currencies()) > 0 { - err := db.SetCurrencies(currency.Base, conf.Engines, exch.Currencies(), ttl) + err := db.SetCurrencies(conf.BaseCurrency, conf.Engines, exch.Currencies(), ttl) if err != nil { log.Error(). Err(err). - Str("base", currency.Base.String()). + Str("base", conf.BaseCurrency.String()). Str("engines", fmt.Sprintf("%v", conf.Engines)). Msg("Error while setting currencies in cache") } } } else { // Use the cached currencies. - exch = exchange.NewExchange(currency.Base, conf, currencies) + exch = exchange.NewExchange(conf, currencies) } // Check if FROM and TO are supported currencies. if !exch.SupportsCurrency(from) { // User error. return writeResponseJSON(w, http.StatusBadRequest, ErrorResponse{ - Message: "unsupported from currency", + Message: "unsupported FROM currency", Value: from.String(), }) } if !exch.SupportsCurrency(to) { // User error. return writeResponseJSON(w, http.StatusBadRequest, ErrorResponse{ - Message: "unsupported to currency", + Message: "unsupported TO currency", Value: to.String(), }) } @@ -146,7 +146,7 @@ func routeExchange(w http.ResponseWriter, r *http.Request, ver string, conf conf ver, time.Since(startTime).Milliseconds(), }, - currency.Base, + conf.BaseCurrency, from, to, amount, From 4e499759c05c5301e03384c60e941dc80e4a2b6d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Aleksa=20Siri=C5=A1ki?= <31509435+aleksasiriski@users.noreply.github.com> Date: Fri, 12 Jul 2024 02:30:24 +0200 Subject: [PATCH 10/13] fix(currencyapi): json extraction --- src/exchange/engines/currencyapi/json.go | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/exchange/engines/currencyapi/json.go b/src/exchange/engines/currencyapi/json.go index 92b0c16c..8b7a4b4f 100644 --- a/src/exchange/engines/currencyapi/json.go +++ b/src/exchange/engines/currencyapi/json.go @@ -4,6 +4,7 @@ import ( "encoding/json" "fmt" "regexp" + "strings" "github.com/hearchco/agent/src/exchange/currency" ) @@ -17,8 +18,11 @@ func extractRatesFromResp(resp string, base currency.Currency) (map[string]float return nil, fmt.Errorf("could not find JSON field for base currency %s", base) } + // Remove `"":`` from the match + jsonRates := strings.TrimSpace((match[len(base.Lower())+3:])) + var rates map[string]float64 - if err := json.Unmarshal([]byte(match), &rates); err != nil { + if err := json.Unmarshal([]byte(jsonRates), &rates); err != nil { return nil, fmt.Errorf("could not unmarshal JSON field for base currency %s: %w", base, err) } From 809593d49b2dcc309789c3bd354ffe1dec257185 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Aleksa=20Siri=C5=A1ki?= <31509435+aleksasiriski@users.noreply.github.com> Date: Fri, 12 Jul 2024 15:16:36 +0200 Subject: [PATCH 11/13] fix(exchange): generated code --- .gitignore | 1 + generate/exchanger/exchanger.go | 333 ++++++++++++++++++++++++++++++++ generate/exchanger/structs.go | 50 +++++ generate/exchanger/util.go | 30 +++ src/exchange/engines.go | 28 --- src/exchange/engines/name.go | 30 +-- 6 files changed, 419 insertions(+), 53 deletions(-) create mode 100644 generate/exchanger/exchanger.go create mode 100644 generate/exchanger/structs.go create mode 100644 generate/exchanger/util.go delete mode 100644 src/exchange/engines.go diff --git a/.gitignore b/.gitignore index 113de184..247c67bd 100644 --- a/.gitignore +++ b/.gitignore @@ -39,6 +39,7 @@ profiling/ *_stringer.go *_enumer.go *_enginer.go +*_exchanger.go # test dump testdump* diff --git a/generate/exchanger/exchanger.go b/generate/exchanger/exchanger.go new file mode 100644 index 00000000..4cdeb3da --- /dev/null +++ b/generate/exchanger/exchanger.go @@ -0,0 +1,333 @@ +package main + +import ( + "flag" + "fmt" + "go/ast" + "go/constant" + "go/format" + "go/token" + "go/types" + "log" + "os" + "path" + "strings" + + "golang.org/x/tools/go/packages" +) + +var ( + typeName = flag.String("type", "", "type name; must be set") + output = flag.String("output", "", "output file name; default srcdir/_exchanger.go") + trimprefix = flag.String("trimprefix", "", "trim the `prefix` from the generated constant names") + buildTags = flag.String("tags", "", "comma-separated list of build tags to apply") + packageName = flag.String("packagename", "", "name of the package for generated code; default current package") + interfacesPackage = flag.String("interfacespackage", "engines", "name of the package for the interfaces; default engines") + interfaceExchanger = flag.String("interfaceexchanger", "Exchanger", "name of the nginer interface; default engines.Exchanger") + enginesImport = flag.String("enginesimport", "github.com/hearchco/agent/src/exchange/engines", "source of the engines import, which is prefixed to imports for engines; default github.com/hearchco/agent/src/exchange/engines") +) + +// Usage is a replacement usage function for the flags package. +func Usage() { + fmt.Fprintf(os.Stderr, "Usage of exchanger:\n") + fmt.Fprintf(os.Stderr, "\texchanger [flags] -type T [directory]\n") + fmt.Fprintf(os.Stderr, "\texchanger [flags] -type T files... # Must be a single package\n") + fmt.Fprintf(os.Stderr, "Flags:\n") + flag.PrintDefaults() +} + +func main() { + log.SetFlags(0) + log.SetPrefix("exchanger: ") + flag.Usage = Usage + flag.Parse() + if len(*typeName) == 0 { + flag.Usage() + os.Exit(2) + } + /* ---------------------------------- + //! Should be comma seperated list of type names, currently is only the first type name + ---------------------------------- */ + types := strings.Split(*typeName, ",") + var tags []string + if len(*buildTags) > 0 { + tags = strings.Split(*buildTags, ",") + } + + // We accept either one directory or a list of files. Which do we have? + args := flag.Args() + if len(args) == 0 { + // Default: process whole package in current directory. + args = []string{"."} + } + + // Parse the package once. + var dir string + g := Generator{ + trimPrefix: *trimprefix, + } + + if len(args) == 1 && isDirectoryFatal(args[0]) { + dir = args[0] + } else { + if len(tags) != 0 { + log.Fatal("-tags option applies only to directories, not when files are specified") + // ^FATAL + } + dir = path.Dir(args[0]) + } + + g.parsePackage(args, tags) + + // Print the header and package clause. + g.Printf("// Code generated by \"exchanger %s\"; DO NOT EDIT.\n", strings.Join(os.Args[1:], " ")) + g.Printf("\n") + var pkgName string + if *packageName == "" { + pkgName = g.pkg.name + } else { + pkgName = *packageName + } + g.Printf("package %s", pkgName) + g.Printf("\n") + g.Printf("import \"%s\"\n", *enginesImport) + + // Run generate for each type. + for _, typeName := range types { + g.generate(typeName) + } + + // Format the output. + src := g.format() + + // Write to file. + outputName := *output + if outputName == "" { + baseName := fmt.Sprintf("%s_exchanger.go", types[0]) + outputName = path.Join(dir, strings.ToLower(baseName)) + } + err := os.WriteFile(outputName, src, 0644) + if err != nil { + log.Fatalf("writing output: %s", err) + // ^FATAL + } +} + +func (g *Generator) Printf(format string, args ...interface{}) { + fmt.Fprintf(&g.buf, format, args...) +} + +// parsePackage analyzes the single package constructed from the patterns and tags. +// parsePackage exits if there is an error. +func (g *Generator) parsePackage(patterns []string, tags []string) { + cfg := &packages.Config{ + Mode: packages.NeedName | packages.NeedTypes | packages.NeedTypesInfo | packages.NeedSyntax, + Tests: false, + BuildFlags: []string{fmt.Sprintf("-tags=%s", strings.Join(tags, " "))}, + Logf: g.logf, + } + pkgs, err := packages.Load(cfg, patterns...) + if err != nil { + log.Fatal(err) + // ^FATAL + } + if len(pkgs) != 1 { + log.Fatalf("error: %d packages matching %v", len(pkgs), strings.Join(patterns, " ")) + // ^FATAL + } + g.addPackage(pkgs[0]) +} + +// addPackage adds a type checked Package and its syntax files to the generator. +func (g *Generator) addPackage(pkg *packages.Package) { + g.pkg = &Package{ + name: pkg.Name, + defs: pkg.TypesInfo.Defs, + files: make([]*File, len(pkg.Syntax)), + } + + for i, file := range pkg.Syntax { + g.pkg.files[i] = &File{ + file: file, + pkg: g.pkg, + trimPrefix: g.trimPrefix, + } + } +} + +// generate produces imports and the NewEngineStarter method for the named type. +func (g *Generator) generate(typeName string) { + values := make([]Value, 0, 100) + for _, file := range g.pkg.files { + // Set the state for this run of the walker. + file.typeName = typeName + file.values = nil + if file.file != nil { + ast.Inspect(file.file, file.genDecl) + values = append(values, file.values...) + } + } + + if len(values) == 0 { + log.Fatalf("no values defined for type %s", typeName) + // ^FATAL + } + + // Generate code for importing engines + for _, v := range values { + if validConst(v) { + g.Printf("import \"%s/%s\"\n", *enginesImport, strings.ToLower(v.name)) + } + } + + // Generate code that will fail if the constants change value. + g.Printf("func _() {\n") + g.Printf("\t// An \"invalid array index\" compiler error signifies that the constant values have changed.\n") + g.Printf("\t// Re-run the exchanger command to generate them again.\n") + g.Printf("\tvar x [1]struct{}\n") + for _, v := range values { + origName := v.originalName + if *packageName != "" { + origName = fmt.Sprintf("%s.%s", g.pkg.name, v.originalName) + } + g.Printf("\t_ = x[%s - (%s)]\n", origName, v.str) + } + g.Printf("}\n") + + g.printExchangerLen(values) + g.printInterfaces(values, *interfaceExchanger) +} + +// format returns the gofmt-ed contents of the Generator's buffer. +func (g *Generator) format() []byte { + src, err := format.Source(g.buf.Bytes()) + if err != nil { + // Should never happen, but can arise when developing this code. + // The user can compile the output to see the error. + log.Printf("warning: internal error: invalid Go generated: %s", err) + log.Printf("warning: compile the package to analyze the error") + return g.buf.Bytes() + } + return src +} + +func (v *Value) String() string { + return v.str +} + +// genDecl processes one declaration clause. +func (f *File) genDecl(node ast.Node) bool { + decl, ok := node.(*ast.GenDecl) + if !ok || decl.Tok != token.CONST { + // We only care about const declarations. + return true + } + // The name of the type of the constants we are declaring. + // Can change if this is a multi-element declaration. + typ := "" + // Loop over the elements of the declaration. Each element is a ValueSpec: + // a list of names possibly followed by a type, possibly followed by values. + // If the type and value are both missing, we carry down the type (and value, + // but the "go/types" package takes care of that). + for _, spec := range decl.Specs { + vspec := spec.(*ast.ValueSpec) // Guaranteed to succeed as this is CONST. + if vspec.Type == nil && len(vspec.Values) > 0 { + // "X = 1". With no type but a value. If the constant is untyped, + // skip this vspec and reset the remembered type. + typ = "" + + // If this is a simple type conversion, remember the type. + // We don't mind if this is actually a call; a qualified call won't + // be matched (that will be SelectorExpr, not Ident), and only unusual + // situations will result in a function call that appears to be + // a type conversion. + ce, ok := vspec.Values[0].(*ast.CallExpr) + if !ok { + continue + } + id, ok := ce.Fun.(*ast.Ident) + if !ok { + continue + } + typ = id.Name + } + if vspec.Type != nil { + // "X T". We have a type. Remember it. + ident, ok := vspec.Type.(*ast.Ident) + if !ok { + continue + } + typ = ident.Name + } + if typ != f.typeName { + // This is not the type we're looking for. + continue + } + // We now have a list of names (from one line of source code) all being + // declared with the desired type. + // Grab their names and actual values and store them in f.values. + for _, name := range vspec.Names { + if name.Name == "_" { + continue + } + // This dance lets the type checker find the values for us. It's a + // bit tricky: look up the object declared by the name, find its + // types.Const, and extract its value. + obj, ok := f.pkg.defs[name] + if !ok { + log.Fatalf("no value for constant %s", name) + // ^FATAL + } + info := obj.Type().Underlying().(*types.Basic).Info() + if info&types.IsInteger == 0 { + log.Fatalf("can't handle non-integer constant type %s", typ) + // ^FATAL + } + value := obj.(*types.Const).Val() // Guaranteed to succeed as this is CONST. + if value.Kind() != constant.Int { + log.Fatalf("can't happen: constant is not an integer %s", name) + // ^FATAL + } + i64, isInt := constant.Int64Val(value) + u64, isUint := constant.Uint64Val(value) + if !isInt && !isUint { + log.Fatalf("internal error: value of %s is not an integer: %s", name, value.String()) + // ^FATAL + } + if !isInt { + u64 = uint64(i64) + } + v := Value{ + originalName: name.Name, + value: u64, + signed: info&types.IsUnsigned == 0, + str: value.String(), + } + v.name = strings.TrimPrefix(v.originalName, f.trimPrefix) + if c := vspec.Comment; c != nil && len(c.List) == 1 { + v.interfaces = strings.Split(strings.TrimSpace(c.Text()), ",") + } + f.values = append(f.values, v) + } + } + return false +} + +func (g *Generator) printExchangerLen(values []Value) { + g.Printf("\n") + g.Printf("\nconst exchangerLen = %d", len(values)) + g.Printf("\n") +} + +func (g *Generator) printInterfaces(values []Value, interfaceName string) { + g.Printf("\n") + g.Printf("\nfunc %sArray() [exchangerLen]%s.%s {", strings.ToLower(interfaceName), *interfacesPackage, interfaceName) + g.Printf("\n\tvar engineArray [exchangerLen]%s.%s", *interfacesPackage, interfaceName) + for _, v := range values { + if validConst(v) { + g.Printf("\n\tengineArray[%s.%s] = %s.New()", g.pkg.name, v.name, strings.ToLower(v.name)) + } + } + g.Printf("\n\treturn engineArray") + g.Printf("\n}") +} diff --git a/generate/exchanger/structs.go b/generate/exchanger/structs.go new file mode 100644 index 00000000..766749d9 --- /dev/null +++ b/generate/exchanger/structs.go @@ -0,0 +1,50 @@ +package main + +import ( + "bytes" + "go/ast" + "go/types" +) + +// Value represents a declared constant. +type Value struct { + originalName string // The name of the constant. + name string // The name with trimmed prefix. + // The value is stored as a bit pattern alone. The boolean tells us + // whether to interpret it as an int64 or a uint64; the only place + // this matters is when sorting. + // Much of the time the str field is all we need; it is printed + // by Value.String. + value uint64 // Will be converted to int64 when needed. + signed bool // Whether the constant is a signed type. + str string // The string representation given by the "go/constant" package. + interfaces []string // The interfaces that the constant implements. +} + +// Generator holds the state of the analysis. Primarily used to buffer +// the output for format.Source. +type Generator struct { + buf bytes.Buffer // Accumulated output. + pkg *Package // Package we are scanning. + + trimPrefix string + + logf func(format string, args ...interface{}) // test logging hook; nil when not testing +} + +// File holds a single parsed file and associated data. +type File struct { + pkg *Package // Package to which this file belongs. + file *ast.File // Parsed AST. + // These fields are reset for each type being generated. + typeName string // Name of the constant type. + values []Value // Accumulator for constant values of that type. + + trimPrefix string +} + +type Package struct { + name string + defs map[*ast.Ident]types.Object + files []*File +} diff --git a/generate/exchanger/util.go b/generate/exchanger/util.go new file mode 100644 index 00000000..d953fb38 --- /dev/null +++ b/generate/exchanger/util.go @@ -0,0 +1,30 @@ +package main + +import ( + "log" + "os" + "strings" +) + +func validConst(v Value) bool { + lowerName := strings.ToLower(v.name) + return lowerName != "undefined" && isDirectory(lowerName) +} + +// isDirectory reports whether the named file is a directory. +func isDirectory(path string) bool { + info, err := os.Stat(path) + if err != nil { + return false + } + return info.IsDir() +} + +func isDirectoryFatal(path string) bool { + info, err := os.Stat(path) + if err != nil { + log.Fatal(err) + // ^FATAL + } + return info.IsDir() +} diff --git a/src/exchange/engines.go b/src/exchange/engines.go deleted file mode 100644 index d82d7c7e..00000000 --- a/src/exchange/engines.go +++ /dev/null @@ -1,28 +0,0 @@ -package exchange - -import ( - "github.com/hearchco/agent/src/exchange/engines" - "github.com/hearchco/agent/src/exchange/engines/currencyapi" - "github.com/hearchco/agent/src/exchange/engines/exchangerateapi" - "github.com/hearchco/agent/src/exchange/engines/frankfurter" -) - -func _() { - // An "invalid array index" compiler error signifies that the constant values have changed. - // Re-run the enginer command to generate them again. - var x [1]struct{} - _ = x[engines.UNDEFINED-(0)] - _ = x[engines.CURRENCYAPI-(1)] - _ = x[engines.EXCHANGERATEAPI-(2)] - _ = x[engines.FRANKFURTER-(3)] -} - -const enginerLen = 4 - -func exchangerArray() [enginerLen]engines.Exchanger { - var engineArray [enginerLen]engines.Exchanger - engineArray[engines.CURRENCYAPI] = currencyapi.New() - engineArray[engines.EXCHANGERATEAPI] = exchangerateapi.New() - engineArray[engines.FRANKFURTER] = frankfurter.New() - return engineArray -} diff --git a/src/exchange/engines/name.go b/src/exchange/engines/name.go index 258ed707..c47e15d8 100644 --- a/src/exchange/engines/name.go +++ b/src/exchange/engines/name.go @@ -1,12 +1,13 @@ package engines import ( - "fmt" "strings" ) type Name int +//go:generate enumer -type=Name -json -text -sql +//go:generate go run github.com/hearchco/agent/generate/exchanger -type=Name -packagename exchange -output ../engine_exchanger.go const ( UNDEFINED Name = iota CURRENCYAPI @@ -14,32 +15,11 @@ const ( FRANKFURTER ) -func (n Name) String() string { - switch n { - case CURRENCYAPI: - return "CurrencyAPI" - case EXCHANGERATEAPI: - return "ExchangeRateAPI" - case FRANKFURTER: - return "Frankfurter" - default: - return "Undefined" - } +// Returns engine names without UNDEFINED. +func Names() []Name { + return _NameValues[1:] } func (n Name) ToLower() string { return strings.ToLower(n.String()) } - -func NameString(s string) (Name, error) { - switch strings.ToLower(s) { - case CURRENCYAPI.ToLower(): - return CURRENCYAPI, nil - case EXCHANGERATEAPI.ToLower(): - return EXCHANGERATEAPI, nil - case FRANKFURTER.ToLower(): - return FRANKFURTER, nil - default: - return UNDEFINED, fmt.Errorf("%s does not belong to Name values", s) - } -} From 3193a21acea10e94ebc09c81f6b9d3624f2d11d4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Aleksa=20Siri=C5=A1ki?= <31509435+aleksasiriski@users.noreply.github.com> Date: Fri, 12 Jul 2024 15:16:56 +0200 Subject: [PATCH 12/13] fix(go.mod): update --- go.mod | 2 +- go.sum | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/go.mod b/go.mod index 0735278d..1e4a3612 100644 --- a/go.mod +++ b/go.mod @@ -59,7 +59,7 @@ require ( github.com/gobwas/glob v0.2.3 // indirect github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da // indirect github.com/golang/protobuf v1.5.4 // indirect - github.com/google/pprof v0.0.0-20240625030939-27f56978b8b0 // indirect + github.com/google/pprof v0.0.0-20240711041743-f6c9dda6c6da // indirect github.com/jmespath/go-jmespath v0.4.0 // indirect github.com/kennygrant/sanitize v1.2.4 // indirect github.com/knadh/koanf/maps v0.1.1 // indirect diff --git a/go.sum b/go.sum index 0defe55c..f9b3dcc2 100644 --- a/go.sum +++ b/go.sum @@ -136,8 +136,8 @@ 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/pprof v0.0.0-20211214055906-6f57359322fd/go.mod h1:KgnwoLYCZ8IQu3XUZ8Nc/bM9CCZFOyjUNOSygVozoDg= github.com/google/pprof v0.0.0-20240227163752-401108e1b7e7/go.mod h1:czg5+yv1E0ZGTi6S6vVK1mke0fV+FaUhNGcd6VRS9Ik= -github.com/google/pprof v0.0.0-20240625030939-27f56978b8b0 h1:e+8XbKB6IMn8A4OAyZccO4pYfB3s7bt6azNIPE7AnPg= -github.com/google/pprof v0.0.0-20240625030939-27f56978b8b0/go.mod h1:K1liHPHnj73Fdn/EKuT8nrFqBihUSKXoLYU0BuatOYo= +github.com/google/pprof v0.0.0-20240711041743-f6c9dda6c6da h1:xRmpO92tb8y+Z85iUOMOicpCfaYcv7o3Cg3wKrIpg8g= +github.com/google/pprof v0.0.0-20240711041743-f6c9dda6c6da/go.mod h1:K1liHPHnj73Fdn/EKuT8nrFqBihUSKXoLYU0BuatOYo= github.com/hexops/gotextdiff v1.0.3 h1:gitA9+qJrrTCsiCl7+kh75nPqQt1cx4ZkudSTLoUqJM= github.com/hexops/gotextdiff v1.0.3/go.mod h1:pSWU5MAI3yDq+fZBTazCSJysOMbxWL1BSow5/V2vxeg= github.com/ianlancetaylor/demangle v0.0.0-20210905161508-09a460cdf81d/go.mod h1:aYm2/VgdVmcIU8iMfdMvDMsRAQjcfZSKFby6HOFvi/w= From 5b7b9a490667c25b0361e0f1fc299a5b87e1baaf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Aleksa=20Siri=C5=A1ki?= <31509435+aleksasiriski@users.noreply.github.com> Date: Fri, 12 Jul 2024 15:35:12 +0200 Subject: [PATCH 13/13] fix(exchange): unneeded string --- src/exchange/engines/currencyapi/exchange.go | 2 +- src/exchange/engines/currencyapi/json.go | 2 +- src/exchange/engines/currencyapi/new.go | 8 +++----- src/exchange/engines/exchangerateapi/new.go | 8 +++----- src/exchange/engines/frankfurter/new.go | 6 ++---- 5 files changed, 10 insertions(+), 16 deletions(-) diff --git a/src/exchange/engines/currencyapi/exchange.go b/src/exchange/engines/currencyapi/exchange.go index af0fc246..b2e70abf 100644 --- a/src/exchange/engines/currencyapi/exchange.go +++ b/src/exchange/engines/currencyapi/exchange.go @@ -25,7 +25,7 @@ func (e Exchange) Exchange(base currency.Currency) (currency.Currencies, error) } // Unmarshal the response. - dataRates, err := extractRatesFromResp(string(body), base) + dataRates, err := e.extractRates(string(body), base) if err != nil { return nil, fmt.Errorf("failed to extract rates from response: %w", err) } diff --git a/src/exchange/engines/currencyapi/json.go b/src/exchange/engines/currencyapi/json.go index 8b7a4b4f..fc21e8c4 100644 --- a/src/exchange/engines/currencyapi/json.go +++ b/src/exchange/engines/currencyapi/json.go @@ -10,7 +10,7 @@ import ( ) // Rates field is named the same as base currency. -func extractRatesFromResp(resp string, base currency.Currency) (map[string]float64, error) { +func (e Exchange) extractRates(resp string, base currency.Currency) (map[string]float64, error) { pattern := `"` + base.Lower() + `":\s*{[^}]*}` regexp := regexp.MustCompile(pattern) match := regexp.FindString(resp) diff --git a/src/exchange/engines/currencyapi/new.go b/src/exchange/engines/currencyapi/new.go index dd7b7a3a..23e62ff7 100644 --- a/src/exchange/engines/currencyapi/new.go +++ b/src/exchange/engines/currencyapi/new.go @@ -4,14 +4,12 @@ import ( "github.com/hearchco/agent/src/exchange/currency" ) -type Exchange struct { - apiUrl string -} +type Exchange struct{} func New() Exchange { - return Exchange{apiUrl} + return Exchange{} } func (e Exchange) apiUrlWithBaseCurrency(base currency.Currency) string { - return e.apiUrl + "/" + base.Lower() + ".json" + return apiUrl + "/" + base.Lower() + ".json" } diff --git a/src/exchange/engines/exchangerateapi/new.go b/src/exchange/engines/exchangerateapi/new.go index 585abb25..e699fab8 100644 --- a/src/exchange/engines/exchangerateapi/new.go +++ b/src/exchange/engines/exchangerateapi/new.go @@ -4,14 +4,12 @@ import ( "github.com/hearchco/agent/src/exchange/currency" ) -type Exchange struct { - apiUrl string -} +type Exchange struct{} func New() Exchange { - return Exchange{apiUrl} + return Exchange{} } func (e Exchange) apiUrlWithBaseCurrency(base currency.Currency) string { - return e.apiUrl + "/" + base.String() + return apiUrl + "/" + base.String() } diff --git a/src/exchange/engines/frankfurter/new.go b/src/exchange/engines/frankfurter/new.go index 3fb51268..9cd21476 100644 --- a/src/exchange/engines/frankfurter/new.go +++ b/src/exchange/engines/frankfurter/new.go @@ -4,12 +4,10 @@ import ( "github.com/hearchco/agent/src/exchange/currency" ) -type Exchange struct { - apiUrl string -} +type Exchange struct{} func New() Exchange { - return Exchange{apiUrl} + return Exchange{} } func (e Exchange) apiUrlWithBaseCurrency(base currency.Currency) string {