From f5e9a1b26331cfbce4e9711b3df97385d22b2953 Mon Sep 17 00:00:00 2001 From: aminch18 Date: Wed, 24 Apr 2024 12:49:29 +0200 Subject: [PATCH 1/9] Development done Each project have related folder now. Task force Adding simple API Adding readme Update Readme.md Removing unnecessary folder --- jobs/Backend/Task/Currency.cs | 20 -- .../Controllers/ApiControllerBase.cs | 13 ++ .../Controllers/ExchangeRatesController.cs | 30 +++ .../EchangeRateUpdater.Api.csproj | 18 ++ .../Task/EchangeRateUpdater.Api/Program.cs | 24 ++ .../Properties/launchSettings.json | 31 +++ .../appsettings.Development.json | 8 + .../EchangeRateUpdater.Api/appsettings.json | 12 + jobs/Backend/Task/ExchangeRate.cs | 23 -- jobs/Backend/Task/ExchangeRateProvider.cs | 19 -- .../Common/Behaviours/ValidationBehaviour.cs | 37 ++++ .../Common/Exceptions/NotFoundException.cs | 24 ++ .../Common/Exceptions/ValidationException.cs | 22 ++ .../Interfaces/IExchangeRateApiClient.cs | 17 ++ .../Interfaces/IExchangeRateProvider.cs | 16 ++ .../Profiles/ExchangeRateApiDtoProfile.cs | 15 ++ .../Profiles/ExchangeRateDtoProfile.cs | 18 ++ .../Common/Models/Result.cs | 43 ++++ .../ExchangeRateUpdater.Application.csproj | 19 ++ .../ExchangeRates/Dtos/ExchangeRateApiDto.cs | 12 + .../Dtos/ExchangeRateApiResponse.cs | 8 + .../ExchangeRates/Dtos/ExchangeRateDto.cs | 24 ++ .../GetExchangesRatesByDateQuery.cs | 26 +++ .../GetExchangesRatesByDateQueryHandler.cs | 65 ++++++ .../GetExchangesRatesByDateQueryValidator.cs | 17 ++ .../ServiceCollectionExtensions.cs | 20 ++ .../Common/Ensure.cs | 208 ++++++++++++++++++ .../Entities/ExchangeRate.cs | 36 +++ .../Enums/Language.cs | 7 + .../ExchangeRateUpdater.Domain.csproj | 9 + .../Repositories/ICacheRepository.cs | 12 + .../ValueObjects/Currency.cs | 18 ++ .../ApiClients/ExchangeRateApiClient.cs | 54 +++++ .../ExchangeRateApiClientConfig.cs | 6 + .../Data/CacheRepository.cs | 48 ++++ .../ExchangeRateUpdater.Infrastructure.csproj | 20 ++ .../ServiceCollectionExtensions.cs | 37 ++++ jobs/Backend/Task/ExchangeRateUpdater.csproj | 8 - jobs/Backend/Task/ExchangeRateUpdater.sln | 30 ++- .../ExchangeRateUpdater.csproj | 28 +++ .../Task/ExhangeRateUpdater/Program.cs | 65 ++++++ .../Task/ExhangeRateUpdater/appsettings.json | 12 + jobs/Backend/Task/Program.cs | 43 ---- jobs/Backend/Task/Readme.md | 19 ++ 44 files changed, 1127 insertions(+), 114 deletions(-) delete mode 100644 jobs/Backend/Task/Currency.cs create mode 100644 jobs/Backend/Task/EchangeRateUpdater.Api/Controllers/ApiControllerBase.cs create mode 100644 jobs/Backend/Task/EchangeRateUpdater.Api/Controllers/ExchangeRatesController.cs create mode 100644 jobs/Backend/Task/EchangeRateUpdater.Api/EchangeRateUpdater.Api.csproj create mode 100644 jobs/Backend/Task/EchangeRateUpdater.Api/Program.cs create mode 100644 jobs/Backend/Task/EchangeRateUpdater.Api/Properties/launchSettings.json create mode 100644 jobs/Backend/Task/EchangeRateUpdater.Api/appsettings.Development.json create mode 100644 jobs/Backend/Task/EchangeRateUpdater.Api/appsettings.json delete mode 100644 jobs/Backend/Task/ExchangeRate.cs delete mode 100644 jobs/Backend/Task/ExchangeRateProvider.cs create mode 100644 jobs/Backend/Task/ExchangeRateUpdater.Application/Common/Behaviours/ValidationBehaviour.cs create mode 100644 jobs/Backend/Task/ExchangeRateUpdater.Application/Common/Exceptions/NotFoundException.cs create mode 100644 jobs/Backend/Task/ExchangeRateUpdater.Application/Common/Exceptions/ValidationException.cs create mode 100644 jobs/Backend/Task/ExchangeRateUpdater.Application/Common/Interfaces/IExchangeRateApiClient.cs create mode 100644 jobs/Backend/Task/ExchangeRateUpdater.Application/Common/Interfaces/IExchangeRateProvider.cs create mode 100644 jobs/Backend/Task/ExchangeRateUpdater.Application/Common/Mappings/Profiles/ExchangeRateApiDtoProfile.cs create mode 100644 jobs/Backend/Task/ExchangeRateUpdater.Application/Common/Mappings/Profiles/ExchangeRateDtoProfile.cs create mode 100644 jobs/Backend/Task/ExchangeRateUpdater.Application/Common/Models/Result.cs create mode 100644 jobs/Backend/Task/ExchangeRateUpdater.Application/ExchangeRateUpdater.Application.csproj create mode 100644 jobs/Backend/Task/ExchangeRateUpdater.Application/ExchangeRates/Dtos/ExchangeRateApiDto.cs create mode 100644 jobs/Backend/Task/ExchangeRateUpdater.Application/ExchangeRates/Dtos/ExchangeRateApiResponse.cs create mode 100644 jobs/Backend/Task/ExchangeRateUpdater.Application/ExchangeRates/Dtos/ExchangeRateDto.cs create mode 100644 jobs/Backend/Task/ExchangeRateUpdater.Application/ExchangeRates/Query/GetExchangeRatesDaily/GetExchangesRatesByDateQuery.cs create mode 100644 jobs/Backend/Task/ExchangeRateUpdater.Application/ExchangeRates/Query/GetExchangeRatesDaily/GetExchangesRatesByDateQueryHandler.cs create mode 100644 jobs/Backend/Task/ExchangeRateUpdater.Application/ExchangeRates/Query/GetExchangeRatesDaily/GetExchangesRatesByDateQueryValidator.cs create mode 100644 jobs/Backend/Task/ExchangeRateUpdater.Application/ServiceCollectionExtensions.cs create mode 100644 jobs/Backend/Task/ExchangeRateUpdater.Domain/Common/Ensure.cs create mode 100644 jobs/Backend/Task/ExchangeRateUpdater.Domain/Entities/ExchangeRate.cs create mode 100644 jobs/Backend/Task/ExchangeRateUpdater.Domain/Enums/Language.cs create mode 100644 jobs/Backend/Task/ExchangeRateUpdater.Domain/ExchangeRateUpdater.Domain.csproj create mode 100644 jobs/Backend/Task/ExchangeRateUpdater.Domain/Repositories/ICacheRepository.cs create mode 100644 jobs/Backend/Task/ExchangeRateUpdater.Domain/ValueObjects/Currency.cs create mode 100644 jobs/Backend/Task/ExchangeRateUpdater.Infrastructure/ApiClients/ExchangeRateApiClient.cs create mode 100644 jobs/Backend/Task/ExchangeRateUpdater.Infrastructure/Configurations/ExchangeRateApiClientConfig.cs create mode 100644 jobs/Backend/Task/ExchangeRateUpdater.Infrastructure/Data/CacheRepository.cs create mode 100644 jobs/Backend/Task/ExchangeRateUpdater.Infrastructure/ExchangeRateUpdater.Infrastructure.csproj create mode 100644 jobs/Backend/Task/ExchangeRateUpdater.Infrastructure/ServiceCollectionExtensions.cs delete mode 100644 jobs/Backend/Task/ExchangeRateUpdater.csproj create mode 100644 jobs/Backend/Task/ExhangeRateUpdater/ExchangeRateUpdater.csproj create mode 100644 jobs/Backend/Task/ExhangeRateUpdater/Program.cs create mode 100644 jobs/Backend/Task/ExhangeRateUpdater/appsettings.json delete mode 100644 jobs/Backend/Task/Program.cs create mode 100644 jobs/Backend/Task/Readme.md 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/EchangeRateUpdater.Api/Controllers/ApiControllerBase.cs b/jobs/Backend/Task/EchangeRateUpdater.Api/Controllers/ApiControllerBase.cs new file mode 100644 index 0000000000..c79f649c2c --- /dev/null +++ b/jobs/Backend/Task/EchangeRateUpdater.Api/Controllers/ApiControllerBase.cs @@ -0,0 +1,13 @@ +namespace EchangeRateUpdater.Api.Controllers; + +using MediatR; +using Microsoft.AspNetCore.Mvc; + +[ApiController] +[Route("api/[controller]")] +public class ApiControllerBase : ControllerBase +{ + private ISender _mediator = null!; + + protected ISender Mediator => _mediator ??= HttpContext.RequestServices.GetRequiredService(); +} \ No newline at end of file diff --git a/jobs/Backend/Task/EchangeRateUpdater.Api/Controllers/ExchangeRatesController.cs b/jobs/Backend/Task/EchangeRateUpdater.Api/Controllers/ExchangeRatesController.cs new file mode 100644 index 0000000000..4fd63f02f3 --- /dev/null +++ b/jobs/Backend/Task/EchangeRateUpdater.Api/Controllers/ExchangeRatesController.cs @@ -0,0 +1,30 @@ +namespace EchangeRateUpdater.Api.Controllers; + +using System.ComponentModel.DataAnnotations; +using ExchangeRateUpdater.Application.Common.Models; +using ExchangeRateUpdater.Application.ExchangeRates.Dtos; +using ExchangeRateUpdater.Application.ExchangeRates.Query.GetExchangeRatesDaily; +using Microsoft.AspNetCore.Mvc; + +public class ExchangeRatesController : ApiControllerBase +{ + [HttpGet] + public async Task>>> GetExchangeRatesByDate( + [FromQuery] GetExchangesRatesByDateQuery query) + { + try + { + var result = await Mediator.Send(query); + if (!result.Succeeded) + { + return BadRequest(result.Errors); + } + + return Ok(result); + } + catch (Exception e) + { + return BadRequest(e); + } + } +} \ No newline at end of file diff --git a/jobs/Backend/Task/EchangeRateUpdater.Api/EchangeRateUpdater.Api.csproj b/jobs/Backend/Task/EchangeRateUpdater.Api/EchangeRateUpdater.Api.csproj new file mode 100644 index 0000000000..93f3bbe2d1 --- /dev/null +++ b/jobs/Backend/Task/EchangeRateUpdater.Api/EchangeRateUpdater.Api.csproj @@ -0,0 +1,18 @@ + + + + net6.0 + enable + + + + + + + + + + + + + diff --git a/jobs/Backend/Task/EchangeRateUpdater.Api/Program.cs b/jobs/Backend/Task/EchangeRateUpdater.Api/Program.cs new file mode 100644 index 0000000000..b8620de601 --- /dev/null +++ b/jobs/Backend/Task/EchangeRateUpdater.Api/Program.cs @@ -0,0 +1,24 @@ +using System.Text.Json; +using ExchangeRateUpdater.Application; +using ExchangeRateUpdater.Infrastructure; +using SharpGrip.FluentValidation.AutoValidation.Mvc.Extensions; + +var builder = WebApplication.CreateBuilder(args); + +builder.Services.AddEndpointsApiExplorer(); +builder.Services.AddSwaggerGen(); +builder.Services.AddFluentValidationAutoValidation(); +builder.Services.AddHttpContextAccessor(); +builder.Services.AddApplicationServices(); +builder.Services.AddInfrastructure(builder.Configuration); +builder.Services.AddControllers(); + + +var app = builder.Build(); + +app.UseSwagger(); +app.UseSwaggerUI(); +app.UseHttpsRedirection(); +app.MapControllers(); + +app.Run(); \ No newline at end of file diff --git a/jobs/Backend/Task/EchangeRateUpdater.Api/Properties/launchSettings.json b/jobs/Backend/Task/EchangeRateUpdater.Api/Properties/launchSettings.json new file mode 100644 index 0000000000..fdacde6df9 --- /dev/null +++ b/jobs/Backend/Task/EchangeRateUpdater.Api/Properties/launchSettings.json @@ -0,0 +1,31 @@ +{ + "$schema": "https://json.schemastore.org/launchsettings.json", + "iisSettings": { + "windowsAuthentication": false, + "anonymousAuthentication": true, + "iisExpress": { + "applicationUrl": "http://localhost:42839", + "sslPort": 44392 + } + }, + "profiles": { + "EchangeRateUpdater.Api": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": true, + "launchUrl": "swagger", + "applicationUrl": "https://localhost:7197;http://localhost:5173", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + }, + "IIS Express": { + "commandName": "IISExpress", + "launchBrowser": true, + "launchUrl": "swagger", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + } + } +} diff --git a/jobs/Backend/Task/EchangeRateUpdater.Api/appsettings.Development.json b/jobs/Backend/Task/EchangeRateUpdater.Api/appsettings.Development.json new file mode 100644 index 0000000000..0c208ae918 --- /dev/null +++ b/jobs/Backend/Task/EchangeRateUpdater.Api/appsettings.Development.json @@ -0,0 +1,8 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + } +} diff --git a/jobs/Backend/Task/EchangeRateUpdater.Api/appsettings.json b/jobs/Backend/Task/EchangeRateUpdater.Api/appsettings.json new file mode 100644 index 0000000000..becc4a06b9 --- /dev/null +++ b/jobs/Backend/Task/EchangeRateUpdater.Api/appsettings.json @@ -0,0 +1,12 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + }, + "AllowedHosts": "*", + "ExchangeRateApiClientConfig": { + "BaseUrl": "https://api.cnb.cz/cnbapi/exrates/" + } +} 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.Application/Common/Behaviours/ValidationBehaviour.cs b/jobs/Backend/Task/ExchangeRateUpdater.Application/Common/Behaviours/ValidationBehaviour.cs new file mode 100644 index 0000000000..95342785fb --- /dev/null +++ b/jobs/Backend/Task/ExchangeRateUpdater.Application/Common/Behaviours/ValidationBehaviour.cs @@ -0,0 +1,37 @@ +namespace ExchangeRateUpdater.Application.Common.Behaviours; + +using FluentValidation; +using MediatR; + +public class ValidationBehaviour : IPipelineBehavior + where TRequest : IRequest +{ + private readonly IEnumerable> _validators; + + public ValidationBehaviour(IEnumerable> validators) + { + _validators = validators; + } + + public async Task Handle(TRequest request, RequestHandlerDelegate next, CancellationToken cancellationToken) + { + if (!_validators.Any()) + return await next(); + + var context = new ValidationContext(request); + + var validationResults = await Task.WhenAll( + _validators.Select(v => + v.ValidateAsync(context, cancellationToken))); + + var failures = validationResults + .Where(r => r.Errors.Any()) + .SelectMany(r => r.Errors) + .ToList(); + + if (failures.Any()) + throw new ValidationException(failures); + + return await next(); + } +} \ No newline at end of file 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/Exceptions/ValidationException.cs b/jobs/Backend/Task/ExchangeRateUpdater.Application/Common/Exceptions/ValidationException.cs new file mode 100644 index 0000000000..209b6f7d5b --- /dev/null +++ b/jobs/Backend/Task/ExchangeRateUpdater.Application/Common/Exceptions/ValidationException.cs @@ -0,0 +1,22 @@ +namespace ExchangeRateUpdater.Application.Common.Exceptions; + +using FluentValidation.Results; + +public class ValidationException: Exception +{ + public ValidationException() + : base("One or more validation failures have occurred.") + { + Errors = new Dictionary(); + } + + public ValidationException(IEnumerable failures) + : this() + { + Errors = failures + .GroupBy(e => e.PropertyName, e => e.ErrorMessage) + .ToDictionary(failureGroup => failureGroup.Key, failureGroup => failureGroup.ToArray()); + } + + public IDictionary Errors { get; } +} \ 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..5848e7d129 --- /dev/null +++ b/jobs/Backend/Task/ExchangeRateUpdater.Application/Common/Interfaces/IExchangeRateApiClient.cs @@ -0,0 +1,17 @@ +namespace ExchangeRateUpdater.Application.Common.Interfaces; + +using Domain.Entities; +using Domain.Enums; +using ExchangeRates.Dtos; +using ExchangeRates.Query; + +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); +} \ No newline at end of file diff --git a/jobs/Backend/Task/ExchangeRateUpdater.Application/Common/Interfaces/IExchangeRateProvider.cs b/jobs/Backend/Task/ExchangeRateUpdater.Application/Common/Interfaces/IExchangeRateProvider.cs new file mode 100644 index 0000000000..10ed8d6f57 --- /dev/null +++ b/jobs/Backend/Task/ExchangeRateUpdater.Application/Common/Interfaces/IExchangeRateProvider.cs @@ -0,0 +1,16 @@ +namespace ExchangeRateUpdater.Application.Common.Interfaces; + +using Domain.Entities; +using Domain.Enums; + +public interface IExchangeRateProvider +{ + /// + /// Should return exchange rates among the specified currencies that are defined by the source. But only those defined + /// by the source, do not return calculated exchange rates. E.g. if the source contains "CZK/USD" but not "USD/CZK", + /// do not return exchange rate "USD/CZK" with value calculated as 1 / "CZK/USD". If the source does not provide + /// some of the currencies, ignore them. + /// + Task> GetExchangeRatesAsync(IEnumerable currencies, DateTime? date, + Language? language); +} \ No newline at end of file diff --git a/jobs/Backend/Task/ExchangeRateUpdater.Application/Common/Mappings/Profiles/ExchangeRateApiDtoProfile.cs b/jobs/Backend/Task/ExchangeRateUpdater.Application/Common/Mappings/Profiles/ExchangeRateApiDtoProfile.cs new file mode 100644 index 0000000000..059c961ff1 --- /dev/null +++ b/jobs/Backend/Task/ExchangeRateUpdater.Application/Common/Mappings/Profiles/ExchangeRateApiDtoProfile.cs @@ -0,0 +1,15 @@ +namespace ExchangeRateUpdater.Application.Common.Mappings.Profiles; + +using AutoMapper; +using ExchangeRateUpdater.Application.ExchangeRates.Dtos; +using ExchangeRateUpdater.Domain.Entities; + +public class ExchangeRateApiDtoProfile : Profile +{ + public ExchangeRateApiDtoProfile() + { + CreateMap().ConstructUsing(x => + new ExchangeRate(new Currency(x.CurrencyCode), new Currency("CZK"), decimal.Divide(x.Rate, x.Amount))); + + } +} \ No newline at end of file diff --git a/jobs/Backend/Task/ExchangeRateUpdater.Application/Common/Mappings/Profiles/ExchangeRateDtoProfile.cs b/jobs/Backend/Task/ExchangeRateUpdater.Application/Common/Mappings/Profiles/ExchangeRateDtoProfile.cs new file mode 100644 index 0000000000..6c7e45c82b --- /dev/null +++ b/jobs/Backend/Task/ExchangeRateUpdater.Application/Common/Mappings/Profiles/ExchangeRateDtoProfile.cs @@ -0,0 +1,18 @@ +namespace ExchangeRateUpdater.Application.Common.Mappings.Profiles; + +using AutoMapper; +using ExchangeRateUpdater.Application.ExchangeRates.Dtos; +using ExchangeRateUpdater.Domain.Entities; + +public class ExchangeRateDtoProfile : Profile +{ + public ExchangeRateDtoProfile() + { + CreateMap() + .ConvertUsing(s => new Currency(s)); + CreateMap() + .ForMember(dest => dest.SourceCurrencyCode, opt => opt.MapFrom(src => src.SourceCurrency.Code)) + .ForMember(dest => dest.Value, opt => opt.MapFrom(src => src.Value)) + .ForMember(dest => dest.TargetCurrencyCode, opt => opt.MapFrom(src => "CZK")); + } +} \ 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/ExchangeRateUpdater.Application.csproj b/jobs/Backend/Task/ExchangeRateUpdater.Application/ExchangeRateUpdater.Application.csproj new file mode 100644 index 0000000000..f1809c72f7 --- /dev/null +++ b/jobs/Backend/Task/ExchangeRateUpdater.Application/ExchangeRateUpdater.Application.csproj @@ -0,0 +1,19 @@ + + + + net6.0 + enable + + + + + + + + + + + + + + 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..6b4bab77c1 --- /dev/null +++ b/jobs/Backend/Task/ExchangeRateUpdater.Application/ExchangeRates/Dtos/ExchangeRateApiDto.cs @@ -0,0 +1,12 @@ +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; } +} \ 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..8d212c2cec --- /dev/null +++ b/jobs/Backend/Task/ExchangeRateUpdater.Application/ExchangeRates/Dtos/ExchangeRateApiResponse.cs @@ -0,0 +1,8 @@ +namespace ExchangeRateUpdater.Application.ExchangeRates.Dtos; + +using Query; + +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..935f997aeb --- /dev/null +++ b/jobs/Backend/Task/ExchangeRateUpdater.Application/ExchangeRates/Dtos/ExchangeRateDto.cs @@ -0,0 +1,24 @@ +namespace ExchangeRateUpdater.Application.ExchangeRates.Dtos; + +using Common.Mappings; +using Domain.Entities; + +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..86b7c96a76 --- /dev/null +++ b/jobs/Backend/Task/ExchangeRateUpdater.Application/ExchangeRates/Query/GetExchangeRatesDaily/GetExchangesRatesByDateQuery.cs @@ -0,0 +1,26 @@ +namespace ExchangeRateUpdater.Application.ExchangeRates.Query.GetExchangeRatesDaily; + +using Common.Models; +using Domain.Enums; +using Dtos; +using MediatR; + +public class GetExchangesRatesByDateQuery : IRequest>> +{ + /// + /// List of three-letter ISO 4217 currency codes for which exchange rates are requested. + /// If empty, all available rates are fetched. + /// + public 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..68f339fe66 --- /dev/null +++ b/jobs/Backend/Task/ExchangeRateUpdater.Application/ExchangeRates/Query/GetExchangeRatesDaily/GetExchangesRatesByDateQueryHandler.cs @@ -0,0 +1,65 @@ +namespace ExchangeRateUpdater.Application.ExchangeRates.Query.GetExchangeRatesDaily; + +using AutoMapper; +using Common.Interfaces; +using Common.Models; +using Domain.Common; +using Domain.Entities; +using Domain.Repositories; +using Dtos; +using MediatR; + +/// +/// 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 : IRequestHandler>> +{ + private readonly ICacheRepository _cacheRepository; + private readonly IExchangeRateApiClient _exchangeRateApiClient; + private readonly IMapper _mapper; + private const string CacheKey = "ExchangeRates"; + + public GetExchangesRatesByDateQueryHandler(ICacheRepository cacheRepository, + IExchangeRateApiClient exchangeRateApiClient, IMapper mapper) + { + Ensure.Argument.NotNull(cacheRepository, nameof(cacheRepository)); + Ensure.Argument.NotNull(exchangeRateApiClient, nameof(exchangeRateApiClient)); + Ensure.Argument.NotNull(mapper, nameof(mapper)); + _cacheRepository = cacheRepository; + _exchangeRateApiClient = exchangeRateApiClient; + _mapper = mapper; + } + + public async Task>> Handle(GetExchangesRatesByDateQuery request, + CancellationToken cancellationToken) + { + try + { + var requestedCurrencies = request.CurrencyCodes.Select(currencyCode => new Currency(currencyCode)); + var exchangeRates = _cacheRepository + .GetFromCache>(CacheKey); + + if (exchangeRates == null || !exchangeRates.Any()) + { + var exchangeRateApiDtos = await _exchangeRateApiClient.GetExchangeRatesAsync(request.Date, request.Language); + exchangeRates = exchangeRateApiDtos.Select(x => _mapper.Map(x)).ToList(); + _cacheRepository.SetCache(CacheKey, exchangeRates); + } + + var requestedExchangeRates = exchangeRates + .Where(rates => requestedCurrencies.Any(currency => currency == rates.SourceCurrency)) + .Select(exchangeRate => _mapper.Map(exchangeRate)).ToList(); + + return Result>.Success(requestedExchangeRates); + } + catch (Exception e) + { + return Result>.Failure(e.Message); + throw; + } + + } +} \ 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..628f180bb7 --- /dev/null +++ b/jobs/Backend/Task/ExchangeRateUpdater.Application/ServiceCollectionExtensions.cs @@ -0,0 +1,20 @@ +namespace ExchangeRateUpdater.Application; + +using System.Reflection; +using Common.Behaviours; +using FluentValidation; +using MediatR; +using Microsoft.Extensions.DependencyInjection; + +public static class ServiceCollectionExtensions +{ + public static IServiceCollection AddApplicationServices(this IServiceCollection services) + { + services.AddAutoMapper(Assembly.GetExecutingAssembly()); + services.AddValidatorsFromAssembly(Assembly.GetExecutingAssembly()); + services.AddMediatR(Assembly.GetExecutingAssembly()); + services.AddTransient(typeof(IPipelineBehavior<,>), typeof(ValidationBehaviour<,>)); + + 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..03365d405c --- /dev/null +++ b/jobs/Backend/Task/ExchangeRateUpdater.Domain/Entities/ExchangeRate.cs @@ -0,0 +1,36 @@ +namespace ExchangeRateUpdater.Domain.Entities; + +using Common; + +public class ExchangeRate +{ + public ExchangeRate() + { + } + + public ExchangeRate(Currency sourceCurrency, Currency targetCurrency, decimal value) + { + SourceCurrency = sourceCurrency; + TargetCurrency = targetCurrency; + Value = value; + } + + public Currency SourceCurrency { get; set; } + + public Currency TargetCurrency { get; set; } + + public decimal Value { get; } + + // public static ExchangeRate New(Currency sourceCurrency, Currency targetCurrency, decimal value) + // { + // Ensure.Argument.NotNull(sourceCurrency, "Source currency should not be null"); + // Ensure.Argument.NotNull(targetCurrency, "Target currency should not be null"); + // Ensure.Argument.NotNull(value, "Exchange value should not be null"); + // return new ExchangeRate(sourceCurrency, targetCurrency, 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..eb2460e910 --- /dev/null +++ b/jobs/Backend/Task/ExchangeRateUpdater.Domain/ExchangeRateUpdater.Domain.csproj @@ -0,0 +1,9 @@ + + + + net6.0 + enable + enable + + + 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..d3a029a44b --- /dev/null +++ b/jobs/Backend/Task/ExchangeRateUpdater.Domain/Repositories/ICacheRepository.cs @@ -0,0 +1,12 @@ +namespace ExchangeRateUpdater.Domain.Repositories; + +public interface ICacheRepository +{ + T GetFromCache(string key); + + void SetCache(string key, T value, TimeSpan expirationDate); + + void SetCache(string key, T value); + + void ClearCache(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..481d91887b --- /dev/null +++ b/jobs/Backend/Task/ExchangeRateUpdater.Domain/ValueObjects/Currency.cs @@ -0,0 +1,18 @@ +namespace ExchangeRateUpdater; + +public record Currency +{ + public Currency(string code) + { + Code = code; + } + /// + /// 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..16868f4e05 --- /dev/null +++ b/jobs/Backend/Task/ExchangeRateUpdater.Infrastructure/ApiClients/ExchangeRateApiClient.cs @@ -0,0 +1,54 @@ +namespace ExchangeRateUpdater.Infrastructure.ApiClients; + +using System.Text; +using System.Text.Json; +using Application.Common.Interfaces; +using Application.ExchangeRates.Dtos; +using Application.ExchangeRates.Query; +using Domain.Common; +using Domain.Enums; +using Domain.Repositories; + +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; + } + + 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}"); + } + + 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/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..563fccc128 --- /dev/null +++ b/jobs/Backend/Task/ExchangeRateUpdater.Infrastructure/Data/CacheRepository.cs @@ -0,0 +1,48 @@ +namespace ExchangeRateUpdater.Infrastructure.Data; + +using Domain.Common; +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 T GetFromCache(string key) + { + cache.TryGetValue(key, out T cachedResponse); + logger.LogInformation(cachedResponse is null ? $"{key} not found in cache" : $"{key} found in cache"); + return cachedResponse; + } + + public void SetCache(string key, T value, TimeSpan absoluteExpiration) + { + logger.LogInformation("{Key} added to cache", key); + cache.Set(key, value, new MemoryCacheEntryOptions() + .SetAbsoluteExpiration(absoluteExpiration)); + } + + public void SetCache(string key, T value) + { + var defaultExpiration = TimeSpan.FromHours(DefaultExpirationHours); + SetCache(key, value, defaultExpiration); + } + + public void ClearCache(string key) + { + logger.LogInformation("{Key} removed from cache", key); + cache.Remove(key); + } +} \ 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..dad786bbff --- /dev/null +++ b/jobs/Backend/Task/ExchangeRateUpdater.Infrastructure/ExchangeRateUpdater.Infrastructure.csproj @@ -0,0 +1,20 @@ + + + + net6.0 + enable + + + + + + + + + + + + + + + diff --git a/jobs/Backend/Task/ExchangeRateUpdater.Infrastructure/ServiceCollectionExtensions.cs b/jobs/Backend/Task/ExchangeRateUpdater.Infrastructure/ServiceCollectionExtensions.cs new file mode 100644 index 0000000000..7f0e0484b0 --- /dev/null +++ b/jobs/Backend/Task/ExchangeRateUpdater.Infrastructure/ServiceCollectionExtensions.cs @@ -0,0 +1,37 @@ +namespace ExchangeRateUpdater.Infrastructure; + +using System; +using ApiClients; +using Application.Common.Interfaces; +using Configurations; +using Domain.Repositories; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Options; +using Data; +using Polly; +using Polly.Extensions.Http; + +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>(); + httpClient.BaseAddress = new Uri(exchangeRateApiClientConfig.Value.BaseUrl); + }).AddPolicyHandler(GetRetryPolicy()); + + return self + // Configuration settings. + .AddSingleton() + .AddMemoryCache(); + } + + 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.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..22e731c073 100644 --- a/jobs/Backend/Task/ExchangeRateUpdater.sln +++ b/jobs/Backend/Task/ExchangeRateUpdater.sln @@ -3,7 +3,15 @@ Microsoft Visual Studio Solution File, Format Version 12.00 # Visual Studio 14 VisualStudioVersion = 14.0.25123.0 MinimumVisualStudioVersion = 10.0.40219.1 -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ExchangeRateUpdater", "ExchangeRateUpdater.csproj", "{7B2695D6-D24C-4460-A58E-A10F08550CE0}" +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ExchangeRateUpdater.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}") = "EchangeRateUpdater.Api", "EchangeRateUpdater.Api\EchangeRateUpdater.Api.csproj", "{41423C35-E755-4C50-A946-17DD032DEFD1}" EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution @@ -15,6 +23,26 @@ 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 EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE diff --git a/jobs/Backend/Task/ExhangeRateUpdater/ExchangeRateUpdater.csproj b/jobs/Backend/Task/ExhangeRateUpdater/ExchangeRateUpdater.csproj new file mode 100644 index 0000000000..d5cb8fd076 --- /dev/null +++ b/jobs/Backend/Task/ExhangeRateUpdater/ExchangeRateUpdater.csproj @@ -0,0 +1,28 @@ + + + + Exe + net6.0 + + + + + + + + + + + + + + + + + + + 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..c293e0b352 --- /dev/null +++ b/jobs/Backend/Task/ExhangeRateUpdater/Program.cs @@ -0,0 +1,65 @@ +using System; +using System.Collections.Generic; +using System.IO; +using ExchangeRateUpdater.Application; +using ExchangeRateUpdater.Application.ExchangeRates.Query.GetExchangeRatesDaily; +using ExchangeRateUpdater.Infrastructure; +using MediatR; +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 + }); + + if (response.Succeeded) + { + Console.WriteLine($"Successfully retrieved {response.Value.Count} exchange rates:"); + foreach (var rate in response.Value) + { + 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..9e62830994 --- /dev/null +++ b/jobs/Backend/Task/Readme.md @@ -0,0 +1,19 @@ +# Backend Task +Inside these changes you'll be able to find 2 csprojs where we can run all our ExchangeRateUpdater application, one of them is a single Console app, the other one is an REST Api. + +What I've applied: +- Clean Architecture +- Mediator pattern. +- CQS pattern. +- Options pattern in order to access the configuration data. +- Retry policy using Polly for ExchangeRateApiClient. + +Missing code: +- Unit tests and integration tests (due to personal matters I could not add all the required test coverage) +- Missing Unit tests: GetExchangesRatesByDateQueryValidatorTest, GetExchangesRatesByDateQueryHandlerTest. +- Missing integration tests: GetExchangeRatesByDate endpoint test. + +Improvements: +- Add Background service or CronJob that will update memory cache every X time, with this we'll apply 100% the CQS pattern because right now GetExchangesRatesByDateQueryHandler is muttating the system adding the data on memory cache in case doens't exist. +- In case when every time the exchangerate changes and other systems requires to know how it changed I'd apply an event driven system in order to send events to some RabbitMQ queue in order to notify an internal application that will send events to external systems per example using Kafka or Azure eventhub. +- Instead of using memory cache use a distributed cache like Redis. From fb48cd41c70b10df1b4990ad14fea178aa181b4e Mon Sep 17 00:00:00 2001 From: Amin Chouaibi Date: Tue, 25 Nov 2025 10:34:16 +0100 Subject: [PATCH 2/9] Test --- .../Controllers/ExchangeRatesController.cs | 16 +++++ .../Controllers/ScalarController.cs | 27 ++++++++ .../Task/EchangeRateUpdater.Api/Dockerfile | 13 ++++ .../EchangeRateUpdater.Api.csproj | 3 +- .../Task/EchangeRateUpdater.Api/Program.cs | 23 +++++++ .../ExchangeRateUpdater.Application.csproj | 3 +- .../ExchangeRateUpdater.Domain.csproj | 2 +- .../Cache/RedisCacheRepository.cs | 62 +++++++++++++++++++ .../ExchangeRateUpdater.Infrastructure.csproj | 5 +- .../ServiceCollectionExtensions.cs | 23 +++++-- .../ExchangeRateUpdater.Worker/Dockerfile | 13 ++++ .../ExchangeRateUpdater.Worker.csproj | 30 +++++++++ .../Jobs/RefreshRatesJob.cs | 43 +++++++++++++ .../ExchangeRateUpdater.Worker/Program.cs | 36 +++++++++++ .../appsettings.Development.json | 9 +++ .../appsettings.json | 9 +++ .../ExchangeRateUpdater.csproj | 4 +- jobs/Backend/Task/docker-compose.yml | 29 +++++++++ 18 files changed, 341 insertions(+), 9 deletions(-) create mode 100644 jobs/Backend/Task/EchangeRateUpdater.Api/Controllers/ScalarController.cs create mode 100644 jobs/Backend/Task/EchangeRateUpdater.Api/Dockerfile create mode 100644 jobs/Backend/Task/ExchangeRateUpdater.Infrastructure/Cache/RedisCacheRepository.cs create mode 100644 jobs/Backend/Task/ExchangeRateUpdater.Worker/Dockerfile create mode 100644 jobs/Backend/Task/ExchangeRateUpdater.Worker/ExchangeRateUpdater.Worker.csproj create mode 100644 jobs/Backend/Task/ExchangeRateUpdater.Worker/Jobs/RefreshRatesJob.cs create mode 100644 jobs/Backend/Task/ExchangeRateUpdater.Worker/Program.cs create mode 100644 jobs/Backend/Task/ExchangeRateUpdater.Worker/appsettings.Development.json create mode 100644 jobs/Backend/Task/ExchangeRateUpdater.Worker/appsettings.json create mode 100644 jobs/Backend/Task/docker-compose.yml diff --git a/jobs/Backend/Task/EchangeRateUpdater.Api/Controllers/ExchangeRatesController.cs b/jobs/Backend/Task/EchangeRateUpdater.Api/Controllers/ExchangeRatesController.cs index 4fd63f02f3..67924d6b7c 100644 --- a/jobs/Backend/Task/EchangeRateUpdater.Api/Controllers/ExchangeRatesController.cs +++ b/jobs/Backend/Task/EchangeRateUpdater.Api/Controllers/ExchangeRatesController.cs @@ -4,6 +4,7 @@ namespace EchangeRateUpdater.Api.Controllers; using ExchangeRateUpdater.Application.Common.Models; using ExchangeRateUpdater.Application.ExchangeRates.Dtos; using ExchangeRateUpdater.Application.ExchangeRates.Query.GetExchangeRatesDaily; +using ExchangeRateUpdater.Domain.Repositories; using Microsoft.AspNetCore.Mvc; public class ExchangeRatesController : ApiControllerBase @@ -14,12 +15,27 @@ public async Task>>> GetExchangeRatesB { try { + // Try cache first + var cache = HttpContext.RequestServices.GetService(); + if (cache is not null) + { + var cached = cache.GetFromCache>>("rates:latest"); + if (cached is not null) + { + return Ok(cached); + } + } + var result = await Mediator.Send(query); if (!result.Succeeded) { return BadRequest(result.Errors); } + // store in cache for later + cache?.SetCache("rates:latest", result); + cache?.SetCache("rates:latest:updatedAt", DateTimeOffset.UtcNow); + return Ok(result); } catch (Exception e) diff --git a/jobs/Backend/Task/EchangeRateUpdater.Api/Controllers/ScalarController.cs b/jobs/Backend/Task/EchangeRateUpdater.Api/Controllers/ScalarController.cs new file mode 100644 index 0000000000..6e14e14c5d --- /dev/null +++ b/jobs/Backend/Task/EchangeRateUpdater.Api/Controllers/ScalarController.cs @@ -0,0 +1,27 @@ +namespace EchangeRateUpdater.Api.Controllers; + +using ExchangeRateUpdater.Application.ExchangeRates.Dtos; +using ExchangeRateUpdater.Domain.Repositories; +using Microsoft.AspNetCore.Mvc; + +[Route("api/[controller]")] +[ApiController] +public class ScalarController : ControllerBase +{ + [HttpGet] + public ActionResult Get() + { + var cache = HttpContext.RequestServices.GetService(); + if (cache is null) + { + return Ok(new { count = 0, lastUpdated = (DateTimeOffset?)null }); + } + + var cached = cache.GetFromCache>("rates:latest"); + var updated = cache.GetFromCache("rates:latest:updatedAt"); + + var count = cached?.Count ?? 0; + + return Ok(new { count, lastUpdated = updated }); + } +} diff --git a/jobs/Backend/Task/EchangeRateUpdater.Api/Dockerfile b/jobs/Backend/Task/EchangeRateUpdater.Api/Dockerfile new file mode 100644 index 0000000000..682e525167 --- /dev/null +++ b/jobs/Backend/Task/EchangeRateUpdater.Api/Dockerfile @@ -0,0 +1,13 @@ +FROM mcr.microsoft.com/dotnet/aspnet:9.0 AS base +WORKDIR /app + +FROM mcr.microsoft.com/dotnet/sdk:9.0 AS build +WORKDIR /src +COPY . . +# Publish the API project by specifying the .csproj file present in the build context +RUN dotnet publish "EchangeRateUpdater.Api/EchangeRateUpdater.Api.csproj" -c Release -o /app/publish + +FROM base AS final +WORKDIR /app +COPY --from=build /app/publish ./ +ENTRYPOINT ["dotnet", "EchangeRateUpdater.Api.dll"] diff --git a/jobs/Backend/Task/EchangeRateUpdater.Api/EchangeRateUpdater.Api.csproj b/jobs/Backend/Task/EchangeRateUpdater.Api/EchangeRateUpdater.Api.csproj index 93f3bbe2d1..0feb627ae5 100644 --- a/jobs/Backend/Task/EchangeRateUpdater.Api/EchangeRateUpdater.Api.csproj +++ b/jobs/Backend/Task/EchangeRateUpdater.Api/EchangeRateUpdater.Api.csproj @@ -1,8 +1,9 @@ - net6.0 + net9.0 enable + enable diff --git a/jobs/Backend/Task/EchangeRateUpdater.Api/Program.cs b/jobs/Backend/Task/EchangeRateUpdater.Api/Program.cs index b8620de601..3d254da936 100644 --- a/jobs/Backend/Task/EchangeRateUpdater.Api/Program.cs +++ b/jobs/Backend/Task/EchangeRateUpdater.Api/Program.cs @@ -1,10 +1,27 @@ using System.Text.Json; using ExchangeRateUpdater.Application; using ExchangeRateUpdater.Infrastructure; +using Microsoft.Extensions.Diagnostics.HealthChecks; +using Serilog; using SharpGrip.FluentValidation.AutoValidation.Mvc.Extensions; var builder = WebApplication.CreateBuilder(args); +// Read REDIS_CONNECTION env var into configuration (if present) +var redisConnection = Environment.GetEnvironmentVariable("REDIS_CONNECTION"); +if (!string.IsNullOrWhiteSpace(redisConnection)) +{ + builder.Configuration["Redis:Connection"] = redisConnection; +} + +// Serilog +Log.Logger = new LoggerConfiguration() + .ReadFrom.Configuration(builder.Configuration) + .Enrich.FromLogContext() + .CreateLogger(); + +builder.Host.UseSerilog(); + builder.Services.AddEndpointsApiExplorer(); builder.Services.AddSwaggerGen(); builder.Services.AddFluentValidationAutoValidation(); @@ -13,12 +30,18 @@ builder.Services.AddInfrastructure(builder.Configuration); builder.Services.AddControllers(); +builder.Services.AddHealthChecks() + .AddCheck("self", () => HealthCheckResult.Healthy()) + .AddRedis(builder.Configuration.GetValue("Redis:Connection"), name: "redis", tags: new[] { "ready" }); var app = builder.Build(); +app.UseSerilogRequestLogging(); + app.UseSwagger(); app.UseSwaggerUI(); app.UseHttpsRedirection(); app.MapControllers(); +app.MapHealthChecks("/healthz"); app.Run(); \ 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 index f1809c72f7..dae6b7cab3 100644 --- a/jobs/Backend/Task/ExchangeRateUpdater.Application/ExchangeRateUpdater.Application.csproj +++ b/jobs/Backend/Task/ExchangeRateUpdater.Application/ExchangeRateUpdater.Application.csproj @@ -1,8 +1,9 @@ - net6.0 + net9.0 enable + enable diff --git a/jobs/Backend/Task/ExchangeRateUpdater.Domain/ExchangeRateUpdater.Domain.csproj b/jobs/Backend/Task/ExchangeRateUpdater.Domain/ExchangeRateUpdater.Domain.csproj index eb2460e910..b903a56cce 100644 --- a/jobs/Backend/Task/ExchangeRateUpdater.Domain/ExchangeRateUpdater.Domain.csproj +++ b/jobs/Backend/Task/ExchangeRateUpdater.Domain/ExchangeRateUpdater.Domain.csproj @@ -1,7 +1,7 @@ - net6.0 + net9.0 enable enable 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..cd701af8fa --- /dev/null +++ b/jobs/Backend/Task/ExchangeRateUpdater.Infrastructure/Cache/RedisCacheRepository.cs @@ -0,0 +1,62 @@ +namespace ExchangeRateUpdater.Infrastructure.Cache; + +using System; +using System.Text.Json; +using Domain.Repositories; +using Microsoft.Extensions.Logging; +using StackExchange.Redis; + +public class RedisCacheRepository : ICacheRepository +{ + private readonly IConnectionMultiplexer connection; + private readonly IDatabase database; + private readonly ILogger logger; + + public RedisCacheRepository(IConnectionMultiplexer connection, ILogger logger) + { + this.connection = connection ?? throw new ArgumentNullException(nameof(connection)); + this.logger = logger ?? throw new ArgumentNullException(nameof(logger)); + this.database = this.connection.GetDatabase(); + } + + public T GetFromCache(string key) + { + var value = database.StringGet(key); + if (!value.HasValue) + { + logger.LogInformation("{Key} not found in redis cache", key); + return default!; + } + + try + { + var result = JsonSerializer.Deserialize(value!); + logger.LogInformation("{Key} found in redis cache", key); + return result!; + } + catch (Exception e) + { + logger.LogError(e, "Failed to deserialize cached value for {Key}", key); + return default!; + } + } + + public void SetCache(string key, T value, TimeSpan expirationDate) + { + var json = JsonSerializer.Serialize(value); + database.StringSet(key, json, expirationDate); + logger.LogInformation("{Key} added to redis cache", key); + } + + public void SetCache(string key, T value) + { + // default to 1 hour + SetCache(key, value, TimeSpan.FromHours(1)); + } + + public void ClearCache(string key) + { + database.KeyDelete(key); + logger.LogInformation("{Key} removed from redis cache", key); + } +} diff --git a/jobs/Backend/Task/ExchangeRateUpdater.Infrastructure/ExchangeRateUpdater.Infrastructure.csproj b/jobs/Backend/Task/ExchangeRateUpdater.Infrastructure/ExchangeRateUpdater.Infrastructure.csproj index dad786bbff..56b109d075 100644 --- a/jobs/Backend/Task/ExchangeRateUpdater.Infrastructure/ExchangeRateUpdater.Infrastructure.csproj +++ b/jobs/Backend/Task/ExchangeRateUpdater.Infrastructure/ExchangeRateUpdater.Infrastructure.csproj @@ -1,8 +1,9 @@ - net6.0 + net9.0 enable + enable @@ -12,6 +13,8 @@ + + diff --git a/jobs/Backend/Task/ExchangeRateUpdater.Infrastructure/ServiceCollectionExtensions.cs b/jobs/Backend/Task/ExchangeRateUpdater.Infrastructure/ServiceCollectionExtensions.cs index 7f0e0484b0..79dd87e1a4 100644 --- a/jobs/Backend/Task/ExchangeRateUpdater.Infrastructure/ServiceCollectionExtensions.cs +++ b/jobs/Backend/Task/ExchangeRateUpdater.Infrastructure/ServiceCollectionExtensions.cs @@ -12,6 +12,9 @@ namespace ExchangeRateUpdater.Infrastructure; using Data; using Polly; using Polly.Extensions.Http; +using StackExchange.Redis; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.Logging; public static class ServiceCollectionExtensions { @@ -24,10 +27,22 @@ public static IServiceCollection AddInfrastructure(this IServiceCollection self, httpClient.BaseAddress = new Uri(exchangeRateApiClientConfig.Value.BaseUrl); }).AddPolicyHandler(GetRetryPolicy()); - return self - // Configuration settings. - .AddSingleton() - .AddMemoryCache(); + // Redis configuration + var redisConnection = configuration.GetValue("Redis:Connection"); + + if (!string.IsNullOrWhiteSpace(redisConnection)) + { + var mux = ConnectionMultiplexer.Connect(redisConnection); + self.AddSingleton(mux); + self.AddSingleton(); + } + else + { + self.AddSingleton(); + self.AddMemoryCache(); + } + + return self; } static IAsyncPolicy GetRetryPolicy() => diff --git a/jobs/Backend/Task/ExchangeRateUpdater.Worker/Dockerfile b/jobs/Backend/Task/ExchangeRateUpdater.Worker/Dockerfile new file mode 100644 index 0000000000..bd2704bc30 --- /dev/null +++ b/jobs/Backend/Task/ExchangeRateUpdater.Worker/Dockerfile @@ -0,0 +1,13 @@ +FROM mcr.microsoft.com/dotnet/runtime:9.0 AS base +WORKDIR /app + +FROM mcr.microsoft.com/dotnet/sdk:9.0 AS build +WORKDIR /src +COPY . . +# Publish the Worker project by specifying the .csproj file present in the build context +RUN dotnet publish "ExchangeRateUpdater.Worker/ExchangeRateUpdater.Worker.csproj" -c Release -o /app/publish + +FROM base AS final +WORKDIR /app +COPY --from=build /app/publish ./ +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..817b1f8b59 --- /dev/null +++ b/jobs/Backend/Task/ExchangeRateUpdater.Worker/ExchangeRateUpdater.Worker.csproj @@ -0,0 +1,30 @@ + + + + net9.0 + enable + enable + + + + + + + + + + + + + + + + + PreserveNewest + + + PreserveNewest + + + + diff --git a/jobs/Backend/Task/ExchangeRateUpdater.Worker/Jobs/RefreshRatesJob.cs b/jobs/Backend/Task/ExchangeRateUpdater.Worker/Jobs/RefreshRatesJob.cs new file mode 100644 index 0000000000..f9b44f30a8 --- /dev/null +++ b/jobs/Backend/Task/ExchangeRateUpdater.Worker/Jobs/RefreshRatesJob.cs @@ -0,0 +1,43 @@ +namespace ExchangeRateUpdater.Worker.Jobs; + +using System; +using System.Linq; +using System.Threading.Tasks; +using Application.Common.Interfaces; +using Domain.Repositories; +using Microsoft.Extensions.Logging; +using Quartz; + +[DisallowConcurrentExecution] +public class RefreshRatesJob : IJob +{ + private readonly IExchangeRateApiClient apiClient; + private readonly ICacheRepository cacheRepository; + private readonly ILogger logger; + + public RefreshRatesJob(IExchangeRateApiClient apiClient, ICacheRepository cacheRepository, ILogger logger) + { + this.apiClient = apiClient; + this.cacheRepository = cacheRepository; + this.logger = logger; + } + + public async Task Execute(IJobExecutionContext context) + { + try + { + logger.LogInformation("Starting RefreshRatesJob at {Time}", DateTimeOffset.UtcNow); + var rates = await apiClient.GetExchangeRatesAsync(null, null); + var list = rates?.ToList() ?? new System.Collections.Generic.List(); + cacheRepository.SetCache("rates:latest", list); + // store updated timestamp separately for scalar metrics + cacheRepository.SetCache("rates:latest:updatedAt", DateTimeOffset.UtcNow); + logger.LogInformation("RefreshRatesJob completed - {Count} rates cached", list.Count); + } + catch (Exception e) + { + logger.LogError(e, "RefreshRatesJob failed"); + throw; + } + } +} diff --git a/jobs/Backend/Task/ExchangeRateUpdater.Worker/Program.cs b/jobs/Backend/Task/ExchangeRateUpdater.Worker/Program.cs new file mode 100644 index 0000000000..c0e5bba440 --- /dev/null +++ b/jobs/Backend/Task/ExchangeRateUpdater.Worker/Program.cs @@ -0,0 +1,36 @@ +using ExchangeRateUpdater.Application; +using ExchangeRateUpdater.Infrastructure; +using ExchangeRateUpdater.Worker.Jobs; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using Quartz; + +// Ensure REDIS_CONNECTION env var is visible in IConfiguration +var redisConnection = Environment.GetEnvironmentVariable("REDIS_CONNECTION"); +var builder = Host.CreateDefaultBuilder(args) + .ConfigureServices((hostContext, services) => + { + if (!string.IsNullOrWhiteSpace(redisConnection)) + { + hostContext.Configuration["Redis:Connection"] = redisConnection; + } + services.AddApplicationServices(); + services.AddInfrastructure(hostContext.Configuration); + + services.AddQuartz(q => + { + q.UseMicrosoftDependencyInjectionScopedJobFactory(); + + var jobKey = new JobKey("RefreshRatesJob"); + q.AddJob(opts => opts.WithIdentity(jobKey)); + q.AddTrigger(opts => opts + .ForJob(jobKey) + .WithIdentity("RefreshRatesTrigger") + .WithCronSchedule("0 0/1 * * * ?")); + }); + + services.AddQuartzHostedService(q => q.WaitForJobsToComplete = true); + }) + .Build(); + +await builder.RunAsync(); 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..4f30a00f83 --- /dev/null +++ b/jobs/Backend/Task/ExchangeRateUpdater.Worker/appsettings.Development.json @@ -0,0 +1,9 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Debug", + "Microsoft": "Warning", + "Microsoft.Hosting.Lifetime": "Information" + } + } +} diff --git a/jobs/Backend/Task/ExchangeRateUpdater.Worker/appsettings.json b/jobs/Backend/Task/ExchangeRateUpdater.Worker/appsettings.json new file mode 100644 index 0000000000..8983e0fc1c --- /dev/null +++ b/jobs/Backend/Task/ExchangeRateUpdater.Worker/appsettings.json @@ -0,0 +1,9 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft": "Warning", + "Microsoft.Hosting.Lifetime": "Information" + } + } +} diff --git a/jobs/Backend/Task/ExhangeRateUpdater/ExchangeRateUpdater.csproj b/jobs/Backend/Task/ExhangeRateUpdater/ExchangeRateUpdater.csproj index d5cb8fd076..a1af9ef45f 100644 --- a/jobs/Backend/Task/ExhangeRateUpdater/ExchangeRateUpdater.csproj +++ b/jobs/Backend/Task/ExhangeRateUpdater/ExchangeRateUpdater.csproj @@ -2,7 +2,9 @@ Exe - net6.0 + net9.0 + enable + enable diff --git a/jobs/Backend/Task/docker-compose.yml b/jobs/Backend/Task/docker-compose.yml new file mode 100644 index 0000000000..7951a7af91 --- /dev/null +++ b/jobs/Backend/Task/docker-compose.yml @@ -0,0 +1,29 @@ +version: '3.8' +services: + redis: + image: redis:7 + container_name: exchange-rate-redis + ports: + - "6379:6379" + + api: + build: + context: . + dockerfile: EchangeRateUpdater.Api/Dockerfile + environment: + - REDIS_CONNECTION=redis:6379 + - ExchangeRateApiClientConfig__BaseUrl=https://api.cnb.cz/cnbapi/exrates/ + depends_on: + - redis + ports: + - "5000:80" + + worker: + build: + context: . + dockerfile: ExchangeRateUpdater.Worker/Dockerfile + environment: + - REDIS_CONNECTION=redis:6379 + - ExchangeRateApiClientConfig__BaseUrl=https://api.cnb.cz/cnbapi/exrates/ + depends_on: + - redis From e469dd80a68b51d920ddcac67ec08fb57033f91f Mon Sep 17 00:00:00 2001 From: Amin Chouaibi Date: Tue, 25 Nov 2025 18:30:02 +0100 Subject: [PATCH 3/9] wip --- .../ApplicationBuilderExtensions.cs | 31 ++++++++ .../Extensions/OpenApiConfiguration.cs | 26 +++++++ .../Extensions/ServiceCollectionExtensions.cs | 19 +++++ .../Controllers/ApiControllerBase.cs | 13 ---- .../Controllers/ExchangeRatesController.cs | 49 +++--------- .../Controllers/ScalarController.cs | 27 ------- .../EchangeRateUpdater.Api.csproj | 5 +- .../GlobalExceptionHandler.cs | 62 +++++++++++++++ .../Task/EchangeRateUpdater.Api/Program.cs | 75 ++++++++++--------- .../Properties/launchSettings.json | 22 ++---- .../appsettings.Development.json | 6 ++ .../EchangeRateUpdater.Api/appsettings.json | 3 + .../Behaviours/MessageValidatorBehaviour.cs | 16 ++++ .../Common/Behaviours/ValidationBehaviour.cs | 37 --------- .../Common/Exceptions/ValidationException.cs | 22 ------ .../Interfaces/IExchangeRateApiClient.cs | 2 - .../Interfaces/IExchangeRateProvider.cs | 16 ---- .../Profiles/ExchangeRateApiDtoProfile.cs | 1 + .../Profiles/ExchangeRateDtoProfile.cs | 1 + .../ExchangeRateUpdater.Application.csproj | 6 +- .../GetExchangesRatesByDateQuery.cs | 5 +- .../GetExchangesRatesByDateQueryHandler.cs | 40 ++++------ .../ServiceCollectionExtensions.cs | 14 ++-- .../Entities/ExchangeRate.cs | 34 +++------ .../ValueObjects/Currency.cs | 2 +- .../ApiClients/ExchangeRateApiClient.cs | 2 - .../ServiceCollectionExtensions.cs | 1 + .../ExchangeRateUpdater.Worker.csproj | 5 +- .../Jobs/RefreshRatesJob.cs | 2 +- .../ExchangeRateUpdater.Worker/Program.cs | 6 +- jobs/Backend/Task/ExchangeRateUpdater.sln | 6 ++ .../Task/ExhangeRateUpdater/Program.cs | 18 ++--- 32 files changed, 289 insertions(+), 285 deletions(-) create mode 100644 jobs/Backend/Task/EchangeRateUpdater.Api/Configurations/Extensions/ApplicationBuilderExtensions.cs create mode 100644 jobs/Backend/Task/EchangeRateUpdater.Api/Configurations/Extensions/OpenApiConfiguration.cs create mode 100644 jobs/Backend/Task/EchangeRateUpdater.Api/Configurations/Extensions/ServiceCollectionExtensions.cs delete mode 100644 jobs/Backend/Task/EchangeRateUpdater.Api/Controllers/ApiControllerBase.cs delete mode 100644 jobs/Backend/Task/EchangeRateUpdater.Api/Controllers/ScalarController.cs create mode 100644 jobs/Backend/Task/EchangeRateUpdater.Api/GlobalExceptionHandler.cs create mode 100644 jobs/Backend/Task/ExchangeRateUpdater.Application/Common/Behaviours/MessageValidatorBehaviour.cs delete mode 100644 jobs/Backend/Task/ExchangeRateUpdater.Application/Common/Behaviours/ValidationBehaviour.cs delete mode 100644 jobs/Backend/Task/ExchangeRateUpdater.Application/Common/Exceptions/ValidationException.cs delete mode 100644 jobs/Backend/Task/ExchangeRateUpdater.Application/Common/Interfaces/IExchangeRateProvider.cs diff --git a/jobs/Backend/Task/EchangeRateUpdater.Api/Configurations/Extensions/ApplicationBuilderExtensions.cs b/jobs/Backend/Task/EchangeRateUpdater.Api/Configurations/Extensions/ApplicationBuilderExtensions.cs new file mode 100644 index 0000000000..518e8897b5 --- /dev/null +++ b/jobs/Backend/Task/EchangeRateUpdater.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/EchangeRateUpdater.Api/Configurations/Extensions/OpenApiConfiguration.cs b/jobs/Backend/Task/EchangeRateUpdater.Api/Configurations/Extensions/OpenApiConfiguration.cs new file mode 100644 index 0000000000..1c99b7dd07 --- /dev/null +++ b/jobs/Backend/Task/EchangeRateUpdater.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/EchangeRateUpdater.Api/Configurations/Extensions/ServiceCollectionExtensions.cs b/jobs/Backend/Task/EchangeRateUpdater.Api/Configurations/Extensions/ServiceCollectionExtensions.cs new file mode 100644 index 0000000000..e6f48556ee --- /dev/null +++ b/jobs/Backend/Task/EchangeRateUpdater.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/EchangeRateUpdater.Api/Controllers/ApiControllerBase.cs b/jobs/Backend/Task/EchangeRateUpdater.Api/Controllers/ApiControllerBase.cs deleted file mode 100644 index c79f649c2c..0000000000 --- a/jobs/Backend/Task/EchangeRateUpdater.Api/Controllers/ApiControllerBase.cs +++ /dev/null @@ -1,13 +0,0 @@ -namespace EchangeRateUpdater.Api.Controllers; - -using MediatR; -using Microsoft.AspNetCore.Mvc; - -[ApiController] -[Route("api/[controller]")] -public class ApiControllerBase : ControllerBase -{ - private ISender _mediator = null!; - - protected ISender Mediator => _mediator ??= HttpContext.RequestServices.GetRequiredService(); -} \ No newline at end of file diff --git a/jobs/Backend/Task/EchangeRateUpdater.Api/Controllers/ExchangeRatesController.cs b/jobs/Backend/Task/EchangeRateUpdater.Api/Controllers/ExchangeRatesController.cs index 67924d6b7c..3dd812f7bc 100644 --- a/jobs/Backend/Task/EchangeRateUpdater.Api/Controllers/ExchangeRatesController.cs +++ b/jobs/Backend/Task/EchangeRateUpdater.Api/Controllers/ExchangeRatesController.cs @@ -1,46 +1,21 @@ -namespace EchangeRateUpdater.Api.Controllers; +namespace ExchangeRateUpdater.Api.Controllers; -using System.ComponentModel.DataAnnotations; -using ExchangeRateUpdater.Application.Common.Models; -using ExchangeRateUpdater.Application.ExchangeRates.Dtos; -using ExchangeRateUpdater.Application.ExchangeRates.Query.GetExchangeRatesDaily; -using ExchangeRateUpdater.Domain.Repositories; +using Application.ExchangeRates.Dtos; +using Application.ExchangeRates.Query.GetExchangeRatesDaily; +using Mediator; using Microsoft.AspNetCore.Mvc; -public class ExchangeRatesController : ApiControllerBase +[ApiController] +[Route("exchange-rates")] +public class ExchangeRatesController (IMediator mediator) : Controller { [HttpGet] - public async Task>>> GetExchangeRatesByDate( + [ProducesResponseType(typeof(List), StatusCodes.Status200OK)] + [ProducesResponseType(typeof(ValidationProblemDetails), StatusCodes.Status400BadRequest)] + public async Task>> GetExchangeRatesByDate( [FromQuery] GetExchangesRatesByDateQuery query) { - try - { - // Try cache first - var cache = HttpContext.RequestServices.GetService(); - if (cache is not null) - { - var cached = cache.GetFromCache>>("rates:latest"); - if (cached is not null) - { - return Ok(cached); - } - } - - var result = await Mediator.Send(query); - if (!result.Succeeded) - { - return BadRequest(result.Errors); - } - - // store in cache for later - cache?.SetCache("rates:latest", result); - cache?.SetCache("rates:latest:updatedAt", DateTimeOffset.UtcNow); - - return Ok(result); - } - catch (Exception e) - { - return BadRequest(e); - } + var result = await mediator.Send(query); + return Ok(result); } } \ No newline at end of file diff --git a/jobs/Backend/Task/EchangeRateUpdater.Api/Controllers/ScalarController.cs b/jobs/Backend/Task/EchangeRateUpdater.Api/Controllers/ScalarController.cs deleted file mode 100644 index 6e14e14c5d..0000000000 --- a/jobs/Backend/Task/EchangeRateUpdater.Api/Controllers/ScalarController.cs +++ /dev/null @@ -1,27 +0,0 @@ -namespace EchangeRateUpdater.Api.Controllers; - -using ExchangeRateUpdater.Application.ExchangeRates.Dtos; -using ExchangeRateUpdater.Domain.Repositories; -using Microsoft.AspNetCore.Mvc; - -[Route("api/[controller]")] -[ApiController] -public class ScalarController : ControllerBase -{ - [HttpGet] - public ActionResult Get() - { - var cache = HttpContext.RequestServices.GetService(); - if (cache is null) - { - return Ok(new { count = 0, lastUpdated = (DateTimeOffset?)null }); - } - - var cached = cache.GetFromCache>("rates:latest"); - var updated = cache.GetFromCache("rates:latest:updatedAt"); - - var count = cached?.Count ?? 0; - - return Ok(new { count, lastUpdated = updated }); - } -} diff --git a/jobs/Backend/Task/EchangeRateUpdater.Api/EchangeRateUpdater.Api.csproj b/jobs/Backend/Task/EchangeRateUpdater.Api/EchangeRateUpdater.Api.csproj index 0feb627ae5..8182ea87af 100644 --- a/jobs/Backend/Task/EchangeRateUpdater.Api/EchangeRateUpdater.Api.csproj +++ b/jobs/Backend/Task/EchangeRateUpdater.Api/EchangeRateUpdater.Api.csproj @@ -7,8 +7,9 @@ - - + + + diff --git a/jobs/Backend/Task/EchangeRateUpdater.Api/GlobalExceptionHandler.cs b/jobs/Backend/Task/EchangeRateUpdater.Api/GlobalExceptionHandler.cs new file mode 100644 index 0000000000..7cfcc4c680 --- /dev/null +++ b/jobs/Backend/Task/EchangeRateUpdater.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/EchangeRateUpdater.Api/Program.cs b/jobs/Backend/Task/EchangeRateUpdater.Api/Program.cs index 3d254da936..4d0c0d8507 100644 --- a/jobs/Backend/Task/EchangeRateUpdater.Api/Program.cs +++ b/jobs/Backend/Task/EchangeRateUpdater.Api/Program.cs @@ -1,47 +1,48 @@ -using System.Text.Json; +using EchangeRateUpdater.Api.Configurations.Extensions; using ExchangeRateUpdater.Application; using ExchangeRateUpdater.Infrastructure; -using Microsoft.Extensions.Diagnostics.HealthChecks; using Serilog; -using SharpGrip.FluentValidation.AutoValidation.Mvc.Extensions; -var builder = WebApplication.CreateBuilder(args); +InitializeBootstrapLogger(); -// Read REDIS_CONNECTION env var into configuration (if present) -var redisConnection = Environment.GetEnvironmentVariable("REDIS_CONNECTION"); -if (!string.IsNullOrWhiteSpace(redisConnection)) +try { - builder.Configuration["Redis:Connection"] = redisConnection; + 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(); } -// Serilog -Log.Logger = new LoggerConfiguration() - .ReadFrom.Configuration(builder.Configuration) - .Enrich.FromLogContext() - .CreateLogger(); - -builder.Host.UseSerilog(); - -builder.Services.AddEndpointsApiExplorer(); -builder.Services.AddSwaggerGen(); -builder.Services.AddFluentValidationAutoValidation(); -builder.Services.AddHttpContextAccessor(); -builder.Services.AddApplicationServices(); -builder.Services.AddInfrastructure(builder.Configuration); -builder.Services.AddControllers(); - -builder.Services.AddHealthChecks() - .AddCheck("self", () => HealthCheckResult.Healthy()) - .AddRedis(builder.Configuration.GetValue("Redis:Connection"), name: "redis", tags: new[] { "ready" }); - -var app = builder.Build(); - -app.UseSerilogRequestLogging(); +return; -app.UseSwagger(); -app.UseSwaggerUI(); -app.UseHttpsRedirection(); -app.MapControllers(); -app.MapHealthChecks("/healthz"); -app.Run(); \ No newline at end of file +void InitializeBootstrapLogger() +{ + var config = new LoggerConfiguration().WriteTo.Console(); + + Log.Logger = config.CreateBootstrapLogger(); +} \ No newline at end of file diff --git a/jobs/Backend/Task/EchangeRateUpdater.Api/Properties/launchSettings.json b/jobs/Backend/Task/EchangeRateUpdater.Api/Properties/launchSettings.json index fdacde6df9..1731fb8766 100644 --- a/jobs/Backend/Task/EchangeRateUpdater.Api/Properties/launchSettings.json +++ b/jobs/Backend/Task/EchangeRateUpdater.Api/Properties/launchSettings.json @@ -1,28 +1,22 @@ { "$schema": "https://json.schemastore.org/launchsettings.json", - "iisSettings": { - "windowsAuthentication": false, - "anonymousAuthentication": true, - "iisExpress": { - "applicationUrl": "http://localhost:42839", - "sslPort": 44392 - } - }, "profiles": { - "EchangeRateUpdater.Api": { + "http": { "commandName": "Project", "dotnetRunMessages": true, "launchBrowser": true, - "launchUrl": "swagger", - "applicationUrl": "https://localhost:7197;http://localhost:5173", + "launchUrl": "scalar", + "applicationUrl": "http://localhost:5087", "environmentVariables": { "ASPNETCORE_ENVIRONMENT": "Development" } }, - "IIS Express": { - "commandName": "IISExpress", + "https": { + "commandName": "Project", + "dotnetRunMessages": true, "launchBrowser": true, - "launchUrl": "swagger", + "launchUrl": "scalar", + "applicationUrl": "https://localhost:7169;http://localhost:5087", "environmentVariables": { "ASPNETCORE_ENVIRONMENT": "Development" } diff --git a/jobs/Backend/Task/EchangeRateUpdater.Api/appsettings.Development.json b/jobs/Backend/Task/EchangeRateUpdater.Api/appsettings.Development.json index 0c208ae918..4e8476eef3 100644 --- a/jobs/Backend/Task/EchangeRateUpdater.Api/appsettings.Development.json +++ b/jobs/Backend/Task/EchangeRateUpdater.Api/appsettings.Development.json @@ -4,5 +4,11 @@ "Default": "Information", "Microsoft.AspNetCore": "Warning" } + }, + "ExchangeRateApiClientConfig": { + "BaseUrl": "https://api.cnb.cz/cnbapi/exrates/" + }, + "ApiDocumentation": { + "Enabled": true } } diff --git a/jobs/Backend/Task/EchangeRateUpdater.Api/appsettings.json b/jobs/Backend/Task/EchangeRateUpdater.Api/appsettings.json index becc4a06b9..3106d792d2 100644 --- a/jobs/Backend/Task/EchangeRateUpdater.Api/appsettings.json +++ b/jobs/Backend/Task/EchangeRateUpdater.Api/appsettings.json @@ -8,5 +8,8 @@ "AllowedHosts": "*", "ExchangeRateApiClientConfig": { "BaseUrl": "https://api.cnb.cz/cnbapi/exrates/" + }, + "ApiDocumentation": { + "Enabled": false } } 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/Behaviours/ValidationBehaviour.cs b/jobs/Backend/Task/ExchangeRateUpdater.Application/Common/Behaviours/ValidationBehaviour.cs deleted file mode 100644 index 95342785fb..0000000000 --- a/jobs/Backend/Task/ExchangeRateUpdater.Application/Common/Behaviours/ValidationBehaviour.cs +++ /dev/null @@ -1,37 +0,0 @@ -namespace ExchangeRateUpdater.Application.Common.Behaviours; - -using FluentValidation; -using MediatR; - -public class ValidationBehaviour : IPipelineBehavior - where TRequest : IRequest -{ - private readonly IEnumerable> _validators; - - public ValidationBehaviour(IEnumerable> validators) - { - _validators = validators; - } - - public async Task Handle(TRequest request, RequestHandlerDelegate next, CancellationToken cancellationToken) - { - if (!_validators.Any()) - return await next(); - - var context = new ValidationContext(request); - - var validationResults = await Task.WhenAll( - _validators.Select(v => - v.ValidateAsync(context, cancellationToken))); - - var failures = validationResults - .Where(r => r.Errors.Any()) - .SelectMany(r => r.Errors) - .ToList(); - - if (failures.Any()) - throw new ValidationException(failures); - - return await next(); - } -} \ No newline at end of file diff --git a/jobs/Backend/Task/ExchangeRateUpdater.Application/Common/Exceptions/ValidationException.cs b/jobs/Backend/Task/ExchangeRateUpdater.Application/Common/Exceptions/ValidationException.cs deleted file mode 100644 index 209b6f7d5b..0000000000 --- a/jobs/Backend/Task/ExchangeRateUpdater.Application/Common/Exceptions/ValidationException.cs +++ /dev/null @@ -1,22 +0,0 @@ -namespace ExchangeRateUpdater.Application.Common.Exceptions; - -using FluentValidation.Results; - -public class ValidationException: Exception -{ - public ValidationException() - : base("One or more validation failures have occurred.") - { - Errors = new Dictionary(); - } - - public ValidationException(IEnumerable failures) - : this() - { - Errors = failures - .GroupBy(e => e.PropertyName, e => e.ErrorMessage) - .ToDictionary(failureGroup => failureGroup.Key, failureGroup => failureGroup.ToArray()); - } - - public IDictionary Errors { get; } -} \ 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 index 5848e7d129..243c7819d5 100644 --- a/jobs/Backend/Task/ExchangeRateUpdater.Application/Common/Interfaces/IExchangeRateApiClient.cs +++ b/jobs/Backend/Task/ExchangeRateUpdater.Application/Common/Interfaces/IExchangeRateApiClient.cs @@ -1,9 +1,7 @@ namespace ExchangeRateUpdater.Application.Common.Interfaces; -using Domain.Entities; using Domain.Enums; using ExchangeRates.Dtos; -using ExchangeRates.Query; public interface IExchangeRateApiClient { diff --git a/jobs/Backend/Task/ExchangeRateUpdater.Application/Common/Interfaces/IExchangeRateProvider.cs b/jobs/Backend/Task/ExchangeRateUpdater.Application/Common/Interfaces/IExchangeRateProvider.cs deleted file mode 100644 index 10ed8d6f57..0000000000 --- a/jobs/Backend/Task/ExchangeRateUpdater.Application/Common/Interfaces/IExchangeRateProvider.cs +++ /dev/null @@ -1,16 +0,0 @@ -namespace ExchangeRateUpdater.Application.Common.Interfaces; - -using Domain.Entities; -using Domain.Enums; - -public interface IExchangeRateProvider -{ - /// - /// Should return exchange rates among the specified currencies that are defined by the source. But only those defined - /// by the source, do not return calculated exchange rates. E.g. if the source contains "CZK/USD" but not "USD/CZK", - /// do not return exchange rate "USD/CZK" with value calculated as 1 / "CZK/USD". If the source does not provide - /// some of the currencies, ignore them. - /// - Task> GetExchangeRatesAsync(IEnumerable currencies, DateTime? date, - Language? language); -} \ No newline at end of file diff --git a/jobs/Backend/Task/ExchangeRateUpdater.Application/Common/Mappings/Profiles/ExchangeRateApiDtoProfile.cs b/jobs/Backend/Task/ExchangeRateUpdater.Application/Common/Mappings/Profiles/ExchangeRateApiDtoProfile.cs index 059c961ff1..9419db4dc6 100644 --- a/jobs/Backend/Task/ExchangeRateUpdater.Application/Common/Mappings/Profiles/ExchangeRateApiDtoProfile.cs +++ b/jobs/Backend/Task/ExchangeRateUpdater.Application/Common/Mappings/Profiles/ExchangeRateApiDtoProfile.cs @@ -1,6 +1,7 @@ namespace ExchangeRateUpdater.Application.Common.Mappings.Profiles; using AutoMapper; +using Domain.ValueObjects; using ExchangeRateUpdater.Application.ExchangeRates.Dtos; using ExchangeRateUpdater.Domain.Entities; diff --git a/jobs/Backend/Task/ExchangeRateUpdater.Application/Common/Mappings/Profiles/ExchangeRateDtoProfile.cs b/jobs/Backend/Task/ExchangeRateUpdater.Application/Common/Mappings/Profiles/ExchangeRateDtoProfile.cs index 6c7e45c82b..4c82903579 100644 --- a/jobs/Backend/Task/ExchangeRateUpdater.Application/Common/Mappings/Profiles/ExchangeRateDtoProfile.cs +++ b/jobs/Backend/Task/ExchangeRateUpdater.Application/Common/Mappings/Profiles/ExchangeRateDtoProfile.cs @@ -1,6 +1,7 @@ namespace ExchangeRateUpdater.Application.Common.Mappings.Profiles; using AutoMapper; +using Domain.ValueObjects; using ExchangeRateUpdater.Application.ExchangeRates.Dtos; using ExchangeRateUpdater.Domain.Entities; diff --git a/jobs/Backend/Task/ExchangeRateUpdater.Application/ExchangeRateUpdater.Application.csproj b/jobs/Backend/Task/ExchangeRateUpdater.Application/ExchangeRateUpdater.Application.csproj index dae6b7cab3..cb19e7b36e 100644 --- a/jobs/Backend/Task/ExchangeRateUpdater.Application/ExchangeRateUpdater.Application.csproj +++ b/jobs/Backend/Task/ExchangeRateUpdater.Application/ExchangeRateUpdater.Application.csproj @@ -10,7 +10,11 @@ - + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + diff --git a/jobs/Backend/Task/ExchangeRateUpdater.Application/ExchangeRates/Query/GetExchangeRatesDaily/GetExchangesRatesByDateQuery.cs b/jobs/Backend/Task/ExchangeRateUpdater.Application/ExchangeRates/Query/GetExchangeRatesDaily/GetExchangesRatesByDateQuery.cs index 86b7c96a76..c7ebaab609 100644 --- a/jobs/Backend/Task/ExchangeRateUpdater.Application/ExchangeRates/Query/GetExchangeRatesDaily/GetExchangesRatesByDateQuery.cs +++ b/jobs/Backend/Task/ExchangeRateUpdater.Application/ExchangeRates/Query/GetExchangeRatesDaily/GetExchangesRatesByDateQuery.cs @@ -1,11 +1,10 @@ namespace ExchangeRateUpdater.Application.ExchangeRates.Query.GetExchangeRatesDaily; -using Common.Models; using Domain.Enums; using Dtos; -using MediatR; +using Mediator; -public class GetExchangesRatesByDateQuery : IRequest>> +public class GetExchangesRatesByDateQuery : IQuery> { /// /// List of three-letter ISO 4217 currency codes for which exchange rates are requested. diff --git a/jobs/Backend/Task/ExchangeRateUpdater.Application/ExchangeRates/Query/GetExchangeRatesDaily/GetExchangesRatesByDateQueryHandler.cs b/jobs/Backend/Task/ExchangeRateUpdater.Application/ExchangeRates/Query/GetExchangeRatesDaily/GetExchangesRatesByDateQueryHandler.cs index 68f339fe66..d3954e20e6 100644 --- a/jobs/Backend/Task/ExchangeRateUpdater.Application/ExchangeRates/Query/GetExchangeRatesDaily/GetExchangesRatesByDateQueryHandler.cs +++ b/jobs/Backend/Task/ExchangeRateUpdater.Application/ExchangeRates/Query/GetExchangeRatesDaily/GetExchangesRatesByDateQueryHandler.cs @@ -6,8 +6,9 @@ namespace ExchangeRateUpdater.Application.ExchangeRates.Query.GetExchangeRatesDa using Domain.Common; using Domain.Entities; using Domain.Repositories; +using Domain.ValueObjects; using Dtos; -using MediatR; +using Mediator; /// /// Should return exchange rates among the specified currencies that are defined by the source. But only those defined @@ -15,7 +16,7 @@ namespace ExchangeRateUpdater.Application.ExchangeRates.Query.GetExchangeRatesDa /// 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 : IRequestHandler>> +public class GetExchangesRatesByDateQueryHandler : IQueryHandler> { private readonly ICacheRepository _cacheRepository; private readonly IExchangeRateApiClient _exchangeRateApiClient; @@ -33,33 +34,24 @@ public GetExchangesRatesByDateQueryHandler(ICacheRepository cacheRepository, _mapper = mapper; } - public async Task>> Handle(GetExchangesRatesByDateQuery request, + public async ValueTask> Handle(GetExchangesRatesByDateQuery request, CancellationToken cancellationToken) { - try - { - var requestedCurrencies = request.CurrencyCodes.Select(currencyCode => new Currency(currencyCode)); - var exchangeRates = _cacheRepository - .GetFromCache>(CacheKey); - - if (exchangeRates == null || !exchangeRates.Any()) - { - var exchangeRateApiDtos = await _exchangeRateApiClient.GetExchangeRatesAsync(request.Date, request.Language); - exchangeRates = exchangeRateApiDtos.Select(x => _mapper.Map(x)).ToList(); - _cacheRepository.SetCache(CacheKey, exchangeRates); - } - - var requestedExchangeRates = exchangeRates - .Where(rates => requestedCurrencies.Any(currency => currency == rates.SourceCurrency)) - .Select(exchangeRate => _mapper.Map(exchangeRate)).ToList(); + var requestedCurrencies = request.CurrencyCodes.Select(currencyCode => new Currency(currencyCode)); + var exchangeRates = _cacheRepository + .GetFromCache>(CacheKey); - return Result>.Success(requestedExchangeRates); - } - catch (Exception e) + if (!exchangeRates.Any()) { - return Result>.Failure(e.Message); - throw; + var exchangeRateApiDtos = await _exchangeRateApiClient.GetExchangeRatesAsync(request.Date, request.Language); + exchangeRates = exchangeRateApiDtos.Select(x => _mapper.Map(x)).ToList(); + _cacheRepository.SetCache(CacheKey, exchangeRates); } + var requestedExchangeRates = exchangeRates + .Where(rates => requestedCurrencies.Any(currency => currency == rates.SourceCurrency)) + .Select(exchangeRate => _mapper.Map(exchangeRate)).ToList(); + + return requestedExchangeRates; } } \ No newline at end of file diff --git a/jobs/Backend/Task/ExchangeRateUpdater.Application/ServiceCollectionExtensions.cs b/jobs/Backend/Task/ExchangeRateUpdater.Application/ServiceCollectionExtensions.cs index 628f180bb7..b722773672 100644 --- a/jobs/Backend/Task/ExchangeRateUpdater.Application/ServiceCollectionExtensions.cs +++ b/jobs/Backend/Task/ExchangeRateUpdater.Application/ServiceCollectionExtensions.cs @@ -3,17 +3,21 @@ namespace ExchangeRateUpdater.Application; using System.Reflection; using Common.Behaviours; using FluentValidation; -using MediatR; using Microsoft.Extensions.DependencyInjection; public static class ServiceCollectionExtensions { public static IServiceCollection AddApplicationServices(this IServiceCollection services) { - services.AddAutoMapper(Assembly.GetExecutingAssembly()); - services.AddValidatorsFromAssembly(Assembly.GetExecutingAssembly()); - services.AddMediatR(Assembly.GetExecutingAssembly()); - services.AddTransient(typeof(IPipelineBehavior<,>), typeof(ValidationBehaviour<,>)); + services.AddAutoMapper(Assembly.GetExecutingAssembly()) + .AddMediator(options => + { + options.ServiceLifetime = ServiceLifetime.Transient; + options.GenerateTypesAsInternal = true; + options.Assemblies = [typeof(ServiceCollectionExtensions).Assembly]; + options.PipelineBehaviors = [typeof(MessageValidatorBehaviour<,>)]; + }) + .AddValidatorsFromAssembly(typeof(ServiceCollectionExtensions).Assembly); return services; } diff --git a/jobs/Backend/Task/ExchangeRateUpdater.Domain/Entities/ExchangeRate.cs b/jobs/Backend/Task/ExchangeRateUpdater.Domain/Entities/ExchangeRate.cs index 03365d405c..e9610d8678 100644 --- a/jobs/Backend/Task/ExchangeRateUpdater.Domain/Entities/ExchangeRate.cs +++ b/jobs/Backend/Task/ExchangeRateUpdater.Domain/Entities/ExchangeRate.cs @@ -1,33 +1,23 @@ namespace ExchangeRateUpdater.Domain.Entities; using Common; +using ValueObjects; -public class ExchangeRate +public class ExchangeRate(Currency sourceCurrency, Currency targetCurrency, decimal value) { - public ExchangeRate() - { - } - - public ExchangeRate(Currency sourceCurrency, Currency targetCurrency, decimal value) - { - SourceCurrency = sourceCurrency; - TargetCurrency = targetCurrency; - Value = value; - } - - public Currency SourceCurrency { get; set; } + public Currency SourceCurrency { get; set; } = sourceCurrency; - public Currency TargetCurrency { get; set; } + public Currency TargetCurrency { get; set; } = targetCurrency; - public decimal Value { get; } + public decimal Value { get; } = value; - // public static ExchangeRate New(Currency sourceCurrency, Currency targetCurrency, decimal value) - // { - // Ensure.Argument.NotNull(sourceCurrency, "Source currency should not be null"); - // Ensure.Argument.NotNull(targetCurrency, "Target currency should not be null"); - // Ensure.Argument.NotNull(value, "Exchange value should not be null"); - // return new ExchangeRate(sourceCurrency, targetCurrency, value); - // } + public static ExchangeRate New(Currency sourceCurrency, Currency targetCurrency, decimal value) + { + Ensure.Argument.NotNull(sourceCurrency, "Source currency should not be null"); + Ensure.Argument.NotNull(targetCurrency, "Target currency should not be null"); + Ensure.Argument.NotNull(value, "Exchange value should not be null"); + return new ExchangeRate(sourceCurrency, targetCurrency, value); + } public override string ToString() { diff --git a/jobs/Backend/Task/ExchangeRateUpdater.Domain/ValueObjects/Currency.cs b/jobs/Backend/Task/ExchangeRateUpdater.Domain/ValueObjects/Currency.cs index 481d91887b..856e5c2bf5 100644 --- a/jobs/Backend/Task/ExchangeRateUpdater.Domain/ValueObjects/Currency.cs +++ b/jobs/Backend/Task/ExchangeRateUpdater.Domain/ValueObjects/Currency.cs @@ -1,4 +1,4 @@ -namespace ExchangeRateUpdater; +namespace ExchangeRateUpdater.Domain.ValueObjects; public record Currency { diff --git a/jobs/Backend/Task/ExchangeRateUpdater.Infrastructure/ApiClients/ExchangeRateApiClient.cs b/jobs/Backend/Task/ExchangeRateUpdater.Infrastructure/ApiClients/ExchangeRateApiClient.cs index 16868f4e05..a5f1ef8ad0 100644 --- a/jobs/Backend/Task/ExchangeRateUpdater.Infrastructure/ApiClients/ExchangeRateApiClient.cs +++ b/jobs/Backend/Task/ExchangeRateUpdater.Infrastructure/ApiClients/ExchangeRateApiClient.cs @@ -4,10 +4,8 @@ using System.Text.Json; using Application.Common.Interfaces; using Application.ExchangeRates.Dtos; -using Application.ExchangeRates.Query; using Domain.Common; using Domain.Enums; -using Domain.Repositories; public class ExchangeRateApiClient : IExchangeRateApiClient { diff --git a/jobs/Backend/Task/ExchangeRateUpdater.Infrastructure/ServiceCollectionExtensions.cs b/jobs/Backend/Task/ExchangeRateUpdater.Infrastructure/ServiceCollectionExtensions.cs index 79dd87e1a4..9802686fe4 100644 --- a/jobs/Backend/Task/ExchangeRateUpdater.Infrastructure/ServiceCollectionExtensions.cs +++ b/jobs/Backend/Task/ExchangeRateUpdater.Infrastructure/ServiceCollectionExtensions.cs @@ -3,6 +3,7 @@ namespace ExchangeRateUpdater.Infrastructure; using System; using ApiClients; using Application.Common.Interfaces; +using Cache; using Configurations; using Domain.Repositories; using Microsoft.Extensions.Configuration; diff --git a/jobs/Backend/Task/ExchangeRateUpdater.Worker/ExchangeRateUpdater.Worker.csproj b/jobs/Backend/Task/ExchangeRateUpdater.Worker/ExchangeRateUpdater.Worker.csproj index 817b1f8b59..56b67787c8 100644 --- a/jobs/Backend/Task/ExchangeRateUpdater.Worker/ExchangeRateUpdater.Worker.csproj +++ b/jobs/Backend/Task/ExchangeRateUpdater.Worker/ExchangeRateUpdater.Worker.csproj @@ -1,4 +1,4 @@ - + net9.0 @@ -8,8 +8,9 @@ - + + diff --git a/jobs/Backend/Task/ExchangeRateUpdater.Worker/Jobs/RefreshRatesJob.cs b/jobs/Backend/Task/ExchangeRateUpdater.Worker/Jobs/RefreshRatesJob.cs index f9b44f30a8..5a39bef1a2 100644 --- a/jobs/Backend/Task/ExchangeRateUpdater.Worker/Jobs/RefreshRatesJob.cs +++ b/jobs/Backend/Task/ExchangeRateUpdater.Worker/Jobs/RefreshRatesJob.cs @@ -28,7 +28,7 @@ public async Task Execute(IJobExecutionContext context) { logger.LogInformation("Starting RefreshRatesJob at {Time}", DateTimeOffset.UtcNow); var rates = await apiClient.GetExchangeRatesAsync(null, null); - var list = rates?.ToList() ?? new System.Collections.Generic.List(); + var list = rates?.ToList(); cacheRepository.SetCache("rates:latest", list); // store updated timestamp separately for scalar metrics cacheRepository.SetCache("rates:latest:updatedAt", DateTimeOffset.UtcNow); diff --git a/jobs/Backend/Task/ExchangeRateUpdater.Worker/Program.cs b/jobs/Backend/Task/ExchangeRateUpdater.Worker/Program.cs index c0e5bba440..50399f962b 100644 --- a/jobs/Backend/Task/ExchangeRateUpdater.Worker/Program.cs +++ b/jobs/Backend/Task/ExchangeRateUpdater.Worker/Program.cs @@ -1,8 +1,6 @@ using ExchangeRateUpdater.Application; using ExchangeRateUpdater.Infrastructure; using ExchangeRateUpdater.Worker.Jobs; -using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.Hosting; using Quartz; // Ensure REDIS_CONNECTION env var is visible in IConfiguration @@ -19,8 +17,6 @@ services.AddQuartz(q => { - q.UseMicrosoftDependencyInjectionScopedJobFactory(); - var jobKey = new JobKey("RefreshRatesJob"); q.AddJob(opts => opts.WithIdentity(jobKey)); q.AddTrigger(opts => opts @@ -28,7 +24,7 @@ .WithIdentity("RefreshRatesTrigger") .WithCronSchedule("0 0/1 * * * ?")); }); - + services.AddQuartzHostedService(q => q.WaitForJobsToComplete = true); }) .Build(); diff --git a/jobs/Backend/Task/ExchangeRateUpdater.sln b/jobs/Backend/Task/ExchangeRateUpdater.sln index 22e731c073..292dfa18f6 100644 --- a/jobs/Backend/Task/ExchangeRateUpdater.sln +++ b/jobs/Backend/Task/ExchangeRateUpdater.sln @@ -13,6 +13,8 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ExchangeRateUpdater", "Exha EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "EchangeRateUpdater.Api", "EchangeRateUpdater.Api\EchangeRateUpdater.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 Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -43,6 +45,10 @@ Global {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 EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE diff --git a/jobs/Backend/Task/ExhangeRateUpdater/Program.cs b/jobs/Backend/Task/ExhangeRateUpdater/Program.cs index c293e0b352..62db6903fd 100644 --- a/jobs/Backend/Task/ExhangeRateUpdater/Program.cs +++ b/jobs/Backend/Task/ExhangeRateUpdater/Program.cs @@ -1,10 +1,7 @@ -using System; -using System.Collections.Generic; -using System.IO; -using ExchangeRateUpdater.Application; +using ExchangeRateUpdater.Application; using ExchangeRateUpdater.Application.ExchangeRates.Query.GetExchangeRatesDaily; using ExchangeRateUpdater.Infrastructure; -using MediatR; +using Mediator; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Hosting; @@ -35,7 +32,6 @@ void ConfigureConfigurationBuilder(IConfigurationBuilder config, string[] args, try { - var mediator = host.Services.GetRequiredService(); var response = await mediator.Send(new GetExchangesRatesByDateQuery { @@ -48,13 +44,11 @@ void ConfigureConfigurationBuilder(IConfigurationBuilder config, string[] args, Language = null }); - if (response.Succeeded) + + Console.WriteLine($"Successfully retrieved {response.Count} exchange rates:"); + foreach (var rate in response) { - Console.WriteLine($"Successfully retrieved {response.Value.Count} exchange rates:"); - foreach (var rate in response.Value) - { - Console.WriteLine(rate.ToString()); - } + Console.WriteLine(rate.ToString()); } } catch (Exception e) From d1dfc3066b55970df68dae7a234a59461b67a97d Mon Sep 17 00:00:00 2001 From: Amin Chouaibi Date: Tue, 25 Nov 2025 19:12:27 +0100 Subject: [PATCH 4/9] WIP --- .../Mappings/ExchangeRateMappingExtensions.cs | 29 +++++++++++++++++++ .../Profiles/ExchangeRateApiDtoProfile.cs | 16 ---------- .../Profiles/ExchangeRateDtoProfile.cs | 19 ------------ .../ExchangeRateUpdater.Application.csproj | 1 - .../GetExchangesRatesByDateQuery.cs | 3 +- .../GetExchangesRatesByDateQueryHandler.cs | 12 +++----- .../ServiceCollectionExtensions.cs | 18 +++++------- .../Entities/ExchangeRate.cs | 8 ----- .../ServiceCollectionExtensions.cs | 3 +- 9 files changed, 44 insertions(+), 65 deletions(-) create mode 100644 jobs/Backend/Task/ExchangeRateUpdater.Application/Common/Mappings/ExchangeRateMappingExtensions.cs delete mode 100644 jobs/Backend/Task/ExchangeRateUpdater.Application/Common/Mappings/Profiles/ExchangeRateApiDtoProfile.cs delete mode 100644 jobs/Backend/Task/ExchangeRateUpdater.Application/Common/Mappings/Profiles/ExchangeRateDtoProfile.cs 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..5406653cc7 --- /dev/null +++ b/jobs/Backend/Task/ExchangeRateUpdater.Application/Common/Mappings/ExchangeRateMappingExtensions.cs @@ -0,0 +1,29 @@ +namespace ExchangeRateUpdater.Application.Common.Mappings; + +using ExchangeRates.Dtos; +using Domain.Entities; +using Domain.ValueObjects; + +public static class ExchangeRateMappingExtensions +{ + // Maps domain ExchangeRate -> DTO used by your application/API + public static ExchangeRateDto ToDto(this ExchangeRate source) + { + return new ExchangeRateDto + { + SourceCurrencyCode = source.SourceCurrency.Code, + TargetCurrencyCode = "CZK", + Value = source.Value + }; + } + + // Maps incoming API DTO -> domain ExchangeRate (mirrors ConstructUsing in your AutoMapper profile) + public static ExchangeRate ToExchangeRateEntity(this ExchangeRateApiDto apiDto) + { + var sourceCurrency = new Currency(apiDto.CurrencyCode); + var targetCurrency = new Currency("CZK"); + var value = apiDto.Amount == 0 ? 0m : decimal.Divide(apiDto.Rate, apiDto.Amount); + + return new ExchangeRate(sourceCurrency, targetCurrency, value); + } +} \ No newline at end of file diff --git a/jobs/Backend/Task/ExchangeRateUpdater.Application/Common/Mappings/Profiles/ExchangeRateApiDtoProfile.cs b/jobs/Backend/Task/ExchangeRateUpdater.Application/Common/Mappings/Profiles/ExchangeRateApiDtoProfile.cs deleted file mode 100644 index 9419db4dc6..0000000000 --- a/jobs/Backend/Task/ExchangeRateUpdater.Application/Common/Mappings/Profiles/ExchangeRateApiDtoProfile.cs +++ /dev/null @@ -1,16 +0,0 @@ -namespace ExchangeRateUpdater.Application.Common.Mappings.Profiles; - -using AutoMapper; -using Domain.ValueObjects; -using ExchangeRateUpdater.Application.ExchangeRates.Dtos; -using ExchangeRateUpdater.Domain.Entities; - -public class ExchangeRateApiDtoProfile : Profile -{ - public ExchangeRateApiDtoProfile() - { - CreateMap().ConstructUsing(x => - new ExchangeRate(new Currency(x.CurrencyCode), new Currency("CZK"), decimal.Divide(x.Rate, x.Amount))); - - } -} \ No newline at end of file diff --git a/jobs/Backend/Task/ExchangeRateUpdater.Application/Common/Mappings/Profiles/ExchangeRateDtoProfile.cs b/jobs/Backend/Task/ExchangeRateUpdater.Application/Common/Mappings/Profiles/ExchangeRateDtoProfile.cs deleted file mode 100644 index 4c82903579..0000000000 --- a/jobs/Backend/Task/ExchangeRateUpdater.Application/Common/Mappings/Profiles/ExchangeRateDtoProfile.cs +++ /dev/null @@ -1,19 +0,0 @@ -namespace ExchangeRateUpdater.Application.Common.Mappings.Profiles; - -using AutoMapper; -using Domain.ValueObjects; -using ExchangeRateUpdater.Application.ExchangeRates.Dtos; -using ExchangeRateUpdater.Domain.Entities; - -public class ExchangeRateDtoProfile : Profile -{ - public ExchangeRateDtoProfile() - { - CreateMap() - .ConvertUsing(s => new Currency(s)); - CreateMap() - .ForMember(dest => dest.SourceCurrencyCode, opt => opt.MapFrom(src => src.SourceCurrency.Code)) - .ForMember(dest => dest.Value, opt => opt.MapFrom(src => src.Value)) - .ForMember(dest => dest.TargetCurrencyCode, opt => opt.MapFrom(src => "CZK")); - } -} \ 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 index cb19e7b36e..66d4d89d50 100644 --- a/jobs/Backend/Task/ExchangeRateUpdater.Application/ExchangeRateUpdater.Application.csproj +++ b/jobs/Backend/Task/ExchangeRateUpdater.Application/ExchangeRateUpdater.Application.csproj @@ -7,7 +7,6 @@ - diff --git a/jobs/Backend/Task/ExchangeRateUpdater.Application/ExchangeRates/Query/GetExchangeRatesDaily/GetExchangesRatesByDateQuery.cs b/jobs/Backend/Task/ExchangeRateUpdater.Application/ExchangeRates/Query/GetExchangeRatesDaily/GetExchangesRatesByDateQuery.cs index c7ebaab609..f4ff6ed5dc 100644 --- a/jobs/Backend/Task/ExchangeRateUpdater.Application/ExchangeRates/Query/GetExchangeRatesDaily/GetExchangesRatesByDateQuery.cs +++ b/jobs/Backend/Task/ExchangeRateUpdater.Application/ExchangeRates/Query/GetExchangeRatesDaily/GetExchangesRatesByDateQuery.cs @@ -8,9 +8,8 @@ public class GetExchangesRatesByDateQuery : IQuery> { /// /// List of three-letter ISO 4217 currency codes for which exchange rates are requested. - /// If empty, all available rates are fetched. /// - public List? CurrencyCodes { get; set; } + public required List CurrencyCodes { get; set; } /// /// Date for which exchange rates are requested. diff --git a/jobs/Backend/Task/ExchangeRateUpdater.Application/ExchangeRates/Query/GetExchangeRatesDaily/GetExchangesRatesByDateQueryHandler.cs b/jobs/Backend/Task/ExchangeRateUpdater.Application/ExchangeRates/Query/GetExchangeRatesDaily/GetExchangesRatesByDateQueryHandler.cs index d3954e20e6..a590bc48bc 100644 --- a/jobs/Backend/Task/ExchangeRateUpdater.Application/ExchangeRates/Query/GetExchangeRatesDaily/GetExchangesRatesByDateQueryHandler.cs +++ b/jobs/Backend/Task/ExchangeRateUpdater.Application/ExchangeRates/Query/GetExchangeRatesDaily/GetExchangesRatesByDateQueryHandler.cs @@ -1,8 +1,7 @@ namespace ExchangeRateUpdater.Application.ExchangeRates.Query.GetExchangeRatesDaily; -using AutoMapper; using Common.Interfaces; -using Common.Models; +using Common.Mappings; using Domain.Common; using Domain.Entities; using Domain.Repositories; @@ -20,18 +19,15 @@ public class GetExchangesRatesByDateQueryHandler : IQueryHandler> Handle(GetExchangesRatesByDateQuery request, @@ -44,13 +40,13 @@ public async ValueTask> Handle(GetExchangesRatesByDateQuer if (!exchangeRates.Any()) { var exchangeRateApiDtos = await _exchangeRateApiClient.GetExchangeRatesAsync(request.Date, request.Language); - exchangeRates = exchangeRateApiDtos.Select(x => _mapper.Map(x)).ToList(); + exchangeRates = exchangeRateApiDtos.Select(x => x.ToExchangeRateEntity()).ToList(); _cacheRepository.SetCache(CacheKey, exchangeRates); } var requestedExchangeRates = exchangeRates .Where(rates => requestedCurrencies.Any(currency => currency == rates.SourceCurrency)) - .Select(exchangeRate => _mapper.Map(exchangeRate)).ToList(); + .Select(exchangeRate => exchangeRate.ToDto()).ToList(); return requestedExchangeRates; } diff --git a/jobs/Backend/Task/ExchangeRateUpdater.Application/ServiceCollectionExtensions.cs b/jobs/Backend/Task/ExchangeRateUpdater.Application/ServiceCollectionExtensions.cs index b722773672..15a3bd4a72 100644 --- a/jobs/Backend/Task/ExchangeRateUpdater.Application/ServiceCollectionExtensions.cs +++ b/jobs/Backend/Task/ExchangeRateUpdater.Application/ServiceCollectionExtensions.cs @@ -1,6 +1,5 @@ namespace ExchangeRateUpdater.Application; -using System.Reflection; using Common.Behaviours; using FluentValidation; using Microsoft.Extensions.DependencyInjection; @@ -9,15 +8,14 @@ public static class ServiceCollectionExtensions { public static IServiceCollection AddApplicationServices(this IServiceCollection services) { - services.AddAutoMapper(Assembly.GetExecutingAssembly()) - .AddMediator(options => - { - options.ServiceLifetime = ServiceLifetime.Transient; - options.GenerateTypesAsInternal = true; - options.Assemblies = [typeof(ServiceCollectionExtensions).Assembly]; - options.PipelineBehaviors = [typeof(MessageValidatorBehaviour<,>)]; - }) - .AddValidatorsFromAssembly(typeof(ServiceCollectionExtensions).Assembly); + 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; } diff --git a/jobs/Backend/Task/ExchangeRateUpdater.Domain/Entities/ExchangeRate.cs b/jobs/Backend/Task/ExchangeRateUpdater.Domain/Entities/ExchangeRate.cs index e9610d8678..39707c6aff 100644 --- a/jobs/Backend/Task/ExchangeRateUpdater.Domain/Entities/ExchangeRate.cs +++ b/jobs/Backend/Task/ExchangeRateUpdater.Domain/Entities/ExchangeRate.cs @@ -11,14 +11,6 @@ public class ExchangeRate(Currency sourceCurrency, Currency targetCurrency, deci public decimal Value { get; } = value; - public static ExchangeRate New(Currency sourceCurrency, Currency targetCurrency, decimal value) - { - Ensure.Argument.NotNull(sourceCurrency, "Source currency should not be null"); - Ensure.Argument.NotNull(targetCurrency, "Target currency should not be null"); - Ensure.Argument.NotNull(value, "Exchange value should not be null"); - return new ExchangeRate(sourceCurrency, targetCurrency, value); - } - public override string ToString() { return $"{SourceCurrency}/{TargetCurrency}={Value}"; diff --git a/jobs/Backend/Task/ExchangeRateUpdater.Infrastructure/ServiceCollectionExtensions.cs b/jobs/Backend/Task/ExchangeRateUpdater.Infrastructure/ServiceCollectionExtensions.cs index 9802686fe4..f705b789a5 100644 --- a/jobs/Backend/Task/ExchangeRateUpdater.Infrastructure/ServiceCollectionExtensions.cs +++ b/jobs/Backend/Task/ExchangeRateUpdater.Infrastructure/ServiceCollectionExtensions.cs @@ -24,7 +24,8 @@ public static IServiceCollection AddInfrastructure(this IServiceCollection self, self.Configure(configuration.GetSection(nameof(ExchangeRateApiClientConfig))) .AddHttpClient((sp, httpClient) => { - var exchangeRateApiClientConfig = sp.GetService>(); + var exchangeRateApiClientConfig = sp.GetService>() ?? + throw new NullReferenceException($"{nameof(ExchangeRateApiClientConfig)} is not configured"); httpClient.BaseAddress = new Uri(exchangeRateApiClientConfig.Value.BaseUrl); }).AddPolicyHandler(GetRetryPolicy()); From c60fbc914314acb9afc8292229ebb13a3c01a301 Mon Sep 17 00:00:00 2001 From: Amin Chouaibi Date: Thu, 27 Nov 2025 16:49:45 +0100 Subject: [PATCH 5/9] Finished --- .../Task/EchangeRateUpdater.Api/Dockerfile | 13 -- .../ApplicationBuilderExtensions.cs | 0 .../Extensions/OpenApiConfiguration.cs | 0 .../Extensions/ServiceCollectionExtensions.cs | 0 .../Controllers/ExchangeRatesController.cs | 0 .../Task/ExchangeRateUpdater.Api/Dockerfile | 32 +++ .../ExchangeRateUpdater.Api.csproj} | 7 + .../GlobalExceptionHandler.cs | 0 .../Program.cs | 1 - .../Properties/launchSettings.json | 0 .../appsettings.Development.json | 3 + .../appsettings.json | 5 +- .../Interfaces/IExchangeRateApiClient.cs | 2 + .../Mappings/ExchangeRateMappingExtensions.cs | 13 +- .../Common/Utils/CacheKeyHelper.cs | 11 + .../ExchangeRateUpdater.Application.csproj | 1 + .../ExchangeRates/Dtos/ExchangeRateApiDto.cs | 5 + .../GetExchangesRatesByDateQueryHandler.cs | 37 ++-- .../ExchangeRateUpdater.Domain.csproj | 1 + .../Repositories/ICacheRepository.cs | 21 +- .../ValueObjects/Currency.cs | 2 +- .../ApiClients/ExchangeRateApiClient.cs | 20 +- .../Cache/RedisCacheRepository.cs | 139 ++++++++++--- .../Data/CacheRepository.cs | 38 +++- .../ExchangeRateUpdater.Infrastructure.csproj | 1 + .../ServiceCollectionExtensions.cs | 21 +- .../ExchangeRateUpdater.UnitTests.csproj | 37 ++++ .../Fakers/FakeCacheRepository.cs | 55 +++++ .../Fakers/FakeExchangeRateApiClient.cs | 40 ++++ ...etExchangesRatesByDateQueryHandlerTests.cs | 94 +++++++++ .../ExchangeRateMappingExtensionsTests.cs | 57 +++++ .../Services/CzYearProcessorTests.cs | 88 ++++++++ .../Services/PerDayProcessorTests.cs | 86 ++++++++ ...ExchangesRatesByDateQueryValidatorTests.cs | 37 ++++ .../ExchangeRateUpdater.Worker/Dockerfile | 25 ++- .../ExchangeRateUpdater.Worker.csproj | 7 + .../Jobs/DailyExchangeRatesRefreshJob.cs | 63 ++++++ .../Jobs/ExchangeRatesBackfillJob.cs | 195 ++++++++++++++++++ .../Jobs/RefreshRatesJob.cs | 43 ---- .../ExchangeRateUpdater.Worker/Program.cs | 32 +-- .../ServiceCollectionExtensions.cs | 16 ++ .../Services/CzYearProcessor.cs | 77 +++++++ .../Services/PerDayProcessor.cs | 62 ++++++ .../appsettings.Development.json | 6 + .../appsettings.json | 6 + jobs/Backend/Task/ExchangeRateUpdater.sln | 27 ++- jobs/Backend/Task/Readme.md | 182 ++++++++++++++-- jobs/Backend/Task/docker-compose.yml | 54 ++++- 48 files changed, 1478 insertions(+), 184 deletions(-) delete mode 100644 jobs/Backend/Task/EchangeRateUpdater.Api/Dockerfile rename jobs/Backend/Task/{EchangeRateUpdater.Api => ExchangeRateUpdater.Api}/Configurations/Extensions/ApplicationBuilderExtensions.cs (100%) rename jobs/Backend/Task/{EchangeRateUpdater.Api => ExchangeRateUpdater.Api}/Configurations/Extensions/OpenApiConfiguration.cs (100%) rename jobs/Backend/Task/{EchangeRateUpdater.Api => ExchangeRateUpdater.Api}/Configurations/Extensions/ServiceCollectionExtensions.cs (100%) rename jobs/Backend/Task/{EchangeRateUpdater.Api => ExchangeRateUpdater.Api}/Controllers/ExchangeRatesController.cs (100%) create mode 100644 jobs/Backend/Task/ExchangeRateUpdater.Api/Dockerfile rename jobs/Backend/Task/{EchangeRateUpdater.Api/EchangeRateUpdater.Api.csproj => ExchangeRateUpdater.Api/ExchangeRateUpdater.Api.csproj} (77%) rename jobs/Backend/Task/{EchangeRateUpdater.Api => ExchangeRateUpdater.Api}/GlobalExceptionHandler.cs (100%) rename jobs/Backend/Task/{EchangeRateUpdater.Api => ExchangeRateUpdater.Api}/Program.cs (99%) rename jobs/Backend/Task/{EchangeRateUpdater.Api => ExchangeRateUpdater.Api}/Properties/launchSettings.json (100%) rename jobs/Backend/Task/{EchangeRateUpdater.Api => ExchangeRateUpdater.Api}/appsettings.Development.json (83%) rename jobs/Backend/Task/{EchangeRateUpdater.Api => ExchangeRateUpdater.Api}/appsettings.json (78%) create mode 100644 jobs/Backend/Task/ExchangeRateUpdater.Application/Common/Utils/CacheKeyHelper.cs create mode 100644 jobs/Backend/Task/ExchangeRateUpdater.UnitTests/ExchangeRateUpdater.UnitTests.csproj create mode 100644 jobs/Backend/Task/ExchangeRateUpdater.UnitTests/Fakers/FakeCacheRepository.cs create mode 100644 jobs/Backend/Task/ExchangeRateUpdater.UnitTests/Fakers/FakeExchangeRateApiClient.cs create mode 100644 jobs/Backend/Task/ExchangeRateUpdater.UnitTests/Handlers/GetExchangesRatesByDateQueryHandlerTests.cs create mode 100644 jobs/Backend/Task/ExchangeRateUpdater.UnitTests/Mapping/ExchangeRateMappingExtensionsTests.cs create mode 100644 jobs/Backend/Task/ExchangeRateUpdater.UnitTests/Services/CzYearProcessorTests.cs create mode 100644 jobs/Backend/Task/ExchangeRateUpdater.UnitTests/Services/PerDayProcessorTests.cs create mode 100644 jobs/Backend/Task/ExchangeRateUpdater.UnitTests/Validation/GetExchangesRatesByDateQueryValidatorTests.cs create mode 100644 jobs/Backend/Task/ExchangeRateUpdater.Worker/Jobs/DailyExchangeRatesRefreshJob.cs create mode 100644 jobs/Backend/Task/ExchangeRateUpdater.Worker/Jobs/ExchangeRatesBackfillJob.cs delete mode 100644 jobs/Backend/Task/ExchangeRateUpdater.Worker/Jobs/RefreshRatesJob.cs create mode 100644 jobs/Backend/Task/ExchangeRateUpdater.Worker/ServiceCollectionExtensions.cs create mode 100644 jobs/Backend/Task/ExchangeRateUpdater.Worker/Services/CzYearProcessor.cs create mode 100644 jobs/Backend/Task/ExchangeRateUpdater.Worker/Services/PerDayProcessor.cs diff --git a/jobs/Backend/Task/EchangeRateUpdater.Api/Dockerfile b/jobs/Backend/Task/EchangeRateUpdater.Api/Dockerfile deleted file mode 100644 index 682e525167..0000000000 --- a/jobs/Backend/Task/EchangeRateUpdater.Api/Dockerfile +++ /dev/null @@ -1,13 +0,0 @@ -FROM mcr.microsoft.com/dotnet/aspnet:9.0 AS base -WORKDIR /app - -FROM mcr.microsoft.com/dotnet/sdk:9.0 AS build -WORKDIR /src -COPY . . -# Publish the API project by specifying the .csproj file present in the build context -RUN dotnet publish "EchangeRateUpdater.Api/EchangeRateUpdater.Api.csproj" -c Release -o /app/publish - -FROM base AS final -WORKDIR /app -COPY --from=build /app/publish ./ -ENTRYPOINT ["dotnet", "EchangeRateUpdater.Api.dll"] diff --git a/jobs/Backend/Task/EchangeRateUpdater.Api/Configurations/Extensions/ApplicationBuilderExtensions.cs b/jobs/Backend/Task/ExchangeRateUpdater.Api/Configurations/Extensions/ApplicationBuilderExtensions.cs similarity index 100% rename from jobs/Backend/Task/EchangeRateUpdater.Api/Configurations/Extensions/ApplicationBuilderExtensions.cs rename to jobs/Backend/Task/ExchangeRateUpdater.Api/Configurations/Extensions/ApplicationBuilderExtensions.cs diff --git a/jobs/Backend/Task/EchangeRateUpdater.Api/Configurations/Extensions/OpenApiConfiguration.cs b/jobs/Backend/Task/ExchangeRateUpdater.Api/Configurations/Extensions/OpenApiConfiguration.cs similarity index 100% rename from jobs/Backend/Task/EchangeRateUpdater.Api/Configurations/Extensions/OpenApiConfiguration.cs rename to jobs/Backend/Task/ExchangeRateUpdater.Api/Configurations/Extensions/OpenApiConfiguration.cs diff --git a/jobs/Backend/Task/EchangeRateUpdater.Api/Configurations/Extensions/ServiceCollectionExtensions.cs b/jobs/Backend/Task/ExchangeRateUpdater.Api/Configurations/Extensions/ServiceCollectionExtensions.cs similarity index 100% rename from jobs/Backend/Task/EchangeRateUpdater.Api/Configurations/Extensions/ServiceCollectionExtensions.cs rename to jobs/Backend/Task/ExchangeRateUpdater.Api/Configurations/Extensions/ServiceCollectionExtensions.cs diff --git a/jobs/Backend/Task/EchangeRateUpdater.Api/Controllers/ExchangeRatesController.cs b/jobs/Backend/Task/ExchangeRateUpdater.Api/Controllers/ExchangeRatesController.cs similarity index 100% rename from jobs/Backend/Task/EchangeRateUpdater.Api/Controllers/ExchangeRatesController.cs rename to jobs/Backend/Task/ExchangeRateUpdater.Api/Controllers/ExchangeRatesController.cs 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/EchangeRateUpdater.Api/EchangeRateUpdater.Api.csproj b/jobs/Backend/Task/ExchangeRateUpdater.Api/ExchangeRateUpdater.Api.csproj similarity index 77% rename from jobs/Backend/Task/EchangeRateUpdater.Api/EchangeRateUpdater.Api.csproj rename to jobs/Backend/Task/ExchangeRateUpdater.Api/ExchangeRateUpdater.Api.csproj index 8182ea87af..e6e3c4ccef 100644 --- a/jobs/Backend/Task/EchangeRateUpdater.Api/EchangeRateUpdater.Api.csproj +++ b/jobs/Backend/Task/ExchangeRateUpdater.Api/ExchangeRateUpdater.Api.csproj @@ -4,6 +4,7 @@ net9.0 enable enable + EchangeRateUpdater.Api @@ -17,4 +18,10 @@ + + + appsettings.json + + + diff --git a/jobs/Backend/Task/EchangeRateUpdater.Api/GlobalExceptionHandler.cs b/jobs/Backend/Task/ExchangeRateUpdater.Api/GlobalExceptionHandler.cs similarity index 100% rename from jobs/Backend/Task/EchangeRateUpdater.Api/GlobalExceptionHandler.cs rename to jobs/Backend/Task/ExchangeRateUpdater.Api/GlobalExceptionHandler.cs diff --git a/jobs/Backend/Task/EchangeRateUpdater.Api/Program.cs b/jobs/Backend/Task/ExchangeRateUpdater.Api/Program.cs similarity index 99% rename from jobs/Backend/Task/EchangeRateUpdater.Api/Program.cs rename to jobs/Backend/Task/ExchangeRateUpdater.Api/Program.cs index 4d0c0d8507..0878a3eac4 100644 --- a/jobs/Backend/Task/EchangeRateUpdater.Api/Program.cs +++ b/jobs/Backend/Task/ExchangeRateUpdater.Api/Program.cs @@ -21,7 +21,6 @@ app.UseExceptionHandler(); app.UseHealthChecks("/health"); - app.UseHttpsRedirection(); app.UseApiDocumentation(); app.MapControllers(); diff --git a/jobs/Backend/Task/EchangeRateUpdater.Api/Properties/launchSettings.json b/jobs/Backend/Task/ExchangeRateUpdater.Api/Properties/launchSettings.json similarity index 100% rename from jobs/Backend/Task/EchangeRateUpdater.Api/Properties/launchSettings.json rename to jobs/Backend/Task/ExchangeRateUpdater.Api/Properties/launchSettings.json diff --git a/jobs/Backend/Task/EchangeRateUpdater.Api/appsettings.Development.json b/jobs/Backend/Task/ExchangeRateUpdater.Api/appsettings.Development.json similarity index 83% rename from jobs/Backend/Task/EchangeRateUpdater.Api/appsettings.Development.json rename to jobs/Backend/Task/ExchangeRateUpdater.Api/appsettings.Development.json index 4e8476eef3..4725b8a7d2 100644 --- a/jobs/Backend/Task/EchangeRateUpdater.Api/appsettings.Development.json +++ b/jobs/Backend/Task/ExchangeRateUpdater.Api/appsettings.Development.json @@ -10,5 +10,8 @@ }, "ApiDocumentation": { "Enabled": true + }, + "Redis": { + "Connection": "localhost:6379" } } diff --git a/jobs/Backend/Task/EchangeRateUpdater.Api/appsettings.json b/jobs/Backend/Task/ExchangeRateUpdater.Api/appsettings.json similarity index 78% rename from jobs/Backend/Task/EchangeRateUpdater.Api/appsettings.json rename to jobs/Backend/Task/ExchangeRateUpdater.Api/appsettings.json index 3106d792d2..ca32d894f3 100644 --- a/jobs/Backend/Task/EchangeRateUpdater.Api/appsettings.json +++ b/jobs/Backend/Task/ExchangeRateUpdater.Api/appsettings.json @@ -10,6 +10,9 @@ "BaseUrl": "https://api.cnb.cz/cnbapi/exrates/" }, "ApiDocumentation": { - "Enabled": false + "Enabled": true + }, + "Redis": { + "Connection": "localhost:6379" } } diff --git a/jobs/Backend/Task/ExchangeRateUpdater.Application/Common/Interfaces/IExchangeRateApiClient.cs b/jobs/Backend/Task/ExchangeRateUpdater.Application/Common/Interfaces/IExchangeRateApiClient.cs index 243c7819d5..49a86e75f2 100644 --- a/jobs/Backend/Task/ExchangeRateUpdater.Application/Common/Interfaces/IExchangeRateApiClient.cs +++ b/jobs/Backend/Task/ExchangeRateUpdater.Application/Common/Interfaces/IExchangeRateApiClient.cs @@ -12,4 +12,6 @@ public interface IExchangeRateApiClient /// 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 index 5406653cc7..b96f5f646b 100644 --- a/jobs/Backend/Task/ExchangeRateUpdater.Application/Common/Mappings/ExchangeRateMappingExtensions.cs +++ b/jobs/Backend/Task/ExchangeRateUpdater.Application/Common/Mappings/ExchangeRateMappingExtensions.cs @@ -6,7 +6,8 @@ namespace ExchangeRateUpdater.Application.Common.Mappings; public static class ExchangeRateMappingExtensions { - // Maps domain ExchangeRate -> DTO used by your application/API + private const string DefaultTargetCurrencyCode = "CZK"; + public static ExchangeRateDto ToDto(this ExchangeRate source) { return new ExchangeRateDto @@ -17,13 +18,19 @@ public static ExchangeRateDto ToDto(this ExchangeRate source) }; } - // Maps incoming API DTO -> domain ExchangeRate (mirrors ConstructUsing in your AutoMapper profile) public static ExchangeRate ToExchangeRateEntity(this ExchangeRateApiDto apiDto) { var sourceCurrency = new Currency(apiDto.CurrencyCode); - var targetCurrency = new Currency("CZK"); + 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/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 index 66d4d89d50..1e8e4b5b3a 100644 --- a/jobs/Backend/Task/ExchangeRateUpdater.Application/ExchangeRateUpdater.Application.csproj +++ b/jobs/Backend/Task/ExchangeRateUpdater.Application/ExchangeRateUpdater.Application.csproj @@ -4,6 +4,7 @@ net9.0 enable enable + ExchangeRateUpdater.Application diff --git a/jobs/Backend/Task/ExchangeRateUpdater.Application/ExchangeRates/Dtos/ExchangeRateApiDto.cs b/jobs/Backend/Task/ExchangeRateUpdater.Application/ExchangeRates/Dtos/ExchangeRateApiDto.cs index 6b4bab77c1..6c9ea9cbe5 100644 --- a/jobs/Backend/Task/ExchangeRateUpdater.Application/ExchangeRates/Dtos/ExchangeRateApiDto.cs +++ b/jobs/Backend/Task/ExchangeRateUpdater.Application/ExchangeRates/Dtos/ExchangeRateApiDto.cs @@ -9,4 +9,9 @@ public class ExchangeRateApiDto 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/Query/GetExchangeRatesDaily/GetExchangesRatesByDateQueryHandler.cs b/jobs/Backend/Task/ExchangeRateUpdater.Application/ExchangeRates/Query/GetExchangeRatesDaily/GetExchangesRatesByDateQueryHandler.cs index a590bc48bc..7c2892c3a3 100644 --- a/jobs/Backend/Task/ExchangeRateUpdater.Application/ExchangeRates/Query/GetExchangeRatesDaily/GetExchangesRatesByDateQueryHandler.cs +++ b/jobs/Backend/Task/ExchangeRateUpdater.Application/ExchangeRates/Query/GetExchangeRatesDaily/GetExchangesRatesByDateQueryHandler.cs @@ -2,8 +2,10 @@ namespace ExchangeRateUpdater.Application.ExchangeRates.Query.GetExchangeRatesDa using Common.Interfaces; using Common.Mappings; +using Common.Utils; using Domain.Common; using Domain.Entities; +using Domain.Enums; using Domain.Repositories; using Domain.ValueObjects; using Dtos; @@ -17,37 +19,34 @@ namespace ExchangeRateUpdater.Application.ExchangeRates.Query.GetExchangeRatesDa /// public class GetExchangesRatesByDateQueryHandler : IQueryHandler> { - private readonly ICacheRepository _cacheRepository; - private readonly IExchangeRateApiClient _exchangeRateApiClient; - private const string CacheKey = "ExchangeRates"; + private readonly ICacheRepository _redisRepository; - public GetExchangesRatesByDateQueryHandler(ICacheRepository cacheRepository, - IExchangeRateApiClient exchangeRateApiClient) + public GetExchangesRatesByDateQueryHandler(ICacheRepository redisRepository) { - Ensure.Argument.NotNull(cacheRepository, nameof(cacheRepository)); - Ensure.Argument.NotNull(exchangeRateApiClient, nameof(exchangeRateApiClient)); - _cacheRepository = cacheRepository; - _exchangeRateApiClient = exchangeRateApiClient; + 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 = _cacheRepository - .GetFromCache>(CacheKey); + var exchangeRates = await _redisRepository + .GetRatesListAsync(cacheKey); - if (!exchangeRates.Any()) - { - var exchangeRateApiDtos = await _exchangeRateApiClient.GetExchangeRatesAsync(request.Date, request.Language); - exchangeRates = exchangeRateApiDtos.Select(x => x.ToExchangeRateEntity()).ToList(); - _cacheRepository.SetCache(CacheKey, exchangeRates); - } - - var requestedExchangeRates = exchangeRates + 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.Domain/ExchangeRateUpdater.Domain.csproj b/jobs/Backend/Task/ExchangeRateUpdater.Domain/ExchangeRateUpdater.Domain.csproj index b903a56cce..7a57e2526b 100644 --- a/jobs/Backend/Task/ExchangeRateUpdater.Domain/ExchangeRateUpdater.Domain.csproj +++ b/jobs/Backend/Task/ExchangeRateUpdater.Domain/ExchangeRateUpdater.Domain.csproj @@ -4,6 +4,7 @@ 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 index d3a029a44b..854c3a4bf7 100644 --- a/jobs/Backend/Task/ExchangeRateUpdater.Domain/Repositories/ICacheRepository.cs +++ b/jobs/Backend/Task/ExchangeRateUpdater.Domain/Repositories/ICacheRepository.cs @@ -1,12 +1,21 @@ namespace ExchangeRateUpdater.Domain.Repositories; +using Domain.Entities; +using System.Collections.Generic; + public interface ICacheRepository { - T GetFromCache(string key); + 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); - void SetCache(string key, T value, TimeSpan expirationDate); - - void SetCache(string key, T value); - - void ClearCache(string key); + 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 index 856e5c2bf5..62b9fb4fcc 100644 --- a/jobs/Backend/Task/ExchangeRateUpdater.Domain/ValueObjects/Currency.cs +++ b/jobs/Backend/Task/ExchangeRateUpdater.Domain/ValueObjects/Currency.cs @@ -4,7 +4,7 @@ public record Currency { public Currency(string code) { - Code = code; + Code = code.ToUpperInvariant(); } /// /// Three-letter ISO 4217 code of the currency. diff --git a/jobs/Backend/Task/ExchangeRateUpdater.Infrastructure/ApiClients/ExchangeRateApiClient.cs b/jobs/Backend/Task/ExchangeRateUpdater.Infrastructure/ApiClients/ExchangeRateApiClient.cs index a5f1ef8ad0..8ff6f98c57 100644 --- a/jobs/Backend/Task/ExchangeRateUpdater.Infrastructure/ApiClients/ExchangeRateApiClient.cs +++ b/jobs/Backend/Task/ExchangeRateUpdater.Infrastructure/ApiClients/ExchangeRateApiClient.cs @@ -28,9 +28,25 @@ public async Task> GetExchangeRatesAsync(DateTim { PropertyNamingPolicy = JsonNamingPolicy.CamelCase }); - return exchangeRates.Rates; + 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); @@ -39,7 +55,7 @@ private string BuildExchangeRateDailyEndpointPath(DateTime? date, Language? lang if (date is not null) { - stringBuilder.Append($"?date={date}"); + stringBuilder.Append($"?date={date:yyyy-MM-dd}"); } if (language is not null) diff --git a/jobs/Backend/Task/ExchangeRateUpdater.Infrastructure/Cache/RedisCacheRepository.cs b/jobs/Backend/Task/ExchangeRateUpdater.Infrastructure/Cache/RedisCacheRepository.cs index cd701af8fa..ddf3482800 100644 --- a/jobs/Backend/Task/ExchangeRateUpdater.Infrastructure/Cache/RedisCacheRepository.cs +++ b/jobs/Backend/Task/ExchangeRateUpdater.Infrastructure/Cache/RedisCacheRepository.cs @@ -2,61 +2,146 @@ 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.Entities; public class RedisCacheRepository : ICacheRepository { - private readonly IConnectionMultiplexer connection; - private readonly IDatabase database; - private readonly ILogger logger; + private readonly IDatabase _database; + private readonly ILogger _logger; public RedisCacheRepository(IConnectionMultiplexer connection, ILogger logger) { - this.connection = connection ?? throw new ArgumentNullException(nameof(connection)); - this.logger = logger ?? throw new ArgumentNullException(nameof(logger)); - this.database = this.connection.GetDatabase(); + _database = connection?.GetDatabase() ?? throw new ArgumentNullException(nameof(connection)); + _logger = logger ?? throw new ArgumentNullException(nameof(logger)); } - public T GetFromCache(string key) + public async Task?> GetRatesDictionaryAsync(string key) { - var value = database.StringGet(key); - if (!value.HasValue) + 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.LogInformation("{Key} not found in redis cache", key); - return default!; + _logger.LogError(e, "Failed to get or deserialize cached value for {Key}", key); + return default; } + } + public async Task?> GetRatesListAsync(string key) + { try { - var result = JsonSerializer.Deserialize(value!); - logger.LogInformation("{Key} found in redis cache", key); - return result!; + var value = await _database.StringGetAsync(key).ConfigureAwait(false); + if (!value.HasValue) + { + _logger.LogInformation("{Key} not found in redis cache", key); + return default; + } + + var dtos = JsonSerializer.Deserialize>(value!); + if (dtos == null) return default; + var list = dtos.Select(d => d.ToEntity()).ToList(); + _logger.LogInformation("{Key} found in redis cache (string)", key); + return list; } catch (Exception e) { - logger.LogError(e, "Failed to deserialize cached value for {Key}", key); - return default!; + _logger.LogError(e, "Failed to get or deserialize cached value for {Key}", key); + return default; } } - public void SetCache(string key, T value, TimeSpan expirationDate) + public async Task SetRatesAsync(string key, Dictionary value) { - var json = JsonSerializer.Serialize(value); - database.StringSet(key, json, expirationDate); - logger.LogInformation("{Key} added to redis cache", key); + 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 void SetCache(string key, T value) + public async Task SetRatesAsync(string key, Dictionary value, TimeSpan expirationDate) { - // default to 1 hour - SetCache(key, value, TimeSpan.FromHours(1)); + 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); + } + } + + 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); + } } - public void ClearCache(string key) + public async Task SetRatesListAsync(string key, List value) { - database.KeyDelete(key); - logger.LogInformation("{Key} removed from redis cache", key); + 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/Data/CacheRepository.cs b/jobs/Backend/Task/ExchangeRateUpdater.Infrastructure/Data/CacheRepository.cs index 563fccc128..6649d61c88 100644 --- a/jobs/Backend/Task/ExchangeRateUpdater.Infrastructure/Data/CacheRepository.cs +++ b/jobs/Backend/Task/ExchangeRateUpdater.Infrastructure/Data/CacheRepository.cs @@ -1,6 +1,7 @@ namespace ExchangeRateUpdater.Infrastructure.Data; using Domain.Common; +using Domain.Entities; using Domain.Repositories; using Microsoft.Extensions.Caching.Memory; using Microsoft.Extensions.Logging; @@ -20,29 +21,52 @@ public CacheRepository(IMemoryCache cache, ILogger logger) this.logger = logger; } - public T GetFromCache(string key) + public Task?> GetRatesDictionaryAsync(string key) { - cache.TryGetValue(key, out T cachedResponse); + cache.TryGetValue(key, out Dictionary? cachedResponse); logger.LogInformation(cachedResponse is null ? $"{key} not found in cache" : $"{key} found in cache"); - return cachedResponse; + return Task.FromResult(cachedResponse); } - public void SetCache(string key, T value, TimeSpan absoluteExpiration) + 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 void SetCache(string key, T value) + public Task SetRatesListAsync(string key, List value) { var defaultExpiration = TimeSpan.FromHours(DefaultExpirationHours); - SetCache(key, value, defaultExpiration); + return SetRatesListAsync(key, value, defaultExpiration); } - public void ClearCache(string key) + 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 index 56b109d075..54c5d5f49b 100644 --- a/jobs/Backend/Task/ExchangeRateUpdater.Infrastructure/ExchangeRateUpdater.Infrastructure.csproj +++ b/jobs/Backend/Task/ExchangeRateUpdater.Infrastructure/ExchangeRateUpdater.Infrastructure.csproj @@ -4,6 +4,7 @@ net9.0 enable enable + ExchangeRateUpdater.Infrastructure diff --git a/jobs/Backend/Task/ExchangeRateUpdater.Infrastructure/ServiceCollectionExtensions.cs b/jobs/Backend/Task/ExchangeRateUpdater.Infrastructure/ServiceCollectionExtensions.cs index f705b789a5..1d583b55b7 100644 --- a/jobs/Backend/Task/ExchangeRateUpdater.Infrastructure/ServiceCollectionExtensions.cs +++ b/jobs/Backend/Task/ExchangeRateUpdater.Infrastructure/ServiceCollectionExtensions.cs @@ -8,14 +8,10 @@ namespace ExchangeRateUpdater.Infrastructure; using Domain.Repositories; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Options; -using Data; using Polly; using Polly.Extensions.Http; using StackExchange.Redis; -using Microsoft.Extensions.Configuration; -using Microsoft.Extensions.Logging; public static class ServiceCollectionExtensions { @@ -28,21 +24,16 @@ public static IServiceCollection AddInfrastructure(this IServiceCollection self, throw new NullReferenceException($"{nameof(ExchangeRateApiClientConfig)} is not configured"); httpClient.BaseAddress = new Uri(exchangeRateApiClientConfig.Value.BaseUrl); }).AddPolicyHandler(GetRetryPolicy()); - - // Redis configuration + var redisConnection = configuration.GetValue("Redis:Connection"); - if (!string.IsNullOrWhiteSpace(redisConnection)) - { - var mux = ConnectionMultiplexer.Connect(redisConnection); - self.AddSingleton(mux); - self.AddSingleton(); - } - else + if (string.IsNullOrWhiteSpace(redisConnection)) { - self.AddSingleton(); - self.AddMemoryCache(); + throw new NullReferenceException("Redis:Connection setting is not configured"); } + + self.AddScoped(sp => ConnectionMultiplexer.Connect(redisConnection)); + self.AddScoped(); return self; } 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..3fc1fecc7a --- /dev/null +++ b/jobs/Backend/Task/ExchangeRateUpdater.UnitTests/Handlers/GetExchangesRatesByDateQueryHandlerTests.cs @@ -0,0 +1,94 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using ExchangeRateUpdater.Application.ExchangeRates.Query.GetExchangeRatesDaily; +using ExchangeRateUpdater.Domain.Entities; +using ExchangeRateUpdater.Domain.Repositories; +using ExchangeRateUpdater.Domain.ValueObjects; +using Shouldly; +using Xunit; + +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..523fbe15dc --- /dev/null +++ b/jobs/Backend/Task/ExchangeRateUpdater.UnitTests/Mapping/ExchangeRateMappingExtensionsTests.cs @@ -0,0 +1,57 @@ +using System.Collections.Generic; +using ExchangeRateUpdater.Application.Common.Mappings; +using ExchangeRateUpdater.Domain.Entities; +using ExchangeRateUpdater.Domain.ValueObjects; +using Shouldly; +using Xunit; + +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..ec6431f2b8 --- /dev/null +++ b/jobs/Backend/Task/ExchangeRateUpdater.UnitTests/Services/CzYearProcessorTests.cs @@ -0,0 +1,88 @@ +using ExchangeRateUpdater.Worker.Services; +using ExchangeRateUpdater.Application.ExchangeRates.Dtos; +using ExchangeRateUpdater.Domain.Entities; +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 index bd2704bc30..2b8599ac9c 100644 --- a/jobs/Backend/Task/ExchangeRateUpdater.Worker/Dockerfile +++ b/jobs/Backend/Task/ExchangeRateUpdater.Worker/Dockerfile @@ -2,12 +2,31 @@ 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 . . -# Publish the Worker project by specifying the .csproj file present in the build context -RUN dotnet publish "ExchangeRateUpdater.Worker/ExchangeRateUpdater.Worker.csproj" -c Release -o /app/publish + +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=build /app/publish ./ +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 index 56b67787c8..fb3f1c80ea 100644 --- a/jobs/Backend/Task/ExchangeRateUpdater.Worker/ExchangeRateUpdater.Worker.csproj +++ b/jobs/Backend/Task/ExchangeRateUpdater.Worker/ExchangeRateUpdater.Worker.csproj @@ -12,6 +12,7 @@ + @@ -28,4 +29,10 @@ + + + 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..a747a93e85 --- /dev/null +++ b/jobs/Backend/Task/ExchangeRateUpdater.Worker/Jobs/ExchangeRatesBackfillJob.cs @@ -0,0 +1,195 @@ +namespace ExchangeRateUpdater.Worker.Jobs; + +using System.Collections.Concurrent; +using Domain.Enums; +using System.Diagnostics; +using Application.Common.Utils; +using Domain.Common; +using Domain.Entities; +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/Jobs/RefreshRatesJob.cs b/jobs/Backend/Task/ExchangeRateUpdater.Worker/Jobs/RefreshRatesJob.cs deleted file mode 100644 index 5a39bef1a2..0000000000 --- a/jobs/Backend/Task/ExchangeRateUpdater.Worker/Jobs/RefreshRatesJob.cs +++ /dev/null @@ -1,43 +0,0 @@ -namespace ExchangeRateUpdater.Worker.Jobs; - -using System; -using System.Linq; -using System.Threading.Tasks; -using Application.Common.Interfaces; -using Domain.Repositories; -using Microsoft.Extensions.Logging; -using Quartz; - -[DisallowConcurrentExecution] -public class RefreshRatesJob : IJob -{ - private readonly IExchangeRateApiClient apiClient; - private readonly ICacheRepository cacheRepository; - private readonly ILogger logger; - - public RefreshRatesJob(IExchangeRateApiClient apiClient, ICacheRepository cacheRepository, ILogger logger) - { - this.apiClient = apiClient; - this.cacheRepository = cacheRepository; - this.logger = logger; - } - - public async Task Execute(IJobExecutionContext context) - { - try - { - logger.LogInformation("Starting RefreshRatesJob at {Time}", DateTimeOffset.UtcNow); - var rates = await apiClient.GetExchangeRatesAsync(null, null); - var list = rates?.ToList(); - cacheRepository.SetCache("rates:latest", list); - // store updated timestamp separately for scalar metrics - cacheRepository.SetCache("rates:latest:updatedAt", DateTimeOffset.UtcNow); - logger.LogInformation("RefreshRatesJob completed - {Count} rates cached", list.Count); - } - catch (Exception e) - { - logger.LogError(e, "RefreshRatesJob failed"); - throw; - } - } -} diff --git a/jobs/Backend/Task/ExchangeRateUpdater.Worker/Program.cs b/jobs/Backend/Task/ExchangeRateUpdater.Worker/Program.cs index 50399f962b..51abb01d11 100644 --- a/jobs/Backend/Task/ExchangeRateUpdater.Worker/Program.cs +++ b/jobs/Backend/Task/ExchangeRateUpdater.Worker/Program.cs @@ -1,32 +1,38 @@ using ExchangeRateUpdater.Application; using ExchangeRateUpdater.Infrastructure; +using ExchangeRateUpdater.Worker; using ExchangeRateUpdater.Worker.Jobs; using Quartz; -// Ensure REDIS_CONNECTION env var is visible in IConfiguration -var redisConnection = Environment.GetEnvironmentVariable("REDIS_CONNECTION"); var builder = Host.CreateDefaultBuilder(args) .ConfigureServices((hostContext, services) => { - if (!string.IsNullOrWhiteSpace(redisConnection)) - { - hostContext.Configuration["Redis:Connection"] = redisConnection; - } services.AddApplicationServices(); services.AddInfrastructure(hostContext.Configuration); + services.AddExchangeRateWorkerServices(); services.AddQuartz(q => { - var jobKey = new JobKey("RefreshRatesJob"); - q.AddJob(opts => opts.WithIdentity(jobKey)); + //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(jobKey) - .WithIdentity("RefreshRatesTrigger") - .WithCronSchedule("0 0/1 * * * ?")); + .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(); +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..6754c4a98d --- /dev/null +++ b/jobs/Backend/Task/ExchangeRateUpdater.Worker/ServiceCollectionExtensions.cs @@ -0,0 +1,16 @@ +namespace ExchangeRateUpdater.Worker; + +using Serilog; +using Services; + +public static class ServiceCollectionExtensions +{ + public static IServiceCollection AddExchangeRateWorkerServices(this IServiceCollection services) + { + services.AddSerilog(); + 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..81956c82bb --- /dev/null +++ b/jobs/Backend/Task/ExchangeRateUpdater.Worker/Services/CzYearProcessor.cs @@ -0,0 +1,77 @@ +namespace ExchangeRateUpdater.Worker.Services; + +using System.Globalization; +using Application.Common.Interfaces; +using Application.Common.Mappings; +using Application.Common.Utils; +using Domain.Common; +using Domain.Entities; +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..8063043b99 --- /dev/null +++ b/jobs/Backend/Task/ExchangeRateUpdater.Worker/Services/PerDayProcessor.cs @@ -0,0 +1,62 @@ +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.Entities; +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 index 4f30a00f83..cb2f1037e9 100644 --- a/jobs/Backend/Task/ExchangeRateUpdater.Worker/appsettings.Development.json +++ b/jobs/Backend/Task/ExchangeRateUpdater.Worker/appsettings.Development.json @@ -5,5 +5,11 @@ "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 index 8983e0fc1c..314dfd1322 100644 --- a/jobs/Backend/Task/ExchangeRateUpdater.Worker/appsettings.json +++ b/jobs/Backend/Task/ExchangeRateUpdater.Worker/appsettings.json @@ -5,5 +5,11 @@ "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.sln b/jobs/Backend/Task/ExchangeRateUpdater.sln index 292dfa18f6..5e02b3ce21 100644 --- a/jobs/Backend/Task/ExchangeRateUpdater.sln +++ b/jobs/Backend/Task/ExchangeRateUpdater.sln @@ -11,10 +11,21 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ExchangeRateUpdater.Infrast EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ExchangeRateUpdater", "ExhangeRateUpdater\ExchangeRateUpdater.csproj", "{F0F3840E-08DE-4324-BD93-9B270915294E}" EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "EchangeRateUpdater.Api", "EchangeRateUpdater.Api\EchangeRateUpdater.Api.csproj", "{41423C35-E755-4C50-A946-17DD032DEFD1}" +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 Debug|Any CPU = Debug|Any CPU @@ -49,8 +60,22 @@ Global {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/Readme.md b/jobs/Backend/Task/Readme.md index 9e62830994..32d2dccce6 100644 --- a/jobs/Backend/Task/Readme.md +++ b/jobs/Backend/Task/Readme.md @@ -1,19 +1,163 @@ -# Backend Task -Inside these changes you'll be able to find 2 csprojs where we can run all our ExchangeRateUpdater application, one of them is a single Console app, the other one is an REST Api. - -What I've applied: -- Clean Architecture -- Mediator pattern. -- CQS pattern. -- Options pattern in order to access the configuration data. -- Retry policy using Polly for ExchangeRateApiClient. - -Missing code: -- Unit tests and integration tests (due to personal matters I could not add all the required test coverage) -- Missing Unit tests: GetExchangesRatesByDateQueryValidatorTest, GetExchangesRatesByDateQueryHandlerTest. -- Missing integration tests: GetExchangeRatesByDate endpoint test. - -Improvements: -- Add Background service or CronJob that will update memory cache every X time, with this we'll apply 100% the CQS pattern because right now GetExchangesRatesByDateQueryHandler is muttating the system adding the data on memory cache in case doens't exist. -- In case when every time the exchangerate changes and other systems requires to know how it changed I'd apply an event driven system in order to send events to some RabbitMQ queue in order to notify an internal application that will send events to external systems per example using Kafka or Azure eventhub. -- Instead of using memory cache use a distributed cache like Redis. +# 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 + +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. + +## 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+. + +## Best practices for 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 index 7951a7af91..b6e9ba9bc8 100644 --- a/jobs/Backend/Task/docker-compose.yml +++ b/jobs/Backend/Task/docker-compose.yml @@ -1,29 +1,63 @@ -version: '3.8' 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 - api: + worker: build: context: . - dockerfile: EchangeRateUpdater.Api/Dockerfile + dockerfile: ExchangeRateUpdater.Worker/Dockerfile + image: exchange-rate-updater-worker:latest + container_name: exchange-rate-updater-worker environment: - - REDIS_CONNECTION=redis:6379 + - Redis__Connection=redis:6379,abortConnect=false - ExchangeRateApiClientConfig__BaseUrl=https://api.cnb.cz/cnbapi/exrates/ depends_on: - redis - ports: - - "5000:80" + volumes: + - shared-data:/shared + healthcheck: + test: [ "CMD", "sh", "-c", "test -f /shared/worker_ready"] + interval: 5s + timeout: 5s + retries: 20 - worker: + api: build: context: . - dockerfile: ExchangeRateUpdater.Worker/Dockerfile + dockerfile: ExchangeRateUpdater.Api/Dockerfile + image: exchange-rate-updater-api:latest + container_name: exchange-rate-updater-api environment: - - REDIS_CONNECTION=redis:6379 + - 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 + 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 From 5a5cca7a636f2da3af5a6bf061f8e66bdd35f369 Mon Sep 17 00:00:00 2001 From: Amin Chouaibi Date: Thu, 27 Nov 2025 17:44:06 +0100 Subject: [PATCH 6/9] Development finished --- .../ApplicationBuilderExtensions.cs | 31 +++ .../Extensions/OpenApiConfiguration.cs | 26 +++ .../Extensions/ServiceCollectionExtensions.cs | 19 ++ .../Controllers/ExchangeRatesController.cs | 21 ++ Task/ExchangeRateUpdater.Api/Dockerfile | 32 +++ .../ExchangeRateUpdater.Api.csproj | 27 +++ .../GlobalExceptionHandler.cs | 62 ++++++ Task/ExchangeRateUpdater.Api/Program.cs | 47 ++++ .../Properties/launchSettings.json | 25 +++ .../appsettings.Development.json | 17 ++ Task/ExchangeRateUpdater.Api/appsettings.json | 18 ++ .../Behaviours/MessageValidatorBehaviour.cs | 16 ++ .../Common/Exceptions/NotFoundException.cs | 24 ++ .../Interfaces/IExchangeRateApiClient.cs | 17 ++ .../Mappings/ExchangeRateMappingExtensions.cs | 36 +++ .../Common/Models/Result.cs | 43 ++++ .../Common/Utils/CacheKeyHelper.cs | 11 + .../ExchangeRateUpdater.Application.csproj | 24 ++ .../ExchangeRates/Dtos/ExchangeRateApiDto.cs | 17 ++ .../Dtos/ExchangeRateApiResponse.cs | 6 + .../ExchangeRates/Dtos/ExchangeRateDto.cs | 21 ++ .../GetExchangesRatesByDateQuery.cs | 24 ++ .../GetExchangesRatesByDateQueryHandler.cs | 51 +++++ .../GetExchangesRatesByDateQueryValidator.cs | 17 ++ .../ServiceCollectionExtensions.cs | 22 ++ .../Common/Ensure.cs | 208 ++++++++++++++++++ .../Entities/ExchangeRate.cs | 17 ++ .../Enums/Language.cs | 7 + .../ExchangeRateUpdater.Domain.csproj | 10 + .../Repositories/ICacheRepository.cs | 21 ++ .../ValueObjects/Currency.cs | 18 ++ .../ApiClients/ExchangeRateApiClient.cs | 68 ++++++ .../Cache/RedisCacheRepository.cs | 142 ++++++++++++ .../ExchangeRateApiClientConfig.cs | 6 + .../Data/CacheRepository.cs | 72 ++++++ .../ExchangeRateUpdater.Infrastructure.csproj | 24 ++ .../ServiceCollectionExtensions.cs | 45 ++++ .../ExchangeRateUpdater.UnitTests.csproj | 37 ++++ .../Fakers/FakeCacheRepository.cs | 55 +++++ .../Fakers/FakeExchangeRateApiClient.cs | 40 ++++ ...etExchangesRatesByDateQueryHandlerTests.cs | 88 ++++++++ .../ExchangeRateMappingExtensionsTests.cs | 54 +++++ .../Services/CzYearProcessorTests.cs | 87 ++++++++ .../Services/PerDayProcessorTests.cs | 86 ++++++++ ...ExchangesRatesByDateQueryValidatorTests.cs | 37 ++++ Task/ExchangeRateUpdater.Worker/Dockerfile | 32 +++ .../ExchangeRateUpdater.Worker.csproj | 37 ++++ .../Jobs/DailyExchangeRatesRefreshJob.cs | 63 ++++++ .../Jobs/ExchangeRatesBackfillJob.cs | 194 ++++++++++++++++ Task/ExchangeRateUpdater.Worker/Program.cs | 38 ++++ .../ServiceCollectionExtensions.cs | 14 ++ .../Services/CzYearProcessor.cs | 76 +++++++ .../Services/PerDayProcessor.cs | 61 +++++ .../appsettings.Development.json | 15 ++ .../appsettings.json | 15 ++ Task/ExchangeRateUpdater.sln | 81 +++++++ .../ExchangeRateUpdater.csproj | 30 +++ Task/ExhangeRateUpdater/Program.cs | 59 +++++ Task/ExhangeRateUpdater/appsettings.json | 12 + Task/Readme.md | 176 +++++++++++++++ Task/docker-compose.yml | 63 ++++++ 61 files changed, 2742 insertions(+) create mode 100644 Task/ExchangeRateUpdater.Api/Configurations/Extensions/ApplicationBuilderExtensions.cs create mode 100644 Task/ExchangeRateUpdater.Api/Configurations/Extensions/OpenApiConfiguration.cs create mode 100644 Task/ExchangeRateUpdater.Api/Configurations/Extensions/ServiceCollectionExtensions.cs create mode 100644 Task/ExchangeRateUpdater.Api/Controllers/ExchangeRatesController.cs create mode 100644 Task/ExchangeRateUpdater.Api/Dockerfile create mode 100644 Task/ExchangeRateUpdater.Api/ExchangeRateUpdater.Api.csproj create mode 100644 Task/ExchangeRateUpdater.Api/GlobalExceptionHandler.cs create mode 100644 Task/ExchangeRateUpdater.Api/Program.cs create mode 100644 Task/ExchangeRateUpdater.Api/Properties/launchSettings.json create mode 100644 Task/ExchangeRateUpdater.Api/appsettings.Development.json create mode 100644 Task/ExchangeRateUpdater.Api/appsettings.json create mode 100644 Task/ExchangeRateUpdater.Application/Common/Behaviours/MessageValidatorBehaviour.cs create mode 100644 Task/ExchangeRateUpdater.Application/Common/Exceptions/NotFoundException.cs create mode 100644 Task/ExchangeRateUpdater.Application/Common/Interfaces/IExchangeRateApiClient.cs create mode 100644 Task/ExchangeRateUpdater.Application/Common/Mappings/ExchangeRateMappingExtensions.cs create mode 100644 Task/ExchangeRateUpdater.Application/Common/Models/Result.cs create mode 100644 Task/ExchangeRateUpdater.Application/Common/Utils/CacheKeyHelper.cs create mode 100644 Task/ExchangeRateUpdater.Application/ExchangeRateUpdater.Application.csproj create mode 100644 Task/ExchangeRateUpdater.Application/ExchangeRates/Dtos/ExchangeRateApiDto.cs create mode 100644 Task/ExchangeRateUpdater.Application/ExchangeRates/Dtos/ExchangeRateApiResponse.cs create mode 100644 Task/ExchangeRateUpdater.Application/ExchangeRates/Dtos/ExchangeRateDto.cs create mode 100644 Task/ExchangeRateUpdater.Application/ExchangeRates/Query/GetExchangeRatesDaily/GetExchangesRatesByDateQuery.cs create mode 100644 Task/ExchangeRateUpdater.Application/ExchangeRates/Query/GetExchangeRatesDaily/GetExchangesRatesByDateQueryHandler.cs create mode 100644 Task/ExchangeRateUpdater.Application/ExchangeRates/Query/GetExchangeRatesDaily/GetExchangesRatesByDateQueryValidator.cs create mode 100644 Task/ExchangeRateUpdater.Application/ServiceCollectionExtensions.cs create mode 100644 Task/ExchangeRateUpdater.Domain/Common/Ensure.cs create mode 100644 Task/ExchangeRateUpdater.Domain/Entities/ExchangeRate.cs create mode 100644 Task/ExchangeRateUpdater.Domain/Enums/Language.cs create mode 100644 Task/ExchangeRateUpdater.Domain/ExchangeRateUpdater.Domain.csproj create mode 100644 Task/ExchangeRateUpdater.Domain/Repositories/ICacheRepository.cs create mode 100644 Task/ExchangeRateUpdater.Domain/ValueObjects/Currency.cs create mode 100644 Task/ExchangeRateUpdater.Infrastructure/ApiClients/ExchangeRateApiClient.cs create mode 100644 Task/ExchangeRateUpdater.Infrastructure/Cache/RedisCacheRepository.cs create mode 100644 Task/ExchangeRateUpdater.Infrastructure/Configurations/ExchangeRateApiClientConfig.cs create mode 100644 Task/ExchangeRateUpdater.Infrastructure/Data/CacheRepository.cs create mode 100644 Task/ExchangeRateUpdater.Infrastructure/ExchangeRateUpdater.Infrastructure.csproj create mode 100644 Task/ExchangeRateUpdater.Infrastructure/ServiceCollectionExtensions.cs create mode 100644 Task/ExchangeRateUpdater.UnitTests/ExchangeRateUpdater.UnitTests.csproj create mode 100644 Task/ExchangeRateUpdater.UnitTests/Fakers/FakeCacheRepository.cs create mode 100644 Task/ExchangeRateUpdater.UnitTests/Fakers/FakeExchangeRateApiClient.cs create mode 100644 Task/ExchangeRateUpdater.UnitTests/Handlers/GetExchangesRatesByDateQueryHandlerTests.cs create mode 100644 Task/ExchangeRateUpdater.UnitTests/Mapping/ExchangeRateMappingExtensionsTests.cs create mode 100644 Task/ExchangeRateUpdater.UnitTests/Services/CzYearProcessorTests.cs create mode 100644 Task/ExchangeRateUpdater.UnitTests/Services/PerDayProcessorTests.cs create mode 100644 Task/ExchangeRateUpdater.UnitTests/Validation/GetExchangesRatesByDateQueryValidatorTests.cs create mode 100644 Task/ExchangeRateUpdater.Worker/Dockerfile create mode 100644 Task/ExchangeRateUpdater.Worker/ExchangeRateUpdater.Worker.csproj create mode 100644 Task/ExchangeRateUpdater.Worker/Jobs/DailyExchangeRatesRefreshJob.cs create mode 100644 Task/ExchangeRateUpdater.Worker/Jobs/ExchangeRatesBackfillJob.cs create mode 100644 Task/ExchangeRateUpdater.Worker/Program.cs create mode 100644 Task/ExchangeRateUpdater.Worker/ServiceCollectionExtensions.cs create mode 100644 Task/ExchangeRateUpdater.Worker/Services/CzYearProcessor.cs create mode 100644 Task/ExchangeRateUpdater.Worker/Services/PerDayProcessor.cs create mode 100644 Task/ExchangeRateUpdater.Worker/appsettings.Development.json create mode 100644 Task/ExchangeRateUpdater.Worker/appsettings.json create mode 100644 Task/ExchangeRateUpdater.sln create mode 100644 Task/ExhangeRateUpdater/ExchangeRateUpdater.csproj create mode 100644 Task/ExhangeRateUpdater/Program.cs create mode 100644 Task/ExhangeRateUpdater/appsettings.json create mode 100644 Task/Readme.md create mode 100644 Task/docker-compose.yml diff --git a/Task/ExchangeRateUpdater.Api/Configurations/Extensions/ApplicationBuilderExtensions.cs b/Task/ExchangeRateUpdater.Api/Configurations/Extensions/ApplicationBuilderExtensions.cs new file mode 100644 index 0000000000..518e8897b5 --- /dev/null +++ b/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/Task/ExchangeRateUpdater.Api/Configurations/Extensions/OpenApiConfiguration.cs b/Task/ExchangeRateUpdater.Api/Configurations/Extensions/OpenApiConfiguration.cs new file mode 100644 index 0000000000..1c99b7dd07 --- /dev/null +++ b/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/Task/ExchangeRateUpdater.Api/Configurations/Extensions/ServiceCollectionExtensions.cs b/Task/ExchangeRateUpdater.Api/Configurations/Extensions/ServiceCollectionExtensions.cs new file mode 100644 index 0000000000..e6f48556ee --- /dev/null +++ b/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/Task/ExchangeRateUpdater.Api/Controllers/ExchangeRatesController.cs b/Task/ExchangeRateUpdater.Api/Controllers/ExchangeRatesController.cs new file mode 100644 index 0000000000..3dd812f7bc --- /dev/null +++ b/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/Task/ExchangeRateUpdater.Api/Dockerfile b/Task/ExchangeRateUpdater.Api/Dockerfile new file mode 100644 index 0000000000..9f6af80bf8 --- /dev/null +++ b/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/Task/ExchangeRateUpdater.Api/ExchangeRateUpdater.Api.csproj b/Task/ExchangeRateUpdater.Api/ExchangeRateUpdater.Api.csproj new file mode 100644 index 0000000000..e6e3c4ccef --- /dev/null +++ b/Task/ExchangeRateUpdater.Api/ExchangeRateUpdater.Api.csproj @@ -0,0 +1,27 @@ + + + + net9.0 + enable + enable + EchangeRateUpdater.Api + + + + + + + + + + + + + + + + appsettings.json + + + + diff --git a/Task/ExchangeRateUpdater.Api/GlobalExceptionHandler.cs b/Task/ExchangeRateUpdater.Api/GlobalExceptionHandler.cs new file mode 100644 index 0000000000..7cfcc4c680 --- /dev/null +++ b/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/Task/ExchangeRateUpdater.Api/Program.cs b/Task/ExchangeRateUpdater.Api/Program.cs new file mode 100644 index 0000000000..0878a3eac4 --- /dev/null +++ b/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/Task/ExchangeRateUpdater.Api/Properties/launchSettings.json b/Task/ExchangeRateUpdater.Api/Properties/launchSettings.json new file mode 100644 index 0000000000..1731fb8766 --- /dev/null +++ b/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/Task/ExchangeRateUpdater.Api/appsettings.Development.json b/Task/ExchangeRateUpdater.Api/appsettings.Development.json new file mode 100644 index 0000000000..4725b8a7d2 --- /dev/null +++ b/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/Task/ExchangeRateUpdater.Api/appsettings.json b/Task/ExchangeRateUpdater.Api/appsettings.json new file mode 100644 index 0000000000..ca32d894f3 --- /dev/null +++ b/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/Task/ExchangeRateUpdater.Application/Common/Behaviours/MessageValidatorBehaviour.cs b/Task/ExchangeRateUpdater.Application/Common/Behaviours/MessageValidatorBehaviour.cs new file mode 100644 index 0000000000..72f70f01ba --- /dev/null +++ b/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/Task/ExchangeRateUpdater.Application/Common/Exceptions/NotFoundException.cs b/Task/ExchangeRateUpdater.Application/Common/Exceptions/NotFoundException.cs new file mode 100644 index 0000000000..bc91a9b2ae --- /dev/null +++ b/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/Task/ExchangeRateUpdater.Application/Common/Interfaces/IExchangeRateApiClient.cs b/Task/ExchangeRateUpdater.Application/Common/Interfaces/IExchangeRateApiClient.cs new file mode 100644 index 0000000000..49a86e75f2 --- /dev/null +++ b/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/Task/ExchangeRateUpdater.Application/Common/Mappings/ExchangeRateMappingExtensions.cs b/Task/ExchangeRateUpdater.Application/Common/Mappings/ExchangeRateMappingExtensions.cs new file mode 100644 index 0000000000..b96f5f646b --- /dev/null +++ b/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/Task/ExchangeRateUpdater.Application/Common/Models/Result.cs b/Task/ExchangeRateUpdater.Application/Common/Models/Result.cs new file mode 100644 index 0000000000..39065e18f6 --- /dev/null +++ b/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/Task/ExchangeRateUpdater.Application/Common/Utils/CacheKeyHelper.cs b/Task/ExchangeRateUpdater.Application/Common/Utils/CacheKeyHelper.cs new file mode 100644 index 0000000000..8da8a59cbc --- /dev/null +++ b/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/Task/ExchangeRateUpdater.Application/ExchangeRateUpdater.Application.csproj b/Task/ExchangeRateUpdater.Application/ExchangeRateUpdater.Application.csproj new file mode 100644 index 0000000000..1e8e4b5b3a --- /dev/null +++ b/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/Task/ExchangeRateUpdater.Application/ExchangeRates/Dtos/ExchangeRateApiDto.cs b/Task/ExchangeRateUpdater.Application/ExchangeRates/Dtos/ExchangeRateApiDto.cs new file mode 100644 index 0000000000..6c9ea9cbe5 --- /dev/null +++ b/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/Task/ExchangeRateUpdater.Application/ExchangeRates/Dtos/ExchangeRateApiResponse.cs b/Task/ExchangeRateUpdater.Application/ExchangeRates/Dtos/ExchangeRateApiResponse.cs new file mode 100644 index 0000000000..77512e9d82 --- /dev/null +++ b/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/Task/ExchangeRateUpdater.Application/ExchangeRates/Dtos/ExchangeRateDto.cs b/Task/ExchangeRateUpdater.Application/ExchangeRates/Dtos/ExchangeRateDto.cs new file mode 100644 index 0000000000..f16a509bee --- /dev/null +++ b/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/Task/ExchangeRateUpdater.Application/ExchangeRates/Query/GetExchangeRatesDaily/GetExchangesRatesByDateQuery.cs b/Task/ExchangeRateUpdater.Application/ExchangeRates/Query/GetExchangeRatesDaily/GetExchangesRatesByDateQuery.cs new file mode 100644 index 0000000000..f4ff6ed5dc --- /dev/null +++ b/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/Task/ExchangeRateUpdater.Application/ExchangeRates/Query/GetExchangeRatesDaily/GetExchangesRatesByDateQueryHandler.cs b/Task/ExchangeRateUpdater.Application/ExchangeRates/Query/GetExchangeRatesDaily/GetExchangesRatesByDateQueryHandler.cs new file mode 100644 index 0000000000..f55473926b --- /dev/null +++ b/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/Task/ExchangeRateUpdater.Application/ExchangeRates/Query/GetExchangeRatesDaily/GetExchangesRatesByDateQueryValidator.cs b/Task/ExchangeRateUpdater.Application/ExchangeRates/Query/GetExchangeRatesDaily/GetExchangesRatesByDateQueryValidator.cs new file mode 100644 index 0000000000..7c1fd98ae2 --- /dev/null +++ b/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/Task/ExchangeRateUpdater.Application/ServiceCollectionExtensions.cs b/Task/ExchangeRateUpdater.Application/ServiceCollectionExtensions.cs new file mode 100644 index 0000000000..15a3bd4a72 --- /dev/null +++ b/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/Task/ExchangeRateUpdater.Domain/Common/Ensure.cs b/Task/ExchangeRateUpdater.Domain/Common/Ensure.cs new file mode 100644 index 0000000000..1ea5f94bd7 --- /dev/null +++ b/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/Task/ExchangeRateUpdater.Domain/Entities/ExchangeRate.cs b/Task/ExchangeRateUpdater.Domain/Entities/ExchangeRate.cs new file mode 100644 index 0000000000..1cb9be7b6e --- /dev/null +++ b/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/Task/ExchangeRateUpdater.Domain/Enums/Language.cs b/Task/ExchangeRateUpdater.Domain/Enums/Language.cs new file mode 100644 index 0000000000..a0a067290a --- /dev/null +++ b/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/Task/ExchangeRateUpdater.Domain/ExchangeRateUpdater.Domain.csproj b/Task/ExchangeRateUpdater.Domain/ExchangeRateUpdater.Domain.csproj new file mode 100644 index 0000000000..7a57e2526b --- /dev/null +++ b/Task/ExchangeRateUpdater.Domain/ExchangeRateUpdater.Domain.csproj @@ -0,0 +1,10 @@ + + + + net9.0 + enable + enable + ExchangeRateUpdater.Domain + + + diff --git a/Task/ExchangeRateUpdater.Domain/Repositories/ICacheRepository.cs b/Task/ExchangeRateUpdater.Domain/Repositories/ICacheRepository.cs new file mode 100644 index 0000000000..854c3a4bf7 --- /dev/null +++ b/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/Task/ExchangeRateUpdater.Domain/ValueObjects/Currency.cs b/Task/ExchangeRateUpdater.Domain/ValueObjects/Currency.cs new file mode 100644 index 0000000000..62b9fb4fcc --- /dev/null +++ b/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/Task/ExchangeRateUpdater.Infrastructure/ApiClients/ExchangeRateApiClient.cs b/Task/ExchangeRateUpdater.Infrastructure/ApiClients/ExchangeRateApiClient.cs new file mode 100644 index 0000000000..8ff6f98c57 --- /dev/null +++ b/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/Task/ExchangeRateUpdater.Infrastructure/Cache/RedisCacheRepository.cs b/Task/ExchangeRateUpdater.Infrastructure/Cache/RedisCacheRepository.cs new file mode 100644 index 0000000000..4e72745eb5 --- /dev/null +++ b/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/Task/ExchangeRateUpdater.Infrastructure/Configurations/ExchangeRateApiClientConfig.cs b/Task/ExchangeRateUpdater.Infrastructure/Configurations/ExchangeRateApiClientConfig.cs new file mode 100644 index 0000000000..f94db90890 --- /dev/null +++ b/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/Task/ExchangeRateUpdater.Infrastructure/Data/CacheRepository.cs b/Task/ExchangeRateUpdater.Infrastructure/Data/CacheRepository.cs new file mode 100644 index 0000000000..6649d61c88 --- /dev/null +++ b/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/Task/ExchangeRateUpdater.Infrastructure/ExchangeRateUpdater.Infrastructure.csproj b/Task/ExchangeRateUpdater.Infrastructure/ExchangeRateUpdater.Infrastructure.csproj new file mode 100644 index 0000000000..54c5d5f49b --- /dev/null +++ b/Task/ExchangeRateUpdater.Infrastructure/ExchangeRateUpdater.Infrastructure.csproj @@ -0,0 +1,24 @@ + + + + net9.0 + enable + enable + ExchangeRateUpdater.Infrastructure + + + + + + + + + + + + + + + + + diff --git a/Task/ExchangeRateUpdater.Infrastructure/ServiceCollectionExtensions.cs b/Task/ExchangeRateUpdater.Infrastructure/ServiceCollectionExtensions.cs new file mode 100644 index 0000000000..1d583b55b7 --- /dev/null +++ b/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/Task/ExchangeRateUpdater.UnitTests/ExchangeRateUpdater.UnitTests.csproj b/Task/ExchangeRateUpdater.UnitTests/ExchangeRateUpdater.UnitTests.csproj new file mode 100644 index 0000000000..77222bf306 --- /dev/null +++ b/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/Task/ExchangeRateUpdater.UnitTests/Fakers/FakeCacheRepository.cs b/Task/ExchangeRateUpdater.UnitTests/Fakers/FakeCacheRepository.cs new file mode 100644 index 0000000000..eb443b699d --- /dev/null +++ b/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/Task/ExchangeRateUpdater.UnitTests/Fakers/FakeExchangeRateApiClient.cs b/Task/ExchangeRateUpdater.UnitTests/Fakers/FakeExchangeRateApiClient.cs new file mode 100644 index 0000000000..7f446ac47d --- /dev/null +++ b/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/Task/ExchangeRateUpdater.UnitTests/Handlers/GetExchangesRatesByDateQueryHandlerTests.cs b/Task/ExchangeRateUpdater.UnitTests/Handlers/GetExchangesRatesByDateQueryHandlerTests.cs new file mode 100644 index 0000000000..e44a50775a --- /dev/null +++ b/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/Task/ExchangeRateUpdater.UnitTests/Mapping/ExchangeRateMappingExtensionsTests.cs b/Task/ExchangeRateUpdater.UnitTests/Mapping/ExchangeRateMappingExtensionsTests.cs new file mode 100644 index 0000000000..6e087f8214 --- /dev/null +++ b/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/Task/ExchangeRateUpdater.UnitTests/Services/CzYearProcessorTests.cs b/Task/ExchangeRateUpdater.UnitTests/Services/CzYearProcessorTests.cs new file mode 100644 index 0000000000..9d5fb22cd9 --- /dev/null +++ b/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/Task/ExchangeRateUpdater.UnitTests/Services/PerDayProcessorTests.cs b/Task/ExchangeRateUpdater.UnitTests/Services/PerDayProcessorTests.cs new file mode 100644 index 0000000000..a26679a081 --- /dev/null +++ b/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/Task/ExchangeRateUpdater.UnitTests/Validation/GetExchangesRatesByDateQueryValidatorTests.cs b/Task/ExchangeRateUpdater.UnitTests/Validation/GetExchangesRatesByDateQueryValidatorTests.cs new file mode 100644 index 0000000000..bb3bc877c3 --- /dev/null +++ b/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/Task/ExchangeRateUpdater.Worker/Dockerfile b/Task/ExchangeRateUpdater.Worker/Dockerfile new file mode 100644 index 0000000000..2b8599ac9c --- /dev/null +++ b/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/Task/ExchangeRateUpdater.Worker/ExchangeRateUpdater.Worker.csproj b/Task/ExchangeRateUpdater.Worker/ExchangeRateUpdater.Worker.csproj new file mode 100644 index 0000000000..5892573029 --- /dev/null +++ b/Task/ExchangeRateUpdater.Worker/ExchangeRateUpdater.Worker.csproj @@ -0,0 +1,37 @@ + + + + net9.0 + enable + enable + + + + + + + + + + + + + + + + + + PreserveNewest + + + PreserveNewest + + + + + + appsettings.json + + + + diff --git a/Task/ExchangeRateUpdater.Worker/Jobs/DailyExchangeRatesRefreshJob.cs b/Task/ExchangeRateUpdater.Worker/Jobs/DailyExchangeRatesRefreshJob.cs new file mode 100644 index 0000000000..1a7292149d --- /dev/null +++ b/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/Task/ExchangeRateUpdater.Worker/Jobs/ExchangeRatesBackfillJob.cs b/Task/ExchangeRateUpdater.Worker/Jobs/ExchangeRatesBackfillJob.cs new file mode 100644 index 0000000000..cb36ca6de3 --- /dev/null +++ b/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/Task/ExchangeRateUpdater.Worker/Program.cs b/Task/ExchangeRateUpdater.Worker/Program.cs new file mode 100644 index 0000000000..51abb01d11 --- /dev/null +++ b/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/Task/ExchangeRateUpdater.Worker/ServiceCollectionExtensions.cs b/Task/ExchangeRateUpdater.Worker/ServiceCollectionExtensions.cs new file mode 100644 index 0000000000..f54894960b --- /dev/null +++ b/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/Task/ExchangeRateUpdater.Worker/Services/CzYearProcessor.cs b/Task/ExchangeRateUpdater.Worker/Services/CzYearProcessor.cs new file mode 100644 index 0000000000..bc013eefc0 --- /dev/null +++ b/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/Task/ExchangeRateUpdater.Worker/Services/PerDayProcessor.cs b/Task/ExchangeRateUpdater.Worker/Services/PerDayProcessor.cs new file mode 100644 index 0000000000..6779d737f4 --- /dev/null +++ b/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/Task/ExchangeRateUpdater.Worker/appsettings.Development.json b/Task/ExchangeRateUpdater.Worker/appsettings.Development.json new file mode 100644 index 0000000000..cb2f1037e9 --- /dev/null +++ b/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/Task/ExchangeRateUpdater.Worker/appsettings.json b/Task/ExchangeRateUpdater.Worker/appsettings.json new file mode 100644 index 0000000000..314dfd1322 --- /dev/null +++ b/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/Task/ExchangeRateUpdater.sln b/Task/ExchangeRateUpdater.sln new file mode 100644 index 0000000000..5e02b3ce21 --- /dev/null +++ b/Task/ExchangeRateUpdater.sln @@ -0,0 +1,81 @@ + +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.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 + Debug|Any CPU = Debug|Any CPU + Release|Any CPU = Release|Any CPU + EndGlobalSection + GlobalSection(ProjectConfigurationPlatforms) = postSolution + {7B2695D6-D24C-4460-A58E-A10F08550CE0}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {7B2695D6-D24C-4460-A58E-A10F08550CE0}.Debug|Any CPU.Build.0 = Debug|Any CPU + {7B2695D6-D24C-4460-A58E-A10F08550CE0}.Release|Any CPU.ActiveCfg = Release|Any CPU + {7B2695D6-D24C-4460-A58E-A10F08550CE0}.Release|Any CPU.Build.0 = Release|Any CPU + {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/Task/ExhangeRateUpdater/ExchangeRateUpdater.csproj b/Task/ExhangeRateUpdater/ExchangeRateUpdater.csproj new file mode 100644 index 0000000000..a1af9ef45f --- /dev/null +++ b/Task/ExhangeRateUpdater/ExchangeRateUpdater.csproj @@ -0,0 +1,30 @@ + + + + Exe + net9.0 + enable + enable + + + + + + + + + + + + + + + + + + + PreserveNewest + + + + \ No newline at end of file diff --git a/Task/ExhangeRateUpdater/Program.cs b/Task/ExhangeRateUpdater/Program.cs new file mode 100644 index 0000000000..62db6903fd --- /dev/null +++ b/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/Task/ExhangeRateUpdater/appsettings.json b/Task/ExhangeRateUpdater/appsettings.json new file mode 100644 index 0000000000..7e15d068c2 --- /dev/null +++ b/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/Task/Readme.md b/Task/Readme.md new file mode 100644 index 0000000000..5d60bba5ef --- /dev/null +++ b/Task/Readme.md @@ -0,0 +1,176 @@ +# 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 +- CQS pattern +- Options pattern: Access the configuration data +- Retry policy using Polly for ExchangeRateApiClient +- 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/Task/docker-compose.yml b/Task/docker-compose.yml new file mode 100644 index 0000000000..b6e9ba9bc8 --- /dev/null +++ b/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 From cb7d573136e32e858327663f16c9802a46f164ef Mon Sep 17 00:00:00 2001 From: Amin Chouaibi Date: Thu, 27 Nov 2025 17:45:23 +0100 Subject: [PATCH 7/9] Updating Readme --- Task/Readme.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/Task/Readme.md b/Task/Readme.md index 5d60bba5ef..acf2692256 100644 --- a/Task/Readme.md +++ b/Task/Readme.md @@ -12,9 +12,10 @@ This document explains the architecture, startup sequence, readiness signalling, Design details: - Clean architecture - Mediator pattern -- CQS 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 From 257fd947131d66fe551df0a5153acab9eab85eca Mon Sep 17 00:00:00 2001 From: Amin Chouaibi Date: Thu, 27 Nov 2025 17:57:00 +0100 Subject: [PATCH 8/9] Development finished --- .../Dtos/ExchangeRateApiResponse.cs | 2 -- .../ExchangeRates/Dtos/ExchangeRateDto.cs | 3 --- .../GetExchangesRatesByDateQueryHandler.cs | 1 - .../Entities/ExchangeRate.cs | 1 - .../Cache/RedisCacheRepository.cs | 27 ++++++++----------- ...etExchangesRatesByDateQueryHandlerTests.cs | 6 ----- .../ExchangeRateMappingExtensionsTests.cs | 3 --- .../Services/CzYearProcessorTests.cs | 1 - .../ExchangeRateUpdater.Worker.csproj | 1 - .../Jobs/ExchangeRatesBackfillJob.cs | 1 - .../ServiceCollectionExtensions.cs | 2 -- .../Services/CzYearProcessor.cs | 1 - .../Services/PerDayProcessor.cs | 1 - jobs/Backend/Task/Readme.md | 16 ++++++++++- 14 files changed, 26 insertions(+), 40 deletions(-) diff --git a/jobs/Backend/Task/ExchangeRateUpdater.Application/ExchangeRates/Dtos/ExchangeRateApiResponse.cs b/jobs/Backend/Task/ExchangeRateUpdater.Application/ExchangeRates/Dtos/ExchangeRateApiResponse.cs index 8d212c2cec..77512e9d82 100644 --- a/jobs/Backend/Task/ExchangeRateUpdater.Application/ExchangeRates/Dtos/ExchangeRateApiResponse.cs +++ b/jobs/Backend/Task/ExchangeRateUpdater.Application/ExchangeRates/Dtos/ExchangeRateApiResponse.cs @@ -1,7 +1,5 @@ namespace ExchangeRateUpdater.Application.ExchangeRates.Dtos; -using Query; - public class ExchangeRateApiResponse { public ExchangeRateApiDto[] Rates { get; set; } diff --git a/jobs/Backend/Task/ExchangeRateUpdater.Application/ExchangeRates/Dtos/ExchangeRateDto.cs b/jobs/Backend/Task/ExchangeRateUpdater.Application/ExchangeRates/Dtos/ExchangeRateDto.cs index 935f997aeb..f16a509bee 100644 --- a/jobs/Backend/Task/ExchangeRateUpdater.Application/ExchangeRates/Dtos/ExchangeRateDto.cs +++ b/jobs/Backend/Task/ExchangeRateUpdater.Application/ExchangeRates/Dtos/ExchangeRateDto.cs @@ -1,8 +1,5 @@ namespace ExchangeRateUpdater.Application.ExchangeRates.Dtos; -using Common.Mappings; -using Domain.Entities; - public record ExchangeRateDto { /// diff --git a/jobs/Backend/Task/ExchangeRateUpdater.Application/ExchangeRates/Query/GetExchangeRatesDaily/GetExchangesRatesByDateQueryHandler.cs b/jobs/Backend/Task/ExchangeRateUpdater.Application/ExchangeRates/Query/GetExchangeRatesDaily/GetExchangesRatesByDateQueryHandler.cs index 7c2892c3a3..f55473926b 100644 --- a/jobs/Backend/Task/ExchangeRateUpdater.Application/ExchangeRates/Query/GetExchangeRatesDaily/GetExchangesRatesByDateQueryHandler.cs +++ b/jobs/Backend/Task/ExchangeRateUpdater.Application/ExchangeRates/Query/GetExchangeRatesDaily/GetExchangesRatesByDateQueryHandler.cs @@ -1,6 +1,5 @@ namespace ExchangeRateUpdater.Application.ExchangeRates.Query.GetExchangeRatesDaily; -using Common.Interfaces; using Common.Mappings; using Common.Utils; using Domain.Common; diff --git a/jobs/Backend/Task/ExchangeRateUpdater.Domain/Entities/ExchangeRate.cs b/jobs/Backend/Task/ExchangeRateUpdater.Domain/Entities/ExchangeRate.cs index 39707c6aff..1cb9be7b6e 100644 --- a/jobs/Backend/Task/ExchangeRateUpdater.Domain/Entities/ExchangeRate.cs +++ b/jobs/Backend/Task/ExchangeRateUpdater.Domain/Entities/ExchangeRate.cs @@ -1,6 +1,5 @@ namespace ExchangeRateUpdater.Domain.Entities; -using Common; using ValueObjects; public class ExchangeRate(Currency sourceCurrency, Currency targetCurrency, decimal value) diff --git a/jobs/Backend/Task/ExchangeRateUpdater.Infrastructure/Cache/RedisCacheRepository.cs b/jobs/Backend/Task/ExchangeRateUpdater.Infrastructure/Cache/RedisCacheRepository.cs index ddf3482800..4e72745eb5 100644 --- a/jobs/Backend/Task/ExchangeRateUpdater.Infrastructure/Cache/RedisCacheRepository.cs +++ b/jobs/Backend/Task/ExchangeRateUpdater.Infrastructure/Cache/RedisCacheRepository.cs @@ -9,6 +9,7 @@ namespace ExchangeRateUpdater.Infrastructure.Cache; using System.Collections.Generic; using System.Linq; using Application.Common.Mappings; +using Domain.Common; using Domain.Entities; public class RedisCacheRepository : ICacheRepository @@ -18,8 +19,10 @@ public class RedisCacheRepository : ICacheRepository public RedisCacheRepository(IConnectionMultiplexer connection, ILogger logger) { - _database = connection?.GetDatabase() ?? throw new ArgumentNullException(nameof(connection)); - _logger = logger ?? throw new ArgumentNullException(nameof(logger)); + Ensure.Argument.NotNull(connection, nameof(connection)); + Ensure.Argument.NotNull(logger, nameof(logger)); + _database = connection.GetDatabase(); + _logger = logger; } public async Task?> GetRatesDictionaryAsync(string key) @@ -49,7 +52,7 @@ public RedisCacheRepository(IConnectionMultiplexer connection, ILogger>(value!); - if (dtos == null) return default; - var list = dtos.Select(d => d.ToEntity()).ToList(); - _logger.LogInformation("{Key} found in redis cache (string)", key); - return list; + 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); - return default; + throw; } } @@ -109,6 +102,7 @@ public async Task SetRatesAsync(string key, Dictionary val catch (Exception e) { _logger.LogError(e, "Failed to set cache for {Key}", key); + throw; } } @@ -123,6 +117,7 @@ public async Task SetRatesListAsync(string key, List value, TimeSp catch (Exception e) { _logger.LogError(e, "Failed to set cache for {Key}", key); + throw; } } diff --git a/jobs/Backend/Task/ExchangeRateUpdater.UnitTests/Handlers/GetExchangesRatesByDateQueryHandlerTests.cs b/jobs/Backend/Task/ExchangeRateUpdater.UnitTests/Handlers/GetExchangesRatesByDateQueryHandlerTests.cs index 3fc1fecc7a..e44a50775a 100644 --- a/jobs/Backend/Task/ExchangeRateUpdater.UnitTests/Handlers/GetExchangesRatesByDateQueryHandlerTests.cs +++ b/jobs/Backend/Task/ExchangeRateUpdater.UnitTests/Handlers/GetExchangesRatesByDateQueryHandlerTests.cs @@ -1,12 +1,6 @@ -using System; -using System.Collections.Generic; -using System.Linq; using ExchangeRateUpdater.Application.ExchangeRates.Query.GetExchangeRatesDaily; using ExchangeRateUpdater.Domain.Entities; -using ExchangeRateUpdater.Domain.Repositories; using ExchangeRateUpdater.Domain.ValueObjects; -using Shouldly; -using Xunit; namespace UnitTests.Handlers; diff --git a/jobs/Backend/Task/ExchangeRateUpdater.UnitTests/Mapping/ExchangeRateMappingExtensionsTests.cs b/jobs/Backend/Task/ExchangeRateUpdater.UnitTests/Mapping/ExchangeRateMappingExtensionsTests.cs index 523fbe15dc..6e087f8214 100644 --- a/jobs/Backend/Task/ExchangeRateUpdater.UnitTests/Mapping/ExchangeRateMappingExtensionsTests.cs +++ b/jobs/Backend/Task/ExchangeRateUpdater.UnitTests/Mapping/ExchangeRateMappingExtensionsTests.cs @@ -1,9 +1,6 @@ -using System.Collections.Generic; using ExchangeRateUpdater.Application.Common.Mappings; using ExchangeRateUpdater.Domain.Entities; using ExchangeRateUpdater.Domain.ValueObjects; -using Shouldly; -using Xunit; namespace UnitTests.Mapping; diff --git a/jobs/Backend/Task/ExchangeRateUpdater.UnitTests/Services/CzYearProcessorTests.cs b/jobs/Backend/Task/ExchangeRateUpdater.UnitTests/Services/CzYearProcessorTests.cs index ec6431f2b8..9d5fb22cd9 100644 --- a/jobs/Backend/Task/ExchangeRateUpdater.UnitTests/Services/CzYearProcessorTests.cs +++ b/jobs/Backend/Task/ExchangeRateUpdater.UnitTests/Services/CzYearProcessorTests.cs @@ -1,6 +1,5 @@ using ExchangeRateUpdater.Worker.Services; using ExchangeRateUpdater.Application.ExchangeRates.Dtos; -using ExchangeRateUpdater.Domain.Entities; using Microsoft.Extensions.Logging.Abstractions; using UnitTests.Fakers; diff --git a/jobs/Backend/Task/ExchangeRateUpdater.Worker/ExchangeRateUpdater.Worker.csproj b/jobs/Backend/Task/ExchangeRateUpdater.Worker/ExchangeRateUpdater.Worker.csproj index fb3f1c80ea..5892573029 100644 --- a/jobs/Backend/Task/ExchangeRateUpdater.Worker/ExchangeRateUpdater.Worker.csproj +++ b/jobs/Backend/Task/ExchangeRateUpdater.Worker/ExchangeRateUpdater.Worker.csproj @@ -12,7 +12,6 @@ - diff --git a/jobs/Backend/Task/ExchangeRateUpdater.Worker/Jobs/ExchangeRatesBackfillJob.cs b/jobs/Backend/Task/ExchangeRateUpdater.Worker/Jobs/ExchangeRatesBackfillJob.cs index a747a93e85..cb36ca6de3 100644 --- a/jobs/Backend/Task/ExchangeRateUpdater.Worker/Jobs/ExchangeRatesBackfillJob.cs +++ b/jobs/Backend/Task/ExchangeRateUpdater.Worker/Jobs/ExchangeRatesBackfillJob.cs @@ -5,7 +5,6 @@ namespace ExchangeRateUpdater.Worker.Jobs; using System.Diagnostics; using Application.Common.Utils; using Domain.Common; -using Domain.Entities; using Domain.Repositories; using Quartz; using Services; diff --git a/jobs/Backend/Task/ExchangeRateUpdater.Worker/ServiceCollectionExtensions.cs b/jobs/Backend/Task/ExchangeRateUpdater.Worker/ServiceCollectionExtensions.cs index 6754c4a98d..f54894960b 100644 --- a/jobs/Backend/Task/ExchangeRateUpdater.Worker/ServiceCollectionExtensions.cs +++ b/jobs/Backend/Task/ExchangeRateUpdater.Worker/ServiceCollectionExtensions.cs @@ -1,13 +1,11 @@ namespace ExchangeRateUpdater.Worker; -using Serilog; using Services; public static class ServiceCollectionExtensions { public static IServiceCollection AddExchangeRateWorkerServices(this IServiceCollection services) { - services.AddSerilog(); services.AddSingleton(); services.AddSingleton(); diff --git a/jobs/Backend/Task/ExchangeRateUpdater.Worker/Services/CzYearProcessor.cs b/jobs/Backend/Task/ExchangeRateUpdater.Worker/Services/CzYearProcessor.cs index 81956c82bb..bc013eefc0 100644 --- a/jobs/Backend/Task/ExchangeRateUpdater.Worker/Services/CzYearProcessor.cs +++ b/jobs/Backend/Task/ExchangeRateUpdater.Worker/Services/CzYearProcessor.cs @@ -5,7 +5,6 @@ namespace ExchangeRateUpdater.Worker.Services; using Application.Common.Mappings; using Application.Common.Utils; using Domain.Common; -using Domain.Entities; using Domain.Enums; using Domain.Repositories; diff --git a/jobs/Backend/Task/ExchangeRateUpdater.Worker/Services/PerDayProcessor.cs b/jobs/Backend/Task/ExchangeRateUpdater.Worker/Services/PerDayProcessor.cs index 8063043b99..6779d737f4 100644 --- a/jobs/Backend/Task/ExchangeRateUpdater.Worker/Services/PerDayProcessor.cs +++ b/jobs/Backend/Task/ExchangeRateUpdater.Worker/Services/PerDayProcessor.cs @@ -8,7 +8,6 @@ namespace ExchangeRateUpdater.Worker.Services; using Application.Common.Mappings; using Application.Common.Utils; using Domain.Common; -using Domain.Entities; using Domain.Repositories; using Microsoft.Extensions.Logging; diff --git a/jobs/Backend/Task/Readme.md b/jobs/Backend/Task/Readme.md index 32d2dccce6..acf2692256 100644 --- a/jobs/Backend/Task/Readme.md +++ b/jobs/Backend/Task/Readme.md @@ -5,9 +5,23 @@ This repository contains an ExchangeRateUpdater system composed of three main se - 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: @@ -132,7 +146,7 @@ curl http://localhost:5000/api/exchangerates?date=2025-11-27 - depends_on with health conditions ensures proper start ordering for Compose v2+. -## Best practices for container orchestration & reliability +## 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: From b86fe1dc10e1e00b477f07c3f5a6ab5c95cce3bb Mon Sep 17 00:00:00 2001 From: Amin Chouaibi Date: Thu, 27 Nov 2025 18:01:36 +0100 Subject: [PATCH 9/9] Removing incorrect folder --- .../ApplicationBuilderExtensions.cs | 31 --- .../Extensions/OpenApiConfiguration.cs | 26 --- .../Extensions/ServiceCollectionExtensions.cs | 19 -- .../Controllers/ExchangeRatesController.cs | 21 -- Task/ExchangeRateUpdater.Api/Dockerfile | 32 --- .../ExchangeRateUpdater.Api.csproj | 27 --- .../GlobalExceptionHandler.cs | 62 ------ Task/ExchangeRateUpdater.Api/Program.cs | 47 ---- .../Properties/launchSettings.json | 25 --- .../appsettings.Development.json | 17 -- Task/ExchangeRateUpdater.Api/appsettings.json | 18 -- .../Behaviours/MessageValidatorBehaviour.cs | 16 -- .../Common/Exceptions/NotFoundException.cs | 24 -- .../Interfaces/IExchangeRateApiClient.cs | 17 -- .../Mappings/ExchangeRateMappingExtensions.cs | 36 --- .../Common/Models/Result.cs | 43 ---- .../Common/Utils/CacheKeyHelper.cs | 11 - .../ExchangeRateUpdater.Application.csproj | 24 -- .../ExchangeRates/Dtos/ExchangeRateApiDto.cs | 17 -- .../Dtos/ExchangeRateApiResponse.cs | 6 - .../ExchangeRates/Dtos/ExchangeRateDto.cs | 21 -- .../GetExchangesRatesByDateQuery.cs | 24 -- .../GetExchangesRatesByDateQueryHandler.cs | 51 ----- .../GetExchangesRatesByDateQueryValidator.cs | 17 -- .../ServiceCollectionExtensions.cs | 22 -- .../Common/Ensure.cs | 208 ------------------ .../Entities/ExchangeRate.cs | 17 -- .../Enums/Language.cs | 7 - .../ExchangeRateUpdater.Domain.csproj | 10 - .../Repositories/ICacheRepository.cs | 21 -- .../ValueObjects/Currency.cs | 18 -- .../ApiClients/ExchangeRateApiClient.cs | 68 ------ .../Cache/RedisCacheRepository.cs | 142 ------------ .../ExchangeRateApiClientConfig.cs | 6 - .../Data/CacheRepository.cs | 72 ------ .../ExchangeRateUpdater.Infrastructure.csproj | 24 -- .../ServiceCollectionExtensions.cs | 45 ---- .../ExchangeRateUpdater.UnitTests.csproj | 37 ---- .../Fakers/FakeCacheRepository.cs | 55 ----- .../Fakers/FakeExchangeRateApiClient.cs | 40 ---- ...etExchangesRatesByDateQueryHandlerTests.cs | 88 -------- .../ExchangeRateMappingExtensionsTests.cs | 54 ----- .../Services/CzYearProcessorTests.cs | 87 -------- .../Services/PerDayProcessorTests.cs | 86 -------- ...ExchangesRatesByDateQueryValidatorTests.cs | 37 ---- Task/ExchangeRateUpdater.Worker/Dockerfile | 32 --- .../ExchangeRateUpdater.Worker.csproj | 37 ---- .../Jobs/DailyExchangeRatesRefreshJob.cs | 63 ------ .../Jobs/ExchangeRatesBackfillJob.cs | 194 ---------------- Task/ExchangeRateUpdater.Worker/Program.cs | 38 ---- .../ServiceCollectionExtensions.cs | 14 -- .../Services/CzYearProcessor.cs | 76 ------- .../Services/PerDayProcessor.cs | 61 ----- .../appsettings.Development.json | 15 -- .../appsettings.json | 15 -- Task/ExchangeRateUpdater.sln | 81 ------- .../ExchangeRateUpdater.csproj | 30 --- Task/ExhangeRateUpdater/Program.cs | 59 ----- Task/ExhangeRateUpdater/appsettings.json | 12 - Task/Readme.md | 177 --------------- Task/docker-compose.yml | 63 ------ 61 files changed, 2743 deletions(-) delete mode 100644 Task/ExchangeRateUpdater.Api/Configurations/Extensions/ApplicationBuilderExtensions.cs delete mode 100644 Task/ExchangeRateUpdater.Api/Configurations/Extensions/OpenApiConfiguration.cs delete mode 100644 Task/ExchangeRateUpdater.Api/Configurations/Extensions/ServiceCollectionExtensions.cs delete mode 100644 Task/ExchangeRateUpdater.Api/Controllers/ExchangeRatesController.cs delete mode 100644 Task/ExchangeRateUpdater.Api/Dockerfile delete mode 100644 Task/ExchangeRateUpdater.Api/ExchangeRateUpdater.Api.csproj delete mode 100644 Task/ExchangeRateUpdater.Api/GlobalExceptionHandler.cs delete mode 100644 Task/ExchangeRateUpdater.Api/Program.cs delete mode 100644 Task/ExchangeRateUpdater.Api/Properties/launchSettings.json delete mode 100644 Task/ExchangeRateUpdater.Api/appsettings.Development.json delete mode 100644 Task/ExchangeRateUpdater.Api/appsettings.json delete mode 100644 Task/ExchangeRateUpdater.Application/Common/Behaviours/MessageValidatorBehaviour.cs delete mode 100644 Task/ExchangeRateUpdater.Application/Common/Exceptions/NotFoundException.cs delete mode 100644 Task/ExchangeRateUpdater.Application/Common/Interfaces/IExchangeRateApiClient.cs delete mode 100644 Task/ExchangeRateUpdater.Application/Common/Mappings/ExchangeRateMappingExtensions.cs delete mode 100644 Task/ExchangeRateUpdater.Application/Common/Models/Result.cs delete mode 100644 Task/ExchangeRateUpdater.Application/Common/Utils/CacheKeyHelper.cs delete mode 100644 Task/ExchangeRateUpdater.Application/ExchangeRateUpdater.Application.csproj delete mode 100644 Task/ExchangeRateUpdater.Application/ExchangeRates/Dtos/ExchangeRateApiDto.cs delete mode 100644 Task/ExchangeRateUpdater.Application/ExchangeRates/Dtos/ExchangeRateApiResponse.cs delete mode 100644 Task/ExchangeRateUpdater.Application/ExchangeRates/Dtos/ExchangeRateDto.cs delete mode 100644 Task/ExchangeRateUpdater.Application/ExchangeRates/Query/GetExchangeRatesDaily/GetExchangesRatesByDateQuery.cs delete mode 100644 Task/ExchangeRateUpdater.Application/ExchangeRates/Query/GetExchangeRatesDaily/GetExchangesRatesByDateQueryHandler.cs delete mode 100644 Task/ExchangeRateUpdater.Application/ExchangeRates/Query/GetExchangeRatesDaily/GetExchangesRatesByDateQueryValidator.cs delete mode 100644 Task/ExchangeRateUpdater.Application/ServiceCollectionExtensions.cs delete mode 100644 Task/ExchangeRateUpdater.Domain/Common/Ensure.cs delete mode 100644 Task/ExchangeRateUpdater.Domain/Entities/ExchangeRate.cs delete mode 100644 Task/ExchangeRateUpdater.Domain/Enums/Language.cs delete mode 100644 Task/ExchangeRateUpdater.Domain/ExchangeRateUpdater.Domain.csproj delete mode 100644 Task/ExchangeRateUpdater.Domain/Repositories/ICacheRepository.cs delete mode 100644 Task/ExchangeRateUpdater.Domain/ValueObjects/Currency.cs delete mode 100644 Task/ExchangeRateUpdater.Infrastructure/ApiClients/ExchangeRateApiClient.cs delete mode 100644 Task/ExchangeRateUpdater.Infrastructure/Cache/RedisCacheRepository.cs delete mode 100644 Task/ExchangeRateUpdater.Infrastructure/Configurations/ExchangeRateApiClientConfig.cs delete mode 100644 Task/ExchangeRateUpdater.Infrastructure/Data/CacheRepository.cs delete mode 100644 Task/ExchangeRateUpdater.Infrastructure/ExchangeRateUpdater.Infrastructure.csproj delete mode 100644 Task/ExchangeRateUpdater.Infrastructure/ServiceCollectionExtensions.cs delete mode 100644 Task/ExchangeRateUpdater.UnitTests/ExchangeRateUpdater.UnitTests.csproj delete mode 100644 Task/ExchangeRateUpdater.UnitTests/Fakers/FakeCacheRepository.cs delete mode 100644 Task/ExchangeRateUpdater.UnitTests/Fakers/FakeExchangeRateApiClient.cs delete mode 100644 Task/ExchangeRateUpdater.UnitTests/Handlers/GetExchangesRatesByDateQueryHandlerTests.cs delete mode 100644 Task/ExchangeRateUpdater.UnitTests/Mapping/ExchangeRateMappingExtensionsTests.cs delete mode 100644 Task/ExchangeRateUpdater.UnitTests/Services/CzYearProcessorTests.cs delete mode 100644 Task/ExchangeRateUpdater.UnitTests/Services/PerDayProcessorTests.cs delete mode 100644 Task/ExchangeRateUpdater.UnitTests/Validation/GetExchangesRatesByDateQueryValidatorTests.cs delete mode 100644 Task/ExchangeRateUpdater.Worker/Dockerfile delete mode 100644 Task/ExchangeRateUpdater.Worker/ExchangeRateUpdater.Worker.csproj delete mode 100644 Task/ExchangeRateUpdater.Worker/Jobs/DailyExchangeRatesRefreshJob.cs delete mode 100644 Task/ExchangeRateUpdater.Worker/Jobs/ExchangeRatesBackfillJob.cs delete mode 100644 Task/ExchangeRateUpdater.Worker/Program.cs delete mode 100644 Task/ExchangeRateUpdater.Worker/ServiceCollectionExtensions.cs delete mode 100644 Task/ExchangeRateUpdater.Worker/Services/CzYearProcessor.cs delete mode 100644 Task/ExchangeRateUpdater.Worker/Services/PerDayProcessor.cs delete mode 100644 Task/ExchangeRateUpdater.Worker/appsettings.Development.json delete mode 100644 Task/ExchangeRateUpdater.Worker/appsettings.json delete mode 100644 Task/ExchangeRateUpdater.sln delete mode 100644 Task/ExhangeRateUpdater/ExchangeRateUpdater.csproj delete mode 100644 Task/ExhangeRateUpdater/Program.cs delete mode 100644 Task/ExhangeRateUpdater/appsettings.json delete mode 100644 Task/Readme.md delete mode 100644 Task/docker-compose.yml diff --git a/Task/ExchangeRateUpdater.Api/Configurations/Extensions/ApplicationBuilderExtensions.cs b/Task/ExchangeRateUpdater.Api/Configurations/Extensions/ApplicationBuilderExtensions.cs deleted file mode 100644 index 518e8897b5..0000000000 --- a/Task/ExchangeRateUpdater.Api/Configurations/Extensions/ApplicationBuilderExtensions.cs +++ /dev/null @@ -1,31 +0,0 @@ -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/Task/ExchangeRateUpdater.Api/Configurations/Extensions/OpenApiConfiguration.cs b/Task/ExchangeRateUpdater.Api/Configurations/Extensions/OpenApiConfiguration.cs deleted file mode 100644 index 1c99b7dd07..0000000000 --- a/Task/ExchangeRateUpdater.Api/Configurations/Extensions/OpenApiConfiguration.cs +++ /dev/null @@ -1,26 +0,0 @@ -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/Task/ExchangeRateUpdater.Api/Configurations/Extensions/ServiceCollectionExtensions.cs b/Task/ExchangeRateUpdater.Api/Configurations/Extensions/ServiceCollectionExtensions.cs deleted file mode 100644 index e6f48556ee..0000000000 --- a/Task/ExchangeRateUpdater.Api/Configurations/Extensions/ServiceCollectionExtensions.cs +++ /dev/null @@ -1,19 +0,0 @@ -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/Task/ExchangeRateUpdater.Api/Controllers/ExchangeRatesController.cs b/Task/ExchangeRateUpdater.Api/Controllers/ExchangeRatesController.cs deleted file mode 100644 index 3dd812f7bc..0000000000 --- a/Task/ExchangeRateUpdater.Api/Controllers/ExchangeRatesController.cs +++ /dev/null @@ -1,21 +0,0 @@ -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/Task/ExchangeRateUpdater.Api/Dockerfile b/Task/ExchangeRateUpdater.Api/Dockerfile deleted file mode 100644 index 9f6af80bf8..0000000000 --- a/Task/ExchangeRateUpdater.Api/Dockerfile +++ /dev/null @@ -1,32 +0,0 @@ -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/Task/ExchangeRateUpdater.Api/ExchangeRateUpdater.Api.csproj b/Task/ExchangeRateUpdater.Api/ExchangeRateUpdater.Api.csproj deleted file mode 100644 index e6e3c4ccef..0000000000 --- a/Task/ExchangeRateUpdater.Api/ExchangeRateUpdater.Api.csproj +++ /dev/null @@ -1,27 +0,0 @@ - - - - net9.0 - enable - enable - EchangeRateUpdater.Api - - - - - - - - - - - - - - - - appsettings.json - - - - diff --git a/Task/ExchangeRateUpdater.Api/GlobalExceptionHandler.cs b/Task/ExchangeRateUpdater.Api/GlobalExceptionHandler.cs deleted file mode 100644 index 7cfcc4c680..0000000000 --- a/Task/ExchangeRateUpdater.Api/GlobalExceptionHandler.cs +++ /dev/null @@ -1,62 +0,0 @@ -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/Task/ExchangeRateUpdater.Api/Program.cs b/Task/ExchangeRateUpdater.Api/Program.cs deleted file mode 100644 index 0878a3eac4..0000000000 --- a/Task/ExchangeRateUpdater.Api/Program.cs +++ /dev/null @@ -1,47 +0,0 @@ -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/Task/ExchangeRateUpdater.Api/Properties/launchSettings.json b/Task/ExchangeRateUpdater.Api/Properties/launchSettings.json deleted file mode 100644 index 1731fb8766..0000000000 --- a/Task/ExchangeRateUpdater.Api/Properties/launchSettings.json +++ /dev/null @@ -1,25 +0,0 @@ -{ - "$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/Task/ExchangeRateUpdater.Api/appsettings.Development.json b/Task/ExchangeRateUpdater.Api/appsettings.Development.json deleted file mode 100644 index 4725b8a7d2..0000000000 --- a/Task/ExchangeRateUpdater.Api/appsettings.Development.json +++ /dev/null @@ -1,17 +0,0 @@ -{ - "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/Task/ExchangeRateUpdater.Api/appsettings.json b/Task/ExchangeRateUpdater.Api/appsettings.json deleted file mode 100644 index ca32d894f3..0000000000 --- a/Task/ExchangeRateUpdater.Api/appsettings.json +++ /dev/null @@ -1,18 +0,0 @@ -{ - "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/Task/ExchangeRateUpdater.Application/Common/Behaviours/MessageValidatorBehaviour.cs b/Task/ExchangeRateUpdater.Application/Common/Behaviours/MessageValidatorBehaviour.cs deleted file mode 100644 index 72f70f01ba..0000000000 --- a/Task/ExchangeRateUpdater.Application/Common/Behaviours/MessageValidatorBehaviour.cs +++ /dev/null @@ -1,16 +0,0 @@ -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/Task/ExchangeRateUpdater.Application/Common/Exceptions/NotFoundException.cs b/Task/ExchangeRateUpdater.Application/Common/Exceptions/NotFoundException.cs deleted file mode 100644 index bc91a9b2ae..0000000000 --- a/Task/ExchangeRateUpdater.Application/Common/Exceptions/NotFoundException.cs +++ /dev/null @@ -1,24 +0,0 @@ -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/Task/ExchangeRateUpdater.Application/Common/Interfaces/IExchangeRateApiClient.cs b/Task/ExchangeRateUpdater.Application/Common/Interfaces/IExchangeRateApiClient.cs deleted file mode 100644 index 49a86e75f2..0000000000 --- a/Task/ExchangeRateUpdater.Application/Common/Interfaces/IExchangeRateApiClient.cs +++ /dev/null @@ -1,17 +0,0 @@ -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/Task/ExchangeRateUpdater.Application/Common/Mappings/ExchangeRateMappingExtensions.cs b/Task/ExchangeRateUpdater.Application/Common/Mappings/ExchangeRateMappingExtensions.cs deleted file mode 100644 index b96f5f646b..0000000000 --- a/Task/ExchangeRateUpdater.Application/Common/Mappings/ExchangeRateMappingExtensions.cs +++ /dev/null @@ -1,36 +0,0 @@ -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/Task/ExchangeRateUpdater.Application/Common/Models/Result.cs b/Task/ExchangeRateUpdater.Application/Common/Models/Result.cs deleted file mode 100644 index 39065e18f6..0000000000 --- a/Task/ExchangeRateUpdater.Application/Common/Models/Result.cs +++ /dev/null @@ -1,43 +0,0 @@ -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/Task/ExchangeRateUpdater.Application/Common/Utils/CacheKeyHelper.cs b/Task/ExchangeRateUpdater.Application/Common/Utils/CacheKeyHelper.cs deleted file mode 100644 index 8da8a59cbc..0000000000 --- a/Task/ExchangeRateUpdater.Application/Common/Utils/CacheKeyHelper.cs +++ /dev/null @@ -1,11 +0,0 @@ -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/Task/ExchangeRateUpdater.Application/ExchangeRateUpdater.Application.csproj b/Task/ExchangeRateUpdater.Application/ExchangeRateUpdater.Application.csproj deleted file mode 100644 index 1e8e4b5b3a..0000000000 --- a/Task/ExchangeRateUpdater.Application/ExchangeRateUpdater.Application.csproj +++ /dev/null @@ -1,24 +0,0 @@ - - - - net9.0 - enable - enable - ExchangeRateUpdater.Application - - - - - - - - all - runtime; build; native; contentfiles; analyzers; buildtransitive - - - - - - - - diff --git a/Task/ExchangeRateUpdater.Application/ExchangeRates/Dtos/ExchangeRateApiDto.cs b/Task/ExchangeRateUpdater.Application/ExchangeRates/Dtos/ExchangeRateApiDto.cs deleted file mode 100644 index 6c9ea9cbe5..0000000000 --- a/Task/ExchangeRateUpdater.Application/ExchangeRates/Dtos/ExchangeRateApiDto.cs +++ /dev/null @@ -1,17 +0,0 @@ -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/Task/ExchangeRateUpdater.Application/ExchangeRates/Dtos/ExchangeRateApiResponse.cs b/Task/ExchangeRateUpdater.Application/ExchangeRates/Dtos/ExchangeRateApiResponse.cs deleted file mode 100644 index 77512e9d82..0000000000 --- a/Task/ExchangeRateUpdater.Application/ExchangeRates/Dtos/ExchangeRateApiResponse.cs +++ /dev/null @@ -1,6 +0,0 @@ -namespace ExchangeRateUpdater.Application.ExchangeRates.Dtos; - -public class ExchangeRateApiResponse -{ - public ExchangeRateApiDto[] Rates { get; set; } -} \ No newline at end of file diff --git a/Task/ExchangeRateUpdater.Application/ExchangeRates/Dtos/ExchangeRateDto.cs b/Task/ExchangeRateUpdater.Application/ExchangeRates/Dtos/ExchangeRateDto.cs deleted file mode 100644 index f16a509bee..0000000000 --- a/Task/ExchangeRateUpdater.Application/ExchangeRates/Dtos/ExchangeRateDto.cs +++ /dev/null @@ -1,21 +0,0 @@ -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/Task/ExchangeRateUpdater.Application/ExchangeRates/Query/GetExchangeRatesDaily/GetExchangesRatesByDateQuery.cs b/Task/ExchangeRateUpdater.Application/ExchangeRates/Query/GetExchangeRatesDaily/GetExchangesRatesByDateQuery.cs deleted file mode 100644 index f4ff6ed5dc..0000000000 --- a/Task/ExchangeRateUpdater.Application/ExchangeRates/Query/GetExchangeRatesDaily/GetExchangesRatesByDateQuery.cs +++ /dev/null @@ -1,24 +0,0 @@ -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/Task/ExchangeRateUpdater.Application/ExchangeRates/Query/GetExchangeRatesDaily/GetExchangesRatesByDateQueryHandler.cs b/Task/ExchangeRateUpdater.Application/ExchangeRates/Query/GetExchangeRatesDaily/GetExchangesRatesByDateQueryHandler.cs deleted file mode 100644 index f55473926b..0000000000 --- a/Task/ExchangeRateUpdater.Application/ExchangeRates/Query/GetExchangeRatesDaily/GetExchangesRatesByDateQueryHandler.cs +++ /dev/null @@ -1,51 +0,0 @@ -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/Task/ExchangeRateUpdater.Application/ExchangeRates/Query/GetExchangeRatesDaily/GetExchangesRatesByDateQueryValidator.cs b/Task/ExchangeRateUpdater.Application/ExchangeRates/Query/GetExchangeRatesDaily/GetExchangesRatesByDateQueryValidator.cs deleted file mode 100644 index 7c1fd98ae2..0000000000 --- a/Task/ExchangeRateUpdater.Application/ExchangeRates/Query/GetExchangeRatesDaily/GetExchangesRatesByDateQueryValidator.cs +++ /dev/null @@ -1,17 +0,0 @@ -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/Task/ExchangeRateUpdater.Application/ServiceCollectionExtensions.cs b/Task/ExchangeRateUpdater.Application/ServiceCollectionExtensions.cs deleted file mode 100644 index 15a3bd4a72..0000000000 --- a/Task/ExchangeRateUpdater.Application/ServiceCollectionExtensions.cs +++ /dev/null @@ -1,22 +0,0 @@ -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/Task/ExchangeRateUpdater.Domain/Common/Ensure.cs b/Task/ExchangeRateUpdater.Domain/Common/Ensure.cs deleted file mode 100644 index 1ea5f94bd7..0000000000 --- a/Task/ExchangeRateUpdater.Domain/Common/Ensure.cs +++ /dev/null @@ -1,208 +0,0 @@ -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/Task/ExchangeRateUpdater.Domain/Entities/ExchangeRate.cs b/Task/ExchangeRateUpdater.Domain/Entities/ExchangeRate.cs deleted file mode 100644 index 1cb9be7b6e..0000000000 --- a/Task/ExchangeRateUpdater.Domain/Entities/ExchangeRate.cs +++ /dev/null @@ -1,17 +0,0 @@ -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/Task/ExchangeRateUpdater.Domain/Enums/Language.cs b/Task/ExchangeRateUpdater.Domain/Enums/Language.cs deleted file mode 100644 index a0a067290a..0000000000 --- a/Task/ExchangeRateUpdater.Domain/Enums/Language.cs +++ /dev/null @@ -1,7 +0,0 @@ -namespace ExchangeRateUpdater.Domain.Enums; - -public enum Language -{ - CZ, - EN -} \ No newline at end of file diff --git a/Task/ExchangeRateUpdater.Domain/ExchangeRateUpdater.Domain.csproj b/Task/ExchangeRateUpdater.Domain/ExchangeRateUpdater.Domain.csproj deleted file mode 100644 index 7a57e2526b..0000000000 --- a/Task/ExchangeRateUpdater.Domain/ExchangeRateUpdater.Domain.csproj +++ /dev/null @@ -1,10 +0,0 @@ - - - - net9.0 - enable - enable - ExchangeRateUpdater.Domain - - - diff --git a/Task/ExchangeRateUpdater.Domain/Repositories/ICacheRepository.cs b/Task/ExchangeRateUpdater.Domain/Repositories/ICacheRepository.cs deleted file mode 100644 index 854c3a4bf7..0000000000 --- a/Task/ExchangeRateUpdater.Domain/Repositories/ICacheRepository.cs +++ /dev/null @@ -1,21 +0,0 @@ -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/Task/ExchangeRateUpdater.Domain/ValueObjects/Currency.cs b/Task/ExchangeRateUpdater.Domain/ValueObjects/Currency.cs deleted file mode 100644 index 62b9fb4fcc..0000000000 --- a/Task/ExchangeRateUpdater.Domain/ValueObjects/Currency.cs +++ /dev/null @@ -1,18 +0,0 @@ -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/Task/ExchangeRateUpdater.Infrastructure/ApiClients/ExchangeRateApiClient.cs b/Task/ExchangeRateUpdater.Infrastructure/ApiClients/ExchangeRateApiClient.cs deleted file mode 100644 index 8ff6f98c57..0000000000 --- a/Task/ExchangeRateUpdater.Infrastructure/ApiClients/ExchangeRateApiClient.cs +++ /dev/null @@ -1,68 +0,0 @@ -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/Task/ExchangeRateUpdater.Infrastructure/Cache/RedisCacheRepository.cs b/Task/ExchangeRateUpdater.Infrastructure/Cache/RedisCacheRepository.cs deleted file mode 100644 index 4e72745eb5..0000000000 --- a/Task/ExchangeRateUpdater.Infrastructure/Cache/RedisCacheRepository.cs +++ /dev/null @@ -1,142 +0,0 @@ -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/Task/ExchangeRateUpdater.Infrastructure/Configurations/ExchangeRateApiClientConfig.cs b/Task/ExchangeRateUpdater.Infrastructure/Configurations/ExchangeRateApiClientConfig.cs deleted file mode 100644 index f94db90890..0000000000 --- a/Task/ExchangeRateUpdater.Infrastructure/Configurations/ExchangeRateApiClientConfig.cs +++ /dev/null @@ -1,6 +0,0 @@ -namespace ExchangeRateUpdater.Infrastructure.Configurations; - -public class ExchangeRateApiClientConfig -{ - public string BaseUrl { get; set; } -} \ No newline at end of file diff --git a/Task/ExchangeRateUpdater.Infrastructure/Data/CacheRepository.cs b/Task/ExchangeRateUpdater.Infrastructure/Data/CacheRepository.cs deleted file mode 100644 index 6649d61c88..0000000000 --- a/Task/ExchangeRateUpdater.Infrastructure/Data/CacheRepository.cs +++ /dev/null @@ -1,72 +0,0 @@ -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/Task/ExchangeRateUpdater.Infrastructure/ExchangeRateUpdater.Infrastructure.csproj b/Task/ExchangeRateUpdater.Infrastructure/ExchangeRateUpdater.Infrastructure.csproj deleted file mode 100644 index 54c5d5f49b..0000000000 --- a/Task/ExchangeRateUpdater.Infrastructure/ExchangeRateUpdater.Infrastructure.csproj +++ /dev/null @@ -1,24 +0,0 @@ - - - - net9.0 - enable - enable - ExchangeRateUpdater.Infrastructure - - - - - - - - - - - - - - - - - diff --git a/Task/ExchangeRateUpdater.Infrastructure/ServiceCollectionExtensions.cs b/Task/ExchangeRateUpdater.Infrastructure/ServiceCollectionExtensions.cs deleted file mode 100644 index 1d583b55b7..0000000000 --- a/Task/ExchangeRateUpdater.Infrastructure/ServiceCollectionExtensions.cs +++ /dev/null @@ -1,45 +0,0 @@ -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/Task/ExchangeRateUpdater.UnitTests/ExchangeRateUpdater.UnitTests.csproj b/Task/ExchangeRateUpdater.UnitTests/ExchangeRateUpdater.UnitTests.csproj deleted file mode 100644 index 77222bf306..0000000000 --- a/Task/ExchangeRateUpdater.UnitTests/ExchangeRateUpdater.UnitTests.csproj +++ /dev/null @@ -1,37 +0,0 @@ - - - - net9.0 - enable - enable - false - UnitTests - - - - - - - - - - - - - - - - - - - - - - - - - ..\..\..\..\..\..\..\.dotnet\shared\Microsoft.AspNetCore.App\9.0.7\Microsoft.Extensions.Logging.Abstractions.dll - - - - diff --git a/Task/ExchangeRateUpdater.UnitTests/Fakers/FakeCacheRepository.cs b/Task/ExchangeRateUpdater.UnitTests/Fakers/FakeCacheRepository.cs deleted file mode 100644 index eb443b699d..0000000000 --- a/Task/ExchangeRateUpdater.UnitTests/Fakers/FakeCacheRepository.cs +++ /dev/null @@ -1,55 +0,0 @@ -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/Task/ExchangeRateUpdater.UnitTests/Fakers/FakeExchangeRateApiClient.cs b/Task/ExchangeRateUpdater.UnitTests/Fakers/FakeExchangeRateApiClient.cs deleted file mode 100644 index 7f446ac47d..0000000000 --- a/Task/ExchangeRateUpdater.UnitTests/Fakers/FakeExchangeRateApiClient.cs +++ /dev/null @@ -1,40 +0,0 @@ -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/Task/ExchangeRateUpdater.UnitTests/Handlers/GetExchangesRatesByDateQueryHandlerTests.cs b/Task/ExchangeRateUpdater.UnitTests/Handlers/GetExchangesRatesByDateQueryHandlerTests.cs deleted file mode 100644 index e44a50775a..0000000000 --- a/Task/ExchangeRateUpdater.UnitTests/Handlers/GetExchangesRatesByDateQueryHandlerTests.cs +++ /dev/null @@ -1,88 +0,0 @@ -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/Task/ExchangeRateUpdater.UnitTests/Mapping/ExchangeRateMappingExtensionsTests.cs b/Task/ExchangeRateUpdater.UnitTests/Mapping/ExchangeRateMappingExtensionsTests.cs deleted file mode 100644 index 6e087f8214..0000000000 --- a/Task/ExchangeRateUpdater.UnitTests/Mapping/ExchangeRateMappingExtensionsTests.cs +++ /dev/null @@ -1,54 +0,0 @@ -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/Task/ExchangeRateUpdater.UnitTests/Services/CzYearProcessorTests.cs b/Task/ExchangeRateUpdater.UnitTests/Services/CzYearProcessorTests.cs deleted file mode 100644 index 9d5fb22cd9..0000000000 --- a/Task/ExchangeRateUpdater.UnitTests/Services/CzYearProcessorTests.cs +++ /dev/null @@ -1,87 +0,0 @@ -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/Task/ExchangeRateUpdater.UnitTests/Services/PerDayProcessorTests.cs b/Task/ExchangeRateUpdater.UnitTests/Services/PerDayProcessorTests.cs deleted file mode 100644 index a26679a081..0000000000 --- a/Task/ExchangeRateUpdater.UnitTests/Services/PerDayProcessorTests.cs +++ /dev/null @@ -1,86 +0,0 @@ -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/Task/ExchangeRateUpdater.UnitTests/Validation/GetExchangesRatesByDateQueryValidatorTests.cs b/Task/ExchangeRateUpdater.UnitTests/Validation/GetExchangesRatesByDateQueryValidatorTests.cs deleted file mode 100644 index bb3bc877c3..0000000000 --- a/Task/ExchangeRateUpdater.UnitTests/Validation/GetExchangesRatesByDateQueryValidatorTests.cs +++ /dev/null @@ -1,37 +0,0 @@ -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/Task/ExchangeRateUpdater.Worker/Dockerfile b/Task/ExchangeRateUpdater.Worker/Dockerfile deleted file mode 100644 index 2b8599ac9c..0000000000 --- a/Task/ExchangeRateUpdater.Worker/Dockerfile +++ /dev/null @@ -1,32 +0,0 @@ -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/Task/ExchangeRateUpdater.Worker/ExchangeRateUpdater.Worker.csproj b/Task/ExchangeRateUpdater.Worker/ExchangeRateUpdater.Worker.csproj deleted file mode 100644 index 5892573029..0000000000 --- a/Task/ExchangeRateUpdater.Worker/ExchangeRateUpdater.Worker.csproj +++ /dev/null @@ -1,37 +0,0 @@ - - - - net9.0 - enable - enable - - - - - - - - - - - - - - - - - - PreserveNewest - - - PreserveNewest - - - - - - appsettings.json - - - - diff --git a/Task/ExchangeRateUpdater.Worker/Jobs/DailyExchangeRatesRefreshJob.cs b/Task/ExchangeRateUpdater.Worker/Jobs/DailyExchangeRatesRefreshJob.cs deleted file mode 100644 index 1a7292149d..0000000000 --- a/Task/ExchangeRateUpdater.Worker/Jobs/DailyExchangeRatesRefreshJob.cs +++ /dev/null @@ -1,63 +0,0 @@ -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/Task/ExchangeRateUpdater.Worker/Jobs/ExchangeRatesBackfillJob.cs b/Task/ExchangeRateUpdater.Worker/Jobs/ExchangeRatesBackfillJob.cs deleted file mode 100644 index cb36ca6de3..0000000000 --- a/Task/ExchangeRateUpdater.Worker/Jobs/ExchangeRatesBackfillJob.cs +++ /dev/null @@ -1,194 +0,0 @@ -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/Task/ExchangeRateUpdater.Worker/Program.cs b/Task/ExchangeRateUpdater.Worker/Program.cs deleted file mode 100644 index 51abb01d11..0000000000 --- a/Task/ExchangeRateUpdater.Worker/Program.cs +++ /dev/null @@ -1,38 +0,0 @@ -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/Task/ExchangeRateUpdater.Worker/ServiceCollectionExtensions.cs b/Task/ExchangeRateUpdater.Worker/ServiceCollectionExtensions.cs deleted file mode 100644 index f54894960b..0000000000 --- a/Task/ExchangeRateUpdater.Worker/ServiceCollectionExtensions.cs +++ /dev/null @@ -1,14 +0,0 @@ -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/Task/ExchangeRateUpdater.Worker/Services/CzYearProcessor.cs b/Task/ExchangeRateUpdater.Worker/Services/CzYearProcessor.cs deleted file mode 100644 index bc013eefc0..0000000000 --- a/Task/ExchangeRateUpdater.Worker/Services/CzYearProcessor.cs +++ /dev/null @@ -1,76 +0,0 @@ -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/Task/ExchangeRateUpdater.Worker/Services/PerDayProcessor.cs b/Task/ExchangeRateUpdater.Worker/Services/PerDayProcessor.cs deleted file mode 100644 index 6779d737f4..0000000000 --- a/Task/ExchangeRateUpdater.Worker/Services/PerDayProcessor.cs +++ /dev/null @@ -1,61 +0,0 @@ -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/Task/ExchangeRateUpdater.Worker/appsettings.Development.json b/Task/ExchangeRateUpdater.Worker/appsettings.Development.json deleted file mode 100644 index cb2f1037e9..0000000000 --- a/Task/ExchangeRateUpdater.Worker/appsettings.Development.json +++ /dev/null @@ -1,15 +0,0 @@ -{ - "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/Task/ExchangeRateUpdater.Worker/appsettings.json b/Task/ExchangeRateUpdater.Worker/appsettings.json deleted file mode 100644 index 314dfd1322..0000000000 --- a/Task/ExchangeRateUpdater.Worker/appsettings.json +++ /dev/null @@ -1,15 +0,0 @@ -{ - "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/Task/ExchangeRateUpdater.sln b/Task/ExchangeRateUpdater.sln deleted file mode 100644 index 5e02b3ce21..0000000000 --- a/Task/ExchangeRateUpdater.sln +++ /dev/null @@ -1,81 +0,0 @@ - -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.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 - Debug|Any CPU = Debug|Any CPU - Release|Any CPU = Release|Any CPU - EndGlobalSection - GlobalSection(ProjectConfigurationPlatforms) = postSolution - {7B2695D6-D24C-4460-A58E-A10F08550CE0}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {7B2695D6-D24C-4460-A58E-A10F08550CE0}.Debug|Any CPU.Build.0 = Debug|Any CPU - {7B2695D6-D24C-4460-A58E-A10F08550CE0}.Release|Any CPU.ActiveCfg = Release|Any CPU - {7B2695D6-D24C-4460-A58E-A10F08550CE0}.Release|Any CPU.Build.0 = Release|Any CPU - {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/Task/ExhangeRateUpdater/ExchangeRateUpdater.csproj b/Task/ExhangeRateUpdater/ExchangeRateUpdater.csproj deleted file mode 100644 index a1af9ef45f..0000000000 --- a/Task/ExhangeRateUpdater/ExchangeRateUpdater.csproj +++ /dev/null @@ -1,30 +0,0 @@ - - - - Exe - net9.0 - enable - enable - - - - - - - - - - - - - - - - - - - PreserveNewest - - - - \ No newline at end of file diff --git a/Task/ExhangeRateUpdater/Program.cs b/Task/ExhangeRateUpdater/Program.cs deleted file mode 100644 index 62db6903fd..0000000000 --- a/Task/ExhangeRateUpdater/Program.cs +++ /dev/null @@ -1,59 +0,0 @@ -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/Task/ExhangeRateUpdater/appsettings.json b/Task/ExhangeRateUpdater/appsettings.json deleted file mode 100644 index 7e15d068c2..0000000000 --- a/Task/ExhangeRateUpdater/appsettings.json +++ /dev/null @@ -1,12 +0,0 @@ -{ - "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/Task/Readme.md b/Task/Readme.md deleted file mode 100644 index acf2692256..0000000000 --- a/Task/Readme.md +++ /dev/null @@ -1,177 +0,0 @@ -# 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/Task/docker-compose.yml b/Task/docker-compose.yml deleted file mode 100644 index b6e9ba9bc8..0000000000 --- a/Task/docker-compose.yml +++ /dev/null @@ -1,63 +0,0 @@ -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