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.Tests/CnbXmlParserTests.cs b/jobs/Backend/Task/ExchangeRateUpdater.Tests/CnbXmlParserTests.cs new file mode 100644 index 000000000..d1057e124 --- /dev/null +++ b/jobs/Backend/Task/ExchangeRateUpdater.Tests/CnbXmlParserTests.cs @@ -0,0 +1,43 @@ +using System.Linq; +using ExchangeRateUpdater.Exceptions; +using ExchangeRateUpdater.Models; +using ExchangeRateUpdater.Parsers; +using NUnit.Framework; + +namespace ExchangeRateUpdater.Tests; + +[TestFixture] +public class CnbXmlParserTests +{ + [Test] + public void Parse_ValidXml_ReturnsCorrectExchangeRates() + { + // Arrange + var parser = new CnbXmlParser(); + var baseCurrency = new Currency("CZK"); + + // Act + var rates = parser.Parse(TestData.CnbExchangeRateXml, baseCurrency).ToList(); + + // Assert + + Assert.That(rates.Count, Is.EqualTo(31)); + + var audRate = rates.FirstOrDefault(r => r.TargetCurrency.Code == "AUD"); + Assert.That(audRate.SourceCurrency.Code, Is.EqualTo("CZK")); + Assert.That(audRate.Value, Is.EqualTo(13.862m)); + } + [Test] + public void Parse_InvalidXml_ReturnsCorrectExchangeRates() + { + // Arrange + var parser = new CnbXmlParser(); + var baseCurrency = new Currency("CZK"); + + // Act + // Assert + Assert.Throws(() => parser.Parse(TestData.InvalidCnbExchangeRateXml, baseCurrency)); + + + } +} \ No newline at end of file diff --git a/jobs/Backend/Task/ExchangeRateUpdater.Tests/ExchangeRateControllerTests.cs b/jobs/Backend/Task/ExchangeRateUpdater.Tests/ExchangeRateControllerTests.cs new file mode 100644 index 000000000..e9bfa51e1 --- /dev/null +++ b/jobs/Backend/Task/ExchangeRateUpdater.Tests/ExchangeRateControllerTests.cs @@ -0,0 +1,154 @@ +using System.Collections.Generic; +using System.Net; +using System.Net.Http; +using System.Threading; +using System.Threading.Tasks; +using ExchangeRateUpdater.Controllers; +using ExchangeRateUpdater.Models; +using ExchangeRateUpdater.Services; +using Microsoft.AspNetCore.Mvc; +using Microsoft.Extensions.Caching.Memory; +using Microsoft.Extensions.Configuration; +using Moq; +using Moq.Protected; +using NUnit.Framework; + +namespace ExchangeRateUpdater.Tests; + +[TestFixture] +public class ExchangeRateControllerTests +{ + private ExchangeRateController _controller; + private IConfiguration _configuration; + private Mock _mockCache; + private ExchangeRateSettingsResolver _settingsResolver; + private ExchangeRateProvider _provider; + + [SetUp] + public void Setup() + { + _configuration = new ConfigurationBuilder() + .SetBasePath(TestContext.CurrentContext.TestDirectory) + .AddJsonFile("appsettings.test.json", optional: false) + .Build(); + _mockCache = new Mock(); + + + _settingsResolver = new ExchangeRateSettingsResolver(_configuration); + _mockCache.Setup(m => m.TryGetValue(It.IsAny(), out It.Ref.IsAny)) + .Returns(false); + _mockCache.Setup(m => m.CreateEntry(It.IsAny())) + .Returns(Mock.Of()); + + var mockHttpMessageHandler = new Mock(); + mockHttpMessageHandler.Protected() + .Setup>("SendAsync", ItExpr.IsAny(), ItExpr.IsAny()) + .ReturnsAsync(new HttpResponseMessage + { + StatusCode = HttpStatusCode.OK, + Content = new StringContent(TestData.CnbExchangeRateXml), + }); + + var httpClient = new HttpClient(mockHttpMessageHandler.Object); + + _provider = new ExchangeRateProvider(_mockCache.Object, httpClient, _settingsResolver, _configuration); + _controller = new ExchangeRateController(_provider); + } + + [Test] + public async Task GetExchangeRates_ValidCurrencies_ReturnsOkWithRates() + { + // Arrange + var currencies = "EUR,USD"; + var baseCurrency = TestData.BaseCurrency; + var expectedRates = new List + { + new () { SourceCurrency = baseCurrency, TargetCurrency = "EUR", ExchangeRate = 24.610m }, + new () { SourceCurrency = baseCurrency, TargetCurrency = "USD", ExchangeRate = 21.331m} + }; + + // Act + var result = await _controller.GetExchangeRates(currencies, baseCurrency); + + // Assert + Assert.That(result.Result, Is.InstanceOf()); + var okResult = result.Result as OkObjectResult; + Assert.That(okResult, Is.Not.Null); + var returnedRates = okResult.Value as IEnumerable; + Assert.That(returnedRates, Is.Not.Null); + Assert.That(returnedRates, Is.EquivalentTo(expectedRates) + .Using((x, y) => x.SourceCurrency == y.SourceCurrency && + x.TargetCurrency == y.TargetCurrency && + x.ExchangeRate == y.ExchangeRate)); + } + + [Test] + public async Task GetExchangeRates_EmptyCurrencies_ReturnsBadRequest() + { + // Arrange + var currencies = ""; + var baseCurrency = TestData.BaseCurrency; + + // Act + var result = await _controller.GetExchangeRates(currencies, baseCurrency); + + // Assert + Assert.That(result.Result, Is.InstanceOf()); + var badRequestResult = result.Result as BadRequestObjectResult; + Assert.That(badRequestResult, Is.Not.Null); + Assert.That(badRequestResult.Value, Is.EqualTo("Currency codes cannot be empty.")); + } + + [Test] + public async Task GetExchangeRates_ArgumentExceptionFromProvider_ReturnsBadRequest() + { + // Arrange + var currency = "TEST"; + var baseCurrency = TestData.BaseCurrency; + var errorMessage = $"The following currencies are not allowed: {currency}"; + + + // Act + var result = await _controller.GetExchangeRates(currency, baseCurrency); + + // Assert + Assert.That(result.Result, Is.InstanceOf()); + var badRequestResult = result.Result as BadRequestObjectResult; + Assert.That(badRequestResult, Is.Not.Null); + Assert.That(badRequestResult.Value, Is.EqualTo(errorMessage)); + } + + [TestCase(HttpStatusCode.ServiceUnavailable)] + [TestCase(HttpStatusCode.InternalServerError)] + [TestCase(HttpStatusCode.BadRequest)] + + public async Task GetExchangeRates_ExchangeRateApiExceptionFromProvider_ReturnsServiceUnavailable(HttpStatusCode statusCode) + { + // Arrange + var currencies = "EUR"; + var baseCurrency = TestData.BaseCurrency; + + var mockHttpMessageHandler = new Mock(); + mockHttpMessageHandler.Protected() + .Setup>("SendAsync", ItExpr.IsAny(), ItExpr.IsAny()) + .ReturnsAsync(new HttpResponseMessage + { + StatusCode = statusCode, + }); + + var httpClient = new HttpClient(mockHttpMessageHandler.Object); + + var provider = new ExchangeRateProvider( _mockCache.Object, httpClient, _settingsResolver, _configuration); + var controller = new ExchangeRateController(provider); + + // Act + var result = await controller.GetExchangeRates(currencies, baseCurrency); + + // Assert + Assert.That(result.Result, Is.InstanceOf()); + var objectResult = result.Result as ObjectResult; + Assert.That(objectResult, Is.Not.Null); + Assert.That(objectResult.StatusCode, Is.GreaterThan(299)); + Assert.That(objectResult.Value, Contains.Substring($"Service Unavailable: Failed to retrieve exchange rates from ")); + } +} diff --git a/jobs/Backend/Task/ExchangeRateUpdater.Tests/ExchangeRateProviderTests.cs b/jobs/Backend/Task/ExchangeRateUpdater.Tests/ExchangeRateProviderTests.cs new file mode 100644 index 000000000..032e2551f --- /dev/null +++ b/jobs/Backend/Task/ExchangeRateUpdater.Tests/ExchangeRateProviderTests.cs @@ -0,0 +1,122 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Net.Http; +using System.Threading; +using System.Threading.Tasks; +using ExchangeRateUpdater.Exceptions; +using ExchangeRateUpdater.Models; +using ExchangeRateUpdater.Services; +using Microsoft.Extensions.Caching.Memory; +using Microsoft.Extensions.Configuration; +using Moq; +using Moq.Protected; +using NUnit.Framework; + +namespace ExchangeRateUpdater.Tests; + +[TestFixture] +public class ExchangeRateProviderTests +{ + private Mock _mockCache; + private IConfiguration _configuration; + private ExchangeRateSettingsResolver _settingsResolver; + private ExchangeRateProvider _provider; + + [SetUp] + public void Setup() + { + _mockCache = new Mock(); + + _configuration = new ConfigurationBuilder() + .SetBasePath(TestContext.CurrentContext.TestDirectory) + .AddJsonFile("appsettings.test.json", optional: false) + .Build(); + + _settingsResolver = new ExchangeRateSettingsResolver(_configuration); + _mockCache.Setup(m => m.TryGetValue(It.IsAny(), out It.Ref.IsAny)) + .Returns(false); + _mockCache.Setup(m => m.CreateEntry(It.IsAny())) + .Returns(Mock.Of()); + + var mockHttpMessageHandler = new Mock(); + mockHttpMessageHandler.Protected() + .Setup>("SendAsync", ItExpr.IsAny(), ItExpr.IsAny()) + .ReturnsAsync(new HttpResponseMessage + { + StatusCode = System.Net.HttpStatusCode.OK, + Content = new StringContent(TestData.CnbExchangeRateXml), + }); + + var httpClient = new HttpClient(mockHttpMessageHandler.Object); + + _provider = new ExchangeRateProvider(_mockCache.Object, httpClient, _settingsResolver, _configuration); + } + + [Test] + public async Task GetExchangeRates_ValidCurrencies_ReturnsRates() + { + // Arrange + var currencies = TestData.ValidCurrenciesForTest; + var baseCurrency = new Currency(TestData.BaseCurrency); + var expectedRates = new List + { + new ExchangeRate(baseCurrency, new Currency("EUR"), 24.610m), + new ExchangeRate(baseCurrency, new Currency("USD"), 21.331m) + }; + + // Act + var result = (await _provider.GetExchangeRates(currencies, baseCurrency)).ToList(); + + // Assert + Assert.That(result, Is.EquivalentTo(expectedRates) + .Using((x, y) => x.SourceCurrency.Code == y.SourceCurrency.Code && + x.TargetCurrency.Code == y.TargetCurrency.Code && + x.Value == y.Value)); + } + + + [TestCase("TEST")] + [TestCase("")] + public void GetExchangeRates_InvalidBaseCurrency_ThrowsArgumentException(string currencyCode) + { + // Arrange + var currencies = TestData.ValidCurrenciesForTest; + var baseCurrency = new Currency(currencyCode); + + // Act & Assert + Assert.ThrowsAsync(() => _provider.GetExchangeRates(currencies, baseCurrency)); + } + [Test] + public void GetExchangeRates_NotImplementedBaseCurrency_ThrowsApiException() + { + // Arrange + var currencies = TestData.ValidCurrenciesForTest; + var baseCurrency = new Currency("USD"); + + // Act & Assert + Assert.ThrowsAsync(() => _provider.GetExchangeRates(currencies, baseCurrency)); + } + + [Test] + public void GetExchangeRates_InvalidTargetCurrency_ThrowsArgumentException() + { + // Arrange + var currencies = TestData.InvalidCurrenciesForTest; + var baseCurrency = new Currency(TestData.BaseCurrency); + + // Act & Assert + Assert.ThrowsAsync(() => _provider.GetExchangeRates(currencies, baseCurrency)); + } + [Test] + public void GetExchangeRates_EmptyTargetCurrencies_ThrowsArgumentException() + { + // Arrange + var baseCurrency = new Currency(TestData.BaseCurrency); + + // Act & Assert + Assert.ThrowsAsync(() => _provider.GetExchangeRates([], baseCurrency)); + } +} + + 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..178526b1d --- /dev/null +++ b/jobs/Backend/Task/ExchangeRateUpdater.Tests/ExchangeRateUpdater.Tests.csproj @@ -0,0 +1,27 @@ + + + + net8.0 + false + true + + + + + + + + + + + + + + + + + Always + + + + diff --git a/jobs/Backend/Task/ExchangeRateUpdater.Tests/TestData.cs b/jobs/Backend/Task/ExchangeRateUpdater.Tests/TestData.cs new file mode 100644 index 000000000..40120235b --- /dev/null +++ b/jobs/Backend/Task/ExchangeRateUpdater.Tests/TestData.cs @@ -0,0 +1,57 @@ +using System.Collections.Generic; +using ExchangeRateUpdater.Models; + +namespace ExchangeRateUpdater.Tests; + +public static class TestData +{ + public const string CnbExchangeRateXml = """ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + """; + public const string InvalidCnbExchangeRateXml = """ + + + + + + + """; + + public static string BaseCurrency = "CZK"; + public static readonly List ValidCurrenciesForTest = [new Currency("USD"), new Currency("EUR")]; + public static readonly List InvalidCurrenciesForTest = [new Currency("TEST")]; +} \ No newline at end of file diff --git a/jobs/Backend/Task/ExchangeRateUpdater.Tests/appsettings.test.json b/jobs/Backend/Task/ExchangeRateUpdater.Tests/appsettings.test.json new file mode 100644 index 000000000..42435009b --- /dev/null +++ b/jobs/Backend/Task/ExchangeRateUpdater.Tests/appsettings.test.json @@ -0,0 +1,16 @@ +{ + "ExchangeRateSources": [ + { + "BaseCurrency": "CZK", + "Url": "https://thiswebsitedefinetelydoesntexists12345", + "ParserType": "CnbXmlParser" + } + ], + "AllowedCurrencies": [ + "CZK", + "USD", + "EUR", + "GBP", + "AUD" + ] +} \ No newline at end of file diff --git a/jobs/Backend/Task/ExchangeRateUpdater.csproj b/jobs/Backend/Task/ExchangeRateUpdater.csproj index 2fc654a12..d8a899a68 100644 --- a/jobs/Backend/Task/ExchangeRateUpdater.csproj +++ b/jobs/Backend/Task/ExchangeRateUpdater.csproj @@ -2,7 +2,19 @@ Exe - net6.0 + net8.0 - + + + + + + + Always + + + + + + \ No newline at end of file diff --git a/jobs/Backend/Task/ExchangeRateUpdater.sln b/jobs/Backend/Task/ExchangeRateUpdater.sln index 89be84daf..438116d70 100644 --- a/jobs/Backend/Task/ExchangeRateUpdater.sln +++ b/jobs/Backend/Task/ExchangeRateUpdater.sln @@ -5,6 +5,8 @@ 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", "ExchangeRateUpdater.Tests\ExchangeRateUpdater.Tests.csproj", "{22B0A12A-EC5B-4152-9F31-43F5332B984C}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -15,8 +17,15 @@ Global {7B2695D6-D24C-4460-A58E-A10F08550CE0}.Debug|Any CPU.Build.0 = Debug|Any CPU {7B2695D6-D24C-4460-A58E-A10F08550CE0}.Release|Any CPU.ActiveCfg = Release|Any CPU {7B2695D6-D24C-4460-A58E-A10F08550CE0}.Release|Any CPU.Build.0 = Release|Any CPU + {22B0A12A-EC5B-4152-9F31-43F5332B984C}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {22B0A12A-EC5B-4152-9F31-43F5332B984C}.Debug|Any CPU.Build.0 = Debug|Any CPU + {22B0A12A-EC5B-4152-9F31-43F5332B984C}.Release|Any CPU.ActiveCfg = Release|Any CPU + {22B0A12A-EC5B-4152-9F31-43F5332B984C}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE EndGlobalSection + GlobalSection(ProjectDependencies) = postSolution + {22B0A12A-EC5B-4152-9F31-43F5332B984C}.0 = {7B2695D6-D24C-4460-A58E-A10F08550CE0} + EndGlobalSection EndGlobal 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(); - } - } -} diff --git a/jobs/Backend/Task/README.md b/jobs/Backend/Task/README.md new file mode 100644 index 000000000..74cca4863 --- /dev/null +++ b/jobs/Backend/Task/README.md @@ -0,0 +1,23 @@ +# Proposed API solution +This API is created with the goal of being extensible. It allows to get the current exchange rates from the CNB, and makes them available through the API. +This could be extended adding new providers with new parsers. +The appsettings.json should include the allowed sources set by base currency and allowed currencies. + +It will have only one GET endpoint "ExchangeRate" that will take a mandatory parameter "currencies" and an optional parameter "baseCurrency" which is currently defaulting to "CZK" as it's the only one implemented at the moment. + +So to get the exchange rates for EUR and USD you can use the following: + +/ExchangeRate?currencies=EUR,USD + +or + +/ExchangeRate?currencies=EUR,USD&baseCurrency=CZK + +since CZK is the default + +You can test the solution using swagger (/swagger) + +The data used by the API comes from https://www.cnb.cz/cs/financni_trhy/devizovy_trh/kurzy_devizoveho_trhu/denni_kurz.xml + +## Testing +The solution includes tests for the main logic. It also tests the controller, that although not ideal, it tests the expected return of the API while no integration tests are present. \ 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 000000000..c8a2cfee7 --- /dev/null +++ b/jobs/Backend/Task/appsettings.json @@ -0,0 +1,43 @@ +{ + "ExchangeRateSources": [ + { + "BaseCurrency": "CZK", + "Url": "https://www.cnb.cz/cs/financni_trhy/devizovy_trh/kurzy_devizoveho_trhu/denni_kurz.xml", + "ParserType": "CnbXmlParser" + } + ], + "AllowedCurrencies": [ + "AUD", + "CZK", + "BRL", + "BGN", + "CNY", + "DKK", + "EUR", + "PHP", + "HKD", + "INR", + "IDR", + "ISK", + "ILS", + "JPY", + "ZAR", + "CAD", + "KRW", + "HUF", + "MYR", + "MXN", + "XDR", + "NOK", + "NZD", + "PLN", + "RON", + "SGD", + "SEK", + "CHF", + "THB", + "TRY", + "USD", + "GBP" + ] +} diff --git a/jobs/Backend/Task/src/Controllers/ExchageRateController.cs b/jobs/Backend/Task/src/Controllers/ExchageRateController.cs new file mode 100644 index 000000000..1701a86d4 --- /dev/null +++ b/jobs/Backend/Task/src/Controllers/ExchageRateController.cs @@ -0,0 +1,50 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using ExchangeRateUpdater.Exceptions; +using ExchangeRateUpdater.Interfaces; +using ExchangeRateUpdater.Models; +using Microsoft.AspNetCore.Mvc; + +namespace ExchangeRateUpdater.Controllers; + +[ApiController] +[Route("ExchangeRate")] +public class ExchangeRateController : ControllerBase +{ + private readonly IExchangeRateProvider _provider; + + public ExchangeRateController(IExchangeRateProvider provider) + { + _provider = provider; + } + + [HttpGet] + public async Task>> GetExchangeRates( + [FromQuery] string currencies, [FromQuery] string baseCurrency = "CZK") + { + if (string.IsNullOrWhiteSpace(currencies)) + { + return BadRequest("Currency codes cannot be empty."); + } + + var currencyObjects = currencies.Split(',') + .Select(c => new Currency(c.Trim().ToUpper())).ToList(); + + try + { + var exchangeRates = await _provider.GetExchangeRates(currencyObjects, new Currency(baseCurrency)); + + return Ok(ExchangeRate.GetResponse(exchangeRates)); + } + catch (ArgumentException ex) + { + return BadRequest(ex.Message); + } + catch (ExchangeRateApiException ex) + { + return StatusCode(503, $"Service Unavailable: {ex.Message}"); + } + } +} \ No newline at end of file diff --git a/jobs/Backend/Task/src/Exceptions/ExchangeRateApiException.cs b/jobs/Backend/Task/src/Exceptions/ExchangeRateApiException.cs new file mode 100644 index 000000000..0cf5304ad --- /dev/null +++ b/jobs/Backend/Task/src/Exceptions/ExchangeRateApiException.cs @@ -0,0 +1,6 @@ +using System; + +namespace ExchangeRateUpdater.Exceptions; + +public class ExchangeRateApiException(string message, Exception innerException) + : Exception(message, innerException); \ No newline at end of file diff --git a/jobs/Backend/Task/src/Exceptions/ParsingException.cs b/jobs/Backend/Task/src/Exceptions/ParsingException.cs new file mode 100644 index 000000000..8c772c169 --- /dev/null +++ b/jobs/Backend/Task/src/Exceptions/ParsingException.cs @@ -0,0 +1,6 @@ +using System; + +namespace ExchangeRateUpdater.Exceptions; + +public class ParsingException(string message, Exception innerException) + : Exception(message, innerException); \ No newline at end of file diff --git a/jobs/Backend/Task/src/Interfaces/IExchangeRateParser.cs b/jobs/Backend/Task/src/Interfaces/IExchangeRateParser.cs new file mode 100644 index 000000000..b5ef39f05 --- /dev/null +++ b/jobs/Backend/Task/src/Interfaces/IExchangeRateParser.cs @@ -0,0 +1,9 @@ +using System.Collections.Generic; +using ExchangeRateUpdater.Models; + +namespace ExchangeRateUpdater.Interfaces; + +public interface IExchangeRateParser +{ + List Parse(string data, Currency baseCurrency); +} \ No newline at end of file diff --git a/jobs/Backend/Task/src/Interfaces/IExchangeRateProvider.cs b/jobs/Backend/Task/src/Interfaces/IExchangeRateProvider.cs new file mode 100644 index 000000000..136dee7dc --- /dev/null +++ b/jobs/Backend/Task/src/Interfaces/IExchangeRateProvider.cs @@ -0,0 +1,10 @@ +using System.Collections.Generic; +using System.Threading.Tasks; +using ExchangeRateUpdater.Models; + +namespace ExchangeRateUpdater.Interfaces; + +public interface IExchangeRateProvider +{ + Task> GetExchangeRates(List currencies, Currency baseCurrency); +} \ No newline at end of file diff --git a/jobs/Backend/Task/src/Interfaces/IExchangeRateSettingsResolver.cs b/jobs/Backend/Task/src/Interfaces/IExchangeRateSettingsResolver.cs new file mode 100644 index 000000000..6925c77b9 --- /dev/null +++ b/jobs/Backend/Task/src/Interfaces/IExchangeRateSettingsResolver.cs @@ -0,0 +1,8 @@ +using ExchangeRateUpdater.Models; + +namespace ExchangeRateUpdater.Interfaces; + +public interface IExchangeRateSettingsResolver +{ + ExchangeRateSettings ResolveSourceSettings(Currency baseCurrency); +} \ No newline at end of file diff --git a/jobs/Backend/Task/src/Models/Currency.cs b/jobs/Backend/Task/src/Models/Currency.cs new file mode 100644 index 000000000..5cd9dcd77 --- /dev/null +++ b/jobs/Backend/Task/src/Models/Currency.cs @@ -0,0 +1,20 @@ +namespace ExchangeRateUpdater.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/src/Models/ExchangeRate.cs b/jobs/Backend/Task/src/Models/ExchangeRate.cs new file mode 100644 index 000000000..2a0b91a4f --- /dev/null +++ b/jobs/Backend/Task/src/Models/ExchangeRate.cs @@ -0,0 +1,35 @@ +using System.Collections.Generic; +using System.Linq; +namespace ExchangeRateUpdater.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}"; + } + + public static IEnumerable GetResponse(IEnumerable rates) + { + return rates.Select(er => new ExchangeRateResponse + { + SourceCurrency = er.SourceCurrency.Code, + TargetCurrency = er.TargetCurrency.Code, + ExchangeRate = er.Value + }); + } +} + diff --git a/jobs/Backend/Task/src/Models/ExchangeRateResponse.cs b/jobs/Backend/Task/src/Models/ExchangeRateResponse.cs new file mode 100644 index 000000000..d4b0b23eb --- /dev/null +++ b/jobs/Backend/Task/src/Models/ExchangeRateResponse.cs @@ -0,0 +1,8 @@ +namespace ExchangeRateUpdater.Models; + +public class ExchangeRateResponse +{ + public string SourceCurrency { get; init; } + public string TargetCurrency { get; init; } + public decimal ExchangeRate { get; init; } +} \ No newline at end of file diff --git a/jobs/Backend/Task/src/Models/ExchangeRateSettings.cs b/jobs/Backend/Task/src/Models/ExchangeRateSettings.cs new file mode 100644 index 000000000..f859f3ca2 --- /dev/null +++ b/jobs/Backend/Task/src/Models/ExchangeRateSettings.cs @@ -0,0 +1,9 @@ +using ExchangeRateUpdater.Interfaces; + +namespace ExchangeRateUpdater.Models; + +public class ExchangeRateSettings(string url, IExchangeRateParser parser) +{ + public string Url { get; } = url; + public IExchangeRateParser Parser { get; } = parser; +} \ No newline at end of file diff --git a/jobs/Backend/Task/src/Models/ExchangeRateSources.cs b/jobs/Backend/Task/src/Models/ExchangeRateSources.cs new file mode 100644 index 000000000..4345f6f86 --- /dev/null +++ b/jobs/Backend/Task/src/Models/ExchangeRateSources.cs @@ -0,0 +1,8 @@ +namespace ExchangeRateUpdater.Models; + +public class ExchangeRateSources +{ + public string BaseCurrency { get; init; } + public string Url { get; init; } + public string ParserType { get; init; } +} \ No newline at end of file diff --git a/jobs/Backend/Task/src/Parsers/CnbXmlParser.cs b/jobs/Backend/Task/src/Parsers/CnbXmlParser.cs new file mode 100644 index 000000000..6969bb017 --- /dev/null +++ b/jobs/Backend/Task/src/Parsers/CnbXmlParser.cs @@ -0,0 +1,32 @@ +using System; +using System.Collections.Generic; +using System.Globalization; +using System.Linq; +using System.Xml.Linq; +using ExchangeRateUpdater.Exceptions; +using ExchangeRateUpdater.Interfaces; +using ExchangeRateUpdater.Models; + +namespace ExchangeRateUpdater.Parsers; + +public class CnbXmlParser : IExchangeRateParser +{ + public List Parse(string data, Currency baseCurrency) + { + try + { + var dataDoc = XDocument.Parse(data); + return dataDoc.Descendants("radek").Select(currency => new ExchangeRate( + baseCurrency, + new Currency(currency.Attribute("kod")!.Value), + // make sure the comma is used as a decimal delimiter + decimal.Parse(currency.Attribute("kurz")?.Value!, CultureInfo.GetCultureInfo("cs-CZ")) / + int.Parse(currency.Attribute("mnozstvi")!.Value) + )).ToList(); + } + catch (Exception ex) + { + throw new ParsingException("Failed to parse CNB exchange rates", ex); + } + } +} \ No newline at end of file diff --git a/jobs/Backend/Task/src/Parsers/ParserFactory.cs b/jobs/Backend/Task/src/Parsers/ParserFactory.cs new file mode 100644 index 000000000..1940f0749 --- /dev/null +++ b/jobs/Backend/Task/src/Parsers/ParserFactory.cs @@ -0,0 +1,16 @@ +using System; +using ExchangeRateUpdater.Interfaces; + +namespace ExchangeRateUpdater.Parsers; + +public static class ParserFactory +{ + public static IExchangeRateParser CreateParser(string parserType) + { + return parserType switch + { + "CnbXmlParser" => new CnbXmlParser(), + _ => throw new InvalidOperationException($"Unknown parser type: {parserType}"), + }; + } +} \ No newline at end of file diff --git a/jobs/Backend/Task/src/Program.cs b/jobs/Backend/Task/src/Program.cs new file mode 100644 index 000000000..b7e6644fa --- /dev/null +++ b/jobs/Backend/Task/src/Program.cs @@ -0,0 +1,29 @@ +using ExchangeRateUpdater.Interfaces; +using ExchangeRateUpdater.Services; +using Microsoft.AspNetCore.Builder; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.OpenApi; + +var builder = WebApplication.CreateBuilder(args); + +builder.Services.AddMemoryCache(); +builder.Services.AddHttpClient(); +builder.Services.AddSingleton(); +builder.Services.AddSingleton(); +builder.Services.AddControllers(); + +builder.Services.AddEndpointsApiExplorer(); +builder.Services.AddSwaggerGen(); + +builder.Configuration.AddJsonFile("appsettings.json", optional: false, reloadOnChange: true); +var app = builder.Build(); + +app.UseSwagger(c => +{ + c.OpenApiVersion = OpenApiSpecVersion.OpenApi2_0; +}); +app.UseSwaggerUI(); +app.MapGet("/", () => "Exchange Rate Updater is running!"); +app.MapControllers(); +app.Run(); \ No newline at end of file diff --git a/jobs/Backend/Task/src/Services/ExchangeRateProvider.cs b/jobs/Backend/Task/src/Services/ExchangeRateProvider.cs new file mode 100644 index 000000000..5cc48e4ff --- /dev/null +++ b/jobs/Backend/Task/src/Services/ExchangeRateProvider.cs @@ -0,0 +1,82 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Net.Http; +using System.Threading.Tasks; +using ExchangeRateUpdater.Exceptions; +using ExchangeRateUpdater.Interfaces; +using ExchangeRateUpdater.Models; +using Microsoft.Extensions.Caching.Memory; +using Microsoft.Extensions.Configuration; + +namespace ExchangeRateUpdater.Services; + +public class ExchangeRateProvider : IExchangeRateProvider +{ + private readonly IMemoryCache _cache; + private readonly HttpClient _httpClient; + private readonly IExchangeRateSettingsResolver _settingsResolver; + private readonly List _allowedCurrencies; + + public ExchangeRateProvider(IMemoryCache cache, HttpClient httpClient, IExchangeRateSettingsResolver settingsResolver, IConfiguration configuration) + { + _cache = cache; + _httpClient = httpClient; + _settingsResolver = settingsResolver; + _allowedCurrencies = configuration.GetSection("AllowedCurrencies").Get>(); + } + + public async Task> GetExchangeRates(List currencies, Currency baseCurrency) + { + ValidateCurrencies(currencies, baseCurrency); + + var cacheKey = $"ExchangeRates_{baseCurrency.Code}"; + + if (!_cache.TryGetValue(cacheKey, out List rates)) + { + ExchangeRateSettings settings; + try + { + settings = _settingsResolver.ResolveSourceSettings(baseCurrency); + } + + catch (InvalidOperationException ex) + { + throw new ExchangeRateApiException(ex.Message, ex); + } + try + { + var response = await _httpClient.GetStringAsync(settings.Url); + rates = settings.Parser.Parse(response, baseCurrency).ToList(); + } + catch (HttpRequestException ex) + { + throw new ExchangeRateApiException($"Failed to retrieve exchange rates from {settings.Url}", ex); + } + _cache.Set(cacheKey, rates, TimeSpan.FromMinutes(5)); + } + + var requestedCodes = new HashSet(currencies.Select(c => c.Code)); + return rates.Where(r => requestedCodes.Contains(r.TargetCurrency.Code)); + } + + private void ValidateCurrencies(List currencies, Currency baseCurrency) + { + if (!_allowedCurrencies.Contains(baseCurrency.Code.ToUpper())) + { + throw new ArgumentException($"Base currency '{baseCurrency}' is not allowed."); + } + + if (!currencies.Any()) + { + throw new ArgumentException("Target currencies cannot be empty."); + } + + var unallowedCurrencies = currencies.Where(c => !_allowedCurrencies.Contains(c.Code.ToUpper())).ToList(); + if (unallowedCurrencies.Any()) + { + throw new ArgumentException($"The following currencies are not allowed: {string.Join(", ", unallowedCurrencies.Select(c => c.Code))}"); + } + } +} + diff --git a/jobs/Backend/Task/src/Services/ExchangeRateSettingsResolver.cs b/jobs/Backend/Task/src/Services/ExchangeRateSettingsResolver.cs new file mode 100644 index 000000000..3215eaf13 --- /dev/null +++ b/jobs/Backend/Task/src/Services/ExchangeRateSettingsResolver.cs @@ -0,0 +1,36 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using ExchangeRateUpdater.Interfaces; +using ExchangeRateUpdater.Models; +using ExchangeRateUpdater.Parsers; +using Microsoft.Extensions.Configuration; + +namespace ExchangeRateUpdater.Services; + +public class ExchangeRateSettingsResolver : IExchangeRateSettingsResolver +{ + private readonly IConfiguration _configuration; + + public ExchangeRateSettingsResolver(IConfiguration configuration) + { + _configuration = configuration; + } + + public ExchangeRateSettings ResolveSourceSettings(Currency baseCurrency) + { + var sources = _configuration.GetSection("ExchangeRateSources").Get>(); + + var source = sources + .FirstOrDefault(s => s.BaseCurrency.Equals(baseCurrency.Code, StringComparison.OrdinalIgnoreCase)); + + if (source == null) + { + throw new InvalidOperationException($"No exchange rate source found for base currency {baseCurrency.Code}"); + } + + var parser = ParserFactory.CreateParser(source.ParserType); + + return new ExchangeRateSettings(source.Url, parser); + } +} \ No newline at end of file