diff --git a/.gitignore b/.gitignore index fd35865456..94ab79d5e8 100644 --- a/.gitignore +++ b/.gitignore @@ -21,3 +21,5 @@ node_modules bower_components npm-debug.log +/jobs/Backend/Task/.vs +/.vs diff --git a/jobs/Backend/Task/Dockerfile b/jobs/Backend/Task/Dockerfile new file mode 100644 index 0000000000..38ae487373 --- /dev/null +++ b/jobs/Backend/Task/Dockerfile @@ -0,0 +1,20 @@ +FROM mcr.microsoft.com/dotnet/sdk:8.0 AS build +WORKDIR /src + +COPY ["ExchangeRates.Api/ExchangeRates.Api.csproj", "ExchangeRates.Api/"] +COPY ["ExchangeRates.Application/ExchangeRates.Application.csproj", "ExchangeRates.Application/"] +COPY ["ExchangeRates.Domain/ExchangeRates.Domain.csproj", "ExchangeRates.Domain/"] +COPY ["ExchangeRates.Infrastructure/ExchangeRates.Infrastructure.csproj", "ExchangeRates.Infrastructure/"] + +RUN dotnet restore "ExchangeRates.Api/ExchangeRates.Api.csproj" + +COPY . . +WORKDIR "/src/ExchangeRates.Api" +RUN dotnet publish "ExchangeRates.Api.csproj" -c Release -o /app/publish /p:UseAppHost=false + +FROM mcr.microsoft.com/dotnet/aspnet:8.0 AS runtime +WORKDIR /app +COPY --from=build /app/publish . + +EXPOSE 80 +ENTRYPOINT ["dotnet", "ExchangeRates.Api.dll"] 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.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 deleted file mode 100644 index 89be84daff..0000000000 --- a/jobs/Backend/Task/ExchangeRateUpdater.sln +++ /dev/null @@ -1,22 +0,0 @@ - -Microsoft Visual Studio Solution File, Format Version 12.00 -# Visual Studio 14 -VisualStudioVersion = 14.0.25123.0 -MinimumVisualStudioVersion = 10.0.40219.1 -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ExchangeRateUpdater", "ExchangeRateUpdater.csproj", "{7B2695D6-D24C-4460-A58E-A10F08550CE0}" -EndProject -Global - GlobalSection(SolutionConfigurationPlatforms) = preSolution - Debug|Any CPU = Debug|Any CPU - Release|Any CPU = Release|Any CPU - EndGlobalSection - GlobalSection(ProjectConfigurationPlatforms) = postSolution - {7B2695D6-D24C-4460-A58E-A10F08550CE0}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {7B2695D6-D24C-4460-A58E-A10F08550CE0}.Debug|Any CPU.Build.0 = Debug|Any CPU - {7B2695D6-D24C-4460-A58E-A10F08550CE0}.Release|Any CPU.ActiveCfg = Release|Any CPU - {7B2695D6-D24C-4460-A58E-A10F08550CE0}.Release|Any CPU.Build.0 = Release|Any CPU - EndGlobalSection - GlobalSection(SolutionProperties) = preSolution - HideSolutionNode = FALSE - EndGlobalSection -EndGlobal diff --git a/jobs/Backend/Task/ExchangeRates.Api/Controllers/ExchangeRatesController.cs b/jobs/Backend/Task/ExchangeRates.Api/Controllers/ExchangeRatesController.cs new file mode 100644 index 0000000000..b2d6154d09 --- /dev/null +++ b/jobs/Backend/Task/ExchangeRates.Api/Controllers/ExchangeRatesController.cs @@ -0,0 +1,30 @@ +using ExchangeRates.Api.DTOs; +using ExchangeRates.Application.Providers; +using ExchangeRates.Domain.Entities; +using Microsoft.AspNetCore.Mvc; + +[ApiController] +[Route("api/[controller]")] +public class ExchangeRatesController : ControllerBase +{ + private readonly IExchangeRatesProvider _exchangeRatesProvider; + + public ExchangeRatesController(IExchangeRatesProvider exchangeRatesProvider, ILogger logger) + { + _exchangeRatesProvider = exchangeRatesProvider; + } + + [HttpGet] + [ProducesResponseType(typeof(IEnumerable), StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status400BadRequest)] + [ProducesResponseType(StatusCodes.Status500InternalServerError)] + public async Task>> Get([FromQuery] GetExchangeRatesRequest request, CancellationToken cancellationToken) + { + if (!ModelState.IsValid) + return BadRequest(ModelState); + + var currencies = request.Currencies ?? Array.Empty(); + var rates = await _exchangeRatesProvider.GetExchangeRatesAsync(currencies, cancellationToken); + return Ok(rates); + } +} diff --git a/jobs/Backend/Task/ExchangeRates.Api/Dtos/GetExchangeRatesRequest.cs b/jobs/Backend/Task/ExchangeRates.Api/Dtos/GetExchangeRatesRequest.cs new file mode 100644 index 0000000000..db00dd4f8b --- /dev/null +++ b/jobs/Backend/Task/ExchangeRates.Api/Dtos/GetExchangeRatesRequest.cs @@ -0,0 +1,10 @@ +using ExchangeRates.Api.Dtos; + +namespace ExchangeRates.Api.DTOs +{ + public class GetExchangeRatesRequest + { + [ValidCurrencyCodes] + public IEnumerable? Currencies { get; set; } + } +} diff --git a/jobs/Backend/Task/ExchangeRates.Api/Dtos/ValidCurrencyCodesAttribute.cs b/jobs/Backend/Task/ExchangeRates.Api/Dtos/ValidCurrencyCodesAttribute.cs new file mode 100644 index 0000000000..fad9f795a4 --- /dev/null +++ b/jobs/Backend/Task/ExchangeRates.Api/Dtos/ValidCurrencyCodesAttribute.cs @@ -0,0 +1,22 @@ +using System.ComponentModel.DataAnnotations; +using System.Text.RegularExpressions; + +namespace ExchangeRates.Api.Dtos +{ + [AttributeUsage(AttributeTargets.Property | AttributeTargets.Parameter)] + public class ValidCurrencyCodesAttribute : ValidationAttribute + { + protected override ValidationResult? IsValid(object? value, ValidationContext validationContext) + { + if (value is IEnumerable arr) + { + foreach (var c in arr) + { + if (string.IsNullOrWhiteSpace(c) || !Regex.IsMatch(c, "^[A-Za-z]{3}$")) + return new ValidationResult("All currency codes must be exactly 3 alphabetic characters."); + } + } + return ValidationResult.Success; + } + } +} diff --git a/jobs/Backend/Task/ExchangeRates.Api/ExchangeRates.Api.csproj b/jobs/Backend/Task/ExchangeRates.Api/ExchangeRates.Api.csproj new file mode 100644 index 0000000000..d56f28bd77 --- /dev/null +++ b/jobs/Backend/Task/ExchangeRates.Api/ExchangeRates.Api.csproj @@ -0,0 +1,20 @@ + + + + net8.0 + enable + enable + + + + + + + + + + + + + + diff --git a/jobs/Backend/Task/ExchangeRates.Api/Extensions/ApplicationBuilderExtensions.cs b/jobs/Backend/Task/ExchangeRates.Api/Extensions/ApplicationBuilderExtensions.cs new file mode 100644 index 0000000000..05cdd068ba --- /dev/null +++ b/jobs/Backend/Task/ExchangeRates.Api/Extensions/ApplicationBuilderExtensions.cs @@ -0,0 +1,27 @@ +namespace ExchangeRates.Api.Extensions +{ + public static class ApplicationBuilderExtensions + { + public static WebApplication ConfigureApp(this WebApplication app, IHostEnvironment env) + { + app.UseExceptionHandler("/error"); + app.UseCors("AllowAll"); + app.UseRouting(); + + app.UseSwagger(); + app.UseSwaggerUI(c => + { + c.SwaggerEndpoint("/swagger/v1/swagger.json", "Exchange Rates API v1"); + c.RoutePrefix = "swagger"; + }); + + app.MapControllers(); + app.Map("/error", (HttpContext httpContext) => + { + return Results.Problem("An unexpected error occurred. Please try again later."); + }); + + return app; + } + } +} diff --git a/jobs/Backend/Task/ExchangeRates.Api/Extensions/ServiceCollectionExtensions.cs b/jobs/Backend/Task/ExchangeRates.Api/Extensions/ServiceCollectionExtensions.cs new file mode 100644 index 0000000000..0412d4f423 --- /dev/null +++ b/jobs/Backend/Task/ExchangeRates.Api/Extensions/ServiceCollectionExtensions.cs @@ -0,0 +1,103 @@ +using ExchangeRates.Application.Options; +using ExchangeRates.Application.Providers; +using ExchangeRates.Infrastructure.Clients.CNB; +using Microsoft.OpenApi.Models; + +namespace ExchangeRates.Api.Extensions +{ + public static class ServiceCollectionExtensions + { + public static IServiceCollection AddExchangeRatesServices(this IServiceCollection services, IConfiguration configuration) + { + services.AddControllers(); + + services + .AddSwaggerDocumentation() + .AddCorsPolicy() + .AddCacheSupport() + .AddAppSettings(configuration) + .AddCNBClient(configuration) + .AddApplicationServices(); + + return services; + } + + public static IServiceCollection AddAppSettings(this IServiceCollection services, IConfiguration configuration) + { + services.Configure(configuration.GetSection("ExchangeRates")); + services.Configure(configuration.GetSection("CNBApi:HttpClient")); + return services; + } + + public static IServiceCollection AddSwaggerDocumentation(this IServiceCollection services) + { + services.AddSwaggerGen(options => + { + options.SwaggerDoc("v1", new OpenApiInfo + { + Version = "v1", + Title = "Exchange Rates API", + Description = "API that provides daily exchange rates from the Czech National Bank." + }); + }); + return services; + } + + public static IServiceCollection AddCorsPolicy(this IServiceCollection services) + { + services.AddCors(options => + { + options.AddPolicy("AllowAll", policy => + { + policy.AllowAnyOrigin() + .AllowAnyMethod() + .AllowAnyHeader(); + }); + }); + return services; + } + + public static IServiceCollection AddCacheSupport(this IServiceCollection services) + { + services.AddDistributedMemoryCache(); + return services; + } + + public static IServiceCollection AddCNBClient(this IServiceCollection services, IConfiguration configuration) + { + var options = configuration + .GetSection("CNBApi:HttpClient") + .Get() + ?? new CnbHttpClientOptions(); + + if (options == null) + throw new InvalidOperationException("CNBApi configuration section is missing."); + + if (string.IsNullOrWhiteSpace(options.BaseUrl)) + throw new InvalidOperationException("CNBApi:HttpClient:BaseUrl configuration value is missing."); + + services.AddHttpClient((serviceProvider, client) => + { + client.BaseAddress = new Uri(options.BaseUrl); + client.DefaultRequestHeaders.Add("Accept", "application/json"); + }) + .AddPolicyHandler((serviceProvider, request) => + { + return CnbHttpClientPolicies.TimeoutPolicy(options); + }) + .AddPolicyHandler((serviceProvider, request) => + { + var logger = serviceProvider.GetRequiredService>(); + return CnbHttpClientPolicies.RetryPolicy(options, logger); + }); + + return services; + } + + public static IServiceCollection AddApplicationServices(this IServiceCollection services) + { + services.AddScoped(); + return services; + } + } +} diff --git a/jobs/Backend/Task/ExchangeRates.Api/Program.cs b/jobs/Backend/Task/ExchangeRates.Api/Program.cs new file mode 100644 index 0000000000..a8ffed89a3 --- /dev/null +++ b/jobs/Backend/Task/ExchangeRates.Api/Program.cs @@ -0,0 +1,24 @@ +using ExchangeRates.Api.Extensions; + +namespace ExchangeRates.Api +{ + public class Program + { + public static void Main(string[] args) + { + var builder = WebApplication.CreateBuilder(args); + + builder.Logging.ClearProviders(); + builder.Logging.AddConfiguration(builder.Configuration.GetSection("Logging")); + builder.Logging.AddConsole(); + + builder.Services.AddExchangeRatesServices(builder.Configuration); + + var app = builder.Build(); + + app.ConfigureApp(builder.Environment); + + app.Run(); + } + } +} diff --git a/jobs/Backend/Task/ExchangeRates.Api/Properties/launchSettings.json b/jobs/Backend/Task/ExchangeRates.Api/Properties/launchSettings.json new file mode 100644 index 0000000000..b0e3c212b5 --- /dev/null +++ b/jobs/Backend/Task/ExchangeRates.Api/Properties/launchSettings.json @@ -0,0 +1,31 @@ +{ + "$schema": "http://json.schemastore.org/launchsettings.json", + "iisSettings": { + "windowsAuthentication": false, + "anonymousAuthentication": true, + "iisExpress": { + "applicationUrl": "http://localhost:17427", + "sslPort": 44320 + } + }, + "profiles": { + "Development": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": true, + "launchUrl": "swagger", + "applicationUrl": "https://localhost:7253;http://localhost:5282", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + }, + "IIS Express": { + "commandName": "IISExpress", + "launchBrowser": true, + "launchUrl": "swagger", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + } + } +} diff --git a/jobs/Backend/Task/ExchangeRates.Api/appsettings.Development.json b/jobs/Backend/Task/ExchangeRates.Api/appsettings.Development.json new file mode 100644 index 0000000000..33e1b2c9f3 --- /dev/null +++ b/jobs/Backend/Task/ExchangeRates.Api/appsettings.Development.json @@ -0,0 +1,9 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Warning", + "Microsoft": "Warning", + "ExchangeRates": "Debug" + } + } +} \ No newline at end of file diff --git a/jobs/Backend/Task/ExchangeRates.Api/appsettings.json b/jobs/Backend/Task/ExchangeRates.Api/appsettings.json new file mode 100644 index 0000000000..f23a520012 --- /dev/null +++ b/jobs/Backend/Task/ExchangeRates.Api/appsettings.json @@ -0,0 +1,23 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Error", + "Microsoft": "Error", + "System": "Error", + "ExchangeRates": "Information" + } + }, + "AllowedHosts": "*", + "CNBApi": { + "HttpClient": { + "BaseUrl": "https://api.cnb.cz/", + "TimeoutSeconds": 10, + "RetryCount": 2, + "RetryBaseDelaySeconds": 2, + "DailyRefreshTimeCZ": "14:30:00" + } + }, + "ExchangeRates": { + "DefaultCurrencies": [ "USD", "EUR", "CZK", "JPY", "KES", "RUB", "THB", "TRY", "XYZ" ] + } +} \ No newline at end of file diff --git a/jobs/Backend/Task/ExchangeRates.Application/ExchangeRates.Application.csproj b/jobs/Backend/Task/ExchangeRates.Application/ExchangeRates.Application.csproj new file mode 100644 index 0000000000..a4f795c02b --- /dev/null +++ b/jobs/Backend/Task/ExchangeRates.Application/ExchangeRates.Application.csproj @@ -0,0 +1,18 @@ + + + + net8.0 + enable + enable + + + + + + + + + + + + diff --git a/jobs/Backend/Task/ExchangeRates.Application/Options/ExchangeRatesOptions.cs b/jobs/Backend/Task/ExchangeRates.Application/Options/ExchangeRatesOptions.cs new file mode 100644 index 0000000000..4543d48be6 --- /dev/null +++ b/jobs/Backend/Task/ExchangeRates.Application/Options/ExchangeRatesOptions.cs @@ -0,0 +1,7 @@ +namespace ExchangeRates.Application.Options +{ + public class ExchangeRatesOptions + { + public string[] DefaultCurrencies { get; set; } = Array.Empty(); + } +} diff --git a/jobs/Backend/Task/ExchangeRates.Application/Providers/ExchangeRatesProvider.cs b/jobs/Backend/Task/ExchangeRates.Application/Providers/ExchangeRatesProvider.cs new file mode 100644 index 0000000000..cbc9a1b539 --- /dev/null +++ b/jobs/Backend/Task/ExchangeRates.Application/Providers/ExchangeRatesProvider.cs @@ -0,0 +1,113 @@ +using ExchangeRates.Application.Options; +using ExchangeRates.Domain.Entities; +using ExchangeRates.Infrastructure.Cache; +using ExchangeRates.Infrastructure.Clients.CNB; +using ExchangesRates.Infrastructure.External.CNB.Dtos; +using Microsoft.Extensions.Caching.Distributed; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; +using System.Text.Json; + +namespace ExchangeRates.Application.Providers +{ + public interface IExchangeRatesProvider + { + Task> GetExchangeRatesAsync(IEnumerable currencyCodes, CancellationToken cancellationToken = default); + } + + public class ExchangeRatesProvider : IExchangeRatesProvider + { + private readonly ICnbHttpClient _cnbHttpClient; + private readonly IDistributedCache _cache; + private readonly TimeOnly _refreshTimeCZ; + private readonly string[] _defaultCurrencies; + private readonly ILogger _logger; + + public ExchangeRatesProvider( + ICnbHttpClient cnbHttpClient, + IDistributedCache cache, + IOptions cnbSettings, + IOptions exchangeRatesSettings, + ILogger logger) + { + _cnbHttpClient = cnbHttpClient; + _cache = cache; + _logger = logger; + + _refreshTimeCZ = cnbSettings.Value.DailyRefreshTimeCZ; + _defaultCurrencies = exchangeRatesSettings.Value.DefaultCurrencies; + } + + public async Task> GetExchangeRatesAsync(IEnumerable? currencyCodes, CancellationToken cancellationToken = default) + { + var currencies = (currencyCodes != null && currencyCodes.Any()) + ? currencyCodes.Select(c => new Currency(c.Trim().ToUpperInvariant())) + : _defaultCurrencies.Select(c => new Currency(c.Trim().ToUpperInvariant())); + + return await GetExchangeRatesAsync(currencies, cancellationToken); + } + + private async Task> GetExchangeRatesAsync(IEnumerable currencies, CancellationToken cancellationToken = default) + { + var cacheKey = CacheKeys.ExchangeRatesDaily(); + CnbExRatesResponse? response; + + _logger.LogInformation("Fetching exchange rates for currencies: {Currencies}", string.Join(", ", currencies.Select(c => c.Code))); + + try + { + var cachedData = await _cache.GetStringAsync(cacheKey, cancellationToken); + + if (!string.IsNullOrEmpty(cachedData)) + { + _logger.LogInformation("Cache hit for key '{CacheKey}'. Deserializing exchange rates.", cacheKey); + response = JsonSerializer.Deserialize(cachedData)!; + } + else + { + response = await _cnbHttpClient.GetDailyExchangeRatesAsync(cancellationToken: cancellationToken); + + if (response?.Rates == null || !response.Rates.Any()) + { + _logger.LogError("CNB API returned no exchange rate data."); + return Enumerable.Empty(); + } + + var serialized = JsonSerializer.Serialize(response); + var apiDataLastUpdated = response.Rates.Min(c => c.ValidFor); + var expiration = CacheExpirationHelper.GetCacheExpirationToNextCzTime(_refreshTimeCZ, apiDataLastUpdated); + + _logger.LogInformation("Caching exchange rates under key '{CacheKey}' for {Expiration} seconds.", cacheKey, expiration.TotalSeconds); + await _cache.SetStringAsync( + cacheKey, + serialized, + new DistributedCacheEntryOptions + { + AbsoluteExpirationRelativeToNow = expiration + }, + cancellationToken); + } + + var czk = new Currency("CZK"); + var currencySet = currencies.Select(c => c.Code).ToHashSet(); + + var result = response.Rates + .Where(r => currencySet.Contains(r.CurrencyCode!)) + .Select(r => + { + var target = currencies.First(c => c.Code == r.CurrencyCode); + return new ExchangeRate(czk, target, r.Rate / r.Amount); + }) + .ToList(); + + _logger.LogInformation("Returning {Count} exchange rates.", result.Count); + return result; + } + catch (Exception ex) + { + _logger.LogError(ex, "Error occurred while fetching exchange rates."); + throw; + } + } + } +} diff --git a/jobs/Backend/Task/Currency.cs b/jobs/Backend/Task/ExchangeRates.Domain/Entities/Currency.cs similarity index 89% rename from jobs/Backend/Task/Currency.cs rename to jobs/Backend/Task/ExchangeRates.Domain/Entities/Currency.cs index f375776f25..9f592c9a40 100644 --- a/jobs/Backend/Task/Currency.cs +++ b/jobs/Backend/Task/ExchangeRates.Domain/Entities/Currency.cs @@ -1,4 +1,4 @@ -namespace ExchangeRateUpdater +namespace ExchangeRates.Domain.Entities { public class Currency { diff --git a/jobs/Backend/Task/ExchangeRate.cs b/jobs/Backend/Task/ExchangeRates.Domain/Entities/ExchangeRate.cs similarity index 92% rename from jobs/Backend/Task/ExchangeRate.cs rename to jobs/Backend/Task/ExchangeRates.Domain/Entities/ExchangeRate.cs index 58c5bb10e0..d74767a950 100644 --- a/jobs/Backend/Task/ExchangeRate.cs +++ b/jobs/Backend/Task/ExchangeRates.Domain/Entities/ExchangeRate.cs @@ -1,4 +1,4 @@ -namespace ExchangeRateUpdater +namespace ExchangeRates.Domain.Entities { public class ExchangeRate { diff --git a/jobs/Backend/Task/ExchangeRates.Domain/ExchangeRates.Domain.csproj b/jobs/Backend/Task/ExchangeRates.Domain/ExchangeRates.Domain.csproj new file mode 100644 index 0000000000..fa71b7ae6a --- /dev/null +++ b/jobs/Backend/Task/ExchangeRates.Domain/ExchangeRates.Domain.csproj @@ -0,0 +1,9 @@ + + + + net8.0 + enable + enable + + + diff --git a/jobs/Backend/Task/ExchangeRates.Infrastructure/Cache/CacheExpirationsHelper.cs b/jobs/Backend/Task/ExchangeRates.Infrastructure/Cache/CacheExpirationsHelper.cs new file mode 100644 index 0000000000..5b501f160b --- /dev/null +++ b/jobs/Backend/Task/ExchangeRates.Infrastructure/Cache/CacheExpirationsHelper.cs @@ -0,0 +1,42 @@ +namespace ExchangeRates.Infrastructure.Cache +{ + public static class CacheExpirationHelper + { + public static TimeSpan GetCacheExpirationToNextCzTime( + TimeOnly dailyRefreshTime, + DateTime dataLastUpdated, + DateTime? utcNow = null) + { + var nowUtc = utcNow ?? DateTime.UtcNow; + var czTimeZone = TimeZoneInfo.FindSystemTimeZoneById("Central European Standard Time"); + var nowCz = TimeZoneInfo.ConvertTimeFromUtc(nowUtc, czTimeZone); + + var lastUpdateCz = TimeZoneInfo.ConvertTimeFromUtc(dataLastUpdated.Date, czTimeZone); + var candidateRefreshCz = lastUpdateCz.Date + .AddDays(1) + .AddHours(dailyRefreshTime.Hour) + .AddMinutes(dailyRefreshTime.Minute) + .AddSeconds(dailyRefreshTime.Second); + + // Move to next business day if next refresh is weekend + var nextRefreshCz = GetNextBusinessDay(candidateRefreshCz); + + // If the current time has already passed the scheduled refresh, set the cache to expire in 5 minutes + if (nowCz >= nextRefreshCz) + { + return TimeSpan.FromMinutes(5); + } + + return nextRefreshCz - nowCz; + } + + private static DateTime GetNextBusinessDay(DateTime date) + { + while (date.DayOfWeek == DayOfWeek.Saturday || date.DayOfWeek == DayOfWeek.Sunday) + { + date = date.AddDays(1); + } + return date; + } + } +} \ No newline at end of file diff --git a/jobs/Backend/Task/ExchangeRates.Infrastructure/Cache/CacheKeys.cs b/jobs/Backend/Task/ExchangeRates.Infrastructure/Cache/CacheKeys.cs new file mode 100644 index 0000000000..a1648cda27 --- /dev/null +++ b/jobs/Backend/Task/ExchangeRates.Infrastructure/Cache/CacheKeys.cs @@ -0,0 +1,7 @@ +namespace ExchangeRates.Infrastructure.Cache +{ + public static class CacheKeys + { + public static string ExchangeRatesDaily() => "ExchangeRatesDaily"; + } +} diff --git a/jobs/Backend/Task/ExchangeRates.Infrastructure/Clients/CNB/CnbHttpClient.cs b/jobs/Backend/Task/ExchangeRates.Infrastructure/Clients/CNB/CnbHttpClient.cs new file mode 100644 index 0000000000..986d25e0b7 --- /dev/null +++ b/jobs/Backend/Task/ExchangeRates.Infrastructure/Clients/CNB/CnbHttpClient.cs @@ -0,0 +1,84 @@ +using ExchangesRates.Infrastructure.External.CNB.Dtos; +using Microsoft.Extensions.Logging; +using System.Net; +using System.Net.Http.Json; +using System.Text.Json; + +namespace ExchangeRates.Infrastructure.Clients.CNB +{ + public interface ICnbHttpClient + { + Task GetDailyExchangeRatesAsync(string? date = null, string lang = "EN", CancellationToken cancellationToken = default); + } + + public class CnbHttpClient : ICnbHttpClient + { + private readonly HttpClient _httpClient; + private readonly ILogger _logger; + + private static readonly JsonSerializerOptions _jsonOptions = new() + { + PropertyNameCaseInsensitive = true + }; + + public CnbHttpClient(HttpClient httpClient, ILogger logger) + { + _httpClient = httpClient; + _logger = logger; + } + + /// + /// Gets the daily exchange rates from the Czech National Bank API. + /// + public async Task GetDailyExchangeRatesAsync(string? date = null, string lang = "EN", CancellationToken cancellationToken = default) + { + var query = $"?lang={lang}"; + if (!string.IsNullOrEmpty(date)) + query += $"&date={date}"; + + var endpoint = $"/cnbapi/exrates/daily{query}"; + + _logger.LogInformation("Requesting CNB daily exchange rates from {Endpoint}", endpoint); + + var stopwatch = System.Diagnostics.Stopwatch.StartNew(); + + try + { + using var response = await _httpClient.GetAsync(endpoint, cancellationToken); + + if (response.StatusCode == HttpStatusCode.NotFound) + { + _logger.LogWarning("CNB API returned 404: Data not found for date {Date}.", date ?? "latest"); + return null; + } + + if (!response.IsSuccessStatusCode) + { + var body = await response.Content.ReadAsStringAsync(cancellationToken); + var message = $"CNB API returned error {(int)response.StatusCode}: {response.ReasonPhrase}. Body: {body}"; + _logger.LogError(message); + throw new HttpRequestException(message); + } + + var result = await response.Content.ReadFromJsonAsync(_jsonOptions, cancellationToken); + + if (result == null || result.Rates == null || !result.Rates.Any()) + { + _logger.LogWarning("CNB API returned an empty response for date {Date}.", date ?? "latest"); + return null; + } + + stopwatch.Stop(); + _logger.LogInformation("Successfully retrieved {Count} exchange rates from CNB in {ElapsedMilliseconds}ms.", + result.Rates.Count, stopwatch.ElapsedMilliseconds); + + return result; + } + catch (Exception ex) + { + _logger.LogError(ex, "Unexpected error while fetching CNB exchange rates."); + throw; + } + } + } +} diff --git a/jobs/Backend/Task/ExchangeRates.Infrastructure/Clients/CNB/CnbHttpClientOptions.cs b/jobs/Backend/Task/ExchangeRates.Infrastructure/Clients/CNB/CnbHttpClientOptions.cs new file mode 100644 index 0000000000..a9ccee28a2 --- /dev/null +++ b/jobs/Backend/Task/ExchangeRates.Infrastructure/Clients/CNB/CnbHttpClientOptions.cs @@ -0,0 +1,16 @@ +namespace ExchangeRates.Infrastructure.Clients.CNB +{ + public class CnbHttpClientOptions + { + public string? BaseUrl { get; set; } + public TimeOnly DailyRefreshTimeCZ { get; set; } + + public int TimeoutSeconds { get; set; } = 10; + + public int RetryCount { get; set; } = 3; + public int RetryBaseDelaySeconds { get; set; } = 2; + + public int CircuitBreakerFailures { get; set; } = 2; + public int CircuitBreakerDurationSeconds { get; set; } = 30; + } +} diff --git a/jobs/Backend/Task/ExchangeRates.Infrastructure/Clients/CNB/CnbHttpClientPolicies.cs b/jobs/Backend/Task/ExchangeRates.Infrastructure/Clients/CNB/CnbHttpClientPolicies.cs new file mode 100644 index 0000000000..ea395b9dc0 --- /dev/null +++ b/jobs/Backend/Task/ExchangeRates.Infrastructure/Clients/CNB/CnbHttpClientPolicies.cs @@ -0,0 +1,34 @@ +using Microsoft.Extensions.Logging; +using Polly; +using Polly.Extensions.Http; + +namespace ExchangeRates.Infrastructure.Clients.CNB +{ + public static class CnbHttpClientPolicies + { + public static IAsyncPolicy RetryPolicy(CnbHttpClientOptions options, ILogger logger) + { + return HttpPolicyExtensions + .HandleTransientHttpError() + .WaitAndRetryAsync( + options.RetryCount, + retryAttempt => TimeSpan.FromSeconds(Math.Pow(options.RetryBaseDelaySeconds, retryAttempt)), + onRetry: (outcome, timespan, retryAttempt, context) => + { + logger.LogWarning( + "Retry {RetryAttempt} after {Delay}s due to {Reason}", + retryAttempt, + timespan.TotalSeconds, + outcome.Exception?.Message ?? outcome.Result?.StatusCode.ToString() + ); + }); + } + + public static IAsyncPolicy TimeoutPolicy(CnbHttpClientOptions options) + { + return Policy.TimeoutAsync( + TimeSpan.FromSeconds(options.TimeoutSeconds) + ); + } + } +} diff --git a/jobs/Backend/Task/ExchangeRates.Infrastructure/Clients/CNB/Dtos/CnbExRate.cs b/jobs/Backend/Task/ExchangeRates.Infrastructure/Clients/CNB/Dtos/CnbExRate.cs new file mode 100644 index 0000000000..bf254f43a5 --- /dev/null +++ b/jobs/Backend/Task/ExchangeRates.Infrastructure/Clients/CNB/Dtos/CnbExRate.cs @@ -0,0 +1,13 @@ +namespace ExchangesRates.Infrastructure.External.CNB.Dtos +{ + public class CnbExRate + { + public long Amount { get; set; } + public string? Country { get; set; } + public string? Currency { get; set; } + public string? CurrencyCode { get; set; } + public int Order { get; set; } + public decimal Rate { get; set; } + public DateTime ValidFor { get; set; } + } +} diff --git a/jobs/Backend/Task/ExchangeRates.Infrastructure/Clients/CNB/Dtos/CnbExRatesResponse.cs b/jobs/Backend/Task/ExchangeRates.Infrastructure/Clients/CNB/Dtos/CnbExRatesResponse.cs new file mode 100644 index 0000000000..cdd089e79f --- /dev/null +++ b/jobs/Backend/Task/ExchangeRates.Infrastructure/Clients/CNB/Dtos/CnbExRatesResponse.cs @@ -0,0 +1,7 @@ +namespace ExchangesRates.Infrastructure.External.CNB.Dtos +{ + public class CnbExRatesResponse + { + public List Rates { get; set; } = new(); + } +} diff --git a/jobs/Backend/Task/ExchangeRates.Infrastructure/ExchangeRates.Infrastructure.csproj b/jobs/Backend/Task/ExchangeRates.Infrastructure/ExchangeRates.Infrastructure.csproj new file mode 100644 index 0000000000..943533e284 --- /dev/null +++ b/jobs/Backend/Task/ExchangeRates.Infrastructure/ExchangeRates.Infrastructure.csproj @@ -0,0 +1,14 @@ + + + + net8.0 + enable + enable + + + + + + + + diff --git a/jobs/Backend/Task/ExchangeRates.UnitTests/Application/Providers/ExchangeRatesProviderTests.cs b/jobs/Backend/Task/ExchangeRates.UnitTests/Application/Providers/ExchangeRatesProviderTests.cs new file mode 100644 index 0000000000..935453a1b1 --- /dev/null +++ b/jobs/Backend/Task/ExchangeRates.UnitTests/Application/Providers/ExchangeRatesProviderTests.cs @@ -0,0 +1,189 @@ +using ExchangeRates.Application.Options; +using ExchangeRates.Application.Providers; +using ExchangeRates.Infrastructure.Clients.CNB; +using ExchangesRates.Infrastructure.External.CNB.Dtos; +using FluentAssertions; +using Microsoft.Extensions.Caching.Distributed; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; +using Moq; +using System.Text.Json; + +namespace ExchangeRates.UnitTests.Application.Providers +{ + public class ExchangeRatesProviderTests + { + private readonly Mock _cnbMock; + private readonly Mock _cacheMock; + private readonly IOptions _cnbOptions; + private readonly IOptions _exchangeOptions; + private readonly ILogger _logger; + + public ExchangeRatesProviderTests() + { + _cnbMock = new Mock(); + _cacheMock = new Mock(); + + _cnbOptions = Options.Create(new CnbHttpClientOptions + { + DailyRefreshTimeCZ = new TimeOnly(14, 30) + }); + + _exchangeOptions = Options.Create(new ExchangeRatesOptions + { + DefaultCurrencies = new[] { "USD", "EUR", "CZK" } + }); + + _logger = Microsoft.Extensions.Logging.Abstractions.NullLogger.Instance; + } + + [Fact] + public async Task GetExchangeRatesAsync_ReturnsRates_FromCNBClient() + { + // Arrange + var cnbResponse = new CnbExRatesResponse + { + Rates = new List + { + new CnbExRate { CurrencyCode = "USD", Amount = 1, Rate = 22 }, + new CnbExRate { CurrencyCode = "EUR", Amount = 1, Rate = 24 } + } + }; + + _cnbMock.Setup(c => c.GetDailyExchangeRatesAsync(It.IsAny(), It.IsAny(), It.IsAny())) + .ReturnsAsync(cnbResponse); + + var provider = new ExchangeRatesProvider(_cnbMock.Object, _cacheMock.Object, _cnbOptions, _exchangeOptions, _logger); + + // Act + var result = await provider.GetExchangeRatesAsync(new[] { "USD", "EUR" }, CancellationToken.None); + + // Assert + result.Should().HaveCount(2); + result.First(r => r.TargetCurrency.Code == "USD").Value.Should().Be(22); + result.First(r => r.TargetCurrency.Code == "EUR").Value.Should().Be(24); + } + + [Fact] + public async Task GetExchangeRatesAsync_UsesCache_WhenDataExists() + { + // Arrange + var cachedResponse = new CnbExRatesResponse + { + Rates = new List { new CnbExRate { CurrencyCode = "USD", Amount = 1, Rate = 21 } } + }; + + var serialized = JsonSerializer.Serialize(cachedResponse); + var bytes = System.Text.Encoding.UTF8.GetBytes(serialized); + + _cacheMock + .Setup(c => c.GetAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(bytes); + + var provider = new ExchangeRatesProvider(_cnbMock.Object, _cacheMock.Object, _cnbOptions, _exchangeOptions, _logger); + + // Act + var result = await provider.GetExchangeRatesAsync(new[] { "USD" }, CancellationToken.None); + + // Assert + result.Should().HaveCount(1); + result.First().Value.Should().Be(21); + + _cacheMock.Verify(c => c.GetAsync(It.IsAny(), It.IsAny()), Times.Once); + _cnbMock.Verify(c => c.GetDailyExchangeRatesAsync(It.IsAny(), It.IsAny(), It.IsAny()), Times.Never); + } + + + [Fact] + public async Task GetExchangeRatesAsync_ReturnsEmpty_WhenCurrencyNotFound() + { + // Arrange + var cnbResponse = new CnbExRatesResponse + { + Rates = new List { new CnbExRate { CurrencyCode = "USD", Amount = 1, Rate = 22 } } + }; + + _cnbMock.Setup(c => c.GetDailyExchangeRatesAsync(It.IsAny(), It.IsAny(), It.IsAny())) + .ReturnsAsync(cnbResponse); + + var provider = new ExchangeRatesProvider(_cnbMock.Object, _cacheMock.Object, _cnbOptions, _exchangeOptions, _logger); + + // Act + var result = await provider.GetExchangeRatesAsync(new[] { "EUR" }, CancellationToken.None); + + // Assert + result.Should().BeEmpty(); + } + + [Fact] + public async Task GetExchangeRatesAsync_UsesDefaultCurrencies_WhenInputIsNull() + { + // Arrange + var cnbResponse = new CnbExRatesResponse + { + Rates = new List + { + new CnbExRate { CurrencyCode = "USD", Amount = 1, Rate = 22 }, + new CnbExRate { CurrencyCode = "EUR", Amount = 1, Rate = 24 }, + new CnbExRate { CurrencyCode = "CZK", Amount = 1, Rate = 1 } + } + }; + + _cnbMock.Setup(c => c.GetDailyExchangeRatesAsync(It.IsAny(), It.IsAny(), It.IsAny())) + .ReturnsAsync(cnbResponse); + + var provider = new ExchangeRatesProvider(_cnbMock.Object, _cacheMock.Object, _cnbOptions, _exchangeOptions, _logger); + + // Act + var result = await provider.GetExchangeRatesAsync((IEnumerable?)null, CancellationToken.None); + + // Assert + result.Should().HaveCount(3); + result.Select(r => r.TargetCurrency.Code).Should().Contain(new[] { "USD", "EUR", "CZK" }); + } + + [Fact] + public async Task GetExchangeRatesAsync_Throws_WhenCancellationRequested() + { + // Arrange + var cts = new CancellationTokenSource(); + cts.Cancel(); + + _cnbMock + .Setup(c => c.GetDailyExchangeRatesAsync(It.IsAny(), It.IsAny(), It.IsAny())) + .Returns((date, lang, token) => + { + token.ThrowIfCancellationRequested(); + return Task.FromResult(new CnbExRatesResponse + { + Rates = new List { new CnbExRate { CurrencyCode = "USD", Amount = 1, Rate = 22 } } + }); + }); + + var provider = new ExchangeRatesProvider(_cnbMock.Object, _cacheMock.Object, _cnbOptions, _exchangeOptions, _logger); + + // Act + Func act = async () => await provider.GetExchangeRatesAsync(new[] { "USD" }, cts.Token); + + // Assert + await act.Should().ThrowAsync(); + } + + + [Fact] + public async Task GetExchangeRatesAsync_Throws_WhenCNBClientFails() + { + // Arrange + _cnbMock.Setup(c => c.GetDailyExchangeRatesAsync(It.IsAny(), It.IsAny(), It.IsAny())) + .ThrowsAsync(new Exception("CNB API error")); + + var provider = new ExchangeRatesProvider(_cnbMock.Object, _cacheMock.Object, _cnbOptions, _exchangeOptions, _logger); + + // Act + Func act = async () => await provider.GetExchangeRatesAsync(new[] { "USD" }, CancellationToken.None); + + // Assert + await act.Should().ThrowAsync().WithMessage("CNB API error"); + } + } +} \ No newline at end of file diff --git a/jobs/Backend/Task/ExchangeRates.UnitTests/ExchangeRates.UnitTests.csproj b/jobs/Backend/Task/ExchangeRates.UnitTests/ExchangeRates.UnitTests.csproj new file mode 100644 index 0000000000..75c7159a27 --- /dev/null +++ b/jobs/Backend/Task/ExchangeRates.UnitTests/ExchangeRates.UnitTests.csproj @@ -0,0 +1,33 @@ + + + + net8.0 + enable + enable + + false + true + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/jobs/Backend/Task/ExchangeRates.UnitTests/Infrastructure/Cache/CacheExpirationHelperTests.cs b/jobs/Backend/Task/ExchangeRates.UnitTests/Infrastructure/Cache/CacheExpirationHelperTests.cs new file mode 100644 index 0000000000..5039a642a2 --- /dev/null +++ b/jobs/Backend/Task/ExchangeRates.UnitTests/Infrastructure/Cache/CacheExpirationHelperTests.cs @@ -0,0 +1,124 @@ +using ExchangeRates.Infrastructure.Cache; +using FluentAssertions; + +namespace ExchangeRates.UnitTests.Infrastructure.Cache +{ + public class CacheExpirationHelperTests + { + [Fact] + public void GetCacheExpirationToNextCzTime_BeforeRefresh_ReturnsSameDayTimeSpan() + { + // Arrange: Current time is before daily refresh on a weekday + var czTimeZone = TimeZoneInfo.FindSystemTimeZoneById("Central European Standard Time"); + var nowCz = new DateTime(2025, 10, 31, 12, 0, 0); // Friday 12:00 CZ + var nowUtc = TimeZoneInfo.ConvertTimeToUtc(nowCz, czTimeZone); + + var dailyRefreshTime = new TimeOnly(14, 0); // 14:00 CZ + var lastUpdateDate = new DateTime(2025, 10, 30); + + // Act + var timespan = CacheExpirationHelper.GetCacheExpirationToNextCzTime( + dailyRefreshTime, + lastUpdateDate, + nowUtc + ); + + // Assert: 2 hours until next refresh + timespan.Should().Be(TimeSpan.FromHours(2)); + } + + [Fact] + public void GetCacheExpirationToNextCzTime_AfterRefresh_ReturnsNextDayDayTimeSpan() + { + // Arrange: Current time is before daily refresh on a weekday + var czTimeZone = TimeZoneInfo.FindSystemTimeZoneById("Central European Standard Time"); + var nowCz = new DateTime(2025, 10, 30, 15, 0, 0); // Thursday 15:00 CZ + var nowUtc = TimeZoneInfo.ConvertTimeToUtc(nowCz, czTimeZone); + + var dailyRefreshTime = new TimeOnly(14, 0); // 14:00 CZ + var lastUpdateDate = new DateTime(2025, 10, 30); + + // Act + var timespan = CacheExpirationHelper.GetCacheExpirationToNextCzTime( + dailyRefreshTime, + lastUpdateDate, + nowUtc + ); + + // Assert: 2 hours until next refresh + timespan.Should().Be(TimeSpan.FromHours(23)); + } + + [Fact] + public void GetCacheExpirationToNextCzTime_DelayedRefresh_ReturnsExtraMinutesTimeSpan() + { + // Arrange: Current time is after daily refresh on a weekday + var czTimeZone = TimeZoneInfo.FindSystemTimeZoneById("Central European Standard Time"); + var nowCz = new DateTime(2025, 10, 31, 14, 15, 0); // Friday 14:15 CZ + var nowUtc = TimeZoneInfo.ConvertTimeToUtc(nowCz, czTimeZone); + + var dailyRefreshTime = new TimeOnly(14, 0); // 14:00 CZ + var lastUpdateDate = new DateTime(2025, 10, 30); + + // Act + var timespan = CacheExpirationHelper.GetCacheExpirationToNextCzTime( + dailyRefreshTime, + lastUpdateDate, + nowUtc + ); + + // Assert: 5 minutes until next refresh + timespan.Should().Be(TimeSpan.FromMinutes(5)); + } + + [Fact] + public void GetCacheExpirationToNextCzTime_AfterRefresh_OnWeekday_ReturnsTimeUntilNextBusinessDay() + { + // Arrange: Current time is after daily refresh on Friday + var czTimeZone = TimeZoneInfo.FindSystemTimeZoneById("Central European Standard Time"); + var nowCz = new DateTime(2025, 10, 31, 15, 0, 0); // Friday 15:00 CZ + var nowUtc = TimeZoneInfo.ConvertTimeToUtc(nowCz, czTimeZone); + + var dailyRefreshTime = new TimeOnly(14, 0); // 14:00 CZ + var lastUpdateDate = new DateTime(2025, 10, 31); // Last update same day + + // Act + var timespan = CacheExpirationHelper.GetCacheExpirationToNextCzTime( + dailyRefreshTime, + lastUpdateDate, + nowUtc + ); + + // Assert: Next refresh is Monday 3 Nov 14:00 + var nextRefreshCz = new DateTime(2025, 11, 3, 14, 0, 0); + var expected = nextRefreshCz - nowCz; + + timespan.Should().Be(expected); + } + + [Fact] + public void GetCacheExpirationToNextCzTime_OnSaturday_ReturnsTimeUntilNextBusinessDay() + { + // Arrange: Current time is on Saturday + var czTimeZone = TimeZoneInfo.FindSystemTimeZoneById("Central European Standard Time"); + var nowCz = new DateTime(2025, 11, 1, 10, 0, 0); // Saturday 10:00 CZ + var nowUtc = TimeZoneInfo.ConvertTimeToUtc(nowCz, czTimeZone); + + var dailyRefreshTime = new TimeOnly(14, 0); // 14:00 CZ + var lastUpdateDate = new DateTime(2025, 10, 31); // Last update was Friday + + // Act + var timespan = CacheExpirationHelper.GetCacheExpirationToNextCzTime( + dailyRefreshTime, + lastUpdateDate, + nowUtc + ); + + // Assert: Next refresh is Monday 3 Nov 14:00 + var nextRefreshCz = new DateTime(2025, 11, 3, 14, 0, 0); + var expected = nextRefreshCz - nowCz; + + timespan.Should().Be(expected); + } + } +} diff --git a/jobs/Backend/Task/ExchangeRates.sln b/jobs/Backend/Task/ExchangeRates.sln new file mode 100644 index 0000000000..f5d99252f5 --- /dev/null +++ b/jobs/Backend/Task/ExchangeRates.sln @@ -0,0 +1,56 @@ + +Microsoft Visual Studio Solution File, Format Version 12.00 +# Visual Studio Version 17 +VisualStudioVersion = 17.14.36127.28 +MinimumVisualStudioVersion = 10.0.40219.1 +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ExchangeRates.Api", "ExchangeRates.Api\ExchangeRates.Api.csproj", "{5864D3CB-5E68-4DD7-A8CC-26FF3C1EE553}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ExchangeRates.Application", "ExchangeRates.Application\ExchangeRates.Application.csproj", "{073FC653-7578-8555-169F-F4582C5018A9}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ExchangeRates.Domain", "ExchangeRates.Domain\ExchangeRates.Domain.csproj", "{C4A559F9-6783-3BBE-EF14-233C00F0346B}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ExchangeRates.Infrastructure", "ExchangeRates.Infrastructure\ExchangeRates.Infrastructure.csproj", "{F3BD4E7F-732C-2DB0-6F4D-B1F55BCAEBB8}" +EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Tests", "Tests", "{02EA681E-C7D8-13C7-8484-4AC65E1B71E8}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ExchangeRates.UnitTests", "ExchangeRates.UnitTests\ExchangeRates.UnitTests.csproj", "{92369D9A-AA0C-48BF-B7A3-31E267ABFA12}" +EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Solution Items", "Solution Items", "{8EC462FD-D22E-90A8-E5CE-7E832BA40C5D}" +EndProject +Global + GlobalSection(SolutionConfigurationPlatforms) = preSolution + Debug|Any CPU = Debug|Any CPU + Release|Any CPU = Release|Any CPU + EndGlobalSection + GlobalSection(ProjectConfigurationPlatforms) = postSolution + {5864D3CB-5E68-4DD7-A8CC-26FF3C1EE553}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {5864D3CB-5E68-4DD7-A8CC-26FF3C1EE553}.Debug|Any CPU.Build.0 = Debug|Any CPU + {5864D3CB-5E68-4DD7-A8CC-26FF3C1EE553}.Release|Any CPU.ActiveCfg = Release|Any CPU + {5864D3CB-5E68-4DD7-A8CC-26FF3C1EE553}.Release|Any CPU.Build.0 = Release|Any CPU + {073FC653-7578-8555-169F-F4582C5018A9}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {073FC653-7578-8555-169F-F4582C5018A9}.Debug|Any CPU.Build.0 = Debug|Any CPU + {073FC653-7578-8555-169F-F4582C5018A9}.Release|Any CPU.ActiveCfg = Release|Any CPU + {073FC653-7578-8555-169F-F4582C5018A9}.Release|Any CPU.Build.0 = Release|Any CPU + {C4A559F9-6783-3BBE-EF14-233C00F0346B}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {C4A559F9-6783-3BBE-EF14-233C00F0346B}.Debug|Any CPU.Build.0 = Debug|Any CPU + {C4A559F9-6783-3BBE-EF14-233C00F0346B}.Release|Any CPU.ActiveCfg = Release|Any CPU + {C4A559F9-6783-3BBE-EF14-233C00F0346B}.Release|Any CPU.Build.0 = Release|Any CPU + {F3BD4E7F-732C-2DB0-6F4D-B1F55BCAEBB8}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {F3BD4E7F-732C-2DB0-6F4D-B1F55BCAEBB8}.Debug|Any CPU.Build.0 = Debug|Any CPU + {F3BD4E7F-732C-2DB0-6F4D-B1F55BCAEBB8}.Release|Any CPU.ActiveCfg = Release|Any CPU + {F3BD4E7F-732C-2DB0-6F4D-B1F55BCAEBB8}.Release|Any CPU.Build.0 = Release|Any CPU + {92369D9A-AA0C-48BF-B7A3-31E267ABFA12}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {92369D9A-AA0C-48BF-B7A3-31E267ABFA12}.Debug|Any CPU.Build.0 = Debug|Any CPU + {92369D9A-AA0C-48BF-B7A3-31E267ABFA12}.Release|Any CPU.ActiveCfg = Release|Any CPU + {92369D9A-AA0C-48BF-B7A3-31E267ABFA12}.Release|Any CPU.Build.0 = Release|Any CPU + EndGlobalSection + GlobalSection(SolutionProperties) = preSolution + HideSolutionNode = FALSE + EndGlobalSection + GlobalSection(NestedProjects) = preSolution + {92369D9A-AA0C-48BF-B7A3-31E267ABFA12} = {02EA681E-C7D8-13C7-8484-4AC65E1B71E8} + EndGlobalSection + GlobalSection(ExtensibilityGlobals) = postSolution + SolutionGuid = {99130711-4A8F-4B56-997C-E2F6019554CC} + 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(); - } - } -}