diff --git a/.gitignore b/.gitignore index fd3586545..8742acdd6 100644 --- a/.gitignore +++ b/.gitignore @@ -1,23 +1,58 @@ -!.gitkeep -!.gitignore -!*.dll -[Oo]bj -[Bb]in -*.user -*.suo -*.[Cc]ache -*.bak -*.ncb -*.DS_Store -*.userprefs -*.iml -*.ncrunch* -.*crunch*.local.xml -.idea -[Tt]humbs.db -*.tgz -*.sublime-* - -node_modules -bower_components -npm-debug.log +## A streamlined .gitignore for modern .NET projects +## including temporary files, build results, and +## files generated by popular .NET tools. If you are +## developing with Visual Studio, the VS .gitignore +## https://github.com/github/gitignore/blob/main/VisualStudio.gitignore +## has more thorough IDE-specific entries. +## +## Get latest from https://github.com/github/gitignore/blob/main/Dotnet.gitignore + +# Rider +.idea/ +*.DotSettings.user + +# Build results +[Dd]ebug/ +[Dd]ebugPublic/ +[Rr]elease/ +[Rr]eleases/ +x64/ +x86/ +[Ww][Ii][Nn]32/ +[Aa][Rr][Mm]/ +[Aa][Rr][Mm]64/ +bld/ +[Bb]in/ +[Oo]bj/ +[Ll]og/ +[Ll]ogs/ + +# .NET Core +project.lock.json +project.fragment.lock.json +artifacts/ + +# ASP.NET Scaffolding +ScaffoldingReadMe.txt + +# NuGet Packages +*.nupkg +# NuGet Symbol Packages +*.snupkg + +# Others +~$* +*~ +CodeCoverage/ + +# MSBuild Binary and Structured Log +*.binlog + +# MSTest test Results +[Tt]est[Rr]esult*/ +[Bb]uild[Ll]og.* + +# NUnit +*.VisualState.xml +TestResult.xml +nunit-*.xml \ No newline at end of file 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/Dockerfile b/jobs/Backend/Task/Dockerfile new file mode 100644 index 000000000..baf5b6344 --- /dev/null +++ b/jobs/Backend/Task/Dockerfile @@ -0,0 +1,47 @@ +FROM mcr.microsoft.com/dotnet/aspnet:8.0 AS base +USER $APP_UID +WORKDIR /app +EXPOSE 8080 + +FROM mcr.microsoft.com/dotnet/sdk:8.0 AS build +ARG BUILD_CONFIGURATION=Release + +WORKDIR /src + +COPY ./ExchangeRateUpdater.sln . +COPY ./Exchange.Api/ ./Exchange.Api/ +COPY ./Exchange.Application/ ./Exchange.Application/ +COPY ./Exchange.ConsoleApp/ ./Exchange.ConsoleApp/ +COPY ./Exchange.Domain/ ./Exchange.Domain/ +COPY ./Exchange.Infrastructure/ ./Exchange.Infrastructure/ + +COPY ./Exchange.Application.UnitTests ./Exchange.Application.UnitTests +COPY ./Exchange.Domain.UnitTests ./Exchange.Domain.UnitTests +COPY ./Exchange.Infrastructure.UnitTests ./Exchange.Infrastructure.UnitTests + +RUN dotnet restore ExchangeRateUpdater.sln + +COPY . . + +# Build API +WORKDIR /src/Exchange.Api +RUN dotnet build -c $BUILD_CONFIGURATION -o /app/build + +# Build ConsoleApp +WORKDIR /src/Exchange.ConsoleApp +RUN dotnet build -c $BUILD_CONFIGURATION -o /app/build + +FROM build AS publish +ARG BUILD_CONFIGURATION=Release + +WORKDIR /src/Exchange.Api +RUN dotnet publish -c $BUILD_CONFIGURATION -o /app/publish /p:UseAppHost=false + +WORKDIR /src/Exchange.ConsoleApp +RUN dotnet publish -c $BUILD_CONFIGURATION -o /app/publish /p:UseAppHost=false + +FROM base AS final +WORKDIR /app +COPY --from=publish /app/publish . + +CMD [ "dotnet", "Exchange.ConsoleApp.dll" ] \ No newline at end of file diff --git a/jobs/Backend/Task/Exchange.Api/Dtos/ExchangeRateDto.cs b/jobs/Backend/Task/Exchange.Api/Dtos/ExchangeRateDto.cs new file mode 100644 index 000000000..9f5c961dc --- /dev/null +++ b/jobs/Backend/Task/Exchange.Api/Dtos/ExchangeRateDto.cs @@ -0,0 +1,3 @@ +namespace Exchange.Api.Dtos; + +public record ExchangeRateDto(string SourceCurrency, string TargetCurrency, decimal Value); \ No newline at end of file diff --git a/jobs/Backend/Task/Exchange.Api/Exchange.Api.csproj b/jobs/Backend/Task/Exchange.Api/Exchange.Api.csproj new file mode 100644 index 000000000..fe009853f --- /dev/null +++ b/jobs/Backend/Task/Exchange.Api/Exchange.Api.csproj @@ -0,0 +1,19 @@ + + + + net8.0 + enable + enable + + + + + + + + + + + + + diff --git a/jobs/Backend/Task/Exchange.Api/Middlewares/ExceptionHandlingMiddleware.cs b/jobs/Backend/Task/Exchange.Api/Middlewares/ExceptionHandlingMiddleware.cs new file mode 100644 index 000000000..87163ca5c --- /dev/null +++ b/jobs/Backend/Task/Exchange.Api/Middlewares/ExceptionHandlingMiddleware.cs @@ -0,0 +1,39 @@ +using Exchange.Domain.Abstractions.Exceptions; + +namespace Exchange.Api.Middlewares; + +public class ExceptionHandlingMiddleware(RequestDelegate next, ILogger logger) +{ + public async Task InvokeAsync(HttpContext context) + { + try + { + await next(context); + } + catch (BadRequestException ex) + { + logger.LogError(ex, "A bad request occurred"); + context.Response.StatusCode = StatusCodes.Status400BadRequest; + context.Response.ContentType = "application/json"; + var response = new + { + error = ex.Message + }; + await context.Response.WriteAsJsonAsync(response); + } + catch (Exception ex) + { + logger.LogError(ex, "An unhandled exception occurred during request processing"); + + context.Response.StatusCode = StatusCodes.Status500InternalServerError; + context.Response.ContentType = "application/json"; + + var response = new + { + error = "An internal server error occurred" + }; + + await context.Response.WriteAsJsonAsync(response); + } + } +} \ No newline at end of file diff --git a/jobs/Backend/Task/Exchange.Api/Middlewares/RequestTimingMiddleware.cs b/jobs/Backend/Task/Exchange.Api/Middlewares/RequestTimingMiddleware.cs new file mode 100644 index 000000000..d9e5561ff --- /dev/null +++ b/jobs/Backend/Task/Exchange.Api/Middlewares/RequestTimingMiddleware.cs @@ -0,0 +1,28 @@ +using System.Diagnostics; + +namespace Exchange.Api.Middlewares; + +public class RequestTimingMiddleware(RequestDelegate next, ILogger logger) +{ + public async Task Invoke(HttpContext context) + { + var stopwatch = Stopwatch.StartNew(); + + await next(context); + + stopwatch.Stop(); + + var elapsedMs = stopwatch.ElapsedMilliseconds; + var method = context.Request.Method; + var path = context.Request.Path; + var statusCode = context.Response.StatusCode; + + logger.LogInformation( + "Request {Method} {Path} responded {StatusCode} in {Elapsed} ms", + method, + path, + statusCode, + elapsedMs + ); + } +} \ No newline at end of file diff --git a/jobs/Backend/Task/Exchange.Api/Program.cs b/jobs/Backend/Task/Exchange.Api/Program.cs new file mode 100644 index 000000000..678df9d8f --- /dev/null +++ b/jobs/Backend/Task/Exchange.Api/Program.cs @@ -0,0 +1,51 @@ +using Exchange.Api.Dtos; +using Exchange.Api.Middlewares; +using Exchange.Application.Extensions; +using Exchange.Application.Services; +using Exchange.Domain.ValueObjects; +using Exchange.Infrastructure.Extensions.ServiceCollectionExtensions; +using Microsoft.AspNetCore.Mvc; + +var builder = WebApplication.CreateBuilder(args); + +// Add services to the container. +// Learn more about configuring Swagger/OpenAPI at https://aka.ms/aspnetcore/swashbuckle +builder.Services.AddEndpointsApiExplorer(); +builder.Services.AddSwaggerGen(); + +builder.Services.AddDateTimeProvider(); +builder.Services.AddCnbApiClient(builder.Configuration); +builder.Services.AddInMemoryCache(builder.Configuration); +builder.Services.AddExchangeRateProvider(); + +var app = builder.Build(); + +app.UseMiddleware(); +app.UseMiddleware(); + +app.UseSwagger(); +app.UseSwaggerUI(); + +app.MapGet("/exchange-rates", async ( + [FromServices] IExchangeRateProvider exchangeRateProvider, + [FromQuery] string[] currencyCodes, + CancellationToken cancellationToken + ) => + { + var requestedCurrencies = currencyCodes.Select(Currency.FromCode).ToList(); + + var exchangeRates = await exchangeRateProvider.GetExchangeRatesAsync(requestedCurrencies, cancellationToken); + + return exchangeRates.Select(er => + new ExchangeRateDto( + er.SourceCurrency.Code, + er.TargetCurrency.Code, + er.Value) + ).ToList(); + }) + .WithName("GetExchangeRates") + .Produces>() + .Produces(StatusCodes.Status400BadRequest) + .WithOpenApi(); + +await app.RunAsync(); \ No newline at end of file diff --git a/jobs/Backend/Task/Exchange.Api/Properties/launchSettings.json b/jobs/Backend/Task/Exchange.Api/Properties/launchSettings.json new file mode 100644 index 000000000..70903dc0c --- /dev/null +++ b/jobs/Backend/Task/Exchange.Api/Properties/launchSettings.json @@ -0,0 +1,25 @@ +{ + "$schema": "http://json.schemastore.org/launchsettings.json", + "profiles": { + "http": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": true, + "launchUrl": "swagger", + "applicationUrl": "http://localhost:5032", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + }, + "https": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": true, + "launchUrl": "swagger", + "applicationUrl": "https://localhost:7167;http://localhost:5032", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + } + } +} diff --git a/jobs/Backend/Task/Exchange.Api/appsettings.Development.json b/jobs/Backend/Task/Exchange.Api/appsettings.Development.json new file mode 100644 index 000000000..0c208ae91 --- /dev/null +++ b/jobs/Backend/Task/Exchange.Api/appsettings.Development.json @@ -0,0 +1,8 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + } +} diff --git a/jobs/Backend/Task/Exchange.Api/appsettings.json b/jobs/Backend/Task/Exchange.Api/appsettings.json new file mode 100644 index 000000000..d0f8e78c7 --- /dev/null +++ b/jobs/Backend/Task/Exchange.Api/appsettings.json @@ -0,0 +1,17 @@ +{ + "CnbApi": { + "BaseAddress": "https://api.cnb.cz/", + "TimeoutInSeconds": 30 + }, + "Cache": { + "DefaultAbsoluteExpiration": "10" + }, + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning", + "System.Net.Http.HttpClient": "Warning" + } + }, + "AllowedHosts": "*" +} diff --git a/jobs/Backend/Task/Exchange.Application.UnitTests/Exchange.Application.UnitTests.csproj b/jobs/Backend/Task/Exchange.Application.UnitTests/Exchange.Application.UnitTests.csproj new file mode 100644 index 000000000..446192e1d --- /dev/null +++ b/jobs/Backend/Task/Exchange.Application.UnitTests/Exchange.Application.UnitTests.csproj @@ -0,0 +1,29 @@ + + + + net8.0 + enable + enable + + false + true + + + + + + + + + + + + + + + + + + + + diff --git a/jobs/Backend/Task/Exchange.Application.UnitTests/Services/ExchangeRateProviderTests.cs b/jobs/Backend/Task/Exchange.Application.UnitTests/Services/ExchangeRateProviderTests.cs new file mode 100644 index 000000000..ef6d13847 --- /dev/null +++ b/jobs/Backend/Task/Exchange.Application.UnitTests/Services/ExchangeRateProviderTests.cs @@ -0,0 +1,91 @@ +using Exchange.Application.Abstractions.ApiClients; +using Exchange.Application.Services; +using Exchange.Domain.Entities; +using Exchange.Domain.ValueObjects; +using FluentAssertions; +using Moq; + +namespace Exchange.Application.UnitTests.Services; + +public class ExchangeRateProviderTests +{ + private readonly Mock _cnbApiClientMock = new(); + private readonly ExchangeRateProvider _sut; + + public ExchangeRateProviderTests() + { + _sut = new ExchangeRateProvider(_cnbApiClientMock.Object); + } + + [Fact] + public async Task GetExchangeRatesAsync_WhenCurrenciesExistInSource_ThenReturnsFilteredRates() + { + // Arrange + IEnumerable requestedCurrencies = [Currency.BRL, Currency.EUR]; + IEnumerable cnbExchangeRates = + [ + new("2025-01-01", 1, Currency.BRL.Country, Currency.BRL.Name, 1, Currency.BRL.Code, 3.881), + new("2025-01-01", 1, Currency.EUR.Country, Currency.EUR.Name, 1, Currency.EUR.Code, 24.295) + ]; + _cnbApiClientMock + .Setup(x => x.GetExchangeRatesAsync(It.IsAny())) + .ReturnsAsync(cnbExchangeRates); + + // Act + var result = await _sut.GetExchangeRatesAsync(requestedCurrencies); + + // Assert + var expectedResult = new List() + { + new(Currency.BRL, Currency.CZK, 3.881m), + new(Currency.EUR, Currency.CZK, 24.295m) + }; + result.Should().BeEquivalentTo(expectedResult); + } + + [Fact] + public async Task GetExchangeRatesAsync_WhenNoCurrenciesMatch_ThenReturnsEmpty() + { + // Arrange + IEnumerable requestedCurrencies = [Currency.USD, Currency.GBP]; + IEnumerable cnbExchangeRates = + [ + new("2025-01-01", 1, Currency.BRL.Country, Currency.BRL.Name, 1, Currency.BRL.Code, 3.881), + new("2025-01-01", 1, Currency.EUR.Country, Currency.EUR.Name, 1, Currency.EUR.Code, 24.295) + ]; + _cnbApiClientMock + .Setup(x => x.GetExchangeRatesAsync(It.IsAny())) + .ReturnsAsync(cnbExchangeRates); + + // Act + var result = await _sut.GetExchangeRatesAsync(requestedCurrencies); + + // Assert + var expectedResult = Enumerable.Empty(); + result.Should().BeEquivalentTo(expectedResult); + } + + [Fact] + public async Task GetExchangeRatesAsync_WhenAmountBiggerThanOne_ThenCalculateCalculateExchangeRateForOneCzk() + { + // Arrange + IEnumerable requestedCurrencies = [Currency.IDR]; + IEnumerable cnbExchangeRates = + [ + new("2025-01-01", 1, Currency.IDR.Country, Currency.IDR.Name, 1000, Currency.IDR.Code, 2) + ]; + _cnbApiClientMock + .Setup(x => x.GetExchangeRatesAsync(It.IsAny())) + .ReturnsAsync(cnbExchangeRates); + + // Act + var result = await _sut.GetExchangeRatesAsync(requestedCurrencies); + + // Assert + var expectedResult = new List() + { + new(Currency.IDR, Currency.CZK, 500m) + }; + result.Should().BeEquivalentTo(expectedResult); + } +} \ No newline at end of file diff --git a/jobs/Backend/Task/Exchange.Application/Abstractions/ApiClients/CnbExchangeRate.cs b/jobs/Backend/Task/Exchange.Application/Abstractions/ApiClients/CnbExchangeRate.cs new file mode 100644 index 000000000..545beaf29 --- /dev/null +++ b/jobs/Backend/Task/Exchange.Application/Abstractions/ApiClients/CnbExchangeRate.cs @@ -0,0 +1,11 @@ +namespace Exchange.Application.Abstractions.ApiClients; + +public record CnbExchangeRate( + string ValidFor, + int Order, + string Country, + string Currency, + int Amount, + string CurrencyCode, + double Rate +); \ No newline at end of file diff --git a/jobs/Backend/Task/Exchange.Application/Abstractions/ApiClients/ICnbApiClient.cs b/jobs/Backend/Task/Exchange.Application/Abstractions/ApiClients/ICnbApiClient.cs new file mode 100644 index 000000000..feba2ea46 --- /dev/null +++ b/jobs/Backend/Task/Exchange.Application/Abstractions/ApiClients/ICnbApiClient.cs @@ -0,0 +1,6 @@ +namespace Exchange.Application.Abstractions.ApiClients; + +public interface ICnbApiClient +{ + Task> GetExchangeRatesAsync(CancellationToken cancellationToken = default); +} \ No newline at end of file diff --git a/jobs/Backend/Task/Exchange.Application/Abstractions/Caching/ICachingService.cs b/jobs/Backend/Task/Exchange.Application/Abstractions/Caching/ICachingService.cs new file mode 100644 index 000000000..23d59f0c8 --- /dev/null +++ b/jobs/Backend/Task/Exchange.Application/Abstractions/Caching/ICachingService.cs @@ -0,0 +1,7 @@ +namespace Exchange.Application.Abstractions.Caching; + +public interface ICacheService +{ + Task GetAsync(string key); + Task SetAsync(string key, T value, TimeSpan? absoluteExpiration = null); +} \ No newline at end of file diff --git a/jobs/Backend/Task/Exchange.Application/Exchange.Application.csproj b/jobs/Backend/Task/Exchange.Application/Exchange.Application.csproj new file mode 100644 index 000000000..7cacb698e --- /dev/null +++ b/jobs/Backend/Task/Exchange.Application/Exchange.Application.csproj @@ -0,0 +1,18 @@ + + + + net8.0 + enable + enable + + + + + + + + + + + + diff --git a/jobs/Backend/Task/Exchange.Application/Extensions/ExchangeRateProviderServiceCollectionExtension.cs b/jobs/Backend/Task/Exchange.Application/Extensions/ExchangeRateProviderServiceCollectionExtension.cs new file mode 100644 index 000000000..6333fa72e --- /dev/null +++ b/jobs/Backend/Task/Exchange.Application/Extensions/ExchangeRateProviderServiceCollectionExtension.cs @@ -0,0 +1,15 @@ +using Exchange.Application.Services; +using Microsoft.Extensions.DependencyInjection; + +namespace Exchange.Application.Extensions; + +public static class ExchangeRateProviderServiceCollectionExtension +{ + public static IServiceCollection AddExchangeRateProvider(this IServiceCollection services) + { + services + .AddScoped(); + + return services; + } +} \ No newline at end of file diff --git a/jobs/Backend/Task/Exchange.Application/Services/ExchangeRateProvider.cs b/jobs/Backend/Task/Exchange.Application/Services/ExchangeRateProvider.cs new file mode 100644 index 000000000..23e925e68 --- /dev/null +++ b/jobs/Backend/Task/Exchange.Application/Services/ExchangeRateProvider.cs @@ -0,0 +1,51 @@ +using Exchange.Application.Abstractions.ApiClients; +using Exchange.Domain.Entities; +using Exchange.Domain.ValueObjects; + +namespace Exchange.Application.Services; + +public interface IExchangeRateProvider +{ + Task> GetExchangeRatesAsync( + IEnumerable currencies, + CancellationToken cancellationToken = default + ); +} + +public class ExchangeRateProvider(ICnbApiClient cnbApiClient) : IExchangeRateProvider +{ + /// + /// 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 async Task> GetExchangeRatesAsync( + IEnumerable currencies, + CancellationToken cancellationToken = default + ) + { + var requestedCurrencyCodes = new HashSet( + currencies.Select(c => c.Code), + StringComparer.OrdinalIgnoreCase + ); + + var allCnbExchangeRates = await cnbApiClient.GetExchangeRatesAsync(cancellationToken); + + var filteredExchangeRates = allCnbExchangeRates + .Where(r => requestedCurrencyCodes.Contains(r.CurrencyCode)) + .Select(er => new ExchangeRate( + Currency.FromCode(er.CurrencyCode), + Currency.CZK, + CalculateValue(er) + )) + .ToList(); + + return filteredExchangeRates; + + decimal CalculateValue(CnbExchangeRate exchangeRate) + { + return (decimal)(exchangeRate.Amount == 1 ? exchangeRate.Rate : exchangeRate.Amount / exchangeRate.Rate); + } + } +} \ No newline at end of file diff --git a/jobs/Backend/Task/Exchange.ConsoleApp/App.cs b/jobs/Backend/Task/Exchange.ConsoleApp/App.cs new file mode 100644 index 000000000..a7d7de0d6 --- /dev/null +++ b/jobs/Backend/Task/Exchange.ConsoleApp/App.cs @@ -0,0 +1,81 @@ +using Exchange.Application.Services; +using Exchange.Domain.Abstractions.Exceptions; +using Exchange.Domain.ValueObjects; + +namespace Exchange.ConsoleApp; + +public class App(IExchangeRateProvider exchangeRateProvider) +{ + private static readonly IEnumerable InitialCurrencies = + [ + Currency.USD, + Currency.BRL, + Currency.EUR, + Currency.CZK, + Currency.JPY, + Currency.KES, + Currency.RUB, + Currency.THB, + Currency.TRY, + Currency.IDR + ]; + + public async Task RunAsync() + { + var userInput = string.Empty; + + do + { + await DisplayExchangeRates( + GetCurrencies(userInput) + ); + + Console.WriteLine("Enter currency codes to retrieve exchange rates for:"); + Console.WriteLine("Ex: USD,BRL,EUR"); + Console.WriteLine("Empty value to exit."); + + userInput = Console.ReadLine(); + } while (!string.IsNullOrWhiteSpace(userInput)); + + + Console.ReadLine(); + } + + private async Task DisplayExchangeRates(IEnumerable currencies) + { + try + { + var exchangeRates = await exchangeRateProvider.GetExchangeRatesAsync(currencies); + + Console.WriteLine($"Successfully retrieved {exchangeRates.Count()} exchange rates:"); + + foreach (var exchangeRate in exchangeRates) + { + Console.WriteLine(exchangeRate.ToString()); + } + } + catch (Exception e) + { + Console.WriteLine($"Could not retrieve exchange rates: '{e.Message}'."); + } + } + + private static IEnumerable GetCurrencies(string currencyCodes = "") + { + if (string.IsNullOrWhiteSpace(currencyCodes)) + return InitialCurrencies; + + var codes = currencyCodes.Trim().Split(','); + + try + { + return codes.Select(c => Currency.FromCode(c.Trim())); + } + catch (BadRequestException ex) + { + Console.WriteLine(ex.Message); + } + + return InitialCurrencies; + } +} \ No newline at end of file diff --git a/jobs/Backend/Task/Exchange.ConsoleApp/Exchange.ConsoleApp.csproj b/jobs/Backend/Task/Exchange.ConsoleApp/Exchange.ConsoleApp.csproj new file mode 100644 index 000000000..53353d680 --- /dev/null +++ b/jobs/Backend/Task/Exchange.ConsoleApp/Exchange.ConsoleApp.csproj @@ -0,0 +1,27 @@ + + + + Exe + net8.0 + enable + enable + + + + + + + + + + + + + + + + PreserveNewest + + + + diff --git a/jobs/Backend/Task/Exchange.ConsoleApp/Program.cs b/jobs/Backend/Task/Exchange.ConsoleApp/Program.cs new file mode 100644 index 000000000..29ddef294 --- /dev/null +++ b/jobs/Backend/Task/Exchange.ConsoleApp/Program.cs @@ -0,0 +1,37 @@ +using Exchange.Application.Extensions; +using Exchange.ConsoleApp; +using Exchange.Infrastructure.Extensions.ServiceCollectionExtensions; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; + +var host = Host.CreateDefaultBuilder(args) + .ConfigureLogging(builder => + { + builder.ClearProviders(); + builder.AddConsole(); + }) + .ConfigureAppConfiguration(builder => + { + builder.AddJsonFile("appsettings.json", optional: false, reloadOnChange: true); + }) + .ConfigureServices((context, services) => + { + services.AddDateTimeProvider(); + services.AddCnbApiClient(context.Configuration); + services.AddInMemoryCache(context.Configuration); + services.AddExchangeRateProvider(); + services.AddTransient(); + }) + .UseDefaultServiceProvider((context, options) => + { + options.ValidateScopes = true; + options.ValidateOnBuild = true; + }) + .Build(); + +using var scope = host.Services.CreateScope(); +await scope.ServiceProvider + .GetRequiredService() + .RunAsync(); \ No newline at end of file diff --git a/jobs/Backend/Task/Exchange.ConsoleApp/appsettings.json b/jobs/Backend/Task/Exchange.ConsoleApp/appsettings.json new file mode 100644 index 000000000..5e0a39333 --- /dev/null +++ b/jobs/Backend/Task/Exchange.ConsoleApp/appsettings.json @@ -0,0 +1,15 @@ +{ + "CnbApi": { + "BaseAddress": "https://api.cnb.cz/", + "TimeoutInSeconds": 30 + }, + "Cache": { + "DefaultAbsoluteExpiration": "10" + }, + "Logging": { + "LogLevel": { + "Default": "Information", + "System.Net.Http.HttpClient": "Warning" + } + } +} diff --git a/jobs/Backend/Task/Exchange.Domain.UnitTests/Exchange.Domain.UnitTests.csproj b/jobs/Backend/Task/Exchange.Domain.UnitTests/Exchange.Domain.UnitTests.csproj new file mode 100644 index 000000000..b21effccd --- /dev/null +++ b/jobs/Backend/Task/Exchange.Domain.UnitTests/Exchange.Domain.UnitTests.csproj @@ -0,0 +1,28 @@ + + + + net8.0 + enable + enable + + false + true + + + + + + + + + + + + + + + + + + + diff --git a/jobs/Backend/Task/Exchange.Domain.UnitTests/ValueObjects/CurrencyTests.cs b/jobs/Backend/Task/Exchange.Domain.UnitTests/ValueObjects/CurrencyTests.cs new file mode 100644 index 000000000..96314c8a6 --- /dev/null +++ b/jobs/Backend/Task/Exchange.Domain.UnitTests/ValueObjects/CurrencyTests.cs @@ -0,0 +1,47 @@ +using Exchange.Domain.Exceptions; +using Exchange.Domain.ValueObjects; +using FluentAssertions; + +namespace Exchange.Domain.UnitTests.ValueObjects; + +public class CurrencyTests +{ + [Fact] + public void FromCode_WhenCodeIsValid_ReturnsCurrency() + { + // Arrange + const string code = "EUR"; + + // Act + var currency = Currency.FromCode(code); + + // Assert + currency.Should().Be(Currency.EUR); + } + + [Fact] + public void FromCode_WhenCodeIsInvalid_ThrowsException() + { + // Arrange + const string code = "INVALID"; + + // Act + var act = () => Currency.FromCode(code); + + // Assert + act.Should().Throw(); + } + + [Fact] + public void FromCode_WhenCodeIsEmpty_ThenThrowsException() + { + // Arrange + const string code = ""; + + // Act + var act = () => Currency.FromCode(code); + + // Assert + act.Should().Throw(); + } +} \ No newline at end of file diff --git a/jobs/Backend/Task/Exchange.Domain/Abstractions/Exceptions/BadRequestException.cs b/jobs/Backend/Task/Exchange.Domain/Abstractions/Exceptions/BadRequestException.cs new file mode 100644 index 000000000..802fce5f7 --- /dev/null +++ b/jobs/Backend/Task/Exchange.Domain/Abstractions/Exceptions/BadRequestException.cs @@ -0,0 +1,3 @@ +namespace Exchange.Domain.Abstractions.Exceptions; + +public abstract class BadRequestException(string? message) : Exception(message); \ No newline at end of file diff --git a/jobs/Backend/Task/Exchange.Domain/Entities/ExchangeRate.cs b/jobs/Backend/Task/Exchange.Domain/Entities/ExchangeRate.cs new file mode 100644 index 000000000..796cd475d --- /dev/null +++ b/jobs/Backend/Task/Exchange.Domain/Entities/ExchangeRate.cs @@ -0,0 +1,24 @@ +using Exchange.Domain.ValueObjects; + +namespace Exchange.Domain.Entities; + +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.Code}/{TargetCurrency.Code}={Value:000.000}"; + } +} \ No newline at end of file diff --git a/jobs/Backend/Task/Exchange.Domain/Exceptions/InvalidCurrencyCodeException.cs b/jobs/Backend/Task/Exchange.Domain/Exceptions/InvalidCurrencyCodeException.cs new file mode 100644 index 000000000..9dbbf9fd4 --- /dev/null +++ b/jobs/Backend/Task/Exchange.Domain/Exceptions/InvalidCurrencyCodeException.cs @@ -0,0 +1,5 @@ +using Exchange.Domain.Abstractions.Exceptions; + +namespace Exchange.Domain.Exceptions; + +public class InvalidCurrencyCodeException(string code) : BadRequestException($"Invalid currency code [{code}]."); \ No newline at end of file diff --git a/jobs/Backend/Task/Exchange.Domain/Exceptions/NotSupportedCurrencyCodeException.cs b/jobs/Backend/Task/Exchange.Domain/Exceptions/NotSupportedCurrencyCodeException.cs new file mode 100644 index 000000000..74cd31769 --- /dev/null +++ b/jobs/Backend/Task/Exchange.Domain/Exceptions/NotSupportedCurrencyCodeException.cs @@ -0,0 +1,6 @@ +using Exchange.Domain.Abstractions.Exceptions; + +namespace Exchange.Domain.Exceptions; + +public class NotSupportedCurrencyCodeException(string code) + : BadRequestException($"Not supported currency code [{code}]."); \ No newline at end of file diff --git a/jobs/Backend/Task/Exchange.Domain/Exchange.Domain.csproj b/jobs/Backend/Task/Exchange.Domain/Exchange.Domain.csproj new file mode 100644 index 000000000..3a6353295 --- /dev/null +++ b/jobs/Backend/Task/Exchange.Domain/Exchange.Domain.csproj @@ -0,0 +1,9 @@ + + + + net8.0 + enable + enable + + + diff --git a/jobs/Backend/Task/Exchange.Domain/ValueObjects/Currency.cs b/jobs/Backend/Task/Exchange.Domain/ValueObjects/Currency.cs new file mode 100644 index 000000000..f4719e838 --- /dev/null +++ b/jobs/Backend/Task/Exchange.Domain/ValueObjects/Currency.cs @@ -0,0 +1,107 @@ +using Exchange.Domain.Exceptions; + +namespace Exchange.Domain.ValueObjects; + +public record Currency +{ + /// + /// Three-letter ISO 4217 code of the currency. + /// + public string Code { get; init; } + + public string Country { get; init; } + + public string Name { get; init; } + + private Currency(string Code, string Country, string Name) + { + this.Code = Code; + this.Country = Country; + this.Name = Name; + } + + public static readonly Currency AUD = new("AUD", "Australia", "dollar"); + public static readonly Currency BRL = new("BRL", "Brazil", "real"); + public static readonly Currency BGN = new("BGN", "Bulgaria", "lev"); + public static readonly Currency CAD = new("CAD", "Canada", "dollar"); + public static readonly Currency CNY = new("CNY", "China", "renminbi"); + public static readonly Currency CZK = new("CZK", "Czech Republic", "koruna"); + public static readonly Currency HRK = new("HRK", "Croatia", "kuna"); + public static readonly Currency DKK = new("DKK", "Denmark", "krone"); + public static readonly Currency EUR = new("EUR", "EMU", "euro"); + public static readonly Currency HKD = new("HKD", "Hongkong", "dollar"); + public static readonly Currency HUF = new("HUF", "Hungary", "forint"); + public static readonly Currency ISK = new("ISK", "Iceland", "krona"); + public static readonly Currency XDR = new("XDR", "IMF", "SDR"); + public static readonly Currency INR = new("INR", "India", "rupee"); + public static readonly Currency IDR = new("IDR", "Indonesia", "rupiah"); + public static readonly Currency ILS = new("ILS", "Israel", "new shekel"); + public static readonly Currency JPY = new("JPY", "Japan", "yen"); + public static readonly Currency MYR = new("MYR", "Malaysia", "ringgit"); + public static readonly Currency MXN = new("MXN", "Mexico", "peso"); + public static readonly Currency NZD = new("NZD", "New Zealand", "dollar"); + public static readonly Currency NOK = new("NOK", "Norway", "krone"); + public static readonly Currency PHP = new("PHP", "Philippines", "peso"); + public static readonly Currency PLN = new("PLN", "Poland", "zloty"); + public static readonly Currency RON = new("RON", "Romania", "leu"); + public static readonly Currency RUB = new("RUB", "Russia", "rouble"); + public static readonly Currency SGD = new("SGD", "Singapore", "dollar"); + public static readonly Currency ZAR = new("ZAR", "South Africa", "rand"); + public static readonly Currency KES = new("KES", "Kenya", "shilling"); + public static readonly Currency KRW = new("KRW", "South Korea", "won"); + public static readonly Currency SEK = new("SEK", "Sweden", "krona"); + public static readonly Currency CHF = new("CHF", "Switzerland", "franc"); + public static readonly Currency THB = new("THB", "Thailand", "baht"); + public static readonly Currency TRY = new("TRY", "Turkey", "lira"); + public static readonly Currency GBP = new("GBP", "United Kingdom", "pound"); + public static readonly Currency USD = new("USD", "USA", "dollar"); + + private static readonly Dictionary Currencies = new(StringComparer.OrdinalIgnoreCase) + { + ["AUD"] = AUD, + ["BRL"] = BRL, + ["BGN"] = BGN, + ["CAD"] = CAD, + ["CNY"] = CNY, + ["HRK"] = HRK, + ["DKK"] = DKK, + ["EUR"] = EUR, + ["HKD"] = HKD, + ["HUF"] = HUF, + ["ISK"] = ISK, + ["XDR"] = XDR, + ["INR"] = INR, + ["IDR"] = IDR, + ["ILS"] = ILS, + ["JPY"] = JPY, + ["MYR"] = MYR, + ["MXN"] = MXN, + ["NZD"] = NZD, + ["NOK"] = NOK, + ["PHP"] = PHP, + ["PLN"] = PLN, + ["RON"] = RON, + ["RUB"] = RUB, + ["SGD"] = SGD, + ["ZAR"] = ZAR, + ["KRW"] = KRW, + ["SEK"] = SEK, + ["CHF"] = CHF, + ["THB"] = THB, + ["TRY"] = TRY, + ["GBP"] = GBP, + ["USD"] = USD, + ["CZK"] = CZK + }; + + public static Currency FromCode(string code) + { + if (string.IsNullOrWhiteSpace(code)) + throw new InvalidCurrencyCodeException(code); + + if (Currencies.TryGetValue(code, out var currency)) + return currency; + + throw new NotSupportedCurrencyCodeException(code); + } +} \ No newline at end of file diff --git a/jobs/Backend/Task/Exchange.Infrastructure.UnitTests/ApiClients/CnbApiClientCacheDecoratorTests.cs b/jobs/Backend/Task/Exchange.Infrastructure.UnitTests/ApiClients/CnbApiClientCacheDecoratorTests.cs new file mode 100644 index 000000000..ccb7366f3 --- /dev/null +++ b/jobs/Backend/Task/Exchange.Infrastructure.UnitTests/ApiClients/CnbApiClientCacheDecoratorTests.cs @@ -0,0 +1,156 @@ +using Exchange.Application.Abstractions.ApiClients; +using Exchange.Application.Abstractions.Caching; +using Exchange.Infrastructure.ApiClients; +using Exchange.Infrastructure.DateTimeProviders; +using FluentAssertions; +using Microsoft.Extensions.Logging; +using Moq; + +namespace Exchange.Infrastructure.UnitTests.ApiClients; + +public class CnbApiClientCacheDecoratorTests +{ + private readonly Mock _mockCnbApiClient = new(); + private readonly Mock _mockCacheService = new(); + private readonly Mock _mockDateTimeProvider = new(); + private readonly Mock _mockUpdateCalculator = new(); + private readonly Mock> _mockLogger = new(); + private readonly CnbApiClientCacheDecorator _sut; + + private readonly List _sampleExchangeRates = + [ + new("2023-01-01", 1, "USA", "Dollar", 1, "USD", 22.5) + ]; + + public CnbApiClientCacheDecoratorTests() + { + _sut = new CnbApiClientCacheDecorator( + _mockCnbApiClient.Object, + _mockCacheService.Object, + _mockUpdateCalculator.Object, + _mockDateTimeProvider.Object, + _mockLogger.Object + ); + } + + [Fact] + public async Task GetExchangeRatesAsync_WhenExchangeRatesCached_ReturnsFromCache() + { + // Arrange + var currentDate = new DateTime(2023, 1, 1, 13, 0, 0); + _mockDateTimeProvider.Setup(x => x.Now).Returns(currentDate); + _mockCacheService + .Setup(x => x.GetAsync>(It.IsAny())) + .ReturnsAsync(_sampleExchangeRates); + + // Act + var result = await _sut.GetExchangeRatesAsync(); + + // Assert + result.Should().BeEquivalentTo(_sampleExchangeRates); + _mockCnbApiClient.Verify(x => x.GetExchangeRatesAsync(It.IsAny()), Times.Never); + _mockCacheService.Verify( + x => x.SetAsync(It.IsAny(), It.IsAny>(), It.IsAny()), + Times.Never); + } + + [Fact] + public async Task GetExchangeRatesAsync_WhenExchangeRatesNotCached_ReturnsFromApi() + { + // Arrange + IEnumerable? cachedData = null; + _mockCacheService + .Setup(x => x.GetAsync>(It.IsAny())) + .ReturnsAsync(cachedData); + + _mockCnbApiClient + .Setup(x => x.GetExchangeRatesAsync(It.IsAny())) + .ReturnsAsync(_sampleExchangeRates); + + // Act + var result = await _sut.GetExchangeRatesAsync(); + + // Assert + result.Should().BeEquivalentTo(_sampleExchangeRates); + _mockCnbApiClient.Verify(x => x.GetExchangeRatesAsync(It.IsAny()), Times.Once); + _mockCacheService.Verify(x => x.SetAsync( + It.IsAny(), It.IsAny>(), It.IsAny() + ), Times.Once + ); + } + + [Fact] + public async Task GetExchangeRatesAsync_WhenPastUpdateTimeAndApiResponseNotUpdated_UseShortAbsoluteExpiration() + { + // Arrange + IEnumerable? cachedData = null; + _mockCacheService + .Setup(x => x.GetAsync>(It.IsAny())) + .ReturnsAsync(cachedData); + + _mockCnbApiClient + .Setup(x => x.GetExchangeRatesAsync(It.IsAny())) + .ReturnsAsync(_sampleExchangeRates); + + var expectedUpdateDate = new DateTime(2023, 1, 1, 14, 30, 0); + var currentDateTime = new DateTime(2023, 1, 1, 15, 0, 0); + var shortAbsoluteExpiration = TimeSpan.FromMinutes(10); + + _mockUpdateCalculator + .Setup(x => x.GetNextExpectedUpdateDate(It.IsAny())) + .Returns(expectedUpdateDate); + + _mockDateTimeProvider.Setup(x => x.Now).Returns(currentDateTime); + + // Act + var result = await _sut.GetExchangeRatesAsync(); + + // Assert + result.Should().BeEquivalentTo(_sampleExchangeRates); + _mockCnbApiClient.Verify(x => x.GetExchangeRatesAsync(It.IsAny()), Times.Once); + _mockCacheService.Verify(x => x.SetAsync( + It.IsAny(), It.IsAny>(), shortAbsoluteExpiration), + Times.Once + ); + } + + [Fact] + public async Task GetExchangeRatesAsync_WhenPastUpdateTimeAndApiResponseUpdated_UseCalculatedExpiration() + { + // Arrange + var updatedExchangeRates = new List + { + new("2023-01-02", 1, "USA", "Dollar", 1, "USD", 23.0) + }; + + IEnumerable? cachedData = null; + _mockCacheService + .Setup(x => x.GetAsync>(It.IsAny())) + .ReturnsAsync(cachedData); + + _mockCnbApiClient + .Setup(x => x.GetExchangeRatesAsync(It.IsAny())) + .ReturnsAsync(updatedExchangeRates); + + var currentDateTime = new DateTime(2023, 1, 2, 15, 0, 0); + var nextUpdateDateTime = new DateTime(2023, 1, 3, 14, 30, 0); + var expectedCacheExpiration = nextUpdateDateTime - currentDateTime; + + _mockDateTimeProvider.Setup(x => x.Now).Returns(currentDateTime); + _mockUpdateCalculator + .Setup(x => x.GetNextExpectedUpdateDate(new DateOnly(2023, 1, 2))) + .Returns(nextUpdateDateTime); + + // Act + var result = await _sut.GetExchangeRatesAsync(); + + // Assert + result.Should().BeEquivalentTo(updatedExchangeRates); + _mockCnbApiClient.Verify(x => x.GetExchangeRatesAsync(It.IsAny()), Times.Once); + _mockCacheService.Verify(x => x.SetAsync( + It.IsAny(), It.IsAny>(), expectedCacheExpiration), + Times.Once + ); + _mockUpdateCalculator.Verify(x => x.GetNextExpectedUpdateDate(new DateOnly(2023, 1, 2)), Times.Once); + } +} \ No newline at end of file diff --git a/jobs/Backend/Task/Exchange.Infrastructure.UnitTests/ApiClients/CnbApiClientDataUpdateCalculatorTests.cs b/jobs/Backend/Task/Exchange.Infrastructure.UnitTests/ApiClients/CnbApiClientDataUpdateCalculatorTests.cs new file mode 100644 index 000000000..b84dde448 --- /dev/null +++ b/jobs/Backend/Task/Exchange.Infrastructure.UnitTests/ApiClients/CnbApiClientDataUpdateCalculatorTests.cs @@ -0,0 +1,140 @@ +using Exchange.Infrastructure.ApiClients; +using Exchange.Infrastructure.DateTimeProviders; +using FluentAssertions; +using Microsoft.Extensions.Logging; +using Moq; + +namespace Exchange.Infrastructure.UnitTests.ApiClients; + +public class CnbApiClientDataUpdateCalculatorTests +{ + private readonly Mock _dateTimeProviderMock; + private readonly Mock> _loggerMock = new(); + private readonly CnbApiClientDataUpdateCalculator _sut; + + public CnbApiClientDataUpdateCalculatorTests() + { + _dateTimeProviderMock = new Mock(); + _sut = new CnbApiClientDataUpdateCalculator(_dateTimeProviderMock.Object, _loggerMock.Object); + } + + [Fact] + public void GetNextExpectedUpdateDate_WhenCurrentTimeIsBeforeUpdateTimeOnWorkingDay_ShouldReturnSameDay() + { + // Arrange + var currentDateWednesday = new DateOnly(2025, 10, 15); + var timeBeforeDataUpdateTime = new TimeOnly(10, 0); + var currentDateTime = currentDateWednesday.ToDateTime(timeBeforeDataUpdateTime); + + _dateTimeProviderMock.Setup(p => p.Now).Returns(currentDateTime); + + // Act + var result = _sut.GetNextExpectedUpdateDate(currentDateWednesday); + + // Assert + result.Should().Be(currentDateWednesday.ToDateTime(new TimeOnly(14, 30))); + } + + [Fact] + public void GetNextExpectedUpdateDate_WhenCurrentTimeIsAfterUpdateTimeOnWorkingDay_ShouldReturnNextWorkingDay() + { + // Arrange + var currentDateWednesday = new DateOnly(2025, 10, 15); + var timeAfterDataUpdateTime = new TimeOnly(15, 0); + var currentDateTime = currentDateWednesday.ToDateTime(timeAfterDataUpdateTime); + + _dateTimeProviderMock.Setup(p => p.Now).Returns(currentDateTime); + + // Act + var result = _sut.GetNextExpectedUpdateDate(currentDateWednesday); + + // Assert + var expectedDate = new DateOnly(2025, 10, 16); + result.Should().Be(expectedDate.ToDateTime(new TimeOnly(14, 30))); + } + + [Fact] + public void GetNextExpectedUpdateDate_WhenCurrentDateIsWeekend_ShouldReturnNextWorkingDay() + { + // Arrange + var currentDateSaturday = new DateOnly(2025, 10, 18); + var currentDateTime = currentDateSaturday.ToDateTime(new TimeOnly(10, 0)); + + _dateTimeProviderMock.Setup(p => p.Now).Returns(currentDateTime); + + // Act + var result = _sut.GetNextExpectedUpdateDate(currentDateSaturday); + + // Assert + var expectedDate = new DateOnly(2025, 10, 20); + result.Should().Be(expectedDate.ToDateTime(new TimeOnly(14, 30))); + } + + [Fact] + public void GetNextExpectedUpdateDate_WhenCurrentDateIsHoliday_ShouldReturnNextWorkingDay() + { + // Arrange + var holidayDate = new DateOnly(2025, 12, 25); + var currentDateTime = holidayDate.ToDateTime(new TimeOnly(10, 0)); + + _dateTimeProviderMock.Setup(p => p.Now).Returns(currentDateTime); + + // Act + var result = _sut.GetNextExpectedUpdateDate(holidayDate); + + // Assert + var expectedDate = new DateOnly(2025, 12, 26); + result.Should().Be(expectedDate.ToDateTime(new TimeOnly(14, 30))); + } + + [Fact] + public void GetNextExpectedUpdateDate_WhenNextDayIsWeekend_ShouldSkipWeekendDays() + { + // Arrange + var currentDateFriday = new DateOnly(2025, 10, 17); + var timeAfterDataUpdateTime = currentDateFriday.ToDateTime(new TimeOnly(15, 0)); + + _dateTimeProviderMock.Setup(p => p.Now).Returns(timeAfterDataUpdateTime); + + // Act + var result = _sut.GetNextExpectedUpdateDate(currentDateFriday); + + // Assert + var expectedDate = new DateOnly(2025, 10, 20); + result.Should().Be(expectedDate.ToDateTime(new TimeOnly(14, 30))); + } + + [Fact] + public void GetNextExpectedUpdateDate_WhenNextDayIsHoliday_ShouldSkipHoliday() + { + // Arrange + var dayBeforeHoliday = new DateOnly(2025, 11, 30); + var currentDateTime = dayBeforeHoliday.ToDateTime(new TimeOnly(15, 0)); + + _dateTimeProviderMock.Setup(p => p.Now).Returns(currentDateTime); + + // Act + var result = _sut.GetNextExpectedUpdateDate(dayBeforeHoliday); + + // Assert + var expectedDate = new DateOnly(2025, 12, 1); + result.Should().Be(expectedDate.ToDateTime(new TimeOnly(14, 30))); + } + + [Fact] + public void GetNextExpectedUpdateDate_WhenMultipleNonWorkingDaysAhead_ShouldFindFirstWorkingDay() + { + // Arrange + var dayBeforeWeekendAndHolidays = new DateOnly(2025, 12, 5); + var currentDateTime = dayBeforeWeekendAndHolidays.ToDateTime(new TimeOnly(15, 0)); + + _dateTimeProviderMock.Setup(p => p.Now).Returns(currentDateTime); + + // Act + var result = _sut.GetNextExpectedUpdateDate(dayBeforeWeekendAndHolidays); + + // Assert + var expectedDate = new DateOnly(2025, 12, 9); + result.Should().Be(expectedDate.ToDateTime(new TimeOnly(14, 30))); + } +} \ No newline at end of file diff --git a/jobs/Backend/Task/Exchange.Infrastructure.UnitTests/Exchange.Infrastructure.UnitTests.csproj b/jobs/Backend/Task/Exchange.Infrastructure.UnitTests/Exchange.Infrastructure.UnitTests.csproj new file mode 100644 index 000000000..2ea4c78d6 --- /dev/null +++ b/jobs/Backend/Task/Exchange.Infrastructure.UnitTests/Exchange.Infrastructure.UnitTests.csproj @@ -0,0 +1,29 @@ + + + + net8.0 + enable + enable + + false + true + + + + + + + + + + + + + + + + + + + + diff --git a/jobs/Backend/Task/Exchange.Infrastructure/ApiClients/CnbApiClient.cs b/jobs/Backend/Task/Exchange.Infrastructure/ApiClients/CnbApiClient.cs new file mode 100644 index 000000000..510dcfba2 --- /dev/null +++ b/jobs/Backend/Task/Exchange.Infrastructure/ApiClients/CnbApiClient.cs @@ -0,0 +1,20 @@ +using System.Net.Http.Json; +using Exchange.Application.Abstractions.ApiClients; + + +namespace Exchange.Infrastructure.ApiClients; + +public class CnbApiClient(HttpClient httpClient) : ICnbApiClient +{ + private const string ExchangeRatesUrl = "cnbapi/exrates/daily?lang=EN"; + + public async Task> GetExchangeRatesAsync(CancellationToken cancellationToken = default) + { + var response = await httpClient.GetAsync(ExchangeRatesUrl, cancellationToken); + var exchangeRates = + await response.Content.ReadFromJsonAsync(cancellationToken: cancellationToken); + return exchangeRates?.Rates ?? []; + } +} + +file record ExchangeRatesResponse(List Rates); \ No newline at end of file diff --git a/jobs/Backend/Task/Exchange.Infrastructure/ApiClients/CnbApiClientCacheDecorator.cs b/jobs/Backend/Task/Exchange.Infrastructure/ApiClients/CnbApiClientCacheDecorator.cs new file mode 100644 index 000000000..3f35facca --- /dev/null +++ b/jobs/Backend/Task/Exchange.Infrastructure/ApiClients/CnbApiClientCacheDecorator.cs @@ -0,0 +1,70 @@ +using Exchange.Application.Abstractions.ApiClients; +using Exchange.Application.Abstractions.Caching; +using Exchange.Infrastructure.DateTimeProviders; +using Microsoft.Extensions.Logging; + +namespace Exchange.Infrastructure.ApiClients; + +public class CnbApiClientCacheDecorator( + ICnbApiClient cnbApiClient, + ICacheService cacheService, + ICnbApiClientDataUpdateCalculator cnbApiClientDataUpdateCalculator, + IDateTimeProvider dateTimeProvider, + ILogger logger +) : ICnbApiClient +{ + private readonly TimeSpan _shortAbsoluteExpiration = TimeSpan.FromMinutes(10); + + private const string CacheKey = nameof(CnbApiClient); + + public async Task> GetExchangeRatesAsync(CancellationToken cancellationToken = default) + { + var cached = await cacheService.GetAsync>(CacheKey); + + if (cached is not null) + { + logger.LogInformation("Data retrieved from cache."); + return cached; + } + + var cnbExchangeRates = await cnbApiClient.GetExchangeRatesAsync(cancellationToken); + + await CacheExchangeRatesAsync(cnbExchangeRates); + + return cnbExchangeRates; + } + + private async Task CacheExchangeRatesAsync(IEnumerable cnbExchangeRates) + { + logger.LogInformation("Caching exchange rates."); + var cacheExpiration = CalculateCacheExpiration(cnbExchangeRates); + logger.LogInformation( + "Cache expiration: {Days} days, {Hours} hours, {Minutes} minutes, {Seconds} seconds", + cacheExpiration.Days, + cacheExpiration.Hours, + cacheExpiration.Minutes, + cacheExpiration.Seconds + ); + await cacheService.SetAsync(CacheKey, cnbExchangeRates, cacheExpiration); + } + + private TimeSpan CalculateCacheExpiration(IEnumerable exchangeRates) + { + var lastUpdateDate = GetLastUpdateDate(exchangeRates); + var expectedUpdateTime = cnbApiClientDataUpdateCalculator.GetNextExpectedUpdateDate(lastUpdateDate); + logger.LogInformation("Expected update time: {ExpectedUpdateTime}", expectedUpdateTime); + var currentTime = dateTimeProvider.Now; + + return expectedUpdateTime > currentTime + ? expectedUpdateTime - currentTime + : _shortAbsoluteExpiration; + } + + private DateOnly GetLastUpdateDate(IEnumerable exchangeRates) + { + var firstRate = exchangeRates.First(); + var lastUpdateDate = DateOnly.Parse(firstRate.ValidFor); + logger.LogInformation("Last update date: {LastUpdateDate}", lastUpdateDate); + return lastUpdateDate; + } +} \ No newline at end of file diff --git a/jobs/Backend/Task/Exchange.Infrastructure/ApiClients/CnbApiClientDataUpdateCalculator.cs b/jobs/Backend/Task/Exchange.Infrastructure/ApiClients/CnbApiClientDataUpdateCalculator.cs new file mode 100644 index 000000000..de450befd --- /dev/null +++ b/jobs/Backend/Task/Exchange.Infrastructure/ApiClients/CnbApiClientDataUpdateCalculator.cs @@ -0,0 +1,62 @@ +using Exchange.Infrastructure.DateTimeProviders; +using Exchange.Infrastructure.Extensions; +using Microsoft.Extensions.Logging; + +namespace Exchange.Infrastructure.ApiClients; + +public interface ICnbApiClientDataUpdateCalculator +{ + DateTime GetNextExpectedUpdateDate(DateOnly lastUpdate); +} + +public class CnbApiClientDataUpdateCalculator( + IDateTimeProvider dateTimeProvider, + ILogger logger +) : ICnbApiClientDataUpdateCalculator +{ + private static readonly TimeOnly DataUpdateTime = new(14, 30); + + private readonly DateOnly[] _bankHolidays = + [ + new(2025, 11, 1), + new(2025, 12, 6), + new(2025, 12, 8), + new(2025, 12, 25) + ]; + + + public DateTime GetNextExpectedUpdateDate(DateOnly lastUpdate) + { + var targetDate = lastUpdate; + + if (CanUpdateOnSameDay(targetDate, dateTimeProvider.Now)) + { + logger.LogInformation("Data update is expected on the same day."); + return targetDate.ToDateTime(DataUpdateTime); + } + + logger.LogInformation("Data update is expected on the next working day."); + return GetNextWorkingDate(targetDate).ToDateTime(DataUpdateTime); + } + + private bool CanUpdateOnSameDay(DateOnly date, DateTime currentDateTime) => + IsWorkingDay(date) && + date == currentDateTime.ToDateOnly() && + currentDateTime.TimeOfDay < DataUpdateTime.ToTimeSpan(); + + private bool IsWorkingDay(DateOnly date) => + !date.IsWeekend() && !IsHoliday(date); + + private bool IsHoliday(DateOnly date) => _bankHolidays.Contains(date); + + private DateOnly GetNextWorkingDate(DateOnly startDate) + { + var nextDate = startDate.AddDays(1); + while (!IsWorkingDay(nextDate)) + { + nextDate = nextDate.AddDays(1); + } + + return nextDate; + } +} \ No newline at end of file diff --git a/jobs/Backend/Task/Exchange.Infrastructure/ApiClients/CnbApiOptions.cs b/jobs/Backend/Task/Exchange.Infrastructure/ApiClients/CnbApiOptions.cs new file mode 100644 index 000000000..cb85fe322 --- /dev/null +++ b/jobs/Backend/Task/Exchange.Infrastructure/ApiClients/CnbApiOptions.cs @@ -0,0 +1,10 @@ +using System.ComponentModel.DataAnnotations; + +namespace Exchange.Infrastructure.ApiClients; + +public class CnbApiOptions +{ + public const string SectionName = "CnbApi"; + [Required] [Url] public required string BaseAddress { get; set; } + [Range(1, 100)] public required int TimeoutInSeconds { get; set; } = 60; +} \ No newline at end of file diff --git a/jobs/Backend/Task/Exchange.Infrastructure/Caching/CacheOptions.cs b/jobs/Backend/Task/Exchange.Infrastructure/Caching/CacheOptions.cs new file mode 100644 index 000000000..b4389c299 --- /dev/null +++ b/jobs/Backend/Task/Exchange.Infrastructure/Caching/CacheOptions.cs @@ -0,0 +1,7 @@ +namespace Exchange.Infrastructure.Caching; + +public class CacheOptions +{ + public const string SectionName = "Cache"; + public TimeSpan DefaultAbsoluteExpiration { get; set; } = TimeSpan.FromMinutes(5); +} \ No newline at end of file diff --git a/jobs/Backend/Task/Exchange.Infrastructure/Caching/InMemoryCacheService.cs b/jobs/Backend/Task/Exchange.Infrastructure/Caching/InMemoryCacheService.cs new file mode 100644 index 000000000..bbf0dada9 --- /dev/null +++ b/jobs/Backend/Task/Exchange.Infrastructure/Caching/InMemoryCacheService.cs @@ -0,0 +1,23 @@ +using Exchange.Application.Abstractions.Caching; +using Microsoft.Extensions.Caching.Memory; +using Microsoft.Extensions.Options; + +namespace Exchange.Infrastructure.Caching; + +public class InMemoryCacheService(IMemoryCache memoryCache, IOptions options) : ICacheService +{ + public Task GetAsync(string key) + { + memoryCache.TryGetValue(key, out T? value); + return Task.FromResult(value); + } + + public Task SetAsync(string key, T value, TimeSpan? absoluteExpiration = null) + { + var cacheEntryOptions = new MemoryCacheEntryOptions(); + cacheEntryOptions.SetAbsoluteExpiration(absoluteExpiration ?? options.Value.DefaultAbsoluteExpiration); + + memoryCache.Set(key, value, cacheEntryOptions); + return Task.CompletedTask; + } +} \ No newline at end of file diff --git a/jobs/Backend/Task/Exchange.Infrastructure/DateTimeProviders/DateTimeProvider.cs b/jobs/Backend/Task/Exchange.Infrastructure/DateTimeProviders/DateTimeProvider.cs new file mode 100644 index 000000000..abbf3b89c --- /dev/null +++ b/jobs/Backend/Task/Exchange.Infrastructure/DateTimeProviders/DateTimeProvider.cs @@ -0,0 +1,11 @@ +namespace Exchange.Infrastructure.DateTimeProviders; + +public interface IDateTimeProvider +{ + DateTime Now { get; } +} + +public class DateTimeProvider : IDateTimeProvider +{ + public DateTime Now => DateTime.Now; +} \ No newline at end of file diff --git a/jobs/Backend/Task/Exchange.Infrastructure/Exchange.Infrastructure.csproj b/jobs/Backend/Task/Exchange.Infrastructure/Exchange.Infrastructure.csproj new file mode 100644 index 000000000..4992311e2 --- /dev/null +++ b/jobs/Backend/Task/Exchange.Infrastructure/Exchange.Infrastructure.csproj @@ -0,0 +1,21 @@ + + + + net8.0 + enable + enable + + + + + + + + + + + + + + + diff --git a/jobs/Backend/Task/Exchange.Infrastructure/Extensions/DateOnlyExtension.cs b/jobs/Backend/Task/Exchange.Infrastructure/Extensions/DateOnlyExtension.cs new file mode 100644 index 000000000..81c7d2ac7 --- /dev/null +++ b/jobs/Backend/Task/Exchange.Infrastructure/Extensions/DateOnlyExtension.cs @@ -0,0 +1,6 @@ +namespace Exchange.Infrastructure.Extensions; + +public static class DateOnlyExtension +{ + public static bool IsWeekend(this DateOnly date) => date.DayOfWeek is DayOfWeek.Saturday or DayOfWeek.Sunday; +} \ No newline at end of file diff --git a/jobs/Backend/Task/Exchange.Infrastructure/Extensions/DateTimeExtension.cs b/jobs/Backend/Task/Exchange.Infrastructure/Extensions/DateTimeExtension.cs new file mode 100644 index 000000000..e08592fe4 --- /dev/null +++ b/jobs/Backend/Task/Exchange.Infrastructure/Extensions/DateTimeExtension.cs @@ -0,0 +1,6 @@ +namespace Exchange.Infrastructure.Extensions; + +public static class DateTimeExtension +{ + public static DateOnly ToDateOnly(this DateTime dateTime) => DateOnly.FromDateTime(dateTime); +} \ No newline at end of file diff --git a/jobs/Backend/Task/Exchange.Infrastructure/Extensions/ServiceCollectionExtensions/CachingServiceCollectionExtension.cs b/jobs/Backend/Task/Exchange.Infrastructure/Extensions/ServiceCollectionExtensions/CachingServiceCollectionExtension.cs new file mode 100644 index 000000000..49bdb882f --- /dev/null +++ b/jobs/Backend/Task/Exchange.Infrastructure/Extensions/ServiceCollectionExtensions/CachingServiceCollectionExtension.cs @@ -0,0 +1,24 @@ +using Exchange.Application.Abstractions.Caching; +using Exchange.Infrastructure.Caching; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; + +namespace Exchange.Infrastructure.Extensions.ServiceCollectionExtensions; + +public static class CachingServiceCollectionExtension +{ + public static IServiceCollection AddInMemoryCache( + this IServiceCollection services, + IConfiguration configuration + ) + { + services + .AddOptions() + .Bind(configuration.GetSection(CacheOptions.SectionName)); + + services + .AddMemoryCache() + .AddSingleton(); + return services; + } +} \ No newline at end of file diff --git a/jobs/Backend/Task/Exchange.Infrastructure/Extensions/ServiceCollectionExtensions/CnbApiClientServiceCollectionExtension.cs b/jobs/Backend/Task/Exchange.Infrastructure/Extensions/ServiceCollectionExtensions/CnbApiClientServiceCollectionExtension.cs new file mode 100644 index 000000000..804b17470 --- /dev/null +++ b/jobs/Backend/Task/Exchange.Infrastructure/Extensions/ServiceCollectionExtensions/CnbApiClientServiceCollectionExtension.cs @@ -0,0 +1,58 @@ +using Exchange.Application.Abstractions.ApiClients; +using Exchange.Infrastructure.ApiClients; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Options; +using Polly; +using Polly.Extensions.Http; + +namespace Exchange.Infrastructure.Extensions.ServiceCollectionExtensions; + +public static class CnbApiClientServiceCollectionExtension +{ + public static IServiceCollection AddCnbApiClient(this IServiceCollection services, IConfiguration configuration) + { + services + .AddOptions() + .Bind(configuration.GetSection(CnbApiOptions.SectionName)) + .ValidateDataAnnotations() + .ValidateOnStart(); + + services.AddSingleton(); + + services.AddHttpClient(ConfigureCnbApiClient()) + .AddPolicyHandler(GetRetryPolicy()) + .AddPolicyHandler(GetCircuitBreakerPolicy()); + + services.Decorate(); + + return services; + } + + private static Action ConfigureCnbApiClient() + { + return (provider, client) => + { + var cnbApiOptions = provider.GetRequiredService>().Value; + client.BaseAddress = new Uri(cnbApiOptions.BaseAddress); + client.Timeout = TimeSpan.FromSeconds(cnbApiOptions.TimeoutInSeconds); + client.DefaultRequestHeaders.Add("Accept", "application/json"); + }; + } + + private static IAsyncPolicy GetRetryPolicy() => + HttpPolicyExtensions + .HandleTransientHttpError() + .WaitAndRetryAsync( + retryCount: 3, + retryAttempt => TimeSpan.FromSeconds(Math.Pow(2, retryAttempt)) + ); + + private static IAsyncPolicy GetCircuitBreakerPolicy() => + HttpPolicyExtensions + .HandleTransientHttpError() + .CircuitBreakerAsync( + handledEventsAllowedBeforeBreaking: 3, + durationOfBreak: TimeSpan.FromSeconds(30) + ); +} \ No newline at end of file diff --git a/jobs/Backend/Task/Exchange.Infrastructure/Extensions/ServiceCollectionExtensions/DateTimeServiceCollectionExtension.cs b/jobs/Backend/Task/Exchange.Infrastructure/Extensions/ServiceCollectionExtensions/DateTimeServiceCollectionExtension.cs new file mode 100644 index 000000000..8fd99269e --- /dev/null +++ b/jobs/Backend/Task/Exchange.Infrastructure/Extensions/ServiceCollectionExtensions/DateTimeServiceCollectionExtension.cs @@ -0,0 +1,14 @@ +using Exchange.Infrastructure.DateTimeProviders; +using Microsoft.Extensions.DependencyInjection; + +namespace Exchange.Infrastructure.Extensions.ServiceCollectionExtensions; + +public static class DateTimeServiceCollectionExtension +{ + public static IServiceCollection AddDateTimeProvider(this IServiceCollection services) + { + services.AddSingleton(); + + return services; + } +} \ No newline at end of file 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.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..a32a9a018 100644 --- a/jobs/Backend/Task/ExchangeRateUpdater.sln +++ b/jobs/Backend/Task/ExchangeRateUpdater.sln @@ -3,7 +3,35 @@ 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}") = "Exchange.Infrastructure", "Exchange.Infrastructure\Exchange.Infrastructure.csproj", "{D2F5C81D-272F-4302-9F27-609F1D404B64}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Exchange.Domain", "Exchange.Domain\Exchange.Domain.csproj", "{889137B9-9077-486F-AD80-5CBC96502F89}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Exchange.Api", "Exchange.Api\Exchange.Api.csproj", "{8F1423E3-C75E-4CF2-905F-72E41FCA93CD}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Exchange.ConsoleApp", "Exchange.ConsoleApp\Exchange.ConsoleApp.csproj", "{9DD81895-8C6B-43CA-9278-7AD6218733B8}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Exchange.Application", "Exchange.Application\Exchange.Application.csproj", "{68101D08-9A34-441C-A65C-2093F9ED3F54}" +EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Tests", "Tests", "{471CD861-7E09-4B8C-A0B1-F137751B1B10}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Exchange.Domain.UnitTests", "Exchange.Domain.UnitTests\Exchange.Domain.UnitTests.csproj", "{0CB71C33-F564-4B8A-A5B4-0B6380FE2B1C}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Exchange.Infrastructure.UnitTests", "Exchange.Infrastructure.UnitTests\Exchange.Infrastructure.UnitTests.csproj", "{4EE17604-72BE-4EE3-A4CC-98B934B21182}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Exchange.Application.UnitTests", "Exchange.Application.UnitTests\Exchange.Application.UnitTests.csproj", "{842D2482-7B8D-4A0D-81BB-69C1AF554F2C}" +EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Docker", "Docker", "{E752DE29-3CAD-48C0-9FEE-71E3DE446DC2}" + ProjectSection(SolutionItems) = preProject + .dockerignore = .dockerignore + Dockerfile = Dockerfile + docker-compose.yaml = docker-compose.yaml + EndProjectSection +EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Docs", "Docs", "{AB6927D7-EEBF-4709-B84E-4F2617E9B284}" + ProjectSection(SolutionItems) = preProject + README.md = README.md + EndProjectSection EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution @@ -11,12 +39,45 @@ 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 + {D2F5C81D-272F-4302-9F27-609F1D404B64}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {D2F5C81D-272F-4302-9F27-609F1D404B64}.Debug|Any CPU.Build.0 = Debug|Any CPU + {D2F5C81D-272F-4302-9F27-609F1D404B64}.Release|Any CPU.ActiveCfg = Release|Any CPU + {D2F5C81D-272F-4302-9F27-609F1D404B64}.Release|Any CPU.Build.0 = Release|Any CPU + {889137B9-9077-486F-AD80-5CBC96502F89}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {889137B9-9077-486F-AD80-5CBC96502F89}.Debug|Any CPU.Build.0 = Debug|Any CPU + {889137B9-9077-486F-AD80-5CBC96502F89}.Release|Any CPU.ActiveCfg = Release|Any CPU + {889137B9-9077-486F-AD80-5CBC96502F89}.Release|Any CPU.Build.0 = Release|Any CPU + {8F1423E3-C75E-4CF2-905F-72E41FCA93CD}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {8F1423E3-C75E-4CF2-905F-72E41FCA93CD}.Debug|Any CPU.Build.0 = Debug|Any CPU + {8F1423E3-C75E-4CF2-905F-72E41FCA93CD}.Release|Any CPU.ActiveCfg = Release|Any CPU + {8F1423E3-C75E-4CF2-905F-72E41FCA93CD}.Release|Any CPU.Build.0 = Release|Any CPU + {9DD81895-8C6B-43CA-9278-7AD6218733B8}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {9DD81895-8C6B-43CA-9278-7AD6218733B8}.Debug|Any CPU.Build.0 = Debug|Any CPU + {9DD81895-8C6B-43CA-9278-7AD6218733B8}.Release|Any CPU.ActiveCfg = Release|Any CPU + {9DD81895-8C6B-43CA-9278-7AD6218733B8}.Release|Any CPU.Build.0 = Release|Any CPU + {68101D08-9A34-441C-A65C-2093F9ED3F54}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {68101D08-9A34-441C-A65C-2093F9ED3F54}.Debug|Any CPU.Build.0 = Debug|Any CPU + {68101D08-9A34-441C-A65C-2093F9ED3F54}.Release|Any CPU.ActiveCfg = Release|Any CPU + {68101D08-9A34-441C-A65C-2093F9ED3F54}.Release|Any CPU.Build.0 = Release|Any CPU + {0CB71C33-F564-4B8A-A5B4-0B6380FE2B1C}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {0CB71C33-F564-4B8A-A5B4-0B6380FE2B1C}.Debug|Any CPU.Build.0 = Debug|Any CPU + {0CB71C33-F564-4B8A-A5B4-0B6380FE2B1C}.Release|Any CPU.ActiveCfg = Release|Any CPU + {0CB71C33-F564-4B8A-A5B4-0B6380FE2B1C}.Release|Any CPU.Build.0 = Release|Any CPU + {4EE17604-72BE-4EE3-A4CC-98B934B21182}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {4EE17604-72BE-4EE3-A4CC-98B934B21182}.Debug|Any CPU.Build.0 = Debug|Any CPU + {4EE17604-72BE-4EE3-A4CC-98B934B21182}.Release|Any CPU.ActiveCfg = Release|Any CPU + {4EE17604-72BE-4EE3-A4CC-98B934B21182}.Release|Any CPU.Build.0 = Release|Any CPU + {842D2482-7B8D-4A0D-81BB-69C1AF554F2C}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {842D2482-7B8D-4A0D-81BB-69C1AF554F2C}.Debug|Any CPU.Build.0 = Debug|Any CPU + {842D2482-7B8D-4A0D-81BB-69C1AF554F2C}.Release|Any CPU.ActiveCfg = Release|Any CPU + {842D2482-7B8D-4A0D-81BB-69C1AF554F2C}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE EndGlobalSection + GlobalSection(NestedProjects) = preSolution + {0CB71C33-F564-4B8A-A5B4-0B6380FE2B1C} = {471CD861-7E09-4B8C-A0B1-F137751B1B10} + {4EE17604-72BE-4EE3-A4CC-98B934B21182} = {471CD861-7E09-4B8C-A0B1-F137751B1B10} + {842D2482-7B8D-4A0D-81BB-69C1AF554F2C} = {471CD861-7E09-4B8C-A0B1-F137751B1B10} + EndGlobalSection EndGlobal diff --git a/jobs/Backend/Task/Program.cs b/jobs/Backend/Task/Program.cs deleted file mode 100644 index 379a69b1f..000000000 --- a/jobs/Backend/Task/Program.cs +++ /dev/null @@ -1,43 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Linq; - -namespace ExchangeRateUpdater -{ - public static class Program - { - private static IEnumerable currencies = new[] - { - new Currency("USD"), - new Currency("EUR"), - new Currency("CZK"), - new Currency("JPY"), - new Currency("KES"), - new Currency("RUB"), - new Currency("THB"), - new Currency("TRY"), - new Currency("XYZ") - }; - - public static void Main(string[] args) - { - try - { - var provider = new ExchangeRateProvider(); - var rates = provider.GetExchangeRates(currencies); - - Console.WriteLine($"Successfully retrieved {rates.Count()} exchange rates:"); - foreach (var rate in rates) - { - Console.WriteLine(rate.ToString()); - } - } - catch (Exception e) - { - Console.WriteLine($"Could not retrieve exchange rates: '{e.Message}'."); - } - - Console.ReadLine(); - } - } -} diff --git a/jobs/Backend/Task/README.md b/jobs/Backend/Task/README.md new file mode 100644 index 000000000..2946b41ee --- /dev/null +++ b/jobs/Backend/Task/README.md @@ -0,0 +1,160 @@ +# Code Challenge – CNB Exchange Rate Provider + +This pull request implements a solution for retrieving exchange rate data from the Czech National Bank (CNB) web services. + +--- + +## ✅ Requirements Implemented + +- **ExchangeRateProvider**: + - Fetches daily exchange rates from the CNB JSON API. + - Filters results based on requested currency codes. + +--- + +## 🔍 Data Source Analysis + +The CNB provides daily exchange rates in three formats: + +- **JSON**: `https://api.cnb.cz/cnbapi/exrates/daily?date=YYYY-MM-DD&lang=EN` +- **Text**: Plain text format. +- **XML**: Standard XML with metadata. + +> **Chosen Format**: The JSON API was selected for this implementation due to its modern structure and ease of parsing via `.NET`’s `System.Text.Json`. + +> **Authentication**: None of the CNB endpoints require authentication. +--- + +## 🕒 Exchange Rate Update Schedule + +- **Update Time**: On working days after **14:30 local time** (no guaranteed exact time). +- **No Updates**: On weekends and **Czech public holidays**. +- **Update Detection**: + - JSON: `validFor` field + - Text: First line (e.g., `19.09.2025 #183`) + - XML: Root attribute `date` (e.g., ``) + +> Note: The system determines the next working day programmatically, considering weekends and Czech national holidays. + +--- + +## 🧱 Architectural Approach + +### Clean Architecture + +The project follows Clean Architecture principles, separating responsibilities across the following layers: + +- **Presentation**: + - `Api`: RESTful interface for consumers + - `ConsoleApp`: Lightweight CLI tool for debugging or testing +- **Application / Domain / Infrastructure**: + - Encapsulates core logic, abstractions, and external dependencies + +### Benefits + +- **Testability**: Interfaces are used throughout, enabling mocking. +- **Maintainability**: Clear separation of concerns. +- **Extensibility**: Components can be replaced or extended with minimal impact. + +--- + +## 🌐 CNB Integration + +- **Typed HTTP Client** is used for structured, reusable access. +- **Deserialization**: Handled by `System.Text.Json.JsonSerializer`. +- **Resilience**: Powered by **Polly** with retry policies for transient failures. + +--- + +## 🧠 Caching Strategy + +To optimize performance and respect the CNB’s update cadence: + +- **Initial Request**: Triggers data fetch and in-memory cache population. +- **Cache Expiry**: + - Set to 14:30 on the next working day. +- **Post-14:30 Logic**: + - After 14:30, the system checks for updates every 10 minutes until the `validFor` value changes. + +### Key Benefits + +- Reduces unnecessary API calls +- Ensures timely refreshes once new data becomes available +- Enables consistent and predictable refresh cycles + +> **Note**: In-memory caching is used for simplicity. This can be swapped with a distributed cache like Redis for scalability. + +--- + +## 🧰 Middleware + +### ExceptionHandlingMiddleware + +Centralized error handling that: + +- Catches unhandled exceptions +- Returns standardized error responses +- Prevents internal details from leaking +- Logs exceptions for diagnostics + +### RequestTimingMiddleware + +- Logs the duration of each request for observability + +--- + +## 📝 Logging + +Key application events are logged, including: + +- Source of data (cache vs CNB API) +- Current `validFor` value +- Next expected refresh time +- Errors or retry attempts (via Polly) + +--- + +## 🔄 Dependency Injection + +The project uses **.NET’s built-in Dependency Injection** system. + +### Composition Root Strategy + +- Each layer registers its dependencies via extension methods. +- Shared configuration is centralized and reused across both the API and Console apps. + +### Examples of Registered Services + +```csharp +services.AddTransient(); +services.AddHttpClient(); +services.AddMemoryCache(); +``` + +### Application Entry Points + +- **API**: Configured via Program.cs using standard WebApplicationBuilder. +- **ConsoleApp**: Uses HostBuilder to replicate the same DI setup, ensuring full parity between environments. + + +## 🧪 Unit Testing + +The project includes unit tests organized by layer: +- Follows the same structure as the production code for easier traceability. +- Each test project targets: + - Domain logic + - Application services + - Infrastructure adapters +- Uses appropriate mocking (e.g., Moq) to isolate behaviors. + - Test Frameworks: + - xUnit + - Moq (for mocking dependencies) + - FluentAssertion + +--- +## ✅ Summary + +This implementation: +- Meets all functional requirements. +- Follows modern .NET and Clean Architecture best practices. +- Is resilient, testable, and ready for extension (e.g., distributed caching, fallback APIs, scheduled jobs). diff --git a/jobs/Backend/Task/docker-compose.yaml b/jobs/Backend/Task/docker-compose.yaml new file mode 100644 index 000000000..ded9a350e --- /dev/null +++ b/jobs/Backend/Task/docker-compose.yaml @@ -0,0 +1,10 @@ +services: + api: + build: + context: . + dockerfile: Dockerfile + image: exchange-api + container_name: exchange-api + ports: + - "8080:8080" + command: [ "dotnet", "Exchange.Api.dll" ] \ No newline at end of file