diff --git a/.gitignore b/.gitignore index fd3586545..b4a3d380b 100644 --- a/.gitignore +++ b/.gitignore @@ -17,6 +17,10 @@ [Tt]humbs.db *.tgz *.sublime-* +**/bin/ +**/obj/ +.vs/ +packages/ node_modules bower_components diff --git a/jobs/Backend/ExchangeRateUpdater.UnitTests/ExchangeRateProviderTests.cs b/jobs/Backend/ExchangeRateUpdater.UnitTests/ExchangeRateProviderTests.cs new file mode 100644 index 000000000..3112099e9 --- /dev/null +++ b/jobs/Backend/ExchangeRateUpdater.UnitTests/ExchangeRateProviderTests.cs @@ -0,0 +1,144 @@ +//using ExchangeRateUpdater.Src; +//using ExchangeRateUpdater.Src.Cnb; +//using Microsoft.Extensions.Caching.Distributed; +//using Microsoft.Extensions.DependencyInjection; +//using Microsoft.Extensions.Logging.Abstractions; +//using Microsoft.Extensions.Options; +//using NUnit.Framework; +//using System; +//using System.Linq; +//using System.Net; +//using System.Net.Http; +//using System.Text; +//using System.Text.Json; +//using System.Threading; +//using System.Threading.Tasks; + +//namespace ExchangeRateUpdater.UnitTests; + +//[TestFixture] +//public class ExchangeRateProviderTests +//{ +// [Test] +// public async Task JsonApi_ParsesAndCaches_OnSecondCallHitsCache() +// { +// var json = """ +// [ +// {"CurrencyCode":"EUR","Amount":1,"Rate":25.123,"ValidFor":"2025-09-12"}, +// {"CurrencyCode":"USD","Amount":1,"Rate":22.987,"ValidFor":"2025-09-12"} +// ] +// """; + +// int httpHits = 0; +// var handler = new FakeHandler(_ => +// { +// httpHits++; +// return new HttpResponseMessage(HttpStatusCode.OK) +// { +// Content = new StringContent(json, Encoding.UTF8, "application/json") +// }; +// }); + +// var http = new HttpClient(handler); +// var services = new ServiceCollection() +// .AddDistributedMemoryCache() +// .BuildServiceProvider(); + +// var cache = services.GetRequiredService(); +// var opts = Options.Create(new CnbOptions()); +// var provider = new ExchangeRateProvider(http, cache, opts, NullLogger.Instance); + +// var date = new DateOnly(2025, 9, 12); + +// var r1 = await provider.GetAsync(date); +// Assert.That(r1.Count, Is.EqualTo(2)); +// var eur = r1.First(x => x.SourceCurrency == "EUR"); +// Assert.That(eur.Value, Is.EqualTo(25.123m)); +// Assert.That(eur.ValidFor, Is.EqualTo(date)); + +// var r2 = await provider.GetAsync(date); +// Assert.That(r2.Count, Is.EqualTo(2)); +// Assert.That(httpHits, Is.EqualTo(1)); // cached +// } + +// [Test] +// public async Task JsonApi_StaleIfError_ServesPreviousBusinessDayFromCache() +// { +// var resolved = new DateOnly(2025, 9, 12); +// var prevDay = new DateOnly(2025, 9, 11); + +// var seeded = new[] +// { +// new ExchangeRate("EUR", "CZK", 25.000m, prevDay), +// new ExchangeRate("USD", "CZK", 23.000m, prevDay), +// }; +// var seedJson = JsonSerializer.Serialize(seeded); + +// var handler = new FakeHandler(_ => new HttpResponseMessage(HttpStatusCode.InternalServerError)); +// var http = new HttpClient(handler); + +// var services = new ServiceCollection() +// .AddDistributedMemoryCache() +// .BuildServiceProvider(); + +// var cache = services.GetRequiredService(); +// var opts = Options.Create(new CnbOptions()); + +// var keyPrev = $"{opts.Value.CacheKeyPrefix}{prevDay:yyyy-MM-dd}"; +// await cache.SetStringAsync(keyPrev, seedJson, new DistributedCacheEntryOptions +// { +// AbsoluteExpirationRelativeToNow = TimeSpan.FromMinutes(10) +// }); + +// var provider = new ExchangeRateProvider(http, cache, opts, NullLogger.Instance); +// var result = await provider.GetAsync(resolved); + +// Assert.That(result.First().ValidFor, Is.EqualTo(prevDay)); +// Assert.That(result.Any(r => r.SourceCurrency == "EUR" && r.Value == 25.000m), Is.True); +// } + +// [Test] +// public async Task JsonApi_ParsesEnvelopeWithItems() +// { +// var json = """ +// { +// "date": "2025-09-12", +// "items": [ +// {"CurrencyCode":"GBP","Amount":1,"Rate":"29.876","ValidFor":"2025-09-12"} +// ] +// } +// """; + +// var handler = new FakeHandler(_ => new HttpResponseMessage(HttpStatusCode.OK) +// { +// Content = new StringContent(json, Encoding.UTF8, "application/json") +// }); + +// var http = new HttpClient(handler); +// var services = new ServiceCollection() +// .AddDistributedMemoryCache() +// .BuildServiceProvider(); + +// var cache = services.GetRequiredService(); +// var opts = Options.Create(new CnbOptions()); +// var provider = new ExchangeRateProvider(http, cache, opts, NullLogger.Instance); + +// var date = new DateOnly(2025, 9, 12); +// var rates = await provider.GetAsync(date); + +// Assert.That(rates.Count, Is.EqualTo(1)); +// var gbp = rates.Single(); +// Assert.That(gbp.SourceCurrency, Is.EqualTo("GBP")); +// Assert.That(gbp.Value, Is.EqualTo(29.876m)); +// Assert.That(gbp.ValidFor, Is.EqualTo(date)); +// } + +// private sealed class FakeHandler : HttpMessageHandler +// { +// private readonly Func _responder; +// public FakeHandler(Func responder) => _responder = responder; + +// protected override Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) => +// Task.FromResult(_responder(request)); +// } +//} diff --git a/jobs/Backend/ExchangeRateUpdater.UnitTests/ExchangeRateUpdater.UnitTests.csproj b/jobs/Backend/ExchangeRateUpdater.UnitTests/ExchangeRateUpdater.UnitTests.csproj new file mode 100644 index 000000000..b99d5a49f --- /dev/null +++ b/jobs/Backend/ExchangeRateUpdater.UnitTests/ExchangeRateUpdater.UnitTests.csproj @@ -0,0 +1,15 @@ + + + net8.0 + false + enable + + + + + + + + + + diff --git a/jobs/Backend/Task/Contracts/IExchangeRateCache.cs b/jobs/Backend/Task/Contracts/IExchangeRateCache.cs new file mode 100644 index 000000000..5b24f8e10 --- /dev/null +++ b/jobs/Backend/Task/Contracts/IExchangeRateCache.cs @@ -0,0 +1,7 @@ +namespace ExchangeRateUpdater.Contracts; + +public interface IExchangeRateCache +{ + Task?> GetAsync(string key, CancellationToken ct); + Task SetAsync(string key, List value, CancellationToken ct); +} diff --git a/jobs/Backend/Task/Contracts/IExchangeRateProvider.cs b/jobs/Backend/Task/Contracts/IExchangeRateProvider.cs new file mode 100644 index 000000000..68d68e694 --- /dev/null +++ b/jobs/Backend/Task/Contracts/IExchangeRateProvider.cs @@ -0,0 +1,7 @@ +namespace ExchangeRateUpdater.Contracts +{ + public interface IExchangeRateProvider + { + Task> GetAsync(DateOnly date, CancellationToken ct = default); + } +} diff --git a/jobs/Backend/Task/ExchangeRate.cs b/jobs/Backend/Task/ExchangeRate.cs index 58c5bb10e..a32391d52 100644 --- a/jobs/Backend/Task/ExchangeRate.cs +++ b/jobs/Backend/Task/ExchangeRate.cs @@ -1,23 +1,19 @@ -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; } +namespace ExchangeRateUpdater; - public decimal Value { get; } +public sealed class ExchangeRate +{ + public string SourceCurrency { get; } + public string TargetCurrency { get; } + public decimal Value { get; } + public DateOnly ValidFor { get; } - public override string ToString() - { - return $"{SourceCurrency}/{TargetCurrency}={Value}"; - } + public ExchangeRate(string sourceCurrency, string targetCurrency, decimal value, DateOnly validFor) + { + SourceCurrency = sourceCurrency; + TargetCurrency = targetCurrency; + Value = value; + ValidFor = validFor; } + + public override string ToString() => $"{SourceCurrency}/{TargetCurrency}={Value}"; } diff --git a/jobs/Backend/Task/ExchangeRateProvider.cs b/jobs/Backend/Task/ExchangeRateProvider.cs deleted file mode 100644 index 6f82a97fb..000000000 --- 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 index 2fc654a12..1a987c7cd 100644 --- a/jobs/Backend/Task/ExchangeRateUpdater.csproj +++ b/jobs/Backend/Task/ExchangeRateUpdater.csproj @@ -1,8 +1,19 @@  + + Exe + net8.0 + enable + enable + true + - - Exe - net6.0 - - + + + + + + + + + \ No newline at end of file diff --git a/jobs/Backend/Task/ExchangeRateUpdater.sln b/jobs/Backend/Task/ExchangeRateUpdater.sln index 89be84daf..e578fea6a 100644 --- a/jobs/Backend/Task/ExchangeRateUpdater.sln +++ b/jobs/Backend/Task/ExchangeRateUpdater.sln @@ -1,10 +1,12 @@  Microsoft Visual Studio Solution File, Format Version 12.00 -# Visual Studio 14 -VisualStudioVersion = 14.0.25123.0 +# Visual Studio Version 17 +VisualStudioVersion = 17.14.36414.22 MinimumVisualStudioVersion = 10.0.40219.1 Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ExchangeRateUpdater", "ExchangeRateUpdater.csproj", "{7B2695D6-D24C-4460-A58E-A10F08550CE0}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ExchangeRateUpdater.UnitTests", "..\ExchangeRateUpdater.UnitTests\ExchangeRateUpdater.UnitTests.csproj", "{A1B84AD2-20DB-4358-908C-91AA3AB7DD62}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -15,8 +17,15 @@ Global {7B2695D6-D24C-4460-A58E-A10F08550CE0}.Debug|Any CPU.Build.0 = Debug|Any CPU {7B2695D6-D24C-4460-A58E-A10F08550CE0}.Release|Any CPU.ActiveCfg = Release|Any CPU {7B2695D6-D24C-4460-A58E-A10F08550CE0}.Release|Any CPU.Build.0 = Release|Any CPU + {A1B84AD2-20DB-4358-908C-91AA3AB7DD62}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {A1B84AD2-20DB-4358-908C-91AA3AB7DD62}.Debug|Any CPU.Build.0 = Debug|Any CPU + {A1B84AD2-20DB-4358-908C-91AA3AB7DD62}.Release|Any CPU.ActiveCfg = Release|Any CPU + {A1B84AD2-20DB-4358-908C-91AA3AB7DD62}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE EndGlobalSection + GlobalSection(ExtensibilityGlobals) = postSolution + SolutionGuid = {D3F6DA87-1B07-4886-BCA5-812EA94E118F} + EndGlobalSection EndGlobal diff --git a/jobs/Backend/Task/Program.cs b/jobs/Backend/Task/Program.cs index 379a69b1f..74b5c1d79 100644 --- a/jobs/Backend/Task/Program.cs +++ b/jobs/Backend/Task/Program.cs @@ -1,43 +1,118 @@ -using System; -using System.Collections.Generic; -using System.Linq; +using ExchangeRateUpdater.Contracts; +using ExchangeRateUpdater.Src; +using ExchangeRateUpdater.Src.Cnb; +using ExchangeRateUpdater.Src.Infrastructure; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using StackExchange.Redis; +using System.Globalization; -namespace ExchangeRateUpdater +namespace ExchangeRateUpdater; + +public static class Program { - public static class Program + public static async Task Main(string[] args) { - private static IEnumerable currencies = new[] + using var cts = new CancellationTokenSource(); + Console.CancelKeyPress += (_, e) => { e.Cancel = true; cts.Cancel(); }; + + DateOnly requestedDate = ParseRequestedDate(args); + + var services = new ServiceCollection(); + ConfigureLogging(services); + ConfigureOptions(services); + await ConfigureCachingAsync(services); + ConfigureAppServices(services); + + using ServiceProvider provider = services.BuildServiceProvider(); + ILogger logger = provider.GetRequiredService().CreateLogger("Main"); + + try + { + var rateProvider = provider.GetRequiredService(); + List rates = await rateProvider.GetAsync(requestedDate, cts.Token); + + string effective = rates[0].ValidFor.ToString("yyyy-MM-dd", CultureInfo.InvariantCulture); + Console.WriteLine($"CNB Exchange Rates (ValidFor={effective}) — {rates.Count} items:"); + foreach (ExchangeRate r in rates.OrderBy(x => x.SourceCurrency)) + Console.WriteLine(r.ToString()); + + return 0; + } + catch (OperationCanceledException) { - 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) + logger.LogWarning("Operation canceled."); + return 130; + } + catch (Exception ex) { - try + logger.LogError(ex, "Could not retrieve exchange rates."); + return 1; + } + } + + private static DateOnly ParseRequestedDate(string[] args) + { + if (args.Length == 1 && DateOnly.TryParse(args[0], out DateOnly d)) + return d; + return DateOnly.FromDateTime(DateTime.UtcNow); + } + + private static void ConfigureLogging(ServiceCollection services) + { + services.AddLogging(b => b + .AddSimpleConsole(o => { - var provider = new ExchangeRateProvider(); - var rates = provider.GetExchangeRates(currencies); + o.SingleLine = true; + o.TimestampFormat = "HH:mm:ss "; + o.IncludeScopes = true; + }) + .SetMinimumLevel(LogLevel.Information)); + } - Console.WriteLine($"Successfully retrieved {rates.Count()} exchange rates:"); - foreach (var rate in rates) - { - Console.WriteLine(rate.ToString()); - } - } - catch (Exception e) + private static void ConfigureOptions(ServiceCollection services) + { + services.Configure(_ => { }); + } + + private static async Task ConfigureCachingAsync(ServiceCollection services) + { + string conn = Environment.GetEnvironmentVariable("REDIS_CONNECTION_STRING") ?? "127.0.0.1:6379"; + + IConnectionMultiplexer? mux = null; + try + { + var cfg = ConfigurationOptions.Parse(conn); + cfg.AbortOnConnectFail = false; + cfg.ConnectTimeout = 2000; + cfg.SyncTimeout = 2000; + + mux = await ConnectionMultiplexer.ConnectAsync(cfg); + if (mux.IsConnected) { - Console.WriteLine($"Could not retrieve exchange rates: '{e.Message}'."); + Console.WriteLine($"INFO: Connected to Redis at {string.Join(",", cfg.EndPoints)}"); + services.AddStackExchangeRedisCache(o => + { + o.ConnectionMultiplexerFactory = () => Task.FromResult(mux); + o.InstanceName = "cnb:"; + }); } + } + catch (Exception ex) + { + Console.WriteLine($"WARN: Could not connect to Redis at '{conn}' ({ex.Message}). Falling back to in-memory cache."); + } - Console.ReadLine(); + if (mux is null || !mux.IsConnected) + { + Console.WriteLine("WARN: Redis unavailable; using in-memory distributed cache."); + services.AddDistributedMemoryCache(); } } + + private static void ConfigureAppServices(ServiceCollection services) + { + services.AddSingleton(); + services.AddHttpClient(); + } } diff --git a/jobs/Backend/Task/Properties/launchSettings.json b/jobs/Backend/Task/Properties/launchSettings.json new file mode 100644 index 000000000..b5a98a57c --- /dev/null +++ b/jobs/Backend/Task/Properties/launchSettings.json @@ -0,0 +1,10 @@ +{ + "profiles": { + "Console (Redis localhost:6379)": { + "commandName": "Project", + "environmentVariables": { + "REDIS_CONNECTION_STRING": "localhost" + } + } + } +} \ No newline at end of file diff --git a/jobs/Backend/Task/Src/Cnb/CnbOptions.cs b/jobs/Backend/Task/Src/Cnb/CnbOptions.cs new file mode 100644 index 000000000..444720235 --- /dev/null +++ b/jobs/Backend/Task/Src/Cnb/CnbOptions.cs @@ -0,0 +1,19 @@ +namespace ExchangeRateUpdater.Src.Cnb; + +public class CnbOptions +{ + public bool EnablePublishTimeFallback { get; set; } = true; + public string PublishTimeZone { get; set; } = "Europe/Prague"; + public TimeOnly PublishTime { get; set; } = new(14, 30); + + public TimeSpan HttpTimeout { get; set; } = TimeSpan.FromSeconds(15); + public int RetryCount { get; set; } = 3; + + public TimeSpan CacheTtl { get; set; } = TimeSpan.FromDays(30); + public string CacheKeyPrefix { get; set; } = "cnb:rates:"; + + public string JsonApiBase { get; set; } = "https://api.cnb.cz/cnbapi"; + public string JsonDailyEndpoint { get; set; } = "/exrates/daily"; + public string JsonDateParam { get; set; } = "date"; + public string JsonDateFormat { get; set; } = "yyyy-MM-dd"; +} diff --git a/jobs/Backend/Task/Src/ExchangeRateProvider.cs b/jobs/Backend/Task/Src/ExchangeRateProvider.cs new file mode 100644 index 000000000..10b9fbe6d --- /dev/null +++ b/jobs/Backend/Task/Src/ExchangeRateProvider.cs @@ -0,0 +1,204 @@ +using ExchangeRateUpdater.Contracts; +using ExchangeRateUpdater.Src.Cnb; +using ExchangeRateUpdater.Src.Infrastructure; +using Microsoft.Extensions.Caching.Distributed; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; +using Polly; +using System.Globalization; +using System.Net.Http.Headers; +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace ExchangeRateUpdater.Src; + +public sealed class ExchangeRateProvider : IExchangeRateProvider +{ + private readonly HttpClient _http; + private readonly ILogger _log; + private readonly CnbOptions _opt; + private readonly IAsyncPolicy _policy; + private readonly IExchangeRateCache _rateCache; + + // ctor + public ExchangeRateProvider( + HttpClient httpClient, + IExchangeRateCache rateCache, + IOptions options, + ILogger log) + { + _http = httpClient; + _rateCache = rateCache; + _log = log; + _opt = options.Value; + + _http.Timeout = _opt.HttpTimeout; + _http.DefaultRequestHeaders.UserAgent.Add(new System.Net.Http.Headers.ProductInfoHeaderValue("Mews-BackendTask", "1.0")); + _policy = Policies.CreateHttpPolicy(_opt.RetryCount, _log); + } + + public async Task> GetAsync(DateOnly date, CancellationToken ct = default) + { + using var _ = _log.BeginScope(new Dictionary + { + ["requested_date"] = date.ToString("yyyy-MM-dd"), + ["source"] = "json-api" + }); + + var resolved = ResolveBusinessDate(date); + _log.LogInformation("Fetching CNB JSON API (resolved_date: {ResolvedDate})", resolved.ToString("yyyy-MM-dd")); + + var key = $"{_opt.CacheKeyPrefix}{resolved:yyyy-MM-dd}"; + + var cached = await _rateCache.GetAsync(key, ct); + if (cached is not null) + { + _log.LogInformation("Cache hit for {ResolvedDate}", resolved.ToString("yyyy-MM-dd")); + return cached; + } + _log.LogInformation("Cache miss for {ResolvedDate}", resolved.ToString("yyyy-MM-dd")); + + string url = BuildJsonUrl(resolved); + var ctx = new Context(); + ctx["url"] = url; + ctx["date"] = resolved.ToString("yyyy-MM-dd"); + + try + { + HttpResponseMessage resp = await _policy.ExecuteAsync((_, token) => _http.GetAsync(url, token), ctx, ct); + if (!resp.IsSuccessStatusCode) + throw new HttpRequestException($"CNB JSON request failed: {(int)resp.StatusCode} {resp.ReasonPhrase}"); + + string payload = await resp.Content.ReadAsStringAsync(ct); + List rates = ParseJsonRows(payload, resolved); + + await _rateCache.SetAsync(key, rates, ct); + _log.LogInformation("Cached {Count} rates for {Date}", rates.Count, rates[0].ValidFor.ToString("yyyy-MM-dd", CultureInfo.InvariantCulture)); + return rates; + } + catch (Exception ex) + { + _log.LogWarning(ex, "JSON API fetch failed; attempting stale cache for {ResolvedDate}", resolved.ToString("yyyy-MM-dd")); + + foreach (var prev in PreviousBusinessDays(resolved, 7)) + { + var prevKey = $"{_opt.CacheKeyPrefix}{prev:yyyy-MM-dd}"; + var stale = await _rateCache.GetAsync(prevKey, ct); + if (stale is not null) + { + _log.LogInformation("Serving stale data for {StaleDate}", prev.ToString("yyyy-MM-dd")); + return stale; + } + } + + _log.LogError(ex, "No live data and no stale cache available for {ResolvedDate}", resolved.ToString("yyyy-MM-dd")); + throw; + } + } + + private string BuildJsonUrl(DateOnly date) + { + var baseUrl = _opt.JsonApiBase.TrimEnd('/'); + var path = _opt.JsonDailyEndpoint.StartsWith('/') ? _opt.JsonDailyEndpoint : "/" + _opt.JsonDailyEndpoint; + var d = date.ToDateTime(TimeOnly.MinValue).ToString(_opt.JsonDateFormat, CultureInfo.InvariantCulture); + return $"{baseUrl}{path}?{_opt.JsonDateParam}={Uri.EscapeDataString(d)}"; + } + + private static List ParseJsonRows(string json, DateOnly fallbackDate) + { + JsonSerializerOptions opts = new JsonSerializerOptions + { + PropertyNameCaseInsensitive = true, + NumberHandling = JsonNumberHandling.AllowReadingFromString + }; + + CnbRate[]? arr = JsonSerializer.Deserialize(json, opts); + IEnumerable? source = (arr is { Length: > 0 }) ? arr : null; + + if (source is null) + { + CnbDaily? daily = JsonSerializer.Deserialize(json, opts); + source = daily?.Items; + } + + if (source is null) + throw new FormatException("CNB JSON payload did not contain usable rates."); + + List list = new List(); + foreach (CnbRate r in source) + { + if (string.IsNullOrWhiteSpace(r.CurrencyCode) || r.Amount <= 0 || r.Rate <= 0m) + continue; + + DateOnly validFor = r.ValidFor.HasValue + ? DateOnly.FromDateTime(r.ValidFor.Value) + : fallbackDate; + + decimal perUnit = r.Rate / r.Amount; + list.Add(new ExchangeRate(r.CurrencyCode.ToUpperInvariant(), "CZK", perUnit, validFor)); + } + + if (list.Count == 0) + throw new FormatException("CNB JSON payload did not contain usable rates."); + + return list; + } + + private sealed class CnbDaily + { + [JsonPropertyName("items")] + public List? Items { get; set; } + } + + private sealed class CnbRate + { + public string? CurrencyCode { get; set; } + public int Amount { get; set; } + public decimal Rate { get; set; } + public DateTime? ValidFor { get; set; } + } + + private DateOnly ResolveBusinessDate(DateOnly requested) + { + var d = requested; + if (d.DayOfWeek is DayOfWeek.Saturday) d = d.AddDays(-1); + else if (d.DayOfWeek is DayOfWeek.Sunday) d = d.AddDays(-2); + + if (!_opt.EnablePublishTimeFallback) return d; + + try + { + var tz = TimeZoneInfo.FindSystemTimeZoneById(_opt.PublishTimeZone); + var nowTz = TimeZoneInfo.ConvertTime(DateTimeOffset.UtcNow, tz); + var today = DateOnly.FromDateTime(nowTz.Date); + if (d == today) + { + DateTime publishAt = nowTz.Date + _opt.PublishTime.ToTimeSpan(); + if (nowTz.DateTime < publishAt) + { + d = PreviousBusinessDay(d); + _log.LogInformation("Before publish time; using previous business day {ResolvedDate}", d.ToString("yyyy-MM-dd")); + } + } + } + catch (TimeZoneNotFoundException) + { + _log.LogWarning("Timezone {TimeZone} not found; skipping publish fallback.", _opt.PublishTimeZone); + } + return d; + } + + private static DateOnly PreviousBusinessDay(DateOnly date) + { + var d = date.AddDays(-1); + if (d.DayOfWeek == DayOfWeek.Sunday) d = d.AddDays(-2); + else if (d.DayOfWeek == DayOfWeek.Saturday) d = d.AddDays(-1); + return d; + } + + private static IEnumerable PreviousBusinessDays(DateOnly start, int days) + { + var d = start; + for (int i = 0; i < days; i++) { d = PreviousBusinessDay(d); yield return d; } + } +} \ No newline at end of file diff --git a/jobs/Backend/Task/Src/Infrastructure/ExchangeRateCache.cs b/jobs/Backend/Task/Src/Infrastructure/ExchangeRateCache.cs new file mode 100644 index 000000000..7e8053a32 --- /dev/null +++ b/jobs/Backend/Task/Src/Infrastructure/ExchangeRateCache.cs @@ -0,0 +1,53 @@ +using ExchangeRateUpdater.Contracts; +using ExchangeRateUpdater.Src.Cnb; +using Microsoft.Extensions.Caching.Distributed; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; +using System.Text.Json; + +namespace ExchangeRateUpdater.Src.Infrastructure; + +public sealed class ExchangeRateCache : IExchangeRateCache +{ + private readonly IDistributedCache _cache; + private readonly CnbOptions _options; + private readonly ILogger _log; + + public ExchangeRateCache(IDistributedCache cache, IOptions options, ILogger log) + { + _cache = cache; + _options = options.Value; + _log = log; + } + + public async Task?> GetAsync(string key, CancellationToken ct) + { + try + { + string? hit = await _cache.GetStringAsync(key, ct); + return string.IsNullOrEmpty(hit) ? null : JsonSerializer.Deserialize>(hit); + } + catch (Exception ex) + { + _log.LogWarning(ex, "Redis get failed (key: {Key})", key); + return null; + } + } + + public async Task SetAsync(string key, List value, CancellationToken ct) + { + try + { + string payload = JsonSerializer.Serialize(value); + await _cache.SetStringAsync( + key, + payload, + new DistributedCacheEntryOptions { AbsoluteExpirationRelativeToNow = _options.CacheTtl }, + ct); + } + catch (Exception ex) + { + _log.LogWarning(ex, "Redis set failed (key: {Key})", key); + } + } +} diff --git a/jobs/Backend/Task/Src/Infrastructure/Policies.cs b/jobs/Backend/Task/Src/Infrastructure/Policies.cs new file mode 100644 index 000000000..3b50273f9 --- /dev/null +++ b/jobs/Backend/Task/Src/Infrastructure/Policies.cs @@ -0,0 +1,44 @@ +using Microsoft.Extensions.Logging; +using Polly; +using Polly.CircuitBreaker; +using Polly.Contrib.WaitAndRetry; +using Polly.Retry; + +namespace ExchangeRateUpdater.Src.Infrastructure; + +public static class Policies +{ + public static IAsyncPolicy CreateHttpPolicy(int retries, ILogger logger) + { + AsyncRetryPolicy retry = Policy + .Handle() + .Or() + .OrResult(r => (int)r.StatusCode is >= 500 or 429 or 408) + .WaitAndRetryAsync( + Backoff.DecorrelatedJitterBackoffV2(TimeSpan.FromMilliseconds(300), Math.Max(1, retries)), + onRetry: (outcome, delay, attempt, context) => + { + var reason = outcome.Exception?.GetType().Name + ?? $"{(int)outcome.Result!.StatusCode} {outcome.Result.ReasonPhrase}"; + logger.LogWarning( + "HTTP retry {Attempt} in {Delay} due to {Reason}. Context: {Context}", + attempt, delay, reason, context); + }); + + AsyncCircuitBreakerPolicy breaker = Policy + .Handle() + .Or() + .OrResult(r => (int)r.StatusCode is >= 500 or 429 or 408) + .CircuitBreakerAsync( + handledEventsAllowedBeforeBreaking: 5, + durationOfBreak: TimeSpan.FromSeconds(30), + onBreak: (outcome, breakDelay, context) => + logger.LogWarning("Circuit OPEN for {Delay}. Context: {Context}", breakDelay, context), + onReset: (context) => + logger.LogInformation("Circuit RESET. Context: {Context}", context), + onHalfOpen: () => + logger.LogInformation("Circuit HALF-OPEN")); + + return Policy.WrapAsync(breaker, retry); + } +}