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/Configurations/Extensions/ApplicationBuilderExtensions.cs b/jobs/Backend/Task/ExchangeRateUpdater.Api/Configurations/Extensions/ApplicationBuilderExtensions.cs new file mode 100644 index 0000000000..518e8897b5 --- /dev/null +++ b/jobs/Backend/Task/ExchangeRateUpdater.Api/Configurations/Extensions/ApplicationBuilderExtensions.cs @@ -0,0 +1,31 @@ +namespace EchangeRateUpdater.Api.Configurations.Extensions; + +using Scalar.AspNetCore; + +public static class ApplicationBuilderExtensions +{ + public static IApplicationBuilder UseApiDocumentation(this WebApplication app) + { + var enableApiDocumentation = app.Configuration.GetValue("ApiDocumentation:Enabled", false); + if (!enableApiDocumentation) + { + return app; + } + + app.MapOpenApi(); + app.MapScalarApiReference((options, _) => + { + options + .AddPreferredSecuritySchemes("Bearer") + .WithTitle("Exchange Rates Updater API") + .WithDarkMode(false) + .WithLayout(ScalarLayout.Classic) + .WithDefaultHttpClient(ScalarTarget.Shell, ScalarClient.Curl) + .WithTheme(ScalarTheme.Kepler) + .WithModels(false) + .WithDefaultOpenAllTags(false); + }); + + return app; + } +} diff --git a/jobs/Backend/Task/ExchangeRateUpdater.Api/Configurations/Extensions/OpenApiConfiguration.cs b/jobs/Backend/Task/ExchangeRateUpdater.Api/Configurations/Extensions/OpenApiConfiguration.cs new file mode 100644 index 0000000000..1c99b7dd07 --- /dev/null +++ b/jobs/Backend/Task/ExchangeRateUpdater.Api/Configurations/Extensions/OpenApiConfiguration.cs @@ -0,0 +1,26 @@ +namespace EchangeRateUpdater.Api.Configurations.Extensions; + +using Microsoft.OpenApi.Models; + +public static class OpenApiConfiguration +{ + public static IServiceCollection AddOpenApiConfiguration(this IServiceCollection services) + { + services.AddOpenApi(options => + { + options.AddDocumentTransformer((document, _, _) => + { + document.Info.Title = "Exchange Rate Updater API"; + document.Info.Contact = new OpenApiContact + { + Name = "Amin Ch", + Email = "experimentalaminch@outlook.com" + }; + + return Task.CompletedTask; + }); + }); + + return services; + } +} \ No newline at end of file diff --git a/jobs/Backend/Task/ExchangeRateUpdater.Api/Configurations/Extensions/ServiceCollectionExtensions.cs b/jobs/Backend/Task/ExchangeRateUpdater.Api/Configurations/Extensions/ServiceCollectionExtensions.cs new file mode 100644 index 0000000000..e6f48556ee --- /dev/null +++ b/jobs/Backend/Task/ExchangeRateUpdater.Api/Configurations/Extensions/ServiceCollectionExtensions.cs @@ -0,0 +1,19 @@ +namespace EchangeRateUpdater.Api.Configurations.Extensions; + +using Serilog; + +public static class ServiceCollectionExtensions +{ + public static IServiceCollection AddApiServices(this IServiceCollection services) + { + services.AddHealthChecks(); + + services + .AddExceptionHandler() + .AddProblemDetails() + .AddSerilog() + .AddOpenApiConfiguration(); + + return services; + } +} \ No newline at end of file diff --git a/jobs/Backend/Task/ExchangeRateUpdater.Api/Controllers/ExchangeRatesController.cs b/jobs/Backend/Task/ExchangeRateUpdater.Api/Controllers/ExchangeRatesController.cs new file mode 100644 index 0000000000..3dd812f7bc --- /dev/null +++ b/jobs/Backend/Task/ExchangeRateUpdater.Api/Controllers/ExchangeRatesController.cs @@ -0,0 +1,21 @@ +namespace ExchangeRateUpdater.Api.Controllers; + +using Application.ExchangeRates.Dtos; +using Application.ExchangeRates.Query.GetExchangeRatesDaily; +using Mediator; +using Microsoft.AspNetCore.Mvc; + +[ApiController] +[Route("exchange-rates")] +public class ExchangeRatesController (IMediator mediator) : Controller +{ + [HttpGet] + [ProducesResponseType(typeof(List), StatusCodes.Status200OK)] + [ProducesResponseType(typeof(ValidationProblemDetails), StatusCodes.Status400BadRequest)] + public async Task>> GetExchangeRatesByDate( + [FromQuery] GetExchangesRatesByDateQuery query) + { + var result = await mediator.Send(query); + return Ok(result); + } +} \ No newline at end of file diff --git a/jobs/Backend/Task/ExchangeRateUpdater.Api/Dockerfile b/jobs/Backend/Task/ExchangeRateUpdater.Api/Dockerfile new file mode 100644 index 0000000000..9f6af80bf8 --- /dev/null +++ b/jobs/Backend/Task/ExchangeRateUpdater.Api/Dockerfile @@ -0,0 +1,32 @@ +FROM mcr.microsoft.com/dotnet/aspnet:9.0 AS base +WORKDIR /app + +FROM mcr.microsoft.com/dotnet/sdk:9.0 AS build +ARG BUILD_CONFIGURATION=Release +WORKDIR /src + +# Copy csproj files to leverage Docker layer caching during restore +COPY ExchangeRateUpdater.Api/ExchangeRateUpdater.Api.csproj ExchangeRateUpdater.Api/ +COPY ExchangeRateUpdater.Application/ExchangeRateUpdater.Application.csproj ExchangeRateUpdater.Application/ +COPY ExchangeRateUpdater.Infrastructure/ExchangeRateUpdater.Infrastructure.csproj ExchangeRateUpdater.Infrastructure/ +COPY ExchangeRateUpdater.Domain/ExchangeRateUpdater.Domain.csproj ExchangeRateUpdater.Domain/ + +RUN dotnet restore ExchangeRateUpdater.Api/ExchangeRateUpdater.Api.csproj + +# Copy remaining sources +COPY . . + +WORKDIR /src/ExchangeRateUpdater.Api +RUN dotnet build "ExchangeRateUpdater.Api.csproj" -c $BUILD_CONFIGURATION -o /app/build + +FROM build AS publish +ARG BUILD_CONFIGURATION=Release +WORKDIR /src/ExchangeRateUpdater.Api +RUN dotnet publish "ExchangeRateUpdater.Api.csproj" -c $BUILD_CONFIGURATION -o /app/publish /p:UseAppHost=false + +FROM base AS final +WORKDIR /app +COPY --from=publish /app/publish ./ + +# Start the published API DLL directly. +ENTRYPOINT ["dotnet", "ExchangeRateUpdater.Api.dll"] \ 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..e6e3c4ccef --- /dev/null +++ b/jobs/Backend/Task/ExchangeRateUpdater.Api/ExchangeRateUpdater.Api.csproj @@ -0,0 +1,27 @@ + + + + net9.0 + enable + enable + EchangeRateUpdater.Api + + + + + + + + + + + + + + + + appsettings.json + + + + diff --git a/jobs/Backend/Task/ExchangeRateUpdater.Api/GlobalExceptionHandler.cs b/jobs/Backend/Task/ExchangeRateUpdater.Api/GlobalExceptionHandler.cs new file mode 100644 index 0000000000..7cfcc4c680 --- /dev/null +++ b/jobs/Backend/Task/ExchangeRateUpdater.Api/GlobalExceptionHandler.cs @@ -0,0 +1,62 @@ +namespace EchangeRateUpdater.Api; + +using System.Text.Json; +using FluentValidation; +using Microsoft.AspNetCore.Diagnostics; +using Microsoft.AspNetCore.Mvc; + +public class GlobalExceptionHandler( + IProblemDetailsService problemDetailsService, + ILogger logger) : IExceptionHandler +{ + public async ValueTask TryHandleAsync( + HttpContext httpContext, + Exception exception, + CancellationToken cancellationToken) + { + logger.LogError(exception, "An unexpected error occurred while processing the request."); + + var responseStatusCode = StatusCodes.Status500InternalServerError; + var problemDetails = new ProblemDetails + { + Type = "internal_server_error", + Title = "An unexpected error occurred", + Instance = httpContext.Request.Path + }; + + if (exception is ValidationException validationException) + { + responseStatusCode = StatusCodes.Status400BadRequest; + var errors = validationException.Errors + .GroupBy(x => x.PropertyName) + .ToDictionary( + g => JsonNamingPolicy.CamelCase.ConvertName(g.Key), + g => g.Select(x => x.ErrorMessage).ToArray() + ); + + problemDetails.Type = "validation_error"; + + if (errors.Count > 0) + { + problemDetails.Title = "One or more validation errors occurred"; + problemDetails.Extensions.Add("errors", errors); + } + else + { + problemDetails.Title = validationException.Message; + } + } + + httpContext.Response.StatusCode = responseStatusCode; + problemDetails.Status = responseStatusCode; + var context = new ProblemDetailsContext + { + HttpContext = httpContext, + Exception = exception, + ProblemDetails = problemDetails + }; + + await problemDetailsService.WriteAsync(context); + return true; + } +} \ No newline at end of file diff --git a/jobs/Backend/Task/ExchangeRateUpdater.Api/Program.cs b/jobs/Backend/Task/ExchangeRateUpdater.Api/Program.cs new file mode 100644 index 0000000000..0878a3eac4 --- /dev/null +++ b/jobs/Backend/Task/ExchangeRateUpdater.Api/Program.cs @@ -0,0 +1,47 @@ +using EchangeRateUpdater.Api.Configurations.Extensions; +using ExchangeRateUpdater.Application; +using ExchangeRateUpdater.Infrastructure; +using Serilog; + +InitializeBootstrapLogger(); + +try +{ + var builder = WebApplication.CreateBuilder(args); + + builder.Services.AddControllers(); + + builder.Services.AddApiServices() + .AddApplicationServices() + .AddInfrastructure(builder.Configuration) + .AddControllers(); + + var app = builder.Build(); + + app.UseExceptionHandler(); + app.UseHealthChecks("/health"); + + app.UseHttpsRedirection(); + app.UseApiDocumentation(); + app.MapControllers(); + + app.Run(); +} +catch (Exception ex) +{ + Log.Error(ex, "Unhandled exception"); +} +finally +{ + await Log.CloseAndFlushAsync(); +} + +return; + + +void InitializeBootstrapLogger() +{ + var config = new LoggerConfiguration().WriteTo.Console(); + + Log.Logger = config.CreateBootstrapLogger(); +} \ 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..1731fb8766 --- /dev/null +++ b/jobs/Backend/Task/ExchangeRateUpdater.Api/Properties/launchSettings.json @@ -0,0 +1,25 @@ +{ + "$schema": "https://json.schemastore.org/launchsettings.json", + "profiles": { + "http": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": true, + "launchUrl": "scalar", + "applicationUrl": "http://localhost:5087", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + }, + "https": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": true, + "launchUrl": "scalar", + "applicationUrl": "https://localhost:7169;http://localhost:5087", + "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..4725b8a7d2 --- /dev/null +++ b/jobs/Backend/Task/ExchangeRateUpdater.Api/appsettings.Development.json @@ -0,0 +1,17 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + }, + "ExchangeRateApiClientConfig": { + "BaseUrl": "https://api.cnb.cz/cnbapi/exrates/" + }, + "ApiDocumentation": { + "Enabled": true + }, + "Redis": { + "Connection": "localhost:6379" + } +} diff --git a/jobs/Backend/Task/ExchangeRateUpdater.Api/appsettings.json b/jobs/Backend/Task/ExchangeRateUpdater.Api/appsettings.json new file mode 100644 index 0000000000..ca32d894f3 --- /dev/null +++ b/jobs/Backend/Task/ExchangeRateUpdater.Api/appsettings.json @@ -0,0 +1,18 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + }, + "AllowedHosts": "*", + "ExchangeRateApiClientConfig": { + "BaseUrl": "https://api.cnb.cz/cnbapi/exrates/" + }, + "ApiDocumentation": { + "Enabled": true + }, + "Redis": { + "Connection": "localhost:6379" + } +} diff --git a/jobs/Backend/Task/ExchangeRateUpdater.Application/Common/Behaviours/MessageValidatorBehaviour.cs b/jobs/Backend/Task/ExchangeRateUpdater.Application/Common/Behaviours/MessageValidatorBehaviour.cs new file mode 100644 index 0000000000..72f70f01ba --- /dev/null +++ b/jobs/Backend/Task/ExchangeRateUpdater.Application/Common/Behaviours/MessageValidatorBehaviour.cs @@ -0,0 +1,16 @@ +namespace ExchangeRateUpdater.Application.Common.Behaviours; + +using FluentValidation; +using Mediator; + +public sealed class MessageValidatorBehaviour(IEnumerable> validators) : MessagePreProcessor + where TMessage : IMessage +{ + protected override async ValueTask Handle(TMessage message, CancellationToken cancellationToken) + { + foreach (var validator in validators) + { + await validator.ValidateAndThrowAsync(message, cancellationToken); + } + } +} diff --git a/jobs/Backend/Task/ExchangeRateUpdater.Application/Common/Exceptions/NotFoundException.cs b/jobs/Backend/Task/ExchangeRateUpdater.Application/Common/Exceptions/NotFoundException.cs new file mode 100644 index 0000000000..bc91a9b2ae --- /dev/null +++ b/jobs/Backend/Task/ExchangeRateUpdater.Application/Common/Exceptions/NotFoundException.cs @@ -0,0 +1,24 @@ +namespace ExchangeRateUpdater.Application.Common.Exceptions; + +public class NotFoundException: Exception +{ + public NotFoundException() + : base() + { + } + + public NotFoundException(string message) + : base(message) + { + } + + public NotFoundException(string message, Exception innerException) + : base(message, innerException) + { + } + + public NotFoundException(string name, object key) + : base($"Entity \"{name}\" ({key}) was not found.") + { + } +} \ No newline at end of file diff --git a/jobs/Backend/Task/ExchangeRateUpdater.Application/Common/Interfaces/IExchangeRateApiClient.cs b/jobs/Backend/Task/ExchangeRateUpdater.Application/Common/Interfaces/IExchangeRateApiClient.cs new file mode 100644 index 0000000000..49a86e75f2 --- /dev/null +++ b/jobs/Backend/Task/ExchangeRateUpdater.Application/Common/Interfaces/IExchangeRateApiClient.cs @@ -0,0 +1,17 @@ +namespace ExchangeRateUpdater.Application.Common.Interfaces; + +using Domain.Enums; +using ExchangeRates.Dtos; + +public interface IExchangeRateApiClient +{ + /// + /// 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. + /// + Task> GetExchangeRatesAsync(DateTime? date, Language? language); + + Task> GetDefaultExchangeRatesForYearAsync(int year); +} \ No newline at end of file diff --git a/jobs/Backend/Task/ExchangeRateUpdater.Application/Common/Mappings/ExchangeRateMappingExtensions.cs b/jobs/Backend/Task/ExchangeRateUpdater.Application/Common/Mappings/ExchangeRateMappingExtensions.cs new file mode 100644 index 0000000000..b96f5f646b --- /dev/null +++ b/jobs/Backend/Task/ExchangeRateUpdater.Application/Common/Mappings/ExchangeRateMappingExtensions.cs @@ -0,0 +1,36 @@ +namespace ExchangeRateUpdater.Application.Common.Mappings; + +using ExchangeRates.Dtos; +using Domain.Entities; +using Domain.ValueObjects; + +public static class ExchangeRateMappingExtensions +{ + private const string DefaultTargetCurrencyCode = "CZK"; + + public static ExchangeRateDto ToDto(this ExchangeRate source) + { + return new ExchangeRateDto + { + SourceCurrencyCode = source.SourceCurrency.Code, + TargetCurrencyCode = "CZK", + Value = source.Value + }; + } + + public static ExchangeRate ToExchangeRateEntity(this ExchangeRateApiDto apiDto) + { + var sourceCurrency = new Currency(apiDto.CurrencyCode); + var targetCurrency = new Currency(DefaultTargetCurrencyCode); + var value = apiDto.Amount == 0 ? 0m : decimal.Divide(apiDto.Rate, apiDto.Amount); + + return new ExchangeRate(sourceCurrency, targetCurrency, value); + } + + public static ExchangeRate ToEntity(this ExchangeRateDto dto) + { + var source = new Domain.ValueObjects.Currency(dto.SourceCurrencyCode); + var target = new Domain.ValueObjects.Currency(dto.TargetCurrencyCode); + return new ExchangeRate(source, target, dto.Value); + } +} \ No newline at end of file diff --git a/jobs/Backend/Task/ExchangeRateUpdater.Application/Common/Models/Result.cs b/jobs/Backend/Task/ExchangeRateUpdater.Application/Common/Models/Result.cs new file mode 100644 index 0000000000..39065e18f6 --- /dev/null +++ b/jobs/Backend/Task/ExchangeRateUpdater.Application/Common/Models/Result.cs @@ -0,0 +1,43 @@ +namespace ExchangeRateUpdater.Application.Common.Models; + +public class Result +{ + public Result() + { + + } + private Result(T value, bool succeeded, string errorMessage) + { + Value = value; + Succeeded = succeeded; + Error = errorMessage; + } + + private Result(T value, bool succeeded, IEnumerable errors) + { + Value = value; + Succeeded = succeeded; + Errors = errors.ToArray(); + } + + public T Value { get; private set; } + public bool Succeeded { get; private set; } + + public string[] Errors { get; private set; } + public string Error { get; private set; } + + public static Result Success(T value) + { + return new Result(value, true, new string[] { }); + } + + public static Result Failure(string errorMessage) + { + return new Result(default(T), false, errorMessage); + } + + public static Result Failure(IEnumerable errorMessages) + { + return new Result(default(T), false, errorMessages); + } +} \ No newline at end of file diff --git a/jobs/Backend/Task/ExchangeRateUpdater.Application/Common/Utils/CacheKeyHelper.cs b/jobs/Backend/Task/ExchangeRateUpdater.Application/Common/Utils/CacheKeyHelper.cs new file mode 100644 index 0000000000..8da8a59cbc --- /dev/null +++ b/jobs/Backend/Task/ExchangeRateUpdater.Application/Common/Utils/CacheKeyHelper.cs @@ -0,0 +1,11 @@ +namespace ExchangeRateUpdater.Application.Common.Utils; + +using ExchangeRateUpdater.Domain.Enums; + +public static class CacheKeyHelper +{ + public static string RatesKey(Language language, DateTime date) => + $"exrates:{language.ToString().ToLower()}:{date:yyyy-MM-dd}"; + + public static TimeSpan DefaultTtlYears(int years) => TimeSpan.FromDays(365 * Math.Max(1, years)); +} \ 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..1e8e4b5b3a --- /dev/null +++ b/jobs/Backend/Task/ExchangeRateUpdater.Application/ExchangeRateUpdater.Application.csproj @@ -0,0 +1,24 @@ + + + + net9.0 + enable + enable + ExchangeRateUpdater.Application + + + + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + + + + diff --git a/jobs/Backend/Task/ExchangeRateUpdater.Application/ExchangeRates/Dtos/ExchangeRateApiDto.cs b/jobs/Backend/Task/ExchangeRateUpdater.Application/ExchangeRates/Dtos/ExchangeRateApiDto.cs new file mode 100644 index 0000000000..6c9ea9cbe5 --- /dev/null +++ b/jobs/Backend/Task/ExchangeRateUpdater.Application/ExchangeRates/Dtos/ExchangeRateApiDto.cs @@ -0,0 +1,17 @@ +namespace ExchangeRateUpdater.Application.ExchangeRates.Dtos; + +public class ExchangeRateApiDto +{ + public string ValidFor { get; set; } + public int Order { get; set; } + public string Country { get; set; } + public string Currency { get; set; } + public int Amount { get; set; } + public string CurrencyCode { get; set; } + public decimal Rate { get; set; } +} + +public class ExchangeYearRatesApiDto +{ + public List Rates { get; set; } = new(); +} \ No newline at end of file diff --git a/jobs/Backend/Task/ExchangeRateUpdater.Application/ExchangeRates/Dtos/ExchangeRateApiResponse.cs b/jobs/Backend/Task/ExchangeRateUpdater.Application/ExchangeRates/Dtos/ExchangeRateApiResponse.cs new file mode 100644 index 0000000000..77512e9d82 --- /dev/null +++ b/jobs/Backend/Task/ExchangeRateUpdater.Application/ExchangeRates/Dtos/ExchangeRateApiResponse.cs @@ -0,0 +1,6 @@ +namespace ExchangeRateUpdater.Application.ExchangeRates.Dtos; + +public class ExchangeRateApiResponse +{ + public ExchangeRateApiDto[] Rates { get; set; } +} \ No newline at end of file diff --git a/jobs/Backend/Task/ExchangeRateUpdater.Application/ExchangeRates/Dtos/ExchangeRateDto.cs b/jobs/Backend/Task/ExchangeRateUpdater.Application/ExchangeRates/Dtos/ExchangeRateDto.cs new file mode 100644 index 0000000000..f16a509bee --- /dev/null +++ b/jobs/Backend/Task/ExchangeRateUpdater.Application/ExchangeRates/Dtos/ExchangeRateDto.cs @@ -0,0 +1,21 @@ +namespace ExchangeRateUpdater.Application.ExchangeRates.Dtos; + +public record ExchangeRateDto +{ + /// + /// Source currency of the exchange rate. + /// + public string SourceCurrencyCode { get; set; } + + /// + /// Target currency of the exchange rate. + /// + public string TargetCurrencyCode { get; set; } + + /// + /// Value of the exchange rate from 1 unit of the source currency to the target currency. + /// + public decimal Value { get; set; } + + public sealed override string ToString() => $"{SourceCurrencyCode}/{TargetCurrencyCode}={Value}"; +} \ No newline at end of file diff --git a/jobs/Backend/Task/ExchangeRateUpdater.Application/ExchangeRates/Query/GetExchangeRatesDaily/GetExchangesRatesByDateQuery.cs b/jobs/Backend/Task/ExchangeRateUpdater.Application/ExchangeRates/Query/GetExchangeRatesDaily/GetExchangesRatesByDateQuery.cs new file mode 100644 index 0000000000..f4ff6ed5dc --- /dev/null +++ b/jobs/Backend/Task/ExchangeRateUpdater.Application/ExchangeRates/Query/GetExchangeRatesDaily/GetExchangesRatesByDateQuery.cs @@ -0,0 +1,24 @@ +namespace ExchangeRateUpdater.Application.ExchangeRates.Query.GetExchangeRatesDaily; + +using Domain.Enums; +using Dtos; +using Mediator; + +public class GetExchangesRatesByDateQuery : IQuery> +{ + /// + /// List of three-letter ISO 4217 currency codes for which exchange rates are requested. + /// + public required List CurrencyCodes { get; set; } + + /// + /// Date for which exchange rates are requested. + /// If null, the latest available rates are fetched. + /// + public DateTime? Date { get; set; } + + /// + /// Language enumeration; default value: CZ + /// + public Language? Language { get; set; } +} \ No newline at end of file diff --git a/jobs/Backend/Task/ExchangeRateUpdater.Application/ExchangeRates/Query/GetExchangeRatesDaily/GetExchangesRatesByDateQueryHandler.cs b/jobs/Backend/Task/ExchangeRateUpdater.Application/ExchangeRates/Query/GetExchangeRatesDaily/GetExchangesRatesByDateQueryHandler.cs new file mode 100644 index 0000000000..f55473926b --- /dev/null +++ b/jobs/Backend/Task/ExchangeRateUpdater.Application/ExchangeRates/Query/GetExchangeRatesDaily/GetExchangesRatesByDateQueryHandler.cs @@ -0,0 +1,51 @@ +namespace ExchangeRateUpdater.Application.ExchangeRates.Query.GetExchangeRatesDaily; + +using Common.Mappings; +using Common.Utils; +using Domain.Common; +using Domain.Entities; +using Domain.Enums; +using Domain.Repositories; +using Domain.ValueObjects; +using Dtos; +using Mediator; + +/// +/// 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 class GetExchangesRatesByDateQueryHandler : IQueryHandler> +{ + private readonly ICacheRepository _redisRepository; + + public GetExchangesRatesByDateQueryHandler(ICacheRepository redisRepository) + { + Ensure.Argument.NotNull(redisRepository, nameof(redisRepository)); + _redisRepository = redisRepository; + } + + public async ValueTask> Handle(GetExchangesRatesByDateQuery request, + CancellationToken cancellationToken) + { + var cacheKey = GetCacheKey(request); + + var requestedCurrencies = request.CurrencyCodes.Select(currencyCode => new Currency(currencyCode)); + var exchangeRates = await _redisRepository + .GetRatesListAsync(cacheKey); + + var requestedExchangeRates = (exchangeRates ?? Enumerable.Empty()) + .Where(rates => requestedCurrencies.Any(currency => currency == rates.SourceCurrency)) + .Select(exchangeRate => exchangeRate.ToDto()).ToList(); + + return requestedExchangeRates; + } + + private string GetCacheKey(GetExchangesRatesByDateQuery request) + { + var requestedDate = request.Date ?? DateTime.UtcNow.Date; + var requestedLanguage = request.Language ?? Language.CZ; + return CacheKeyHelper.RatesKey(requestedLanguage, requestedDate); + } +} \ No newline at end of file diff --git a/jobs/Backend/Task/ExchangeRateUpdater.Application/ExchangeRates/Query/GetExchangeRatesDaily/GetExchangesRatesByDateQueryValidator.cs b/jobs/Backend/Task/ExchangeRateUpdater.Application/ExchangeRates/Query/GetExchangeRatesDaily/GetExchangesRatesByDateQueryValidator.cs new file mode 100644 index 0000000000..7c1fd98ae2 --- /dev/null +++ b/jobs/Backend/Task/ExchangeRateUpdater.Application/ExchangeRates/Query/GetExchangeRatesDaily/GetExchangesRatesByDateQueryValidator.cs @@ -0,0 +1,17 @@ +namespace ExchangeRateUpdater.Application.ExchangeRates.Query.GetExchangeRatesDaily; + +using FluentValidation; + +public class GetExchangesRatesByDateQueryValidator : AbstractValidator +{ + public GetExchangesRatesByDateQueryValidator() + { + RuleFor(x => x.CurrencyCodes) + .NotEmpty().NotNull() + .ForEach(code => + { + code.NotEmpty().NotNull().Must(x => x.Length == 3) + .WithMessage("Currency Code must to be three-letter ISO 4217 code of the currency."); + }); + } +} \ No newline at end of file diff --git a/jobs/Backend/Task/ExchangeRateUpdater.Application/ServiceCollectionExtensions.cs b/jobs/Backend/Task/ExchangeRateUpdater.Application/ServiceCollectionExtensions.cs new file mode 100644 index 0000000000..15a3bd4a72 --- /dev/null +++ b/jobs/Backend/Task/ExchangeRateUpdater.Application/ServiceCollectionExtensions.cs @@ -0,0 +1,22 @@ +namespace ExchangeRateUpdater.Application; + +using Common.Behaviours; +using FluentValidation; +using Microsoft.Extensions.DependencyInjection; + +public static class ServiceCollectionExtensions +{ + public static IServiceCollection AddApplicationServices(this IServiceCollection services) + { + services.AddMediator(options => + { + options.ServiceLifetime = ServiceLifetime.Transient; + options.GenerateTypesAsInternal = true; + options.Assemblies = [typeof(ServiceCollectionExtensions).Assembly]; + options.PipelineBehaviors = [typeof(MessageValidatorBehaviour<,>)]; + }) + .AddValidatorsFromAssembly(typeof(ServiceCollectionExtensions).Assembly); + + return services; + } +} \ No newline at end of file diff --git a/jobs/Backend/Task/ExchangeRateUpdater.Domain/Common/Ensure.cs b/jobs/Backend/Task/ExchangeRateUpdater.Domain/Common/Ensure.cs new file mode 100644 index 0000000000..1ea5f94bd7 --- /dev/null +++ b/jobs/Backend/Task/ExchangeRateUpdater.Domain/Common/Ensure.cs @@ -0,0 +1,208 @@ +namespace ExchangeRateUpdater.Domain.Common; + +using System.Diagnostics; + +/// +/// Will throw exceptions when conditions are not satisfied. +/// +[DebuggerStepThrough] +public static class Ensure +{ + /// + /// Ensures that the given expression is true + /// + /// Exception thrown if false condition + /// Condition to test/ensure + /// Message for the exception + /// Thrown when is false + public static void That(bool condition, string message = "") + { + That(condition, message); + } + + /// + /// Ensures that the given expression is true + /// + /// Type of exception to throw + /// Condition to test/ensure + /// Message for the exception + /// Thrown when is false + /// must have a constructor that takes a single string + public static void That(bool condition, string message = "") where TException : Exception + { + if (!condition) + { + throw (TException)Activator.CreateInstance(typeof(TException), message); + } + } + + /// + /// Ensures given condition is false + /// + /// Type of exception to throw + /// Condition to test + /// Message for the exception + /// Thrown when is true + /// must have a constructor that takes a single string + public static void Not(bool condition, string message = "") where TException : Exception + { + That(!condition, message); + } + + /// + /// Ensures given condition is false + /// + /// Condition to test + /// Message for the exception + /// Thrown when is true + public static void Not(bool condition, string message = "") + { + Not(condition, message); + } + + /// + /// Ensures given object is not null + /// + /// Value of the object to test for null reference + /// Message for the Null Reference Exception + /// Thrown when is null + public static void NotNull(object value, string message = "") + { + That(value != null, message); + } + + /// + /// Ensures given string is not null or empty + /// + /// String value to compare + /// Message of the exception if value is null or empty + /// string value is null or empty + public static void NotNullOrEmpty(string value, string message = "String cannot be null or empty") + { + That(!String.IsNullOrEmpty(value), message); + } + + /// + /// Ensures given objects are equal + /// + /// Type of objects to compare for equality + /// First Value to Compare + /// Second Value to Compare + /// Message of the exception when values equal + /// Exception is thrown when not equal to + /// Null values will cause an exception to be thrown + public static void Equal(T left, T right, string message = "Values must be equal") + { + That(left != null && right != null && left.Equals(right), message); + } + + /// + /// Ensures given objects are not equal + /// + /// Type of objects to compare for equality + /// First Value to Compare + /// Second Value to Compare + /// Message of the exception when values equal + /// Thrown when equal to + /// Null values will cause an exception to be thrown + public static void NotEqual(T left, T right, string message = "Values must not be equal") + { + That(left != null && right != null && !left.Equals(right), message); + } + + /// + /// Ensures given collection contains a value that satisfied a predicate + /// + /// Collection type + /// Collection to test + /// Predicate where one value in the collection must satisfy + /// Message of the exception if value not found + /// + /// Thrown if collection is null, empty or doesn't contain a value that satisfies + /// + public static void Contains(IEnumerable collection, Func predicate, string message = "") + { + That(collection != null && collection.Any(predicate), message); + } + + /// + /// Ensures ALL items in the given collection satisfy a predicate + /// + /// Collection type + /// Collection to test + /// Predicate that ALL values in the collection must satisfy + /// Message of the exception if not all values are valid + /// + /// Thrown if collection is null, empty or not all values satisfies + /// + public static void Items(IEnumerable collection, Func predicate, string message = "") + { + That(collection != null && !collection.Any(x => !predicate(x)), message); + } + + /// + /// Argument-specific ensure methods + /// + public static class Argument + { + /// + /// Ensures given condition is true + /// + /// Condition to test + /// Message of the exception if condition fails + /// + /// Thrown if is false + /// + public static void Is(bool condition, string message = "") + { + That(condition, message); + } + + /// + /// Ensures given condition is false + /// + /// Condition to test + /// Message of the exception if condition is true + /// + /// Thrown if is true + /// + public static void IsNot(bool condition, string message = "") + { + Is(!condition, message); + } + + /// + /// Ensures given value is not null + /// + /// Value to test for null + /// Name of the parameter in the method + /// + /// Thrown if is null + /// + public static void NotNull(object value, string paramName = "") + { + That(value != null, paramName); + } + + /// + /// Ensures the given string value is not null or empty + /// + /// Value to test for null or empty + /// Name of the parameter in the method + /// + /// Thrown if is null or empty string + /// + public static void NotNullOrEmpty(string value, string paramName = "") + { + if (value == null) + { + throw new ArgumentNullException(paramName, "String value cannot be null"); + } + + if (string.Empty.Equals(value)) + { + throw new ArgumentException("String value cannot be empty", paramName); + } + } + } +} \ No newline at end of file diff --git a/jobs/Backend/Task/ExchangeRateUpdater.Domain/Entities/ExchangeRate.cs b/jobs/Backend/Task/ExchangeRateUpdater.Domain/Entities/ExchangeRate.cs new file mode 100644 index 0000000000..1cb9be7b6e --- /dev/null +++ b/jobs/Backend/Task/ExchangeRateUpdater.Domain/Entities/ExchangeRate.cs @@ -0,0 +1,17 @@ +namespace ExchangeRateUpdater.Domain.Entities; + +using ValueObjects; + +public class ExchangeRate(Currency sourceCurrency, Currency targetCurrency, decimal value) +{ + public Currency SourceCurrency { get; set; } = sourceCurrency; + + public Currency TargetCurrency { get; set; } = targetCurrency; + + public decimal Value { get; } = value; + + public override string ToString() + { + return $"{SourceCurrency}/{TargetCurrency}={Value}"; + } +} \ No newline at end of file diff --git a/jobs/Backend/Task/ExchangeRateUpdater.Domain/Enums/Language.cs b/jobs/Backend/Task/ExchangeRateUpdater.Domain/Enums/Language.cs new file mode 100644 index 0000000000..a0a067290a --- /dev/null +++ b/jobs/Backend/Task/ExchangeRateUpdater.Domain/Enums/Language.cs @@ -0,0 +1,7 @@ +namespace ExchangeRateUpdater.Domain.Enums; + +public enum Language +{ + CZ, + EN +} \ No newline at end of file diff --git a/jobs/Backend/Task/ExchangeRateUpdater.Domain/ExchangeRateUpdater.Domain.csproj b/jobs/Backend/Task/ExchangeRateUpdater.Domain/ExchangeRateUpdater.Domain.csproj new file mode 100644 index 0000000000..7a57e2526b --- /dev/null +++ b/jobs/Backend/Task/ExchangeRateUpdater.Domain/ExchangeRateUpdater.Domain.csproj @@ -0,0 +1,10 @@ + + + + net9.0 + enable + enable + ExchangeRateUpdater.Domain + + + diff --git a/jobs/Backend/Task/ExchangeRateUpdater.Domain/Repositories/ICacheRepository.cs b/jobs/Backend/Task/ExchangeRateUpdater.Domain/Repositories/ICacheRepository.cs new file mode 100644 index 0000000000..854c3a4bf7 --- /dev/null +++ b/jobs/Backend/Task/ExchangeRateUpdater.Domain/Repositories/ICacheRepository.cs @@ -0,0 +1,21 @@ +namespace ExchangeRateUpdater.Domain.Repositories; + +using Domain.Entities; +using System.Collections.Generic; + +public interface ICacheRepository +{ + Task?> GetRatesDictionaryAsync(string key); + + Task?> GetRatesListAsync(string key); + + Task SetRatesAsync(string key, Dictionary value, TimeSpan expirationDate); + + Task SetRatesAsync(string key, Dictionary value); + + Task SetRatesListAsync(string key, List value, TimeSpan expirationDate); + + Task SetRatesListAsync(string key, List value); + + Task ClearCacheAsync(string key); +} \ No newline at end of file diff --git a/jobs/Backend/Task/ExchangeRateUpdater.Domain/ValueObjects/Currency.cs b/jobs/Backend/Task/ExchangeRateUpdater.Domain/ValueObjects/Currency.cs new file mode 100644 index 0000000000..62b9fb4fcc --- /dev/null +++ b/jobs/Backend/Task/ExchangeRateUpdater.Domain/ValueObjects/Currency.cs @@ -0,0 +1,18 @@ +namespace ExchangeRateUpdater.Domain.ValueObjects; + +public record Currency +{ + public Currency(string code) + { + Code = code.ToUpperInvariant(); + } + /// + /// Three-letter ISO 4217 code of the currency. + /// + public string Code { get; init; } + + public sealed override string ToString() + { + return Code; + } +} \ No newline at end of file diff --git a/jobs/Backend/Task/ExchangeRateUpdater.Infrastructure/ApiClients/ExchangeRateApiClient.cs b/jobs/Backend/Task/ExchangeRateUpdater.Infrastructure/ApiClients/ExchangeRateApiClient.cs new file mode 100644 index 0000000000..8ff6f98c57 --- /dev/null +++ b/jobs/Backend/Task/ExchangeRateUpdater.Infrastructure/ApiClients/ExchangeRateApiClient.cs @@ -0,0 +1,68 @@ +namespace ExchangeRateUpdater.Infrastructure.ApiClients; + +using System.Text; +using System.Text.Json; +using Application.Common.Interfaces; +using Application.ExchangeRates.Dtos; +using Domain.Common; +using Domain.Enums; + +public class ExchangeRateApiClient : IExchangeRateApiClient +{ + private readonly HttpClient _httpClient; + + public ExchangeRateApiClient(HttpClient httpClient) + { + Ensure.Argument.NotNull(httpClient, nameof(httpClient)); + _httpClient = httpClient; + } + + public async Task> GetExchangeRatesAsync(DateTime? date, Language? language) + { + var endpointRute = BuildExchangeRateDailyEndpointPath(date, language); + var response = await _httpClient.GetAsync(endpointRute); + response.EnsureSuccessStatusCode(); + + var apiResponse = await response.Content.ReadAsStringAsync(); + var exchangeRates = JsonSerializer.Deserialize(apiResponse, new JsonSerializerOptions + { + PropertyNamingPolicy = JsonNamingPolicy.CamelCase + }); + return exchangeRates!.Rates; + } + + public async Task> GetDefaultExchangeRatesForYearAsync(int year) + { + var endpointRute = BuildExchangeRateDailyYearEndpointPath(year); + var response = await _httpClient.GetAsync(endpointRute); + response.EnsureSuccessStatusCode(); + var apiResponse = await response.Content.ReadAsStringAsync(); + var yearResponse = JsonSerializer.Deserialize(apiResponse, new JsonSerializerOptions + { + PropertyNamingPolicy = JsonNamingPolicy.CamelCase + }); + return yearResponse!.Rates; + } + + private string BuildExchangeRateDailyYearEndpointPath(int year) + => new StringBuilder("daily-year?year=" + year).ToString(); + + private string BuildExchangeRateDailyEndpointPath(DateTime? date, Language? language) + { + var stringBuilder = new StringBuilder(string.Empty); + + stringBuilder.Append("daily"); + + if (date is not null) + { + stringBuilder.Append($"?date={date:yyyy-MM-dd}"); + } + + if (language is not null) + { + stringBuilder.Append($"&lang={language}"); + } + + return stringBuilder.ToString(); + } +} \ No newline at end of file diff --git a/jobs/Backend/Task/ExchangeRateUpdater.Infrastructure/Cache/RedisCacheRepository.cs b/jobs/Backend/Task/ExchangeRateUpdater.Infrastructure/Cache/RedisCacheRepository.cs new file mode 100644 index 0000000000..4e72745eb5 --- /dev/null +++ b/jobs/Backend/Task/ExchangeRateUpdater.Infrastructure/Cache/RedisCacheRepository.cs @@ -0,0 +1,142 @@ +namespace ExchangeRateUpdater.Infrastructure.Cache; + +using System; +using System.Text.Json; +using Application.ExchangeRates.Dtos; +using Domain.Repositories; +using Microsoft.Extensions.Logging; +using StackExchange.Redis; +using System.Collections.Generic; +using System.Linq; +using Application.Common.Mappings; +using Domain.Common; +using Domain.Entities; + +public class RedisCacheRepository : ICacheRepository +{ + private readonly IDatabase _database; + private readonly ILogger _logger; + + public RedisCacheRepository(IConnectionMultiplexer connection, ILogger logger) + { + Ensure.Argument.NotNull(connection, nameof(connection)); + Ensure.Argument.NotNull(logger, nameof(logger)); + _database = connection.GetDatabase(); + _logger = logger; + } + + public async Task?> GetRatesDictionaryAsync(string key) + { + try + { + var entries = await _database.HashGetAllAsync(key); + if (entries.Length == 0) + { + _logger.LogInformation("{Key} not found in redis cache", key); + return default; + } + + var dict = new Dictionary(); + foreach (var entry in entries) + { + var dto = JsonSerializer.Deserialize(entry.Value!); + if (dto != null) + { + dict[entry.Name!] = dto.ToEntity(); + } + } + + _logger.LogInformation("{Key} found in redis cache (hash)", key); + return dict; + } + catch (Exception e) + { + _logger.LogError(e, "Failed to get or deserialize cached value for {Key}", key); + throw; + } + } + + public async Task?> GetRatesListAsync(string key) + { + try + { + var dictionary = await GetRatesDictionaryAsync(key); + return dictionary?.Values.ToList(); + } + catch (Exception e) + { + _logger.LogError(e, "Failed to get or deserialize cached value for {Key}", key); + throw; + } + } + + public async Task SetRatesAsync(string key, Dictionary value) + { + try + { + var hashEntries = value.Select(pair => + new HashEntry(pair.Key, JsonSerializer.Serialize(pair.Value.ToDto())) + ).ToArray(); + + await _database.HashSetAsync(key, hashEntries); + _logger.LogInformation("{Key} added to redis cache (hash)", key); + } + catch (Exception e) + { + _logger.LogError(e, "Failed to set cache for {Key}", key); + } + } + + public async Task SetRatesAsync(string key, Dictionary value, TimeSpan expirationDate) + { + try + { + var hashEntries = value.Select(pair => + new HashEntry(pair.Key, JsonSerializer.Serialize(pair.Value.ToDto())) + ).ToArray(); + + await _database.HashSetAsync(key, hashEntries); + await _database.KeyExpireAsync(key, expirationDate); + _logger.LogInformation("{Key} added to redis cache (hash)", key); + } + catch (Exception e) + { + _logger.LogError(e, "Failed to set cache for {Key}", key); + throw; + } + } + + public async Task SetRatesListAsync(string key, List value, TimeSpan expirationDate) + { + try + { + var json = JsonSerializer.Serialize(value.Select(r => r.ToDto())); + await _database.StringSetAsync(key, json, expirationDate); + _logger.LogInformation("{Key} added to redis cache (string)", key); + } + catch (Exception e) + { + _logger.LogError(e, "Failed to set cache for {Key}", key); + throw; + } + } + + public async Task SetRatesListAsync(string key, List value) + { + var defaultExpiration = TimeSpan.FromDays(365); + await SetRatesListAsync(key, value, defaultExpiration); + } + + public async Task ClearCacheAsync(string key) + { + try + { + await _database.KeyDeleteAsync(key); + _logger.LogInformation("{Key} removed from redis cache", key); + } + catch (Exception e) + { + _logger.LogError(e, "Failed to clear cache for {Key}", key); + } + } +} \ No newline at end of file diff --git a/jobs/Backend/Task/ExchangeRateUpdater.Infrastructure/Configurations/ExchangeRateApiClientConfig.cs b/jobs/Backend/Task/ExchangeRateUpdater.Infrastructure/Configurations/ExchangeRateApiClientConfig.cs new file mode 100644 index 0000000000..f94db90890 --- /dev/null +++ b/jobs/Backend/Task/ExchangeRateUpdater.Infrastructure/Configurations/ExchangeRateApiClientConfig.cs @@ -0,0 +1,6 @@ +namespace ExchangeRateUpdater.Infrastructure.Configurations; + +public class ExchangeRateApiClientConfig +{ + public string BaseUrl { get; set; } +} \ No newline at end of file diff --git a/jobs/Backend/Task/ExchangeRateUpdater.Infrastructure/Data/CacheRepository.cs b/jobs/Backend/Task/ExchangeRateUpdater.Infrastructure/Data/CacheRepository.cs new file mode 100644 index 0000000000..6649d61c88 --- /dev/null +++ b/jobs/Backend/Task/ExchangeRateUpdater.Infrastructure/Data/CacheRepository.cs @@ -0,0 +1,72 @@ +namespace ExchangeRateUpdater.Infrastructure.Data; + +using Domain.Common; +using Domain.Entities; +using Domain.Repositories; +using Microsoft.Extensions.Caching.Memory; +using Microsoft.Extensions.Logging; + +public class CacheRepository : ICacheRepository +{ + private const int DefaultExpirationHours = 1; + + private readonly IMemoryCache cache; + private readonly ILogger logger; + + public CacheRepository(IMemoryCache cache, ILogger logger) + { + Ensure.Argument.NotNull(cache, nameof(cache)); + Ensure.Argument.NotNull(logger, nameof(logger)); + this.cache = cache; + this.logger = logger; + } + + public Task?> GetRatesDictionaryAsync(string key) + { + cache.TryGetValue(key, out Dictionary? cachedResponse); + logger.LogInformation(cachedResponse is null ? $"{key} not found in cache" : $"{key} found in cache"); + return Task.FromResult(cachedResponse); + } + + public Task?> GetRatesListAsync(string key) + { + cache.TryGetValue(key, out List? cachedResponse); + logger.LogInformation(cachedResponse is null ? $"{key} not found in cache" : $"{key} found in cache"); + return Task.FromResult(cachedResponse); + } + + public Task SetRatesAsync(string key, Dictionary value, TimeSpan absoluteExpiration) + { + logger.LogInformation("{Key} added to cache", key); + cache.Set(key, value, new MemoryCacheEntryOptions() + .SetAbsoluteExpiration(absoluteExpiration)); + return Task.CompletedTask; + } + + public Task SetRatesAsync(string key, Dictionary value) + { + var defaultExpiration = TimeSpan.FromHours(DefaultExpirationHours); + return SetRatesAsync(key, value, defaultExpiration); + } + + public Task SetRatesListAsync(string key, List value, TimeSpan absoluteExpiration) + { + logger.LogInformation("{Key} added to cache", key); + cache.Set(key, value, new MemoryCacheEntryOptions() + .SetAbsoluteExpiration(absoluteExpiration)); + return Task.CompletedTask; + } + + public Task SetRatesListAsync(string key, List value) + { + var defaultExpiration = TimeSpan.FromHours(DefaultExpirationHours); + return SetRatesListAsync(key, value, defaultExpiration); + } + + public Task ClearCacheAsync(string key) + { + logger.LogInformation("{Key} removed from cache", key); + cache.Remove(key); + return Task.CompletedTask; + } +} \ 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..54c5d5f49b --- /dev/null +++ b/jobs/Backend/Task/ExchangeRateUpdater.Infrastructure/ExchangeRateUpdater.Infrastructure.csproj @@ -0,0 +1,24 @@ + + + + net9.0 + enable + enable + ExchangeRateUpdater.Infrastructure + + + + + + + + + + + + + + + + + diff --git a/jobs/Backend/Task/ExchangeRateUpdater.Infrastructure/ServiceCollectionExtensions.cs b/jobs/Backend/Task/ExchangeRateUpdater.Infrastructure/ServiceCollectionExtensions.cs new file mode 100644 index 0000000000..1d583b55b7 --- /dev/null +++ b/jobs/Backend/Task/ExchangeRateUpdater.Infrastructure/ServiceCollectionExtensions.cs @@ -0,0 +1,45 @@ +namespace ExchangeRateUpdater.Infrastructure; + +using System; +using ApiClients; +using Application.Common.Interfaces; +using Cache; +using Configurations; +using Domain.Repositories; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Options; +using Polly; +using Polly.Extensions.Http; +using StackExchange.Redis; + +public static class ServiceCollectionExtensions +{ + public static IServiceCollection AddInfrastructure(this IServiceCollection self, IConfiguration configuration) + { + self.Configure(configuration.GetSection(nameof(ExchangeRateApiClientConfig))) + .AddHttpClient((sp, httpClient) => + { + var exchangeRateApiClientConfig = sp.GetService>() ?? + throw new NullReferenceException($"{nameof(ExchangeRateApiClientConfig)} is not configured"); + httpClient.BaseAddress = new Uri(exchangeRateApiClientConfig.Value.BaseUrl); + }).AddPolicyHandler(GetRetryPolicy()); + + var redisConnection = configuration.GetValue("Redis:Connection"); + + if (string.IsNullOrWhiteSpace(redisConnection)) + { + throw new NullReferenceException("Redis:Connection setting is not configured"); + } + + self.AddScoped(sp => ConnectionMultiplexer.Connect(redisConnection)); + self.AddScoped(); + + return self; + } + + static IAsyncPolicy GetRetryPolicy() => + HttpPolicyExtensions + .HandleTransientHttpError() + .WaitAndRetryAsync(3, retryAttempt => TimeSpan.FromSeconds(Math.Pow(2, retryAttempt))); +} \ 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..77222bf306 --- /dev/null +++ b/jobs/Backend/Task/ExchangeRateUpdater.UnitTests/ExchangeRateUpdater.UnitTests.csproj @@ -0,0 +1,37 @@ + + + + net9.0 + enable + enable + false + UnitTests + + + + + + + + + + + + + + + + + + + + + + + + + ..\..\..\..\..\..\..\.dotnet\shared\Microsoft.AspNetCore.App\9.0.7\Microsoft.Extensions.Logging.Abstractions.dll + + + + diff --git a/jobs/Backend/Task/ExchangeRateUpdater.UnitTests/Fakers/FakeCacheRepository.cs b/jobs/Backend/Task/ExchangeRateUpdater.UnitTests/Fakers/FakeCacheRepository.cs new file mode 100644 index 0000000000..eb443b699d --- /dev/null +++ b/jobs/Backend/Task/ExchangeRateUpdater.UnitTests/Fakers/FakeCacheRepository.cs @@ -0,0 +1,55 @@ +namespace UnitTests.Fakers; + +using ExchangeRateUpdater.Domain.Entities; +using ExchangeRateUpdater.Domain.Repositories; + +public class FakeCacheRepository : ICacheRepository +{ + private readonly Dictionary _store = new(); + + public Task?> GetRatesDictionaryAsync(string key) + { + if (_store.TryGetValue(key, out var v) && v is Dictionary t) + return Task.FromResult?>(t); + + return Task.FromResult?>(default); + } + + public Task?> GetRatesListAsync(string key) + { + if (_store.TryGetValue(key, out var v) && v is List t) + return Task.FromResult?>(t); + + return Task.FromResult?>(default); + } + + public Task SetRatesAsync(string key, Dictionary value, TimeSpan expirationDate) + { + _store[key] = value!; + return Task.CompletedTask; + } + + public Task SetRatesAsync(string key, Dictionary value) + { + _store[key] = value!; + return Task.CompletedTask; + } + + public Task SetRatesListAsync(string key, List value, TimeSpan expirationDate) + { + _store[key] = value!; + return Task.CompletedTask; + } + + public Task SetRatesListAsync(string key, List value) + { + _store[key] = value!; + return Task.CompletedTask; + } + + public Task ClearCacheAsync(string key) + { + _store.Remove(key); + return Task.CompletedTask; + } +} \ No newline at end of file diff --git a/jobs/Backend/Task/ExchangeRateUpdater.UnitTests/Fakers/FakeExchangeRateApiClient.cs b/jobs/Backend/Task/ExchangeRateUpdater.UnitTests/Fakers/FakeExchangeRateApiClient.cs new file mode 100644 index 0000000000..7f446ac47d --- /dev/null +++ b/jobs/Backend/Task/ExchangeRateUpdater.UnitTests/Fakers/FakeExchangeRateApiClient.cs @@ -0,0 +1,40 @@ +namespace UnitTests.Fakers; + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using System.Threading; +using ExchangeRateUpdater.Application.ExchangeRates.Dtos; +using ExchangeRateUpdater.Application.Common.Interfaces; +using ExchangeRateUpdater.Domain.Enums; + +public class FakeExchangeRateApiClient : IExchangeRateApiClient +{ + public Func>>? OnGetExchangeRatesAsync { get; set; } + public Func>>? OnGetDefaultExchangeRatesForYearAsync { get; set; } + + public Task> GetExchangeRatesAsync(DateTime? date, Language? language) + { + if (OnGetExchangeRatesAsync != null) + return OnGetExchangeRatesAsync(date, language ?? Language.EN, CancellationToken.None); + + return Task.FromResult(Enumerable.Empty()); + } + + public Task> GetExchangeRatesAsync(DateTime? date, Language? language, CancellationToken ct) + { + if (OnGetExchangeRatesAsync != null) + return OnGetExchangeRatesAsync(date, language ?? Language.EN, ct); + + return Task.FromResult(Enumerable.Empty()); + } + + public Task> GetDefaultExchangeRatesForYearAsync(int year) + { + if (OnGetDefaultExchangeRatesForYearAsync != null) + return OnGetDefaultExchangeRatesForYearAsync(year); + + return Task.FromResult(new List()); + } +} diff --git a/jobs/Backend/Task/ExchangeRateUpdater.UnitTests/Handlers/GetExchangesRatesByDateQueryHandlerTests.cs b/jobs/Backend/Task/ExchangeRateUpdater.UnitTests/Handlers/GetExchangesRatesByDateQueryHandlerTests.cs new file mode 100644 index 0000000000..e44a50775a --- /dev/null +++ b/jobs/Backend/Task/ExchangeRateUpdater.UnitTests/Handlers/GetExchangesRatesByDateQueryHandlerTests.cs @@ -0,0 +1,88 @@ +using ExchangeRateUpdater.Application.ExchangeRates.Query.GetExchangeRatesDaily; +using ExchangeRateUpdater.Domain.Entities; +using ExchangeRateUpdater.Domain.ValueObjects; + +namespace UnitTests.Handlers; + +using ExchangeRateUpdater.Application.Common.Utils; +using ExchangeRateUpdater.Domain.Enums; +using Fakers; + +public class GetExchangesRatesByDateQueryHandlerTests +{ + [Fact] + public async Task Handle_Returns_Only_Requested_SourceCurrencies() + { + var date = DateTime.UtcNow.Date; + + var key = CacheKeyHelper.RatesKey(Language.CZ, date); + + var existing = new List + { + new(new Currency("USD"), new Currency("CZK"), 24.5m), + new(new Currency("EUR"), new Currency("CZK"), 26m), + new(new Currency("GBP"), new Currency("CZK"), 29m) + }; + + var cache = new FakeCacheRepository(); + await cache.SetRatesListAsync(key, existing); + + var handler = new GetExchangesRatesByDateQueryHandler(cache); + + var q = new GetExchangesRatesByDateQuery { CurrencyCodes = new List { "USD", "GBP" }, Date = date }; + + var result = await handler.Handle(q, default); + + result.Count.ShouldBe(2); + result.Any(r => r.SourceCurrencyCode == "USD").ShouldBeTrue(); + result.Any(r => r.SourceCurrencyCode == "GBP").ShouldBeTrue(); + } + + [Fact] + public async Task Handle_Uses_Defaults_When_Null_Date_And_Language() + { + var date = DateTime.UtcNow.Date; + + var key = CacheKeyHelper.RatesKey(Language.CZ, date); + + var existing = new List + { + new ExchangeRate(new Currency("USD"), new Currency("CZK"), 24.5m) + }; + + var cache = new FakeCacheRepository(); + await cache.SetRatesListAsync(key, existing); + + var handler = new GetExchangesRatesByDateQueryHandler(cache); + + var q = new GetExchangesRatesByDateQuery { CurrencyCodes = new List { "USD" } }; + + var result = await handler.Handle(q, default); + + result.Count.ShouldBe(1); + } + + [Fact] + public async Task Handle_Returns_Empty_When_No_Matching_SourceCurrency() + { + var date = DateTime.UtcNow.Date; + + var key = CacheKeyHelper.RatesKey(Language.CZ, date); + + var existing = new List + { + new ExchangeRate(new Currency("EUR"), new Currency("CZK"), 26m) + }; + + var cache = new FakeCacheRepository(); + await cache.SetRatesListAsync(key, existing); + + var handler = new GetExchangesRatesByDateQueryHandler(cache); + + var q = new GetExchangesRatesByDateQuery { CurrencyCodes = new List { "USD" }, Date = date }; + + var result = await handler.Handle(q, default); + + result.ShouldBeEmpty(); + } +} diff --git a/jobs/Backend/Task/ExchangeRateUpdater.UnitTests/Mapping/ExchangeRateMappingExtensionsTests.cs b/jobs/Backend/Task/ExchangeRateUpdater.UnitTests/Mapping/ExchangeRateMappingExtensionsTests.cs new file mode 100644 index 0000000000..6e087f8214 --- /dev/null +++ b/jobs/Backend/Task/ExchangeRateUpdater.UnitTests/Mapping/ExchangeRateMappingExtensionsTests.cs @@ -0,0 +1,54 @@ +using ExchangeRateUpdater.Application.Common.Mappings; +using ExchangeRateUpdater.Domain.Entities; +using ExchangeRateUpdater.Domain.ValueObjects; + +namespace UnitTests.Mapping; + +using ExchangeRateUpdater.Application.ExchangeRates.Dtos; + +public class ExchangeRateMappingExtensionsTests +{ + [Fact] + public void ToDto_MapsCurrencyCodesAndValue() + { + var source = new ExchangeRate(new Currency("USD"), new Currency("CZK"), 25.5m); + + var dto = source.ToDto(); + + dto.SourceCurrencyCode.ShouldBe("USD"); + dto.TargetCurrencyCode.ShouldBe("CZK"); + dto.Value.ShouldBe(25.5m); + } + + [Fact] + public void ToExchangeRateEntity_CalculatesValue_WhenAmountNonZero() + { + var apiDto = new ExchangeRateApiDto + { + CurrencyCode = "USD", + Amount = 100, + Rate = 2500m + }; + + var entity = apiDto.ToExchangeRateEntity(); + + entity.SourceCurrency.Code.ShouldBe("USD"); + entity.TargetCurrency.Code.ShouldBe("CZK"); + entity.Value.ShouldBe(2500m / 100m); + } + + [Fact] + public void ToExchangeRateEntity_HandlesZeroAmount_ReturnsZeroValue() + { + var apiDto = new ExchangeRateApiDto + { + CurrencyCode = "USD", + Amount = 0, + Rate = 1234m + }; + + var entity = apiDto.ToExchangeRateEntity(); + + entity.Value.ShouldBe(0m); + } +} diff --git a/jobs/Backend/Task/ExchangeRateUpdater.UnitTests/Services/CzYearProcessorTests.cs b/jobs/Backend/Task/ExchangeRateUpdater.UnitTests/Services/CzYearProcessorTests.cs new file mode 100644 index 0000000000..9d5fb22cd9 --- /dev/null +++ b/jobs/Backend/Task/ExchangeRateUpdater.UnitTests/Services/CzYearProcessorTests.cs @@ -0,0 +1,87 @@ +using ExchangeRateUpdater.Worker.Services; +using ExchangeRateUpdater.Application.ExchangeRates.Dtos; +using Microsoft.Extensions.Logging.Abstractions; +using UnitTests.Fakers; + +namespace UnitTests.Services; + +public class CzYearProcessorTests +{ + [Fact] + public async Task ProcessYearAsync_Returns_Null_When_No_Data() + { + var api = new FakeExchangeRateApiClient + { + OnGetDefaultExchangeRatesForYearAsync = year => Task.FromResult(new List()) + }; + + var cache = new FakeCacheRepository(); + var logger = new NullLogger(); + + var p = new CzYearProcessor(api, cache, logger); + + var res = await p.ProcessYearAsync(2020, CancellationToken.None); + + res.ShouldBeNull(); + } + + [Fact] + public async Task ProcessYearAsync_Caches_And_Returns_Earliest_Date() + { + var dto1 = new ExchangeRateApiDto { ValidFor = "2020-01-02", Amount = 1, CurrencyCode = "USD", Rate = 25m }; + var dto2 = new ExchangeRateApiDto { ValidFor = "2020-01-01", Amount = 1, CurrencyCode = "EUR", Rate = 26m }; + var api = new FakeExchangeRateApiClient + { + OnGetDefaultExchangeRatesForYearAsync = year => Task.FromResult(new List { dto1, dto2 }) + }; + + var cache = new FakeCacheRepository(); + var logger = new NullLogger(); + + var p = new CzYearProcessor(api, cache, logger); + + var res = await p.ProcessYearAsync(2020, CancellationToken.None); + + res.ShouldNotBeNull(); + res.Value.ShouldBe(new DateTime(2020, 1, 1)); + + var key1 = $"exrates:cz:{new DateTime(2020,1,1):yyyy-MM-dd}"; + var key2 = $"exrates:cz:{new DateTime(2020,1,2):yyyy-MM-dd}"; + + var cached1 = await cache.GetRatesDictionaryAsync(key1); + var cached2 = await cache.GetRatesDictionaryAsync(key2); + + cached1.ShouldNotBeNull(); + cached1.ContainsKey("EUR").ShouldBeTrue(); + + cached2.ShouldNotBeNull(); + cached2.ContainsKey("USD").ShouldBeTrue(); + } + + [Fact] + public async Task ProcessYearAsync_Skips_Invalid_ValidFor() + { + var dto1 = new ExchangeRateApiDto { ValidFor = "", Amount = 1, CurrencyCode = "USD", Rate = 25m }; + var dto2 = new ExchangeRateApiDto { ValidFor = "2020-01-05", Amount = 1, CurrencyCode = "EUR", Rate = 26m }; + var api = new FakeExchangeRateApiClient + { + OnGetDefaultExchangeRatesForYearAsync = year => Task.FromResult(new List { dto1, dto2 }) + }; + + var cache = new FakeCacheRepository(); + var logger = new NullLogger(); + + var p = new CzYearProcessor(api, cache, logger); + + var res = await p.ProcessYearAsync(2020, CancellationToken.None); + + res.ShouldNotBeNull(); + res.Value.ShouldBe(new DateTime(2020, 1, 5)); + + var key = $"exrates:cz:{res.Value:yyyy-MM-dd}"; + var cached = await cache.GetRatesDictionaryAsync(key); + cached.ShouldNotBeNull(); + cached.Count.ShouldBe(1); + cached.ContainsKey("EUR").ShouldBeTrue(); + } +} diff --git a/jobs/Backend/Task/ExchangeRateUpdater.UnitTests/Services/PerDayProcessorTests.cs b/jobs/Backend/Task/ExchangeRateUpdater.UnitTests/Services/PerDayProcessorTests.cs new file mode 100644 index 0000000000..a26679a081 --- /dev/null +++ b/jobs/Backend/Task/ExchangeRateUpdater.UnitTests/Services/PerDayProcessorTests.cs @@ -0,0 +1,86 @@ +using ExchangeRateUpdater.Worker.Services; +using ExchangeRateUpdater.Application.ExchangeRates.Dtos; +using ExchangeRateUpdater.Domain.Entities; +using ExchangeRateUpdater.Domain.ValueObjects; +using ExchangeRateUpdater.Domain.Enums; +using Microsoft.Extensions.Logging.Abstractions; +using UnitTests.Fakers; +using ExchangeRateUpdater.Application.Common.Utils; + +namespace UnitTests.Services; + +public class PerDayProcessorTests +{ + [Fact] + public async Task ProcessDateAsync_Skips_When_Cache_Hit() + { + var date = DateTime.UtcNow.Date; + + var key = CacheKeyHelper.RatesKey(Language.EN, date); + + var cache = new FakeCacheRepository(); + await cache.SetRatesAsync(key, new Dictionary { { "USD", new ExchangeRate(new Currency("USD"), new Currency("CZK"), 1m) } }); + + var api = new FakeExchangeRateApiClient + { + OnGetExchangeRatesAsync = (d, l, ct) => Task.FromResult>(new[] { new ExchangeRateApiDto { ValidFor = date.ToString("yyyy-MM-dd"), Amount = 1, CurrencyCode = "USD", Rate = 25m } }) + }; + + var p = new PerDayProcessor(api, cache, new NullLogger()); + + await p.ProcessDateAsync(date, Language.EN, CancellationToken.None); + + // Ensure cache was not overwritten (still 1 item) + var cached = await cache.GetRatesDictionaryAsync(key); + cached.ShouldNotBeNull(); + cached.Count.ShouldBe(1); + } + + [Fact] + public async Task ProcessDateAsync_Does_Nothing_When_Api_Returns_Empty() + { + var date = DateTime.UtcNow.Date; + var key = CacheKeyHelper.RatesKey(Language.EN, date); + + var cache = new FakeCacheRepository(); + + var api = new FakeExchangeRateApiClient + { + OnGetExchangeRatesAsync = (d, l, ct) => Task.FromResult>(Array.Empty()) + }; + + var p = new PerDayProcessor(api, cache, new NullLogger()); + + await p.ProcessDateAsync(date, Language.EN, CancellationToken.None); + + var cached = await cache.GetRatesDictionaryAsync(key); + cached.ShouldBeNull(); + } + + [Fact] + public async Task ProcessDateAsync_Caches_Api_Results() + { + var date = DateTime.UtcNow.Date; + var key = CacheKeyHelper.RatesKey(Language.EN, date); + + var api = new FakeExchangeRateApiClient + { + OnGetExchangeRatesAsync = (d, l, ct) => Task.FromResult>(new[] { + new ExchangeRateApiDto { ValidFor = date.ToString("yyyy-MM-dd"), Amount = 1, CurrencyCode = "USD", Rate = 25m }, + new ExchangeRateApiDto { ValidFor = date.ToString("yyyy-MM-dd"), Amount = 1, CurrencyCode = "EUR", Rate = 26m } + }) + }; + + var cache = new FakeCacheRepository(); + + var p = new PerDayProcessor(api, cache, new NullLogger()); + + await p.ProcessDateAsync(date, Language.EN, CancellationToken.None); + + var cached = await cache.GetRatesDictionaryAsync(key); + cached.ShouldNotBeNull(); + cached.Count.ShouldBe(2); + cached.ContainsKey("USD").ShouldBeTrue(); + cached.ContainsKey("EUR").ShouldBeTrue(); + } +} diff --git a/jobs/Backend/Task/ExchangeRateUpdater.UnitTests/Validation/GetExchangesRatesByDateQueryValidatorTests.cs b/jobs/Backend/Task/ExchangeRateUpdater.UnitTests/Validation/GetExchangesRatesByDateQueryValidatorTests.cs new file mode 100644 index 0000000000..bb3bc877c3 --- /dev/null +++ b/jobs/Backend/Task/ExchangeRateUpdater.UnitTests/Validation/GetExchangesRatesByDateQueryValidatorTests.cs @@ -0,0 +1,37 @@ +using ExchangeRateUpdater.Application.ExchangeRates.Query.GetExchangeRatesDaily; +using FluentValidation.TestHelper; + +namespace UnitTests.Validation; + +public class GetExchangesRatesByDateQueryValidatorTests +{ + private readonly GetExchangesRatesByDateQueryValidator _validator = new(); + + [Fact] + public void Validator_Fails_When_CurrencyCodes_NullOrEmpty() + { + var q1 = new GetExchangesRatesByDateQuery { CurrencyCodes = null! }; + var r1 = _validator.TestValidate(q1); + r1.ShouldHaveValidationErrorFor(x => x.CurrencyCodes); + + var q2 = new GetExchangesRatesByDateQuery { CurrencyCodes = new List() }; + var r2 = _validator.TestValidate(q2); + r2.ShouldHaveValidationErrorFor(x => x.CurrencyCodes); + } + + [Fact] + public void Validator_Fails_When_Code_Length_Not_3() + { + var q = new GetExchangesRatesByDateQuery { CurrencyCodes = new List { "US", "CZK" } }; + var r = _validator.TestValidate(q); + r.ShouldHaveValidationErrorFor("CurrencyCodes[0]"); + } + + [Fact] + public void Validator_Succeeds_For_Valid_Codes() + { + var q = new GetExchangesRatesByDateQuery { CurrencyCodes = new List { "USD", "EUR" } }; + var r = _validator.TestValidate(q); + r.ShouldNotHaveValidationErrorFor(x => x.CurrencyCodes); + } +} diff --git a/jobs/Backend/Task/ExchangeRateUpdater.Worker/Dockerfile b/jobs/Backend/Task/ExchangeRateUpdater.Worker/Dockerfile new file mode 100644 index 0000000000..2b8599ac9c --- /dev/null +++ b/jobs/Backend/Task/ExchangeRateUpdater.Worker/Dockerfile @@ -0,0 +1,32 @@ +FROM mcr.microsoft.com/dotnet/runtime:9.0 AS base +WORKDIR /app + +FROM mcr.microsoft.com/dotnet/sdk:9.0 AS build +ARG BUILD_CONFIGURATION=Release +WORKDIR /src + +# copy csproj files for restore caching +COPY ExchangeRateUpdater.Worker/ExchangeRateUpdater.Worker.csproj ExchangeRateUpdater.Worker/ +COPY ExchangeRateUpdater.Application/ExchangeRateUpdater.Application.csproj ExchangeRateUpdater.Application/ +COPY ExchangeRateUpdater.Infrastructure/ExchangeRateUpdater.Infrastructure.csproj ExchangeRateUpdater.Infrastructure/ +COPY ExchangeRateUpdater.Domain/ExchangeRateUpdater.Domain.csproj ExchangeRateUpdater.Domain/ + +RUN dotnet restore ExchangeRateUpdater.Worker/ExchangeRateUpdater.Worker.csproj + +# copy everything else +COPY . . + +WORKDIR /src/ExchangeRateUpdater.Worker +RUN dotnet build "ExchangeRateUpdater.Worker.csproj" -c $BUILD_CONFIGURATION -o /app/build + +FROM build AS publish +ARG BUILD_CONFIGURATION=Release +WORKDIR /src/ExchangeRateUpdater.Worker +RUN dotnet publish "ExchangeRateUpdater.Worker.csproj" -c $BUILD_CONFIGURATION -o /app/publish /p:UseAppHost=false + +FROM base AS final +WORKDIR /app +COPY --from=publish /app/publish ./ + +# Start the published worker DLL directly. +ENTRYPOINT ["dotnet", "ExchangeRateUpdater.Worker.dll"] diff --git a/jobs/Backend/Task/ExchangeRateUpdater.Worker/ExchangeRateUpdater.Worker.csproj b/jobs/Backend/Task/ExchangeRateUpdater.Worker/ExchangeRateUpdater.Worker.csproj new file mode 100644 index 0000000000..5892573029 --- /dev/null +++ b/jobs/Backend/Task/ExchangeRateUpdater.Worker/ExchangeRateUpdater.Worker.csproj @@ -0,0 +1,37 @@ + + + + net9.0 + enable + enable + + + + + + + + + + + + + + + + + + PreserveNewest + + + PreserveNewest + + + + + + appsettings.json + + + + diff --git a/jobs/Backend/Task/ExchangeRateUpdater.Worker/Jobs/DailyExchangeRatesRefreshJob.cs b/jobs/Backend/Task/ExchangeRateUpdater.Worker/Jobs/DailyExchangeRatesRefreshJob.cs new file mode 100644 index 0000000000..1a7292149d --- /dev/null +++ b/jobs/Backend/Task/ExchangeRateUpdater.Worker/Jobs/DailyExchangeRatesRefreshJob.cs @@ -0,0 +1,63 @@ +namespace ExchangeRateUpdater.Worker.Jobs; + +using Application.Common.Interfaces; +using Application.Common.Mappings; +using Domain.Common; +using Domain.Enums; +using Domain.Repositories; +using ExchangeRateUpdater.Application.Common.Utils; +using Quartz; + +[DisallowConcurrentExecution] +public class DailyExchangeRatesRefreshJob : IJob +{ + private readonly IExchangeRateApiClient _apiClient; + private readonly ICacheRepository _cacheRepository; + private readonly ILogger _logger; + + public DailyExchangeRatesRefreshJob(IExchangeRateApiClient apiClient, + ICacheRepository cacheRepository, + ILogger logger) + { + Ensure.Argument.NotNull(apiClient, nameof(apiClient)); + Ensure.Argument.NotNull(cacheRepository, nameof(cacheRepository)); + Ensure.Argument.NotNull(logger, nameof(logger)); + _apiClient = apiClient; + _cacheRepository = cacheRepository; + _logger = logger; + } + + public async Task Execute(IJobExecutionContext context) + { + try + { + _logger.LogInformation("Starting RefreshRatesJob at {Time}", DateTimeOffset.UtcNow); + + await SetDailyExchangeRatesInCacheByLanguage(Language.CZ); + + await SetDailyExchangeRatesInCacheByLanguage(Language.EN); + } + catch (Exception e) + { + _logger.LogError(e, "RefreshRatesJob failed"); + throw; + } + } + + private async Task SetDailyExchangeRatesInCacheByLanguage(Language language) + { + var rates = (await _apiClient.GetExchangeRatesAsync(null, null)).Select(r => r.ToExchangeRateEntity()).ToList(); + if (!rates.Any()) + { + _logger.LogWarning("No rates returned from API"); + } + + var today = DateTime.UtcNow.Date; + var dateKey = CacheKeyHelper.RatesKey(language, today); + var ratesDict = rates.ToDictionary(r => r.SourceCurrency.Code, r => r); + + await _cacheRepository.SetRatesAsync(dateKey, ratesDict, TimeSpan.FromDays(365)); + + _logger.LogInformation("RefreshRatesJob completed - {Count} rates cached for {Languague} - {Date}", ratesDict.Count, language, today); + } +} \ No newline at end of file diff --git a/jobs/Backend/Task/ExchangeRateUpdater.Worker/Jobs/ExchangeRatesBackfillJob.cs b/jobs/Backend/Task/ExchangeRateUpdater.Worker/Jobs/ExchangeRatesBackfillJob.cs new file mode 100644 index 0000000000..cb36ca6de3 --- /dev/null +++ b/jobs/Backend/Task/ExchangeRateUpdater.Worker/Jobs/ExchangeRatesBackfillJob.cs @@ -0,0 +1,194 @@ +namespace ExchangeRateUpdater.Worker.Jobs; + +using System.Collections.Concurrent; +using Domain.Enums; +using System.Diagnostics; +using Application.Common.Utils; +using Domain.Common; +using Domain.Repositories; +using Quartz; +using Services; + +[DisallowConcurrentExecution] +public class ExchangeRatesBackfillJob : IJob +{ + private const int DefaultYears = 4; + private const int DefaultParallelism = 8; + private const string FlagFilePath = "/shared/worker_ready"; + + private readonly ICzYearProcessor _czYearProcessor; + private readonly IPerDayProcessor _perDayProcessor; + private readonly ICacheRepository _cacheRepository; + private readonly ILogger _logger; + + public ExchangeRatesBackfillJob(ICzYearProcessor czYearProcessor, + IPerDayProcessor perDayProcessor, + ICacheRepository cacheRepository, + ILogger logger) + { + Ensure.Argument.NotNull(czYearProcessor, nameof(czYearProcessor)); + Ensure.Argument.NotNull(perDayProcessor, nameof(perDayProcessor)); + Ensure.Argument.NotNull(cacheRepository, nameof(cacheRepository)); + Ensure.Argument.NotNull(logger, nameof(logger)); + _czYearProcessor = czYearProcessor; + _perDayProcessor = perDayProcessor; + _cacheRepository = cacheRepository; + _logger = logger; + } + + public async Task Execute(IJobExecutionContext context) + { + EnsureFlagFileDoesntExist(); + var overallSw = Stopwatch.StartNew(); + try + { + _logger.LogInformation( + "Starting ExchangeRatesBackfillJob for last {Years} years with parallelism {Parallelism} at {Time}", + DefaultYears, DefaultParallelism, DateTimeOffset.UtcNow); + + var end = DateTime.UtcNow.Date; + var defaultStart = end.AddYears(-DefaultYears); + + var needBackfill = !(await CacheHasStartEndAsync(Language.CZ, defaultStart, end) && + await CacheHasStartEndAsync(Language.EN, defaultStart, end)); + + if (needBackfill) + { + var czEarliest = await BackfillCzAsync(context, DefaultYears, DefaultParallelism); + await BackfillEnAsync(context, DefaultYears, DefaultParallelism, czEarliest); + } + else + { + _logger.LogInformation("Cache already has data for the required date range. Skipping backfill."); + } + + overallSw.Stop(); + _logger.LogInformation("ExchangeRatesBackfillJob completed at {Time} - total duration {Elapsed:c}", + DateTimeOffset.UtcNow, overallSw.Elapsed); + } + catch (OperationCanceledException) + { + _logger.LogWarning("ExchangeRatesBackfillJob cancelled"); + throw; + } + catch (Exception ex) + { + _logger.LogError(ex, "ExchangeRatesBackfillJob failed"); + throw; + } + finally + { + await NotifyBackfillCompleted(); + } + } + + private void EnsureFlagFileDoesntExist() + { + if (File.Exists(FlagFilePath)) + { + File.Delete(FlagFilePath); + } + } + + private async Task NotifyBackfillCompleted() + { + await File.WriteAllTextAsync(FlagFilePath, "ready"); + } + + private async Task BackfillCzAsync(IJobExecutionContext context, int years, int parallelism) + { + var czSw = Stopwatch.StartNew(); + var czEarliest = await BackfillCzAsync(years, parallelism, context.CancellationToken); + czSw.Stop(); + _logger.LogInformation("CZ backfill completed in {Elapsed:c}", czSw.Elapsed); + return czEarliest; + } + + private async Task BackfillCzAsync(int years, int degreeOfParallelism, + CancellationToken cancellationToken) + { + var end = DateTime.UtcNow.Date; + var defaultStart = end.AddYears(-years); + var yearsRange = Enumerable.Range(defaultStart.Year, end.Year - defaultStart.Year + 1).ToList(); + var foundDates = new ConcurrentBag(); + + var options = new ParallelOptions + { MaxDegreeOfParallelism = degreeOfParallelism, CancellationToken = cancellationToken }; + var processed = 0; + await Parallel.ForEachAsync(yearsRange, options, async (year, ct) => + { + ct.ThrowIfCancellationRequested(); + try + { + var minForYear = await _czYearProcessor.ProcessYearAsync(year, ct); + if (minForYear.HasValue) foundDates.Add(minForYear.Value); + } + finally + { + var count = Interlocked.Increment(ref processed); + _logger.LogInformation("CZ backfill: processed {Processed}/{Total} years", count, yearsRange.Count); + } + }); + + if (foundDates.IsEmpty) return null; + var overallMin = foundDates.Min(); + _logger.LogInformation("CZ earliest date found: {Date}", overallMin); + return overallMin; + } + + private async Task BackfillEnAsync(IJobExecutionContext context, int years, int parallelism, DateTime? czEarliest) + { + var enSw = Stopwatch.StartNew(); + await BackfillPerDayAsync(years, parallelism, context.CancellationToken, czEarliest); + enSw.Stop(); + _logger.LogInformation("EN backfill completed in {Elapsed:c}", enSw.Elapsed); + } + + private async Task BackfillPerDayAsync(int years, int degreeOfParallelism, CancellationToken cancellationToken, + DateTime? overrideStart = null) + { + var language = Language.EN; + var end = DateTime.UtcNow.Date; + var defaultStart = end.AddYears(-years); + var start = overrideStart ?? defaultStart; + + _logger.LogInformation("Backfilling language {Language} from {Start} to {End}", language, start, end); + + var totalDays = (end - start).Days + 1; + if (totalDays <= 0) return; + + var dates = Enumerable.Range(0, totalDays).Select(d => start.AddDays(d)).ToList(); + var processedDates = 0; + var optionsDates = new ParallelOptions + { MaxDegreeOfParallelism = degreeOfParallelism, CancellationToken = cancellationToken }; + + await Parallel.ForEachAsync(dates, optionsDates, async (date, ct) => + { + ct.ThrowIfCancellationRequested(); + try + { + await _perDayProcessor.ProcessDateAsync(date, language, ct); + } + finally + { + var current = Interlocked.Increment(ref processedDates); + if (current % 100 == 0 || current == dates.Count) + _logger.LogInformation("Backfill {Language}: processed {Processed}/{Total}", language, current, + dates.Count); + } + }); + + _logger.LogInformation("Finished backfill for language {Language}", language); + } + + private async Task CacheHasStartEndAsync(Language lang, DateTime start, DateTime end) + { + var startKey = CacheKeyHelper.RatesKey(lang, start); + var endKey = CacheKeyHelper.RatesKey(lang, end); + + var startCached = await _cacheRepository.GetRatesDictionaryAsync(startKey); + var endCached = await _cacheRepository.GetRatesDictionaryAsync(endKey); + + return startCached is not null && startCached.Any() && endCached is not null && endCached.Any(); + } +} \ No newline at end of file diff --git a/jobs/Backend/Task/ExchangeRateUpdater.Worker/Program.cs b/jobs/Backend/Task/ExchangeRateUpdater.Worker/Program.cs new file mode 100644 index 0000000000..51abb01d11 --- /dev/null +++ b/jobs/Backend/Task/ExchangeRateUpdater.Worker/Program.cs @@ -0,0 +1,38 @@ +using ExchangeRateUpdater.Application; +using ExchangeRateUpdater.Infrastructure; +using ExchangeRateUpdater.Worker; +using ExchangeRateUpdater.Worker.Jobs; +using Quartz; + +var builder = Host.CreateDefaultBuilder(args) + .ConfigureServices((hostContext, services) => + { + services.AddApplicationServices(); + services.AddInfrastructure(hostContext.Configuration); + services.AddExchangeRateWorkerServices(); + + services.AddQuartz(q => + { + //All this part could be improved by using a loop getting all Jobs via reflection in case there were many jobs to register. + + var backfillJobKey = new JobKey("ExchangeRatesBackfillJob"); + q.AddJob(opts => opts.WithIdentity(backfillJobKey)); + q.AddTrigger(opts => opts + .ForJob(backfillJobKey) + .WithIdentity("ExchangeRatesBackfillJob") + .StartNow() + ); + var dailyJobKey = new JobKey("DailyExchangeRatesRefreshJob"); + q.AddJob(opts => opts.WithIdentity(dailyJobKey)); + q.AddTrigger(opts => opts + .ForJob(dailyJobKey) + .WithIdentity("DailyExchangeRatesRefreshJob") + .WithCronSchedule("0 0 0 ? * * *") + ); + }); + + services.AddQuartzHostedService(q => q.WaitForJobsToComplete = true); + }) + .Build(); + +await builder.RunAsync(); \ No newline at end of file diff --git a/jobs/Backend/Task/ExchangeRateUpdater.Worker/ServiceCollectionExtensions.cs b/jobs/Backend/Task/ExchangeRateUpdater.Worker/ServiceCollectionExtensions.cs new file mode 100644 index 0000000000..f54894960b --- /dev/null +++ b/jobs/Backend/Task/ExchangeRateUpdater.Worker/ServiceCollectionExtensions.cs @@ -0,0 +1,14 @@ +namespace ExchangeRateUpdater.Worker; + +using Services; + +public static class ServiceCollectionExtensions +{ + public static IServiceCollection AddExchangeRateWorkerServices(this IServiceCollection services) + { + services.AddSingleton(); + services.AddSingleton(); + + return services; + } +} \ No newline at end of file diff --git a/jobs/Backend/Task/ExchangeRateUpdater.Worker/Services/CzYearProcessor.cs b/jobs/Backend/Task/ExchangeRateUpdater.Worker/Services/CzYearProcessor.cs new file mode 100644 index 0000000000..bc013eefc0 --- /dev/null +++ b/jobs/Backend/Task/ExchangeRateUpdater.Worker/Services/CzYearProcessor.cs @@ -0,0 +1,76 @@ +namespace ExchangeRateUpdater.Worker.Services; + +using System.Globalization; +using Application.Common.Interfaces; +using Application.Common.Mappings; +using Application.Common.Utils; +using Domain.Common; +using Domain.Enums; +using Domain.Repositories; + +public interface ICzYearProcessor +{ + Task ProcessYearAsync(int year, CancellationToken ct); +} + +public class CzYearProcessor : ICzYearProcessor +{ + private readonly IExchangeRateApiClient _apiClient; + private readonly ICacheRepository _cacheRepository; + private readonly ILogger _logger; + private readonly int _ttlYears; + + public CzYearProcessor(IExchangeRateApiClient apiClient, ICacheRepository cacheRepository, + ILogger logger, int ttlYears = 4) + { + Ensure.Argument.NotNull(apiClient, nameof(apiClient)); + Ensure.Argument.NotNull(cacheRepository, nameof(cacheRepository)); + Ensure.Argument.NotNull(logger, nameof(logger)); + _apiClient = apiClient; + _cacheRepository = cacheRepository; + _logger = logger; + _ttlYears = ttlYears; + } + + public async Task ProcessYearAsync(int year, CancellationToken ct) + { + ct.ThrowIfCancellationRequested(); + + var yearRates = await _apiClient.GetDefaultExchangeRatesForYearAsync(year); + if (!yearRates.Any()) + { + _logger.LogWarning("No rates returned for CZ year {Year}", year); + return null; + } + + var grouped = yearRates + .Where(r => !string.IsNullOrWhiteSpace(r.ValidFor)) + .Select(r => new + { + DateTime.ParseExact(r.ValidFor, "yyyy-MM-dd", CultureInfo.InvariantCulture).Date, + Rate = r.ToExchangeRateEntity() + }) + .GroupBy(x => x.Date, x => x.Rate) + .ToList(); + + foreach (var grp in grouped) + { + ct.ThrowIfCancellationRequested(); + var date = grp.Key; + var key = CacheKeyHelper.RatesKey(Language.CZ, date); + + var existing = await _cacheRepository.GetRatesDictionaryAsync(key); + if (existing is not null && existing.Any()) + { + continue; + } + + var dict = grp.ToDictionary(r => r.SourceCurrency.Code, r => r); + await _cacheRepository.SetRatesAsync(key, dict, CacheKeyHelper.DefaultTtlYears(_ttlYears)); + } + + _logger.LogInformation("Backfilled CZ year {Year} -> {Days} days cached", year, grouped.Count); + + return grouped.Min(g => g.Key); + } +} \ No newline at end of file diff --git a/jobs/Backend/Task/ExchangeRateUpdater.Worker/Services/PerDayProcessor.cs b/jobs/Backend/Task/ExchangeRateUpdater.Worker/Services/PerDayProcessor.cs new file mode 100644 index 0000000000..6779d737f4 --- /dev/null +++ b/jobs/Backend/Task/ExchangeRateUpdater.Worker/Services/PerDayProcessor.cs @@ -0,0 +1,61 @@ +namespace ExchangeRateUpdater.Worker.Services; + +using System; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Application.Common.Interfaces; +using Application.Common.Mappings; +using Application.Common.Utils; +using Domain.Common; +using Domain.Repositories; +using Microsoft.Extensions.Logging; + +public interface IPerDayProcessor +{ + Task ProcessDateAsync(DateTime date, Domain.Enums.Language language, CancellationToken ct); +} + +public class PerDayProcessor : IPerDayProcessor +{ + private readonly IExchangeRateApiClient _apiClient; + private readonly ICacheRepository _cacheRepository; + private readonly ILogger _logger; + private readonly int _ttlYears; + + public PerDayProcessor(IExchangeRateApiClient apiClient, ICacheRepository cacheRepository, + ILogger logger, int ttlYears = 4) + { + Ensure.Argument.NotNull(apiClient, nameof(apiClient)); + Ensure.Argument.NotNull(cacheRepository, nameof(cacheRepository)); + Ensure.Argument.NotNull(logger, nameof(logger)); + + _apiClient = apiClient; + _cacheRepository = cacheRepository; + _logger = logger; + _ttlYears = ttlYears; + } + + public async Task ProcessDateAsync(DateTime date, Domain.Enums.Language language, CancellationToken ct) + { + ct.ThrowIfCancellationRequested(); + + var key = CacheKeyHelper.RatesKey(language, date); + var existing = await _cacheRepository.GetRatesDictionaryAsync(key); + if (existing is not null && existing.Any()) + { + _logger.LogDebug("Cache hit for {Language} on {Date}, skipping fetch", language, date); + return; + } + + var rates = (await _apiClient.GetExchangeRatesAsync(date, language)).Select(x => x.ToExchangeRateEntity()).ToList(); + if (!rates.Any()) + { + _logger.LogDebug("No rates for {Language} on {Date}", language, date); + return; + } + + var dict = rates.ToDictionary(r => r.SourceCurrency.Code, r => r); + await _cacheRepository.SetRatesAsync(key, dict, CacheKeyHelper.DefaultTtlYears(_ttlYears)); + } +} \ No newline at end of file diff --git a/jobs/Backend/Task/ExchangeRateUpdater.Worker/appsettings.Development.json b/jobs/Backend/Task/ExchangeRateUpdater.Worker/appsettings.Development.json new file mode 100644 index 0000000000..cb2f1037e9 --- /dev/null +++ b/jobs/Backend/Task/ExchangeRateUpdater.Worker/appsettings.Development.json @@ -0,0 +1,15 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Debug", + "Microsoft": "Warning", + "Microsoft.Hosting.Lifetime": "Information" + } + }, + "Redis": { + "Connection": "localhost:6379" + }, + "ExchangeRateApiClientConfig": { + "BaseUrl": "https://api.cnb.cz/cnbapi/exrates/" + } +} diff --git a/jobs/Backend/Task/ExchangeRateUpdater.Worker/appsettings.json b/jobs/Backend/Task/ExchangeRateUpdater.Worker/appsettings.json new file mode 100644 index 0000000000..314dfd1322 --- /dev/null +++ b/jobs/Backend/Task/ExchangeRateUpdater.Worker/appsettings.json @@ -0,0 +1,15 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft": "Warning", + "Microsoft.Hosting.Lifetime": "Information" + } + }, + "Redis": { + "Connection": "localhost:6379" + }, + "ExchangeRateApiClientConfig": { + "BaseUrl": "https://api.cnb.cz/cnbapi/exrates/" + } +} 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..5e02b3ce21 100644 --- a/jobs/Backend/Task/ExchangeRateUpdater.sln +++ b/jobs/Backend/Task/ExchangeRateUpdater.sln @@ -3,7 +3,28 @@ 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.Application", "ExchangeRateUpdater.Application\ExchangeRateUpdater.Application.csproj", "{2492396C-83FC-4E5D-B85C-B563E1234178}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ExchangeRateUpdater.Domain", "ExchangeRateUpdater.Domain\ExchangeRateUpdater.Domain.csproj", "{750AF4DF-C4FA-41F8-9852-87275250C6CD}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ExchangeRateUpdater.Infrastructure", "ExchangeRateUpdater.Infrastructure\ExchangeRateUpdater.Infrastructure.csproj", "{FB706A22-36FE-4F81-A834-AD03B8FEECAE}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ExchangeRateUpdater", "ExhangeRateUpdater\ExchangeRateUpdater.csproj", "{F0F3840E-08DE-4324-BD93-9B270915294E}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ExchangeRateUpdater.Api", "ExchangeRateUpdater.Api\ExchangeRateUpdater.Api.csproj", "{41423C35-E755-4C50-A946-17DD032DEFD1}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ExchangeRateUpdater.Worker", "ExchangeRateUpdater.Worker\ExchangeRateUpdater.Worker.csproj", "{BA598031-E326-4B3B-B8B2-3DE223ECEF00}" +EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "src", "src", "{64854085-B1E9-46EF-9DB7-E01D47224143}" +EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "tests", "tests", "{2C3E178A-D5CB-46C6-912E-C3BBA5484BB1}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ExchangeRateUpdater.UnitTests", "ExchangeRateUpdater.UnitTests\ExchangeRateUpdater.UnitTests.csproj", "{0D2587B7-E3DE-4F8C-A6C4-9F22525635A0}" +EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "docker", "docker", "{DA727DAB-5A37-45A4-AFEF-F2D99AF1BD26}" + ProjectSection(SolutionItems) = preProject + docker-compose.yml = docker-compose.yml + EndProjectSection EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution @@ -15,8 +36,46 @@ Global {7B2695D6-D24C-4460-A58E-A10F08550CE0}.Debug|Any CPU.Build.0 = Debug|Any CPU {7B2695D6-D24C-4460-A58E-A10F08550CE0}.Release|Any CPU.ActiveCfg = Release|Any CPU {7B2695D6-D24C-4460-A58E-A10F08550CE0}.Release|Any CPU.Build.0 = Release|Any CPU + {2492396C-83FC-4E5D-B85C-B563E1234178}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {2492396C-83FC-4E5D-B85C-B563E1234178}.Debug|Any CPU.Build.0 = Debug|Any CPU + {2492396C-83FC-4E5D-B85C-B563E1234178}.Release|Any CPU.ActiveCfg = Release|Any CPU + {2492396C-83FC-4E5D-B85C-B563E1234178}.Release|Any CPU.Build.0 = Release|Any CPU + {750AF4DF-C4FA-41F8-9852-87275250C6CD}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {750AF4DF-C4FA-41F8-9852-87275250C6CD}.Debug|Any CPU.Build.0 = Debug|Any CPU + {750AF4DF-C4FA-41F8-9852-87275250C6CD}.Release|Any CPU.ActiveCfg = Release|Any CPU + {750AF4DF-C4FA-41F8-9852-87275250C6CD}.Release|Any CPU.Build.0 = Release|Any CPU + {FB706A22-36FE-4F81-A834-AD03B8FEECAE}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {FB706A22-36FE-4F81-A834-AD03B8FEECAE}.Debug|Any CPU.Build.0 = Debug|Any CPU + {FB706A22-36FE-4F81-A834-AD03B8FEECAE}.Release|Any CPU.ActiveCfg = Release|Any CPU + {FB706A22-36FE-4F81-A834-AD03B8FEECAE}.Release|Any CPU.Build.0 = Release|Any CPU + {F0F3840E-08DE-4324-BD93-9B270915294E}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {F0F3840E-08DE-4324-BD93-9B270915294E}.Debug|Any CPU.Build.0 = Debug|Any CPU + {F0F3840E-08DE-4324-BD93-9B270915294E}.Release|Any CPU.ActiveCfg = Release|Any CPU + {F0F3840E-08DE-4324-BD93-9B270915294E}.Release|Any CPU.Build.0 = Release|Any CPU + {41423C35-E755-4C50-A946-17DD032DEFD1}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {41423C35-E755-4C50-A946-17DD032DEFD1}.Debug|Any CPU.Build.0 = Debug|Any CPU + {41423C35-E755-4C50-A946-17DD032DEFD1}.Release|Any CPU.ActiveCfg = Release|Any CPU + {41423C35-E755-4C50-A946-17DD032DEFD1}.Release|Any CPU.Build.0 = Release|Any CPU + {BA598031-E326-4B3B-B8B2-3DE223ECEF00}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {BA598031-E326-4B3B-B8B2-3DE223ECEF00}.Debug|Any CPU.Build.0 = Debug|Any CPU + {BA598031-E326-4B3B-B8B2-3DE223ECEF00}.Release|Any CPU.ActiveCfg = Release|Any CPU + {BA598031-E326-4B3B-B8B2-3DE223ECEF00}.Release|Any CPU.Build.0 = Release|Any CPU + {0D2587B7-E3DE-4F8C-A6C4-9F22525635A0}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {0D2587B7-E3DE-4F8C-A6C4-9F22525635A0}.Debug|Any CPU.Build.0 = Debug|Any CPU + {0D2587B7-E3DE-4F8C-A6C4-9F22525635A0}.Release|Any CPU.ActiveCfg = Release|Any CPU + {0D2587B7-E3DE-4F8C-A6C4-9F22525635A0}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE EndGlobalSection + GlobalSection(NestedProjects) = preSolution + {41423C35-E755-4C50-A946-17DD032DEFD1} = {64854085-B1E9-46EF-9DB7-E01D47224143} + {F0F3840E-08DE-4324-BD93-9B270915294E} = {64854085-B1E9-46EF-9DB7-E01D47224143} + {2492396C-83FC-4E5D-B85C-B563E1234178} = {64854085-B1E9-46EF-9DB7-E01D47224143} + {750AF4DF-C4FA-41F8-9852-87275250C6CD} = {64854085-B1E9-46EF-9DB7-E01D47224143} + {FB706A22-36FE-4F81-A834-AD03B8FEECAE} = {64854085-B1E9-46EF-9DB7-E01D47224143} + {BA598031-E326-4B3B-B8B2-3DE223ECEF00} = {64854085-B1E9-46EF-9DB7-E01D47224143} + + {0D2587B7-E3DE-4F8C-A6C4-9F22525635A0} = {2C3E178A-D5CB-46C6-912E-C3BBA5484BB1} + EndGlobalSection EndGlobal diff --git a/jobs/Backend/Task/ExhangeRateUpdater/ExchangeRateUpdater.csproj b/jobs/Backend/Task/ExhangeRateUpdater/ExchangeRateUpdater.csproj new file mode 100644 index 0000000000..a1af9ef45f --- /dev/null +++ b/jobs/Backend/Task/ExhangeRateUpdater/ExchangeRateUpdater.csproj @@ -0,0 +1,30 @@ + + + + Exe + net9.0 + enable + enable + + + + + + + + + + + + + + + + + + + PreserveNewest + + + + \ No newline at end of file diff --git a/jobs/Backend/Task/ExhangeRateUpdater/Program.cs b/jobs/Backend/Task/ExhangeRateUpdater/Program.cs new file mode 100644 index 0000000000..62db6903fd --- /dev/null +++ b/jobs/Backend/Task/ExhangeRateUpdater/Program.cs @@ -0,0 +1,59 @@ +using ExchangeRateUpdater.Application; +using ExchangeRateUpdater.Application.ExchangeRates.Query.GetExchangeRatesDaily; +using ExchangeRateUpdater.Infrastructure; +using Mediator; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; + +string GetEnvironment() + => Environment.GetEnvironmentVariable("ASPNETCORE_ENVIRONMENT") ?? Environments.Production; + +void ConfigureConfigurationBuilder(IConfigurationBuilder config, string[] args, string environment) + => config + .SetBasePath(Directory.GetCurrentDirectory()) + .AddJsonFile("appsettings.json", optional: false) + .AddJsonFile($"appsettings.{environment.ToLower()}.json", optional: true) + .AddEnvironmentVariables(); + +var host = new HostBuilder() + .UseEnvironment(GetEnvironment()) + .ConfigureAppConfiguration((hostingContext, config) => + { + ConfigureConfigurationBuilder(config, args, hostingContext.HostingEnvironment.EnvironmentName); + }) + .ConfigureServices((hostContext, services) => + { + services.AddApplicationServices() + .AddInfrastructure(hostContext.Configuration); + }) + .Build(); + + +try +{ + var mediator = host.Services.GetRequiredService(); + var response = await mediator.Send(new GetExchangesRatesByDateQuery + { + CurrencyCodes = new List() + { + "USD", "EUR", "CZK", "JPY", + "KES", "RUB", "THB", "TRY", "XYZ" + }, + Date = null, + Language = null + }); + + + Console.WriteLine($"Successfully retrieved {response.Count} exchange rates:"); + foreach (var rate in response) + { + Console.WriteLine(rate.ToString()); + } +} +catch (Exception e) +{ + Console.WriteLine($"Could not retrieve exchange rates: '{e.Message}'."); +} + +Console.ReadLine(); \ No newline at end of file diff --git a/jobs/Backend/Task/ExhangeRateUpdater/appsettings.json b/jobs/Backend/Task/ExhangeRateUpdater/appsettings.json new file mode 100644 index 0000000000..7e15d068c2 --- /dev/null +++ b/jobs/Backend/Task/ExhangeRateUpdater/appsettings.json @@ -0,0 +1,12 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft": "Warning", + "Microsoft.Hosting.Lifetime": "Information" + } + }, + "ExchangeRateApiClientConfig": { + "BaseUrl": "https://api.cnb.cz/cnbapi/exrates/" + } +} \ No newline at end of file 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..acf2692256 --- /dev/null +++ b/jobs/Backend/Task/Readme.md @@ -0,0 +1,177 @@ +# ExchangeRateUpdater — Architecture & Docker Compose Overview + +This repository contains an ExchangeRateUpdater system composed of three main services coordinated with Docker Compose: + +- Redis — distributed cache and optional pub/sub transport +- Worker — background service that performs backfills and daily updates and signals readiness +- API — HTTP service that serves exchange rate queries and depends on the worker being healthy +- ConsoleApp - The initial console app given in the task. + +This document explains the architecture, startup sequence, readiness signalling, healthchecks, shared volumes, and operational best practices. It also includes diagrams (Mermaid) and simple commands for running the system locally with Docker Compose. + +Design details: +- Clean architecture +- Mediator pattern +- Strict CQS pattern +- Options pattern: Access the configuration data +- Retry policy using Polly for ExchangeRateApiClient +- Open Api Documentation using Scalar +- Health Check: Worker readiness file, API HTTP endpoint +- Dependency/Startup Ordering: Compose depends_on with health conditions +- Shared Resource/Volume: Shared Docker volume for readiness signaling +- Caching: Redis service + + +## Goals and Problem Statement + +The system is designed to reliably populate and serve exchange rate data for two languages (CZ and EN). Key requirements: + +- Populate historical rates (bulk backfill) when data is missing. +- Continuously refresh daily rates. +- Provide cached, fast read access via the API. +- Coordinate startup so the API does not accept traffic until the Worker has completed its initial backfill. +- Use Redis as the shared cache and, optionally, a message bus for cross-service notifications. + +## High-level Architecture + +- Worker: on startup, runs a bulk backfill job (multi-year) across languages and writes per-date rates into the cache. When the initial backfill completes the worker writes a readiness file into a shared Docker volume (`/shared/worker_ready`). The worker continues running and updates daily rates. +- API: exposes HTTP endpoints to read exchange rates (reads exclusively from Redis). The API includes an HTTP health endpoint and only starts accepting traffic once the Worker is healthy. +- Redis: stores cached exchange rates keyed by language + date and provides data persistence (named Docker volume) across container restarts. + +Mermaid sequence diagram (startup + readiness): + +```mermaid +sequenceDiagram + participant DockerCompose as Compose + participant Redis as Redis + participant Worker as Worker + participant API as API + + DockerCompose->>Redis: start (named volume mounted) + DockerCompose->>Worker: start (shared volume mounted) + Worker->>Redis: perform backfill -> write per-day keys + Worker->>Worker: write readiness file `/shared/worker_ready` + DockerCompose-x API: wait for Worker health (healthcheck) + DockerCompose->>API: start when Worker healthy + API->>Redis: serve read requests + Worker->>Redis: daily updates (periodic) +``` + +Component diagram: + +```mermaid +graph TD + Redis[Redis cache] + Worker[Worker backfill & daily job] + API[API HTTP] + Volume[Shared Volume] + + Worker -->|writes rates| Redis + API -->|reads rates| Redis + Worker -->|writes `/shared/worker_ready`| Volume + API -->|reads `/shared/worker_ready` via healthcheck| Volume +``` + +## Startup sequence and readiness signaling + +1. Docker Compose creates named volumes and starts containers in dependency order. Redis usually starts first because other services rely on it. +2. The Worker container starts and immediately begins the bulk backfill job. This job fetches historical rates (years back) and writes them into Redis. The Worker implements parallel processing and uses the cache repository to store per-day dictionaries keyed by language and date. +3. After the backfill completes, the Worker writes a small readiness file into the shared volume: `/shared/worker_ready` (content `ready`). This file acts as a file-system-native readiness signal that the initial population is complete. +4. The Worker container exposes a Docker healthcheck that returns success only when the readiness file exists. Docker Compose uses the worker health status when evaluating dependencies. +5. Docker Compose starts the API container only after the Worker is healthy (Compose `depends_on: condition: service_healthy`). The API also has its own HTTP health endpoint (e.g., `/health`) which it uses for liveness probing and monitoring. + +Notes: +- The readiness file enables decoupling the worker's internal logic from the container lifecycle. It's durable across simple container restarts when using the shared volume. +- The Worker continues to run after writing the readiness file. It performs daily updates and refreshes the cache for new dates. + +## Healthchecks and Compose dependency behavior + +- Worker healthcheck: checks for the presence of `/shared/worker_ready`. Example shell-style healthcheck: + + - Command: `CMD test -f /shared/worker_ready || exit 1` + +- API healthcheck: performs an HTTP request against the API (`/health`) and expects a 200 OK response. + +- Docker Compose `depends_on`: use the `condition: service_healthy` option (Compose v2+ uses `healthcheck` and `depends_on` to control startup ordering). This ensures API container will not be considered started (or will not be routed to) until the Worker reaches the healthy state. + +## Purpose of shared volumes + +- Data persistence for Redis: a named volume such as `redis-data` stores Redis data files so that restart of the Redis container preserves the dataset. +- Coordination and signaling: a shared volume (e.g., `shared`) mounted at `/shared` in both Worker and API containers is used to exchange the readiness file. The Worker will write `/shared/worker_ready` and the API's healthcheck (or a sidecar script) can read it to determine if the worker completed initial setup. + +File-system-based signaling is simple, robust, and works across containers on the same Docker host without additional orchestration dependencies. + +## How caching is organized + +- Keys: the system uses strongly named cache keys in the form `exrates:{language}:{yyyy-MM-dd}`. Each key maps to a dictionary keyed by source currency code, or a list when used by application endpoints. +- TTL: cached entries are written with an expiration time computed from a configured TTL (e.g., 4 years). This keeps historical entries valid while allowing future expiration. +- Redis storage strategy: the Redis implementation stores per-day dictionaries as either Redis hashes (one entry per source currency) or as serialized JSON for lists depending on the access pattern. + +## Design patterns and architectural choices + +- Clean Architecture: the codebase separates domain, application, infrastructure, and worker orchestration concerns. This keeps business rules decoupled from technical details. +- CQS (Command-Query Separation) & Mediator: application-level queries and commands use a Mediator pattern to centralize handling. Queries only read data while commands mutate state. +- Background worker (Cron-like behavior): the Worker has two responsibilities: an initial backfill and periodic daily updates. This keeps the API focused on serving reads and reduces request latency. +- File-based readiness signaling: chosen because it's simple and requires no coordinator service. It is sufficient for single-host Docker Compose deployments. + +## How to run the system (local / dev) + +Prerequisites: Docker and Docker Compose installed. + +1. From the `root` folder run: + +```bash +docker compose up --build +``` + +2. Compose will show Redis starting, then the Worker beginning the backfill. The Worker will log progress (processed years/dates). When the Worker finishes the initial backfill it writes `/shared/worker_ready` into the shared volume and becomes healthy. + +3. Once the Worker healthcheck passes, Compose will start the API and it will register as healthy after its `/health` endpoint responds. + +4. Use the API to query rates (example): + +```bash +curl http://localhost:5000/api/exchangerates?date=2025-11-27 +``` + +## Example Docker Compose considerations + +- Volumes: + - `redis-data` — persist Redis RDB/AOF files. + - `shared` — mounted to both Worker and API at `/shared` so they can exchange the readiness file. + +- Healthchecks: + - Worker: file-existence script checking `/shared/worker_ready`. + - API: HTTP GET `/health` expecting 200. + +- depends_on with health conditions ensures proper start ordering for Compose v2+. + +## Improvements for production container orchestration & reliability + +- Prefer orchestration platforms (Kubernetes, ECS) for production. They offer richer primitives (readiness/liveness probes, rollout strategies, PodDisruptionBudgets, init containers) and cluster-wide scheduling. +- In Kubernetes, replace file-based readiness with: + - an init container that runs the initial backfill (if you prefer one-time init semantics), or + - use the same Worker as a separate Deployment and use a readiness probe that checks a status endpoint instead of a file. +- Use robust retries and circuit breakers when calling remote ExchangeRate APIs (Polly or native SDKs). Keep network timeouts small and implement exponential backoff. +- Use monitoring and metrics: expose Prometheus metrics from both API and Worker (rate of processed dates, errors, cache hit/miss rates). +- Use alerting on healthcheck failures, high error rates, or Redis memory pressure. +- Practice blue/green or rolling deployments for API changes. Ensure backward compatibility for cache key format. +- Secure Redis with authentication and network policies in production. Don't expose Redis to public networks. + +## Observability and troubleshooting + +- Logs: Worker logs contain progress info for backfill and daily updates. API logs should correlate requests with cache keys. +- Health endpoints: both Worker (file readiness) and API (`/health`) provide quick status checks for orchestration and monitoring. +- Redis inspection: use `redis-cli` to inspect keys (`KEYS exrates:*`) and TTLs. + +## Diagram explanation + +- Sequence diagram: shows how Compose brings up Redis and Worker first, Worker populates Redis and writes the readiness file, and when Worker is healthy Compose brings up the API. +- Component diagram: shows data flow: Worker writes, API reads, Redis stores, and the shared volume carries the readiness file. + +## Notes and future improvements + +- For distributed deployments, remove file-based readiness and replace it with a network-accessible readiness endpoint or a small coordination service (e.g., lease in Redis, or a k8s `Job` + `Deployment` pattern). +- Consider sharding Redis keys by language or date range if the record set grows very large. +- Add integration tests that start a Compose environment and verify the end-to-end backfill and API responses. + diff --git a/jobs/Backend/Task/docker-compose.yml b/jobs/Backend/Task/docker-compose.yml new file mode 100644 index 0000000000..b6e9ba9bc8 --- /dev/null +++ b/jobs/Backend/Task/docker-compose.yml @@ -0,0 +1,63 @@ +services: + redis: + image: redis:7 + container_name: exchange-rate-redis + ports: + - "6379:6379" + volumes: + - exchange-rate-redis-data:/data + healthcheck: + test: ["CMD", "redis-cli", "ping"] + interval: 5s + timeout: 5s + retries: 5 + + worker: + build: + context: . + dockerfile: ExchangeRateUpdater.Worker/Dockerfile + image: exchange-rate-updater-worker:latest + container_name: exchange-rate-updater-worker + environment: + - Redis__Connection=redis:6379,abortConnect=false + - ExchangeRateApiClientConfig__BaseUrl=https://api.cnb.cz/cnbapi/exrates/ + depends_on: + - redis + volumes: + - shared-data:/shared + healthcheck: + test: [ "CMD", "sh", "-c", "test -f /shared/worker_ready"] + interval: 5s + timeout: 5s + retries: 20 + + api: + build: + context: . + dockerfile: ExchangeRateUpdater.Api/Dockerfile + image: exchange-rate-updater-api:latest + container_name: exchange-rate-updater-api + environment: + - Redis__Connection=redis:6379,abortConnect=false + - ExchangeRateApiClientConfig__BaseUrl=https://api.cnb.cz/cnbapi/exrates/ + - ApiDocumentation__Enabled=true + - ASPNETCORE_ENVIRONMENT=Development + - ASPNETCORE_URLS=http://+:80 + depends_on: + redis: + condition: service_started + worker: + condition: service_healthy + volumes: + - shared-data:/shared + ports: + - "5001:80" + healthcheck: + test: [ "CMD", "curl", "-f", "http://localhost/health"] + interval: 5s + timeout: 5s + retries: 10 + +volumes: + exchange-rate-redis-data: + shared-data: \ No newline at end of file