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/ExchangeRateProvider.cs b/jobs/Backend/Task/ExchangeRateProvider.cs deleted file mode 100644 index 6f82a97fbe..0000000000 --- a/jobs/Backend/Task/ExchangeRateProvider.cs +++ /dev/null @@ -1,19 +0,0 @@ -using System.Collections.Generic; -using System.Linq; - -namespace ExchangeRateUpdater -{ - public class ExchangeRateProvider - { - /// - /// Should return exchange rates among the specified currencies that are defined by the source. But only those defined - /// by the source, do not return calculated exchange rates. E.g. if the source contains "CZK/USD" but not "USD/CZK", - /// do not return exchange rate "USD/CZK" with value calculated as 1 / "CZK/USD". If the source does not provide - /// some of the currencies, ignore them. - /// - public IEnumerable GetExchangeRates(IEnumerable currencies) - { - return Enumerable.Empty(); - } - } -} diff --git a/jobs/Backend/Task/ExchangeRateUpdater.Api/Endpoints/ExchangeRateEndpoints.cs b/jobs/Backend/Task/ExchangeRateUpdater.Api/Endpoints/ExchangeRateEndpoints.cs new file mode 100644 index 0000000000..6075387016 --- /dev/null +++ b/jobs/Backend/Task/ExchangeRateUpdater.Api/Endpoints/ExchangeRateEndpoints.cs @@ -0,0 +1,30 @@ +using ExchangeRateUpdater.Application.Interfaces; +using ExchangeRateUpdater.Application.Validation; + +namespace ExchangeRateUpdater.Api.Endpoints; + +public static class ExchangeRateEndpoints +{ + public static void MapExchangeRatesEndpoints(this WebApplication app) + { + app.MapGet("/exchange-rates", async ( + string[] currencies, + IExchangeRateProvider exchangeRateProvider, + CancellationToken cancellationToken) => + { + var validator = new ExchangeRatesRequestValidator(); + var validationResult = await validator.ValidateAsync(currencies, cancellationToken); + if (!validationResult.IsValid) + { + return Results.BadRequest(validationResult.Errors.Select(e => e.ErrorMessage)); + } + + var result = await exchangeRateProvider.GetExchangeRates(currencies, cancellationToken); + + return Results.Ok(result); + }) + .WithName("GetExchangeRates") + .WithDescription("Gets exchange rates for provided currencies to CZK from Czech National Bank") + .WithTags("Exchange Rates"); + } +} \ No newline at end of file diff --git a/jobs/Backend/Task/ExchangeRateUpdater.Api/ExchangeRateUpdater.Api.csproj b/jobs/Backend/Task/ExchangeRateUpdater.Api/ExchangeRateUpdater.Api.csproj new file mode 100644 index 0000000000..8acd98d614 --- /dev/null +++ b/jobs/Backend/Task/ExchangeRateUpdater.Api/ExchangeRateUpdater.Api.csproj @@ -0,0 +1,20 @@ + + + + net10.0 + enable + enable + + + + + + + + + + + + + + diff --git a/jobs/Backend/Task/ExchangeRateUpdater.Api/ExchangeRateUpdater.Api.http b/jobs/Backend/Task/ExchangeRateUpdater.Api/ExchangeRateUpdater.Api.http new file mode 100644 index 0000000000..a736895ede --- /dev/null +++ b/jobs/Backend/Task/ExchangeRateUpdater.Api/ExchangeRateUpdater.Api.http @@ -0,0 +1,5 @@ +@HostAddress = https://localhost:7232 + +### Get exchange rates for EUR and USD +GET {{HostAddress}}/exchange-rates?currencies=eur¤cies=usd +Accept: application/json diff --git a/jobs/Backend/Task/ExchangeRateUpdater.Api/Program.cs b/jobs/Backend/Task/ExchangeRateUpdater.Api/Program.cs new file mode 100644 index 0000000000..26c07018d8 --- /dev/null +++ b/jobs/Backend/Task/ExchangeRateUpdater.Api/Program.cs @@ -0,0 +1,40 @@ +using ExchangeRateUpdater.Api.Endpoints; +using ExchangeRateUpdater.Application.Configuration; +using ExchangeRateUpdater.Application.Interfaces; +using ExchangeRateUpdater.Application.Services; +using ExchangeRateUpdater.Infrastructure.HttpClients; +using Microsoft.Extensions.Options; + +var builder = WebApplication.CreateBuilder(args); + +builder.Services.AddEndpointsApiExplorer(); +builder.Services.AddSwaggerGen(); + +builder.Services.AddScoped(); + +builder.Services.Configure( + builder.Configuration.GetSection("ExchangeRateConfig")); + +builder.Services.AddSingleton(sp => + sp.GetRequiredService>().Value); + +builder.Services.AddHttpClient((sp, client) => +{ + var config = sp.GetRequiredService>().Value; + client.BaseAddress = new Uri(config.BaseUrl); + client.Timeout = TimeSpan.FromSeconds(config.Timeout); +}); + +var app = builder.Build(); + +if (app.Environment.IsDevelopment()) +{ + app.UseSwagger(); + app.UseSwaggerUI(); +} + +app.UseHttpsRedirection(); + +app.MapExchangeRatesEndpoints(); + +app.Run(); \ No newline at end of file diff --git a/jobs/Backend/Task/ExchangeRateUpdater.Api/Properties/launchSettings.json b/jobs/Backend/Task/ExchangeRateUpdater.Api/Properties/launchSettings.json new file mode 100644 index 0000000000..dd2fc1ba0d --- /dev/null +++ b/jobs/Backend/Task/ExchangeRateUpdater.Api/Properties/launchSettings.json @@ -0,0 +1,15 @@ +{ + "$schema": "https://json.schemastore.org/launchsettings.json", + "profiles": { + "https": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": true, + "launchUrl": "swagger/index.html", + "applicationUrl": "https://localhost:7232;http://localhost:5028", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + } + } +} diff --git a/jobs/Backend/Task/ExchangeRateUpdater.Api/appsettings.Development.json b/jobs/Backend/Task/ExchangeRateUpdater.Api/appsettings.Development.json new file mode 100644 index 0000000000..0c208ae918 --- /dev/null +++ b/jobs/Backend/Task/ExchangeRateUpdater.Api/appsettings.Development.json @@ -0,0 +1,8 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + } +} diff --git a/jobs/Backend/Task/ExchangeRateUpdater.Api/appsettings.json b/jobs/Backend/Task/ExchangeRateUpdater.Api/appsettings.json new file mode 100644 index 0000000000..c241514622 --- /dev/null +++ b/jobs/Backend/Task/ExchangeRateUpdater.Api/appsettings.json @@ -0,0 +1,15 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + }, + "AllowedHosts": "*", + "ExchangeRateConfig": { + "DefaultCurrency": "CZK", + "BaseUrl": "https://api.cnb.cz/cnbapi/", + "Timeout": 30, + "RatePrecision": 3 + } +} diff --git a/jobs/Backend/Task/ExchangeRateUpdater.Application/Configuration/ExchangeRateConfig.cs b/jobs/Backend/Task/ExchangeRateUpdater.Application/Configuration/ExchangeRateConfig.cs new file mode 100644 index 0000000000..60171ac806 --- /dev/null +++ b/jobs/Backend/Task/ExchangeRateUpdater.Application/Configuration/ExchangeRateConfig.cs @@ -0,0 +1,12 @@ +namespace ExchangeRateUpdater.Application.Configuration; + +public sealed class ExchangeRateConfig +{ + public string BaseUrl { get; set; } + + public string DefaultCurrency { get; set; } + + public int Timeout { get; set; } + + public int RatePrecision { get; set; } +} \ No newline at end of file diff --git a/jobs/Backend/Task/ExchangeRateUpdater.Application/ExchangeRateUpdater.Application.csproj b/jobs/Backend/Task/ExchangeRateUpdater.Application/ExchangeRateUpdater.Application.csproj new file mode 100644 index 0000000000..005ede6cfb --- /dev/null +++ b/jobs/Backend/Task/ExchangeRateUpdater.Application/ExchangeRateUpdater.Application.csproj @@ -0,0 +1,22 @@ + + + + net10.0 + enable + enable + + + + + ..\..\..\..\..\..\..\..\Program Files\dotnet\shared\Microsoft.AspNetCore.App\10.0.2\Microsoft.Extensions.Logging.Abstractions.dll + + + ..\..\..\..\..\..\..\..\Program Files\dotnet\shared\Microsoft.AspNetCore.App\10.0.2\Microsoft.Extensions.Options.dll + + + + + + + + diff --git a/jobs/Backend/Task/ExchangeRateUpdater.Application/Interfaces/IExchangeRateApiClient.cs b/jobs/Backend/Task/ExchangeRateUpdater.Application/Interfaces/IExchangeRateApiClient.cs new file mode 100644 index 0000000000..b1803bf404 --- /dev/null +++ b/jobs/Backend/Task/ExchangeRateUpdater.Application/Interfaces/IExchangeRateApiClient.cs @@ -0,0 +1,8 @@ +using ExchangeRateUpdater.Application.Models; + +namespace ExchangeRateUpdater.Application.Interfaces; + +public interface IExchangeRateApiClient +{ + Task> GetAllExchangeRatesAsync(CancellationToken cancellationToken); +} \ No newline at end of file diff --git a/jobs/Backend/Task/ExchangeRateUpdater.Application/Interfaces/IExchangeRateProvider.cs b/jobs/Backend/Task/ExchangeRateUpdater.Application/Interfaces/IExchangeRateProvider.cs new file mode 100644 index 0000000000..6ac3ee2b54 --- /dev/null +++ b/jobs/Backend/Task/ExchangeRateUpdater.Application/Interfaces/IExchangeRateProvider.cs @@ -0,0 +1,8 @@ +using ExchangeRateUpdater.Application.Models; + +namespace ExchangeRateUpdater.Application.Interfaces; + +public interface IExchangeRateProvider +{ + Task> GetExchangeRates(string[] currencies, CancellationToken cancellationToken); +} \ No newline at end of file diff --git a/jobs/Backend/Task/ExchangeRateUpdater.Application/Models/CnbExchangeRate.cs b/jobs/Backend/Task/ExchangeRateUpdater.Application/Models/CnbExchangeRate.cs new file mode 100644 index 0000000000..ae4468cbce --- /dev/null +++ b/jobs/Backend/Task/ExchangeRateUpdater.Application/Models/CnbExchangeRate.cs @@ -0,0 +1,3 @@ +namespace ExchangeRateUpdater.Application.Models; + +public sealed record CnbExchangeRate(string CurrencyCode, decimal Rate, int Amount); \ No newline at end of file diff --git a/jobs/Backend/Task/ExchangeRateUpdater.Application/Models/CnbExchangeRateResponse.cs b/jobs/Backend/Task/ExchangeRateUpdater.Application/Models/CnbExchangeRateResponse.cs new file mode 100644 index 0000000000..2dd7e7760f --- /dev/null +++ b/jobs/Backend/Task/ExchangeRateUpdater.Application/Models/CnbExchangeRateResponse.cs @@ -0,0 +1,3 @@ +namespace ExchangeRateUpdater.Application.Models; + +public sealed record CnbExchangeRateResponse(CnbExchangeRate[] Rates); \ No newline at end of file diff --git a/jobs/Backend/Task/ExchangeRateUpdater.Application/Models/ExchangeRate.cs b/jobs/Backend/Task/ExchangeRateUpdater.Application/Models/ExchangeRate.cs new file mode 100644 index 0000000000..6f3f5a7f73 --- /dev/null +++ b/jobs/Backend/Task/ExchangeRateUpdater.Application/Models/ExchangeRate.cs @@ -0,0 +1,3 @@ +namespace ExchangeRateUpdater.Application.Models; + +public sealed record ExchangeRate(string SourceCurrency, string TargetCurrency, decimal Rate); \ No newline at end of file diff --git a/jobs/Backend/Task/ExchangeRateUpdater.Application/Services/ExchangeRateProvider.cs b/jobs/Backend/Task/ExchangeRateUpdater.Application/Services/ExchangeRateProvider.cs new file mode 100644 index 0000000000..bd018d047b --- /dev/null +++ b/jobs/Backend/Task/ExchangeRateUpdater.Application/Services/ExchangeRateProvider.cs @@ -0,0 +1,51 @@ +using ExchangeRateUpdater.Application.Configuration; +using ExchangeRateUpdater.Application.Interfaces; +using ExchangeRateUpdater.Application.Models; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; + +namespace ExchangeRateUpdater.Application.Services; + +public sealed class ExchangeRateProvider : IExchangeRateProvider +{ + private readonly IExchangeRateApiClient _apiClient; + private readonly ExchangeRateConfig _exchangeRateConfig; + private readonly ILogger _logger; + + public ExchangeRateProvider( + IExchangeRateApiClient apiClient, + IOptions exchangeRateConfig, + ILogger logger) + { + _apiClient = apiClient; + _exchangeRateConfig = exchangeRateConfig.Value; + _logger = logger; + } + + public async Task> GetExchangeRates(string[] currencies, CancellationToken cancellationToken) + { + var cnbExchangeRates = await _apiClient.GetAllExchangeRatesAsync(cancellationToken); + return MapExchangeRates(cnbExchangeRates, currencies).ToList(); + } + + private IEnumerable MapExchangeRates( + IEnumerable cnbExchangeRates, + string[] currencyCodes) + { + var rateByCurrencyCode = + cnbExchangeRates.ToDictionary(x => x.CurrencyCode, StringComparer.OrdinalIgnoreCase); + foreach (var currencyCode in currencyCodes) + { + if (!rateByCurrencyCode.TryGetValue(currencyCode, out var exchangeRate)) + { + _logger.LogWarning("No matches were found among CNB rates for currency '{@currencyCode}'", currencyCode); + continue; + } + + yield return new( + exchangeRate.CurrencyCode, + _exchangeRateConfig.DefaultCurrency, + Math.Round(exchangeRate.Rate / exchangeRate.Amount, _exchangeRateConfig.RatePrecision)); + } + } +} \ No newline at end of file diff --git a/jobs/Backend/Task/ExchangeRateUpdater.Application/Validation/ExchangeRatesRequestValidator.cs b/jobs/Backend/Task/ExchangeRateUpdater.Application/Validation/ExchangeRatesRequestValidator.cs new file mode 100644 index 0000000000..c3f4fc0559 --- /dev/null +++ b/jobs/Backend/Task/ExchangeRateUpdater.Application/Validation/ExchangeRatesRequestValidator.cs @@ -0,0 +1,19 @@ +using FluentValidation; + +namespace ExchangeRateUpdater.Application.Validation; + +public sealed class ExchangeRatesRequestValidator : AbstractValidator +{ + public ExchangeRatesRequestValidator() + { + RuleFor(x => x) + .NotEmpty() + .WithMessage("No currencies were provided as input"); + + RuleForEach(x => x) + .NotEmpty() + .WithMessage("Currency must not be empty") + .Matches("^[A-Za-z]{3}$") + .WithMessage("Each currency must be a 3-letter code. Example: USD"); + } +} \ 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..353cfa7909 --- /dev/null +++ b/jobs/Backend/Task/ExchangeRateUpdater.Infrastructure/ExchangeRateUpdater.Infrastructure.csproj @@ -0,0 +1,25 @@ + + + + net10.0 + enable + enable + + + + + + + + + ..\..\..\..\..\..\..\..\Program Files\dotnet\shared\Microsoft.AspNetCore.App\10.0.2\Microsoft.Extensions.DependencyInjection.Abstractions.dll + + + ..\..\..\..\..\..\..\..\Program Files\dotnet\shared\Microsoft.AspNetCore.App\10.0.2\Microsoft.Extensions.Logging.Abstractions.dll + + + ..\..\..\..\..\..\..\..\Program Files\dotnet\shared\Microsoft.AspNetCore.App\10.0.2\Microsoft.Extensions.Options.dll + + + + diff --git a/jobs/Backend/Task/ExchangeRateUpdater.Infrastructure/HttpClients/ExchangeRateApiClient.cs b/jobs/Backend/Task/ExchangeRateUpdater.Infrastructure/HttpClients/ExchangeRateApiClient.cs new file mode 100644 index 0000000000..74528c40cb --- /dev/null +++ b/jobs/Backend/Task/ExchangeRateUpdater.Infrastructure/HttpClients/ExchangeRateApiClient.cs @@ -0,0 +1,38 @@ +using System.Net.Http.Json; +using ExchangeRateUpdater.Application.Interfaces; +using ExchangeRateUpdater.Application.Models; +using Microsoft.Extensions.Logging; + +namespace ExchangeRateUpdater.Infrastructure.HttpClients; + +public sealed class ExchangeRateApiClient : IExchangeRateApiClient +{ + private readonly HttpClient _httpClient; + private readonly ILogger _logger; + + public ExchangeRateApiClient(HttpClient httpClient, ILogger logger) + { + _httpClient = httpClient; + _logger = logger; + } + + public async Task> GetAllExchangeRatesAsync( + CancellationToken cancellationToken) + { + try + { + var response = await _httpClient.GetFromJsonAsync("exrates/daily", cancellationToken); + if (response == null || response.Rates.Length == 0) + { + _logger.LogWarning("No exchange rates were provided by CNB"); + return []; + } + return response.Rates; + } + catch (HttpRequestException ex) + { + _logger.LogError("Http request to CNB has failed with exception {@exception}", ex); + throw; + } + } +} \ No newline at end of file diff --git a/jobs/Backend/Task/ExchangeRateUpdater.IntegrationTests/CustomWebApplicationFactory.cs b/jobs/Backend/Task/ExchangeRateUpdater.IntegrationTests/CustomWebApplicationFactory.cs new file mode 100644 index 0000000000..1a38b0de6f --- /dev/null +++ b/jobs/Backend/Task/ExchangeRateUpdater.IntegrationTests/CustomWebApplicationFactory.cs @@ -0,0 +1,32 @@ +using ExchangeRateUpdater.Application.Interfaces; +using ExchangeRateUpdater.Application.Models; +using Microsoft.AspNetCore.Hosting; +using Microsoft.AspNetCore.Mvc.Testing; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.DependencyInjection.Extensions; +using Moq; + +namespace ExchangeRateUpdater.IntegrationTests; + +public sealed class CustomWebApplicationFactory : WebApplicationFactory +{ + public Mock ApiClient; + + protected override void ConfigureWebHost(IWebHostBuilder builder) + { + builder.ConfigureServices(services => + { + services.RemoveAll(); + + ApiClient = new Mock(); + ApiClient.Setup(x => x.GetAllExchangeRatesAsync(It.IsAny())) + .ReturnsAsync(new List() + { + new("EUR", 20, 1), + new("USD", 30, 10) + }); + + services.AddSingleton(ApiClient.Object); + }); + } +} \ No newline at end of file diff --git a/jobs/Backend/Task/ExchangeRateUpdater.IntegrationTests/ExchangeRateUpdater.IntegrationTests.csproj b/jobs/Backend/Task/ExchangeRateUpdater.IntegrationTests/ExchangeRateUpdater.IntegrationTests.csproj new file mode 100644 index 0000000000..8006c64670 --- /dev/null +++ b/jobs/Backend/Task/ExchangeRateUpdater.IntegrationTests/ExchangeRateUpdater.IntegrationTests.csproj @@ -0,0 +1,25 @@ + + + + net10.0 + enable + enable + + + + + + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + + + + diff --git a/jobs/Backend/Task/ExchangeRateUpdater.IntegrationTests/GetExchangeRatesTests.cs b/jobs/Backend/Task/ExchangeRateUpdater.IntegrationTests/GetExchangeRatesTests.cs new file mode 100644 index 0000000000..25195ab51a --- /dev/null +++ b/jobs/Backend/Task/ExchangeRateUpdater.IntegrationTests/GetExchangeRatesTests.cs @@ -0,0 +1,63 @@ +using System.Net; +using System.Net.Http.Json; +using ExchangeRateUpdater.Application.Models; +using Microsoft.AspNetCore.WebUtilities; +using Xunit; + +namespace ExchangeRateUpdater.IntegrationTests; + +public sealed class GetExchangeRatesTests : IClassFixture +{ + private readonly HttpClient _client; + + public GetExchangeRatesTests(CustomWebApplicationFactory factory) + { + _client = factory.CreateClient(); + } + + [Fact] + public async Task GetExchangeRates_WhenValidCurrenciesProvided_SuccessReturns200() + { + // Act + var url = QueryHelpers.AddQueryString( + "/exchange-rates", + [ + new KeyValuePair("currencies", "usd"), + new KeyValuePair("currencies", "eur") + ]); + + var response = await _client.GetAsync(url); + + // Assert + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + + var result = await response.Content.ReadFromJsonAsync>(); + Assert.Equal([new("USD", "CZK", 3), new("EUR", "CZK", 20)], result); + } + + [Fact] + public async Task GetExchangeRates_WhenCurrenciesNotProvided_Returns400() + { + // Act + var response = await _client.GetAsync("/exchange-rates"); + + // Assert + Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); + } + + [Fact] + public async Task GetExchangeRates_WhenCurrenciesAreWrongFormat_Returns400() + { + // Act + var url = QueryHelpers.AddQueryString( + "/exchange-rates", + [ + new KeyValuePair("currencies", "u") + ]); + + var response = await _client.GetAsync(url); + + // Assert + Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); + } +} \ No newline at end of file diff --git a/jobs/Backend/Task/ExchangeRateUpdater.UnitTests/ExchangeRateUpdater.UnitTests.csproj b/jobs/Backend/Task/ExchangeRateUpdater.UnitTests/ExchangeRateUpdater.UnitTests.csproj new file mode 100644 index 0000000000..bd5454aa77 --- /dev/null +++ b/jobs/Backend/Task/ExchangeRateUpdater.UnitTests/ExchangeRateUpdater.UnitTests.csproj @@ -0,0 +1,33 @@ + + + + net10.0 + enable + enable + + + + + + + + + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + + ..\..\..\..\..\..\..\..\Program Files\dotnet\shared\Microsoft.AspNetCore.App\10.0.2\Microsoft.Extensions.Logging.Abstractions.dll + + + ..\..\..\..\..\..\..\..\Program Files\dotnet\shared\Microsoft.AspNetCore.App\10.0.2\Microsoft.Extensions.Options.dll + + + + diff --git a/jobs/Backend/Task/ExchangeRateUpdater.UnitTests/Extensions/HttpMessageHandlerMockExtensions.cs b/jobs/Backend/Task/ExchangeRateUpdater.UnitTests/Extensions/HttpMessageHandlerMockExtensions.cs new file mode 100644 index 0000000000..9e0cca2fc6 --- /dev/null +++ b/jobs/Backend/Task/ExchangeRateUpdater.UnitTests/Extensions/HttpMessageHandlerMockExtensions.cs @@ -0,0 +1,20 @@ +using Moq; +using Moq.Language.Flow; +using Moq.Protected; + +namespace ExchangeRateUpdater.UnitTests.Extensions; + +public static class HttpMessageHandlerMockExtensions +{ + public static ISetup> + SetupGet(this Mock handler, string relativeUrl) + { + return handler.Protected() + .Setup>( + "SendAsync", + ItExpr.Is(req => + req.Method == HttpMethod.Get && + req.RequestUri!.AbsolutePath == relativeUrl), + ItExpr.IsAny()); + } +} \ No newline at end of file diff --git a/jobs/Backend/Task/ExchangeRateUpdater.UnitTests/Extensions/LoggerExtensions.cs b/jobs/Backend/Task/ExchangeRateUpdater.UnitTests/Extensions/LoggerExtensions.cs new file mode 100644 index 0000000000..66197dceec --- /dev/null +++ b/jobs/Backend/Task/ExchangeRateUpdater.UnitTests/Extensions/LoggerExtensions.cs @@ -0,0 +1,25 @@ +using Microsoft.Extensions.Logging; +using Moq; + +namespace ExchangeRateUpdater.UnitTests.Extensions; + +public static class LoggerExtensions +{ + public static void VerifyLogged( + this Mock> loggerMock, + LogLevel level, + string messageSubstring, + Times? times = null) + { + times ??= Times.Once(); + + loggerMock.Verify( + x => x.Log( + level, + It.IsAny(), + It.Is((v, _) => v.ToString()!.Contains(messageSubstring)), + It.IsAny(), + It.IsAny>()), + times.Value); + } +} \ No newline at end of file diff --git a/jobs/Backend/Task/ExchangeRateUpdater.UnitTests/HttpClients/ExchangeRateApiClientTests.cs b/jobs/Backend/Task/ExchangeRateUpdater.UnitTests/HttpClients/ExchangeRateApiClientTests.cs new file mode 100644 index 0000000000..a4c7c4c159 --- /dev/null +++ b/jobs/Backend/Task/ExchangeRateUpdater.UnitTests/HttpClients/ExchangeRateApiClientTests.cs @@ -0,0 +1,85 @@ +using System.Net; +using System.Net.Http.Json; +using ExchangeRateUpdater.Application.Interfaces; +using ExchangeRateUpdater.Application.Models; +using ExchangeRateUpdater.Infrastructure.HttpClients; +using ExchangeRateUpdater.UnitTests.Extensions; +using Microsoft.Extensions.Logging; +using Moq; +using Xunit; + +namespace ExchangeRateUpdater.UnitTests.HttpClients; + +public class ExchangeRateApiClientTests +{ + private readonly Mock _httpMessageHandlerMock = new(); + private readonly Mock> _logger = new(); + private readonly IExchangeRateApiClient _exchangeRateApiClient; + + public ExchangeRateApiClientTests() + { + var httpClient = new HttpClient(_httpMessageHandlerMock.Object) + { + BaseAddress = new Uri("https://test.com/") + }; + + _exchangeRateApiClient = new ExchangeRateApiClient(httpClient, _logger.Object); + } + + [Fact] + public async Task GetCnbExchangeRates_ApiReturnsCorrectData_Success() + { + // Arrange + var expectedResponse = + new CnbExchangeRateResponse([new CnbExchangeRate("USD", 20, 1), new CnbExchangeRate("EUR", 30, 1)]); + _httpMessageHandlerMock + .SetupGet("/exrates/daily") + .ReturnsAsync(new HttpResponseMessage + { + StatusCode = HttpStatusCode.OK, + Content = JsonContent.Create(expectedResponse) + }); + + // Act + var result = await _exchangeRateApiClient.GetAllExchangeRatesAsync(CancellationToken.None); + + // Assert + CnbExchangeRate[] expected = [new("USD", 20, 1), new("EUR", 30, 1)]; + Assert.Equal(expected, result); + } + + [Fact] + public async Task GetCnbExchangeRates_ApiReturnsEmptyCollection_LogsWarningReturnsEmptyCollection() + { + // Arrange + var expectedResponse = new CnbExchangeRateResponse([]); + _httpMessageHandlerMock + .SetupGet("/exrates/daily") + .ReturnsAsync(new HttpResponseMessage + { + StatusCode = HttpStatusCode.OK, + Content = JsonContent.Create(expectedResponse) + }); + + // Act + var result = await _exchangeRateApiClient.GetAllExchangeRatesAsync(CancellationToken.None); + + // Assert + Assert.Equal([], result); + _logger.VerifyLogged(LogLevel.Warning, "No exchange rates were provided by CNB"); + } + + [Fact] + public async Task GetCnbExchangeRates_ApiThrowsException_LogsErrorThrowsException() + { + // Arrange + _httpMessageHandlerMock + .SetupGet("/exrates/daily") + .ThrowsAsync(new HttpRequestException()); + + // Act & Assert + await Assert.ThrowsAsync(() => + _exchangeRateApiClient.GetAllExchangeRatesAsync(CancellationToken.None)); + _logger.VerifyLogged(LogLevel.Error, "Http request to CNB has failed"); + } +} \ No newline at end of file diff --git a/jobs/Backend/Task/ExchangeRateUpdater.UnitTests/Services/ExchangeRateProviderTests.cs b/jobs/Backend/Task/ExchangeRateUpdater.UnitTests/Services/ExchangeRateProviderTests.cs new file mode 100644 index 0000000000..c842b35fd2 --- /dev/null +++ b/jobs/Backend/Task/ExchangeRateUpdater.UnitTests/Services/ExchangeRateProviderTests.cs @@ -0,0 +1,72 @@ +using ExchangeRateUpdater.Application.Configuration; +using ExchangeRateUpdater.Application.Interfaces; +using ExchangeRateUpdater.Application.Models; +using ExchangeRateUpdater.Application.Services; +using ExchangeRateUpdater.UnitTests.Extensions; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; +using Moq; +using Xunit; + +namespace ExchangeRateUpdater.UnitTests.Services; + +public sealed class ExchangeRateProviderTests +{ + private readonly ExchangeRateConfig _exchangeRateConfig = new() + { + DefaultCurrency = "CZK" + }; + + private readonly ExchangeRateProvider _exchangeRateProvider; + private readonly Mock> _logger = new(); + + public ExchangeRateProviderTests() + { + var mockApi = new Mock(); + mockApi.Setup(x => x.GetAllExchangeRatesAsync(CancellationToken.None)) + .ReturnsAsync([ + new CnbExchangeRate("USD", 10, 1), + new CnbExchangeRate("EUR", 20, 1), + new CnbExchangeRate("GBP", 30, 10) + ]); + _exchangeRateProvider = + new ExchangeRateProvider(mockApi.Object, Options.Create(_exchangeRateConfig), _logger.Object); + } + + [Fact] + public async Task GetExchangeRates_WithValidCurrencies_ReturnedOnlyProvidedCurrencyRate() + { + // Arrange + string[] currencies = ["USD", "EUR"]; + + // Act + var result = await _exchangeRateProvider.GetExchangeRates(currencies, CancellationToken.None); + + // Assert + var expected = new List + { + new("USD", "CZK", 10), + new("EUR", "CZK", 20), + }; + Assert.Equal(expected, result); + } + + [Fact] + public async Task GetExchangeRates_WithInvalidCurrency_ReturnedOnlyValidCurrencyRate() + { + // Arrange + const string nonExistingCurrency = "XXX"; + string[] currencies = ["USD", nonExistingCurrency]; + + // Act + var result = await _exchangeRateProvider.GetExchangeRates(currencies, CancellationToken.None); + + // Assert + var expected = new List + { + new("USD", "CZK", 10) + }; + Assert.Equal(expected, result); + _logger.VerifyLogged(LogLevel.Warning, "No matches were found among CNB 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..f76ba15feb 100644 --- a/jobs/Backend/Task/ExchangeRateUpdater.sln +++ b/jobs/Backend/Task/ExchangeRateUpdater.sln @@ -3,7 +3,15 @@ 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.Api", "ExchangeRateUpdater.Api\ExchangeRateUpdater.Api.csproj", "{DC241402-E1F1-4E3F-B5A0-6FA60F979232}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ExchangeRateUpdater.Application", "ExchangeRateUpdater.Application\ExchangeRateUpdater.Application.csproj", "{3BDD0C14-83F3-4F6D-9A79-EF81B5EFBDC1}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ExchangeRateUpdater.Infrastructure", "ExchangeRateUpdater.Infrastructure\ExchangeRateUpdater.Infrastructure.csproj", "{3EC1EFF4-4F66-4C34-8D66-D054322D3D3B}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ExchangeRateUpdater.UnitTests", "ExchangeRateUpdater.UnitTests\ExchangeRateUpdater.UnitTests.csproj", "{9E3DA66E-8ED1-4821-8E18-E1A70FECE084}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ExchangeRateUpdater.IntegrationTests", "ExchangeRateUpdater.IntegrationTests\ExchangeRateUpdater.IntegrationTests.csproj", "{BAB7EBBE-D88F-4A09-92BE-D54CDEA4F2B3}" EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution @@ -11,10 +19,26 @@ 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 + {DC241402-E1F1-4E3F-B5A0-6FA60F979232}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {DC241402-E1F1-4E3F-B5A0-6FA60F979232}.Debug|Any CPU.Build.0 = Debug|Any CPU + {DC241402-E1F1-4E3F-B5A0-6FA60F979232}.Release|Any CPU.ActiveCfg = Release|Any CPU + {DC241402-E1F1-4E3F-B5A0-6FA60F979232}.Release|Any CPU.Build.0 = Release|Any CPU + {3BDD0C14-83F3-4F6D-9A79-EF81B5EFBDC1}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {3BDD0C14-83F3-4F6D-9A79-EF81B5EFBDC1}.Debug|Any CPU.Build.0 = Debug|Any CPU + {3BDD0C14-83F3-4F6D-9A79-EF81B5EFBDC1}.Release|Any CPU.ActiveCfg = Release|Any CPU + {3BDD0C14-83F3-4F6D-9A79-EF81B5EFBDC1}.Release|Any CPU.Build.0 = Release|Any CPU + {3EC1EFF4-4F66-4C34-8D66-D054322D3D3B}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {3EC1EFF4-4F66-4C34-8D66-D054322D3D3B}.Debug|Any CPU.Build.0 = Debug|Any CPU + {3EC1EFF4-4F66-4C34-8D66-D054322D3D3B}.Release|Any CPU.ActiveCfg = Release|Any CPU + {3EC1EFF4-4F66-4C34-8D66-D054322D3D3B}.Release|Any CPU.Build.0 = Release|Any CPU + {9E3DA66E-8ED1-4821-8E18-E1A70FECE084}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {9E3DA66E-8ED1-4821-8E18-E1A70FECE084}.Debug|Any CPU.Build.0 = Debug|Any CPU + {9E3DA66E-8ED1-4821-8E18-E1A70FECE084}.Release|Any CPU.ActiveCfg = Release|Any CPU + {9E3DA66E-8ED1-4821-8E18-E1A70FECE084}.Release|Any CPU.Build.0 = Release|Any CPU + {BAB7EBBE-D88F-4A09-92BE-D54CDEA4F2B3}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {BAB7EBBE-D88F-4A09-92BE-D54CDEA4F2B3}.Debug|Any CPU.Build.0 = Debug|Any CPU + {BAB7EBBE-D88F-4A09-92BE-D54CDEA4F2B3}.Release|Any CPU.ActiveCfg = Release|Any CPU + {BAB7EBBE-D88F-4A09-92BE-D54CDEA4F2B3}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE 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..e9aa528296 --- /dev/null +++ b/jobs/Backend/Task/Readme.md @@ -0,0 +1,61 @@ +# Exchange Rate Provider for Czech National Bank. + +ASP.NET Core Web API built on .NET 10 that fetches daily exchange rates from the +Czech National Bank and exposes an endpoint for consumers. +I use https://api.cnb.cz/cnbapi/exrates/daily API with default parameters to retrieve rates. + +## Architecture + +- Minimal API +- Clean separation between: + - API layer + - Application layer + - Infrastructure + +## Infrastructure +- Cnb api parameters are stored in appsettings.json +- ILogger from Microsoft.Extensions.Logging is used to log warnings and errors. +- Provided currency codes are compared with CNB currency codes ignoring case. +- AbstractValidator from FluentValidation is used to validate input parameters. + +## Tests + +- xUnit +- Unit tests for services and api client +- Integration tests using WebApplicationFactory +- External API calls are mocked + +## Future improvements + +- Add caching for exchange rates +- Implement resilience policies for external API calls +- Add option to change parameters for CNB API +- Improve exception handling +- Write rates to database for persistency and historical data audit + +## How to run + + +### CLI +```bash +dotnet run --project ExchangeRateUpdater.Api +``` + +### Swagger +Swagger UI is available at `/swagger/index.html` once the application is running. +The exact port is shown in the application startup logs. + +### HTTP requests +The file ExchangeRateUpdater.Api.http can be used to send requests +to the API directly from the IDE. + +## API + +### GET /exchange-rates + +Query parameters: +- `currencies`: list of currency codes + +Example: +```http +GET /exchange-rates?currencies=usd¤cies=eur \ No newline at end of file