diff --git a/jobs/Backend/Task/CnbClient.cs b/jobs/Backend/Task/CnbClient.cs new file mode 100644 index 0000000000..09eb97111f --- /dev/null +++ b/jobs/Backend/Task/CnbClient.cs @@ -0,0 +1,56 @@ +using Polly; +using Polly.Retry; +using System; +using System.IO; +using System.Net.Http; + +namespace ExchangeRateUpdater +{ + + internal class CnbClient : ICnbClient + { + private readonly CnbSettings _settings; + private readonly HttpClient _client = new(); + private readonly RetryPolicy _retryPolicy; + + public CnbClient(CnbSettings settings) + { + _settings = settings; + _retryPolicy = Policy + .Handle() + .Or() + .WaitAndRetry(_settings.RetryAttempts, retryAttempt => TimeSpan.FromSeconds(Math.Pow(2, retryAttempt))); + } + + public (bool Success, string RawData) GetRawDailyExchanges() + { + string rawData; + + try + { + rawData = _retryPolicy.Execute(ReadRawData); + } + catch (HttpRequestException) + { + return (false, string.Empty); + } + catch (IOException) + { + return (false, string.Empty); + } + + return (true, rawData); + } + + private string ReadRawData() + { + using var request = new HttpRequestMessage(HttpMethod.Get, _settings.SourceUrl); + using var response = _client.Send(request); + + response.EnsureSuccessStatusCode(); + + using var reader = new StreamReader(response.Content.ReadAsStream()); + return reader.ReadToEnd(); + } + } +} diff --git a/jobs/Backend/Task/CnbSettings.cs b/jobs/Backend/Task/CnbSettings.cs new file mode 100644 index 0000000000..5af458da4b --- /dev/null +++ b/jobs/Backend/Task/CnbSettings.cs @@ -0,0 +1,5 @@ +namespace ExchangeRateUpdater +{ + public record CnbSettings(string SourceUrl, string TargetCurrencyCode, int RetryAttempts); +} + diff --git a/jobs/Backend/Task/ConfigurationReader.cs b/jobs/Backend/Task/ConfigurationReader.cs new file mode 100644 index 0000000000..e2b6e14184 --- /dev/null +++ b/jobs/Backend/Task/ConfigurationReader.cs @@ -0,0 +1,26 @@ +using Microsoft.Extensions.Configuration; +using System; + +namespace ExchangeRateUpdater +{ + public class ConfigurationReader(IConfiguration config) : IConfigurationReader + { + private readonly IConfiguration _config = config; + private const string SectionName = "CnbSettings"; + + public CnbSettings ReadSettings() + { + var section = _config.GetSection("CnbSettings"); + string url = section["SourceUrl"] ?? throw new InvalidOperationException($"{SectionName}:SourceUrl is missing in appsettings.json."); + string target = section["TargetCurrencyCode"] ?? "CZK"; + + if (!int.TryParse(section["RetryAttempts"], out int retryAttempts)) + { + retryAttempts = 3; // Default fallback + } + + return new CnbSettings(url, target, retryAttempts); + } + } +} + diff --git a/jobs/Backend/Task/ExchangeRateProvider.cs b/jobs/Backend/Task/ExchangeRateProvider.cs index 6f82a97fbe..70d3b4d05b 100644 --- a/jobs/Backend/Task/ExchangeRateProvider.cs +++ b/jobs/Backend/Task/ExchangeRateProvider.cs @@ -1,19 +1,71 @@ -using System.Collections.Generic; +using System; +using System.Collections.Generic; +using System.Globalization; using System.Linq; namespace ExchangeRateUpdater { public class ExchangeRateProvider { + private readonly CnbSettings _settings; + private readonly Currency _targetCurrency; + private readonly ICnbClient _cnbClient; + + public ExchangeRateProvider(CnbSettings settings, ICnbClient cnbClient) + { + _settings = settings; + _targetCurrency = new Currency(_settings.TargetCurrencyCode); + _cnbClient = cnbClient; + } + /// /// Should return exchange rates among the specified currencies that are defined by the source. But only those defined /// by the source, do not return calculated exchange rates. E.g. if the source contains "CZK/USD" but not "USD/CZK", /// do not return exchange rate "USD/CZK" with value calculated as 1 / "CZK/USD". If the source does not provide /// some of the currencies, ignore them. /// + /// public IEnumerable GetExchangeRates(IEnumerable currencies) { - return Enumerable.Empty(); + var requestedCurrencies = currencies.ToDictionary(c => c.Code, c => c, StringComparer.OrdinalIgnoreCase); + + if (requestedCurrencies.Count == 0) + { + return []; + + } + + var (success, rawData) = _cnbClient.GetRawDailyExchanges(); + if (!success) + { + return []; + } + + var rates = ParseData(rawData, requestedCurrencies); + return rates; + } + + private IEnumerable ParseData(string rawData, Dictionary requestedCurrencies) + { + var rates = new List(); + var lines = rawData.Split(["\n", "\r\n"], StringSplitOptions.RemoveEmptyEntries); + + foreach (var line in lines.Skip(2)) + { + var cols = line.Split('|'); + if (cols.Length < 5) continue; + + var code = cols[3].Trim(); + if (requestedCurrencies.TryGetValue(code, out var sourceCurrency)) + { + if (int.TryParse(cols[2].Trim(), out int amount) && amount > 0 && + decimal.TryParse(cols[4].Trim(), NumberStyles.Any, CultureInfo.InvariantCulture, out decimal rateValue)) + { + rates.Add(new ExchangeRate(sourceCurrency, _targetCurrency, rateValue / amount)); + } + } + } + return rates; } } } diff --git a/jobs/Backend/Task/ExchangeRateUpdater.csproj b/jobs/Backend/Task/ExchangeRateUpdater.csproj index 2fc654a12b..b469ca77ff 100644 --- a/jobs/Backend/Task/ExchangeRateUpdater.csproj +++ b/jobs/Backend/Task/ExchangeRateUpdater.csproj @@ -2,7 +2,19 @@ Exe - net6.0 + net10.0 + + + + + + + + + PreserveNewest + + + \ No newline at end of file diff --git a/jobs/Backend/Task/ICnbClient.cs b/jobs/Backend/Task/ICnbClient.cs new file mode 100644 index 0000000000..e5d109b3b9 --- /dev/null +++ b/jobs/Backend/Task/ICnbClient.cs @@ -0,0 +1,7 @@ +namespace ExchangeRateUpdater +{ + public interface ICnbClient + { + (bool Success, string RawData) GetRawDailyExchanges(); + } +} diff --git a/jobs/Backend/Task/IConfigurationReader.cs b/jobs/Backend/Task/IConfigurationReader.cs new file mode 100644 index 0000000000..a04b81d630 --- /dev/null +++ b/jobs/Backend/Task/IConfigurationReader.cs @@ -0,0 +1,8 @@ +namespace ExchangeRateUpdater +{ + public interface IConfigurationReader + { + CnbSettings ReadSettings(); + } +} + diff --git a/jobs/Backend/Task/Program.cs b/jobs/Backend/Task/Program.cs index 379a69b1f8..5cd47752b0 100644 --- a/jobs/Backend/Task/Program.cs +++ b/jobs/Backend/Task/Program.cs @@ -1,5 +1,7 @@ -using System; +using Microsoft.Extensions.Configuration; +using System; using System.Collections.Generic; +using System.IO; using System.Linq; namespace ExchangeRateUpdater @@ -23,7 +25,17 @@ public static void Main(string[] args) { try { - var provider = new ExchangeRateProvider(); + var config = new ConfigurationBuilder() + .AddJsonFile(Path.Combine(AppContext.BaseDirectory, "appSettings.json")) + .Build(); + + var configurationReader = new ConfigurationReader(config); + + var settings = configurationReader.ReadSettings(); + + var client = new CnbClient(settings); + + var provider = new ExchangeRateProvider(settings, client); var rates = provider.GetExchangeRates(currencies); Console.WriteLine($"Successfully retrieved {rates.Count()} exchange rates:"); diff --git a/jobs/Backend/Task/appSettings.json b/jobs/Backend/Task/appSettings.json new file mode 100644 index 0000000000..50949eca47 --- /dev/null +++ b/jobs/Backend/Task/appSettings.json @@ -0,0 +1,7 @@ +{ + "CnbSettings": { + "SourceUrl": "https://www.cnb.cz/en/financial-markets/foreign-exchange-market/central-bank-exchange-rate-fixing/central-bank-exchange-rate-fixing/daily.txt", + "TargetCurrencyCode": "CZK", + "RetryAttempts": 3 + } +} \ No newline at end of file