diff --git a/.gitignore b/.gitignore index fd3586545..d05410c5c 100644 --- a/.gitignore +++ b/.gitignore @@ -21,3 +21,5 @@ node_modules bower_components npm-debug.log +*.vsidx +/jobs/Backend/Task/.vs/ExchangeRates/v17/TestStore/0 diff --git a/jobs/Backend/Task/ExchangeRateProvider.cs b/jobs/Backend/Task/ExchangeRateProvider.cs deleted file mode 100644 index 6f82a97fb..000000000 --- a/jobs/Backend/Task/ExchangeRateProvider.cs +++ /dev/null @@ -1,19 +0,0 @@ -using System.Collections.Generic; -using System.Linq; - -namespace ExchangeRateUpdater -{ - public class ExchangeRateProvider - { - /// - /// Should return exchange rates among the specified currencies that are defined by the source. But only those defined - /// by the source, do not return calculated exchange rates. E.g. if the source contains "CZK/USD" but not "USD/CZK", - /// do not return exchange rate "USD/CZK" with value calculated as 1 / "CZK/USD". If the source does not provide - /// some of the currencies, ignore them. - /// - public IEnumerable GetExchangeRates(IEnumerable currencies) - { - return Enumerable.Empty(); - } - } -} diff --git a/jobs/Backend/Task/ExchangeRateUpdater.csproj b/jobs/Backend/Task/ExchangeRateUpdater.csproj deleted file mode 100644 index 2fc654a12..000000000 --- a/jobs/Backend/Task/ExchangeRateUpdater.csproj +++ /dev/null @@ -1,8 +0,0 @@ - - - - Exe - net6.0 - - - \ No newline at end of file diff --git a/jobs/Backend/Task/ExchangeRateUpdater.sln b/jobs/Backend/Task/ExchangeRateUpdater.sln deleted file mode 100644 index 89be84daf..000000000 --- a/jobs/Backend/Task/ExchangeRateUpdater.sln +++ /dev/null @@ -1,22 +0,0 @@ - -Microsoft Visual Studio Solution File, Format Version 12.00 -# Visual Studio 14 -VisualStudioVersion = 14.0.25123.0 -MinimumVisualStudioVersion = 10.0.40219.1 -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ExchangeRateUpdater", "ExchangeRateUpdater.csproj", "{7B2695D6-D24C-4460-A58E-A10F08550CE0}" -EndProject -Global - GlobalSection(SolutionConfigurationPlatforms) = preSolution - Debug|Any CPU = Debug|Any CPU - Release|Any CPU = Release|Any CPU - EndGlobalSection - GlobalSection(ProjectConfigurationPlatforms) = postSolution - {7B2695D6-D24C-4460-A58E-A10F08550CE0}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {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 - EndGlobalSection - GlobalSection(SolutionProperties) = preSolution - HideSolutionNode = FALSE - EndGlobalSection -EndGlobal diff --git a/jobs/Backend/Task/ExchangeRates.sln b/jobs/Backend/Task/ExchangeRates.sln new file mode 100644 index 000000000..395ea2ead --- /dev/null +++ b/jobs/Backend/Task/ExchangeRates.sln @@ -0,0 +1,28 @@ + +Microsoft Visual Studio Solution File, Format Version 12.00 +# Visual Studio Version 17 +VisualStudioVersion = 17.0.31903.59 +MinimumVisualStudioVersion = 10.0.40219.1 +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ExchangesRatesApp", "src\ExchangesRatesApp.csproj", "{0C2EC045-59FC-4AE7-B39E-C3FD037096A6}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ExchangeRatesTests", "test\ExchangeRatesTests.csproj", "{CE380A25-4484-41B4-BEC0-FC3292A46DF7}" +EndProject +Global + GlobalSection(SolutionConfigurationPlatforms) = preSolution + Debug|Any CPU = Debug|Any CPU + Release|Any CPU = Release|Any CPU + EndGlobalSection + GlobalSection(SolutionProperties) = preSolution + HideSolutionNode = FALSE + EndGlobalSection + GlobalSection(ProjectConfigurationPlatforms) = postSolution + {0C2EC045-59FC-4AE7-B39E-C3FD037096A6}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {0C2EC045-59FC-4AE7-B39E-C3FD037096A6}.Debug|Any CPU.Build.0 = Debug|Any CPU + {0C2EC045-59FC-4AE7-B39E-C3FD037096A6}.Release|Any CPU.ActiveCfg = Release|Any CPU + {0C2EC045-59FC-4AE7-B39E-C3FD037096A6}.Release|Any CPU.Build.0 = Release|Any CPU + {CE380A25-4484-41B4-BEC0-FC3292A46DF7}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {CE380A25-4484-41B4-BEC0-FC3292A46DF7}.Debug|Any CPU.Build.0 = Debug|Any CPU + {CE380A25-4484-41B4-BEC0-FC3292A46DF7}.Release|Any CPU.ActiveCfg = Release|Any CPU + {CE380A25-4484-41B4-BEC0-FC3292A46DF7}.Release|Any CPU.Build.0 = Release|Any CPU + EndGlobalSection +EndGlobal diff --git a/jobs/Backend/Task/Program.cs b/jobs/Backend/Task/Program.cs deleted file mode 100644 index 379a69b1f..000000000 --- a/jobs/Backend/Task/Program.cs +++ /dev/null @@ -1,43 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Linq; - -namespace ExchangeRateUpdater -{ - 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") - }; - - public static void Main(string[] args) - { - try - { - var provider = new ExchangeRateProvider(); - var rates = provider.GetExchangeRates(currencies); - - Console.WriteLine($"Successfully retrieved {rates.Count()} exchange rates:"); - foreach (var rate in rates) - { - Console.WriteLine(rate.ToString()); - } - } - catch (Exception e) - { - Console.WriteLine($"Could not retrieve exchange rates: '{e.Message}'."); - } - - Console.ReadLine(); - } - } -} diff --git a/jobs/Backend/Task/src/ExchangesRatesApp.csproj b/jobs/Backend/Task/src/ExchangesRatesApp.csproj new file mode 100644 index 000000000..1ee008290 --- /dev/null +++ b/jobs/Backend/Task/src/ExchangesRatesApp.csproj @@ -0,0 +1,24 @@ + + + + Exe + net6.0 + enable + enable + + + + + + + + + + + + + Always + + + + diff --git a/jobs/Backend/Task/src/Models/CnbRateDailyResponse.cs b/jobs/Backend/Task/src/Models/CnbRateDailyResponse.cs new file mode 100644 index 000000000..b48ca4671 --- /dev/null +++ b/jobs/Backend/Task/src/Models/CnbRateDailyResponse.cs @@ -0,0 +1,18 @@ +namespace ExchangeRateUpdater.Models +{ + public class CnbRateDailyResponse + { + public IEnumerable Rates { get; set; } + } + + public class CnbRate + { + public string ValidFor { get; set; } + public int Order { get; set; } + public string Country { get; set; } + public string Currency { get; set; } + public int Amount { get; set; } + public string CurrencyCode { get; set; } + public decimal Rate { get; set; } + } +} \ No newline at end of file diff --git a/jobs/Backend/Task/Currency.cs b/jobs/Backend/Task/src/Models/Currency.cs similarity index 89% rename from jobs/Backend/Task/Currency.cs rename to jobs/Backend/Task/src/Models/Currency.cs index f375776f2..8336d740e 100644 --- a/jobs/Backend/Task/Currency.cs +++ b/jobs/Backend/Task/src/Models/Currency.cs @@ -1,4 +1,4 @@ -namespace ExchangeRateUpdater +namespace ExchangeRateUpdater.Models { public class Currency { diff --git a/jobs/Backend/Task/ExchangeRate.cs b/jobs/Backend/Task/src/Models/ExchangeRate.cs similarity index 93% rename from jobs/Backend/Task/ExchangeRate.cs rename to jobs/Backend/Task/src/Models/ExchangeRate.cs index 58c5bb10e..2133586d4 100644 --- a/jobs/Backend/Task/ExchangeRate.cs +++ b/jobs/Backend/Task/src/Models/ExchangeRate.cs @@ -1,4 +1,4 @@ -namespace ExchangeRateUpdater +namespace ExchangeRateUpdater.Models { public class ExchangeRate { diff --git a/jobs/Backend/Task/src/Options/CnbApiOptions.cs b/jobs/Backend/Task/src/Options/CnbApiOptions.cs new file mode 100644 index 000000000..18fed5a4b --- /dev/null +++ b/jobs/Backend/Task/src/Options/CnbApiOptions.cs @@ -0,0 +1,10 @@ +namespace ExchangeRateUpdater.Options +{ + public class CnbApiOptions + { + public const string OptionsName = "CnbApi"; + public const string ClientName = "CnbClient"; + + public string BaseAddress { get; set; } + } +} diff --git a/jobs/Backend/Task/src/Options/CurrenciesOptions.cs b/jobs/Backend/Task/src/Options/CurrenciesOptions.cs new file mode 100644 index 000000000..ebdc40c8b --- /dev/null +++ b/jobs/Backend/Task/src/Options/CurrenciesOptions.cs @@ -0,0 +1,9 @@ +namespace ExchangeRateUpdater.Options +{ + public class CurrenciesOptions + { + public const string OptionsName = "CurrenciesOptions"; + + public IEnumerable Currencies { get; set; } + } +} diff --git a/jobs/Backend/Task/src/Program.cs b/jobs/Backend/Task/src/Program.cs new file mode 100644 index 000000000..f5059187b --- /dev/null +++ b/jobs/Backend/Task/src/Program.cs @@ -0,0 +1,64 @@ +using ExchangeRateUpdater.Models; +using ExchangeRateUpdater.Options; +using ExchangeRateUpdater.Services; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.DependencyInjection.Extensions; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Http; + +namespace ExchangeRateUpdater +{ + public static class Program + { + public static void Main(string[] args) + { + var host = CreateHostBuilder(args).Build(); + + var rates = host.Services.GetService().GetExchangeRates(System.Threading.CancellationToken.None).GetAwaiter().GetResult(); + + foreach (var rate in rates) + { + Console.WriteLine(rate.ToString()); + } + } + + private static IHostBuilder CreateHostBuilder(string[] args) + { + var _configuration = new ConfigurationBuilder() + .AddJsonFile("local.settings.json", + optional: true, + reloadOnChange: true) + .Build(); + + var hostBuilder = Host.CreateDefaultBuilder(args) + .ConfigureAppConfiguration((context, builder) => + { + builder.SetBasePath(Directory.GetCurrentDirectory()); + }) + .ConfigureServices((context, services) => + { + // Remove httpClient default Console.WriteLine behaviour + services.RemoveAll(); + + // Options + services.Configure(_configuration.GetSection(CurrenciesOptions.OptionsName)); + + var cnbApiOptions = new CnbApiOptions(); + _configuration.GetSection(CnbApiOptions.OptionsName).Bind(cnbApiOptions); + + // Services + services.AddSingleton(); + services.AddSingleton(); + + // CnbHttpClient + services.AddHttpClient(CnbApiOptions.ClientName, x => + { + x.BaseAddress = new Uri(cnbApiOptions.BaseAddress); + }); + }); + + return hostBuilder; + } + } +} diff --git a/jobs/Backend/Task/src/Services/CnbApiService.cs b/jobs/Backend/Task/src/Services/CnbApiService.cs new file mode 100644 index 000000000..e05c9e16f --- /dev/null +++ b/jobs/Backend/Task/src/Services/CnbApiService.cs @@ -0,0 +1,46 @@ +using ExchangeRateUpdater.Models; +using ExchangeRateUpdater.Options; +using Newtonsoft.Json; +using Microsoft.Extensions.Options; + +namespace ExchangeRateUpdater.Services +{ + public class CnbApiService : ICnbApiService + { + private readonly string GET_RATES_ENDPOINT = "/cnbapi/exrates/daily"; + + private readonly HttpClient _client; + + public CnbApiService(IHttpClientFactory clientFactory) + { + _client = clientFactory.CreateClient(CnbApiOptions.ClientName); + } + + public async Task GetExchangeRate(CancellationToken cancellationToken) + { + cancellationToken.ThrowIfCancellationRequested(); + + var httpRequest = new HttpRequestMessage + { + Method = HttpMethod.Get, + RequestUri = new Uri(GET_RATES_ENDPOINT, UriKind.Relative) + }; + + var httpResponse = await _client.SendAsync(httpRequest, cancellationToken); + + if (!httpResponse.IsSuccessStatusCode) + { + return new CnbRateDailyResponse + { + Rates = new List() + }; + } + + var response = await httpResponse.Content.ReadAsStringAsync(cancellationToken); + + var cnbRates = JsonConvert.DeserializeObject(response); + + return cnbRates; + } + } +} \ No newline at end of file diff --git a/jobs/Backend/Task/src/Services/ExchangeRateProvider.cs b/jobs/Backend/Task/src/Services/ExchangeRateProvider.cs new file mode 100644 index 000000000..0a0e4aa3c --- /dev/null +++ b/jobs/Backend/Task/src/Services/ExchangeRateProvider.cs @@ -0,0 +1,58 @@ +using ExchangeRateUpdater.Models; +using ExchangeRateUpdater.Options; +using Microsoft.Extensions.Options; + +namespace ExchangeRateUpdater.Services +{ + public class ExchangeRateProvider : IExchangeRateProvider + { + /// + /// 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. + /// + + private readonly ICnbApiService _cnbApiService; + private readonly CurrenciesOptions _currenciesOptions; + + public ExchangeRateProvider( + ICnbApiService cnbApiService, + IOptionsMonitor currenciesOptions + ) + { + _cnbApiService = cnbApiService; + _currenciesOptions = currenciesOptions.CurrentValue; + } + + public async Task> GetExchangeRates(CancellationToken cancellationToken) + { + cancellationToken.ThrowIfCancellationRequested(); + + // GET CNB RATES + var cnbRates = await _cnbApiService.GetExchangeRate(cancellationToken); + + var result = new List(); + + foreach (var currencyCode in _currenciesOptions.Currencies) + { + var currencyTarget = new Currency(currencyCode); + + var rate = cnbRates.Rates.FirstOrDefault(x => x.CurrencyCode.Equals(currencyTarget.Code)); + + if (rate == null) + { + continue; + } + + var value = rate.Rate / rate.Amount; + + var exchangeRate = new ExchangeRate(new Currency("CZK"), currencyTarget, value); + + result.Add(exchangeRate); + } + + return result; + } + } +} diff --git a/jobs/Backend/Task/src/Services/ICnbApiService.cs b/jobs/Backend/Task/src/Services/ICnbApiService.cs new file mode 100644 index 000000000..56c76e559 --- /dev/null +++ b/jobs/Backend/Task/src/Services/ICnbApiService.cs @@ -0,0 +1,9 @@ +using ExchangeRateUpdater.Models; + +namespace ExchangeRateUpdater.Services +{ + public interface ICnbApiService + { + Task GetExchangeRate(CancellationToken cancellationToken); + } +} \ No newline at end of file diff --git a/jobs/Backend/Task/src/Services/IExchangeRateProvider.cs b/jobs/Backend/Task/src/Services/IExchangeRateProvider.cs new file mode 100644 index 000000000..741e7309a --- /dev/null +++ b/jobs/Backend/Task/src/Services/IExchangeRateProvider.cs @@ -0,0 +1,9 @@ +using ExchangeRateUpdater.Models; + +namespace ExchangeRateUpdater.Services +{ + public interface IExchangeRateProvider + { + Task> GetExchangeRates(CancellationToken cancellationToken); + } +} \ No newline at end of file diff --git a/jobs/Backend/Task/src/local.settings.json b/jobs/Backend/Task/src/local.settings.json new file mode 100644 index 000000000..4fbd502b5 --- /dev/null +++ b/jobs/Backend/Task/src/local.settings.json @@ -0,0 +1,18 @@ +{ + "CnbApi": { + "BaseAddress": "https://api.cnb.cz" + }, + "CurrenciesOptions": { + "Currencies": [ + "USD", + "EUR", + "CZK", + "JPY", + "KES", + "RUB", + "THB", + "TRY", + "XYZ" + ] + } +} \ No newline at end of file diff --git a/jobs/Backend/Task/test/ExchangeRatesTests.csproj b/jobs/Backend/Task/test/ExchangeRatesTests.csproj new file mode 100644 index 000000000..2e98757cb --- /dev/null +++ b/jobs/Backend/Task/test/ExchangeRatesTests.csproj @@ -0,0 +1,29 @@ + + + + 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/test/Services/ExchangeRateProviderTests.cs b/jobs/Backend/Task/test/Services/ExchangeRateProviderTests.cs new file mode 100644 index 000000000..5e4fbc8e7 --- /dev/null +++ b/jobs/Backend/Task/test/Services/ExchangeRateProviderTests.cs @@ -0,0 +1,93 @@ +using ExchangeRateUpdater.Models; +using ExchangeRateUpdater.Options; +using ExchangeRateUpdater.Services; +using Microsoft.Extensions.Options; +using Moq; +using Xunit; + +namespace ExchangeRateUpdaterTest.Services; + +public class ExchangeRateUpdaterTests +{ + private readonly Mock _cnbApiServiceMock; + private readonly Mock> _currenciesOptionsMock; + private readonly ExchangeRateProvider _exchangeRateProvider; + + public ExchangeRateUpdaterTests() + { + _cnbApiServiceMock = new Mock(); + + _currenciesOptionsMock = new Mock>(); + _currenciesOptionsMock + .Setup(x => x.CurrentValue) + .Returns(new CurrenciesOptions + { + Currencies = new List + { + "USD", + "EUR" + } + }); + + _exchangeRateProvider = new ExchangeRateProvider(_cnbApiServiceMock.Object, _currenciesOptionsMock.Object); + } + + + [Fact] + public async Task GetExchangeRates_ShouldReturnExchangeRates_WhenCurrenciesAreProvided() + { + // Arrange + var cancellationToken = CancellationToken.None; + + var cnbRates = new CnbRateDailyResponse + { + Rates = new List + { + new CnbRate { CurrencyCode = "USD", Rate = 22.5m, Amount = 1 }, + new CnbRate { CurrencyCode = "EUR", Rate = 25.5m, Amount = 1 } + } + }; + + _cnbApiServiceMock + .Setup(service => service.GetExchangeRate(cancellationToken)) + .ReturnsAsync(cnbRates); + + // Act + var result = await _exchangeRateProvider.GetExchangeRates(cancellationToken); + + // Assert + var exchangeRates = result.ToList(); + Assert.Equal(2, exchangeRates.Count); + Assert.Contains(exchangeRates, rate => rate.TargetCurrency.Code == "USD" && rate.Value == 22.5m); + Assert.Contains(exchangeRates, rate => rate.TargetCurrency.Code == "EUR" && rate.Value == 25.5m); + } + + + [Fact] + public async Task GetExchangeRates_ShouldIgnoreUndefinedCurrencies() + { + // Arrange + var cancellationToken = CancellationToken.None; + + var cnbRates = new CnbRateDailyResponse + { + Rates = new List + { + new CnbRate { CurrencyCode = "USD", Rate = 22.5m, Amount = 1 } + } + }; + + _cnbApiServiceMock + .Setup(service => service.GetExchangeRate(cancellationToken)) + .ReturnsAsync(cnbRates); + + // Act + var result = await _exchangeRateProvider.GetExchangeRates(cancellationToken); + + // Assert + var exchangeRates = result.ToList(); + Assert.Single(exchangeRates); + Assert.Contains(exchangeRates, rate => rate.TargetCurrency.Code == "USD" && rate.Value == 22.5m); + Assert.DoesNotContain(exchangeRates, rate => rate.TargetCurrency.Code == "EUR"); + } +} \ No newline at end of file