diff --git a/ExchangeRateUpdater/.vs/ExchangeRateUpdater/CopilotIndices/17.14.1431.25910/CodeChunks.db b/ExchangeRateUpdater/.vs/ExchangeRateUpdater/CopilotIndices/17.14.1431.25910/CodeChunks.db new file mode 100644 index 000000000..f8c0ead15 Binary files /dev/null and b/ExchangeRateUpdater/.vs/ExchangeRateUpdater/CopilotIndices/17.14.1431.25910/CodeChunks.db differ diff --git a/ExchangeRateUpdater/.vs/ExchangeRateUpdater/CopilotIndices/17.14.1431.25910/SemanticSymbols.db b/ExchangeRateUpdater/.vs/ExchangeRateUpdater/CopilotIndices/17.14.1431.25910/SemanticSymbols.db new file mode 100644 index 000000000..18d6da4ba Binary files /dev/null and b/ExchangeRateUpdater/.vs/ExchangeRateUpdater/CopilotIndices/17.14.1431.25910/SemanticSymbols.db differ diff --git a/ExchangeRateUpdater/.vs/ExchangeRateUpdater/DesignTimeBuild/.dtbcache.v2 b/ExchangeRateUpdater/.vs/ExchangeRateUpdater/DesignTimeBuild/.dtbcache.v2 new file mode 100644 index 000000000..30310e717 Binary files /dev/null and b/ExchangeRateUpdater/.vs/ExchangeRateUpdater/DesignTimeBuild/.dtbcache.v2 differ diff --git a/ExchangeRateUpdater/.vs/ExchangeRateUpdater/FileContentIndex/36282d28-6132-48c3-8512-f85835b2f394.vsidx b/ExchangeRateUpdater/.vs/ExchangeRateUpdater/FileContentIndex/36282d28-6132-48c3-8512-f85835b2f394.vsidx new file mode 100644 index 000000000..adc83e519 Binary files /dev/null and b/ExchangeRateUpdater/.vs/ExchangeRateUpdater/FileContentIndex/36282d28-6132-48c3-8512-f85835b2f394.vsidx differ diff --git a/ExchangeRateUpdater/.vs/ExchangeRateUpdater/FileContentIndex/888382d4-02f3-4671-b900-17d449760895.vsidx b/ExchangeRateUpdater/.vs/ExchangeRateUpdater/FileContentIndex/888382d4-02f3-4671-b900-17d449760895.vsidx new file mode 100644 index 000000000..1165d2dfa Binary files /dev/null and b/ExchangeRateUpdater/.vs/ExchangeRateUpdater/FileContentIndex/888382d4-02f3-4671-b900-17d449760895.vsidx differ diff --git a/ExchangeRateUpdater/.vs/ExchangeRateUpdater/FileContentIndex/9ea21c94-818e-481e-bff6-638adfc1731e.vsidx b/ExchangeRateUpdater/.vs/ExchangeRateUpdater/FileContentIndex/9ea21c94-818e-481e-bff6-638adfc1731e.vsidx new file mode 100644 index 000000000..65fed7bab Binary files /dev/null and b/ExchangeRateUpdater/.vs/ExchangeRateUpdater/FileContentIndex/9ea21c94-818e-481e-bff6-638adfc1731e.vsidx differ diff --git a/ExchangeRateUpdater/.vs/ExchangeRateUpdater/FileContentIndex/f35f0a0d-cd21-458e-9332-f515123e0f4e.vsidx b/ExchangeRateUpdater/.vs/ExchangeRateUpdater/FileContentIndex/f35f0a0d-cd21-458e-9332-f515123e0f4e.vsidx new file mode 100644 index 000000000..c9e217144 Binary files /dev/null and b/ExchangeRateUpdater/.vs/ExchangeRateUpdater/FileContentIndex/f35f0a0d-cd21-458e-9332-f515123e0f4e.vsidx differ diff --git a/ExchangeRateUpdater/.vs/ExchangeRateUpdater/FileContentIndex/fb18cc91-a2c1-46b8-87e7-3b55c50aab66.vsidx b/ExchangeRateUpdater/.vs/ExchangeRateUpdater/FileContentIndex/fb18cc91-a2c1-46b8-87e7-3b55c50aab66.vsidx new file mode 100644 index 000000000..70aef67a0 Binary files /dev/null and b/ExchangeRateUpdater/.vs/ExchangeRateUpdater/FileContentIndex/fb18cc91-a2c1-46b8-87e7-3b55c50aab66.vsidx differ diff --git a/ExchangeRateUpdater/.vs/ExchangeRateUpdater/v17/.futdcache.v2 b/ExchangeRateUpdater/.vs/ExchangeRateUpdater/v17/.futdcache.v2 new file mode 100644 index 000000000..68aedd34e Binary files /dev/null and b/ExchangeRateUpdater/.vs/ExchangeRateUpdater/v17/.futdcache.v2 differ diff --git a/ExchangeRateUpdater/.vs/ExchangeRateUpdater/v17/DocumentLayout.backup.json b/ExchangeRateUpdater/.vs/ExchangeRateUpdater/v17/DocumentLayout.backup.json new file mode 100644 index 000000000..5b0ada326 --- /dev/null +++ b/ExchangeRateUpdater/.vs/ExchangeRateUpdater/v17/DocumentLayout.backup.json @@ -0,0 +1,55 @@ +{ + "Version": 1, + "WorkspaceRootPath": "C:\\1_task\\ExchangeRateUpdater\\", + "Documents": [ + { + "AbsoluteMoniker": "D:0:0:{F718280A-7459-4E33-B42A-E19CB8ACE541}|ExchangeRateUpdater\\ExchangeRateUpdater.csproj|c:\\1_task\\exchangerateupdater\\exchangerateupdater\\exchangerateprovider.cs||{A6C744A8-0E4A-4FC6-886A-064283054674}", + "RelativeMoniker": "D:0:0:{F718280A-7459-4E33-B42A-E19CB8ACE541}|ExchangeRateUpdater\\ExchangeRateUpdater.csproj|solutionrelative:exchangerateupdater\\exchangerateprovider.cs||{A6C744A8-0E4A-4FC6-886A-064283054674}" + }, + { + "AbsoluteMoniker": "D:0:0:{4A3E62F0-B85E-4261-BBFD-354692E70DD6}|Demo\\Demo.csproj|c:\\1_task\\exchangerateupdater\\demo\\program.cs||{A6C744A8-0E4A-4FC6-886A-064283054674}", + "RelativeMoniker": "D:0:0:{4A3E62F0-B85E-4261-BBFD-354692E70DD6}|Demo\\Demo.csproj|solutionrelative:demo\\program.cs||{A6C744A8-0E4A-4FC6-886A-064283054674}" + } + ], + "DocumentGroupContainers": [ + { + "Orientation": 0, + "VerticalTabListWidth": 256, + "DocumentGroups": [ + { + "DockedWidth": 200, + "SelectedChildIndex": 0, + "Children": [ + { + "$type": "Document", + "DocumentIndex": 0, + "Title": "ExchangeRateProvider.cs", + "DocumentMoniker": "C:\\1_task\\ExchangeRateUpdater\\ExchangeRateUpdater\\ExchangeRateProvider.cs", + "RelativeDocumentMoniker": "ExchangeRateUpdater\\ExchangeRateProvider.cs", + "ToolTip": "C:\\1_task\\ExchangeRateUpdater\\ExchangeRateUpdater\\ExchangeRateProvider.cs", + "RelativeToolTip": "ExchangeRateUpdater\\ExchangeRateProvider.cs", + "ViewState": "AgIAALcAAAAAAAAAAAAIwNUAAAAAAAAAAAAAAA==", + "Icon": "ae27a6b0-e345-4288-96df-5eaf394ee369.000738|", + "WhenOpened": "2025-12-29T16:03:14.636Z", + "IsPinned": true, + "EditorCaption": "" + }, + { + "$type": "Document", + "DocumentIndex": 1, + "Title": "Program.cs", + "DocumentMoniker": "C:\\1_task\\ExchangeRateUpdater\\Demo\\Program.cs", + "RelativeDocumentMoniker": "Demo\\Program.cs", + "ToolTip": "C:\\1_task\\ExchangeRateUpdater\\Demo\\Program.cs", + "RelativeToolTip": "Demo\\Program.cs", + "ViewState": "AgIAAAAAAAAAAAAAAAAAADsAAAAAAAAAAAAAAA==", + "Icon": "ae27a6b0-e345-4288-96df-5eaf394ee369.000738|", + "WhenOpened": "2025-12-29T16:05:37.307Z", + "EditorCaption": "" + } + ] + } + ] + } + ] +} \ No newline at end of file diff --git a/ExchangeRateUpdater/.vs/ExchangeRateUpdater/v17/DocumentLayout.json b/ExchangeRateUpdater/.vs/ExchangeRateUpdater/v17/DocumentLayout.json new file mode 100644 index 000000000..5b0ada326 --- /dev/null +++ b/ExchangeRateUpdater/.vs/ExchangeRateUpdater/v17/DocumentLayout.json @@ -0,0 +1,55 @@ +{ + "Version": 1, + "WorkspaceRootPath": "C:\\1_task\\ExchangeRateUpdater\\", + "Documents": [ + { + "AbsoluteMoniker": "D:0:0:{F718280A-7459-4E33-B42A-E19CB8ACE541}|ExchangeRateUpdater\\ExchangeRateUpdater.csproj|c:\\1_task\\exchangerateupdater\\exchangerateupdater\\exchangerateprovider.cs||{A6C744A8-0E4A-4FC6-886A-064283054674}", + "RelativeMoniker": "D:0:0:{F718280A-7459-4E33-B42A-E19CB8ACE541}|ExchangeRateUpdater\\ExchangeRateUpdater.csproj|solutionrelative:exchangerateupdater\\exchangerateprovider.cs||{A6C744A8-0E4A-4FC6-886A-064283054674}" + }, + { + "AbsoluteMoniker": "D:0:0:{4A3E62F0-B85E-4261-BBFD-354692E70DD6}|Demo\\Demo.csproj|c:\\1_task\\exchangerateupdater\\demo\\program.cs||{A6C744A8-0E4A-4FC6-886A-064283054674}", + "RelativeMoniker": "D:0:0:{4A3E62F0-B85E-4261-BBFD-354692E70DD6}|Demo\\Demo.csproj|solutionrelative:demo\\program.cs||{A6C744A8-0E4A-4FC6-886A-064283054674}" + } + ], + "DocumentGroupContainers": [ + { + "Orientation": 0, + "VerticalTabListWidth": 256, + "DocumentGroups": [ + { + "DockedWidth": 200, + "SelectedChildIndex": 0, + "Children": [ + { + "$type": "Document", + "DocumentIndex": 0, + "Title": "ExchangeRateProvider.cs", + "DocumentMoniker": "C:\\1_task\\ExchangeRateUpdater\\ExchangeRateUpdater\\ExchangeRateProvider.cs", + "RelativeDocumentMoniker": "ExchangeRateUpdater\\ExchangeRateProvider.cs", + "ToolTip": "C:\\1_task\\ExchangeRateUpdater\\ExchangeRateUpdater\\ExchangeRateProvider.cs", + "RelativeToolTip": "ExchangeRateUpdater\\ExchangeRateProvider.cs", + "ViewState": "AgIAALcAAAAAAAAAAAAIwNUAAAAAAAAAAAAAAA==", + "Icon": "ae27a6b0-e345-4288-96df-5eaf394ee369.000738|", + "WhenOpened": "2025-12-29T16:03:14.636Z", + "IsPinned": true, + "EditorCaption": "" + }, + { + "$type": "Document", + "DocumentIndex": 1, + "Title": "Program.cs", + "DocumentMoniker": "C:\\1_task\\ExchangeRateUpdater\\Demo\\Program.cs", + "RelativeDocumentMoniker": "Demo\\Program.cs", + "ToolTip": "C:\\1_task\\ExchangeRateUpdater\\Demo\\Program.cs", + "RelativeToolTip": "Demo\\Program.cs", + "ViewState": "AgIAAAAAAAAAAAAAAAAAADsAAAAAAAAAAAAAAA==", + "Icon": "ae27a6b0-e345-4288-96df-5eaf394ee369.000738|", + "WhenOpened": "2025-12-29T16:05:37.307Z", + "EditorCaption": "" + } + ] + } + ] + } + ] +} \ No newline at end of file diff --git a/ExchangeRateUpdater/.vs/ProjectEvaluation/exchangerateupdater.metadata.v9.bin b/ExchangeRateUpdater/.vs/ProjectEvaluation/exchangerateupdater.metadata.v9.bin new file mode 100644 index 000000000..bfb62d45c Binary files /dev/null and b/ExchangeRateUpdater/.vs/ProjectEvaluation/exchangerateupdater.metadata.v9.bin differ diff --git a/ExchangeRateUpdater/.vs/ProjectEvaluation/exchangerateupdater.projects.v9.bin b/ExchangeRateUpdater/.vs/ProjectEvaluation/exchangerateupdater.projects.v9.bin new file mode 100644 index 000000000..539da212e Binary files /dev/null and b/ExchangeRateUpdater/.vs/ProjectEvaluation/exchangerateupdater.projects.v9.bin differ diff --git a/ExchangeRateUpdater/.vs/ProjectEvaluation/exchangerateupdater.strings.v9.bin b/ExchangeRateUpdater/.vs/ProjectEvaluation/exchangerateupdater.strings.v9.bin new file mode 100644 index 000000000..3e3dd7b92 Binary files /dev/null and b/ExchangeRateUpdater/.vs/ProjectEvaluation/exchangerateupdater.strings.v9.bin differ diff --git a/ExchangeRateUpdater/Demo/Demo.csproj b/ExchangeRateUpdater/Demo/Demo.csproj new file mode 100644 index 000000000..c15173963 --- /dev/null +++ b/ExchangeRateUpdater/Demo/Demo.csproj @@ -0,0 +1,19 @@ + + + + Exe + net8.0 + enable + enable + + + + + + + + + + + + diff --git a/ExchangeRateUpdater/Demo/Program.cs b/ExchangeRateUpdater/Demo/Program.cs new file mode 100644 index 000000000..1f471b299 --- /dev/null +++ b/ExchangeRateUpdater/Demo/Program.cs @@ -0,0 +1,74 @@ +using System; +using System.Linq; +using System.Net.Http; +using System.Threading; +using System.Threading.Tasks; +using ExchangeRateUpdater; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; + +internal static class Program +{ + private static async Task Main(string[] args) + { + using var cts = new CancellationTokenSource(); + Console.CancelKeyPress += (_, e) => + { + e.Cancel = true; + cts.Cancel(); + }; + + var services = new ServiceCollection(); + + services.AddLogging(b => + { + b.AddSimpleConsole(o => + { + o.SingleLine = true; + o.TimestampFormat = "HH:mm:ss "; + }); + b.SetMinimumLevel(LogLevel.Information); + }); + + services.AddHttpClient(c => + { + c.Timeout = TimeSpan.FromSeconds(10); + }); + + using var provider = services.BuildServiceProvider(); + + var rateProvider = provider.GetRequiredService(); + + var currencies = new[] + { + new Currency("CZK"), + new Currency("EUR"), + new Currency("USD"), + new Currency("GBP"), + }; + + try + { + var rates = await rateProvider + .GetCzkBasedRatesAsync(currencies, cts.Token) + .ConfigureAwait(false); + + foreach (var r in rates.OrderBy(r => r.TargetCurrency.Code)) + { + Console.WriteLine(r); + } + + return 0; + } + catch (OperationCanceledException) + { + Console.WriteLine("Cancelled."); + return 1; + } + catch (Exception ex) + { + Console.Error.WriteLine($"Error: {ex.Message}"); + return 2; + } + } +} diff --git a/ExchangeRateUpdater/ExchangeRateUpdater.sln b/ExchangeRateUpdater/ExchangeRateUpdater.sln new file mode 100644 index 000000000..26263d5ef --- /dev/null +++ b/ExchangeRateUpdater/ExchangeRateUpdater.sln @@ -0,0 +1,31 @@ + +Microsoft Visual Studio Solution File, Format Version 12.00 +# Visual Studio Version 17 +VisualStudioVersion = 17.14.36705.20 d17.14 +MinimumVisualStudioVersion = 10.0.40219.1 +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ExchangeRateUpdater", "ExchangeRateUpdater\ExchangeRateUpdater.csproj", "{F718280A-7459-4E33-B42A-E19CB8ACE541}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Demo", "Demo\Demo.csproj", "{4A3E62F0-B85E-4261-BBFD-354692E70DD6}" +EndProject +Global + GlobalSection(SolutionConfigurationPlatforms) = preSolution + Debug|Any CPU = Debug|Any CPU + Release|Any CPU = Release|Any CPU + EndGlobalSection + GlobalSection(ProjectConfigurationPlatforms) = postSolution + {F718280A-7459-4E33-B42A-E19CB8ACE541}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {F718280A-7459-4E33-B42A-E19CB8ACE541}.Debug|Any CPU.Build.0 = Debug|Any CPU + {F718280A-7459-4E33-B42A-E19CB8ACE541}.Release|Any CPU.ActiveCfg = Release|Any CPU + {F718280A-7459-4E33-B42A-E19CB8ACE541}.Release|Any CPU.Build.0 = Release|Any CPU + {4A3E62F0-B85E-4261-BBFD-354692E70DD6}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {4A3E62F0-B85E-4261-BBFD-354692E70DD6}.Debug|Any CPU.Build.0 = Debug|Any CPU + {4A3E62F0-B85E-4261-BBFD-354692E70DD6}.Release|Any CPU.ActiveCfg = Release|Any CPU + {4A3E62F0-B85E-4261-BBFD-354692E70DD6}.Release|Any CPU.Build.0 = Release|Any CPU + EndGlobalSection + GlobalSection(SolutionProperties) = preSolution + HideSolutionNode = FALSE + EndGlobalSection + GlobalSection(ExtensibilityGlobals) = postSolution + SolutionGuid = {7BB53F43-2197-4A44-BBD1-967E5B8C1B6F} + EndGlobalSection +EndGlobal diff --git a/ExchangeRateUpdater/ExchangeRateUpdater/Currency.cs b/ExchangeRateUpdater/ExchangeRateUpdater/Currency.cs new file mode 100644 index 000000000..e3723cbda --- /dev/null +++ b/ExchangeRateUpdater/ExchangeRateUpdater/Currency.cs @@ -0,0 +1,20 @@ +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; + } + } +} \ No newline at end of file diff --git a/ExchangeRateUpdater/ExchangeRateUpdater/ExchangeRate.cs b/ExchangeRateUpdater/ExchangeRateUpdater/ExchangeRate.cs new file mode 100644 index 000000000..e93737eef --- /dev/null +++ b/ExchangeRateUpdater/ExchangeRateUpdater/ExchangeRate.cs @@ -0,0 +1,23 @@ +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}"; + } + } +} \ No newline at end of file diff --git a/ExchangeRateUpdater/ExchangeRateUpdater/ExchangeRateProvider.cs b/ExchangeRateUpdater/ExchangeRateUpdater/ExchangeRateProvider.cs new file mode 100644 index 000000000..42b9c9b2d --- /dev/null +++ b/ExchangeRateUpdater/ExchangeRateUpdater/ExchangeRateProvider.cs @@ -0,0 +1,213 @@ +using System; +using System.Collections.Concurrent; +using System.Collections.Generic; +using System.Globalization; +using System.Linq; +using System.Net; +using System.Net.Http; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Extensions.Logging; + +namespace ExchangeRateUpdater +{ + public sealed class ExchangeRateProvider + { + private const string CnbDailyUrl = + "https://www.cnb.cz/en/financial_markets/foreign_exchange_market/exchange_rate_fixing/daily.txt"; + + private static readonly StringComparer CodeComparer = StringComparer.OrdinalIgnoreCase; + + private readonly HttpClient _http; + private readonly ILogger _log; + + // Reuse also prevents pointless allocations. + private readonly ConcurrentDictionary _currencyCache = new(CodeComparer); + + public ExchangeRateProvider(HttpClient http, ILogger log) + { + _http = http ?? throw new ArgumentNullException(nameof(http)); + _log = log ?? throw new ArgumentNullException(nameof(log)); + + // If someone forgot to configure it, keep us from hanging forever. + if (_http.Timeout == Timeout.InfiniteTimeSpan) + _http.Timeout = TimeSpan.FromSeconds(10); + } + + /// + /// Returns CZK->X exchange rates for requested currencies. + /// + public async Task> GetCzkBasedRatesAsync( + IEnumerable currencies, + CancellationToken ct = default) + { + if (currencies is null) throw new ArgumentNullException(nameof(currencies)); + + var requested = new HashSet( + currencies.Select(c => c?.Code).Where(s => !string.IsNullOrWhiteSpace(s))!, + CodeComparer); + + if (requested.Count == 0) + return Array.Empty(); + + // CNB daily.txt is CZK-based. If caller doesn't care about CZK, we can't help here. + if (!requested.Contains("CZK")) + return Array.Empty(); + + string body; + try + { + body = await DownloadWithRetryAsync(CnbDailyUrl, ct).ConfigureAwait(false); + } + catch (Exception ex) when (!ct.IsCancellationRequested) + { + _log.LogError(ex, "Failed to download CNB daily rates."); + throw; + } + + var all = ParseCnbDailyTxt(body); + + // Return only CZK->requested (excluding CZK->CZK) + var result = all + .Where(r => requested.Contains(r.TargetCurrency.Code) && + !CodeComparer.Equals(r.TargetCurrency.Code, "CZK")) + .ToList(); + + _log.LogDebug("CNB rates: parsed={Parsed}, returned={Returned}", all.Count, result.Count); + return result; + } + + private async Task DownloadWithRetryAsync(string url, CancellationToken ct) + { + const int maxAttempts = 3; + + for (var attempt = 1; attempt <= maxAttempts; attempt++) + { + try + { + using var req = new HttpRequestMessage(HttpMethod.Get, url); + + using var resp = await _http.SendAsync( + req, + HttpCompletionOption.ResponseHeadersRead, + ct) + .ConfigureAwait(false); + + if (IsTransient(resp.StatusCode) && attempt < maxAttempts) + { + var delay = Backoff(attempt); + _log.LogWarning("CNB request transient ({StatusCode}). Attempt {Attempt}/{Max}. Retrying in {Delay}ms.", + (int)resp.StatusCode, attempt, maxAttempts, (int)delay.TotalMilliseconds); + + await Task.Delay(delay, ct).ConfigureAwait(false); + continue; + } + + if (!resp.IsSuccessStatusCode) + { + throw new HttpRequestException( + $"CNB request failed: {(int)resp.StatusCode} {resp.ReasonPhrase}"); + } + + + return await resp.Content.ReadAsStringAsync(ct).ConfigureAwait(false); + } + catch (OperationCanceledException) when (ct.IsCancellationRequested) + { + // Caller canceled: don’t retry, just bubble. + throw; + } + catch (TaskCanceledException ex) when (attempt < maxAttempts) + { + // Timeout typically ends up here; treat as transient. + var delay = Backoff(attempt); + _log.LogWarning(ex, "CNB request timed out. Attempt {Attempt}/{Max}. Retrying in {Delay}ms.", + attempt, maxAttempts, (int)delay.TotalMilliseconds); + + await Task.Delay(delay, ct).ConfigureAwait(false); + } + catch (HttpRequestException ex) when (attempt < maxAttempts) + { + var delay = Backoff(attempt); + _log.LogWarning(ex, "CNB request failed (network). Attempt {Attempt}/{Max}. Retrying in {Delay}ms.", + attempt, maxAttempts, (int)delay.TotalMilliseconds); + + await Task.Delay(delay, ct).ConfigureAwait(false); + } + } + + throw new InvalidOperationException("Failed to download CNB exchange rates after multiple attempts."); + } + + private static bool IsTransient(HttpStatusCode code) + { + var n = (int)code; + return code == HttpStatusCode.TooManyRequests || (n >= 500 && n <= 599); + } + + private static TimeSpan Backoff(int attempt) + => TimeSpan.FromMilliseconds(250 * Math.Pow(2, attempt - 1)); + + + private List ParseCnbDailyTxt(string text) + { + if (string.IsNullOrWhiteSpace(text)) + throw new FormatException("Unexpected CNB response: empty body."); + + var lines = text.Split(new[] { "\r\n", "\n" }, StringSplitOptions.RemoveEmptyEntries); + + // CNB daily.txt typically has 2 header lines, then data rows. + if (lines.Length < 3) + throw new FormatException($"Unexpected CNB response format. Lines={lines.Length}."); + + var czk = GetCurrency("CZK"); + var result = new List(capacity: Math.Max(0, lines.Length - 2)); + + for (int i = 2; i < lines.Length; i++) + { + // Expected: Country|Currency|Amount|Code|Rate + var parts = lines[i].Split('|'); + if (parts.Length != 5) + { + _log.LogDebug("Skipping CNB line {Line}: unexpected field count ({Count}).", i + 1, parts.Length); + continue; + } + + if (!int.TryParse(parts[2], NumberStyles.Integer, CultureInfo.InvariantCulture, out var amount) || amount <= 0) + { + _log.LogDebug("Skipping CNB line {Line}: bad amount '{Amount}'.", i + 1, parts[2]); + continue; + } + + var code = (parts[3] ?? "").Trim(); + if (code.Length != 3) + { + _log.LogDebug("Skipping CNB line {Line}: bad code '{Code}'.", i + 1, code); + continue; + } + + if (!decimal.TryParse(parts[4], NumberStyles.Number, CultureInfo.InvariantCulture, out var rate) || rate <= 0m) + { + _log.LogDebug("Skipping CNB line {Line}: bad rate '{Rate}'.", i + 1, parts[4]); + continue; + } + + var target = GetCurrency(code); + + // CNB provides CZK per (amount) units => normalize to CZK per 1 unit + result.Add(new ExchangeRate( + sourceCurrency: czk, + targetCurrency: target, + value: rate / amount)); + } + + if (result.Count == 0) + _log.LogWarning("CNB response parsed successfully but produced 0 rates. Header: '{Header}'", lines[0]); + + return result; + } + + private Currency GetCurrency(string code) + => _currencyCache.GetOrAdd(code.Trim(), c => new Currency(c.Trim().ToUpperInvariant())); + } +} diff --git a/ExchangeRateUpdater/ExchangeRateUpdater/ExchangeRateUpdater.csproj b/ExchangeRateUpdater/ExchangeRateUpdater/ExchangeRateUpdater.csproj new file mode 100644 index 000000000..ca87ef7ce --- /dev/null +++ b/ExchangeRateUpdater/ExchangeRateUpdater/ExchangeRateUpdater.csproj @@ -0,0 +1,13 @@ + + + + net8.0 + enable + enable + + + + + + +