From 0d313bb8c4ec8839228818176fba337b3b6fe522 Mon Sep 17 00:00:00 2001 From: filipworks Date: Mon, 17 Nov 2025 17:34:55 +0100 Subject: [PATCH 1/5] Initial commit. Refactored according to DDD but as separate projects BusinessLogic (Domain), Data (Infrastructure) and move and renamed main project to Console and moved it as a sub project. Also added Unit Tests for custom csv based parser. --- .gitignore | 2 + jobs/Backend/Task/ExchangeRateProvider.cs | 19 -- jobs/Backend/Task/ExchangeRateUpdater.csproj | 8 - jobs/Backend/Task/ExchangeRateUpdater.sln | 108 +++++++++--- jobs/Backend/Task/Program.cs | 43 ----- .../ExchangeRateUpdater.BusinessLogic.csproj | 7 + .../ExchangeRateUpdater.cs | 32 ++++ .../IExchangeRateUpdater.cs | 17 ++ .../Models}/Currency.cs | 40 ++--- .../Models}/ExchangeRate.cs | 46 ++--- .../ExchangeRateUpdater.Console.csproj | 19 ++ .../ExchangeRateUpdater.Console/Program.cs | 61 +++++++ .../ExchangeRateProvider.cs | 165 ++++++++++++++++++ .../ExchangeRateUpdater.Data.csproj | 17 ++ .../IExchangeRateProvider.cs | 16 ++ .../ExchangeRateUpdater.UnitTests.csproj | 18 ++ .../ParserTests.cs | 44 +++++ 17 files changed, 527 insertions(+), 135 deletions(-) delete mode 100644 jobs/Backend/Task/ExchangeRateProvider.cs delete mode 100644 jobs/Backend/Task/ExchangeRateUpdater.csproj delete mode 100644 jobs/Backend/Task/Program.cs create mode 100644 jobs/Backend/Task/src/ExchangeRateUpdater.BusinessLogic/ExchangeRateUpdater.BusinessLogic.csproj create mode 100644 jobs/Backend/Task/src/ExchangeRateUpdater.BusinessLogic/ExchangeRateUpdater.cs create mode 100644 jobs/Backend/Task/src/ExchangeRateUpdater.BusinessLogic/IExchangeRateUpdater.cs rename jobs/Backend/Task/{ => src/ExchangeRateUpdater.BusinessLogic/Models}/Currency.cs (83%) rename jobs/Backend/Task/{ => src/ExchangeRateUpdater.BusinessLogic/Models}/ExchangeRate.cs (87%) create mode 100644 jobs/Backend/Task/src/ExchangeRateUpdater.Console/ExchangeRateUpdater.Console.csproj create mode 100644 jobs/Backend/Task/src/ExchangeRateUpdater.Console/Program.cs create mode 100644 jobs/Backend/Task/src/ExchangeRateUpdater.Data/ExchangeRateProvider.cs create mode 100644 jobs/Backend/Task/src/ExchangeRateUpdater.Data/ExchangeRateUpdater.Data.csproj create mode 100644 jobs/Backend/Task/src/ExchangeRateUpdater.Data/IExchangeRateProvider.cs create mode 100644 jobs/Backend/Task/tests/ExchangeRateUpdater.UnitTests/ExchangeRateUpdater.UnitTests.csproj create mode 100644 jobs/Backend/Task/tests/ExchangeRateUpdater.UnitTests/ParserTests.cs diff --git a/.gitignore b/.gitignore index fd35865456..faa5fb0b81 100644 --- a/.gitignore +++ b/.gitignore @@ -18,6 +18,8 @@ *.tgz *.sublime-* +.vs/ + node_modules bower_components npm-debug.log 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 deleted file mode 100644 index 2fc654a12b..0000000000 --- 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 index 89be84daff..8baa6cccce 100644 --- a/jobs/Backend/Task/ExchangeRateUpdater.sln +++ b/jobs/Backend/Task/ExchangeRateUpdater.sln @@ -1,22 +1,86 @@ - -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 + +Microsoft Visual Studio Solution File, Format Version 12.00 +# Visual Studio Version 18 +VisualStudioVersion = 18.0.11205.157 d18.0 +MinimumVisualStudioVersion = 10.0.40219.1 +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "src", "src", "{827E0CD3-B72D-47B6-A68D-7590B98EB39B}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ExchangeRateUpdater.Console", "src\ExchangeRateUpdater.Console\ExchangeRateUpdater.Console.csproj", "{7B2695D6-D24C-4460-A58E-A10F08550CE0}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ExchangeRateUpdater.BusinessLogic", "src\ExchangeRateUpdater.BusinessLogic\ExchangeRateUpdater.BusinessLogic.csproj", "{741FA146-0B48-4A16-80F0-0A4D84A6AEA5}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ExchangeRateUpdater.Data", "src\ExchangeRateUpdater.Data\ExchangeRateUpdater.Data.csproj", "{2046544F-2178-4BB8-9E5A-904780231DB2}" +EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "tests", "tests", "{0AB3BF05-4346-4AA6-1389-037BE0695223}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ExchangeRateUpdater.UnitTests", "tests\ExchangeRateUpdater.UnitTests\ExchangeRateUpdater.UnitTests.csproj", "{02DCD6F6-F79D-460E-9943-A9E164F79452}" +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 + {741FA146-0B48-4A16-80F0-0A4D84A6AEA5}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {741FA146-0B48-4A16-80F0-0A4D84A6AEA5}.Debug|Any CPU.Build.0 = Debug|Any CPU + {741FA146-0B48-4A16-80F0-0A4D84A6AEA5}.Debug|x64.ActiveCfg = Debug|Any CPU + {741FA146-0B48-4A16-80F0-0A4D84A6AEA5}.Debug|x64.Build.0 = Debug|Any CPU + {741FA146-0B48-4A16-80F0-0A4D84A6AEA5}.Debug|x86.ActiveCfg = Debug|Any CPU + {741FA146-0B48-4A16-80F0-0A4D84A6AEA5}.Debug|x86.Build.0 = Debug|Any CPU + {741FA146-0B48-4A16-80F0-0A4D84A6AEA5}.Release|Any CPU.ActiveCfg = Release|Any CPU + {741FA146-0B48-4A16-80F0-0A4D84A6AEA5}.Release|Any CPU.Build.0 = Release|Any CPU + {741FA146-0B48-4A16-80F0-0A4D84A6AEA5}.Release|x64.ActiveCfg = Release|Any CPU + {741FA146-0B48-4A16-80F0-0A4D84A6AEA5}.Release|x64.Build.0 = Release|Any CPU + {741FA146-0B48-4A16-80F0-0A4D84A6AEA5}.Release|x86.ActiveCfg = Release|Any CPU + {741FA146-0B48-4A16-80F0-0A4D84A6AEA5}.Release|x86.Build.0 = Release|Any CPU + {2046544F-2178-4BB8-9E5A-904780231DB2}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {2046544F-2178-4BB8-9E5A-904780231DB2}.Debug|Any CPU.Build.0 = Debug|Any CPU + {2046544F-2178-4BB8-9E5A-904780231DB2}.Debug|x64.ActiveCfg = Debug|Any CPU + {2046544F-2178-4BB8-9E5A-904780231DB2}.Debug|x64.Build.0 = Debug|Any CPU + {2046544F-2178-4BB8-9E5A-904780231DB2}.Debug|x86.ActiveCfg = Debug|Any CPU + {2046544F-2178-4BB8-9E5A-904780231DB2}.Debug|x86.Build.0 = Debug|Any CPU + {2046544F-2178-4BB8-9E5A-904780231DB2}.Release|Any CPU.ActiveCfg = Release|Any CPU + {2046544F-2178-4BB8-9E5A-904780231DB2}.Release|Any CPU.Build.0 = Release|Any CPU + {2046544F-2178-4BB8-9E5A-904780231DB2}.Release|x64.ActiveCfg = Release|Any CPU + {2046544F-2178-4BB8-9E5A-904780231DB2}.Release|x64.Build.0 = Release|Any CPU + {2046544F-2178-4BB8-9E5A-904780231DB2}.Release|x86.ActiveCfg = Release|Any CPU + {2046544F-2178-4BB8-9E5A-904780231DB2}.Release|x86.Build.0 = Release|Any CPU + {02DCD6F6-F79D-460E-9943-A9E164F79452}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {02DCD6F6-F79D-460E-9943-A9E164F79452}.Debug|Any CPU.Build.0 = Debug|Any CPU + {02DCD6F6-F79D-460E-9943-A9E164F79452}.Debug|x64.ActiveCfg = Debug|Any CPU + {02DCD6F6-F79D-460E-9943-A9E164F79452}.Debug|x64.Build.0 = Debug|Any CPU + {02DCD6F6-F79D-460E-9943-A9E164F79452}.Debug|x86.ActiveCfg = Debug|Any CPU + {02DCD6F6-F79D-460E-9943-A9E164F79452}.Debug|x86.Build.0 = Debug|Any CPU + {02DCD6F6-F79D-460E-9943-A9E164F79452}.Release|Any CPU.ActiveCfg = Release|Any CPU + {02DCD6F6-F79D-460E-9943-A9E164F79452}.Release|Any CPU.Build.0 = Release|Any CPU + {02DCD6F6-F79D-460E-9943-A9E164F79452}.Release|x64.ActiveCfg = Release|Any CPU + {02DCD6F6-F79D-460E-9943-A9E164F79452}.Release|x64.Build.0 = Release|Any CPU + {02DCD6F6-F79D-460E-9943-A9E164F79452}.Release|x86.ActiveCfg = Release|Any CPU + {02DCD6F6-F79D-460E-9943-A9E164F79452}.Release|x86.Build.0 = Release|Any CPU + EndGlobalSection + GlobalSection(SolutionProperties) = preSolution + HideSolutionNode = FALSE + EndGlobalSection + GlobalSection(NestedProjects) = preSolution + {7B2695D6-D24C-4460-A58E-A10F08550CE0} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B} + {741FA146-0B48-4A16-80F0-0A4D84A6AEA5} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B} + {2046544F-2178-4BB8-9E5A-904780231DB2} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B} + {02DCD6F6-F79D-460E-9943-A9E164F79452} = {0AB3BF05-4346-4AA6-1389-037BE0695223} + EndGlobalSection +EndGlobal diff --git a/jobs/Backend/Task/Program.cs b/jobs/Backend/Task/Program.cs deleted file mode 100644 index 379a69b1f8..0000000000 --- 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(); - } - } -} diff --git a/jobs/Backend/Task/src/ExchangeRateUpdater.BusinessLogic/ExchangeRateUpdater.BusinessLogic.csproj b/jobs/Backend/Task/src/ExchangeRateUpdater.BusinessLogic/ExchangeRateUpdater.BusinessLogic.csproj new file mode 100644 index 0000000000..0fd66c5c9b --- /dev/null +++ b/jobs/Backend/Task/src/ExchangeRateUpdater.BusinessLogic/ExchangeRateUpdater.BusinessLogic.csproj @@ -0,0 +1,7 @@ + + + net7.0 + enable + enable + + \ No newline at end of file diff --git a/jobs/Backend/Task/src/ExchangeRateUpdater.BusinessLogic/ExchangeRateUpdater.cs b/jobs/Backend/Task/src/ExchangeRateUpdater.BusinessLogic/ExchangeRateUpdater.cs new file mode 100644 index 0000000000..72fea5c9c8 --- /dev/null +++ b/jobs/Backend/Task/src/ExchangeRateUpdater.BusinessLogic/ExchangeRateUpdater.cs @@ -0,0 +1,32 @@ +using ExchangeRateUpdater.BusinessLogic.Models; +using System; +using System.Collections.Generic; + +namespace ExchangeRateUpdater.BusinessLogic +{ + /// + /// Business logic service that retrieves exchange rates. + /// Accepts a provider via dependency injection (wired at composition root in Console). + /// + public class ExchangeRateUpdater : IExchangeRateUpdater + { + private readonly Func, IEnumerable> _getExchangeRates; + + /// + /// Constructor accepting a function that retrieves exchange rates from the data layer. + /// This avoids circular dependency between BusinessLogic and Data projects. + /// + public ExchangeRateUpdater(Func, IEnumerable> getExchangeRates) + { + _getExchangeRates = getExchangeRates ?? throw new ArgumentNullException(nameof(getExchangeRates)); + } + + /// + /// Retrieves exchange rates for the specified currencies from the data layer provider. + /// + public IEnumerable GetExchangeRates(IEnumerable currencies) + { + return _getExchangeRates(currencies); + } + } +} diff --git a/jobs/Backend/Task/src/ExchangeRateUpdater.BusinessLogic/IExchangeRateUpdater.cs b/jobs/Backend/Task/src/ExchangeRateUpdater.BusinessLogic/IExchangeRateUpdater.cs new file mode 100644 index 0000000000..df257b0cef --- /dev/null +++ b/jobs/Backend/Task/src/ExchangeRateUpdater.BusinessLogic/IExchangeRateUpdater.cs @@ -0,0 +1,17 @@ +using ExchangeRateUpdater.BusinessLogic.Models; +using System.Collections.Generic; + +namespace ExchangeRateUpdater.BusinessLogic +{ + /// + /// Business logic service for retrieving exchange rates. + /// + public interface IExchangeRateUpdater + { + /// + /// 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. + /// + IEnumerable GetExchangeRates(IEnumerable currencies); + } +} diff --git a/jobs/Backend/Task/Currency.cs b/jobs/Backend/Task/src/ExchangeRateUpdater.BusinessLogic/Models/Currency.cs similarity index 83% rename from jobs/Backend/Task/Currency.cs rename to jobs/Backend/Task/src/ExchangeRateUpdater.BusinessLogic/Models/Currency.cs index f375776f25..74590a9a5d 100644 --- a/jobs/Backend/Task/Currency.cs +++ b/jobs/Backend/Task/src/ExchangeRateUpdater.BusinessLogic/Models/Currency.cs @@ -1,20 +1,20 @@ -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; - } - } -} +namespace ExchangeRateUpdater.BusinessLogic.Models +{ + 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/src/ExchangeRateUpdater.BusinessLogic/Models/ExchangeRate.cs similarity index 87% rename from jobs/Backend/Task/ExchangeRate.cs rename to jobs/Backend/Task/src/ExchangeRateUpdater.BusinessLogic/Models/ExchangeRate.cs index 58c5bb10e0..abbc1f0384 100644 --- a/jobs/Backend/Task/ExchangeRate.cs +++ b/jobs/Backend/Task/src/ExchangeRateUpdater.BusinessLogic/Models/ExchangeRate.cs @@ -1,23 +1,23 @@ -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}"; - } - } -} +namespace ExchangeRateUpdater.BusinessLogic.Models +{ + 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/src/ExchangeRateUpdater.Console/ExchangeRateUpdater.Console.csproj b/jobs/Backend/Task/src/ExchangeRateUpdater.Console/ExchangeRateUpdater.Console.csproj new file mode 100644 index 0000000000..55b29d811e --- /dev/null +++ b/jobs/Backend/Task/src/ExchangeRateUpdater.Console/ExchangeRateUpdater.Console.csproj @@ -0,0 +1,19 @@ + + + Exe + net7.0 + enable + enable + + + + + + + + + + + + + \ No newline at end of file diff --git a/jobs/Backend/Task/src/ExchangeRateUpdater.Console/Program.cs b/jobs/Backend/Task/src/ExchangeRateUpdater.Console/Program.cs new file mode 100644 index 0000000000..2c15fbc0a1 --- /dev/null +++ b/jobs/Backend/Task/src/ExchangeRateUpdater.Console/Program.cs @@ -0,0 +1,61 @@ +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; +using ExchangeRateUpdater.BusinessLogic; +using ExchangeRateUpdater.BusinessLogic.Models; +using ExchangeRateUpdater.Data; +using BusinessLogicUpdater = ExchangeRateUpdater.BusinessLogic.ExchangeRateUpdater; + +var host = Host.CreateDefaultBuilder(args) + .ConfigureServices(s => + { + s.AddLogging(l => l.AddConsole()); + s.AddHttpClient(); + s.AddScoped(sp => + new BusinessLogicUpdater( + currencies => sp.GetRequiredService().GetExchangeRates(currencies) + ) + ); + }) + .Build(); + +var logger = host.Services.GetRequiredService>(); + +try +{ + var updater = host.Services.GetRequiredService(); + + var 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") + }; + + var rates = updater.GetExchangeRates(currencies); + + Console.WriteLine($"Successfully retrieved {rates.Count()} exchange rates:"); + foreach (var rate in rates) + { + Console.WriteLine(rate.ToString()); + } + + return 0; +} +catch (Exception e) +{ + logger.LogError(e, "Could not retrieve exchange rates."); + Console.WriteLine($"Could not retrieve exchange rates: '{e.Message}'."); + return 1; +} +finally +{ + Console.WriteLine("Press Enter to exit."); + Console.ReadLine(); +} \ No newline at end of file diff --git a/jobs/Backend/Task/src/ExchangeRateUpdater.Data/ExchangeRateProvider.cs b/jobs/Backend/Task/src/ExchangeRateUpdater.Data/ExchangeRateProvider.cs new file mode 100644 index 0000000000..6fca334e52 --- /dev/null +++ b/jobs/Backend/Task/src/ExchangeRateUpdater.Data/ExchangeRateProvider.cs @@ -0,0 +1,165 @@ +using System; +using System.Collections.Generic; +using System.Globalization; +using System.IO; +using System.Linq; +using System.Net.Http; +using System.Threading.Tasks; +using CsvHelper; +using CsvHelper.Configuration; +using ExchangeRateUpdater.BusinessLogic.Models; +using Microsoft.Extensions.Logging; + +namespace ExchangeRateUpdater.Data +{ + /// + /// Exchange rate provider that fetches rates from CNB (Czech National Bank). + /// + public class ExchangeRateProvider : IExchangeRateProvider + { + private readonly HttpClient _httpClient; + private readonly ILogger _logger; + private const string DailyRatesUrl = "https://www.cnb.cz/en/financial_markets/foreign_exchange_market/exchange_rate_fixing/daily.txt"; + + public ExchangeRateProvider(HttpClient httpClient, ILogger logger) + { + _httpClient = httpClient ?? throw new ArgumentNullException(nameof(httpClient)); + _logger = logger ?? throw new ArgumentNullException(nameof(logger)); + } + + public IEnumerable GetExchangeRates(IEnumerable currencies) + { + // Synchronous wrapper around async operation + var result = GetExchangeRatesAsync(currencies).GetAwaiter().GetResult(); + return result; + } + + private async Task> GetExchangeRatesAsync(IEnumerable currencies) + { + try + { + _logger.LogDebug("Downloading daily exchange rates from {Url}", DailyRatesUrl); + + using var res = await _httpClient.GetAsync(DailyRatesUrl, HttpCompletionOption.ResponseHeadersRead); + res.EnsureSuccessStatusCode(); + await using var stream = await res.Content.ReadAsStreamAsync(); + using var sr = new StreamReader(stream); + + var allText = await sr.ReadToEndAsync(); + + return ParseRates(allText, currencies); + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to download or parse exchange rates."); + throw; + } + } + + /// + /// Parses exchange rates from the source content. + /// + public IEnumerable ParseRates(string content, IEnumerable currencies) + { + var lines = content.Split(new[] { '\n', '\r' }, StringSplitOptions.RemoveEmptyEntries); + if (lines.Length < 3) + return Enumerable.Empty(); + + var codesInInput = new HashSet(currencies.Select(c => c.Code), StringComparer.OrdinalIgnoreCase); + var targetCurrencyCode = "CZK"; + + var result = new List(); + + try + { + // The CNB data format: + // Line 1: Date (e.g., "14 Nov 2025") + // Line 2: Reference number (e.g., "#222") + // Line 3: Header (e.g., "Country|Currency|Amount|Code|Rate") + // Line 4+: Data rows + + var csvLines = lines.Skip(2).ToList(); + + if (csvLines.Count == 0) + return Enumerable.Empty(); + + var csvContent = string.Join(Environment.NewLine, csvLines); + + using var reader = new StringReader(csvContent); + var config = new CsvConfiguration(CultureInfo.InvariantCulture) + { + Delimiter = "|", + HasHeaderRecord = true, + HeaderValidated = null, // Disable header validation + }; + using var csv = new CsvReader(reader, config); + + // Register the class map for proper field mapping + csv.Context.RegisterClassMap(); + + // Read all records - materialize the enumerable to ensure parsing happens + var records = csv.GetRecords().ToList(); + + foreach (var record in records) + { + try + { + // Skip records with invalid data + if (record?.Code == null || string.IsNullOrWhiteSpace(record.Code)) + continue; + + // Validate that both source currency and target currency (CZK) are requested + if (!codesInInput.Contains(record.Code) || !codesInInput.Contains(targetCurrencyCode)) + continue; + + // Calculate unit rate: rate / amount + var unitRate = record.Rate / record.Amount; + var sourceCurrency = new Currency(record.Code); + var targetCurrency = new Currency(targetCurrencyCode); + + result.Add(new ExchangeRate(sourceCurrency, targetCurrency, unitRate)); + } + catch (Exception ex) + { + _logger.LogWarning(ex, "Failed to parse record for currency code."); + } + } + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to parse CSV content."); + } + + return result; + } + + /// + /// CSV record mapping for exchange rate data from CNB. + /// + private class ExchangeRateRecord + { + public string Country { get; set; } + public string Currency { get; set; } + public decimal Amount { get; set; } + public string Code { get; set; } + public decimal Rate { get; set; } + } + + /// + /// Class map for CsvHelper to properly map CSV columns to ExchangeRateRecord properties. + /// Maps by column index (0-based) instead of by header name for better flexibility. + /// + private sealed class ExchangeRateRecordMap : ClassMap + { + public ExchangeRateRecordMap() + { + // Map by index instead of header name for more flexibility + Map(m => m.Country).Index(0); + Map(m => m.Currency).Index(1); + Map(m => m.Amount).Index(2); + Map(m => m.Code).Index(3); + Map(m => m.Rate).Index(4); + } + } + } +} diff --git a/jobs/Backend/Task/src/ExchangeRateUpdater.Data/ExchangeRateUpdater.Data.csproj b/jobs/Backend/Task/src/ExchangeRateUpdater.Data/ExchangeRateUpdater.Data.csproj new file mode 100644 index 0000000000..afd1f555f5 --- /dev/null +++ b/jobs/Backend/Task/src/ExchangeRateUpdater.Data/ExchangeRateUpdater.Data.csproj @@ -0,0 +1,17 @@ + + + net7.0 + enable + enable + + + + + + + + + + + + \ No newline at end of file diff --git a/jobs/Backend/Task/src/ExchangeRateUpdater.Data/IExchangeRateProvider.cs b/jobs/Backend/Task/src/ExchangeRateUpdater.Data/IExchangeRateProvider.cs new file mode 100644 index 0000000000..344057310e --- /dev/null +++ b/jobs/Backend/Task/src/ExchangeRateUpdater.Data/IExchangeRateProvider.cs @@ -0,0 +1,16 @@ +using System.Collections.Generic; +using ExchangeRateUpdater.BusinessLogic.Models; + +namespace ExchangeRateUpdater.Data +{ + /// + /// Data layer provider interface for retrieving exchange rates from a source. + /// + public interface IExchangeRateProvider + { + /// + /// Retrieves exchange rates from the data source for the specified currencies. + /// + IEnumerable GetExchangeRates(IEnumerable currencies); + } +} diff --git a/jobs/Backend/Task/tests/ExchangeRateUpdater.UnitTests/ExchangeRateUpdater.UnitTests.csproj b/jobs/Backend/Task/tests/ExchangeRateUpdater.UnitTests/ExchangeRateUpdater.UnitTests.csproj new file mode 100644 index 0000000000..5d19b41c73 --- /dev/null +++ b/jobs/Backend/Task/tests/ExchangeRateUpdater.UnitTests/ExchangeRateUpdater.UnitTests.csproj @@ -0,0 +1,18 @@ + + + net7.0 + false + + + + + + + + + + + + + + \ No newline at end of file diff --git a/jobs/Backend/Task/tests/ExchangeRateUpdater.UnitTests/ParserTests.cs b/jobs/Backend/Task/tests/ExchangeRateUpdater.UnitTests/ParserTests.cs new file mode 100644 index 0000000000..702dfabcfa --- /dev/null +++ b/jobs/Backend/Task/tests/ExchangeRateUpdater.UnitTests/ParserTests.cs @@ -0,0 +1,44 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using ExchangeRateUpdater.Data; +using ExchangeRateUpdater.BusinessLogic.Models; +using Xunit; + +namespace ExchangeRateUpdater.UnitTests +{ + public class ParserTests + { + [Fact] + public void ParseRates_ReturnsRatesForRequestedCurrencies() + { + var content = "14 Nov 2025\n#222\nCountry|Currency|Amount|Code|Rate\nUSA|dollar|1|USD|20.786\nJapan|yen|100|JPY|13.511\n"; + var provider = new ExchangeRateProvider(new System.Net.Http.HttpClient(), new Microsoft.Extensions.Logging.Abstractions.NullLogger()); + var currencies = new[] { new Currency("USD"), new Currency("CZK"), new Currency("JPY") }; + + var rates = provider.ParseRates(content, currencies).ToList(); + + Assert.Equal(2, rates.Count); + + var usd = rates.Single(r => r.SourceCurrency.Code == "USD"); + Assert.Equal("CZK", usd.TargetCurrency.Code); + Assert.Equal(20.786m, usd.Value); + + var jpy = rates.Single(r => r.SourceCurrency.Code == "JPY"); + Assert.Equal("CZK", jpy.TargetCurrency.Code); + Assert.Equal(13.511m / 100m, jpy.Value); + } + + [Fact] + public void ParseRates_IgnoresCurrenciesNotRequested() + { + var content = "14 Nov 2025\n#222\nCountry|Currency|Amount|Code|Rate\nUSA|dollar|1|USD|20.786\n"; + var provider = new ExchangeRateProvider(new System.Net.Http.HttpClient(), new Microsoft.Extensions.Logging.Abstractions.NullLogger()); + var currencies = new[] { new Currency("JPY"), new Currency("CZK") }; + + var rates = provider.ParseRates(content, currencies); + + Assert.Empty(rates); + } + } +} From 49b3d31643888830e2244a677f53fca95b136861 Mon Sep 17 00:00:00 2001 From: filipworks Date: Mon, 17 Nov 2025 22:24:57 +0100 Subject: [PATCH 2/5] Added api with controller and swagger and basic client to test out the logic. Also small rename of BusinessLogic class from updater to manager. --- jobs/Backend/Task/ExchangeRateUpdater.sln | 32 +- .../Controllers/ExchangeRatesController.cs | 80 +++++ .../ExchangeRateUpdater.Api.csproj | 20 ++ .../Models/ExchangeRateViewModel.cs | 23 ++ .../src/ExchangeRateUpdater.Api/Program.cs | 65 ++++ .../Properties/launchSettings.json | 13 + .../ExchangeRateUpdater.Api/appsettings.json | 10 + ...eRateUpdater.cs => ExchangeRateManager.cs} | 8 +- ...RateUpdater.cs => IExchangeRateManager.cs} | 4 +- .../ExchangeRateUpdater.Client.csproj | 7 + .../src/ExchangeRateUpdater.Client/Program.cs | 14 + .../Properties/launchSettings.json | 12 + .../appsettings.json | 10 + .../wwwroot/index.html | 298 ++++++++++++++++++ .../ExchangeRateUpdater.Console/Program.cs | 9 +- .../ExchangeRateUpdater.UnitTests.csproj | 2 +- 16 files changed, 594 insertions(+), 13 deletions(-) create mode 100644 jobs/Backend/Task/src/ExchangeRateUpdater.Api/Controllers/ExchangeRatesController.cs create mode 100644 jobs/Backend/Task/src/ExchangeRateUpdater.Api/ExchangeRateUpdater.Api.csproj create mode 100644 jobs/Backend/Task/src/ExchangeRateUpdater.Api/Models/ExchangeRateViewModel.cs create mode 100644 jobs/Backend/Task/src/ExchangeRateUpdater.Api/Program.cs create mode 100644 jobs/Backend/Task/src/ExchangeRateUpdater.Api/Properties/launchSettings.json create mode 100644 jobs/Backend/Task/src/ExchangeRateUpdater.Api/appsettings.json rename jobs/Backend/Task/src/ExchangeRateUpdater.BusinessLogic/{ExchangeRateUpdater.cs => ExchangeRateManager.cs} (80%) rename jobs/Backend/Task/src/ExchangeRateUpdater.BusinessLogic/{IExchangeRateUpdater.cs => IExchangeRateManager.cs} (79%) create mode 100644 jobs/Backend/Task/src/ExchangeRateUpdater.Client/ExchangeRateUpdater.Client.csproj create mode 100644 jobs/Backend/Task/src/ExchangeRateUpdater.Client/Program.cs create mode 100644 jobs/Backend/Task/src/ExchangeRateUpdater.Client/Properties/launchSettings.json create mode 100644 jobs/Backend/Task/src/ExchangeRateUpdater.Client/appsettings.json create mode 100644 jobs/Backend/Task/src/ExchangeRateUpdater.Client/wwwroot/index.html diff --git a/jobs/Backend/Task/ExchangeRateUpdater.sln b/jobs/Backend/Task/ExchangeRateUpdater.sln index 8baa6cccce..305c8c2250 100644 --- a/jobs/Backend/Task/ExchangeRateUpdater.sln +++ b/jobs/Backend/Task/ExchangeRateUpdater.sln @@ -1,7 +1,7 @@  Microsoft Visual Studio Solution File, Format Version 12.00 # Visual Studio Version 18 -VisualStudioVersion = 18.0.11205.157 d18.0 +VisualStudioVersion = 18.0.11205.157 MinimumVisualStudioVersion = 10.0.40219.1 Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "src", "src", "{827E0CD3-B72D-47B6-A68D-7590B98EB39B}" EndProject @@ -15,6 +15,10 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "tests", "tests", "{0AB3BF05 EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ExchangeRateUpdater.UnitTests", "tests\ExchangeRateUpdater.UnitTests\ExchangeRateUpdater.UnitTests.csproj", "{02DCD6F6-F79D-460E-9943-A9E164F79452}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ExchangeRateUpdater.Api", "src\ExchangeRateUpdater.Api\ExchangeRateUpdater.Api.csproj", "{60D975FE-B57B-E1C4-6D3F-CF509327D85B}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ExchangeRateUpdater.Client", "src\ExchangeRateUpdater.Client\ExchangeRateUpdater.Client.csproj", "{26F867DC-B5EF-9545-2053-6EEE6D5E8BE9}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -73,6 +77,30 @@ Global {02DCD6F6-F79D-460E-9943-A9E164F79452}.Release|x64.Build.0 = Release|Any CPU {02DCD6F6-F79D-460E-9943-A9E164F79452}.Release|x86.ActiveCfg = Release|Any CPU {02DCD6F6-F79D-460E-9943-A9E164F79452}.Release|x86.Build.0 = Release|Any CPU + {60D975FE-B57B-E1C4-6D3F-CF509327D85B}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {60D975FE-B57B-E1C4-6D3F-CF509327D85B}.Debug|Any CPU.Build.0 = Debug|Any CPU + {60D975FE-B57B-E1C4-6D3F-CF509327D85B}.Debug|x64.ActiveCfg = Debug|Any CPU + {60D975FE-B57B-E1C4-6D3F-CF509327D85B}.Debug|x64.Build.0 = Debug|Any CPU + {60D975FE-B57B-E1C4-6D3F-CF509327D85B}.Debug|x86.ActiveCfg = Debug|Any CPU + {60D975FE-B57B-E1C4-6D3F-CF509327D85B}.Debug|x86.Build.0 = Debug|Any CPU + {60D975FE-B57B-E1C4-6D3F-CF509327D85B}.Release|Any CPU.ActiveCfg = Release|Any CPU + {60D975FE-B57B-E1C4-6D3F-CF509327D85B}.Release|Any CPU.Build.0 = Release|Any CPU + {60D975FE-B57B-E1C4-6D3F-CF509327D85B}.Release|x64.ActiveCfg = Release|Any CPU + {60D975FE-B57B-E1C4-6D3F-CF509327D85B}.Release|x64.Build.0 = Release|Any CPU + {60D975FE-B57B-E1C4-6D3F-CF509327D85B}.Release|x86.ActiveCfg = Release|Any CPU + {60D975FE-B57B-E1C4-6D3F-CF509327D85B}.Release|x86.Build.0 = Release|Any CPU + {26F867DC-B5EF-9545-2053-6EEE6D5E8BE9}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {26F867DC-B5EF-9545-2053-6EEE6D5E8BE9}.Debug|Any CPU.Build.0 = Debug|Any CPU + {26F867DC-B5EF-9545-2053-6EEE6D5E8BE9}.Debug|x64.ActiveCfg = Debug|Any CPU + {26F867DC-B5EF-9545-2053-6EEE6D5E8BE9}.Debug|x64.Build.0 = Debug|Any CPU + {26F867DC-B5EF-9545-2053-6EEE6D5E8BE9}.Debug|x86.ActiveCfg = Debug|Any CPU + {26F867DC-B5EF-9545-2053-6EEE6D5E8BE9}.Debug|x86.Build.0 = Debug|Any CPU + {26F867DC-B5EF-9545-2053-6EEE6D5E8BE9}.Release|Any CPU.ActiveCfg = Release|Any CPU + {26F867DC-B5EF-9545-2053-6EEE6D5E8BE9}.Release|Any CPU.Build.0 = Release|Any CPU + {26F867DC-B5EF-9545-2053-6EEE6D5E8BE9}.Release|x64.ActiveCfg = Release|Any CPU + {26F867DC-B5EF-9545-2053-6EEE6D5E8BE9}.Release|x64.Build.0 = Release|Any CPU + {26F867DC-B5EF-9545-2053-6EEE6D5E8BE9}.Release|x86.ActiveCfg = Release|Any CPU + {26F867DC-B5EF-9545-2053-6EEE6D5E8BE9}.Release|x86.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE @@ -82,5 +110,7 @@ Global {741FA146-0B48-4A16-80F0-0A4D84A6AEA5} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B} {2046544F-2178-4BB8-9E5A-904780231DB2} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B} {02DCD6F6-F79D-460E-9943-A9E164F79452} = {0AB3BF05-4346-4AA6-1389-037BE0695223} + {60D975FE-B57B-E1C4-6D3F-CF509327D85B} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B} + {26F867DC-B5EF-9545-2053-6EEE6D5E8BE9} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B} EndGlobalSection EndGlobal diff --git a/jobs/Backend/Task/src/ExchangeRateUpdater.Api/Controllers/ExchangeRatesController.cs b/jobs/Backend/Task/src/ExchangeRateUpdater.Api/Controllers/ExchangeRatesController.cs new file mode 100644 index 0000000000..ed765de7fe --- /dev/null +++ b/jobs/Backend/Task/src/ExchangeRateUpdater.Api/Controllers/ExchangeRatesController.cs @@ -0,0 +1,80 @@ +using Microsoft.AspNetCore.Mvc; +using ExchangeRateUpdater.BusinessLogic; +using ExchangeRateUpdater.BusinessLogic.Models; + +namespace ExchangeRateUpdater.Api.Controllers +{ + /// + /// API controller for exchange rate operations. + /// + [ApiController] + [Route("api/[controller]")] + public class ExchangeRatesController : ControllerBase + { + private readonly IExchangeRateManager _exchangeRateManager; + private readonly ILogger _logger; + + /// + /// Constructor for ExchangeRatesController. + /// + public ExchangeRatesController(IExchangeRateManager exchangeRateManager, ILogger logger) + { + _exchangeRateManager = exchangeRateManager ?? throw new ArgumentNullException(nameof(exchangeRateManager)); + _logger = logger ?? throw new ArgumentNullException(nameof(logger)); + } + + /// + /// Gets exchange rates for specified currency codes. + /// + /// Comma-separated list of currency codes (e.g., "USD,EUR,JPY") + /// List of exchange rates from source currency to CZK + [HttpGet] + [ProducesResponseType(typeof(IEnumerable), StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status400BadRequest)] + [ProducesResponseType(StatusCodes.Status500InternalServerError)] + public IActionResult GetExchangeRates([FromQuery] string? currencies = null) + { + try + { + if (string.IsNullOrWhiteSpace(currencies)) + { + return BadRequest("Currencies parameter is required. Provide comma-separated currency codes."); + } + + var currencyList = currencies + .Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries) + .Select(c => new Currency(c.ToUpperInvariant())) + .ToList(); + + if (currencyList.Count == 0) + { + return BadRequest("At least one currency code is required."); + } + + // Always include CZK for consistency + if (!currencyList.Any(c => c.Code == "CZK")) + { + currencyList.Add(new Currency("CZK")); + } + + var rates = _exchangeRateManager.GetExchangeRates(currencyList); + + var result = rates + .Select(r => new ExchangeRateViewModel + { + SourceCurrencyCode = r.SourceCurrency.Code, + TargetCurrencyCode = r.TargetCurrency.Code, + Rate = r.Value + }) + .ToList(); + + return Ok(result); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error retrieving exchange rates for currencies: {Currencies}", currencies); + return StatusCode(StatusCodes.Status500InternalServerError, new { error = "Failed to retrieve exchange rates." }); + } + } + } +} diff --git a/jobs/Backend/Task/src/ExchangeRateUpdater.Api/ExchangeRateUpdater.Api.csproj b/jobs/Backend/Task/src/ExchangeRateUpdater.Api/ExchangeRateUpdater.Api.csproj new file mode 100644 index 0000000000..c8dd088c16 --- /dev/null +++ b/jobs/Backend/Task/src/ExchangeRateUpdater.Api/ExchangeRateUpdater.Api.csproj @@ -0,0 +1,20 @@ + + + net7.0 + enable + enable + + + + + + + + + + + + + + + diff --git a/jobs/Backend/Task/src/ExchangeRateUpdater.Api/Models/ExchangeRateViewModel.cs b/jobs/Backend/Task/src/ExchangeRateUpdater.Api/Models/ExchangeRateViewModel.cs new file mode 100644 index 0000000000..3ca1cd2e5c --- /dev/null +++ b/jobs/Backend/Task/src/ExchangeRateUpdater.Api/Models/ExchangeRateViewModel.cs @@ -0,0 +1,23 @@ +namespace ExchangeRateUpdater.Api.Controllers +{ + /// + /// DTO for exchange rate information. + /// + public class ExchangeRateViewModel + { + /// + /// Source currency code (e.g., USD, EUR). + /// + public string SourceCurrencyCode { get; set; } = string.Empty; + + /// + /// Target currency code (typically CZK). + /// + public string TargetCurrencyCode { get; set; } = string.Empty; + + /// + /// Exchange rate (how many target currency units per one source currency unit). + /// + public decimal Rate { get; set; } + } +} \ No newline at end of file diff --git a/jobs/Backend/Task/src/ExchangeRateUpdater.Api/Program.cs b/jobs/Backend/Task/src/ExchangeRateUpdater.Api/Program.cs new file mode 100644 index 0000000000..7dc9db05bd --- /dev/null +++ b/jobs/Backend/Task/src/ExchangeRateUpdater.Api/Program.cs @@ -0,0 +1,65 @@ +using Microsoft.OpenApi.Models; +using ExchangeRateUpdater.BusinessLogic; +using ExchangeRateUpdater.Data; + +var builder = WebApplication.CreateBuilder(args); + +// Add services +builder.Services.AddControllers(); +builder.Services.AddCors(options => +{ + options.AddPolicy("AllowAll", policy => + { + policy.AllowAnyOrigin() + .AllowAnyMethod() + .AllowAnyHeader(); + }); +}); + +builder.Services.AddLogging(l => l.AddConsole()); +builder.Services.AddHttpClient(); +builder.Services.AddScoped(); +builder.Services.AddScoped(sp => + new ExchangeRateManager( + currencies => sp.GetRequiredService().GetExchangeRates(currencies) + ) +); + +// Add Swagger/OpenAPI +builder.Services.AddEndpointsApiExplorer(); +builder.Services.AddSwaggerGen(c => +{ + c.SwaggerDoc("v1", new OpenApiInfo + { + Title = "Exchange Rate API", + Version = "v1", + Description = "API for retrieving Czech National Bank (CNB) exchange rates", + Contact = new OpenApiContact + { + Name = "Mews Systems" + } + }); + + // Include XML comments if available + var xmlFile = Path.Combine(AppContext.BaseDirectory, "ExchangeRateUpdater.Api.xml"); + if (File.Exists(xmlFile)) + { + c.IncludeXmlComments(xmlFile); + } +}); + +var app = builder.Build(); + +// Configure the HTTP request pipeline +app.UseSwagger(); +app.UseSwaggerUI(c => +{ + c.SwaggerEndpoint("/swagger/v1/swagger.json", "Exchange Rate API V1"); +}); + +app.UseHttpsRedirection(); +app.UseCors("AllowAll"); +app.UseAuthorization(); +app.MapControllers(); + +app.Run(); diff --git a/jobs/Backend/Task/src/ExchangeRateUpdater.Api/Properties/launchSettings.json b/jobs/Backend/Task/src/ExchangeRateUpdater.Api/Properties/launchSettings.json new file mode 100644 index 0000000000..ec94e4cd7f --- /dev/null +++ b/jobs/Backend/Task/src/ExchangeRateUpdater.Api/Properties/launchSettings.json @@ -0,0 +1,13 @@ +{ + "profiles": { + "ExchangeRateUpdater.Api": { + "commandName": "Project", + "launchBrowser": true, + "launchUrl": "swagger", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + }, + "applicationUrl": "https://localhost:5001" + } + } +} \ No newline at end of file diff --git a/jobs/Backend/Task/src/ExchangeRateUpdater.Api/appsettings.json b/jobs/Backend/Task/src/ExchangeRateUpdater.Api/appsettings.json new file mode 100644 index 0000000000..7cfd94fe33 --- /dev/null +++ b/jobs/Backend/Task/src/ExchangeRateUpdater.Api/appsettings.json @@ -0,0 +1,10 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + }, + "AllowedHosts": "*", + "Urls": "https://localhost:5001" +} diff --git a/jobs/Backend/Task/src/ExchangeRateUpdater.BusinessLogic/ExchangeRateUpdater.cs b/jobs/Backend/Task/src/ExchangeRateUpdater.BusinessLogic/ExchangeRateManager.cs similarity index 80% rename from jobs/Backend/Task/src/ExchangeRateUpdater.BusinessLogic/ExchangeRateUpdater.cs rename to jobs/Backend/Task/src/ExchangeRateUpdater.BusinessLogic/ExchangeRateManager.cs index 72fea5c9c8..81bc0b1e97 100644 --- a/jobs/Backend/Task/src/ExchangeRateUpdater.BusinessLogic/ExchangeRateUpdater.cs +++ b/jobs/Backend/Task/src/ExchangeRateUpdater.BusinessLogic/ExchangeRateManager.cs @@ -5,10 +5,10 @@ namespace ExchangeRateUpdater.BusinessLogic { /// - /// Business logic service that retrieves exchange rates. - /// Accepts a provider via dependency injection (wired at composition root in Console). + /// Business logic service that manages and retrieves exchange rates. + /// Accepts a provider via dependency injection (wired at composition root in Console/API). /// - public class ExchangeRateUpdater : IExchangeRateUpdater + public class ExchangeRateManager : IExchangeRateManager { private readonly Func, IEnumerable> _getExchangeRates; @@ -16,7 +16,7 @@ public class ExchangeRateUpdater : IExchangeRateUpdater /// Constructor accepting a function that retrieves exchange rates from the data layer. /// This avoids circular dependency between BusinessLogic and Data projects. /// - public ExchangeRateUpdater(Func, IEnumerable> getExchangeRates) + public ExchangeRateManager(Func, IEnumerable> getExchangeRates) { _getExchangeRates = getExchangeRates ?? throw new ArgumentNullException(nameof(getExchangeRates)); } diff --git a/jobs/Backend/Task/src/ExchangeRateUpdater.BusinessLogic/IExchangeRateUpdater.cs b/jobs/Backend/Task/src/ExchangeRateUpdater.BusinessLogic/IExchangeRateManager.cs similarity index 79% rename from jobs/Backend/Task/src/ExchangeRateUpdater.BusinessLogic/IExchangeRateUpdater.cs rename to jobs/Backend/Task/src/ExchangeRateUpdater.BusinessLogic/IExchangeRateManager.cs index df257b0cef..3e71456299 100644 --- a/jobs/Backend/Task/src/ExchangeRateUpdater.BusinessLogic/IExchangeRateUpdater.cs +++ b/jobs/Backend/Task/src/ExchangeRateUpdater.BusinessLogic/IExchangeRateManager.cs @@ -4,9 +4,9 @@ namespace ExchangeRateUpdater.BusinessLogic { /// - /// Business logic service for retrieving exchange rates. + /// Business logic service for managing and retrieving exchange rates. /// - public interface IExchangeRateUpdater + public interface IExchangeRateManager { /// /// Should return exchange rates among the specified currencies that are defined by the source. diff --git a/jobs/Backend/Task/src/ExchangeRateUpdater.Client/ExchangeRateUpdater.Client.csproj b/jobs/Backend/Task/src/ExchangeRateUpdater.Client/ExchangeRateUpdater.Client.csproj new file mode 100644 index 0000000000..10cbea439a --- /dev/null +++ b/jobs/Backend/Task/src/ExchangeRateUpdater.Client/ExchangeRateUpdater.Client.csproj @@ -0,0 +1,7 @@ + + + net7.0 + enable + enable + + diff --git a/jobs/Backend/Task/src/ExchangeRateUpdater.Client/Program.cs b/jobs/Backend/Task/src/ExchangeRateUpdater.Client/Program.cs new file mode 100644 index 0000000000..c6ef8f838f --- /dev/null +++ b/jobs/Backend/Task/src/ExchangeRateUpdater.Client/Program.cs @@ -0,0 +1,14 @@ +using Microsoft.AspNetCore; + +var builder = WebApplication.CreateBuilder(args); + +builder.Services.AddHttpClient(); + +var app = builder.Build(); + +app.UseDefaultFiles(); +app.UseStaticFiles(); + +app.MapFallbackToFile("index.html"); + +app.Run(); diff --git a/jobs/Backend/Task/src/ExchangeRateUpdater.Client/Properties/launchSettings.json b/jobs/Backend/Task/src/ExchangeRateUpdater.Client/Properties/launchSettings.json new file mode 100644 index 0000000000..45400c75fd --- /dev/null +++ b/jobs/Backend/Task/src/ExchangeRateUpdater.Client/Properties/launchSettings.json @@ -0,0 +1,12 @@ +{ + "profiles": { + "ExchangeRateUpdater.Client": { + "commandName": "Project", + "launchBrowser": true, + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + }, + "applicationUrl": "https://localhost:57076;http://localhost:57077" + } + } +} \ No newline at end of file diff --git a/jobs/Backend/Task/src/ExchangeRateUpdater.Client/appsettings.json b/jobs/Backend/Task/src/ExchangeRateUpdater.Client/appsettings.json new file mode 100644 index 0000000000..219e56b077 --- /dev/null +++ b/jobs/Backend/Task/src/ExchangeRateUpdater.Client/appsettings.json @@ -0,0 +1,10 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + }, + "AllowedHosts": "*", + "Urls": "http://localhost:5002;https://localhost:5003" +} diff --git a/jobs/Backend/Task/src/ExchangeRateUpdater.Client/wwwroot/index.html b/jobs/Backend/Task/src/ExchangeRateUpdater.Client/wwwroot/index.html new file mode 100644 index 0000000000..4acf0b301a --- /dev/null +++ b/jobs/Backend/Task/src/ExchangeRateUpdater.Client/wwwroot/index.html @@ -0,0 +1,298 @@ + + + + + + Exchange Rate Viewer + + + +
+

