diff --git a/jobs/Backend/Task/.dockerignore b/jobs/Backend/Task/.dockerignore new file mode 100644 index 000000000..cd967fc3a --- /dev/null +++ b/jobs/Backend/Task/.dockerignore @@ -0,0 +1,25 @@ +**/.dockerignore +**/.env +**/.git +**/.gitignore +**/.project +**/.settings +**/.toolstarget +**/.vs +**/.vscode +**/.idea +**/*.*proj.user +**/*.dbmdl +**/*.jfm +**/azds.yaml +**/bin +**/charts +**/docker-compose* +**/Dockerfile* +**/node_modules +**/npm-debug.log +**/obj +**/secrets.dev.yaml +**/values.dev.yaml +LICENSE +README.md \ No newline at end of file diff --git a/jobs/Backend/Task/Currency.cs b/jobs/Backend/Task/Currency.cs deleted file mode 100644 index f375776f2..000000000 --- a/jobs/Backend/Task/Currency.cs +++ /dev/null @@ -1,20 +0,0 @@ -namespace ExchangeRateUpdater -{ - public class Currency - { - public Currency(string code) - { - Code = code; - } - - /// - /// Three-letter ISO 4217 code of the currency. - /// - public string Code { get; } - - public override string ToString() - { - return Code; - } - } -} diff --git a/jobs/Backend/Task/ExchangeRate.cs b/jobs/Backend/Task/ExchangeRate.cs deleted file mode 100644 index 58c5bb10e..000000000 --- a/jobs/Backend/Task/ExchangeRate.cs +++ /dev/null @@ -1,23 +0,0 @@ -namespace ExchangeRateUpdater -{ - public class ExchangeRate - { - public ExchangeRate(Currency sourceCurrency, Currency targetCurrency, decimal value) - { - SourceCurrency = sourceCurrency; - TargetCurrency = targetCurrency; - Value = value; - } - - public Currency SourceCurrency { get; } - - public Currency TargetCurrency { get; } - - public decimal Value { get; } - - public override string ToString() - { - return $"{SourceCurrency}/{TargetCurrency}={Value}"; - } - } -} diff --git a/jobs/Backend/Task/ExchangeRateProvider.cs b/jobs/Backend/Task/ExchangeRateProvider.cs deleted file mode 100644 index 6f82a97fb..000000000 --- a/jobs/Backend/Task/ExchangeRateProvider.cs +++ /dev/null @@ -1,19 +0,0 @@ -using System.Collections.Generic; -using System.Linq; - -namespace ExchangeRateUpdater -{ - public class ExchangeRateProvider - { - /// - /// Should return exchange rates among the specified currencies that are defined by the source. But only those defined - /// by the source, do not return calculated exchange rates. E.g. if the source contains "CZK/USD" but not "USD/CZK", - /// do not return exchange rate "USD/CZK" with value calculated as 1 / "CZK/USD". If the source does not provide - /// some of the currencies, ignore them. - /// - public IEnumerable GetExchangeRates(IEnumerable currencies) - { - return Enumerable.Empty(); - } - } -} diff --git a/jobs/Backend/Task/ExchangeRateUpdater.Domain/ApiClients/ExchangeRateApiClient.cs b/jobs/Backend/Task/ExchangeRateUpdater.Domain/ApiClients/ExchangeRateApiClient.cs new file mode 100644 index 000000000..980bc8ce4 --- /dev/null +++ b/jobs/Backend/Task/ExchangeRateUpdater.Domain/ApiClients/ExchangeRateApiClient.cs @@ -0,0 +1,39 @@ +using ExchangeRateUpdater.Domain.ApiClients.Interfaces; +using Microsoft.Extensions.Logging; + +namespace ExchangeRateUpdater.Domain.ApiClients; + +public sealed class ExchangeRateApiClient(HttpClient httpClient, ILogger logger) + : IExchangeRateApiClient +{ + private readonly HttpClient _httpClient = httpClient ?? throw new ArgumentNullException(nameof(httpClient)); + private readonly ILogger _logger = logger ?? throw new ArgumentNullException(nameof(logger)); + private const string ErrorMessage = "Request to ExchangeRate source failed."; + + public async Task GetExchangeRatesXml(CancellationToken cancellationToken) + { + const string endpoint = "/cs/financni_trhy/devizovy_trh/kurzy_devizoveho_trhu/denni_kurz.xml"; + var requestUri = new Uri(_httpClient.BaseAddress!, endpoint); + + var requestMessage = new HttpRequestMessage(HttpMethod.Get, requestUri); + + var response = await _httpClient.SendAsync(requestMessage, cancellationToken); + + if (!response.IsSuccessStatusCode) + { + var errorContent = await response.Content.ReadAsStringAsync(cancellationToken); + + _logger.LogError( + "HTTP GET {Url} failed with status {StatusCode}: {Content}", + requestUri, + response.StatusCode, + errorContent + ); + + throw new HttpRequestException(ErrorMessage); + } + + var content = await response.Content.ReadAsStringAsync(cancellationToken); + return content; + } +} \ No newline at end of file diff --git a/jobs/Backend/Task/ExchangeRateUpdater.Domain/ApiClients/Interfaces/IExchangeRateApiClient.cs b/jobs/Backend/Task/ExchangeRateUpdater.Domain/ApiClients/Interfaces/IExchangeRateApiClient.cs new file mode 100644 index 000000000..1cf6e4f43 --- /dev/null +++ b/jobs/Backend/Task/ExchangeRateUpdater.Domain/ApiClients/Interfaces/IExchangeRateApiClient.cs @@ -0,0 +1,6 @@ +namespace ExchangeRateUpdater.Domain.ApiClients.Interfaces; + +public interface IExchangeRateApiClient +{ + Task GetExchangeRatesXml(CancellationToken cancellationToken); +} \ No newline at end of file diff --git a/jobs/Backend/Task/ExchangeRateUpdater.Domain/ApiClients/Models/GetExchangeRatesResponse.cs b/jobs/Backend/Task/ExchangeRateUpdater.Domain/ApiClients/Models/GetExchangeRatesResponse.cs new file mode 100644 index 000000000..e19566b7d --- /dev/null +++ b/jobs/Backend/Task/ExchangeRateUpdater.Domain/ApiClients/Models/GetExchangeRatesResponse.cs @@ -0,0 +1,38 @@ +using System.Globalization; +using System.Xml.Serialization; + +namespace ExchangeRateUpdater.Domain.ApiClients.Models; + +[XmlRoot("kurzy")] +public class ExchangeRatesResponse +{ + [XmlElement("tabulka")] + public ExchangeRateTable[] Tables { get; set; } = []; +} + +public class ExchangeRateTable +{ + [XmlElement("radek")] + public ExchangeRateRow[] Rows { get; set; } = []; +} + +public class ExchangeRateRow +{ + [XmlAttribute("kod")] + public string CurrencyCode { get; set; } = string.Empty; + + [XmlAttribute("mena")] + public string CurrencyName { get; set; } = string.Empty; + + [XmlAttribute("kurz")] + public string RateString { get; set; } = string.Empty; + + [XmlAttribute("mnozstvi")] + public string AmountString { get; set; } = "1"; + + [XmlIgnore] + public decimal Rate => decimal.Parse(RateString, NumberStyles.Number, new CultureInfo("cs-CZ")); + + [XmlIgnore] + public decimal Amount => decimal.Parse(AmountString, NumberStyles.Number, new CultureInfo("cs-CZ")); +} \ No newline at end of file diff --git a/jobs/Backend/Task/ExchangeRateUpdater.Domain/Exceptions/InvalidExchangeRateDataException.cs b/jobs/Backend/Task/ExchangeRateUpdater.Domain/Exceptions/InvalidExchangeRateDataException.cs new file mode 100644 index 000000000..5dc22e09c --- /dev/null +++ b/jobs/Backend/Task/ExchangeRateUpdater.Domain/Exceptions/InvalidExchangeRateDataException.cs @@ -0,0 +1,3 @@ +namespace ExchangeRateUpdater.Domain.Exceptions; + +public sealed class InvalidExchangeRateDataException(string message) : Exception(message); \ No newline at end of file diff --git a/jobs/Backend/Task/ExchangeRateUpdater.Domain/Exceptions/UnknownCurrencyException.cs b/jobs/Backend/Task/ExchangeRateUpdater.Domain/Exceptions/UnknownCurrencyException.cs new file mode 100644 index 000000000..fabb4494e --- /dev/null +++ b/jobs/Backend/Task/ExchangeRateUpdater.Domain/Exceptions/UnknownCurrencyException.cs @@ -0,0 +1,3 @@ +namespace ExchangeRateUpdater.Domain.Exceptions; + +public sealed class UnknownCurrencyException(string code) : Exception($"Unknown currency code: {code}"); \ No newline at end of file diff --git a/jobs/Backend/Task/ExchangeRateUpdater.Domain/ExchangeRateUpdater.Domain.csproj b/jobs/Backend/Task/ExchangeRateUpdater.Domain/ExchangeRateUpdater.Domain.csproj new file mode 100644 index 000000000..70ddad264 --- /dev/null +++ b/jobs/Backend/Task/ExchangeRateUpdater.Domain/ExchangeRateUpdater.Domain.csproj @@ -0,0 +1,15 @@ + + + + net8.0 + enable + enable + + + + + + + + + diff --git a/jobs/Backend/Task/ExchangeRateUpdater.Domain/Models/Currency.cs b/jobs/Backend/Task/ExchangeRateUpdater.Domain/Models/Currency.cs new file mode 100644 index 000000000..e19e61d84 --- /dev/null +++ b/jobs/Backend/Task/ExchangeRateUpdater.Domain/Models/Currency.cs @@ -0,0 +1,8 @@ +namespace ExchangeRateUpdater.Domain.Models; + +public sealed class Currency(string code) +{ + public string Code { get; } = code ?? throw new ArgumentNullException(nameof(code)); + + public override string ToString() => Code; +} \ No newline at end of file diff --git a/jobs/Backend/Task/ExchangeRateUpdater.Domain/Models/ExchangeRate.cs b/jobs/Backend/Task/ExchangeRateUpdater.Domain/Models/ExchangeRate.cs new file mode 100644 index 000000000..4e5a4d3f8 --- /dev/null +++ b/jobs/Backend/Task/ExchangeRateUpdater.Domain/Models/ExchangeRate.cs @@ -0,0 +1,10 @@ +namespace ExchangeRateUpdater.Domain.Models; + +public sealed record ExchangeRate +{ + public required Currency SourceCurrency { get; init; } + public required Currency TargetCurrency { get; init; } + public required decimal Value { get; init; } + + public override string ToString() => $"{SourceCurrency}/{TargetCurrency}={Value}"; +} \ No newline at end of file diff --git a/jobs/Backend/Task/ExchangeRateUpdater.Domain/Services/Implementations/ExchangeRateUpdaterProvider.cs b/jobs/Backend/Task/ExchangeRateUpdater.Domain/Services/Implementations/ExchangeRateUpdaterProvider.cs new file mode 100644 index 000000000..e61abfa49 --- /dev/null +++ b/jobs/Backend/Task/ExchangeRateUpdater.Domain/Services/Implementations/ExchangeRateUpdaterProvider.cs @@ -0,0 +1,87 @@ +using System.Text.Json; +using ExchangeRateUpdater.Domain.ApiClients.Interfaces; +using ExchangeRateUpdater.Domain.Models; +using ExchangeRateUpdater.Domain.Services.Interfaces; +using Microsoft.Extensions.Caching.Distributed; +using Microsoft.Extensions.Logging; + +namespace ExchangeRateUpdater.Domain.Services.Implementations +{ + public sealed class ExchangeRateUpdaterProvider( + IExchangeRateApiClient apiClient, + IExchangeRateParser parser, + IDistributedCache cache, + ILogger logger) + : IExchangeRateProvider + { + private const string CacheKey = "ExchangeRates"; + + public async Task> GetExchangeRatesForCurrenciesAsync(IEnumerable currencies, CancellationToken cancellationToken) + { + List? exchangeRatesPerCurrency = []; + var currenciesContainer = currencies.ToArray(); + var targetCurrencyCodes = currenciesContainer.Select(x => x.Code).ToHashSet(); + + if (targetCurrencyCodes.Count == 0) + { + logger.LogWarning("Input currencies should contain codes. Currently none is existing"); + return []; + } + + try + { + var cached = await cache.GetStringAsync(CacheKey, token: cancellationToken); + + if (!string.IsNullOrEmpty(cached)) + exchangeRatesPerCurrency = JsonSerializer.Deserialize>(cached); + + if (exchangeRatesPerCurrency is not { Count: > 0 }) + { + var xmlRates = await apiClient.GetExchangeRatesXml(cancellationToken); + + var parsedRates = await parser.ParseAsync(xmlRates); + + var exchangeRates = parsedRates as ExchangeRate[] ?? parsedRates.ToArray(); + if (exchangeRates.Length <= 0) + { + logger.LogWarning("Parsed Rates yielding no values"); + return []; + } + + var exchangeRatesSerialized = JsonSerializer.Serialize(exchangeRates); + + if (string.IsNullOrEmpty(exchangeRatesSerialized)) + { + logger.LogWarning("Parsed Rates serialized is empty. Returning zero results"); + return []; + } + + await PersistDataIntoCache(exchangeRatesSerialized, cancellationToken); + + return exchangeRates.Where(x => targetCurrencyCodes.Contains(x.TargetCurrency.Code)); + } + + } + catch (JsonException jsonException) + { + logger.LogError("Json Exception while deserializing Cached Entries : {JsonException}", jsonException); + throw; + } + catch (Exception ex) + { + logger.LogError("Unhandled exception : {Exception}", ex); + throw; + } + + return exchangeRatesPerCurrency.Where(x => targetCurrencyCodes.Contains(x.TargetCurrency.Code)); + } + + private async Task PersistDataIntoCache(string exchangeRatesSerialized, CancellationToken cancellationToken) + { + await cache.SetStringAsync(CacheKey, exchangeRatesSerialized, new DistributedCacheEntryOptions + { + AbsoluteExpirationRelativeToNow = TimeSpan.FromHours(24), + }, token: cancellationToken); + } + } +} diff --git a/jobs/Backend/Task/ExchangeRateUpdater.Domain/Services/Implementations/XmlExchangeRatesParser.cs b/jobs/Backend/Task/ExchangeRateUpdater.Domain/Services/Implementations/XmlExchangeRatesParser.cs new file mode 100644 index 000000000..430f13735 --- /dev/null +++ b/jobs/Backend/Task/ExchangeRateUpdater.Domain/Services/Implementations/XmlExchangeRatesParser.cs @@ -0,0 +1,49 @@ +using System.Xml.Serialization; +using ExchangeRateUpdater.Domain.ApiClients.Models; +using ExchangeRateUpdater.Domain.Models; +using ExchangeRateUpdater.Domain.Services.Interfaces; +using Microsoft.Extensions.Logging; + +namespace ExchangeRateUpdater.Domain.Services.Implementations; +public sealed class XmlExchangeRatesParser(ILogger logger) : IExchangeRateParser +{ + public Task> ParseAsync(string xmlContent) + { + var rates = new List(); + try + { + var serializer = new XmlSerializer(typeof(ExchangeRatesResponse)); + using var reader = new StringReader(xmlContent); + var deserializedContent = serializer.Deserialize(reader) as ExchangeRatesResponse; + + if (deserializedContent == null || deserializedContent.Tables.Length <= 0) + { + logger.LogWarning("Deserialized ExchangeRates Xml content is null or empty"); + return Task.FromResult>([]); + } + + foreach (var table in deserializedContent.Tables) + { + foreach (var row in table.Rows) + { + if(string.IsNullOrEmpty(row.CurrencyCode)) + continue; + + rates.Add(new ExchangeRate + { + SourceCurrency = new Currency("CZK"), + TargetCurrency = new Currency(row.CurrencyCode), + Value = row.Rate / row.Amount + }); + } + } + + } + catch (Exception ex) + { + logger.LogError(ex, "Failed to deserialize CNB XML."); + } + + return Task.FromResult>(rates); + } +} \ No newline at end of file diff --git a/jobs/Backend/Task/ExchangeRateUpdater.Domain/Services/Interfaces/IExchangeRateParser.cs b/jobs/Backend/Task/ExchangeRateUpdater.Domain/Services/Interfaces/IExchangeRateParser.cs new file mode 100644 index 000000000..ad7c3f0b7 --- /dev/null +++ b/jobs/Backend/Task/ExchangeRateUpdater.Domain/Services/Interfaces/IExchangeRateParser.cs @@ -0,0 +1,8 @@ +using ExchangeRateUpdater.Domain.Models; + +namespace ExchangeRateUpdater.Domain.Services.Interfaces; + +public interface IExchangeRateParser +{ + Task> ParseAsync(string xmlContent); +} \ No newline at end of file diff --git a/jobs/Backend/Task/ExchangeRateUpdater.Domain/Services/Interfaces/IExchangeRateProvider.cs b/jobs/Backend/Task/ExchangeRateUpdater.Domain/Services/Interfaces/IExchangeRateProvider.cs new file mode 100644 index 000000000..0fcbb7f08 --- /dev/null +++ b/jobs/Backend/Task/ExchangeRateUpdater.Domain/Services/Interfaces/IExchangeRateProvider.cs @@ -0,0 +1,9 @@ +using ExchangeRateUpdater.Domain.Models; + +namespace ExchangeRateUpdater.Domain.Services.Interfaces; + +public interface IExchangeRateProvider +{ + Task> GetExchangeRatesForCurrenciesAsync(IEnumerable currencies, CancellationToken cancellationToken); + +} \ No newline at end of file diff --git a/jobs/Backend/Task/ExchangeRateUpdater.Tests/ExchangeRateUpdater.Tests.csproj b/jobs/Backend/Task/ExchangeRateUpdater.Tests/ExchangeRateUpdater.Tests.csproj new file mode 100644 index 000000000..308fb1002 --- /dev/null +++ b/jobs/Backend/Task/ExchangeRateUpdater.Tests/ExchangeRateUpdater.Tests.csproj @@ -0,0 +1,28 @@ + + + + net8.0 + enable + enable + + + + + + + + + + + + + + + Always + + + Always + + + + diff --git a/jobs/Backend/Task/ExchangeRateUpdater.Tests/Services/ExchangeRateUpdaterProviderTests.cs b/jobs/Backend/Task/ExchangeRateUpdater.Tests/Services/ExchangeRateUpdaterProviderTests.cs new file mode 100644 index 000000000..505aba80a --- /dev/null +++ b/jobs/Backend/Task/ExchangeRateUpdater.Tests/Services/ExchangeRateUpdaterProviderTests.cs @@ -0,0 +1,122 @@ +using System.Text.Json; +using ExchangeRateUpdater.Domain.Models; +using FluentAssertions; +using Microsoft.Extensions.Caching.Distributed; +using Microsoft.Extensions.DependencyInjection; +using NSubstitute; +using Xunit; + +namespace ExchangeRateUpdater.Tests.Services; + +public class ExchangeRateUpdaterProviderTests(TestFixture fixture) : IClassFixture, IAsyncLifetime +{ + private const string CzkCode = "CZK"; + private const string CacheKey = "ExchangeRates"; + private const string XmlResultString = "DummyXmlResult"; + + public async Task InitializeAsync() + { + var cache = fixture.Services.GetRequiredService(); + await cache.RemoveAsync(CacheKey); + fixture.ApiClientMock.ClearReceivedCalls(); + } + + public Task DisposeAsync() => Task.CompletedTask; + + [Fact] + public async Task WhenCacheKeyExists_ExchangeRatesShouldBeReturned() + { + // Arrange + var eur = new Currency("EUR"); + var usd = new Currency("USD"); + var expectedRates = new List + { + new() { SourceCurrency = new Currency(CzkCode), TargetCurrency = eur, Value = 27.178m }, + new() { SourceCurrency = new Currency(CzkCode), TargetCurrency = usd, Value = 22.615m } + }; + + var cache = fixture.Services.GetRequiredService(); + var serialized = JsonSerializer.Serialize(expectedRates); + await cache.SetStringAsync(CacheKey, serialized); + + // Act + var result = await fixture.ExchangeRateProvider.GetExchangeRatesForCurrenciesAsync([eur, usd], CancellationToken.None); + + // Assert + result.Should().BeEquivalentTo(expectedRates, opts => opts.WithStrictOrdering()); + await fixture.ApiClientMock.DidNotReceive().GetExchangeRatesXml(Arg.Any()); + } + + [Fact] + public async Task WhenCacheIsNotPresent_ExchangeRatesShouldBeFetchedAndReturned() + { + // Arrange + var eur = new Currency("EUR"); + var usd = new Currency("USD"); + + + fixture.ApiClientMock.GetExchangeRatesXml(Arg.Any()) + .Returns(Task.FromResult(XmlResultString)); + + var parsedRates = new List + { + new() { SourceCurrency = new Currency(CzkCode), TargetCurrency = eur, Value = 27.178m }, + new() { SourceCurrency = new Currency(CzkCode), TargetCurrency = usd, Value = 22.615m } + }; + fixture.ParserMock.ParseAsync(XmlResultString).Returns(Task.FromResult(parsedRates.AsEnumerable())); + + // Act + var result = await fixture.ExchangeRateProvider.GetExchangeRatesForCurrenciesAsync([eur, usd], CancellationToken.None); + + // Assert + result.Should().BeEquivalentTo(parsedRates, opts => opts.WithStrictOrdering()); + await fixture.ApiClientMock.Received(1).GetExchangeRatesXml(Arg.Any()); + } + + [Fact] + public async Task WhenCurrencyIsUnsupported_ShouldIgnoreAndReturnMatchingExchangeRates() + { + // Arrange + var eur = new Currency("EUR"); + var xyz = new Currency("XYZ"); + + + fixture.ApiClientMock.GetExchangeRatesXml(Arg.Any()) + .Returns(Task.FromResult(XmlResultString)); + + var parsedRates = new List + { + new() { SourceCurrency = new Currency(CzkCode), TargetCurrency = eur, Value = 27.178m } + }; + + fixture.ParserMock.ParseAsync(XmlResultString).Returns(Task.FromResult(parsedRates.AsEnumerable())); + + // Act + var result = + await fixture.ExchangeRateProvider.GetExchangeRatesForCurrenciesAsync([eur, xyz], CancellationToken.None); + + // Assert + result.Should().BeEquivalentTo(parsedRates, opts => opts.WithStrictOrdering()); + } + + [Fact] + public async Task WhenXmlIsIncorrect_ShouldReturnEmptyRates() + { + // Arrange + var eur = new Currency("EUR"); + var usd = new Currency("USD"); + + fixture.ApiClientMock.GetExchangeRatesXml(Arg.Any()) + .Returns(Task.FromResult(XmlResultString)); + + fixture.ParserMock.ParseAsync(XmlResultString) + .Returns([]); + + var result = + await fixture.ExchangeRateProvider.GetExchangeRatesForCurrenciesAsync([eur, usd], CancellationToken.None); + + // Assert + result.Should().BeEmpty(); + } + +} diff --git a/jobs/Backend/Task/ExchangeRateUpdater.Tests/Services/XmlExchangeRatesParserTests.cs b/jobs/Backend/Task/ExchangeRateUpdater.Tests/Services/XmlExchangeRatesParserTests.cs new file mode 100644 index 000000000..93a46dbe8 --- /dev/null +++ b/jobs/Backend/Task/ExchangeRateUpdater.Tests/Services/XmlExchangeRatesParserTests.cs @@ -0,0 +1,45 @@ +using ExchangeRateUpdater.Domain.Models; +using ExchangeRateUpdater.Domain.Services.Implementations; +using FluentAssertions; +using Microsoft.Extensions.Logging; +using NSubstitute; +using Xunit; + +namespace ExchangeRateUpdater.Tests.Services; + +public class XmlExchangeRatesParserTests +{ + private const string ExchangeRatesCorrectFileXmlName = "ExchangeRatesCorrectFile.xml"; + private const string ExchangeRatesIncorrectFormatName = "ExchangeRatesIncorrectFormat.xml"; + private readonly XmlExchangeRatesParser _parser; + + public XmlExchangeRatesParserTests() + { + var logger = Substitute.For>(); + _parser = new XmlExchangeRatesParser(logger); + } + + [Fact] + public async Task ParseAsync_ShouldReturnCorrectRates_WhenXmlIsValid() + { + var xml = TestDataHelper.LoadTestData(ExchangeRatesCorrectFileXmlName); + + var rates = await _parser.ParseAsync(xml); + + var exchangeRates = rates as ExchangeRate[] ?? rates.ToArray(); + exchangeRates.Should().ContainSingle(x => x.TargetCurrency.Code == "EUR" && x.Value == 24.300m); + exchangeRates.Should().ContainSingle(x => x.TargetCurrency.Code == "USD" && x.Value == 20.563m); + } + + [Fact] + public async Task ParseAsync_ShouldReturnEmptyRates_WhenDeserializationOfXmlIsNotPossible() + { + var xml = TestDataHelper.LoadTestData(ExchangeRatesIncorrectFormatName); + + var rates = await _parser.ParseAsync(xml); + + var exchangeRates = rates as ExchangeRate[] ?? rates.ToArray(); + exchangeRates.Should().BeEmpty(); + + } +} diff --git a/jobs/Backend/Task/ExchangeRateUpdater.Tests/TestData/ExchangeRatesCorrectFile.xml b/jobs/Backend/Task/ExchangeRateUpdater.Tests/TestData/ExchangeRatesCorrectFile.xml new file mode 100644 index 000000000..9b1673a24 --- /dev/null +++ b/jobs/Backend/Task/ExchangeRateUpdater.Tests/TestData/ExchangeRatesCorrectFile.xml @@ -0,0 +1,70 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/jobs/Backend/Task/ExchangeRateUpdater.Tests/TestData/ExchangeRatesIncorrectFormat.xml b/jobs/Backend/Task/ExchangeRateUpdater.Tests/TestData/ExchangeRatesIncorrectFormat.xml new file mode 100644 index 000000000..80629e3bf --- /dev/null +++ b/jobs/Backend/Task/ExchangeRateUpdater.Tests/TestData/ExchangeRatesIncorrectFormat.xml @@ -0,0 +1,70 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/jobs/Backend/Task/ExchangeRateUpdater.Tests/TestDataHelper.cs b/jobs/Backend/Task/ExchangeRateUpdater.Tests/TestDataHelper.cs new file mode 100644 index 000000000..06ca822f1 --- /dev/null +++ b/jobs/Backend/Task/ExchangeRateUpdater.Tests/TestDataHelper.cs @@ -0,0 +1,15 @@ +namespace ExchangeRateUpdater.Tests; + +public static class TestDataHelper +{ + public static string LoadTestData(string fileName) + { + var basePath = Path.Combine(AppContext.BaseDirectory, "TestData"); + var filePath = Path.Combine(basePath, fileName); + + if (!File.Exists(filePath)) + throw new FileNotFoundException($"Test data file not found: {filePath}"); + + return File.ReadAllText(filePath); + } +} \ No newline at end of file diff --git a/jobs/Backend/Task/ExchangeRateUpdater.Tests/TestFixture.cs b/jobs/Backend/Task/ExchangeRateUpdater.Tests/TestFixture.cs new file mode 100644 index 000000000..9ee409285 --- /dev/null +++ b/jobs/Backend/Task/ExchangeRateUpdater.Tests/TestFixture.cs @@ -0,0 +1,36 @@ +using ExchangeRateUpdater.Domain.ApiClients.Interfaces; +using ExchangeRateUpdater.Domain.Services.Implementations; +using ExchangeRateUpdater.Domain.Services.Interfaces; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using NSubstitute; + +namespace ExchangeRateUpdater.Tests; + +public class TestFixture +{ + public IExchangeRateProvider ExchangeRateProvider { get; } + public IServiceProvider Services { get; } + public IExchangeRateApiClient ApiClientMock { get; } + public IExchangeRateParser ParserMock { get; } + + public TestFixture() + { + var services = new ServiceCollection(); + + services.AddDistributedMemoryCache(); + + ApiClientMock = Substitute.For(); + ParserMock = Substitute.For(); + + services.AddSingleton(ApiClientMock); + services.AddSingleton(ParserMock); + services.AddSingleton(Substitute.For>()); + services.AddSingleton(Substitute.For>()); + + services.AddSingleton(); + + Services = services.BuildServiceProvider(); + ExchangeRateProvider = Services.GetRequiredService(); + } +} \ No newline at end of file diff --git a/jobs/Backend/Task/ExchangeRateUpdater.csproj b/jobs/Backend/Task/ExchangeRateUpdater.csproj deleted file mode 100644 index 2fc654a12..000000000 --- a/jobs/Backend/Task/ExchangeRateUpdater.csproj +++ /dev/null @@ -1,8 +0,0 @@ - - - - Exe - net6.0 - - - \ No newline at end of file diff --git a/jobs/Backend/Task/ExchangeRateUpdater.sln b/jobs/Backend/Task/ExchangeRateUpdater.sln index 89be84daf..c8cab12e5 100644 --- a/jobs/Backend/Task/ExchangeRateUpdater.sln +++ b/jobs/Backend/Task/ExchangeRateUpdater.sln @@ -3,7 +3,11 @@ 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}" +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ExchangeRateUpdater", "ExchangeRateUpdater\ExchangeRateUpdater.csproj", "{7B2695D6-D24C-4460-A58E-A10F08550CE0}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ExchangeRateUpdater.Domain", "ExchangeRateUpdater.Domain\ExchangeRateUpdater.Domain.csproj", "{DF866C07-3AC6-4509-8297-CDCD94D06331}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ExchangeRateUpdater.Tests", "ExchangeRateUpdater.Tests\ExchangeRateUpdater.Tests.csproj", "{B96DDF07-8E2F-4AFD-B586-E9FE00FE2DD3}" EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution @@ -15,6 +19,14 @@ 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 + {DF866C07-3AC6-4509-8297-CDCD94D06331}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {DF866C07-3AC6-4509-8297-CDCD94D06331}.Debug|Any CPU.Build.0 = Debug|Any CPU + {DF866C07-3AC6-4509-8297-CDCD94D06331}.Release|Any CPU.ActiveCfg = Release|Any CPU + {DF866C07-3AC6-4509-8297-CDCD94D06331}.Release|Any CPU.Build.0 = Release|Any CPU + {B96DDF07-8E2F-4AFD-B586-E9FE00FE2DD3}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {B96DDF07-8E2F-4AFD-B586-E9FE00FE2DD3}.Debug|Any CPU.Build.0 = Debug|Any CPU + {B96DDF07-8E2F-4AFD-B586-E9FE00FE2DD3}.Release|Any CPU.ActiveCfg = Release|Any CPU + {B96DDF07-8E2F-4AFD-B586-E9FE00FE2DD3}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE diff --git a/jobs/Backend/Task/ExchangeRateUpdater/Configuration/DependencyInjection.cs b/jobs/Backend/Task/ExchangeRateUpdater/Configuration/DependencyInjection.cs new file mode 100644 index 000000000..95623f0db --- /dev/null +++ b/jobs/Backend/Task/ExchangeRateUpdater/Configuration/DependencyInjection.cs @@ -0,0 +1,38 @@ +using System; +using ExchangeRateUpdater.Domain.ApiClients; +using ExchangeRateUpdater.Domain.ApiClients.Interfaces; +using ExchangeRateUpdater.Domain.Services.Implementations; +using ExchangeRateUpdater.Domain.Services.Interfaces; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; + +namespace ExchangeRateUpdater.Configuration; + +public static class DependencyInjection +{ + public static void ConfigureApplicationServices(this IServiceCollection services, IConfiguration configuration) + { + services.ConfigureServices(); + services.ConfigureParsers(); + services.ConfigureHttpClients(); + } + + private static void ConfigureServices(this IServiceCollection services) + { + services.AddSingleton(); + } + + private static void ConfigureParsers(this IServiceCollection services) + { + services.AddSingleton(); + } + + private static void ConfigureHttpClients(this IServiceCollection services) + { + services.AddHttpClient(client => + { + client.BaseAddress = new Uri("https://www.cnb.cz/"); + client.Timeout = TimeSpan.FromSeconds(10); + }); + } +} \ No newline at end of file diff --git a/jobs/Backend/Task/ExchangeRateUpdater/Controllers/v1/ExchangeRatesController.cs b/jobs/Backend/Task/ExchangeRateUpdater/Controllers/v1/ExchangeRatesController.cs new file mode 100644 index 000000000..95b19847f --- /dev/null +++ b/jobs/Backend/Task/ExchangeRateUpdater/Controllers/v1/ExchangeRatesController.cs @@ -0,0 +1,55 @@ +using System; +using System.Collections.Immutable; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using ExchangeRateUpdater.Controllers.v1.Requests; +using ExchangeRateUpdater.Controllers.v1.Responses; +using ExchangeRateUpdater.Domain.Exceptions; +using ExchangeRateUpdater.Domain.Models; +using ExchangeRateUpdater.Domain.Services.Interfaces; +using Microsoft.AspNetCore.Mvc; + +namespace ExchangeRateUpdater.Controllers.v1; + +[ApiController] +[Route("api/[controller]")] + +public sealed class ExchangeRatesController(IExchangeRateProvider exchangeRateProvider) : ControllerBase +{ + [HttpPost] + [ProducesResponseType(typeof(GetExchangeRatesForCurrenciesResponse), 200)] + [ProducesResponseType(400)] + [ProducesResponseType(500)] + [Route(nameof(GetExchangeRatesForCurrencies))] + public async Task GetExchangeRatesForCurrencies(GetExchangeRatesForCurrenciesRequest getExchangeRatesForCurrenciesRequest, CancellationToken cancellationToken) + { + try + { + var currencies = getExchangeRatesForCurrenciesRequest.Currencies.Select(x => + new Currency(x)).ToArray(); + + var result = await exchangeRateProvider.GetExchangeRatesForCurrenciesAsync(currencies, cancellationToken); + var rates = result as ExchangeRate[] ?? result.ToArray(); + + var exchangeRatesForCurrenciesResponse = new GetExchangeRatesForCurrenciesResponse + { + ExchangeRates = rates.ToImmutableArray() + }; + + return Ok(exchangeRatesForCurrenciesResponse); + } + catch (UnknownCurrencyException unknownCurrencyException) + { + return BadRequest(unknownCurrencyException.Message); + } + catch (InvalidExchangeRateDataException invalidExchangeRateDataException) + { + return BadRequest(invalidExchangeRateDataException.Message); + } + catch (Exception exception) + { + return Problem(exception.Message); + } + } +} \ No newline at end of file diff --git a/jobs/Backend/Task/ExchangeRateUpdater/Controllers/v1/Requests/GetExchangeRatesForCurrenciesRequest.cs b/jobs/Backend/Task/ExchangeRateUpdater/Controllers/v1/Requests/GetExchangeRatesForCurrenciesRequest.cs new file mode 100644 index 000000000..c82f5da91 --- /dev/null +++ b/jobs/Backend/Task/ExchangeRateUpdater/Controllers/v1/Requests/GetExchangeRatesForCurrenciesRequest.cs @@ -0,0 +1,6 @@ +namespace ExchangeRateUpdater.Controllers.v1.Requests; + +public class GetExchangeRatesForCurrenciesRequest +{ + public string[] Currencies { get; init; } +} \ No newline at end of file diff --git a/jobs/Backend/Task/ExchangeRateUpdater/Controllers/v1/Responses/GetExchangeRatesForCurrenciesResponse.cs b/jobs/Backend/Task/ExchangeRateUpdater/Controllers/v1/Responses/GetExchangeRatesForCurrenciesResponse.cs new file mode 100644 index 000000000..78a4572ab --- /dev/null +++ b/jobs/Backend/Task/ExchangeRateUpdater/Controllers/v1/Responses/GetExchangeRatesForCurrenciesResponse.cs @@ -0,0 +1,9 @@ +using System.Collections.Generic; +using ExchangeRateUpdater.Domain.Models; + +namespace ExchangeRateUpdater.Controllers.v1.Responses; + +public class GetExchangeRatesForCurrenciesResponse +{ + public IReadOnlyCollection ExchangeRates { get; init; } +} \ No newline at end of file diff --git a/jobs/Backend/Task/ExchangeRateUpdater/Dockerfile b/jobs/Backend/Task/ExchangeRateUpdater/Dockerfile new file mode 100644 index 000000000..47f633df7 --- /dev/null +++ b/jobs/Backend/Task/ExchangeRateUpdater/Dockerfile @@ -0,0 +1,20 @@ +FROM mcr.microsoft.com/dotnet/aspnet:6.0 AS base +WORKDIR /app +EXPOSE 80 + +FROM mcr.microsoft.com/dotnet/sdk:6.0 AS build +WORKDIR /src +COPY ["ExchangeRateUpdater/ExchangeRateUpdater.csproj", "ExchangeRateUpdater/"] +COPY ["ExchangeRateUpdater.Domain/ExchangeRateUpdater.Domain.csproj", "ExchangeRateUpdater.Domain/"] +RUN dotnet restore "ExchangeRateUpdater/ExchangeRateUpdater.csproj" +COPY . . +WORKDIR "/src/ExchangeRateUpdater" +RUN dotnet build "ExchangeRateUpdater.csproj" -c Release -o /app/build + +FROM build AS publish +RUN dotnet publish "ExchangeRateUpdater.csproj" -c Release -o /app/publish + +FROM base AS final +WORKDIR /app +COPY --from=publish /app/publish . +ENTRYPOINT ["dotnet", "ExchangeRateUpdater.dll"] diff --git a/jobs/Backend/Task/ExchangeRateUpdater/ExchangeRateUpdater.csproj b/jobs/Backend/Task/ExchangeRateUpdater/ExchangeRateUpdater.csproj new file mode 100644 index 000000000..f5bda13d5 --- /dev/null +++ b/jobs/Backend/Task/ExchangeRateUpdater/ExchangeRateUpdater.csproj @@ -0,0 +1,21 @@ + + + + Exe + net8.0 + Linux + + + + + .dockerignore + + + + + + + + + + diff --git a/jobs/Backend/Task/ExchangeRateUpdater/Program.cs b/jobs/Backend/Task/ExchangeRateUpdater/Program.cs new file mode 100644 index 000000000..4f7a242ae --- /dev/null +++ b/jobs/Backend/Task/ExchangeRateUpdater/Program.cs @@ -0,0 +1,54 @@ +using ExchangeRateUpdater; +using ExchangeRateUpdater.Domain.Models; +using Microsoft.AspNetCore.Builder; +using Microsoft.Extensions.DependencyInjection; +using System; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; + +var builder = WebApplication.CreateBuilder(args); + +var startup = new Startup(builder.Configuration); +startup.ConfigureServices(builder.Services); + +var app = builder.Build(); +Startup.Configure(app, builder.Environment); + +// Run console-style logic as background task +using (var scope = app.Services.CreateScope()) +{ + var provider = scope.ServiceProvider.GetRequiredService(); + + _ = Task.Run(async () => + { + 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") + }; + + try + { + var rates = await provider.GetExchangeRatesForCurrenciesAsync(currencies, CancellationToken.None); + var exchangeRates = rates as ExchangeRate[] ?? rates.ToArray(); + Console.WriteLine($"Successfully retrieved {exchangeRates.Length} exchange rates:"); + foreach (var rate in exchangeRates) + Console.WriteLine($"{rate.SourceCurrency.Code}/{rate.TargetCurrency.Code} = {rate.Value}"); + } + catch (Exception e) + { + Console.WriteLine($"Could not retrieve exchange rates: '{e.Message}'."); + } + }); +} + +// Run the web host (Swagger + API) +await app.RunAsync(); \ No newline at end of file diff --git a/jobs/Backend/Task/ExchangeRateUpdater/Startup.cs b/jobs/Backend/Task/ExchangeRateUpdater/Startup.cs new file mode 100644 index 000000000..7ba86327f --- /dev/null +++ b/jobs/Backend/Task/ExchangeRateUpdater/Startup.cs @@ -0,0 +1,59 @@ +using System.Threading.Tasks; +using ExchangeRateUpdater.Configuration; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Hosting; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; + +namespace ExchangeRateUpdater; + +public sealed class Startup(IConfiguration configuration) +{ + public void ConfigureServices(IServiceCollection services) + { + var redisConnection = configuration.GetConnectionString("Redis"); + + if (string.IsNullOrWhiteSpace(redisConnection)) + { + services.AddDistributedMemoryCache(); + } + else + { + services.AddStackExchangeRedisCache(options => + { + options.Configuration = redisConnection; + }); + } + + services.ConfigureApplicationServices(configuration); + services.AddControllers(); + services.AddEndpointsApiExplorer(); + services.AddSwaggerGen(); + } + + public static void Configure(IApplicationBuilder app, IWebHostEnvironment env) + { + if (env.IsDevelopment()) + { + app.UseDeveloperExceptionPage(); + } + + app.UseSwagger(); + app.UseSwaggerUI(); + + app.UseRouting(); + app.UseAuthorization(); + app.UseEndpoints(endpoints => + { + endpoints.MapControllers(); + + // Redirect root to swagger + endpoints.MapGet("/", context => + { + context.Response.Redirect("/swagger"); + return Task.CompletedTask; + }); + }); + } +} \ No newline at end of file diff --git a/jobs/Backend/Task/ExchangeRateUpdater/appsettings.Development.json b/jobs/Backend/Task/ExchangeRateUpdater/appsettings.Development.json new file mode 100644 index 000000000..0c208ae91 --- /dev/null +++ b/jobs/Backend/Task/ExchangeRateUpdater/appsettings.Development.json @@ -0,0 +1,8 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + } +} diff --git a/jobs/Backend/Task/ExchangeRateUpdater/appsettings.Production.json b/jobs/Backend/Task/ExchangeRateUpdater/appsettings.Production.json new file mode 100644 index 000000000..c68a5c034 --- /dev/null +++ b/jobs/Backend/Task/ExchangeRateUpdater/appsettings.Production.json @@ -0,0 +1,11 @@ +{ + "ConnectionStrings": { + "Redis": "redis:6379" + }, + "Logging": { + "LogLevel": { + "Default": "Warning", + "Microsoft.AspNetCore": "Warning" + } + } +} \ No newline at end of file diff --git a/jobs/Backend/Task/ExchangeRateUpdater/appsettings.json b/jobs/Backend/Task/ExchangeRateUpdater/appsettings.json new file mode 100644 index 000000000..19b1d7246 --- /dev/null +++ b/jobs/Backend/Task/ExchangeRateUpdater/appsettings.json @@ -0,0 +1,12 @@ +{ + "Serilog": { + "MinimumLevel": { + "Default": "Information", + "Override": { + "Microsoft": "Warning", + "Microsoft.Hosting.Lifetime": "Information", + "System": "Warning" + } + } + } +} \ No newline at end of file diff --git a/jobs/Backend/Task/ExchangeRateUpdater/docker-compose.yml b/jobs/Backend/Task/ExchangeRateUpdater/docker-compose.yml new file mode 100644 index 000000000..61b462b5f --- /dev/null +++ b/jobs/Backend/Task/ExchangeRateUpdater/docker-compose.yml @@ -0,0 +1,15 @@ +version: '3.8' +services: + api: + build: ./ExchangeRateUpdater + ports: + - "5000:80" + depends_on: + - redis + environment: + - DOTNET_ENVIRONMENT=Development + + redis: + image: redis:7 + ports: + - "6379:6379" 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..97369dc8f --- /dev/null +++ b/jobs/Backend/Task/README.md @@ -0,0 +1,17 @@ +# ExchangeRateUpdater + +## Overview +A .NET 8 project that fetches daily exchange rates from the **Czech National Bank**, with: +- `IDistributedCache` support (Memory in dev, Redis in prod) +- XML parser with models +- REST API with Swagger +- Unit tests (xUnit + FluentAssertions + NSubstitute) +- Docker + docker-compose + +--- + +## Run Locally + +```bash +dotnet build +dotnet run --project ExchangeRateUpdater