diff --git a/.gitignore b/.gitignore index fd3586545..cad8b3e8a 100644 --- a/.gitignore +++ b/.gitignore @@ -21,3 +21,5 @@ node_modules bower_components npm-debug.log + +.fake \ 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 f375776f2..000000000 --- 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/ExchangeRate.cs b/jobs/Backend/Task/ExchangeRate.cs deleted file mode 100644 index 58c5bb10e..000000000 --- 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 6f82a97fb..000000000 --- a/jobs/Backend/Task/ExchangeRateProvider.cs +++ /dev/null @@ -1,19 +0,0 @@ -using System.Collections.Generic; -using System.Linq; - -namespace ExchangeRateUpdater -{ - public class ExchangeRateProvider - { - /// - /// Should return exchange rates among the specified currencies that are defined by the source. But only those defined - /// by the source, do not return calculated exchange rates. E.g. if the source contains "CZK/USD" but not "USD/CZK", - /// do not return exchange rate "USD/CZK" with value calculated as 1 / "CZK/USD". If the source does not provide - /// some of the currencies, ignore them. - /// - public IEnumerable GetExchangeRates(IEnumerable currencies) - { - return Enumerable.Empty(); - } - } -} diff --git a/jobs/Backend/Task/ExchangeRateUpdater.csproj b/jobs/Backend/Task/ExchangeRateUpdater.csproj deleted file mode 100644 index 2fc654a12..000000000 --- a/jobs/Backend/Task/ExchangeRateUpdater.csproj +++ /dev/null @@ -1,8 +0,0 @@ - - - - Exe - net6.0 - - - \ No newline at end of file diff --git a/jobs/Backend/Task/ExchangeRateUpdater.sln b/jobs/Backend/Task/ExchangeRateUpdater.sln deleted file mode 100644 index 89be84daf..000000000 --- a/jobs/Backend/Task/ExchangeRateUpdater.sln +++ /dev/null @@ -1,22 +0,0 @@ - -Microsoft Visual Studio Solution File, Format Version 12.00 -# Visual Studio 14 -VisualStudioVersion = 14.0.25123.0 -MinimumVisualStudioVersion = 10.0.40219.1 -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ExchangeRateUpdater", "ExchangeRateUpdater.csproj", "{7B2695D6-D24C-4460-A58E-A10F08550CE0}" -EndProject -Global - GlobalSection(SolutionConfigurationPlatforms) = preSolution - Debug|Any CPU = Debug|Any CPU - Release|Any CPU = Release|Any CPU - EndGlobalSection - GlobalSection(ProjectConfigurationPlatforms) = postSolution - {7B2695D6-D24C-4460-A58E-A10F08550CE0}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {7B2695D6-D24C-4460-A58E-A10F08550CE0}.Debug|Any CPU.Build.0 = Debug|Any CPU - {7B2695D6-D24C-4460-A58E-A10F08550CE0}.Release|Any CPU.ActiveCfg = Release|Any CPU - {7B2695D6-D24C-4460-A58E-A10F08550CE0}.Release|Any CPU.Build.0 = Release|Any CPU - EndGlobalSection - GlobalSection(SolutionProperties) = preSolution - HideSolutionNode = FALSE - EndGlobalSection -EndGlobal diff --git a/jobs/Backend/Task/ExchangeRateUpdater/src/Common/TextParsingUtils.cs b/jobs/Backend/Task/ExchangeRateUpdater/src/Common/TextParsingUtils.cs new file mode 100644 index 000000000..69828911d --- /dev/null +++ b/jobs/Backend/Task/ExchangeRateUpdater/src/Common/TextParsingUtils.cs @@ -0,0 +1,60 @@ +namespace ExchangeRateUpdater.Common +{ + /// + /// Provides static utility methods for parsing text-based data. + /// + public static class TextParsingUtils + { + /// + /// Analyzes a sample of data lines to automatically detect the most likely column delimiter. + /// + /// The collection of all lines from the source file. + /// The detected delimiter character, or ',' as a default fallback. + public static char DetectDelimiter(IEnumerable lines) + { + var potentialDelimiters = new[] { '|', ',', ';', '\t' }; + var sampleLines = lines.Where(l => !string.IsNullOrWhiteSpace(l)).Take(10).ToList(); + + if (sampleLines.Count < 2) + { + return ','; + } + + var delimiterInfo = potentialDelimiters + .Select(d => new + { + Delimiter = d, + MostCommonGroup = sampleLines.GroupBy(l => l.Count(c => c == d)) + .Where(g => g.Key > 0) + .OrderByDescending(g => g.Count()) + .ThenByDescending(g => g.Key) + .Select(g => new { DelimiterCount = g.Key, LineCount = g.Count() }) + .FirstOrDefault() + }) + .Where(info => info.MostCommonGroup != null && info.MostCommonGroup.LineCount > 1) + .OrderByDescending(info => info.MostCommonGroup?.LineCount) + .ThenByDescending(info => info.MostCommonGroup?.DelimiterCount) + .FirstOrDefault(); + + return delimiterInfo?.Delimiter ?? ','; + } + + /// + /// Finds the zero-based index of the header row in the data file. + /// + /// The collection of all lines from the source file. + /// The delimiter character to look for in the header. + /// The index of the header row, or -1 if not found. + public static int FindHeaderRowIndex(IReadOnlyList lines, char delimiter) + { + for (int i = 0; i < lines.Count; i++) + { + if (lines[i].Contains(delimiter)) + { + return i; + } + } + return -1; + } + } +} diff --git a/jobs/Backend/Task/ExchangeRateUpdater/src/Core/Interfaces/ICnbApiClient.cs b/jobs/Backend/Task/ExchangeRateUpdater/src/Core/Interfaces/ICnbApiClient.cs new file mode 100644 index 000000000..ca05f68a6 --- /dev/null +++ b/jobs/Backend/Task/ExchangeRateUpdater/src/Core/Interfaces/ICnbApiClient.cs @@ -0,0 +1,14 @@ +namespace ExchangeRateUpdater.Core.Interfaces +{ + /// + /// Interface for a client that interacts with the Czech National Bank API to fetch exchange rates. + /// + public interface ICnbApiClient + { + /// + /// Asynchronously retrieves the latest exchange rates from the Czech National Bank API. + /// + /// A task that represents the asynchronous operation, containing the latest exchange rates. + Task GetLatestExchangeRatesAsync(); + } +} \ No newline at end of file diff --git a/jobs/Backend/Task/ExchangeRateUpdater/src/Core/Interfaces/ICnbExchangeRateRepository.cs b/jobs/Backend/Task/ExchangeRateUpdater/src/Core/Interfaces/ICnbExchangeRateRepository.cs new file mode 100644 index 000000000..0a4dff5d8 --- /dev/null +++ b/jobs/Backend/Task/ExchangeRateUpdater/src/Core/Interfaces/ICnbExchangeRateRepository.cs @@ -0,0 +1,22 @@ +using ExchangeRateUpdater.Core.Models; +namespace ExchangeRateUpdater.Core.Interfaces +{ + /// + /// Interface for a repository that retrieves exchange rates from the Czech National Bank (CNB) API. + /// + public interface ICnbExchangeRateRepository + { + /// + /// Asynchronously retrieves the latest exchange rates. + /// + /// A collection of objects representing the latest exchange rates. + Task> GetExchangeRatesAsync(); + + /// + /// Asynchronously retrieves specific exchange rates for the given currencies. + /// + /// + /// + Task> GetSpecificExchangeRatesAsync(IEnumerable currencies); + } +} \ No newline at end of file diff --git a/jobs/Backend/Task/ExchangeRateUpdater/src/Core/Interfaces/IExchangeRateProvider.cs b/jobs/Backend/Task/ExchangeRateUpdater/src/Core/Interfaces/IExchangeRateProvider.cs new file mode 100644 index 000000000..1746e5882 --- /dev/null +++ b/jobs/Backend/Task/ExchangeRateUpdater/src/Core/Interfaces/IExchangeRateProvider.cs @@ -0,0 +1,17 @@ +using ExchangeRateUpdater.Core.Models; + +namespace ExchangeRateUpdater.Core.Interfaces +{ + /// + /// Provides functionality to retrieve exchange rates for specified currencies. + /// + public interface IExchangeRateProvider + { + /// + /// Gets exchange rates for the specified currencies. + /// + /// Collection of currencies to get exchange rates for. + /// Collection of exchange rates for the specified currencies. + Task> GetExchangeRatesAsync(IEnumerable currencies); + } +} diff --git a/jobs/Backend/Task/ExchangeRateUpdater/src/Core/Models/Currency.cs b/jobs/Backend/Task/ExchangeRateUpdater/src/Core/Models/Currency.cs new file mode 100644 index 000000000..0ffca080d --- /dev/null +++ b/jobs/Backend/Task/ExchangeRateUpdater/src/Core/Models/Currency.cs @@ -0,0 +1,77 @@ +namespace ExchangeRateUpdater.Core.Models +{ + /// + /// Represents a currency. + /// + public class Currency + { + /// + /// Initializes a new instance of the class. + /// + /// The three-letter ISO 4217 code of the currency. + /// + public Currency(string code) + { + if (string.IsNullOrWhiteSpace(code)) + { + throw new ArgumentException("Currency code cannot be null or empty.", nameof(code)); + } + Code = code.ToUpperInvariant(); + } + + /// + /// Three-letter ISO 4217 code of the currency. + /// + public string Code { get; } + + /// + /// Overrides the ToString method to return the currency code. + /// + /// The currency code. + public override string ToString() + { + return Code; + } + + /// + /// Determines whether the specified object is equal to the current currency. + /// + /// The object to compare with the current currency. + /// true if the specified object is equal to the current currency; otherwise, false. + public override bool Equals(object? obj) + { + return obj is Currency currency && Code == currency.Code; + } + + /// + /// Generates a hash code for the current currency. + /// + /// A hash code for the current currency. + public override int GetHashCode() + { + return HashCode.Combine(Code); + } + + /// + /// Overloaded equality operator to compare two Currency objects. + /// + /// The first Currency object to compare. + /// The second Currency object to compare. + /// true if both Currency objects are equal; otherwise, false. + public static bool operator ==(Currency? left, Currency? right) + { + return EqualityComparer.Default.Equals(left, right); + } + + /// + /// Overloaded inequality operator to compare two Currency objects. + /// + /// The first Currency object to compare. + /// The second Currency object to compare. + /// true if both Currency objects are not equal; otherwise, false. + public static bool operator !=(Currency? left, Currency? right) + { + return !(left == right); + } + } +} diff --git a/jobs/Backend/Task/ExchangeRateUpdater/src/Core/Models/ExchangeRate.cs b/jobs/Backend/Task/ExchangeRateUpdater/src/Core/Models/ExchangeRate.cs new file mode 100644 index 000000000..4877387b6 --- /dev/null +++ b/jobs/Backend/Task/ExchangeRateUpdater/src/Core/Models/ExchangeRate.cs @@ -0,0 +1,99 @@ +namespace ExchangeRateUpdater.Core.Models +{ + /// + /// Represents an exchange rate between two currencies. + /// + public class ExchangeRate + { + /// + /// Initializes a new instance of the class. + /// + /// The source currency (the currency to be converted from). + /// The target currency (the currency to be converted to). + /// The exchange rate value, which must be greater than zero. + /// Thrown when source or target currency is null. + /// Thrown when the value is less than or equal to zero. + public ExchangeRate(Currency sourceCurrency, Currency targetCurrency, decimal value) + { + if (string.IsNullOrWhiteSpace(sourceCurrency.Code) || string.IsNullOrWhiteSpace(targetCurrency.Code)) + { + throw new ArgumentException("Source and target currencies cannot be null or empty."); + } + if (value <= 0) + { + throw new ArgumentOutOfRangeException(nameof(value), "Exchange rate value must be greater than zero."); + } + SourceCurrency = sourceCurrency; + TargetCurrency = targetCurrency; + Value = value; + } + + /// + /// The source currency of the exchange rate. + /// + public Currency SourceCurrency { get; } + + /// + /// The target currency of the exchange rate. + /// + public Currency TargetCurrency { get; } + + /// + /// The value of the exchange rate, representing how much one unit of the source currency is worth in the target currency. + /// + public decimal Value { get; } + + /// + /// Overrides the ToString method to return a string representation of the exchange rate. + /// + /// A string in the format "SourceCurrency/TargetCurrency=Value". + public override string ToString() + { + return $"{SourceCurrency}/{TargetCurrency}={Value}"; + } + + /// + /// Determines whether the specified object is equal to the current exchange rate. + /// + /// + /// true if the specified object is equal to the current exchange rate; otherwise, false. + public override bool Equals(object? obj) + { + return obj is ExchangeRate rate && + EqualityComparer.Default.Equals(SourceCurrency, rate.SourceCurrency) && + EqualityComparer.Default.Equals(TargetCurrency, rate.TargetCurrency) && + Value == rate.Value; + } + + /// + /// Generates a hash code for the current exchange rate. + /// + /// A hash code for the current exchange rate. + public override int GetHashCode() + { + return HashCode.Combine(SourceCurrency, TargetCurrency, Value); + } + + /// + /// Overloaded equality operator to compare two ExchangeRate objects. + /// + /// + /// + /// true if both ExchangeRate objects are equal; otherwise, false. + public static bool operator ==(ExchangeRate? left, ExchangeRate? right) + { + return EqualityComparer.Default.Equals(left, right); + } + + /// + /// Overloaded inequality operator to compare two ExchangeRate objects. + /// + /// + /// + /// true if both ExchangeRate objects are not equal; otherwise, false. + public static bool operator !=(ExchangeRate? left, ExchangeRate? right) + { + return !(left == right); + } + } +} diff --git a/jobs/Backend/Task/ExchangeRateUpdater/src/Data/CnbExchangeRateRepository.cs b/jobs/Backend/Task/ExchangeRateUpdater/src/Data/CnbExchangeRateRepository.cs new file mode 100644 index 000000000..20ce0267f --- /dev/null +++ b/jobs/Backend/Task/ExchangeRateUpdater/src/Data/CnbExchangeRateRepository.cs @@ -0,0 +1,123 @@ +using ExchangeRateUpdater.Core.Interfaces; +using ExchangeRateUpdater.Core.Models; +using ExchangeRateUpdater.Common; +using Microsoft.Extensions.Logging; + +namespace ExchangeRateUpdater.Data; + +public class CnbExchangeRateRepository : ICnbExchangeRateRepository +{ + private readonly ICnbApiClient _apiClient; + private readonly ILogger _logger; + private readonly Currency _baseCurrency = new Currency("CZK"); + + /// + /// Initializes a new instance of the class. + /// + /// + /// + /// + public CnbExchangeRateRepository(ICnbApiClient apiClient, ILogger logger) + { + _apiClient = apiClient ?? throw new ArgumentNullException(nameof(apiClient)); + _logger = logger ?? throw new ArgumentNullException(nameof(logger)); + } + + /// + /// Asynchronously retrieves the latest exchange rates from the Czech National Bank (CNB) API. + /// + /// A collection of objects representing the latest exchange rates. + /// + public async Task> GetExchangeRatesAsync() + { + try + { + _logger.LogInformation("Retrieving exchange rates from CNB API."); + var response = await _apiClient.GetLatestExchangeRatesAsync(); + var rates = ParseExchangeRates(response); + _logger.LogInformation($"Successfully retrieved {rates.Count()} exchange rates."); + return rates; + } + catch (Exception e) + { + _logger.LogError(e, "Could not retrieve exchange rates from CNB API."); + throw new HttpRequestException("Could not retrieve exchange rates from CNB API.", e); + } + } + + /// + /// Asynchronously retrieves specific exchange rates for the given currencies. + /// + /// + /// A collection of objects for the specified currencies. + /// + public async Task> GetSpecificExchangeRatesAsync(IEnumerable currencies) + { + try + { + _logger.LogInformation("Retrieving specific exchange rates from CNB API for currencies: {Currencies}", string.Join(", ", currencies)); + var allRates = await GetExchangeRatesAsync(); + var specificRates = allRates.Where(rate => currencies.Contains(rate.SourceCurrency)); + _logger.LogInformation("Successfully retrieved specific exchange rates."); + return specificRates; + } + catch (Exception e) + { + _logger.LogError(e, "Could not retrieve specific exchange rates from CNB API."); + throw new HttpRequestException("Could not retrieve specific exchange rates from CNB API.", e); + } + } + + private IEnumerable ParseExchangeRates(string responseText) + { + if (string.IsNullOrWhiteSpace(responseText)) + { + throw new ArgumentException("Exchange rate response text cannot be null or empty.", nameof(responseText)); + } + + string[] lines = responseText.Split(new[] { '\n' }, StringSplitOptions.RemoveEmptyEntries); + char delimiter = TextParsingUtils.DetectDelimiter(lines); + int headerRowIndex = TextParsingUtils.FindHeaderRowIndex(lines, delimiter); + + if (headerRowIndex == -1) + { + _logger.LogError("Could not find header row in exchange rate response."); + yield break; + } + + foreach (var line in lines.Skip(headerRowIndex + 1)) + { + if (string.IsNullOrWhiteSpace(line)) + { + continue; + } + + var parts = line.Split(delimiter); + if (parts.Length < 5) + { + _logger.LogWarning("Skipping invalid exchange rate line format: {Line}", line); + continue; + } + + if (!int.TryParse(parts[2], out var amount)) + { + _logger.LogWarning("Skipping line due to invalid amount format: {Line}", line); + continue; + } + + if (!decimal.TryParse(parts[4], System.Globalization.NumberStyles.Any, System.Globalization.CultureInfo.InvariantCulture, out var rateValue)) + { + _logger.LogWarning("Skipping line due to invalid rate value format: {Line}", line); + continue; + } + + var normalizedRate = rateValue / amount; + + var currency = new Currency(parts[3]); + yield return new ExchangeRate( + sourceCurrency: currency, + targetCurrency: _baseCurrency, + value: normalizedRate); + } + } +} \ No newline at end of file diff --git a/jobs/Backend/Task/ExchangeRateUpdater/src/ExchangeRateUpdater.csproj b/jobs/Backend/Task/ExchangeRateUpdater/src/ExchangeRateUpdater.csproj new file mode 100644 index 000000000..eb243badf --- /dev/null +++ b/jobs/Backend/Task/ExchangeRateUpdater/src/ExchangeRateUpdater.csproj @@ -0,0 +1,35 @@ + + + + Exe + net8.0 + enable + enable + ExchangeRateUpdater + true + 1701;1702;1591 + + + + + + + + + + + + + + + + + + + + + PreserveNewest + + + + \ No newline at end of file diff --git a/jobs/Backend/Task/ExchangeRateUpdater/src/ExchangeRateUpdater.sln b/jobs/Backend/Task/ExchangeRateUpdater/src/ExchangeRateUpdater.sln new file mode 100644 index 000000000..d8c45e36d --- /dev/null +++ b/jobs/Backend/Task/ExchangeRateUpdater/src/ExchangeRateUpdater.sln @@ -0,0 +1,48 @@ + +Microsoft Visual Studio Solution File, Format Version 12.00 +# Visual Studio 14 +VisualStudioVersion = 14.0.25123.0 +MinimumVisualStudioVersion = 10.0.40219.1 +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ExchangeRateUpdater", "ExchangeRateUpdater.csproj", "{7B2695D6-D24C-4460-A58E-A10F08550CE0}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ExchangeRateUpdater.Tests", "../tests/ExchangeRateUpdater.Tests.csproj", "{736A8F22-C35E-4C99-845D-33232BB1E5EB}" +EndProject +Global + GlobalSection(SolutionConfigurationPlatforms) = preSolution + Debug|Any CPU = Debug|Any CPU + Debug|x64 = Debug|x64 + Debug|x86 = Debug|x86 + Release|Any CPU = Release|Any CPU + Release|x64 = Release|x64 + Release|x86 = Release|x86 + EndGlobalSection + GlobalSection(ProjectConfigurationPlatforms) = postSolution + {7B2695D6-D24C-4460-A58E-A10F08550CE0}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {7B2695D6-D24C-4460-A58E-A10F08550CE0}.Debug|Any CPU.Build.0 = Debug|Any CPU + {7B2695D6-D24C-4460-A58E-A10F08550CE0}.Debug|x64.ActiveCfg = Debug|Any CPU + {7B2695D6-D24C-4460-A58E-A10F08550CE0}.Debug|x64.Build.0 = Debug|Any CPU + {7B2695D6-D24C-4460-A58E-A10F08550CE0}.Debug|x86.ActiveCfg = Debug|Any CPU + {7B2695D6-D24C-4460-A58E-A10F08550CE0}.Debug|x86.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 + {7B2695D6-D24C-4460-A58E-A10F08550CE0}.Release|x64.ActiveCfg = Release|Any CPU + {7B2695D6-D24C-4460-A58E-A10F08550CE0}.Release|x64.Build.0 = Release|Any CPU + {7B2695D6-D24C-4460-A58E-A10F08550CE0}.Release|x86.ActiveCfg = Release|Any CPU + {7B2695D6-D24C-4460-A58E-A10F08550CE0}.Release|x86.Build.0 = Release|Any CPU + {736A8F22-C35E-4C99-845D-33232BB1E5EB}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {736A8F22-C35E-4C99-845D-33232BB1E5EB}.Debug|Any CPU.Build.0 = Debug|Any CPU + {736A8F22-C35E-4C99-845D-33232BB1E5EB}.Debug|x64.ActiveCfg = Debug|Any CPU + {736A8F22-C35E-4C99-845D-33232BB1E5EB}.Debug|x64.Build.0 = Debug|Any CPU + {736A8F22-C35E-4C99-845D-33232BB1E5EB}.Debug|x86.ActiveCfg = Debug|Any CPU + {736A8F22-C35E-4C99-845D-33232BB1E5EB}.Debug|x86.Build.0 = Debug|Any CPU + {736A8F22-C35E-4C99-845D-33232BB1E5EB}.Release|Any CPU.ActiveCfg = Release|Any CPU + {736A8F22-C35E-4C99-845D-33232BB1E5EB}.Release|Any CPU.Build.0 = Release|Any CPU + {736A8F22-C35E-4C99-845D-33232BB1E5EB}.Release|x64.ActiveCfg = Release|Any CPU + {736A8F22-C35E-4C99-845D-33232BB1E5EB}.Release|x64.Build.0 = Release|Any CPU + {736A8F22-C35E-4C99-845D-33232BB1E5EB}.Release|x86.ActiveCfg = Release|Any CPU + {736A8F22-C35E-4C99-845D-33232BB1E5EB}.Release|x86.Build.0 = Release|Any CPU + EndGlobalSection + GlobalSection(SolutionProperties) = preSolution + HideSolutionNode = FALSE + EndGlobalSection +EndGlobal diff --git a/jobs/Backend/Task/ExchangeRateUpdater/src/Infrastructure/Http/CnbApiClient.cs b/jobs/Backend/Task/ExchangeRateUpdater/src/Infrastructure/Http/CnbApiClient.cs new file mode 100644 index 000000000..83fde5d69 --- /dev/null +++ b/jobs/Backend/Task/ExchangeRateUpdater/src/Infrastructure/Http/CnbApiClient.cs @@ -0,0 +1,119 @@ +using ExchangeRateUpdater.Core.Interfaces; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; +using Polly; +using Polly.CircuitBreaker; +using Polly.Timeout; + +namespace ExchangeRateUpdater.Infrastructure.Http +{ + /// + /// Implementation of that retrieves exchange rates from the Czech National Bank (CNB) API. + /// Uses Polly for resilience with retry, circuit breaker, timeout, and fallback policies. + /// The client is configured via CnbApiOptions, which can be set in appsettings.json. + /// + public class CnbApiClient : ICnbApiClient + { + private readonly HttpClient _httpClient; + private readonly ILogger _logger; + private readonly CnbApiOptions _options; + private readonly IAsyncPolicy _policy; + + public CnbApiClient(HttpClient httpClient, ILogger logger, IOptions options) + { + _httpClient = httpClient ?? throw new ArgumentNullException(nameof(httpClient)); + _logger = logger ?? throw new ArgumentNullException(nameof(logger)); + _options = options.Value ?? throw new ArgumentNullException(nameof(options)); + + _httpClient.BaseAddress = new Uri(_options.BaseUrl); + _httpClient.Timeout = TimeSpan.FromSeconds(_options.RequestTimeoutSeconds); + + _policy = CreatePolicies(); + } + /// + /// Retrieves latest exchange rates from the Czech National Bank (CNB) API. + /// + /// The latest exchange rates as a string. + /// + public async Task GetLatestExchangeRatesAsync() + { + try + { + return await _policy.ExecuteAsync(async () => + { + _logger.LogDebug("Attempting to retrieve exchange rates from CNB API."); + using var response = await _httpClient.GetAsync(_options.ExchangeRatesEndpoint); + response.EnsureSuccessStatusCode(); + + var content = await response.Content.ReadAsStringAsync(); + _logger.LogDebug("Successfully retrieved exchange rates from CNB API."); + return content; + }); + } + catch (Exception e) + { + _logger.LogError(e, "Failed to retrieve exchange rates from CNB API."); + throw new HttpRequestException("Failed to retrieve exchange rates from CNB API.", e); + } + } + + private IAsyncPolicy CreatePolicies() + { + var retryPolicy = Policy + .Handle() + .Or() + .Or() + .WaitAndRetryAsync(_options.MaxRetries, attempt => TimeSpan.FromSeconds(Math.Pow(2, attempt)), + onRetry: (exception, delay, retryCount, _) => + { + var ex = exception.Exception ?? new Exception(exception.ToString() ?? "Unknown error"); + _logger.LogWarning(ex, "Retry {RetryCount} for request due to: {ExceptionMessage}. Waiting {Delay} before next attempt.", + retryCount, ex.Message, delay); + }); + + var circuitBreakerPolicy = Policy + .Handle() + .Or() + .AdvancedCircuitBreakerAsync( + failureThreshold: _options.CircuitBreakerFailureThreshold, + samplingDuration: TimeSpan.FromSeconds(_options.CircuitBreakerSamplingDurationSeconds), + minimumThroughput: _options.CircuitBreakerMinimumThroughput, + durationOfBreak: TimeSpan.FromSeconds(_options.CircuitBreakerDurationOfBreakSeconds), + onBreak: (exception, breakDelay) => + { + var ex = exception.Exception ?? new Exception(exception.ToString() ?? "Unknown error"); + _logger.LogWarning( + ex, + "Circuit breaker opened for {BreakDelay} seconds", + breakDelay.TotalSeconds); + }, + onReset: () => + { + _logger.LogInformation("Circuit breaker reset, flow is now normal"); + }, + onHalfOpen: () => + { + _logger.LogInformation("Circuit breaker half-open, testing if service has recovered"); + }); + + var timeoutPolicy = Policy.TimeoutAsync( + TimeSpan.FromSeconds(_options.RequestTimeoutSeconds), + TimeoutStrategy.Optimistic); + + var fallbackPolicy = Policy + .Handle() + .Or() + .Or() + .FallbackAsync( + fallbackValue: string.Empty, + onFallbackAsync: async (outcome) => + { + var ex = outcome.Exception ?? new Exception(outcome.ToString() ?? "Unknown error"); + _logger.LogWarning(ex, "Fallback triggered due to error"); + await Task.CompletedTask; + }); + + return Policy.WrapAsync(fallbackPolicy, Policy.WrapAsync(retryPolicy, circuitBreakerPolicy, timeoutPolicy)); + } + } +} diff --git a/jobs/Backend/Task/ExchangeRateUpdater/src/Infrastructure/Http/CnbApiOptions.cs b/jobs/Backend/Task/ExchangeRateUpdater/src/Infrastructure/Http/CnbApiOptions.cs new file mode 100644 index 000000000..ca9e79d98 --- /dev/null +++ b/jobs/Backend/Task/ExchangeRateUpdater/src/Infrastructure/Http/CnbApiOptions.cs @@ -0,0 +1,49 @@ +namespace ExchangeRateUpdater.Infrastructure.Http +{ + /// + /// Provides configuration options for the CnbApiClient. + /// These options are typically configured in appsettings.json and bound at runtime. + /// + public class CnbApiOptions + { + /// + /// Gets or sets the base URL for the Czech National Bank (CNB) API. + /// + public string BaseUrl { get; set; } = "https://www.cnb.cz/"; + + /// + /// Gets or sets the endpoint for retrieving daily exchange rates. + /// + public string ExchangeRatesEndpoint { get; set; } = "en/financial_markets/foreign_exchange_market/exchange_rate_fixing/daily.txt"; + + /// + /// Gets or sets the timeout in seconds for a single HTTP request to the CNB API. + /// + public int RequestTimeoutSeconds { get; set; } = 30; + + /// + /// Gets or sets the maximum number of retries for a failed request. + /// + public int MaxRetries { get; set; } = 3; + + /// + /// Gets or sets the failure threshold for the circuit breaker (e.g., 0.5 means 50% of requests failed). + /// + public double CircuitBreakerFailureThreshold { get; set; } = 0.5; + + /// + /// Gets or sets the duration in seconds over which failures are measured for the circuit breaker. + /// + public int CircuitBreakerSamplingDurationSeconds { get; set; } = 60; + + /// + /// Gets or sets the minimum number of requests in the sampling duration before the circuit breaker can open. + /// + public int CircuitBreakerMinimumThroughput { get; set; } = 5; + + /// + /// Gets or sets the duration in seconds for which the circuit breaker will remain open before transitioning to half-open. + /// + public int CircuitBreakerDurationOfBreakSeconds { get; set; } = 30; + } +} \ No newline at end of file diff --git a/jobs/Backend/Task/ExchangeRateUpdater/src/Program.cs b/jobs/Backend/Task/ExchangeRateUpdater/src/Program.cs new file mode 100644 index 000000000..b44015c6b --- /dev/null +++ b/jobs/Backend/Task/ExchangeRateUpdater/src/Program.cs @@ -0,0 +1,106 @@ +using ExchangeRateUpdater.Core.Interfaces; +using ExchangeRateUpdater.Core.Models; +using ExchangeRateUpdater.Data; +using ExchangeRateUpdater.Infrastructure.Http; +using ExchangeRateUpdater.Services; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; + +namespace ExchangeRateUpdater +{ + public class Program + { + private static readonly IEnumerable DefaultCurrencies = 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 async Task Main(string[] args) + { + try + { + using var host = CreateHostBuilder(args).Build(); + + var exchangeRateProvider = host.Services.GetRequiredService(); + var logger = host.Services.GetRequiredService>(); + + var currencies = args.Length > 0 + ? args.Select(c => new Currency(c.Trim().ToUpper())) + : DefaultCurrencies; + + logger.LogInformation("Starting exchange rate retrieval..."); + + var rates = await exchangeRateProvider.GetExchangeRatesAsync(currencies); + var ratesList = rates.ToList(); + + if (ratesList.Count == 0) + { + logger.LogWarning("No exchange rates found for the specified currencies: {Currencies}", string.Join(", ", currencies)); + return 0; + } + + logger.LogInformation("Successfully retrieved {Count} exchange rates:", ratesList.Count); + foreach (var rate in ratesList) + { + Console.WriteLine(rate.ToString()); + } + + logger.LogInformation("Exchange rate retrieval completed successfully."); + return 0; + } + catch (Exception ex) + { + Console.Error.WriteLine($"An error occurred: {ex.Message}"); + return 1; + } + } + + private static IHostBuilder CreateHostBuilder(string[] args) => + Host.CreateDefaultBuilder(args) + .ConfigureAppConfiguration((hostingContext, config) => + { + var env = hostingContext.HostingEnvironment; + config.Sources.Clear(); + + var appLocation = Path.GetDirectoryName(typeof(Program).Assembly.Location)!; + + config.SetBasePath(appLocation) + .AddJsonFile("appsettings.json", optional: false, reloadOnChange: true) + .AddJsonFile($"appsettings.{env.EnvironmentName}.json", optional: true, reloadOnChange: true) + .AddEnvironmentVariables() + .AddCommandLine(args); + }) + .ConfigureServices((hostContext, services) => + { + + services.Configure( + hostContext.Configuration.GetSection("CnbApi")); + + + services.AddHttpClient((serviceProvider, client) => + { + var options = serviceProvider.GetRequiredService>().Value; + client.BaseAddress = new Uri(options.BaseUrl); + client.Timeout = TimeSpan.FromSeconds(options.RequestTimeoutSeconds); + }); + + + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + + services.AddLogging(configure => + configure.AddConsole().AddDebug().SetMinimumLevel(LogLevel.Information)); + }); + } +} diff --git a/jobs/Backend/Task/ExchangeRateUpdater/src/Services/ExchangerateProvider.cs b/jobs/Backend/Task/ExchangeRateUpdater/src/Services/ExchangerateProvider.cs new file mode 100644 index 000000000..a63cec392 --- /dev/null +++ b/jobs/Backend/Task/ExchangeRateUpdater/src/Services/ExchangerateProvider.cs @@ -0,0 +1,70 @@ +using ExchangeRateUpdater.Core.Interfaces; +using ExchangeRateUpdater.Core.Models; +using Microsoft.Extensions.Logging; + +namespace ExchangeRateUpdater.Services +{ + /// + /// Provides a mechanism for retrieving exchange rates for a given set of currencies. + /// This is the default implementation of and relies on an to fetch the data. + /// + public class ExchangeRateProvider : IExchangeRateProvider + { + private readonly ICnbExchangeRateRepository _cnbExchangeRateRepository; + private readonly ILogger _logger; + + /// + /// Initializes a new instance of the class. + /// + /// The repository for accessing CNB exchange rate data. + /// The logger for logging information and errors. + /// Thrown if or is null. + public ExchangeRateProvider( + ICnbExchangeRateRepository exchangeRateRepository, + ILogger logger) + { + _cnbExchangeRateRepository = exchangeRateRepository ?? throw new ArgumentNullException(nameof(exchangeRateRepository)); + _logger = logger ?? throw new ArgumentNullException(nameof(logger)); + } + + + /// + /// Asynchronously retrieves exchange rates for a specified list of currencies. + /// + /// An enumerable collection of objects for which to retrieve exchange rates. + /// + /// A task that represents the asynchronous operation. The task result contains an enumerable collection of + /// for the requested currencies. Returns an empty collection if the input is empty. + /// + /// Thrown if the collection is null. + /// Rethrows exceptions from the underlying repository on failure. + public async Task> GetExchangeRatesAsync(IEnumerable currencies) + { + if (currencies == null) + throw new ArgumentNullException(nameof(currencies)); + + var currencyList = currencies.ToList(); + if (currencyList.Count == 0) + { + _logger.LogWarning("No currencies provided for exchange rate lookup"); + return Enumerable.Empty(); + } + + try + { + _logger.LogInformation("Retrieving exchange rates for {Count} currencies", currencyList.Count); + + var rates = await _cnbExchangeRateRepository.GetSpecificExchangeRatesAsync(currencyList); + var ratesList = rates.ToList(); + + _logger.LogInformation("Successfully retrieved {Count} exchange rates", ratesList.Count); + return ratesList; + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to retrieve exchange rates"); + throw; + } + } + } +} \ No newline at end of file diff --git a/jobs/Backend/Task/ExchangeRateUpdater/src/appsettings.json b/jobs/Backend/Task/ExchangeRateUpdater/src/appsettings.json new file mode 100644 index 000000000..5e41ee28c --- /dev/null +++ b/jobs/Backend/Task/ExchangeRateUpdater/src/appsettings.json @@ -0,0 +1,19 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft": "Warning", + "Microsoft.Hosting.Lifetime": "Information" + } + }, + "CnbApi": { + "BaseUrl": "https://www.cnb.cz/", + "ExchangeRatesEndpoint": "en/financial_markets/foreign_exchange_market/exchange_rate_fixing/daily.txt", + "RequestTimeoutSeconds": 30, + "MaxRetries": 3, + "CircuitBreakerFailureThreshold": 0.5, + "CircuitBreakerSamplingDurationSeconds": 60, + "CircuitBreakerMinimumThroughput": 5, + "CircuitBreakerDurationOfBreakSeconds": 30 + } +} \ No newline at end of file diff --git a/jobs/Backend/Task/ExchangeRateUpdater/tests/ExchangeRateUpdater.Tests.csproj b/jobs/Backend/Task/ExchangeRateUpdater/tests/ExchangeRateUpdater.Tests.csproj new file mode 100644 index 000000000..fc34557eb --- /dev/null +++ b/jobs/Backend/Task/ExchangeRateUpdater/tests/ExchangeRateUpdater.Tests.csproj @@ -0,0 +1,34 @@ + + + + net8.0 + enable + enable + false + + + + + + + + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + + + + + + + + + + diff --git a/jobs/Backend/Task/ExchangeRateUpdater/tests/Integration/CnbApiClientIntegrationTests.cs b/jobs/Backend/Task/ExchangeRateUpdater/tests/Integration/CnbApiClientIntegrationTests.cs new file mode 100644 index 000000000..72ec84fd8 --- /dev/null +++ b/jobs/Backend/Task/ExchangeRateUpdater/tests/Integration/CnbApiClientIntegrationTests.cs @@ -0,0 +1,28 @@ +using Xunit; +using Moq; +using Microsoft.Extensions.Logging; +using ExchangeRateUpdater.Infrastructure.Http; +using Microsoft.Extensions.Options; + +namespace ExchangeRateUpdater.Tests.Integration +{ + public class CnbApiClientIntegrationTests + { + [Fact] + [Trait("Category", "Integration")] // An attribute to mark this as a slow integration test + public async Task GetLatestExchangeRatesAsync_WhenCalled_ConnectsToCnbAndFetchesData() + { + var options = Options.Create(new CnbApiOptions()); + + var httpClient = new HttpClient(); + var mockLogger = new Mock>(); + + var apiClient = new CnbApiClient(httpClient, mockLogger.Object, options); + + var result = await apiClient.GetLatestExchangeRatesAsync(); + + Assert.NotNull(result); + Assert.False(string.IsNullOrWhiteSpace(result)); + } + } +} \ No newline at end of file diff --git a/jobs/Backend/Task/ExchangeRateUpdater/tests/Unit/CnbExchangeRateRepositoryTest.cs b/jobs/Backend/Task/ExchangeRateUpdater/tests/Unit/CnbExchangeRateRepositoryTest.cs new file mode 100644 index 000000000..1dc08a376 --- /dev/null +++ b/jobs/Backend/Task/ExchangeRateUpdater/tests/Unit/CnbExchangeRateRepositoryTest.cs @@ -0,0 +1,62 @@ +using Xunit; +using Moq; +using Microsoft.Extensions.Logging; +using ExchangeRateUpdater.Data; +using ExchangeRateUpdater.Core.Interfaces; + +namespace ExchangeRateUpdater.Tests.Unit +{ + public class CnbExchangeRateRepositoryTest + { + private readonly Mock _mockApiClient; + private readonly Mock> _mockLogger; + private readonly CnbExchangeRateRepository _repository; + + public CnbExchangeRateRepositoryTest() + { + _mockApiClient = new Mock(); + _mockLogger = new Mock>(); + _repository = new CnbExchangeRateRepository(_mockApiClient.Object, _mockLogger.Object); + } + + [Fact] + public async Task GetExchangeRatesAsync_WithValidData_ReturnsCorrectlyParsedRates() + { + var fakeApiResponse = @"21 Jun 2024 #118 +Country|Currency|Amount|Code|Rate +Australia|dollar|1|AUD|15.426 +Brazil|real|1|BRL|4.274 +Canada|dollar|1|CAD|17.009"; + + _mockApiClient + .Setup(client => client.GetLatestExchangeRatesAsync()) + .ReturnsAsync(fakeApiResponse); + + var result = await _repository.GetExchangeRatesAsync(); + var rates = result.ToList(); + + Assert.NotNull(result); + Assert.Equal(3, rates.Count); + + var audRate = rates.FirstOrDefault(r => r.SourceCurrency.Code == "AUD"); + Assert.NotNull(audRate); + Assert.Equal("AUD", audRate.SourceCurrency.Code); + Assert.Equal("CZK", audRate.TargetCurrency.Code); + Assert.Equal(15.426m, audRate.Value); + } + + [Fact] + public async Task GetExchangeRatesAsync_WithEmptyString_ThrowsHttpRequestException() + { + var fakeApiResponse = string.Empty; + + _mockApiClient + .Setup(client => client.GetLatestExchangeRatesAsync()) + .ReturnsAsync(fakeApiResponse); + + var exception = await Assert.ThrowsAsync(() => _repository.GetExchangeRatesAsync()); + + Assert.IsType(exception.InnerException); + } + } +} \ No newline at end of file diff --git a/jobs/Backend/Task/Program.cs b/jobs/Backend/Task/Program.cs deleted file mode 100644 index 379a69b1f..000000000 --- a/jobs/Backend/Task/Program.cs +++ /dev/null @@ -1,43 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Linq; - -namespace ExchangeRateUpdater -{ - public static class Program - { - private static IEnumerable currencies = new[] - { - new Currency("USD"), - new Currency("EUR"), - new Currency("CZK"), - new Currency("JPY"), - new Currency("KES"), - new Currency("RUB"), - new Currency("THB"), - new Currency("TRY"), - new Currency("XYZ") - }; - - public static void Main(string[] args) - { - try - { - var provider = new ExchangeRateProvider(); - var rates = provider.GetExchangeRates(currencies); - - Console.WriteLine($"Successfully retrieved {rates.Count()} exchange rates:"); - foreach (var rate in rates) - { - Console.WriteLine(rate.ToString()); - } - } - catch (Exception e) - { - Console.WriteLine($"Could not retrieve exchange rates: '{e.Message}'."); - } - - Console.ReadLine(); - } - } -}