From f538591a02acd3930743c054c6d3cb01fd866ef1 Mon Sep 17 00:00:00 2001 From: AndreKasper Date: Thu, 25 Sep 2025 01:35:49 +0200 Subject: [PATCH 01/10] Take-home assignment for ExchangeRateProvider --- jobs/Backend/.gitignore | 11 ++ jobs/Backend/Task/Currency.cs | 20 --- jobs/Backend/Task/ExchangeRate.cs | 23 --- jobs/Backend/Task/ExchangeRateProvider.cs | 19 -- .../Controllers/ExchangeRatesController.cs | 118 +++++++++++++ .../ExchangeRateUpdater.Api.csproj | 19 ++ .../ExchangeRateUpdater.Api.http | 2 + .../Extensions/ExchangeRateExtensions.cs | 25 +++ .../Models/ApiResponse.cs | 12 ++ .../Models/ExchangeRateDto.cs | 17 ++ .../Task/ExchangeRateUpdater.Api/Program.cs | 51 ++++++ .../Properties/launchSettings.json | 14 ++ .../Services/ApiExchangeRateCache.cs | 99 +++++++++++ .../appsettings.Development.json | 13 ++ .../ExchangeRateUpdater.Api/appsettings.json | 26 +++ .../ExchangeRateUpdater.Console.csproj | 23 +++ .../ExchangeRateUpdater.Console/Program.cs | 164 ++++++++++++++++++ .../Services/NoOpExchangeRateCache.cs | 27 +++ .../appsettings.json | 22 +++ .../Common/ExchangeRateProviderException.cs | 10 ++ .../ExchangeRateUpdater.Core/Common/Maybe.cs | 66 +++++++ .../Configuration/CacheSettings.cs | 8 + .../Configuration/CzechNationalBankOptions.cs | 15 ++ .../Configuration/ExchangeRateOptions.cs | 11 ++ .../CoreServiceConfiguration.cs | 51 ++++++ .../ExchangeRateUpdater.Core.csproj | 22 +++ .../Extensions/AsReadonlyExtensions.cs | 25 +++ .../Extensions/MaybeExtensions.cs | 10 ++ .../Extensions/TaskExtensions.cs | 6 + .../Interfaces/IExchangeRateCache.cs | 23 +++ .../Interfaces/IExchangeRateProvider.cs | 25 +++ .../Interfaces/IExchangeRateService.cs | 7 + .../Models/CnbApiResponse.cs | 36 ++++ .../Models/Currency.cs | 25 +++ .../Models/ExchangeRate.cs | 16 ++ .../Providers/CzechNationalBankProvider.cs | 140 +++++++++++++++ .../Services/ExchangeRateService.cs | 100 +++++++++++ .../Api/ApiExchangeRateCacheTests.cs | 150 ++++++++++++++++ .../Api/ExchangeRatesControllerTests.cs | 64 +++++++ .../Core/CurrencyTests.cs | 34 ++++ .../Core/ExchangeRateServiceTests.cs | 106 +++++++++++ .../Core/MaybeTests.cs | 72 ++++++++ .../ExchangeRateUpdater.Tests.csproj | 33 ++++ .../Integration/WeekendHolidayCachingTests.cs | 138 +++++++++++++++ jobs/Backend/Task/ExchangeRateUpdater.csproj | 8 - jobs/Backend/Task/ExchangeRateUpdater.sln | 68 +++++++- jobs/Backend/Task/Program.cs | 43 ----- jobs/Backend/Task/README.md | 144 +++++++++++++++ 48 files changed, 2041 insertions(+), 120 deletions(-) create mode 100644 jobs/Backend/.gitignore delete mode 100644 jobs/Backend/Task/Currency.cs delete mode 100644 jobs/Backend/Task/ExchangeRate.cs delete mode 100644 jobs/Backend/Task/ExchangeRateProvider.cs create mode 100644 jobs/Backend/Task/ExchangeRateUpdater.Api/Controllers/ExchangeRatesController.cs create mode 100644 jobs/Backend/Task/ExchangeRateUpdater.Api/ExchangeRateUpdater.Api.csproj create mode 100644 jobs/Backend/Task/ExchangeRateUpdater.Api/ExchangeRateUpdater.Api.http create mode 100644 jobs/Backend/Task/ExchangeRateUpdater.Api/Extensions/ExchangeRateExtensions.cs create mode 100644 jobs/Backend/Task/ExchangeRateUpdater.Api/Models/ApiResponse.cs create mode 100644 jobs/Backend/Task/ExchangeRateUpdater.Api/Models/ExchangeRateDto.cs create mode 100644 jobs/Backend/Task/ExchangeRateUpdater.Api/Program.cs create mode 100644 jobs/Backend/Task/ExchangeRateUpdater.Api/Properties/launchSettings.json create mode 100644 jobs/Backend/Task/ExchangeRateUpdater.Api/Services/ApiExchangeRateCache.cs create mode 100644 jobs/Backend/Task/ExchangeRateUpdater.Api/appsettings.Development.json create mode 100644 jobs/Backend/Task/ExchangeRateUpdater.Api/appsettings.json create mode 100644 jobs/Backend/Task/ExchangeRateUpdater.Console/ExchangeRateUpdater.Console.csproj create mode 100644 jobs/Backend/Task/ExchangeRateUpdater.Console/Program.cs create mode 100644 jobs/Backend/Task/ExchangeRateUpdater.Console/Services/NoOpExchangeRateCache.cs create mode 100644 jobs/Backend/Task/ExchangeRateUpdater.Console/appsettings.json create mode 100644 jobs/Backend/Task/ExchangeRateUpdater.Core/Common/ExchangeRateProviderException.cs create mode 100644 jobs/Backend/Task/ExchangeRateUpdater.Core/Common/Maybe.cs create mode 100644 jobs/Backend/Task/ExchangeRateUpdater.Core/Configuration/CacheSettings.cs create mode 100644 jobs/Backend/Task/ExchangeRateUpdater.Core/Configuration/CzechNationalBankOptions.cs create mode 100644 jobs/Backend/Task/ExchangeRateUpdater.Core/Configuration/ExchangeRateOptions.cs create mode 100644 jobs/Backend/Task/ExchangeRateUpdater.Core/CoreServiceConfiguration.cs create mode 100644 jobs/Backend/Task/ExchangeRateUpdater.Core/ExchangeRateUpdater.Core.csproj create mode 100644 jobs/Backend/Task/ExchangeRateUpdater.Core/Extensions/AsReadonlyExtensions.cs create mode 100644 jobs/Backend/Task/ExchangeRateUpdater.Core/Extensions/MaybeExtensions.cs create mode 100644 jobs/Backend/Task/ExchangeRateUpdater.Core/Extensions/TaskExtensions.cs create mode 100644 jobs/Backend/Task/ExchangeRateUpdater.Core/Interfaces/IExchangeRateCache.cs create mode 100644 jobs/Backend/Task/ExchangeRateUpdater.Core/Interfaces/IExchangeRateProvider.cs create mode 100644 jobs/Backend/Task/ExchangeRateUpdater.Core/Interfaces/IExchangeRateService.cs create mode 100644 jobs/Backend/Task/ExchangeRateUpdater.Core/Models/CnbApiResponse.cs create mode 100644 jobs/Backend/Task/ExchangeRateUpdater.Core/Models/Currency.cs create mode 100644 jobs/Backend/Task/ExchangeRateUpdater.Core/Models/ExchangeRate.cs create mode 100644 jobs/Backend/Task/ExchangeRateUpdater.Core/Providers/CzechNationalBankProvider.cs create mode 100644 jobs/Backend/Task/ExchangeRateUpdater.Core/Services/ExchangeRateService.cs create mode 100644 jobs/Backend/Task/ExchangeRateUpdater.Tests/Api/ApiExchangeRateCacheTests.cs create mode 100644 jobs/Backend/Task/ExchangeRateUpdater.Tests/Api/ExchangeRatesControllerTests.cs create mode 100644 jobs/Backend/Task/ExchangeRateUpdater.Tests/Core/CurrencyTests.cs create mode 100644 jobs/Backend/Task/ExchangeRateUpdater.Tests/Core/ExchangeRateServiceTests.cs create mode 100644 jobs/Backend/Task/ExchangeRateUpdater.Tests/Core/MaybeTests.cs create mode 100644 jobs/Backend/Task/ExchangeRateUpdater.Tests/ExchangeRateUpdater.Tests.csproj create mode 100644 jobs/Backend/Task/ExchangeRateUpdater.Tests/Integration/WeekendHolidayCachingTests.cs delete mode 100644 jobs/Backend/Task/ExchangeRateUpdater.csproj delete mode 100644 jobs/Backend/Task/Program.cs create mode 100644 jobs/Backend/Task/README.md diff --git a/jobs/Backend/.gitignore b/jobs/Backend/.gitignore new file mode 100644 index 0000000000..7a34e8f84f --- /dev/null +++ b/jobs/Backend/.gitignore @@ -0,0 +1,11 @@ +/.vs +/.vscode +/Task/.vs +/jobs/Backend/Task/.vscode +/jobs/Backend/Task/.vs +/jobs/Backend/Task/bin +/jobs/Backend/Task/obj +/jobs/Backend/Task/Properties +/jobs/Backend/Task/appsettings.json +/jobs/Backend/Task/appsettings.Development.json +/jobs/Backend/Task/appsettings.Production.json \ No newline at end of file diff --git a/jobs/Backend/Task/Currency.cs b/jobs/Backend/Task/Currency.cs deleted file mode 100644 index f375776f25..0000000000 --- a/jobs/Backend/Task/Currency.cs +++ /dev/null @@ -1,20 +0,0 @@ -namespace ExchangeRateUpdater -{ - public class Currency - { - public Currency(string code) - { - Code = code; - } - - /// - /// Three-letter ISO 4217 code of the currency. - /// - public string Code { get; } - - public override string ToString() - { - return Code; - } - } -} diff --git a/jobs/Backend/Task/ExchangeRate.cs b/jobs/Backend/Task/ExchangeRate.cs deleted file mode 100644 index 58c5bb10e0..0000000000 --- a/jobs/Backend/Task/ExchangeRate.cs +++ /dev/null @@ -1,23 +0,0 @@ -namespace ExchangeRateUpdater -{ - public class ExchangeRate - { - public ExchangeRate(Currency sourceCurrency, Currency targetCurrency, decimal value) - { - SourceCurrency = sourceCurrency; - TargetCurrency = targetCurrency; - Value = value; - } - - public Currency SourceCurrency { get; } - - public Currency TargetCurrency { get; } - - public decimal Value { get; } - - public override string ToString() - { - return $"{SourceCurrency}/{TargetCurrency}={Value}"; - } - } -} diff --git a/jobs/Backend/Task/ExchangeRateProvider.cs b/jobs/Backend/Task/ExchangeRateProvider.cs deleted file mode 100644 index 6f82a97fbe..0000000000 --- a/jobs/Backend/Task/ExchangeRateProvider.cs +++ /dev/null @@ -1,19 +0,0 @@ -using System.Collections.Generic; -using System.Linq; - -namespace ExchangeRateUpdater -{ - public class ExchangeRateProvider - { - /// - /// Should return exchange rates among the specified currencies that are defined by the source. But only those defined - /// by the source, do not return calculated exchange rates. E.g. if the source contains "CZK/USD" but not "USD/CZK", - /// do not return exchange rate "USD/CZK" with value calculated as 1 / "CZK/USD". If the source does not provide - /// some of the currencies, ignore them. - /// - public IEnumerable GetExchangeRates(IEnumerable currencies) - { - return Enumerable.Empty(); - } - } -} diff --git a/jobs/Backend/Task/ExchangeRateUpdater.Api/Controllers/ExchangeRatesController.cs b/jobs/Backend/Task/ExchangeRateUpdater.Api/Controllers/ExchangeRatesController.cs new file mode 100644 index 0000000000..1c9c268ded --- /dev/null +++ b/jobs/Backend/Task/ExchangeRateUpdater.Api/Controllers/ExchangeRatesController.cs @@ -0,0 +1,118 @@ +using Microsoft.AspNetCore.Mvc; +using ExchangeRateUpdater.Api.Extensions; +using ExchangeRateUpdater.Api.Models; +using ExchangeRateUpdater.Core.Models; +using ExchangeRateUpdater.Core.Extensions; + +namespace ExchangeRateUpdater.Api.Controllers; + +[ApiController] +[Route("api/[controller]")] +[Produces("application/json")] +public class ExchangeRatesController : ControllerBase +{ + private readonly IExchangeRateService _exchangeRateService; + private readonly ILogger _logger; + + public ExchangeRatesController(IExchangeRateService exchangeRateService, ILogger logger) + { + _exchangeRateService = exchangeRateService ?? throw new ArgumentNullException(nameof(exchangeRateService)); + _logger = logger ?? throw new ArgumentNullException(nameof(logger)); + } + + /// + /// Get exchange rates for specified currencies + /// + /// Comma-separated list of currency codes (e.g., USD,EUR,JPY) + /// Optional date in YYYY-MM-DD format. Defaults to today. + /// Exchange rates for the specified currencies + /// Returns the exchange rates + /// If the request is invalid + /// If no exchange rates found for the specified currencies + [HttpGet] + [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status200OK)] + [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status400BadRequest)] + [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status404NotFound)] + public async Task>> GetExchangeRates( + [FromQuery] string currencies, + [FromQuery] string? date = null) + { + try + { + var dateValidationResult = ValidateAndParseDate(date); + if (dateValidationResult.HasError) + { + return BadRequest(dateValidationResult.ErrorResponse); + } + + var currencyCodes = currencies.Split(',', StringSplitOptions.RemoveEmptyEntries).ToHashSet(); + if (!TryCreateCurrencyObjects(currencyCodes, out var currencyObjects)) + { + return BadRequest(CreateErrorResponse("At least one currency code must be provided", "At least one currency code must be provided")); + } + + var exchangeRates = await _exchangeRateService.GetExchangeRates(currencyObjects, dateValidationResult.ParsedDate.AsMaybe()); + + if (!exchangeRates.Any()) + { + var currencyList = string.Join(", ", currencyObjects.Select(c => c.Code)); + return NotFound(CreateErrorResponse( + $"No exchange rates found for the specified currencies: {currencyList}", + $"No exchange rates found for the specified currencies: {currencyList}")); + } + + var response = exchangeRates.ToExchangeRateResponse(dateValidationResult.ParsedDate ?? DateTime.Today); + + return Ok(new ApiResponse + { + Data = response, + Success = true, + Message = "Exchange rates retrieved successfully" + }); + } + catch (ArgumentException ex) + { + _logger.LogWarning("Invalid currency code provided: {Message}", ex.Message); + return BadRequest(CreateErrorResponse($"Invalid currency code: {ex.Message}", $"Invalid currency code: {ex.Message}")); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error occurred while fetching exchange rates"); + return StatusCode(StatusCodes.Status500InternalServerError, CreateErrorResponse("An error occurred while processing your request", "An error occurred while processing your request")); + } + } + + private static bool TryCreateCurrencyObjects(HashSet currencyCodes, out IEnumerable currencies) + { + currencies = new List(); + if (!currencyCodes.Any()) + return false; + + currencies = currencyCodes.Select(code => new Currency(code.Trim().ToUpperInvariant())); + return true; + } + + private static ApiResponse CreateErrorResponse(string message, string error) + { + return new ApiResponse + { + Message = message, + Errors = new List { error }, + Success = false + }; + } + + private static (bool HasError, ApiResponse? ErrorResponse, DateTime? ParsedDate) ValidateAndParseDate(string? date) + { + if (string.IsNullOrEmpty(date)) + return (false, null, null); + + if (!DateTime.TryParseExact(date, "yyyy-MM-dd", null, System.Globalization.DateTimeStyles.None, out var validDate)) + { + var errorMessage = $"Invalid date format. Expected format: YYYY-MM-DD (e.g., 2024-01-15). Received: '{date}'"; + return (true, CreateErrorResponse(errorMessage, errorMessage), null); + } + + return (false, null, validDate); + } +} diff --git a/jobs/Backend/Task/ExchangeRateUpdater.Api/ExchangeRateUpdater.Api.csproj b/jobs/Backend/Task/ExchangeRateUpdater.Api/ExchangeRateUpdater.Api.csproj new file mode 100644 index 0000000000..1b8d6da142 --- /dev/null +++ b/jobs/Backend/Task/ExchangeRateUpdater.Api/ExchangeRateUpdater.Api.csproj @@ -0,0 +1,19 @@ + + + + net9.0 + enable + enable + + + + + + + + + + + + + diff --git a/jobs/Backend/Task/ExchangeRateUpdater.Api/ExchangeRateUpdater.Api.http b/jobs/Backend/Task/ExchangeRateUpdater.Api/ExchangeRateUpdater.Api.http new file mode 100644 index 0000000000..99532cfcd2 --- /dev/null +++ b/jobs/Backend/Task/ExchangeRateUpdater.Api/ExchangeRateUpdater.Api.http @@ -0,0 +1,2 @@ +@ExchangeRateUpdater.Api_HostAddress = http://localhost:5216 +### diff --git a/jobs/Backend/Task/ExchangeRateUpdater.Api/Extensions/ExchangeRateExtensions.cs b/jobs/Backend/Task/ExchangeRateUpdater.Api/Extensions/ExchangeRateExtensions.cs new file mode 100644 index 0000000000..3145b2abf6 --- /dev/null +++ b/jobs/Backend/Task/ExchangeRateUpdater.Api/Extensions/ExchangeRateExtensions.cs @@ -0,0 +1,25 @@ +using ExchangeRateUpdater.Api.Models; +using ExchangeRateUpdater.Core.Models; + +namespace ExchangeRateUpdater.Api.Extensions; + +public static class ExchangeRateExtensions +{ + public static ExchangeRateResponse ToExchangeRateResponse( + this IEnumerable exchangeRates, + DateTime requestedDate) + { + var rateList = exchangeRates.ToList(); + + return new ExchangeRateResponse( + Rates: rateList.Select(rate => new ExchangeRateDto( + SourceCurrency: rate.SourceCurrency.Code, + TargetCurrency: rate.TargetCurrency.Code, + Value: rate.Value, + Date: rate.Date + )).ToList(), + RequestedDate: requestedDate, + TotalCount: rateList.Count + ); + } +} diff --git a/jobs/Backend/Task/ExchangeRateUpdater.Api/Models/ApiResponse.cs b/jobs/Backend/Task/ExchangeRateUpdater.Api/Models/ApiResponse.cs new file mode 100644 index 0000000000..f47de449e7 --- /dev/null +++ b/jobs/Backend/Task/ExchangeRateUpdater.Api/Models/ApiResponse.cs @@ -0,0 +1,12 @@ +public class ApiResponse +{ + public bool Success { get; set; } + public string Message { get; set; } = string.Empty; + public List Errors { get; set; } = new List(); + public DateTime Timestamp { get; set; } = DateTime.UtcNow; +} + +public class ApiResponse : ApiResponse +{ + public T? Data { get; set; } +} diff --git a/jobs/Backend/Task/ExchangeRateUpdater.Api/Models/ExchangeRateDto.cs b/jobs/Backend/Task/ExchangeRateUpdater.Api/Models/ExchangeRateDto.cs new file mode 100644 index 0000000000..be8c79c387 --- /dev/null +++ b/jobs/Backend/Task/ExchangeRateUpdater.Api/Models/ExchangeRateDto.cs @@ -0,0 +1,17 @@ +namespace ExchangeRateUpdater.Api.Models; + +public record ExchangeRateDto( + string SourceCurrency, + string TargetCurrency, + decimal Value, + DateTime Date); + +public record ExchangeRateRequest( + List Currencies, + DateTime? Date = null); + +public record ExchangeRateResponse( + List Rates, + DateTime RequestedDate, + int TotalCount); + \ No newline at end of file diff --git a/jobs/Backend/Task/ExchangeRateUpdater.Api/Program.cs b/jobs/Backend/Task/ExchangeRateUpdater.Api/Program.cs new file mode 100644 index 0000000000..60c0913da6 --- /dev/null +++ b/jobs/Backend/Task/ExchangeRateUpdater.Api/Program.cs @@ -0,0 +1,51 @@ +using ExchangeRateUpdater.Api.Services; +using ExchangeRateUpdater.Core; +using ExchangeRateUpdater.Core.Interfaces; +using Microsoft.Extensions.Caching.Memory; +using ExchangeRateUpdater.Core.Configuration; +using Microsoft.Extensions.Options; + +var builder = WebApplication.CreateBuilder(args); + +// Add configuration +builder.Configuration + .SetBasePath(Directory.GetCurrentDirectory()) + .AddJsonFile("appsettings.json", optional: false, reloadOnChange: true) + .AddJsonFile($"appsettings.{builder.Environment.EnvironmentName}.json", optional: true) + .AddEnvironmentVariables(); + +// Add services to the container +builder.Services.AddControllers(); +builder.Services.AddMemoryCache(); +builder.Services.Configure(builder.Configuration.GetSection("CacheSettings")); +builder.Services.Configure(options => +{ + var cacheSettings = builder.Services.BuildServiceProvider().GetRequiredService>().Value; + options.SizeLimit = cacheSettings.SizeLimit; + options.CompactionPercentage = cacheSettings.CompactionPercentage; + options.ExpirationScanFrequency = cacheSettings.ExpirationScanFrequency; +}); + + +builder.Services.AddExchangeRateCoreDependencies(builder.Configuration); +builder.Services.AddSingleton(); + +builder.Services.AddOpenApi(); + +var app = builder.Build(); + +// Configure the HTTP request pipeline +if (app.Environment.IsDevelopment()) +{ + app.MapOpenApi(); + app.UseHttpsRedirection(); +} + +app.UseHttpsRedirection(); +app.MapControllers(); + +app.Run(); + +public partial class Program { } + + diff --git a/jobs/Backend/Task/ExchangeRateUpdater.Api/Properties/launchSettings.json b/jobs/Backend/Task/ExchangeRateUpdater.Api/Properties/launchSettings.json new file mode 100644 index 0000000000..cf67092c4d --- /dev/null +++ b/jobs/Backend/Task/ExchangeRateUpdater.Api/Properties/launchSettings.json @@ -0,0 +1,14 @@ +{ + "$schema": "https://json.schemastore.org/launchsettings.json", + "profiles": { + "http": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": false, + "applicationUrl": "http://localhost:5216", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + } + } +} diff --git a/jobs/Backend/Task/ExchangeRateUpdater.Api/Services/ApiExchangeRateCache.cs b/jobs/Backend/Task/ExchangeRateUpdater.Api/Services/ApiExchangeRateCache.cs new file mode 100644 index 0000000000..ab8b8abc06 --- /dev/null +++ b/jobs/Backend/Task/ExchangeRateUpdater.Api/Services/ApiExchangeRateCache.cs @@ -0,0 +1,99 @@ +using Microsoft.Extensions.Caching.Memory; +using ExchangeRateUpdater.Core.Common; +using ExchangeRateUpdater.Core.Interfaces; +using ExchangeRateUpdater.Core.Models; +using PublicHoliday; +using ExchangeRateUpdater.Core.Extensions; + +namespace ExchangeRateUpdater.Api.Services; + +/// +/// Enhanced memory cache implementation for the API that caches all exchange rates per the date returned by CNB's API +/// +public class ApiExchangeRateCache : IExchangeRateCache +{ + private readonly IMemoryCache _memoryCache; + private readonly ILogger _logger; + private readonly CzechRepublicPublicHoliday _czechRepublicPublicHoliday = new(); + + public ApiExchangeRateCache(IMemoryCache memoryCache, ILogger logger) + { + _memoryCache = memoryCache ?? throw new ArgumentNullException(nameof(memoryCache)); + _logger = logger ?? throw new ArgumentNullException(nameof(logger)); + } + + public Task>> GetCachedRates(IEnumerable currencies, Maybe date) + { + if (currencies == null) + throw new ArgumentNullException(nameof(currencies)); + + var currencyList = currencies.ToList(); + if (!currencyList.Any()) + return Maybe>.Nothing.AsTask(); + + var targetDate = GetBusinessDayForCacheCheck(date); + + if (_memoryCache.TryGetValue(targetDate, out var cachedRates) && cachedRates is IEnumerable allRates) + { + var requestedCurrencyCodes = currencyList.Select(c => c.Code).ToHashSet(StringComparer.OrdinalIgnoreCase); + var filteredRates = allRates.Where(rate => requestedCurrencyCodes.Contains(rate.SourceCurrency.Code)).ToList(); + _logger.LogInformation($"Cache hit for date {targetDate:yyyy-MM-dd}, returning {filteredRates.Count} rates"); + + if (filteredRates.Any()) + { + return ((IReadOnlyList)filteredRates).AsMaybe().AsTask(); + } + } + + _logger.LogInformation($"Cache miss for date {targetDate:yyyy-MM-dd} and currencies: {string.Join(", ", currencyList.Select(c => c.Code))}"); + return Maybe>.Nothing.AsTask(); + } + + public Task CacheRates(IReadOnlyCollection rates, TimeSpan cacheExpiry) + { + if (rates == null) + throw new ArgumentNullException(nameof(rates)); + + if (!rates.Any()) + return Task.CompletedTask; + + // Cache all rates with the date returned by the CNB provider + var date = rates.First().Date.Date; + + var cacheOptions = new MemoryCacheEntryOptions + { + AbsoluteExpirationRelativeToNow = cacheExpiry, + SlidingExpiration = cacheExpiry / 2, // Refresh cache if accessed within half the expiry time + Size = rates.Count + }; + + _memoryCache.Set(date, rates, cacheOptions); + + _logger.LogInformation($"Cached {rates.Count} exchange rates for date {date:yyyy-MM-dd}, expires in {cacheExpiry.TotalMinutes} minutes"); + + return Task.CompletedTask; + } + + public Task ClearCache() + { + if (_memoryCache is MemoryCache mc) + { + mc.Clear(); + _logger.LogInformation("Cleared all cached entries"); + } + return Task.CompletedTask; + } + + private static DateTime GetBusinessDayForCacheCheck(Maybe date) + { + if (!date.TryGetValue(out var dateValue)) + dateValue = DateTime.Today; + + while (new CzechRepublicPublicHoliday().IsPublicHoliday(dateValue) || dateValue.DayOfWeek is DayOfWeek.Saturday or DayOfWeek.Sunday) + { + dateValue = dateValue.AddDays(-1); + } + + return dateValue; + } +} diff --git a/jobs/Backend/Task/ExchangeRateUpdater.Api/appsettings.Development.json b/jobs/Backend/Task/ExchangeRateUpdater.Api/appsettings.Development.json new file mode 100644 index 0000000000..a274bdce4c --- /dev/null +++ b/jobs/Backend/Task/ExchangeRateUpdater.Api/appsettings.Development.json @@ -0,0 +1,13 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + }, + "CacheSettings": { + "SizeLimit": 5, + "CompactionPercentage": 0.5, + "ExpirationScanFrequency": "00:01:00" + } +} diff --git a/jobs/Backend/Task/ExchangeRateUpdater.Api/appsettings.json b/jobs/Backend/Task/ExchangeRateUpdater.Api/appsettings.json new file mode 100644 index 0000000000..3b0af99d0b --- /dev/null +++ b/jobs/Backend/Task/ExchangeRateUpdater.Api/appsettings.json @@ -0,0 +1,26 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft": "Warning", + "Microsoft.Hosting.Lifetime": "Information" + } + }, + "ExchangeRate": { + "DefaultCacheExpiry": "01:00:00", + "MaxRetryAttempts": 3, + "RetryDelay": "00:00:05", + "RequestTimeout": "00:00:30", + "EnableCaching": true + }, + "CzechNationalBank": { + "BaseUrl": "https://api.cnb.cz/cnbapi/exrates/daily", + "DateFormat": "yyyy-MM-dd", + "Language": "EN" + }, + "CacheSettings": { + "SizeLimit": 1000, + "CompactionPercentage": 0.25, + "ExpirationScanFrequency": "00:05:00" + } +} \ No newline at end of file diff --git a/jobs/Backend/Task/ExchangeRateUpdater.Console/ExchangeRateUpdater.Console.csproj b/jobs/Backend/Task/ExchangeRateUpdater.Console/ExchangeRateUpdater.Console.csproj new file mode 100644 index 0000000000..3e9f4ab27a --- /dev/null +++ b/jobs/Backend/Task/ExchangeRateUpdater.Console/ExchangeRateUpdater.Console.csproj @@ -0,0 +1,23 @@ + + + + Exe + net9.0 + enable + enable + + + + + + + + + + + + + + + + diff --git a/jobs/Backend/Task/ExchangeRateUpdater.Console/Program.cs b/jobs/Backend/Task/ExchangeRateUpdater.Console/Program.cs new file mode 100644 index 0000000000..b9ff64357d --- /dev/null +++ b/jobs/Backend/Task/ExchangeRateUpdater.Console/Program.cs @@ -0,0 +1,164 @@ +using ExchangeRateUpdater.Console.Services; +using ExchangeRateUpdater.Core; +using ExchangeRateUpdater.Core.Interfaces; +using ExchangeRateUpdater.Core.Models; +using ExchangeRateUpdater.Core.Extensions; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using System.CommandLine; +using System.Globalization; + +namespace ExchangeRateUpdater.Console; + +public static class Program +{ + private static readonly IEnumerable DefaultCurrenciesList = 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 async Task Main(string[] args) + { + // Define command line options + var dateOption = new Option( + "--date", + description: "The date to fetch exchange rates for (format: yyyy-MM-dd). Defaults to today.", + parseArgument: result => + { + if (result.Tokens.Count == 0) + return null; + + var dateString = result.Tokens.Single().Value; + if (DateTime.TryParseExact(dateString, "yyyy-MM-dd", CultureInfo.InvariantCulture, DateTimeStyles.None, out var date)) + { + return date; + } + + result.ErrorMessage = $"Invalid date format. Please use yyyy-MM-dd format (e.g., 2023-12-01)."; + return null; + }); + + var currenciesOption = new Option( + "--currencies", + description: "Comma-separated list of currency codes to fetch (e.g., USD,EUR,JPY). Defaults to predefined list.", + parseArgument: result => + { + if (result.Tokens.Count == 0) + return Array.Empty(); + + var currenciesString = result.Tokens.Single().Value; + return currenciesString.Split(',', StringSplitOptions.RemoveEmptyEntries) + .Select(c => c.Trim().ToUpperInvariant()) + .ToArray(); + }); + + // Create root command + var rootCommand = new RootCommand("Fetches exchange rates from the Czech National Bank.") + { + dateOption, + currenciesOption + }; + + rootCommand.SetHandler(async (DateTime? date, string[] currencyCodes) => + { + await RunExchangeRateUpdaterAsync(date, currencyCodes); + }, dateOption, currenciesOption); + + // Parse and execute + await rootCommand.InvokeAsync(args); + } + + private static async Task RunExchangeRateUpdaterAsync(DateTime? date, string[] currencyCodes) + { + try + { + var currenciesToFetch = GetCurrenciesToFetch(currencyCodes); + + var configuration = new ConfigurationBuilder() + .SetBasePath(Directory.GetCurrentDirectory()) + .AddJsonFile("appsettings.json", optional: false, reloadOnChange: true) + .AddEnvironmentVariables() + .Build(); + + // Add Core services + var services = new ServiceCollection(); + services.AddExchangeRateCoreDependencies(configuration); + services.AddScoped(); + + // Build service provider + var serviceProvider = services.BuildServiceProvider(); + var exchangeRateService = serviceProvider.GetRequiredService(); + + System.Console.WriteLine("=== Exchange Rate Updater ==="); + System.Console.WriteLine(); + + var rates = await exchangeRateService.GetExchangeRates(currenciesToFetch, date.AsMaybe()); + + var rateList = rates.ToList(); + if (rateList.Any()) + { + System.Console.WriteLine($"Successfully retrieved {rateList.Count} exchange rates:"); + System.Console.WriteLine(); + + foreach (var rate in rateList) + { + System.Console.WriteLine($" {rate}"); + } + } + else + { + System.Console.WriteLine("No exchange rates were found for the requested currencies."); + } + + System.Console.WriteLine(); + System.Console.WriteLine("Press any key to exit..."); + System.Console.ReadKey(); + } + catch (Exception ex) + { + System.Console.WriteLine($"Error: {ex.Message}"); + if (ex.InnerException != null) + { + System.Console.WriteLine($"Inner Exception: {ex.InnerException.Message}"); + } + System.Console.WriteLine(); + System.Console.WriteLine("Press any key to exit..."); + System.Console.ReadKey(); + } + } + + private static IEnumerable GetCurrenciesToFetch(string[] currencyCodes) + { + if (currencyCodes != null && currencyCodes.Length > 0) + { + var currencies = new List(); + foreach (var code in currencyCodes) + { + try + { + currencies.Add(new Currency(code)); + } + catch (ArgumentException ex) + { + System.Console.WriteLine($"Warning: Invalid currency code '{code}' - {ex.Message}"); + } + } + + if (currencies.Any()) + { + return currencies; + } + } + + System.Console.WriteLine("Warning: No valid currencies provided, using default set."); + return DefaultCurrenciesList; + } +} diff --git a/jobs/Backend/Task/ExchangeRateUpdater.Console/Services/NoOpExchangeRateCache.cs b/jobs/Backend/Task/ExchangeRateUpdater.Console/Services/NoOpExchangeRateCache.cs new file mode 100644 index 0000000000..5758e0e6f6 --- /dev/null +++ b/jobs/Backend/Task/ExchangeRateUpdater.Console/Services/NoOpExchangeRateCache.cs @@ -0,0 +1,27 @@ +using ExchangeRateUpdater.Core.Common; +using ExchangeRateUpdater.Core.Extensions; +using ExchangeRateUpdater.Core.Interfaces; +using ExchangeRateUpdater.Core.Models; + +namespace ExchangeRateUpdater.Console.Services; + +/// +/// No-operation cache implementation for console application (caching disabled) +/// +public class NoOpExchangeRateCache : IExchangeRateCache +{ + public Task>> GetCachedRates(IEnumerable currencies, Maybe date) + { + return Maybe>.Nothing.AsTask(); + } + + public Task CacheRates(IReadOnlyCollection rates, TimeSpan cacheExpiry) + { + return Task.CompletedTask; + } + + public Task ClearCache() + { + return Task.CompletedTask; + } +} diff --git a/jobs/Backend/Task/ExchangeRateUpdater.Console/appsettings.json b/jobs/Backend/Task/ExchangeRateUpdater.Console/appsettings.json new file mode 100644 index 0000000000..fbc57ac632 --- /dev/null +++ b/jobs/Backend/Task/ExchangeRateUpdater.Console/appsettings.json @@ -0,0 +1,22 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft": "Warning", + "Microsoft.Hosting.Lifetime": "Information" + } + }, + "ExchangeRate": { + "DefaultCacheExpiry": "01:00:00", + "MaxRetryAttempts": 3, + "RetryDelay": "00:00:02", + "RequestTimeout": "00:00:30", + "EnableCaching": true + }, + "CzechNationalBank": { + "BaseUrl": "https://api.cnb.cz/cnbapi/exrates/daily", + "DateFormat": "yyyy-MM-dd", + "Language": "EN" + } +} + diff --git a/jobs/Backend/Task/ExchangeRateUpdater.Core/Common/ExchangeRateProviderException.cs b/jobs/Backend/Task/ExchangeRateUpdater.Core/Common/ExchangeRateProviderException.cs new file mode 100644 index 0000000000..2bbcdc52a3 --- /dev/null +++ b/jobs/Backend/Task/ExchangeRateUpdater.Core/Common/ExchangeRateProviderException.cs @@ -0,0 +1,10 @@ +namespace ExchangeRateUpdater.Core.Common; + +/// +/// Custom exception for exchange rate service errors +/// +public class ExchangeRateProviderException : Exception +{ + public ExchangeRateProviderException(string message) : base(message) { } + public ExchangeRateProviderException(string message, Exception innerException) : base(message, innerException) { } +} diff --git a/jobs/Backend/Task/ExchangeRateUpdater.Core/Common/Maybe.cs b/jobs/Backend/Task/ExchangeRateUpdater.Core/Common/Maybe.cs new file mode 100644 index 0000000000..4f4976d27e --- /dev/null +++ b/jobs/Backend/Task/ExchangeRateUpdater.Core/Common/Maybe.cs @@ -0,0 +1,66 @@ +namespace ExchangeRateUpdater.Core.Common; + +public readonly struct Maybe +{ + private readonly T? _value; + private readonly bool _hasValue; + + private Maybe(T value) + { + _value = value; + _hasValue = true; + } + + public static Maybe Nothing + { + get => default; + } + + public static implicit operator Maybe(T? value) => value != null ? new Maybe(value) : Nothing; + + public bool HasValue => _hasValue; + + /// + /// Gets the value if it exists, otherwise throws an exception + /// + public T Value => _hasValue ? _value! : throw new InvalidOperationException("Maybe has no value"); + + + public T GetValueOrDefault(T defaultValue = default!) + { + return _hasValue ? _value! : defaultValue; + } + + public bool TryGetValue(out T value) + { + value = _hasValue ? _value! : default!; + return _hasValue; + } + + public override bool Equals(object? obj) + { + if (obj is Maybe other) + { + if (!_hasValue && !other._hasValue) + return true; + if (_hasValue && other._hasValue) + return EqualityComparer.Default.Equals(_value, other._value); + } + return false; + } + + public static bool operator ==(Maybe left, Maybe right) + { + return left.Equals(right); + } + + public static bool operator !=(Maybe left, Maybe right) + { + return !left.Equals(right); + } + + public override int GetHashCode() + { + return _hasValue ? EqualityComparer.Default.GetHashCode(_value!) : 0; + } +} \ No newline at end of file diff --git a/jobs/Backend/Task/ExchangeRateUpdater.Core/Configuration/CacheSettings.cs b/jobs/Backend/Task/ExchangeRateUpdater.Core/Configuration/CacheSettings.cs new file mode 100644 index 0000000000..2320aa94ea --- /dev/null +++ b/jobs/Backend/Task/ExchangeRateUpdater.Core/Configuration/CacheSettings.cs @@ -0,0 +1,8 @@ +namespace ExchangeRateUpdater.Core.Configuration; + +public class CacheSettings +{ + public int SizeLimit { get; set; } = 1000; + public double CompactionPercentage { get; set; } = 0.25; + public TimeSpan ExpirationScanFrequency { get; set; } = TimeSpan.FromMinutes(5); +} \ No newline at end of file diff --git a/jobs/Backend/Task/ExchangeRateUpdater.Core/Configuration/CzechNationalBankOptions.cs b/jobs/Backend/Task/ExchangeRateUpdater.Core/Configuration/CzechNationalBankOptions.cs new file mode 100644 index 0000000000..f6622c5f4f --- /dev/null +++ b/jobs/Backend/Task/ExchangeRateUpdater.Core/Configuration/CzechNationalBankOptions.cs @@ -0,0 +1,15 @@ +namespace ExchangeRateUpdater.Core.Configuration; + +public enum CnbLanguage +{ + EN, + CZ +} + +public class CzechNationalBankOptions +{ + public const string SectionName = "CzechNationalBank"; + public string BaseUrl { get; set; } = "https://api.cnb.cz/cnbapi/exrates/daily"; + public string DateFormat { get; set; } = "yyyy-MM-dd"; + public CnbLanguage Language { get; set; } = CnbLanguage.EN; +} diff --git a/jobs/Backend/Task/ExchangeRateUpdater.Core/Configuration/ExchangeRateOptions.cs b/jobs/Backend/Task/ExchangeRateUpdater.Core/Configuration/ExchangeRateOptions.cs new file mode 100644 index 0000000000..007e1a2961 --- /dev/null +++ b/jobs/Backend/Task/ExchangeRateUpdater.Core/Configuration/ExchangeRateOptions.cs @@ -0,0 +1,11 @@ +namespace ExchangeRateUpdater.Core.Configuration; + +public class ExchangeRateOptions +{ + public const string SectionName = "ExchangeRate"; + public TimeSpan DefaultCacheExpiry { get; set; } = TimeSpan.FromHours(2); + public int MaxRetryAttempts { get; set; } = 3; + public TimeSpan RetryDelay { get; set; } = TimeSpan.FromSeconds(2); + public TimeSpan RequestTimeout { get; set; } = TimeSpan.FromSeconds(30); + public bool EnableCaching { get; set; } = true; +} diff --git a/jobs/Backend/Task/ExchangeRateUpdater.Core/CoreServiceConfiguration.cs b/jobs/Backend/Task/ExchangeRateUpdater.Core/CoreServiceConfiguration.cs new file mode 100644 index 0000000000..1e2b470051 --- /dev/null +++ b/jobs/Backend/Task/ExchangeRateUpdater.Core/CoreServiceConfiguration.cs @@ -0,0 +1,51 @@ +using ExchangeRateUpdater.Core.Configuration; +using ExchangeRateUpdater.Core.Interfaces; +using ExchangeRateUpdater.Core.Providers; +using ExchangeRateUpdater.Core.Services; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Options; +using Polly; +using Polly.Extensions.Http; + +namespace ExchangeRateUpdater.Core; + +/// +/// Configuration for dependency injection in Core library +/// +public static class CoreServiceConfiguration +{ + public static IServiceCollection AddExchangeRateCoreDependencies(this IServiceCollection services, IConfiguration configuration) + { + services.Configure(configuration.GetSection(ExchangeRateOptions.SectionName)); + services.Configure(configuration.GetSection(CzechNationalBankOptions.SectionName)); + var exchangeRateOptions = services.BuildServiceProvider().GetRequiredService>().Value; + + services.AddHttpClient((serviceProvider, client) => + { + var options = serviceProvider.GetRequiredService>().Value; + client.BaseAddress = new Uri(options.BaseUrl); + client.Timeout = TimeSpan.FromSeconds(30); + }) + .AddPolicyHandler(GetRetryPolicy(exchangeRateOptions)) + .AddPolicyHandler(Policy.TimeoutAsync(exchangeRateOptions.RequestTimeout)); + + services.AddScoped(); + services.AddScoped(); + + return services; + } + + private static IAsyncPolicy GetRetryPolicy(ExchangeRateOptions options) + { + return HttpPolicyExtensions + .HandleTransientHttpError() + .WaitAndRetryAsync( + options.MaxRetryAttempts, + retryAttempt => retryAttempt * options.RetryDelay, + onRetry: (outcome, timespan, retryCount, context) => + { + Console.WriteLine($"Retry {retryCount} after {timespan.TotalMilliseconds}ms due to: {outcome.Exception?.Message ?? outcome.Result?.StatusCode.ToString()}"); + }); + } +} diff --git a/jobs/Backend/Task/ExchangeRateUpdater.Core/ExchangeRateUpdater.Core.csproj b/jobs/Backend/Task/ExchangeRateUpdater.Core/ExchangeRateUpdater.Core.csproj new file mode 100644 index 0000000000..b6665b58ac --- /dev/null +++ b/jobs/Backend/Task/ExchangeRateUpdater.Core/ExchangeRateUpdater.Core.csproj @@ -0,0 +1,22 @@ + + + + net9.0 + enable + enable + + + + + + + + + + + + + + + + diff --git a/jobs/Backend/Task/ExchangeRateUpdater.Core/Extensions/AsReadonlyExtensions.cs b/jobs/Backend/Task/ExchangeRateUpdater.Core/Extensions/AsReadonlyExtensions.cs new file mode 100644 index 0000000000..63da8dbdb4 --- /dev/null +++ b/jobs/Backend/Task/ExchangeRateUpdater.Core/Extensions/AsReadonlyExtensions.cs @@ -0,0 +1,25 @@ +using System.Diagnostics; + +namespace ExchangeRateUpdater.Core.Extensions; + +[DebuggerStepThrough] +public static class AsReadonlyExtensions +{ + public static IReadOnlyList AsReadOnlyList(this IEnumerable self) + { + return self switch + { + IReadOnlyList list => list, + _ => self.ToList() + }; + } + + public static IReadOnlyCollection AsReadOnlyCollection(this IEnumerable self) + { + return self switch + { + IReadOnlyCollection collection => collection, + _ => self.ToList() + }; + } +} \ No newline at end of file diff --git a/jobs/Backend/Task/ExchangeRateUpdater.Core/Extensions/MaybeExtensions.cs b/jobs/Backend/Task/ExchangeRateUpdater.Core/Extensions/MaybeExtensions.cs new file mode 100644 index 0000000000..7a82310f53 --- /dev/null +++ b/jobs/Backend/Task/ExchangeRateUpdater.Core/Extensions/MaybeExtensions.cs @@ -0,0 +1,10 @@ +using ExchangeRateUpdater.Core.Common; + +namespace ExchangeRateUpdater.Core.Extensions; + +public static class MaybeExtensions +{ + public static Maybe AsMaybe(this T? self) => self; + public static Maybe AsMaybe(this T? self) where T : struct => self ?? Maybe.Nothing; + public static Maybe AsMaybe(this object self) where T : class => self as T; +} diff --git a/jobs/Backend/Task/ExchangeRateUpdater.Core/Extensions/TaskExtensions.cs b/jobs/Backend/Task/ExchangeRateUpdater.Core/Extensions/TaskExtensions.cs new file mode 100644 index 0000000000..0488f090fb --- /dev/null +++ b/jobs/Backend/Task/ExchangeRateUpdater.Core/Extensions/TaskExtensions.cs @@ -0,0 +1,6 @@ +namespace ExchangeRateUpdater.Core.Extensions; + +public static class TaskExtensions +{ + public static Task AsTask(this T value) => Task.FromResult(value); +} diff --git a/jobs/Backend/Task/ExchangeRateUpdater.Core/Interfaces/IExchangeRateCache.cs b/jobs/Backend/Task/ExchangeRateUpdater.Core/Interfaces/IExchangeRateCache.cs new file mode 100644 index 0000000000..9a879ae7b3 --- /dev/null +++ b/jobs/Backend/Task/ExchangeRateUpdater.Core/Interfaces/IExchangeRateCache.cs @@ -0,0 +1,23 @@ +using ExchangeRateUpdater.Core.Common; +using ExchangeRateUpdater.Core.Models; + +namespace ExchangeRateUpdater.Core.Interfaces; + +public interface IExchangeRateCache +{ + /// + /// Gets cached exchange rates for the specified currencies + /// + /// The currencies to get cached rates for + /// Maybe containing cached exchange rates + Task>> GetCachedRates(IEnumerable currencies, Maybe date); + + /// + /// Caches exchange rates + /// + /// The rates to cache + /// How long to cache the rates for + Task CacheRates(IReadOnlyCollection rates, TimeSpan cacheExpiry); + + Task ClearCache(); +} diff --git a/jobs/Backend/Task/ExchangeRateUpdater.Core/Interfaces/IExchangeRateProvider.cs b/jobs/Backend/Task/ExchangeRateUpdater.Core/Interfaces/IExchangeRateProvider.cs new file mode 100644 index 0000000000..5bb11d293a --- /dev/null +++ b/jobs/Backend/Task/ExchangeRateUpdater.Core/Interfaces/IExchangeRateProvider.cs @@ -0,0 +1,25 @@ +using ExchangeRateUpdater.Core.Common; +using ExchangeRateUpdater.Core.Models; + +namespace ExchangeRateUpdater.Core.Interfaces; + +public interface IExchangeRateProvider +{ + /// + /// Gets exchange rates for the specified currencies for a specific date + /// + /// The currencies to get rates for + /// The date to get exchange rates for (uses today if None) + /// Maybe containing collection of exchange rates + Task>> GetExchangeRates(IEnumerable currencies, Maybe date); + + /// + /// Gets the name of this provider + /// + string ProviderName { get; } + + /// + /// Gets the base currency for this provider + /// + string BaseCurrency { get; } +} diff --git a/jobs/Backend/Task/ExchangeRateUpdater.Core/Interfaces/IExchangeRateService.cs b/jobs/Backend/Task/ExchangeRateUpdater.Core/Interfaces/IExchangeRateService.cs new file mode 100644 index 0000000000..9571274177 --- /dev/null +++ b/jobs/Backend/Task/ExchangeRateUpdater.Core/Interfaces/IExchangeRateService.cs @@ -0,0 +1,7 @@ +using ExchangeRateUpdater.Core.Common; +using ExchangeRateUpdater.Core.Models; + +public interface IExchangeRateService +{ + Task> GetExchangeRates(IEnumerable currencies, Maybe date); +} diff --git a/jobs/Backend/Task/ExchangeRateUpdater.Core/Models/CnbApiResponse.cs b/jobs/Backend/Task/ExchangeRateUpdater.Core/Models/CnbApiResponse.cs new file mode 100644 index 0000000000..f65eee2f27 --- /dev/null +++ b/jobs/Backend/Task/ExchangeRateUpdater.Core/Models/CnbApiResponse.cs @@ -0,0 +1,36 @@ +using System.Text.Json.Serialization; + +namespace ExchangeRateUpdater.Core.Models; + +/// +/// Root response object from Czech National Bank API +/// +public class CnbApiResponse +{ + [JsonPropertyName("rates")] + public List Rates { get; set; } = new(); +} + +public class CnbExchangeRateDto +{ + [JsonPropertyName("validFor")] + public string ValidFor { get; set; } = string.Empty; + + [JsonPropertyName("order")] + public int Order { get; set; } + + [JsonPropertyName("country")] + public string Country { get; set; } = string.Empty; + + [JsonPropertyName("currency")] + public string Currency { get; set; } = string.Empty; + + [JsonPropertyName("amount")] + public int Amount { get; set; } + + [JsonPropertyName("currencyCode")] + public string CurrencyCode { get; set; } = string.Empty; + + [JsonPropertyName("rate")] + public decimal Rate { get; set; } +} diff --git a/jobs/Backend/Task/ExchangeRateUpdater.Core/Models/Currency.cs b/jobs/Backend/Task/ExchangeRateUpdater.Core/Models/Currency.cs new file mode 100644 index 0000000000..f07f899fa4 --- /dev/null +++ b/jobs/Backend/Task/ExchangeRateUpdater.Core/Models/Currency.cs @@ -0,0 +1,25 @@ +namespace ExchangeRateUpdater.Core.Models; + +/// +/// Currency with a three-letter ISO 4217 code. +/// +public record Currency(string Code) +{ + public string Code { get; } = ValidateAndNormalizeCode(Code); + + private static string ValidateAndNormalizeCode(string code) + { + if (string.IsNullOrWhiteSpace(code)) + throw new ArgumentException("Currency code cannot be null or empty", nameof(code)); + + if (code.Length != 3) + throw new ArgumentException("Currency code must be exactly 3 characters", nameof(code)); + + return code.ToUpperInvariant(); + } + + public override string ToString() + { + return Code; + } +} diff --git a/jobs/Backend/Task/ExchangeRateUpdater.Core/Models/ExchangeRate.cs b/jobs/Backend/Task/ExchangeRateUpdater.Core/Models/ExchangeRate.cs new file mode 100644 index 0000000000..77ecdfe7d3 --- /dev/null +++ b/jobs/Backend/Task/ExchangeRateUpdater.Core/Models/ExchangeRate.cs @@ -0,0 +1,16 @@ +namespace ExchangeRateUpdater.Core.Models; + +/// +/// Represents an exchange rate between two currencies for a specific date. +/// +/// The source currency +/// The target currency +/// The exchange rate value +/// The date for which this rate is valid +public record ExchangeRate(Currency SourceCurrency, Currency TargetCurrency, decimal Value, DateTime Date) +{ + public override string ToString() + { + return $"{SourceCurrency}/{TargetCurrency}={Value} (Date: {Date:yyyy-MM-dd})"; + } +} diff --git a/jobs/Backend/Task/ExchangeRateUpdater.Core/Providers/CzechNationalBankProvider.cs b/jobs/Backend/Task/ExchangeRateUpdater.Core/Providers/CzechNationalBankProvider.cs new file mode 100644 index 0000000000..2ce94a7e49 --- /dev/null +++ b/jobs/Backend/Task/ExchangeRateUpdater.Core/Providers/CzechNationalBankProvider.cs @@ -0,0 +1,140 @@ +using System.Text.Json; +using ExchangeRateUpdater.Core.Common; +using ExchangeRateUpdater.Core.Configuration; +using ExchangeRateUpdater.Core.Extensions; +using ExchangeRateUpdater.Core.Interfaces; +using ExchangeRateUpdater.Core.Models; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; + +namespace ExchangeRateUpdater.Core.Providers; + +/// +/// Exchange rate provider for Czech National Bank +/// +public class CzechNationalBankProvider : IExchangeRateProvider +{ + private readonly HttpClient _httpClient; + private readonly CzechNationalBankOptions _options; + private readonly ILogger _logger; + + public string ProviderName => "Czech National Bank"; + public string BaseCurrency => "CZK"; + + public CzechNationalBankProvider( + HttpClient httpClient, + IOptions options, + ILogger logger) + { + _httpClient = httpClient ?? throw new ArgumentNullException(nameof(httpClient)); + _options = options?.Value ?? throw new ArgumentNullException(nameof(options)); + _logger = logger ?? throw new ArgumentNullException(nameof(logger)); + } + + public async Task>> GetExchangeRates(IEnumerable currencies, Maybe date) + { + if (currencies == null) + throw new ArgumentNullException(nameof(currencies)); + + var currencyList = currencies.ToList(); + if (!currencyList.Any()) + return Maybe>.Nothing; + + var targetDate = date.GetValueOrDefault(DateTime.Today); + _logger.LogInformation($"Fetching exchange rates from {ProviderName} for {currencyList.Count} currencies for date {targetDate:yyyy-MM-dd}"); + + try + { + var url = BuildApiUrl(targetDate); + _logger.LogInformation($"Requesting data from: {url}"); + + var response = await _httpClient.GetAsync(url); + response.EnsureSuccessStatusCode(); + + var jsonContent = await response.Content.ReadAsStringAsync(); + var rates = ParseCnbJsonFormat(jsonContent, currencyList); + + if (!rates.Any()) + { + _logger.LogWarning("No rates found in API response"); + return Maybe>.Nothing; + } + + _logger.LogInformation($"Successfully retrieved {rates.Count} exchange rates from {ProviderName}"); + + return rates.AsReadOnlyCollection().AsMaybe(); + } + catch (HttpRequestException ex) + { + _logger.LogError(ex, $"HTTP error occurred while fetching exchange rates from {ProviderName}"); + throw new ExchangeRateProviderException($"Failed to fetch exchange rates from {ProviderName}", ex); + } + catch (FormatException ex) + { + _logger.LogError(ex, $"Parsing error occurred while processing response from {ProviderName}"); + throw new ExchangeRateProviderException($"Failed to parse response from {ProviderName}", ex); + } + catch (Exception ex) + { + _logger.LogError(ex, $"Unexpected error occurred while fetching exchange rates from {ProviderName}"); + throw new ExchangeRateProviderException($"Unexpected error occurred while fetching exchange rates from {ProviderName}", ex); + } + } + + private string BuildApiUrl(DateTime date) + { + var dateString = date.ToString(_options.DateFormat); + return $"{_options.BaseUrl}?date={dateString}&lang={_options.Language}"; + } + + private List ParseCnbJsonFormat(string jsonContent, IEnumerable requestedCurrencies) + { + var rates = new List(); + var requestedCurrencyCodes = requestedCurrencies.Select(c => c.Code).ToHashSet(StringComparer.OrdinalIgnoreCase); + + try + { + var options = new JsonSerializerOptions + { + PropertyNameCaseInsensitive = true + }; + + var apiResponse = JsonSerializer.Deserialize(jsonContent, options); + + if (apiResponse?.Rates == null) + { + _logger.LogWarning("No rates found in JSON response"); + return rates; + } + + foreach (var rateDto in apiResponse.Rates) + { + if (requestedCurrencyCodes.Contains(rateDto.CurrencyCode)) + { + // CNB rates are given as: amount units = rate CZK + // We want: 1 unit = (rate / amount) CZK + var ratePerUnit = rateDto.Rate / rateDto.Amount; + var sourceCurrency = new Currency(rateDto.CurrencyCode); + var targetCurrency = new Currency(BaseCurrency); + + DateTime exchangeDate = DateTime.TryParse(rateDto.ValidFor, out var parsedDate) ? parsedDate : DateTime.Today; + + var exchangeRate = new ExchangeRate(sourceCurrency, targetCurrency, ratePerUnit, exchangeDate); + rates.Add(exchangeRate); + } + } + } + catch (JsonException ex) + { + _logger.LogError(ex, "Error parsing CNB JSON format"); + throw new ExchangeRateProviderException("Failed to parse CNB JSON response format", ex); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error processing CNB JSON response"); + throw new ExchangeRateProviderException("Failed to process CNB JSON response", ex); + } + + return rates; + } +} diff --git a/jobs/Backend/Task/ExchangeRateUpdater.Core/Services/ExchangeRateService.cs b/jobs/Backend/Task/ExchangeRateUpdater.Core/Services/ExchangeRateService.cs new file mode 100644 index 0000000000..a65b3ce0f3 --- /dev/null +++ b/jobs/Backend/Task/ExchangeRateUpdater.Core/Services/ExchangeRateService.cs @@ -0,0 +1,100 @@ +using ExchangeRateUpdater.Core.Common; +using ExchangeRateUpdater.Core.Configuration; +using ExchangeRateUpdater.Core.Interfaces; +using ExchangeRateUpdater.Core.Models; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; + +namespace ExchangeRateUpdater.Core.Services; + +public class ExchangeRateService : IExchangeRateService +{ + private readonly IExchangeRateProvider _provider; + private readonly IExchangeRateCache _cache; + private readonly ILogger _logger; + private readonly ExchangeRateOptions _options; + + public ExchangeRateService( + IExchangeRateProvider provider, + IExchangeRateCache cache, + ILogger logger, + IOptions options) + { + _provider = provider ?? throw new ArgumentNullException(nameof(provider)); + _cache = cache ?? throw new ArgumentNullException(nameof(cache)); + _logger = logger ?? throw new ArgumentNullException(nameof(logger)); + _options = options?.Value ?? throw new ArgumentNullException(nameof(options)); + } + + public async Task> GetExchangeRates( + IEnumerable currencies, + Maybe date + ) + { + if (currencies == null) + throw new ArgumentNullException(nameof(currencies)); + + var currencyList = currencies.ToList(); + if (!currencyList.Any()) + return Enumerable.Empty(); + + var targetDate = date.GetValueOrDefault(DateTime.Today); + _logger.LogInformation($"Getting exchange rates for {currencyList.Count} currencies ({string.Join(", ", currencyList.Select(c => c.Code))}) for date {targetDate:yyyy-MM-dd}"); + + try + { + if (_options.EnableCaching) + { + var cachedRates = await _cache.GetCachedRates(currencyList, targetDate); + if (cachedRates.HasValue) + { + _logger.LogInformation($"Returning {cachedRates.Value.Count()} cached exchange rates"); + return cachedRates.Value; + } + } + + // Fetch from provider + _logger.LogInformation($"Fetching fresh exchange rates from {_provider.ProviderName}"); + var maybeRates = await _provider.GetExchangeRates(currencyList, date); + + if (maybeRates.TryGetValue(out var rateList)) + { + if (rateList.Any()) + { + if (_options.EnableCaching) + { + await _cache.CacheRates(rateList, _options.DefaultCacheExpiry); + } + + _logger.LogInformation($"Successfully retrieved {rateList.Count()} exchange rates"); + return rateList; + } + else + { + _logger.LogWarning("No exchange rates found for the requested currencies"); + return Enumerable.Empty(); + } + } + else + { + _logger.LogWarning("Failed to retrieve exchange rates from provider"); + return Enumerable.Empty(); + } + } + catch (ExchangeRateProviderException) + { + throw; + } + catch (Exception ex) + { + _logger.LogError(ex, "Unexpected error occurred while getting exchange rates"); + throw new ExchangeRateServiceException("An unexpected error occurred while getting exchange rates", ex); + } + } +} + +public class ExchangeRateServiceException : Exception +{ + public ExchangeRateServiceException(string message) : base(message) { } + public ExchangeRateServiceException(string message, Exception innerException) : base(message, innerException) { } +} diff --git a/jobs/Backend/Task/ExchangeRateUpdater.Tests/Api/ApiExchangeRateCacheTests.cs b/jobs/Backend/Task/ExchangeRateUpdater.Tests/Api/ApiExchangeRateCacheTests.cs new file mode 100644 index 0000000000..7f41a8ce04 --- /dev/null +++ b/jobs/Backend/Task/ExchangeRateUpdater.Tests/Api/ApiExchangeRateCacheTests.cs @@ -0,0 +1,150 @@ +using Microsoft.Extensions.Caching.Memory; +using Microsoft.Extensions.Logging; +using ExchangeRateUpdater.Api.Services; +using ExchangeRateUpdater.Core.Models; +using ExchangeRateUpdater.Core.Common; +using ExchangeRateUpdater.Core.Extensions; +using FluentAssertions; +using NSubstitute; + +namespace ExchangeRateUpdater.Tests.Api; + +public class ApiExchangeRateCacheTests +{ + private readonly IMemoryCache _memoryCache; + private readonly ILogger _logger; + private readonly ApiExchangeRateCache _cache; + private readonly DateTime TARGET_DATE = new(2025, 9, 26); + + public ApiExchangeRateCacheTests() + { + _memoryCache = Substitute.For(); + _logger = Substitute.For>(); + _cache = new ApiExchangeRateCache(_memoryCache, _logger); + } + + [Fact] + public async Task GetCachedRates_WithNullCurrencies_ShouldThrowArgumentNullException() + { + // Act & Assert + var exception = await Assert.ThrowsAsync(() => + _cache.GetCachedRates(null!, DateTime.Today.AsMaybe())); + + exception.ParamName.Should().Be("currencies"); + } + + [Fact] + public async Task GetCachedRates_WithEmptyCurrencies_ShouldReturnNothing() + { + // Arrange + var emptyCurrencies = Array.Empty(); + + // Act + var result = await _cache.GetCachedRates(emptyCurrencies, DateTime.Today.AsMaybe()); + + // Assert + result.Should().Be(Maybe>.Nothing); + } + + [Fact] + public async Task GetCachedRates_WithCacheHit_ShouldReturnFilteredRates() + { + // Arrange + var currencies = new[] { new Currency("USD"), new Currency("EUR") }; + var allCachedRates = new List + { + new(new Currency("USD"), new Currency("CZK"), 25.0m, TARGET_DATE), + new(new Currency("EUR"), new Currency("CZK"), 27.0m, TARGET_DATE), + new(new Currency("GBP"), new Currency("CZK"), 30.0m, TARGET_DATE) + }; + + _memoryCache.TryGetValue(TARGET_DATE, out Arg.Any()) + .Returns(x => + { + x[1] = allCachedRates; + return true; + }); + + // Act + var result = await _cache.GetCachedRates(currencies, TARGET_DATE.AsMaybe()); + + // Assert + result.Should().NotBe(Maybe>.Nothing); + result.HasValue.Should().BeTrue(); + result.Value.Should().HaveCount(2); + result.Value.Should().Contain(r => r.SourceCurrency.Code == "USD"); + result.Value.Should().Contain(r => r.SourceCurrency.Code == "EUR"); + result.Value.Should().NotContain(r => r.SourceCurrency.Code == "GBP"); + } + + [Fact] + public async Task GetCachedRates_WithCacheMiss_ShouldReturnNothing() + { + // Arrange + var currencies = new[] { new Currency("USD") }; + + _memoryCache.TryGetValue(TARGET_DATE, out Arg.Any()) + .Returns(false); + + // Act + var result = await _cache.GetCachedRates(currencies, TARGET_DATE.AsMaybe()); + + // Assert + result.Should().Be(Maybe>.Nothing); + } + + [Fact] + public async Task GetCachedRates_WithCacheHitButNoMatchingCurrencies_ShouldReturnNothing() + { + // Arrange + var currencies = new[] { new Currency("USD") }; + var allCachedRates = new List + { + new(new Currency("EUR"), new Currency("CZK"), 27.0m, TARGET_DATE), + new(new Currency("GBP"), new Currency("CZK"), 30.0m, TARGET_DATE) + }; + + _memoryCache.TryGetValue(TARGET_DATE, out Arg.Any()) + .Returns(x => + { + x[1] = allCachedRates; + return true; + }); + + // Act + var result = await _cache.GetCachedRates(currencies, TARGET_DATE.AsMaybe()); + + // Assert + result.Should().Be(Maybe>.Nothing); + } + + [Fact] + public async Task CacheRates_WithNullRates_ShouldThrowArgumentNullException() + { + // Act & Assert + var exception = await Assert.ThrowsAsync(() => + _cache.CacheRates(null!, TimeSpan.FromHours(1))); + + exception.ParamName.Should().Be("rates"); + } + + [Fact] + public async Task CacheRates_WithDifferentDates_ShouldCacheWithFirstRateDate() + { + // Arrange + var yesterday = TARGET_DATE.AddDays(-1); + var rates = new List + { + new(new Currency("USD"), new Currency("CZK"), 25.0m, yesterday), + new(new Currency("EUR"), new Currency("CZK"), 27.0m, TARGET_DATE) + }; + + // Act + await _cache.CacheRates(rates, TimeSpan.FromHours(1)); + + // Assert + _memoryCache.Received(1).Set( + yesterday, + rates); + } +} diff --git a/jobs/Backend/Task/ExchangeRateUpdater.Tests/Api/ExchangeRatesControllerTests.cs b/jobs/Backend/Task/ExchangeRateUpdater.Tests/Api/ExchangeRatesControllerTests.cs new file mode 100644 index 0000000000..2b68d0d6f0 --- /dev/null +++ b/jobs/Backend/Task/ExchangeRateUpdater.Tests/Api/ExchangeRatesControllerTests.cs @@ -0,0 +1,64 @@ +using Microsoft.AspNetCore.Mvc.Testing; +using Microsoft.Extensions.DependencyInjection; +using System.Net; +using System.Text.Json; +using ExchangeRateUpdater.Api.Models; +using ExchangeRateUpdater.Core.Interfaces; +using ExchangeRateUpdater.Core.Models; +using ExchangeRateUpdater.Core.Common; +using Moq; + +namespace ExchangeRateUpdater.Tests.Api; + +public class ExchangeRatesControllerTests : IClassFixture> +{ + private readonly WebApplicationFactory _factory; + private readonly HttpClient _client; + + public ExchangeRatesControllerTests(WebApplicationFactory factory) + { + _factory = factory; + _client = _factory.CreateClient(); + } + + [Fact] + public async Task GetExchangeRates_ValidCurrencies_ShouldReturnOk() + { + // Act + var response = await _client.GetAsync("/api/exchangerates?currencies=USD,EUR"); + + // Assert + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + + var content = await response.Content.ReadAsStringAsync(); + var result = JsonSerializer.Deserialize>(content, new JsonSerializerOptions + { + PropertyNameCaseInsensitive = true + }); + + Assert.NotNull(result); + Assert.True(result.Success); + Assert.NotNull(result.Data); + Assert.NotEmpty(result.Data.Rates); + } + + [Fact] + public async Task GetExchangeRates_EmptyCurrencies_ShouldReturnBadRequest() + { + // Act + var response = await _client.GetAsync("/api/exchangerates?currencies="); + + // Assert + Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); + } + + [Fact] + public async Task GetExchangeRates_NoCurrenciesParameter_ShouldReturnBadRequest() + { + // Act + var response = await _client.GetAsync("/api/exchangerates"); + + // Assert + Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); + } +} diff --git a/jobs/Backend/Task/ExchangeRateUpdater.Tests/Core/CurrencyTests.cs b/jobs/Backend/Task/ExchangeRateUpdater.Tests/Core/CurrencyTests.cs new file mode 100644 index 0000000000..7c432566a7 --- /dev/null +++ b/jobs/Backend/Task/ExchangeRateUpdater.Tests/Core/CurrencyTests.cs @@ -0,0 +1,34 @@ +using ExchangeRateUpdater.Core.Models; + +namespace ExchangeRateUpdater.Tests.Core; + +public class CurrencyTests +{ + [Fact] + public void Currency_ValidCode_ShouldCreate() + { + var currency = new Currency("USD"); + + Assert.Equal("USD", currency.Code); + } + + [Fact] + public void Currency_LowercaseCode_ShouldConvertToUppercase() + { + var currency = new Currency("usd"); + + Assert.Equal("USD", currency.Code); + } + + [Fact] + public void Currency_TooShortCode_ShouldThrowArgumentException() + { + Assert.Throws(() => new Currency("US")); + } + + [Fact] + public void Currency_TooLongCode_ShouldThrowArgumentException() + { + Assert.Throws(() => new Currency("USDD")); + } +} diff --git a/jobs/Backend/Task/ExchangeRateUpdater.Tests/Core/ExchangeRateServiceTests.cs b/jobs/Backend/Task/ExchangeRateUpdater.Tests/Core/ExchangeRateServiceTests.cs new file mode 100644 index 0000000000..c1d4c032c0 --- /dev/null +++ b/jobs/Backend/Task/ExchangeRateUpdater.Tests/Core/ExchangeRateServiceTests.cs @@ -0,0 +1,106 @@ +using ExchangeRateUpdater.Core.Common; +using ExchangeRateUpdater.Core.Configuration; +using ExchangeRateUpdater.Core.Interfaces; +using ExchangeRateUpdater.Core.Models; +using ExchangeRateUpdater.Core.Extensions; +using ExchangeRateUpdater.Core.Services; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; +using Moq; + +namespace ExchangeRateUpdater.Tests.Core; + +public class ExchangeRateServiceTests +{ + private readonly Mock _mockProvider; + private readonly Mock _mockCache; + private readonly Mock> _mockLogger; + private readonly Mock> _mockOptions; + private readonly ExchangeRateService _service; + + public ExchangeRateServiceTests() + { + _mockProvider = new Mock(); + _mockCache = new Mock(); + _mockLogger = new Mock>(); + _mockOptions = new Mock>(); + + var options = new ExchangeRateOptions + { + EnableCaching = true, + DefaultCacheExpiry = TimeSpan.FromHours(1) + }; + + _mockOptions.Setup(x => x.Value).Returns(options); + + _service = new ExchangeRateService(_mockProvider.Object, _mockCache.Object, _mockLogger.Object, _mockOptions.Object); + } + + [Fact] + public async Task GetExchangeRatesAsync_WithCachedRates_ShouldReturnCachedRates() + { + // Arrange + var currencies = new[] { new Currency("USD"), new Currency("EUR") }; + var cachedRates = new[] + { + new ExchangeRate(new Currency("USD"), new Currency("CZK"), 25.0m, DateTime.Today), + new ExchangeRate(new Currency("EUR"), new Currency("CZK"), 27.0m, DateTime.Today) + }; + + _mockCache.Setup(x => x.GetCachedRates(It.IsAny>(), It.IsAny>())) + .ReturnsAsync(((IReadOnlyList)cachedRates).AsMaybe()); + + // Act + var result = await _service.GetExchangeRates(currencies, DateTime.Today.AsMaybe()); + + // Assert + Assert.Equal(2, result.Count()); + _mockProvider.Verify(x => x.GetExchangeRates(It.IsAny>(), It.IsAny>()), Times.Never); + } + + [Fact] + public async Task GetExchangeRatesAsync_WithoutCachedRates_ShouldFetchFromProvider() + { + // Arrange + var currencies = new[] { new Currency("USD") }; + var providerRates = new[] + { + new ExchangeRate(new Currency("USD"), new Currency("CZK"), 25.0m, DateTime.Today) + }; + + _mockCache.Setup(x => x.GetCachedRates(It.IsAny>(), It.IsAny>())) + .ReturnsAsync(Maybe>.Nothing); + + _mockProvider.Setup(x => x.GetExchangeRates(It.IsAny>(), It.IsAny>())) + .ReturnsAsync(((IReadOnlyCollection)providerRates).AsMaybe()); + + // Act + var result = await _service.GetExchangeRates(currencies, DateTime.Today.AsMaybe()); + + // Assert + Assert.Single(result); + Assert.Equal("USD", result.First().SourceCurrency.Code); + _mockCache.Verify(x => x.CacheRates(It.IsAny>(), It.IsAny()), Times.Once); + } + + [Fact] + public async Task GetExchangeRatesAsync_WithNullCurrencies_ShouldThrowArgumentNullException() + { + // Act & Assert + await Assert.ThrowsAsync(() => + _service.GetExchangeRates(null!, DateTime.Today.AsMaybe())); + } + + [Fact] + public async Task GetExchangeRatesAsync_WithEmptyCurrencies_ShouldReturnEmpty() + { + // Arrange + var emptyCurrencies = Array.Empty(); + + // Act + var result = await _service.GetExchangeRates(emptyCurrencies, DateTime.Today.AsMaybe()); + + // Assert + Assert.Empty(result); + } +} diff --git a/jobs/Backend/Task/ExchangeRateUpdater.Tests/Core/MaybeTests.cs b/jobs/Backend/Task/ExchangeRateUpdater.Tests/Core/MaybeTests.cs new file mode 100644 index 0000000000..2c89e6dedb --- /dev/null +++ b/jobs/Backend/Task/ExchangeRateUpdater.Tests/Core/MaybeTests.cs @@ -0,0 +1,72 @@ +using ExchangeRateUpdater.Core.Common; + +namespace ExchangeRateUpdater.Tests.Core; + +public class MaybeTests +{ + [Fact] + public void Maybe_WithValue_ShouldHaveValue() + { + var maybe = Maybe.Nothing; + var value = "test"; + maybe = value; + + Assert.True(maybe.HasValue); + Assert.Equal(value, maybe.Value); + } + + [Fact] + public void Maybe_WithNull_ShouldNotHaveValue() + { + var maybe = Maybe.Nothing; + string? nullValue = null; + maybe = nullValue; + + Assert.False(maybe.HasValue); + } + + [Fact] + public void Maybe_GetValueOrDefault_WithValue_ShouldReturnValue() + { + var value = "test"; + Maybe maybe = value; + + var result = maybe.GetValueOrDefault("default"); + + Assert.Equal(value, result); + } + + [Fact] + public void Maybe_GetValueOrDefault_WithoutValue_ShouldReturnDefault() + { + var defaultValue = "default"; + var maybe = Maybe.Nothing; + + var result = maybe.GetValueOrDefault(defaultValue); + + Assert.Equal(defaultValue, result); + } + + [Fact] + public void Maybe_TryGetValue_WithValue_ShouldReturnTrue() + { + var value = "test"; + Maybe maybe = value; + + var result = maybe.TryGetValue(out var retrievedValue); + + Assert.True(result); + Assert.Equal(value, retrievedValue); + } + + [Fact] + public void Maybe_TryGetValue_WithoutValue_ShouldReturnFalse() + { + var maybe = Maybe.Nothing; + + var result = maybe.TryGetValue(out var retrievedValue); + + Assert.False(result); + Assert.Null(retrievedValue); + } +} diff --git a/jobs/Backend/Task/ExchangeRateUpdater.Tests/ExchangeRateUpdater.Tests.csproj b/jobs/Backend/Task/ExchangeRateUpdater.Tests/ExchangeRateUpdater.Tests.csproj new file mode 100644 index 0000000000..57fbccc157 --- /dev/null +++ b/jobs/Backend/Task/ExchangeRateUpdater.Tests/ExchangeRateUpdater.Tests.csproj @@ -0,0 +1,33 @@ + + + + net9.0 + enable + enable + false + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/jobs/Backend/Task/ExchangeRateUpdater.Tests/Integration/WeekendHolidayCachingTests.cs b/jobs/Backend/Task/ExchangeRateUpdater.Tests/Integration/WeekendHolidayCachingTests.cs new file mode 100644 index 0000000000..b9811d3fc4 --- /dev/null +++ b/jobs/Backend/Task/ExchangeRateUpdater.Tests/Integration/WeekendHolidayCachingTests.cs @@ -0,0 +1,138 @@ +using Microsoft.Extensions.Caching.Memory; +using Microsoft.Extensions.Logging; +using ExchangeRateUpdater.Api.Services; +using ExchangeRateUpdater.Core.Models; +using ExchangeRateUpdater.Core.Common; +using ExchangeRateUpdater.Core.Extensions; +using FluentAssertions; +using NSubstitute; + +namespace ExchangeRateUpdater.Tests.Integration; + +public class WeekendHolidayCachingTests +{ + private readonly IMemoryCache _memoryCache; + private readonly ILogger _logger; + private readonly ApiExchangeRateCache _cache; + + public WeekendHolidayCachingTests() + { + _memoryCache = new MemoryCache(new MemoryCacheOptions()); + _logger = Substitute.For>(); + _cache = new ApiExchangeRateCache(_memoryCache, _logger); + } + + [Fact] + public async Task GetCachedRates_OnWeekend_ShouldReturnPreviousBusinessDayRates() + { + // Arrange + var friday = new DateTime(2024, 1, 5); // Friday + var saturday = new DateTime(2024, 1, 6); // Saturday + var rates = new List + { + new(new Currency("USD"), new Currency("CZK"), 25.0m, friday), + new(new Currency("EUR"), new Currency("CZK"), 27.0m, friday) + }; + + await _cache.CacheRates(rates, TimeSpan.FromHours(1)); + + // Act + var result = await _cache.GetCachedRates( + new[] { new Currency("USD"), new Currency("EUR") }, + saturday.AsMaybe()); + + // Assert + result.Should().NotBe(Maybe>.Nothing); + result.HasValue.Should().BeTrue(); + result.Value.Should().HaveCount(2); + result.Value.Should().AllSatisfy(rate => rate.Date.Should().Be(friday)); + } + + [Fact] + public async Task CacheRates_WithDifferentDates_ShouldCacheSeparately() + { + // Arrange + var monday = new DateTime(2024, 1, 1); + var tuesday = new DateTime(2024, 1, 2); + var mondayRates = new List + { + new(new Currency("USD"), new Currency("CZK"), 25.0m, monday) + }; + var tuesdayRates = new List + { + new(new Currency("USD"), new Currency("CZK"), 26.0m, tuesday) + }; + + // Act + await _cache.CacheRates(mondayRates, TimeSpan.FromHours(1)); + await _cache.CacheRates(tuesdayRates, TimeSpan.FromHours(1)); + + // Assert + var cachedMondayRates = _memoryCache.Get>(monday); + var cachedTuesdayRates = _memoryCache.Get>(tuesday); + + cachedMondayRates.Should().NotBeNull(); + cachedTuesdayRates.Should().NotBeNull(); + cachedMondayRates.Should().HaveCount(1); + cachedTuesdayRates.Should().HaveCount(1); + cachedMondayRates.First().Value.Should().Be(25.0m); + cachedTuesdayRates.First().Value.Should().Be(26.0m); + } + + [Fact] + public async Task GetCachedRates_WithPartialCurrencyMatch_ShouldReturnOnlyMatchingCurrencies() + { + // Arrange + var businessDay = new DateTime(2024, 1, 2); // Tuesday + var rates = new List + { + new(new Currency("USD"), new Currency("CZK"), 25.0m, businessDay), + new(new Currency("EUR"), new Currency("CZK"), 27.0m, businessDay), + new(new Currency("GBP"), new Currency("CZK"), 30.0m, businessDay) + }; + + await _cache.CacheRates(rates, TimeSpan.FromHours(1)); + + // Act + var result = await _cache.GetCachedRates( + new[] { new Currency("USD"), new Currency("GBP") }, + businessDay.AsMaybe()); + + // Assert + result.Should().NotBe(Maybe>.Nothing); + result.HasValue.Should().BeTrue(); + result.Value.Should().HaveCount(2); + result.Value.Should().Contain(r => r.SourceCurrency.Code == "USD"); + result.Value.Should().Contain(r => r.SourceCurrency.Code == "GBP"); + result.Value.Should().NotContain(r => r.SourceCurrency.Code == "EUR"); + } + + [Fact] + public async Task GetCachedRates_WithCaseInsensitiveCurrencyMatch_ShouldReturnRates() + { + // Arrange + var businessDay = new DateTime(2024, 1, 2); // Tuesday + var rates = new List + { + new(new Currency("USD"), new Currency("CZK"), 25.0m, businessDay) + }; + + await _cache.CacheRates(rates, TimeSpan.FromHours(1)); + + // Act + var result = await _cache.GetCachedRates( + new[] { new Currency("usd") }, + businessDay.AsMaybe()); + + // Assert + result.Should().NotBe(Maybe>.Nothing); + result.HasValue.Should().BeTrue(); + result.Value.Should().HaveCount(1); + result.Value.First().SourceCurrency.Code.Should().Be("USD"); + } + + private void Dispose() + { + _memoryCache?.Dispose(); + } +} 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..d856410af4 100644 --- a/jobs/Backend/Task/ExchangeRateUpdater.sln +++ b/jobs/Backend/Task/ExchangeRateUpdater.sln @@ -1,20 +1,74 @@  Microsoft Visual Studio Solution File, Format Version 12.00 -# Visual Studio 14 -VisualStudioVersion = 14.0.25123.0 +# Visual Studio Version 17 +VisualStudioVersion = 17.0.31903.59 MinimumVisualStudioVersion = 10.0.40219.1 -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ExchangeRateUpdater", "ExchangeRateUpdater.csproj", "{7B2695D6-D24C-4460-A58E-A10F08550CE0}" +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ExchangeRateUpdater.Core", "ExchangeRateUpdater.Core\ExchangeRateUpdater.Core.csproj", "{B9A8096D-3BC1-465D-8DC7-F4BCE0AB13B9}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ExchangeRateUpdater.Console", "ExchangeRateUpdater.Console\ExchangeRateUpdater.Console.csproj", "{3927442B-AC37-43A4-A20A-4677DE7BE856}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ExchangeRateUpdater.Api", "ExchangeRateUpdater.Api\ExchangeRateUpdater.Api.csproj", "{C082C898-9F9C-4994-A20D-FDBF5F5185C8}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ExchangeRateUpdater.Tests", "ExchangeRateUpdater.Tests\ExchangeRateUpdater.Tests.csproj", "{727555B1-68C4-493E-8A6F-3EAD93E7F7B3}" EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU + Debug|x64 = Debug|x64 + Debug|x86 = Debug|x86 Release|Any CPU = Release|Any CPU + Release|x64 = Release|x64 + Release|x86 = Release|x86 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 + {B9A8096D-3BC1-465D-8DC7-F4BCE0AB13B9}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {B9A8096D-3BC1-465D-8DC7-F4BCE0AB13B9}.Debug|Any CPU.Build.0 = Debug|Any CPU + {B9A8096D-3BC1-465D-8DC7-F4BCE0AB13B9}.Debug|x64.ActiveCfg = Debug|Any CPU + {B9A8096D-3BC1-465D-8DC7-F4BCE0AB13B9}.Debug|x64.Build.0 = Debug|Any CPU + {B9A8096D-3BC1-465D-8DC7-F4BCE0AB13B9}.Debug|x86.ActiveCfg = Debug|Any CPU + {B9A8096D-3BC1-465D-8DC7-F4BCE0AB13B9}.Debug|x86.Build.0 = Debug|Any CPU + {B9A8096D-3BC1-465D-8DC7-F4BCE0AB13B9}.Release|Any CPU.ActiveCfg = Release|Any CPU + {B9A8096D-3BC1-465D-8DC7-F4BCE0AB13B9}.Release|Any CPU.Build.0 = Release|Any CPU + {B9A8096D-3BC1-465D-8DC7-F4BCE0AB13B9}.Release|x64.ActiveCfg = Release|Any CPU + {B9A8096D-3BC1-465D-8DC7-F4BCE0AB13B9}.Release|x64.Build.0 = Release|Any CPU + {B9A8096D-3BC1-465D-8DC7-F4BCE0AB13B9}.Release|x86.ActiveCfg = Release|Any CPU + {B9A8096D-3BC1-465D-8DC7-F4BCE0AB13B9}.Release|x86.Build.0 = Release|Any CPU + {3927442B-AC37-43A4-A20A-4677DE7BE856}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {3927442B-AC37-43A4-A20A-4677DE7BE856}.Debug|Any CPU.Build.0 = Debug|Any CPU + {3927442B-AC37-43A4-A20A-4677DE7BE856}.Debug|x64.ActiveCfg = Debug|Any CPU + {3927442B-AC37-43A4-A20A-4677DE7BE856}.Debug|x64.Build.0 = Debug|Any CPU + {3927442B-AC37-43A4-A20A-4677DE7BE856}.Debug|x86.ActiveCfg = Debug|Any CPU + {3927442B-AC37-43A4-A20A-4677DE7BE856}.Debug|x86.Build.0 = Debug|Any CPU + {3927442B-AC37-43A4-A20A-4677DE7BE856}.Release|Any CPU.ActiveCfg = Release|Any CPU + {3927442B-AC37-43A4-A20A-4677DE7BE856}.Release|Any CPU.Build.0 = Release|Any CPU + {3927442B-AC37-43A4-A20A-4677DE7BE856}.Release|x64.ActiveCfg = Release|Any CPU + {3927442B-AC37-43A4-A20A-4677DE7BE856}.Release|x64.Build.0 = Release|Any CPU + {3927442B-AC37-43A4-A20A-4677DE7BE856}.Release|x86.ActiveCfg = Release|Any CPU + {3927442B-AC37-43A4-A20A-4677DE7BE856}.Release|x86.Build.0 = Release|Any CPU + {C082C898-9F9C-4994-A20D-FDBF5F5185C8}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {C082C898-9F9C-4994-A20D-FDBF5F5185C8}.Debug|Any CPU.Build.0 = Debug|Any CPU + {C082C898-9F9C-4994-A20D-FDBF5F5185C8}.Debug|x64.ActiveCfg = Debug|Any CPU + {C082C898-9F9C-4994-A20D-FDBF5F5185C8}.Debug|x64.Build.0 = Debug|Any CPU + {C082C898-9F9C-4994-A20D-FDBF5F5185C8}.Debug|x86.ActiveCfg = Debug|Any CPU + {C082C898-9F9C-4994-A20D-FDBF5F5185C8}.Debug|x86.Build.0 = Debug|Any CPU + {C082C898-9F9C-4994-A20D-FDBF5F5185C8}.Release|Any CPU.ActiveCfg = Release|Any CPU + {C082C898-9F9C-4994-A20D-FDBF5F5185C8}.Release|Any CPU.Build.0 = Release|Any CPU + {C082C898-9F9C-4994-A20D-FDBF5F5185C8}.Release|x64.ActiveCfg = Release|Any CPU + {C082C898-9F9C-4994-A20D-FDBF5F5185C8}.Release|x64.Build.0 = Release|Any CPU + {C082C898-9F9C-4994-A20D-FDBF5F5185C8}.Release|x86.ActiveCfg = Release|Any CPU + {C082C898-9F9C-4994-A20D-FDBF5F5185C8}.Release|x86.Build.0 = Release|Any CPU + {727555B1-68C4-493E-8A6F-3EAD93E7F7B3}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {727555B1-68C4-493E-8A6F-3EAD93E7F7B3}.Debug|Any CPU.Build.0 = Debug|Any CPU + {727555B1-68C4-493E-8A6F-3EAD93E7F7B3}.Debug|x64.ActiveCfg = Debug|Any CPU + {727555B1-68C4-493E-8A6F-3EAD93E7F7B3}.Debug|x64.Build.0 = Debug|Any CPU + {727555B1-68C4-493E-8A6F-3EAD93E7F7B3}.Debug|x86.ActiveCfg = Debug|Any CPU + {727555B1-68C4-493E-8A6F-3EAD93E7F7B3}.Debug|x86.Build.0 = Debug|Any CPU + {727555B1-68C4-493E-8A6F-3EAD93E7F7B3}.Release|Any CPU.ActiveCfg = Release|Any CPU + {727555B1-68C4-493E-8A6F-3EAD93E7F7B3}.Release|Any CPU.Build.0 = Release|Any CPU + {727555B1-68C4-493E-8A6F-3EAD93E7F7B3}.Release|x64.ActiveCfg = Release|Any CPU + {727555B1-68C4-493E-8A6F-3EAD93E7F7B3}.Release|x64.Build.0 = Release|Any CPU + {727555B1-68C4-493E-8A6F-3EAD93E7F7B3}.Release|x86.ActiveCfg = Release|Any CPU + {727555B1-68C4-493E-8A6F-3EAD93E7F7B3}.Release|x86.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE diff --git a/jobs/Backend/Task/Program.cs b/jobs/Backend/Task/Program.cs deleted file mode 100644 index 379a69b1f8..0000000000 --- a/jobs/Backend/Task/Program.cs +++ /dev/null @@ -1,43 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Linq; - -namespace ExchangeRateUpdater -{ - public static class Program - { - private static IEnumerable currencies = new[] - { - new Currency("USD"), - new Currency("EUR"), - new Currency("CZK"), - new Currency("JPY"), - new Currency("KES"), - new Currency("RUB"), - new Currency("THB"), - new Currency("TRY"), - new Currency("XYZ") - }; - - public static void Main(string[] args) - { - try - { - var provider = new ExchangeRateProvider(); - var rates = provider.GetExchangeRates(currencies); - - Console.WriteLine($"Successfully retrieved {rates.Count()} exchange rates:"); - foreach (var rate in rates) - { - Console.WriteLine(rate.ToString()); - } - } - catch (Exception e) - { - Console.WriteLine($"Could not retrieve exchange rates: '{e.Message}'."); - } - - Console.ReadLine(); - } - } -} diff --git a/jobs/Backend/Task/README.md b/jobs/Backend/Task/README.md new file mode 100644 index 0000000000..2353f75bdc --- /dev/null +++ b/jobs/Backend/Task/README.md @@ -0,0 +1,144 @@ +# Exchange Rate Updater + +A production-ready .NET 9 solution for fetching and managing exchange rates from the Czech National Bank, featuring both console and Web API interfaces. + +## Architecture + +This solution implements a clean, modular architecture with the following components: + +### Projects + +``` +ExchangeRateUpdater.sln +├── ExchangeRateUpdater.Core/ # Shared business logic library +├── ExchangeRateUpdater.Console/ # Console application +├── ExchangeRateUpdater.Api/ # Web API project +└── ExchangeRateUpdater.Tests/ # Unit and integration tests +``` + +### Core Library (`ExchangeRateUpdater.Core`) + +Contains all shared business logic, models, and interfaces: + +- **Models**: `Currency`, `ExchangeRate`, DTOs for API responses +- **Interfaces**: `IExchangeRateProvider`, `IExchangeRateCache`, `ILogger` +- **Services**: `ExchangeRateService`, `InMemoryExchangeRateCache` +- **Providers**: `CzechNationalBankProvider` +- **Configuration**: Options classes for dependency injection + +### Console Application (`ExchangeRateUpdater.Console`) + +A command-line interface for fetching exchange rates: +- No caching (as per requirements) +- Uses `System.CommandLine` for argument parsing +- References the Core library + +### Web API (`ExchangeRateUpdater.Api`) + +A REST API with the following features: +- OpenAPI/Swagger documentation +- Enhanced caching strategy (caches all rates per date) +- Follows REST API best practices +- Memory-based caching using `IMemoryCache` + +### Testing (`ExchangeRateUpdater.Tests`) + +Comprehensive test suite covering: +- Unit tests for core components +- Integration tests for the API +- Mock-based testing using Moq + +## Key Features + +- **Clean Architecture**: Separation of concerns with Core library +- **Dependency Injection**: Full DI container setup across all projects +- **Error Handling**: Comprehensive error handling with retry policies using Polly +- **Caching**: Different caching strategies for Console (none) and API (enhanced) +- **Configuration**: JSON-based configuration with environment variable overrides +- **API Documentation**: OpenAPI/Swagger integration +- **Testing**: Unit and integration tests + +### Running the Console Application + +```bash +cd ExchangeRateUpdater.Console + +# Basic usage (defaults to today's date and predefined currencies) +dotnet run + +# Specify a custom date +dotnet run -- --date 2025-09-20 + +# Specify custom currencies +dotnet run -- --currencies USD,EUR,JPY + +# Combine both parameters +dotnet run -- --date 2025-09-20 --currencies USD,EUR,JPY + +# View help information +dotnet run -- --help +``` + +### Running the API + +```bash +cd ExchangeRateUpdater.Api +dotnet run +``` + +The API will be available at `https://localhost:5001` (or the port shown in the console). +OpenAPI document is available at `/openapi/v1.json` when running in Development mode. + +### API Endpoints + +- `GET /api/exchangerates?currencies=USD,EUR&date=2025-09-20` - Get exchange rates + +### Running Tests + +```bash +cd ExchangeRateUpdater.Tests +dotnet test +``` + +## Configuration + +The application uses `appsettings.json` for configuration: + +```json +{ + "ExchangeRate": { + "DefaultCacheExpiry": "01:00:00", + "MaxRetryAttempts": 3, + "RetryDelay": "00:00:02", + "RequestTimeout": "00:00:30", + "EnableCaching": true + }, + "CzechNationalBank": { + "BaseUrl": "https://api.cnb.cz/cnbapi/exrates/daily", + "DateFormat": "yyyy-MM-dd", + "Language": "EN" + } +} +``` + +## Caching Strategy + +### Console Application +- **No caching** (as per requirements) +- Direct calls to the provider + +### API Application +- **Enhanced caching**: All exchange rates are cached per date +- When requesting specific currencies, the API returns filtered results from the cached data +- Uses `IMemoryCache` for efficient in-memory storage +- Configurable cache expiry times + +## API Design + +The API follows REST best practices: + +- **Resource-based URLs**: `/api/exchangerates` +- **HTTP verbs**: GET for queries +- **Status codes**: Proper HTTP status codes (200, 400, 404, 500) +- **Content negotiation**: JSON responses +- **OpenAPI documentation**: Complete API documentation with Swagger From 1a6fc1e5a2e95bf38bdc3c44daf2876c586dbd42 Mon Sep 17 00:00:00 2001 From: AndreKasper Date: Sat, 27 Sep 2025 16:10:38 +0200 Subject: [PATCH 02/10] Fix issue on caching not storing all currencies --- .../Interfaces/IExchangeRateProvider.cs | 10 +---- .../Providers/CzechNationalBankProvider.cs | 39 +++++++------------ .../Services/ExchangeRateService.cs | 4 +- .../Core/ExchangeRateServiceTests.cs | 4 +- 4 files changed, 19 insertions(+), 38 deletions(-) diff --git a/jobs/Backend/Task/ExchangeRateUpdater.Core/Interfaces/IExchangeRateProvider.cs b/jobs/Backend/Task/ExchangeRateUpdater.Core/Interfaces/IExchangeRateProvider.cs index 5bb11d293a..6f32095609 100644 --- a/jobs/Backend/Task/ExchangeRateUpdater.Core/Interfaces/IExchangeRateProvider.cs +++ b/jobs/Backend/Task/ExchangeRateUpdater.Core/Interfaces/IExchangeRateProvider.cs @@ -8,18 +8,10 @@ public interface IExchangeRateProvider /// /// Gets exchange rates for the specified currencies for a specific date /// - /// The currencies to get rates for /// The date to get exchange rates for (uses today if None) /// Maybe containing collection of exchange rates - Task>> GetExchangeRates(IEnumerable currencies, Maybe date); + Task>> GetExchangeRatesForDate(Maybe date); - /// - /// Gets the name of this provider - /// string ProviderName { get; } - - /// - /// Gets the base currency for this provider - /// string BaseCurrency { get; } } diff --git a/jobs/Backend/Task/ExchangeRateUpdater.Core/Providers/CzechNationalBankProvider.cs b/jobs/Backend/Task/ExchangeRateUpdater.Core/Providers/CzechNationalBankProvider.cs index 2ce94a7e49..6b96799842 100644 --- a/jobs/Backend/Task/ExchangeRateUpdater.Core/Providers/CzechNationalBankProvider.cs +++ b/jobs/Backend/Task/ExchangeRateUpdater.Core/Providers/CzechNationalBankProvider.cs @@ -31,17 +31,10 @@ public CzechNationalBankProvider( _logger = logger ?? throw new ArgumentNullException(nameof(logger)); } - public async Task>> GetExchangeRates(IEnumerable currencies, Maybe date) + public async Task>> GetExchangeRatesForDate(Maybe date) { - if (currencies == null) - throw new ArgumentNullException(nameof(currencies)); - - var currencyList = currencies.ToList(); - if (!currencyList.Any()) - return Maybe>.Nothing; - var targetDate = date.GetValueOrDefault(DateTime.Today); - _logger.LogInformation($"Fetching exchange rates from {ProviderName} for {currencyList.Count} currencies for date {targetDate:yyyy-MM-dd}"); + _logger.LogInformation($"Fetching exchange rates from {ProviderName} currencies for date {targetDate:yyyy-MM-dd}"); try { @@ -52,7 +45,7 @@ public async Task>> GetExchangeRates(IEn response.EnsureSuccessStatusCode(); var jsonContent = await response.Content.ReadAsStringAsync(); - var rates = ParseCnbJsonFormat(jsonContent, currencyList); + var rates = ParseCnbJsonFormat(jsonContent); if (!rates.Any()) { @@ -87,10 +80,9 @@ private string BuildApiUrl(DateTime date) return $"{_options.BaseUrl}?date={dateString}&lang={_options.Language}"; } - private List ParseCnbJsonFormat(string jsonContent, IEnumerable requestedCurrencies) + private List ParseCnbJsonFormat(string jsonContent) { var rates = new List(); - var requestedCurrencyCodes = requestedCurrencies.Select(c => c.Code).ToHashSet(StringComparer.OrdinalIgnoreCase); try { @@ -109,19 +101,16 @@ private List ParseCnbJsonFormat(string jsonContent, IEnumerable date // Fetch from provider _logger.LogInformation($"Fetching fresh exchange rates from {_provider.ProviderName}"); - var maybeRates = await _provider.GetExchangeRates(currencyList, date); + var maybeRates = await _provider.GetExchangeRatesForDate(date); if (maybeRates.TryGetValue(out var rateList)) { @@ -67,7 +67,7 @@ Maybe date } _logger.LogInformation($"Successfully retrieved {rateList.Count()} exchange rates"); - return rateList; + return rateList.Where(rate => currencyList.Contains(rate.SourceCurrency)); } else { diff --git a/jobs/Backend/Task/ExchangeRateUpdater.Tests/Core/ExchangeRateServiceTests.cs b/jobs/Backend/Task/ExchangeRateUpdater.Tests/Core/ExchangeRateServiceTests.cs index c1d4c032c0..4896b4d009 100644 --- a/jobs/Backend/Task/ExchangeRateUpdater.Tests/Core/ExchangeRateServiceTests.cs +++ b/jobs/Backend/Task/ExchangeRateUpdater.Tests/Core/ExchangeRateServiceTests.cs @@ -55,7 +55,7 @@ public async Task GetExchangeRatesAsync_WithCachedRates_ShouldReturnCachedRates( // Assert Assert.Equal(2, result.Count()); - _mockProvider.Verify(x => x.GetExchangeRates(It.IsAny>(), It.IsAny>()), Times.Never); + _mockProvider.Verify(x => x.GetExchangeRatesForDate(It.IsAny>()), Times.Never); } [Fact] @@ -71,7 +71,7 @@ public async Task GetExchangeRatesAsync_WithoutCachedRates_ShouldFetchFromProvid _mockCache.Setup(x => x.GetCachedRates(It.IsAny>(), It.IsAny>())) .ReturnsAsync(Maybe>.Nothing); - _mockProvider.Setup(x => x.GetExchangeRates(It.IsAny>(), It.IsAny>())) + _mockProvider.Setup(x => x.GetExchangeRatesForDate(It.IsAny>())) .ReturnsAsync(((IReadOnlyCollection)providerRates).AsMaybe()); // Act From 20620e1e3ba13770165db2159ba0c1ccd1998187 Mon Sep 17 00:00:00 2001 From: AndreKasper Date: Sat, 27 Sep 2025 18:49:29 +0200 Subject: [PATCH 03/10] Add docker support --- jobs/Backend/Task/.dockerignore | 27 +++++++++++++ jobs/Backend/Task/Dockerfile.api | 38 +++++++++++++++++++ .../ExchangeRateUpdater.Api.http | 2 - .../ExchangeRateUpdater.Api/appsettings.json | 4 +- jobs/Backend/Task/docker-compose.dev.yml | 17 +++++++++ jobs/Backend/Task/docker-compose.yml | 13 +++++++ 6 files changed, 97 insertions(+), 4 deletions(-) create mode 100644 jobs/Backend/Task/.dockerignore create mode 100644 jobs/Backend/Task/Dockerfile.api delete mode 100644 jobs/Backend/Task/ExchangeRateUpdater.Api/ExchangeRateUpdater.Api.http create mode 100644 jobs/Backend/Task/docker-compose.dev.yml create mode 100644 jobs/Backend/Task/docker-compose.yml diff --git a/jobs/Backend/Task/.dockerignore b/jobs/Backend/Task/.dockerignore new file mode 100644 index 0000000000..ab5bec978a --- /dev/null +++ b/jobs/Backend/Task/.dockerignore @@ -0,0 +1,27 @@ +**/.dockerignore +**/.env +**/.env.* +**/.git +**/.gitignore +**/.project +**/.settings +**/.toolstarget +**/.vs +**/.vscode +**/*.*proj.user +**/*.dbmdl +**/*.jfm +**/azds.yaml +**/bin +**/charts +**/docker-compose* +**/Dockerfile* +**/node_modules +**/npm-debug.log +**/obj +**/secrets.dev.yaml +**/values.dev.yaml +**/TestResults +**/coverage +LICENSE +README.md diff --git a/jobs/Backend/Task/Dockerfile.api b/jobs/Backend/Task/Dockerfile.api new file mode 100644 index 0000000000..c8165fecec --- /dev/null +++ b/jobs/Backend/Task/Dockerfile.api @@ -0,0 +1,38 @@ +FROM mcr.microsoft.com/dotnet/aspnet:9.0 AS base +WORKDIR /app +EXPOSE 8080 + +FROM mcr.microsoft.com/dotnet/sdk:9.0 AS build + +WORKDIR /src + +COPY ["ExchangeRateUpdater.Api/ExchangeRateUpdater.Api.csproj", "ExchangeRateUpdater.Api/"] +COPY ["ExchangeRateUpdater.Console/ExchangeRateUpdater.Console.csproj", "ExchangeRateUpdater.Console/"] +COPY ["ExchangeRateUpdater.Core/ExchangeRateUpdater.Core.csproj", "ExchangeRateUpdater.Core/"] + +RUN dotnet restore "ExchangeRateUpdater.Api/ExchangeRateUpdater.Api.csproj" + +COPY . . + +WORKDIR "/src/ExchangeRateUpdater.Api" +RUN dotnet build "ExchangeRateUpdater.Api.csproj" -c Release -o /app/build + +WORKDIR "/src/ExchangeRateUpdater.Console" +RUN dotnet build "ExchangeRateUpdater.Console.csproj" -c Release -o /app/build + +FROM build AS publish + +WORKDIR "/src/ExchangeRateUpdater.Api" +RUN dotnet publish "ExchangeRateUpdater.Api.csproj" -c Release -o /app/publish /p:UseAppHost=false + +WORKDIR "/src/ExchangeRateUpdater.Console" +RUN dotnet publish "ExchangeRateUpdater.Console.csproj" -c Release -o /app/publish /p:UseAppHost=false + +FROM base AS final +WORKDIR /app +COPY --from=publish /app/publish . + +ENV ASPNETCORE_ENVIRONMENT=Production +ENV ASPNETCORE_URLS=http://+:8080 + +ENTRYPOINT ["dotnet", "ExchangeRateUpdater.Api.dll"] \ No newline at end of file diff --git a/jobs/Backend/Task/ExchangeRateUpdater.Api/ExchangeRateUpdater.Api.http b/jobs/Backend/Task/ExchangeRateUpdater.Api/ExchangeRateUpdater.Api.http deleted file mode 100644 index 99532cfcd2..0000000000 --- a/jobs/Backend/Task/ExchangeRateUpdater.Api/ExchangeRateUpdater.Api.http +++ /dev/null @@ -1,2 +0,0 @@ -@ExchangeRateUpdater.Api_HostAddress = http://localhost:5216 -### diff --git a/jobs/Backend/Task/ExchangeRateUpdater.Api/appsettings.json b/jobs/Backend/Task/ExchangeRateUpdater.Api/appsettings.json index 3b0af99d0b..060b7cd7af 100644 --- a/jobs/Backend/Task/ExchangeRateUpdater.Api/appsettings.json +++ b/jobs/Backend/Task/ExchangeRateUpdater.Api/appsettings.json @@ -7,9 +7,9 @@ } }, "ExchangeRate": { - "DefaultCacheExpiry": "01:00:00", + "DefaultCacheExpiry": "24:00:00", "MaxRetryAttempts": 3, - "RetryDelay": "00:00:05", + "RetryDelay": "00:00:02", "RequestTimeout": "00:00:30", "EnableCaching": true }, diff --git a/jobs/Backend/Task/docker-compose.dev.yml b/jobs/Backend/Task/docker-compose.dev.yml new file mode 100644 index 0000000000..08a32fb789 --- /dev/null +++ b/jobs/Backend/Task/docker-compose.dev.yml @@ -0,0 +1,17 @@ +version: '3.8' + +services: + exchange-rate-api: + build: + context: . + dockerfile: Dockerfile.api + ports: + - "5216:8080" + environment: + - ASPNETCORE_ENVIRONMENT=Development + - ASPNETCORE_URLS=http://+:8080 + + - Logging__LogLevel__Default=Information + - Logging__LogLevel__Microsoft.AspNetCore=Information + restart: unless-stopped + \ No newline at end of file diff --git a/jobs/Backend/Task/docker-compose.yml b/jobs/Backend/Task/docker-compose.yml new file mode 100644 index 0000000000..ec4fa83815 --- /dev/null +++ b/jobs/Backend/Task/docker-compose.yml @@ -0,0 +1,13 @@ +version: '3.8' + +services: + exchange-rate-api: + build: + context: . + dockerfile: Dockerfile.api + ports: + - "8080:8080" + environment: + - ASPNETCORE_ENVIRONMENT=Production + - ASPNETCORE_URLS=http://+:8080 + restart: unless-stopped From 4f4a1f5dbabf47cace1d98564e1af48a8e334393 Mon Sep 17 00:00:00 2001 From: AndreKasper Date: Sat, 27 Sep 2025 20:43:38 +0200 Subject: [PATCH 04/10] Cleanup and samll refactoring --- .../Controllers/ExchangeRatesController.cs | 15 +++-- .../Models/ApiResponse.cs | 1 + .../Task/ExchangeRateUpdater.Api/Program.cs | 15 ++--- .../Services/NoOpExchangeRateCache.cs | 3 - .../appsettings.json | 2 +- .../CoreServiceConfiguration.cs | 5 +- .../Api/ApiExchangeRateCacheTests.cs | 48 +++++++------- .../Api/ExchangeRatesControllerTests.cs | 46 +++++++------ .../Core/ExchangeRateServiceTests.cs | 64 ++++++++++--------- .../Integration/WeekendHolidayCachingTests.cs | 26 ++++---- jobs/Backend/Task/docker-compose.dev.yml | 4 +- 11 files changed, 113 insertions(+), 116 deletions(-) diff --git a/jobs/Backend/Task/ExchangeRateUpdater.Api/Controllers/ExchangeRatesController.cs b/jobs/Backend/Task/ExchangeRateUpdater.Api/Controllers/ExchangeRatesController.cs index 1c9c268ded..7c4da94af3 100644 --- a/jobs/Backend/Task/ExchangeRateUpdater.Api/Controllers/ExchangeRatesController.cs +++ b/jobs/Backend/Task/ExchangeRateUpdater.Api/Controllers/ExchangeRatesController.cs @@ -48,17 +48,19 @@ public async Task>> GetExchangeRa var currencyCodes = currencies.Split(',', StringSplitOptions.RemoveEmptyEntries).ToHashSet(); if (!TryCreateCurrencyObjects(currencyCodes, out var currencyObjects)) { - return BadRequest(CreateErrorResponse("At least one currency code must be provided", "At least one currency code must be provided")); + return BadRequest( + CreateErrorResponse("At least one currency code must be provided", "At least one currency code must be provided") + ); } var exchangeRates = await _exchangeRateService.GetExchangeRates(currencyObjects, dateValidationResult.ParsedDate.AsMaybe()); - if (!exchangeRates.Any()) { var currencyList = string.Join(", ", currencyObjects.Select(c => c.Code)); return NotFound(CreateErrorResponse( - $"No exchange rates found for the specified currencies: {currencyList}", - $"No exchange rates found for the specified currencies: {currencyList}")); + $"No results found", + $"No exchange rates found for the specified currencies: {currencyList}") + ); } var response = exchangeRates.ToExchangeRateResponse(dateValidationResult.ParsedDate ?? DateTime.Today); @@ -72,13 +74,12 @@ public async Task>> GetExchangeRa } catch (ArgumentException ex) { - _logger.LogWarning("Invalid currency code provided: {Message}", ex.Message); - return BadRequest(CreateErrorResponse($"Invalid currency code: {ex.Message}", $"Invalid currency code: {ex.Message}")); + return BadRequest(CreateErrorResponse($"Invalid currency code provided", $"Invalid currency code: {ex.Message}")); } catch (Exception ex) { _logger.LogError(ex, "Error occurred while fetching exchange rates"); - return StatusCode(StatusCodes.Status500InternalServerError, CreateErrorResponse("An error occurred while processing your request", "An error occurred while processing your request")); + return StatusCode(StatusCodes.Status500InternalServerError, CreateErrorResponse("An error occurred while processing your request", $"Error details: {ex.Message}")); } } diff --git a/jobs/Backend/Task/ExchangeRateUpdater.Api/Models/ApiResponse.cs b/jobs/Backend/Task/ExchangeRateUpdater.Api/Models/ApiResponse.cs index f47de449e7..a328e7d621 100644 --- a/jobs/Backend/Task/ExchangeRateUpdater.Api/Models/ApiResponse.cs +++ b/jobs/Backend/Task/ExchangeRateUpdater.Api/Models/ApiResponse.cs @@ -4,6 +4,7 @@ public class ApiResponse public string Message { get; set; } = string.Empty; public List Errors { get; set; } = new List(); public DateTime Timestamp { get; set; } = DateTime.UtcNow; + public int StatusCode { get; set; } } public class ApiResponse : ApiResponse diff --git a/jobs/Backend/Task/ExchangeRateUpdater.Api/Program.cs b/jobs/Backend/Task/ExchangeRateUpdater.Api/Program.cs index 60c0913da6..48042480c3 100644 --- a/jobs/Backend/Task/ExchangeRateUpdater.Api/Program.cs +++ b/jobs/Backend/Task/ExchangeRateUpdater.Api/Program.cs @@ -3,11 +3,9 @@ using ExchangeRateUpdater.Core.Interfaces; using Microsoft.Extensions.Caching.Memory; using ExchangeRateUpdater.Core.Configuration; -using Microsoft.Extensions.Options; var builder = WebApplication.CreateBuilder(args); -// Add configuration builder.Configuration .SetBasePath(Directory.GetCurrentDirectory()) .AddJsonFile("appsettings.json", optional: false, reloadOnChange: true) @@ -18,15 +16,14 @@ builder.Services.AddControllers(); builder.Services.AddMemoryCache(); builder.Services.Configure(builder.Configuration.GetSection("CacheSettings")); +var cacheSettings = builder.Configuration.GetSection("CacheSettings").Get() ?? new CacheSettings(); builder.Services.Configure(options => { - var cacheSettings = builder.Services.BuildServiceProvider().GetRequiredService>().Value; options.SizeLimit = cacheSettings.SizeLimit; options.CompactionPercentage = cacheSettings.CompactionPercentage; options.ExpirationScanFrequency = cacheSettings.ExpirationScanFrequency; }); - builder.Services.AddExchangeRateCoreDependencies(builder.Configuration); builder.Services.AddSingleton(); @@ -34,15 +31,11 @@ var app = builder.Build(); -// Configure the HTTP request pipeline -if (app.Environment.IsDevelopment()) -{ - app.MapOpenApi(); - app.UseHttpsRedirection(); -} - app.UseHttpsRedirection(); app.MapControllers(); +app.MapOpenApi(); + +app.MapGet("/", () => "Exchange Rate Updater API is running."); app.Run(); diff --git a/jobs/Backend/Task/ExchangeRateUpdater.Console/Services/NoOpExchangeRateCache.cs b/jobs/Backend/Task/ExchangeRateUpdater.Console/Services/NoOpExchangeRateCache.cs index 5758e0e6f6..4c1193ed02 100644 --- a/jobs/Backend/Task/ExchangeRateUpdater.Console/Services/NoOpExchangeRateCache.cs +++ b/jobs/Backend/Task/ExchangeRateUpdater.Console/Services/NoOpExchangeRateCache.cs @@ -5,9 +5,6 @@ namespace ExchangeRateUpdater.Console.Services; -/// -/// No-operation cache implementation for console application (caching disabled) -/// public class NoOpExchangeRateCache : IExchangeRateCache { public Task>> GetCachedRates(IEnumerable currencies, Maybe date) diff --git a/jobs/Backend/Task/ExchangeRateUpdater.Console/appsettings.json b/jobs/Backend/Task/ExchangeRateUpdater.Console/appsettings.json index fbc57ac632..747fb0974b 100644 --- a/jobs/Backend/Task/ExchangeRateUpdater.Console/appsettings.json +++ b/jobs/Backend/Task/ExchangeRateUpdater.Console/appsettings.json @@ -11,7 +11,7 @@ "MaxRetryAttempts": 3, "RetryDelay": "00:00:02", "RequestTimeout": "00:00:30", - "EnableCaching": true + "EnableCaching": false }, "CzechNationalBank": { "BaseUrl": "https://api.cnb.cz/cnbapi/exrates/daily", diff --git a/jobs/Backend/Task/ExchangeRateUpdater.Core/CoreServiceConfiguration.cs b/jobs/Backend/Task/ExchangeRateUpdater.Core/CoreServiceConfiguration.cs index 1e2b470051..2ca1f0adcc 100644 --- a/jobs/Backend/Task/ExchangeRateUpdater.Core/CoreServiceConfiguration.cs +++ b/jobs/Backend/Task/ExchangeRateUpdater.Core/CoreServiceConfiguration.cs @@ -10,16 +10,13 @@ namespace ExchangeRateUpdater.Core; -/// -/// Configuration for dependency injection in Core library -/// public static class CoreServiceConfiguration { public static IServiceCollection AddExchangeRateCoreDependencies(this IServiceCollection services, IConfiguration configuration) { services.Configure(configuration.GetSection(ExchangeRateOptions.SectionName)); services.Configure(configuration.GetSection(CzechNationalBankOptions.SectionName)); - var exchangeRateOptions = services.BuildServiceProvider().GetRequiredService>().Value; + var exchangeRateOptions = configuration.GetSection(ExchangeRateOptions.SectionName).Get() ?? new ExchangeRateOptions(); services.AddHttpClient((serviceProvider, client) => { diff --git a/jobs/Backend/Task/ExchangeRateUpdater.Tests/Api/ApiExchangeRateCacheTests.cs b/jobs/Backend/Task/ExchangeRateUpdater.Tests/Api/ApiExchangeRateCacheTests.cs index 7f41a8ce04..9d16104a31 100644 --- a/jobs/Backend/Task/ExchangeRateUpdater.Tests/Api/ApiExchangeRateCacheTests.cs +++ b/jobs/Backend/Task/ExchangeRateUpdater.Tests/Api/ApiExchangeRateCacheTests.cs @@ -13,23 +13,23 @@ public class ApiExchangeRateCacheTests { private readonly IMemoryCache _memoryCache; private readonly ILogger _logger; - private readonly ApiExchangeRateCache _cache; - private readonly DateTime TARGET_DATE = new(2025, 9, 26); + private readonly ApiExchangeRateCache _sut; + private readonly DateTime _testDate = new(2025, 9, 26); public ApiExchangeRateCacheTests() { _memoryCache = Substitute.For(); _logger = Substitute.For>(); - _cache = new ApiExchangeRateCache(_memoryCache, _logger); + _sut = new ApiExchangeRateCache(_memoryCache, _logger); } [Fact] public async Task GetCachedRates_WithNullCurrencies_ShouldThrowArgumentNullException() { // Act & Assert - var exception = await Assert.ThrowsAsync(() => - _cache.GetCachedRates(null!, DateTime.Today.AsMaybe())); - + var exception = await Assert.ThrowsAsync(() => + _sut.GetCachedRates(null!, DateTime.Today.AsMaybe())); + exception.ParamName.Should().Be("currencies"); } @@ -40,7 +40,7 @@ public async Task GetCachedRates_WithEmptyCurrencies_ShouldReturnNothing() var emptyCurrencies = Array.Empty(); // Act - var result = await _cache.GetCachedRates(emptyCurrencies, DateTime.Today.AsMaybe()); + var result = await _sut.GetCachedRates(emptyCurrencies, DateTime.Today.AsMaybe()); // Assert result.Should().Be(Maybe>.Nothing); @@ -53,12 +53,12 @@ public async Task GetCachedRates_WithCacheHit_ShouldReturnFilteredRates() var currencies = new[] { new Currency("USD"), new Currency("EUR") }; var allCachedRates = new List { - new(new Currency("USD"), new Currency("CZK"), 25.0m, TARGET_DATE), - new(new Currency("EUR"), new Currency("CZK"), 27.0m, TARGET_DATE), - new(new Currency("GBP"), new Currency("CZK"), 30.0m, TARGET_DATE) + new(new Currency("USD"), new Currency("CZK"), 25.0m, _testDate), + new(new Currency("EUR"), new Currency("CZK"), 27.0m, _testDate), + new(new Currency("GBP"), new Currency("CZK"), 30.0m, _testDate) }; - _memoryCache.TryGetValue(TARGET_DATE, out Arg.Any()) + _memoryCache.TryGetValue(_testDate, out Arg.Any()) .Returns(x => { x[1] = allCachedRates; @@ -66,7 +66,7 @@ public async Task GetCachedRates_WithCacheHit_ShouldReturnFilteredRates() }); // Act - var result = await _cache.GetCachedRates(currencies, TARGET_DATE.AsMaybe()); + var result = await _sut.GetCachedRates(currencies, _testDate.AsMaybe()); // Assert result.Should().NotBe(Maybe>.Nothing); @@ -83,11 +83,11 @@ public async Task GetCachedRates_WithCacheMiss_ShouldReturnNothing() // Arrange var currencies = new[] { new Currency("USD") }; - _memoryCache.TryGetValue(TARGET_DATE, out Arg.Any()) + _memoryCache.TryGetValue(_testDate, out Arg.Any()) .Returns(false); // Act - var result = await _cache.GetCachedRates(currencies, TARGET_DATE.AsMaybe()); + var result = await _sut.GetCachedRates(currencies, _testDate.AsMaybe()); // Assert result.Should().Be(Maybe>.Nothing); @@ -100,11 +100,11 @@ public async Task GetCachedRates_WithCacheHitButNoMatchingCurrencies_ShouldRetur var currencies = new[] { new Currency("USD") }; var allCachedRates = new List { - new(new Currency("EUR"), new Currency("CZK"), 27.0m, TARGET_DATE), - new(new Currency("GBP"), new Currency("CZK"), 30.0m, TARGET_DATE) + new(new Currency("EUR"), new Currency("CZK"), 27.0m, _testDate), + new(new Currency("GBP"), new Currency("CZK"), 30.0m, _testDate) }; - _memoryCache.TryGetValue(TARGET_DATE, out Arg.Any()) + _memoryCache.TryGetValue(_testDate, out Arg.Any()) .Returns(x => { x[1] = allCachedRates; @@ -112,7 +112,7 @@ public async Task GetCachedRates_WithCacheHitButNoMatchingCurrencies_ShouldRetur }); // Act - var result = await _cache.GetCachedRates(currencies, TARGET_DATE.AsMaybe()); + var result = await _sut.GetCachedRates(currencies, _testDate.AsMaybe()); // Assert result.Should().Be(Maybe>.Nothing); @@ -122,9 +122,9 @@ public async Task GetCachedRates_WithCacheHitButNoMatchingCurrencies_ShouldRetur public async Task CacheRates_WithNullRates_ShouldThrowArgumentNullException() { // Act & Assert - var exception = await Assert.ThrowsAsync(() => - _cache.CacheRates(null!, TimeSpan.FromHours(1))); - + var exception = await Assert.ThrowsAsync(() => + _sut.CacheRates(null!, TimeSpan.FromHours(1))); + exception.ParamName.Should().Be("rates"); } @@ -132,15 +132,15 @@ public async Task CacheRates_WithNullRates_ShouldThrowArgumentNullException() public async Task CacheRates_WithDifferentDates_ShouldCacheWithFirstRateDate() { // Arrange - var yesterday = TARGET_DATE.AddDays(-1); + var yesterday = _testDate.AddDays(-1); var rates = new List { new(new Currency("USD"), new Currency("CZK"), 25.0m, yesterday), - new(new Currency("EUR"), new Currency("CZK"), 27.0m, TARGET_DATE) + new(new Currency("EUR"), new Currency("CZK"), 27.0m, _testDate) }; // Act - await _cache.CacheRates(rates, TimeSpan.FromHours(1)); + await _sut.CacheRates(rates, TimeSpan.FromHours(1)); // Assert _memoryCache.Received(1).Set( diff --git a/jobs/Backend/Task/ExchangeRateUpdater.Tests/Api/ExchangeRatesControllerTests.cs b/jobs/Backend/Task/ExchangeRateUpdater.Tests/Api/ExchangeRatesControllerTests.cs index 2b68d0d6f0..f170533db3 100644 --- a/jobs/Backend/Task/ExchangeRateUpdater.Tests/Api/ExchangeRatesControllerTests.cs +++ b/jobs/Backend/Task/ExchangeRateUpdater.Tests/Api/ExchangeRatesControllerTests.cs @@ -1,12 +1,10 @@ using Microsoft.AspNetCore.Mvc.Testing; -using Microsoft.Extensions.DependencyInjection; +using Microsoft.AspNetCore.Mvc; using System.Net; -using System.Text.Json; using ExchangeRateUpdater.Api.Models; -using ExchangeRateUpdater.Core.Interfaces; -using ExchangeRateUpdater.Core.Models; -using ExchangeRateUpdater.Core.Common; -using Moq; +using NSubstitute; +using Microsoft.Extensions.Logging; +using ExchangeRateUpdater.Api.Controllers; namespace ExchangeRateUpdater.Tests.Api; @@ -14,51 +12,57 @@ public class ExchangeRatesControllerTests : IClassFixture _factory; private readonly HttpClient _client; + private readonly IExchangeRateService _exchangeRateService; + private readonly ILogger _logger; + private readonly ExchangeRatesController _sut; public ExchangeRatesControllerTests(WebApplicationFactory factory) { _factory = factory; _client = _factory.CreateClient(); + _exchangeRateService = Substitute.For(); + _logger = Substitute.For>(); + + _sut = new ExchangeRatesController(_exchangeRateService, _logger); } [Fact] public async Task GetExchangeRates_ValidCurrencies_ShouldReturnOk() { // Act - var response = await _client.GetAsync("/api/exchangerates?currencies=USD,EUR"); + var response = await _sut.GetExchangeRates("USD,EUR"); // Assert - Assert.Equal(HttpStatusCode.OK, response.StatusCode); + var result = response.Result as OkObjectResult; + Assert.Equal((int)HttpStatusCode.OK, result?.StatusCode); - var content = await response.Content.ReadAsStringAsync(); - var result = JsonSerializer.Deserialize>(content, new JsonSerializerOptions - { - PropertyNameCaseInsensitive = true - }); + var apiResponse = result?.Value as ApiResponse; - Assert.NotNull(result); - Assert.True(result.Success); - Assert.NotNull(result.Data); - Assert.NotEmpty(result.Data.Rates); + Assert.NotNull(apiResponse); + Assert.True(apiResponse.Success); + Assert.NotNull(apiResponse.Data); + Assert.NotEmpty(apiResponse.Data.Rates); } [Fact] public async Task GetExchangeRates_EmptyCurrencies_ShouldReturnBadRequest() { // Act - var response = await _client.GetAsync("/api/exchangerates?currencies="); + var response = await _sut.GetExchangeRates(""); // Assert - Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); + var result = response.Result as BadRequestObjectResult; + Assert.Equal((int)HttpStatusCode.BadRequest, result?.StatusCode); } [Fact] public async Task GetExchangeRates_NoCurrenciesParameter_ShouldReturnBadRequest() { // Act - var response = await _client.GetAsync("/api/exchangerates"); + var response = await _sut.GetExchangeRates(""); // Assert - Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); + var result = response.Result as BadRequestObjectResult; + Assert.Equal((int)HttpStatusCode.BadRequest, result?.StatusCode); } } diff --git a/jobs/Backend/Task/ExchangeRateUpdater.Tests/Core/ExchangeRateServiceTests.cs b/jobs/Backend/Task/ExchangeRateUpdater.Tests/Core/ExchangeRateServiceTests.cs index 4896b4d009..4d988182dd 100644 --- a/jobs/Backend/Task/ExchangeRateUpdater.Tests/Core/ExchangeRateServiceTests.cs +++ b/jobs/Backend/Task/ExchangeRateUpdater.Tests/Core/ExchangeRateServiceTests.cs @@ -6,24 +6,26 @@ using ExchangeRateUpdater.Core.Services; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; -using Moq; +using FluentAssertions; +using NSubstitute; namespace ExchangeRateUpdater.Tests.Core; public class ExchangeRateServiceTests { - private readonly Mock _mockProvider; - private readonly Mock _mockCache; - private readonly Mock> _mockLogger; - private readonly Mock> _mockOptions; - private readonly ExchangeRateService _service; + private readonly IExchangeRateProvider _exchangeProvider; + private readonly IExchangeRateCache _exchangeCache; + private readonly ILogger _exchangeRateservice; + private readonly IOptions exchangeOptions; + private readonly ExchangeRateService _sut; + private readonly DateTime _testDate = new DateTime(2025, 9, 26); public ExchangeRateServiceTests() { - _mockProvider = new Mock(); - _mockCache = new Mock(); - _mockLogger = new Mock>(); - _mockOptions = new Mock>(); + _exchangeProvider = Substitute.For(); + _exchangeCache = Substitute.For(); + _exchangeRateservice = Substitute.For>(); + exchangeOptions = Substitute.For>(); var options = new ExchangeRateOptions { @@ -31,9 +33,9 @@ public ExchangeRateServiceTests() DefaultCacheExpiry = TimeSpan.FromHours(1) }; - _mockOptions.Setup(x => x.Value).Returns(options); + exchangeOptions.Value.Returns(options); - _service = new ExchangeRateService(_mockProvider.Object, _mockCache.Object, _mockLogger.Object, _mockOptions.Object); + _sut = new ExchangeRateService(_exchangeProvider, _exchangeCache, _exchangeRateservice, exchangeOptions); } [Fact] @@ -43,19 +45,19 @@ public async Task GetExchangeRatesAsync_WithCachedRates_ShouldReturnCachedRates( var currencies = new[] { new Currency("USD"), new Currency("EUR") }; var cachedRates = new[] { - new ExchangeRate(new Currency("USD"), new Currency("CZK"), 25.0m, DateTime.Today), - new ExchangeRate(new Currency("EUR"), new Currency("CZK"), 27.0m, DateTime.Today) + new ExchangeRate(new Currency("USD"), new Currency("CZK"), 25.0m, _testDate), + new ExchangeRate(new Currency("EUR"), new Currency("CZK"), 27.0m, _testDate) }; - _mockCache.Setup(x => x.GetCachedRates(It.IsAny>(), It.IsAny>())) - .ReturnsAsync(((IReadOnlyList)cachedRates).AsMaybe()); + _exchangeCache.GetCachedRates(Arg.Any>(), Arg.Any>()) + .Returns(((IReadOnlyList)cachedRates).AsMaybe()); // Act - var result = await _service.GetExchangeRates(currencies, DateTime.Today.AsMaybe()); + var result = await _sut.GetExchangeRates(currencies, _testDate.AsMaybe()); // Assert - Assert.Equal(2, result.Count()); - _mockProvider.Verify(x => x.GetExchangeRatesForDate(It.IsAny>()), Times.Never); + result.Should().HaveCount(2); + await _exchangeProvider.Received(1).GetExchangeRatesForDate(Arg.Any>()); } [Fact] @@ -65,22 +67,22 @@ public async Task GetExchangeRatesAsync_WithoutCachedRates_ShouldFetchFromProvid var currencies = new[] { new Currency("USD") }; var providerRates = new[] { - new ExchangeRate(new Currency("USD"), new Currency("CZK"), 25.0m, DateTime.Today) + new ExchangeRate(new Currency("USD"), new Currency("CZK"), 25.0m, _testDate) }; - _mockCache.Setup(x => x.GetCachedRates(It.IsAny>(), It.IsAny>())) - .ReturnsAsync(Maybe>.Nothing); + _exchangeCache.GetCachedRates(Arg.Any>(), Arg.Any>()) + .Returns(Maybe>.Nothing); - _mockProvider.Setup(x => x.GetExchangeRatesForDate(It.IsAny>())) - .ReturnsAsync(((IReadOnlyCollection)providerRates).AsMaybe()); + _exchangeProvider.GetExchangeRatesForDate(Arg.Any>()) + .Returns(((IReadOnlyCollection)providerRates).AsMaybe()); // Act - var result = await _service.GetExchangeRates(currencies, DateTime.Today.AsMaybe()); + var result = await _sut.GetExchangeRates(currencies, _testDate.AsMaybe()); // Assert - Assert.Single(result); - Assert.Equal("USD", result.First().SourceCurrency.Code); - _mockCache.Verify(x => x.CacheRates(It.IsAny>(), It.IsAny()), Times.Once); + result.Should().HaveCount(1); + result.First().SourceCurrency.Code.Should().Be("USD"); + await _exchangeCache.Received(1).CacheRates(Arg.Any>(), Arg.Any()); } [Fact] @@ -88,7 +90,7 @@ public async Task GetExchangeRatesAsync_WithNullCurrencies_ShouldThrowArgumentNu { // Act & Assert await Assert.ThrowsAsync(() => - _service.GetExchangeRates(null!, DateTime.Today.AsMaybe())); + _sut.GetExchangeRates(null!, _testDate.AsMaybe())); } [Fact] @@ -98,9 +100,9 @@ public async Task GetExchangeRatesAsync_WithEmptyCurrencies_ShouldReturnEmpty() var emptyCurrencies = Array.Empty(); // Act - var result = await _service.GetExchangeRates(emptyCurrencies, DateTime.Today.AsMaybe()); + var result = await _sut.GetExchangeRates(emptyCurrencies, _testDate.AsMaybe()); // Assert - Assert.Empty(result); + result.Should().BeEmpty(); } } diff --git a/jobs/Backend/Task/ExchangeRateUpdater.Tests/Integration/WeekendHolidayCachingTests.cs b/jobs/Backend/Task/ExchangeRateUpdater.Tests/Integration/WeekendHolidayCachingTests.cs index b9811d3fc4..17181b23e5 100644 --- a/jobs/Backend/Task/ExchangeRateUpdater.Tests/Integration/WeekendHolidayCachingTests.cs +++ b/jobs/Backend/Task/ExchangeRateUpdater.Tests/Integration/WeekendHolidayCachingTests.cs @@ -13,13 +13,13 @@ public class WeekendHolidayCachingTests { private readonly IMemoryCache _memoryCache; private readonly ILogger _logger; - private readonly ApiExchangeRateCache _cache; + private readonly ApiExchangeRateCache _sut; public WeekendHolidayCachingTests() { _memoryCache = new MemoryCache(new MemoryCacheOptions()); _logger = Substitute.For>(); - _cache = new ApiExchangeRateCache(_memoryCache, _logger); + _sut = new ApiExchangeRateCache(_memoryCache, _logger); } [Fact] @@ -34,11 +34,11 @@ public async Task GetCachedRates_OnWeekend_ShouldReturnPreviousBusinessDayRates( new(new Currency("EUR"), new Currency("CZK"), 27.0m, friday) }; - await _cache.CacheRates(rates, TimeSpan.FromHours(1)); + await _sut.CacheRates(rates, TimeSpan.FromHours(1)); // Act - var result = await _cache.GetCachedRates( - new[] { new Currency("USD"), new Currency("EUR") }, + var result = await _sut.GetCachedRates( + [new Currency("USD"), new Currency("EUR")], saturday.AsMaybe()); // Assert @@ -64,8 +64,8 @@ public async Task CacheRates_WithDifferentDates_ShouldCacheSeparately() }; // Act - await _cache.CacheRates(mondayRates, TimeSpan.FromHours(1)); - await _cache.CacheRates(tuesdayRates, TimeSpan.FromHours(1)); + await _sut.CacheRates(mondayRates, TimeSpan.FromHours(1)); + await _sut.CacheRates(tuesdayRates, TimeSpan.FromHours(1)); // Assert var cachedMondayRates = _memoryCache.Get>(monday); @@ -91,11 +91,11 @@ public async Task GetCachedRates_WithPartialCurrencyMatch_ShouldReturnOnlyMatchi new(new Currency("GBP"), new Currency("CZK"), 30.0m, businessDay) }; - await _cache.CacheRates(rates, TimeSpan.FromHours(1)); + await _sut.CacheRates(rates, TimeSpan.FromHours(1)); // Act - var result = await _cache.GetCachedRates( - new[] { new Currency("USD"), new Currency("GBP") }, + var result = await _sut.GetCachedRates( + [new Currency("USD"), new Currency("GBP")], businessDay.AsMaybe()); // Assert @@ -117,11 +117,11 @@ public async Task GetCachedRates_WithCaseInsensitiveCurrencyMatch_ShouldReturnRa new(new Currency("USD"), new Currency("CZK"), 25.0m, businessDay) }; - await _cache.CacheRates(rates, TimeSpan.FromHours(1)); + await _sut.CacheRates(rates, TimeSpan.FromHours(1)); // Act - var result = await _cache.GetCachedRates( - new[] { new Currency("usd") }, + var result = await _sut.GetCachedRates( + [new Currency("usd")], businessDay.AsMaybe()); // Assert diff --git a/jobs/Backend/Task/docker-compose.dev.yml b/jobs/Backend/Task/docker-compose.dev.yml index 08a32fb789..503b33cb6c 100644 --- a/jobs/Backend/Task/docker-compose.dev.yml +++ b/jobs/Backend/Task/docker-compose.dev.yml @@ -11,7 +11,9 @@ services: - ASPNETCORE_ENVIRONMENT=Development - ASPNETCORE_URLS=http://+:8080 + - ExchangeRate__DefaultCacheExpiry=00:05:00 + - ExchangeRate__MaxRetryAttempts=1 + - Logging__LogLevel__Default=Information - Logging__LogLevel__Microsoft.AspNetCore=Information restart: unless-stopped - \ No newline at end of file From bdfacbf59940246f00ebba731f5a1fd0980ed04d Mon Sep 17 00:00:00 2001 From: AndreKasper Date: Sun, 28 Sep 2025 01:45:51 +0200 Subject: [PATCH 05/10] Add middleware for exceptions and some small refactors --- .../Controllers/ExchangeRatesController.cs | 94 ++++------- .../Extensions/MiddlewareExtensions.cs | 9 + .../GlobalExceptionHandlingMiddleware.cs | 74 +++++++++ .../Task/ExchangeRateUpdater.Api/Program.cs | 2 + .../Services/ApiExchangeRateCache.cs | 24 +-- .../appsettings.Development.json | 1 + .../ExchangeRateUpdater.Api/appsettings.json | 4 +- .../ExchangeRateUpdater.Console/Program.cs | 6 +- .../appsettings.json | 1 - .../Configuration/CacheSettings.cs | 1 + .../Configuration/ExchangeRateOptions.cs | 1 - .../Interfaces/IExchangeRateCache.cs | 13 -- .../Services/ExchangeRateService.cs | 15 +- .../Api/ExchangeRatesControllerTests.cs | 99 +++++++---- .../GlobalExceptionHandlingMiddlewareTests.cs | 154 ++++++++++++++++++ .../Core/ExchangeRateServiceTests.cs | 19 ++- jobs/Backend/Task/README.md | 138 +++------------- jobs/Backend/Task/docker-compose.dev.yml | 2 - jobs/Backend/Task/docker-compose.yml | 2 - 19 files changed, 401 insertions(+), 258 deletions(-) create mode 100644 jobs/Backend/Task/ExchangeRateUpdater.Api/Extensions/MiddlewareExtensions.cs create mode 100644 jobs/Backend/Task/ExchangeRateUpdater.Api/Middleware/GlobalExceptionHandlingMiddleware.cs create mode 100644 jobs/Backend/Task/ExchangeRateUpdater.Tests/Api/Middleware/GlobalExceptionHandlingMiddlewareTests.cs diff --git a/jobs/Backend/Task/ExchangeRateUpdater.Api/Controllers/ExchangeRatesController.cs b/jobs/Backend/Task/ExchangeRateUpdater.Api/Controllers/ExchangeRatesController.cs index 7c4da94af3..f54347053e 100644 --- a/jobs/Backend/Task/ExchangeRateUpdater.Api/Controllers/ExchangeRatesController.cs +++ b/jobs/Backend/Task/ExchangeRateUpdater.Api/Controllers/ExchangeRatesController.cs @@ -37,83 +37,55 @@ public async Task>> GetExchangeRa [FromQuery] string currencies, [FromQuery] string? date = null) { - try - { - var dateValidationResult = ValidateAndParseDate(date); - if (dateValidationResult.HasError) - { - return BadRequest(dateValidationResult.ErrorResponse); - } - - var currencyCodes = currencies.Split(',', StringSplitOptions.RemoveEmptyEntries).ToHashSet(); - if (!TryCreateCurrencyObjects(currencyCodes, out var currencyObjects)) - { - return BadRequest( - CreateErrorResponse("At least one currency code must be provided", "At least one currency code must be provided") - ); - } - - var exchangeRates = await _exchangeRateService.GetExchangeRates(currencyObjects, dateValidationResult.ParsedDate.AsMaybe()); - if (!exchangeRates.Any()) - { - var currencyList = string.Join(", ", currencyObjects.Select(c => c.Code)); - return NotFound(CreateErrorResponse( - $"No results found", - $"No exchange rates found for the specified currencies: {currencyList}") - ); - } - - var response = exchangeRates.ToExchangeRateResponse(dateValidationResult.ParsedDate ?? DateTime.Today); + var parsedDate = ValidateAndParseDate(date); + var currencyObjects = ValidateAndrParseCurrencies(currencies); - return Ok(new ApiResponse + var exchangeRates = await _exchangeRateService.GetExchangeRates(currencyObjects, parsedDate.AsMaybe()); + if (!exchangeRates.Any()) + { + var currencyList = string.Join(", ", currencyObjects.Select(c => c.Code)); + return NotFound(new ApiResponse { - Data = response, - Success = true, - Message = "Exchange rates retrieved successfully" + Success = false, + Message = "No results found", + Errors = new List { $"No exchange rates found for the specified currencies: {currencyList}" } }); } - catch (ArgumentException ex) - { - return BadRequest(CreateErrorResponse($"Invalid currency code provided", $"Invalid currency code: {ex.Message}")); - } - catch (Exception ex) + + return Ok(new ApiResponse { - _logger.LogError(ex, "Error occurred while fetching exchange rates"); - return StatusCode(StatusCodes.Status500InternalServerError, CreateErrorResponse("An error occurred while processing your request", $"Error details: {ex.Message}")); - } + Data = exchangeRates.ToExchangeRateResponse(parsedDate ?? DateTime.Today), + Success = true, + Message = "Exchange rates retrieved successfully" + }); } - private static bool TryCreateCurrencyObjects(HashSet currencyCodes, out IEnumerable currencies) + private static DateTime? ValidateAndParseDate(string? date) { - currencies = new List(); - if (!currencyCodes.Any()) - return false; - - currencies = currencyCodes.Select(code => new Currency(code.Trim().ToUpperInvariant())); - return true; - } + if (string.IsNullOrEmpty(date)) + { + return null; + } - private static ApiResponse CreateErrorResponse(string message, string error) - { - return new ApiResponse + if (!DateTime.TryParseExact(date, "yyyy-MM-dd", null, System.Globalization.DateTimeStyles.None, out var validDate)) { - Message = message, - Errors = new List { error }, - Success = false - }; + throw new ArgumentException($"Invalid date format. Expected format: YYYY-MM-DD (e.g., 2024-01-15). Received: '{date}'"); + } + + return validDate; } - private static (bool HasError, ApiResponse? ErrorResponse, DateTime? ParsedDate) ValidateAndParseDate(string? date) + private static IEnumerable ValidateAndrParseCurrencies(string currencies) { - if (string.IsNullOrEmpty(date)) - return (false, null, null); + var currencyCodes = currencies.Split(',', StringSplitOptions.RemoveEmptyEntries) + .Select(code => code.Trim().ToUpperInvariant()) + .ToHashSet(); - if (!DateTime.TryParseExact(date, "yyyy-MM-dd", null, System.Globalization.DateTimeStyles.None, out var validDate)) + if (!currencyCodes.Any()) { - var errorMessage = $"Invalid date format. Expected format: YYYY-MM-DD (e.g., 2024-01-15). Received: '{date}'"; - return (true, CreateErrorResponse(errorMessage, errorMessage), null); + throw new ArgumentException("At least one currency code must be provided"); } - return (false, null, validDate); + return currencyCodes.Select(code => new Currency(code)); } } diff --git a/jobs/Backend/Task/ExchangeRateUpdater.Api/Extensions/MiddlewareExtensions.cs b/jobs/Backend/Task/ExchangeRateUpdater.Api/Extensions/MiddlewareExtensions.cs new file mode 100644 index 0000000000..f38335de46 --- /dev/null +++ b/jobs/Backend/Task/ExchangeRateUpdater.Api/Extensions/MiddlewareExtensions.cs @@ -0,0 +1,9 @@ +namespace ExchangeRateUpdater.Api.Middleware; + +public static class MiddlewareExtensions +{ + public static IApplicationBuilder UseGlobalExceptionHandling(this IApplicationBuilder builder) + { + return builder.UseMiddleware(); + } +} \ No newline at end of file diff --git a/jobs/Backend/Task/ExchangeRateUpdater.Api/Middleware/GlobalExceptionHandlingMiddleware.cs b/jobs/Backend/Task/ExchangeRateUpdater.Api/Middleware/GlobalExceptionHandlingMiddleware.cs new file mode 100644 index 0000000000..6efaa08a5f --- /dev/null +++ b/jobs/Backend/Task/ExchangeRateUpdater.Api/Middleware/GlobalExceptionHandlingMiddleware.cs @@ -0,0 +1,74 @@ +using System.Net; +using System.Text.Json; +using ExchangeRateUpdater.Core.Common; + +namespace ExchangeRateUpdater.Api.Middleware; + +public class GlobalExceptionHandlingMiddleware +{ + private readonly RequestDelegate _next; + private readonly ILogger _logger; + private readonly IHostEnvironment _environment; + + public GlobalExceptionHandlingMiddleware( + RequestDelegate next, + ILogger logger, + IHostEnvironment environment) + { + _next = next; + _logger = logger; + _environment = environment; + } + + public async Task InvokeAsync(HttpContext context) + { + try + { + await _next(context); + } + catch (Exception ex) + { + await HandleExceptionAsync(context, ex); + } + } + + private async Task HandleExceptionAsync(HttpContext context, Exception exception) + { + _logger.LogError(exception, "An unhandled exception occurred."); + + var response = context.Response; + response.ContentType = "application/json"; + + var apiResponse = new ApiResponse + { + Success = false, + Message = "An error occurred while processing your request", + Errors = new List() + }; + + switch (exception) + { + case ExchangeRateProviderException: + response.StatusCode = (int)HttpStatusCode.BadGateway; + apiResponse.Message = "Exchange rate provider error"; + apiResponse.Errors.Add(exception.Message); + break; + + case ArgumentException: + response.StatusCode = (int)HttpStatusCode.BadRequest; + apiResponse.Message = "Invalid input"; + apiResponse.Errors.Add(exception.Message); + break; + + default: + response.StatusCode = (int)HttpStatusCode.InternalServerError; + apiResponse.Errors.Add(_environment.IsDevelopment() + ? exception.ToString() + : "An unexpected error occurred. Please try again later."); + break; + } + + var result = JsonSerializer.Serialize(apiResponse); + await response.WriteAsync(result); + } +} diff --git a/jobs/Backend/Task/ExchangeRateUpdater.Api/Program.cs b/jobs/Backend/Task/ExchangeRateUpdater.Api/Program.cs index 48042480c3..68b2d0026b 100644 --- a/jobs/Backend/Task/ExchangeRateUpdater.Api/Program.cs +++ b/jobs/Backend/Task/ExchangeRateUpdater.Api/Program.cs @@ -3,6 +3,7 @@ using ExchangeRateUpdater.Core.Interfaces; using Microsoft.Extensions.Caching.Memory; using ExchangeRateUpdater.Core.Configuration; +using ExchangeRateUpdater.Api.Middleware; var builder = WebApplication.CreateBuilder(args); @@ -31,6 +32,7 @@ var app = builder.Build(); +app.UseGlobalExceptionHandling(); app.UseHttpsRedirection(); app.MapControllers(); app.MapOpenApi(); diff --git a/jobs/Backend/Task/ExchangeRateUpdater.Api/Services/ApiExchangeRateCache.cs b/jobs/Backend/Task/ExchangeRateUpdater.Api/Services/ApiExchangeRateCache.cs index ab8b8abc06..d268c28054 100644 --- a/jobs/Backend/Task/ExchangeRateUpdater.Api/Services/ApiExchangeRateCache.cs +++ b/jobs/Backend/Task/ExchangeRateUpdater.Api/Services/ApiExchangeRateCache.cs @@ -7,9 +7,7 @@ namespace ExchangeRateUpdater.Api.Services; -/// -/// Enhanced memory cache implementation for the API that caches all exchange rates per the date returned by CNB's API -/// +// Memory cache implementation for exchange rates that handles business day logic and provides efficient currency filtering. public class ApiExchangeRateCache : IExchangeRateCache { private readonly IMemoryCache _memoryCache; @@ -41,7 +39,7 @@ public Task>> GetCachedRates(IEnumerable)filteredRates).AsMaybe().AsTask(); + return filteredRates.AsReadOnlyList().AsMaybe().AsTask(); } } @@ -63,7 +61,7 @@ public Task CacheRates(IReadOnlyCollection rates, TimeSpan cacheEx var cacheOptions = new MemoryCacheEntryOptions { AbsoluteExpirationRelativeToNow = cacheExpiry, - SlidingExpiration = cacheExpiry / 2, // Refresh cache if accessed within half the expiry time + SlidingExpiration = cacheExpiry / 2, Size = rates.Count }; @@ -73,27 +71,17 @@ public Task CacheRates(IReadOnlyCollection rates, TimeSpan cacheEx return Task.CompletedTask; } - - public Task ClearCache() - { - if (_memoryCache is MemoryCache mc) - { - mc.Clear(); - _logger.LogInformation("Cleared all cached entries"); - } - return Task.CompletedTask; - } - + private static DateTime GetBusinessDayForCacheCheck(Maybe date) { if (!date.TryGetValue(out var dateValue)) dateValue = DateTime.Today; - + while (new CzechRepublicPublicHoliday().IsPublicHoliday(dateValue) || dateValue.DayOfWeek is DayOfWeek.Saturday or DayOfWeek.Sunday) { dateValue = dateValue.AddDays(-1); } - + return dateValue; } } diff --git a/jobs/Backend/Task/ExchangeRateUpdater.Api/appsettings.Development.json b/jobs/Backend/Task/ExchangeRateUpdater.Api/appsettings.Development.json index a274bdce4c..08717cfc9e 100644 --- a/jobs/Backend/Task/ExchangeRateUpdater.Api/appsettings.Development.json +++ b/jobs/Backend/Task/ExchangeRateUpdater.Api/appsettings.Development.json @@ -6,6 +6,7 @@ } }, "CacheSettings": { + "DefaultCacheExpiry": "00:05:00", "SizeLimit": 5, "CompactionPercentage": 0.5, "ExpirationScanFrequency": "00:01:00" diff --git a/jobs/Backend/Task/ExchangeRateUpdater.Api/appsettings.json b/jobs/Backend/Task/ExchangeRateUpdater.Api/appsettings.json index 060b7cd7af..156e441f14 100644 --- a/jobs/Backend/Task/ExchangeRateUpdater.Api/appsettings.json +++ b/jobs/Backend/Task/ExchangeRateUpdater.Api/appsettings.json @@ -7,7 +7,6 @@ } }, "ExchangeRate": { - "DefaultCacheExpiry": "24:00:00", "MaxRetryAttempts": 3, "RetryDelay": "00:00:02", "RequestTimeout": "00:00:30", @@ -19,8 +18,9 @@ "Language": "EN" }, "CacheSettings": { + "DefaultCacheExpiry": "24:00:00", "SizeLimit": 1000, "CompactionPercentage": 0.25, - "ExpirationScanFrequency": "00:05:00" + "ExpirationScanFrequency": "00:20:00" } } \ No newline at end of file diff --git a/jobs/Backend/Task/ExchangeRateUpdater.Console/Program.cs b/jobs/Backend/Task/ExchangeRateUpdater.Console/Program.cs index b9ff64357d..0170aa2102 100644 --- a/jobs/Backend/Task/ExchangeRateUpdater.Console/Program.cs +++ b/jobs/Backend/Task/ExchangeRateUpdater.Console/Program.cs @@ -12,8 +12,8 @@ namespace ExchangeRateUpdater.Console; public static class Program { - private static readonly IEnumerable DefaultCurrenciesList = new[] - { + private static readonly IEnumerable DefaultCurrenciesList = + [ new Currency("USD"), new Currency("EUR"), new Currency("CZK"), @@ -23,7 +23,7 @@ public static class Program new Currency("THB"), new Currency("TRY"), new Currency("XYZ"), - }; + ]; public static async Task Main(string[] args) { diff --git a/jobs/Backend/Task/ExchangeRateUpdater.Console/appsettings.json b/jobs/Backend/Task/ExchangeRateUpdater.Console/appsettings.json index 747fb0974b..d3d26cb538 100644 --- a/jobs/Backend/Task/ExchangeRateUpdater.Console/appsettings.json +++ b/jobs/Backend/Task/ExchangeRateUpdater.Console/appsettings.json @@ -7,7 +7,6 @@ } }, "ExchangeRate": { - "DefaultCacheExpiry": "01:00:00", "MaxRetryAttempts": 3, "RetryDelay": "00:00:02", "RequestTimeout": "00:00:30", diff --git a/jobs/Backend/Task/ExchangeRateUpdater.Core/Configuration/CacheSettings.cs b/jobs/Backend/Task/ExchangeRateUpdater.Core/Configuration/CacheSettings.cs index 2320aa94ea..ce48844549 100644 --- a/jobs/Backend/Task/ExchangeRateUpdater.Core/Configuration/CacheSettings.cs +++ b/jobs/Backend/Task/ExchangeRateUpdater.Core/Configuration/CacheSettings.cs @@ -2,6 +2,7 @@ namespace ExchangeRateUpdater.Core.Configuration; public class CacheSettings { + public TimeSpan DefaultCacheExpiry { get; set; } = TimeSpan.FromHours(12); public int SizeLimit { get; set; } = 1000; public double CompactionPercentage { get; set; } = 0.25; public TimeSpan ExpirationScanFrequency { get; set; } = TimeSpan.FromMinutes(5); diff --git a/jobs/Backend/Task/ExchangeRateUpdater.Core/Configuration/ExchangeRateOptions.cs b/jobs/Backend/Task/ExchangeRateUpdater.Core/Configuration/ExchangeRateOptions.cs index 007e1a2961..dec7d5d651 100644 --- a/jobs/Backend/Task/ExchangeRateUpdater.Core/Configuration/ExchangeRateOptions.cs +++ b/jobs/Backend/Task/ExchangeRateUpdater.Core/Configuration/ExchangeRateOptions.cs @@ -3,7 +3,6 @@ namespace ExchangeRateUpdater.Core.Configuration; public class ExchangeRateOptions { public const string SectionName = "ExchangeRate"; - public TimeSpan DefaultCacheExpiry { get; set; } = TimeSpan.FromHours(2); public int MaxRetryAttempts { get; set; } = 3; public TimeSpan RetryDelay { get; set; } = TimeSpan.FromSeconds(2); public TimeSpan RequestTimeout { get; set; } = TimeSpan.FromSeconds(30); diff --git a/jobs/Backend/Task/ExchangeRateUpdater.Core/Interfaces/IExchangeRateCache.cs b/jobs/Backend/Task/ExchangeRateUpdater.Core/Interfaces/IExchangeRateCache.cs index 9a879ae7b3..dff81fe274 100644 --- a/jobs/Backend/Task/ExchangeRateUpdater.Core/Interfaces/IExchangeRateCache.cs +++ b/jobs/Backend/Task/ExchangeRateUpdater.Core/Interfaces/IExchangeRateCache.cs @@ -5,19 +5,6 @@ namespace ExchangeRateUpdater.Core.Interfaces; public interface IExchangeRateCache { - /// - /// Gets cached exchange rates for the specified currencies - /// - /// The currencies to get cached rates for - /// Maybe containing cached exchange rates Task>> GetCachedRates(IEnumerable currencies, Maybe date); - - /// - /// Caches exchange rates - /// - /// The rates to cache - /// How long to cache the rates for Task CacheRates(IReadOnlyCollection rates, TimeSpan cacheExpiry); - - Task ClearCache(); } diff --git a/jobs/Backend/Task/ExchangeRateUpdater.Core/Services/ExchangeRateService.cs b/jobs/Backend/Task/ExchangeRateUpdater.Core/Services/ExchangeRateService.cs index ac603f887f..3f75abbd16 100644 --- a/jobs/Backend/Task/ExchangeRateUpdater.Core/Services/ExchangeRateService.cs +++ b/jobs/Backend/Task/ExchangeRateUpdater.Core/Services/ExchangeRateService.cs @@ -12,18 +12,21 @@ public class ExchangeRateService : IExchangeRateService private readonly IExchangeRateProvider _provider; private readonly IExchangeRateCache _cache; private readonly ILogger _logger; - private readonly ExchangeRateOptions _options; + private readonly ExchangeRateOptions _exchangeOptions; + private readonly CacheSettings _cacheSettings; public ExchangeRateService( IExchangeRateProvider provider, IExchangeRateCache cache, ILogger logger, - IOptions options) + IOptions options, + IOptions cacheSettings) { _provider = provider ?? throw new ArgumentNullException(nameof(provider)); _cache = cache ?? throw new ArgumentNullException(nameof(cache)); _logger = logger ?? throw new ArgumentNullException(nameof(logger)); - _options = options?.Value ?? throw new ArgumentNullException(nameof(options)); + _exchangeOptions = options?.Value ?? throw new ArgumentNullException(nameof(options)); + _cacheSettings = cacheSettings?.Value ?? throw new ArgumentNullException(nameof(cacheSettings)); } public async Task> GetExchangeRates( @@ -43,7 +46,7 @@ Maybe date try { - if (_options.EnableCaching) + if (_exchangeOptions.EnableCaching) { var cachedRates = await _cache.GetCachedRates(currencyList, targetDate); if (cachedRates.HasValue) @@ -61,9 +64,9 @@ Maybe date { if (rateList.Any()) { - if (_options.EnableCaching) + if (_exchangeOptions.EnableCaching) { - await _cache.CacheRates(rateList, _options.DefaultCacheExpiry); + await _cache.CacheRates(rateList, _cacheSettings.DefaultCacheExpiry); } _logger.LogInformation($"Successfully retrieved {rateList.Count()} exchange rates"); diff --git a/jobs/Backend/Task/ExchangeRateUpdater.Tests/Api/ExchangeRatesControllerTests.cs b/jobs/Backend/Task/ExchangeRateUpdater.Tests/Api/ExchangeRatesControllerTests.cs index f170533db3..c9d911e714 100644 --- a/jobs/Backend/Task/ExchangeRateUpdater.Tests/Api/ExchangeRatesControllerTests.cs +++ b/jobs/Backend/Task/ExchangeRateUpdater.Tests/Api/ExchangeRatesControllerTests.cs @@ -1,68 +1,107 @@ -using Microsoft.AspNetCore.Mvc.Testing; using Microsoft.AspNetCore.Mvc; -using System.Net; +using ExchangeRateUpdater.Api.Controllers; using ExchangeRateUpdater.Api.Models; -using NSubstitute; +using ExchangeRateUpdater.Core.Common; +using ExchangeRateUpdater.Core.Models; using Microsoft.Extensions.Logging; -using ExchangeRateUpdater.Api.Controllers; +using NSubstitute; +using FluentAssertions; namespace ExchangeRateUpdater.Tests.Api; -public class ExchangeRatesControllerTests : IClassFixture> +public class ExchangeRatesControllerTests { - private readonly WebApplicationFactory _factory; - private readonly HttpClient _client; private readonly IExchangeRateService _exchangeRateService; private readonly ILogger _logger; private readonly ExchangeRatesController _sut; - public ExchangeRatesControllerTests(WebApplicationFactory factory) + public ExchangeRatesControllerTests() { - _factory = factory; - _client = _factory.CreateClient(); _exchangeRateService = Substitute.For(); _logger = Substitute.For>(); - _sut = new ExchangeRatesController(_exchangeRateService, _logger); } [Fact] - public async Task GetExchangeRates_ValidCurrencies_ShouldReturnOk() + public async Task GetExchangeRates_ValidRequest_ReturnsOkResult() { + // Arrange + var expectedRates = new[] + { + new ExchangeRate(new Currency("CZK"), new Currency("USD"), 21.5m, DateTime.Today), + new ExchangeRate(new Currency("CZK"), new Currency("EUR"), 25.5m, DateTime.Today) + }; + + _exchangeRateService + .GetExchangeRates( + Arg.Is>(c => c.Any(x => x.Code == "USD" || x.Code == "EUR")), + Arg.Any>()) + .Returns(expectedRates); + // Act - var response = await _sut.GetExchangeRates("USD,EUR"); + var result = await _sut.GetExchangeRates("USD,EUR"); // Assert - var result = response.Result as OkObjectResult; - Assert.Equal((int)HttpStatusCode.OK, result?.StatusCode); + result.Result.Should().BeOfType() + .Which.Value.Should().BeOfType>() + .Which.Should().Match>(r => + r.Success && + r.Data != null && + r.Data.Rates.Count() == 2); + } - var apiResponse = result?.Value as ApiResponse; + [Fact] + public async Task GetExchangeRates_EmptyCurrencies_ThrowsArgumentException() + { + // Act & Assert + var action = () => _sut.GetExchangeRates(""); - Assert.NotNull(apiResponse); - Assert.True(apiResponse.Success); - Assert.NotNull(apiResponse.Data); - Assert.NotEmpty(apiResponse.Data.Rates); + await action.Should().ThrowAsync() + .WithMessage("At least one currency code must be provided"); } [Fact] - public async Task GetExchangeRates_EmptyCurrencies_ShouldReturnBadRequest() + public async Task GetExchangeRates_InvalidDate_ThrowsArgumentException() { - // Act - var response = await _sut.GetExchangeRates(""); + // Act & Assert + var action = () => _sut.GetExchangeRates("USD", "invalid-date"); - // Assert - var result = response.Result as BadRequestObjectResult; - Assert.Equal((int)HttpStatusCode.BadRequest, result?.StatusCode); + await action.Should().ThrowAsync() + .WithMessage("*Invalid date format*"); } [Fact] - public async Task GetExchangeRates_NoCurrenciesParameter_ShouldReturnBadRequest() + public async Task GetExchangeRates_ServiceThrowsException_PropagatesException() { + // Arrange + _exchangeRateService + .GetExchangeRates(Arg.Any>(), Arg.Any>()) + .Returns(Task.FromException>( + new ExchangeRateProviderException("Provider error"))); + + // Act & Assert + var action = () => _sut.GetExchangeRates("USD"); + + await action.Should().ThrowAsync() + .WithMessage("Provider error"); + } + + [Fact] + public async Task GetExchangeRates_NoRatesFound_ReturnsNotFound() + { + // Arrange + _exchangeRateService + .GetExchangeRates(Arg.Any>(), Arg.Any>()) + .Returns(Array.Empty()); + // Act - var response = await _sut.GetExchangeRates(""); + var result = await _sut.GetExchangeRates("USD"); // Assert - var result = response.Result as BadRequestObjectResult; - Assert.Equal((int)HttpStatusCode.BadRequest, result?.StatusCode); + var response = result.Result.Should().BeOfType().Subject; + var apiResponse = response.Value.Should().BeOfType().Subject; + + apiResponse.Success.Should().BeFalse(); + apiResponse.Errors.Should().ContainMatch("*No exchange rates found*"); } } diff --git a/jobs/Backend/Task/ExchangeRateUpdater.Tests/Api/Middleware/GlobalExceptionHandlingMiddlewareTests.cs b/jobs/Backend/Task/ExchangeRateUpdater.Tests/Api/Middleware/GlobalExceptionHandlingMiddlewareTests.cs new file mode 100644 index 0000000000..32e828e541 --- /dev/null +++ b/jobs/Backend/Task/ExchangeRateUpdater.Tests/Api/Middleware/GlobalExceptionHandlingMiddlewareTests.cs @@ -0,0 +1,154 @@ +using System.Net; +using System.Text.Json; +using ExchangeRateUpdater.Api.Middleware; +using ExchangeRateUpdater.Core.Common; +using FluentAssertions; +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; +using NSubstitute; + +namespace ExchangeRateUpdater.Tests.Api.Middleware; + +public class GlobalExceptionHandlingMiddlewareTests +{ + private readonly ILogger _logger; + private readonly IHostEnvironment _environment; + private readonly DefaultHttpContext _httpContext; + private readonly RequestDelegate _nextMock; + + public GlobalExceptionHandlingMiddlewareTests() + { + _logger = Substitute.For>(); + _environment = Substitute.For(); + _httpContext = new DefaultHttpContext(); + _httpContext.Response.Body = new MemoryStream(); + _nextMock = Substitute.For(); + } + + [Fact] + public async Task InvokeAsync_NoException_CallsNextDelegate() + { + // Arrange + _nextMock.Invoke(Arg.Any()).Returns(Task.CompletedTask); + var middleware = new GlobalExceptionHandlingMiddleware(_nextMock, _logger, _environment); + + // Act + await middleware.InvokeAsync(_httpContext); + + // Assert + await _nextMock.Received(1).Invoke(Arg.Is(ctx => ctx == _httpContext)); + } + + [Fact] + public async Task InvokeAsync_ExchangeRateProviderException_ReturnsBadGateway() + { + // Arrange + const string errorMessage = "Provider error"; + _nextMock + .Invoke(Arg.Any()) + .Returns(x => throw new ExchangeRateProviderException(errorMessage)); + + var middleware = new GlobalExceptionHandlingMiddleware(_nextMock, _logger, _environment); + + // Act + await middleware.InvokeAsync(_httpContext); + + // Assert + var response = await GetResponseAs(); + _httpContext.Response.StatusCode.Should().Be((int)HttpStatusCode.BadGateway); + response.Should().NotBeNull(); + response.Message.Should().Be("Exchange rate provider error"); + response.Errors.Should().Contain(errorMessage); + response.Success.Should().BeFalse(); + + _logger.Received(1).Log( + Arg.Is(l => l == LogLevel.Error), + Arg.Any(), + Arg.Any(), + Arg.Is(e => e.Message == errorMessage), + Arg.Any>()); + } + + [Fact] + public async Task InvokeAsync_ArgumentException_ReturnsBadRequest() + { + // Arrange + const string errorMessage = "Invalid input"; + _nextMock + .Invoke(Arg.Any()) + .Returns(x => throw new ArgumentException(errorMessage)); + + var middleware = new GlobalExceptionHandlingMiddleware(_nextMock, _logger, _environment); + + // Act + await middleware.InvokeAsync(_httpContext); + + // Assert + var response = await GetResponseAs(); + _httpContext.Response.StatusCode.Should().Be((int)HttpStatusCode.BadRequest); + response.Should().NotBeNull(); + response.Message.Should().Be("Invalid input"); + response.Errors.Should().Contain(errorMessage); + response.Success.Should().BeFalse(); + } + + [Theory] + [InlineData(true)] + [InlineData(false)] + public async Task InvokeAsync_UnhandledException_ReturnsInternalServerError(bool isDevelopment) + { + // Arrange + const string errorMessage = "Unexpected error"; + _environment.EnvironmentName = isDevelopment ? Environments.Development : Environments.Production; + _nextMock + .Invoke(Arg.Any()) + .Returns(x => throw new Exception(errorMessage)); + + var middleware = new GlobalExceptionHandlingMiddleware(_nextMock, _logger, _environment); + + // Act + await middleware.InvokeAsync(_httpContext); + + // Assert + var response = await GetResponseAs(); + _httpContext.Response.StatusCode.Should().Be((int)HttpStatusCode.InternalServerError); + response.Should().NotBeNull(); + response.Message.Should().Be("An error occurred while processing your request"); + response.Success.Should().BeFalse(); + + if (isDevelopment) + { + response.Errors.Should().Contain(e => e.Contains(errorMessage)); + } + else + { + response.Errors.Should().ContainSingle() + .Which.Should().Be("An unexpected error occurred. Please try again later."); + } + } + + [Fact] + public async Task InvokeAsync_EnsuresResponseContentTypeIsJson() + { + // Arrange + _nextMock + .Invoke(Arg.Any()) + .Returns(x => throw new Exception("Test error")); + + var middleware = new GlobalExceptionHandlingMiddleware(_nextMock, _logger, _environment); + + // Act + await middleware.InvokeAsync(_httpContext); + + // Assert + _httpContext.Response.ContentType.Should().Be("application/json"); + } + + private async Task GetResponseAs() + { + _httpContext.Response.Body.Seek(0, SeekOrigin.Begin); + var content = await new StreamReader(_httpContext.Response.Body).ReadToEndAsync(); + return JsonSerializer.Deserialize(content)!; + } +} \ No newline at end of file diff --git a/jobs/Backend/Task/ExchangeRateUpdater.Tests/Core/ExchangeRateServiceTests.cs b/jobs/Backend/Task/ExchangeRateUpdater.Tests/Core/ExchangeRateServiceTests.cs index 4d988182dd..d1e2876dcc 100644 --- a/jobs/Backend/Task/ExchangeRateUpdater.Tests/Core/ExchangeRateServiceTests.cs +++ b/jobs/Backend/Task/ExchangeRateUpdater.Tests/Core/ExchangeRateServiceTests.cs @@ -16,7 +16,8 @@ public class ExchangeRateServiceTests private readonly IExchangeRateProvider _exchangeProvider; private readonly IExchangeRateCache _exchangeCache; private readonly ILogger _exchangeRateservice; - private readonly IOptions exchangeOptions; + private readonly IOptions _exchangeOptions; + private readonly IOptions _cacheSettings; private readonly ExchangeRateService _sut; private readonly DateTime _testDate = new DateTime(2025, 9, 26); @@ -25,17 +26,23 @@ public ExchangeRateServiceTests() _exchangeProvider = Substitute.For(); _exchangeCache = Substitute.For(); _exchangeRateservice = Substitute.For>(); - exchangeOptions = Substitute.For>(); + _exchangeOptions = Substitute.For>(); + _cacheSettings = Substitute.For>(); var options = new ExchangeRateOptions { - EnableCaching = true, + EnableCaching = true + }; + + var cacheSettings = new CacheSettings + { DefaultCacheExpiry = TimeSpan.FromHours(1) }; - exchangeOptions.Value.Returns(options); + _exchangeOptions.Value.Returns(options); + _cacheSettings.Value.Returns(cacheSettings); - _sut = new ExchangeRateService(_exchangeProvider, _exchangeCache, _exchangeRateservice, exchangeOptions); + _sut = new ExchangeRateService(_exchangeProvider, _exchangeCache, _exchangeRateservice, _exchangeOptions, _cacheSettings); } [Fact] @@ -57,7 +64,7 @@ public async Task GetExchangeRatesAsync_WithCachedRates_ShouldReturnCachedRates( // Assert result.Should().HaveCount(2); - await _exchangeProvider.Received(1).GetExchangeRatesForDate(Arg.Any>()); + await _exchangeProvider.DidNotReceive().GetExchangeRatesForDate(Arg.Any>()); } [Fact] diff --git a/jobs/Backend/Task/README.md b/jobs/Backend/Task/README.md index 2353f75bdc..701d4eee96 100644 --- a/jobs/Backend/Task/README.md +++ b/jobs/Backend/Task/README.md @@ -1,10 +1,7 @@ # Exchange Rate Updater -A production-ready .NET 9 solution for fetching and managing exchange rates from the Czech National Bank, featuring both console and Web API interfaces. +A .NET application that provides exchange rates from the Czech National Bank. Available as both a REST API service (with caching) and a command-line application. -## Architecture - -This solution implements a clean, modular architecture with the following components: ### Projects @@ -16,129 +13,44 @@ ExchangeRateUpdater.sln └── ExchangeRateUpdater.Tests/ # Unit and integration tests ``` -### Core Library (`ExchangeRateUpdater.Core`) - -Contains all shared business logic, models, and interfaces: - -- **Models**: `Currency`, `ExchangeRate`, DTOs for API responses -- **Interfaces**: `IExchangeRateProvider`, `IExchangeRateCache`, `ILogger` -- **Services**: `ExchangeRateService`, `InMemoryExchangeRateCache` -- **Providers**: `CzechNationalBankProvider` -- **Configuration**: Options classes for dependency injection - -### Console Application (`ExchangeRateUpdater.Console`) - -A command-line interface for fetching exchange rates: -- No caching (as per requirements) -- Uses `System.CommandLine` for argument parsing -- References the Core library - -### Web API (`ExchangeRateUpdater.Api`) - -A REST API with the following features: -- OpenAPI/Swagger documentation -- Enhanced caching strategy (caches all rates per date) -- Follows REST API best practices -- Memory-based caching using `IMemoryCache` +## Usage Examples -### Testing (`ExchangeRateUpdater.Tests`) +### Console Application -Comprehensive test suite covering: -- Unit tests for core components -- Integration tests for the API -- Mock-based testing using Moq +```powershell +# Get specific currencies +dotnet run --project ExchangeRateUpdater.Console -- --currencies USD,EUR,GBP -## Key Features +# Get rates for specific date +dotnet run --project ExchangeRateUpdater.Console -- --currencies USD,EUR,GBP --date 2025-09-28 -- **Clean Architecture**: Separation of concerns with Core library -- **Dependency Injection**: Full DI container setup across all projects -- **Error Handling**: Comprehensive error handling with retry policies using Polly -- **Caching**: Different caching strategies for Console (none) and API (enhanced) -- **Configuration**: JSON-based configuration with environment variable overrides -- **API Documentation**: OpenAPI/Swagger integration -- **Testing**: Unit and integration tests +``` -### Running the Console Application +### API Endpoints ```bash -cd ExchangeRateUpdater.Console - -# Basic usage (defaults to today's date and predefined currencies) -dotnet run - -# Specify a custom date -dotnet run -- --date 2025-09-20 - -# Specify custom currencies -dotnet run -- --currencies USD,EUR,JPY - -# Combine both parameters -dotnet run -- --date 2025-09-20 --currencies USD,EUR,JPY +# Get specific currencies +GET http://localhost:5000/api/exchangerates?currencies=USD,EUR -# View help information -dotnet run -- --help +# Combine date and currencies +GET http://localhost:5000/api/exchangerates?date=2025-09-28¤cies=USD,EUR ``` -### Running the API +## Docker Setup +1. Build and start the services: ```bash -cd ExchangeRateUpdater.Api -dotnet run +docker-compose up -d ``` -The API will be available at `https://localhost:5001` (or the port shown in the console). -OpenAPI document is available at `/openapi/v1.json` when running in Development mode. - -### API Endpoints - -- `GET /api/exchangerates?currencies=USD,EUR&date=2025-09-20` - Get exchange rates - -### Running Tests +2. Access the applications: + - API: http://localhost:5000/api/exchangerates + - Console app: + ```bash + docker-compose run console --date 2025-09-28 + ``` +3. Stop the services: ```bash -cd ExchangeRateUpdater.Tests -dotnet test +docker-compose down ``` - -## Configuration - -The application uses `appsettings.json` for configuration: - -```json -{ - "ExchangeRate": { - "DefaultCacheExpiry": "01:00:00", - "MaxRetryAttempts": 3, - "RetryDelay": "00:00:02", - "RequestTimeout": "00:00:30", - "EnableCaching": true - }, - "CzechNationalBank": { - "BaseUrl": "https://api.cnb.cz/cnbapi/exrates/daily", - "DateFormat": "yyyy-MM-dd", - "Language": "EN" - } -} -``` - -## Caching Strategy - -### Console Application -- **No caching** (as per requirements) -- Direct calls to the provider - -### API Application -- **Enhanced caching**: All exchange rates are cached per date -- When requesting specific currencies, the API returns filtered results from the cached data -- Uses `IMemoryCache` for efficient in-memory storage -- Configurable cache expiry times - -## API Design - -The API follows REST best practices: - -- **Resource-based URLs**: `/api/exchangerates` -- **HTTP verbs**: GET for queries -- **Status codes**: Proper HTTP status codes (200, 400, 404, 500) -- **Content negotiation**: JSON responses -- **OpenAPI documentation**: Complete API documentation with Swagger diff --git a/jobs/Backend/Task/docker-compose.dev.yml b/jobs/Backend/Task/docker-compose.dev.yml index 503b33cb6c..78f5e6a3b2 100644 --- a/jobs/Backend/Task/docker-compose.dev.yml +++ b/jobs/Backend/Task/docker-compose.dev.yml @@ -1,5 +1,3 @@ -version: '3.8' - services: exchange-rate-api: build: diff --git a/jobs/Backend/Task/docker-compose.yml b/jobs/Backend/Task/docker-compose.yml index ec4fa83815..09486c7fb2 100644 --- a/jobs/Backend/Task/docker-compose.yml +++ b/jobs/Backend/Task/docker-compose.yml @@ -1,5 +1,3 @@ -version: '3.8' - services: exchange-rate-api: build: From cc11e130daf47e85e400fa34c315550fcd0adba6 Mon Sep 17 00:00:00 2001 From: AndreKasper Date: Sun, 28 Sep 2025 13:42:38 +0200 Subject: [PATCH 06/10] Add swagger UI and split Core into Infrastructure and Domain projects * Split was done to separate contracts from implemetation * Fixed bug on size for IMemory and standirdized the cache key --- jobs/Backend/Task/Dockerfile.api | 3 +- .../Controllers/ExchangeRatesController.cs | 10 ++-- .../ExchangeRateUpdater.Api.csproj | 8 ++- .../Extensions/ExchangeRateExtensions.cs | 6 +- .../Extensions/MiddlewareExtensions.cs | 9 --- .../Installers/ExchangeRateApiInstaller.cs | 50 ++++++++++++++++ .../GlobalExceptionHandlingMiddleware.cs | 2 +- .../Models/ApiResponse.cs | 1 - .../Models/ExchangeRateDto.cs | 8 +-- .../Task/ExchangeRateUpdater.Api/Program.cs | 33 +++------- .../appsettings.Development.json | 2 +- .../ExchangeRateUpdater.Console.csproj | 7 ++- .../ExchangeRateUpdater.Console/Program.cs | 14 ++--- .../ExchangeRateUpdater.Core.csproj | 22 ------- .../Common/ExchangeRateProviderException.cs | 2 +- .../Common/Maybe.cs | 4 +- .../ExchangeRateUpdater.Domain.csproj | 16 +++++ .../Extensions/AsReadonlyExtensions.cs | 4 +- .../Extensions/MaybeExtensions.cs | 4 +- .../Extensions/TaskExtensions.cs | 2 +- .../Interfaces/IExchangeRateCache.cs | 6 +- .../Interfaces/IExchangeRateProvider.cs | 6 +- .../Interfaces/IExchangeRateService.cs | 4 +- .../Models}/CacheSettings.cs | 4 +- .../Models/CnbApiResponse.cs | 2 +- .../Models/Currency.cs | 2 +- .../Models}/CzechNationalBankOptions.cs | 2 +- .../Models/ExchangeRate.cs | 2 +- .../Models}/ExchangeRateOptions.cs | 2 +- .../Caching}/ApiExchangeRateCache.cs | 60 +++++++++++-------- .../Caching}/NoOpExchangeRateCache.cs | 10 ++-- .../ExchangeRateUpdater.Infrastructure.csproj | 20 +++++++ .../Extensions/ServiceCollectionExtensions.cs | 46 ++++++++++++++ .../Installers/CacheInstaller.cs | 35 +++++++++++ .../Installers/ExchangeRateInstaller.cs} | 23 ++++--- .../Installers/ServiceCollectionExtensions.cs | 19 ++++++ .../Providers/CzechNationalBankProvider.cs | 11 ++-- .../Services/ExchangeRateService.cs | 9 ++- .../Api/ApiExchangeRateCacheTests.cs | 37 ++++-------- .../Api/ExchangeRatesControllerTests.cs | 9 +-- .../GlobalExceptionHandlingMiddlewareTests.cs | 2 +- .../Core/CurrencyTests.cs | 2 +- .../Core/ExchangeRateServiceTests.cs | 11 ++-- .../Core/MaybeTests.cs | 2 +- .../ExchangeRateUpdater.Tests.csproj | 6 +- .../Integration/WeekendHolidayCachingTests.cs | 38 ++++++------ jobs/Backend/Task/ExchangeRateUpdater.sln | 45 +++++++++----- jobs/Backend/Task/README.md | 18 ++---- 48 files changed, 388 insertions(+), 252 deletions(-) delete mode 100644 jobs/Backend/Task/ExchangeRateUpdater.Api/Extensions/MiddlewareExtensions.cs create mode 100644 jobs/Backend/Task/ExchangeRateUpdater.Api/Installers/ExchangeRateApiInstaller.cs delete mode 100644 jobs/Backend/Task/ExchangeRateUpdater.Core/ExchangeRateUpdater.Core.csproj rename jobs/Backend/Task/{ExchangeRateUpdater.Core => ExchangeRateUpdater.Domain}/Common/ExchangeRateProviderException.cs (88%) rename jobs/Backend/Task/{ExchangeRateUpdater.Core => ExchangeRateUpdater.Domain}/Common/Maybe.cs (97%) create mode 100644 jobs/Backend/Task/ExchangeRateUpdater.Domain/ExchangeRateUpdater.Domain.csproj rename jobs/Backend/Task/{ExchangeRateUpdater.Core => ExchangeRateUpdater.Domain}/Extensions/AsReadonlyExtensions.cs (91%) rename jobs/Backend/Task/{ExchangeRateUpdater.Core => ExchangeRateUpdater.Domain}/Extensions/MaybeExtensions.cs (76%) rename jobs/Backend/Task/{ExchangeRateUpdater.Core => ExchangeRateUpdater.Domain}/Extensions/TaskExtensions.cs (70%) rename jobs/Backend/Task/{ExchangeRateUpdater.Core => ExchangeRateUpdater.Domain}/Interfaces/IExchangeRateCache.cs (64%) rename jobs/Backend/Task/{ExchangeRateUpdater.Core => ExchangeRateUpdater.Domain}/Interfaces/IExchangeRateProvider.cs (78%) rename jobs/Backend/Task/{ExchangeRateUpdater.Core => ExchangeRateUpdater.Domain}/Interfaces/IExchangeRateService.cs (65%) rename jobs/Backend/Task/{ExchangeRateUpdater.Core/Configuration => ExchangeRateUpdater.Domain/Models}/CacheSettings.cs (85%) rename jobs/Backend/Task/{ExchangeRateUpdater.Core => ExchangeRateUpdater.Domain}/Models/CnbApiResponse.cs (95%) rename jobs/Backend/Task/{ExchangeRateUpdater.Core => ExchangeRateUpdater.Domain}/Models/Currency.cs (93%) rename jobs/Backend/Task/{ExchangeRateUpdater.Core/Configuration => ExchangeRateUpdater.Domain/Models}/CzechNationalBankOptions.cs (87%) rename jobs/Backend/Task/{ExchangeRateUpdater.Core => ExchangeRateUpdater.Domain}/Models/ExchangeRate.cs (93%) rename jobs/Backend/Task/{ExchangeRateUpdater.Core/Configuration => ExchangeRateUpdater.Domain/Models}/ExchangeRateOptions.cs (87%) rename jobs/Backend/Task/{ExchangeRateUpdater.Api/Services => ExchangeRateUpdater.Infrastructure/Caching}/ApiExchangeRateCache.cs (51%) rename jobs/Backend/Task/{ExchangeRateUpdater.Console/Services => ExchangeRateUpdater.Infrastructure/Caching}/NoOpExchangeRateCache.cs (67%) create mode 100644 jobs/Backend/Task/ExchangeRateUpdater.Infrastructure/ExchangeRateUpdater.Infrastructure.csproj create mode 100644 jobs/Backend/Task/ExchangeRateUpdater.Infrastructure/Extensions/ServiceCollectionExtensions.cs create mode 100644 jobs/Backend/Task/ExchangeRateUpdater.Infrastructure/Installers/CacheInstaller.cs rename jobs/Backend/Task/{ExchangeRateUpdater.Core/CoreServiceConfiguration.cs => ExchangeRateUpdater.Infrastructure/Installers/ExchangeRateInstaller.cs} (73%) create mode 100644 jobs/Backend/Task/ExchangeRateUpdater.Infrastructure/Installers/ServiceCollectionExtensions.cs rename jobs/Backend/Task/{ExchangeRateUpdater.Core => ExchangeRateUpdater.Infrastructure}/Providers/CzechNationalBankProvider.cs (94%) rename jobs/Backend/Task/{ExchangeRateUpdater.Core => ExchangeRateUpdater.Infrastructure}/Services/ExchangeRateService.cs (94%) diff --git a/jobs/Backend/Task/Dockerfile.api b/jobs/Backend/Task/Dockerfile.api index c8165fecec..19c6c9dc04 100644 --- a/jobs/Backend/Task/Dockerfile.api +++ b/jobs/Backend/Task/Dockerfile.api @@ -8,7 +8,8 @@ WORKDIR /src COPY ["ExchangeRateUpdater.Api/ExchangeRateUpdater.Api.csproj", "ExchangeRateUpdater.Api/"] COPY ["ExchangeRateUpdater.Console/ExchangeRateUpdater.Console.csproj", "ExchangeRateUpdater.Console/"] -COPY ["ExchangeRateUpdater.Core/ExchangeRateUpdater.Core.csproj", "ExchangeRateUpdater.Core/"] +COPY ["ExchangeRateUpdater.Domain/ExchangeRateUpdater.Domain.csproj", "ExchangeRateUpdater.Domain/"] +COPY ["ExchangeRateUpdater.Infrastructure/ExchangeRateUpdater.Infrastructure.csproj", "ExchangeRateUpdater.Infrastructure/"] RUN dotnet restore "ExchangeRateUpdater.Api/ExchangeRateUpdater.Api.csproj" diff --git a/jobs/Backend/Task/ExchangeRateUpdater.Api/Controllers/ExchangeRatesController.cs b/jobs/Backend/Task/ExchangeRateUpdater.Api/Controllers/ExchangeRatesController.cs index f54347053e..14064f19ca 100644 --- a/jobs/Backend/Task/ExchangeRateUpdater.Api/Controllers/ExchangeRatesController.cs +++ b/jobs/Backend/Task/ExchangeRateUpdater.Api/Controllers/ExchangeRatesController.cs @@ -1,8 +1,8 @@ using Microsoft.AspNetCore.Mvc; using ExchangeRateUpdater.Api.Extensions; using ExchangeRateUpdater.Api.Models; -using ExchangeRateUpdater.Core.Models; -using ExchangeRateUpdater.Core.Extensions; +using ExchangeRateUpdater.Domain.Models; +using ExchangeRateUpdater.Domain.Extensions; namespace ExchangeRateUpdater.Api.Controllers; @@ -30,10 +30,10 @@ public ExchangeRatesController(IExchangeRateService exchangeRateService, ILogger /// If the request is invalid /// If no exchange rates found for the specified currencies [HttpGet] - [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status200OK)] + [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status200OK)] [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status400BadRequest)] [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status404NotFound)] - public async Task>> GetExchangeRates( + public async Task>> GetExchangeRates( [FromQuery] string currencies, [FromQuery] string? date = null) { @@ -52,7 +52,7 @@ public async Task>> GetExchangeRa }); } - return Ok(new ApiResponse + return Ok(new ApiResponse { Data = exchangeRates.ToExchangeRateResponse(parsedDate ?? DateTime.Today), Success = true, diff --git a/jobs/Backend/Task/ExchangeRateUpdater.Api/ExchangeRateUpdater.Api.csproj b/jobs/Backend/Task/ExchangeRateUpdater.Api/ExchangeRateUpdater.Api.csproj index 1b8d6da142..ecbd82a766 100644 --- a/jobs/Backend/Task/ExchangeRateUpdater.Api/ExchangeRateUpdater.Api.csproj +++ b/jobs/Backend/Task/ExchangeRateUpdater.Api/ExchangeRateUpdater.Api.csproj @@ -4,16 +4,18 @@ net9.0 enable enable + true + $(NoWarn);1591 - - + - + + diff --git a/jobs/Backend/Task/ExchangeRateUpdater.Api/Extensions/ExchangeRateExtensions.cs b/jobs/Backend/Task/ExchangeRateUpdater.Api/Extensions/ExchangeRateExtensions.cs index 3145b2abf6..a827009d08 100644 --- a/jobs/Backend/Task/ExchangeRateUpdater.Api/Extensions/ExchangeRateExtensions.cs +++ b/jobs/Backend/Task/ExchangeRateUpdater.Api/Extensions/ExchangeRateExtensions.cs @@ -1,17 +1,17 @@ using ExchangeRateUpdater.Api.Models; -using ExchangeRateUpdater.Core.Models; +using ExchangeRateUpdater.Domain.Models; namespace ExchangeRateUpdater.Api.Extensions; public static class ExchangeRateExtensions { - public static ExchangeRateResponse ToExchangeRateResponse( + public static ExchangeRateResponseDto ToExchangeRateResponse( this IEnumerable exchangeRates, DateTime requestedDate) { var rateList = exchangeRates.ToList(); - return new ExchangeRateResponse( + return new ExchangeRateResponseDto( Rates: rateList.Select(rate => new ExchangeRateDto( SourceCurrency: rate.SourceCurrency.Code, TargetCurrency: rate.TargetCurrency.Code, diff --git a/jobs/Backend/Task/ExchangeRateUpdater.Api/Extensions/MiddlewareExtensions.cs b/jobs/Backend/Task/ExchangeRateUpdater.Api/Extensions/MiddlewareExtensions.cs deleted file mode 100644 index f38335de46..0000000000 --- a/jobs/Backend/Task/ExchangeRateUpdater.Api/Extensions/MiddlewareExtensions.cs +++ /dev/null @@ -1,9 +0,0 @@ -namespace ExchangeRateUpdater.Api.Middleware; - -public static class MiddlewareExtensions -{ - public static IApplicationBuilder UseGlobalExceptionHandling(this IApplicationBuilder builder) - { - return builder.UseMiddleware(); - } -} \ No newline at end of file diff --git a/jobs/Backend/Task/ExchangeRateUpdater.Api/Installers/ExchangeRateApiInstaller.cs b/jobs/Backend/Task/ExchangeRateUpdater.Api/Installers/ExchangeRateApiInstaller.cs new file mode 100644 index 0000000000..e78ed6c894 --- /dev/null +++ b/jobs/Backend/Task/ExchangeRateUpdater.Api/Installers/ExchangeRateApiInstaller.cs @@ -0,0 +1,50 @@ +using Microsoft.Extensions.Caching.Memory; +using ExchangeRateUpdater.Domain.Models; +using ExchangeRateUpdater.Infrastructure.Installers; +using ExchangeRateUpdater.Api.Middleware; + +namespace ExchangeRateUpdater.Api.Extensions; + +public static class ExchangeRateApiInstaller +{ + public static IServiceCollection AddApiServices(this IServiceCollection services, IConfiguration configuration) + { + services.AddControllers(); + services.AddOpenApiServices(); + services.AddExchangeRateInfrastructure(configuration, useApiCache: true); + + return services; + } + + //public static IServiceCollection AddCachingServices(this IServiceCollection services, IConfiguration configuration) + //{ + // services.AddMemoryCache(); + // services.Configure(configuration.GetSection("CacheSettings")); + // var cacheSettings = configuration.GetSection("CacheSettings").Get() ?? new CacheSettings(); + // services.Configure(options => + // { + // options.SizeLimit = cacheSettings.SizeLimit; + // options.CompactionPercentage = cacheSettings.CompactionPercentage; + // options.ExpirationScanFrequency = cacheSettings.ExpirationScanFrequency; + // }); + + // return services; + //} + + public static IServiceCollection AddOpenApiServices(this IServiceCollection services) + { + services.AddOpenApi(); + services.AddSwaggerGen(options => + { + var xmlFilename = $"{System.Reflection.Assembly.GetExecutingAssembly().GetName().Name}.xml"; + options.IncludeXmlComments(Path.Combine(AppContext.BaseDirectory, xmlFilename), includeControllerXmlComments: true); + }); + + return services; + } + + public static IApplicationBuilder UseGlobalExceptionHandling(this IApplicationBuilder builder) + { + return builder.UseMiddleware(); + } +} diff --git a/jobs/Backend/Task/ExchangeRateUpdater.Api/Middleware/GlobalExceptionHandlingMiddleware.cs b/jobs/Backend/Task/ExchangeRateUpdater.Api/Middleware/GlobalExceptionHandlingMiddleware.cs index 6efaa08a5f..693353a516 100644 --- a/jobs/Backend/Task/ExchangeRateUpdater.Api/Middleware/GlobalExceptionHandlingMiddleware.cs +++ b/jobs/Backend/Task/ExchangeRateUpdater.Api/Middleware/GlobalExceptionHandlingMiddleware.cs @@ -1,6 +1,6 @@ using System.Net; using System.Text.Json; -using ExchangeRateUpdater.Core.Common; +using ExchangeRateUpdater.Domain.Common; namespace ExchangeRateUpdater.Api.Middleware; diff --git a/jobs/Backend/Task/ExchangeRateUpdater.Api/Models/ApiResponse.cs b/jobs/Backend/Task/ExchangeRateUpdater.Api/Models/ApiResponse.cs index a328e7d621..f47de449e7 100644 --- a/jobs/Backend/Task/ExchangeRateUpdater.Api/Models/ApiResponse.cs +++ b/jobs/Backend/Task/ExchangeRateUpdater.Api/Models/ApiResponse.cs @@ -4,7 +4,6 @@ public class ApiResponse public string Message { get; set; } = string.Empty; public List Errors { get; set; } = new List(); public DateTime Timestamp { get; set; } = DateTime.UtcNow; - public int StatusCode { get; set; } } public class ApiResponse : ApiResponse diff --git a/jobs/Backend/Task/ExchangeRateUpdater.Api/Models/ExchangeRateDto.cs b/jobs/Backend/Task/ExchangeRateUpdater.Api/Models/ExchangeRateDto.cs index be8c79c387..3ebfdf2fd8 100644 --- a/jobs/Backend/Task/ExchangeRateUpdater.Api/Models/ExchangeRateDto.cs +++ b/jobs/Backend/Task/ExchangeRateUpdater.Api/Models/ExchangeRateDto.cs @@ -6,12 +6,8 @@ public record ExchangeRateDto( decimal Value, DateTime Date); -public record ExchangeRateRequest( - List Currencies, - DateTime? Date = null); - -public record ExchangeRateResponse( +public record ExchangeRateResponseDto( List Rates, DateTime RequestedDate, int TotalCount); - \ No newline at end of file + diff --git a/jobs/Backend/Task/ExchangeRateUpdater.Api/Program.cs b/jobs/Backend/Task/ExchangeRateUpdater.Api/Program.cs index 68b2d0026b..db5f1f5464 100644 --- a/jobs/Backend/Task/ExchangeRateUpdater.Api/Program.cs +++ b/jobs/Backend/Task/ExchangeRateUpdater.Api/Program.cs @@ -1,9 +1,4 @@ -using ExchangeRateUpdater.Api.Services; -using ExchangeRateUpdater.Core; -using ExchangeRateUpdater.Core.Interfaces; -using Microsoft.Extensions.Caching.Memory; -using ExchangeRateUpdater.Core.Configuration; -using ExchangeRateUpdater.Api.Middleware; +using ExchangeRateUpdater.Api.Extensions; var builder = WebApplication.CreateBuilder(args); @@ -13,32 +8,22 @@ .AddJsonFile($"appsettings.{builder.Environment.EnvironmentName}.json", optional: true) .AddEnvironmentVariables(); -// Add services to the container -builder.Services.AddControllers(); -builder.Services.AddMemoryCache(); -builder.Services.Configure(builder.Configuration.GetSection("CacheSettings")); -var cacheSettings = builder.Configuration.GetSection("CacheSettings").Get() ?? new CacheSettings(); -builder.Services.Configure(options => -{ - options.SizeLimit = cacheSettings.SizeLimit; - options.CompactionPercentage = cacheSettings.CompactionPercentage; - options.ExpirationScanFrequency = cacheSettings.ExpirationScanFrequency; -}); - -builder.Services.AddExchangeRateCoreDependencies(builder.Configuration); -builder.Services.AddSingleton(); - -builder.Services.AddOpenApi(); +builder.Services.AddApiServices(builder.Configuration); var app = builder.Build(); +app.UseSwagger(); +app.UseSwaggerUI(options => +{ + options.SwaggerEndpoint("/openapi/v1.json", "Exchange Rate API V1"); + options.RoutePrefix = "swagger"; +}); + app.UseGlobalExceptionHandling(); app.UseHttpsRedirection(); app.MapControllers(); app.MapOpenApi(); -app.MapGet("/", () => "Exchange Rate Updater API is running."); - app.Run(); public partial class Program { } diff --git a/jobs/Backend/Task/ExchangeRateUpdater.Api/appsettings.Development.json b/jobs/Backend/Task/ExchangeRateUpdater.Api/appsettings.Development.json index 08717cfc9e..4c55a1bd50 100644 --- a/jobs/Backend/Task/ExchangeRateUpdater.Api/appsettings.Development.json +++ b/jobs/Backend/Task/ExchangeRateUpdater.Api/appsettings.Development.json @@ -7,7 +7,7 @@ }, "CacheSettings": { "DefaultCacheExpiry": "00:05:00", - "SizeLimit": 5, + "SizeLimit": 10, "CompactionPercentage": 0.5, "ExpirationScanFrequency": "00:01:00" } diff --git a/jobs/Backend/Task/ExchangeRateUpdater.Console/ExchangeRateUpdater.Console.csproj b/jobs/Backend/Task/ExchangeRateUpdater.Console/ExchangeRateUpdater.Console.csproj index 3e9f4ab27a..67394a6d13 100644 --- a/jobs/Backend/Task/ExchangeRateUpdater.Console/ExchangeRateUpdater.Console.csproj +++ b/jobs/Backend/Task/ExchangeRateUpdater.Console/ExchangeRateUpdater.Console.csproj @@ -8,16 +8,17 @@ - + - + - + + diff --git a/jobs/Backend/Task/ExchangeRateUpdater.Console/Program.cs b/jobs/Backend/Task/ExchangeRateUpdater.Console/Program.cs index 0170aa2102..0b5b66e3dd 100644 --- a/jobs/Backend/Task/ExchangeRateUpdater.Console/Program.cs +++ b/jobs/Backend/Task/ExchangeRateUpdater.Console/Program.cs @@ -1,8 +1,7 @@ -using ExchangeRateUpdater.Console.Services; -using ExchangeRateUpdater.Core; -using ExchangeRateUpdater.Core.Interfaces; -using ExchangeRateUpdater.Core.Models; -using ExchangeRateUpdater.Core.Extensions; +using ExchangeRateUpdater.Domain.Models; +using ExchangeRateUpdater.Domain.Extensions; +using ExchangeRateUpdater.Infrastructure.Installers; +using ExchangeRateUpdater.Infrastructure.Caching; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; using System.CommandLine; @@ -88,10 +87,9 @@ private static async Task RunExchangeRateUpdaterAsync(DateTime? date, string[] c .AddEnvironmentVariables() .Build(); - // Add Core services + // Configure services var services = new ServiceCollection(); - services.AddExchangeRateCoreDependencies(configuration); - services.AddScoped(); + services.AddExchangeRateInfrastructure(configuration, useApiCache: false); // Build service provider var serviceProvider = services.BuildServiceProvider(); diff --git a/jobs/Backend/Task/ExchangeRateUpdater.Core/ExchangeRateUpdater.Core.csproj b/jobs/Backend/Task/ExchangeRateUpdater.Core/ExchangeRateUpdater.Core.csproj deleted file mode 100644 index b6665b58ac..0000000000 --- a/jobs/Backend/Task/ExchangeRateUpdater.Core/ExchangeRateUpdater.Core.csproj +++ /dev/null @@ -1,22 +0,0 @@ - - - - net9.0 - enable - enable - - - - - - - - - - - - - - - - diff --git a/jobs/Backend/Task/ExchangeRateUpdater.Core/Common/ExchangeRateProviderException.cs b/jobs/Backend/Task/ExchangeRateUpdater.Domain/Common/ExchangeRateProviderException.cs similarity index 88% rename from jobs/Backend/Task/ExchangeRateUpdater.Core/Common/ExchangeRateProviderException.cs rename to jobs/Backend/Task/ExchangeRateUpdater.Domain/Common/ExchangeRateProviderException.cs index 2bbcdc52a3..7ea1f67634 100644 --- a/jobs/Backend/Task/ExchangeRateUpdater.Core/Common/ExchangeRateProviderException.cs +++ b/jobs/Backend/Task/ExchangeRateUpdater.Domain/Common/ExchangeRateProviderException.cs @@ -1,4 +1,4 @@ -namespace ExchangeRateUpdater.Core.Common; +namespace ExchangeRateUpdater.Domain.Common; /// /// Custom exception for exchange rate service errors diff --git a/jobs/Backend/Task/ExchangeRateUpdater.Core/Common/Maybe.cs b/jobs/Backend/Task/ExchangeRateUpdater.Domain/Common/Maybe.cs similarity index 97% rename from jobs/Backend/Task/ExchangeRateUpdater.Core/Common/Maybe.cs rename to jobs/Backend/Task/ExchangeRateUpdater.Domain/Common/Maybe.cs index 4f4976d27e..c8c8b6f531 100644 --- a/jobs/Backend/Task/ExchangeRateUpdater.Core/Common/Maybe.cs +++ b/jobs/Backend/Task/ExchangeRateUpdater.Domain/Common/Maybe.cs @@ -1,4 +1,4 @@ -namespace ExchangeRateUpdater.Core.Common; +namespace ExchangeRateUpdater.Domain.Common; public readonly struct Maybe { @@ -63,4 +63,4 @@ public override int GetHashCode() { return _hasValue ? EqualityComparer.Default.GetHashCode(_value!) : 0; } -} \ 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..9acc731bc0 --- /dev/null +++ b/jobs/Backend/Task/ExchangeRateUpdater.Domain/ExchangeRateUpdater.Domain.csproj @@ -0,0 +1,16 @@ + + + + net9.0 + enable + enable + + + + + + + + + + diff --git a/jobs/Backend/Task/ExchangeRateUpdater.Core/Extensions/AsReadonlyExtensions.cs b/jobs/Backend/Task/ExchangeRateUpdater.Domain/Extensions/AsReadonlyExtensions.cs similarity index 91% rename from jobs/Backend/Task/ExchangeRateUpdater.Core/Extensions/AsReadonlyExtensions.cs rename to jobs/Backend/Task/ExchangeRateUpdater.Domain/Extensions/AsReadonlyExtensions.cs index 63da8dbdb4..70ef6bafb7 100644 --- a/jobs/Backend/Task/ExchangeRateUpdater.Core/Extensions/AsReadonlyExtensions.cs +++ b/jobs/Backend/Task/ExchangeRateUpdater.Domain/Extensions/AsReadonlyExtensions.cs @@ -1,6 +1,6 @@ using System.Diagnostics; -namespace ExchangeRateUpdater.Core.Extensions; +namespace ExchangeRateUpdater.Domain.Extensions; [DebuggerStepThrough] public static class AsReadonlyExtensions @@ -22,4 +22,4 @@ public static IReadOnlyCollection AsReadOnlyCollection(this IEnumerable _ => self.ToList() }; } -} \ No newline at end of file +} diff --git a/jobs/Backend/Task/ExchangeRateUpdater.Core/Extensions/MaybeExtensions.cs b/jobs/Backend/Task/ExchangeRateUpdater.Domain/Extensions/MaybeExtensions.cs similarity index 76% rename from jobs/Backend/Task/ExchangeRateUpdater.Core/Extensions/MaybeExtensions.cs rename to jobs/Backend/Task/ExchangeRateUpdater.Domain/Extensions/MaybeExtensions.cs index 7a82310f53..3a20677629 100644 --- a/jobs/Backend/Task/ExchangeRateUpdater.Core/Extensions/MaybeExtensions.cs +++ b/jobs/Backend/Task/ExchangeRateUpdater.Domain/Extensions/MaybeExtensions.cs @@ -1,6 +1,6 @@ -using ExchangeRateUpdater.Core.Common; +using ExchangeRateUpdater.Domain.Common; -namespace ExchangeRateUpdater.Core.Extensions; +namespace ExchangeRateUpdater.Domain.Extensions; public static class MaybeExtensions { diff --git a/jobs/Backend/Task/ExchangeRateUpdater.Core/Extensions/TaskExtensions.cs b/jobs/Backend/Task/ExchangeRateUpdater.Domain/Extensions/TaskExtensions.cs similarity index 70% rename from jobs/Backend/Task/ExchangeRateUpdater.Core/Extensions/TaskExtensions.cs rename to jobs/Backend/Task/ExchangeRateUpdater.Domain/Extensions/TaskExtensions.cs index 0488f090fb..65746ff21b 100644 --- a/jobs/Backend/Task/ExchangeRateUpdater.Core/Extensions/TaskExtensions.cs +++ b/jobs/Backend/Task/ExchangeRateUpdater.Domain/Extensions/TaskExtensions.cs @@ -1,4 +1,4 @@ -namespace ExchangeRateUpdater.Core.Extensions; +namespace ExchangeRateUpdater.Domain.Extensions; public static class TaskExtensions { diff --git a/jobs/Backend/Task/ExchangeRateUpdater.Core/Interfaces/IExchangeRateCache.cs b/jobs/Backend/Task/ExchangeRateUpdater.Domain/Interfaces/IExchangeRateCache.cs similarity index 64% rename from jobs/Backend/Task/ExchangeRateUpdater.Core/Interfaces/IExchangeRateCache.cs rename to jobs/Backend/Task/ExchangeRateUpdater.Domain/Interfaces/IExchangeRateCache.cs index dff81fe274..41ded7fdfd 100644 --- a/jobs/Backend/Task/ExchangeRateUpdater.Core/Interfaces/IExchangeRateCache.cs +++ b/jobs/Backend/Task/ExchangeRateUpdater.Domain/Interfaces/IExchangeRateCache.cs @@ -1,7 +1,7 @@ -using ExchangeRateUpdater.Core.Common; -using ExchangeRateUpdater.Core.Models; +using ExchangeRateUpdater.Domain.Common; +using ExchangeRateUpdater.Domain.Models; -namespace ExchangeRateUpdater.Core.Interfaces; +namespace ExchangeRateUpdater.Domain.Interfaces; public interface IExchangeRateCache { diff --git a/jobs/Backend/Task/ExchangeRateUpdater.Core/Interfaces/IExchangeRateProvider.cs b/jobs/Backend/Task/ExchangeRateUpdater.Domain/Interfaces/IExchangeRateProvider.cs similarity index 78% rename from jobs/Backend/Task/ExchangeRateUpdater.Core/Interfaces/IExchangeRateProvider.cs rename to jobs/Backend/Task/ExchangeRateUpdater.Domain/Interfaces/IExchangeRateProvider.cs index 6f32095609..5638240fb2 100644 --- a/jobs/Backend/Task/ExchangeRateUpdater.Core/Interfaces/IExchangeRateProvider.cs +++ b/jobs/Backend/Task/ExchangeRateUpdater.Domain/Interfaces/IExchangeRateProvider.cs @@ -1,7 +1,7 @@ -using ExchangeRateUpdater.Core.Common; -using ExchangeRateUpdater.Core.Models; +using ExchangeRateUpdater.Domain.Common; +using ExchangeRateUpdater.Domain.Models; -namespace ExchangeRateUpdater.Core.Interfaces; +namespace ExchangeRateUpdater.Domain.Interfaces; public interface IExchangeRateProvider { diff --git a/jobs/Backend/Task/ExchangeRateUpdater.Core/Interfaces/IExchangeRateService.cs b/jobs/Backend/Task/ExchangeRateUpdater.Domain/Interfaces/IExchangeRateService.cs similarity index 65% rename from jobs/Backend/Task/ExchangeRateUpdater.Core/Interfaces/IExchangeRateService.cs rename to jobs/Backend/Task/ExchangeRateUpdater.Domain/Interfaces/IExchangeRateService.cs index 9571274177..1e07ac8b07 100644 --- a/jobs/Backend/Task/ExchangeRateUpdater.Core/Interfaces/IExchangeRateService.cs +++ b/jobs/Backend/Task/ExchangeRateUpdater.Domain/Interfaces/IExchangeRateService.cs @@ -1,5 +1,5 @@ -using ExchangeRateUpdater.Core.Common; -using ExchangeRateUpdater.Core.Models; +using ExchangeRateUpdater.Domain.Common; +using ExchangeRateUpdater.Domain.Models; public interface IExchangeRateService { diff --git a/jobs/Backend/Task/ExchangeRateUpdater.Core/Configuration/CacheSettings.cs b/jobs/Backend/Task/ExchangeRateUpdater.Domain/Models/CacheSettings.cs similarity index 85% rename from jobs/Backend/Task/ExchangeRateUpdater.Core/Configuration/CacheSettings.cs rename to jobs/Backend/Task/ExchangeRateUpdater.Domain/Models/CacheSettings.cs index ce48844549..4f58eadd8b 100644 --- a/jobs/Backend/Task/ExchangeRateUpdater.Core/Configuration/CacheSettings.cs +++ b/jobs/Backend/Task/ExchangeRateUpdater.Domain/Models/CacheSettings.cs @@ -1,4 +1,4 @@ -namespace ExchangeRateUpdater.Core.Configuration; +namespace ExchangeRateUpdater.Domain.Models; public class CacheSettings { @@ -6,4 +6,4 @@ public class CacheSettings public int SizeLimit { get; set; } = 1000; public double CompactionPercentage { get; set; } = 0.25; public TimeSpan ExpirationScanFrequency { get; set; } = TimeSpan.FromMinutes(5); -} \ No newline at end of file +} diff --git a/jobs/Backend/Task/ExchangeRateUpdater.Core/Models/CnbApiResponse.cs b/jobs/Backend/Task/ExchangeRateUpdater.Domain/Models/CnbApiResponse.cs similarity index 95% rename from jobs/Backend/Task/ExchangeRateUpdater.Core/Models/CnbApiResponse.cs rename to jobs/Backend/Task/ExchangeRateUpdater.Domain/Models/CnbApiResponse.cs index f65eee2f27..b8be067b35 100644 --- a/jobs/Backend/Task/ExchangeRateUpdater.Core/Models/CnbApiResponse.cs +++ b/jobs/Backend/Task/ExchangeRateUpdater.Domain/Models/CnbApiResponse.cs @@ -1,6 +1,6 @@ using System.Text.Json.Serialization; -namespace ExchangeRateUpdater.Core.Models; +namespace ExchangeRateUpdater.Domain.Models; /// /// Root response object from Czech National Bank API diff --git a/jobs/Backend/Task/ExchangeRateUpdater.Core/Models/Currency.cs b/jobs/Backend/Task/ExchangeRateUpdater.Domain/Models/Currency.cs similarity index 93% rename from jobs/Backend/Task/ExchangeRateUpdater.Core/Models/Currency.cs rename to jobs/Backend/Task/ExchangeRateUpdater.Domain/Models/Currency.cs index f07f899fa4..e1ab5cecf9 100644 --- a/jobs/Backend/Task/ExchangeRateUpdater.Core/Models/Currency.cs +++ b/jobs/Backend/Task/ExchangeRateUpdater.Domain/Models/Currency.cs @@ -1,4 +1,4 @@ -namespace ExchangeRateUpdater.Core.Models; +namespace ExchangeRateUpdater.Domain.Models; /// /// Currency with a three-letter ISO 4217 code. diff --git a/jobs/Backend/Task/ExchangeRateUpdater.Core/Configuration/CzechNationalBankOptions.cs b/jobs/Backend/Task/ExchangeRateUpdater.Domain/Models/CzechNationalBankOptions.cs similarity index 87% rename from jobs/Backend/Task/ExchangeRateUpdater.Core/Configuration/CzechNationalBankOptions.cs rename to jobs/Backend/Task/ExchangeRateUpdater.Domain/Models/CzechNationalBankOptions.cs index f6622c5f4f..ebf59a3ffa 100644 --- a/jobs/Backend/Task/ExchangeRateUpdater.Core/Configuration/CzechNationalBankOptions.cs +++ b/jobs/Backend/Task/ExchangeRateUpdater.Domain/Models/CzechNationalBankOptions.cs @@ -1,4 +1,4 @@ -namespace ExchangeRateUpdater.Core.Configuration; +namespace ExchangeRateUpdater.Domain.Models; public enum CnbLanguage { diff --git a/jobs/Backend/Task/ExchangeRateUpdater.Core/Models/ExchangeRate.cs b/jobs/Backend/Task/ExchangeRateUpdater.Domain/Models/ExchangeRate.cs similarity index 93% rename from jobs/Backend/Task/ExchangeRateUpdater.Core/Models/ExchangeRate.cs rename to jobs/Backend/Task/ExchangeRateUpdater.Domain/Models/ExchangeRate.cs index 77ecdfe7d3..615b6a7852 100644 --- a/jobs/Backend/Task/ExchangeRateUpdater.Core/Models/ExchangeRate.cs +++ b/jobs/Backend/Task/ExchangeRateUpdater.Domain/Models/ExchangeRate.cs @@ -1,4 +1,4 @@ -namespace ExchangeRateUpdater.Core.Models; +namespace ExchangeRateUpdater.Domain.Models; /// /// Represents an exchange rate between two currencies for a specific date. diff --git a/jobs/Backend/Task/ExchangeRateUpdater.Core/Configuration/ExchangeRateOptions.cs b/jobs/Backend/Task/ExchangeRateUpdater.Domain/Models/ExchangeRateOptions.cs similarity index 87% rename from jobs/Backend/Task/ExchangeRateUpdater.Core/Configuration/ExchangeRateOptions.cs rename to jobs/Backend/Task/ExchangeRateUpdater.Domain/Models/ExchangeRateOptions.cs index dec7d5d651..cc25927c47 100644 --- a/jobs/Backend/Task/ExchangeRateUpdater.Core/Configuration/ExchangeRateOptions.cs +++ b/jobs/Backend/Task/ExchangeRateUpdater.Domain/Models/ExchangeRateOptions.cs @@ -1,4 +1,4 @@ -namespace ExchangeRateUpdater.Core.Configuration; +namespace ExchangeRateUpdater.Domain.Models; public class ExchangeRateOptions { diff --git a/jobs/Backend/Task/ExchangeRateUpdater.Api/Services/ApiExchangeRateCache.cs b/jobs/Backend/Task/ExchangeRateUpdater.Infrastructure/Caching/ApiExchangeRateCache.cs similarity index 51% rename from jobs/Backend/Task/ExchangeRateUpdater.Api/Services/ApiExchangeRateCache.cs rename to jobs/Backend/Task/ExchangeRateUpdater.Infrastructure/Caching/ApiExchangeRateCache.cs index d268c28054..68e2e867bc 100644 --- a/jobs/Backend/Task/ExchangeRateUpdater.Api/Services/ApiExchangeRateCache.cs +++ b/jobs/Backend/Task/ExchangeRateUpdater.Infrastructure/Caching/ApiExchangeRateCache.cs @@ -1,11 +1,12 @@ using Microsoft.Extensions.Caching.Memory; -using ExchangeRateUpdater.Core.Common; -using ExchangeRateUpdater.Core.Interfaces; -using ExchangeRateUpdater.Core.Models; +using Microsoft.Extensions.Logging; +using ExchangeRateUpdater.Domain.Common; +using ExchangeRateUpdater.Domain.Interfaces; +using ExchangeRateUpdater.Domain.Models; using PublicHoliday; -using ExchangeRateUpdater.Core.Extensions; +using ExchangeRateUpdater.Domain.Extensions; -namespace ExchangeRateUpdater.Api.Services; +namespace ExchangeRateUpdater.Infrastructure.Caching; // Memory cache implementation for exchange rates that handles business day logic and provides efficient currency filtering. public class ApiExchangeRateCache : IExchangeRateCache @@ -29,21 +30,23 @@ public Task>> GetCachedRates(IEnumerable>.Nothing.AsTask(); - var targetDate = GetBusinessDayForCacheCheck(date); - - if (_memoryCache.TryGetValue(targetDate, out var cachedRates) && cachedRates is IEnumerable allRates) + var targetDate = date.TryGetValue(out var dateValue) ? dateValue.Date : DateTime.Today; + var businessDate = GetBusinessDayForCacheCheck(targetDate); + var cacheKey = GetCacheKey(businessDate); + + if (_memoryCache.TryGetValue(cacheKey, out List cachedRates)) { var requestedCurrencyCodes = currencyList.Select(c => c.Code).ToHashSet(StringComparer.OrdinalIgnoreCase); - var filteredRates = allRates.Where(rate => requestedCurrencyCodes.Contains(rate.SourceCurrency.Code)).ToList(); - _logger.LogInformation($"Cache hit for date {targetDate:yyyy-MM-dd}, returning {filteredRates.Count} rates"); - + var filteredRates = cachedRates.Where(rate => requestedCurrencyCodes.Contains(rate.SourceCurrency.Code)).ToList(); + _logger.LogInformation($"Cache HIT - Key: {cacheKey}, Total rates: {cachedRates.Count}, Filtered rates: {filteredRates.Count}"); + if (filteredRates.Any()) { return filteredRates.AsReadOnlyList().AsMaybe().AsTask(); } } - - _logger.LogInformation($"Cache miss for date {targetDate:yyyy-MM-dd} and currencies: {string.Join(", ", currencyList.Select(c => c.Code))}"); + + _logger.LogInformation($"Cache MISS - Key: {cacheKey} not found"); return Maybe>.Nothing.AsTask(); } @@ -55,33 +58,38 @@ public Task CacheRates(IReadOnlyCollection rates, TimeSpan cacheEx if (!rates.Any()) return Task.CompletedTask; - // Cache all rates with the date returned by the CNB provider - var date = rates.First().Date.Date; + // Get the provider date and its corresponding business day + var providerDate = rates.First().Date.Date; + var businessDate = GetBusinessDayForCacheCheck(providerDate); // Should return the same date var cacheOptions = new MemoryCacheEntryOptions { AbsoluteExpirationRelativeToNow = cacheExpiry, SlidingExpiration = cacheExpiry / 2, - Size = rates.Count + Size = 1 }; - _memoryCache.Set(date, rates, cacheOptions); - - _logger.LogInformation($"Cached {rates.Count} exchange rates for date {date:yyyy-MM-dd}, expires in {cacheExpiry.TotalMinutes} minutes"); + var cacheKey = GetCacheKey(businessDate); + _memoryCache.Set(cacheKey, rates, cacheOptions); + _logger.LogInformation($"Cache SET - Key: {cacheKey}, Rates: {rates.Count}, Business date: {businessDate:yyyy-MM-dd}, Provider date: {providerDate:yyyy-MM-dd}"); return Task.CompletedTask; } - private static DateTime GetBusinessDayForCacheCheck(Maybe date) + private DateTime GetBusinessDayForCacheCheck(DateTime date) { - if (!date.TryGetValue(out var dateValue)) - dateValue = DateTime.Today; - - while (new CzechRepublicPublicHoliday().IsPublicHoliday(dateValue) || dateValue.DayOfWeek is DayOfWeek.Saturday or DayOfWeek.Sunday) + var checkDate = date.Date; + + while (_czechRepublicPublicHoliday.IsPublicHoliday(checkDate) || checkDate.DayOfWeek is DayOfWeek.Saturday or DayOfWeek.Sunday) { - dateValue = dateValue.AddDays(-1); + checkDate = checkDate.AddDays(-1); } - return dateValue; + return checkDate; + } + + private static string GetCacheKey(DateTime businessDate) + { + return $"ExchangeRates_{businessDate:yyyy-MM-dd}"; } } diff --git a/jobs/Backend/Task/ExchangeRateUpdater.Console/Services/NoOpExchangeRateCache.cs b/jobs/Backend/Task/ExchangeRateUpdater.Infrastructure/Caching/NoOpExchangeRateCache.cs similarity index 67% rename from jobs/Backend/Task/ExchangeRateUpdater.Console/Services/NoOpExchangeRateCache.cs rename to jobs/Backend/Task/ExchangeRateUpdater.Infrastructure/Caching/NoOpExchangeRateCache.cs index 4c1193ed02..e3788ec19f 100644 --- a/jobs/Backend/Task/ExchangeRateUpdater.Console/Services/NoOpExchangeRateCache.cs +++ b/jobs/Backend/Task/ExchangeRateUpdater.Infrastructure/Caching/NoOpExchangeRateCache.cs @@ -1,9 +1,9 @@ -using ExchangeRateUpdater.Core.Common; -using ExchangeRateUpdater.Core.Extensions; -using ExchangeRateUpdater.Core.Interfaces; -using ExchangeRateUpdater.Core.Models; +using ExchangeRateUpdater.Domain.Common; +using ExchangeRateUpdater.Domain.Extensions; +using ExchangeRateUpdater.Domain.Interfaces; +using ExchangeRateUpdater.Domain.Models; -namespace ExchangeRateUpdater.Console.Services; +namespace ExchangeRateUpdater.Infrastructure.Caching; public class NoOpExchangeRateCache : IExchangeRateCache { 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..30c0a638e6 --- /dev/null +++ b/jobs/Backend/Task/ExchangeRateUpdater.Infrastructure/ExchangeRateUpdater.Infrastructure.csproj @@ -0,0 +1,20 @@ + + + + + + + + + + + + + + + net9.0 + enable + enable + + + diff --git a/jobs/Backend/Task/ExchangeRateUpdater.Infrastructure/Extensions/ServiceCollectionExtensions.cs b/jobs/Backend/Task/ExchangeRateUpdater.Infrastructure/Extensions/ServiceCollectionExtensions.cs new file mode 100644 index 0000000000..c00b884e65 --- /dev/null +++ b/jobs/Backend/Task/ExchangeRateUpdater.Infrastructure/Extensions/ServiceCollectionExtensions.cs @@ -0,0 +1,46 @@ +using ExchangeRateUpdater.Domain.Models; +using ExchangeRateUpdater.Domain.Interfaces; +using ExchangeRateUpdater.Infrastructure.Providers; +using ExchangeRateUpdater.Infrastructure.Services; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Options; +using Polly; +using Polly.Extensions.Http; + +namespace ExchangeRateUpdater.Infrastructure.Extensions; + +public static class ServiceCollectionExtensions +{ + public static IServiceCollection AddExchangeRateInfrastructureDependencies(this IServiceCollection services, IConfiguration configuration) + { + var exchangeRateOptions = configuration.GetSection(ExchangeRateOptions.SectionName).Get() ?? new ExchangeRateOptions(); + + services.AddHttpClient((serviceProvider, client) => + { + var options = serviceProvider.GetRequiredService>().Value; + client.BaseAddress = new Uri(options.BaseUrl); + client.Timeout = TimeSpan.FromSeconds(30); + }) + .AddPolicyHandler(GetRetryPolicy(exchangeRateOptions)) + .AddPolicyHandler(Policy.TimeoutAsync(exchangeRateOptions.RequestTimeout)); + + services.AddScoped(); + services.AddScoped(); + + return services; + } + + private static IAsyncPolicy GetRetryPolicy(ExchangeRateOptions options) + { + return HttpPolicyExtensions + .HandleTransientHttpError() + .WaitAndRetryAsync( + options.MaxRetryAttempts, + retryAttempt => retryAttempt * options.RetryDelay, + onRetry: (outcome, timespan, retryCount, context) => + { + Console.WriteLine($"Retry {retryCount} after {timespan.TotalMilliseconds}ms due to: {outcome.Exception?.Message ?? outcome.Result?.StatusCode.ToString()}"); + }); + } +} diff --git a/jobs/Backend/Task/ExchangeRateUpdater.Infrastructure/Installers/CacheInstaller.cs b/jobs/Backend/Task/ExchangeRateUpdater.Infrastructure/Installers/CacheInstaller.cs new file mode 100644 index 0000000000..c95ae9d97b --- /dev/null +++ b/jobs/Backend/Task/ExchangeRateUpdater.Infrastructure/Installers/CacheInstaller.cs @@ -0,0 +1,35 @@ +using ExchangeRateUpdater.Domain.Interfaces; +using ExchangeRateUpdater.Domain.Models; +using ExchangeRateUpdater.Infrastructure.Caching; +using Microsoft.Extensions.Caching.Memory; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; + +namespace ExchangeRateUpdater.Infrastructure.Installers; + +public static class CacheInstaller +{ + public static IServiceCollection AddCaching(this IServiceCollection services, IConfiguration configuration, bool useApiCache = true) + { + if (useApiCache) + { + services.Configure(configuration.GetSection("CacheSettings")); + var cacheSettings = configuration.GetSection("CacheSettings").Get() ?? new CacheSettings(); + + services.AddMemoryCache(options => + { + options.SizeLimit = cacheSettings.SizeLimit; + options.CompactionPercentage = cacheSettings.CompactionPercentage; + options.ExpirationScanFrequency = cacheSettings.ExpirationScanFrequency; + }); + + services.AddSingleton(); + } + else + { + services.AddSingleton(); + } + + return services; + } +} \ No newline at end of file diff --git a/jobs/Backend/Task/ExchangeRateUpdater.Core/CoreServiceConfiguration.cs b/jobs/Backend/Task/ExchangeRateUpdater.Infrastructure/Installers/ExchangeRateInstaller.cs similarity index 73% rename from jobs/Backend/Task/ExchangeRateUpdater.Core/CoreServiceConfiguration.cs rename to jobs/Backend/Task/ExchangeRateUpdater.Infrastructure/Installers/ExchangeRateInstaller.cs index 2ca1f0adcc..54dc4657bd 100644 --- a/jobs/Backend/Task/ExchangeRateUpdater.Core/CoreServiceConfiguration.cs +++ b/jobs/Backend/Task/ExchangeRateUpdater.Infrastructure/Installers/ExchangeRateInstaller.cs @@ -1,23 +1,27 @@ -using ExchangeRateUpdater.Core.Configuration; -using ExchangeRateUpdater.Core.Interfaces; -using ExchangeRateUpdater.Core.Providers; -using ExchangeRateUpdater.Core.Services; +using ExchangeRateUpdater.Domain.Models; +using ExchangeRateUpdater.Domain.Interfaces; +using ExchangeRateUpdater.Infrastructure.Providers; +using ExchangeRateUpdater.Infrastructure.Services; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Options; using Polly; using Polly.Extensions.Http; -namespace ExchangeRateUpdater.Core; +namespace ExchangeRateUpdater.Infrastructure.Installers; -public static class CoreServiceConfiguration +public static class ExchangeRateInstaller { - public static IServiceCollection AddExchangeRateCoreDependencies(this IServiceCollection services, IConfiguration configuration) + public static IServiceCollection AddExchangeRateServices(this IServiceCollection services, IConfiguration configuration) { + // Configure options services.Configure(configuration.GetSection(ExchangeRateOptions.SectionName)); services.Configure(configuration.GetSection(CzechNationalBankOptions.SectionName)); - var exchangeRateOptions = configuration.GetSection(ExchangeRateOptions.SectionName).Get() ?? new ExchangeRateOptions(); + + var exchangeRateOptions = configuration.GetSection(ExchangeRateOptions.SectionName).Get() + ?? new ExchangeRateOptions(); + // Configure CNB Provider with resilience policies services.AddHttpClient((serviceProvider, client) => { var options = serviceProvider.GetRequiredService>().Value; @@ -27,6 +31,7 @@ public static IServiceCollection AddExchangeRateCoreDependencies(this IServiceCo .AddPolicyHandler(GetRetryPolicy(exchangeRateOptions)) .AddPolicyHandler(Policy.TimeoutAsync(exchangeRateOptions.RequestTimeout)); + // Register services services.AddScoped(); services.AddScoped(); @@ -45,4 +50,4 @@ private static IAsyncPolicy GetRetryPolicy(ExchangeRateOpti Console.WriteLine($"Retry {retryCount} after {timespan.TotalMilliseconds}ms due to: {outcome.Exception?.Message ?? outcome.Result?.StatusCode.ToString()}"); }); } -} +} \ No newline at end of file diff --git a/jobs/Backend/Task/ExchangeRateUpdater.Infrastructure/Installers/ServiceCollectionExtensions.cs b/jobs/Backend/Task/ExchangeRateUpdater.Infrastructure/Installers/ServiceCollectionExtensions.cs new file mode 100644 index 0000000000..cc6ab0e798 --- /dev/null +++ b/jobs/Backend/Task/ExchangeRateUpdater.Infrastructure/Installers/ServiceCollectionExtensions.cs @@ -0,0 +1,19 @@ +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; + +namespace ExchangeRateUpdater.Infrastructure.Installers; + +public static class ServiceCollectionExtensions +{ + public static IServiceCollection AddExchangeRateInfrastructure( + this IServiceCollection services, + IConfiguration configuration, + bool useApiCache = true) + { + services + .AddExchangeRateServices(configuration) + .AddCaching(configuration, useApiCache); + + return services; + } +} \ No newline at end of file diff --git a/jobs/Backend/Task/ExchangeRateUpdater.Core/Providers/CzechNationalBankProvider.cs b/jobs/Backend/Task/ExchangeRateUpdater.Infrastructure/Providers/CzechNationalBankProvider.cs similarity index 94% rename from jobs/Backend/Task/ExchangeRateUpdater.Core/Providers/CzechNationalBankProvider.cs rename to jobs/Backend/Task/ExchangeRateUpdater.Infrastructure/Providers/CzechNationalBankProvider.cs index 6b96799842..47f444e400 100644 --- a/jobs/Backend/Task/ExchangeRateUpdater.Core/Providers/CzechNationalBankProvider.cs +++ b/jobs/Backend/Task/ExchangeRateUpdater.Infrastructure/Providers/CzechNationalBankProvider.cs @@ -1,13 +1,12 @@ using System.Text.Json; -using ExchangeRateUpdater.Core.Common; -using ExchangeRateUpdater.Core.Configuration; -using ExchangeRateUpdater.Core.Extensions; -using ExchangeRateUpdater.Core.Interfaces; -using ExchangeRateUpdater.Core.Models; +using ExchangeRateUpdater.Domain.Common; +using ExchangeRateUpdater.Domain.Models; +using ExchangeRateUpdater.Domain.Extensions; +using ExchangeRateUpdater.Domain.Interfaces; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; -namespace ExchangeRateUpdater.Core.Providers; +namespace ExchangeRateUpdater.Infrastructure.Providers; /// /// Exchange rate provider for Czech National Bank diff --git a/jobs/Backend/Task/ExchangeRateUpdater.Core/Services/ExchangeRateService.cs b/jobs/Backend/Task/ExchangeRateUpdater.Infrastructure/Services/ExchangeRateService.cs similarity index 94% rename from jobs/Backend/Task/ExchangeRateUpdater.Core/Services/ExchangeRateService.cs rename to jobs/Backend/Task/ExchangeRateUpdater.Infrastructure/Services/ExchangeRateService.cs index 3f75abbd16..8889b81cd5 100644 --- a/jobs/Backend/Task/ExchangeRateUpdater.Core/Services/ExchangeRateService.cs +++ b/jobs/Backend/Task/ExchangeRateUpdater.Infrastructure/Services/ExchangeRateService.cs @@ -1,11 +1,10 @@ -using ExchangeRateUpdater.Core.Common; -using ExchangeRateUpdater.Core.Configuration; -using ExchangeRateUpdater.Core.Interfaces; -using ExchangeRateUpdater.Core.Models; +using ExchangeRateUpdater.Domain.Common; +using ExchangeRateUpdater.Domain.Models; +using ExchangeRateUpdater.Domain.Interfaces; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; -namespace ExchangeRateUpdater.Core.Services; +namespace ExchangeRateUpdater.Infrastructure.Services; public class ExchangeRateService : IExchangeRateService { diff --git a/jobs/Backend/Task/ExchangeRateUpdater.Tests/Api/ApiExchangeRateCacheTests.cs b/jobs/Backend/Task/ExchangeRateUpdater.Tests/Api/ApiExchangeRateCacheTests.cs index 9d16104a31..c67d435464 100644 --- a/jobs/Backend/Task/ExchangeRateUpdater.Tests/Api/ApiExchangeRateCacheTests.cs +++ b/jobs/Backend/Task/ExchangeRateUpdater.Tests/Api/ApiExchangeRateCacheTests.cs @@ -1,9 +1,9 @@ using Microsoft.Extensions.Caching.Memory; using Microsoft.Extensions.Logging; -using ExchangeRateUpdater.Api.Services; -using ExchangeRateUpdater.Core.Models; -using ExchangeRateUpdater.Core.Common; -using ExchangeRateUpdater.Core.Extensions; +using ExchangeRateUpdater.Infrastructure.Caching; +using ExchangeRateUpdater.Domain.Models; +using ExchangeRateUpdater.Domain.Common; +using ExchangeRateUpdater.Domain.Extensions; using FluentAssertions; using NSubstitute; @@ -58,7 +58,8 @@ public async Task GetCachedRates_WithCacheHit_ShouldReturnFilteredRates() new(new Currency("GBP"), new Currency("CZK"), 30.0m, _testDate) }; - _memoryCache.TryGetValue(_testDate, out Arg.Any()) + var expectedCacheKey = $"ExchangeRates_{_testDate:yyyy-MM-dd}"; + _memoryCache.TryGetValue(expectedCacheKey, out object? _) .Returns(x => { x[1] = allCachedRates; @@ -83,7 +84,8 @@ public async Task GetCachedRates_WithCacheMiss_ShouldReturnNothing() // Arrange var currencies = new[] { new Currency("USD") }; - _memoryCache.TryGetValue(_testDate, out Arg.Any()) + var expectedCacheKey = $"ExchangeRates_{_testDate:yyyy-MM-dd}"; + _memoryCache.TryGetValue(expectedCacheKey, out object? _) .Returns(false); // Act @@ -104,7 +106,8 @@ public async Task GetCachedRates_WithCacheHitButNoMatchingCurrencies_ShouldRetur new(new Currency("GBP"), new Currency("CZK"), 30.0m, _testDate) }; - _memoryCache.TryGetValue(_testDate, out Arg.Any()) + var expectedCacheKey = $"ExchangeRates_{_testDate:yyyy-MM-dd}"; + _memoryCache.TryGetValue(expectedCacheKey, out object? _) .Returns(x => { x[1] = allCachedRates; @@ -127,24 +130,4 @@ public async Task CacheRates_WithNullRates_ShouldThrowArgumentNullException() exception.ParamName.Should().Be("rates"); } - - [Fact] - public async Task CacheRates_WithDifferentDates_ShouldCacheWithFirstRateDate() - { - // Arrange - var yesterday = _testDate.AddDays(-1); - var rates = new List - { - new(new Currency("USD"), new Currency("CZK"), 25.0m, yesterday), - new(new Currency("EUR"), new Currency("CZK"), 27.0m, _testDate) - }; - - // Act - await _sut.CacheRates(rates, TimeSpan.FromHours(1)); - - // Assert - _memoryCache.Received(1).Set( - yesterday, - rates); - } } diff --git a/jobs/Backend/Task/ExchangeRateUpdater.Tests/Api/ExchangeRatesControllerTests.cs b/jobs/Backend/Task/ExchangeRateUpdater.Tests/Api/ExchangeRatesControllerTests.cs index c9d911e714..022e1cf043 100644 --- a/jobs/Backend/Task/ExchangeRateUpdater.Tests/Api/ExchangeRatesControllerTests.cs +++ b/jobs/Backend/Task/ExchangeRateUpdater.Tests/Api/ExchangeRatesControllerTests.cs @@ -1,8 +1,9 @@ using Microsoft.AspNetCore.Mvc; using ExchangeRateUpdater.Api.Controllers; using ExchangeRateUpdater.Api.Models; -using ExchangeRateUpdater.Core.Common; -using ExchangeRateUpdater.Core.Models; +using ExchangeRateUpdater.Domain.Common; +using ExchangeRateUpdater.Domain.Models; +using ExchangeRateUpdater.Domain.Interfaces; using Microsoft.Extensions.Logging; using NSubstitute; using FluentAssertions; @@ -43,8 +44,8 @@ public async Task GetExchangeRates_ValidRequest_ReturnsOkResult() // Assert result.Result.Should().BeOfType() - .Which.Value.Should().BeOfType>() - .Which.Should().Match>(r => + .Which.Value.Should().BeOfType>() + .Which.Should().Match>(r => r.Success && r.Data != null && r.Data.Rates.Count() == 2); diff --git a/jobs/Backend/Task/ExchangeRateUpdater.Tests/Api/Middleware/GlobalExceptionHandlingMiddlewareTests.cs b/jobs/Backend/Task/ExchangeRateUpdater.Tests/Api/Middleware/GlobalExceptionHandlingMiddlewareTests.cs index 32e828e541..6232bedba8 100644 --- a/jobs/Backend/Task/ExchangeRateUpdater.Tests/Api/Middleware/GlobalExceptionHandlingMiddlewareTests.cs +++ b/jobs/Backend/Task/ExchangeRateUpdater.Tests/Api/Middleware/GlobalExceptionHandlingMiddlewareTests.cs @@ -1,7 +1,7 @@ using System.Net; using System.Text.Json; using ExchangeRateUpdater.Api.Middleware; -using ExchangeRateUpdater.Core.Common; +using ExchangeRateUpdater.Domain.Common; using FluentAssertions; using Microsoft.AspNetCore.Http; using Microsoft.Extensions.Hosting; diff --git a/jobs/Backend/Task/ExchangeRateUpdater.Tests/Core/CurrencyTests.cs b/jobs/Backend/Task/ExchangeRateUpdater.Tests/Core/CurrencyTests.cs index 7c432566a7..5132ec36a3 100644 --- a/jobs/Backend/Task/ExchangeRateUpdater.Tests/Core/CurrencyTests.cs +++ b/jobs/Backend/Task/ExchangeRateUpdater.Tests/Core/CurrencyTests.cs @@ -1,4 +1,4 @@ -using ExchangeRateUpdater.Core.Models; +using ExchangeRateUpdater.Domain.Models; namespace ExchangeRateUpdater.Tests.Core; diff --git a/jobs/Backend/Task/ExchangeRateUpdater.Tests/Core/ExchangeRateServiceTests.cs b/jobs/Backend/Task/ExchangeRateUpdater.Tests/Core/ExchangeRateServiceTests.cs index d1e2876dcc..d55f680794 100644 --- a/jobs/Backend/Task/ExchangeRateUpdater.Tests/Core/ExchangeRateServiceTests.cs +++ b/jobs/Backend/Task/ExchangeRateUpdater.Tests/Core/ExchangeRateServiceTests.cs @@ -1,9 +1,8 @@ -using ExchangeRateUpdater.Core.Common; -using ExchangeRateUpdater.Core.Configuration; -using ExchangeRateUpdater.Core.Interfaces; -using ExchangeRateUpdater.Core.Models; -using ExchangeRateUpdater.Core.Extensions; -using ExchangeRateUpdater.Core.Services; +using ExchangeRateUpdater.Domain.Common; +using ExchangeRateUpdater.Domain.Interfaces; +using ExchangeRateUpdater.Domain.Models; +using ExchangeRateUpdater.Domain.Extensions; +using ExchangeRateUpdater.Infrastructure.Services; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; using FluentAssertions; diff --git a/jobs/Backend/Task/ExchangeRateUpdater.Tests/Core/MaybeTests.cs b/jobs/Backend/Task/ExchangeRateUpdater.Tests/Core/MaybeTests.cs index 2c89e6dedb..87fe0254e2 100644 --- a/jobs/Backend/Task/ExchangeRateUpdater.Tests/Core/MaybeTests.cs +++ b/jobs/Backend/Task/ExchangeRateUpdater.Tests/Core/MaybeTests.cs @@ -1,4 +1,4 @@ -using ExchangeRateUpdater.Core.Common; +using ExchangeRateUpdater.Domain.Common; namespace ExchangeRateUpdater.Tests.Core; diff --git a/jobs/Backend/Task/ExchangeRateUpdater.Tests/ExchangeRateUpdater.Tests.csproj b/jobs/Backend/Task/ExchangeRateUpdater.Tests/ExchangeRateUpdater.Tests.csproj index 57fbccc157..e6b3b2a5bf 100644 --- a/jobs/Backend/Task/ExchangeRateUpdater.Tests/ExchangeRateUpdater.Tests.csproj +++ b/jobs/Backend/Task/ExchangeRateUpdater.Tests/ExchangeRateUpdater.Tests.csproj @@ -12,11 +12,8 @@ - - - @@ -25,7 +22,8 @@ - + + diff --git a/jobs/Backend/Task/ExchangeRateUpdater.Tests/Integration/WeekendHolidayCachingTests.cs b/jobs/Backend/Task/ExchangeRateUpdater.Tests/Integration/WeekendHolidayCachingTests.cs index 17181b23e5..f2bfa4e75d 100644 --- a/jobs/Backend/Task/ExchangeRateUpdater.Tests/Integration/WeekendHolidayCachingTests.cs +++ b/jobs/Backend/Task/ExchangeRateUpdater.Tests/Integration/WeekendHolidayCachingTests.cs @@ -1,9 +1,9 @@ using Microsoft.Extensions.Caching.Memory; using Microsoft.Extensions.Logging; -using ExchangeRateUpdater.Api.Services; -using ExchangeRateUpdater.Core.Models; -using ExchangeRateUpdater.Core.Common; -using ExchangeRateUpdater.Core.Extensions; +using ExchangeRateUpdater.Infrastructure.Caching; +using ExchangeRateUpdater.Domain.Models; +using ExchangeRateUpdater.Domain.Common; +using ExchangeRateUpdater.Domain.Extensions; using FluentAssertions; using NSubstitute; @@ -52,31 +52,31 @@ public async Task GetCachedRates_OnWeekend_ShouldReturnPreviousBusinessDayRates( public async Task CacheRates_WithDifferentDates_ShouldCacheSeparately() { // Arrange - var monday = new DateTime(2024, 1, 1); var tuesday = new DateTime(2024, 1, 2); - var mondayRates = new List + var wednesday = new DateTime(2024, 1, 3); + var tuesdayRates = new List { - new(new Currency("USD"), new Currency("CZK"), 25.0m, monday) + new(new Currency("USD"), new Currency("CZK"), 25.0m, tuesday) }; - var tuesdayRates = new List + var wednesdayRates = new List { - new(new Currency("USD"), new Currency("CZK"), 26.0m, tuesday) + new(new Currency("USD"), new Currency("CZK"), 26.0m, wednesday) }; // Act - await _sut.CacheRates(mondayRates, TimeSpan.FromHours(1)); await _sut.CacheRates(tuesdayRates, TimeSpan.FromHours(1)); + await _sut.CacheRates(wednesdayRates, TimeSpan.FromHours(1)); // Assert - var cachedMondayRates = _memoryCache.Get>(monday); - var cachedTuesdayRates = _memoryCache.Get>(tuesday); - - cachedMondayRates.Should().NotBeNull(); - cachedTuesdayRates.Should().NotBeNull(); - cachedMondayRates.Should().HaveCount(1); - cachedTuesdayRates.Should().HaveCount(1); - cachedMondayRates.First().Value.Should().Be(25.0m); - cachedTuesdayRates.First().Value.Should().Be(26.0m); + var tuesdayCachedRates = _memoryCache.Get>($"ExchangeRates_{tuesday:yyyy-MM-dd}"); + var wednesdayCachedRates = _memoryCache.Get>($"ExchangeRates_{wednesday:yyyy-MM-dd}"); + + tuesdayCachedRates.Should().NotBeNull(); + wednesdayCachedRates.Should().NotBeNull(); + tuesdayCachedRates.Should().HaveCount(1); + wednesdayCachedRates.Should().HaveCount(1); + tuesdayCachedRates!.First().Value.Should().Be(25.0m); + wednesdayCachedRates!.First().Value.Should().Be(26.0m); } [Fact] diff --git a/jobs/Backend/Task/ExchangeRateUpdater.sln b/jobs/Backend/Task/ExchangeRateUpdater.sln index d856410af4..8a253c834f 100644 --- a/jobs/Backend/Task/ExchangeRateUpdater.sln +++ b/jobs/Backend/Task/ExchangeRateUpdater.sln @@ -3,14 +3,16 @@ Microsoft Visual Studio Solution File, Format Version 12.00 # Visual Studio Version 17 VisualStudioVersion = 17.0.31903.59 MinimumVisualStudioVersion = 10.0.40219.1 -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ExchangeRateUpdater.Core", "ExchangeRateUpdater.Core\ExchangeRateUpdater.Core.csproj", "{B9A8096D-3BC1-465D-8DC7-F4BCE0AB13B9}" -EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ExchangeRateUpdater.Console", "ExchangeRateUpdater.Console\ExchangeRateUpdater.Console.csproj", "{3927442B-AC37-43A4-A20A-4677DE7BE856}" EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ExchangeRateUpdater.Api", "ExchangeRateUpdater.Api\ExchangeRateUpdater.Api.csproj", "{C082C898-9F9C-4994-A20D-FDBF5F5185C8}" EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ExchangeRateUpdater.Tests", "ExchangeRateUpdater.Tests\ExchangeRateUpdater.Tests.csproj", "{727555B1-68C4-493E-8A6F-3EAD93E7F7B3}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ExchangeRateUpdater.Domain", "ExchangeRateUpdater.Domain\ExchangeRateUpdater.Domain.csproj", "{26B66444-8E9E-48F2-B9E1-E7103B1F0068}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ExchangeRateUpdater.Infrastructure", "ExchangeRateUpdater.Infrastructure\ExchangeRateUpdater.Infrastructure.csproj", "{976FB8D2-7E4E-4EAF-95D7-E59DB6A1FEDF}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -21,18 +23,6 @@ Global Release|x86 = Release|x86 EndGlobalSection GlobalSection(ProjectConfigurationPlatforms) = postSolution - {B9A8096D-3BC1-465D-8DC7-F4BCE0AB13B9}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {B9A8096D-3BC1-465D-8DC7-F4BCE0AB13B9}.Debug|Any CPU.Build.0 = Debug|Any CPU - {B9A8096D-3BC1-465D-8DC7-F4BCE0AB13B9}.Debug|x64.ActiveCfg = Debug|Any CPU - {B9A8096D-3BC1-465D-8DC7-F4BCE0AB13B9}.Debug|x64.Build.0 = Debug|Any CPU - {B9A8096D-3BC1-465D-8DC7-F4BCE0AB13B9}.Debug|x86.ActiveCfg = Debug|Any CPU - {B9A8096D-3BC1-465D-8DC7-F4BCE0AB13B9}.Debug|x86.Build.0 = Debug|Any CPU - {B9A8096D-3BC1-465D-8DC7-F4BCE0AB13B9}.Release|Any CPU.ActiveCfg = Release|Any CPU - {B9A8096D-3BC1-465D-8DC7-F4BCE0AB13B9}.Release|Any CPU.Build.0 = Release|Any CPU - {B9A8096D-3BC1-465D-8DC7-F4BCE0AB13B9}.Release|x64.ActiveCfg = Release|Any CPU - {B9A8096D-3BC1-465D-8DC7-F4BCE0AB13B9}.Release|x64.Build.0 = Release|Any CPU - {B9A8096D-3BC1-465D-8DC7-F4BCE0AB13B9}.Release|x86.ActiveCfg = Release|Any CPU - {B9A8096D-3BC1-465D-8DC7-F4BCE0AB13B9}.Release|x86.Build.0 = Release|Any CPU {3927442B-AC37-43A4-A20A-4677DE7BE856}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {3927442B-AC37-43A4-A20A-4677DE7BE856}.Debug|Any CPU.Build.0 = Debug|Any CPU {3927442B-AC37-43A4-A20A-4677DE7BE856}.Debug|x64.ActiveCfg = Debug|Any CPU @@ -69,8 +59,35 @@ Global {727555B1-68C4-493E-8A6F-3EAD93E7F7B3}.Release|x64.Build.0 = Release|Any CPU {727555B1-68C4-493E-8A6F-3EAD93E7F7B3}.Release|x86.ActiveCfg = Release|Any CPU {727555B1-68C4-493E-8A6F-3EAD93E7F7B3}.Release|x86.Build.0 = Release|Any CPU + {26B66444-8E9E-48F2-B9E1-E7103B1F0068}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {26B66444-8E9E-48F2-B9E1-E7103B1F0068}.Debug|Any CPU.Build.0 = Debug|Any CPU + {26B66444-8E9E-48F2-B9E1-E7103B1F0068}.Debug|x64.ActiveCfg = Debug|Any CPU + {26B66444-8E9E-48F2-B9E1-E7103B1F0068}.Debug|x64.Build.0 = Debug|Any CPU + {26B66444-8E9E-48F2-B9E1-E7103B1F0068}.Debug|x86.ActiveCfg = Debug|Any CPU + {26B66444-8E9E-48F2-B9E1-E7103B1F0068}.Debug|x86.Build.0 = Debug|Any CPU + {26B66444-8E9E-48F2-B9E1-E7103B1F0068}.Release|Any CPU.ActiveCfg = Release|Any CPU + {26B66444-8E9E-48F2-B9E1-E7103B1F0068}.Release|Any CPU.Build.0 = Release|Any CPU + {26B66444-8E9E-48F2-B9E1-E7103B1F0068}.Release|x64.ActiveCfg = Release|Any CPU + {26B66444-8E9E-48F2-B9E1-E7103B1F0068}.Release|x64.Build.0 = Release|Any CPU + {26B66444-8E9E-48F2-B9E1-E7103B1F0068}.Release|x86.ActiveCfg = Release|Any CPU + {26B66444-8E9E-48F2-B9E1-E7103B1F0068}.Release|x86.Build.0 = Release|Any CPU + {976FB8D2-7E4E-4EAF-95D7-E59DB6A1FEDF}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {976FB8D2-7E4E-4EAF-95D7-E59DB6A1FEDF}.Debug|Any CPU.Build.0 = Debug|Any CPU + {976FB8D2-7E4E-4EAF-95D7-E59DB6A1FEDF}.Debug|x64.ActiveCfg = Debug|Any CPU + {976FB8D2-7E4E-4EAF-95D7-E59DB6A1FEDF}.Debug|x64.Build.0 = Debug|Any CPU + {976FB8D2-7E4E-4EAF-95D7-E59DB6A1FEDF}.Debug|x86.ActiveCfg = Debug|Any CPU + {976FB8D2-7E4E-4EAF-95D7-E59DB6A1FEDF}.Debug|x86.Build.0 = Debug|Any CPU + {976FB8D2-7E4E-4EAF-95D7-E59DB6A1FEDF}.Release|Any CPU.ActiveCfg = Release|Any CPU + {976FB8D2-7E4E-4EAF-95D7-E59DB6A1FEDF}.Release|Any CPU.Build.0 = Release|Any CPU + {976FB8D2-7E4E-4EAF-95D7-E59DB6A1FEDF}.Release|x64.ActiveCfg = Release|Any CPU + {976FB8D2-7E4E-4EAF-95D7-E59DB6A1FEDF}.Release|x64.Build.0 = Release|Any CPU + {976FB8D2-7E4E-4EAF-95D7-E59DB6A1FEDF}.Release|x86.ActiveCfg = Release|Any CPU + {976FB8D2-7E4E-4EAF-95D7-E59DB6A1FEDF}.Release|x86.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE EndGlobalSection + GlobalSection(ExtensibilityGlobals) = postSolution + SolutionGuid = {175B6269-9401-4F6A-A879-03EC18D4C42D} + EndGlobalSection EndGlobal diff --git a/jobs/Backend/Task/README.md b/jobs/Backend/Task/README.md index 701d4eee96..145b187738 100644 --- a/jobs/Backend/Task/README.md +++ b/jobs/Backend/Task/README.md @@ -2,34 +2,24 @@ A .NET application that provides exchange rates from the Czech National Bank. Available as both a REST API service (with caching) and a command-line application. - -### Projects - -``` -ExchangeRateUpdater.sln -├── ExchangeRateUpdater.Core/ # Shared business logic library -├── ExchangeRateUpdater.Console/ # Console application -├── ExchangeRateUpdater.Api/ # Web API project -└── ExchangeRateUpdater.Tests/ # Unit and integration tests -``` - ## Usage Examples ### Console Application ```powershell +# Below are from the ExchangeRateUpdater.Console folder # Get specific currencies -dotnet run --project ExchangeRateUpdater.Console -- --currencies USD,EUR,GBP +dotnet run --currencies USD,EUR,GBP # Get rates for specific date -dotnet run --project ExchangeRateUpdater.Console -- --currencies USD,EUR,GBP --date 2025-09-28 +dotnet run --currencies USD,EUR,GBP --date 2025-09-28 ``` ### API Endpoints ```bash -# Get specific currencies +# Get specific currencies for the most recent date GET http://localhost:5000/api/exchangerates?currencies=USD,EUR # Combine date and currencies From 288f30294566b866718131f573b06601052e1f96 Mon Sep 17 00:00:00 2001 From: AndreKasper Date: Sun, 28 Sep 2025 20:08:27 +0200 Subject: [PATCH 07/10] Refactor of controller and MISC changes * Cache config dependency moved to implementation only * Moved from DateTime to DateOnly * Added custom binder to controller + rely on .NET auto conversion to Dateonly --- .../Binders/CommaSeparatedQueryBinder.cs | 24 +++++++++++ .../Controllers/ExchangeRatesController.cs | 35 ++++------------ .../Extensions/ExchangeRateExtensions.cs | 7 ++-- .../Installers/ExchangeRateApiInstaller.cs | 17 -------- .../ExchangeRateUpdater.Console/Program.cs | 9 ++-- .../Common/DateHelper.cs | 6 +++ .../Common/ExchangeRateProviderException.cs | 3 -- .../Interfaces/IExchangeRateCache.cs | 4 +- .../Interfaces/IExchangeRateProvider.cs | 2 +- .../Interfaces/IExchangeRateService.cs | 2 +- .../Models/ExchangeRate.cs | 9 +--- .../Caching/ApiExchangeRateCache.cs | 41 +++++++++++-------- .../Caching/NoOpExchangeRateCache.cs | 4 +- .../Providers/CzechNationalBankProvider.cs | 10 ++--- .../Services/ExchangeRateService.cs | 11 ++--- .../Api/ApiExchangeRateCacheTests.cs | 24 +++++++---- .../Api/ExchangeRatesControllerTests.cs | 36 ++++------------ .../Core/ExchangeRateServiceTests.cs | 26 ++++-------- .../Integration/WeekendHolidayCachingTests.cs | 38 ++++++++++------- jobs/Backend/Task/README.md | 13 ++++-- 20 files changed, 153 insertions(+), 168 deletions(-) create mode 100644 jobs/Backend/Task/ExchangeRateUpdater.Api/Binders/CommaSeparatedQueryBinder.cs create mode 100644 jobs/Backend/Task/ExchangeRateUpdater.Domain/Common/DateHelper.cs diff --git a/jobs/Backend/Task/ExchangeRateUpdater.Api/Binders/CommaSeparatedQueryBinder.cs b/jobs/Backend/Task/ExchangeRateUpdater.Api/Binders/CommaSeparatedQueryBinder.cs new file mode 100644 index 0000000000..81dce257d2 --- /dev/null +++ b/jobs/Backend/Task/ExchangeRateUpdater.Api/Binders/CommaSeparatedQueryBinder.cs @@ -0,0 +1,24 @@ +using Microsoft.AspNetCore.Mvc.ModelBinding; + +namespace ExchangeRateUpdater.Api.Binders; + +public class CommaSeparatedQueryBinder : IModelBinder +{ + public Task BindModelAsync(ModelBindingContext bindingContext) + { + var value = bindingContext.ValueProvider.GetValue(bindingContext.ModelName).ToString(); + + if (string.IsNullOrWhiteSpace(value)) + { + bindingContext.Result = ModelBindingResult.Success(new List()); + return Task.CompletedTask; + } + + var values = value.Split(',', StringSplitOptions.RemoveEmptyEntries) + .Select(v => v.Trim().ToUpperInvariant()) + .ToList(); + + bindingContext.Result = ModelBindingResult.Success(values); + return Task.CompletedTask; + } +} diff --git a/jobs/Backend/Task/ExchangeRateUpdater.Api/Controllers/ExchangeRatesController.cs b/jobs/Backend/Task/ExchangeRateUpdater.Api/Controllers/ExchangeRatesController.cs index 14064f19ca..367245ddb6 100644 --- a/jobs/Backend/Task/ExchangeRateUpdater.Api/Controllers/ExchangeRatesController.cs +++ b/jobs/Backend/Task/ExchangeRateUpdater.Api/Controllers/ExchangeRatesController.cs @@ -3,6 +3,7 @@ using ExchangeRateUpdater.Api.Models; using ExchangeRateUpdater.Domain.Models; using ExchangeRateUpdater.Domain.Extensions; +using ExchangeRateUpdater.Api.Binders; namespace ExchangeRateUpdater.Api.Controllers; @@ -23,7 +24,7 @@ public ExchangeRatesController(IExchangeRateService exchangeRateService, ILogger /// /// Get exchange rates for specified currencies /// - /// Comma-separated list of currency codes (e.g., USD,EUR,JPY) + /// Comma-separated list of currency codes (e.g., USD,EUR,JPY), provided as a list of strings (e.g., currencies=USD¤cies=EUR) /// Optional date in YYYY-MM-DD format. Defaults to today. /// Exchange rates for the specified currencies /// Returns the exchange rates @@ -34,13 +35,12 @@ public ExchangeRatesController(IExchangeRateService exchangeRateService, ILogger [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status400BadRequest)] [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status404NotFound)] public async Task>> GetExchangeRates( - [FromQuery] string currencies, - [FromQuery] string? date = null) + [ModelBinder(BinderType = typeof(CommaSeparatedQueryBinder))] List currencies, + [FromQuery] DateOnly? date = null) { - var parsedDate = ValidateAndParseDate(date); - var currencyObjects = ValidateAndrParseCurrencies(currencies); + var currencyObjects = ParseCurrencies(currencies); - var exchangeRates = await _exchangeRateService.GetExchangeRates(currencyObjects, parsedDate.AsMaybe()); + var exchangeRates = await _exchangeRateService.GetExchangeRates(currencyObjects, date.AsMaybe()); if (!exchangeRates.Any()) { var currencyList = string.Join(", ", currencyObjects.Select(c => c.Code)); @@ -54,32 +54,15 @@ public async Task>> GetExchang return Ok(new ApiResponse { - Data = exchangeRates.ToExchangeRateResponse(parsedDate ?? DateTime.Today), + Data = exchangeRates.ToExchangeRateResponse(date.AsMaybe()), Success = true, Message = "Exchange rates retrieved successfully" }); } - private static DateTime? ValidateAndParseDate(string? date) + private static IEnumerable ParseCurrencies(List currencies) { - if (string.IsNullOrEmpty(date)) - { - return null; - } - - if (!DateTime.TryParseExact(date, "yyyy-MM-dd", null, System.Globalization.DateTimeStyles.None, out var validDate)) - { - throw new ArgumentException($"Invalid date format. Expected format: YYYY-MM-DD (e.g., 2024-01-15). Received: '{date}'"); - } - - return validDate; - } - - private static IEnumerable ValidateAndrParseCurrencies(string currencies) - { - var currencyCodes = currencies.Split(',', StringSplitOptions.RemoveEmptyEntries) - .Select(code => code.Trim().ToUpperInvariant()) - .ToHashSet(); + var currencyCodes = currencies.Select(code => code.Trim().ToUpperInvariant()).ToHashSet(); if (!currencyCodes.Any()) { diff --git a/jobs/Backend/Task/ExchangeRateUpdater.Api/Extensions/ExchangeRateExtensions.cs b/jobs/Backend/Task/ExchangeRateUpdater.Api/Extensions/ExchangeRateExtensions.cs index a827009d08..552b25a227 100644 --- a/jobs/Backend/Task/ExchangeRateUpdater.Api/Extensions/ExchangeRateExtensions.cs +++ b/jobs/Backend/Task/ExchangeRateUpdater.Api/Extensions/ExchangeRateExtensions.cs @@ -1,4 +1,5 @@ using ExchangeRateUpdater.Api.Models; +using ExchangeRateUpdater.Domain.Common; using ExchangeRateUpdater.Domain.Models; namespace ExchangeRateUpdater.Api.Extensions; @@ -7,7 +8,7 @@ public static class ExchangeRateExtensions { public static ExchangeRateResponseDto ToExchangeRateResponse( this IEnumerable exchangeRates, - DateTime requestedDate) + Maybe requestedDate) { var rateList = exchangeRates.ToList(); @@ -16,9 +17,9 @@ public static ExchangeRateResponseDto ToExchangeRateResponse( SourceCurrency: rate.SourceCurrency.Code, TargetCurrency: rate.TargetCurrency.Code, Value: rate.Value, - Date: rate.Date + Date: rate.Date.ToDateTime(TimeOnly.MinValue) )).ToList(), - RequestedDate: requestedDate, + RequestedDate: requestedDate.TryGetValue(out var date) ? date.ToDateTime(TimeOnly.MinValue) : DateHelper.Today.ToDateTime(TimeOnly.MinValue), TotalCount: rateList.Count ); } diff --git a/jobs/Backend/Task/ExchangeRateUpdater.Api/Installers/ExchangeRateApiInstaller.cs b/jobs/Backend/Task/ExchangeRateUpdater.Api/Installers/ExchangeRateApiInstaller.cs index e78ed6c894..87744fd097 100644 --- a/jobs/Backend/Task/ExchangeRateUpdater.Api/Installers/ExchangeRateApiInstaller.cs +++ b/jobs/Backend/Task/ExchangeRateUpdater.Api/Installers/ExchangeRateApiInstaller.cs @@ -1,5 +1,3 @@ -using Microsoft.Extensions.Caching.Memory; -using ExchangeRateUpdater.Domain.Models; using ExchangeRateUpdater.Infrastructure.Installers; using ExchangeRateUpdater.Api.Middleware; @@ -16,21 +14,6 @@ public static IServiceCollection AddApiServices(this IServiceCollection services return services; } - //public static IServiceCollection AddCachingServices(this IServiceCollection services, IConfiguration configuration) - //{ - // services.AddMemoryCache(); - // services.Configure(configuration.GetSection("CacheSettings")); - // var cacheSettings = configuration.GetSection("CacheSettings").Get() ?? new CacheSettings(); - // services.Configure(options => - // { - // options.SizeLimit = cacheSettings.SizeLimit; - // options.CompactionPercentage = cacheSettings.CompactionPercentage; - // options.ExpirationScanFrequency = cacheSettings.ExpirationScanFrequency; - // }); - - // return services; - //} - public static IServiceCollection AddOpenApiServices(this IServiceCollection services) { services.AddOpenApi(); diff --git a/jobs/Backend/Task/ExchangeRateUpdater.Console/Program.cs b/jobs/Backend/Task/ExchangeRateUpdater.Console/Program.cs index 0b5b66e3dd..270653ee91 100644 --- a/jobs/Backend/Task/ExchangeRateUpdater.Console/Program.cs +++ b/jobs/Backend/Task/ExchangeRateUpdater.Console/Program.cs @@ -1,3 +1,4 @@ +using ExchangeRateUpdater.Domain.Common; using ExchangeRateUpdater.Domain.Models; using ExchangeRateUpdater.Domain.Extensions; using ExchangeRateUpdater.Infrastructure.Installers; @@ -27,7 +28,7 @@ public static class Program public static async Task Main(string[] args) { // Define command line options - var dateOption = new Option( + var dateOption = new Option( "--date", description: "The date to fetch exchange rates for (format: yyyy-MM-dd). Defaults to today.", parseArgument: result => @@ -36,7 +37,7 @@ public static async Task Main(string[] args) return null; var dateString = result.Tokens.Single().Value; - if (DateTime.TryParseExact(dateString, "yyyy-MM-dd", CultureInfo.InvariantCulture, DateTimeStyles.None, out var date)) + if (DateOnly.TryParseExact(dateString, "yyyy-MM-dd", CultureInfo.InvariantCulture, DateTimeStyles.None, out var date)) { return date; } @@ -66,7 +67,7 @@ public static async Task Main(string[] args) currenciesOption }; - rootCommand.SetHandler(async (DateTime? date, string[] currencyCodes) => + rootCommand.SetHandler(async (DateOnly? date, string[] currencyCodes) => { await RunExchangeRateUpdaterAsync(date, currencyCodes); }, dateOption, currenciesOption); @@ -75,7 +76,7 @@ public static async Task Main(string[] args) await rootCommand.InvokeAsync(args); } - private static async Task RunExchangeRateUpdaterAsync(DateTime? date, string[] currencyCodes) + private static async Task RunExchangeRateUpdaterAsync(DateOnly? date, string[] currencyCodes) { try { diff --git a/jobs/Backend/Task/ExchangeRateUpdater.Domain/Common/DateHelper.cs b/jobs/Backend/Task/ExchangeRateUpdater.Domain/Common/DateHelper.cs new file mode 100644 index 0000000000..5c8bd58557 --- /dev/null +++ b/jobs/Backend/Task/ExchangeRateUpdater.Domain/Common/DateHelper.cs @@ -0,0 +1,6 @@ +namespace ExchangeRateUpdater.Domain.Common; + +public static class DateHelper +{ + public static DateOnly Today => DateOnly.FromDateTime(DateTime.Today); +} diff --git a/jobs/Backend/Task/ExchangeRateUpdater.Domain/Common/ExchangeRateProviderException.cs b/jobs/Backend/Task/ExchangeRateUpdater.Domain/Common/ExchangeRateProviderException.cs index 7ea1f67634..32db494a98 100644 --- a/jobs/Backend/Task/ExchangeRateUpdater.Domain/Common/ExchangeRateProviderException.cs +++ b/jobs/Backend/Task/ExchangeRateUpdater.Domain/Common/ExchangeRateProviderException.cs @@ -1,8 +1,5 @@ namespace ExchangeRateUpdater.Domain.Common; -/// -/// Custom exception for exchange rate service errors -/// public class ExchangeRateProviderException : Exception { public ExchangeRateProviderException(string message) : base(message) { } diff --git a/jobs/Backend/Task/ExchangeRateUpdater.Domain/Interfaces/IExchangeRateCache.cs b/jobs/Backend/Task/ExchangeRateUpdater.Domain/Interfaces/IExchangeRateCache.cs index 41ded7fdfd..4782020ab9 100644 --- a/jobs/Backend/Task/ExchangeRateUpdater.Domain/Interfaces/IExchangeRateCache.cs +++ b/jobs/Backend/Task/ExchangeRateUpdater.Domain/Interfaces/IExchangeRateCache.cs @@ -5,6 +5,6 @@ namespace ExchangeRateUpdater.Domain.Interfaces; public interface IExchangeRateCache { - Task>> GetCachedRates(IEnumerable currencies, Maybe date); - Task CacheRates(IReadOnlyCollection rates, TimeSpan cacheExpiry); + Task>> GetCachedRates(IEnumerable currencies, DateOnly date); + Task CacheRates(IReadOnlyCollection rates); } diff --git a/jobs/Backend/Task/ExchangeRateUpdater.Domain/Interfaces/IExchangeRateProvider.cs b/jobs/Backend/Task/ExchangeRateUpdater.Domain/Interfaces/IExchangeRateProvider.cs index 5638240fb2..31397101fb 100644 --- a/jobs/Backend/Task/ExchangeRateUpdater.Domain/Interfaces/IExchangeRateProvider.cs +++ b/jobs/Backend/Task/ExchangeRateUpdater.Domain/Interfaces/IExchangeRateProvider.cs @@ -10,7 +10,7 @@ public interface IExchangeRateProvider /// /// The date to get exchange rates for (uses today if None) /// Maybe containing collection of exchange rates - Task>> GetExchangeRatesForDate(Maybe date); + Task>> GetExchangeRatesForDate(Maybe date); string ProviderName { get; } string BaseCurrency { get; } diff --git a/jobs/Backend/Task/ExchangeRateUpdater.Domain/Interfaces/IExchangeRateService.cs b/jobs/Backend/Task/ExchangeRateUpdater.Domain/Interfaces/IExchangeRateService.cs index 1e07ac8b07..d960ce35be 100644 --- a/jobs/Backend/Task/ExchangeRateUpdater.Domain/Interfaces/IExchangeRateService.cs +++ b/jobs/Backend/Task/ExchangeRateUpdater.Domain/Interfaces/IExchangeRateService.cs @@ -3,5 +3,5 @@ public interface IExchangeRateService { - Task> GetExchangeRates(IEnumerable currencies, Maybe date); + Task> GetExchangeRates(IEnumerable currencies, Maybe date); } diff --git a/jobs/Backend/Task/ExchangeRateUpdater.Domain/Models/ExchangeRate.cs b/jobs/Backend/Task/ExchangeRateUpdater.Domain/Models/ExchangeRate.cs index 615b6a7852..86483d64fc 100644 --- a/jobs/Backend/Task/ExchangeRateUpdater.Domain/Models/ExchangeRate.cs +++ b/jobs/Backend/Task/ExchangeRateUpdater.Domain/Models/ExchangeRate.cs @@ -1,13 +1,6 @@ namespace ExchangeRateUpdater.Domain.Models; -/// -/// Represents an exchange rate between two currencies for a specific date. -/// -/// The source currency -/// The target currency -/// The exchange rate value -/// The date for which this rate is valid -public record ExchangeRate(Currency SourceCurrency, Currency TargetCurrency, decimal Value, DateTime Date) +public record ExchangeRate(Currency SourceCurrency, Currency TargetCurrency, decimal Value, DateOnly Date) { public override string ToString() { diff --git a/jobs/Backend/Task/ExchangeRateUpdater.Infrastructure/Caching/ApiExchangeRateCache.cs b/jobs/Backend/Task/ExchangeRateUpdater.Infrastructure/Caching/ApiExchangeRateCache.cs index 68e2e867bc..069482bb01 100644 --- a/jobs/Backend/Task/ExchangeRateUpdater.Infrastructure/Caching/ApiExchangeRateCache.cs +++ b/jobs/Backend/Task/ExchangeRateUpdater.Infrastructure/Caching/ApiExchangeRateCache.cs @@ -5,6 +5,7 @@ using ExchangeRateUpdater.Domain.Models; using PublicHoliday; using ExchangeRateUpdater.Domain.Extensions; +using Microsoft.Extensions.Options; namespace ExchangeRateUpdater.Infrastructure.Caching; @@ -14,14 +15,16 @@ public class ApiExchangeRateCache : IExchangeRateCache private readonly IMemoryCache _memoryCache; private readonly ILogger _logger; private readonly CzechRepublicPublicHoliday _czechRepublicPublicHoliday = new(); + private readonly CacheSettings _cacheSettings; - public ApiExchangeRateCache(IMemoryCache memoryCache, ILogger logger) + public ApiExchangeRateCache(IMemoryCache memoryCache, ILogger logger, IOptions cacheSettings) { _memoryCache = memoryCache ?? throw new ArgumentNullException(nameof(memoryCache)); _logger = logger ?? throw new ArgumentNullException(nameof(logger)); + _cacheSettings = cacheSettings?.Value ?? throw new ArgumentNullException(nameof(cacheSettings)); } - public Task>> GetCachedRates(IEnumerable currencies, Maybe date) + public Task>> GetCachedRates(IEnumerable currencies, DateOnly date) { if (currencies == null) throw new ArgumentNullException(nameof(currencies)); @@ -30,11 +33,10 @@ public Task>> GetCachedRates(IEnumerable>.Nothing.AsTask(); - var targetDate = date.TryGetValue(out var dateValue) ? dateValue.Date : DateTime.Today; - var businessDate = GetBusinessDayForCacheCheck(targetDate); + var businessDate = GetBusinessDayForCacheCheck(date); var cacheKey = GetCacheKey(businessDate); - if (_memoryCache.TryGetValue(cacheKey, out List cachedRates)) + if (_memoryCache.TryGetValue(cacheKey, out List? cachedRates) && cachedRates != null) { var requestedCurrencyCodes = currencyList.Select(c => c.Code).ToHashSet(StringComparer.OrdinalIgnoreCase); var filteredRates = cachedRates.Where(rate => requestedCurrencyCodes.Contains(rate.SourceCurrency.Code)).ToList(); @@ -50,7 +52,7 @@ public Task>> GetCachedRates(IEnumerable>.Nothing.AsTask(); } - public Task CacheRates(IReadOnlyCollection rates, TimeSpan cacheExpiry) + public Task CacheRates(IReadOnlyCollection rates) { if (rates == null) throw new ArgumentNullException(nameof(rates)); @@ -58,29 +60,32 @@ public Task CacheRates(IReadOnlyCollection rates, TimeSpan cacheEx if (!rates.Any()) return Task.CompletedTask; - // Get the provider date and its corresponding business day - var providerDate = rates.First().Date.Date; - var businessDate = GetBusinessDayForCacheCheck(providerDate); // Should return the same date + var providerDate = rates.First().Date; var cacheOptions = new MemoryCacheEntryOptions { - AbsoluteExpirationRelativeToNow = cacheExpiry, - SlidingExpiration = cacheExpiry / 2, + AbsoluteExpirationRelativeToNow = _cacheSettings.DefaultCacheExpiry, + SlidingExpiration = _cacheSettings.DefaultCacheExpiry / 2, Size = 1 }; - var cacheKey = GetCacheKey(businessDate); + var cacheKey = GetCacheKey(providerDate); _memoryCache.Set(cacheKey, rates, cacheOptions); - _logger.LogInformation($"Cache SET - Key: {cacheKey}, Rates: {rates.Count}, Business date: {businessDate:yyyy-MM-dd}, Provider date: {providerDate:yyyy-MM-dd}"); + _logger.LogInformation($"Cache SET - Key: {cacheKey}, Rates: {rates.Count}, Provider date: {providerDate:yyyy-MM-dd}"); return Task.CompletedTask; } - private DateTime GetBusinessDayForCacheCheck(DateTime date) + /// + /// CNB API returns the closest busines date in case we request rates for a holiday or weekend. This is to match that behaviour when reading the cache. + /// + /// + /// / + private DateOnly GetBusinessDayForCacheCheck(DateOnly date) { - var checkDate = date.Date; - - while (_czechRepublicPublicHoliday.IsPublicHoliday(checkDate) || checkDate.DayOfWeek is DayOfWeek.Saturday or DayOfWeek.Sunday) + var checkDate = date; + + while (_czechRepublicPublicHoliday.IsPublicHoliday(checkDate.ToDateTime(TimeOnly.MinValue)) || checkDate.DayOfWeek is DayOfWeek.Saturday or DayOfWeek.Sunday) { checkDate = checkDate.AddDays(-1); } @@ -88,7 +93,7 @@ private DateTime GetBusinessDayForCacheCheck(DateTime date) return checkDate; } - private static string GetCacheKey(DateTime businessDate) + private static string GetCacheKey(DateOnly businessDate) { return $"ExchangeRates_{businessDate:yyyy-MM-dd}"; } diff --git a/jobs/Backend/Task/ExchangeRateUpdater.Infrastructure/Caching/NoOpExchangeRateCache.cs b/jobs/Backend/Task/ExchangeRateUpdater.Infrastructure/Caching/NoOpExchangeRateCache.cs index e3788ec19f..56c3913369 100644 --- a/jobs/Backend/Task/ExchangeRateUpdater.Infrastructure/Caching/NoOpExchangeRateCache.cs +++ b/jobs/Backend/Task/ExchangeRateUpdater.Infrastructure/Caching/NoOpExchangeRateCache.cs @@ -7,12 +7,12 @@ namespace ExchangeRateUpdater.Infrastructure.Caching; public class NoOpExchangeRateCache : IExchangeRateCache { - public Task>> GetCachedRates(IEnumerable currencies, Maybe date) + public Task>> GetCachedRates(IEnumerable currencies, DateOnly date) { return Maybe>.Nothing.AsTask(); } - public Task CacheRates(IReadOnlyCollection rates, TimeSpan cacheExpiry) + public Task CacheRates(IReadOnlyCollection rates) { return Task.CompletedTask; } diff --git a/jobs/Backend/Task/ExchangeRateUpdater.Infrastructure/Providers/CzechNationalBankProvider.cs b/jobs/Backend/Task/ExchangeRateUpdater.Infrastructure/Providers/CzechNationalBankProvider.cs index 47f444e400..3d3c5e9c9d 100644 --- a/jobs/Backend/Task/ExchangeRateUpdater.Infrastructure/Providers/CzechNationalBankProvider.cs +++ b/jobs/Backend/Task/ExchangeRateUpdater.Infrastructure/Providers/CzechNationalBankProvider.cs @@ -30,10 +30,10 @@ public CzechNationalBankProvider( _logger = logger ?? throw new ArgumentNullException(nameof(logger)); } - public async Task>> GetExchangeRatesForDate(Maybe date) + public async Task>> GetExchangeRatesForDate(Maybe date) { - var targetDate = date.GetValueOrDefault(DateTime.Today); - _logger.LogInformation($"Fetching exchange rates from {ProviderName} currencies for date {targetDate:yyyy-MM-dd}"); + var targetDate = date.GetValueOrDefault(DateHelper.Today); + _logger.LogInformation($"Fetching exchange rates from {ProviderName} currencies for date {targetDate}"); try { @@ -73,7 +73,7 @@ public async Task>> GetExchangeRatesForD } } - private string BuildApiUrl(DateTime date) + private string BuildApiUrl(DateOnly date) { var dateString = date.ToString(_options.DateFormat); return $"{_options.BaseUrl}?date={dateString}&lang={_options.Language}"; @@ -106,7 +106,7 @@ private List ParseCnbJsonFormat(string jsonContent) var sourceCurrency = new Currency(rateDto.CurrencyCode); var targetCurrency = new Currency(BaseCurrency); - DateTime exchangeDate = DateTime.TryParse(rateDto.ValidFor, out var parsedDate) ? parsedDate : DateTime.Today; + DateOnly exchangeDate = DateOnly.TryParse(rateDto.ValidFor, out var parsedDate) ? parsedDate : DateHelper.Today; var exchangeRate = new ExchangeRate(sourceCurrency, targetCurrency, ratePerUnit, exchangeDate); rates.Add(exchangeRate); diff --git a/jobs/Backend/Task/ExchangeRateUpdater.Infrastructure/Services/ExchangeRateService.cs b/jobs/Backend/Task/ExchangeRateUpdater.Infrastructure/Services/ExchangeRateService.cs index 8889b81cd5..dba577f1c7 100644 --- a/jobs/Backend/Task/ExchangeRateUpdater.Infrastructure/Services/ExchangeRateService.cs +++ b/jobs/Backend/Task/ExchangeRateUpdater.Infrastructure/Services/ExchangeRateService.cs @@ -12,25 +12,22 @@ public class ExchangeRateService : IExchangeRateService private readonly IExchangeRateCache _cache; private readonly ILogger _logger; private readonly ExchangeRateOptions _exchangeOptions; - private readonly CacheSettings _cacheSettings; public ExchangeRateService( IExchangeRateProvider provider, IExchangeRateCache cache, ILogger logger, - IOptions options, - IOptions cacheSettings) + IOptions options) { _provider = provider ?? throw new ArgumentNullException(nameof(provider)); _cache = cache ?? throw new ArgumentNullException(nameof(cache)); _logger = logger ?? throw new ArgumentNullException(nameof(logger)); _exchangeOptions = options?.Value ?? throw new ArgumentNullException(nameof(options)); - _cacheSettings = cacheSettings?.Value ?? throw new ArgumentNullException(nameof(cacheSettings)); } public async Task> GetExchangeRates( IEnumerable currencies, - Maybe date + Maybe date ) { if (currencies == null) @@ -40,7 +37,7 @@ Maybe date if (!currencyList.Any()) return Enumerable.Empty(); - var targetDate = date.GetValueOrDefault(DateTime.Today); + var targetDate = date.GetValueOrDefault(DateHelper.Today); _logger.LogInformation($"Getting exchange rates for {currencyList.Count} currencies ({string.Join(", ", currencyList.Select(c => c.Code))}) for date {targetDate:yyyy-MM-dd}"); try @@ -65,7 +62,7 @@ Maybe date { if (_exchangeOptions.EnableCaching) { - await _cache.CacheRates(rateList, _cacheSettings.DefaultCacheExpiry); + await _cache.CacheRates(rateList); } _logger.LogInformation($"Successfully retrieved {rateList.Count()} exchange rates"); diff --git a/jobs/Backend/Task/ExchangeRateUpdater.Tests/Api/ApiExchangeRateCacheTests.cs b/jobs/Backend/Task/ExchangeRateUpdater.Tests/Api/ApiExchangeRateCacheTests.cs index c67d435464..f487cecd6c 100644 --- a/jobs/Backend/Task/ExchangeRateUpdater.Tests/Api/ApiExchangeRateCacheTests.cs +++ b/jobs/Backend/Task/ExchangeRateUpdater.Tests/Api/ApiExchangeRateCacheTests.cs @@ -6,6 +6,7 @@ using ExchangeRateUpdater.Domain.Extensions; using FluentAssertions; using NSubstitute; +using Microsoft.Extensions.Options; namespace ExchangeRateUpdater.Tests.Api; @@ -13,14 +14,21 @@ public class ApiExchangeRateCacheTests { private readonly IMemoryCache _memoryCache; private readonly ILogger _logger; + private readonly IOptions _cacheSettings; private readonly ApiExchangeRateCache _sut; - private readonly DateTime _testDate = new(2025, 9, 26); + private readonly DateOnly _testDate = new(2025, 9, 26); public ApiExchangeRateCacheTests() { _memoryCache = Substitute.For(); _logger = Substitute.For>(); - _sut = new ApiExchangeRateCache(_memoryCache, _logger); + _cacheSettings = Substitute.For>(); + _cacheSettings.Value.Returns(new CacheSettings + { + DefaultCacheExpiry = TimeSpan.FromHours(1) + }); + + _sut = new ApiExchangeRateCache(_memoryCache, _logger, _cacheSettings); } [Fact] @@ -28,7 +36,7 @@ public async Task GetCachedRates_WithNullCurrencies_ShouldThrowArgumentNullExcep { // Act & Assert var exception = await Assert.ThrowsAsync(() => - _sut.GetCachedRates(null!, DateTime.Today.AsMaybe())); + _sut.GetCachedRates(null!, DateHelper.Today)); exception.ParamName.Should().Be("currencies"); } @@ -40,7 +48,7 @@ public async Task GetCachedRates_WithEmptyCurrencies_ShouldReturnNothing() var emptyCurrencies = Array.Empty(); // Act - var result = await _sut.GetCachedRates(emptyCurrencies, DateTime.Today.AsMaybe()); + var result = await _sut.GetCachedRates(emptyCurrencies, DateHelper.Today); // Assert result.Should().Be(Maybe>.Nothing); @@ -67,7 +75,7 @@ public async Task GetCachedRates_WithCacheHit_ShouldReturnFilteredRates() }); // Act - var result = await _sut.GetCachedRates(currencies, _testDate.AsMaybe()); + var result = await _sut.GetCachedRates(currencies, _testDate); // Assert result.Should().NotBe(Maybe>.Nothing); @@ -89,7 +97,7 @@ public async Task GetCachedRates_WithCacheMiss_ShouldReturnNothing() .Returns(false); // Act - var result = await _sut.GetCachedRates(currencies, _testDate.AsMaybe()); + var result = await _sut.GetCachedRates(currencies, _testDate); // Assert result.Should().Be(Maybe>.Nothing); @@ -115,7 +123,7 @@ public async Task GetCachedRates_WithCacheHitButNoMatchingCurrencies_ShouldRetur }); // Act - var result = await _sut.GetCachedRates(currencies, _testDate.AsMaybe()); + var result = await _sut.GetCachedRates(currencies, _testDate); // Assert result.Should().Be(Maybe>.Nothing); @@ -126,7 +134,7 @@ public async Task CacheRates_WithNullRates_ShouldThrowArgumentNullException() { // Act & Assert var exception = await Assert.ThrowsAsync(() => - _sut.CacheRates(null!, TimeSpan.FromHours(1))); + _sut.CacheRates(null!)); exception.ParamName.Should().Be("rates"); } diff --git a/jobs/Backend/Task/ExchangeRateUpdater.Tests/Api/ExchangeRatesControllerTests.cs b/jobs/Backend/Task/ExchangeRateUpdater.Tests/Api/ExchangeRatesControllerTests.cs index 022e1cf043..839d4e25f5 100644 --- a/jobs/Backend/Task/ExchangeRateUpdater.Tests/Api/ExchangeRatesControllerTests.cs +++ b/jobs/Backend/Task/ExchangeRateUpdater.Tests/Api/ExchangeRatesControllerTests.cs @@ -29,18 +29,18 @@ public async Task GetExchangeRates_ValidRequest_ReturnsOkResult() // Arrange var expectedRates = new[] { - new ExchangeRate(new Currency("CZK"), new Currency("USD"), 21.5m, DateTime.Today), - new ExchangeRate(new Currency("CZK"), new Currency("EUR"), 25.5m, DateTime.Today) + new ExchangeRate(new Currency("CZK"), new Currency("USD"), 21.5m, DateHelper.Today), + new ExchangeRate(new Currency("CZK"), new Currency("EUR"), 25.5m, DateHelper.Today) }; _exchangeRateService .GetExchangeRates( Arg.Is>(c => c.Any(x => x.Code == "USD" || x.Code == "EUR")), - Arg.Any>()) + Arg.Any>()) .Returns(expectedRates); // Act - var result = await _sut.GetExchangeRates("USD,EUR"); + var result = await _sut.GetExchangeRates(["USD", "EUR"]); // Assert result.Result.Should().BeOfType() @@ -51,37 +51,17 @@ public async Task GetExchangeRates_ValidRequest_ReturnsOkResult() r.Data.Rates.Count() == 2); } - [Fact] - public async Task GetExchangeRates_EmptyCurrencies_ThrowsArgumentException() - { - // Act & Assert - var action = () => _sut.GetExchangeRates(""); - - await action.Should().ThrowAsync() - .WithMessage("At least one currency code must be provided"); - } - - [Fact] - public async Task GetExchangeRates_InvalidDate_ThrowsArgumentException() - { - // Act & Assert - var action = () => _sut.GetExchangeRates("USD", "invalid-date"); - - await action.Should().ThrowAsync() - .WithMessage("*Invalid date format*"); - } - [Fact] public async Task GetExchangeRates_ServiceThrowsException_PropagatesException() { // Arrange _exchangeRateService - .GetExchangeRates(Arg.Any>(), Arg.Any>()) + .GetExchangeRates(Arg.Any>(), Arg.Any>()) .Returns(Task.FromException>( new ExchangeRateProviderException("Provider error"))); // Act & Assert - var action = () => _sut.GetExchangeRates("USD"); + var action = () => _sut.GetExchangeRates(["USD", "EUR"]); await action.Should().ThrowAsync() .WithMessage("Provider error"); @@ -92,11 +72,11 @@ public async Task GetExchangeRates_NoRatesFound_ReturnsNotFound() { // Arrange _exchangeRateService - .GetExchangeRates(Arg.Any>(), Arg.Any>()) + .GetExchangeRates(Arg.Any>(), Arg.Any>()) .Returns(Array.Empty()); // Act - var result = await _sut.GetExchangeRates("USD"); + var result = await _sut.GetExchangeRates(["USD", "EUR"]); // Assert var response = result.Result.Should().BeOfType().Subject; diff --git a/jobs/Backend/Task/ExchangeRateUpdater.Tests/Core/ExchangeRateServiceTests.cs b/jobs/Backend/Task/ExchangeRateUpdater.Tests/Core/ExchangeRateServiceTests.cs index d55f680794..c1dead4f11 100644 --- a/jobs/Backend/Task/ExchangeRateUpdater.Tests/Core/ExchangeRateServiceTests.cs +++ b/jobs/Backend/Task/ExchangeRateUpdater.Tests/Core/ExchangeRateServiceTests.cs @@ -16,9 +16,8 @@ public class ExchangeRateServiceTests private readonly IExchangeRateCache _exchangeCache; private readonly ILogger _exchangeRateservice; private readonly IOptions _exchangeOptions; - private readonly IOptions _cacheSettings; private readonly ExchangeRateService _sut; - private readonly DateTime _testDate = new DateTime(2025, 9, 26); + private readonly DateOnly _testDate = new DateOnly(2025, 9, 26); public ExchangeRateServiceTests() { @@ -26,22 +25,15 @@ public ExchangeRateServiceTests() _exchangeCache = Substitute.For(); _exchangeRateservice = Substitute.For>(); _exchangeOptions = Substitute.For>(); - _cacheSettings = Substitute.For>(); var options = new ExchangeRateOptions { EnableCaching = true }; - var cacheSettings = new CacheSettings - { - DefaultCacheExpiry = TimeSpan.FromHours(1) - }; - _exchangeOptions.Value.Returns(options); - _cacheSettings.Value.Returns(cacheSettings); - _sut = new ExchangeRateService(_exchangeProvider, _exchangeCache, _exchangeRateservice, _exchangeOptions, _cacheSettings); + _sut = new ExchangeRateService(_exchangeProvider, _exchangeCache, _exchangeRateservice, _exchangeOptions); } [Fact] @@ -55,15 +47,15 @@ public async Task GetExchangeRatesAsync_WithCachedRates_ShouldReturnCachedRates( new ExchangeRate(new Currency("EUR"), new Currency("CZK"), 27.0m, _testDate) }; - _exchangeCache.GetCachedRates(Arg.Any>(), Arg.Any>()) + _exchangeCache.GetCachedRates(Arg.Any>(), Arg.Any()) .Returns(((IReadOnlyList)cachedRates).AsMaybe()); // Act - var result = await _sut.GetExchangeRates(currencies, _testDate.AsMaybe()); + var result = await _sut.GetExchangeRates(currencies, _testDate); // Assert result.Should().HaveCount(2); - await _exchangeProvider.DidNotReceive().GetExchangeRatesForDate(Arg.Any>()); + await _exchangeProvider.DidNotReceive().GetExchangeRatesForDate(Arg.Any>()); } [Fact] @@ -76,19 +68,19 @@ public async Task GetExchangeRatesAsync_WithoutCachedRates_ShouldFetchFromProvid new ExchangeRate(new Currency("USD"), new Currency("CZK"), 25.0m, _testDate) }; - _exchangeCache.GetCachedRates(Arg.Any>(), Arg.Any>()) + _exchangeCache.GetCachedRates(Arg.Any>(), Arg.Any()) .Returns(Maybe>.Nothing); - _exchangeProvider.GetExchangeRatesForDate(Arg.Any>()) + _exchangeProvider.GetExchangeRatesForDate(Arg.Any>()) .Returns(((IReadOnlyCollection)providerRates).AsMaybe()); // Act - var result = await _sut.GetExchangeRates(currencies, _testDate.AsMaybe()); + var result = await _sut.GetExchangeRates(currencies, _testDate); // Assert result.Should().HaveCount(1); result.First().SourceCurrency.Code.Should().Be("USD"); - await _exchangeCache.Received(1).CacheRates(Arg.Any>(), Arg.Any()); + await _exchangeCache.Received(1).CacheRates(Arg.Any>()); } [Fact] diff --git a/jobs/Backend/Task/ExchangeRateUpdater.Tests/Integration/WeekendHolidayCachingTests.cs b/jobs/Backend/Task/ExchangeRateUpdater.Tests/Integration/WeekendHolidayCachingTests.cs index f2bfa4e75d..bad327bce8 100644 --- a/jobs/Backend/Task/ExchangeRateUpdater.Tests/Integration/WeekendHolidayCachingTests.cs +++ b/jobs/Backend/Task/ExchangeRateUpdater.Tests/Integration/WeekendHolidayCachingTests.cs @@ -6,6 +6,7 @@ using ExchangeRateUpdater.Domain.Extensions; using FluentAssertions; using NSubstitute; +using Microsoft.Extensions.Options; namespace ExchangeRateUpdater.Tests.Integration; @@ -14,32 +15,39 @@ public class WeekendHolidayCachingTests private readonly IMemoryCache _memoryCache; private readonly ILogger _logger; private readonly ApiExchangeRateCache _sut; + private readonly IOptions _cacheSettings; public WeekendHolidayCachingTests() { _memoryCache = new MemoryCache(new MemoryCacheOptions()); _logger = Substitute.For>(); - _sut = new ApiExchangeRateCache(_memoryCache, _logger); + _cacheSettings = Substitute.For>(); + _cacheSettings.Value.Returns(new CacheSettings + { + DefaultCacheExpiry = TimeSpan.FromHours(1) + }); + + _sut = new ApiExchangeRateCache(_memoryCache, _logger, _cacheSettings); } [Fact] public async Task GetCachedRates_OnWeekend_ShouldReturnPreviousBusinessDayRates() { // Arrange - var friday = new DateTime(2024, 1, 5); // Friday - var saturday = new DateTime(2024, 1, 6); // Saturday + var friday = new DateOnly(2024, 1, 5); // Friday + var saturday = new DateOnly(2024, 1, 6); // Saturday var rates = new List { new(new Currency("USD"), new Currency("CZK"), 25.0m, friday), new(new Currency("EUR"), new Currency("CZK"), 27.0m, friday) }; - await _sut.CacheRates(rates, TimeSpan.FromHours(1)); + await _sut.CacheRates(rates); // Act var result = await _sut.GetCachedRates( [new Currency("USD"), new Currency("EUR")], - saturday.AsMaybe()); + saturday); // Assert result.Should().NotBe(Maybe>.Nothing); @@ -52,8 +60,8 @@ public async Task GetCachedRates_OnWeekend_ShouldReturnPreviousBusinessDayRates( public async Task CacheRates_WithDifferentDates_ShouldCacheSeparately() { // Arrange - var tuesday = new DateTime(2024, 1, 2); - var wednesday = new DateTime(2024, 1, 3); + var tuesday = new DateOnly(2024, 1, 2); + var wednesday = new DateOnly(2024, 1, 3); var tuesdayRates = new List { new(new Currency("USD"), new Currency("CZK"), 25.0m, tuesday) @@ -64,8 +72,8 @@ public async Task CacheRates_WithDifferentDates_ShouldCacheSeparately() }; // Act - await _sut.CacheRates(tuesdayRates, TimeSpan.FromHours(1)); - await _sut.CacheRates(wednesdayRates, TimeSpan.FromHours(1)); + await _sut.CacheRates(tuesdayRates); + await _sut.CacheRates(wednesdayRates); // Assert var tuesdayCachedRates = _memoryCache.Get>($"ExchangeRates_{tuesday:yyyy-MM-dd}"); @@ -83,7 +91,7 @@ public async Task CacheRates_WithDifferentDates_ShouldCacheSeparately() public async Task GetCachedRates_WithPartialCurrencyMatch_ShouldReturnOnlyMatchingCurrencies() { // Arrange - var businessDay = new DateTime(2024, 1, 2); // Tuesday + var businessDay = new DateOnly(2024, 1, 2); // Tuesday var rates = new List { new(new Currency("USD"), new Currency("CZK"), 25.0m, businessDay), @@ -91,12 +99,12 @@ public async Task GetCachedRates_WithPartialCurrencyMatch_ShouldReturnOnlyMatchi new(new Currency("GBP"), new Currency("CZK"), 30.0m, businessDay) }; - await _sut.CacheRates(rates, TimeSpan.FromHours(1)); + await _sut.CacheRates(rates); // Act var result = await _sut.GetCachedRates( [new Currency("USD"), new Currency("GBP")], - businessDay.AsMaybe()); + businessDay); // Assert result.Should().NotBe(Maybe>.Nothing); @@ -111,18 +119,18 @@ public async Task GetCachedRates_WithPartialCurrencyMatch_ShouldReturnOnlyMatchi public async Task GetCachedRates_WithCaseInsensitiveCurrencyMatch_ShouldReturnRates() { // Arrange - var businessDay = new DateTime(2024, 1, 2); // Tuesday + var businessDay = new DateOnly(2024, 1, 2); // Tuesday var rates = new List { new(new Currency("USD"), new Currency("CZK"), 25.0m, businessDay) }; - await _sut.CacheRates(rates, TimeSpan.FromHours(1)); + await _sut.CacheRates(rates); // Act var result = await _sut.GetCachedRates( [new Currency("usd")], - businessDay.AsMaybe()); + businessDay); // Assert result.Should().NotBe(Maybe>.Nothing); diff --git a/jobs/Backend/Task/README.md b/jobs/Backend/Task/README.md index 145b187738..829cc2e864 100644 --- a/jobs/Backend/Task/README.md +++ b/jobs/Backend/Task/README.md @@ -18,14 +18,21 @@ dotnet run --currencies USD,EUR,GBP --date 2025-09-28 ### API Endpoints +Port will be 5216 when running with .NET locally or 8080 when running via docker. + ```bash # Get specific currencies for the most recent date -GET http://localhost:5000/api/exchangerates?currencies=USD,EUR +GET http://localhost:8080/api/exchangerates?currencies=USD,EUR + +# Currencies can also be passed as a list like +GET http://localhost:8080/api/exchangerates?currencies=USD¤cies=EUR&date=2025-09-28 # Combine date and currencies -GET http://localhost:5000/api/exchangerates?date=2025-09-28¤cies=USD,EUR +GET http://localhost:8080/api/exchangerates?date=2025-09-28¤cies=USD,EUR ``` +Swagger is available at `http://localhost:8080/swagger`. + ## Docker Setup 1. Build and start the services: @@ -34,7 +41,7 @@ docker-compose up -d ``` 2. Access the applications: - - API: http://localhost:5000/api/exchangerates + - API: http://localhost:8080/api/exchangerates - Console app: ```bash docker-compose run console --date 2025-09-28 From 7d2723893d57763f93ef43c9a812e1a58c289ef6 Mon Sep 17 00:00:00 2001 From: AndreKasper Date: Mon, 29 Sep 2025 11:31:35 +0200 Subject: [PATCH 08/10] Add ApiResponse builder --- .../Builders/ApiResponseBuilder.cs | 72 +++++++++++++++++++ .../Controllers/ExchangeRatesController.cs | 21 ++---- .../Models/ApiResponseBuilder.cs | 72 +++++++++++++++++++ 3 files changed, 151 insertions(+), 14 deletions(-) create mode 100644 jobs/Backend/Task/ExchangeRateUpdater.Api/Builders/ApiResponseBuilder.cs create mode 100644 jobs/Backend/Task/ExchangeRateUpdater.Api/Models/ApiResponseBuilder.cs diff --git a/jobs/Backend/Task/ExchangeRateUpdater.Api/Builders/ApiResponseBuilder.cs b/jobs/Backend/Task/ExchangeRateUpdater.Api/Builders/ApiResponseBuilder.cs new file mode 100644 index 0000000000..a9e570fe76 --- /dev/null +++ b/jobs/Backend/Task/ExchangeRateUpdater.Api/Builders/ApiResponseBuilder.cs @@ -0,0 +1,72 @@ +namespace ExchangeRateUpdater.Api.Models; + +public class ApiResponseBuilder +{ + private bool _success; + private string _message = string.Empty; + private List _errors = new(); + private DateTime _timestamp = DateTime.UtcNow; + + public ApiResponseBuilder WithSuccess(bool success = true) + { + _success = success; + return this; + } + + public ApiResponseBuilder WithMessage(string message) + { + _message = message; + return this; + } + + public ApiResponseBuilder WithErrors(params string[] errors) + { + _errors.AddRange(errors); + return this; + } + + public ApiResponseBuilder WithTimestamp(DateTime timestamp) + { + _timestamp = timestamp; + return this; + } + + public ApiResponse Build() + { + return new ApiResponse + { + Success = _success, + Message = _message, + Errors = _errors, + Timestamp = _timestamp + }; + } + + public ApiResponse Build(T data) + { + return new ApiResponse + { + Success = _success, + Message = _message, + Data = data, + Errors = _errors, + Timestamp = _timestamp + }; + } + + // Convenience static methods for common scenarios + public static ApiResponse Success(string message = "Operation completed successfully") + => new ApiResponseBuilder().WithSuccess().WithMessage(message).Build(); + + public static ApiResponse Success(T data, string message = "Operation completed successfully") + => new ApiResponseBuilder().WithSuccess().WithMessage(message).Build(data); + + public static ApiResponse BadRequest(string message, params string[] errors) + => new ApiResponseBuilder().WithSuccess(false).WithMessage(message).WithErrors(errors).Build(); + + public static ApiResponse NotFound(string message, params string[] errors) + => new ApiResponseBuilder().WithSuccess(false).WithMessage(message).WithErrors(errors).Build(); + + public static ApiResponse InternalError(string message = "An unexpected error occurred") + => new ApiResponseBuilder().WithSuccess(false).WithMessage(message).Build(); +} \ No newline at end of file diff --git a/jobs/Backend/Task/ExchangeRateUpdater.Api/Controllers/ExchangeRatesController.cs b/jobs/Backend/Task/ExchangeRateUpdater.Api/Controllers/ExchangeRatesController.cs index 367245ddb6..b4cc9998d7 100644 --- a/jobs/Backend/Task/ExchangeRateUpdater.Api/Controllers/ExchangeRatesController.cs +++ b/jobs/Backend/Task/ExchangeRateUpdater.Api/Controllers/ExchangeRatesController.cs @@ -22,9 +22,9 @@ public ExchangeRatesController(IExchangeRateService exchangeRateService, ILogger } /// - /// Get exchange rates for specified currencies + /// Get exchange rates for specified currencies on specified date or closest business day if date is not provided. /// - /// Comma-separated list of currency codes (e.g., USD,EUR,JPY), provided as a list of strings (e.g., currencies=USD¤cies=EUR) + /// Comma-separated list of currency codes provided as a list of strings like [USD,EUR,JPY] or currencies=USD¤cies=EUR /// Optional date in YYYY-MM-DD format. Defaults to today. /// Exchange rates for the specified currencies /// Returns the exchange rates @@ -44,20 +44,13 @@ public async Task>> GetExchang if (!exchangeRates.Any()) { var currencyList = string.Join(", ", currencyObjects.Select(c => c.Code)); - return NotFound(new ApiResponse - { - Success = false, - Message = "No results found", - Errors = new List { $"No exchange rates found for the specified currencies: {currencyList}" } - }); + return NotFound(ApiResponseBuilder.NotFound("No results found", + $"No exchange rates found for the specified currencies: {currencyList}")); } - return Ok(new ApiResponse - { - Data = exchangeRates.ToExchangeRateResponse(date.AsMaybe()), - Success = true, - Message = "Exchange rates retrieved successfully" - }); + return Ok(ApiResponseBuilder.Success( + exchangeRates.ToExchangeRateResponse(date.AsMaybe()), + "Exchange rates retrieved successfully")); } private static IEnumerable ParseCurrencies(List currencies) diff --git a/jobs/Backend/Task/ExchangeRateUpdater.Api/Models/ApiResponseBuilder.cs b/jobs/Backend/Task/ExchangeRateUpdater.Api/Models/ApiResponseBuilder.cs new file mode 100644 index 0000000000..a9e570fe76 --- /dev/null +++ b/jobs/Backend/Task/ExchangeRateUpdater.Api/Models/ApiResponseBuilder.cs @@ -0,0 +1,72 @@ +namespace ExchangeRateUpdater.Api.Models; + +public class ApiResponseBuilder +{ + private bool _success; + private string _message = string.Empty; + private List _errors = new(); + private DateTime _timestamp = DateTime.UtcNow; + + public ApiResponseBuilder WithSuccess(bool success = true) + { + _success = success; + return this; + } + + public ApiResponseBuilder WithMessage(string message) + { + _message = message; + return this; + } + + public ApiResponseBuilder WithErrors(params string[] errors) + { + _errors.AddRange(errors); + return this; + } + + public ApiResponseBuilder WithTimestamp(DateTime timestamp) + { + _timestamp = timestamp; + return this; + } + + public ApiResponse Build() + { + return new ApiResponse + { + Success = _success, + Message = _message, + Errors = _errors, + Timestamp = _timestamp + }; + } + + public ApiResponse Build(T data) + { + return new ApiResponse + { + Success = _success, + Message = _message, + Data = data, + Errors = _errors, + Timestamp = _timestamp + }; + } + + // Convenience static methods for common scenarios + public static ApiResponse Success(string message = "Operation completed successfully") + => new ApiResponseBuilder().WithSuccess().WithMessage(message).Build(); + + public static ApiResponse Success(T data, string message = "Operation completed successfully") + => new ApiResponseBuilder().WithSuccess().WithMessage(message).Build(data); + + public static ApiResponse BadRequest(string message, params string[] errors) + => new ApiResponseBuilder().WithSuccess(false).WithMessage(message).WithErrors(errors).Build(); + + public static ApiResponse NotFound(string message, params string[] errors) + => new ApiResponseBuilder().WithSuccess(false).WithMessage(message).WithErrors(errors).Build(); + + public static ApiResponse InternalError(string message = "An unexpected error occurred") + => new ApiResponseBuilder().WithSuccess(false).WithMessage(message).Build(); +} \ No newline at end of file From 5429b60f68f14368cf7bbb6b1b1110730f766680 Mon Sep 17 00:00:00 2001 From: AndreKasper Date: Mon, 29 Sep 2025 13:47:56 +0200 Subject: [PATCH 09/10] Add open telemetry support --- .../ExchangeRateUpdater.Api.csproj | 2 +- .../Installers/ExchangeRateApiInstaller.cs | 1 + .../GlobalExceptionHandlingMiddleware.cs | 1 + .../Models/ApiResponseBuilder.cs | 72 ---------------- .../ExchangeRateUpdater.Console/Program.cs | 1 + .../Models/ApiResponse.cs | 2 + .../Models}/ApiResponseBuilder.cs | 2 +- .../Caching/ApiExchangeRateCache.cs | 83 +++++++++++++------ .../ExchangeRateUpdater.Infrastructure.csproj | 6 ++ .../Installers/OpenTelemetryInstaller.cs | 67 +++++++++++++++ .../Services/ExchangeRateService.cs | 23 ++++- .../Telemetry/ExchangeRateTelemetry.cs | 29 +++++++ .../GlobalExceptionHandlingMiddlewareTests.cs | 3 +- .../Integration/WeekendHolidayCachingTests.cs | 5 +- 14 files changed, 193 insertions(+), 104 deletions(-) delete mode 100644 jobs/Backend/Task/ExchangeRateUpdater.Api/Models/ApiResponseBuilder.cs rename jobs/Backend/Task/{ExchangeRateUpdater.Api => ExchangeRateUpdater.Domain}/Models/ApiResponse.cs (87%) rename jobs/Backend/Task/{ExchangeRateUpdater.Api/Builders => ExchangeRateUpdater.Domain/Models}/ApiResponseBuilder.cs (97%) create mode 100644 jobs/Backend/Task/ExchangeRateUpdater.Infrastructure/Installers/OpenTelemetryInstaller.cs create mode 100644 jobs/Backend/Task/ExchangeRateUpdater.Infrastructure/Telemetry/ExchangeRateTelemetry.cs diff --git a/jobs/Backend/Task/ExchangeRateUpdater.Api/ExchangeRateUpdater.Api.csproj b/jobs/Backend/Task/ExchangeRateUpdater.Api/ExchangeRateUpdater.Api.csproj index ecbd82a766..57f79ea316 100644 --- a/jobs/Backend/Task/ExchangeRateUpdater.Api/ExchangeRateUpdater.Api.csproj +++ b/jobs/Backend/Task/ExchangeRateUpdater.Api/ExchangeRateUpdater.Api.csproj @@ -1,4 +1,4 @@ - + net9.0 diff --git a/jobs/Backend/Task/ExchangeRateUpdater.Api/Installers/ExchangeRateApiInstaller.cs b/jobs/Backend/Task/ExchangeRateUpdater.Api/Installers/ExchangeRateApiInstaller.cs index 87744fd097..46671d14aa 100644 --- a/jobs/Backend/Task/ExchangeRateUpdater.Api/Installers/ExchangeRateApiInstaller.cs +++ b/jobs/Backend/Task/ExchangeRateUpdater.Api/Installers/ExchangeRateApiInstaller.cs @@ -10,6 +10,7 @@ public static IServiceCollection AddApiServices(this IServiceCollection services services.AddControllers(); services.AddOpenApiServices(); services.AddExchangeRateInfrastructure(configuration, useApiCache: true); + services.AddOpenTelemetry(configuration, "ExchangeRateUpdaterApi"); return services; } diff --git a/jobs/Backend/Task/ExchangeRateUpdater.Api/Middleware/GlobalExceptionHandlingMiddleware.cs b/jobs/Backend/Task/ExchangeRateUpdater.Api/Middleware/GlobalExceptionHandlingMiddleware.cs index 693353a516..734505f2cb 100644 --- a/jobs/Backend/Task/ExchangeRateUpdater.Api/Middleware/GlobalExceptionHandlingMiddleware.cs +++ b/jobs/Backend/Task/ExchangeRateUpdater.Api/Middleware/GlobalExceptionHandlingMiddleware.cs @@ -1,6 +1,7 @@ using System.Net; using System.Text.Json; using ExchangeRateUpdater.Domain.Common; +using ExchangeRateUpdater.Domain.Models; namespace ExchangeRateUpdater.Api.Middleware; diff --git a/jobs/Backend/Task/ExchangeRateUpdater.Api/Models/ApiResponseBuilder.cs b/jobs/Backend/Task/ExchangeRateUpdater.Api/Models/ApiResponseBuilder.cs deleted file mode 100644 index a9e570fe76..0000000000 --- a/jobs/Backend/Task/ExchangeRateUpdater.Api/Models/ApiResponseBuilder.cs +++ /dev/null @@ -1,72 +0,0 @@ -namespace ExchangeRateUpdater.Api.Models; - -public class ApiResponseBuilder -{ - private bool _success; - private string _message = string.Empty; - private List _errors = new(); - private DateTime _timestamp = DateTime.UtcNow; - - public ApiResponseBuilder WithSuccess(bool success = true) - { - _success = success; - return this; - } - - public ApiResponseBuilder WithMessage(string message) - { - _message = message; - return this; - } - - public ApiResponseBuilder WithErrors(params string[] errors) - { - _errors.AddRange(errors); - return this; - } - - public ApiResponseBuilder WithTimestamp(DateTime timestamp) - { - _timestamp = timestamp; - return this; - } - - public ApiResponse Build() - { - return new ApiResponse - { - Success = _success, - Message = _message, - Errors = _errors, - Timestamp = _timestamp - }; - } - - public ApiResponse Build(T data) - { - return new ApiResponse - { - Success = _success, - Message = _message, - Data = data, - Errors = _errors, - Timestamp = _timestamp - }; - } - - // Convenience static methods for common scenarios - public static ApiResponse Success(string message = "Operation completed successfully") - => new ApiResponseBuilder().WithSuccess().WithMessage(message).Build(); - - public static ApiResponse Success(T data, string message = "Operation completed successfully") - => new ApiResponseBuilder().WithSuccess().WithMessage(message).Build(data); - - public static ApiResponse BadRequest(string message, params string[] errors) - => new ApiResponseBuilder().WithSuccess(false).WithMessage(message).WithErrors(errors).Build(); - - public static ApiResponse NotFound(string message, params string[] errors) - => new ApiResponseBuilder().WithSuccess(false).WithMessage(message).WithErrors(errors).Build(); - - public static ApiResponse InternalError(string message = "An unexpected error occurred") - => new ApiResponseBuilder().WithSuccess(false).WithMessage(message).Build(); -} \ No newline at end of file diff --git a/jobs/Backend/Task/ExchangeRateUpdater.Console/Program.cs b/jobs/Backend/Task/ExchangeRateUpdater.Console/Program.cs index 270653ee91..f4b502368f 100644 --- a/jobs/Backend/Task/ExchangeRateUpdater.Console/Program.cs +++ b/jobs/Backend/Task/ExchangeRateUpdater.Console/Program.cs @@ -91,6 +91,7 @@ private static async Task RunExchangeRateUpdaterAsync(DateOnly? date, string[] c // Configure services var services = new ServiceCollection(); services.AddExchangeRateInfrastructure(configuration, useApiCache: false); + services.AddOpenTelemetry(configuration, "ExchangeRateUpdaterConsole"); // Build service provider var serviceProvider = services.BuildServiceProvider(); diff --git a/jobs/Backend/Task/ExchangeRateUpdater.Api/Models/ApiResponse.cs b/jobs/Backend/Task/ExchangeRateUpdater.Domain/Models/ApiResponse.cs similarity index 87% rename from jobs/Backend/Task/ExchangeRateUpdater.Api/Models/ApiResponse.cs rename to jobs/Backend/Task/ExchangeRateUpdater.Domain/Models/ApiResponse.cs index f47de449e7..e3e7151309 100644 --- a/jobs/Backend/Task/ExchangeRateUpdater.Api/Models/ApiResponse.cs +++ b/jobs/Backend/Task/ExchangeRateUpdater.Domain/Models/ApiResponse.cs @@ -1,3 +1,5 @@ +namespace ExchangeRateUpdater.Domain.Models; + public class ApiResponse { public bool Success { get; set; } diff --git a/jobs/Backend/Task/ExchangeRateUpdater.Api/Builders/ApiResponseBuilder.cs b/jobs/Backend/Task/ExchangeRateUpdater.Domain/Models/ApiResponseBuilder.cs similarity index 97% rename from jobs/Backend/Task/ExchangeRateUpdater.Api/Builders/ApiResponseBuilder.cs rename to jobs/Backend/Task/ExchangeRateUpdater.Domain/Models/ApiResponseBuilder.cs index a9e570fe76..d789c3b030 100644 --- a/jobs/Backend/Task/ExchangeRateUpdater.Api/Builders/ApiResponseBuilder.cs +++ b/jobs/Backend/Task/ExchangeRateUpdater.Domain/Models/ApiResponseBuilder.cs @@ -1,4 +1,4 @@ -namespace ExchangeRateUpdater.Api.Models; +namespace ExchangeRateUpdater.Domain.Models; public class ApiResponseBuilder { diff --git a/jobs/Backend/Task/ExchangeRateUpdater.Infrastructure/Caching/ApiExchangeRateCache.cs b/jobs/Backend/Task/ExchangeRateUpdater.Infrastructure/Caching/ApiExchangeRateCache.cs index 069482bb01..7e25da8bd1 100644 --- a/jobs/Backend/Task/ExchangeRateUpdater.Infrastructure/Caching/ApiExchangeRateCache.cs +++ b/jobs/Backend/Task/ExchangeRateUpdater.Infrastructure/Caching/ApiExchangeRateCache.cs @@ -3,6 +3,7 @@ using ExchangeRateUpdater.Domain.Common; using ExchangeRateUpdater.Domain.Interfaces; using ExchangeRateUpdater.Domain.Models; +using ExchangeRateUpdater.Infrastructure.Telemetry; using PublicHoliday; using ExchangeRateUpdater.Domain.Extensions; using Microsoft.Extensions.Options; @@ -26,6 +27,10 @@ public ApiExchangeRateCache(IMemoryCache memoryCache, ILogger>> GetCachedRates(IEnumerable currencies, DateOnly date) { + using var activity = ExchangeRateTelemetry.ActivitySource.StartActivity("GetCachedRates"); + activity?.SetTag("currency.count", currencies.Count()); + activity?.SetTag("date", date.ToString()); + if (currencies == null) throw new ArgumentNullException(nameof(currencies)); @@ -36,54 +41,84 @@ public Task>> GetCachedRates(IEnumerable? cachedRates) && cachedRates != null) + try { - var requestedCurrencyCodes = currencyList.Select(c => c.Code).ToHashSet(StringComparer.OrdinalIgnoreCase); - var filteredRates = cachedRates.Where(rate => requestedCurrencyCodes.Contains(rate.SourceCurrency.Code)).ToList(); - _logger.LogInformation($"Cache HIT - Key: {cacheKey}, Total rates: {cachedRates.Count}, Filtered rates: {filteredRates.Count}"); - - if (filteredRates.Any()) + if (_memoryCache.TryGetValue(cacheKey, out List? cachedRates) && cachedRates != null) { - return filteredRates.AsReadOnlyList().AsMaybe().AsTask(); + var requestedCurrencyCodes = currencyList.Select(c => c.Code).ToHashSet(StringComparer.OrdinalIgnoreCase); + var filteredRates = cachedRates.Where(rate => requestedCurrencyCodes.Contains(rate.SourceCurrency.Code)).ToList(); + _logger.LogInformation($"Cache HIT - Key: {cacheKey}, Total rates: {cachedRates.Count}, Filtered rates: {filteredRates.Count}"); + + if (filteredRates.Any()) + { + ExchangeRateTelemetry.CacheHits.Add(1, new KeyValuePair("currency.count", currencyList.Count)); + return filteredRates.AsReadOnlyList().AsMaybe().AsTask(); + } } + + _logger.LogInformation($"Cache MISS - Key: {cacheKey} not found"); + ExchangeRateTelemetry.CacheMisses.Add(1, new KeyValuePair("currency.count", currencyList.Count)); + return Maybe>.Nothing.AsTask(); + } + finally + { + ExchangeRateTelemetry.CacheOperationDuration.Record(activity?.Duration.TotalSeconds ?? 0); } - - _logger.LogInformation($"Cache MISS - Key: {cacheKey} not found"); - return Maybe>.Nothing.AsTask(); } public Task CacheRates(IReadOnlyCollection rates) { + using var activity = ExchangeRateTelemetry.ActivitySource.StartActivity("CacheRates"); + activity?.SetTag("rates.count", rates.Count); + if (rates == null) throw new ArgumentNullException(nameof(rates)); if (!rates.Any()) return Task.CompletedTask; - var providerDate = rates.First().Date; - - var cacheOptions = new MemoryCacheEntryOptions + try { - AbsoluteExpirationRelativeToNow = _cacheSettings.DefaultCacheExpiry, - SlidingExpiration = _cacheSettings.DefaultCacheExpiry / 2, - Size = 1 - }; + var providerDate = rates.First().Date; + + var cacheOptions = new MemoryCacheEntryOptions + { + AbsoluteExpirationRelativeToNow = _cacheSettings.DefaultCacheExpiry, + SlidingExpiration = _cacheSettings.DefaultCacheExpiry / 2, + Size = 1 + }; - var cacheKey = GetCacheKey(providerDate); - _memoryCache.Set(cacheKey, rates, cacheOptions); + var cacheKey = GetCacheKey(providerDate); + _memoryCache.Set(cacheKey, rates, cacheOptions); - _logger.LogInformation($"Cache SET - Key: {cacheKey}, Rates: {rates.Count}, Provider date: {providerDate:yyyy-MM-dd}"); - return Task.CompletedTask; + _logger.LogInformation($"Cache SET - Key: {cacheKey}, Rates: {rates.Count}, Provider date: {providerDate:yyyy-MM-dd}"); + ExchangeRateTelemetry.CacheOperations.Add(1, new KeyValuePair("rates.count", rates.Count)); + + return Task.CompletedTask; + } + finally + { + ExchangeRateTelemetry.CacheOperationDuration.Record(activity?.Duration.TotalSeconds ?? 0); + } } - /// - /// CNB API returns the closest busines date in case we request rates for a holiday or weekend. This is to match that behaviour when reading the cache. + /// CNB API returns the closest business date in case we request rates for a holiday or weekend. + /// For today's date, if it's before 3PM, we use the previous business day since CNB publishes rates at 2:30PM. + /// This is to match that behaviour when reading the cache. /// /// - /// / + /// private DateOnly GetBusinessDayForCacheCheck(DateOnly date) { var checkDate = date; + + var czechTimeZone = TimeZoneInfo.FindSystemTimeZoneById("Central European Standard Time"); + var czechTime = TimeZoneInfo.ConvertTime(DateTime.UtcNow, czechTimeZone); + + if (checkDate == DateHelper.Today && czechTime.Hour < 15) + { + checkDate = checkDate.AddDays(-1); + } while (_czechRepublicPublicHoliday.IsPublicHoliday(checkDate.ToDateTime(TimeOnly.MinValue)) || checkDate.DayOfWeek is DayOfWeek.Saturday or DayOfWeek.Sunday) { diff --git a/jobs/Backend/Task/ExchangeRateUpdater.Infrastructure/ExchangeRateUpdater.Infrastructure.csproj b/jobs/Backend/Task/ExchangeRateUpdater.Infrastructure/ExchangeRateUpdater.Infrastructure.csproj index 30c0a638e6..471c31ac3c 100644 --- a/jobs/Backend/Task/ExchangeRateUpdater.Infrastructure/ExchangeRateUpdater.Infrastructure.csproj +++ b/jobs/Backend/Task/ExchangeRateUpdater.Infrastructure/ExchangeRateUpdater.Infrastructure.csproj @@ -9,6 +9,12 @@ + + + + + + diff --git a/jobs/Backend/Task/ExchangeRateUpdater.Infrastructure/Installers/OpenTelemetryInstaller.cs b/jobs/Backend/Task/ExchangeRateUpdater.Infrastructure/Installers/OpenTelemetryInstaller.cs new file mode 100644 index 0000000000..90d72bf70c --- /dev/null +++ b/jobs/Backend/Task/ExchangeRateUpdater.Infrastructure/Installers/OpenTelemetryInstaller.cs @@ -0,0 +1,67 @@ +using OpenTelemetry; +using OpenTelemetry.Metrics; +using OpenTelemetry.Resources; +using OpenTelemetry.Trace; +using ExchangeRateUpdater.Infrastructure.Telemetry; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Configuration; + +namespace ExchangeRateUpdater.Infrastructure.Installers; + +public static class OpenTelemetryInstaller +{ + public static IServiceCollection AddOpenTelemetry(this IServiceCollection services, IConfiguration configuration, string serviceName) + { + services.AddOpenTelemetry() + .WithTracing(builder => + { + builder + .AddAspNetCoreInstrumentation(options => + { + options.RecordException = true; + options.EnrichWithHttpRequest = (activity, httpRequest) => + { + activity.SetTag("http.request.body.size", httpRequest.ContentLength); + }; + options.EnrichWithHttpResponse = (activity, httpResponse) => + { + activity.SetTag("http.response.body.size", httpResponse.ContentLength); + }; + }) + .AddHttpClientInstrumentation(options => + { + options.RecordException = true; + options.EnrichWithHttpRequestMessage = (activity, request) => + { + activity.SetTag("http.client.request.url", request.RequestUri?.ToString()); + }; + }) + .AddSource(ExchangeRateTelemetry.ActivitySource.Name) + .SetResourceBuilder(ResourceBuilder.CreateDefault() + .AddService(serviceName, "1.0.0") + .AddAttributes(new Dictionary + { + ["deployment.environment"] = Environment.GetEnvironmentVariable("ASPNETCORE_ENVIRONMENT") ?? "Development" + })) + .AddConsoleExporter(); + // .AddOtlpExporter(options => + // { + // options.Endpoint = new Uri(configuration["OpenTelemetry:OtlpEndpoint"] ?? "http://localhost:4317"); + // }); + }) + .WithMetrics(builder => + { + builder + .AddAspNetCoreInstrumentation() + .AddHttpClientInstrumentation() + .AddMeter(ExchangeRateTelemetry.Meter.Name) + .AddConsoleExporter(); + // .AddOtlpExporter(options => + // { + // options.Endpoint = new Uri(configuration["OpenTelemetry:OtlpEndpoint"] ?? "http://localhost:4317"); + // }); + }); + + return services; + } +} diff --git a/jobs/Backend/Task/ExchangeRateUpdater.Infrastructure/Services/ExchangeRateService.cs b/jobs/Backend/Task/ExchangeRateUpdater.Infrastructure/Services/ExchangeRateService.cs index dba577f1c7..8c8aa0174b 100644 --- a/jobs/Backend/Task/ExchangeRateUpdater.Infrastructure/Services/ExchangeRateService.cs +++ b/jobs/Backend/Task/ExchangeRateUpdater.Infrastructure/Services/ExchangeRateService.cs @@ -1,6 +1,7 @@ using ExchangeRateUpdater.Domain.Common; using ExchangeRateUpdater.Domain.Models; using ExchangeRateUpdater.Domain.Interfaces; +using ExchangeRateUpdater.Infrastructure.Telemetry; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; @@ -30,6 +31,10 @@ public async Task> GetExchangeRates( Maybe date ) { + using var activity = ExchangeRateTelemetry.ActivitySource.StartActivity("GetExchangeRates"); + activity?.SetTag("currency.count", currencies.Count()); + activity?.SetTag("date", date.ToString()); + if (currencies == null) throw new ArgumentNullException(nameof(currencies)); @@ -39,17 +44,23 @@ Maybe date var targetDate = date.GetValueOrDefault(DateHelper.Today); _logger.LogInformation($"Getting exchange rates for {currencyList.Count} currencies ({string.Join(", ", currencyList.Select(c => c.Code))}) for date {targetDate:yyyy-MM-dd}"); + var cachedRates = Maybe>.Nothing; try { if (_exchangeOptions.EnableCaching) { - var cachedRates = await _cache.GetCachedRates(currencyList, targetDate); + cachedRates = await _cache.GetCachedRates(currencyList, targetDate); if (cachedRates.HasValue) { _logger.LogInformation($"Returning {cachedRates.Value.Count()} cached exchange rates"); + ExchangeRateTelemetry.CacheHits.Add(1, new KeyValuePair("currency.count", currencyList.Count)); return cachedRates.Value; } + else + { + ExchangeRateTelemetry.CacheMisses.Add(1, new KeyValuePair("currency.count", currencyList.Count)); + } } // Fetch from provider @@ -64,8 +75,9 @@ Maybe date { await _cache.CacheRates(rateList); } - + _logger.LogInformation($"Successfully retrieved {rateList.Count()} exchange rates"); + ExchangeRateTelemetry.ExchangeRateRequests.Add(1, new KeyValuePair("currency.count", currencyList.Count)); return rateList.Where(rate => currencyList.Contains(rate.SourceCurrency)); } else @@ -89,6 +101,13 @@ Maybe date _logger.LogError(ex, "Unexpected error occurred while getting exchange rates"); throw new ExchangeRateServiceException("An unexpected error occurred while getting exchange rates", ex); } + finally + { + ExchangeRateTelemetry.ExchangeRateDuration.Record( + activity?.Duration.TotalSeconds ?? 0, + new KeyValuePair("source", cachedRates.HasValue ? "cache" : "provider") + ); + } } } diff --git a/jobs/Backend/Task/ExchangeRateUpdater.Infrastructure/Telemetry/ExchangeRateTelemetry.cs b/jobs/Backend/Task/ExchangeRateUpdater.Infrastructure/Telemetry/ExchangeRateTelemetry.cs new file mode 100644 index 0000000000..d73b3141d5 --- /dev/null +++ b/jobs/Backend/Task/ExchangeRateUpdater.Infrastructure/Telemetry/ExchangeRateTelemetry.cs @@ -0,0 +1,29 @@ +using System.Diagnostics; +using System.Diagnostics.Metrics; + +namespace ExchangeRateUpdater.Infrastructure.Telemetry; + +public static class ExchangeRateTelemetry +{ + public static readonly ActivitySource ActivitySource = new("ExchangeRateUpdater"); + public static readonly Meter Meter = new("ExchangeRateUpdater.Metrics"); + + // Business metrics + public static readonly Counter ExchangeRateRequests = + Meter.CreateCounter("exchange_rate_requests_total", "Total number of exchange rate requests"); + + public static readonly Counter CacheHits = + Meter.CreateCounter("cache_hits_total", "Total number of cache hits"); + + public static readonly Counter CacheMisses = + Meter.CreateCounter("cache_misses_total", "Total number of cache misses"); + + public static readonly Counter CacheOperations = + Meter.CreateCounter("cache_operations_total", "Total number of cache operations"); + + public static readonly Histogram ExchangeRateDuration = + Meter.CreateHistogram("exchange_rate_duration_seconds", "Duration of exchange rate operations"); + + public static readonly Histogram CacheOperationDuration = + Meter.CreateHistogram("cache_operation_duration_seconds", "Duration of cache operations"); +} diff --git a/jobs/Backend/Task/ExchangeRateUpdater.Tests/Api/Middleware/GlobalExceptionHandlingMiddlewareTests.cs b/jobs/Backend/Task/ExchangeRateUpdater.Tests/Api/Middleware/GlobalExceptionHandlingMiddlewareTests.cs index 6232bedba8..dd0cdf433e 100644 --- a/jobs/Backend/Task/ExchangeRateUpdater.Tests/Api/Middleware/GlobalExceptionHandlingMiddlewareTests.cs +++ b/jobs/Backend/Task/ExchangeRateUpdater.Tests/Api/Middleware/GlobalExceptionHandlingMiddlewareTests.cs @@ -2,6 +2,7 @@ using System.Text.Json; using ExchangeRateUpdater.Api.Middleware; using ExchangeRateUpdater.Domain.Common; +using ExchangeRateUpdater.Domain.Models; using FluentAssertions; using Microsoft.AspNetCore.Http; using Microsoft.Extensions.Hosting; @@ -151,4 +152,4 @@ private async Task GetResponseAs() var content = await new StreamReader(_httpContext.Response.Body).ReadToEndAsync(); return JsonSerializer.Deserialize(content)!; } -} \ No newline at end of file +} diff --git a/jobs/Backend/Task/ExchangeRateUpdater.Tests/Integration/WeekendHolidayCachingTests.cs b/jobs/Backend/Task/ExchangeRateUpdater.Tests/Integration/WeekendHolidayCachingTests.cs index bad327bce8..f4a1fd2634 100644 --- a/jobs/Backend/Task/ExchangeRateUpdater.Tests/Integration/WeekendHolidayCachingTests.cs +++ b/jobs/Backend/Task/ExchangeRateUpdater.Tests/Integration/WeekendHolidayCachingTests.cs @@ -3,7 +3,6 @@ using ExchangeRateUpdater.Infrastructure.Caching; using ExchangeRateUpdater.Domain.Models; using ExchangeRateUpdater.Domain.Common; -using ExchangeRateUpdater.Domain.Extensions; using FluentAssertions; using NSubstitute; using Microsoft.Extensions.Options; @@ -46,7 +45,7 @@ public async Task GetCachedRates_OnWeekend_ShouldReturnPreviousBusinessDayRates( // Act var result = await _sut.GetCachedRates( - [new Currency("USD"), new Currency("EUR")], + [new Currency("USD"), new Currency("EUR")], saturday); // Assert @@ -103,7 +102,7 @@ public async Task GetCachedRates_WithPartialCurrencyMatch_ShouldReturnOnlyMatchi // Act var result = await _sut.GetCachedRates( - [new Currency("USD"), new Currency("GBP")], + [new Currency("USD"), new Currency("GBP")], businessDay); // Assert From 5aedbf77d4eade03eea14703dbf2647a1a9d82a4 Mon Sep 17 00:00:00 2001 From: AndreKasper Date: Mon, 29 Sep 2025 15:11:49 +0200 Subject: [PATCH 10/10] Edge cache on cache read fix --- .../Controllers/ExchangeRatesController.cs | 8 +-- .../ExchangeRateUpdater.Console/Program.cs | 2 - .../Caching/ApiExchangeRateCache.cs | 57 ++++++++++++------- .../Installers/OpenTelemetryInstaller.cs | 1 - .../Services/ExchangeRateService.cs | 5 +- .../Api/ExchangeRatesControllerTests.cs | 4 +- jobs/Backend/Task/README.md | 16 ++++++ 7 files changed, 60 insertions(+), 33 deletions(-) diff --git a/jobs/Backend/Task/ExchangeRateUpdater.Api/Controllers/ExchangeRatesController.cs b/jobs/Backend/Task/ExchangeRateUpdater.Api/Controllers/ExchangeRatesController.cs index b4cc9998d7..5fba3df990 100644 --- a/jobs/Backend/Task/ExchangeRateUpdater.Api/Controllers/ExchangeRatesController.cs +++ b/jobs/Backend/Task/ExchangeRateUpdater.Api/Controllers/ExchangeRatesController.cs @@ -13,19 +13,17 @@ namespace ExchangeRateUpdater.Api.Controllers; public class ExchangeRatesController : ControllerBase { private readonly IExchangeRateService _exchangeRateService; - private readonly ILogger _logger; - public ExchangeRatesController(IExchangeRateService exchangeRateService, ILogger logger) + public ExchangeRatesController(IExchangeRateService exchangeRateService) { _exchangeRateService = exchangeRateService ?? throw new ArgumentNullException(nameof(exchangeRateService)); - _logger = logger ?? throw new ArgumentNullException(nameof(logger)); } /// /// Get exchange rates for specified currencies on specified date or closest business day if date is not provided. /// - /// Comma-separated list of currency codes provided as a list of strings like [USD,EUR,JPY] or currencies=USD¤cies=EUR - /// Optional date in YYYY-MM-DD format. Defaults to today. + /// Comma-separated list of currency codes provided as a list of strings like [USD,EUR,JPY] or multiple currency parameters + /// Optional date in YYYY-MM-DD format. Defaults to today if not present or if a future date is provided. /// Exchange rates for the specified currencies /// Returns the exchange rates /// If the request is invalid diff --git a/jobs/Backend/Task/ExchangeRateUpdater.Console/Program.cs b/jobs/Backend/Task/ExchangeRateUpdater.Console/Program.cs index f4b502368f..d43d39c1d6 100644 --- a/jobs/Backend/Task/ExchangeRateUpdater.Console/Program.cs +++ b/jobs/Backend/Task/ExchangeRateUpdater.Console/Program.cs @@ -1,8 +1,6 @@ -using ExchangeRateUpdater.Domain.Common; using ExchangeRateUpdater.Domain.Models; using ExchangeRateUpdater.Domain.Extensions; using ExchangeRateUpdater.Infrastructure.Installers; -using ExchangeRateUpdater.Infrastructure.Caching; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; using System.CommandLine; diff --git a/jobs/Backend/Task/ExchangeRateUpdater.Infrastructure/Caching/ApiExchangeRateCache.cs b/jobs/Backend/Task/ExchangeRateUpdater.Infrastructure/Caching/ApiExchangeRateCache.cs index 7e25da8bd1..7ec0eb10ae 100644 --- a/jobs/Backend/Task/ExchangeRateUpdater.Infrastructure/Caching/ApiExchangeRateCache.cs +++ b/jobs/Backend/Task/ExchangeRateUpdater.Infrastructure/Caching/ApiExchangeRateCache.cs @@ -38,25 +38,22 @@ public Task>> GetCachedRates(IEnumerable>.Nothing.AsTask(); - var businessDate = GetBusinessDayForCacheCheck(date); - var cacheKey = GetCacheKey(businessDate); - try { - if (_memoryCache.TryGetValue(cacheKey, out List? cachedRates) && cachedRates != null) - { - var requestedCurrencyCodes = currencyList.Select(c => c.Code).ToHashSet(StringComparer.OrdinalIgnoreCase); - var filteredRates = cachedRates.Where(rate => requestedCurrencyCodes.Contains(rate.SourceCurrency.Code)).ToList(); - _logger.LogInformation($"Cache HIT - Key: {cacheKey}, Total rates: {cachedRates.Count}, Filtered rates: {filteredRates.Count}"); - - if (filteredRates.Any()) - { - ExchangeRateTelemetry.CacheHits.Add(1, new KeyValuePair("currency.count", currencyList.Count)); - return filteredRates.AsReadOnlyList().AsMaybe().AsTask(); - } + // First, try to get cached rates for the exact requested date + var exactDateKey = GetCacheKey(date); + if (TryGetCachedRates(exactDateKey, currencyList, out var cachedRatesT)) + return cachedRatesT.AsTask(); + + // If exact date not found, check if it is a business day; if not, find the previous business day + var businessDate = GetBusinessDayForCacheCheck(date); + if (businessDate != date){ + var businessDateKey = GetCacheKey(businessDate); + if (TryGetCachedRates(businessDateKey, currencyList, out var cachedRates)) + return cachedRates.AsTask(); } - _logger.LogInformation($"Cache MISS - Key: {cacheKey} not found"); + _logger.LogInformation($"Cache MISS - No rates found for date {date:yyyy-MM-dd} or previous business day {businessDate:yyyy-MM-dd}"); ExchangeRateTelemetry.CacheMisses.Add(1, new KeyValuePair("currency.count", currencyList.Count)); return Maybe>.Nothing.AsTask(); } @@ -101,9 +98,30 @@ public Task CacheRates(IReadOnlyCollection rates) ExchangeRateTelemetry.CacheOperationDuration.Record(activity?.Duration.TotalSeconds ?? 0); } } + + private bool TryGetCachedRates(string cacheKey, List currencyList, out Maybe> cachedRatesValue) + { + if (_memoryCache.TryGetValue(cacheKey, out List? cachedRates) && cachedRates != null) + { + _logger.LogInformation($"Cache HIT - date key: {cacheKey}"); + ExchangeRateTelemetry.CacheHits.Add(1, new KeyValuePair("currency.count", currencyList.Count)); + + var requestedCurrencyCodes = currencyList.Select(c => c.Code).ToHashSet(StringComparer.OrdinalIgnoreCase); + var filteredRates = cachedRates.Where(rate => requestedCurrencyCodes.Contains(rate.SourceCurrency.Code)).ToList(); + + if (filteredRates.Any()) + { + cachedRatesValue = filteredRates.AsReadOnlyList().AsMaybe(); + return true; + } + } + + cachedRatesValue = Maybe>.Nothing; + return false; + } + /// - /// CNB API returns the closest business date in case we request rates for a holiday or weekend. - /// For today's date, if it's before 3PM, we use the previous business day since CNB publishes rates at 2:30PM. + /// CNB API returns the closest business date in case we request rates for a holiday or weekend or simply before 2:30 PM when the rates are published. /// This is to match that behaviour when reading the cache. /// /// @@ -112,10 +130,7 @@ private DateOnly GetBusinessDayForCacheCheck(DateOnly date) { var checkDate = date; - var czechTimeZone = TimeZoneInfo.FindSystemTimeZoneById("Central European Standard Time"); - var czechTime = TimeZoneInfo.ConvertTime(DateTime.UtcNow, czechTimeZone); - - if (checkDate == DateHelper.Today && czechTime.Hour < 15) + if (checkDate == DateHelper.Today) { checkDate = checkDate.AddDays(-1); } diff --git a/jobs/Backend/Task/ExchangeRateUpdater.Infrastructure/Installers/OpenTelemetryInstaller.cs b/jobs/Backend/Task/ExchangeRateUpdater.Infrastructure/Installers/OpenTelemetryInstaller.cs index 90d72bf70c..9407af1a9f 100644 --- a/jobs/Backend/Task/ExchangeRateUpdater.Infrastructure/Installers/OpenTelemetryInstaller.cs +++ b/jobs/Backend/Task/ExchangeRateUpdater.Infrastructure/Installers/OpenTelemetryInstaller.cs @@ -1,4 +1,3 @@ -using OpenTelemetry; using OpenTelemetry.Metrics; using OpenTelemetry.Resources; using OpenTelemetry.Trace; diff --git a/jobs/Backend/Task/ExchangeRateUpdater.Infrastructure/Services/ExchangeRateService.cs b/jobs/Backend/Task/ExchangeRateUpdater.Infrastructure/Services/ExchangeRateService.cs index 8c8aa0174b..ca18c0fa34 100644 --- a/jobs/Backend/Task/ExchangeRateUpdater.Infrastructure/Services/ExchangeRateService.cs +++ b/jobs/Backend/Task/ExchangeRateUpdater.Infrastructure/Services/ExchangeRateService.cs @@ -42,7 +42,10 @@ Maybe date if (!currencyList.Any()) return Enumerable.Empty(); - var targetDate = date.GetValueOrDefault(DateHelper.Today); + var targetDate = DateHelper.Today; + if (date.TryGetValue(out var providedValue)) + targetDate = providedValue > DateHelper.Today ? DateHelper.Today : providedValue; + _logger.LogInformation($"Getting exchange rates for {currencyList.Count} currencies ({string.Join(", ", currencyList.Select(c => c.Code))}) for date {targetDate:yyyy-MM-dd}"); var cachedRates = Maybe>.Nothing; diff --git a/jobs/Backend/Task/ExchangeRateUpdater.Tests/Api/ExchangeRatesControllerTests.cs b/jobs/Backend/Task/ExchangeRateUpdater.Tests/Api/ExchangeRatesControllerTests.cs index 839d4e25f5..7192c8d14d 100644 --- a/jobs/Backend/Task/ExchangeRateUpdater.Tests/Api/ExchangeRatesControllerTests.cs +++ b/jobs/Backend/Task/ExchangeRateUpdater.Tests/Api/ExchangeRatesControllerTests.cs @@ -13,14 +13,12 @@ namespace ExchangeRateUpdater.Tests.Api; public class ExchangeRatesControllerTests { private readonly IExchangeRateService _exchangeRateService; - private readonly ILogger _logger; private readonly ExchangeRatesController _sut; public ExchangeRatesControllerTests() { _exchangeRateService = Substitute.For(); - _logger = Substitute.For>(); - _sut = new ExchangeRatesController(_exchangeRateService, _logger); + _sut = new ExchangeRatesController(_exchangeRateService); } [Fact] diff --git a/jobs/Backend/Task/README.md b/jobs/Backend/Task/README.md index 829cc2e864..f097fc88e8 100644 --- a/jobs/Backend/Task/README.md +++ b/jobs/Backend/Task/README.md @@ -2,6 +2,22 @@ A .NET application that provides exchange rates from the Czech National Bank. Available as both a REST API service (with caching) and a command-line application. +## Features + +- REST API with built-in caching for efficient rate lookups +- Command-line interface for quick rate checks +- Supports multiple currency queries +- Historical exchange rate lookups +- Swagger/OpenAPI documentation +- Docker support for easy deployment +- Telemetry integration +- Global error handling + +## Prerequisites + +- .NET 9.0 or later +- Docker (optional, for containerized deployment) + ## Usage Examples ### Console Application