diff --git a/conf/lab.ini b/conf/lab.ini index e14c4b2f..f9325dc6 100644 --- a/conf/lab.ini +++ b/conf/lab.ini @@ -111,7 +111,7 @@ enable_register = true enable_export = true [exchange_rates] -# Exchange rates data source, supports "euro_central_bank" currently +# Exchange rates data source, supports "euro_central_bank", "czech_national_bank" currently data_source = euro_central_bank # Requesting exchange rates data timeout (milliseconds), default is 10000 (10 seconds) diff --git a/pkg/exchangerates/czech_national_bank_datasource.go b/pkg/exchangerates/czech_national_bank_datasource.go new file mode 100644 index 00000000..435978c0 --- /dev/null +++ b/pkg/exchangerates/czech_national_bank_datasource.go @@ -0,0 +1,165 @@ +package exchangerates + +import ( + "math" + "strings" + "time" + + "github.com/mayswind/lab/pkg/core" + "github.com/mayswind/lab/pkg/errs" + "github.com/mayswind/lab/pkg/log" + "github.com/mayswind/lab/pkg/models" + "github.com/mayswind/lab/pkg/utils" + "github.com/mayswind/lab/pkg/validators" +) + +const czechNationalBankDailyExchangeRateUrl = "https://www.cnb.cz/en/financial-markets/foreign-exchange-market/central-bank-exchange-rate-fixing/central-bank-exchange-rate-fixing/daily.txt" +const czechNationalBankMonthlyOtherExchangeRateUrl = "https://www.cnb.cz/en/financial-markets/foreign-exchange-market/fx-rates-of-other-currencies/fx-rates-of-other-currencies/fx_rates.txt" +const czechNationalBankExchangeRateReferenceUrl = "https://www.cnb.cz/en/financial-markets/foreign-exchange-market/central-bank-exchange-rate-fixing/central-bank-exchange-rate-fixing/" +const czechNationalBankDataSource = "Česká národní banka" +const czechNationalBankBaseCurrency = "CZK" + +const czechNationalBankDataUpdateDateFormat = "02 Jan 2006 15:04" +const czechNationalBankDataUpdateDateTimezone = "Europe/Prague" + +// CzechNationalBankDataSource defines the structure of exchange rates data source of Czech National Bank +type CzechNationalBankDataSource struct { + ExchangeRatesDataSource +} + +// GetRequestUrls returns the czech nation bank data source urls +func (e *CzechNationalBankDataSource) GetRequestUrls() []string { + return []string{czechNationalBankMonthlyOtherExchangeRateUrl, czechNationalBankDailyExchangeRateUrl} +} + +// Parse returns the common response entity according to the czech nation bank data source raw response +func (e *CzechNationalBankDataSource) Parse(c *core.Context, content []byte) (*models.LatestExchangeRateResponse, error) { + lines := strings.Split(string(content), "\n") + + if len(lines) < 3 { + log.ErrorfWithRequestId(c, "[czech_national_bank_datasource.Parse] content is invalid, content is %s", string(content)) + return nil, errs.ErrFailedToRequestRemoteApi + } + + headerLineItems := strings.Split(lines[0], "#") + + if len(headerLineItems) != 2 { + log.ErrorfWithRequestId(c, "[czech_national_bank_datasource.Parse] first line of content is invalid, content is %s", lines[0]) + return nil, errs.ErrFailedToRequestRemoteApi + } + + updateDate := strings.TrimSpace(headerLineItems[0]) + + titleLineItems := strings.Split(lines[1], "|") + titleItemMap := make(map[string]int) + + for i := 0; i < len(titleLineItems); i++ { + titleItemMap[titleLineItems[i]] = i + } + + currencyCodeColumnIndex, exists := titleItemMap["Code"] + + if !exists { + log.ErrorfWithRequestId(c, "[czech_national_bank_datasource.Parse] missing currency code column in title line, title line is %s", lines[1]) + return nil, errs.ErrFailedToRequestRemoteApi + } + + amountColumnIndex, exists := titleItemMap["Amount"] + + if !exists { + log.ErrorfWithRequestId(c, "[czech_national_bank_datasource.Parse] missing amount column in title line, title line is %s", lines[1]) + return nil, errs.ErrFailedToRequestRemoteApi + } + + rateColumnIndex, exists := titleItemMap["Rate"] + + if !exists { + log.ErrorfWithRequestId(c, "[czech_national_bank_datasource.Parse] missing rate column in title line, title line is %s", lines[1]) + return nil, errs.ErrFailedToRequestRemoteApi + } + + exchangeRates := make(models.LatestExchangeRateSlice, 0, len(lines)-2) + + for i := 2; i < len(lines); i++ { + line := strings.TrimSpace(lines[i]) + exchangeRate := e.parseExchangeRate(c, line, currencyCodeColumnIndex, amountColumnIndex, rateColumnIndex) + + if exchangeRate != nil { + exchangeRates = append(exchangeRates, exchangeRate) + } + } + + timezone, err := time.LoadLocation(czechNationalBankDataUpdateDateTimezone) + + if err != nil { + log.ErrorfWithRequestId(c, "[czech_national_bank_datasource.Parse] failed to get timezone, timezone name is %s", czechNationalBankDataUpdateDateTimezone) + return nil, errs.ErrFailedToRequestRemoteApi + } + + updateDateTime := updateDate + " 14:30" // Exchange rates of commonly traded currencies are declared every working day after 2.30 p.m. + updateTime, err := time.ParseInLocation(czechNationalBankDataUpdateDateFormat, updateDateTime, timezone) + + if err != nil { + log.ErrorfWithRequestId(c, "[czech_national_bank_datasource.Parse] failed to parse update date, datetime is %s", updateDateTime) + return nil, errs.ErrFailedToRequestRemoteApi + } + + latestExchangeRateResp := &models.LatestExchangeRateResponse{ + DataSource: czechNationalBankDataSource, + ReferenceUrl: czechNationalBankExchangeRateReferenceUrl, + UpdateTime: updateTime.Unix(), + BaseCurrency: czechNationalBankBaseCurrency, + ExchangeRates: exchangeRates, + } + + return latestExchangeRateResp, nil +} + +func (e *CzechNationalBankDataSource) parseExchangeRate(c *core.Context, line string, currencyCodeColumnIndex int, amountColumnIndex int, rateColumnIndex int) *models.LatestExchangeRate { + if len(line) < 1 { + return nil + } + + items := strings.Split(line, "|") + + if currencyCodeColumnIndex >= len(items) || amountColumnIndex >= len(items) || rateColumnIndex >= len(items) { + log.WarnfWithRequestId(c, "[czech_national_bank_datasource.parseExchangeRate] missing column in data line, line is %s", line) + return nil + } + + currencyCode := items[currencyCodeColumnIndex] + + if _, exists := validators.AllCurrencyNames[currencyCode]; !exists { + return nil + } + + amount, err := utils.StringToInt64(items[amountColumnIndex]) + + if err != nil { + log.WarnfWithRequestId(c, "[czech_national_bank_datasource.parseExchangeRate] failed to parse amount, line is %s", line) + return nil + } + + rate, err := utils.StringToFloat64(items[rateColumnIndex]) + + if err != nil { + log.WarnfWithRequestId(c, "[czech_national_bank_datasource.parseExchangeRate] failed to parse rate, line is %s", line) + return nil + } + + if rate <= 0 { + log.WarnfWithRequestId(c, "[czech_national_bank_datasource.parseExchangeRate] rate is invalid, line is %s", line) + return nil + } + + finalRate := float64(amount) / rate + + if math.IsInf(finalRate, 0) { + return nil + } + + return &models.LatestExchangeRate{ + Currency: currencyCode, + Rate: utils.Float64ToString(finalRate), + } +} diff --git a/pkg/exchangerates/exchange_rates_datasource_container.go b/pkg/exchangerates/exchange_rates_datasource_container.go index ec12cd36..6b5b3c88 100644 --- a/pkg/exchangerates/exchange_rates_datasource_container.go +++ b/pkg/exchangerates/exchange_rates_datasource_container.go @@ -20,6 +20,9 @@ func InitializeExchangeRatesDataSource(config *settings.Config) error { if config.ExchangeRatesDataSource == settings.EuroCentralBankDataSource { Container.Current = &EuroCentralBankDataSource{} return nil + } else if config.ExchangeRatesDataSource == settings.CzechNationalBankDataSource { + Container.Current = &CzechNationalBankDataSource{} + return nil } return errs.ErrInvalidExchangeRatesDataSource diff --git a/pkg/settings/setting.go b/pkg/settings/setting.go index c286296e..19ef07cf 100644 --- a/pkg/settings/setting.go +++ b/pkg/settings/setting.go @@ -65,6 +65,7 @@ const ( // Exchange rates data source types const ( EuroCentralBankDataSource string = "euro_central_bank" + CzechNationalBankDataSource string = "czech_national_bank" ) const ( @@ -412,6 +413,8 @@ func loadDataConfiguration(config *Config, configFile *ini.File, sectionName str func loadExchangeRatesConfiguration(config *Config, configFile *ini.File, sectionName string) error { if getConfigItemStringValue(configFile, sectionName, "data_source") == EuroCentralBankDataSource { config.ExchangeRatesDataSource = EuroCentralBankDataSource + } else if getConfigItemStringValue(configFile, sectionName, "data_source") == CzechNationalBankDataSource { + config.ExchangeRatesDataSource = CzechNationalBankDataSource } else { return errs.ErrInvalidExchangeRatesDataSource } diff --git a/pkg/utils/converter.go b/pkg/utils/converter.go index 5eb8512a..a8739118 100644 --- a/pkg/utils/converter.go +++ b/pkg/utils/converter.go @@ -73,3 +73,13 @@ func StringTryToInt64(str string, defaultValue int64) int64 { return num } + +// Float64ToString returns the textual representation of this number +func Float64ToString(num float64) string { + return strconv.FormatFloat(num, 'f', -1, 64) +} + +// StringToFloat64 parses a textual representation of the number to float64 +func StringToFloat64(str string) (float64, error) { + return strconv.ParseFloat(str, 64) +}