Exchange Rate Viewer

+ +
+ Enter currency codes (e.g., USD, EUR, JPY) separated by commas to view their exchange rates to CZK +
+ +
+
+ + +
+ +
+ +
+
+
+ + + + diff --git a/jobs/Backend/Task/src/ExchangeRateUpdater.Console/Program.cs b/jobs/Backend/Task/src/ExchangeRateUpdater.Console/Program.cs index 2c15fbc0a1..c7d3484f36 100644 --- a/jobs/Backend/Task/src/ExchangeRateUpdater.Console/Program.cs +++ b/jobs/Backend/Task/src/ExchangeRateUpdater.Console/Program.cs @@ -4,15 +4,14 @@ using ExchangeRateUpdater.BusinessLogic; using ExchangeRateUpdater.BusinessLogic.Models; using ExchangeRateUpdater.Data; -using BusinessLogicUpdater = ExchangeRateUpdater.BusinessLogic.ExchangeRateUpdater; var host = Host.CreateDefaultBuilder(args) .ConfigureServices(s => { s.AddLogging(l => l.AddConsole()); s.AddHttpClient(); - s.AddScoped(sp => - new BusinessLogicUpdater( + s.AddScoped(sp => + new ExchangeRateManager( currencies => sp.GetRequiredService().GetExchangeRates(currencies) ) ); @@ -23,7 +22,7 @@ try { - var updater = host.Services.GetRequiredService(); + var manager = host.Services.GetRequiredService(); var currencies = new[] { @@ -38,7 +37,7 @@ new Currency("XYZ") }; - var rates = updater.GetExchangeRates(currencies); + var rates = manager.GetExchangeRates(currencies); Console.WriteLine($"Successfully retrieved {rates.Count()} exchange rates:"); foreach (var rate in rates) diff --git a/jobs/Backend/Task/tests/ExchangeRateUpdater.UnitTests/ExchangeRateUpdater.UnitTests.csproj b/jobs/Backend/Task/tests/ExchangeRateUpdater.UnitTests/ExchangeRateUpdater.UnitTests.csproj index 5d19b41c73..f2bdee508a 100644 --- a/jobs/Backend/Task/tests/ExchangeRateUpdater.UnitTests/ExchangeRateUpdater.UnitTests.csproj +++ b/jobs/Backend/Task/tests/ExchangeRateUpdater.UnitTests/ExchangeRateUpdater.UnitTests.csproj @@ -8,7 +8,7 @@ - + From 64bee59c6511756c70b4dbfb25f947777dcef607 Mon Sep 17 00:00:00 2001 From: filipworks Date: Tue, 18 Nov 2025 18:15:41 +0100 Subject: [PATCH 3/5] Fix copilot comments. Moved the parser logic to an internal helper and exposed it to test project via assembly info. Removed some blank spaces in Client. Added the requested ConfigureAway(false) to prevent deadlock. Also made all exceptions more specific not using generic Exception ex anymore. --- .../Controllers/ExchangeRatesController.cs | 19 ++- .../ExchangeRateUpdater.Api.csproj | 3 - .../wwwroot/index.html | 4 +- .../ExchangeRateUpdater.Data/AssemblyInfo.cs | 3 + .../ExchangeRateProvider.cs | 128 +++--------------- .../Helpers/ParseHelper.cs | 128 ++++++++++++++++++ .../Models/ExchangeRateRecord.cs | 20 +++ .../ParserTests.cs | 8 +- 8 files changed, 189 insertions(+), 124 deletions(-) create mode 100644 jobs/Backend/Task/src/ExchangeRateUpdater.Data/AssemblyInfo.cs create mode 100644 jobs/Backend/Task/src/ExchangeRateUpdater.Data/Helpers/ParseHelper.cs create mode 100644 jobs/Backend/Task/src/ExchangeRateUpdater.Data/Models/ExchangeRateRecord.cs diff --git a/jobs/Backend/Task/src/ExchangeRateUpdater.Api/Controllers/ExchangeRatesController.cs b/jobs/Backend/Task/src/ExchangeRateUpdater.Api/Controllers/ExchangeRatesController.cs index ed765de7fe..b0209aed7d 100644 --- a/jobs/Backend/Task/src/ExchangeRateUpdater.Api/Controllers/ExchangeRatesController.cs +++ b/jobs/Backend/Task/src/ExchangeRateUpdater.Api/Controllers/ExchangeRatesController.cs @@ -70,9 +70,24 @@ public IActionResult GetExchangeRates([FromQuery] string? currencies = null) return Ok(result); } - catch (Exception ex) + catch (HttpRequestException httpEx) { - _logger.LogError(ex, "Error retrieving exchange rates for currencies: {Currencies}", currencies); + _logger.LogError(httpEx, "HTTP error occurred while retrieving exchange rates for currencies: {Currencies}", currencies); + return StatusCode(StatusCodes.Status503ServiceUnavailable, new { error = "Exchange rate service is temporarily unavailable." }); + } + catch (OperationCanceledException cancelEx) + { + _logger.LogWarning(cancelEx, "Exchange rate request was cancelled for currencies: {Currencies}", currencies); + return StatusCode(StatusCodes.Status408RequestTimeout, new { error = "Request timeout while retrieving exchange rates." }); + } + catch (ArgumentException argEx) + { + _logger.LogWarning(argEx, "Invalid argument provided for currencies: {Currencies}", currencies); + return BadRequest(new { error = "Invalid currency data." }); + } + catch (InvalidOperationException ioEx) + { + _logger.LogError(ioEx, "Invalid operation while retrieving exchange rates for currencies: {Currencies}", currencies); return StatusCode(StatusCodes.Status500InternalServerError, new { error = "Failed to retrieve exchange rates." }); } } diff --git a/jobs/Backend/Task/src/ExchangeRateUpdater.Api/ExchangeRateUpdater.Api.csproj b/jobs/Backend/Task/src/ExchangeRateUpdater.Api/ExchangeRateUpdater.Api.csproj index c8dd088c16..b119816f1e 100644 --- a/jobs/Backend/Task/src/ExchangeRateUpdater.Api/ExchangeRateUpdater.Api.csproj +++ b/jobs/Backend/Task/src/ExchangeRateUpdater.Api/ExchangeRateUpdater.Api.csproj @@ -14,7 +14,4 @@ - - - diff --git a/jobs/Backend/Task/src/ExchangeRateUpdater.Client/wwwroot/index.html b/jobs/Backend/Task/src/ExchangeRateUpdater.Client/wwwroot/index.html index 4acf0b301a..2287987253 100644 --- a/jobs/Backend/Task/src/ExchangeRateUpdater.Client/wwwroot/index.html +++ b/jobs/Backend/Task/src/ExchangeRateUpdater.Client/wwwroot/index.html @@ -263,7 +263,7 @@

Exchange Rate Viewer

} displayRates(rates); - statusDiv.innerHTML = `
Successfully retrieved ${rates.length} exchange rate(s)
`; + statusDiv.innerHTML = `
Successfully retrieved ${rates.length} exchange rate(s)
`; } catch (error) { showError(`Failed to fetch exchange rates: ${error.message}`); } finally { @@ -290,7 +290,7 @@

Exchange Rate Viewer

} function showError(message) { - statusDiv.innerHTML = `
${message}
`; + statusDiv.innerHTML = `
${message}
`; ratesDiv.innerHTML = ''; } diff --git a/jobs/Backend/Task/src/ExchangeRateUpdater.Data/AssemblyInfo.cs b/jobs/Backend/Task/src/ExchangeRateUpdater.Data/AssemblyInfo.cs new file mode 100644 index 0000000000..918ac8f078 --- /dev/null +++ b/jobs/Backend/Task/src/ExchangeRateUpdater.Data/AssemblyInfo.cs @@ -0,0 +1,3 @@ +using System.Runtime.CompilerServices; +// Expose internal logic for unit testing +[assembly: InternalsVisibleTo("ExchangeRateUpdater.UnitTests")] diff --git a/jobs/Backend/Task/src/ExchangeRateUpdater.Data/ExchangeRateProvider.cs b/jobs/Backend/Task/src/ExchangeRateUpdater.Data/ExchangeRateProvider.cs index 6fca334e52..9e3dd966e6 100644 --- a/jobs/Backend/Task/src/ExchangeRateUpdater.Data/ExchangeRateProvider.cs +++ b/jobs/Backend/Task/src/ExchangeRateUpdater.Data/ExchangeRateProvider.cs @@ -5,9 +5,8 @@ using System.Linq; using System.Net.Http; using System.Threading.Tasks; -using CsvHelper; -using CsvHelper.Configuration; using ExchangeRateUpdater.BusinessLogic.Models; +using ExchangeRateUpdater.Data.Helpers; using Microsoft.Extensions.Logging; namespace ExchangeRateUpdater.Data @@ -29,8 +28,8 @@ public ExchangeRateProvider(HttpClient httpClient, ILogger public IEnumerable GetExchangeRates(IEnumerable currencies) { - // Synchronous wrapper around async operation - var result = GetExchangeRatesAsync(currencies).GetAwaiter().GetResult(); + // Synchronous wrapper around async operation and I am now using ConfigureAwait(false) to prevent deadlock + var result = GetExchangeRatesAsync(currencies).ConfigureAwait(false).GetAwaiter().GetResult(); return result; } @@ -40,125 +39,30 @@ private async Task> GetExchangeRatesAsync(IEnumerable< { _logger.LogDebug("Downloading daily exchange rates from {Url}", DailyRatesUrl); - using var res = await _httpClient.GetAsync(DailyRatesUrl, HttpCompletionOption.ResponseHeadersRead); + using var res = await _httpClient.GetAsync(DailyRatesUrl, HttpCompletionOption.ResponseHeadersRead).ConfigureAwait(false); res.EnsureSuccessStatusCode(); - await using var stream = await res.Content.ReadAsStreamAsync(); + await using var stream = await res.Content.ReadAsStreamAsync().ConfigureAwait(false); using var sr = new StreamReader(stream); - var allText = await sr.ReadToEndAsync(); + var allText = await sr.ReadToEndAsync().ConfigureAwait(false); - return ParseRates(allText, currencies); + // Parser is now a static helper + return ParseHelper.ParseRates(allText, currencies); } - catch (Exception ex) + catch (HttpRequestException httpEx) { - _logger.LogError(ex, "Failed to download or parse exchange rates."); + _logger.LogError(httpEx, "Failed to download exchange rates from {Url}. HTTP error occurred.", DailyRatesUrl); throw; } - } - - /// - /// Parses exchange rates from the source content. - /// - public IEnumerable ParseRates(string content, IEnumerable currencies) - { - var lines = content.Split(new[] { '\n', '\r' }, StringSplitOptions.RemoveEmptyEntries); - if (lines.Length < 3) - return Enumerable.Empty(); - - var codesInInput = new HashSet(currencies.Select(c => c.Code), StringComparer.OrdinalIgnoreCase); - var targetCurrencyCode = "CZK"; - - var result = new List(); - - try - { - // The CNB data format: - // Line 1: Date (e.g., "14 Nov 2025") - // Line 2: Reference number (e.g., "#222") - // Line 3: Header (e.g., "Country|Currency|Amount|Code|Rate") - // Line 4+: Data rows - - var csvLines = lines.Skip(2).ToList(); - - if (csvLines.Count == 0) - return Enumerable.Empty(); - - var csvContent = string.Join(Environment.NewLine, csvLines); - - using var reader = new StringReader(csvContent); - var config = new CsvConfiguration(CultureInfo.InvariantCulture) - { - Delimiter = "|", - HasHeaderRecord = true, - HeaderValidated = null, // Disable header validation - }; - using var csv = new CsvReader(reader, config); - - // Register the class map for proper field mapping - csv.Context.RegisterClassMap(); - - // Read all records - materialize the enumerable to ensure parsing happens - var records = csv.GetRecords().ToList(); - - foreach (var record in records) - { - try - { - // Skip records with invalid data - if (record?.Code == null || string.IsNullOrWhiteSpace(record.Code)) - continue; - - // Validate that both source currency and target currency (CZK) are requested - if (!codesInInput.Contains(record.Code) || !codesInInput.Contains(targetCurrencyCode)) - continue; - - // Calculate unit rate: rate / amount - var unitRate = record.Rate / record.Amount; - var sourceCurrency = new Currency(record.Code); - var targetCurrency = new Currency(targetCurrencyCode); - - result.Add(new ExchangeRate(sourceCurrency, targetCurrency, unitRate)); - } - catch (Exception ex) - { - _logger.LogWarning(ex, "Failed to parse record for currency code."); - } - } - } - catch (Exception ex) + catch (IOException ioEx) { - _logger.LogError(ex, "Failed to parse CSV content."); + _logger.LogError(ioEx, "I/O error occurred while reading exchange rate data."); + throw; } - - return result; - } - - /// - /// CSV record mapping for exchange rate data from CNB. - /// - private class ExchangeRateRecord - { - public string Country { get; set; } - public string Currency { get; set; } - public decimal Amount { get; set; } - public string Code { get; set; } - public decimal Rate { get; set; } - } - - /// - /// Class map for CsvHelper to properly map CSV columns to ExchangeRateRecord properties. - /// Maps by column index (0-based) instead of by header name for better flexibility. - /// - private sealed class ExchangeRateRecordMap : ClassMap - { - public ExchangeRateRecordMap() + catch (OperationCanceledException cancelEx) { - // Map by index instead of header name for more flexibility - Map(m => m.Country).Index(0); - Map(m => m.Currency).Index(1); - Map(m => m.Amount).Index(2); - Map(m => m.Code).Index(3); - Map(m => m.Rate).Index(4); + _logger.LogWarning(cancelEx, "Exchange rate download was cancelled."); + throw; } } } diff --git a/jobs/Backend/Task/src/ExchangeRateUpdater.Data/Helpers/ParseHelper.cs b/jobs/Backend/Task/src/ExchangeRateUpdater.Data/Helpers/ParseHelper.cs new file mode 100644 index 0000000000..e1bb86c7f3 --- /dev/null +++ b/jobs/Backend/Task/src/ExchangeRateUpdater.Data/Helpers/ParseHelper.cs @@ -0,0 +1,128 @@ +using CsvHelper; +using CsvHelper.Configuration; +using ExchangeRateUpdater.BusinessLogic.Models; +using ExchangeRateUpdater.Data.Models; +using System; +using System.Collections.Generic; +using System.Globalization; +using System.Linq; +using System.Text; +using System.Threading.Tasks; + +namespace ExchangeRateUpdater.Data.Helpers +{ + /// + /// Helper class for parsing exchange rate data. + /// Marked as internal to discourage direct use; the public API is IExchangeRateProvider. + /// Tests access it via InternalsVisibleTo attribute. + /// + internal static class ParseHelper + { + /// + /// Parses exchange rates from the source content. + /// Internal method - only called from IExchangeRateProvider implementation and tests. + /// + internal static IEnumerable ParseRates(string content, IEnumerable currencies) + { + var lines = content.Split(new[] { '\n', '\r' }, StringSplitOptions.RemoveEmptyEntries); + if (lines.Length < 3) + return Enumerable.Empty(); + + var codesInInput = new HashSet(currencies.Select(c => c.Code), StringComparer.OrdinalIgnoreCase); + var targetCurrencyCode = "CZK"; + + var result = new List(); + + try + { + // The CNB data format: + // Line 1: Date (e.g., "14 Nov 2025") + // Line 2: Reference number (e.g., "#222") + // Line 3: Header (e.g., "Country|Currency|Amount|Code|Rate") + // Line 4+: Data rows + + var csvLines = lines.Skip(2).ToList(); + + if (csvLines.Count == 0) + return Enumerable.Empty(); + + var csvContent = string.Join(Environment.NewLine, csvLines); + + using var reader = new StringReader(csvContent); + var config = new CsvConfiguration(CultureInfo.InvariantCulture) + { + Delimiter = "|", + HasHeaderRecord = true, + HeaderValidated = null, // Disable header validation + }; + using var csv = new CsvReader(reader, config); + + // Register the class map for proper field mapping + csv.Context.RegisterClassMap(); + + // Read all records - materialize the enumerable to ensure parsing happens + var records = csv.GetRecords().ToList(); + + foreach (var record in records) + { + try + { + // Skip records with invalid data + if (record?.Code == null || string.IsNullOrWhiteSpace(record.Code)) + continue; + + // Validate that both source currency and target currency (CZK) are requested + if (!codesInInput.Contains(record.Code) || !codesInInput.Contains(targetCurrencyCode)) + continue; + + // Calculate unit rate: rate / amount + var unitRate = record.Rate / record.Amount; + var sourceCurrency = new Currency(record.Code); + var targetCurrency = new Currency(targetCurrencyCode); + + result.Add(new ExchangeRate(sourceCurrency, targetCurrency, unitRate)); + } + catch (FormatException) + { + // Skip records with format issues (invalid data types) + continue; + } + catch (OverflowException) + { + // Skip records with numeric overflow + continue; + } + } + } + catch (ArgumentException) + { + // Invalid CSV content structure - return empty results + return Enumerable.Empty(); + } + catch (OperationCanceledException) + { + // Parsing was cancelled - return empty results + return Enumerable.Empty(); + } + + return result; + } + + /// + /// Class map for CsvHelper to properly map CSV columns to ExchangeRateRecord properties. + /// Maps by column index (0-based) instead of by header name for better flexibility. + /// + private sealed class ExchangeRateRecordMap : ClassMap + { + public ExchangeRateRecordMap() + { + // Map by index instead of header name for more flexibility + Map(m => m.Country).Index(0); + Map(m => m.Currency).Index(1); + Map(m => m.Amount).Index(2); + Map(m => m.Code).Index(3); + Map(m => m.Rate).Index(4); + } + } + } +} diff --git a/jobs/Backend/Task/src/ExchangeRateUpdater.Data/Models/ExchangeRateRecord.cs b/jobs/Backend/Task/src/ExchangeRateUpdater.Data/Models/ExchangeRateRecord.cs new file mode 100644 index 0000000000..81b5509961 --- /dev/null +++ b/jobs/Backend/Task/src/ExchangeRateUpdater.Data/Models/ExchangeRateRecord.cs @@ -0,0 +1,20 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; + +namespace ExchangeRateUpdater.Data.Models +{ + /// + /// CSV record mapping for exchange rate data from CNB. + /// + class ExchangeRateRecord + { + public string Country { get; set; } + public string Currency { get; set; } + public decimal Amount { get; set; } + public string Code { get; set; } + public decimal Rate { get; set; } + } +} diff --git a/jobs/Backend/Task/tests/ExchangeRateUpdater.UnitTests/ParserTests.cs b/jobs/Backend/Task/tests/ExchangeRateUpdater.UnitTests/ParserTests.cs index 702dfabcfa..f28f918494 100644 --- a/jobs/Backend/Task/tests/ExchangeRateUpdater.UnitTests/ParserTests.cs +++ b/jobs/Backend/Task/tests/ExchangeRateUpdater.UnitTests/ParserTests.cs @@ -1,7 +1,7 @@ using System; using System.Collections.Generic; using System.Linq; -using ExchangeRateUpdater.Data; +using ExchangeRateUpdater.Data.Helpers; using ExchangeRateUpdater.BusinessLogic.Models; using Xunit; @@ -13,10 +13,9 @@ public class ParserTests public void ParseRates_ReturnsRatesForRequestedCurrencies() { var content = "14 Nov 2025\n#222\nCountry|Currency|Amount|Code|Rate\nUSA|dollar|1|USD|20.786\nJapan|yen|100|JPY|13.511\n"; - var provider = new ExchangeRateProvider(new System.Net.Http.HttpClient(), new Microsoft.Extensions.Logging.Abstractions.NullLogger()); var currencies = new[] { new Currency("USD"), new Currency("CZK"), new Currency("JPY") }; - var rates = provider.ParseRates(content, currencies).ToList(); + var rates = ParseHelper.ParseRates(content, currencies).ToList(); Assert.Equal(2, rates.Count); @@ -33,10 +32,9 @@ public void ParseRates_ReturnsRatesForRequestedCurrencies() public void ParseRates_IgnoresCurrenciesNotRequested() { var content = "14 Nov 2025\n#222\nCountry|Currency|Amount|Code|Rate\nUSA|dollar|1|USD|20.786\n"; - var provider = new ExchangeRateProvider(new System.Net.Http.HttpClient(), new Microsoft.Extensions.Logging.Abstractions.NullLogger()); var currencies = new[] { new Currency("JPY"), new Currency("CZK") }; - var rates = provider.ParseRates(content, currencies); + var rates = ParseHelper.ParseRates(content, currencies); Assert.Empty(rates); } From 5d8e9f7645c7af938d671b063ed4892f7077a67b Mon Sep 17 00:00:00 2001 From: filipworks Date: Tue, 18 Nov 2025 18:20:06 +0100 Subject: [PATCH 4/5] Small fix missing the namespace change from Controller to Models --- .../Controllers/ExchangeRatesController.cs | 1 + .../src/ExchangeRateUpdater.Api/Models/ExchangeRateViewModel.cs | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/jobs/Backend/Task/src/ExchangeRateUpdater.Api/Controllers/ExchangeRatesController.cs b/jobs/Backend/Task/src/ExchangeRateUpdater.Api/Controllers/ExchangeRatesController.cs index b0209aed7d..b5be4dea66 100644 --- a/jobs/Backend/Task/src/ExchangeRateUpdater.Api/Controllers/ExchangeRatesController.cs +++ b/jobs/Backend/Task/src/ExchangeRateUpdater.Api/Controllers/ExchangeRatesController.cs @@ -1,6 +1,7 @@ using Microsoft.AspNetCore.Mvc; using ExchangeRateUpdater.BusinessLogic; using ExchangeRateUpdater.BusinessLogic.Models; +using ExchangeRateUpdater.Api.Models; namespace ExchangeRateUpdater.Api.Controllers { diff --git a/jobs/Backend/Task/src/ExchangeRateUpdater.Api/Models/ExchangeRateViewModel.cs b/jobs/Backend/Task/src/ExchangeRateUpdater.Api/Models/ExchangeRateViewModel.cs index 3ca1cd2e5c..d2a08dee85 100644 --- a/jobs/Backend/Task/src/ExchangeRateUpdater.Api/Models/ExchangeRateViewModel.cs +++ b/jobs/Backend/Task/src/ExchangeRateUpdater.Api/Models/ExchangeRateViewModel.cs @@ -1,4 +1,4 @@ -namespace ExchangeRateUpdater.Api.Controllers +namespace ExchangeRateUpdater.Api.Models { /// /// DTO for exchange rate information. From 58b8403b98bc7420a6b13337b356b35de4044ec3 Mon Sep 17 00:00:00 2001 From: filipworks Date: Tue, 25 Nov 2025 09:16:41 +0100 Subject: [PATCH 5/5] Added docs section and a README.MD --- jobs/Backend/Task/ExchangeRateUpdater.sln | 8 ++ jobs/Backend/Task/README.MD | 92 +++++++++++++++++++++++ 2 files changed, 100 insertions(+) create mode 100644 jobs/Backend/Task/README.MD diff --git a/jobs/Backend/Task/ExchangeRateUpdater.sln b/jobs/Backend/Task/ExchangeRateUpdater.sln index 305c8c2250..db8a549dab 100644 --- a/jobs/Backend/Task/ExchangeRateUpdater.sln +++ b/jobs/Backend/Task/ExchangeRateUpdater.sln @@ -19,6 +19,11 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ExchangeRateUpdater.Api", " EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ExchangeRateUpdater.Client", "src\ExchangeRateUpdater.Client\ExchangeRateUpdater.Client.csproj", "{26F867DC-B5EF-9545-2053-6EEE6D5E8BE9}" EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "docs", "docs", "{101353D8-A63C-43AD-B04B-A2A5C7C02270}" + ProjectSection(SolutionItems) = preProject + README.MD = README.MD + EndProjectSection +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -113,4 +118,7 @@ Global {60D975FE-B57B-E1C4-6D3F-CF509327D85B} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B} {26F867DC-B5EF-9545-2053-6EEE6D5E8BE9} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B} EndGlobalSection + GlobalSection(ExtensibilityGlobals) = postSolution + SolutionGuid = {B62F2767-E00D-40E9-834A-79B58C8960FB} + EndGlobalSection EndGlobal diff --git a/jobs/Backend/Task/README.MD b/jobs/Backend/Task/README.MD new file mode 100644 index 0000000000..0cbbbb9194 --- /dev/null +++ b/jobs/Backend/Task/README.MD @@ -0,0 +1,92 @@ +# Exchange Rate Provider + +This project implements a .NET solution for managing and retrieving echange rates from the Czech Natinal Bank. + +# Source for updating daily rates + +The daily rates are found at this location: +https://www.cnb.cz/en/financial-markets/foreign-exchange-market/central-bank-exchange-rate-fixing/central-bank-exchange-rate-fixing/index.html?date=14.11.2025 + +And can be accessed directly as a csv plain text file at this location: +https://www.cnb.cz/en/financial-markets/foreign-exchange-market/central-bank-exchange-rate-fixing/central-bank-exchange-rate-fixing/daily.txt + +There is also an official official .NET library but is outdated so I went ahead and implemented my own provider with similar idea using csv parsing to split and extract the rates from the csv plain text file. +https://github.com/msigut/CNB.Exchange/tree/master + +# Structure +Split the solution into 5+1 different projects to keep it more organized + +src/ +- **ExchangeRateUpdater.Api** - Presentation/Application API Layer +- **ExchangeRateUpdate.Client** - This is the test client that uses the API + **ExchangeRateUpdate.Console** - Console only test of the solution +- **ExchangeRateUpdate.BusinessLogic** - Domain Layer +- **ExchangeRateUpdate.Data** - Infrastructure Layer + +test/ +- **ExchangeRateUpdate.UniTests** - Unit tests + +here is also where integration tests would be added eventually + +## Core Models + +### `ExchangeRate` +Represents a currency exchange rate with: +- `SourceCurrency`: The currency being converted from +- `TargetCurrency`: The currency being converted to +- `Value`: The exchange rate value + +### `Currency` +Represents a currency with its code (e.g., USD, EUR) + +## Getting Started + +### Prerequisites + +- .NET 7.0 or higher +- Visual Studio 2022 or Visual Studio Code + +### Observations + +#### API Configuration + +The API is set to run at `https://localhost:5001` (or as configured in `appsettings.json` and `launchSettings.json`) and has one single GET endpoint + +It also opens a swagger page at `https://localhost:5001/swagger/index.html` for easy testing and exploration of the API. + +#### Client Configuration + +Currently it doesn't have a setting where to look for the external service in appsettings.json but instead it expecting the API endpoint to be located at localhost:5001 in the javascript. Thus should be changed in the future so that it can be configured from appsettings.json. + +## Architecture + +The solution follows a layered architecture: + +``` +┌─────────────────────────────────────┐ +│ Presentation Layer │ +│ (Console, API, Client) │ +└──────────────┬──────────────────────┘ + │ +┌──────────────▼──────────────────────┐ +│ Business Logic Layer │ +│ (ExchangeRateManager, Services) │ +└──────────────┬──────────────────────┘ + │ +┌──────────────▼──────────────────────┐ +│ Data Access Layer │ +│ (Repositories, Database, Data) │ +└─────────────────────────────────────┘ +``` + +## License + +This is a Mews developer challenge project. + +## Contributing + +When contributing to this solution: +1. Ensure all unit tests pass +2. Follow C# coding standards +3. Add tests for new functionality +4. Update documentation as needed