diff --git a/jobs/Backend/Task/Clients/CnpApiClient.cs b/jobs/Backend/Task/Clients/CnpApiClient.cs new file mode 100644 index 0000000000..1e18c96cab --- /dev/null +++ b/jobs/Backend/Task/Clients/CnpApiClient.cs @@ -0,0 +1,53 @@ +using ExchangeRateUpdater.Configuration; +using ExchangeRateUpdater.Exceptions; +using Microsoft.Extensions.Options; +using System.Net.Http; +using System.Threading.Tasks; +using System.Threading; +using System; +using System.Runtime.ConstrainedExecution; + +namespace ExchangeRateUpdater.Clients +{ + /// + /// Czech National Bank specific implementation of exchange rate API client. + /// Fetches data from CNB's daily exchange rate fixing endpoint. + /// + public class CnbApiClient : IExchangeRateApiClient + { + private readonly HttpClient _httpClient; + private readonly ExchangeRateApiSettings _settings; + + public CnbApiClient(HttpClient httpClient, IOptions settings) + { + _httpClient = httpClient ?? throw new ArgumentNullException(nameof(httpClient)); + _settings = settings?.Value ?? throw new ArgumentNullException(nameof(settings)); + + _httpClient.Timeout = TimeSpan.FromSeconds(_settings.TimeoutSeconds); + } + + public async Task GetDailyRatesAsync(CancellationToken cancellationToken = default) + { + try + { + var response = await _httpClient.GetAsync(_settings.DailyRatesUrl, cancellationToken); + response.EnsureSuccessStatusCode(); + + return await response.Content.ReadAsStringAsync(cancellationToken); + } + //Consistent error handling regardless of which client implementation is used + catch (HttpRequestException ex) + { + throw new ExchangeRateException( + "Failed to retrieve exchange rates from CNB API.", + ex); + } + catch (TaskCanceledException ex) + { + throw new ExchangeRateException( + "Request to CNB API timed out.", + ex); + } + } + } +} \ No newline at end of file diff --git a/jobs/Backend/Task/Clients/IExchangeRateApiClient.cs b/jobs/Backend/Task/Clients/IExchangeRateApiClient.cs new file mode 100644 index 0000000000..0c852cc2bd --- /dev/null +++ b/jobs/Backend/Task/Clients/IExchangeRateApiClient.cs @@ -0,0 +1,17 @@ +using System.Threading.Tasks; +using System.Threading; + +namespace ExchangeRateUpdater.Clients +{ + /// + /// Generic contract for fetching exchange rate data from any source. + /// Implementation can target different central banks or financial data providers. + /// + public interface IExchangeRateApiClient + { + /// + /// Fetches raw exchange rate data from the source. + /// + Task GetDailyRatesAsync(CancellationToken cancellationToken = default); + } +} \ No newline at end of file diff --git a/jobs/Backend/Task/Configuration/ExchangeRateApiSettings.cs b/jobs/Backend/Task/Configuration/ExchangeRateApiSettings.cs new file mode 100644 index 0000000000..d06a5548eb --- /dev/null +++ b/jobs/Backend/Task/Configuration/ExchangeRateApiSettings.cs @@ -0,0 +1,12 @@ +namespace ExchangeRateUpdater.Configuration +{ + /// + /// Configuration settings for exchange rate API access - Generic structure usable for any exchange rate source. + /// Compile time binding via IOptions pattern better than raw string keys + /// + public class ExchangeRateApiSettings + { + public string DailyRatesUrl { get; set; } = string.Empty; + public int TimeoutSeconds { get; set; } = 30; + } +} \ 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/ExchangeRateException.cs b/jobs/Backend/Task/Exceptions/ExchangeRateException.cs new file mode 100644 index 0000000000..d7165ef323 --- /dev/null +++ b/jobs/Backend/Task/Exceptions/ExchangeRateException.cs @@ -0,0 +1,20 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Runtime.InteropServices; +using System.Text; +using System.Threading.Tasks; + +namespace ExchangeRateUpdater.Exceptions +{ + /// + /// Custom exception for exchange rate operations. + /// caller can distinguish domain errors from infrastructure errors + /// + public class ExchangeRateException : Exception + { + public ExchangeRateException(string message) : base(message) { } + public ExchangeRateException(string message, Exception innerException) + : base(message, innerException) { } + } +} 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..832083d5c5 100644 --- a/jobs/Backend/Task/ExchangeRateUpdater.csproj +++ b/jobs/Backend/Task/ExchangeRateUpdater.csproj @@ -5,4 +5,26 @@ net6.0 + + + + + + + + + + + + + Always + + + Always + + + Always + + + \ No newline at end of file diff --git a/jobs/Backend/Task/ExchangeRateUpdater.sln b/jobs/Backend/Task/ExchangeRateUpdater.sln index 89be84daff..f5967b0622 100644 --- a/jobs/Backend/Task/ExchangeRateUpdater.sln +++ b/jobs/Backend/Task/ExchangeRateUpdater.sln @@ -1,9 +1,11 @@  Microsoft Visual Studio Solution File, Format Version 12.00 -# Visual Studio 14 -VisualStudioVersion = 14.0.25123.0 +# Visual Studio Version 17 +VisualStudioVersion = 17.10.35122.118 MinimumVisualStudioVersion = 10.0.40219.1 -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ExchangeRateUpdater", "ExchangeRateUpdater.csproj", "{7B2695D6-D24C-4460-A58E-A10F08550CE0}" +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "ExchangeRateUpdater", "ExchangeRateUpdater.csproj", "{7B2695D6-D24C-4460-A58E-A10F08550CE0}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ExchangeRateUpdater.XUnitTests", "..\ExchangeRateUpdater.XUnitTests\ExchangeRateUpdater.XUnitTests.csproj", "{ABAEC4CE-7FFB-4DAE-AFED-CBCD6D699BF4}" EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution @@ -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 + {ABAEC4CE-7FFB-4DAE-AFED-CBCD6D699BF4}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {ABAEC4CE-7FFB-4DAE-AFED-CBCD6D699BF4}.Debug|Any CPU.Build.0 = Debug|Any CPU + {ABAEC4CE-7FFB-4DAE-AFED-CBCD6D699BF4}.Release|Any CPU.ActiveCfg = Release|Any CPU + {ABAEC4CE-7FFB-4DAE-AFED-CBCD6D699BF4}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE EndGlobalSection + GlobalSection(ExtensibilityGlobals) = postSolution + SolutionGuid = {7A601B6F-E9B2-4165-8DB1-8AB0DD43684F} + EndGlobalSection EndGlobal diff --git a/jobs/Backend/Task/Models/Currency.cs b/jobs/Backend/Task/Models/Currency.cs new file mode 100644 index 0000000000..5d0a73c578 --- /dev/null +++ b/jobs/Backend/Task/Models/Currency.cs @@ -0,0 +1,37 @@ +using System; + +namespace ExchangeRateUpdater.Models +{ + /// + /// Immutable value object representing a currency. + /// + public class Currency + { + //Validate in constructor. Don't allow invalid states. + public Currency(string code) + { + if (string.IsNullOrWhiteSpace(code)) + throw new ArgumentException("Currency code cannot be null or empty.", nameof(code)); + + //Normalize to uppercase (usd : USD) for consistency + Code = code.ToUpperInvariant(); + } + + /// + /// Three-letter ISO 4217 code of the currency. + /// + public string Code { get; } + + public override string ToString() + { + return Code; + } + + // Equality based on Code to use it in HashSet/Dictionary + public override bool Equals(object? obj) => + obj is Currency other && Code == other.Code; + + // Required when overriding Equals: Ensures Dictionary/HashSet work correctly + public override int GetHashCode() => Code.GetHashCode(); + } +} \ No newline at end of file diff --git a/jobs/Backend/Task/Models/ExchangeRate.cs b/jobs/Backend/Task/Models/ExchangeRate.cs new file mode 100644 index 0000000000..5bef2260d3 --- /dev/null +++ b/jobs/Backend/Task/Models/ExchangeRate.cs @@ -0,0 +1,31 @@ +using System; +using System.Runtime.ConstrainedExecution; + +namespace ExchangeRateUpdater.Models +{ + /// + /// Immutable value object representing an exchange rate between two currencies. + /// + public class ExchangeRate + { + //validations in constructor + public ExchangeRate(Currency sourceCurrency, Currency targetCurrency, decimal value) + { + SourceCurrency = sourceCurrency ?? throw new ArgumentNullException(nameof(sourceCurrency)); + TargetCurrency = targetCurrency ?? throw new ArgumentNullException(nameof(targetCurrency)); + + if (value <= 0) + throw new ArgumentException("Exchange rate value must be positive.", nameof(value)); + + Value = value; + } + + public Currency SourceCurrency { get; } + public Currency TargetCurrency { get; } + public decimal Value { get; } + + //consistent output(4 decimal places) + public override string ToString() => + $"{SourceCurrency}/{TargetCurrency}={Value:F4}"; + } +} \ No newline at end of file diff --git a/jobs/Backend/Task/Parsers/CnbDataParser.cs b/jobs/Backend/Task/Parsers/CnbDataParser.cs new file mode 100644 index 0000000000..a2d4b8735e --- /dev/null +++ b/jobs/Backend/Task/Parsers/CnbDataParser.cs @@ -0,0 +1,86 @@ +using ExchangeRateUpdater.Exceptions; +using ExchangeRateUpdater.Models; +using System.Collections.Generic; +using System.Linq; +using System; + +namespace ExchangeRateUpdater.Parsers +{ + /// + /// Parses CNB daily exchange rates text format. + /// Format: Country|Currency|Amount|Code|Rate + /// Example: USA|dollar|1|USD|24.123 + /// + public class CnbDataParser : IExchangeRateDataParser + { + private const int ExpectedColumnCount = 5; + private const int AmountIndex = 2; + private const int CodeIndex = 3; + private const int RateIndex = 4; + private static readonly Currency CzkCurrency = new("CZK"); + + public IEnumerable Parse(string rawData, IEnumerable targetCurrencies) + { + if (string.IsNullOrWhiteSpace(rawData)) + throw new ExchangeRateException("CNB data is empty."); + + // enhances performance and ensures that the filtering process is accurate and efficient. + // HashSet for O(1) lookup performance instead of O(n) with List.Contains--> Checking if currency is requested happens for every parsed line + var currencyCodesSet = new HashSet( + targetCurrencies.Select(c => c.Code), + StringComparer.OrdinalIgnoreCase); + + //Parse all lines and then filter + return ParseLines(rawData) + .Where(rate => currencyCodesSet.Contains(rate.SourceCurrency.Code)) + .ToList(); + } + + private IEnumerable ParseLines(string rawData) + { + var lines = rawData.Split(new[] { '\r', '\n' }, StringSplitOptions.RemoveEmptyEntries); + + // first 2 lines: date and column headers + return lines.Skip(2) + .Select(ParseLine) + .Where(rate => rate != null) + .Cast(); + } + + private ExchangeRate? ParseLine(string line) + { + try + { + var columns = line.Split('|'); + + if (columns.Length != ExpectedColumnCount) + return null; + + var currencyCode = columns[CodeIndex].Trim(); + var amount = ParseDecimal(columns[AmountIndex]); + var rate = ParseDecimal(columns[RateIndex]); + + // CNB rates are: {amount} {currency} = {rate} CZK + // We want: 1 {currency} = {rate/amount} CZK. We normalize it to 1 unit. + var normalizedRate = rate / amount; + + return new ExchangeRate( + sourceCurrency: new Currency(currencyCode), + targetCurrency: CzkCurrency, + value: normalizedRate); + } + catch + { + // Skip malformed lines + return null; + } + } + + private decimal ParseDecimal(string value) + { + // CNB uses comma as decimal separator + var normalized = value.Trim().Replace(',', '.'); + return decimal.Parse(normalized, System.Globalization.CultureInfo.InvariantCulture); + } + } +} \ No newline at end of file diff --git a/jobs/Backend/Task/Parsers/IExchangeRateDataParser.cs b/jobs/Backend/Task/Parsers/IExchangeRateDataParser.cs new file mode 100644 index 0000000000..b3f38bc3ab --- /dev/null +++ b/jobs/Backend/Task/Parsers/IExchangeRateDataParser.cs @@ -0,0 +1,20 @@ +using ExchangeRateUpdater.Models; +using System.Collections.Generic; + +namespace ExchangeRateUpdater.Parsers +{ + /// + /// Generic contract for parsing exchange rate data from any format. + /// Implementations can handle different data formats (TXT, XML, JSON, CSV). + /// + public interface IExchangeRateDataParser + { + /// + /// Parses raw data into structured exchange rates. + /// + /// Raw text data from the API + /// Currencies to filter for + /// Collection of exchange rates + IEnumerable Parse(string rawData, IEnumerable targetCurrencies); + } +} \ No newline at end of file diff --git a/jobs/Backend/Task/Program.cs b/jobs/Backend/Task/Program.cs index 379a69b1f8..cc6d354155 100644 --- a/jobs/Backend/Task/Program.cs +++ b/jobs/Backend/Task/Program.cs @@ -1,12 +1,27 @@ using System; using System.Collections.Generic; -using System.Linq; +using System.IO; +using System.Linq; +using System.Threading.Tasks; +using ExchangeRateUpdater.Clients; +using ExchangeRateUpdater.Configuration; +using ExchangeRateUpdater.Models; +using ExchangeRateUpdater.Parsers; +using ExchangeRateUpdater.Services; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; namespace ExchangeRateUpdater { + /// + /// TOP DECISIONS: + /// - Settings for Environment-specific configuration + /// - Dependency Injection for testability and loose coupling + /// - IHttpClientFactory pattern to prevent socket exhaustion + /// public static class Program { - private static IEnumerable currencies = new[] + private static readonly IEnumerable currencies = new[] { new Currency("USD"), new Currency("EUR"), @@ -19,12 +34,15 @@ 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"; + var services = ConfigureServices(environment); + try { - var provider = new ExchangeRateProvider(); - var rates = provider.GetExchangeRates(currencies); + var provider = services.GetRequiredService(); + var rates = await provider.GetExchangeRatesAsync(currencies); Console.WriteLine($"Successfully retrieved {rates.Count()} exchange rates:"); foreach (var rate in rates) @@ -37,7 +55,31 @@ public static void Main(string[] args) Console.WriteLine($"Could not retrieve exchange rates: '{e.Message}'."); } + Console.WriteLine("\nPress any key to exit..."); 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(); + + // Options Pattern: Strongly-typed configuration with validation support, Type-safe, compile-time checking + services.Configure(configuration.GetSection("ExchangeRateApi")); + + // HttpClient and Parser with generic interfaces for easy abstraction and testing, and swappable with other implementations of api or parser + services.AddHttpClient(); + services.AddTransient(); + + // Provider has no interface: It's already generic, just orchestrates dependencies. Contains zero business logic + services.AddTransient(); + + return services.BuildServiceProvider(); + } } -} +} \ No newline at end of file diff --git a/jobs/Backend/Task/Properties/launchSettings.json b/jobs/Backend/Task/Properties/launchSettings.json new file mode 100644 index 0000000000..af678b4ea3 --- /dev/null +++ b/jobs/Backend/Task/Properties/launchSettings.json @@ -0,0 +1,10 @@ +{ + "profiles": { + "ExchangeRateUpdater": { + "commandName": "Project", + "environmentVariables": { + "DOTNET_ENVIRONMENT": "Development" + } + } + } +} \ No newline at end of file diff --git a/jobs/Backend/Task/Services/ExchangeRateProvider.cs b/jobs/Backend/Task/Services/ExchangeRateProvider.cs new file mode 100644 index 0000000000..897effe921 --- /dev/null +++ b/jobs/Backend/Task/Services/ExchangeRateProvider.cs @@ -0,0 +1,46 @@ +using ExchangeRateUpdater.Clients; +using ExchangeRateUpdater.Models; +using ExchangeRateUpdater.Parsers; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using System.Threading; +using System; + +namespace ExchangeRateUpdater.Services +{ + /// + /// Generic exchange rate provider that orchestrates data fetching and parsing. + /// It is reusable across different exchange rate sources by injecting different implementations + /// of IExchangeRateApiClient and IExchangeRateDataParser. + /// + public class ExchangeRateProvider + { + private readonly IExchangeRateApiClient _apiClient; + private readonly IExchangeRateDataParser _dataParser; + + public ExchangeRateProvider(IExchangeRateApiClient apiClient, IExchangeRateDataParser dataParser) + { + _apiClient = apiClient ?? throw new ArgumentNullException(nameof(apiClient)); + _dataParser = dataParser ?? throw new ArgumentNullException(nameof(dataParser)); + } + + /// + /// Gets exchange rates for specified currencies. + /// Returns only rates explicitly provided by the source (no calculated/reverse rates). + /// Cancellation token allows cooperative cancellation of async operations + /// + public async Task> GetExchangeRatesAsync( + IEnumerable currencies, + CancellationToken cancellationToken = default) + { + var currenciesList = currencies?.ToList() ?? throw new ArgumentNullException(nameof(currencies)); + + if (!currenciesList.Any()) + return Enumerable.Empty(); + + var rawData = await _apiClient.GetDailyRatesAsync(cancellationToken); + return _dataParser.Parse(rawData, currenciesList); + } + } +} \ No newline at end of file diff --git a/jobs/Backend/Task/appsettings.Development.json b/jobs/Backend/Task/appsettings.Development.json new file mode 100644 index 0000000000..ecb99b52f8 --- /dev/null +++ b/jobs/Backend/Task/appsettings.Development.json @@ -0,0 +1,6 @@ +{ + "ExchangeRateApi": { + "DailyRatesUrl": "https://www.cnb.cz/en/financial-markets/foreign-exchange-market/central-bank-exchange-rate-fixing/central-bank-exchange-rate-fixing/daily.txt", + "TimeoutSeconds": 30 + } +} \ No newline at end of file diff --git a/jobs/Backend/Task/appsettings.Production.json b/jobs/Backend/Task/appsettings.Production.json new file mode 100644 index 0000000000..ecb99b52f8 --- /dev/null +++ b/jobs/Backend/Task/appsettings.Production.json @@ -0,0 +1,6 @@ +{ + "ExchangeRateApi": { + "DailyRatesUrl": "https://www.cnb.cz/en/financial-markets/foreign-exchange-market/central-bank-exchange-rate-fixing/central-bank-exchange-rate-fixing/daily.txt", + "TimeoutSeconds": 30 + } +} \ 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..ecb99b52f8 --- /dev/null +++ b/jobs/Backend/Task/appsettings.json @@ -0,0 +1,6 @@ +{ + "ExchangeRateApi": { + "DailyRatesUrl": "https://www.cnb.cz/en/financial-markets/foreign-exchange-market/central-bank-exchange-rate-fixing/central-bank-exchange-rate-fixing/daily.txt", + "TimeoutSeconds": 30 + } +} \ No newline at end of file