From a2b4ad9d2ff783d5f4a46de7ad890ac375f584b5 Mon Sep 17 00:00:00 2001 From: Anton Miroshkin Date: Fri, 20 Jun 2025 18:14:44 +0200 Subject: [PATCH 01/27] Add the path `/jobs/Backend/Task/.vs/ExchangeRateUpdater` to the `.gitignore` file to prevent tracking of files in this directory. --- .gitignore | 1 + 1 file changed, 1 insertion(+) diff --git a/.gitignore b/.gitignore index fd3586545..87be8af06 100644 --- a/.gitignore +++ b/.gitignore @@ -21,3 +21,4 @@ node_modules bower_components npm-debug.log +/jobs/Backend/Task/.vs/ExchangeRateUpdater From 84cbcb4c24f77932f8d26d4492e3e754ce49f9f0 Mon Sep 17 00:00:00 2001 From: Anton Miroshkin Date: Tue, 24 Jun 2025 01:09:34 +0200 Subject: [PATCH 02/27] Add http client and cnb api link --- jobs/Backend/Task/ExchangeRateProvider.cs | 45 ++++++++++++++++++-- jobs/Backend/Task/ExchangeRateUpdater.csproj | 4 ++ jobs/Backend/Task/Program.cs | 25 +++++------ 3 files changed, 59 insertions(+), 15 deletions(-) diff --git a/jobs/Backend/Task/ExchangeRateProvider.cs b/jobs/Backend/Task/ExchangeRateProvider.cs index 6f82a97fb..94a7b3222 100644 --- a/jobs/Backend/Task/ExchangeRateProvider.cs +++ b/jobs/Backend/Task/ExchangeRateProvider.cs @@ -1,19 +1,58 @@ -using System.Collections.Generic; +using System; +using System.Collections.Generic; using System.Linq; +using System.Net.Http; +using System.Threading.Tasks; +using Newtonsoft.Json; namespace ExchangeRateUpdater { public class ExchangeRateProvider { + private static readonly HttpClient HttpClient = new HttpClient(); + private static readonly string CnbUrl = "https://api.cnb.cz/cnbapi/exrates/daily?lang=EN"; + private static readonly Currency Czk = new Currency("CZK"); + /// /// 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) + public async Task> GetExchangeRatesAsync(IEnumerable currencies) + { + var response = await HttpClient.GetStringAsync(CnbUrl); + var rates = new List(); + var currencyCodes = new HashSet(currencies.Select(c => c.Code), StringComparer.OrdinalIgnoreCase); + + var cnbResponse = JsonConvert.DeserializeObject(response); + if (cnbResponse?.Rates == null) + return rates; + + foreach (var rate in cnbResponse.Rates) + { + if (!currencyCodes.Contains(rate.CurrencyCode)) + continue; + var currency = new Currency(rate.CurrencyCode); + rates.Add(new ExchangeRate(currency, Czk, rate.Rate / rate.Amount)); + } + return rates; + } + + private class CnbApiResponse + { + [JsonProperty("rates")] + public List Rates { get; set; } + } + + private class CnbApiRate { - return Enumerable.Empty(); + [JsonProperty("currencyCode")] + public string CurrencyCode { get; set; } + [JsonProperty("amount")] + public int Amount { get; set; } + [JsonProperty("rate")] + public decimal Rate { get; set; } } } } diff --git a/jobs/Backend/Task/ExchangeRateUpdater.csproj b/jobs/Backend/Task/ExchangeRateUpdater.csproj index 2fc654a12..e651e76c1 100644 --- a/jobs/Backend/Task/ExchangeRateUpdater.csproj +++ b/jobs/Backend/Task/ExchangeRateUpdater.csproj @@ -5,4 +5,8 @@ net6.0 + + + + \ No newline at end of file diff --git a/jobs/Backend/Task/Program.cs b/jobs/Backend/Task/Program.cs index 379a69b1f..a404435fd 100644 --- a/jobs/Backend/Task/Program.cs +++ b/jobs/Backend/Task/Program.cs @@ -1,6 +1,7 @@ using System; using System.Collections.Generic; using System.Linq; +using System.Threading.Tasks; namespace ExchangeRateUpdater { @@ -8,23 +9,23 @@ public static class Program { private static IEnumerable currencies = new[] { - new Currency("USD"), - new Currency("EUR"), - new Currency("CZK"), - new Currency("JPY"), - new Currency("KES"), - new Currency("RUB"), - new Currency("THB"), - new Currency("TRY"), - new Currency("XYZ") - }; + new Currency("USD"), + new Currency("EUR"), + new Currency("CZK"), + new Currency("JPY"), + new Currency("KES"), + new Currency("RUB"), + new Currency("THB"), + new Currency("TRY"), + new Currency("XYZ") + }; - public static void Main(string[] args) + public static async Task Main(string[] args) { try { var provider = new ExchangeRateProvider(); - var rates = provider.GetExchangeRates(currencies); + var rates = await provider.GetExchangeRatesAsync(currencies); Console.WriteLine($"Successfully retrieved {rates.Count()} exchange rates:"); foreach (var rate in rates) From d9333e6b5c0e3cd8bad0d992e765f55bf1dd2e10 Mon Sep 17 00:00:00 2001 From: Anton Miroshkin Date: Tue, 24 Jun 2025 01:38:45 +0200 Subject: [PATCH 03/27] Add app configuration --- jobs/Backend/Task/ExchangeRateProvider.cs | 46 ++++++++++++++++---- jobs/Backend/Task/ExchangeRateUpdater.csproj | 7 +++ jobs/Backend/Task/Program.cs | 24 +++++++++- jobs/Backend/Task/appsettings.json | 6 +++ 4 files changed, 73 insertions(+), 10 deletions(-) create mode 100644 jobs/Backend/Task/appsettings.json diff --git a/jobs/Backend/Task/ExchangeRateProvider.cs b/jobs/Backend/Task/ExchangeRateProvider.cs index 94a7b3222..73e34453d 100644 --- a/jobs/Backend/Task/ExchangeRateProvider.cs +++ b/jobs/Backend/Task/ExchangeRateProvider.cs @@ -7,11 +7,39 @@ namespace ExchangeRateUpdater { + public class RateProviderConfiguration + { + public string Url { get; set; } + public string BaseCurrency { get; set; } + } + public class ExchangeRateProvider { private static readonly HttpClient HttpClient = new HttpClient(); - private static readonly string CnbUrl = "https://api.cnb.cz/cnbapi/exrates/daily?lang=EN"; - private static readonly Currency Czk = new Currency("CZK"); + private readonly string _cnbUrl; + private readonly Currency _czk; + + public static RateProviderConfiguration GetConfiguration(Microsoft.Extensions.Configuration.IConfiguration configuration) + { + var url = configuration["ApiConfiguration:Url"]; + var baseCurrency = configuration["ApiConfiguration:BaseCurrency"]; + + if (string.IsNullOrWhiteSpace(url)) + throw new Exception("ApiConfiguration:Url is not set in appsettings.json"); + + if (string.IsNullOrWhiteSpace(baseCurrency)) + throw new Exception("ApiConfiguration:BaseCurrency is not set in appsettings.json"); + + return new RateProviderConfiguration { Url = url, BaseCurrency = baseCurrency }; + } + + public ExchangeRateProvider(RateProviderConfiguration config) + { + if (config == null) + throw new ArgumentNullException(nameof(config)); + _cnbUrl = config.Url; + _czk = new Currency(config.BaseCurrency); + } /// /// Should return exchange rates among the specified currencies that are defined by the source. But only those defined @@ -21,11 +49,11 @@ public class ExchangeRateProvider /// public async Task> GetExchangeRatesAsync(IEnumerable currencies) { - var response = await HttpClient.GetStringAsync(CnbUrl); + var response = await HttpClient.GetStringAsync(_cnbUrl); var rates = new List(); var currencyCodes = new HashSet(currencies.Select(c => c.Code), StringComparer.OrdinalIgnoreCase); - var cnbResponse = JsonConvert.DeserializeObject(response); + var cnbResponse = JsonConvert.DeserializeObject(response); if (cnbResponse?.Rates == null) return rates; @@ -34,25 +62,25 @@ public async Task> GetExchangeRatesAsync(IEnumerable Rates { get; set; } + public List Rates { get; set; } } - private class CnbApiRate + private class Rate { [JsonProperty("currencyCode")] public string CurrencyCode { get; set; } [JsonProperty("amount")] public int Amount { get; set; } [JsonProperty("rate")] - public decimal Rate { get; set; } + public decimal ExchangeRateValue { get; set; } } } } diff --git a/jobs/Backend/Task/ExchangeRateUpdater.csproj b/jobs/Backend/Task/ExchangeRateUpdater.csproj index e651e76c1..05c280344 100644 --- a/jobs/Backend/Task/ExchangeRateUpdater.csproj +++ b/jobs/Backend/Task/ExchangeRateUpdater.csproj @@ -6,7 +6,14 @@ + + + + PreserveNewest + + + \ No newline at end of file diff --git a/jobs/Backend/Task/Program.cs b/jobs/Backend/Task/Program.cs index a404435fd..ba40283b0 100644 --- a/jobs/Backend/Task/Program.cs +++ b/jobs/Backend/Task/Program.cs @@ -2,6 +2,7 @@ using System.Collections.Generic; using System.Linq; using System.Threading.Tasks; +using Microsoft.Extensions.Configuration; namespace ExchangeRateUpdater { @@ -22,9 +23,10 @@ public static class Program public static async Task Main(string[] args) { + RateProviderConfiguration rateProviderConfig = GetRateProviderConfiguration(); try { - var provider = new ExchangeRateProvider(); + var provider = new ExchangeRateProvider(rateProviderConfig); var rates = await provider.GetExchangeRatesAsync(currencies); Console.WriteLine($"Successfully retrieved {rates.Count()} exchange rates:"); @@ -36,9 +38,29 @@ public static async Task Main(string[] args) catch (Exception e) { Console.WriteLine($"Could not retrieve exchange rates: '{e.Message}'."); + if (e is TypeInitializationException && e.InnerException != null) + { + Console.WriteLine($"Inner exception: {e.InnerException.Message}"); + Console.WriteLine(e.InnerException.StackTrace); + } } Console.ReadLine(); } + + private static RateProviderConfiguration GetRateProviderConfiguration() + { + var configuration = new ConfigurationBuilder() + .AddJsonFile("appsettings.json", optional: false, reloadOnChange: true) + .Build(); + + var rateProviderConfig = new RateProviderConfiguration + { + Url = configuration["ApiConfiguration:Url"], + BaseCurrency = configuration["ApiConfiguration:BaseCurrency"] + }; + + return rateProviderConfig; + } } } diff --git a/jobs/Backend/Task/appsettings.json b/jobs/Backend/Task/appsettings.json new file mode 100644 index 000000000..b5213c64c --- /dev/null +++ b/jobs/Backend/Task/appsettings.json @@ -0,0 +1,6 @@ +{ + "ApiConfiguration": { + "Url": "https://api.cnb.cz/cnbapi/exrates/daily?lang=EN", + "BaseCurrency": "CZK" + } +} From bdca5637d72b48e706232e7b2128fcdc8c3d65c2 Mon Sep 17 00:00:00 2001 From: Anton Miroshkin Date: Tue, 24 Jun 2025 02:10:49 +0200 Subject: [PATCH 04/27] Refactor json handling --- jobs/Backend/Task/ExchangeRateProvider.cs | 43 +++++++++++------------ jobs/Backend/Task/Program.cs | 6 ++++ 2 files changed, 26 insertions(+), 23 deletions(-) diff --git a/jobs/Backend/Task/ExchangeRateProvider.cs b/jobs/Backend/Task/ExchangeRateProvider.cs index 73e34453d..f91390565 100644 --- a/jobs/Backend/Task/ExchangeRateProvider.cs +++ b/jobs/Backend/Task/ExchangeRateProvider.cs @@ -1,9 +1,11 @@ -using System; +using Microsoft.Extensions.Configuration; +using System; using System.Collections.Generic; using System.Linq; using System.Net.Http; +using System.Net.Http.Json; using System.Threading.Tasks; -using Newtonsoft.Json; +using System.Text.Json.Serialization; namespace ExchangeRateUpdater { @@ -16,20 +18,14 @@ public class RateProviderConfiguration public class ExchangeRateProvider { private static readonly HttpClient HttpClient = new HttpClient(); - private readonly string _cnbUrl; - private readonly Currency _czk; + private readonly string _apiUrl; + private readonly Currency _baseCurrency; - public static RateProviderConfiguration GetConfiguration(Microsoft.Extensions.Configuration.IConfiguration configuration) + public static RateProviderConfiguration GetConfiguration(IConfiguration configuration) { var url = configuration["ApiConfiguration:Url"]; var baseCurrency = configuration["ApiConfiguration:BaseCurrency"]; - if (string.IsNullOrWhiteSpace(url)) - throw new Exception("ApiConfiguration:Url is not set in appsettings.json"); - - if (string.IsNullOrWhiteSpace(baseCurrency)) - throw new Exception("ApiConfiguration:BaseCurrency is not set in appsettings.json"); - return new RateProviderConfiguration { Url = url, BaseCurrency = baseCurrency }; } @@ -37,8 +33,8 @@ public ExchangeRateProvider(RateProviderConfiguration config) { if (config == null) throw new ArgumentNullException(nameof(config)); - _cnbUrl = config.Url; - _czk = new Currency(config.BaseCurrency); + _apiUrl = config.Url; + _baseCurrency = new Currency(config.BaseCurrency); } /// @@ -49,11 +45,10 @@ public ExchangeRateProvider(RateProviderConfiguration config) /// public async Task> GetExchangeRatesAsync(IEnumerable currencies) { - var response = await HttpClient.GetStringAsync(_cnbUrl); + var cnbResponse = await HttpClient.GetFromJsonAsync(_apiUrl); var rates = new List(); var currencyCodes = new HashSet(currencies.Select(c => c.Code), StringComparer.OrdinalIgnoreCase); - var cnbResponse = JsonConvert.DeserializeObject(response); if (cnbResponse?.Rates == null) return rates; @@ -62,25 +57,27 @@ public async Task> GetExchangeRatesAsync(IEnumerable Rates { get; set; } + [JsonPropertyName("rates")] + public List Rates { get; set; } } - private class Rate + private class RateDto { - [JsonProperty("currencyCode")] + [JsonPropertyName("currencyCode")] public string CurrencyCode { get; set; } - [JsonProperty("amount")] + + [JsonPropertyName("amount")] public int Amount { get; set; } - [JsonProperty("rate")] - public decimal ExchangeRateValue { get; set; } + + [JsonPropertyName("rate")] + public decimal Rate { get; set; } } } } diff --git a/jobs/Backend/Task/Program.cs b/jobs/Backend/Task/Program.cs index ba40283b0..e72a24ba6 100644 --- a/jobs/Backend/Task/Program.cs +++ b/jobs/Backend/Task/Program.cs @@ -60,6 +60,12 @@ private static RateProviderConfiguration GetRateProviderConfiguration() BaseCurrency = configuration["ApiConfiguration:BaseCurrency"] }; + if (string.IsNullOrWhiteSpace(rateProviderConfig.Url)) + throw new Exception("ApiConfiguration:Url is not set in appsettings.json"); + + if (string.IsNullOrWhiteSpace(rateProviderConfig.BaseCurrency)) + throw new Exception("ApiConfiguration:BaseCurrency is not set in appsettings.json"); + return rateProviderConfig; } } From eb5bc93a25437e26d3783e58aad0411274850cbf Mon Sep 17 00:00:00 2001 From: Anton Miroshkin Date: Tue, 24 Jun 2025 21:28:35 +0200 Subject: [PATCH 05/27] Refactor classes - extract interfaces and create separate files --- jobs/Backend/Task/ExchangeRateProvider.cs | 41 +++---------------- jobs/Backend/Task/IExchangeRateProvider.cs | 10 +++++ .../Task/IRateProviderConfiguration.cs | 8 ++++ jobs/Backend/Task/Program.cs | 6 +-- jobs/Backend/Task/RateDto.cs | 16 ++++++++ .../Backend/Task/RateProviderConfiguration.cs | 8 ++++ jobs/Backend/Task/Response.cs | 11 +++++ 7 files changed, 62 insertions(+), 38 deletions(-) create mode 100644 jobs/Backend/Task/IExchangeRateProvider.cs create mode 100644 jobs/Backend/Task/IRateProviderConfiguration.cs create mode 100644 jobs/Backend/Task/RateDto.cs create mode 100644 jobs/Backend/Task/RateProviderConfiguration.cs create mode 100644 jobs/Backend/Task/Response.cs diff --git a/jobs/Backend/Task/ExchangeRateProvider.cs b/jobs/Backend/Task/ExchangeRateProvider.cs index f91390565..a897f78b4 100644 --- a/jobs/Backend/Task/ExchangeRateProvider.cs +++ b/jobs/Backend/Task/ExchangeRateProvider.cs @@ -5,34 +5,23 @@ using System.Net.Http; using System.Net.Http.Json; using System.Threading.Tasks; -using System.Text.Json.Serialization; namespace ExchangeRateUpdater { - public class RateProviderConfiguration - { - public string Url { get; set; } - public string BaseCurrency { get; set; } - } - public class ExchangeRateProvider + public partial class ExchangeRateProvider : IExchangeRateProvider { private static readonly HttpClient HttpClient = new HttpClient(); private readonly string _apiUrl; private readonly Currency _baseCurrency; - public static RateProviderConfiguration GetConfiguration(IConfiguration configuration) + public ExchangeRateProvider(IRateProviderConfiguration config) { - var url = configuration["ApiConfiguration:Url"]; - var baseCurrency = configuration["ApiConfiguration:BaseCurrency"]; - - return new RateProviderConfiguration { Url = url, BaseCurrency = baseCurrency }; - } + if (config == null) + { + throw new ArgumentNullException(nameof(config)); + } - public ExchangeRateProvider(RateProviderConfiguration config) - { - if (config == null) - throw new ArgumentNullException(nameof(config)); _apiUrl = config.Url; _baseCurrency = new Currency(config.BaseCurrency); } @@ -61,23 +50,5 @@ public async Task> GetExchangeRatesAsync(IEnumerable Rates { get; set; } - } - - private class RateDto - { - [JsonPropertyName("currencyCode")] - public string CurrencyCode { get; set; } - - [JsonPropertyName("amount")] - public int Amount { get; set; } - - [JsonPropertyName("rate")] - public decimal Rate { get; set; } - } } } diff --git a/jobs/Backend/Task/IExchangeRateProvider.cs b/jobs/Backend/Task/IExchangeRateProvider.cs new file mode 100644 index 000000000..8db782de2 --- /dev/null +++ b/jobs/Backend/Task/IExchangeRateProvider.cs @@ -0,0 +1,10 @@ +using System.Collections.Generic; +using System.Threading.Tasks; + +namespace ExchangeRateUpdater +{ + public interface IExchangeRateProvider + { + Task> GetExchangeRatesAsync(IEnumerable currencies); + } +} \ No newline at end of file diff --git a/jobs/Backend/Task/IRateProviderConfiguration.cs b/jobs/Backend/Task/IRateProviderConfiguration.cs new file mode 100644 index 000000000..17d379831 --- /dev/null +++ b/jobs/Backend/Task/IRateProviderConfiguration.cs @@ -0,0 +1,8 @@ +namespace ExchangeRateUpdater +{ + public interface IRateProviderConfiguration + { + string BaseCurrency { get; set; } + string Url { get; set; } + } +} \ No newline at end of file diff --git a/jobs/Backend/Task/Program.cs b/jobs/Backend/Task/Program.cs index e72a24ba6..6e5243266 100644 --- a/jobs/Backend/Task/Program.cs +++ b/jobs/Backend/Task/Program.cs @@ -23,10 +23,10 @@ public static class Program public static async Task Main(string[] args) { - RateProviderConfiguration rateProviderConfig = GetRateProviderConfiguration(); + try { - var provider = new ExchangeRateProvider(rateProviderConfig); + var provider = new ExchangeRateProvider(GetRateProviderConfiguration()); var rates = await provider.GetExchangeRatesAsync(currencies); Console.WriteLine($"Successfully retrieved {rates.Count()} exchange rates:"); @@ -48,7 +48,7 @@ public static async Task Main(string[] args) Console.ReadLine(); } - private static RateProviderConfiguration GetRateProviderConfiguration() + private static IRateProviderConfiguration GetRateProviderConfiguration() { var configuration = new ConfigurationBuilder() .AddJsonFile("appsettings.json", optional: false, reloadOnChange: true) diff --git a/jobs/Backend/Task/RateDto.cs b/jobs/Backend/Task/RateDto.cs new file mode 100644 index 000000000..eaf7329af --- /dev/null +++ b/jobs/Backend/Task/RateDto.cs @@ -0,0 +1,16 @@ +using System.Text.Json.Serialization; + +namespace ExchangeRateUpdater +{ + public class RateDto + { + [JsonPropertyName("currencyCode")] + public string CurrencyCode { get; set; } + + [JsonPropertyName("amount")] + public int Amount { get; set; } + + [JsonPropertyName("rate")] + public decimal Rate { get; set; } + } +} diff --git a/jobs/Backend/Task/RateProviderConfiguration.cs b/jobs/Backend/Task/RateProviderConfiguration.cs new file mode 100644 index 000000000..1514ac6fa --- /dev/null +++ b/jobs/Backend/Task/RateProviderConfiguration.cs @@ -0,0 +1,8 @@ +namespace ExchangeRateUpdater +{ + public class RateProviderConfiguration : IRateProviderConfiguration + { + public string Url { get; set; } + public string BaseCurrency { get; set; } + } +} diff --git a/jobs/Backend/Task/Response.cs b/jobs/Backend/Task/Response.cs new file mode 100644 index 000000000..1c98001dc --- /dev/null +++ b/jobs/Backend/Task/Response.cs @@ -0,0 +1,11 @@ +using System.Collections.Generic; +using System.Text.Json.Serialization; + +namespace ExchangeRateUpdater +{ + public class Response + { + [JsonPropertyName("rates")] + public List Rates { get; set; } + } +} From 2de687d8dbe2b40a8f38db17659f7ce68d6234b5 Mon Sep 17 00:00:00 2001 From: Anton Miroshkin Date: Tue, 24 Jun 2025 21:35:38 +0200 Subject: [PATCH 06/27] Simplify configuration for provider --- jobs/Backend/Task/Program.cs | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/jobs/Backend/Task/Program.cs b/jobs/Backend/Task/Program.cs index 6e5243266..ccb750581 100644 --- a/jobs/Backend/Task/Program.cs +++ b/jobs/Backend/Task/Program.cs @@ -50,9 +50,7 @@ public static async Task Main(string[] args) private static IRateProviderConfiguration GetRateProviderConfiguration() { - var configuration = new ConfigurationBuilder() - .AddJsonFile("appsettings.json", optional: false, reloadOnChange: true) - .Build(); + var configuration = new ConfigurationBuilder().AddJsonFile("appsettings.json").Build(); var rateProviderConfig = new RateProviderConfiguration { From 0d6574b1a54413054763590a8248234d7882a5c6 Mon Sep 17 00:00:00 2001 From: Anton Miroshkin Date: Wed, 25 Jun 2025 00:10:45 +0200 Subject: [PATCH 07/27] Refactoring - Rename classes --- .../Backend/Task/{Response.cs => ApiResponse.cs} | 4 ++-- jobs/Backend/Task/ExchangeRateProvider.cs | 16 +++++++++++----- ...n.cs => ExchangeRateProviderConfiguration.cs} | 2 +- ....cs => IExchangeRateProviderConfiguration.cs} | 2 +- jobs/Backend/Task/Program.cs | 4 ++-- 5 files changed, 17 insertions(+), 11 deletions(-) rename jobs/Backend/Task/{Response.cs => ApiResponse.cs} (65%) rename jobs/Backend/Task/{RateProviderConfiguration.cs => ExchangeRateProviderConfiguration.cs} (61%) rename jobs/Backend/Task/{IRateProviderConfiguration.cs => IExchangeRateProviderConfiguration.cs} (68%) diff --git a/jobs/Backend/Task/Response.cs b/jobs/Backend/Task/ApiResponse.cs similarity index 65% rename from jobs/Backend/Task/Response.cs rename to jobs/Backend/Task/ApiResponse.cs index 1c98001dc..f80b6cd6a 100644 --- a/jobs/Backend/Task/Response.cs +++ b/jobs/Backend/Task/ApiResponse.cs @@ -3,9 +3,9 @@ namespace ExchangeRateUpdater { - public class Response + public class ApiResponse { [JsonPropertyName("rates")] - public List Rates { get; set; } + public IEnumerable Rates { get; set; } } } diff --git a/jobs/Backend/Task/ExchangeRateProvider.cs b/jobs/Backend/Task/ExchangeRateProvider.cs index a897f78b4..82f32ee53 100644 --- a/jobs/Backend/Task/ExchangeRateProvider.cs +++ b/jobs/Backend/Task/ExchangeRateProvider.cs @@ -15,7 +15,7 @@ public partial class ExchangeRateProvider : IExchangeRateProvider private readonly string _apiUrl; private readonly Currency _baseCurrency; - public ExchangeRateProvider(IRateProviderConfiguration config) + public ExchangeRateProvider(IExchangeRateProviderConfiguration config) { if (config == null) { @@ -34,17 +34,23 @@ public ExchangeRateProvider(IRateProviderConfiguration config) /// public async Task> GetExchangeRatesAsync(IEnumerable currencies) { - var cnbResponse = await HttpClient.GetFromJsonAsync(_apiUrl); + var apiResponse = await HttpClient.GetFromJsonAsync(_apiUrl); + var rates = new List(); var currencyCodes = new HashSet(currencies.Select(c => c.Code), StringComparer.OrdinalIgnoreCase); - if (cnbResponse?.Rates == null) - return rates; + if (apiResponse?.Rates == null) + { + return rates; + } - foreach (var rate in cnbResponse.Rates) + foreach (var rate in apiResponse.Rates) { if (!currencyCodes.Contains(rate.CurrencyCode)) + { continue; + } + var currency = new Currency(rate.CurrencyCode); rates.Add(new ExchangeRate(currency, _baseCurrency, rate.Rate / rate.Amount)); } diff --git a/jobs/Backend/Task/RateProviderConfiguration.cs b/jobs/Backend/Task/ExchangeRateProviderConfiguration.cs similarity index 61% rename from jobs/Backend/Task/RateProviderConfiguration.cs rename to jobs/Backend/Task/ExchangeRateProviderConfiguration.cs index 1514ac6fa..bbe1f2323 100644 --- a/jobs/Backend/Task/RateProviderConfiguration.cs +++ b/jobs/Backend/Task/ExchangeRateProviderConfiguration.cs @@ -1,6 +1,6 @@ namespace ExchangeRateUpdater { - public class RateProviderConfiguration : IRateProviderConfiguration + public class ExchangeRateProviderConfiguration : IExchangeRateProviderConfiguration { public string Url { get; set; } public string BaseCurrency { get; set; } diff --git a/jobs/Backend/Task/IRateProviderConfiguration.cs b/jobs/Backend/Task/IExchangeRateProviderConfiguration.cs similarity index 68% rename from jobs/Backend/Task/IRateProviderConfiguration.cs rename to jobs/Backend/Task/IExchangeRateProviderConfiguration.cs index 17d379831..70dc27e42 100644 --- a/jobs/Backend/Task/IRateProviderConfiguration.cs +++ b/jobs/Backend/Task/IExchangeRateProviderConfiguration.cs @@ -1,6 +1,6 @@ namespace ExchangeRateUpdater { - public interface IRateProviderConfiguration + public interface IExchangeRateProviderConfiguration { string BaseCurrency { get; set; } string Url { get; set; } diff --git a/jobs/Backend/Task/Program.cs b/jobs/Backend/Task/Program.cs index ccb750581..3e1b71e2d 100644 --- a/jobs/Backend/Task/Program.cs +++ b/jobs/Backend/Task/Program.cs @@ -48,11 +48,11 @@ public static async Task Main(string[] args) Console.ReadLine(); } - private static IRateProviderConfiguration GetRateProviderConfiguration() + private static IExchangeRateProviderConfiguration GetRateProviderConfiguration() { var configuration = new ConfigurationBuilder().AddJsonFile("appsettings.json").Build(); - var rateProviderConfig = new RateProviderConfiguration + var rateProviderConfig = new ExchangeRateProviderConfiguration { Url = configuration["ApiConfiguration:Url"], BaseCurrency = configuration["ApiConfiguration:BaseCurrency"] From 2625304f1a96c06d9eb3abab13bd550afc760ba4 Mon Sep 17 00:00:00 2001 From: Anton Miroshkin Date: Wed, 25 Jun 2025 03:50:10 +0200 Subject: [PATCH 08/27] Add second API --- jobs/Backend/Task/ApiResponse.cs | 2 +- jobs/Backend/Task/CnbApiResponse.cs | 11 ++++ jobs/Backend/Task/CnbExchangeRateProvider.cs | 49 +++++++++++++++ .../Task/{RateDto.cs => CnbRateDto.cs} | 2 +- jobs/Backend/Task/ExchangeRateApiProvider.cs | 41 +++++++++++++ jobs/Backend/Task/ExchangeRateApiResponse.cs | 17 ++++++ jobs/Backend/Task/ExchangeRateProvider.cs | 60 ------------------- jobs/Backend/Task/ExchangeRateProviderBase.cs | 33 ++++++++++ jobs/Backend/Task/IExchangeRateProvider.cs | 2 +- jobs/Backend/Task/Program.cs | 37 ++++++++++-- jobs/Backend/Task/appsettings.json | 4 +- 11 files changed, 190 insertions(+), 68 deletions(-) create mode 100644 jobs/Backend/Task/CnbApiResponse.cs create mode 100644 jobs/Backend/Task/CnbExchangeRateProvider.cs rename jobs/Backend/Task/{RateDto.cs => CnbRateDto.cs} (92%) create mode 100644 jobs/Backend/Task/ExchangeRateApiProvider.cs create mode 100644 jobs/Backend/Task/ExchangeRateApiResponse.cs delete mode 100644 jobs/Backend/Task/ExchangeRateProvider.cs create mode 100644 jobs/Backend/Task/ExchangeRateProviderBase.cs diff --git a/jobs/Backend/Task/ApiResponse.cs b/jobs/Backend/Task/ApiResponse.cs index f80b6cd6a..78275f1b6 100644 --- a/jobs/Backend/Task/ApiResponse.cs +++ b/jobs/Backend/Task/ApiResponse.cs @@ -6,6 +6,6 @@ namespace ExchangeRateUpdater public class ApiResponse { [JsonPropertyName("rates")] - public IEnumerable Rates { get; set; } + public List Rates { get; set; } } } diff --git a/jobs/Backend/Task/CnbApiResponse.cs b/jobs/Backend/Task/CnbApiResponse.cs new file mode 100644 index 000000000..08c459d28 --- /dev/null +++ b/jobs/Backend/Task/CnbApiResponse.cs @@ -0,0 +1,11 @@ +using System.Collections.Generic; +using System.Text.Json.Serialization; + +namespace ExchangeRateUpdater +{ + public class CnbApiResponse + { + [JsonPropertyName("rates")] + public List Rates { get; set; } + } +} diff --git a/jobs/Backend/Task/CnbExchangeRateProvider.cs b/jobs/Backend/Task/CnbExchangeRateProvider.cs new file mode 100644 index 000000000..9a0ee435f --- /dev/null +++ b/jobs/Backend/Task/CnbExchangeRateProvider.cs @@ -0,0 +1,49 @@ +using Microsoft.Extensions.Configuration; +using System; +using System.Collections.Generic; +using System.Linq; +using System.Net.Http; +using System.Net.Http.Json; +using System.Threading.Tasks; + +namespace ExchangeRateUpdater +{ + + public class CnbExchangeRateProvider : ExchangeRateProviderBase + { + public CnbExchangeRateProvider(IExchangeRateProviderConfiguration config) : base(config) { } + + protected override async Task FetchRawDataAsync() + { + if (typeof(T) != typeof(CnbApiResponse)) + throw new NotSupportedException($"Type {typeof(T)} is not supported by {nameof(CnbExchangeRateProvider)}"); + var result = await HttpClient.GetFromJsonAsync(_apiUrl); + return (T)(object)result; + } + + protected override IEnumerable MapToExchangeRates(T rawData, IEnumerable currencies) + { + var apiResponse = rawData as CnbApiResponse; + var rates = new List(); + var currencyCodes = new HashSet(currencies.Select(c => c.Code), StringComparer.OrdinalIgnoreCase); + + if (apiResponse?.Rates == null) + { + return rates; + } + + foreach (var rate in apiResponse.Rates) + { + if (!currencyCodes.Contains(rate.CurrencyCode)) + { + continue; + } + + var currency = new Currency(rate.CurrencyCode); + int amount = rate.Amount; + rates.Add(new ExchangeRate(currency, _baseCurrency, rate.Rate / amount)); + } + return rates; + } + } +} diff --git a/jobs/Backend/Task/RateDto.cs b/jobs/Backend/Task/CnbRateDto.cs similarity index 92% rename from jobs/Backend/Task/RateDto.cs rename to jobs/Backend/Task/CnbRateDto.cs index eaf7329af..43a21de23 100644 --- a/jobs/Backend/Task/RateDto.cs +++ b/jobs/Backend/Task/CnbRateDto.cs @@ -2,7 +2,7 @@ namespace ExchangeRateUpdater { - public class RateDto + public class CnbRateDto { [JsonPropertyName("currencyCode")] public string CurrencyCode { get; set; } diff --git a/jobs/Backend/Task/ExchangeRateApiProvider.cs b/jobs/Backend/Task/ExchangeRateApiProvider.cs new file mode 100644 index 000000000..d7c7ea4bc --- /dev/null +++ b/jobs/Backend/Task/ExchangeRateApiProvider.cs @@ -0,0 +1,41 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Net.Http; +using System.Net.Http.Json; +using System.Threading.Tasks; + +namespace ExchangeRateUpdater +{ + public class ExchangeRateApiProvider : ExchangeRateProviderBase + { + public ExchangeRateApiProvider(IExchangeRateProviderConfiguration config) : base(config) { } + + protected override async Task FetchRawDataAsync() + { + var result = await HttpClient.GetFromJsonAsync(_apiUrl); + return result; + } + + protected override IEnumerable MapToExchangeRates(T rawData, IEnumerable currencies) + { + var response = rawData as ExchangeRateApiResponse; + var rates = new List(); + var currencyCodes = new HashSet(currencies.Select(c => c.Code), StringComparer.OrdinalIgnoreCase); + + if (response?.ConversionRates == null) + return rates; + + foreach (var code in currencyCodes) + { + decimal rate; + if (response.ConversionRates.TryGetValue(code, out rate)) + { + var currency = new Currency(code); + rates.Add(new ExchangeRate(currency, _baseCurrency, rate)); + } + } + return rates; + } + } +} diff --git a/jobs/Backend/Task/ExchangeRateApiResponse.cs b/jobs/Backend/Task/ExchangeRateApiResponse.cs new file mode 100644 index 000000000..51e440641 --- /dev/null +++ b/jobs/Backend/Task/ExchangeRateApiResponse.cs @@ -0,0 +1,17 @@ +using System.Collections.Generic; +using System.Text.Json.Serialization; + +namespace ExchangeRateUpdater +{ + public class ExchangeRateApiResponse + { + [JsonPropertyName("result")] + public string Result { get; set; } + + [JsonPropertyName("base_code")] + public string BaseCode { get; set; } + + [JsonPropertyName("conversion_rates")] + public Dictionary ConversionRates { get; set; } + } +} diff --git a/jobs/Backend/Task/ExchangeRateProvider.cs b/jobs/Backend/Task/ExchangeRateProvider.cs deleted file mode 100644 index 82f32ee53..000000000 --- a/jobs/Backend/Task/ExchangeRateProvider.cs +++ /dev/null @@ -1,60 +0,0 @@ -using Microsoft.Extensions.Configuration; -using System; -using System.Collections.Generic; -using System.Linq; -using System.Net.Http; -using System.Net.Http.Json; -using System.Threading.Tasks; - -namespace ExchangeRateUpdater -{ - - public partial class ExchangeRateProvider : IExchangeRateProvider - { - private static readonly HttpClient HttpClient = new HttpClient(); - private readonly string _apiUrl; - private readonly Currency _baseCurrency; - - public ExchangeRateProvider(IExchangeRateProviderConfiguration config) - { - if (config == null) - { - throw new ArgumentNullException(nameof(config)); - } - - _apiUrl = config.Url; - _baseCurrency = new Currency(config.BaseCurrency); - } - - /// - /// 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 async Task> GetExchangeRatesAsync(IEnumerable currencies) - { - var apiResponse = await HttpClient.GetFromJsonAsync(_apiUrl); - - var rates = new List(); - var currencyCodes = new HashSet(currencies.Select(c => c.Code), StringComparer.OrdinalIgnoreCase); - - if (apiResponse?.Rates == null) - { - return rates; - } - - foreach (var rate in apiResponse.Rates) - { - if (!currencyCodes.Contains(rate.CurrencyCode)) - { - continue; - } - - var currency = new Currency(rate.CurrencyCode); - rates.Add(new ExchangeRate(currency, _baseCurrency, rate.Rate / rate.Amount)); - } - return rates; - } - } -} diff --git a/jobs/Backend/Task/ExchangeRateProviderBase.cs b/jobs/Backend/Task/ExchangeRateProviderBase.cs new file mode 100644 index 000000000..97fd777f2 --- /dev/null +++ b/jobs/Backend/Task/ExchangeRateProviderBase.cs @@ -0,0 +1,33 @@ +using System; +using System.Collections.Generic; +using System.Net.Http; +using System.Threading.Tasks; +using System.Linq; +using System.Net.Http.Json; + +namespace ExchangeRateUpdater +{ + public abstract class ExchangeRateProviderBase : IExchangeRateProvider + { + protected static readonly HttpClient HttpClient = new HttpClient(); + protected readonly string _apiUrl; + protected readonly Currency _baseCurrency; + + protected ExchangeRateProviderBase(IExchangeRateProviderConfiguration config) + { + if (config == null) + throw new ArgumentNullException(nameof(config)); + _apiUrl = config.Url; + _baseCurrency = new Currency(config.BaseCurrency); + } + + public async Task> GetExchangeRatesAsync(IEnumerable currencies) + { + var response = await FetchRawDataAsync(); + return MapToExchangeRates(response, currencies); + } + + protected abstract Task FetchRawDataAsync(); + protected abstract IEnumerable MapToExchangeRates(T rawData, IEnumerable currencies); + } +} diff --git a/jobs/Backend/Task/IExchangeRateProvider.cs b/jobs/Backend/Task/IExchangeRateProvider.cs index 8db782de2..2372cb4bf 100644 --- a/jobs/Backend/Task/IExchangeRateProvider.cs +++ b/jobs/Backend/Task/IExchangeRateProvider.cs @@ -5,6 +5,6 @@ namespace ExchangeRateUpdater { public interface IExchangeRateProvider { - Task> GetExchangeRatesAsync(IEnumerable currencies); + Task> GetExchangeRatesAsync(IEnumerable currencies); } } \ No newline at end of file diff --git a/jobs/Backend/Task/Program.cs b/jobs/Backend/Task/Program.cs index 3e1b71e2d..c2b4a7abf 100644 --- a/jobs/Backend/Task/Program.cs +++ b/jobs/Backend/Task/Program.cs @@ -23,17 +23,27 @@ public static class Program public static async Task Main(string[] args) { - try { - var provider = new ExchangeRateProvider(GetRateProviderConfiguration()); - var rates = await provider.GetExchangeRatesAsync(currencies); + var provider = new CnbExchangeRateProvider(GetRateProviderConfiguration()); + var provider2 = new ExchangeRateApiProvider(GetExchangeRateApiProviderConfiguration()); - Console.WriteLine($"Successfully retrieved {rates.Count()} exchange rates:"); + // Explicitly specify the type argument for GetExchangeRatesAsync + var rates = await provider.GetExchangeRatesAsync(currencies); + var rates2 = await provider2.GetExchangeRatesAsync(currencies); + + // Print CNB results as returned + Console.WriteLine($"Successfully retrieved {rates.Count()} exchange rates from CNB:"); foreach (var rate in rates) { Console.WriteLine(rate.ToString()); } + // Print ExchangeRate-API results: invert value and order by currencies + Console.WriteLine($"Successfully retrieved {rates2.Count()} exchange rates from CNB:"); + foreach (var rate in rates2) + { + Console.WriteLine(rate.ToString()); + } } catch (Exception e) { @@ -66,5 +76,24 @@ private static IExchangeRateProviderConfiguration GetRateProviderConfiguration() return rateProviderConfig; } + + private static IExchangeRateProviderConfiguration GetExchangeRateApiProviderConfiguration() + { + var configuration = new ConfigurationBuilder().AddJsonFile("appsettings.json").Build(); + + var rateProviderConfig = new ExchangeRateProviderConfiguration + { + Url = configuration["ApiConfiguration:Url2"] + configuration["ApiConfiguration:BaseCurrency2"], + BaseCurrency = configuration["ApiConfiguration:BaseCurrency2"] + }; + + if (string.IsNullOrWhiteSpace(rateProviderConfig.Url)) + throw new Exception("ApiConfiguration:Url2 is not set in appsettings.json"); + + if (string.IsNullOrWhiteSpace(rateProviderConfig.BaseCurrency)) + throw new Exception("ApiConfiguration:BaseCurrency2 is not set in appsettings.json"); + + return rateProviderConfig; + } } } diff --git a/jobs/Backend/Task/appsettings.json b/jobs/Backend/Task/appsettings.json index b5213c64c..cdf70c767 100644 --- a/jobs/Backend/Task/appsettings.json +++ b/jobs/Backend/Task/appsettings.json @@ -1,6 +1,8 @@ { "ApiConfiguration": { "Url": "https://api.cnb.cz/cnbapi/exrates/daily?lang=EN", - "BaseCurrency": "CZK" + "BaseCurrency": "CZK", + "Url2": "https://v6.exchangerate-api.com/v6/48b58d210307b06e68836c82/latest/", + "BaseCurrency2": "CZK" } } From 0dd8210260f1742d21bd9d9957a1040ad0ddc207 Mon Sep 17 00:00:00 2001 From: Anton Miroshkin Date: Wed, 25 Jun 2025 13:45:19 +0200 Subject: [PATCH 09/27] Refactoring - Moving files to folders --- .../exchangerateupdater.metadata.v9.bin | Bin 0 -> 1443 bytes .../exchangerateupdater.projects.v9.bin | Bin 0 -> 26018 bytes .../exchangerateupdater.strings.v9.bin | Bin 0 -> 129642 bytes jobs/Backend/Task/{ => Cnb}/ApiResponse.cs | 2 +- jobs/Backend/Task/{ => Cnb}/CnbRateDto.cs | 2 +- .../ExchangeRateProvider.cs} | 17 +++++++++-------- jobs/Backend/Task/CnbApiResponse.cs | 11 ----------- .../ExchangeRateApiProvider.cs | 2 +- .../ExchangeRateApiResponse.cs | 2 +- .../ExchangeRateProviderBase.cs | 2 +- jobs/Backend/Task/Program.cs | 10 ++++++---- 11 files changed, 20 insertions(+), 28 deletions(-) create mode 100644 jobs/Backend/Task/.vs/ProjectEvaluation/exchangerateupdater.metadata.v9.bin create mode 100644 jobs/Backend/Task/.vs/ProjectEvaluation/exchangerateupdater.projects.v9.bin create mode 100644 jobs/Backend/Task/.vs/ProjectEvaluation/exchangerateupdater.strings.v9.bin rename jobs/Backend/Task/{ => Cnb}/ApiResponse.cs (86%) rename jobs/Backend/Task/{ => Cnb}/CnbRateDto.cs (90%) rename jobs/Backend/Task/{CnbExchangeRateProvider.cs => Cnb/ExchangeRateProvider.cs} (69%) delete mode 100644 jobs/Backend/Task/CnbApiResponse.cs rename jobs/Backend/Task/{ => ExchangeRateApi}/ExchangeRateApiProvider.cs (96%) rename jobs/Backend/Task/{ => ExchangeRateApi}/ExchangeRateApiResponse.cs (90%) rename jobs/Backend/Task/{ => ExchangeRateApi}/ExchangeRateProviderBase.cs (96%) diff --git a/jobs/Backend/Task/.vs/ProjectEvaluation/exchangerateupdater.metadata.v9.bin b/jobs/Backend/Task/.vs/ProjectEvaluation/exchangerateupdater.metadata.v9.bin new file mode 100644 index 0000000000000000000000000000000000000000..2755cadf98637e60b1cb4f8dca89b5783fc23833 GIT binary patch literal 1443 zcmZ{kYfKbZ6vxl-r!q6HvM4n%A)zsiq-YxBGd^NcXpP#&_)1lz_yE~dMCk^hsdh_5 zVoj@tK%+(l-^)f6S|o}_6pb) zuchd`IAA-mfw+X-)djwU`FC{@kNV#B+19CSGjQhihCC(TGw_EyP-eLo^`M$b-G0l@ zeS_o!1FnK~o7Jb?=uCf_tyx;X;LeC#+h-HL%$sM2wl5kBd!{L$;y7rYXR_r-oCO_l zcPMci0zZcHcxd2Ui{HNV6ljqv#R^_rM%|KEsVeXQsH<65DLvwpVzHojiXCt}zt({o zUZg0mLvU56a#fuU1AWaaRawOEpnV6&D~)5Il8d)2QFd}xT?TUYA5vM#TTvt*8%PSR z%Cm|o8bF16t}E3id`%hN)<07v6I*bs#A^re7d!cz>1LiX&zM4X^xQ!2#K0U?)v3VJ zEo(NZuwL-@9wx~A!{l?vet5f?p3ZDx*$OBID`ABR}wwv`E zZZ`bsQFAasCpnH8&x~azGGlbo1pjLk{+r3q?#I)A{kg7HM@nP*j8qn)WTGZN(qytG zM``Ye-;0Qq7u2u*KMNeOsm^*v?&3gftZVyrXf5~bz56HpC`)RbX+;#VY7x^fB4*p8 zp(*`>QXVGX@Q@2eGRI>`o?zax{Ei7RWRk4qGmW1e(IjhF4lq&t4yKyrXnuac3}9jy r-6WSZOgUYXrWuk085c8%8O+3*(rwB(mWD7xO&M>>4_O*!hP3|xhBKGJ literal 0 HcmV?d00001 diff --git a/jobs/Backend/Task/.vs/ProjectEvaluation/exchangerateupdater.projects.v9.bin b/jobs/Backend/Task/.vs/ProjectEvaluation/exchangerateupdater.projects.v9.bin new file mode 100644 index 0000000000000000000000000000000000000000..f76017047c1892dd11f47acc2a33650798e11778 GIT binary patch literal 26018 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 Ki8}yu6#Wn2Z7NIv literal 0 HcmV?d00001 diff --git a/jobs/Backend/Task/ApiResponse.cs b/jobs/Backend/Task/Cnb/ApiResponse.cs similarity index 86% rename from jobs/Backend/Task/ApiResponse.cs rename to jobs/Backend/Task/Cnb/ApiResponse.cs index 78275f1b6..782ed292d 100644 --- a/jobs/Backend/Task/ApiResponse.cs +++ b/jobs/Backend/Task/Cnb/ApiResponse.cs @@ -1,7 +1,7 @@ using System.Collections.Generic; using System.Text.Json.Serialization; -namespace ExchangeRateUpdater +namespace ExchangeRateUpdater.Cnb { public class ApiResponse { diff --git a/jobs/Backend/Task/CnbRateDto.cs b/jobs/Backend/Task/Cnb/CnbRateDto.cs similarity index 90% rename from jobs/Backend/Task/CnbRateDto.cs rename to jobs/Backend/Task/Cnb/CnbRateDto.cs index 43a21de23..7add16105 100644 --- a/jobs/Backend/Task/CnbRateDto.cs +++ b/jobs/Backend/Task/Cnb/CnbRateDto.cs @@ -1,6 +1,6 @@ using System.Text.Json.Serialization; -namespace ExchangeRateUpdater +namespace ExchangeRateUpdater.Cnb { public class CnbRateDto { diff --git a/jobs/Backend/Task/CnbExchangeRateProvider.cs b/jobs/Backend/Task/Cnb/ExchangeRateProvider.cs similarity index 69% rename from jobs/Backend/Task/CnbExchangeRateProvider.cs rename to jobs/Backend/Task/Cnb/ExchangeRateProvider.cs index 9a0ee435f..234ca2f39 100644 --- a/jobs/Backend/Task/CnbExchangeRateProvider.cs +++ b/jobs/Backend/Task/Cnb/ExchangeRateProvider.cs @@ -1,4 +1,5 @@ -using Microsoft.Extensions.Configuration; +using ExchangeRateUpdater.ExchangeRateApi; +using Microsoft.Extensions.Configuration; using System; using System.Collections.Generic; using System.Linq; @@ -6,24 +7,24 @@ using System.Net.Http.Json; using System.Threading.Tasks; -namespace ExchangeRateUpdater +namespace ExchangeRateUpdater.Cnb { - public class CnbExchangeRateProvider : ExchangeRateProviderBase + public class ExchangeRateProvider : ExchangeRateProviderBase { - public CnbExchangeRateProvider(IExchangeRateProviderConfiguration config) : base(config) { } + public ExchangeRateProvider(IExchangeRateProviderConfiguration config) : base(config) { } protected override async Task FetchRawDataAsync() { - if (typeof(T) != typeof(CnbApiResponse)) - throw new NotSupportedException($"Type {typeof(T)} is not supported by {nameof(CnbExchangeRateProvider)}"); - var result = await HttpClient.GetFromJsonAsync(_apiUrl); + if (typeof(T) != typeof(ApiResponse)) + throw new NotSupportedException($"Type {typeof(T)} is not supported by {nameof(ExchangeRateProvider)}"); + var result = await HttpClient.GetFromJsonAsync(_apiUrl); return (T)(object)result; } protected override IEnumerable MapToExchangeRates(T rawData, IEnumerable currencies) { - var apiResponse = rawData as CnbApiResponse; + var apiResponse = rawData as ApiResponse; var rates = new List(); var currencyCodes = new HashSet(currencies.Select(c => c.Code), StringComparer.OrdinalIgnoreCase); diff --git a/jobs/Backend/Task/CnbApiResponse.cs b/jobs/Backend/Task/CnbApiResponse.cs deleted file mode 100644 index 08c459d28..000000000 --- a/jobs/Backend/Task/CnbApiResponse.cs +++ /dev/null @@ -1,11 +0,0 @@ -using System.Collections.Generic; -using System.Text.Json.Serialization; - -namespace ExchangeRateUpdater -{ - public class CnbApiResponse - { - [JsonPropertyName("rates")] - public List Rates { get; set; } - } -} diff --git a/jobs/Backend/Task/ExchangeRateApiProvider.cs b/jobs/Backend/Task/ExchangeRateApi/ExchangeRateApiProvider.cs similarity index 96% rename from jobs/Backend/Task/ExchangeRateApiProvider.cs rename to jobs/Backend/Task/ExchangeRateApi/ExchangeRateApiProvider.cs index d7c7ea4bc..80b1640ca 100644 --- a/jobs/Backend/Task/ExchangeRateApiProvider.cs +++ b/jobs/Backend/Task/ExchangeRateApi/ExchangeRateApiProvider.cs @@ -5,7 +5,7 @@ using System.Net.Http.Json; using System.Threading.Tasks; -namespace ExchangeRateUpdater +namespace ExchangeRateUpdater.ExchangeRateApi { public class ExchangeRateApiProvider : ExchangeRateProviderBase { diff --git a/jobs/Backend/Task/ExchangeRateApiResponse.cs b/jobs/Backend/Task/ExchangeRateApi/ExchangeRateApiResponse.cs similarity index 90% rename from jobs/Backend/Task/ExchangeRateApiResponse.cs rename to jobs/Backend/Task/ExchangeRateApi/ExchangeRateApiResponse.cs index 51e440641..9e8f026f5 100644 --- a/jobs/Backend/Task/ExchangeRateApiResponse.cs +++ b/jobs/Backend/Task/ExchangeRateApi/ExchangeRateApiResponse.cs @@ -1,7 +1,7 @@ using System.Collections.Generic; using System.Text.Json.Serialization; -namespace ExchangeRateUpdater +namespace ExchangeRateUpdater.ExchangeRateApi { public class ExchangeRateApiResponse { diff --git a/jobs/Backend/Task/ExchangeRateProviderBase.cs b/jobs/Backend/Task/ExchangeRateApi/ExchangeRateProviderBase.cs similarity index 96% rename from jobs/Backend/Task/ExchangeRateProviderBase.cs rename to jobs/Backend/Task/ExchangeRateApi/ExchangeRateProviderBase.cs index 97fd777f2..cdd2a5c4b 100644 --- a/jobs/Backend/Task/ExchangeRateProviderBase.cs +++ b/jobs/Backend/Task/ExchangeRateApi/ExchangeRateProviderBase.cs @@ -5,7 +5,7 @@ using System.Linq; using System.Net.Http.Json; -namespace ExchangeRateUpdater +namespace ExchangeRateUpdater.ExchangeRateApi { public abstract class ExchangeRateProviderBase : IExchangeRateProvider { diff --git a/jobs/Backend/Task/Program.cs b/jobs/Backend/Task/Program.cs index c2b4a7abf..5f343082e 100644 --- a/jobs/Backend/Task/Program.cs +++ b/jobs/Backend/Task/Program.cs @@ -2,6 +2,8 @@ using System.Collections.Generic; using System.Linq; using System.Threading.Tasks; +using ExchangeRateUpdater.Cnb; +using ExchangeRateUpdater.ExchangeRateApi; using Microsoft.Extensions.Configuration; namespace ExchangeRateUpdater @@ -25,11 +27,11 @@ public static async Task Main(string[] args) { try { - var provider = new CnbExchangeRateProvider(GetRateProviderConfiguration()); + var provider = new ExchangeRateProvider(GetRateProviderConfiguration()); var provider2 = new ExchangeRateApiProvider(GetExchangeRateApiProviderConfiguration()); // Explicitly specify the type argument for GetExchangeRatesAsync - var rates = await provider.GetExchangeRatesAsync(currencies); + var rates = await provider.GetExchangeRatesAsync(currencies); var rates2 = await provider2.GetExchangeRatesAsync(currencies); // Print CNB results as returned @@ -38,8 +40,8 @@ public static async Task Main(string[] args) { Console.WriteLine(rate.ToString()); } - // Print ExchangeRate-API results: invert value and order by currencies - Console.WriteLine($"Successfully retrieved {rates2.Count()} exchange rates from CNB:"); + // Print ExchangeRate-API results as returned + Console.WriteLine($"Successfully retrieved {rates2.Count()} exchange rates from Exchange Rates API:"); foreach (var rate in rates2) { Console.WriteLine(rate.ToString()); From 68fc4d5978b01b10648dd28b06a5c07b77e85377 Mon Sep 17 00:00:00 2001 From: Anton Miroshkin Date: Wed, 25 Jun 2025 14:06:25 +0200 Subject: [PATCH 10/27] Add NLog logger --- jobs/Backend/Task/Cnb/ExchangeRateProvider.cs | 58 ++++++++++++------- .../ExchangeRateApiProvider.cs | 46 ++++++++++----- .../ExchangeRateProviderBase.cs | 14 ++++- jobs/Backend/Task/ExchangeRateUpdater.csproj | 4 ++ jobs/Backend/Task/NLog.config | 11 ++++ jobs/Backend/Task/Program.cs | 4 ++ 6 files changed, 101 insertions(+), 36 deletions(-) create mode 100644 jobs/Backend/Task/NLog.config diff --git a/jobs/Backend/Task/Cnb/ExchangeRateProvider.cs b/jobs/Backend/Task/Cnb/ExchangeRateProvider.cs index 234ca2f39..2ae53a4d3 100644 --- a/jobs/Backend/Task/Cnb/ExchangeRateProvider.cs +++ b/jobs/Backend/Task/Cnb/ExchangeRateProvider.cs @@ -1,5 +1,6 @@ using ExchangeRateUpdater.ExchangeRateApi; using Microsoft.Extensions.Configuration; +using NLog; using System; using System.Collections.Generic; using System.Linq; @@ -12,39 +13,56 @@ namespace ExchangeRateUpdater.Cnb public class ExchangeRateProvider : ExchangeRateProviderBase { + private static readonly Logger Logger = LogManager.GetCurrentClassLogger(); public ExchangeRateProvider(IExchangeRateProviderConfiguration config) : base(config) { } protected override async Task FetchRawDataAsync() { - if (typeof(T) != typeof(ApiResponse)) - throw new NotSupportedException($"Type {typeof(T)} is not supported by {nameof(ExchangeRateProvider)}"); - var result = await HttpClient.GetFromJsonAsync(_apiUrl); - return (T)(object)result; + try + { + if (typeof(T) != typeof(ApiResponse)) + throw new NotSupportedException($"Type {typeof(T)} is not supported by {nameof(ExchangeRateProvider)}"); + var result = await HttpClient.GetFromJsonAsync(_apiUrl); + return (T)(object)result; + } + catch (Exception ex) + { + Logger.Error(ex, "Error fetching raw data in FetchRawDataAsync"); + throw; + } } protected override IEnumerable MapToExchangeRates(T rawData, IEnumerable currencies) { - var apiResponse = rawData as ApiResponse; - var rates = new List(); - var currencyCodes = new HashSet(currencies.Select(c => c.Code), StringComparer.OrdinalIgnoreCase); + try + { + var apiResponse = rawData as ApiResponse; + var rates = new List(); + var currencyCodes = new HashSet(currencies.Select(c => c.Code), StringComparer.OrdinalIgnoreCase); - if (apiResponse?.Rates == null) - { - return rates; - } + if (apiResponse?.Rates == null) + { + return rates; + } - foreach (var rate in apiResponse.Rates) - { - if (!currencyCodes.Contains(rate.CurrencyCode)) + foreach (var rate in apiResponse.Rates) { - continue; - } + if (!currencyCodes.Contains(rate.CurrencyCode)) + { + continue; + } - var currency = new Currency(rate.CurrencyCode); - int amount = rate.Amount; - rates.Add(new ExchangeRate(currency, _baseCurrency, rate.Rate / amount)); + var currency = new Currency(rate.CurrencyCode); + int amount = rate.Amount; + rates.Add(new ExchangeRate(currency, _baseCurrency, rate.Rate / amount)); + } + return rates; + } + catch (Exception ex) + { + Logger.Error(ex, "Error mapping to exchange rates in MapToExchangeRates"); + throw; } - return rates; } } } diff --git a/jobs/Backend/Task/ExchangeRateApi/ExchangeRateApiProvider.cs b/jobs/Backend/Task/ExchangeRateApi/ExchangeRateApiProvider.cs index 80b1640ca..3808b1017 100644 --- a/jobs/Backend/Task/ExchangeRateApi/ExchangeRateApiProvider.cs +++ b/jobs/Backend/Task/ExchangeRateApi/ExchangeRateApiProvider.cs @@ -4,38 +4,56 @@ using System.Net.Http; using System.Net.Http.Json; using System.Threading.Tasks; +using NLog; namespace ExchangeRateUpdater.ExchangeRateApi { public class ExchangeRateApiProvider : ExchangeRateProviderBase { + private static readonly Logger Logger = LogManager.GetCurrentClassLogger(); public ExchangeRateApiProvider(IExchangeRateProviderConfiguration config) : base(config) { } protected override async Task FetchRawDataAsync() { - var result = await HttpClient.GetFromJsonAsync(_apiUrl); - return result; + try + { + var result = await HttpClient.GetFromJsonAsync(_apiUrl); + return result; + } + catch (Exception ex) + { + Logger.Error(ex, "Error fetching raw data in FetchRawDataAsync"); + throw; + } } protected override IEnumerable MapToExchangeRates(T rawData, IEnumerable currencies) { - var response = rawData as ExchangeRateApiResponse; - var rates = new List(); - var currencyCodes = new HashSet(currencies.Select(c => c.Code), StringComparer.OrdinalIgnoreCase); + try + { + var response = rawData as ExchangeRateApiResponse; + var rates = new List(); + var currencyCodes = new HashSet(currencies.Select(c => c.Code), StringComparer.OrdinalIgnoreCase); - if (response?.ConversionRates == null) - return rates; + if (response?.ConversionRates == null) + return rates; - foreach (var code in currencyCodes) - { - decimal rate; - if (response.ConversionRates.TryGetValue(code, out rate)) + foreach (var code in currencyCodes) { - var currency = new Currency(code); - rates.Add(new ExchangeRate(currency, _baseCurrency, rate)); + decimal rate; + if (response.ConversionRates.TryGetValue(code, out rate)) + { + var currency = new Currency(code); + rates.Add(new ExchangeRate(currency, _baseCurrency, rate)); + } } + return rates; + } + catch (Exception ex) + { + Logger.Error(ex, "Error mapping to exchange rates in MapToExchangeRates"); + throw; } - return rates; } } } diff --git a/jobs/Backend/Task/ExchangeRateApi/ExchangeRateProviderBase.cs b/jobs/Backend/Task/ExchangeRateApi/ExchangeRateProviderBase.cs index cdd2a5c4b..3226e902b 100644 --- a/jobs/Backend/Task/ExchangeRateApi/ExchangeRateProviderBase.cs +++ b/jobs/Backend/Task/ExchangeRateApi/ExchangeRateProviderBase.cs @@ -4,6 +4,7 @@ using System.Threading.Tasks; using System.Linq; using System.Net.Http.Json; +using NLog; namespace ExchangeRateUpdater.ExchangeRateApi { @@ -12,6 +13,7 @@ public abstract class ExchangeRateProviderBase : IExchangeRateProvider protected static readonly HttpClient HttpClient = new HttpClient(); protected readonly string _apiUrl; protected readonly Currency _baseCurrency; + private static readonly Logger Logger = LogManager.GetCurrentClassLogger(); protected ExchangeRateProviderBase(IExchangeRateProviderConfiguration config) { @@ -23,8 +25,16 @@ protected ExchangeRateProviderBase(IExchangeRateProviderConfiguration config) public async Task> GetExchangeRatesAsync(IEnumerable currencies) { - var response = await FetchRawDataAsync(); - return MapToExchangeRates(response, currencies); + try + { + var response = await FetchRawDataAsync(); + return MapToExchangeRates(response, currencies); + } + catch (Exception ex) + { + Logger.Error(ex, "Error in GetExchangeRatesAsync"); + throw; + } } protected abstract Task FetchRawDataAsync(); diff --git a/jobs/Backend/Task/ExchangeRateUpdater.csproj b/jobs/Backend/Task/ExchangeRateUpdater.csproj index 05c280344..a744c29aa 100644 --- a/jobs/Backend/Task/ExchangeRateUpdater.csproj +++ b/jobs/Backend/Task/ExchangeRateUpdater.csproj @@ -8,12 +8,16 @@ + PreserveNewest + + PreserveNewest + \ No newline at end of file diff --git a/jobs/Backend/Task/NLog.config b/jobs/Backend/Task/NLog.config new file mode 100644 index 000000000..01bca5d15 --- /dev/null +++ b/jobs/Backend/Task/NLog.config @@ -0,0 +1,11 @@ + + + + + + + + + + diff --git a/jobs/Backend/Task/Program.cs b/jobs/Backend/Task/Program.cs index 5f343082e..1c58421cc 100644 --- a/jobs/Backend/Task/Program.cs +++ b/jobs/Backend/Task/Program.cs @@ -5,6 +5,7 @@ using ExchangeRateUpdater.Cnb; using ExchangeRateUpdater.ExchangeRateApi; using Microsoft.Extensions.Configuration; +using NLog; namespace ExchangeRateUpdater { @@ -25,6 +26,9 @@ public static class Program public static async Task Main(string[] args) { + // Ensure NLog is initialized and test logging + var logger = LogManager.GetCurrentClassLogger(); + logger.Info("Application started. NLog is working."); try { var provider = new ExchangeRateProvider(GetRateProviderConfiguration()); From bc62a45c1110fb3be90fb174cade245307411570 Mon Sep 17 00:00:00 2001 From: Anton Miroshkin Date: Wed, 25 Jun 2025 14:43:52 +0200 Subject: [PATCH 11/27] Add test project --- .../ExchangeRateUpdater.Tests.csproj | 23 +++++++++++++++++++ .../ExchangingRateUpdaterTests.cs | 11 +++++++++ jobs/Backend/Task/ExchangeRateUpdater.sln | 13 +++++++++-- 3 files changed, 45 insertions(+), 2 deletions(-) create mode 100644 jobs/Backend/ExchangeRateUpdater.Tests/ExchangeRateUpdater.Tests.csproj create mode 100644 jobs/Backend/ExchangeRateUpdater.Tests/ExchangingRateUpdaterTests.cs diff --git a/jobs/Backend/ExchangeRateUpdater.Tests/ExchangeRateUpdater.Tests.csproj b/jobs/Backend/ExchangeRateUpdater.Tests/ExchangeRateUpdater.Tests.csproj new file mode 100644 index 000000000..9c5b30a23 --- /dev/null +++ b/jobs/Backend/ExchangeRateUpdater.Tests/ExchangeRateUpdater.Tests.csproj @@ -0,0 +1,23 @@ + + + + net8.0 + enable + enable + + false + true + + + + + + + + + + + + + + diff --git a/jobs/Backend/ExchangeRateUpdater.Tests/ExchangingRateUpdaterTests.cs b/jobs/Backend/ExchangeRateUpdater.Tests/ExchangingRateUpdaterTests.cs new file mode 100644 index 000000000..ddf7b1e3f --- /dev/null +++ b/jobs/Backend/ExchangeRateUpdater.Tests/ExchangingRateUpdaterTests.cs @@ -0,0 +1,11 @@ +namespace ExchangeRateUpdater.Tests +{ + public class ExchangingRateUpdaterTests + { + [Fact] + public void Test1() + { + + } + } +} \ No newline at end of file diff --git a/jobs/Backend/Task/ExchangeRateUpdater.sln b/jobs/Backend/Task/ExchangeRateUpdater.sln index 89be84daf..a066e9bec 100644 --- a/jobs/Backend/Task/ExchangeRateUpdater.sln +++ b/jobs/Backend/Task/ExchangeRateUpdater.sln @@ -1,10 +1,12 @@  Microsoft Visual Studio Solution File, Format Version 12.00 -# Visual Studio 14 -VisualStudioVersion = 14.0.25123.0 +# Visual Studio Version 17 +VisualStudioVersion = 17.14.36203.30 d17.14 MinimumVisualStudioVersion = 10.0.40219.1 Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ExchangeRateUpdater", "ExchangeRateUpdater.csproj", "{7B2695D6-D24C-4460-A58E-A10F08550CE0}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ExchangeRateUpdater.Tests", "..\ExchangeRateUpdater.Tests\ExchangeRateUpdater.Tests.csproj", "{20FE7E66-DDA2-400D-9929-E536DC54E302}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -15,8 +17,15 @@ Global {7B2695D6-D24C-4460-A58E-A10F08550CE0}.Debug|Any CPU.Build.0 = Debug|Any CPU {7B2695D6-D24C-4460-A58E-A10F08550CE0}.Release|Any CPU.ActiveCfg = Release|Any CPU {7B2695D6-D24C-4460-A58E-A10F08550CE0}.Release|Any CPU.Build.0 = Release|Any CPU + {20FE7E66-DDA2-400D-9929-E536DC54E302}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {20FE7E66-DDA2-400D-9929-E536DC54E302}.Debug|Any CPU.Build.0 = Debug|Any CPU + {20FE7E66-DDA2-400D-9929-E536DC54E302}.Release|Any CPU.ActiveCfg = Release|Any CPU + {20FE7E66-DDA2-400D-9929-E536DC54E302}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE EndGlobalSection + GlobalSection(ExtensibilityGlobals) = postSolution + SolutionGuid = {B7362371-8027-42AD-938F-8E5625C3936D} + EndGlobalSection EndGlobal From 892b3e031520e12f052b925728577ce33b8c85a6 Mon Sep 17 00:00:00 2001 From: Anton Miroshkin Date: Wed, 25 Jun 2025 14:57:41 +0200 Subject: [PATCH 12/27] Add some basic tests --- .../ExchangeRateProviderTests.cs | 62 +++++++++++++++++++ .../ExchangeRateUpdater.Tests.csproj | 10 ++- jobs/Backend/Task/Cnb/ExchangeRateProvider.cs | 2 +- .../ExchangeRateProviderBase.cs | 5 +- 4 files changed, 75 insertions(+), 4 deletions(-) create mode 100644 jobs/Backend/ExchangeRateUpdater.Tests/ExchangeRateProviderTests.cs diff --git a/jobs/Backend/ExchangeRateUpdater.Tests/ExchangeRateProviderTests.cs b/jobs/Backend/ExchangeRateUpdater.Tests/ExchangeRateProviderTests.cs new file mode 100644 index 000000000..44c849db6 --- /dev/null +++ b/jobs/Backend/ExchangeRateUpdater.Tests/ExchangeRateProviderTests.cs @@ -0,0 +1,62 @@ +using ExchangeRateUpdater.Cnb; +using Moq; +using Moq.Protected; +using System.Net; +using System.Net.Http.Json; + +namespace ExchangeRateUpdater.Tests +{ + public class ExchangeRateProviderTests + { + private static ExchangeRateProvider CreateProvider(HttpMessageHandler handler) + { + var httpClient = new HttpClient(handler); + var config = new TestConfig { Url = "http://test/api", BaseCurrency = "CZK" }; + return new ExchangeRateProvider(config, httpClient); + } + + [Fact] + public async Task FetchRawDataAsync_ReturnsRates_OnSuccess() + { + var response = new HttpResponseMessage(HttpStatusCode.OK) + { + Content = JsonContent.Create(new ApiResponse { Rates = new List { new CnbRateDto { CurrencyCode = "USD", Amount = 1, Rate = 25.0m } } }) + }; + var handler = new Mock(); + handler.Protected() + .Setup>("SendAsync", ItExpr.IsAny(), ItExpr.IsAny()) + .ReturnsAsync(response); + + var provider = CreateProvider(handler.Object); + var result = await provider.GetExchangeRatesAsync(new[] { new Currency("USD") }); + Assert.Single(result); + Assert.Equal("USD", result.First().SourceCurrency.Code); + } + + [Fact] + public async Task FetchRawDataAsync_Throws_OnApiError() + { + var errorJson = "{\"description\":\"Bad request\",\"errorCode\":\"BAD_REQUEST\"}"; + var response = new HttpResponseMessage(HttpStatusCode.BadRequest) + { + Content = new StringContent(errorJson, System.Text.Encoding.UTF8, "application/json") + }; + var handler = new Mock(); + handler.Protected() + .Setup>("SendAsync", ItExpr.IsAny(), ItExpr.IsAny()) + .ReturnsAsync(response); + + var provider = CreateProvider(handler.Object); + + await Assert.ThrowsAsync(async () => + await provider.GetExchangeRatesAsync(new[] { new Currency("USD") }) + ); + } + + private class TestConfig : IExchangeRateProviderConfiguration + { + public string Url { get; set; } = string.Empty; + public string BaseCurrency { get; set; } = string.Empty; + } + } +} diff --git a/jobs/Backend/ExchangeRateUpdater.Tests/ExchangeRateUpdater.Tests.csproj b/jobs/Backend/ExchangeRateUpdater.Tests/ExchangeRateUpdater.Tests.csproj index 9c5b30a23..2f3289f3d 100644 --- a/jobs/Backend/ExchangeRateUpdater.Tests/ExchangeRateUpdater.Tests.csproj +++ b/jobs/Backend/ExchangeRateUpdater.Tests/ExchangeRateUpdater.Tests.csproj @@ -12,12 +12,20 @@ + - + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + + + diff --git a/jobs/Backend/Task/Cnb/ExchangeRateProvider.cs b/jobs/Backend/Task/Cnb/ExchangeRateProvider.cs index 2ae53a4d3..d799f7f8b 100644 --- a/jobs/Backend/Task/Cnb/ExchangeRateProvider.cs +++ b/jobs/Backend/Task/Cnb/ExchangeRateProvider.cs @@ -14,7 +14,7 @@ namespace ExchangeRateUpdater.Cnb public class ExchangeRateProvider : ExchangeRateProviderBase { private static readonly Logger Logger = LogManager.GetCurrentClassLogger(); - public ExchangeRateProvider(IExchangeRateProviderConfiguration config) : base(config) { } + public ExchangeRateProvider(IExchangeRateProviderConfiguration config, HttpClient httpClient = null) : base(config, httpClient) { } protected override async Task FetchRawDataAsync() { diff --git a/jobs/Backend/Task/ExchangeRateApi/ExchangeRateProviderBase.cs b/jobs/Backend/Task/ExchangeRateApi/ExchangeRateProviderBase.cs index 3226e902b..3e2ddd009 100644 --- a/jobs/Backend/Task/ExchangeRateApi/ExchangeRateProviderBase.cs +++ b/jobs/Backend/Task/ExchangeRateApi/ExchangeRateProviderBase.cs @@ -10,17 +10,18 @@ namespace ExchangeRateUpdater.ExchangeRateApi { public abstract class ExchangeRateProviderBase : IExchangeRateProvider { - protected static readonly HttpClient HttpClient = new HttpClient(); + protected readonly HttpClient HttpClient; protected readonly string _apiUrl; protected readonly Currency _baseCurrency; private static readonly Logger Logger = LogManager.GetCurrentClassLogger(); - protected ExchangeRateProviderBase(IExchangeRateProviderConfiguration config) + protected ExchangeRateProviderBase(IExchangeRateProviderConfiguration config, HttpClient httpClient = null) { if (config == null) throw new ArgumentNullException(nameof(config)); _apiUrl = config.Url; _baseCurrency = new Currency(config.BaseCurrency); + HttpClient = httpClient ?? new HttpClient(); } public async Task> GetExchangeRatesAsync(IEnumerable currencies) From f066f2c368e7567b13f7b1b53ab04f9e56467f45 Mon Sep 17 00:00:00 2001 From: Anton Miroshkin Date: Wed, 25 Jun 2025 15:11:22 +0200 Subject: [PATCH 13/27] Refactoring - Rename and comment unnecessary code --- .../ExchangeRateProviderTests.cs | 6 ++--- .../Cnb/{ApiResponse.cs => CnbApiResponse.cs} | 2 +- jobs/Backend/Task/Cnb/ExchangeRateProvider.cs | 10 +++----- jobs/Backend/Task/Program.cs | 25 +++++++++++-------- 4 files changed, 22 insertions(+), 21 deletions(-) rename jobs/Backend/Task/Cnb/{ApiResponse.cs => CnbApiResponse.cs} (86%) diff --git a/jobs/Backend/ExchangeRateUpdater.Tests/ExchangeRateProviderTests.cs b/jobs/Backend/ExchangeRateUpdater.Tests/ExchangeRateProviderTests.cs index 44c849db6..af7431e36 100644 --- a/jobs/Backend/ExchangeRateUpdater.Tests/ExchangeRateProviderTests.cs +++ b/jobs/Backend/ExchangeRateUpdater.Tests/ExchangeRateProviderTests.cs @@ -20,7 +20,7 @@ public async Task FetchRawDataAsync_ReturnsRates_OnSuccess() { var response = new HttpResponseMessage(HttpStatusCode.OK) { - Content = JsonContent.Create(new ApiResponse { Rates = new List { new CnbRateDto { CurrencyCode = "USD", Amount = 1, Rate = 25.0m } } }) + Content = JsonContent.Create(new CnbApiResponse { Rates = new List { new CnbRateDto { CurrencyCode = "USD", Amount = 1, Rate = 25.0m } } }) }; var handler = new Mock(); handler.Protected() @@ -28,7 +28,7 @@ public async Task FetchRawDataAsync_ReturnsRates_OnSuccess() .ReturnsAsync(response); var provider = CreateProvider(handler.Object); - var result = await provider.GetExchangeRatesAsync(new[] { new Currency("USD") }); + var result = await provider.GetExchangeRatesAsync(new[] { new Currency("USD") }); Assert.Single(result); Assert.Equal("USD", result.First().SourceCurrency.Code); } @@ -49,7 +49,7 @@ public async Task FetchRawDataAsync_Throws_OnApiError() var provider = CreateProvider(handler.Object); await Assert.ThrowsAsync(async () => - await provider.GetExchangeRatesAsync(new[] { new Currency("USD") }) + await provider.GetExchangeRatesAsync(new[] { new Currency("USD") }) ); } diff --git a/jobs/Backend/Task/Cnb/ApiResponse.cs b/jobs/Backend/Task/Cnb/CnbApiResponse.cs similarity index 86% rename from jobs/Backend/Task/Cnb/ApiResponse.cs rename to jobs/Backend/Task/Cnb/CnbApiResponse.cs index 782ed292d..f5a596853 100644 --- a/jobs/Backend/Task/Cnb/ApiResponse.cs +++ b/jobs/Backend/Task/Cnb/CnbApiResponse.cs @@ -3,7 +3,7 @@ namespace ExchangeRateUpdater.Cnb { - public class ApiResponse + public class CnbApiResponse { [JsonPropertyName("rates")] public List Rates { get; set; } diff --git a/jobs/Backend/Task/Cnb/ExchangeRateProvider.cs b/jobs/Backend/Task/Cnb/ExchangeRateProvider.cs index d799f7f8b..782915412 100644 --- a/jobs/Backend/Task/Cnb/ExchangeRateProvider.cs +++ b/jobs/Backend/Task/Cnb/ExchangeRateProvider.cs @@ -16,14 +16,12 @@ public class ExchangeRateProvider : ExchangeRateProviderBase private static readonly Logger Logger = LogManager.GetCurrentClassLogger(); public ExchangeRateProvider(IExchangeRateProviderConfiguration config, HttpClient httpClient = null) : base(config, httpClient) { } - protected override async Task FetchRawDataAsync() + protected override async Task FetchRawDataAsync() { try { - if (typeof(T) != typeof(ApiResponse)) - throw new NotSupportedException($"Type {typeof(T)} is not supported by {nameof(ExchangeRateProvider)}"); - var result = await HttpClient.GetFromJsonAsync(_apiUrl); - return (T)(object)result; + var result = await HttpClient.GetFromJsonAsync(_apiUrl); + return result; } catch (Exception ex) { @@ -36,7 +34,7 @@ protected override IEnumerable MapToExchangeRates(T rawData, IE { try { - var apiResponse = rawData as ApiResponse; + var apiResponse = rawData as CnbApiResponse; var rates = new List(); var currencyCodes = new HashSet(currencies.Select(c => c.Code), StringComparer.OrdinalIgnoreCase); diff --git a/jobs/Backend/Task/Program.cs b/jobs/Backend/Task/Program.cs index 1c58421cc..084c6bb73 100644 --- a/jobs/Backend/Task/Program.cs +++ b/jobs/Backend/Task/Program.cs @@ -32,11 +32,7 @@ public static async Task Main(string[] args) try { var provider = new ExchangeRateProvider(GetRateProviderConfiguration()); - var provider2 = new ExchangeRateApiProvider(GetExchangeRateApiProviderConfiguration()); - - // Explicitly specify the type argument for GetExchangeRatesAsync - var rates = await provider.GetExchangeRatesAsync(currencies); - var rates2 = await provider2.GetExchangeRatesAsync(currencies); + var rates = await provider.GetExchangeRatesAsync(currencies); // Print CNB results as returned Console.WriteLine($"Successfully retrieved {rates.Count()} exchange rates from CNB:"); @@ -44,12 +40,19 @@ public static async Task Main(string[] args) { Console.WriteLine(rate.ToString()); } - // Print ExchangeRate-API results as returned - Console.WriteLine($"Successfully retrieved {rates2.Count()} exchange rates from Exchange Rates API:"); - foreach (var rate in rates2) - { - Console.WriteLine(rate.ToString()); - } + + #region Another Source of Exchange Rates + //var provider2 = new ExchangeRateApiProvider(GetExchangeRateApiProviderConfiguration()); + //var rates2 = await provider2.GetExchangeRatesAsync(currencies); + + //// Print ExchangeRate-API results as returned + //Console.WriteLine($"Successfully retrieved {rates2.Count()} exchange rates from Exchange Rates API:"); + //foreach (var rate in rates2) + //{ + // Console.WriteLine(rate.ToString()); + //} + #endregion + } catch (Exception e) { From 118de5841e5625f4011ce91a652340cf02e202e9 Mon Sep 17 00:00:00 2001 From: Anton Miroshkin Date: Wed, 25 Jun 2025 15:50:26 +0200 Subject: [PATCH 14/27] Add test project in correct place --- .../ExchangeRateProviderTests.cs | 62 ------------------- .../ExchangingRateUpdaterTests.cs | 11 ---- .../ExchangeRateProvider.Tests.csproj} | 23 +++---- .../ExchangeRateProvider.Tests/UnitTest1.cs | 11 ++++ .../ExchangeRateProvider.Tests/Usings.cs | 1 + jobs/Backend/Task/ExchangeRateUpdater.sln | 12 ++-- 6 files changed, 26 insertions(+), 94 deletions(-) delete mode 100644 jobs/Backend/ExchangeRateUpdater.Tests/ExchangeRateProviderTests.cs delete mode 100644 jobs/Backend/ExchangeRateUpdater.Tests/ExchangingRateUpdaterTests.cs rename jobs/Backend/{ExchangeRateUpdater.Tests/ExchangeRateUpdater.Tests.csproj => Task/ExchangeRateProvider.Tests/ExchangeRateProvider.Tests/ExchangeRateProvider.Tests.csproj} (53%) create mode 100644 jobs/Backend/Task/ExchangeRateProvider.Tests/ExchangeRateProvider.Tests/UnitTest1.cs create mode 100644 jobs/Backend/Task/ExchangeRateProvider.Tests/ExchangeRateProvider.Tests/Usings.cs diff --git a/jobs/Backend/ExchangeRateUpdater.Tests/ExchangeRateProviderTests.cs b/jobs/Backend/ExchangeRateUpdater.Tests/ExchangeRateProviderTests.cs deleted file mode 100644 index af7431e36..000000000 --- a/jobs/Backend/ExchangeRateUpdater.Tests/ExchangeRateProviderTests.cs +++ /dev/null @@ -1,62 +0,0 @@ -using ExchangeRateUpdater.Cnb; -using Moq; -using Moq.Protected; -using System.Net; -using System.Net.Http.Json; - -namespace ExchangeRateUpdater.Tests -{ - public class ExchangeRateProviderTests - { - private static ExchangeRateProvider CreateProvider(HttpMessageHandler handler) - { - var httpClient = new HttpClient(handler); - var config = new TestConfig { Url = "http://test/api", BaseCurrency = "CZK" }; - return new ExchangeRateProvider(config, httpClient); - } - - [Fact] - public async Task FetchRawDataAsync_ReturnsRates_OnSuccess() - { - var response = new HttpResponseMessage(HttpStatusCode.OK) - { - Content = JsonContent.Create(new CnbApiResponse { Rates = new List { new CnbRateDto { CurrencyCode = "USD", Amount = 1, Rate = 25.0m } } }) - }; - var handler = new Mock(); - handler.Protected() - .Setup>("SendAsync", ItExpr.IsAny(), ItExpr.IsAny()) - .ReturnsAsync(response); - - var provider = CreateProvider(handler.Object); - var result = await provider.GetExchangeRatesAsync(new[] { new Currency("USD") }); - Assert.Single(result); - Assert.Equal("USD", result.First().SourceCurrency.Code); - } - - [Fact] - public async Task FetchRawDataAsync_Throws_OnApiError() - { - var errorJson = "{\"description\":\"Bad request\",\"errorCode\":\"BAD_REQUEST\"}"; - var response = new HttpResponseMessage(HttpStatusCode.BadRequest) - { - Content = new StringContent(errorJson, System.Text.Encoding.UTF8, "application/json") - }; - var handler = new Mock(); - handler.Protected() - .Setup>("SendAsync", ItExpr.IsAny(), ItExpr.IsAny()) - .ReturnsAsync(response); - - var provider = CreateProvider(handler.Object); - - await Assert.ThrowsAsync(async () => - await provider.GetExchangeRatesAsync(new[] { new Currency("USD") }) - ); - } - - private class TestConfig : IExchangeRateProviderConfiguration - { - public string Url { get; set; } = string.Empty; - public string BaseCurrency { get; set; } = string.Empty; - } - } -} diff --git a/jobs/Backend/ExchangeRateUpdater.Tests/ExchangingRateUpdaterTests.cs b/jobs/Backend/ExchangeRateUpdater.Tests/ExchangingRateUpdaterTests.cs deleted file mode 100644 index ddf7b1e3f..000000000 --- a/jobs/Backend/ExchangeRateUpdater.Tests/ExchangingRateUpdaterTests.cs +++ /dev/null @@ -1,11 +0,0 @@ -namespace ExchangeRateUpdater.Tests -{ - public class ExchangingRateUpdaterTests - { - [Fact] - public void Test1() - { - - } - } -} \ No newline at end of file diff --git a/jobs/Backend/ExchangeRateUpdater.Tests/ExchangeRateUpdater.Tests.csproj b/jobs/Backend/Task/ExchangeRateProvider.Tests/ExchangeRateProvider.Tests/ExchangeRateProvider.Tests.csproj similarity index 53% rename from jobs/Backend/ExchangeRateUpdater.Tests/ExchangeRateUpdater.Tests.csproj rename to jobs/Backend/Task/ExchangeRateProvider.Tests/ExchangeRateProvider.Tests/ExchangeRateProvider.Tests.csproj index 2f3289f3d..c5d106357 100644 --- a/jobs/Backend/ExchangeRateUpdater.Tests/ExchangeRateUpdater.Tests.csproj +++ b/jobs/Backend/Task/ExchangeRateProvider.Tests/ExchangeRateProvider.Tests/ExchangeRateProvider.Tests.csproj @@ -1,31 +1,24 @@ - net8.0 + net6.0 enable enable false - true - - - - - + + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + runtime; build; native; contentfiles; analyzers; buildtransitive all - - - - - - - - diff --git a/jobs/Backend/Task/ExchangeRateProvider.Tests/ExchangeRateProvider.Tests/UnitTest1.cs b/jobs/Backend/Task/ExchangeRateProvider.Tests/ExchangeRateProvider.Tests/UnitTest1.cs new file mode 100644 index 000000000..53c5558f1 --- /dev/null +++ b/jobs/Backend/Task/ExchangeRateProvider.Tests/ExchangeRateProvider.Tests/UnitTest1.cs @@ -0,0 +1,11 @@ +namespace ExchangeRateProvider.Tests +{ + public class UnitTest1 + { + [Fact] + public void Test1() + { + + } + } +} \ No newline at end of file diff --git a/jobs/Backend/Task/ExchangeRateProvider.Tests/ExchangeRateProvider.Tests/Usings.cs b/jobs/Backend/Task/ExchangeRateProvider.Tests/ExchangeRateProvider.Tests/Usings.cs new file mode 100644 index 000000000..8c927eb74 --- /dev/null +++ b/jobs/Backend/Task/ExchangeRateProvider.Tests/ExchangeRateProvider.Tests/Usings.cs @@ -0,0 +1 @@ +global using Xunit; \ No newline at end of file diff --git a/jobs/Backend/Task/ExchangeRateUpdater.sln b/jobs/Backend/Task/ExchangeRateUpdater.sln index a066e9bec..e91545519 100644 --- a/jobs/Backend/Task/ExchangeRateUpdater.sln +++ b/jobs/Backend/Task/ExchangeRateUpdater.sln @@ -1,11 +1,11 @@  Microsoft Visual Studio Solution File, Format Version 12.00 # Visual Studio Version 17 -VisualStudioVersion = 17.14.36203.30 d17.14 +VisualStudioVersion = 17.14.36203.30 MinimumVisualStudioVersion = 10.0.40219.1 Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ExchangeRateUpdater", "ExchangeRateUpdater.csproj", "{7B2695D6-D24C-4460-A58E-A10F08550CE0}" EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ExchangeRateUpdater.Tests", "..\ExchangeRateUpdater.Tests\ExchangeRateUpdater.Tests.csproj", "{20FE7E66-DDA2-400D-9929-E536DC54E302}" +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ExchangeRateProvider.Tests", "ExchangeRateProvider.Tests\ExchangeRateProvider.Tests\ExchangeRateProvider.Tests.csproj", "{A8132F2B-D058-4BA1-906F-92C85DCAD7ED}" EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution @@ -17,10 +17,10 @@ Global {7B2695D6-D24C-4460-A58E-A10F08550CE0}.Debug|Any CPU.Build.0 = Debug|Any CPU {7B2695D6-D24C-4460-A58E-A10F08550CE0}.Release|Any CPU.ActiveCfg = Release|Any CPU {7B2695D6-D24C-4460-A58E-A10F08550CE0}.Release|Any CPU.Build.0 = Release|Any CPU - {20FE7E66-DDA2-400D-9929-E536DC54E302}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {20FE7E66-DDA2-400D-9929-E536DC54E302}.Debug|Any CPU.Build.0 = Debug|Any CPU - {20FE7E66-DDA2-400D-9929-E536DC54E302}.Release|Any CPU.ActiveCfg = Release|Any CPU - {20FE7E66-DDA2-400D-9929-E536DC54E302}.Release|Any CPU.Build.0 = Release|Any CPU + {A8132F2B-D058-4BA1-906F-92C85DCAD7ED}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {A8132F2B-D058-4BA1-906F-92C85DCAD7ED}.Debug|Any CPU.Build.0 = Debug|Any CPU + {A8132F2B-D058-4BA1-906F-92C85DCAD7ED}.Release|Any CPU.ActiveCfg = Release|Any CPU + {A8132F2B-D058-4BA1-906F-92C85DCAD7ED}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE From 68f3b9681af5ae16cd56fb85fb46f478e6cbcacc Mon Sep 17 00:00:00 2001 From: Anton Miroshkin Date: Wed, 25 Jun 2025 17:25:57 +0200 Subject: [PATCH 15/27] Fix the build --- .../ExchangeRateProvider.Tests.csproj | 24 ------------------- .../ExchangeRateProvider.Tests/UnitTest1.cs | 11 --------- .../ExchangeRateProvider.Tests/Usings.cs | 1 - jobs/Backend/Task/ExchangeRateUpdater.csproj | 4 ++++ jobs/Backend/Task/ExchangeRateUpdater.sln | 6 ----- 5 files changed, 4 insertions(+), 42 deletions(-) delete mode 100644 jobs/Backend/Task/ExchangeRateProvider.Tests/ExchangeRateProvider.Tests/ExchangeRateProvider.Tests.csproj delete mode 100644 jobs/Backend/Task/ExchangeRateProvider.Tests/ExchangeRateProvider.Tests/UnitTest1.cs delete mode 100644 jobs/Backend/Task/ExchangeRateProvider.Tests/ExchangeRateProvider.Tests/Usings.cs diff --git a/jobs/Backend/Task/ExchangeRateProvider.Tests/ExchangeRateProvider.Tests/ExchangeRateProvider.Tests.csproj b/jobs/Backend/Task/ExchangeRateProvider.Tests/ExchangeRateProvider.Tests/ExchangeRateProvider.Tests.csproj deleted file mode 100644 index c5d106357..000000000 --- a/jobs/Backend/Task/ExchangeRateProvider.Tests/ExchangeRateProvider.Tests/ExchangeRateProvider.Tests.csproj +++ /dev/null @@ -1,24 +0,0 @@ - - - - net6.0 - enable - enable - - false - - - - - - - runtime; build; native; contentfiles; analyzers; buildtransitive - all - - - runtime; build; native; contentfiles; analyzers; buildtransitive - all - - - - diff --git a/jobs/Backend/Task/ExchangeRateProvider.Tests/ExchangeRateProvider.Tests/UnitTest1.cs b/jobs/Backend/Task/ExchangeRateProvider.Tests/ExchangeRateProvider.Tests/UnitTest1.cs deleted file mode 100644 index 53c5558f1..000000000 --- a/jobs/Backend/Task/ExchangeRateProvider.Tests/ExchangeRateProvider.Tests/UnitTest1.cs +++ /dev/null @@ -1,11 +0,0 @@ -namespace ExchangeRateProvider.Tests -{ - public class UnitTest1 - { - [Fact] - public void Test1() - { - - } - } -} \ No newline at end of file diff --git a/jobs/Backend/Task/ExchangeRateProvider.Tests/ExchangeRateProvider.Tests/Usings.cs b/jobs/Backend/Task/ExchangeRateProvider.Tests/ExchangeRateProvider.Tests/Usings.cs deleted file mode 100644 index 8c927eb74..000000000 --- a/jobs/Backend/Task/ExchangeRateProvider.Tests/ExchangeRateProvider.Tests/Usings.cs +++ /dev/null @@ -1 +0,0 @@ -global using Xunit; \ No newline at end of file diff --git a/jobs/Backend/Task/ExchangeRateUpdater.csproj b/jobs/Backend/Task/ExchangeRateUpdater.csproj index a744c29aa..c2379b74a 100644 --- a/jobs/Backend/Task/ExchangeRateUpdater.csproj +++ b/jobs/Backend/Task/ExchangeRateUpdater.csproj @@ -3,10 +3,14 @@ Exe net6.0 + false + false + false + diff --git a/jobs/Backend/Task/ExchangeRateUpdater.sln b/jobs/Backend/Task/ExchangeRateUpdater.sln index e91545519..b015b0aa1 100644 --- a/jobs/Backend/Task/ExchangeRateUpdater.sln +++ b/jobs/Backend/Task/ExchangeRateUpdater.sln @@ -5,8 +5,6 @@ VisualStudioVersion = 17.14.36203.30 MinimumVisualStudioVersion = 10.0.40219.1 Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ExchangeRateUpdater", "ExchangeRateUpdater.csproj", "{7B2695D6-D24C-4460-A58E-A10F08550CE0}" EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ExchangeRateProvider.Tests", "ExchangeRateProvider.Tests\ExchangeRateProvider.Tests\ExchangeRateProvider.Tests.csproj", "{A8132F2B-D058-4BA1-906F-92C85DCAD7ED}" -EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -17,10 +15,6 @@ Global {7B2695D6-D24C-4460-A58E-A10F08550CE0}.Debug|Any CPU.Build.0 = Debug|Any CPU {7B2695D6-D24C-4460-A58E-A10F08550CE0}.Release|Any CPU.ActiveCfg = Release|Any CPU {7B2695D6-D24C-4460-A58E-A10F08550CE0}.Release|Any CPU.Build.0 = Release|Any CPU - {A8132F2B-D058-4BA1-906F-92C85DCAD7ED}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {A8132F2B-D058-4BA1-906F-92C85DCAD7ED}.Debug|Any CPU.Build.0 = Debug|Any CPU - {A8132F2B-D058-4BA1-906F-92C85DCAD7ED}.Release|Any CPU.ActiveCfg = Release|Any CPU - {A8132F2B-D058-4BA1-906F-92C85DCAD7ED}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE From 91a8ffc7d772ace61c14b81ec038e3f4e485a475 Mon Sep 17 00:00:00 2001 From: Anton Miroshkin Date: Wed, 25 Jun 2025 18:03:46 +0200 Subject: [PATCH 16/27] Add loggers --- jobs/Backend/Task/Cnb/ExchangeRateProvider.cs | 13 ++++++------- .../ExchangeRateApi/ExchangeRateProviderBase.cs | 8 ++++++-- jobs/Backend/Task/Program.cs | 3 ++- 3 files changed, 14 insertions(+), 10 deletions(-) diff --git a/jobs/Backend/Task/Cnb/ExchangeRateProvider.cs b/jobs/Backend/Task/Cnb/ExchangeRateProvider.cs index 782915412..e6ba4e0b4 100644 --- a/jobs/Backend/Task/Cnb/ExchangeRateProvider.cs +++ b/jobs/Backend/Task/Cnb/ExchangeRateProvider.cs @@ -1,5 +1,4 @@ using ExchangeRateUpdater.ExchangeRateApi; -using Microsoft.Extensions.Configuration; using NLog; using System; using System.Collections.Generic; @@ -10,11 +9,14 @@ namespace ExchangeRateUpdater.Cnb { - public class ExchangeRateProvider : ExchangeRateProviderBase { - private static readonly Logger Logger = LogManager.GetCurrentClassLogger(); - public ExchangeRateProvider(IExchangeRateProviderConfiguration config, HttpClient httpClient = null) : base(config, httpClient) { } + protected static new readonly Logger Logger = LogManager.GetCurrentClassLogger(); + + public ExchangeRateProvider( + IExchangeRateProviderConfiguration config, + HttpClient httpClient = null) + : base(config, httpClient) { } protected override async Task FetchRawDataAsync() { @@ -37,19 +39,16 @@ protected override IEnumerable MapToExchangeRates(T rawData, IE var apiResponse = rawData as CnbApiResponse; var rates = new List(); var currencyCodes = new HashSet(currencies.Select(c => c.Code), StringComparer.OrdinalIgnoreCase); - if (apiResponse?.Rates == null) { return rates; } - foreach (var rate in apiResponse.Rates) { if (!currencyCodes.Contains(rate.CurrencyCode)) { continue; } - var currency = new Currency(rate.CurrencyCode); int amount = rate.Amount; rates.Add(new ExchangeRate(currency, _baseCurrency, rate.Rate / amount)); diff --git a/jobs/Backend/Task/ExchangeRateApi/ExchangeRateProviderBase.cs b/jobs/Backend/Task/ExchangeRateApi/ExchangeRateProviderBase.cs index 3e2ddd009..315f6b19e 100644 --- a/jobs/Backend/Task/ExchangeRateApi/ExchangeRateProviderBase.cs +++ b/jobs/Backend/Task/ExchangeRateApi/ExchangeRateProviderBase.cs @@ -4,7 +4,9 @@ using System.Threading.Tasks; using System.Linq; using System.Net.Http.Json; +using Microsoft.Extensions.Logging; using NLog; +using ILogger = Microsoft.Extensions.Logging.ILogger; namespace ExchangeRateUpdater.ExchangeRateApi { @@ -13,9 +15,11 @@ public abstract class ExchangeRateProviderBase : IExchangeRateProvider protected readonly HttpClient HttpClient; protected readonly string _apiUrl; protected readonly Currency _baseCurrency; - private static readonly Logger Logger = LogManager.GetCurrentClassLogger(); + protected static readonly NLog.Logger Logger = LogManager.GetCurrentClassLogger(); - protected ExchangeRateProviderBase(IExchangeRateProviderConfiguration config, HttpClient httpClient = null) + protected ExchangeRateProviderBase( + IExchangeRateProviderConfiguration config, + HttpClient httpClient = null) { if (config == null) throw new ArgumentNullException(nameof(config)); diff --git a/jobs/Backend/Task/Program.cs b/jobs/Backend/Task/Program.cs index 084c6bb73..6ddea830c 100644 --- a/jobs/Backend/Task/Program.cs +++ b/jobs/Backend/Task/Program.cs @@ -29,10 +29,11 @@ public static async Task Main(string[] args) // Ensure NLog is initialized and test logging var logger = LogManager.GetCurrentClassLogger(); logger.Info("Application started. NLog is working."); + try { var provider = new ExchangeRateProvider(GetRateProviderConfiguration()); - var rates = await provider.GetExchangeRatesAsync(currencies); + var rates = await provider.GetExchangeRatesAsync(currencies); // Print CNB results as returned Console.WriteLine($"Successfully retrieved {rates.Count()} exchange rates from CNB:"); From 8042a4220077cd74ac70b7bf976b71f0a14890b6 Mon Sep 17 00:00:00 2001 From: Anton Miroshkin Date: Thu, 26 Jun 2025 12:56:33 +0200 Subject: [PATCH 17/27] Add logging to file --- jobs/Backend/Task/NLog.config | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/jobs/Backend/Task/NLog.config b/jobs/Backend/Task/NLog.config index 01bca5d15..e80574cea 100644 --- a/jobs/Backend/Task/NLog.config +++ b/jobs/Backend/Task/NLog.config @@ -2,7 +2,13 @@ - + From b61305e7ae7f2c67c70f036a3d29e2988928f6e7 Mon Sep 17 00:00:00 2001 From: Anton Miroshkin Date: Thu, 26 Jun 2025 13:30:54 +0200 Subject: [PATCH 18/27] Add api error handling --- jobs/Backend/Task/Cnb/ApiErrorResponse.cs | 13 ++ jobs/Backend/Task/Cnb/ApiResponse.cs | 9 ++ jobs/Backend/Task/Cnb/ExchangeRateProvider.cs | 16 +-- .../Task/Cnb/ExchangeRateProviderBase.cs | 117 ++++++++++++++++++ .../ExchangeRateApiProvider.cs | 1 + .../ExchangeRateProviderBase.cs | 48 ------- 6 files changed, 146 insertions(+), 58 deletions(-) create mode 100644 jobs/Backend/Task/Cnb/ApiErrorResponse.cs create mode 100644 jobs/Backend/Task/Cnb/ApiResponse.cs create mode 100644 jobs/Backend/Task/Cnb/ExchangeRateProviderBase.cs delete mode 100644 jobs/Backend/Task/ExchangeRateApi/ExchangeRateProviderBase.cs diff --git a/jobs/Backend/Task/Cnb/ApiErrorResponse.cs b/jobs/Backend/Task/Cnb/ApiErrorResponse.cs new file mode 100644 index 000000000..ff0a7165f --- /dev/null +++ b/jobs/Backend/Task/Cnb/ApiErrorResponse.cs @@ -0,0 +1,13 @@ +using System; + +namespace ExchangeRateUpdater.Cnb +{ + public class ApiErrorResponse + { + public string Description { get; set; } + public string EndPoint { get; set; } + public string ErrorCode { get; set; } + public DateTime HappenedAt { get; set; } + public string MessageId { get; set; } + } +} diff --git a/jobs/Backend/Task/Cnb/ApiResponse.cs b/jobs/Backend/Task/Cnb/ApiResponse.cs new file mode 100644 index 000000000..5f3c9a35f --- /dev/null +++ b/jobs/Backend/Task/Cnb/ApiResponse.cs @@ -0,0 +1,9 @@ +namespace ExchangeRateUpdater.Cnb +{ + public class ApiResponse + { + public bool Success { get; set; } + public T Data { get; set; } + public ApiErrorResponse Error { get; set; } + } +} diff --git a/jobs/Backend/Task/Cnb/ExchangeRateProvider.cs b/jobs/Backend/Task/Cnb/ExchangeRateProvider.cs index e6ba4e0b4..c196bb52b 100644 --- a/jobs/Backend/Task/Cnb/ExchangeRateProvider.cs +++ b/jobs/Backend/Task/Cnb/ExchangeRateProvider.cs @@ -1,5 +1,4 @@ -using ExchangeRateUpdater.ExchangeRateApi; -using NLog; +using NLog; using System; using System.Collections.Generic; using System.Linq; @@ -20,16 +19,13 @@ public ExchangeRateProvider( protected override async Task FetchRawDataAsync() { - try + var apiResult = await GetApiDataAsync(_apiUrl); + if (!apiResult.Success) { - var result = await HttpClient.GetFromJsonAsync(_apiUrl); - return result; - } - catch (Exception ex) - { - Logger.Error(ex, "Error fetching raw data in FetchRawDataAsync"); - throw; + Logger.Error($"API Error: {apiResult.Error}"); + throw new Exception($"API Error: {apiResult.Error?.ErrorCode} - {apiResult.Error?.Description}"); } + return apiResult.Data; } protected override IEnumerable MapToExchangeRates(T rawData, IEnumerable currencies) diff --git a/jobs/Backend/Task/Cnb/ExchangeRateProviderBase.cs b/jobs/Backend/Task/Cnb/ExchangeRateProviderBase.cs new file mode 100644 index 000000000..8816fd8e9 --- /dev/null +++ b/jobs/Backend/Task/Cnb/ExchangeRateProviderBase.cs @@ -0,0 +1,117 @@ +using System; +using System.Collections.Generic; +using System.Net.Http; +using System.Threading.Tasks; +using System.Linq; +using System.Net.Http.Json; +using Microsoft.Extensions.Logging; +using NLog; +using ILogger = Microsoft.Extensions.Logging.ILogger; + +namespace ExchangeRateUpdater.Cnb +{ + public abstract class ExchangeRateProviderBase : IExchangeRateProvider + { + protected readonly HttpClient HttpClient; + protected readonly string _apiUrl; + protected readonly Currency _baseCurrency; + protected static readonly Logger Logger = LogManager.GetCurrentClassLogger(); + + protected ExchangeRateProviderBase( + IExchangeRateProviderConfiguration config, + HttpClient httpClient = null) + { + if (config == null) + throw new ArgumentNullException(nameof(config)); + _apiUrl = config.Url; + _baseCurrency = new Currency(config.BaseCurrency); + HttpClient = httpClient ?? new HttpClient(); + } + + public async Task> GetExchangeRatesAsync(IEnumerable currencies) + { + try + { + var response = await FetchRawDataAsync(); + return MapToExchangeRates(response, currencies); + } + catch (Exception ex) + { + Logger.Error(ex, "Error in GetExchangeRatesAsync"); + throw; + } + } + + public async Task> GetApiDataAsync(string url) + { + try + { + var response = await HttpClient.GetAsync(url); + + if (!response.IsSuccessStatusCode) + { + string error = await response.Content.ReadAsStringAsync(); + ApiErrorResponse apiError = GetApiErrorResponse(error); + Logger.Error($"API error: {response.StatusCode} - {error}"); + return new ApiResponse + { + Success = false, + Error = apiError + }; + } + + T data = await GetData(response); + return new ApiResponse + { + Success = true, + Data = data + }; + } + catch (Exception ex) + { + Logger.Error(ex, "Exception in GetApiDataAsync"); + return new ApiResponse + { + Success = false, + Error = new ApiErrorResponse { Description = $"Exception: {ex.Message}" } + }; + } + } + + private static ApiErrorResponse GetApiErrorResponse(string error) + { + ApiErrorResponse apiError = null; + try + { + apiError = System.Text.Json.JsonSerializer.Deserialize(error); + } + catch (Exception jsonEx) + { + Logger.Error(jsonEx, $"Failed to deserialize API error response: {error}"); + throw; + } + + return apiError; + } + + private static async Task GetData(HttpResponseMessage response) + { + var json = await response.Content.ReadAsStringAsync(); + T data = default; + try + { + data = System.Text.Json.JsonSerializer.Deserialize(json); + } + catch (Exception jsonEx) + { + Logger.Error(jsonEx, $"Failed to deserialize API data response: {json}"); + throw; + } + + return data; + } + + protected abstract Task FetchRawDataAsync(); + protected abstract IEnumerable MapToExchangeRates(T rawData, IEnumerable currencies); + } +} diff --git a/jobs/Backend/Task/ExchangeRateApi/ExchangeRateApiProvider.cs b/jobs/Backend/Task/ExchangeRateApi/ExchangeRateApiProvider.cs index 3808b1017..420ad1b2c 100644 --- a/jobs/Backend/Task/ExchangeRateApi/ExchangeRateApiProvider.cs +++ b/jobs/Backend/Task/ExchangeRateApi/ExchangeRateApiProvider.cs @@ -4,6 +4,7 @@ using System.Net.Http; using System.Net.Http.Json; using System.Threading.Tasks; +using ExchangeRateUpdater.Cnb; using NLog; namespace ExchangeRateUpdater.ExchangeRateApi diff --git a/jobs/Backend/Task/ExchangeRateApi/ExchangeRateProviderBase.cs b/jobs/Backend/Task/ExchangeRateApi/ExchangeRateProviderBase.cs deleted file mode 100644 index 315f6b19e..000000000 --- a/jobs/Backend/Task/ExchangeRateApi/ExchangeRateProviderBase.cs +++ /dev/null @@ -1,48 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Net.Http; -using System.Threading.Tasks; -using System.Linq; -using System.Net.Http.Json; -using Microsoft.Extensions.Logging; -using NLog; -using ILogger = Microsoft.Extensions.Logging.ILogger; - -namespace ExchangeRateUpdater.ExchangeRateApi -{ - public abstract class ExchangeRateProviderBase : IExchangeRateProvider - { - protected readonly HttpClient HttpClient; - protected readonly string _apiUrl; - protected readonly Currency _baseCurrency; - protected static readonly NLog.Logger Logger = LogManager.GetCurrentClassLogger(); - - protected ExchangeRateProviderBase( - IExchangeRateProviderConfiguration config, - HttpClient httpClient = null) - { - if (config == null) - throw new ArgumentNullException(nameof(config)); - _apiUrl = config.Url; - _baseCurrency = new Currency(config.BaseCurrency); - HttpClient = httpClient ?? new HttpClient(); - } - - public async Task> GetExchangeRatesAsync(IEnumerable currencies) - { - try - { - var response = await FetchRawDataAsync(); - return MapToExchangeRates(response, currencies); - } - catch (Exception ex) - { - Logger.Error(ex, "Error in GetExchangeRatesAsync"); - throw; - } - } - - protected abstract Task FetchRawDataAsync(); - protected abstract IEnumerable MapToExchangeRates(T rawData, IEnumerable currencies); - } -} From b782e7a25479c8556e047e42149a0503172e7b20 Mon Sep 17 00:00:00 2001 From: Anton Miroshkin Date: Thu, 26 Jun 2025 13:57:14 +0200 Subject: [PATCH 19/27] Add basic tests for provider --- .../ExchangeRateProviderApiTests.cs | 110 ++++++++++++++++++ .../ExchangeRateUpdater.Tests.csproj | 26 +++++ .../ExchangeRateUpdater.Tests.sln | 24 ++++ .../ExchangeRateUpdater.Tests/UnitTest1.cs | 10 ++ jobs/Backend/Task/Cnb/ApiErrorResponse.cs | 10 ++ jobs/Backend/Task/ExchangeRateUpdater.sln | 6 + 6 files changed, 186 insertions(+) create mode 100644 jobs/Backend/Task.Tests/ExchangeRateUpdater.Tests/ExchangeRateProviderApiTests.cs create mode 100644 jobs/Backend/Task.Tests/ExchangeRateUpdater.Tests/ExchangeRateUpdater.Tests.csproj create mode 100644 jobs/Backend/Task.Tests/ExchangeRateUpdater.Tests/ExchangeRateUpdater.Tests.sln create mode 100644 jobs/Backend/Task.Tests/ExchangeRateUpdater.Tests/UnitTest1.cs diff --git a/jobs/Backend/Task.Tests/ExchangeRateUpdater.Tests/ExchangeRateProviderApiTests.cs b/jobs/Backend/Task.Tests/ExchangeRateUpdater.Tests/ExchangeRateProviderApiTests.cs new file mode 100644 index 000000000..51227fc9f --- /dev/null +++ b/jobs/Backend/Task.Tests/ExchangeRateUpdater.Tests/ExchangeRateProviderApiTests.cs @@ -0,0 +1,110 @@ +using ExchangeRateUpdater.Cnb; +using Moq; +using Moq.Protected; +using System.Net; +using System.Text.Json.Serialization; + +namespace ExchangeRateUpdater.Tests +{ + public class ExchangeRateProviderApiTests + { + [Fact] + public async Task GetApiDataAsync_ReturnsData_OnSuccess() + { + // Arrange + var goodJson = "{" + + "\"rates\":[{" + + "\"validFor\":\"2025-06-25\"," + + "\"order\":121," + + "\"country\":\"Australia\"," + + "\"currency\":\"dollar\"," + + "\"amount\":1," + + "\"currencyCode\":\"AUD\"," + + "\"rate\":13.872}]}"; + var handlerMock = new Mock(); + handlerMock.Protected() + .Setup>( + "SendAsync", + ItExpr.IsAny(), + ItExpr.IsAny() + ) + .ReturnsAsync(new HttpResponseMessage + { + StatusCode = HttpStatusCode.OK, + Content = new StringContent(goodJson) + }); + var httpClient = new HttpClient(handlerMock.Object); + var config = new Mock(); + config.SetupGet(c => c.Url).Returns("http://test/api"); + config.SetupGet(c => c.BaseCurrency).Returns("CZK"); + var provider = new TestProvider(config.Object, httpClient); + + // Act + var result = await provider.GetApiDataAsync("http://test/api"); + + // Assert + Assert.True(result.Success); + Assert.NotNull(result.Data); + Assert.NotEmpty(result.Data.Rates); + Assert.Equal("AUD", result.Data.Rates[0].CurrencyCode); + } + + [Fact] + public async Task GetApiDataAsync_ReturnsError_OnApiError() + { + // Arrange + var errorJson = "{" + + "\"description\":\"Internal error\"," + + "\"endPoint\":\"/api\"," + + "\"errorCode\":\"INTERNAL_SERVER_ERROR\"," + + "\"happenedAt\":\"2025-06-26T10:37:28.547Z\"," + + "\"messageId\":\"abc123\"}"; + var handlerMock = new Mock(); + handlerMock.Protected() + .Setup>( + "SendAsync", + ItExpr.IsAny(), + ItExpr.IsAny() + ) + .ReturnsAsync(new HttpResponseMessage + { + StatusCode = HttpStatusCode.InternalServerError, + Content = new StringContent(errorJson) + }); + var httpClient = new HttpClient(handlerMock.Object); + var config = new Mock(); + config.SetupGet(c => c.Url).Returns("http://test/api"); + config.SetupGet(c => c.BaseCurrency).Returns("CZK"); + var provider = new TestProvider(config.Object, httpClient); + + // Act + var result = await provider.GetApiDataAsync("http://test/api"); + + // Assert + Assert.False(result.Success); + Assert.NotNull(result.Error); + Assert.Equal("INTERNAL_SERVER_ERROR", result.Error.ErrorCode); + } + + // Minimal stub for testing + public class TestProvider : ExchangeRateProviderBase + { + public TestProvider(IExchangeRateProviderConfiguration config, HttpClient client) : base(config, client) { } + protected override Task FetchRawDataAsync() => throw new NotImplementedException(); + protected override IEnumerable MapToExchangeRates(T rawData, IEnumerable currencies) => throw new NotImplementedException(); + } + + public class TestApiResponse + { + [JsonPropertyName("rates")] + public List Rates { get; set; } = new List(); + } + public class TestRate + { + [JsonPropertyName("currencyCode")] + public string CurrencyCode { get; set; } = string.Empty; + [JsonPropertyName("rate")] + public double Rate { get; set; } + } + } +} diff --git a/jobs/Backend/Task.Tests/ExchangeRateUpdater.Tests/ExchangeRateUpdater.Tests.csproj b/jobs/Backend/Task.Tests/ExchangeRateUpdater.Tests/ExchangeRateUpdater.Tests.csproj new file mode 100644 index 000000000..c7a993bb6 --- /dev/null +++ b/jobs/Backend/Task.Tests/ExchangeRateUpdater.Tests/ExchangeRateUpdater.Tests.csproj @@ -0,0 +1,26 @@ + + + + net9.0 + enable + enable + false + + + + + + + + + + + + + + + + + + + diff --git a/jobs/Backend/Task.Tests/ExchangeRateUpdater.Tests/ExchangeRateUpdater.Tests.sln b/jobs/Backend/Task.Tests/ExchangeRateUpdater.Tests/ExchangeRateUpdater.Tests.sln new file mode 100644 index 000000000..bbe926355 --- /dev/null +++ b/jobs/Backend/Task.Tests/ExchangeRateUpdater.Tests/ExchangeRateUpdater.Tests.sln @@ -0,0 +1,24 @@ +Microsoft Visual Studio Solution File, Format Version 12.00 +# Visual Studio Version 17 +VisualStudioVersion = 17.5.2.0 +MinimumVisualStudioVersion = 10.0.40219.1 +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ExchangeRateUpdater.Tests", "ExchangeRateUpdater.Tests.csproj", "{5CB870E0-6DD9-17C1-D2D3-F2421C52624D}" +EndProject +Global + GlobalSection(SolutionConfigurationPlatforms) = preSolution + Debug|Any CPU = Debug|Any CPU + Release|Any CPU = Release|Any CPU + EndGlobalSection + GlobalSection(ProjectConfigurationPlatforms) = postSolution + {5CB870E0-6DD9-17C1-D2D3-F2421C52624D}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {5CB870E0-6DD9-17C1-D2D3-F2421C52624D}.Debug|Any CPU.Build.0 = Debug|Any CPU + {5CB870E0-6DD9-17C1-D2D3-F2421C52624D}.Release|Any CPU.ActiveCfg = Release|Any CPU + {5CB870E0-6DD9-17C1-D2D3-F2421C52624D}.Release|Any CPU.Build.0 = Release|Any CPU + EndGlobalSection + GlobalSection(SolutionProperties) = preSolution + HideSolutionNode = FALSE + EndGlobalSection + GlobalSection(ExtensibilityGlobals) = postSolution + SolutionGuid = {5BAC1F21-3528-4D4A-BA6A-716F8EAA3048} + EndGlobalSection +EndGlobal diff --git a/jobs/Backend/Task.Tests/ExchangeRateUpdater.Tests/UnitTest1.cs b/jobs/Backend/Task.Tests/ExchangeRateUpdater.Tests/UnitTest1.cs new file mode 100644 index 000000000..cfd1bbaec --- /dev/null +++ b/jobs/Backend/Task.Tests/ExchangeRateUpdater.Tests/UnitTest1.cs @@ -0,0 +1,10 @@ +namespace ExchangeRateUpdater.Tests; + +public class UnitTest1 +{ + [Fact] + public void Test1() + { + + } +} diff --git a/jobs/Backend/Task/Cnb/ApiErrorResponse.cs b/jobs/Backend/Task/Cnb/ApiErrorResponse.cs index ff0a7165f..d14fa6860 100644 --- a/jobs/Backend/Task/Cnb/ApiErrorResponse.cs +++ b/jobs/Backend/Task/Cnb/ApiErrorResponse.cs @@ -1,13 +1,23 @@ using System; +using System.Text.Json.Serialization; namespace ExchangeRateUpdater.Cnb { public class ApiErrorResponse { + [JsonPropertyName("description")] public string Description { get; set; } + + [JsonPropertyName("endPoint")] public string EndPoint { get; set; } + + [JsonPropertyName("errorCode")] public string ErrorCode { get; set; } + + [JsonPropertyName("happenedAt")] public DateTime HappenedAt { get; set; } + + [JsonPropertyName("messageId")] public string MessageId { get; set; } } } diff --git a/jobs/Backend/Task/ExchangeRateUpdater.sln b/jobs/Backend/Task/ExchangeRateUpdater.sln index b015b0aa1..b9a607d3c 100644 --- a/jobs/Backend/Task/ExchangeRateUpdater.sln +++ b/jobs/Backend/Task/ExchangeRateUpdater.sln @@ -5,6 +5,8 @@ VisualStudioVersion = 17.14.36203.30 MinimumVisualStudioVersion = 10.0.40219.1 Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ExchangeRateUpdater", "ExchangeRateUpdater.csproj", "{7B2695D6-D24C-4460-A58E-A10F08550CE0}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ExchangeRateUpdater.Tests", "..\Task.Tests\ExchangeRateUpdater.Tests\ExchangeRateUpdater.Tests.csproj", "{CA1A5E14-667D-B0F5-355A-2135F658ECA6}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -15,6 +17,10 @@ Global {7B2695D6-D24C-4460-A58E-A10F08550CE0}.Debug|Any CPU.Build.0 = Debug|Any CPU {7B2695D6-D24C-4460-A58E-A10F08550CE0}.Release|Any CPU.ActiveCfg = Release|Any CPU {7B2695D6-D24C-4460-A58E-A10F08550CE0}.Release|Any CPU.Build.0 = Release|Any CPU + {CA1A5E14-667D-B0F5-355A-2135F658ECA6}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {CA1A5E14-667D-B0F5-355A-2135F658ECA6}.Debug|Any CPU.Build.0 = Debug|Any CPU + {CA1A5E14-667D-B0F5-355A-2135F658ECA6}.Release|Any CPU.ActiveCfg = Release|Any CPU + {CA1A5E14-667D-B0F5-355A-2135F658ECA6}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE From bc12668903c30598173e1fb12fd51124f997343a Mon Sep 17 00:00:00 2001 From: Anton Miroshkin Date: Thu, 26 Jun 2025 15:57:08 +0200 Subject: [PATCH 20/27] Configure tests for ExchangeRateProvider --- .../ExchangeRateProviderApiTests.cs | 110 --------- .../ExchangeRateProviderTests.cs | 230 ++++++++++++++++++ .../ExchangeRateUpdater.Tests/UnitTest1.cs | 10 - jobs/Backend/Task/Program.cs | 18 +- 4 files changed, 239 insertions(+), 129 deletions(-) delete mode 100644 jobs/Backend/Task.Tests/ExchangeRateUpdater.Tests/ExchangeRateProviderApiTests.cs create mode 100644 jobs/Backend/Task.Tests/ExchangeRateUpdater.Tests/ExchangeRateProviderTests.cs delete mode 100644 jobs/Backend/Task.Tests/ExchangeRateUpdater.Tests/UnitTest1.cs diff --git a/jobs/Backend/Task.Tests/ExchangeRateUpdater.Tests/ExchangeRateProviderApiTests.cs b/jobs/Backend/Task.Tests/ExchangeRateUpdater.Tests/ExchangeRateProviderApiTests.cs deleted file mode 100644 index 51227fc9f..000000000 --- a/jobs/Backend/Task.Tests/ExchangeRateUpdater.Tests/ExchangeRateProviderApiTests.cs +++ /dev/null @@ -1,110 +0,0 @@ -using ExchangeRateUpdater.Cnb; -using Moq; -using Moq.Protected; -using System.Net; -using System.Text.Json.Serialization; - -namespace ExchangeRateUpdater.Tests -{ - public class ExchangeRateProviderApiTests - { - [Fact] - public async Task GetApiDataAsync_ReturnsData_OnSuccess() - { - // Arrange - var goodJson = "{" + - "\"rates\":[{" + - "\"validFor\":\"2025-06-25\"," + - "\"order\":121," + - "\"country\":\"Australia\"," + - "\"currency\":\"dollar\"," + - "\"amount\":1," + - "\"currencyCode\":\"AUD\"," + - "\"rate\":13.872}]}"; - var handlerMock = new Mock(); - handlerMock.Protected() - .Setup>( - "SendAsync", - ItExpr.IsAny(), - ItExpr.IsAny() - ) - .ReturnsAsync(new HttpResponseMessage - { - StatusCode = HttpStatusCode.OK, - Content = new StringContent(goodJson) - }); - var httpClient = new HttpClient(handlerMock.Object); - var config = new Mock(); - config.SetupGet(c => c.Url).Returns("http://test/api"); - config.SetupGet(c => c.BaseCurrency).Returns("CZK"); - var provider = new TestProvider(config.Object, httpClient); - - // Act - var result = await provider.GetApiDataAsync("http://test/api"); - - // Assert - Assert.True(result.Success); - Assert.NotNull(result.Data); - Assert.NotEmpty(result.Data.Rates); - Assert.Equal("AUD", result.Data.Rates[0].CurrencyCode); - } - - [Fact] - public async Task GetApiDataAsync_ReturnsError_OnApiError() - { - // Arrange - var errorJson = "{" + - "\"description\":\"Internal error\"," + - "\"endPoint\":\"/api\"," + - "\"errorCode\":\"INTERNAL_SERVER_ERROR\"," + - "\"happenedAt\":\"2025-06-26T10:37:28.547Z\"," + - "\"messageId\":\"abc123\"}"; - var handlerMock = new Mock(); - handlerMock.Protected() - .Setup>( - "SendAsync", - ItExpr.IsAny(), - ItExpr.IsAny() - ) - .ReturnsAsync(new HttpResponseMessage - { - StatusCode = HttpStatusCode.InternalServerError, - Content = new StringContent(errorJson) - }); - var httpClient = new HttpClient(handlerMock.Object); - var config = new Mock(); - config.SetupGet(c => c.Url).Returns("http://test/api"); - config.SetupGet(c => c.BaseCurrency).Returns("CZK"); - var provider = new TestProvider(config.Object, httpClient); - - // Act - var result = await provider.GetApiDataAsync("http://test/api"); - - // Assert - Assert.False(result.Success); - Assert.NotNull(result.Error); - Assert.Equal("INTERNAL_SERVER_ERROR", result.Error.ErrorCode); - } - - // Minimal stub for testing - public class TestProvider : ExchangeRateProviderBase - { - public TestProvider(IExchangeRateProviderConfiguration config, HttpClient client) : base(config, client) { } - protected override Task FetchRawDataAsync() => throw new NotImplementedException(); - protected override IEnumerable MapToExchangeRates(T rawData, IEnumerable currencies) => throw new NotImplementedException(); - } - - public class TestApiResponse - { - [JsonPropertyName("rates")] - public List Rates { get; set; } = new List(); - } - public class TestRate - { - [JsonPropertyName("currencyCode")] - public string CurrencyCode { get; set; } = string.Empty; - [JsonPropertyName("rate")] - public double Rate { get; set; } - } - } -} diff --git a/jobs/Backend/Task.Tests/ExchangeRateUpdater.Tests/ExchangeRateProviderTests.cs b/jobs/Backend/Task.Tests/ExchangeRateUpdater.Tests/ExchangeRateProviderTests.cs new file mode 100644 index 000000000..54dbcdded --- /dev/null +++ b/jobs/Backend/Task.Tests/ExchangeRateUpdater.Tests/ExchangeRateProviderTests.cs @@ -0,0 +1,230 @@ +using ExchangeRateUpdater.Cnb; +using Microsoft.Extensions.Logging; +using Moq; +using Moq.Protected; +using System.Net; +using System.Text.Json.Serialization; + +namespace ExchangeRateUpdater.Tests +{ + public class ExchangeRateProviderTests : IDisposable + { + private readonly Mock> _loggerMock; + private readonly Mock _configMock; + private readonly Mock _httpHandlerMock; + private readonly HttpClient _httpClient; + + public ExchangeRateProviderTests() + { + _loggerMock = new Mock>(); + _configMock = new Mock(); + _httpHandlerMock = new Mock(); + _httpClient = new HttpClient(_httpHandlerMock.Object) + { + Timeout = TimeSpan.FromSeconds(1) + }; + + _configMock.SetupGet(c => c.Url).Returns("https://api.cnb.cz/cnbapi/exrates/daily"); + _configMock.SetupGet(c => c.BaseCurrency).Returns("CZK"); + } + + [Fact] + public async Task GetExchangeRatesAsync_WithValidCurrencies_ReturnsExchangeRates() + { + // Arrange + var responseJson = "{" + + "\"rates\":[" + + "{\"validFor\":\"2025-06-26\"," + + "\"order\":121," + + "\"country\":\"USA\"," + + "\"currency\":\"dollar\"," + + "\"amount\":1," + + "\"currencyCode\":\"USD\"," + + "\"rate\":23.50}," + + "{\"validFor\":\"2025-06-26\"," + + "\"order\":122," + + "\"country\":\"EMU\"," + + "\"currency\":\"euro\"," + + "\"amount\":1," + + "\"currencyCode\":\"EUR\"," + + "\"rate\":25.75}" + + "]}"; + + SetupHttpResponse(HttpStatusCode.OK, responseJson); + + var currencies = new List + { + new Currency("USD"), + new Currency("EUR") + }; + + var provider = new ExchangeRateProvider(_configMock.Object, _httpClient); + + // Act + var result = await provider.GetExchangeRatesAsync(currencies); + + // Assert + Assert.NotNull(result); + var resultList = result.ToList(); + Assert.Equal(2, resultList.Count); + + var usdRate = resultList.FirstOrDefault(r => r.SourceCurrency.Code == "USD"); + Assert.NotNull(usdRate); + Assert.Equal("USD", usdRate.SourceCurrency.Code); + Assert.Equal("CZK", usdRate.TargetCurrency.Code); + Assert.Equal(23.50m, usdRate.Value); + + var eurRate = resultList.FirstOrDefault(r => r.SourceCurrency.Code == "EUR"); + Assert.NotNull(eurRate); + Assert.Equal("EUR", eurRate.SourceCurrency.Code); + Assert.Equal("CZK", eurRate.TargetCurrency.Code); + Assert.Equal(25.75m, eurRate.Value); + } + + [Fact] + public async Task GetExchangeRatesAsync_WithEmptyCurrencies_ReturnsEmptyResult() + { + // Arrange + var responseJson = "{\"rates\":[]}"; + SetupHttpResponse(HttpStatusCode.OK, responseJson); + + var currencies = new List(); + var provider = new ExchangeRateProvider(_configMock.Object, _httpClient); + + // Act + var result = await provider.GetExchangeRatesAsync(currencies); + + // Assert + Assert.NotNull(result); + Assert.Empty(result); + } + + [Fact] + public async Task GetExchangeRatesAsync_WithNullCurrencies_ThrowsArgumentNullException() + { + // Arrange + var provider = new ExchangeRateProvider(_configMock.Object, _httpClient); + + // Act & Assert + await Assert.ThrowsAsync(() => + provider.GetExchangeRatesAsync(null)); + } + + [Fact] + public async Task GetExchangeRatesAsync_WithUnavailableCurrency_ReturnsOnlyAvailableRates() + { + // Arrange + var responseJson = "{" + + "\"rates\":[" + + "{\"validFor\":\"2025-06-26\"," + + "\"order\":121," + + "\"country\":\"USA\"," + + "\"currency\":\"dollar\"," + + "\"amount\":1," + + "\"currencyCode\":\"USD\"," + + "\"rate\":23.50}" + + "]}"; + + SetupHttpResponse(HttpStatusCode.OK, responseJson); + + var currencies = new List + { + new Currency("USD"), + new Currency("XYZ") // Non-existent currency + }; + + var provider = new ExchangeRateProvider(_configMock.Object, _httpClient); + + // Act + var result = await provider.GetExchangeRatesAsync(currencies); + + // Assert + Assert.NotNull(result); + var resultList = result.ToList(); + Assert.Single(resultList); + Assert.Equal("USD", resultList.First().SourceCurrency.Code); + } + + [Fact] + public async Task GetExchangeRatesAsync_WhenApiReturnsError_ThrowsException() + { + // Arrange + var errorJson = "{" + + "\"description\":\"API Error\"," + + "\"endPoint\":\"/cnbapi/exrates/daily\"," + + "\"errorCode\":\"INTERNAL_SERVER_ERROR\"," + + "\"happenedAt\":\"2025-06-26T10:37:28.547Z\"," + + "\"messageId\":\"abc123\"}"; + + SetupHttpResponse(HttpStatusCode.InternalServerError, errorJson); + + var currencies = new List { new Currency("USD") }; + var provider = new ExchangeRateProvider(_configMock.Object, _httpClient); + + // Act & Assert + await Assert.ThrowsAsync(() => + provider.GetExchangeRatesAsync(currencies)); + } + + [Fact] + public async Task GetExchangeRatesAsync_WhenNetworkFails_ThrowsException() + { + // Arrange + _httpHandlerMock.Protected() + .Setup>( + "SendAsync", + ItExpr.IsAny(), + ItExpr.IsAny() + ) + .ThrowsAsync(new HttpRequestException("Network error")) + .Verifiable(); + + var currencies = new List { new Currency("USD") }; + var provider = new ExchangeRateProvider(_configMock.Object, _httpClient); + + // Act & Assert + var exception = await Assert.ThrowsAsync(() => + provider.GetExchangeRatesAsync(currencies)); + + Assert.Contains("Network error", exception.Message); + } + + [Fact] + public async Task GetExchangeRatesAsync_WithInvalidJson_ThrowsException() + { + // Arrange + var invalidJson = "{ invalid json structure"; + SetupHttpResponse(HttpStatusCode.OK, invalidJson); + + var currencies = new List { new Currency("USD") }; + var provider = new ExchangeRateProvider(_configMock.Object, _httpClient); + + // Act & Assert + await Assert.ThrowsAsync(() => + provider.GetExchangeRatesAsync(currencies)); + } + + private void SetupHttpResponse(HttpStatusCode statusCode, string content) + { + var response = new HttpResponseMessage + { + StatusCode = statusCode, + Content = new StringContent(content, System.Text.Encoding.UTF8, "application/json") + }; + + _httpHandlerMock.Protected() + .Setup>( + "SendAsync", + ItExpr.IsAny(), + ItExpr.IsAny() + ) + .ReturnsAsync(response) + .Verifiable(); + } + + public void Dispose() + { + _httpClient?.Dispose(); + } + } +} diff --git a/jobs/Backend/Task.Tests/ExchangeRateUpdater.Tests/UnitTest1.cs b/jobs/Backend/Task.Tests/ExchangeRateUpdater.Tests/UnitTest1.cs deleted file mode 100644 index cfd1bbaec..000000000 --- a/jobs/Backend/Task.Tests/ExchangeRateUpdater.Tests/UnitTest1.cs +++ /dev/null @@ -1,10 +0,0 @@ -namespace ExchangeRateUpdater.Tests; - -public class UnitTest1 -{ - [Fact] - public void Test1() - { - - } -} diff --git a/jobs/Backend/Task/Program.cs b/jobs/Backend/Task/Program.cs index 6ddea830c..0acf13193 100644 --- a/jobs/Backend/Task/Program.cs +++ b/jobs/Backend/Task/Program.cs @@ -43,15 +43,15 @@ public static async Task Main(string[] args) } #region Another Source of Exchange Rates - //var provider2 = new ExchangeRateApiProvider(GetExchangeRateApiProviderConfiguration()); - //var rates2 = await provider2.GetExchangeRatesAsync(currencies); - - //// Print ExchangeRate-API results as returned - //Console.WriteLine($"Successfully retrieved {rates2.Count()} exchange rates from Exchange Rates API:"); - //foreach (var rate in rates2) - //{ - // Console.WriteLine(rate.ToString()); - //} + //var provider2 = new ExchangeRateApiProvider(GetExchangeRateApiProviderConfiguration()); + //var rates2 = await provider2.GetExchangeRatesAsync(currencies); + + //// Print ExchangeRate-API results as returned + //Console.WriteLine($"Successfully retrieved {rates2.Count()} exchange rates from Exchange Rates API:"); + //foreach (var rate in rates2) + //{ + // Console.WriteLine(rate.ToString()); + //} #endregion } From d335b640706f7010b1370c56a12f8111877aa49f Mon Sep 17 00:00:00 2001 From: Anton Miroshkin Date: Thu, 26 Jun 2025 16:16:24 +0200 Subject: [PATCH 21/27] Refactor exchange rate provider configuration and logging --- jobs/Backend/Task/Cnb/ExchangeRateProvider.cs | 1 - .../ExchangeRateApiProvider.cs | 2 +- jobs/Backend/Task/Program.cs | 20 ++++++++++--------- jobs/Backend/Task/ReadMe.md | 0 jobs/Backend/Task/appsettings.json | 4 ++-- 5 files changed, 14 insertions(+), 13 deletions(-) create mode 100644 jobs/Backend/Task/ReadMe.md diff --git a/jobs/Backend/Task/Cnb/ExchangeRateProvider.cs b/jobs/Backend/Task/Cnb/ExchangeRateProvider.cs index c196bb52b..7d13071b3 100644 --- a/jobs/Backend/Task/Cnb/ExchangeRateProvider.cs +++ b/jobs/Backend/Task/Cnb/ExchangeRateProvider.cs @@ -3,7 +3,6 @@ using System.Collections.Generic; using System.Linq; using System.Net.Http; -using System.Net.Http.Json; using System.Threading.Tasks; namespace ExchangeRateUpdater.Cnb diff --git a/jobs/Backend/Task/ExchangeRateApi/ExchangeRateApiProvider.cs b/jobs/Backend/Task/ExchangeRateApi/ExchangeRateApiProvider.cs index 420ad1b2c..ddf194cc0 100644 --- a/jobs/Backend/Task/ExchangeRateApi/ExchangeRateApiProvider.cs +++ b/jobs/Backend/Task/ExchangeRateApi/ExchangeRateApiProvider.cs @@ -11,7 +11,7 @@ namespace ExchangeRateUpdater.ExchangeRateApi { public class ExchangeRateApiProvider : ExchangeRateProviderBase { - private static readonly Logger Logger = LogManager.GetCurrentClassLogger(); + private static new readonly Logger Logger = LogManager.GetCurrentClassLogger(); public ExchangeRateApiProvider(IExchangeRateProviderConfiguration config) : base(config) { } protected override async Task FetchRawDataAsync() diff --git a/jobs/Backend/Task/Program.cs b/jobs/Backend/Task/Program.cs index 0acf13193..1a92383e6 100644 --- a/jobs/Backend/Task/Program.cs +++ b/jobs/Backend/Task/Program.cs @@ -28,7 +28,7 @@ public static async Task Main(string[] args) { // Ensure NLog is initialized and test logging var logger = LogManager.GetCurrentClassLogger(); - logger.Info("Application started. NLog is working."); + logger.Info("Application started."); try { @@ -43,12 +43,12 @@ public static async Task Main(string[] args) } #region Another Source of Exchange Rates - //var provider2 = new ExchangeRateApiProvider(GetExchangeRateApiProviderConfiguration()); - //var rates2 = await provider2.GetExchangeRatesAsync(currencies); + //var anotherProvider = new ExchangeRateApiProvider(GetExchangeRateApiProviderConfiguration()); + //var anotherRates = await anotherProvider.GetExchangeRatesAsync(currencies); //// Print ExchangeRate-API results as returned - //Console.WriteLine($"Successfully retrieved {rates2.Count()} exchange rates from Exchange Rates API:"); - //foreach (var rate in rates2) + //Console.WriteLine($"Successfully retrieved {anotherRates.Count()} exchange rates from Exchange Rates API:"); + //foreach (var rate in anotherRates) //{ // Console.WriteLine(rate.ToString()); //} @@ -57,6 +57,8 @@ public static async Task Main(string[] args) } catch (Exception e) { + logger.Error($"Could not retrieve exchange rates: '{e.Message}'."); + Console.WriteLine($"Could not retrieve exchange rates: '{e.Message}'."); if (e is TypeInitializationException && e.InnerException != null) { @@ -93,15 +95,15 @@ private static IExchangeRateProviderConfiguration GetExchangeRateApiProviderConf var rateProviderConfig = new ExchangeRateProviderConfiguration { - Url = configuration["ApiConfiguration:Url2"] + configuration["ApiConfiguration:BaseCurrency2"], - BaseCurrency = configuration["ApiConfiguration:BaseCurrency2"] + Url = configuration["ApiConfiguration:AnotherUrl"] + configuration["ApiConfiguration:AnotherBaseCurrency"], + BaseCurrency = configuration["ApiConfiguration:AnotherBaseCurrency"] }; if (string.IsNullOrWhiteSpace(rateProviderConfig.Url)) - throw new Exception("ApiConfiguration:Url2 is not set in appsettings.json"); + throw new Exception("ApiConfiguration:AnotherUrl is not set in appsettings.json"); if (string.IsNullOrWhiteSpace(rateProviderConfig.BaseCurrency)) - throw new Exception("ApiConfiguration:BaseCurrency2 is not set in appsettings.json"); + throw new Exception("ApiConfiguration:AnotherBaseCurrency is not set in appsettings.json"); return rateProviderConfig; } diff --git a/jobs/Backend/Task/ReadMe.md b/jobs/Backend/Task/ReadMe.md new file mode 100644 index 000000000..e69de29bb diff --git a/jobs/Backend/Task/appsettings.json b/jobs/Backend/Task/appsettings.json index cdf70c767..4f4375c44 100644 --- a/jobs/Backend/Task/appsettings.json +++ b/jobs/Backend/Task/appsettings.json @@ -2,7 +2,7 @@ "ApiConfiguration": { "Url": "https://api.cnb.cz/cnbapi/exrates/daily?lang=EN", "BaseCurrency": "CZK", - "Url2": "https://v6.exchangerate-api.com/v6/48b58d210307b06e68836c82/latest/", - "BaseCurrency2": "CZK" + "AnotherUrl": "https://v6.exchangerate-api.com/v6/48b58d210307b06e68836c82/latest/", + "AnotherBaseCurrency": "CZK" } } From 07a04a1532fa09105a371387b829bc271d7bdc98 Mon Sep 17 00:00:00 2001 From: Anton Miroshkin Date: Thu, 26 Jun 2025 16:56:53 +0200 Subject: [PATCH 22/27] Add Readme.md file --- jobs/Backend/Task/ReadMe.md | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/jobs/Backend/Task/ReadMe.md b/jobs/Backend/Task/ReadMe.md index e69de29bb..739ee9708 100644 --- a/jobs/Backend/Task/ReadMe.md +++ b/jobs/Backend/Task/ReadMe.md @@ -0,0 +1,9 @@ +## Improvement List + +1. **Add Dependency Injection** - Replace manual object creation with proper DI container and HttpClientFactory to avoid socket exhaustion +2. **Create Unit Tests** - Add comprehensive test coverage with mocking to ensure code reliability and prevent regressions +3. **Implement Custom Exceptions** - Replace generic Exception throws with specific domain exceptions for better error handling +4. **Add Retry Policies** - Implement automatic retry logic for API calls to handle temporary network failures +5. **Implement Input Validation** - Add proper validation for currency codes and exchange rate values before processing +6. **Add Caching** - Implement response caching to reduce API calls and improve performance since exchange rates don't change frequently + From f58e4b730ef6ee52f99fee940468c44d799db154 Mon Sep 17 00:00:00 2001 From: Anton Miroshkin Date: Thu, 26 Jun 2025 17:07:20 +0200 Subject: [PATCH 23/27] Correct Dependency Injection in improvement list --- jobs/Backend/Task/ReadMe.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/jobs/Backend/Task/ReadMe.md b/jobs/Backend/Task/ReadMe.md index 739ee9708..6c4cc98cc 100644 --- a/jobs/Backend/Task/ReadMe.md +++ b/jobs/Backend/Task/ReadMe.md @@ -1,6 +1,6 @@ ## Improvement List -1. **Add Dependency Injection** - Replace manual object creation with proper DI container and HttpClientFactory to avoid socket exhaustion +1. **Add Dependency Injection** - (If needed) e.g. for using in web projects. 2. **Create Unit Tests** - Add comprehensive test coverage with mocking to ensure code reliability and prevent regressions 3. **Implement Custom Exceptions** - Replace generic Exception throws with specific domain exceptions for better error handling 4. **Add Retry Policies** - Implement automatic retry logic for API calls to handle temporary network failures From 81cc21bdcb6e650637eab071b2f5bada64fe9d15 Mon Sep 17 00:00:00 2001 From: Anton Miroshkin Date: Thu, 26 Jun 2025 17:12:29 +0200 Subject: [PATCH 24/27] Add generic method improvement into Readme.md --- jobs/Backend/Task/ReadMe.md | 1 + 1 file changed, 1 insertion(+) diff --git a/jobs/Backend/Task/ReadMe.md b/jobs/Backend/Task/ReadMe.md index 6c4cc98cc..96c0f2234 100644 --- a/jobs/Backend/Task/ReadMe.md +++ b/jobs/Backend/Task/ReadMe.md @@ -6,4 +6,5 @@ 4. **Add Retry Policies** - Implement automatic retry logic for API calls to handle temporary network failures 5. **Implement Input Validation** - Add proper validation for currency codes and exchange rate values before processing 6. **Add Caching** - Implement response caching to reduce API calls and improve performance since exchange rates don't change frequently +7. **Add more generic methods/classes** Add more flexibility to reuse classes for other available open APIs From 545f11fe69603073928ea3adb61cb62d3c2e8c13 Mon Sep 17 00:00:00 2001 From: Anton Miroshkin Date: Fri, 27 Jun 2025 10:54:56 +0200 Subject: [PATCH 25/27] Readme - Add point regarding client generation --- jobs/Backend/Task/ReadMe.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/jobs/Backend/Task/ReadMe.md b/jobs/Backend/Task/ReadMe.md index 96c0f2234..638c6d6e7 100644 --- a/jobs/Backend/Task/ReadMe.md +++ b/jobs/Backend/Task/ReadMe.md @@ -6,5 +6,6 @@ 4. **Add Retry Policies** - Implement automatic retry logic for API calls to handle temporary network failures 5. **Implement Input Validation** - Add proper validation for currency codes and exchange rate values before processing 6. **Add Caching** - Implement response caching to reduce API calls and improve performance since exchange rates don't change frequently -7. **Add more generic methods/classes** Add more flexibility to reuse classes for other available open APIs +7. **Add more generic methods/classes** - Add more flexibility to reuse classes for other available open APIs +8. **Use HTTP Client Generation** - Consider using libraries like Refit or NSwag to auto-generate strongly-typed HTTP clients from API specifications From 6af4ed4b82cafdc73244711f1e9acf48a1f72967 Mon Sep 17 00:00:00 2001 From: Anton Miroshkin Date: Fri, 27 Jun 2025 11:11:26 +0200 Subject: [PATCH 26/27] Readme - Add introduction paragraph --- jobs/Backend/Task/ReadMe.md | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/jobs/Backend/Task/ReadMe.md b/jobs/Backend/Task/ReadMe.md index 638c6d6e7..59d142768 100644 --- a/jobs/Backend/Task/ReadMe.md +++ b/jobs/Backend/Task/ReadMe.md @@ -1,3 +1,7 @@ +# ExchangeRateUpdater Solution + +This was an awesome task that gave me a great chance to explore different ways to build an exchange rate system. I enjoyed working with various APIs, designing flexible code, and thinking about real-world needs like error handling and logging. It was rewarding to create a solution that balances simplicity with good functionality. + ## Improvement List 1. **Add Dependency Injection** - (If needed) e.g. for using in web projects. @@ -7,5 +11,5 @@ 5. **Implement Input Validation** - Add proper validation for currency codes and exchange rate values before processing 6. **Add Caching** - Implement response caching to reduce API calls and improve performance since exchange rates don't change frequently 7. **Add more generic methods/classes** - Add more flexibility to reuse classes for other available open APIs -8. **Use HTTP Client Generation** - Consider using libraries like Refit or NSwag to auto-generate strongly-typed HTTP clients from API specifications +8. **Use HTTP Client Generation** - Consider using libraries like NSwag to auto-generate strongly-typed HTTP clients from API specifications From e5d7c29df556c0be36799f27df0f628ef04be0c2 Mon Sep 17 00:00:00 2001 From: Anton Miroshkin Date: Fri, 27 Jun 2025 11:18:48 +0200 Subject: [PATCH 27/27] Add comment regarding another API --- jobs/Backend/Task/Program.cs | 1 + 1 file changed, 1 insertion(+) diff --git a/jobs/Backend/Task/Program.cs b/jobs/Backend/Task/Program.cs index 1a92383e6..44473e754 100644 --- a/jobs/Backend/Task/Program.cs +++ b/jobs/Backend/Task/Program.cs @@ -48,6 +48,7 @@ public static async Task Main(string[] args) //// Print ExchangeRate-API results as returned //Console.WriteLine($"Successfully retrieved {anotherRates.Count()} exchange rates from Exchange Rates API:"); + //Console.WriteLine($"The Exchange Rates API returns rates values if a different manner. To Compare rates it is needed to calculate it 1/rate"); //foreach (var rate in anotherRates) //{ // Console.WriteLine(rate.ToString());