diff --git a/.gitignore b/.gitignore index fd35865456..047fd6e82d 100644 --- a/.gitignore +++ b/.gitignore @@ -21,3 +21,6 @@ node_modules bower_components npm-debug.log + +# .Net Backend Task +jobs/Backend/Task/.vs/* \ No newline at end of file diff --git a/jobs/Backend/ExchangeRateUpdater.Tests/CnbApiClientTests.cs b/jobs/Backend/ExchangeRateUpdater.Tests/CnbApiClientTests.cs new file mode 100644 index 0000000000..c1a348ff59 --- /dev/null +++ b/jobs/Backend/ExchangeRateUpdater.Tests/CnbApiClientTests.cs @@ -0,0 +1,134 @@ +using ExchangeRateUpdater.Common; +using ExchangeRateUpdater.Configuration; +using ExchangeRateUpdater.Services; +using ExchangeRateUpdater.Services.Models.External; +using ExchangeRateUpdater.Tests.Services.TestHelper; +using FakeItEasy; +using FluentAssertions; +using Microsoft.Extensions.Logging; +using System.Text.Json; + +namespace ExchangeRateUpdater.Tests.Services; + +public class CnbApiClientTests +{ + private readonly HttpClient _httpClient; + private readonly FakeHttpMessageHandler _httpHandler; + private readonly ApiConfiguration _config; + private readonly TestLogger _logger; + private readonly IDateTimeSource _dateTimeSource; + + public CnbApiClientTests() + { + _httpHandler = new FakeHttpMessageHandler(); + _httpClient = new HttpClient(_httpHandler); + + _config = new ApiConfiguration + { + Language = "en", + ExchangeRateEndpoint = "https://api.example.com/rates" + }; + + _logger = new TestLogger(); + _dateTimeSource = A.Fake(); + A.CallTo(() => _dateTimeSource.UtcNow) + .Returns(new DateTime(2025, 10, 27)); + } + + [Fact] + public async Task GetExchangeRatesAsync_ReturnsRates_WhenResponseIsValid() + { + var expectedRates = new[] + { + new CnbRate ("USD", 1.0m, 1), + new CnbRate ("EUR", 0.9m, 1) + }; + + var response = new CnbExchangeResponse(expectedRates); + _httpHandler.SetResponse(response); + + var client = new CnbApiClient(_httpClient, _config, _logger, _dateTimeSource); + + var result = await client.GetExchangeRatesAsync(); + + result.Should().BeEquivalentTo(expectedRates); + } + + [Fact] + public async Task GetExchangeRatesAsync_ReturnsEmpty_WhenRatesAreNull() + { + var response = new CnbExchangeResponse(null); + _httpHandler.SetResponse(response); + + var client = new CnbApiClient(_httpClient, _config, _logger, _dateTimeSource); + + var result = await client.GetExchangeRatesAsync(); + + result.Should().BeEmpty(); + } + + [Fact] + public async Task GetExchangeRatesAsync_ThrowsException_WhenHttpFails() + { + _httpHandler.SetException(new HttpRequestException("Network error")); + + var client = new CnbApiClient(_httpClient, _config, _logger, _dateTimeSource); + + Func act = async () => await client.GetExchangeRatesAsync(); + + await act.Should().ThrowAsync() + .WithMessage("Network error"); + + _logger.LogMessages.Should().Contain(m => + m.Message.Contains("An error occurred") + && m.LogLevel == LogLevel.Error); + } + + [Fact] + public async Task GetExchangeRatesAsync_LogsRequestUri() + { + var response = new CnbExchangeResponse([]); + _httpHandler.SetResponse(response); + + var client = new CnbApiClient(_httpClient, _config, _logger, _dateTimeSource); + + await client.GetExchangeRatesAsync(); + + _logger.LogMessages.Should().Contain(m => + m.Message.Contains("https://api.example.com/rates") + && m.LogLevel == LogLevel.Information); + } + + [Fact] + public async Task GetExchangeRatesAsync_ThrowsJsonException_WhenResponseIsMalformed() + { + _httpHandler.SetRawResponse("not valid json"); + + var client = new CnbApiClient(_httpClient, _config, _logger, _dateTimeSource); + + var act = client.GetExchangeRatesAsync; + + await act.Should().ThrowAsync(); + + _logger.LogMessages.Should().Contain(m => + m.Message.Contains("An error occurred") + && m.LogLevel == LogLevel.Error); + } + + [Fact] + public async Task GetExchangeRatesAsync_ThrowsTaskCanceledException_WhenRequestTimesOut() + { + _httpHandler.SetDelayedResponse(TimeSpan.FromSeconds(10)); + _httpClient.Timeout = TimeSpan.FromMilliseconds(100); // Force timeout + + var client = new CnbApiClient(_httpClient, _config, _logger, _dateTimeSource); + + var act = client.GetExchangeRatesAsync; + + await act.Should().ThrowAsync(); + + _logger.LogMessages.Should().Contain(m => + m.Message.Contains("An error occurred") + && m.LogLevel == LogLevel.Error); + } +} diff --git a/jobs/Backend/ExchangeRateUpdater.Tests/ExchangeRateProviderTests.cs b/jobs/Backend/ExchangeRateUpdater.Tests/ExchangeRateProviderTests.cs new file mode 100644 index 0000000000..790e2a0024 --- /dev/null +++ b/jobs/Backend/ExchangeRateUpdater.Tests/ExchangeRateProviderTests.cs @@ -0,0 +1,101 @@ +using ExchangeRateUpdater.Services; +using ExchangeRateUpdater.Services.Interfaces; +using ExchangeRateUpdater.Services.Models; +using ExchangeRateUpdater.Services.Models.External; +using ExchangeRateUpdater.Tests.Services.TestHelper; +using FakeItEasy; +using FluentAssertions; +using Microsoft.Extensions.Logging; + +namespace ExchangeRateUpdater.Tests.Services; + +public class ExchangeRateProviderTests +{ + private readonly IApiClient _apiClient; + private readonly IExchangeRateCacheService _cacheService; + private readonly TestLogger _logger; + private readonly ExchangeRateProvider _provider; + + public ExchangeRateProviderTests() + { + _apiClient = A.Fake>(); + _cacheService = A.Fake(); + _logger = new TestLogger(); + _provider = new ExchangeRateProvider(_apiClient, _cacheService, _logger); + } + + [Fact] + public async Task GetExchangeRates_ShouldReturnRates_WhenCurrenciesAreValid() + { + // Arrange + var currencies = new[] { new Currency("USD"), new Currency("EUR"), new Currency("CZK") }; + var apiRates = new List + { + new CnbRate("USD", 23.5m, 1), + new CnbRate("EUR", 25.0m, 10) + }; + + A.CallTo(() => _apiClient.GetExchangeRatesAsync()) + .Returns(Task.FromResult>(apiRates)); + + // Act + var result = await _provider.GetExchangeRates(currencies); + + // Assert + result.Should().HaveCount(2); + result.Should().Contain(r => r.SourceCurrency.Code == "USD" && r.TargetCurrency.Code == "CZK" && r.Value == 23.5m); + result.Should().Contain(r => r.SourceCurrency.Code == "EUR" && r.TargetCurrency.Code == "CZK" && r.Value == 2.50m); + } + + [Fact] + public async Task GetExchangeRates_ShouldIgnoreTargetCurrency() + { + // Arrange + var currencies = new[] { new Currency("CZK") }; + + // Act + var result = await _provider.GetExchangeRates(currencies); + + // Assert + result.Should().BeEmpty(); + A.CallTo(() => _apiClient.GetExchangeRatesAsync()) + .MustNotHaveHappened(); + } + + [Fact] + public async Task GetExchangeRates_ShouldLogWarning_WhenSomeRatesAreMissing() + { + // Arrange + var currencies = new[] { new Currency("USD"), new Currency("GBP") }; + var apiRates = new List + { + new CnbRate("USD", 23.5m, 1) + }; + + A.CallTo(() => _apiClient.GetExchangeRatesAsync()) + .Returns(Task.FromResult>(apiRates)); + + // Act + var result = await _provider.GetExchangeRates(currencies); + + // Assert + result.Should().HaveCount(1); + result.Should().ContainSingle(r => r.SourceCurrency.Code == "USD"); + + _logger.LogMessages.Should().Contain(m => + m.Message.Contains("GBP") + && m.LogLevel == LogLevel.Warning); + } + + [Fact] + public async Task GetExchangeRates_ShouldThrow_WhenCurrenciesIsNull() + { + // Act + var act = async () => await _provider.GetExchangeRates(null); + + // Assert + await act.Should().ThrowAsync(); + A.CallTo(() => _apiClient.GetExchangeRatesAsync()) + .MustNotHaveHappened(); + } +} diff --git a/jobs/Backend/ExchangeRateUpdater.Tests/ExchangeRateUpdater.Tests.csproj b/jobs/Backend/ExchangeRateUpdater.Tests/ExchangeRateUpdater.Tests.csproj new file mode 100644 index 0000000000..5093a03d7f --- /dev/null +++ b/jobs/Backend/ExchangeRateUpdater.Tests/ExchangeRateUpdater.Tests.csproj @@ -0,0 +1,36 @@ + + + + net8.0 + enable + enable + + false + true + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + + + + + + + + diff --git a/jobs/Backend/ExchangeRateUpdater.Tests/TestHelper/FakeHttpMessageHandler.cs b/jobs/Backend/ExchangeRateUpdater.Tests/TestHelper/FakeHttpMessageHandler.cs new file mode 100644 index 0000000000..19bf726688 --- /dev/null +++ b/jobs/Backend/ExchangeRateUpdater.Tests/TestHelper/FakeHttpMessageHandler.cs @@ -0,0 +1,71 @@ +using System.Net; +using System.Text; +using System.Text.Json; + +namespace ExchangeRateUpdater.Tests.Services.TestHelper; + +public class FakeHttpMessageHandler : HttpMessageHandler +{ + private HttpResponseMessage _response; + private Exception _exception; + private TimeSpan _delay = TimeSpan.Zero; + private Func? _responseFactory; + + public void SetResponse(object content) + { + var json = JsonSerializer.Serialize(content); + _response = new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new StringContent(json, Encoding.UTF8, "application/json") + }; + } + + public void SetException(Exception ex) + { + _exception = ex; + } + + public void SetRawResponse(string rawJson) + { + _response = new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new StringContent(rawJson, Encoding.UTF8, "application/json") + }; + } + + public void SetDelayedResponse(TimeSpan delay) + { + _response = new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new StringContent("{}", Encoding.UTF8, "application/json") + }; + + _delay = delay; + } + + protected override Task SendAsync( + HttpRequestMessage request, + CancellationToken cancellationToken) + { + if (_responseFactory is not null) + { + var result = _responseFactory(); + if (result is Exception ex) + throw ex; + + var json = JsonSerializer.Serialize(result); + return Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new StringContent(json, Encoding.UTF8, "application/json") + }); + } + + if (_exception != null) + throw _exception; + + if (_delay > TimeSpan.Zero) + return Task.Delay(_delay, cancellationToken).ContinueWith(_ => _response, cancellationToken); + + return Task.FromResult(_response); + } +} diff --git a/jobs/Backend/ExchangeRateUpdater.Tests/TestHelper/TestLogger.cs b/jobs/Backend/ExchangeRateUpdater.Tests/TestHelper/TestLogger.cs new file mode 100644 index 0000000000..14ba0f9c90 --- /dev/null +++ b/jobs/Backend/ExchangeRateUpdater.Tests/TestHelper/TestLogger.cs @@ -0,0 +1,54 @@ +using Microsoft.Extensions.Logging; +using System.Text.RegularExpressions; + +namespace ExchangeRateUpdater.Tests.Services.TestHelper +{ + public class TestLogger : ILogger + { + public List LogMessages { get; } + + public TestLogger() + { + LogMessages = new List(); + } + + public void Log( + LogLevel logLevel, + EventId eventId, + TState state, + Exception exception, + Func formatter) + { + LogMessages.Add(new LogMessage(logLevel, formatter(state, exception))); + } + + public void ClearLogs() + { + LogMessages.Clear(); + } + + public bool IsEnabled(LogLevel logLevel) => true; + + public IDisposable BeginScope(TState state) + => default; + + public bool ContainsLog(LogLevel logLevel, string message) => + LogMessages.Any(m => m.LogLevel == logLevel && m.Message == message); + + public bool ContainsLogMatchingRegex(LogLevel logLevel, string regex) => + LogMessages.Any(m => m.LogLevel == logLevel && Regex.IsMatch(m.Message, regex)); + } + + public class LogMessage + { + public LogLevel LogLevel { get; } + public string Message { get; } + + public LogMessage(LogLevel logLevel, string message) + { + LogLevel = logLevel; + Message = message; + } + } +} + diff --git a/jobs/Backend/Readme.md b/jobs/Backend/Readme.md index f2195e44dd..a7330fe381 100644 --- a/jobs/Backend/Readme.md +++ b/jobs/Backend/Readme.md @@ -1,6 +1,105 @@ # Mews backend developer task -We are focused on multiple backend frameworks at Mews. Depending on the job position you are applying for, you can choose among the following: +# 💱 Exchange Rate Updater +A .NET 8 console application that fetches and caches exchange rates from the Czech National Bank (CNB), using Quartz.NET for scheduled execution and Polly for resilient HTTP communication. A pragmatic approach combined with OOP and SOLID principles has been taken that particularly focuses on human-readable and intuitive code with modularity, reliability and ease of future extension. -* [.NET](DotNet.md) -* [Ruby on Rails](RoR.md) +## TLibraries and Dependencies +| Component | Purpose | +|------------------------|-----------------------------------------| +| .NET 8 | Core framework | +| HttpClientFactory | Typed API client | +| Polly | Retry and timeout policies | +| Quartz.NET | Job scheduling | +| MemoryCache | In-memory caching of exchange rates | +| Microsoft.Extensions | Hosting, Logging, Configuration | +--- + +## Main Features +- **CNB API integration** + - Typed HTTP client with configurable headers + - Normalizes rates per 1 unit +- **Caching** + - Avoids redundant API calls + - Skips invalid currencies + - Combines cached and fresh results +- **Modular Architecture** + - Hosted DI setup via `ServiceCollectionExtensions` + - Easily extendable to other exchange rate providers with generics +- **Structured Logging** + - Logs API calls, retries, missing currencies and job execution + +- **Quartz.NET Scheduled Jobs** + - Runs once immediately on startup to catch current exchange rates + - Runs daily at **14:30:30 CEST** (weekdays) + + The `ExchangeRateRefreshJob` runs on startup and at 14:30:30 CEST weekdays: + + ```csharp + q.AddTrigger(opts => opts + .ForJob(jobKey) + .WithIdentity("ExchangeRateRefreshTrigger") + .WithSchedule(CronScheduleBuilder + .CronSchedule("30 30 14 ? * MON-FRI") + .InTimeZone(TimeZoneInfo.FindSystemTimeZoneById("Central Europe Standard Time")))); + ``` + +- **Retry Policy with Polly** + - Retries transient failures and timeouts + - Handles rate limits and `Retry-After` headers + - Adds jitter and exponential backoff + + ```csharp + .WaitAndRetryAsync( + 3, + retryCount => TimeSpan.FromSeconds(Math.Pow(2, retryCount)) + jitter, + onRetry: LogRetry); + ``` + +## Getting Started + +1. Clone the repo +2. [Configure `appsettings.json`](#configuration) with CNB API details and a list of ISO currency symbols +3. Run the app: + +```bash +dotnet run +``` + +You must be connected to the internet, you’ll be presented with: + +- Exchange rates printed to console +- Logs for API calls, retries, and job execution + + image + + +## Configuration + +Example `appsettings.json`: + +```json +{ + "ExchangeRateConfiguration": { + "CurrencyCodes": [ "USD", "EUR", "GBP" ] + }, + "ApiConfiguration": { + "Name": "CNB", + "BaseUrl": "https://api.cnb.cz", + "ExchangeRateEndpoint": "exrates/daily", + "Language": "EN", + "RequestTimeoutInSeconds": 20, + "RetryTimeOutInSeconds": 5, + "DefaultRequestHeaders": { + "Accept": "application/json" + } + } +} +``` + +## Potential Future Enhancements +- Add more tests, especially integration and end-to-end tests +- Add persistent caching, preferably distributed (e.g. Redis) +- Add new providers by implementing `IApiClient` +- Expose rates via REST or gRPC +- Add health checks or metrics +- Add CI/CD pipeline for automated testing and deployment \ No newline at end of file diff --git a/jobs/Backend/Task/Common/Constants.cs b/jobs/Backend/Task/Common/Constants.cs new file mode 100644 index 0000000000..c9f89cdb6b --- /dev/null +++ b/jobs/Backend/Task/Common/Constants.cs @@ -0,0 +1,7 @@ +namespace ExchangeRateUpdater.Common; + +public static class Constants +{ + public const string ApiConfiguration = nameof(ApiConfiguration); + public const string ExchangeRateConfiguration = nameof(ExchangeRateConfiguration); +} \ No newline at end of file diff --git a/jobs/Backend/Task/Common/DateTimeSource.cs b/jobs/Backend/Task/Common/DateTimeSource.cs new file mode 100644 index 0000000000..a96520c700 --- /dev/null +++ b/jobs/Backend/Task/Common/DateTimeSource.cs @@ -0,0 +1,6 @@ +namespace ExchangeRateUpdater.Common; + +public class DateTimeSource : IDateTimeSource +{ + public DateTime UtcNow => DateTime.UtcNow; +} \ No newline at end of file diff --git a/jobs/Backend/Task/Common/IDateTimeSource.cs b/jobs/Backend/Task/Common/IDateTimeSource.cs new file mode 100644 index 0000000000..6b5992fae2 --- /dev/null +++ b/jobs/Backend/Task/Common/IDateTimeSource.cs @@ -0,0 +1,6 @@ +namespace ExchangeRateUpdater.Common; + +public interface IDateTimeSource +{ + DateTime UtcNow { get; } +} \ No newline at end of file diff --git a/jobs/Backend/Task/Configuration/ApiConfiguration.cs b/jobs/Backend/Task/Configuration/ApiConfiguration.cs new file mode 100644 index 0000000000..2bc2dc0f19 --- /dev/null +++ b/jobs/Backend/Task/Configuration/ApiConfiguration.cs @@ -0,0 +1,12 @@ +namespace ExchangeRateUpdater.Configuration; + +public sealed record ApiConfiguration +{ + public string Name { get; init; } = string.Empty; + public string BaseUrl { get; init; } = string.Empty; + public string ExchangeRateEndpoint { get; init; } = string.Empty; + public string Language { get; init; } = "EN"; + public int RequestTimeoutInSeconds { get; init; } = 20; + public int RetryTimeOutInSeconds { get; init; } = 5; + public Dictionary DefaultRequestHeaders { get; init; } = []; +} \ No newline at end of file diff --git a/jobs/Backend/Task/Configuration/ExchangeRateConfiguration.cs b/jobs/Backend/Task/Configuration/ExchangeRateConfiguration.cs new file mode 100644 index 0000000000..b71161008c --- /dev/null +++ b/jobs/Backend/Task/Configuration/ExchangeRateConfiguration.cs @@ -0,0 +1,6 @@ +namespace ExchangeRateUpdater.Configuration; + +public sealed record ExchangeRateConfiguration +{ + public IEnumerable CurrencyCodes { get; init; } = []; +} \ No newline at end of file diff --git a/jobs/Backend/Task/ExchangeRate.cs b/jobs/Backend/Task/ExchangeRate.cs deleted file mode 100644 index 58c5bb10e0..0000000000 --- a/jobs/Backend/Task/ExchangeRate.cs +++ /dev/null @@ -1,23 +0,0 @@ -namespace ExchangeRateUpdater -{ - public class ExchangeRate - { - public ExchangeRate(Currency sourceCurrency, Currency targetCurrency, decimal value) - { - SourceCurrency = sourceCurrency; - TargetCurrency = targetCurrency; - Value = value; - } - - public Currency SourceCurrency { get; } - - public Currency TargetCurrency { get; } - - public decimal Value { get; } - - public override string ToString() - { - return $"{SourceCurrency}/{TargetCurrency}={Value}"; - } - } -} diff --git a/jobs/Backend/Task/ExchangeRateProvider.cs b/jobs/Backend/Task/ExchangeRateProvider.cs deleted file mode 100644 index 6f82a97fbe..0000000000 --- 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 index 2fc654a12b..5c636d00dd 100644 --- a/jobs/Backend/Task/ExchangeRateUpdater.csproj +++ b/jobs/Backend/Task/ExchangeRateUpdater.csproj @@ -1,8 +1,27 @@  - - Exe - net6.0 - + + Exe + net8.0 + enable + enable + + + + + + + + + + + + PreserveNewest + + + + + + \ No newline at end of file diff --git a/jobs/Backend/Task/ExchangeRateUpdater.sln b/jobs/Backend/Task/ExchangeRateUpdater.sln index 89be84daff..e3ac174778 100644 --- a/jobs/Backend/Task/ExchangeRateUpdater.sln +++ b/jobs/Backend/Task/ExchangeRateUpdater.sln @@ -1,10 +1,12 @@  Microsoft Visual Studio Solution File, Format Version 12.00 -# Visual Studio 14 -VisualStudioVersion = 14.0.25123.0 +# Visual Studio Version 18 +VisualStudioVersion = 18.0.11012.119 d18.0 MinimumVisualStudioVersion = 10.0.40219.1 Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ExchangeRateUpdater", "ExchangeRateUpdater.csproj", "{7B2695D6-D24C-4460-A58E-A10F08550CE0}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ExchangeRateUpdater.Tests", "..\ExchangeRateUpdater.Tests\ExchangeRateUpdater.Tests.csproj", "{F694A872-3E7C-42B6-B544-B5C5546875BC}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -15,8 +17,15 @@ Global {7B2695D6-D24C-4460-A58E-A10F08550CE0}.Debug|Any CPU.Build.0 = Debug|Any CPU {7B2695D6-D24C-4460-A58E-A10F08550CE0}.Release|Any CPU.ActiveCfg = Release|Any CPU {7B2695D6-D24C-4460-A58E-A10F08550CE0}.Release|Any CPU.Build.0 = Release|Any CPU + {F694A872-3E7C-42B6-B544-B5C5546875BC}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {F694A872-3E7C-42B6-B544-B5C5546875BC}.Debug|Any CPU.Build.0 = Debug|Any CPU + {F694A872-3E7C-42B6-B544-B5C5546875BC}.Release|Any CPU.ActiveCfg = Release|Any CPU + {F694A872-3E7C-42B6-B544-B5C5546875BC}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE EndGlobalSection + GlobalSection(ExtensibilityGlobals) = postSolution + SolutionGuid = {B43CDE54-9D19-4757-8785-2F0451ACD248} + EndGlobalSection EndGlobal diff --git a/jobs/Backend/Task/Extensions/ServiceCollectionExtensions.cs b/jobs/Backend/Task/Extensions/ServiceCollectionExtensions.cs new file mode 100644 index 0000000000..29ad1bedb2 --- /dev/null +++ b/jobs/Backend/Task/Extensions/ServiceCollectionExtensions.cs @@ -0,0 +1,141 @@ +using ExchangeRateUpdater.Common; +using ExchangeRateUpdater.Configuration; +using ExchangeRateUpdater.Jobs; +using ExchangeRateUpdater.Services; +using ExchangeRateUpdater.Services.Interfaces; +using ExchangeRateUpdater.Services.Models.External; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using Polly; +using Polly.Extensions.Http; +using Polly.Timeout; +using Quartz; + +namespace ExchangeRateUpdater.Extensions +{ + public static class ServiceCollectionExtensions + { + public static void AddServices(this IServiceCollection services, IConfiguration configuration) + { + var apiConfiguration = configuration + .GetSection(Constants.ApiConfiguration) + .Get(); + + var httpClientBuilder = services.AddHttpClient( + apiConfiguration!.Name, + o => + { + o.BaseAddress = new Uri(apiConfiguration.BaseUrl); + o.Timeout = TimeSpan.FromSeconds(apiConfiguration.RequestTimeoutInSeconds); + + if (apiConfiguration.DefaultRequestHeaders.Count == 0) + return; + + foreach (var entry in apiConfiguration.DefaultRequestHeaders) + { + o.DefaultRequestHeaders.Add(entry.Key, entry.Value); + } + }) + + .AddPolicyHandler((serviceProvider, _) => + CreateDefaultTransientRetryPolicy(serviceProvider, apiConfiguration.RetryTimeOutInSeconds)); + + services.AddQuartz(q => + { + var jobKey = new JobKey("ExchangeRateRefreshJob"); + + q.AddJob(opts => opts.WithIdentity(jobKey)); + + // Scheduled trigger (14:30:30 CEST weekdays), allowing a buffer of 30 seconds + q.AddTrigger(opts => opts + .ForJob(jobKey) + .WithIdentity("ExchangeRateRefreshTrigger") + .WithSchedule(CronScheduleBuilder + .CronSchedule("30 30 14 ? * MON-FRI") + .InTimeZone(TimeZoneInfo.FindSystemTimeZoneById("Central Europe Standard Time")))); + + // Immediate trigger on startup + q.AddTrigger(opts => opts + .ForJob(jobKey) + .WithIdentity("ExchangeRateStartupTrigger") + .StartNow()); + }); + + services.AddMemoryCache(); + services.AddQuartzHostedService(); + services.AddSingleton(apiConfiguration); + services.AddScoped(); + services.AddScoped(); + httpClientBuilder.AddTypedClient, CnbApiClient>(); + services.AddScoped(); + } + + // Set up up to 3 retries with Polly with random jitter + private static IAsyncPolicy CreateDefaultTransientRetryPolicy( + IServiceProvider provider, + int retryTimeOut) + { + var jitter = new Random(); + var retryPolicy = HttpPolicyExtensions + .HandleTransientHttpError() + .Or() + .OrResult( + msg => msg.StatusCode == System.Net.HttpStatusCode.TooManyRequests) + .OrResult(msg => msg?.Headers.RetryAfter != null) + .WaitAndRetryAsync( + 3, + (retryCount, response, _) => + response.Result?.Headers.RetryAfter?.Delta ?? + TimeSpan.FromSeconds( + Math.Pow(2, retryCount)) + + TimeSpan.FromMilliseconds( + jitter.Next(0, 100)), + (result, span, count, _) => + { + LogRetry(provider, result, span, count); + return Task.CompletedTask; + }); + + return Policy.WrapAsync(retryPolicy, Policy.TimeoutAsync(retryTimeOut)); + } + + // Log retry attempts + private static void LogRetry( + IServiceProvider provider, + DelegateResult response, + TimeSpan span, + int retryCount) + { + var logger = provider.GetService>(); + if (logger == null) + { + throw new NullReferenceException("Null reference for logger during retry"); + } + + var responseMsg = response.Result; + if (responseMsg is null) + { + logger.LogError( + response.Exception, + "Retry attempt [{RetryCount}] with delay of [{Time}]ms", + retryCount, + span.TotalMilliseconds); + } + else + { + logger.LogWarning( + "Retry attempt [{RetryCount}] with delay of [{Time}]ms [{@Response}]", + retryCount, + span.TotalMilliseconds, + new + { + responseMsg.StatusCode, + responseMsg.Content, + responseMsg.Headers, + responseMsg.RequestMessage?.RequestUri + }); + } + } + } +} diff --git a/jobs/Backend/Task/Jobs/ExchangeRateRefreshJob.cs b/jobs/Backend/Task/Jobs/ExchangeRateRefreshJob.cs new file mode 100644 index 0000000000..fa4616910f --- /dev/null +++ b/jobs/Backend/Task/Jobs/ExchangeRateRefreshJob.cs @@ -0,0 +1,38 @@ + +using ExchangeRateUpdater.Common; +using ExchangeRateUpdater.Services.Interfaces; +using ExchangeRateUpdater.Services.Models; +using ExchangeRateUpdater.Services.Models.External; +using Microsoft.Extensions.Logging; +using Quartz; + +namespace ExchangeRateUpdater.Jobs +{ + public class ExchangeRateRefreshJob( + IApiClient apiClient, + IExchangeRateCacheService cacheService, + IDateTimeSource dateTimeSource, + ILogger logger) : IJob + { + public async Task Execute(IJobExecutionContext context) + { + try + { + var rates = await apiClient.GetExchangeRatesAsync(); + var exchangeRates = rates + .Select(rate => new ExchangeRate( + new Currency(rate.CurrencyCode), + new Currency("CZK"), + rate.Amount == 1 ? rate.Rate : rate.Rate / rate.Amount)); + + cacheService.SetRates(exchangeRates); + logger.LogInformation("Exchange rates succesfully refreshed at [{UTCTime}] UTC", dateTimeSource.UtcNow); + } + catch (Exception ex) + { + logger.LogError(ex, "An error occured while trying to refresh exchange rates at [{UTCTime}] UTC", + dateTimeSource.UtcNow); + } + } + } +} diff --git a/jobs/Backend/Task/Program.cs b/jobs/Backend/Task/Program.cs index 379a69b1f8..a5ad9a842c 100644 --- a/jobs/Backend/Task/Program.cs +++ b/jobs/Backend/Task/Program.cs @@ -1,32 +1,52 @@ -using System; -using System.Collections.Generic; -using System.Linq; +using ExchangeRateUpdater.Common; +using ExchangeRateUpdater.Configuration; +using ExchangeRateUpdater.Extensions; +using ExchangeRateUpdater.Services; +using ExchangeRateUpdater.Services.Interfaces; +using ExchangeRateUpdater.Services.Models; +using ExchangeRateUpdater.Services.Models.External; +using Microsoft.Extensions.Caching.Memory; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; namespace ExchangeRateUpdater { public static class Program { - private static IEnumerable currencies = new[] + public static async Task Main(string[] args) { - 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) + + var builder = Host.CreateApplicationBuilder(args); + builder.Services.AddServices(builder.Configuration); + var app = builder.Build(); + + var serviceProvider = app.Services; + var currencies = GetCurrencies(builder.Configuration); + await GetExchangeRate(serviceProvider, currencies); + await app.RunAsync(); + } + + private static async Task GetExchangeRate( + IServiceProvider serviceProvider, + IEnumerable currencies) { try { - var provider = new ExchangeRateProvider(); - var rates = provider.GetExchangeRates(currencies); + var apiClient = serviceProvider.GetRequiredService>(); + var exchangeRateCache = serviceProvider.GetRequiredService(); + var logger = serviceProvider.GetRequiredService>(); + var dateTimeSource = serviceProvider.GetRequiredService(); + + var provider = new ExchangeRateProvider( + apiClient, + exchangeRateCache, + logger); - Console.WriteLine($"Successfully retrieved {rates.Count()} exchange rates:"); + var rates = await provider.GetExchangeRates(currencies); + + Console.WriteLine($"Successfully retrieved {rates.Count} exchange rates at {dateTimeSource.UtcNow} UTC:"); foreach (var rate in rates) { Console.WriteLine(rate.ToString()); @@ -36,8 +56,15 @@ public static void Main(string[] args) { Console.WriteLine($"Could not retrieve exchange rates: '{e.Message}'."); } + } + + private static IEnumerable GetCurrencies(IConfiguration configuration) + { + var section = configuration + .GetSection(Constants.ExchangeRateConfiguration) + .Get(); - Console.ReadLine(); + return section!.CurrencyCodes.Select(code => new Currency(code)); } } } diff --git a/jobs/Backend/Task/Services/CnbApiClient.cs b/jobs/Backend/Task/Services/CnbApiClient.cs new file mode 100644 index 0000000000..ba9109f0a1 --- /dev/null +++ b/jobs/Backend/Task/Services/CnbApiClient.cs @@ -0,0 +1,43 @@ +using ExchangeRateUpdater.Common; +using ExchangeRateUpdater.Configuration; +using ExchangeRateUpdater.Services.Interfaces; +using ExchangeRateUpdater.Services.Models.External; +using Microsoft.Extensions.Logging; +using System.Net.Http.Json; + +namespace ExchangeRateUpdater.Services +{ + public class CnbApiClient( + HttpClient httpClient, + ApiConfiguration configuration, + ILogger logger, + IDateTimeSource dateTimeSource) + : IApiClient + { + public async Task> GetExchangeRatesAsync() + { + var queryParams = new Dictionary + { + ["date"] = dateTimeSource.UtcNow.ToString("yyyy-MM-dd"), + ["lang"] = configuration.Language + }; + + var queryString = string.Join("&", queryParams.Select(kvp => $"{Uri.EscapeDataString(kvp.Key)}={Uri.EscapeDataString(kvp.Value)}")); + var uri = $"{configuration.ExchangeRateEndpoint}?{queryString}"; + + try + { + logger.LogInformation("Initiated exchange rate request to: [{Uri}]", uri); + + var response = await httpClient.GetFromJsonAsync(uri); + + return response?.Rates ?? []; + } + catch (Exception ex) + { + logger.LogError(ex, "An error occurred while retrieving exchange rates from [{Uri}]", uri); + throw; + } + } + } +} diff --git a/jobs/Backend/Task/Services/ExchangeRateCacheService.cs b/jobs/Backend/Task/Services/ExchangeRateCacheService.cs new file mode 100644 index 0000000000..780e669ad9 --- /dev/null +++ b/jobs/Backend/Task/Services/ExchangeRateCacheService.cs @@ -0,0 +1,46 @@ +using ExchangeRateUpdater.Services.Interfaces; +using ExchangeRateUpdater.Services.Models; +using Microsoft.Extensions.Caching.Memory; + +namespace ExchangeRateUpdater.Services +{ + public class ExchangeRateCacheService(IMemoryCache cache) : IExchangeRateCacheService + { + private const string InvalidCodesKey = "InvalidCodes"; + + public ICollection GetCachedRates(IEnumerable currencyCodes) + { + var results = new List(); + foreach (var code in currencyCodes) + { + var key = code.ToUpperInvariant(); + if (cache.TryGetValue(key, out ExchangeRate? rate) && rate is not null) + results.Add(rate); + } + return results; + } + + public void SetRates(IEnumerable rates) + { + foreach (var rate in rates) + { + var key = rate.SourceCurrency.Code.ToUpperInvariant(); + cache.Set(key, rate); + } + } + + public void UpdateInvalidCodes(IEnumerable codes) + { + var existing = GetInvalidCodes(); + foreach (var code in codes) + existing.Add(code); + + cache.Set(InvalidCodesKey, existing); + } + + public HashSet GetInvalidCodes() => + cache.TryGetValue>(InvalidCodesKey, out var codes) + ? codes + : []; + } +} diff --git a/jobs/Backend/Task/Services/ExchangeRateProvider.cs b/jobs/Backend/Task/Services/ExchangeRateProvider.cs new file mode 100644 index 0000000000..b8b1177ad6 --- /dev/null +++ b/jobs/Backend/Task/Services/ExchangeRateProvider.cs @@ -0,0 +1,76 @@ +using ExchangeRateUpdater.Services.Interfaces; +using ExchangeRateUpdater.Services.Models; +using ExchangeRateUpdater.Services.Models.External; +using Microsoft.Extensions.Logging; + +namespace ExchangeRateUpdater.Services +{ + public class ExchangeRateProvider( + IApiClient apiClient, + IExchangeRateCacheService cacheService, + ILogger logger) + { + private const string TargetCurrencyCode = "CZK"; + + /// + /// Returns exchange rates among the specified currencies that are defined by the source. + /// Omits the currencies if they are not returned in the external API response. + /// + public async Task> GetExchangeRates(IEnumerable currencies) + { + ArgumentNullException.ThrowIfNull(currencies); + + var currencyCodes = currencies + .Select(c => c.Code) + .Where(code => + !string.IsNullOrWhiteSpace(code) && + !string.Equals(code, TargetCurrencyCode, StringComparison.OrdinalIgnoreCase)) + .ToHashSet(StringComparer.OrdinalIgnoreCase); + + if (currencyCodes.Count == 0) + return []; + + var cachedRates = cacheService.GetCachedRates(currencyCodes); + var cachedCodes = cachedRates.Select(r => r.SourceCurrency.Code); + + // Check if any of the missing currency codes have already been cached as invalid. + // There's no need to call the API if all the remain missing codes are simply invalid. + var invalidCodes = cacheService.GetInvalidCodes(); + var missingCodes = currencyCodes.Except(cachedCodes).Except(invalidCodes).ToHashSet(); + + if (missingCodes.Count == 0) + return cachedRates; + + var apiResponse = await apiClient.GetExchangeRatesAsync(); + var exchangeRates = FilterExchangeRates(apiResponse, currencyCodes); + + if (exchangeRates.Count < currencyCodes.Count) + { + var codesNotFound = currencyCodes.Except(exchangeRates.Select(r => r.SourceCurrency.Code)); + cacheService.UpdateInvalidCodes(codesNotFound); + logger.LogWarning("Unable to find rates for the following currencies: [{CodesNotFound}]", string.Join(", ", codesNotFound)); + } + + return cachedRates.Concat(exchangeRates).ToList(); + } + + private static HashSet FilterExchangeRates(IEnumerable rates, HashSet currencyCodes) + { + if (rates is null || currencyCodes.Count == 0) + return []; + + // Filter rates with matching currency codes to a new collection. + // To ensure consistent output, normalise currency rates returned + // by the api so it's always per 1 unit. + return rates + .Where(rate => + currencyCodes.Contains(rate.CurrencyCode)) + .Select(rate => + new ExchangeRate( + new Currency(rate.CurrencyCode), + new Currency(TargetCurrencyCode), + rate.Amount == 1 ? rate.Rate : rate.Rate / rate.Amount)) + .ToHashSet(); + } + } +} diff --git a/jobs/Backend/Task/Services/Interfaces/IApiClient.cs b/jobs/Backend/Task/Services/Interfaces/IApiClient.cs new file mode 100644 index 0000000000..0d4fd601d7 --- /dev/null +++ b/jobs/Backend/Task/Services/Interfaces/IApiClient.cs @@ -0,0 +1,7 @@ +namespace ExchangeRateUpdater.Services.Interfaces +{ + public interface IApiClient + { + Task> GetExchangeRatesAsync(); + } +} diff --git a/jobs/Backend/Task/Services/Interfaces/IExchangeRateCacheService.cs b/jobs/Backend/Task/Services/Interfaces/IExchangeRateCacheService.cs new file mode 100644 index 0000000000..cd7d19bb4a --- /dev/null +++ b/jobs/Backend/Task/Services/Interfaces/IExchangeRateCacheService.cs @@ -0,0 +1,11 @@ +using ExchangeRateUpdater.Services.Models; +namespace ExchangeRateUpdater.Services.Interfaces +{ + public interface IExchangeRateCacheService + { + ICollection GetCachedRates(IEnumerable currencyCodes); + void SetRates(IEnumerable rates); + void UpdateInvalidCodes(IEnumerable codes); + HashSet GetInvalidCodes(); + } +} \ No newline at end of file diff --git a/jobs/Backend/Task/Currency.cs b/jobs/Backend/Task/Services/Models/Currency.cs similarity index 53% rename from jobs/Backend/Task/Currency.cs rename to jobs/Backend/Task/Services/Models/Currency.cs index f375776f25..ade0b1d9b5 100644 --- a/jobs/Backend/Task/Currency.cs +++ b/jobs/Backend/Task/Services/Models/Currency.cs @@ -1,16 +1,11 @@ -namespace ExchangeRateUpdater +namespace ExchangeRateUpdater.Services.Models { - public class Currency + public class Currency(string code) { - public Currency(string code) - { - Code = code; - } - /// /// Three-letter ISO 4217 code of the currency. /// - public string Code { get; } + public string Code { get; } = code; public override string ToString() { diff --git a/jobs/Backend/Task/Services/Models/ExchangeRate.cs b/jobs/Backend/Task/Services/Models/ExchangeRate.cs new file mode 100644 index 0000000000..ca5b27da78 --- /dev/null +++ b/jobs/Backend/Task/Services/Models/ExchangeRate.cs @@ -0,0 +1,14 @@ +namespace ExchangeRateUpdater.Services.Models +{ + public class ExchangeRate(Currency sourceCurrency, Currency targetCurrency, decimal value) + { + public Currency SourceCurrency { get; } = sourceCurrency; + public Currency TargetCurrency { get; } = targetCurrency; + public decimal Value { get; } = value; + + public override string ToString() + { + return $"{SourceCurrency}/{TargetCurrency}={Value}"; + } + } +} diff --git a/jobs/Backend/Task/Services/Models/External/CnbExchangeResponse.cs b/jobs/Backend/Task/Services/Models/External/CnbExchangeResponse.cs new file mode 100644 index 0000000000..7042b06227 --- /dev/null +++ b/jobs/Backend/Task/Services/Models/External/CnbExchangeResponse.cs @@ -0,0 +1,14 @@ +using System.Text.Json.Serialization; + +namespace ExchangeRateUpdater.Services.Models.External +{ + public sealed record CnbExchangeResponse( + [property: JsonPropertyName("rates")] IEnumerable Rates + ); + + public sealed record CnbRate( + [property: JsonPropertyName("currencyCode")] string CurrencyCode, + [property: JsonPropertyName("rate")] decimal Rate, + [property: JsonPropertyName("amount")] int Amount + ); +} \ No newline at end of file diff --git a/jobs/Backend/Task/appsettings.json b/jobs/Backend/Task/appsettings.json new file mode 100644 index 0000000000..a5698ae46f --- /dev/null +++ b/jobs/Backend/Task/appsettings.json @@ -0,0 +1,26 @@ +{ + "ApiConfiguration": { + "Name": "CNB API", + "BaseUrl": "https://api.cnb.cz/cnbapi/", + "ExchangeRateEndpoint": "exrates/daily", + "Language": "EN", + "RequestTimeoutInSeconds": 20, + "RetryTimeOutInSeconds": 5, + "DefaultRequestHeaders": { + "Accept": "application/json" + } + }, + "ExchangeRateConfiguration": { + "CurrencyCodes": [ + "USD", + "EUR", + "CZK", + "JPY", + "KES", + "RUB", + "THB", + "TRY", + "XYZ" + ] + } +} \ No newline at end of file