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 index 89be84daf..21b5c4eea 100644 --- a/jobs/Backend/Task/ExchangeRateUpdater.sln +++ b/jobs/Backend/Task/ExchangeRateUpdater.sln @@ -1,9 +1,15 @@  Microsoft Visual Studio Solution File, Format Version 12.00 -# Visual Studio 14 -VisualStudioVersion = 14.0.25123.0 +# Visual Studio Version 17 +VisualStudioVersion = 17.11.35327.3 MinimumVisualStudioVersion = 10.0.40219.1 -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ExchangeRateUpdater", "ExchangeRateUpdater.csproj", "{7B2695D6-D24C-4460-A58E-A10F08550CE0}" +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "src", "src", "{07118310-E948-4327-B32E-7CC4C834DDFE}" +EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "test", "test", "{A7FF1704-D668-4FEE-9A67-21FA34358614}" +EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "ExchangeRateUpdater.Tests", "test\ExchangeRateUpdater.Tests\ExchangeRateUpdater.Tests.csproj", "{C6753F8E-93E5-45FD-AACF-E8E920142A6F}" +EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "ExchangeRateUpdater", "src\ExchangeRateUpdater\ExchangeRateUpdater.csproj", "{A87805F0-0EB0-44B3-85DA-C70DD72AF0F2}" EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution @@ -11,12 +17,23 @@ Global 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 + {C6753F8E-93E5-45FD-AACF-E8E920142A6F}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {C6753F8E-93E5-45FD-AACF-E8E920142A6F}.Debug|Any CPU.Build.0 = Debug|Any CPU + {C6753F8E-93E5-45FD-AACF-E8E920142A6F}.Release|Any CPU.ActiveCfg = Release|Any CPU + {C6753F8E-93E5-45FD-AACF-E8E920142A6F}.Release|Any CPU.Build.0 = Release|Any CPU + {A87805F0-0EB0-44B3-85DA-C70DD72AF0F2}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {A87805F0-0EB0-44B3-85DA-C70DD72AF0F2}.Debug|Any CPU.Build.0 = Debug|Any CPU + {A87805F0-0EB0-44B3-85DA-C70DD72AF0F2}.Release|Any CPU.ActiveCfg = Release|Any CPU + {A87805F0-0EB0-44B3-85DA-C70DD72AF0F2}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE EndGlobalSection + GlobalSection(NestedProjects) = preSolution + {C6753F8E-93E5-45FD-AACF-E8E920142A6F} = {A7FF1704-D668-4FEE-9A67-21FA34358614} + {A87805F0-0EB0-44B3-85DA-C70DD72AF0F2} = {07118310-E948-4327-B32E-7CC4C834DDFE} + EndGlobalSection + GlobalSection(ExtensibilityGlobals) = postSolution + SolutionGuid = {56D2B0E7-2A0B-4082-A8C4-6BFDB5EDEC32} + 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/README.md b/jobs/Backend/Task/README.md new file mode 100644 index 000000000..6843244bb --- /dev/null +++ b/jobs/Backend/Task/README.md @@ -0,0 +1,34 @@ +# ExchangeRateUpdater + +## Running locally +You can run the ExchangeRateUpdater + 1. using an IDE of your choice (Visual Studio, Visual Studio Code, Rider) + 2. or if you want to use the provided Docker file you can use these commands in the folder with the dockerfile : + `docker build -t exchangerateupdater .` + `docker run --rm exchangerateupdater` + You need to have Docker installed and running for this to work + +## How it works: +The list of currencies we are requesting exchange rates to be retrieved for is in `appsettings.json` file and it is loaded from there at runtime and injected into the AppService class. +We are retrieving the exchange rates for the day from the CNB api endpoint: `https://api.cnb.cz/cnbapi/exrates/daily`. Then we display the rates for all the valid currency codes requested that were present in the CNB source. + +# Notes +Regarding the structure of the solution: I usually use Clean Architecture model for my solutions but since this is a very simple console app I chose to structure my project using basic folders named suggestively. + +Keeping in mind that this was supposed to be implemented as a service that should be production ready, I chose to take advantage of the .NET Core 6 (I have not updated the project to the latest version) DI mechanism +so I moved the already provided code from the Program.cs class into the AppService class which is now the entry point for the app and it is registered and starts as an IHostedService. + +For retrieving the exchange rates I used the CNB api endpoint: `https://api.cnb.cz/cnbapi/exrates/daily` as it seemed most appropriate. I configured the http client to have a retry mechanism with exponential backoff for +transient issues and the number of retries can be configured in the appsettings. When implementing this, since the CNB documentation for their APIs doesn't mention this, I tested to see if the endpoint has some form of rate limiting. +I hit the endpoint a few thousand times in 2 minutes and I was not limited. Therefore I did not implement a circuit breaker for this. + +Since this solution is for providing the exchange rates between different currencies and CZK I decided to keep this simple and not overengineer it at this time but should we want to extend this to support other conversions +we can make the ExchangeRateMapper and the ExchangeRateProvider more generic to accept the target currencies dinamically and make these passable from appsettings as well. For now I tried to follow the KISS and YAGNI principles +and keep the solution simpler. + +Things that can be improved if this service was to run in real world: +1. Since the exchange rates are not changing very often we can implement a caching mechanism to store the rates and improve performance. The number of rates returned by the CNB api is relatively small so we could +even use an in memory cache and if the number increases we can use something else like Redis +2. Add some metrics for better observability (for example the calls duration to the external APIs etc) +3. Have the strings representing the log messages centralised in a static class so we would change them in one place if needed but for now I wanted to keep things simple. +4. Better unit test coverage, integration testing if there are other services in the pipeline, mutation testing, end-to-end testing. diff --git a/jobs/Backend/Task/dockerfile b/jobs/Backend/Task/dockerfile new file mode 100644 index 000000000..3f172a760 --- /dev/null +++ b/jobs/Backend/Task/dockerfile @@ -0,0 +1,19 @@ +FROM mcr.microsoft.com/dotnet/sdk:6.0-alpine AS build +WORKDIR /app + +ARG BUILDCONFIG=Release +ARG VERSION=1.0.0 + +COPY ["./src/ExchangeRateUpdater/ExchangeRateUpdater.csproj", "./src/ExchangeRateUpdater/"] +COPY ["nuget.config", "./src/"] + +RUN dotnet restore "./src/ExchangeRateUpdater/ExchangeRateUpdater.csproj" --configfile ./src/nuget.config + +COPY . . +WORKDIR "./src/ExchangeRateUpdater/" +RUN dotnet publish -c ${BUILDCONFIG} -o out /p:Version=${VERSION} + +FROM mcr.microsoft.com/dotnet/runtime:6.0-alpine AS runtime +WORKDIR /app +COPY --from=build /app/src/ExchangeRateUpdater/out ./ +ENTRYPOINT ["dotnet", "ExchangeRateUpdater.dll"] \ No newline at end of file diff --git a/jobs/Backend/Task/nuget.config b/jobs/Backend/Task/nuget.config new file mode 100644 index 000000000..2913b6f23 --- /dev/null +++ b/jobs/Backend/Task/nuget.config @@ -0,0 +1,7 @@ + + + + + + + \ No newline at end of file diff --git a/jobs/Backend/Task/src/ExchangeRateUpdater/AppService.cs b/jobs/Backend/Task/src/ExchangeRateUpdater/AppService.cs new file mode 100644 index 000000000..e295ef142 --- /dev/null +++ b/jobs/Backend/Task/src/ExchangeRateUpdater/AppService.cs @@ -0,0 +1,68 @@ +using ExchangeRateUpdater.Models; +using ExchangeRateUpdater.Services; +using ExchangeRateUpdater.Settings; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; + +namespace ExchangeRateUpdater +{ + public class AppService : IHostedService + { + private readonly ILogger _logger; + private readonly IExchangeRateProvider _exchangeRateProvider; + private readonly IEnumerable _supportedCurrencyCodes; + + public AppService(IExchangeRateProvider exchangeRateProvider, IOptions currencyOptions, ILogger logger) + { + _exchangeRateProvider = exchangeRateProvider; + _supportedCurrencyCodes = currencyOptions.Value.SupportedCurrencies; + _logger = logger; + } + + public async Task StartAsync(CancellationToken cancellationToken) + { + _logger.LogInformation("Starting application."); + await DisplayExchangeRatesAsync(cancellationToken); + } + + public async Task StopAsync(CancellationToken cancellationToken) + { + _logger.LogInformation("Stopping application."); + } + + public async Task DisplayExchangeRatesAsync(CancellationToken cancellationToken) + { + try + { + var currencies = _supportedCurrencyCodes.Select(c => new Currency(c)); + + var rates = await _exchangeRateProvider.GetExchangeRatesAsync(currencies, date: DateTime.UtcNow, cancellationToken: cancellationToken); + + if (!rates.Any()) + { + Console.WriteLine("There are no rates to display"); + return; + } + + 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/ExchangeRateUpdater/DependencyInjection/ServiceCollectionExtensions.cs b/jobs/Backend/Task/src/ExchangeRateUpdater/DependencyInjection/ServiceCollectionExtensions.cs new file mode 100644 index 000000000..c3d3360b4 --- /dev/null +++ b/jobs/Backend/Task/src/ExchangeRateUpdater/DependencyInjection/ServiceCollectionExtensions.cs @@ -0,0 +1,24 @@ +using ExchangeRateUpdater.Settings; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using System; +using Polly; + +namespace ExchangeRateUpdater.DependencyInjection +{ + public static class ServiceCollectionExtensions + { + public static void AddApiClients(this IServiceCollection services, IConfiguration configuration, string settingsSectionName, string clientName) + { + var random = new Random(); + var apiClientSettings = new ApiClientSettings(); + configuration.GetSection(settingsSectionName).Bind(apiClientSettings); + + services.AddHttpClient(clientName, + client => { client.BaseAddress = new Uri(apiClientSettings.BaseAddress); }) + .AddTransientHttpErrorPolicy(x => + x.WaitAndRetryAsync(apiClientSettings.RetrySettings.MaxRetries, + times => TimeSpan.FromSeconds(Math.Pow(2, times)) + TimeSpan.FromMilliseconds(random.Next(0, 1000)))); + } + } +} diff --git a/jobs/Backend/Task/src/ExchangeRateUpdater/ExchangeRateUpdater.csproj b/jobs/Backend/Task/src/ExchangeRateUpdater/ExchangeRateUpdater.csproj new file mode 100644 index 000000000..a5fcadc83 --- /dev/null +++ b/jobs/Backend/Task/src/ExchangeRateUpdater/ExchangeRateUpdater.csproj @@ -0,0 +1,21 @@ + + + + Exe + net6.0 + + + + + + + + + + + + Always + + + + \ No newline at end of file diff --git a/jobs/Backend/Task/src/ExchangeRateUpdater/Mappers/ExchangeRateMapper.cs b/jobs/Backend/Task/src/ExchangeRateUpdater/Mappers/ExchangeRateMapper.cs new file mode 100644 index 000000000..124dc131d --- /dev/null +++ b/jobs/Backend/Task/src/ExchangeRateUpdater/Mappers/ExchangeRateMapper.cs @@ -0,0 +1,15 @@ +using ExchangeRateUpdater.Models; +using System.Collections.Generic; +using System.Linq; + +namespace ExchangeRateUpdater.Mappers +{ + public static class ExchangeRateMapper + { + public static IEnumerable MapApiFxRatesToExchangeRates(IEnumerable apiFxRates) + { + return apiFxRates?.Select(x => new ExchangeRate(new Currency(x.CurrencyCode), new Currency("CZK"), (decimal)x.Rate)) + ?? Enumerable.Empty(); + } + } +} diff --git a/jobs/Backend/Task/Currency.cs b/jobs/Backend/Task/src/ExchangeRateUpdater/Models/Currency.cs similarity index 89% rename from jobs/Backend/Task/Currency.cs rename to jobs/Backend/Task/src/ExchangeRateUpdater/Models/Currency.cs index f375776f2..8336d740e 100644 --- a/jobs/Backend/Task/Currency.cs +++ b/jobs/Backend/Task/src/ExchangeRateUpdater/Models/Currency.cs @@ -1,4 +1,4 @@ -namespace ExchangeRateUpdater +namespace ExchangeRateUpdater.Models { public class Currency { diff --git a/jobs/Backend/Task/src/ExchangeRateUpdater/Models/ExchangeApiResponse.cs b/jobs/Backend/Task/src/ExchangeRateUpdater/Models/ExchangeApiResponse.cs new file mode 100644 index 000000000..4609d0a11 --- /dev/null +++ b/jobs/Backend/Task/src/ExchangeRateUpdater/Models/ExchangeApiResponse.cs @@ -0,0 +1,10 @@ +using System.Collections.Generic; + +namespace ExchangeRateUpdater.Models +{ + public class ExchangeApiResponse + { + public List Rates { get; set; } + } +} + diff --git a/jobs/Backend/Task/ExchangeRate.cs b/jobs/Backend/Task/src/ExchangeRateUpdater/Models/ExchangeRate.cs similarity index 93% rename from jobs/Backend/Task/ExchangeRate.cs rename to jobs/Backend/Task/src/ExchangeRateUpdater/Models/ExchangeRate.cs index 58c5bb10e..2133586d4 100644 --- a/jobs/Backend/Task/ExchangeRate.cs +++ b/jobs/Backend/Task/src/ExchangeRateUpdater/Models/ExchangeRate.cs @@ -1,4 +1,4 @@ -namespace ExchangeRateUpdater +namespace ExchangeRateUpdater.Models { public class ExchangeRate { diff --git a/jobs/Backend/Task/src/ExchangeRateUpdater/Models/FxRate.cs b/jobs/Backend/Task/src/ExchangeRateUpdater/Models/FxRate.cs new file mode 100644 index 000000000..49f3f7ce8 --- /dev/null +++ b/jobs/Backend/Task/src/ExchangeRateUpdater/Models/FxRate.cs @@ -0,0 +1,16 @@ +using System; + +namespace ExchangeRateUpdater.Models +{ + public class FxRate + { + public DateTime ValidFor { get; set; } + public int Order { get; set; } + public string Country { get; set; } + public string Currency { get; set; } + public decimal Amount { get; set; } + public string CurrencyCode { get; set; } + public double Rate { get; set; } + } +} + diff --git a/jobs/Backend/Task/src/ExchangeRateUpdater/Program.cs b/jobs/Backend/Task/src/ExchangeRateUpdater/Program.cs new file mode 100644 index 000000000..7ccc64971 --- /dev/null +++ b/jobs/Backend/Task/src/ExchangeRateUpdater/Program.cs @@ -0,0 +1,28 @@ +using System.Threading.Tasks; +using ExchangeRateUpdater.DependencyInjection; +using ExchangeRateUpdater.Services; +using ExchangeRateUpdater.Settings; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; + +namespace ExchangeRateUpdater +{ + public static class Program + { + public static async Task Main(string[] args) + { + await CreateHostBuilder(args).Build().RunAsync(); + } + + private static IHostBuilder CreateHostBuilder(string[] args) => + Host.CreateDefaultBuilder() + .ConfigureServices((context, services) => + { + services.Configure(context.Configuration.GetSection("CurrencySettings")); + services.AddApiClients(context.Configuration, "CNBExchangeRatesApiSettings", "CNBExchangeRatesApi"); + services.AddTransient(); + services.AddTransient(); + services.AddHostedService(); + }); + } +} diff --git a/jobs/Backend/Task/src/ExchangeRateUpdater/Services/ExchangeRateProvider.cs b/jobs/Backend/Task/src/ExchangeRateUpdater/Services/ExchangeRateProvider.cs new file mode 100644 index 000000000..d3ec32b31 --- /dev/null +++ b/jobs/Backend/Task/src/ExchangeRateUpdater/Services/ExchangeRateProvider.cs @@ -0,0 +1,52 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using ExchangeRateUpdater.Mappers; +using ExchangeRateUpdater.Models; +using Microsoft.Extensions.Logging; + +namespace ExchangeRateUpdater.Services +{ + + public class ExchangeRateProvider : IExchangeRateProvider + { + private readonly IFxRateService _fxRateService; + private readonly ILogger _logger; + + public ExchangeRateProvider(IFxRateService fxRateService, ILogger logger) + { + _fxRateService = fxRateService; + _logger = logger; + } + + public async Task> GetExchangeRatesAsync( + IEnumerable currencies, + DateTime? date, + string language = "EN", + CancellationToken cancellationToken = default) + { + if (!currencies.Any()) + { + _logger.LogWarning("The requested currencies list was empty. No exchange rates will be extracted."); + return Enumerable.Empty(); + } + if (language is not "EN" or "CZ") + { + _logger.LogInformation("The requested language was invalid. Reverting to default value EN"); + language = "EN"; + } + + var exchangeRatesDate = date ?? DateTime.UtcNow; + + var allApiFxRates = await _fxRateService.GetFxRatesAsync(exchangeRatesDate, language, cancellationToken); + + var currencyCodes = currencies.Select(c=>c.Code).ToHashSet(); + var requestedRates = allApiFxRates.Where(fx => currencyCodes.Contains(fx.CurrencyCode)); + var exchangeRates = ExchangeRateMapper.MapApiFxRatesToExchangeRates(requestedRates); + + return exchangeRates; + } + } +} diff --git a/jobs/Backend/Task/src/ExchangeRateUpdater/Services/FxRateService.cs b/jobs/Backend/Task/src/ExchangeRateUpdater/Services/FxRateService.cs new file mode 100644 index 000000000..fc55dfcd3 --- /dev/null +++ b/jobs/Backend/Task/src/ExchangeRateUpdater/Services/FxRateService.cs @@ -0,0 +1,45 @@ +using ExchangeRateUpdater.Models; +using System; +using System.Net.Http; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using System.Text.Json; +using Microsoft.Extensions.Logging; + +namespace ExchangeRateUpdater.Services +{ + public class CNBFxRateService : IFxRateService + { + private readonly IHttpClientFactory _httpClientFactory; + private readonly ILogger _logger; + + public CNBFxRateService(IHttpClientFactory httpClientFactory, ILogger logger) + { + _httpClientFactory = httpClientFactory; + _logger = logger; + } + + public async Task> GetFxRatesAsync(DateTime date, string language, CancellationToken cancellationToken) + { + var httpClient = _httpClientFactory.CreateClient("CNBExchangeRatesApi"); + var formattedDate = date.ToString("yyyy-MM-dd"); + + _logger.LogInformation($"Retrieving exchange rates for {formattedDate}"); + + var response = await httpClient.GetAsync($"exrates/daily?date={formattedDate}&lang={language}", cancellationToken); + + if (!response.IsSuccessStatusCode) + { + _logger.LogWarning($"The rates could not be retrieved from the CNB exchange rates API. Status code: {response.StatusCode}"); + return new List(); + } + + var stream = await response.Content.ReadAsStreamAsync(cancellationToken); + var jsonOptions = new JsonSerializerOptions { PropertyNameCaseInsensitive = true }; + var apiResponse = await JsonSerializer.DeserializeAsync(stream, jsonOptions, cancellationToken: cancellationToken); + + return apiResponse.Rates; + } + } +} diff --git a/jobs/Backend/Task/src/ExchangeRateUpdater/Services/IExchangeRateProvider.cs b/jobs/Backend/Task/src/ExchangeRateUpdater/Services/IExchangeRateProvider.cs new file mode 100644 index 000000000..4c40b8e5a --- /dev/null +++ b/jobs/Backend/Task/src/ExchangeRateUpdater/Services/IExchangeRateProvider.cs @@ -0,0 +1,13 @@ +using System; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using ExchangeRateUpdater.Models; + +namespace ExchangeRateUpdater.Services +{ + public interface IExchangeRateProvider + { + Task> GetExchangeRatesAsync(IEnumerable currencies, DateTime? date, string language = "EN", CancellationToken cancellationToken = default); + } +} diff --git a/jobs/Backend/Task/src/ExchangeRateUpdater/Services/IFxRateService.cs b/jobs/Backend/Task/src/ExchangeRateUpdater/Services/IFxRateService.cs new file mode 100644 index 000000000..48a045407 --- /dev/null +++ b/jobs/Backend/Task/src/ExchangeRateUpdater/Services/IFxRateService.cs @@ -0,0 +1,13 @@ +using ExchangeRateUpdater.Models; +using System; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; + +namespace ExchangeRateUpdater.Services +{ + public interface IFxRateService + { + Task> GetFxRatesAsync(DateTime date, string language, CancellationToken cancellationToken); + } +} diff --git a/jobs/Backend/Task/src/ExchangeRateUpdater/Settings/ApiClientSettings.cs b/jobs/Backend/Task/src/ExchangeRateUpdater/Settings/ApiClientSettings.cs new file mode 100644 index 000000000..592050240 --- /dev/null +++ b/jobs/Backend/Task/src/ExchangeRateUpdater/Settings/ApiClientSettings.cs @@ -0,0 +1,8 @@ +namespace ExchangeRateUpdater.Settings +{ + public class ApiClientSettings + { + public string BaseAddress { get; set; } + public RetrySettings RetrySettings { get; set; } + } +} diff --git a/jobs/Backend/Task/src/ExchangeRateUpdater/Settings/CurrencySettings.cs b/jobs/Backend/Task/src/ExchangeRateUpdater/Settings/CurrencySettings.cs new file mode 100644 index 000000000..5abdde909 --- /dev/null +++ b/jobs/Backend/Task/src/ExchangeRateUpdater/Settings/CurrencySettings.cs @@ -0,0 +1,9 @@ +using System.Collections.Generic; + +namespace ExchangeRateUpdater.Settings +{ + public class CurrencySettings + { + public IEnumerable SupportedCurrencies { get; set; } + } +} diff --git a/jobs/Backend/Task/src/ExchangeRateUpdater/Settings/RetrySettings.cs b/jobs/Backend/Task/src/ExchangeRateUpdater/Settings/RetrySettings.cs new file mode 100644 index 000000000..81fc2e38e --- /dev/null +++ b/jobs/Backend/Task/src/ExchangeRateUpdater/Settings/RetrySettings.cs @@ -0,0 +1,7 @@ +namespace ExchangeRateUpdater.Settings +{ + public class RetrySettings + { + public int MaxRetries { get; set; } + } +} diff --git a/jobs/Backend/Task/src/ExchangeRateUpdater/appsettings.json b/jobs/Backend/Task/src/ExchangeRateUpdater/appsettings.json new file mode 100644 index 000000000..2a1b078d9 --- /dev/null +++ b/jobs/Backend/Task/src/ExchangeRateUpdater/appsettings.json @@ -0,0 +1,11 @@ +{ + "CNBExchangeRatesApiSettings": { + "BaseAddress": "https://api.cnb.cz/cnbapi/", + "RetrySettings": { + "MaxRetries": 3 + } + }, + "CurrencySettings": { + "SupportedCurrencies": [ "USD", "EUR", "CZK", "JPY", "KES", "RUB", "THB", "TRY", "XYZ"] + } +} diff --git a/jobs/Backend/Task/test/ExchangeRateUpdater.Tests/ExchangeRateMapperTests.cs b/jobs/Backend/Task/test/ExchangeRateUpdater.Tests/ExchangeRateMapperTests.cs new file mode 100644 index 000000000..15af2cd0a --- /dev/null +++ b/jobs/Backend/Task/test/ExchangeRateUpdater.Tests/ExchangeRateMapperTests.cs @@ -0,0 +1,51 @@ +using ExchangeRateUpdater.Mappers; +using ExchangeRateUpdater.Models; +using FluentAssertions; + +namespace ExchangeRateUpdater.Tests +{ + public class ExchangeRateMapperTests + { + + [Fact] + public void Map_ApiFxRate_To_ExchangeRate_() + { + var apiFxRates = new List + { + new() { CurrencyCode = "USD", Rate = 26.331 }, + new() { CurrencyCode = "GBP", Rate = 30.014 }, + new() { CurrencyCode = "RON", Rate = 8.769 }, + new() { CurrencyCode = "AUD", Rate = 15.164 } + }; + + var exchangeRates = ExchangeRateMapper.MapApiFxRatesToExchangeRates(apiFxRates).ToList(); + + exchangeRates.Count.Should().Be(apiFxRates.Count); + + for (var i = 0; i < exchangeRates.Count; i++) + { + exchangeRates[i].SourceCurrency.Code.Should().Be(apiFxRates[i].CurrencyCode); + exchangeRates[i].TargetCurrency.Code.Should().Be("CZK"); + exchangeRates[i].Value.Should().Be((decimal)apiFxRates[i].Rate); + } + } + + [Fact] + public void MapApiFxRatesToExchangeRates_WithEmptyList_ShouldReturnEmpty() + { + var apiFxRates = new List(); + + var exchangeRates = ExchangeRateMapper.MapApiFxRatesToExchangeRates(apiFxRates); + + exchangeRates.Should().BeEmpty(); + } + + [Fact] + public void MapApiFxRatesToExchangeRates_ShouldHandleNullFxRateList() + { + var exchangeRates = ExchangeRateMapper.MapApiFxRatesToExchangeRates(null); + + exchangeRates.Should().BeEmpty(); + } + } +} \ No newline at end of file diff --git a/jobs/Backend/Task/test/ExchangeRateUpdater.Tests/ExchangeRateProviderTests.cs b/jobs/Backend/Task/test/ExchangeRateUpdater.Tests/ExchangeRateProviderTests.cs new file mode 100644 index 000000000..c75b24dc2 --- /dev/null +++ b/jobs/Backend/Task/test/ExchangeRateUpdater.Tests/ExchangeRateProviderTests.cs @@ -0,0 +1,95 @@ +using ExchangeRateUpdater.Models; +using ExchangeRateUpdater.Services; +using FluentAssertions; +using Microsoft.Extensions.Logging; +using Moq; + +namespace ExchangeRateUpdater.Tests +{ + public class ExchangeRateProviderTests + { + private readonly Mock _fxRateService; + private readonly Mock> _logger; + IExchangeRateProvider _exchangeRateProvider; + + public ExchangeRateProviderTests() + { + _fxRateService = new Mock(); + _logger = new Mock>(); + _exchangeRateProvider = new ExchangeRateProvider(_fxRateService.Object, _logger.Object); + } + + [Fact] + public async Task GetExchangeRates_ReturnsTheCorrectExchangeRates_WhenValidListOfCurrenciesIsProvided() + { + var currencies = new List { new("USD"), new("EUR"), new("GBP"), new("AUD"), new("RON"), new("XYZ"), new("BLA") }; + var apiFxRates = new List + { + new() { CurrencyCode = "USD", Rate = 23.546}, + new() { CurrencyCode = "EUR", Rate = 25.254}, + new() { CurrencyCode = "GBP", Rate = 30.021}, + new() { CurrencyCode = "AUD", Rate = 15.006}, + new() { CurrencyCode = "RON", Rate = 8.210}, + }; + var expectedResult = apiFxRates.Select(x => new ExchangeRate(new Currency(x.CurrencyCode), new Currency("CZK"), (decimal)x.Rate)); + _fxRateService.Setup(fx => fx.GetFxRatesAsync(It.IsAny(), It.IsAny(), default)).ReturnsAsync(apiFxRates); + + var result = await _exchangeRateProvider.GetExchangeRatesAsync(currencies, DateTime.UtcNow); + + result.Should().BeEquivalentTo(expectedResult); + } + + [Fact] + public async Task GetExchangeRates_ReturnsOnlyMathcingExchangeRates() + { + var currencies = new List { new("USD"), new("EUR"), new("GBP"), new("AUD"), new("RON") }; + var apiFxRates = new List + { + new() { CurrencyCode = "USD", Rate = 23.546}, + new() { CurrencyCode = "EUR", Rate = 25.254}, + new() { CurrencyCode = "GBP", Rate = 30.021}, + new() { CurrencyCode = "CAD", Rate = 15.006}, + new() { CurrencyCode = "JPY", Rate = 8.210}, + }; + var expectedResult = new List + { + new(new Currency("USD"), new Currency("CZK"), 23.546m), + new(new Currency("EUR"), new Currency("CZK"), 25.254m), + new(new Currency("GBP"), new Currency("CZK"), 30.021m), + }; + _fxRateService.Setup(fx => fx.GetFxRatesAsync(It.IsAny(), It.IsAny(), default)).ReturnsAsync(apiFxRates); + + var result = await _exchangeRateProvider.GetExchangeRatesAsync(currencies, DateTime.UtcNow); + + result.Should().BeEquivalentTo(expectedResult); + } + + [Fact] + public async Task GetExchangeRates_ReturnsOnlyMathcingExchangeRatesWhenRequestedCurrenciesIsEmpty() + { + var currencies = new List(); + var apiFxRates = new List + { + new() { CurrencyCode = "USD", Rate = 23.546}, + new() { CurrencyCode = "EUR", Rate = 25.254}, + new() { CurrencyCode = "GBP", Rate = 30.021}, + new() { CurrencyCode = "CAD", Rate = 15.006}, + new() { CurrencyCode = "JPY", Rate = 8.210}, + }; + + _fxRateService.Setup(fx => fx.GetFxRatesAsync(It.IsAny(), It.IsAny(), default)).ReturnsAsync(apiFxRates); + + var result = await _exchangeRateProvider.GetExchangeRatesAsync(currencies, DateTime.UtcNow); + + result.Should().BeEmpty(); + _logger.Verify( + x => x.Log( + LogLevel.Warning, + It.IsAny(), + It.Is((v, t) => v.ToString().Contains("The requested currencies list was empty. No exchange rates will be extracted.")), + It.IsAny(), + It.Is>((v, t) => true)), + Times.Once); + } + } +} diff --git a/jobs/Backend/Task/test/ExchangeRateUpdater.Tests/ExchangeRateUpdater.Tests.csproj b/jobs/Backend/Task/test/ExchangeRateUpdater.Tests/ExchangeRateUpdater.Tests.csproj new file mode 100644 index 000000000..f5548d1bd --- /dev/null +++ b/jobs/Backend/Task/test/ExchangeRateUpdater.Tests/ExchangeRateUpdater.Tests.csproj @@ -0,0 +1,29 @@ + + + + net6.0 + enable + enable + + false + true + + + + + + + + + + + + + + + + + + + + diff --git a/jobs/Backend/Task/test/ExchangeRateUpdater.Tests/FxRateServiceTests.cs b/jobs/Backend/Task/test/ExchangeRateUpdater.Tests/FxRateServiceTests.cs new file mode 100644 index 000000000..8579ed026 --- /dev/null +++ b/jobs/Backend/Task/test/ExchangeRateUpdater.Tests/FxRateServiceTests.cs @@ -0,0 +1,85 @@ +using Moq.Protected; +using Moq; +using System.Net; +using ExchangeRateUpdater.Services; +using Microsoft.Extensions.Logging; +using ExchangeRateUpdater.Models; +using FluentAssertions; + +namespace ExchangeRateUpdater.Tests +{ + public class FxRateServiceTests + { + private IFxRateService _fxRateService; + private readonly Mock _httpFactory; + private readonly Mock> _logger; + + public FxRateServiceTests() + { + _httpFactory = new Mock(); + _logger = new Mock>(); + } + + [Fact] + public async Task GetExchangeRatesForCZK_ShouldReturnTheApiFxRates_WhenTheCNBApiIsReacheable() + { + _fxRateService = new CNBFxRateService(_httpFactory.Object, _logger.Object); + var httpClient = CreateHttpClientMock(HttpStatusCode.OK); + _httpFactory.Setup(x => x.CreateClient(It.IsAny())).Returns(httpClient); + var expectedFxRates = new List + { + new() { ValidFor = DateTime.Parse("2024-10-01"), Order = 191, Country = "Australia", Currency = "dollar", Amount = 1, CurrencyCode = "AUD", Rate = 15.759 }, + new() { ValidFor = DateTime.Parse("2024-10-01"), Order = 191, Country = "Canada", Currency = "dollar", Amount = 1, CurrencyCode = "CAD", Rate = 16.873 }, + new() { ValidFor = DateTime.Parse("2024-10-01"), Order = 191, Country = "EMU", Currency = "euro", Amount = 1, CurrencyCode = "EUR", Rate = 25.275 }, + new() { ValidFor = DateTime.Parse("2024-10-01"), Order = 191, Country = "United Kingdom", Currency = "pound", Amount = 1, CurrencyCode = "GBP", Rate = 30.389 }, + new() { ValidFor = DateTime.Parse("2024-10-01"), Order = 191, Country = "USA", Currency = "dollar", Amount = 1, CurrencyCode = "USD", Rate = 22.81 } + }; + + var result = await _fxRateService.GetFxRatesAsync(DateTime.UtcNow, "EN", default); + + result.Should().BeEquivalentTo(expectedFxRates); + } + + [Theory] + [InlineData(HttpStatusCode.BadGateway)] + [InlineData(HttpStatusCode.NotFound)] + public async Task GetExchangeRatesForCZK_ShouldLogWarningAndReturnEmptyList_WhenApiIsReachableButNonSuccessStatusCodeIsReturned(HttpStatusCode code) + { + _fxRateService = new CNBFxRateService(_httpFactory.Object, _logger.Object); + var httpClient = CreateHttpClientMock(code); + _httpFactory.Setup(x => x.CreateClient(It.IsAny())).Returns(httpClient); + + var result = await _fxRateService.GetFxRatesAsync(DateTime.UtcNow, "EN", default); + + result.Should().BeEmpty(); + _logger.Verify( + x => x.Log( + LogLevel.Warning, + It.IsAny(), + It.Is((v, t) => v.ToString().Contains("The rates could not be retrieved from the CNB exchange rates API")), + It.IsAny(), + It.Is>((v, t) => true)), + Times.Once); + } + + private HttpClient CreateHttpClientMock(HttpStatusCode returnedCode) + { + var mockHttpMessageHandler = new Mock(); + + mockHttpMessageHandler.Protected() + .Setup>("SendAsync", ItExpr.IsAny(), ItExpr.IsAny()) + .ReturnsAsync(new HttpResponseMessage + { + StatusCode = returnedCode, + Content = new StringContent("{\"rates\":[{\"validFor\":\"2024-10-01\",\"order\":191,\"country\":\"Australia\",\"currency\":\"dollar\",\"amount\":1,\"currencyCode\":\"AUD\",\"rate\":15.759},{\"validFor\":\"2024-10-01\",\"order\":191,\"country\":\"Canada\",\"currency\":\"dollar\",\"amount\":1,\"currencyCode\":\"CAD\",\"rate\":16.873},{\"validFor\":\"2024-10-01\",\"order\":191,\"country\":\"EMU\",\"currency\":\"euro\",\"amount\":1,\"currencyCode\":\"EUR\",\"rate\":25.275},{\"validFor\":\"2024-10-01\",\"order\":191,\"country\":\"United Kingdom\",\"currency\":\"pound\",\"amount\":1,\"currencyCode\":\"GBP\",\"rate\":30.389},{\"validFor\":\"2024-10-01\",\"order\":191,\"country\":\"USA\",\"currency\":\"dollar\",\"amount\":1,\"currencyCode\":\"USD\",\"rate\":22.81}]}") + }); + + var client = new HttpClient(mockHttpMessageHandler.Object) + { + BaseAddress = new Uri("https://test.com") + }; + + return client; + } + } +}