From 5c0ea34f3630240cc07ef591e3123c9c65766ecc Mon Sep 17 00:00:00 2001 From: OmarFarooqKhan Date: Sun, 25 Jan 2026 22:20:56 +0000 Subject: [PATCH 1/4] Inital Commit --- jobs/Backend/Task/ExchangeRateUpdater.sln | 2 +- jobs/Backend/Task/{ => ExchangeRateUpdater}/Currency.cs | 0 jobs/Backend/Task/{ => ExchangeRateUpdater}/ExchangeRate.cs | 0 .../Task/{ => ExchangeRateUpdater}/ExchangeRateProvider.cs | 0 .../Task/{ => ExchangeRateUpdater}/ExchangeRateUpdater.csproj | 0 jobs/Backend/Task/{ => ExchangeRateUpdater}/Program.cs | 0 6 files changed, 1 insertion(+), 1 deletion(-) rename jobs/Backend/Task/{ => ExchangeRateUpdater}/Currency.cs (100%) rename jobs/Backend/Task/{ => ExchangeRateUpdater}/ExchangeRate.cs (100%) rename jobs/Backend/Task/{ => ExchangeRateUpdater}/ExchangeRateProvider.cs (100%) rename jobs/Backend/Task/{ => ExchangeRateUpdater}/ExchangeRateUpdater.csproj (100%) rename jobs/Backend/Task/{ => ExchangeRateUpdater}/Program.cs (100%) diff --git a/jobs/Backend/Task/ExchangeRateUpdater.sln b/jobs/Backend/Task/ExchangeRateUpdater.sln index 89be84daff..d748284427 100644 --- a/jobs/Backend/Task/ExchangeRateUpdater.sln +++ b/jobs/Backend/Task/ExchangeRateUpdater.sln @@ -3,7 +3,7 @@ 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}" +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ExchangeRateUpdater", "ExchangeRateUpdater\ExchangeRateUpdater.csproj", "{7B2695D6-D24C-4460-A58E-A10F08550CE0}" EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution diff --git a/jobs/Backend/Task/Currency.cs b/jobs/Backend/Task/ExchangeRateUpdater/Currency.cs similarity index 100% rename from jobs/Backend/Task/Currency.cs rename to jobs/Backend/Task/ExchangeRateUpdater/Currency.cs diff --git a/jobs/Backend/Task/ExchangeRate.cs b/jobs/Backend/Task/ExchangeRateUpdater/ExchangeRate.cs similarity index 100% rename from jobs/Backend/Task/ExchangeRate.cs rename to jobs/Backend/Task/ExchangeRateUpdater/ExchangeRate.cs diff --git a/jobs/Backend/Task/ExchangeRateProvider.cs b/jobs/Backend/Task/ExchangeRateUpdater/ExchangeRateProvider.cs similarity index 100% rename from jobs/Backend/Task/ExchangeRateProvider.cs rename to jobs/Backend/Task/ExchangeRateUpdater/ExchangeRateProvider.cs diff --git a/jobs/Backend/Task/ExchangeRateUpdater.csproj b/jobs/Backend/Task/ExchangeRateUpdater/ExchangeRateUpdater.csproj similarity index 100% rename from jobs/Backend/Task/ExchangeRateUpdater.csproj rename to jobs/Backend/Task/ExchangeRateUpdater/ExchangeRateUpdater.csproj diff --git a/jobs/Backend/Task/Program.cs b/jobs/Backend/Task/ExchangeRateUpdater/Program.cs similarity index 100% rename from jobs/Backend/Task/Program.cs rename to jobs/Backend/Task/ExchangeRateUpdater/Program.cs From 67b3fd22710c059b2c1714037df0af64cdbde10b Mon Sep 17 00:00:00 2001 From: OmarFarooqKhan Date: Mon, 26 Jan 2026 07:52:14 +0000 Subject: [PATCH 2/4] Working example and setup neccesary to get things running --- jobs/Backend/Task/ExchangeRateUpdater.sln | 6 + .../Business/ExchangeRateProvider.cs | 22 ++ .../Business/IExchangeRateProvider.cs | 16 ++ .../Processors/CzechNationalBankProcessor.cs | 61 ++++++ .../Processors/IExchangeRateProcessor.cs | 15 ++ .../Controllers/ExchangeRatesController.cs | 59 ++++++ .../ExchangeRateProvider.cs | 19 -- .../ExchangeRateUpdater.csproj | 18 +- .../CzechNationalBankExchangeRateClient.cs | 78 +++++++ .../Infrastructure/IExchangeRateClient.cs | 12 ++ .../CzechNationalBankResponseMapper.cs | 24 +++ .../Mapping/IExchangeRateMapper.cs | 13 ++ .../ExchangeRateConfigurationOptions.cs | 13 ++ .../{ => Models}/Currency.cs | 2 +- .../CzechNationalBankClientOptions.cs | 11 + .../CzechNationalBankResponse.cs | 9 + .../Models/CzechNationalBank/ErrorLog.cs | 5 + .../Models/CzechNationalBank/ErrorResponse.cs | 5 + .../Models/CzechNationalBank/Rate.cs | 5 + .../{ => Models}/ExchangeRate.cs | 2 +- .../Task/ExchangeRateUpdater/Models/Result.cs | 20 ++ .../Backend/Task/ExchangeRateUpdater/Notes.md | 67 ++++++ .../Task/ExchangeRateUpdater/Program.cs | 43 ---- .../Properties/launchSettings.json | 15 ++ .../ExchangeRateUpdater/Startup/Program.cs | 36 ++++ .../Startup/ServiceCollectionExtensions.cs | 63 ++++++ .../ExchangeRateCurrencyCodeValidator.cs | 21 ++ .../appsettings.Development.json | 9 + .../Task/ExchangeRateUpdater/appsettings.json | 21 ++ .../Task/ExchangeRateUpdater/global.json | 7 + .../Business/ExchangeRateProviderTests.cs | 35 ++++ .../CzechNationalBankProcessorTests.cs | 104 ++++++++++ .../ExchangeRatesControllerTests.cs | 99 +++++++++ .../ExchangeRateUpdaterTests.csproj | 25 +++ ...zechNationalBankExchangeRateClientTests.cs | 196 ++++++++++++++++++ .../CzechNationalBankResponseMapperTests.cs | 44 ++++ .../ExchangeRateCurrencyCodeValidatorTests.cs | 38 ++++ 37 files changed, 1170 insertions(+), 68 deletions(-) create mode 100644 jobs/Backend/Task/ExchangeRateUpdater/Business/ExchangeRateProvider.cs create mode 100644 jobs/Backend/Task/ExchangeRateUpdater/Business/IExchangeRateProvider.cs create mode 100644 jobs/Backend/Task/ExchangeRateUpdater/Business/Processors/CzechNationalBankProcessor.cs create mode 100644 jobs/Backend/Task/ExchangeRateUpdater/Business/Processors/IExchangeRateProcessor.cs create mode 100644 jobs/Backend/Task/ExchangeRateUpdater/Controllers/ExchangeRatesController.cs delete mode 100644 jobs/Backend/Task/ExchangeRateUpdater/ExchangeRateProvider.cs create mode 100644 jobs/Backend/Task/ExchangeRateUpdater/Infrastructure/CzechNationalBankExchangeRateClient.cs create mode 100644 jobs/Backend/Task/ExchangeRateUpdater/Infrastructure/IExchangeRateClient.cs create mode 100644 jobs/Backend/Task/ExchangeRateUpdater/Mapping/CzechNationalBankResponseMapper.cs create mode 100644 jobs/Backend/Task/ExchangeRateUpdater/Mapping/IExchangeRateMapper.cs create mode 100644 jobs/Backend/Task/ExchangeRateUpdater/Models/Configuration/ExchangeRateConfigurationOptions.cs rename jobs/Backend/Task/ExchangeRateUpdater/{ => Models}/Currency.cs (89%) create mode 100644 jobs/Backend/Task/ExchangeRateUpdater/Models/CzechNationalBank/CzechNationalBankClientOptions.cs create mode 100644 jobs/Backend/Task/ExchangeRateUpdater/Models/CzechNationalBank/CzechNationalBankResponse.cs create mode 100644 jobs/Backend/Task/ExchangeRateUpdater/Models/CzechNationalBank/ErrorLog.cs create mode 100644 jobs/Backend/Task/ExchangeRateUpdater/Models/CzechNationalBank/ErrorResponse.cs create mode 100644 jobs/Backend/Task/ExchangeRateUpdater/Models/CzechNationalBank/Rate.cs rename jobs/Backend/Task/ExchangeRateUpdater/{ => Models}/ExchangeRate.cs (93%) create mode 100644 jobs/Backend/Task/ExchangeRateUpdater/Models/Result.cs create mode 100644 jobs/Backend/Task/ExchangeRateUpdater/Notes.md delete mode 100644 jobs/Backend/Task/ExchangeRateUpdater/Program.cs create mode 100644 jobs/Backend/Task/ExchangeRateUpdater/Properties/launchSettings.json create mode 100644 jobs/Backend/Task/ExchangeRateUpdater/Startup/Program.cs create mode 100644 jobs/Backend/Task/ExchangeRateUpdater/Startup/ServiceCollectionExtensions.cs create mode 100644 jobs/Backend/Task/ExchangeRateUpdater/Validators/ExchangeRateCurrencyCodeValidator.cs create mode 100644 jobs/Backend/Task/ExchangeRateUpdater/appsettings.Development.json create mode 100644 jobs/Backend/Task/ExchangeRateUpdater/appsettings.json create mode 100644 jobs/Backend/Task/ExchangeRateUpdater/global.json create mode 100644 jobs/Backend/Task/ExchangeRateUpdaterTests/Business/ExchangeRateProviderTests.cs create mode 100644 jobs/Backend/Task/ExchangeRateUpdaterTests/Business/Processors/CzechNationalBankProcessorTests.cs create mode 100644 jobs/Backend/Task/ExchangeRateUpdaterTests/Controllers/ExchangeRatesControllerTests.cs create mode 100644 jobs/Backend/Task/ExchangeRateUpdaterTests/ExchangeRateUpdaterTests.csproj create mode 100644 jobs/Backend/Task/ExchangeRateUpdaterTests/Infrastructure/CzechNationalBankExchangeRateClientTests.cs create mode 100644 jobs/Backend/Task/ExchangeRateUpdaterTests/Mapping/CzechNationalBankResponseMapperTests.cs create mode 100644 jobs/Backend/Task/ExchangeRateUpdaterTests/Validators/ExchangeRateCurrencyCodeValidatorTests.cs diff --git a/jobs/Backend/Task/ExchangeRateUpdater.sln b/jobs/Backend/Task/ExchangeRateUpdater.sln index d748284427..866ff1f11e 100644 --- a/jobs/Backend/Task/ExchangeRateUpdater.sln +++ b/jobs/Backend/Task/ExchangeRateUpdater.sln @@ -5,6 +5,8 @@ VisualStudioVersion = 14.0.25123.0 MinimumVisualStudioVersion = 10.0.40219.1 Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ExchangeRateUpdater", "ExchangeRateUpdater\ExchangeRateUpdater.csproj", "{7B2695D6-D24C-4460-A58E-A10F08550CE0}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ExchangeRateUpdaterTests", "ExchangeRateUpdaterTests\ExchangeRateUpdaterTests.csproj", "{C0F5DCC5-1599-44FB-91AF-A5A89A65C1C9}" +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 + {C0F5DCC5-1599-44FB-91AF-A5A89A65C1C9}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {C0F5DCC5-1599-44FB-91AF-A5A89A65C1C9}.Debug|Any CPU.Build.0 = Debug|Any CPU + {C0F5DCC5-1599-44FB-91AF-A5A89A65C1C9}.Release|Any CPU.ActiveCfg = Release|Any CPU + {C0F5DCC5-1599-44FB-91AF-A5A89A65C1C9}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE diff --git a/jobs/Backend/Task/ExchangeRateUpdater/Business/ExchangeRateProvider.cs b/jobs/Backend/Task/ExchangeRateUpdater/Business/ExchangeRateProvider.cs new file mode 100644 index 0000000000..06a52db21e --- /dev/null +++ b/jobs/Backend/Task/ExchangeRateUpdater/Business/ExchangeRateProvider.cs @@ -0,0 +1,22 @@ +using System.Collections.Generic; +using System.Threading.Tasks; +using ExchangeRateUpdater.Business.Processors; +using ExchangeRateUpdater.Models; + +namespace ExchangeRateUpdater.Business +{ + public class ExchangeRateProvider : IExchangeRateProvider + { + private readonly IExchangeRateProcessor _exchangeRateProcessor; + + public ExchangeRateProvider(IExchangeRateProcessor exchangeRateProcessor) + { + _exchangeRateProcessor = exchangeRateProcessor; + } + + public async Task>> GetExchangeRates(string[] currencies) + { + return await _exchangeRateProcessor.ProcessExchangeRates(currencies); + } + } +} diff --git a/jobs/Backend/Task/ExchangeRateUpdater/Business/IExchangeRateProvider.cs b/jobs/Backend/Task/ExchangeRateUpdater/Business/IExchangeRateProvider.cs new file mode 100644 index 0000000000..1d68b9f3d1 --- /dev/null +++ b/jobs/Backend/Task/ExchangeRateUpdater/Business/IExchangeRateProvider.cs @@ -0,0 +1,16 @@ +using System.Collections.Generic; +using System.Threading.Tasks; +using ExchangeRateUpdater.Models; + +namespace ExchangeRateUpdater.Business; + +public interface 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. + /// + public Task>> GetExchangeRates(string[] currencies); +} \ No newline at end of file diff --git a/jobs/Backend/Task/ExchangeRateUpdater/Business/Processors/CzechNationalBankProcessor.cs b/jobs/Backend/Task/ExchangeRateUpdater/Business/Processors/CzechNationalBankProcessor.cs new file mode 100644 index 0000000000..89109ddb89 --- /dev/null +++ b/jobs/Backend/Task/ExchangeRateUpdater/Business/Processors/CzechNationalBankProcessor.cs @@ -0,0 +1,61 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using ExchangeRateUpdater.Infrastructure; +using ExchangeRateUpdater.Mapping; +using ExchangeRateUpdater.Models; +using ExchangeRateUpdater.Models.CzechNationalBank; +using ExchangeRateUpdater.Models.CzechNationalBankResponse; +using Microsoft.Extensions.Logging; + +namespace ExchangeRateUpdater.Business.Processors; + +public class CzechNationalBankProcessor : IExchangeRateProcessor +{ + private readonly IExchangeRateClient _client; + private readonly IExchangeRateMapper _mapper; + private readonly ILogger _logger; + + public CzechNationalBankProcessor( + IExchangeRateClient client, + IExchangeRateMapper mapper, + ILogger logger) + { + _client = client; + _mapper = mapper; + _logger = logger; + } + + public async Task>> ProcessExchangeRates(string[] currenciesToProcess) + { + _logger.LogInformation("CzechNationalBankProcessor_ProcessingCurrencies"); + + var processedRates = new List(); + var responseToProcess = await _client.RetrieveExchangeRatesAsync(); + + if (responseToProcess is EmptyResponse) + { + return Result>.Fail("CzechNationalBankProcessor_UnexpectedClientError"); + } + + var dictionaryOfReturnedRates = + responseToProcess.Rates.ToDictionary(key => key.CurrencyCode, StringComparer.OrdinalIgnoreCase); + + foreach (var desiredCurrency in currenciesToProcess) + { + var haveFoundEntry = dictionaryOfReturnedRates.TryGetValue(desiredCurrency, out var value); + if (!haveFoundEntry) + { + _logger.LogWarning("CzechNationalBankProcessor_CurrencyNotFound_{DesiredCurrency}", desiredCurrency); + continue; + } + + var mappedExchangeRate = _mapper.MapToExchangeRate(value); + processedRates.Add(mappedExchangeRate); + } + + _logger.LogInformation("CzechNationalBankProcessor_FinishedProcessingCurrencies"); + return Result>.Ok(processedRates); + } +} \ No newline at end of file diff --git a/jobs/Backend/Task/ExchangeRateUpdater/Business/Processors/IExchangeRateProcessor.cs b/jobs/Backend/Task/ExchangeRateUpdater/Business/Processors/IExchangeRateProcessor.cs new file mode 100644 index 0000000000..9c917d0e05 --- /dev/null +++ b/jobs/Backend/Task/ExchangeRateUpdater/Business/Processors/IExchangeRateProcessor.cs @@ -0,0 +1,15 @@ +using System.Collections.Generic; +using System.Threading.Tasks; +using ExchangeRateUpdater.Models; + +namespace ExchangeRateUpdater.Business.Processors; + +public interface IExchangeRateProcessor +{ + /// + /// Should return exchange rates among the specified currencies that are defined by the source. But only those defined + /// + /// + /// List of + Task>> ProcessExchangeRates(string[] currenciesToProcess); +} \ No newline at end of file diff --git a/jobs/Backend/Task/ExchangeRateUpdater/Controllers/ExchangeRatesController.cs b/jobs/Backend/Task/ExchangeRateUpdater/Controllers/ExchangeRatesController.cs new file mode 100644 index 0000000000..6babeecbb5 --- /dev/null +++ b/jobs/Backend/Task/ExchangeRateUpdater/Controllers/ExchangeRatesController.cs @@ -0,0 +1,59 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Net; +using System.Threading.Tasks; +using ExchangeRateUpdater.Business; +using ExchangeRateUpdater.Models; +using FluentValidation; +using Microsoft.AspNetCore.Mvc; +using Microsoft.Extensions.Logging; + +namespace ExchangeRateUpdater.Controllers; + +[ApiController] +[Route("api/v1/exchange-rates")] +[Produces("application/json")] +public class ExchangeRatesController : ControllerBase +{ + private readonly IExchangeRateProvider _exchangeRateProvider; + private readonly IValidator _currencyCodeValidator; + private readonly ILogger _logger; + + public ExchangeRatesController(IExchangeRateProvider exchangeRateProvider, IValidator currencyCodeValidator, ILogger logger) + { + _exchangeRateProvider = exchangeRateProvider; + _currencyCodeValidator = currencyCodeValidator; + _logger = logger; + } + + /// + /// Fetches the desired currencies and returns the based on the Source Currency for the current business day. + /// > + /// Desired Currencies + [HttpGet("daily")] + [ProducesResponseType(typeof(ActionResult>),(int)HttpStatusCode.OK)] + [ProducesResponseType(typeof(ActionResult),(int)HttpStatusCode.BadRequest)] + [ProducesResponseType((int)HttpStatusCode.InternalServerError)] + public async Task GetSelectedCurrencyCodesDaily([FromQuery] string[] currencies = null) + { + var validationResult = await _currencyCodeValidator.ValidateAsync(currencies); + + if (!validationResult.IsValid) + { + var errors = validationResult.Errors.Select(x => x.ErrorMessage); + _logger.LogError("ExchangeRatesController_ValidationFailure {Errors}", errors); + return BadRequest(validationResult.Errors.Select(x => x.ErrorMessage)); + } + + var result = await _exchangeRateProvider.GetExchangeRates(currencies); + + if (!result.Success) + { + _logger.LogError("ExchangeRatesController_ProviderError {Error}", result.Error); + return BadRequest(result.Error); + } + + return Ok(result.Value); + } +} \ No newline at end of file diff --git a/jobs/Backend/Task/ExchangeRateUpdater/ExchangeRateProvider.cs b/jobs/Backend/Task/ExchangeRateUpdater/ExchangeRateProvider.cs deleted file mode 100644 index 6f82a97fbe..0000000000 --- a/jobs/Backend/Task/ExchangeRateUpdater/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/ExchangeRateUpdater.csproj b/jobs/Backend/Task/ExchangeRateUpdater/ExchangeRateUpdater.csproj index 2fc654a12b..6ecaebebd9 100644 --- a/jobs/Backend/Task/ExchangeRateUpdater/ExchangeRateUpdater.csproj +++ b/jobs/Backend/Task/ExchangeRateUpdater/ExchangeRateUpdater.csproj @@ -1,8 +1,18 @@ - - + - Exe - net6.0 + net10.0 + + + + + + + + + + true + $(NoWarn);1591 + \ No newline at end of file diff --git a/jobs/Backend/Task/ExchangeRateUpdater/Infrastructure/CzechNationalBankExchangeRateClient.cs b/jobs/Backend/Task/ExchangeRateUpdater/Infrastructure/CzechNationalBankExchangeRateClient.cs new file mode 100644 index 0000000000..9d37edb28e --- /dev/null +++ b/jobs/Backend/Task/ExchangeRateUpdater/Infrastructure/CzechNationalBankExchangeRateClient.cs @@ -0,0 +1,78 @@ +using System; +using System.Net.Http; +using System.Net.Http.Json; +using System.Text.Json; +using System.Threading.Tasks; +using ExchangeRateUpdater.Models.CzechNationalBank; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; +using Polly; + +namespace ExchangeRateUpdater.Infrastructure; + +public class CzechNationalBankExchangeRateClient : IExchangeRateClient +{ + private readonly IOptions _options; + private readonly HttpClient _client; + private readonly ILogger _logger; + private const string DAILY_EXRATES_PATH = "exrates/daily?lang=EN"; + + public CzechNationalBankExchangeRateClient(IOptions options, HttpClient client, + ILogger logger) + { + _options = options; + _client = client; + _logger = logger; + } + + public async Task RetrieveExchangeRatesAsync() + { + try + { + var policy = + Policy.Handle() + .WaitAndRetryAsync( + _options.Value.RetryCount, + attempt => TimeSpan.FromMilliseconds(_options.Value.TimeoutMs), + (exception, timeSpan, retryCount, context) => + { + _logger.LogWarning(exception, "CzechNationalBankExchangeRateClient_RetryAttempt_{RetryAttempt}", + retryCount); + }); + + var result = + await policy.ExecuteAsync(async () => + { + var result = await _client.GetAsync(DAILY_EXRATES_PATH); + return result; + } + ); + + _logger.LogInformation("CzechNationalBankExchangeRateClient_RetrievedResponse"); + + if (result.IsSuccessStatusCode) + { + var parsedResponse = await result.Content.ReadFromJsonAsync(); + return parsedResponse; + } + + var parsedError = await result.Content.ReadFromJsonAsync(); + var errorLog = new ErrorLog(parsedError.description, parsedError.errorCode, result.StatusCode); + _logger.LogError("CzechNationalBankExchangeRateClient_UnsuccessfulResponse {ErrorLog}", errorLog); + } + catch (JsonException ex) + { + _logger.LogError("CzechNationalBankExchangeRateClient_DeserializationException {ErrorLog}", ex); + } + catch (TaskCanceledException ex) + { + _logger.LogError("CzechNationalBankExchangeRateClient_Timeout {ErrorLog}" ,ex); + } + catch (Exception ex) + { + _logger.LogError("CzechNationalBankExchangeRateClient_UnexpectedException {ErrorLog}",ex); + } + + return new EmptyResponse(); + } +} \ No newline at end of file diff --git a/jobs/Backend/Task/ExchangeRateUpdater/Infrastructure/IExchangeRateClient.cs b/jobs/Backend/Task/ExchangeRateUpdater/Infrastructure/IExchangeRateClient.cs new file mode 100644 index 0000000000..41863326f1 --- /dev/null +++ b/jobs/Backend/Task/ExchangeRateUpdater/Infrastructure/IExchangeRateClient.cs @@ -0,0 +1,12 @@ +using System.Threading.Tasks; + +namespace ExchangeRateUpdater.Infrastructure; + +public interface IExchangeRateClient +{ + /// + /// Retrieves ExchangeRate information from a Response via an external data source. + /// + /// Response from the external data source + Task RetrieveExchangeRatesAsync(); +} \ No newline at end of file diff --git a/jobs/Backend/Task/ExchangeRateUpdater/Mapping/CzechNationalBankResponseMapper.cs b/jobs/Backend/Task/ExchangeRateUpdater/Mapping/CzechNationalBankResponseMapper.cs new file mode 100644 index 0000000000..5911fc00d3 --- /dev/null +++ b/jobs/Backend/Task/ExchangeRateUpdater/Mapping/CzechNationalBankResponseMapper.cs @@ -0,0 +1,24 @@ +using System; +using ExchangeRateUpdater.Infrastructure; +using ExchangeRateUpdater.Models; +using ExchangeRateUpdater.Models.Configuration; +using ExchangeRateUpdater.Models.CzechNationalBankResponse; +using Microsoft.Extensions.Options; + +namespace ExchangeRateUpdater.Mapping; + +public class CzechNationalBankResponseMapper : IExchangeRateMapper +{ + private const int DESIREDDECIMALPLACE = 3; + private readonly string _sourceCurrency; + + public CzechNationalBankResponseMapper(IOptions options) + { + _sourceCurrency = options.Value.SourceCurrency; + } + public ExchangeRate MapToExchangeRate(RecordedRate typeToConvert) + { + var calculatedRate = Math.Round(typeToConvert.Rate / typeToConvert.Amount, DESIREDDECIMALPLACE); + return new ExchangeRate(new Currency(_sourceCurrency), new Currency(typeToConvert.CurrencyCode), calculatedRate); + } +} \ No newline at end of file diff --git a/jobs/Backend/Task/ExchangeRateUpdater/Mapping/IExchangeRateMapper.cs b/jobs/Backend/Task/ExchangeRateUpdater/Mapping/IExchangeRateMapper.cs new file mode 100644 index 0000000000..f0b44a47ec --- /dev/null +++ b/jobs/Backend/Task/ExchangeRateUpdater/Mapping/IExchangeRateMapper.cs @@ -0,0 +1,13 @@ +using ExchangeRateUpdater.Models; + +namespace ExchangeRateUpdater.Mapping; + +public interface IExchangeRateMapper +{ + /// + /// Maps the provided type to an + /// + /// + /// Mapped + ExchangeRate MapToExchangeRate(T typeToConvert); +} \ No newline at end of file diff --git a/jobs/Backend/Task/ExchangeRateUpdater/Models/Configuration/ExchangeRateConfigurationOptions.cs b/jobs/Backend/Task/ExchangeRateUpdater/Models/Configuration/ExchangeRateConfigurationOptions.cs new file mode 100644 index 0000000000..377bb69774 --- /dev/null +++ b/jobs/Backend/Task/ExchangeRateUpdater/Models/Configuration/ExchangeRateConfigurationOptions.cs @@ -0,0 +1,13 @@ +using System.ComponentModel.DataAnnotations; + +namespace ExchangeRateUpdater.Models.Configuration; + +public class ExchangeRateConfigurationOptions +{ + /// + /// Three-letter ISO 4217 code of the desired Source Currency. + /// + [Required] + [Length(3,3)] + public string SourceCurrency { get; init; } +} \ No newline at end of file diff --git a/jobs/Backend/Task/ExchangeRateUpdater/Currency.cs b/jobs/Backend/Task/ExchangeRateUpdater/Models/Currency.cs similarity index 89% rename from jobs/Backend/Task/ExchangeRateUpdater/Currency.cs rename to jobs/Backend/Task/ExchangeRateUpdater/Models/Currency.cs index f375776f25..8336d740ee 100644 --- a/jobs/Backend/Task/ExchangeRateUpdater/Currency.cs +++ b/jobs/Backend/Task/ExchangeRateUpdater/Models/Currency.cs @@ -1,4 +1,4 @@ -namespace ExchangeRateUpdater +namespace ExchangeRateUpdater.Models { public class Currency { diff --git a/jobs/Backend/Task/ExchangeRateUpdater/Models/CzechNationalBank/CzechNationalBankClientOptions.cs b/jobs/Backend/Task/ExchangeRateUpdater/Models/CzechNationalBank/CzechNationalBankClientOptions.cs new file mode 100644 index 0000000000..e8bea50f10 --- /dev/null +++ b/jobs/Backend/Task/ExchangeRateUpdater/Models/CzechNationalBank/CzechNationalBankClientOptions.cs @@ -0,0 +1,11 @@ +using System.ComponentModel.DataAnnotations; + +namespace ExchangeRateUpdater.Models.CzechNationalBank; + +public class CzechNationalBankClientOptions +{ + [Required] + public string BaseUri { get; set; } + public int TimeoutMs { get; set; } + public int RetryCount { get; set; } +} \ No newline at end of file diff --git a/jobs/Backend/Task/ExchangeRateUpdater/Models/CzechNationalBank/CzechNationalBankResponse.cs b/jobs/Backend/Task/ExchangeRateUpdater/Models/CzechNationalBank/CzechNationalBankResponse.cs new file mode 100644 index 0000000000..6431ba2838 --- /dev/null +++ b/jobs/Backend/Task/ExchangeRateUpdater/Models/CzechNationalBank/CzechNationalBankResponse.cs @@ -0,0 +1,9 @@ +using System.Collections.Generic; +using System.Linq; +using ExchangeRateUpdater.Models.CzechNationalBankResponse; + +namespace ExchangeRateUpdater.Models.CzechNationalBank; + +public record Response(IEnumerable Rates); + +public record EmptyResponse(): Response (Enumerable.Empty()); \ No newline at end of file diff --git a/jobs/Backend/Task/ExchangeRateUpdater/Models/CzechNationalBank/ErrorLog.cs b/jobs/Backend/Task/ExchangeRateUpdater/Models/CzechNationalBank/ErrorLog.cs new file mode 100644 index 0000000000..c8d71e0478 --- /dev/null +++ b/jobs/Backend/Task/ExchangeRateUpdater/Models/CzechNationalBank/ErrorLog.cs @@ -0,0 +1,5 @@ +using System.Net; + +namespace ExchangeRateUpdater.Models.CzechNationalBank; + +public record ErrorLog(string description, string errorCode, HttpStatusCode StatusCode); \ No newline at end of file diff --git a/jobs/Backend/Task/ExchangeRateUpdater/Models/CzechNationalBank/ErrorResponse.cs b/jobs/Backend/Task/ExchangeRateUpdater/Models/CzechNationalBank/ErrorResponse.cs new file mode 100644 index 0000000000..8d273d4e4c --- /dev/null +++ b/jobs/Backend/Task/ExchangeRateUpdater/Models/CzechNationalBank/ErrorResponse.cs @@ -0,0 +1,5 @@ +using System; + +namespace ExchangeRateUpdater.Models.CzechNationalBank; + +public record ErrorResponse(string description, string endPoint, string errorCode, DateTime happenedAt, string messageId); \ No newline at end of file diff --git a/jobs/Backend/Task/ExchangeRateUpdater/Models/CzechNationalBank/Rate.cs b/jobs/Backend/Task/ExchangeRateUpdater/Models/CzechNationalBank/Rate.cs new file mode 100644 index 0000000000..44ef180e3e --- /dev/null +++ b/jobs/Backend/Task/ExchangeRateUpdater/Models/CzechNationalBank/Rate.cs @@ -0,0 +1,5 @@ +using System; + +namespace ExchangeRateUpdater.Models.CzechNationalBankResponse; + +public record RecordedRate(int Amount, string Country, string Currency, string CurrencyCode, int Order, decimal Rate, DateTime ValidFor); \ No newline at end of file diff --git a/jobs/Backend/Task/ExchangeRateUpdater/ExchangeRate.cs b/jobs/Backend/Task/ExchangeRateUpdater/Models/ExchangeRate.cs similarity index 93% rename from jobs/Backend/Task/ExchangeRateUpdater/ExchangeRate.cs rename to jobs/Backend/Task/ExchangeRateUpdater/Models/ExchangeRate.cs index 58c5bb10e0..2133586d44 100644 --- a/jobs/Backend/Task/ExchangeRateUpdater/ExchangeRate.cs +++ b/jobs/Backend/Task/ExchangeRateUpdater/Models/ExchangeRate.cs @@ -1,4 +1,4 @@ -namespace ExchangeRateUpdater +namespace ExchangeRateUpdater.Models { public class ExchangeRate { diff --git a/jobs/Backend/Task/ExchangeRateUpdater/Models/Result.cs b/jobs/Backend/Task/ExchangeRateUpdater/Models/Result.cs new file mode 100644 index 0000000000..82ab02d8d7 --- /dev/null +++ b/jobs/Backend/Task/ExchangeRateUpdater/Models/Result.cs @@ -0,0 +1,20 @@ +using ExchangeRateUpdater.Models.CzechNationalBank; + +namespace ExchangeRateUpdater.Models; + +public class Result +{ + public bool Success { get; } + public T Value { get; } + public string Error { get; } + + private Result(bool success, T value, string error) + { + Success = success; + Value = value; + Error = error; + } + + public static Result Ok(T value) => new Result(true, value, null); + public static Result Fail(string error) => new Result(false, default(T), error); +} \ No newline at end of file diff --git a/jobs/Backend/Task/ExchangeRateUpdater/Notes.md b/jobs/Backend/Task/ExchangeRateUpdater/Notes.md new file mode 100644 index 0000000000..346fd5f21f --- /dev/null +++ b/jobs/Backend/Task/ExchangeRateUpdater/Notes.md @@ -0,0 +1,67 @@ +### Thoughts + +## Building a requirements List + +The task is to implement an ExchangeRateProvider for Czech National Bank. +Find data source on their web - part of the task is to find the source of the exchange rate data and a way how to extract it from there. +It is up to you to decide which technology (from .NET family) or package to use. +Any code design changes/decisions to the provided skeleton are also completely up to you. +The solution has to be buildable, runnable and the test program should output the obtained exchange rates. +Goal is to implement a fully functional provider based on real world public data source of the assigned bank. +To submit your solution, create a Pull Request from a fork. + +Please write the code like you would if you needed this to run on production environment and had to take care of it long-term. + +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. + +Translates to: + - Finding a datasource + - Able to state what Exchange rates we want to return based on a list of desired currencies + - A "source" currency to calculate against. + - A Desired response to return + +https://www.cnb.cz/cs/casto-kladene-dotazy/Kurzy-devizoveho-trhu-na-www-strankach-CNB/ + +For this project I wanted to be able to easily extract the data regarding exchange rates, there were two approaches to this + - Option 1: Take advantage of the Swagger docs and directly integrate with the API:  https://api.cnb.cz/cnbapi/swagger-ui.html#/%2Fexrates + - Option 2: Utilising the TXT file directly from the page https://www.cnb.cz/en/financial-markets/foreign-exchange-market/central-bank-exchange-rate-fixing/central-bank-exchange-rate-fixing/ + +For a production grade system, I decided to take advantage of the Swagger Docs as they are a typical gold standard for developers on information regarding what can be returned/errors, +and it is safe to assume the route won't change for the API whereas the website can change. + +What this project does: + +- Exposes a Swagger Endpoint for interacting with the API + - ``api/v1/exchange-rates/daily`` + + +## Getting Started + +Follow these steps to build, run, and test the project + +### 1. Build the Solution +Compile the entire solution to resolve dependencies and create binaries. +```bash +dotnet build ExchangeRateUpdater.sln +``` + +```bash +dotnet run --project ExchangeRateUpdater/ExchangeRateUpdater.csproj +``` + +```bash +dotnet test ExchangeRateUpdater.Tests/ExchangeRateUpdater.Tests.csproj +``` + +Things I would do if I had more time: + - Support for the ability to choose a specific date. + - Caching the API response to reduce unnecessary requests calls to the CNB api. + - Add robust integration tests utilising the JustEat client for Interception https://github.com/justeattakeaway/httpclient-interception + - A suite of automated Postman tests used as a smoke test between environments to ensure confidence that the system still works. + - Setup Runbook Alerts within monitoring Frameworks like Datadog so that any unexpected errors are raised to the attention of a developer asap. + + + diff --git a/jobs/Backend/Task/ExchangeRateUpdater/Program.cs b/jobs/Backend/Task/ExchangeRateUpdater/Program.cs deleted file mode 100644 index 379a69b1f8..0000000000 --- a/jobs/Backend/Task/ExchangeRateUpdater/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/ExchangeRateUpdater/Properties/launchSettings.json b/jobs/Backend/Task/ExchangeRateUpdater/Properties/launchSettings.json new file mode 100644 index 0000000000..6b99d4a9a7 --- /dev/null +++ b/jobs/Backend/Task/ExchangeRateUpdater/Properties/launchSettings.json @@ -0,0 +1,15 @@ +{ + "$schema": "http://json.schemastore.org/launchsettings.json", + "profiles": { + "ExchangeRateUpdater": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": true, + "launchUrl": "swagger", + "applicationUrl": "http://localhost:5001", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + } + } +} \ No newline at end of file diff --git a/jobs/Backend/Task/ExchangeRateUpdater/Startup/Program.cs b/jobs/Backend/Task/ExchangeRateUpdater/Startup/Program.cs new file mode 100644 index 0000000000..6bde10c62d --- /dev/null +++ b/jobs/Backend/Task/ExchangeRateUpdater/Startup/Program.cs @@ -0,0 +1,36 @@ +using System.Diagnostics.CodeAnalysis; +using Microsoft.AspNetCore.Builder; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; + +namespace ExchangeRateUpdater.Startup +{ + [ExcludeFromCodeCoverage] + public static class Program + { + + public static void Main(string[] args) + { + + var builder = WebApplication.CreateBuilder(args); + + builder.RegisterConfigurations(); + builder.Services.RegisterValidators(); + builder.Services.AddControllers(); + builder.Services.RegisterHttpClients(builder.Configuration); + builder.Services.AddServices(); + + var app = builder.Build(); + + if (app.Environment.IsDevelopment()) + { + app.MapSwagger(); + app.UseSwagger(); + app.UseSwaggerUI(); + } + + app.MapControllers(); + app.Run(); + } + } +} diff --git a/jobs/Backend/Task/ExchangeRateUpdater/Startup/ServiceCollectionExtensions.cs b/jobs/Backend/Task/ExchangeRateUpdater/Startup/ServiceCollectionExtensions.cs new file mode 100644 index 0000000000..94fcfcb9cd --- /dev/null +++ b/jobs/Backend/Task/ExchangeRateUpdater/Startup/ServiceCollectionExtensions.cs @@ -0,0 +1,63 @@ +using System; +using System.Diagnostics.CodeAnalysis; +using System.IO; +using System.Net.Http.Headers; +using System.Reflection; +using ExchangeRateUpdater.Business; +using ExchangeRateUpdater.Business.Processors; +using ExchangeRateUpdater.Infrastructure; +using ExchangeRateUpdater.Mapping; +using ExchangeRateUpdater.Models.Configuration; +using ExchangeRateUpdater.Models.CzechNationalBank; +using ExchangeRateUpdater.Models.CzechNationalBankResponse; +using ExchangeRateUpdater.Validators; +using FluentValidation; +using Microsoft.AspNetCore.Builder; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.OpenApi; + +namespace ExchangeRateUpdater.Startup; + +[ExcludeFromCodeCoverage] +public static class ServiceCollectionExtensions +{ + public static void AddServices(this IServiceCollection services) + { + services.AddSwaggerGen(c => + { + c.SwaggerDoc("v1", new OpenApiInfo { Title = "ExchangeRateUpdater.Docs", Version = "v1" }); + var xmlFilename = $"{Assembly.GetExecutingAssembly().GetName().Name}.xml"; + c.IncludeXmlComments(Path.Combine(AppContext.BaseDirectory, xmlFilename)); + }); + + services.AddTransient, CzechNationalBankResponseMapper>(); + services.AddTransient(); + services.AddTransient(); + } + + public static void RegisterConfigurations(this WebApplicationBuilder builder) + { + builder.Configuration.AddJsonFile("appsettings.json", optional: true, reloadOnChange: true); + builder.Configuration.AddEnvironmentVariables(); + + builder.Services.AddOptionsWithValidateOnStart("Integrations:ExchangeRateApis:CzechNationalBank"); + builder.Services.Configure(builder.Configuration.GetSection("ExchangeRateConfiguration")); + } + + public static void RegisterHttpClients(this IServiceCollection service, IConfiguration configuration) + { + service.AddHttpClient, CzechNationalBankExchangeRateClient>( client => + { + var options = configuration.GetSection("Integrations:ExchangeRateApis:CzechNationalBank").Get(); + client.BaseAddress = new Uri($"https://{options.BaseUri}"); + client.Timeout = TimeSpan.FromMilliseconds(options.TimeoutMs); + client.DefaultRequestHeaders.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json")); + }); + } + + public static void RegisterValidators(this IServiceCollection serviceCollection) + { + serviceCollection.AddScoped, ExchangeRateCurrencyCodeValidator>(); + } +} \ No newline at end of file diff --git a/jobs/Backend/Task/ExchangeRateUpdater/Validators/ExchangeRateCurrencyCodeValidator.cs b/jobs/Backend/Task/ExchangeRateUpdater/Validators/ExchangeRateCurrencyCodeValidator.cs new file mode 100644 index 0000000000..e71be81247 --- /dev/null +++ b/jobs/Backend/Task/ExchangeRateUpdater/Validators/ExchangeRateCurrencyCodeValidator.cs @@ -0,0 +1,21 @@ +using System; +using FluentValidation; + +namespace ExchangeRateUpdater.Validators; + +public class ExchangeRateCurrencyCodeValidator : AbstractValidator +{ + public ExchangeRateCurrencyCodeValidator() + { + // Input Currency Codes validated to ISO Standard without Upper Casing for Ease of use. + RuleFor(currencyCodes => currencyCodes) + .NotEmpty() + .WithName("Currency Codes"); + + + RuleForEach(currencyCodes => currencyCodes) + .MaximumLength(3) + .MinimumLength(3) + .WithName("Currency Code"); + } +} \ No newline at end of file diff --git a/jobs/Backend/Task/ExchangeRateUpdater/appsettings.Development.json b/jobs/Backend/Task/ExchangeRateUpdater/appsettings.Development.json new file mode 100644 index 0000000000..e203e9407e --- /dev/null +++ b/jobs/Backend/Task/ExchangeRateUpdater/appsettings.Development.json @@ -0,0 +1,9 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Debug", + "System": "Information", + "Microsoft": "Information" + } + } +} diff --git a/jobs/Backend/Task/ExchangeRateUpdater/appsettings.json b/jobs/Backend/Task/ExchangeRateUpdater/appsettings.json new file mode 100644 index 0000000000..05e969a20e --- /dev/null +++ b/jobs/Backend/Task/ExchangeRateUpdater/appsettings.json @@ -0,0 +1,21 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Debug", + "System": "Information", + "Microsoft": "Information" + } + }, + "ExchangeRateConfiguration": { + "SourceCurrency": "CZK" + }, + "Integrations": { + "ExchangeRateApis":{ + "CzechNationalBank": { + "BaseUri": "api.cnb.cz/cnbapi/", + "TimeoutMs": 2000, + "RetryCount": 2 + } + } + } +} \ No newline at end of file diff --git a/jobs/Backend/Task/ExchangeRateUpdater/global.json b/jobs/Backend/Task/ExchangeRateUpdater/global.json new file mode 100644 index 0000000000..a11f48e193 --- /dev/null +++ b/jobs/Backend/Task/ExchangeRateUpdater/global.json @@ -0,0 +1,7 @@ +{ + "sdk": { + "version": "10.0.0", + "rollForward": "latestMajor", + "allowPrerelease": true + } +} \ No newline at end of file diff --git a/jobs/Backend/Task/ExchangeRateUpdaterTests/Business/ExchangeRateProviderTests.cs b/jobs/Backend/Task/ExchangeRateUpdaterTests/Business/ExchangeRateProviderTests.cs new file mode 100644 index 0000000000..4db7d16836 --- /dev/null +++ b/jobs/Backend/Task/ExchangeRateUpdaterTests/Business/ExchangeRateProviderTests.cs @@ -0,0 +1,35 @@ +using ExchangeRateUpdater.Business; +using ExchangeRateUpdater.Business.Processors; +using ExchangeRateUpdater.Models; +using FluentAssertions; +using Moq; +using Xunit; + +namespace ExchangeRateUpdaterTests.Business; + +public class ExchangeRateProviderTests +{ + private readonly Mock _exchangeRateProcessorMock; + private readonly ExchangeRateProvider _provider; + + public ExchangeRateProviderTests() + { + _exchangeRateProcessorMock = new Mock(); + _provider = new ExchangeRateProvider(_exchangeRateProcessorMock.Object); + } + + [Fact] + public async Task Given_CurrencyCodes_When_GetExchangeRates_Then_CallsProcessor() + { + var currencyCodes = new[] { "USD", "EUR" }; + var expectedResult = Result>.Ok(new List()); + _exchangeRateProcessorMock + .Setup(p => p.ProcessExchangeRates(currencyCodes)) + .ReturnsAsync(expectedResult); + + var result = await _provider.GetExchangeRates(currencyCodes); + + expectedResult.Should().Be(result); + _exchangeRateProcessorMock.Verify(p => p.ProcessExchangeRates(currencyCodes), Times.Once); + } +} diff --git a/jobs/Backend/Task/ExchangeRateUpdaterTests/Business/Processors/CzechNationalBankProcessorTests.cs b/jobs/Backend/Task/ExchangeRateUpdaterTests/Business/Processors/CzechNationalBankProcessorTests.cs new file mode 100644 index 0000000000..de59856d30 --- /dev/null +++ b/jobs/Backend/Task/ExchangeRateUpdaterTests/Business/Processors/CzechNationalBankProcessorTests.cs @@ -0,0 +1,104 @@ +using ExchangeRateUpdater.Business.Processors; +using ExchangeRateUpdater.Infrastructure; +using ExchangeRateUpdater.Mapping; +using ExchangeRateUpdater.Models; +using ExchangeRateUpdater.Models.CzechNationalBank; +using ExchangeRateUpdater.Models.CzechNationalBankResponse; +using FluentAssertions; +using Microsoft.Extensions.Logging; +using Moq; +using Xunit; + +namespace ExchangeRateUpdaterTests.Business.Processors; + +public class CzechNationalBankProcessorTests +{ + private readonly Mock> _clientMock; + private readonly Mock> _mapperMock; + private readonly Mock> _loggerMock; + private readonly CzechNationalBankProcessor _processor; + + public CzechNationalBankProcessorTests() + { + _clientMock = new Mock>(); + _mapperMock = new Mock>(); + _loggerMock = new Mock>(); + _processor = new CzechNationalBankProcessor(_clientMock.Object, _mapperMock.Object, _loggerMock.Object); + } + + [Fact] + public async Task Given_EmptyClientResponse_When_ProcessExchangeRates_Then_ReturnsFailure() + { + _clientMock.Setup(c => c.RetrieveExchangeRatesAsync()).ReturnsAsync(new EmptyResponse()); + + var result = await _processor.ProcessExchangeRates(new[] { "USD" }); + + result.Success.Should().BeFalse(); + result.Error.Should().Be("CzechNationalBankProcessor_UnexpectedClientError"); + + _loggerMock.Verify( + x => x.Log( + LogLevel.Information, + It.IsAny(), + It.Is((v, t) => v.ToString()!.Contains("CzechNationalBankProcessor_FinishedProcessingCurrencies")), + null, + It.IsAny>()), + Times.Never); + } + + [Fact] + public async Task Given_ValidResponse_When_ProcessExchangeRates_Then_ReturnsMappedRates() + { + var usdRate = new RecordedRate(1, "USA", "Dollar", "USD", 1, 25.0m, DateTime.Now); + var response = new Response(new List { usdRate }); + _clientMock.Setup(c => c.RetrieveExchangeRatesAsync()).ReturnsAsync(response); + + var mappedRate = new ExchangeRate(new Currency("CZK"), new Currency("USD"), 25.0m); + _mapperMock.Setup(m => m.MapToExchangeRate(usdRate)).Returns(mappedRate); + + var result = await _processor.ProcessExchangeRates(new[] { "USD" }); + + result.Success.Should().BeTrue(); + result.Value.FirstOrDefault().Should().Be(mappedRate); + + _loggerMock.Verify( + x => x.Log( + LogLevel.Information, + It.IsAny(), + It.Is((v, t) => v.ToString()!.Contains("CzechNationalBankProcessor_ProcessingCurrencies")), + null, + It.IsAny>()), + Times.Once); + + _loggerMock.Verify( + x => x.Log( + LogLevel.Information, + It.IsAny(), + It.Is((v, t) => v.ToString()!.Contains("CzechNationalBankProcessor_FinishedProcessingCurrencies")), + null, + It.IsAny>()), + Times.Once); + } + + [Fact] + public async Task Given_CurrencyNotFound_When_ProcessExchangeRates_Then_LogsWarningAndSkips() + { + var eurRate = new RecordedRate(1, "EMU", "Euro", "EUR", 1, 24.0m, DateTime.Now); + var response = new Response(new List { eurRate }); + _clientMock.Setup(c => c.RetrieveExchangeRatesAsync()).ReturnsAsync(response); + + var result = await _processor.ProcessExchangeRates(new[] { "USD" }); + + result.Success.Should().BeTrue(); + result.Value.Should().BeEmpty(); + + _loggerMock.Verify( + x => x.Log( + LogLevel.Warning, + It.IsAny(), + It.Is((v, t) => v.ToString()!.Contains("CzechNationalBankProcessor_CurrencyNotFound_USD")), + It.IsAny(), + It.IsAny>()), + Times.Once); + } +} diff --git a/jobs/Backend/Task/ExchangeRateUpdaterTests/Controllers/ExchangeRatesControllerTests.cs b/jobs/Backend/Task/ExchangeRateUpdaterTests/Controllers/ExchangeRatesControllerTests.cs new file mode 100644 index 0000000000..4a0e078648 --- /dev/null +++ b/jobs/Backend/Task/ExchangeRateUpdaterTests/Controllers/ExchangeRatesControllerTests.cs @@ -0,0 +1,99 @@ +using ExchangeRateUpdater.Business; +using ExchangeRateUpdater.Controllers; +using ExchangeRateUpdater.Models; +using FluentAssertions; +using FluentValidation; +using FluentValidation.Results; +using Microsoft.AspNetCore.Mvc; +using Microsoft.Extensions.Logging; +using Moq; +using Xunit; + +namespace ExchangeRateUpdaterTests.Controllers; + +public class ExchangeRatesControllerTests +{ + private readonly Mock _exchangeRateProviderMock; + private readonly Mock> _currencyCodeValidatorMock; + private readonly Mock> _loggerMock; + private readonly ExchangeRatesController _sut; + + public ExchangeRatesControllerTests() + { + _exchangeRateProviderMock = new Mock(); + _currencyCodeValidatorMock = new Mock>(); + _loggerMock = new Mock>(); + _sut = new ExchangeRatesController( + _exchangeRateProviderMock.Object, + _currencyCodeValidatorMock.Object, + _loggerMock.Object); + } + + [Fact] + public async Task Given_InvalidCurrencies_When_GetSelectedCurrencyCodesDaily_Then_ReturnsBadRequest() + { + var currencies = new[] { "INVALID" }; + var validationFailures = new List { new("currencies", "Invalid currency code") }; + _currencyCodeValidatorMock + .Setup(v => v.ValidateAsync(currencies, It.IsAny())) + .ReturnsAsync(new ValidationResult(validationFailures)); + + var result = await _sut.GetSelectedCurrencyCodesDaily(currencies); + + result.Should().BeOfType() + .Which.Value.Should().BeEquivalentTo(new[] { "Invalid currency code" }); + + _loggerMock.Verify(x => x.Log( + LogLevel.Error, + It.IsAny(), + It.Is((v, t) => + v.ToString()!.Contains("ExchangeRatesController_ValidationFailure") && v.ToString()!.Contains(validationFailures[0].ErrorMessage)), + null, + It.IsAny>()), Times.Once); + } + + [Fact] + public async Task Given_ProviderReturnsFailure_When_GetSelectedCurrencyCodesDaily_Then_ReturnsBadRequestWithError() + { + const string errorMessage = "Provider error"; + var currencies = new[] { "USD" }; + _currencyCodeValidatorMock + .Setup(validator => validator.ValidateAsync(currencies, It.IsAny())) + .ReturnsAsync(new ValidationResult()); + _exchangeRateProviderMock + .Setup(provider => provider.GetExchangeRates(currencies)) + .ReturnsAsync(Result>.Fail(errorMessage)); + + var result = await _sut.GetSelectedCurrencyCodesDaily(currencies); + + result.Should().BeOfType() + .Which.Value.Should().Be(errorMessage); + + _loggerMock.Verify(x => x.Log( + LogLevel.Error, + It.IsAny(), + It.Is((v, t) => + v.ToString()!.Contains("ExchangeRatesController_ProviderError") && + v.ToString()!.Contains(errorMessage)), + null, + It.IsAny>()), Times.Once); + } + + [Fact] + public async Task Given_ValidCurrencyCodes_When_GetSelectedCurrencyCodesDaily_Then_ReturnsOkWithRates() + { + var currencyCodes = new[] { "USD" }; + var rates = new List { new(new Currency("CZK"), new Currency("USD"), 25.0m) }; + _currencyCodeValidatorMock + .Setup(validator => validator.ValidateAsync(currencyCodes, It.IsAny())) + .ReturnsAsync(new ValidationResult()); + _exchangeRateProviderMock + .Setup(provider => provider.GetExchangeRates(currencyCodes)) + .ReturnsAsync(Result>.Ok(rates)); + + var result = await _sut.GetSelectedCurrencyCodesDaily(currencyCodes); + + result.Should().BeOfType() + .Which.Value.Should().BeEquivalentTo(rates); + } +} diff --git a/jobs/Backend/Task/ExchangeRateUpdaterTests/ExchangeRateUpdaterTests.csproj b/jobs/Backend/Task/ExchangeRateUpdaterTests/ExchangeRateUpdaterTests.csproj new file mode 100644 index 0000000000..73fc10a523 --- /dev/null +++ b/jobs/Backend/Task/ExchangeRateUpdaterTests/ExchangeRateUpdaterTests.csproj @@ -0,0 +1,25 @@ + + + + net10.0 + enable + enable + false + true + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/jobs/Backend/Task/ExchangeRateUpdaterTests/Infrastructure/CzechNationalBankExchangeRateClientTests.cs b/jobs/Backend/Task/ExchangeRateUpdaterTests/Infrastructure/CzechNationalBankExchangeRateClientTests.cs new file mode 100644 index 0000000000..f75bd3d6c2 --- /dev/null +++ b/jobs/Backend/Task/ExchangeRateUpdaterTests/Infrastructure/CzechNationalBankExchangeRateClientTests.cs @@ -0,0 +1,196 @@ +using System.Net; +using System.Net.Http.Json; +using System.Text.Json; +using ExchangeRateUpdater.Infrastructure; +using ExchangeRateUpdater.Models.CzechNationalBank; +using ExchangeRateUpdater.Models.CzechNationalBankResponse; +using FluentAssertions; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; +using Moq; +using Moq.Protected; +using Xunit; + +namespace ExchangeRateUpdaterTests.Infrastructure; + +public class CzechNationalBankExchangeRateClientTests +{ + private readonly Mock _handlerMock; + private readonly Mock> _loggerMock; + private readonly CzechNationalBankExchangeRateClient _sut; + + public CzechNationalBankExchangeRateClientTests() + { + var optionsMock = new Mock>(); + optionsMock.Setup(o => o.Value).Returns(new CzechNationalBankClientOptions + { + RetryCount = 2, + TimeoutMs = 10 + }); + + _handlerMock = new Mock(); + var httpClient = new HttpClient(_handlerMock.Object) + { + BaseAddress = new Uri("https://test.com/") + }; + + _loggerMock = new Mock>(); + _sut = new CzechNationalBankExchangeRateClient(optionsMock.Object, httpClient, _loggerMock.Object); + } + + [Fact] + public async Task Given_SuccessfulResponse_When_RetrieveExchangeRatesAsync_Then_ReturnsResponse() + { + // Arrange + var expectedResponse = new Response(new List + { + new(1, "USA", "Dollar", "USD", 1, 25.125m, DateTime.Now) + }); + + _handlerMock.Protected() + .Setup>( + "SendAsync", + ItExpr.IsAny(), + ItExpr.IsAny()) + .ReturnsAsync(new HttpResponseMessage + { + StatusCode = HttpStatusCode.OK, + Content = JsonContent.Create(expectedResponse) + }); + + var result = await _sut.RetrieveExchangeRatesAsync(); + + _loggerMock.Verify( + x => x.Log( + LogLevel.Information, + It.IsAny(), + It.Is((v, t) => v.ToString()!.Contains("CzechNationalBankExchangeRateClient_RetrievedResponse")), + null, + It.IsAny>()), + Times.Once); + + var output = result.Rates.First(); + output.Should().Be(expectedResponse.Rates.First()); + } + + [Fact] + public async Task Given_UnsuccessfulStatusCode_When_RetrieveExchangeRatesAsync_Then_ReturnsEmptyResponseAndLogsError() + { + var errorResponse = new ErrorResponse("Error", "/test", "404", DateTime.Now, "123"); + + _handlerMock.Protected() + .Setup>( + "SendAsync", + ItExpr.IsAny(), + ItExpr.IsAny()) + .ReturnsAsync(new HttpResponseMessage + { + StatusCode = HttpStatusCode.NotFound, + Content = JsonContent.Create(errorResponse) + }); + + var result = await _sut.RetrieveExchangeRatesAsync(); + + result.Should().BeOfType(); + _loggerMock.Verify( + x => x.Log( + LogLevel.Error, + It.IsAny(), + It.Is((v, t) => v.ToString()!.Contains("CzechNationalBankExchangeRateClient_UnsuccessfulResponse")), + It.IsAny(), + It.IsAny>()), + Times.Once); + + _loggerMock.Verify( + x => x.Log( + LogLevel.Information, + It.IsAny(), + It.Is((v, t) => v.ToString()!.Contains("CzechNationalBankExchangeRateClient_RetrievedResponse")), + null, + It.IsAny>()), + Times.Once); + } + + [Fact] + public async Task Given_HttpRequestException_When_RetrieveExchangeRatesAsync_Then_ReturnsEmptyResponseAndLogsError() + { + _handlerMock.Protected() + .Setup>( + "SendAsync", + ItExpr.IsAny(), + ItExpr.IsAny()) + .ThrowsAsync(new HttpRequestException("Network error")); + + var result = await _sut.RetrieveExchangeRatesAsync(); + + result.Should().BeOfType(); + _loggerMock.Verify( + x => x.Log( + LogLevel.Error, + It.IsAny(), + It.Is((v, t) => v.ToString()!.Contains("CzechNationalBankExchangeRateClient_UnexpectedException")), + It.IsAny(), + It.IsAny>()), + Times.Once); + } + + [Fact] + public async Task Given_JsonException_When_RetrieveExchangeRatesAsync_Then_ReturnsEmptyResponseAndLogsError() + { + _handlerMock.Protected() + .Setup>( + "SendAsync", + ItExpr.IsAny(), + ItExpr.IsAny()) + .ThrowsAsync(new JsonException("Error")); + + var result = await _sut.RetrieveExchangeRatesAsync(); + + result.Should().BeOfType(); + _loggerMock.Verify( + x => x.Log( + LogLevel.Error, + It.IsAny(), + It.Is((v, t) => + v.ToString()!.Contains("CzechNationalBankExchangeRateClient_DeserializationException") && + v.ToString()!.Contains("Error")), + It.IsAny(), + It.IsAny>()), + Times.Once); + } + + [Fact] + public async Task Given_TimeoutException_When_RetrieveExchangeRatesAsync_Then_ReturnsEmptyResponseAndLogsError() + { + _handlerMock.Protected() + .Setup>( + "SendAsync", + ItExpr.IsAny(), + ItExpr.IsAny()) + .ThrowsAsync(new TaskCanceledException("Timeout!")); + + var result = await _sut.RetrieveExchangeRatesAsync(); + + result.Should().BeOfType(); + + _loggerMock.Verify( + x => x.Log( + LogLevel.Warning, + It.IsAny(), + It.Is((v, t) => v.ToString()!.Contains("CzechNationalBankExchangeRateClient_RetryAttempt_2")), + It.IsAny(), + It.IsAny>()), + Times.Once); + + _loggerMock.Verify( + x => x.Log( + LogLevel.Error, + It.IsAny(), + It.Is((v, t) => + v.ToString()!.Contains("CzechNationalBankExchangeRateClient_Timeout") && + v.ToString()!.Contains("Timeout!")), + It.IsAny(), + It.IsAny>()), + Times.Once); + } +} diff --git a/jobs/Backend/Task/ExchangeRateUpdaterTests/Mapping/CzechNationalBankResponseMapperTests.cs b/jobs/Backend/Task/ExchangeRateUpdaterTests/Mapping/CzechNationalBankResponseMapperTests.cs new file mode 100644 index 0000000000..913fd2c058 --- /dev/null +++ b/jobs/Backend/Task/ExchangeRateUpdaterTests/Mapping/CzechNationalBankResponseMapperTests.cs @@ -0,0 +1,44 @@ +using System; +using ExchangeRateUpdater.Mapping; +using ExchangeRateUpdater.Models.Configuration; +using ExchangeRateUpdater.Models.CzechNationalBankResponse; +using FluentAssertions; +using Microsoft.Extensions.Options; +using Moq; +using Xunit; + +namespace ExchangeRateUpdaterTests.Mapping; + +public class CzechNationalBankResponseMapperTests +{ + private readonly CzechNationalBankResponseMapper _sut; + + public CzechNationalBankResponseMapperTests() + { + var optionsMock = new Mock>(); + optionsMock.Setup(o => o.Value).Returns(new ExchangeRateConfigurationOptions + { + SourceCurrency = "CZK" + }); + _sut = new CzechNationalBankResponseMapper(optionsMock.Object); + } + + [Fact] + public void Given_RecordedRate_When_MapToExchangeRate_Then_ReturnsCorrectExchangeRate() + { + var recordedRate = new RecordedRate( + Amount: 1, + Country: "USA", + Currency: "Dollar", + CurrencyCode: "USD", + Order: 1, + Rate: 25.1236m, + ValidFor: DateTime.Now); + + var result = _sut.MapToExchangeRate(recordedRate); + + result.SourceCurrency.Code.Should().Be("CZK"); + result.TargetCurrency.Code.Should().Be("USD"); + result.Value.Should().Be(25.124m); + } +} diff --git a/jobs/Backend/Task/ExchangeRateUpdaterTests/Validators/ExchangeRateCurrencyCodeValidatorTests.cs b/jobs/Backend/Task/ExchangeRateUpdaterTests/Validators/ExchangeRateCurrencyCodeValidatorTests.cs new file mode 100644 index 0000000000..71b7b1c88e --- /dev/null +++ b/jobs/Backend/Task/ExchangeRateUpdaterTests/Validators/ExchangeRateCurrencyCodeValidatorTests.cs @@ -0,0 +1,38 @@ +using ExchangeRateUpdater.Validators; +using FluentValidation.TestHelper; +using Xunit; + +namespace ExchangeRateUpdaterTests.Validators; + +public class ExchangeRateCurrencyCodeValidatorTests +{ + private readonly ExchangeRateCurrencyCodeValidator _sut; + + public ExchangeRateCurrencyCodeValidatorTests() + { + _sut = new ExchangeRateCurrencyCodeValidator(); + } + + [Fact] + public void Given_EmptyArray_When_Validating_Then_HasError() + { + var result = _sut.TestValidate([]); + result.ShouldHaveValidationErrorFor("Currency Codes"); + } + + [Theory] + [InlineData("US")] + [InlineData("USDE")] + public void Given_InvalidLengthCurrencyCode_When_Validating_Then_HasError(string code) + { + var result = _sut.TestValidate(new[] { code }); + result.ShouldHaveValidationErrorFor("Currency Code[0]"); + } + + [Fact] + public void Given_ValidCurrencyCodes_When_Validating_Then_HasNoErrors() + { + var result = _sut.TestValidate(new[] { "USD", "EUR", "CZK" }); + result.ShouldNotHaveAnyValidationErrors(); + } +} From 338e71aea30323a4c7f2e63b67095ebbd4d1c379 Mon Sep 17 00:00:00 2001 From: OmarFarooqKhan Date: Mon, 26 Jan 2026 09:23:08 +0000 Subject: [PATCH 3/4] Requirements and Notes --- jobs/Backend/Notes.md | 129 ++++++++++++++++++ .../Backend/Task/ExchangeRateUpdater/Notes.md | 67 --------- 2 files changed, 129 insertions(+), 67 deletions(-) create mode 100644 jobs/Backend/Notes.md delete mode 100644 jobs/Backend/Task/ExchangeRateUpdater/Notes.md diff --git a/jobs/Backend/Notes.md b/jobs/Backend/Notes.md new file mode 100644 index 0000000000..b9fd1b8cf8 --- /dev/null +++ b/jobs/Backend/Notes.md @@ -0,0 +1,129 @@ +### Thoughts + +## Building a requirements List + +The task is to implement an ExchangeRateProvider for Czech National Bank. +Find data source on their web - part of the task is to find the source of the exchange rate data and a way how to extract it from there. +It is up to you to decide which technology (from .NET family) or package to use. +Any code design changes/decisions to the provided skeleton are also completely up to you. +The solution has to be buildable, runnable and the test program should output the obtained exchange rates. +Goal is to implement a fully functional provider based on real world public data source of the assigned bank. +To submit your solution, create a Pull Request from a fork. + +Please write the code like you would if you needed this to run on production environment and had to take care of it long-term. + +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. + +Translates to: +- Finding a datasource +- Able to state what Exchange rates we want to return based on a list of desired currencies +- A "source" currency to calculate against. +- A Desired response to return + +### Discovery +I navigated the CNB website to retrieve information around any API/data that relates to Exchange rates and found the following link describing an API. +https://www.cnb.cz/cs/casto-kladene-dotazy/Kurzy-devizoveho-trhu-na-www-strankach-CNB/ + +For this project I wanted to be able to easily extract the data regarding exchange rates, there were two approaches to this: +- Option 1: Take advantage of the Swagger docs and directly integrate with the API: https://api.cnb.cz/cnbapi/swagger-ui.html#/%2Fexrates +- Option 2: Utilising the TXT file directly from the page https://www.cnb.cz/en/financial-markets/foreign-exchange-market/central-bank-exchange-rate-fixing/central-bank-exchange-rate-fixing/ + +For a production grade system, I decided to take advantage of the Swagger Docs as they are a typical gold standard for developers on information regarding what can be returned/errors, +and it is safe to assume the route won't change for the API whereas the website can change. + +### Programming Philosophies That I followed for this project +- Adherence to SOLID principals +- Testability +- Readability +- Observability +- KISS & DRY + +### Project Configuration +With my approach I decided to upgrade the project to utilise .net 10. +It required little effort and would provide a long term benefit in terms of active support in a production environment and ensure security updates are available. +I utilised the option pattern so that API configuration can be done with a simple application.json modification and allow for a source currency to be defined. + +Additionally I added Polly and FluentValidation to assist with building a more resilient system to solving transient network errors and validating user input respectively. +The Test project mimics the folder structure of the ExchangeRateUpdater Project so that it is easy to identify where the tests are. They were also written with BDD in mind. + +I decided to define a contract `IExchangeRateClient` where if we needed to integrate with another API, we could be free to do so without much hassle + +## Core Principles for the development in the project +## Resiliency +This was achieved using Polly in the CzechNationalBankExchangeRateClient. +This client uses retrys and timeouts to manage the duration of a request and give it the ablilty to deal with transient errors. +These values are configured in appSettings.json + +## Observability + - When it comes to observability, I try to follow the idea of being able to "Follow your code via Logs", and this was a practice that I tried to follow this project too. +I not only accounted for error cases but also what should happen normally e.g, if we recieve a response from the external api. +Additionally i've accounted for exceptions and Logged them individually to identify specific errors. +I've also created a `ErrorLog` to extract the important information like the status code and message should there be an error. + +## Maintainability +- I've explicitly created a "EmptyResponse" object to handle the cases where there is an unexpected failure. +This avoids returning a "null" that is just obscure but instead returns a specific object that describes a fault. +- I've also used the ResultsPattern to help identify successful outcomes to those that are undesired. +- Single responsiblity has been a core component of this project as each class has one purpose in mind. It has allowed for simple and easy tests that are quick to verify. + +### Testing +- FluentAssertions as they are really readable and assertions just make sense. +- Moq for mocking out dependencies; really useful and very familiar with/its wide known. +- XUnit as familiar framework. + +### Testing Strategy +- Generally I try to stay close to the Given_When_Then format as its clear to identify the objective behind a test. +- I prioritised mocking dependencies and Followed the Arrange Act Assert Pattern to make the tests clear to a reader. +- Utilising SOLID patterns also helped make tests easier as the objective behind a class was straight forward. +- Tests share an identical name with their class and follow a similar folder structure to ensure easy traceability. + +What this project does: +- Exposes a Swagger Endpoint for interacting with the API + - `api/v1/exchange-rates/daily` + - This endpoint fetches the desired currencies and returns the Exchange based on the Source Currency for the current business day. + +## Getting Started + +Follow these steps to build, run, and test the project + +### 1. Build the Solution +Compile the entire solution to resolve dependencies and create binaries. +```bash +dotnet build ExchangeRateUpdater.sln +``` + +### 2. Run the Solution +Compile the entire solution to resolve dependencies and create binaries. +```bash +dotnet run --project ExchangeRateUpdater/ExchangeRateUpdater.csproj +``` + +### 3. Call the endpoint +Utilise the Swagger Docs (Accessible via /swagger/index.html) + +Alternatively you can use the following CURL or Call the endpoint `api/v1/exchange-rates/daily` via Postman/Insomnia. + +```markdown +curl -X 'GET' \ + 'http://localhost:5001/api/v1/exchange-rates/daily?currencies=USD¤cies=GBP¤cies=XAK' \ + -H 'accept: application/json' +``` + +### Run the Tests +```bash +dotnet test ExchangeRateUpdater.Tests/ExchangeRateUpdater.Tests.csproj +``` + +### Note +When running locally, utlilse the LaunchSettings configuration so that its simpler to run. + +Things I would do if I had more time: +- Support for the ability to choose a specific date, this could be done via utlising the existing integrated API or Expanding to using the other Endpoints. + - This can easily be done by taking advantage of the `IExchangeRateClient` and `CzechNationalBankExchangeRateClient` since there is a defined contract to retrieve ExchangeRate Data and the Client is open to expansion. +- Caching the API response to reduce unnecessary requests calls to the CNB api. +- Add robust integration tests utilising the JustEat client for Interception https://github.com/justeattakeaway/httpclient-interception +- A suite of automated Postman tests used as a smoke test between environments to ensure confidence that the system still works. +- Setup Runbook Alerts within monitoring Frameworks like Datadog so that any unexpected errors are raised to the attention of a developer asap. diff --git a/jobs/Backend/Task/ExchangeRateUpdater/Notes.md b/jobs/Backend/Task/ExchangeRateUpdater/Notes.md deleted file mode 100644 index 346fd5f21f..0000000000 --- a/jobs/Backend/Task/ExchangeRateUpdater/Notes.md +++ /dev/null @@ -1,67 +0,0 @@ -### Thoughts - -## Building a requirements List - -The task is to implement an ExchangeRateProvider for Czech National Bank. -Find data source on their web - part of the task is to find the source of the exchange rate data and a way how to extract it from there. -It is up to you to decide which technology (from .NET family) or package to use. -Any code design changes/decisions to the provided skeleton are also completely up to you. -The solution has to be buildable, runnable and the test program should output the obtained exchange rates. -Goal is to implement a fully functional provider based on real world public data source of the assigned bank. -To submit your solution, create a Pull Request from a fork. - -Please write the code like you would if you needed this to run on production environment and had to take care of it long-term. - -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. - -Translates to: - - Finding a datasource - - Able to state what Exchange rates we want to return based on a list of desired currencies - - A "source" currency to calculate against. - - A Desired response to return - -https://www.cnb.cz/cs/casto-kladene-dotazy/Kurzy-devizoveho-trhu-na-www-strankach-CNB/ - -For this project I wanted to be able to easily extract the data regarding exchange rates, there were two approaches to this - - Option 1: Take advantage of the Swagger docs and directly integrate with the API:  https://api.cnb.cz/cnbapi/swagger-ui.html#/%2Fexrates - - Option 2: Utilising the TXT file directly from the page https://www.cnb.cz/en/financial-markets/foreign-exchange-market/central-bank-exchange-rate-fixing/central-bank-exchange-rate-fixing/ - -For a production grade system, I decided to take advantage of the Swagger Docs as they are a typical gold standard for developers on information regarding what can be returned/errors, -and it is safe to assume the route won't change for the API whereas the website can change. - -What this project does: - -- Exposes a Swagger Endpoint for interacting with the API - - ``api/v1/exchange-rates/daily`` - - -## Getting Started - -Follow these steps to build, run, and test the project - -### 1. Build the Solution -Compile the entire solution to resolve dependencies and create binaries. -```bash -dotnet build ExchangeRateUpdater.sln -``` - -```bash -dotnet run --project ExchangeRateUpdater/ExchangeRateUpdater.csproj -``` - -```bash -dotnet test ExchangeRateUpdater.Tests/ExchangeRateUpdater.Tests.csproj -``` - -Things I would do if I had more time: - - Support for the ability to choose a specific date. - - Caching the API response to reduce unnecessary requests calls to the CNB api. - - Add robust integration tests utilising the JustEat client for Interception https://github.com/justeattakeaway/httpclient-interception - - A suite of automated Postman tests used as a smoke test between environments to ensure confidence that the system still works. - - Setup Runbook Alerts within monitoring Frameworks like Datadog so that any unexpected errors are raised to the attention of a developer asap. - - - From a4f9095d32a4f16f866d1ec95e36e02e69eaf8cd Mon Sep 17 00:00:00 2001 From: OmarFarooqKhan Date: Mon, 26 Jan 2026 09:46:25 +0000 Subject: [PATCH 4/4] uneeded package ref --- .../ExchangeRateUpdaterTests/ExchangeRateUpdaterTests.csproj | 1 - 1 file changed, 1 deletion(-) diff --git a/jobs/Backend/Task/ExchangeRateUpdaterTests/ExchangeRateUpdaterTests.csproj b/jobs/Backend/Task/ExchangeRateUpdaterTests/ExchangeRateUpdaterTests.csproj index 73fc10a523..fb4f41b0d3 100644 --- a/jobs/Backend/Task/ExchangeRateUpdaterTests/ExchangeRateUpdaterTests.csproj +++ b/jobs/Backend/Task/ExchangeRateUpdaterTests/ExchangeRateUpdaterTests.csproj @@ -13,7 +13,6 @@ -