diff --git a/.gitignore b/.gitignore index fd35865456..34b4b477a7 100644 --- a/.gitignore +++ b/.gitignore @@ -21,3 +21,5 @@ node_modules bower_components npm-debug.log + +.vs/ \ No newline at end of file diff --git a/jobs/Backend/Task.Test/CnbExchangeRateClientTests.cs b/jobs/Backend/Task.Test/CnbExchangeRateClientTests.cs new file mode 100644 index 0000000000..7594fd0492 --- /dev/null +++ b/jobs/Backend/Task.Test/CnbExchangeRateClientTests.cs @@ -0,0 +1,132 @@ +namespace ExchangeReaderUpdater.Test +{ + using ExchangeRateUpdater.Exceptions; + using ExchangeRateUpdater.ExchangeClients; + using ExchangeRateUpdater.Settings; + using FluentAssertions; + using Microsoft.Extensions.Logging.Abstractions; + using Microsoft.Extensions.Options; + using Moq; + using Moq.Protected; + using System; + using System.Net; + using System.Net.Http; + using System.Threading; + using System.Threading.Tasks; + using Xunit; + + public class CnbExchangeRateClientTests + { + [Fact] + public async Task getDailyRatesAsync_returnsContent_whenResponseIsSuccess() + { + var handlerMock = new Mock(); + var response = new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new StringContent("daily content") + }; + + handlerMock.Protected() + .Setup>("SendAsync", + ItExpr.IsAny(), + ItExpr.IsAny()) + .ReturnsAsync(response) + .Verifiable(); + + using var httpClient = new HttpClient(handlerMock.Object); + + var optionsMock = new Mock>(); + optionsMock.SetupGet(o => o.Value).Returns(new ExchangeRateProviderSettings { CnbUrl = "http://test" }); + + var client = new CnbExchangeRateClient(httpClient, optionsMock.Object, NullLogger.Instance); + + var result = await client.GetDailyRatesAsync(CancellationToken.None); + + result.Should().Be("daily content"); + + handlerMock.Protected().Verify( + "SendAsync", + Times.Once(), + ItExpr.Is(req => req.Method == HttpMethod.Get), + ItExpr.IsAny()); + } + + [Fact] + public async Task getDailyRatesAsync_throwsExchangeRateUpdateException_onNonSuccessStatus() + { + var handlerMock = new Mock(); + var response = new HttpResponseMessage(HttpStatusCode.InternalServerError) + { + Content = new StringContent("error") + }; + + handlerMock.Protected() + .Setup>("SendAsync", + ItExpr.IsAny(), + ItExpr.IsAny()) + .ReturnsAsync(response) + .Verifiable(); + + using var httpClient = new HttpClient(handlerMock.Object); + + var optionsMock = new Mock>(); + optionsMock.SetupGet(o => o.Value).Returns(new ExchangeRateProviderSettings { CnbUrl = "http://test" }); + + var client = new CnbExchangeRateClient(httpClient, optionsMock.Object, NullLogger.Instance); + + Func act = async () => await client.GetDailyRatesAsync(CancellationToken.None); + + await act.Should().ThrowAsync() + .Where(ex => ex.InnerException is HttpRequestException); + } + + [Fact] + public async Task getDailyRatesAsync_wrapsHandlerHttpRequestException() + { + var handlerMock = new Mock(); + + handlerMock.Protected() + .Setup>("SendAsync", + ItExpr.IsAny(), + ItExpr.IsAny()) + .ThrowsAsync(new HttpRequestException("network error")) + .Verifiable(); + + using var httpClient = new HttpClient(handlerMock.Object); + + var optionsMock = new Mock>(); + optionsMock.SetupGet(o => o.Value).Returns(new ExchangeRateProviderSettings { CnbUrl = "http://test" }); + + var client = new CnbExchangeRateClient(httpClient, optionsMock.Object, NullLogger.Instance); + + Func act = async () => await client.GetDailyRatesAsync(CancellationToken.None); + + await act.Should().ThrowAsync() + .Where(ex => ex.InnerException is HttpRequestException && ex.InnerException.Message.Contains("network error")); + } + + [Fact] + public async Task getDailyRatesAsync_propagatesCancellation() + { + var handlerMock = new Mock(); + + handlerMock.Protected() + .Setup>("SendAsync", + ItExpr.IsAny(), + ItExpr.IsAny()) + .ThrowsAsync(new TaskCanceledException()) + .Verifiable(); + + using var httpClient = new HttpClient(handlerMock.Object); + + var optionsMock = new Mock>(); + optionsMock.SetupGet(o => o.Value).Returns(new ExchangeRateProviderSettings { CnbUrl = "http://test" }); + + var client = new CnbExchangeRateClient(httpClient, optionsMock.Object, NullLogger.Instance); + + Func act = async () => await client.GetDailyRatesAsync(new CancellationToken(true)); + + await act.Should().ThrowAsync(); + } + } +} diff --git a/jobs/Backend/Task.Test/CnbExchangeRateDataParserTests.cs b/jobs/Backend/Task.Test/CnbExchangeRateDataParserTests.cs new file mode 100644 index 0000000000..ed2ea34d63 --- /dev/null +++ b/jobs/Backend/Task.Test/CnbExchangeRateDataParserTests.cs @@ -0,0 +1,168 @@ +namespace ExchangeReaderUpdater.Test +{ + using ExchangeRateUpdater.Models; + using ExchangeRateUpdater.Parsers; + using FluentAssertions; + using System.Linq; + using Xunit; + + public class CnbExchangeRateDataParserTests + { + private readonly CnbExchangeRateDataParser _parser = new CnbExchangeRateDataParser(); + + [Fact] + public void parse_valid_single_line_returns_exchange_rate() + { + var currencies = new[] { new Currency("USD") }; + var data = "25 Feb 2025 #11\nCountry|Currency|Amount|Code|Rate\nUSA|Dollar|1|USD|21.456\n"; + + var result = _parser.Parse(data, currencies).ToList(); + + result.Should().HaveCount(1); + result.Should().SatisfyRespectively( + first => + { + first.SourceCurrency.Code.Should().Be("USD"); + first.TargetCurrency.Code.Should().Be("CZK"); + first.Value.Should().Be(21.456m); + }); + } + + [Fact] + public void parse_multiple_lines_returns_exchange_rates() + { + var currencies = new[] { new Currency("USD"), new Currency("EUR"), new Currency("AUD") }; + var data = "25 Feb 2025 #11\nCountry|Currency|Amount|Code|Rate\nUSA|Dollar|1|USD|21.456\nEMU|euro|1|EUR|24.285\nAustralia|dollar|1|AUD|14.004"; + var result = _parser.Parse(data, currencies).ToList(); + + result.Should().HaveCount(3); + result.Should().SatisfyRespectively( + first => + { + first.SourceCurrency.Code.Should().Be("USD"); + first.TargetCurrency.Code.Should().Be("CZK"); + first.Value.Should().Be(21.456m); + }, + second => + { + second.SourceCurrency.Code.Should().Be("EUR"); + second.TargetCurrency.Code.Should().Be("CZK"); + second.Value.Should().Be(24.285m); + }, + third => + { + third.SourceCurrency.Code.Should().Be("AUD"); + third.TargetCurrency.Code.Should().Be("CZK"); + third.Value.Should().Be(14.004m); + }); + } + + [Fact] + public void parse_multiple_lines_returns_filtered_exchange_rates() + { + var currencies = new[] { new Currency("EUR"), new Currency("AUD") }; + var data = "25 Feb 2025 #11\nCountry|Currency|Amount|Code|Rate\nUSA|Dollar|1|USD|21.456\nEMU|euro|1|EUR|24.285\nAustralia|dollar|1|AUD|14.004"; + var result = _parser.Parse(data, currencies).ToList(); + + result.Should().HaveCount(2); + result.Should().SatisfyRespectively( + first => + { + first.SourceCurrency.Code.Should().Be("EUR"); + first.TargetCurrency.Code.Should().Be("CZK"); + first.Value.Should().Be(24.285m); + }, + second => + { + second.SourceCurrency.Code.Should().Be("AUD"); + second.TargetCurrency.Code.Should().Be("CZK"); + second.Value.Should().Be(14.004m); + }); + } + + [Fact] + public void parse_invalid_column_count_skips_line() + { + var currencies = new[] { new Currency("USD") }; + var data = "25 Feb 2025 #11\nCountry|Currency|Amount|Code|Rate\nUSA|Dollar|1|USD\n"; + + var result = _parser.Parse(data, currencies).ToList(); + + result.Should().HaveCount(0); + } + + [Fact] + public void parse_whitespace_trims_code() + { + var currencies = new[] { new Currency("USD"), new Currency("CZK") }; + var data = "25 Feb 2025 #11\nCountry|Currency|Amount|Code|Rate\n USA |Dollar|1| USD |21.456\n"; + + var result = _parser.Parse(data, currencies).ToList(); + + result.Should().HaveCount(1); + result.Should().SatisfyRespectively( + first => + { + first.SourceCurrency.Code.Should().Be("USD"); + first.TargetCurrency.Code.Should().Be("CZK"); + first.Value.Should().Be(21.456m); + }); + } + + [Fact] + public void zero_rate_skips_line() + { + var currencies = new[] { new Currency("USD"), new Currency("CZK") }; + var data = "25 Feb 2025 #11\nCountry|Currency|Amount|Code|Rate\nUSA|Dollar|1|USD|0\n"; + + var result = _parser.Parse(data, currencies).ToList(); + + result.Should().HaveCount(0); + } + + [Fact] + public void non_numeric_rate_skips_line() + { + var currencies = new[] { new Currency("USD"), new Currency("CZK") }; + var data = "25 Feb 2025 #11\nCountry|Currency|Amount|Code|Rate\nUSA|Dollar|1|USD|not_a_number\n"; + + var result = _parser.Parse(data, currencies).ToList(); + + result.Should().HaveCount(0); + } + + [Fact] + public void parse_various_newline_formats() + { + var currencies = new[] { new Currency("USD"), new Currency("EUR") }; + var data = "25 Feb 2025 #11\r\nCountry|Currency|Amount|Code|Rate\r\nUSA|Dollar|1|USD|21.456\nEMU|euro|1|EUR|24.285\rAustralia|dollar|1|AUD|14.004"; + + var result = _parser.Parse(data, currencies).ToList(); + + result.Should().HaveCount(2); + result.Select(r => r.SourceCurrency.Code).Should().Contain(new[] { "USD", "EUR" }); + } + + [Fact] + public void amount_zero_skips_line() + { + var currencies = new[] { new Currency("USD"), new Currency("CZK") }; + var data = "25 Feb 2025 #11\nCountry|Currency|Amount|Code|Rate\nUSA|Dollar|0|USD|21.456\n"; + + var result = _parser.Parse(data, currencies).ToList(); + + result.Should().BeEmpty(); + } + + [Fact] + public void negative_rate_skips_line() + { + var currencies = new[] { new Currency("USD"), new Currency("CZK") }; + var data = "25 Feb 2025 #11\nCountry|Currency|Amount|Code|Rate\nUSA|Dollar|1|USD|-21.456\n"; + + var result = _parser.Parse(data, currencies).ToList(); + + result.Should().BeEmpty(); + } + } +} diff --git a/jobs/Backend/Task.Test/ExchangeRateProviderTests.cs b/jobs/Backend/Task.Test/ExchangeRateProviderTests.cs new file mode 100644 index 0000000000..8d3b5ac4ba --- /dev/null +++ b/jobs/Backend/Task.Test/ExchangeRateProviderTests.cs @@ -0,0 +1,88 @@ +namespace ExchangeReaderUpdater.Test +{ + using ExchangeRateUpdater; + using ExchangeRateUpdater.Exceptions; + using ExchangeRateUpdater.ExchangeClients; + using ExchangeRateUpdater.Models; + using ExchangeRateUpdater.Parsers; + using FluentAssertions; + using Microsoft.Extensions.Logging.Abstractions; + using Moq; + using System; + using System.Collections.Generic; + using System.Linq; + using System.Threading; + using System.Threading.Tasks; + using Xunit; + + public class ExchangeRateProviderTests + { + [Fact] + public async Task get_exchangeRatesAsync_returnsParsedRates_fromClientData() + { + var mockClient = new Mock(); + var parser = new CnbExchangeRateDataParser(); + + var currencies = new[] { new Currency("USD"), new Currency("CZK") }; + var rawData = "25 Feb 2025 #11\nCountry|Currency|Amount|Code|Rate\nUSA|Dollar|1|USD|21.456\n"; + + var expectedRates = new List + { + new ExchangeRate(new Currency("USD"),new Currency("CZK"), 21.456m) + }; + + mockClient.Setup(c => c.GetDailyRatesAsync(It.IsAny())) + .ReturnsAsync(rawData); + + var provider = new ExchangeRateProvider(mockClient.Object, parser, NullLogger.Instance); + + var result = (await provider.GetExchangeRatesAsync(currencies)).ToList(); + + result.Should().BeEquivalentTo(expectedRates); + mockClient.Verify(c => c.GetDailyRatesAsync(It.IsAny()), Times.Once); + } + + [Fact] + public async Task getExchangeRatesAsync_nullCurrencies_throwsArgumentNullException() + { + var mockClient = new Mock(); + var mockParser = new Mock(); + var provider = new ExchangeRateProvider(mockClient.Object, mockParser.Object, NullLogger.Instance); + + var act = async () => await provider.GetExchangeRatesAsync(null); + + await act.Should().ThrowAsync(); + } + + [Fact] + public async Task getExchangeRatesAsync_emptyCurrencyList_returnsEmpty() + { + var mockClient = new Mock(); + var mockParser = new Mock(); + var provider = new ExchangeRateProvider(mockClient.Object, mockParser.Object, NullLogger.Instance); + + var result = await provider.GetExchangeRatesAsync(Array.Empty()); + + result.Should().BeEmpty(); + mockClient.Verify(c => c.GetDailyRatesAsync(It.IsAny()), Times.Never); + mockParser.Verify(p => p.Parse(It.IsAny(), It.IsAny>()), Times.Never); + } + + [Fact] + public async Task getExchangeRatesAsync_propagatesExchangeRateUpdateExceptionFromClient() + { + var mockClient = new Mock(); + var mockParser = new Mock(); + var provider = new ExchangeRateProvider(mockClient.Object, mockParser.Object, NullLogger.Instance); + + mockClient.Setup(c => c.GetDailyRatesAsync(It.IsAny())) + .ThrowsAsync(new ExchangeRateUpdateException("remote error")); + + var currencies = new[] { new Currency("USD"), new Currency("CZK") }; + + var act = async () => await provider.GetExchangeRatesAsync(currencies); + + await act.Should().ThrowAsync(); + } + } +} diff --git a/jobs/Backend/Task.Test/ExchangeReaderUpdater.Test.csproj b/jobs/Backend/Task.Test/ExchangeReaderUpdater.Test.csproj new file mode 100644 index 0000000000..1f32912b5b --- /dev/null +++ b/jobs/Backend/Task.Test/ExchangeReaderUpdater.Test.csproj @@ -0,0 +1,33 @@ + + + + net10.0 + enable + enable + false + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/jobs/Backend/Task/Currency.cs b/jobs/Backend/Task/Currency.cs deleted file mode 100644 index f375776f25..0000000000 --- a/jobs/Backend/Task/Currency.cs +++ /dev/null @@ -1,20 +0,0 @@ -namespace ExchangeRateUpdater -{ - public class Currency - { - public Currency(string code) - { - Code = code; - } - - /// - /// Three-letter ISO 4217 code of the currency. - /// - public string Code { get; } - - public override string ToString() - { - return Code; - } - } -} diff --git a/jobs/Backend/Task/Exceptions/ExchangeRateUpdateException.cs b/jobs/Backend/Task/Exceptions/ExchangeRateUpdateException.cs new file mode 100644 index 0000000000..697170f3ad --- /dev/null +++ b/jobs/Backend/Task/Exceptions/ExchangeRateUpdateException.cs @@ -0,0 +1,19 @@ +namespace ExchangeRateUpdater.Exceptions +{ + using System; + + public class ExchangeRateUpdateException : Exception + { + public ExchangeRateUpdateException() + { + } + + public ExchangeRateUpdateException(string message) : base(message) + { + } + + public ExchangeRateUpdateException(string? message, Exception? innerException) : base(message, innerException) + { + } + } +} diff --git a/jobs/Backend/Task/ExchangeClients/CnbExchangeRateClient.cs b/jobs/Backend/Task/ExchangeClients/CnbExchangeRateClient.cs new file mode 100644 index 0000000000..ba916d17aa --- /dev/null +++ b/jobs/Backend/Task/ExchangeClients/CnbExchangeRateClient.cs @@ -0,0 +1,43 @@ +namespace ExchangeRateUpdater.ExchangeClients +{ + using ExchangeRateUpdater.Exceptions; + using ExchangeRateUpdater.Settings; + using Microsoft.Extensions.Logging; + using Microsoft.Extensions.Options; + using System; + using System.Net.Http; + using System.Threading; + using System.Threading.Tasks; + + public sealed class CnbExchangeRateClient : IExchangeRateClient + { + private readonly HttpClient _httpClient; + private readonly ExchangeRateProviderSettings _settings; + private readonly ILogger _logger; + + public CnbExchangeRateClient(HttpClient httpClient, IOptions settings, ILogger logger) + { + _httpClient = httpClient ?? throw new ArgumentNullException(nameof(httpClient)); + _settings = settings?.Value ?? throw new ArgumentNullException(nameof(settings)); + _logger = logger ?? throw new ArgumentNullException(nameof(logger)); + + _httpClient.Timeout = TimeSpan.FromSeconds(_settings.TimeoutInSeconds); + } + + public async Task GetDailyRatesAsync(CancellationToken cancellationToken) + { + try + { + var response = await _httpClient.GetAsync(_settings.CnbUrl, cancellationToken); + response.EnsureSuccessStatusCode(); + + return await response.Content.ReadAsStringAsync(cancellationToken); + } + catch (HttpRequestException ex) + { + _logger.LogError(ex, "HTTP request error while fetching daily rates from CNB."); + throw new ExchangeRateUpdateException("Error fetching daily rates from CNB.", ex); + } + } + } +} diff --git a/jobs/Backend/Task/ExchangeClients/IExchangeRateClient.cs b/jobs/Backend/Task/ExchangeClients/IExchangeRateClient.cs new file mode 100644 index 0000000000..9dbbeff6ec --- /dev/null +++ b/jobs/Backend/Task/ExchangeClients/IExchangeRateClient.cs @@ -0,0 +1,10 @@ +namespace ExchangeRateUpdater.ExchangeClients +{ + using System.Threading; + using System.Threading.Tasks; + + public interface IExchangeRateClient + { + Task GetDailyRatesAsync(CancellationToken cancellationToken); + } +} diff --git a/jobs/Backend/Task/ExchangeRateProvider.cs b/jobs/Backend/Task/ExchangeRateProvider.cs index 6f82a97fbe..807c5e8343 100644 --- a/jobs/Backend/Task/ExchangeRateProvider.cs +++ b/jobs/Backend/Task/ExchangeRateProvider.cs @@ -1,19 +1,64 @@ -using System.Collections.Generic; +using ExchangeRateUpdater.Exceptions; +using ExchangeRateUpdater.ExchangeClients; +using ExchangeRateUpdater.Models; +using ExchangeRateUpdater.Parsers; +using Microsoft.Extensions.Logging; +using System; +using System.Collections.Generic; using System.Linq; +using System.Threading; +using System.Threading.Tasks; namespace ExchangeRateUpdater { public class ExchangeRateProvider { + private readonly IExchangeRateClient _client; + private readonly IExchangeRateDataParser _parser; + private readonly ILogger _logger; + + public ExchangeRateProvider(IExchangeRateClient client, IExchangeRateDataParser parser, ILogger logger) + { + _client = client ?? throw new ArgumentNullException(nameof(client)); + _parser = parser ?? throw new ArgumentNullException(nameof(parser)); + _logger = logger ?? throw new ArgumentNullException(nameof(logger)); + } + /// /// Should return exchange rates among the specified currencies that are defined by the source. But only those defined /// by the source, do not return calculated exchange rates. E.g. if the source contains "CZK/USD" but not "USD/CZK", /// do not return exchange rate "USD/CZK" with value calculated as 1 / "CZK/USD". If the source does not provide /// some of the currencies, ignore them. /// - public IEnumerable GetExchangeRates(IEnumerable currencies) + public async Task> GetExchangeRatesAsync(IEnumerable currencies, CancellationToken cancellationToken = default) { - return Enumerable.Empty(); + var currenciesList = currencies?.ToList() ?? throw new ArgumentNullException(nameof(currencies)); + + if (!currenciesList.Any()) + { + return Enumerable.Empty(); + } + + var rawData = await _client.GetDailyRatesAsync(cancellationToken); + + if (string.IsNullOrWhiteSpace(rawData)) + { + _logger.LogError("Received empty data from exchange client."); + throw new ExchangeRateUpdateException("Received empty data from exchange client."); + } + + IEnumerable parsedData; + try + { + parsedData = _parser.Parse(rawData, currenciesList).ToList(); + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to parse exchange rates."); + throw new ExchangeRateUpdateException("Failed to parse exchange rates.", ex); + } + + return parsedData; } } } diff --git a/jobs/Backend/Task/ExchangeRateUpdater.csproj b/jobs/Backend/Task/ExchangeRateUpdater.csproj index 2fc654a12b..98f3d4ae53 100644 --- a/jobs/Backend/Task/ExchangeRateUpdater.csproj +++ b/jobs/Backend/Task/ExchangeRateUpdater.csproj @@ -1,8 +1,31 @@  - - Exe - net6.0 - + + Exe + net10.0 + enable + + + + + + + + + + + + + + + PreserveNewest + + + PreserveNewest + + + PreserveNewest + + \ No newline at end of file diff --git a/jobs/Backend/Task/ExchangeRateUpdater.sln b/jobs/Backend/Task/ExchangeRateUpdater.sln index 89be84daff..d07938e9a8 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.1.11312.151 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}") = "ExchangeReaderUpdater.Test", "..\Task.Test\ExchangeReaderUpdater.Test.csproj", "{4BD9B410-E8B6-F246-B25C-7712B40872F8}" +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 + {4BD9B410-E8B6-F246-B25C-7712B40872F8}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {4BD9B410-E8B6-F246-B25C-7712B40872F8}.Debug|Any CPU.Build.0 = Debug|Any CPU + {4BD9B410-E8B6-F246-B25C-7712B40872F8}.Release|Any CPU.ActiveCfg = Release|Any CPU + {4BD9B410-E8B6-F246-B25C-7712B40872F8}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE EndGlobalSection + GlobalSection(ExtensibilityGlobals) = postSolution + SolutionGuid = {C96EB8BB-8F80-42F5-A2B9-DAC5510D36B0} + EndGlobalSection EndGlobal diff --git a/jobs/Backend/Task/Models/Currency.cs b/jobs/Backend/Task/Models/Currency.cs new file mode 100644 index 0000000000..ed06661a77 --- /dev/null +++ b/jobs/Backend/Task/Models/Currency.cs @@ -0,0 +1,30 @@ +namespace ExchangeRateUpdater.Models +{ + using System; + + public sealed class Currency : IEquatable + { + public Currency(string code) + { + if (string.IsNullOrWhiteSpace(code)) throw new ArgumentException("Currency code is required", nameof(code)); + + var normalized = code.Trim().ToUpperInvariant(); + if (normalized.Length != 3) throw new ArgumentException("Currency code must be 3 letters (ISO 4217)", nameof(code)); + + Code = normalized; + } + + /// + /// Three-letter ISO 4217 code of the currency. + /// + public string Code { get; } + + public bool Equals(Currency? other) => other is not null && string.Equals(Code, other.Code, StringComparison.OrdinalIgnoreCase); + + public override bool Equals(object? obj) => Equals(obj as Currency); + + public override int GetHashCode() => StringComparer.OrdinalIgnoreCase.GetHashCode(Code); + + public override string ToString() => Code; + } +} diff --git a/jobs/Backend/Task/ExchangeRate.cs b/jobs/Backend/Task/Models/ExchangeRate.cs similarity index 74% rename from jobs/Backend/Task/ExchangeRate.cs rename to jobs/Backend/Task/Models/ExchangeRate.cs index 58c5bb10e0..8e19c15f1b 100644 --- a/jobs/Backend/Task/ExchangeRate.cs +++ b/jobs/Backend/Task/Models/ExchangeRate.cs @@ -1,4 +1,4 @@ -namespace ExchangeRateUpdater +namespace ExchangeRateUpdater.Models { public class ExchangeRate { @@ -17,7 +17,7 @@ public ExchangeRate(Currency sourceCurrency, Currency targetCurrency, decimal va public override string ToString() { - return $"{SourceCurrency}/{TargetCurrency}={Value}"; + return $"{SourceCurrency}/{TargetCurrency}={Value.ToString(System.Globalization.CultureInfo.InvariantCulture)}"; } } } diff --git a/jobs/Backend/Task/Parsers/CnbExchangeRateDataParser.cs b/jobs/Backend/Task/Parsers/CnbExchangeRateDataParser.cs new file mode 100644 index 0000000000..d87b83b466 --- /dev/null +++ b/jobs/Backend/Task/Parsers/CnbExchangeRateDataParser.cs @@ -0,0 +1,65 @@ +namespace ExchangeRateUpdater.Parsers +{ + using ExchangeRateUpdater.Models; + using System; + using System.Collections.Generic; + using System.Globalization; + using System.Linq; + + public sealed class CnbExchangeRateDataParser : IExchangeRateDataParser + { + private const int LinesToSkip = 2; + private const int ExpectedColumnCount = 5; + private const char ColumnDelimiter = '|'; + private static readonly Currency CzkCurrency = new("CZK"); + + + public IEnumerable Parse(string data, IEnumerable currencies) + { + if (data == null) throw new ArgumentNullException(nameof(data)); + if (currencies == null) throw new ArgumentNullException(nameof(currencies)); + + var currencyCodesSet = new HashSet(currencies.Select(c => c.Code), StringComparer.OrdinalIgnoreCase); + + var lines = data + .Split(["\r\n", "\n", "\r"], StringSplitOptions.RemoveEmptyEntries); + + var results = lines + .Skip(LinesToSkip) + .Select(ParseLine) + .Where(rate => rate != null && currencyCodesSet.Contains(rate.SourceCurrency.Code)) + .Select(r => r!) + .ToList(); + + return results; + } + + private static ExchangeRate? ParseLine(string line) + { + var columns = line.Split(ColumnDelimiter); + + if (columns.Length != ExpectedColumnCount) + { + return null; + } + + var currencyCode = columns[3].Trim(); + var amountText = columns[2].Trim(); + var rateText = columns[4].Trim(); + + if (!decimal.TryParse(amountText, NumberStyles.Any, CultureInfo.InvariantCulture, out var amount) || amount <= 0) + { + return null; + } + + if (!decimal.TryParse(rateText, NumberStyles.Any, CultureInfo.InvariantCulture, out var rate) || rate <= 0) + { + return null; + } + + var normalizedRate = rate / amount; + + return new ExchangeRate(sourceCurrency: new Currency(currencyCode), targetCurrency: CzkCurrency, value: normalizedRate); + } + } +} diff --git a/jobs/Backend/Task/Parsers/IExchangeRateDataParser.cs b/jobs/Backend/Task/Parsers/IExchangeRateDataParser.cs new file mode 100644 index 0000000000..6bcec12752 --- /dev/null +++ b/jobs/Backend/Task/Parsers/IExchangeRateDataParser.cs @@ -0,0 +1,10 @@ +namespace ExchangeRateUpdater.Parsers +{ + using ExchangeRateUpdater.Models; + using System.Collections.Generic; + + public interface IExchangeRateDataParser + { + IEnumerable Parse(string data, IEnumerable currencies); + } +} diff --git a/jobs/Backend/Task/Program.cs b/jobs/Backend/Task/Program.cs index 379a69b1f8..8e7f609db0 100644 --- a/jobs/Backend/Task/Program.cs +++ b/jobs/Backend/Task/Program.cs @@ -1,12 +1,22 @@ -using System; -using System.Collections.Generic; -using System.Linq; - -namespace ExchangeRateUpdater +namespace ExchangeRateUpdater { + using ExchangeRateUpdater.ExchangeClients; + using ExchangeRateUpdater.Models; + using ExchangeRateUpdater.Parsers; + using ExchangeRateUpdater.Settings; + using Microsoft.Extensions.Configuration; + using Microsoft.Extensions.DependencyInjection; + using Microsoft.Extensions.Logging; + using Polly; + using System; + using System.Collections.Generic; + using System.IO; + using System.Linq; + using System.Threading.Tasks; + public static class Program { - private static IEnumerable currencies = new[] + private static readonly IEnumerable Currencies = new[] { new Currency("USD"), new Currency("EUR"), @@ -19,12 +29,16 @@ public static class Program new Currency("XYZ") }; - public static void Main(string[] args) + public static async Task Main(string[] args) { + var environment = Environment.GetEnvironmentVariable("DOTNET_ENVIRONMENT") ?? "Production"; + + using var serviceProvider = ConfigureServices(environment); + try { - var provider = new ExchangeRateProvider(); - var rates = provider.GetExchangeRates(currencies); + var exchangeRateProvider = serviceProvider.GetRequiredService(); + var rates = await exchangeRateProvider.GetExchangeRatesAsync(Currencies); Console.WriteLine($"Successfully retrieved {rates.Count()} exchange rates:"); foreach (var rate in rates) @@ -34,10 +48,42 @@ public static void Main(string[] args) } catch (Exception e) { - Console.WriteLine($"Could not retrieve exchange rates: '{e.Message}'."); + var logger = serviceProvider.GetRequiredService>(); + logger.LogError(e, "Could not retrieve exchange rates."); } Console.ReadLine(); } + + private static ServiceProvider ConfigureServices(string environment) + { + var configuration = new ConfigurationBuilder() + .SetBasePath(Directory.GetCurrentDirectory()) + .AddJsonFile("appsettings.json", optional: false, reloadOnChange: true) + .AddJsonFile($"appsettings.{environment}.json", optional: true, reloadOnChange: true) + .Build(); + + var services = new ServiceCollection(); + + services.AddLogging(builder => + { + builder.AddConfiguration(configuration.GetSection("Logging")); + builder.AddConsole(); + }); + + services.Configure(configuration.GetSection("ExchangeRateSettings")); + + services.AddHttpClient() + .AddTransientHttpErrorPolicy(policyBuilder => policyBuilder + .WaitAndRetryAsync( + retryCount: 3, + sleepDurationProvider: retryAttempt => TimeSpan.FromSeconds(2 * retryAttempt))); + + services.AddSingleton(); + + services.AddTransient(); + + return services.BuildServiceProvider(); + } } } diff --git a/jobs/Backend/Task/Settings/ExchangeRateProviderSettings.cs b/jobs/Backend/Task/Settings/ExchangeRateProviderSettings.cs new file mode 100644 index 0000000000..eeb1fa7169 --- /dev/null +++ b/jobs/Backend/Task/Settings/ExchangeRateProviderSettings.cs @@ -0,0 +1,8 @@ +namespace ExchangeRateUpdater.Settings +{ + public sealed class ExchangeRateProviderSettings + { + public string CnbUrl { get; set; } = string.Empty; + public int TimeoutInSeconds { get; set; } = 20; + } +} diff --git a/jobs/Backend/Task/appSettings.Production.json b/jobs/Backend/Task/appSettings.Production.json new file mode 100644 index 0000000000..7836fdd944 --- /dev/null +++ b/jobs/Backend/Task/appSettings.Production.json @@ -0,0 +1,6 @@ +{ + "ExchangeRateSettings": { + "CnbUrl": "https://www.cnb.cz/en/financial-markets/foreign-exchange-market/central-bank-exchange-rate-fixing/central-bank-exchange-rate-fixing/daily.txt", + "TimeoutInSeconds": 20 + } +} \ 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..01251f30b7 --- /dev/null +++ b/jobs/Backend/Task/appSettings.json @@ -0,0 +1,22 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft": "Warning", + "System.Net.Http.HttpClient": "Warning" + }, + "Console": { + "FormatterName": "simple", + "FormatterOptions": { + "SingleLine": true, + "IncludeScopes": false, + "TimestampFormat": "yyyy-MM-dd HH:mm:ss ", + "UseUtcTimestamp": false + } + } + }, + "ExchangeRateSettings": { + "CnbUrl": "https://www.cnb.cz/en/financial-markets/foreign-exchange-market/central-bank-exchange-rate-fixing/central-bank-exchange-rate-fixing/daily.txt", + "TimeoutInSeconds": 20 + } +} \ No newline at end of file diff --git a/jobs/Backend/Task/appSettingsDevelopment.json b/jobs/Backend/Task/appSettingsDevelopment.json new file mode 100644 index 0000000000..7836fdd944 --- /dev/null +++ b/jobs/Backend/Task/appSettingsDevelopment.json @@ -0,0 +1,6 @@ +{ + "ExchangeRateSettings": { + "CnbUrl": "https://www.cnb.cz/en/financial-markets/foreign-exchange-market/central-bank-exchange-rate-fixing/central-bank-exchange-rate-fixing/daily.txt", + "TimeoutInSeconds": 20 + } +} \ No newline at end of file