diff --git a/jobs/Backend/Task/Currency.cs b/jobs/Backend/Task/Currency.cs deleted file mode 100644 index f375776f25..0000000000 --- a/jobs/Backend/Task/Currency.cs +++ /dev/null @@ -1,20 +0,0 @@ -namespace ExchangeRateUpdater -{ - public class Currency - { - public Currency(string code) - { - Code = code; - } - - /// - /// Three-letter ISO 4217 code of the currency. - /// - public string Code { get; } - - public override string ToString() - { - return Code; - } - } -} diff --git a/jobs/Backend/Task/ExchangeRate.cs b/jobs/Backend/Task/ExchangeRate.cs deleted file mode 100644 index 58c5bb10e0..0000000000 --- a/jobs/Backend/Task/ExchangeRate.cs +++ /dev/null @@ -1,23 +0,0 @@ -namespace ExchangeRateUpdater -{ - public class ExchangeRate - { - public ExchangeRate(Currency sourceCurrency, Currency targetCurrency, decimal value) - { - SourceCurrency = sourceCurrency; - TargetCurrency = targetCurrency; - Value = value; - } - - public Currency SourceCurrency { get; } - - public Currency TargetCurrency { get; } - - public decimal Value { get; } - - public override string ToString() - { - return $"{SourceCurrency}/{TargetCurrency}={Value}"; - } - } -} diff --git a/jobs/Backend/Task/ExchangeRateUpdater.Core.Tests/CzechNationalBankExchangeRateProviderTests.cs b/jobs/Backend/Task/ExchangeRateUpdater.Core.Tests/CzechNationalBankExchangeRateProviderTests.cs new file mode 100644 index 0000000000..a44d8cd3ce --- /dev/null +++ b/jobs/Backend/Task/ExchangeRateUpdater.Core.Tests/CzechNationalBankExchangeRateProviderTests.cs @@ -0,0 +1,56 @@ +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using ExchangeRateUpdater.Core.ApiVendors; +using ExchangeRateUpdater.Core.Models; +using ExchangeRateUpdater.Core.Providers; +using FluentAssertions; +using Moq; +using Xunit; + +namespace ExchangeRateUpdater.Core.Tests; + +public class CzechNationalBankExchangeRateProviderTests +{ + [Fact] + public async Task GetExchangeRates_EmptyInput_ReturnsEmpty_AndDoesNotCallVendor() + { + // Arrange + var vendorMock = new Mock(MockBehavior.Strict); + var sut = new CzechNationalBankExchangeRateProvider(vendorMock.Object); + + // Act + var result = await sut.GetExchangeRates([]); + + // Assert + result.Should().BeEmpty(); + vendorMock.Verify(v => v.GetExchangeRates(It.IsAny()), Times.Never); + } + + [Fact] + public async Task GetExchangeRates_FiltersToRequestedCurrencies_AndCallsVendorOnce() + { + // Arrange + var vendorMock = new Mock(); + vendorMock + .Setup(v => v.GetExchangeRates("CZK")) + .ReturnsAsync([ + new(new Currency("CZK"), new Currency("USD"), 0.043m), + new(new Currency("CZK"), new Currency("EUR"), 0.039m), + new(new Currency("CZK"), new Currency("GBP"), 0.034m) + ]); + + var sut = new CzechNationalBankExchangeRateProvider(vendorMock.Object); + + var requested = new[] { new Currency("USD"), new Currency("EUR") }; + + // Act + var result = await sut.GetExchangeRates(requested); + + // Assert + result.Should().HaveCount(2); + result.Select(r => r.TargetCurrency.ToString()).Should().BeEquivalentTo(new[] { "USD", "EUR" }); + result.All(r => r.SourceCurrency.ToString() == "CZK").Should().BeTrue(); + vendorMock.Verify(v => v.GetExchangeRates("CZK"), Times.Once); + } +} diff --git a/jobs/Backend/Task/ExchangeRateUpdater.Core.Tests/ExchangeRateUpdater.Core.Tests.csproj b/jobs/Backend/Task/ExchangeRateUpdater.Core.Tests/ExchangeRateUpdater.Core.Tests.csproj new file mode 100644 index 0000000000..3ae51aebb3 --- /dev/null +++ b/jobs/Backend/Task/ExchangeRateUpdater.Core.Tests/ExchangeRateUpdater.Core.Tests.csproj @@ -0,0 +1,21 @@ + + + net9.0 + false + true + enable + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/jobs/Backend/Task/ExchangeRateUpdater.Core.Tests/ServiceConfigurationTests.cs b/jobs/Backend/Task/ExchangeRateUpdater.Core.Tests/ServiceConfigurationTests.cs new file mode 100644 index 0000000000..db62829313 --- /dev/null +++ b/jobs/Backend/Task/ExchangeRateUpdater.Core.Tests/ServiceConfigurationTests.cs @@ -0,0 +1,42 @@ +using System.Collections.Generic; +using System.Linq; +using ExchangeRateUpdater.Core.Configuration; +using ExchangeRateUpdater.Core.Configuration.Options; +using ExchangeRateUpdater.Core.Providers; +using ExchangeRateUpdater.Core.Models; +using FluentAssertions; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Options; +using Xunit; + +namespace ExchangeRateUpdater.Core.Tests; + +public class ServiceConfigurationTests +{ + [Fact] + public void AddCore_BindsCurrencies_AndRegistersProvider() + { + // Arrange + var inMemory = new[] + { + new KeyValuePair("Currencies:0", "usd"), + new KeyValuePair("Currencies:1", " EUR ") + }; + var configuration = new ConfigurationBuilder().AddInMemoryCollection(inMemory).Build(); + var services = new ServiceCollection(); + + services.AddSingleton(Moq.Mock.Of()); + + // Act + services.AddCore(configuration); + var sp = services.BuildServiceProvider(); + + // Assert + sp.GetService().Should().NotBeNull(); + + var opts = sp.GetRequiredService>().Value; + opts.Currencies.Should().HaveCount(2); + opts.Currencies.Select(c => c.ToString()).Should().BeEquivalentTo(new[] { "usd", " EUR " }.Select(s => new Currency(s).ToString())); + } +} diff --git a/jobs/Backend/Task/ExchangeRateUpdater.Core/ApiVendors/IExchangeRateVendor.cs b/jobs/Backend/Task/ExchangeRateUpdater.Core/ApiVendors/IExchangeRateVendor.cs new file mode 100644 index 0000000000..cc95a1bf83 --- /dev/null +++ b/jobs/Backend/Task/ExchangeRateUpdater.Core/ApiVendors/IExchangeRateVendor.cs @@ -0,0 +1,8 @@ +using ExchangeRateUpdater.Core.Models; + +namespace ExchangeRateUpdater.Core.ApiVendors; + +public interface IExchangeRateVendor +{ + Task> GetExchangeRates(string baseCurrencyCode); +} \ No newline at end of file diff --git a/jobs/Backend/Task/ExchangeRateUpdater.Core/AppExceptions/VendorFailureException.cs b/jobs/Backend/Task/ExchangeRateUpdater.Core/AppExceptions/VendorFailureException.cs new file mode 100644 index 0000000000..bf9f10bbcc --- /dev/null +++ b/jobs/Backend/Task/ExchangeRateUpdater.Core/AppExceptions/VendorFailureException.cs @@ -0,0 +1,3 @@ +namespace ExchangeRateUpdater.Core.AppExceptions; + +public class VendorFailureException(string message) : Exception(message); \ No newline at end of file diff --git a/jobs/Backend/Task/ExchangeRateUpdater.Core/Configuration/Options/CurrencyOptions.cs b/jobs/Backend/Task/ExchangeRateUpdater.Core/Configuration/Options/CurrencyOptions.cs new file mode 100644 index 0000000000..e4da131e1f --- /dev/null +++ b/jobs/Backend/Task/ExchangeRateUpdater.Core/Configuration/Options/CurrencyOptions.cs @@ -0,0 +1,8 @@ +using ExchangeRateUpdater.Core.Models; + +namespace ExchangeRateUpdater.Core.Configuration.Options; + +public class CurrencyOptions +{ + public Currency[] Currencies { get; set; } = []; +} \ No newline at end of file diff --git a/jobs/Backend/Task/ExchangeRateUpdater.Core/Configuration/ServiceConfiguration.cs b/jobs/Backend/Task/ExchangeRateUpdater.Core/Configuration/ServiceConfiguration.cs new file mode 100644 index 0000000000..f4c429882b --- /dev/null +++ b/jobs/Backend/Task/ExchangeRateUpdater.Core/Configuration/ServiceConfiguration.cs @@ -0,0 +1,24 @@ +using ExchangeRateUpdater.Core.Configuration.Options; +using ExchangeRateUpdater.Core.Models; +using ExchangeRateUpdater.Core.Providers; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; + +namespace ExchangeRateUpdater.Core.Configuration; + +public static class ServiceConfiguration +{ + public static IServiceCollection AddCore(this IServiceCollection services, IConfiguration configuration) + { + services.AddMemoryCache(); + services.AddTransient(); + services.AddOptions().Configure(opts => + { + var currencies = configuration.GetSection("Currencies").Get() ?? []; + opts.Currencies = currencies.Select(s => new Currency(s)).ToArray(); + }) + .ValidateOnStart(); + + return services; + } +} \ No newline at end of file diff --git a/jobs/Backend/Task/ExchangeRateUpdater.Core/ExchangeRateUpdater.Core.csproj b/jobs/Backend/Task/ExchangeRateUpdater.Core/ExchangeRateUpdater.Core.csproj new file mode 100644 index 0000000000..9f3759cb9b --- /dev/null +++ b/jobs/Backend/Task/ExchangeRateUpdater.Core/ExchangeRateUpdater.Core.csproj @@ -0,0 +1,16 @@ + + + + net9.0 + enable + enable + + + + + + + + + + diff --git a/jobs/Backend/Task/ExchangeRateUpdater.Core/Models/Currency.cs b/jobs/Backend/Task/ExchangeRateUpdater.Core/Models/Currency.cs new file mode 100644 index 0000000000..9c4671c1bf --- /dev/null +++ b/jobs/Backend/Task/ExchangeRateUpdater.Core/Models/Currency.cs @@ -0,0 +1,16 @@ +namespace ExchangeRateUpdater.Core.Models; + +public class Currency( + string code + ) +{ + /// + /// Three-letter ISO 4217 code of the currency. + /// + private string Code { get; } = code; + + public override string ToString() + { + return Code; + } +} \ No newline at end of file diff --git a/jobs/Backend/Task/ExchangeRateUpdater.Core/Models/ExchangeRate.cs b/jobs/Backend/Task/ExchangeRateUpdater.Core/Models/ExchangeRate.cs new file mode 100644 index 0000000000..27da573ede --- /dev/null +++ b/jobs/Backend/Task/ExchangeRateUpdater.Core/Models/ExchangeRate.cs @@ -0,0 +1,19 @@ +namespace ExchangeRateUpdater.Core.Models; + + public class ExchangeRate( + Currency sourceCurrency, + Currency targetCurrency, + decimal value + ) + { + public Currency SourceCurrency { get; } = sourceCurrency; + + public Currency TargetCurrency { get; } = targetCurrency; + + public decimal Value { get; } = value; + + public override string ToString() + { + return $"{SourceCurrency}/{TargetCurrency}={Value}"; + } + } diff --git a/jobs/Backend/Task/ExchangeRateUpdater.Core/Providers/CzechNationalBankExchangeRateProvider.cs b/jobs/Backend/Task/ExchangeRateUpdater.Core/Providers/CzechNationalBankExchangeRateProvider.cs new file mode 100644 index 0000000000..30ab43a42c --- /dev/null +++ b/jobs/Backend/Task/ExchangeRateUpdater.Core/Providers/CzechNationalBankExchangeRateProvider.cs @@ -0,0 +1,31 @@ +using ExchangeRateUpdater.Core.ApiVendors; +using ExchangeRateUpdater.Core.Models; +using Microsoft.Extensions.Caching.Memory; + +namespace ExchangeRateUpdater.Core.Providers; + +public class CzechNationalBankExchangeRateProvider( + IExchangeRateVendor exchangeRateVendor, + IMemoryCache? cache = null + ) : IExchangeRateProvider +{ + private const string BaseCurrencyCode = "CZK"; + private const string CacheKey = $"ExchangeRates:{BaseCurrencyCode}"; + private readonly IMemoryCache _cache = cache ?? new MemoryCache(new MemoryCacheOptions()); + + public async Task> GetExchangeRates(IEnumerable currencies) + { + currencies = currencies.ToArray(); + if (!currencies.Any()) return []; + + var allRates = await _cache.GetOrCreateAsync(CacheKey, async entry => + { + entry.AbsoluteExpirationRelativeToNow = TimeSpan.FromMinutes(1); + return await exchangeRateVendor.GetExchangeRates(BaseCurrencyCode); + }) ?? []; + + return allRates + .Where(rate => currencies.Any(currency => currency.ToString() == rate.TargetCurrency.ToString())) + .ToList(); + } +} \ No newline at end of file diff --git a/jobs/Backend/Task/ExchangeRateProvider.cs b/jobs/Backend/Task/ExchangeRateUpdater.Core/Providers/ExchangeRateProvider.cs similarity index 64% rename from jobs/Backend/Task/ExchangeRateProvider.cs rename to jobs/Backend/Task/ExchangeRateUpdater.Core/Providers/ExchangeRateProvider.cs index 6f82a97fbe..58233f0974 100644 --- a/jobs/Backend/Task/ExchangeRateProvider.cs +++ b/jobs/Backend/Task/ExchangeRateUpdater.Core/Providers/ExchangeRateProvider.cs @@ -1,9 +1,9 @@ using System.Collections.Generic; using System.Linq; +using ExchangeRateUpdater.Core.Models; -namespace ExchangeRateUpdater -{ - public class ExchangeRateProvider +namespace ExchangeRateUpdater.Core.Providers; + public class ExchangeRateProvider : IExchangeRateProvider { /// /// Should return exchange rates among the specified currencies that are defined by the source. But only those defined @@ -11,9 +11,9 @@ public class ExchangeRateProvider /// 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) + public Task> GetExchangeRates(IEnumerable currencies) { - return Enumerable.Empty(); + return Task.FromResult(new List()); } } -} + diff --git a/jobs/Backend/Task/ExchangeRateUpdater.Core/Providers/IExchangeRateProvider.cs b/jobs/Backend/Task/ExchangeRateUpdater.Core/Providers/IExchangeRateProvider.cs new file mode 100644 index 0000000000..ae5e45c2da --- /dev/null +++ b/jobs/Backend/Task/ExchangeRateUpdater.Core/Providers/IExchangeRateProvider.cs @@ -0,0 +1,8 @@ +using ExchangeRateUpdater.Core.Models; + +namespace ExchangeRateUpdater.Core.Providers; + +public interface IExchangeRateProvider +{ + Task> GetExchangeRates(IEnumerable currencies); +} \ No newline at end of file diff --git a/jobs/Backend/Task/ExchangeRateUpdater.Infrastructure.Tests/CurrencyApiExchangeRateVendorTests.cs b/jobs/Backend/Task/ExchangeRateUpdater.Infrastructure.Tests/CurrencyApiExchangeRateVendorTests.cs new file mode 100644 index 0000000000..cc5ea51f36 --- /dev/null +++ b/jobs/Backend/Task/ExchangeRateUpdater.Infrastructure.Tests/CurrencyApiExchangeRateVendorTests.cs @@ -0,0 +1,87 @@ +using System; +using System.IO; +using System.Net; +using System.Net.Http; +using System.Threading.Tasks; +using ExchangeRateUpdater.Core.AppExceptions; +using ExchangeRateUpdater.Core.Models; +using ExchangeRateUpdater.Infrastructure.Dtos; +using ExchangeRateUpdater.Infrastructure.ExchangeRateVendors; +using FluentAssertions; +using Microsoft.Extensions.Logging.Abstractions; +using Newtonsoft.Json; +using RichardSzalay.MockHttp; +using Xunit; + +namespace ExchangeRateUpdater.Infrastructure.Tests; + +public class CurrencyApiExchangeRateVendorTests +{ + private static CurrencyApiExchangeRateVendor CreateSut(MockHttpMessageHandler mock, Uri baseUri, string apiKey) + { + var client = new HttpClient(mock) + { + BaseAddress = baseUri + }; + client.DefaultRequestHeaders.Add("apiKey", apiKey); + var logger = NullLogger.Instance; + return new CurrencyApiExchangeRateVendor(client, logger); + } + + [Fact] + public async Task GetExchangeRates_Success_ParsesResponse() + { + // Arrange + var baseUri = new Uri("https://api.currencyapi.com/v3/"); + + var jsonResponse = File.ReadAllText("Mocks/ExchangeRates.json"); + var mock = new MockHttpMessageHandler(); + mock.When(HttpMethod.Get, baseUri + "latest?base_currency=CZK") + .WithHeaders("apiKey", "TEST_KEY") + .Respond("application/json", jsonResponse); + + var sut = CreateSut(mock, baseUri, "TEST_KEY"); + + // Act + var result = await sut.GetExchangeRates("CZK"); + + // Assert + result.Should().HaveCount(2); + result.Should().Contain(r => r.SourceCurrency.ToString() == "CZK" && r.TargetCurrency.ToString() == "USD" && r.Value == 0.043m); + result.Should().Contain(r => r.SourceCurrency.ToString() == "CZK" && r.TargetCurrency.ToString() == "EUR" && r.Value == 0.039m); + } + + [Fact] + public async Task GetExchangeRates_NonSuccess_ReturnsEmpty() + { + // Arrange + var baseUri = new Uri("https://api.currencyapi.com/v3/"); + var mock = new MockHttpMessageHandler(); + mock.When(HttpMethod.Get, baseUri + "latest?base_currency=CZK") + .Respond(HttpStatusCode.InternalServerError); + var sut = CreateSut(mock, baseUri, "KEY"); + + // Act + var result = await sut.GetExchangeRates("CZK"); + + // Assert + result.Should().BeEmpty(); + } + + [Fact] + public async Task GetExchangeRates_HandlerThrows_ReturnsEmpty() + { + // Arrange + var baseUri = new Uri("https://api.currencyapi.com/v3/"); + var mock = new MockHttpMessageHandler(); + mock.When(HttpMethod.Get, baseUri + "latest?base_currency=CZK") + .Throw(new HttpRequestException("something happened")); + var sut = CreateSut(mock, baseUri, "KEY"); + + // Act + Action act = () => sut.GetExchangeRates("CZK").GetAwaiter().GetResult(); + + // Assert + act.Should().Throw().WithMessage("Could not retrieve exchange rates"); + } +} diff --git a/jobs/Backend/Task/ExchangeRateUpdater.Infrastructure.Tests/ExchangeRateUpdater.Infrastructure.Tests.csproj b/jobs/Backend/Task/ExchangeRateUpdater.Infrastructure.Tests/ExchangeRateUpdater.Infrastructure.Tests.csproj new file mode 100644 index 0000000000..495262c6a8 --- /dev/null +++ b/jobs/Backend/Task/ExchangeRateUpdater.Infrastructure.Tests/ExchangeRateUpdater.Infrastructure.Tests.csproj @@ -0,0 +1,29 @@ + + + net9.0 + false + true + enable + + + + + + + + + + + + + + + + + + + + Always + + + \ No newline at end of file diff --git a/jobs/Backend/Task/ExchangeRateUpdater.Infrastructure.Tests/Mocks/ExchangeRates.json b/jobs/Backend/Task/ExchangeRateUpdater.Infrastructure.Tests/Mocks/ExchangeRates.json new file mode 100644 index 0000000000..4395301f55 --- /dev/null +++ b/jobs/Backend/Task/ExchangeRateUpdater.Infrastructure.Tests/Mocks/ExchangeRates.json @@ -0,0 +1,6 @@ +{ + "data": { + "USD": { "code": "USD", "value": 0.043 }, + "EUR": { "code": "EUR", "value": 0.039 } + } +} \ No newline at end of file diff --git a/jobs/Backend/Task/ExchangeRateUpdater.Infrastructure.Tests/ServiceConfigurationTests.cs b/jobs/Backend/Task/ExchangeRateUpdater.Infrastructure.Tests/ServiceConfigurationTests.cs new file mode 100644 index 0000000000..aba64043d5 --- /dev/null +++ b/jobs/Backend/Task/ExchangeRateUpdater.Infrastructure.Tests/ServiceConfigurationTests.cs @@ -0,0 +1,60 @@ +using System; +using System.Collections.Generic; +using ExchangeRateUpdater.Core.ApiVendors; +using ExchangeRateUpdater.Infrastructure.Configuration; +using FluentAssertions; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Xunit; + +namespace ExchangeRateUpdater.Infrastructure.Tests; + +public class ServiceConfigurationTests +{ + [Fact] + public void AddInfrastructure_Throws_WhenBaseUrlMissing() + { + var settings = new[] + { + new KeyValuePair("CurrencyApi:ApiKey", "MY_KEY") + }; + var configuration = new ConfigurationBuilder().AddInMemoryCollection(settings).Build(); + var services = new ServiceCollection(); + + Action act = () => services.AddInfrastructure(configuration); + act.Should().Throw() + .WithMessage("*CurrencyApi:BaseUrl*"); + } + + [Fact] + public void AddInfrastructure_Throws_WhenApiKeyMissing() + { + var settings = new[] + { + new KeyValuePair("CurrencyApi:BaseUrl", "https://api.currencyapi.com/v3/") + }; + var configuration = new ConfigurationBuilder().AddInMemoryCollection(settings).Build(); + var services = new ServiceCollection(); + + Action act = () => services.AddInfrastructure(configuration); + act.Should().Throw() + .WithMessage("*CurrencyApi:ApiKey*"); + } + + [Fact] + public void AddInfrastructure_Resolves_TypedVendor() + { + var settings = new[] + { + new KeyValuePair("CurrencyApi:BaseUrl", "https://api.currencyapi.com/v3/"), + new KeyValuePair("CurrencyApi:ApiKey", "MY_KEY") + }; + var configuration = new ConfigurationBuilder().AddInMemoryCollection(settings).Build(); + var services = new ServiceCollection(); + + services.AddInfrastructure(configuration); + var sp = services.BuildServiceProvider(); + + sp.GetService().Should().NotBeNull(); + } +} diff --git a/jobs/Backend/Task/ExchangeRateUpdater.Infrastructure/Configuration/ServiceConfiguration.cs b/jobs/Backend/Task/ExchangeRateUpdater.Infrastructure/Configuration/ServiceConfiguration.cs new file mode 100644 index 0000000000..278cc49cd9 --- /dev/null +++ b/jobs/Backend/Task/ExchangeRateUpdater.Infrastructure/Configuration/ServiceConfiguration.cs @@ -0,0 +1,23 @@ +using ExchangeRateUpdater.Core.ApiVendors; +using ExchangeRateUpdater.Infrastructure.ExchangeRateVendors; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; + +namespace ExchangeRateUpdater.Infrastructure.Configuration; + +public static class ServiceConfiguration +{ + public static IServiceCollection AddInfrastructure(this IServiceCollection services, IConfiguration configuration) + { + string baseAddress = configuration["CurrencyApi:BaseUrl"] ?? throw new ArgumentException("CurrencyApi:BaseUrl is not set in configuration"); + string apiKey = configuration["CurrencyApi:ApiKey"] ?? throw new ArgumentException("CurrencyApi:ApiKey is not set in configuration"); + + services.AddHttpClient(httpClient => + { + httpClient.BaseAddress = new Uri(baseAddress); + httpClient.DefaultRequestHeaders.Add("apiKey", !string.IsNullOrWhiteSpace(apiKey) ? apiKey : throw new ArgumentException("CurrencyApi:ApiKey is not set in configuration")); + }); + + return services; + } +} \ No newline at end of file diff --git a/jobs/Backend/Task/ExchangeRateUpdater.Infrastructure/Dtos/CurrencyApiErrorResponse.cs b/jobs/Backend/Task/ExchangeRateUpdater.Infrastructure/Dtos/CurrencyApiErrorResponse.cs new file mode 100644 index 0000000000..9a15806dde --- /dev/null +++ b/jobs/Backend/Task/ExchangeRateUpdater.Infrastructure/Dtos/CurrencyApiErrorResponse.cs @@ -0,0 +1,7 @@ +namespace ExchangeRateUpdater.Infrastructure.Dtos; + +public class CurrencyApiErrorResponse +{ + public string Message { get; set; } + public string Errors { get; set; } +} \ No newline at end of file diff --git a/jobs/Backend/Task/ExchangeRateUpdater.Infrastructure/Dtos/CurrencyApiResponse.cs b/jobs/Backend/Task/ExchangeRateUpdater.Infrastructure/Dtos/CurrencyApiResponse.cs new file mode 100644 index 0000000000..9712421308 --- /dev/null +++ b/jobs/Backend/Task/ExchangeRateUpdater.Infrastructure/Dtos/CurrencyApiResponse.cs @@ -0,0 +1,18 @@ +using System.Text.Json.Serialization; + +namespace ExchangeRateUpdater.Infrastructure.Dtos; + +public record CurrencyApiResponse() +{ + [JsonPropertyName("data")] + public Dictionary Data { get; set; } +}; + +public record CurrencyApiRate +{ + [JsonPropertyName("code")] + public string Code { get; init; } = string.Empty; + + [JsonPropertyName("value")] + public decimal Value { get; init; } +} \ No newline at end of file diff --git a/jobs/Backend/Task/ExchangeRateUpdater.Infrastructure/ExchangeRateUpdater.Infrastructure.csproj b/jobs/Backend/Task/ExchangeRateUpdater.Infrastructure/ExchangeRateUpdater.Infrastructure.csproj new file mode 100644 index 0000000000..a624d10dd3 --- /dev/null +++ b/jobs/Backend/Task/ExchangeRateUpdater.Infrastructure/ExchangeRateUpdater.Infrastructure.csproj @@ -0,0 +1,18 @@ + + + + net9.0 + enable + enable + + + + + + + + + + + + diff --git a/jobs/Backend/Task/ExchangeRateUpdater.Infrastructure/ExchangeRateVendors/CurrencyApiExchangeRateVendor.cs b/jobs/Backend/Task/ExchangeRateUpdater.Infrastructure/ExchangeRateVendors/CurrencyApiExchangeRateVendor.cs new file mode 100644 index 0000000000..5328509883 --- /dev/null +++ b/jobs/Backend/Task/ExchangeRateUpdater.Infrastructure/ExchangeRateVendors/CurrencyApiExchangeRateVendor.cs @@ -0,0 +1,44 @@ +using System.Text.Json; +using ExchangeRateUpdater.Core.ApiVendors; +using ExchangeRateUpdater.Core.AppExceptions; +using ExchangeRateUpdater.Core.Models; +using ExchangeRateUpdater.Infrastructure.Dtos; +using Microsoft.Extensions.Logging; + +namespace ExchangeRateUpdater.Infrastructure.ExchangeRateVendors; + +public class CurrencyApiExchangeRateVendor( + HttpClient httpClient, + ILogger logger + ) : IExchangeRateVendor +{ + public async Task> GetExchangeRates(string baseCurrencyCode) + { + try + { + logger.LogInformation("Retrieving exchange rates for base currency '{BaseCurrencyCode}'" , baseCurrencyCode); + + string requestUri = $"latest?base_currency={baseCurrencyCode}"; + var result = await httpClient.GetAsync(requestUri); + + if (!result.IsSuccessStatusCode) + { + var reasonPhrase = await result.Content.ReadAsStringAsync(); + logger.LogError("Could not retrieve exchange rates: '{StatusCode}', Reason: '{ReasonPhrase}'.", result.StatusCode, reasonPhrase); + return []; + } + + logger.LogInformation("Successfully retrieved exchange rates."); + var jsonString = await result.Content.ReadAsStringAsync(); + + var exchangeRates = JsonSerializer.Deserialize(jsonString); + + return exchangeRates == null ? [] : exchangeRates.Data.Select(rate => new ExchangeRate(new Currency(baseCurrencyCode), new Currency(rate.Key), rate.Value.Value)).ToList(); + } + catch (Exception e) + { + logger.LogError(e, "Could not retrieve exchange rates: '{EMessage}'.", e.Message); + throw new VendorFailureException("Could not retrieve exchange rates"); + } + } +} \ 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 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..3869c99b2e 100644 --- a/jobs/Backend/Task/ExchangeRateUpdater.sln +++ b/jobs/Backend/Task/ExchangeRateUpdater.sln @@ -3,7 +3,17 @@ 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.Core", "ExchangeRateUpdater.Core\ExchangeRateUpdater.Core.csproj", "{F089DF4F-40EF-465F-96AA-BA2DD075FBC3}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ExchangeRateUpdater.Infrastructure", "ExchangeRateUpdater.Infrastructure\ExchangeRateUpdater.Infrastructure.csproj", "{F8F9AAE8-763A-41C4-A6CA-3B1F86ED30E6}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ExchangeRateUpdater", "ExchangeRateUpdater\ExchangeRateUpdater.csproj", "{5C09772B-C470-4D42-8EE8-E016B574454F}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ExchangeRateUpdater.Core.Tests", "ExchangeRateUpdater.Core.Tests\ExchangeRateUpdater.Core.Tests.csproj", "{41760885-F21C-4928-A490-C8464A9A815F}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ExchangeRateUpdater.Infrastructure.Tests", "ExchangeRateUpdater.Infrastructure.Tests\ExchangeRateUpdater.Infrastructure.Tests.csproj", "{35F75B67-93C1-4B05-8803-611A1EDAD49A}" +EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Tests", "Tests", "{EE6A45A1-223B-4647-8578-61ECFBEB2634}" EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution @@ -11,12 +21,32 @@ Global 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 + {F089DF4F-40EF-465F-96AA-BA2DD075FBC3}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {F089DF4F-40EF-465F-96AA-BA2DD075FBC3}.Debug|Any CPU.Build.0 = Debug|Any CPU + {F089DF4F-40EF-465F-96AA-BA2DD075FBC3}.Release|Any CPU.ActiveCfg = Release|Any CPU + {F089DF4F-40EF-465F-96AA-BA2DD075FBC3}.Release|Any CPU.Build.0 = Release|Any CPU + {F8F9AAE8-763A-41C4-A6CA-3B1F86ED30E6}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {F8F9AAE8-763A-41C4-A6CA-3B1F86ED30E6}.Debug|Any CPU.Build.0 = Debug|Any CPU + {F8F9AAE8-763A-41C4-A6CA-3B1F86ED30E6}.Release|Any CPU.ActiveCfg = Release|Any CPU + {F8F9AAE8-763A-41C4-A6CA-3B1F86ED30E6}.Release|Any CPU.Build.0 = Release|Any CPU + {5C09772B-C470-4D42-8EE8-E016B574454F}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {5C09772B-C470-4D42-8EE8-E016B574454F}.Debug|Any CPU.Build.0 = Debug|Any CPU + {5C09772B-C470-4D42-8EE8-E016B574454F}.Release|Any CPU.ActiveCfg = Release|Any CPU + {5C09772B-C470-4D42-8EE8-E016B574454F}.Release|Any CPU.Build.0 = Release|Any CPU + {41760885-F21C-4928-A490-C8464A9A815F}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {41760885-F21C-4928-A490-C8464A9A815F}.Debug|Any CPU.Build.0 = Debug|Any CPU + {41760885-F21C-4928-A490-C8464A9A815F}.Release|Any CPU.ActiveCfg = Release|Any CPU + {41760885-F21C-4928-A490-C8464A9A815F}.Release|Any CPU.Build.0 = Release|Any CPU + {35F75B67-93C1-4B05-8803-611A1EDAD49A}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {35F75B67-93C1-4B05-8803-611A1EDAD49A}.Debug|Any CPU.Build.0 = Debug|Any CPU + {35F75B67-93C1-4B05-8803-611A1EDAD49A}.Release|Any CPU.ActiveCfg = Release|Any CPU + {35F75B67-93C1-4B05-8803-611A1EDAD49A}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE EndGlobalSection + GlobalSection(NestedProjects) = preSolution + {41760885-F21C-4928-A490-C8464A9A815F} = {EE6A45A1-223B-4647-8578-61ECFBEB2634} + {35F75B67-93C1-4B05-8803-611A1EDAD49A} = {EE6A45A1-223B-4647-8578-61ECFBEB2634} + EndGlobalSection EndGlobal diff --git a/jobs/Backend/Task/ExchangeRateUpdater/ExchangeRateUpdater.csproj b/jobs/Backend/Task/ExchangeRateUpdater/ExchangeRateUpdater.csproj new file mode 100644 index 0000000000..c9307aa07f --- /dev/null +++ b/jobs/Backend/Task/ExchangeRateUpdater/ExchangeRateUpdater.csproj @@ -0,0 +1,25 @@ + + + + Exe + net9.0 + ac453ce2-0607-40b2-9164-c6f948d9facd + + + + + + + + + + + + + + + Always + + + + \ No newline at end of file diff --git a/jobs/Backend/Task/ExchangeRateUpdater/Program.cs b/jobs/Backend/Task/ExchangeRateUpdater/Program.cs new file mode 100644 index 0000000000..c5fb4aa4a2 --- /dev/null +++ b/jobs/Backend/Task/ExchangeRateUpdater/Program.cs @@ -0,0 +1,27 @@ +using ExchangeRateUpdater.Core.Configuration; +using ExchangeRateUpdater.Infrastructure.Configuration; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.DependencyInjection; + +var host = Host.CreateDefaultBuilder(args) + .ConfigureAppConfiguration((context, config) => + { + if (context.HostingEnvironment.IsDevelopment()) + { + config.AddEnvironmentVariables() + .AddJsonFile("appsettings.json") + .AddUserSecrets(); + } + }) + .ConfigureServices((context, services) => + { + services.AddLogging(); + services.AddCore(context.Configuration); + services.AddInfrastructure(context.Configuration); + services.AddHostedService(); + + }) + .Build(); + +await host.RunAsync(); \ No newline at end of file diff --git a/jobs/Backend/Task/ExchangeRateUpdater/Properties/launchSettings.json b/jobs/Backend/Task/ExchangeRateUpdater/Properties/launchSettings.json new file mode 100644 index 0000000000..de0e3b85a9 --- /dev/null +++ b/jobs/Backend/Task/ExchangeRateUpdater/Properties/launchSettings.json @@ -0,0 +1,12 @@ +{ + "$schema": "https://json.schemastore.org/launchsettings.json", + "profiles": { + "ExchangeRateUpdater": { + "commandName": "Project", + "dotnetRunMessages": true, + "environmentVariables": { + "DOTNET_ENVIRONMENT": "Development" + } + } + } +} diff --git a/jobs/Backend/Task/ExchangeRateUpdater/Startup/ExchangeRateStartupService.cs b/jobs/Backend/Task/ExchangeRateUpdater/Startup/ExchangeRateStartupService.cs new file mode 100644 index 0000000000..b4bb47c561 --- /dev/null +++ b/jobs/Backend/Task/ExchangeRateUpdater/Startup/ExchangeRateStartupService.cs @@ -0,0 +1,54 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using ExchangeRateUpdater.Core.Configuration.Options; +using ExchangeRateUpdater.Core.Models; +using ExchangeRateUpdater.Core.Providers; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Options; + +namespace ExchangeRateUpdater.Startup; + +public class ExchangeRateStartupService( + IExchangeRateProvider provider, + IOptions options + ) : BackgroundService +{ + private static readonly IEnumerable DefaultCurrencies = new[] + { + new Currency("USD"), + new Currency("EUR"), + new Currency("CZK"), + new Currency("JPY"), + new Currency("KES"), + new Currency("RUB"), + new Currency("THB"), + new Currency("TRY"), + new Currency("XYZ") + }; + + private readonly IEnumerable _currencies = !options.Value.Currencies.Any() ? DefaultCurrencies : options.Value.Currencies; + + + protected override async Task ExecuteAsync(CancellationToken stoppingToken) + { + try + { + var rates = await 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(); + } +} \ 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 0000000000..f46d1a4b1d --- /dev/null +++ b/jobs/Backend/Task/ExchangeRateUpdater/appsettings.json @@ -0,0 +1,18 @@ +{ + "Currencies": [ + "USD", + "EUR", + "CZK", + "JPY", + "KES", + "RUB", + "THB", + "TRY", + "XYZ", + "NGN" + ], + "CurrencyApi": { + "BaseUrl": "https://api.currencyapi.com/v3/", + "ApiKey": "" + } +} 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/Readme.md b/jobs/Backend/Task/Readme.md new file mode 100644 index 0000000000..2af5182040 --- /dev/null +++ b/jobs/Backend/Task/Readme.md @@ -0,0 +1,24 @@ +# Quick Setup + +Go to the [Currency API](https://app.currencyapi.com/api-keys) and get an API key. You will need to register for an account and get free calls. + +- Enable User Secrets on the app project (from the project folder): + - `cd ExchangeRateUpdater` + - `dotnet user-secrets init` + - `dotnet user-secrets set "CurrencyApi:ApiKey" ""` + +- Secrets/config shape (add via User Secrets): + ```json + { + "CurrencyApi": { + "ApiKey": "", + "BaseUrl": "https://api.currencyapi.com/v3/" + }, + "Currencies": ["USD", "GBP", "CZK"] + } + ``` + +- You can configure currencies: change `Currencies` for which you want to get the exchange rates against the base currency CZK + + +The App is a Hosted Service that runs once and outputs the exchange rates to the console.