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/.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/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/Dockerfile.api b/jobs/Backend/Task/Dockerfile.api new file mode 100644 index 0000000000..19c6c9dc04 --- /dev/null +++ b/jobs/Backend/Task/Dockerfile.api @@ -0,0 +1,39 @@ +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.Domain/ExchangeRateUpdater.Domain.csproj", "ExchangeRateUpdater.Domain/"] +COPY ["ExchangeRateUpdater.Infrastructure/ExchangeRateUpdater.Infrastructure.csproj", "ExchangeRateUpdater.Infrastructure/"] + +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/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/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 new file mode 100644 index 0000000000..5fba3df990 --- /dev/null +++ b/jobs/Backend/Task/ExchangeRateUpdater.Api/Controllers/ExchangeRatesController.cs @@ -0,0 +1,65 @@ +using Microsoft.AspNetCore.Mvc; +using ExchangeRateUpdater.Api.Extensions; +using ExchangeRateUpdater.Api.Models; +using ExchangeRateUpdater.Domain.Models; +using ExchangeRateUpdater.Domain.Extensions; +using ExchangeRateUpdater.Api.Binders; + +namespace ExchangeRateUpdater.Api.Controllers; + +[ApiController] +[Route("api/[controller]")] +[Produces("application/json")] +public class ExchangeRatesController : ControllerBase +{ + private readonly IExchangeRateService _exchangeRateService; + + public ExchangeRatesController(IExchangeRateService exchangeRateService) + { + _exchangeRateService = exchangeRateService ?? throw new ArgumentNullException(nameof(exchangeRateService)); + } + + /// + /// 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 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 + /// 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( + [ModelBinder(BinderType = typeof(CommaSeparatedQueryBinder))] List currencies, + [FromQuery] DateOnly? date = null) + { + var currencyObjects = ParseCurrencies(currencies); + + var exchangeRates = await _exchangeRateService.GetExchangeRates(currencyObjects, date.AsMaybe()); + if (!exchangeRates.Any()) + { + var currencyList = string.Join(", ", currencyObjects.Select(c => c.Code)); + return NotFound(ApiResponseBuilder.NotFound("No results found", + $"No exchange rates found for the specified currencies: {currencyList}")); + } + + return Ok(ApiResponseBuilder.Success( + exchangeRates.ToExchangeRateResponse(date.AsMaybe()), + "Exchange rates retrieved successfully")); + } + + private static IEnumerable ParseCurrencies(List currencies) + { + var currencyCodes = currencies.Select(code => code.Trim().ToUpperInvariant()).ToHashSet(); + + if (!currencyCodes.Any()) + { + throw new ArgumentException("At least one currency code must be provided"); + } + + return currencyCodes.Select(code => new Currency(code)); + } +} 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..57f79ea316 --- /dev/null +++ b/jobs/Backend/Task/ExchangeRateUpdater.Api/ExchangeRateUpdater.Api.csproj @@ -0,0 +1,21 @@ + + + + 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 new file mode 100644 index 0000000000..552b25a227 --- /dev/null +++ b/jobs/Backend/Task/ExchangeRateUpdater.Api/Extensions/ExchangeRateExtensions.cs @@ -0,0 +1,26 @@ +using ExchangeRateUpdater.Api.Models; +using ExchangeRateUpdater.Domain.Common; +using ExchangeRateUpdater.Domain.Models; + +namespace ExchangeRateUpdater.Api.Extensions; + +public static class ExchangeRateExtensions +{ + public static ExchangeRateResponseDto ToExchangeRateResponse( + this IEnumerable exchangeRates, + Maybe requestedDate) + { + var rateList = exchangeRates.ToList(); + + return new ExchangeRateResponseDto( + Rates: rateList.Select(rate => new ExchangeRateDto( + SourceCurrency: rate.SourceCurrency.Code, + TargetCurrency: rate.TargetCurrency.Code, + Value: rate.Value, + Date: rate.Date.ToDateTime(TimeOnly.MinValue) + )).ToList(), + 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 new file mode 100644 index 0000000000..46671d14aa --- /dev/null +++ b/jobs/Backend/Task/ExchangeRateUpdater.Api/Installers/ExchangeRateApiInstaller.cs @@ -0,0 +1,34 @@ +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); + services.AddOpenTelemetry(configuration, "ExchangeRateUpdaterApi"); + + 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 new file mode 100644 index 0000000000..734505f2cb --- /dev/null +++ b/jobs/Backend/Task/ExchangeRateUpdater.Api/Middleware/GlobalExceptionHandlingMiddleware.cs @@ -0,0 +1,75 @@ +using System.Net; +using System.Text.Json; +using ExchangeRateUpdater.Domain.Common; +using ExchangeRateUpdater.Domain.Models; + +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/Models/ExchangeRateDto.cs b/jobs/Backend/Task/ExchangeRateUpdater.Api/Models/ExchangeRateDto.cs new file mode 100644 index 0000000000..3ebfdf2fd8 --- /dev/null +++ b/jobs/Backend/Task/ExchangeRateUpdater.Api/Models/ExchangeRateDto.cs @@ -0,0 +1,13 @@ +namespace ExchangeRateUpdater.Api.Models; + +public record ExchangeRateDto( + string SourceCurrency, + string TargetCurrency, + decimal Value, + DateTime Date); + +public record ExchangeRateResponseDto( + List Rates, + DateTime RequestedDate, + int TotalCount); + diff --git a/jobs/Backend/Task/ExchangeRateUpdater.Api/Program.cs b/jobs/Backend/Task/ExchangeRateUpdater.Api/Program.cs new file mode 100644 index 0000000000..db5f1f5464 --- /dev/null +++ b/jobs/Backend/Task/ExchangeRateUpdater.Api/Program.cs @@ -0,0 +1,31 @@ +using ExchangeRateUpdater.Api.Extensions; + +var builder = WebApplication.CreateBuilder(args); + +builder.Configuration + .SetBasePath(Directory.GetCurrentDirectory()) + .AddJsonFile("appsettings.json", optional: false, reloadOnChange: true) + .AddJsonFile($"appsettings.{builder.Environment.EnvironmentName}.json", optional: true) + .AddEnvironmentVariables(); + +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.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/appsettings.Development.json b/jobs/Backend/Task/ExchangeRateUpdater.Api/appsettings.Development.json new file mode 100644 index 0000000000..4c55a1bd50 --- /dev/null +++ b/jobs/Backend/Task/ExchangeRateUpdater.Api/appsettings.Development.json @@ -0,0 +1,14 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + }, + "CacheSettings": { + "DefaultCacheExpiry": "00:05:00", + "SizeLimit": 10, + "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..156e441f14 --- /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": { + "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" + }, + "CacheSettings": { + "DefaultCacheExpiry": "24:00:00", + "SizeLimit": 1000, + "CompactionPercentage": 0.25, + "ExpirationScanFrequency": "00:20: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..67394a6d13 --- /dev/null +++ b/jobs/Backend/Task/ExchangeRateUpdater.Console/ExchangeRateUpdater.Console.csproj @@ -0,0 +1,24 @@ + + + + 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..d43d39c1d6 --- /dev/null +++ b/jobs/Backend/Task/ExchangeRateUpdater.Console/Program.cs @@ -0,0 +1,162 @@ +using ExchangeRateUpdater.Domain.Models; +using ExchangeRateUpdater.Domain.Extensions; +using ExchangeRateUpdater.Infrastructure.Installers; +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 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 (DateOnly.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 (DateOnly? date, string[] currencyCodes) => + { + await RunExchangeRateUpdaterAsync(date, currencyCodes); + }, dateOption, currenciesOption); + + // Parse and execute + await rootCommand.InvokeAsync(args); + } + + private static async Task RunExchangeRateUpdaterAsync(DateOnly? date, string[] currencyCodes) + { + try + { + var currenciesToFetch = GetCurrenciesToFetch(currencyCodes); + + var configuration = new ConfigurationBuilder() + .SetBasePath(Directory.GetCurrentDirectory()) + .AddJsonFile("appsettings.json", optional: false, reloadOnChange: true) + .AddEnvironmentVariables() + .Build(); + + // Configure services + var services = new ServiceCollection(); + services.AddExchangeRateInfrastructure(configuration, useApiCache: false); + services.AddOpenTelemetry(configuration, "ExchangeRateUpdaterConsole"); + + // 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/appsettings.json b/jobs/Backend/Task/ExchangeRateUpdater.Console/appsettings.json new file mode 100644 index 0000000000..d3d26cb538 --- /dev/null +++ b/jobs/Backend/Task/ExchangeRateUpdater.Console/appsettings.json @@ -0,0 +1,21 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft": "Warning", + "Microsoft.Hosting.Lifetime": "Information" + } + }, + "ExchangeRate": { + "MaxRetryAttempts": 3, + "RetryDelay": "00:00:02", + "RequestTimeout": "00:00:30", + "EnableCaching": false + }, + "CzechNationalBank": { + "BaseUrl": "https://api.cnb.cz/cnbapi/exrates/daily", + "DateFormat": "yyyy-MM-dd", + "Language": "EN" + } +} + 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 new file mode 100644 index 0000000000..32db494a98 --- /dev/null +++ b/jobs/Backend/Task/ExchangeRateUpdater.Domain/Common/ExchangeRateProviderException.cs @@ -0,0 +1,7 @@ +namespace ExchangeRateUpdater.Domain.Common; + +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.Domain/Common/Maybe.cs b/jobs/Backend/Task/ExchangeRateUpdater.Domain/Common/Maybe.cs new file mode 100644 index 0000000000..c8c8b6f531 --- /dev/null +++ b/jobs/Backend/Task/ExchangeRateUpdater.Domain/Common/Maybe.cs @@ -0,0 +1,66 @@ +namespace ExchangeRateUpdater.Domain.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; + } +} 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.Domain/Extensions/AsReadonlyExtensions.cs b/jobs/Backend/Task/ExchangeRateUpdater.Domain/Extensions/AsReadonlyExtensions.cs new file mode 100644 index 0000000000..70ef6bafb7 --- /dev/null +++ b/jobs/Backend/Task/ExchangeRateUpdater.Domain/Extensions/AsReadonlyExtensions.cs @@ -0,0 +1,25 @@ +using System.Diagnostics; + +namespace ExchangeRateUpdater.Domain.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() + }; + } +} diff --git a/jobs/Backend/Task/ExchangeRateUpdater.Domain/Extensions/MaybeExtensions.cs b/jobs/Backend/Task/ExchangeRateUpdater.Domain/Extensions/MaybeExtensions.cs new file mode 100644 index 0000000000..3a20677629 --- /dev/null +++ b/jobs/Backend/Task/ExchangeRateUpdater.Domain/Extensions/MaybeExtensions.cs @@ -0,0 +1,10 @@ +using ExchangeRateUpdater.Domain.Common; + +namespace ExchangeRateUpdater.Domain.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.Domain/Extensions/TaskExtensions.cs b/jobs/Backend/Task/ExchangeRateUpdater.Domain/Extensions/TaskExtensions.cs new file mode 100644 index 0000000000..65746ff21b --- /dev/null +++ b/jobs/Backend/Task/ExchangeRateUpdater.Domain/Extensions/TaskExtensions.cs @@ -0,0 +1,6 @@ +namespace ExchangeRateUpdater.Domain.Extensions; + +public static class TaskExtensions +{ + public static Task AsTask(this T value) => Task.FromResult(value); +} diff --git a/jobs/Backend/Task/ExchangeRateUpdater.Domain/Interfaces/IExchangeRateCache.cs b/jobs/Backend/Task/ExchangeRateUpdater.Domain/Interfaces/IExchangeRateCache.cs new file mode 100644 index 0000000000..4782020ab9 --- /dev/null +++ b/jobs/Backend/Task/ExchangeRateUpdater.Domain/Interfaces/IExchangeRateCache.cs @@ -0,0 +1,10 @@ +using ExchangeRateUpdater.Domain.Common; +using ExchangeRateUpdater.Domain.Models; + +namespace ExchangeRateUpdater.Domain.Interfaces; + +public interface IExchangeRateCache +{ + 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 new file mode 100644 index 0000000000..31397101fb --- /dev/null +++ b/jobs/Backend/Task/ExchangeRateUpdater.Domain/Interfaces/IExchangeRateProvider.cs @@ -0,0 +1,17 @@ +using ExchangeRateUpdater.Domain.Common; +using ExchangeRateUpdater.Domain.Models; + +namespace ExchangeRateUpdater.Domain.Interfaces; + +public interface IExchangeRateProvider +{ + /// + /// Gets exchange rates for the specified currencies for a specific date + /// + /// The date to get exchange rates for (uses today if None) + /// Maybe containing collection of exchange rates + 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 new file mode 100644 index 0000000000..d960ce35be --- /dev/null +++ b/jobs/Backend/Task/ExchangeRateUpdater.Domain/Interfaces/IExchangeRateService.cs @@ -0,0 +1,7 @@ +using ExchangeRateUpdater.Domain.Common; +using ExchangeRateUpdater.Domain.Models; + +public interface IExchangeRateService +{ + Task> GetExchangeRates(IEnumerable currencies, Maybe date); +} diff --git a/jobs/Backend/Task/ExchangeRateUpdater.Domain/Models/ApiResponse.cs b/jobs/Backend/Task/ExchangeRateUpdater.Domain/Models/ApiResponse.cs new file mode 100644 index 0000000000..e3e7151309 --- /dev/null +++ b/jobs/Backend/Task/ExchangeRateUpdater.Domain/Models/ApiResponse.cs @@ -0,0 +1,14 @@ +namespace ExchangeRateUpdater.Domain.Models; + +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.Domain/Models/ApiResponseBuilder.cs b/jobs/Backend/Task/ExchangeRateUpdater.Domain/Models/ApiResponseBuilder.cs new file mode 100644 index 0000000000..d789c3b030 --- /dev/null +++ b/jobs/Backend/Task/ExchangeRateUpdater.Domain/Models/ApiResponseBuilder.cs @@ -0,0 +1,72 @@ +namespace ExchangeRateUpdater.Domain.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.Domain/Models/CacheSettings.cs b/jobs/Backend/Task/ExchangeRateUpdater.Domain/Models/CacheSettings.cs new file mode 100644 index 0000000000..4f58eadd8b --- /dev/null +++ b/jobs/Backend/Task/ExchangeRateUpdater.Domain/Models/CacheSettings.cs @@ -0,0 +1,9 @@ +namespace ExchangeRateUpdater.Domain.Models; + +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.Domain/Models/CnbApiResponse.cs b/jobs/Backend/Task/ExchangeRateUpdater.Domain/Models/CnbApiResponse.cs new file mode 100644 index 0000000000..b8be067b35 --- /dev/null +++ b/jobs/Backend/Task/ExchangeRateUpdater.Domain/Models/CnbApiResponse.cs @@ -0,0 +1,36 @@ +using System.Text.Json.Serialization; + +namespace ExchangeRateUpdater.Domain.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.Domain/Models/Currency.cs b/jobs/Backend/Task/ExchangeRateUpdater.Domain/Models/Currency.cs new file mode 100644 index 0000000000..e1ab5cecf9 --- /dev/null +++ b/jobs/Backend/Task/ExchangeRateUpdater.Domain/Models/Currency.cs @@ -0,0 +1,25 @@ +namespace ExchangeRateUpdater.Domain.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.Domain/Models/CzechNationalBankOptions.cs b/jobs/Backend/Task/ExchangeRateUpdater.Domain/Models/CzechNationalBankOptions.cs new file mode 100644 index 0000000000..ebf59a3ffa --- /dev/null +++ b/jobs/Backend/Task/ExchangeRateUpdater.Domain/Models/CzechNationalBankOptions.cs @@ -0,0 +1,15 @@ +namespace ExchangeRateUpdater.Domain.Models; + +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.Domain/Models/ExchangeRate.cs b/jobs/Backend/Task/ExchangeRateUpdater.Domain/Models/ExchangeRate.cs new file mode 100644 index 0000000000..86483d64fc --- /dev/null +++ b/jobs/Backend/Task/ExchangeRateUpdater.Domain/Models/ExchangeRate.cs @@ -0,0 +1,9 @@ +namespace ExchangeRateUpdater.Domain.Models; + +public record ExchangeRate(Currency SourceCurrency, Currency TargetCurrency, decimal Value, DateOnly Date) +{ + public override string ToString() + { + return $"{SourceCurrency}/{TargetCurrency}={Value} (Date: {Date:yyyy-MM-dd})"; + } +} diff --git a/jobs/Backend/Task/ExchangeRateUpdater.Domain/Models/ExchangeRateOptions.cs b/jobs/Backend/Task/ExchangeRateUpdater.Domain/Models/ExchangeRateOptions.cs new file mode 100644 index 0000000000..cc25927c47 --- /dev/null +++ b/jobs/Backend/Task/ExchangeRateUpdater.Domain/Models/ExchangeRateOptions.cs @@ -0,0 +1,10 @@ +namespace ExchangeRateUpdater.Domain.Models; + +public class ExchangeRateOptions +{ + public const string SectionName = "ExchangeRate"; + 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.Infrastructure/Caching/ApiExchangeRateCache.cs b/jobs/Backend/Task/ExchangeRateUpdater.Infrastructure/Caching/ApiExchangeRateCache.cs new file mode 100644 index 0000000000..7ec0eb10ae --- /dev/null +++ b/jobs/Backend/Task/ExchangeRateUpdater.Infrastructure/Caching/ApiExchangeRateCache.cs @@ -0,0 +1,150 @@ +using Microsoft.Extensions.Caching.Memory; +using Microsoft.Extensions.Logging; +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; + +namespace ExchangeRateUpdater.Infrastructure.Caching; + +// Memory cache implementation for exchange rates that handles business day logic and provides efficient currency filtering. +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, 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, 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)); + + var currencyList = currencies.ToList(); + if (!currencyList.Any()) + return Maybe>.Nothing.AsTask(); + + try + { + // 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 - 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(); + } + finally + { + ExchangeRateTelemetry.CacheOperationDuration.Record(activity?.Duration.TotalSeconds ?? 0); + } + } + + 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; + + try + { + 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); + + _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); + } + } + + 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 or simply before 2:30 PM when the rates are published. + /// This is to match that behaviour when reading the cache. + /// + /// + /// + private DateOnly GetBusinessDayForCacheCheck(DateOnly date) + { + var checkDate = date; + + if (checkDate == DateHelper.Today) + { + checkDate = checkDate.AddDays(-1); + } + + while (_czechRepublicPublicHoliday.IsPublicHoliday(checkDate.ToDateTime(TimeOnly.MinValue)) || checkDate.DayOfWeek is DayOfWeek.Saturday or DayOfWeek.Sunday) + { + checkDate = checkDate.AddDays(-1); + } + + return checkDate; + } + + 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 new file mode 100644 index 0000000000..56c3913369 --- /dev/null +++ b/jobs/Backend/Task/ExchangeRateUpdater.Infrastructure/Caching/NoOpExchangeRateCache.cs @@ -0,0 +1,24 @@ +using ExchangeRateUpdater.Domain.Common; +using ExchangeRateUpdater.Domain.Extensions; +using ExchangeRateUpdater.Domain.Interfaces; +using ExchangeRateUpdater.Domain.Models; + +namespace ExchangeRateUpdater.Infrastructure.Caching; + +public class NoOpExchangeRateCache : IExchangeRateCache +{ + public Task>> GetCachedRates(IEnumerable currencies, DateOnly date) + { + return Maybe>.Nothing.AsTask(); + } + + public Task CacheRates(IReadOnlyCollection rates) + { + return Task.CompletedTask; + } + + public Task ClearCache() + { + return Task.CompletedTask; + } +} 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..471c31ac3c --- /dev/null +++ b/jobs/Backend/Task/ExchangeRateUpdater.Infrastructure/ExchangeRateUpdater.Infrastructure.csproj @@ -0,0 +1,26 @@ + + + + + + + + + + + + + + + + + + + + + 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.Infrastructure/Installers/ExchangeRateInstaller.cs b/jobs/Backend/Task/ExchangeRateUpdater.Infrastructure/Installers/ExchangeRateInstaller.cs new file mode 100644 index 0000000000..54dc4657bd --- /dev/null +++ b/jobs/Backend/Task/ExchangeRateUpdater.Infrastructure/Installers/ExchangeRateInstaller.cs @@ -0,0 +1,53 @@ +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.Installers; + +public static class ExchangeRateInstaller +{ + 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(); + + // Configure CNB Provider with resilience policies + 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)); + + // Register services + 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()}"); + }); + } +} \ No newline at end of file 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..9407af1a9f --- /dev/null +++ b/jobs/Backend/Task/ExchangeRateUpdater.Infrastructure/Installers/OpenTelemetryInstaller.cs @@ -0,0 +1,66 @@ +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/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.Infrastructure/Providers/CzechNationalBankProvider.cs b/jobs/Backend/Task/ExchangeRateUpdater.Infrastructure/Providers/CzechNationalBankProvider.cs new file mode 100644 index 0000000000..3d3c5e9c9d --- /dev/null +++ b/jobs/Backend/Task/ExchangeRateUpdater.Infrastructure/Providers/CzechNationalBankProvider.cs @@ -0,0 +1,128 @@ +using System.Text.Json; +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.Infrastructure.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>> GetExchangeRatesForDate(Maybe date) + { + var targetDate = date.GetValueOrDefault(DateHelper.Today); + _logger.LogInformation($"Fetching exchange rates from {ProviderName} currencies for date {targetDate}"); + + 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); + + 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(DateOnly date) + { + var dateString = date.ToString(_options.DateFormat); + return $"{_options.BaseUrl}?date={dateString}&lang={_options.Language}"; + } + + private List ParseCnbJsonFormat(string jsonContent) + { + var rates = new List(); + + 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) + { + // 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); + + DateOnly exchangeDate = DateOnly.TryParse(rateDto.ValidFor, out var parsedDate) ? parsedDate : DateHelper.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.Infrastructure/Services/ExchangeRateService.cs b/jobs/Backend/Task/ExchangeRateUpdater.Infrastructure/Services/ExchangeRateService.cs new file mode 100644 index 0000000000..ca18c0fa34 --- /dev/null +++ b/jobs/Backend/Task/ExchangeRateUpdater.Infrastructure/Services/ExchangeRateService.cs @@ -0,0 +1,121 @@ +using ExchangeRateUpdater.Domain.Common; +using ExchangeRateUpdater.Domain.Models; +using ExchangeRateUpdater.Domain.Interfaces; +using ExchangeRateUpdater.Infrastructure.Telemetry; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; + +namespace ExchangeRateUpdater.Infrastructure.Services; + +public class ExchangeRateService : IExchangeRateService +{ + private readonly IExchangeRateProvider _provider; + private readonly IExchangeRateCache _cache; + private readonly ILogger _logger; + private readonly ExchangeRateOptions _exchangeOptions; + + 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)); + _exchangeOptions = options?.Value ?? throw new ArgumentNullException(nameof(options)); + } + + public async Task> GetExchangeRates( + IEnumerable currencies, + 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)); + + var currencyList = currencies.ToList(); + if (!currencyList.Any()) + return Enumerable.Empty(); + + 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; + + try + { + if (_exchangeOptions.EnableCaching) + { + 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 + _logger.LogInformation($"Fetching fresh exchange rates from {_provider.ProviderName}"); + var maybeRates = await _provider.GetExchangeRatesForDate(date); + + if (maybeRates.TryGetValue(out var rateList)) + { + if (rateList.Any()) + { + if (_exchangeOptions.EnableCaching) + { + 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 + { + _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); + } + finally + { + ExchangeRateTelemetry.ExchangeRateDuration.Record( + activity?.Duration.TotalSeconds ?? 0, + new KeyValuePair("source", cachedRates.HasValue ? "cache" : "provider") + ); + } + } +} + +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.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/ApiExchangeRateCacheTests.cs b/jobs/Backend/Task/ExchangeRateUpdater.Tests/Api/ApiExchangeRateCacheTests.cs new file mode 100644 index 0000000000..f487cecd6c --- /dev/null +++ b/jobs/Backend/Task/ExchangeRateUpdater.Tests/Api/ApiExchangeRateCacheTests.cs @@ -0,0 +1,141 @@ +using Microsoft.Extensions.Caching.Memory; +using Microsoft.Extensions.Logging; +using ExchangeRateUpdater.Infrastructure.Caching; +using ExchangeRateUpdater.Domain.Models; +using ExchangeRateUpdater.Domain.Common; +using ExchangeRateUpdater.Domain.Extensions; +using FluentAssertions; +using NSubstitute; +using Microsoft.Extensions.Options; + +namespace ExchangeRateUpdater.Tests.Api; + +public class ApiExchangeRateCacheTests +{ + private readonly IMemoryCache _memoryCache; + private readonly ILogger _logger; + private readonly IOptions _cacheSettings; + private readonly ApiExchangeRateCache _sut; + private readonly DateOnly _testDate = new(2025, 9, 26); + + public ApiExchangeRateCacheTests() + { + _memoryCache = Substitute.For(); + _logger = Substitute.For>(); + _cacheSettings = Substitute.For>(); + _cacheSettings.Value.Returns(new CacheSettings + { + DefaultCacheExpiry = TimeSpan.FromHours(1) + }); + + _sut = new ApiExchangeRateCache(_memoryCache, _logger, _cacheSettings); + } + + [Fact] + public async Task GetCachedRates_WithNullCurrencies_ShouldThrowArgumentNullException() + { + // Act & Assert + var exception = await Assert.ThrowsAsync(() => + _sut.GetCachedRates(null!, DateHelper.Today)); + + exception.ParamName.Should().Be("currencies"); + } + + [Fact] + public async Task GetCachedRates_WithEmptyCurrencies_ShouldReturnNothing() + { + // Arrange + var emptyCurrencies = Array.Empty(); + + // Act + var result = await _sut.GetCachedRates(emptyCurrencies, DateHelper.Today); + + // 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, _testDate), + new(new Currency("EUR"), new Currency("CZK"), 27.0m, _testDate), + new(new Currency("GBP"), new Currency("CZK"), 30.0m, _testDate) + }; + + var expectedCacheKey = $"ExchangeRates_{_testDate:yyyy-MM-dd}"; + _memoryCache.TryGetValue(expectedCacheKey, out object? _) + .Returns(x => + { + x[1] = allCachedRates; + return true; + }); + + // Act + var result = await _sut.GetCachedRates(currencies, _testDate); + + // 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") }; + + var expectedCacheKey = $"ExchangeRates_{_testDate:yyyy-MM-dd}"; + _memoryCache.TryGetValue(expectedCacheKey, out object? _) + .Returns(false); + + // Act + var result = await _sut.GetCachedRates(currencies, _testDate); + + // 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, _testDate), + new(new Currency("GBP"), new Currency("CZK"), 30.0m, _testDate) + }; + + var expectedCacheKey = $"ExchangeRates_{_testDate:yyyy-MM-dd}"; + _memoryCache.TryGetValue(expectedCacheKey, out object? _) + .Returns(x => + { + x[1] = allCachedRates; + return true; + }); + + // Act + var result = await _sut.GetCachedRates(currencies, _testDate); + + // Assert + result.Should().Be(Maybe>.Nothing); + } + + [Fact] + public async Task CacheRates_WithNullRates_ShouldThrowArgumentNullException() + { + // Act & Assert + var exception = await Assert.ThrowsAsync(() => + _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 new file mode 100644 index 0000000000..7192c8d14d --- /dev/null +++ b/jobs/Backend/Task/ExchangeRateUpdater.Tests/Api/ExchangeRatesControllerTests.cs @@ -0,0 +1,86 @@ +using Microsoft.AspNetCore.Mvc; +using ExchangeRateUpdater.Api.Controllers; +using ExchangeRateUpdater.Api.Models; +using ExchangeRateUpdater.Domain.Common; +using ExchangeRateUpdater.Domain.Models; +using ExchangeRateUpdater.Domain.Interfaces; +using Microsoft.Extensions.Logging; +using NSubstitute; +using FluentAssertions; + +namespace ExchangeRateUpdater.Tests.Api; + +public class ExchangeRatesControllerTests +{ + private readonly IExchangeRateService _exchangeRateService; + private readonly ExchangeRatesController _sut; + + public ExchangeRatesControllerTests() + { + _exchangeRateService = Substitute.For(); + _sut = new ExchangeRatesController(_exchangeRateService); + } + + [Fact] + public async Task GetExchangeRates_ValidRequest_ReturnsOkResult() + { + // Arrange + var expectedRates = new[] + { + 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>()) + .Returns(expectedRates); + + // Act + var result = await _sut.GetExchangeRates(["USD", "EUR"]); + + // Assert + result.Result.Should().BeOfType() + .Which.Value.Should().BeOfType>() + .Which.Should().Match>(r => + r.Success && + r.Data != null && + r.Data.Rates.Count() == 2); + } + + [Fact] + 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", "EUR"]); + + 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 result = await _sut.GetExchangeRates(["USD", "EUR"]); + + // Assert + 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..dd0cdf433e --- /dev/null +++ b/jobs/Backend/Task/ExchangeRateUpdater.Tests/Api/Middleware/GlobalExceptionHandlingMiddlewareTests.cs @@ -0,0 +1,155 @@ +using System.Net; +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; +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)!; + } +} 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..5132ec36a3 --- /dev/null +++ b/jobs/Backend/Task/ExchangeRateUpdater.Tests/Core/CurrencyTests.cs @@ -0,0 +1,34 @@ +using ExchangeRateUpdater.Domain.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..c1dead4f11 --- /dev/null +++ b/jobs/Backend/Task/ExchangeRateUpdater.Tests/Core/ExchangeRateServiceTests.cs @@ -0,0 +1,106 @@ +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; +using NSubstitute; + +namespace ExchangeRateUpdater.Tests.Core; + +public class ExchangeRateServiceTests +{ + private readonly IExchangeRateProvider _exchangeProvider; + private readonly IExchangeRateCache _exchangeCache; + private readonly ILogger _exchangeRateservice; + private readonly IOptions _exchangeOptions; + private readonly ExchangeRateService _sut; + private readonly DateOnly _testDate = new DateOnly(2025, 9, 26); + + public ExchangeRateServiceTests() + { + _exchangeProvider = Substitute.For(); + _exchangeCache = Substitute.For(); + _exchangeRateservice = Substitute.For>(); + _exchangeOptions = Substitute.For>(); + + var options = new ExchangeRateOptions + { + EnableCaching = true + }; + + _exchangeOptions.Value.Returns(options); + + _sut = new ExchangeRateService(_exchangeProvider, _exchangeCache, _exchangeRateservice, _exchangeOptions); + } + + [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, _testDate), + new ExchangeRate(new Currency("EUR"), new Currency("CZK"), 27.0m, _testDate) + }; + + _exchangeCache.GetCachedRates(Arg.Any>(), Arg.Any()) + .Returns(((IReadOnlyList)cachedRates).AsMaybe()); + + // Act + var result = await _sut.GetExchangeRates(currencies, _testDate); + + // Assert + result.Should().HaveCount(2); + await _exchangeProvider.DidNotReceive().GetExchangeRatesForDate(Arg.Any>()); + } + + [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, _testDate) + }; + + _exchangeCache.GetCachedRates(Arg.Any>(), Arg.Any()) + .Returns(Maybe>.Nothing); + + _exchangeProvider.GetExchangeRatesForDate(Arg.Any>()) + .Returns(((IReadOnlyCollection)providerRates).AsMaybe()); + + // Act + 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>()); + } + + [Fact] + public async Task GetExchangeRatesAsync_WithNullCurrencies_ShouldThrowArgumentNullException() + { + // Act & Assert + await Assert.ThrowsAsync(() => + _sut.GetExchangeRates(null!, _testDate.AsMaybe())); + } + + [Fact] + public async Task GetExchangeRatesAsync_WithEmptyCurrencies_ShouldReturnEmpty() + { + // Arrange + var emptyCurrencies = Array.Empty(); + + // Act + var result = await _sut.GetExchangeRates(emptyCurrencies, _testDate.AsMaybe()); + + // Assert + result.Should().BeEmpty(); + } +} 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..87fe0254e2 --- /dev/null +++ b/jobs/Backend/Task/ExchangeRateUpdater.Tests/Core/MaybeTests.cs @@ -0,0 +1,72 @@ +using ExchangeRateUpdater.Domain.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..e6b3b2a5bf --- /dev/null +++ b/jobs/Backend/Task/ExchangeRateUpdater.Tests/ExchangeRateUpdater.Tests.csproj @@ -0,0 +1,31 @@ + + + + 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..f4a1fd2634 --- /dev/null +++ b/jobs/Backend/Task/ExchangeRateUpdater.Tests/Integration/WeekendHolidayCachingTests.cs @@ -0,0 +1,145 @@ +using Microsoft.Extensions.Caching.Memory; +using Microsoft.Extensions.Logging; +using ExchangeRateUpdater.Infrastructure.Caching; +using ExchangeRateUpdater.Domain.Models; +using ExchangeRateUpdater.Domain.Common; +using FluentAssertions; +using NSubstitute; +using Microsoft.Extensions.Options; + +namespace ExchangeRateUpdater.Tests.Integration; + +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>(); + _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 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); + + // Act + var result = await _sut.GetCachedRates( + [new Currency("USD"), new Currency("EUR")], + saturday); + + // 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 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) + }; + var wednesdayRates = new List + { + new(new Currency("USD"), new Currency("CZK"), 26.0m, wednesday) + }; + + // Act + await _sut.CacheRates(tuesdayRates); + await _sut.CacheRates(wednesdayRates); + + // Assert + 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] + public async Task GetCachedRates_WithPartialCurrencyMatch_ShouldReturnOnlyMatchingCurrencies() + { + // Arrange + var businessDay = new DateOnly(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 _sut.CacheRates(rates); + + // Act + var result = await _sut.GetCachedRates( + [new Currency("USD"), new Currency("GBP")], + businessDay); + + // 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 DateOnly(2024, 1, 2); // Tuesday + var rates = new List + { + new(new Currency("USD"), new Currency("CZK"), 25.0m, businessDay) + }; + + await _sut.CacheRates(rates); + + // Act + var result = await _sut.GetCachedRates( + [new Currency("usd")], + businessDay); + + // 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..8a253c834f 100644 --- a/jobs/Backend/Task/ExchangeRateUpdater.sln +++ b/jobs/Backend/Task/ExchangeRateUpdater.sln @@ -1,22 +1,93 @@  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.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 + 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 + {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 + {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/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..f097fc88e8 --- /dev/null +++ b/jobs/Backend/Task/README.md @@ -0,0 +1,69 @@ +# Exchange Rate Updater + +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 + +```powershell +# Below are from the ExchangeRateUpdater.Console folder +# Get specific currencies +dotnet run --currencies USD,EUR,GBP + +# Get rates for specific date +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: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: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: +```bash +docker-compose up -d +``` + +2. Access the applications: + - API: http://localhost:8080/api/exchangerates + - Console app: + ```bash + docker-compose run console --date 2025-09-28 + ``` + +3. Stop the services: +```bash +docker-compose down +``` diff --git a/jobs/Backend/Task/docker-compose.dev.yml b/jobs/Backend/Task/docker-compose.dev.yml new file mode 100644 index 0000000000..78f5e6a3b2 --- /dev/null +++ b/jobs/Backend/Task/docker-compose.dev.yml @@ -0,0 +1,17 @@ +services: + exchange-rate-api: + build: + context: . + dockerfile: Dockerfile.api + ports: + - "5216:8080" + environment: + - 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 diff --git a/jobs/Backend/Task/docker-compose.yml b/jobs/Backend/Task/docker-compose.yml new file mode 100644 index 0000000000..09486c7fb2 --- /dev/null +++ b/jobs/Backend/Task/docker-compose.yml @@ -0,0 +1,11 @@ +services: + exchange-rate-api: + build: + context: . + dockerfile: Dockerfile.api + ports: + - "8080:8080" + environment: + - ASPNETCORE_ENVIRONMENT=Production + - ASPNETCORE_URLS=http://+:8080 + restart: unless-stopped