diff --git a/.gitignore b/.gitignore index fd35865456..ab49431912 100644 --- a/.gitignore +++ b/.gitignore @@ -21,3 +21,4 @@ node_modules bower_components npm-debug.log +/.vs diff --git a/jobs/Backend/ExchangeRateUpdater.Test/CurrencyTest.cs b/jobs/Backend/ExchangeRateUpdater.Test/CurrencyTest.cs new file mode 100644 index 0000000000..24db06d3f8 --- /dev/null +++ b/jobs/Backend/ExchangeRateUpdater.Test/CurrencyTest.cs @@ -0,0 +1,27 @@ +namespace ExchangeRateUpdater.Test; + +public class CurrencyTest +{ + [Fact] + public void Currency_RecordValueComparison_ReturnsTrueForEqualCurrencies() + { + var symbol = "USD"; + var currency1 = new Currency(symbol); + var currency2 = new Currency(symbol); + + var result = currency1.Equals(currency2); + + Assert.True(result); + } + + [Fact] + public void Currency_RecordValueComparison_ReturnsFalseForDifferentCurrencies() + { + var currency1 = new Currency("USD"); + var currency2 = new Currency("EUR"); + + var result = currency1.Equals(currency2); + + Assert.False(result); + } +} \ No newline at end of file diff --git a/jobs/Backend/ExchangeRateUpdater.Test/ExchangeRateProviderTest.cs b/jobs/Backend/ExchangeRateUpdater.Test/ExchangeRateProviderTest.cs new file mode 100644 index 0000000000..2053e0bae2 --- /dev/null +++ b/jobs/Backend/ExchangeRateUpdater.Test/ExchangeRateProviderTest.cs @@ -0,0 +1,107 @@ +using ExchangeRateUpdater.Contracts; +using ExchangeRateUpdater.Providers.CNB; +using Microsoft.Extensions.Caching.Memory; +using Microsoft.Extensions.Options; +using Moq; + +namespace ExchangeRateUpdater.Test; + +public class ExchangeRateProviderTest +{ + [Fact] + public async Task GetExchangeRatesAsync_UsesCache_WhenCalledTwice() + { + // Arrange + var targetDate = DateOnly.FromDateTime(DateTime.UtcNow); + var expectedCacheKey = $"{nameof(CNBCurrencyExchangeProvider)}_{targetDate:yyyy-MM-dd}"; + + var options = Options.Create(new CNBExchangeRateProviderOptions + { + Name = "CNB", + BaseUrl = "https://api.cnb.cz", + CacheAbsoluteExpiration = TimeSpan.FromMinutes(10), + CacheSlidingExpiration = TimeSpan.FromMinutes(2) + }); + + var apiRates = new List + { + new(new Currency("EUR"), new Currency("CZK"), 25.10m), + new(new Currency("USD"), new Currency("CZK"), 23.40m) + }; + + var clientMock = new Mock(MockBehavior.Strict); + clientMock + .Setup(x => x.FetchExchangeRatesAsync( + It.Is(d => d == targetDate), + It.IsAny())) + .ReturnsAsync(apiRates); + + var cacheMock = new Mock(MockBehavior.Strict); + + var callCount = 0; + + object? cachedObject = null; + + cacheMock + .Setup(c => c.TryGetValue(It.IsAny(), out cachedObject)) + .Returns((object key, out object? value) => + { + Assert.Equal(expectedCacheKey, key); + + callCount++; + + if (callCount == 1) + { + value = null; + return false; + } + + value = apiRates; + return true; + }); + + // Because _cache.Set(...) is an extension method, it calls CreateEntry internally. + var entryMock = new Mock(); + + cacheMock + .Setup(c => c.CreateEntry(It.Is(k => k.Equals(expectedCacheKey)))) + .Returns(entryMock.Object); + + // The Set extension will set Value and Expiration properties and then dispose the entry. + entryMock.SetupAllProperties(); + entryMock.Setup(e => e.Dispose()); + + var sut = new CNBCurrencyExchangeProvider(clientMock.Object, cacheMock.Object, options); + + var requestedCurrencies = new[] { new Currency("EUR") }; + + // Act + + // cache miss + var first = (await sut.GetExchangeRatesAsync(requestedCurrencies)).ToList(); + + // cache hit + var second = (await sut.GetExchangeRatesAsync(requestedCurrencies)).ToList(); + + Assert.Single(first); + Assert.Single(second); + Assert.Equal("EUR", first[0].SourceCurrency.Code); + Assert.Equal("EUR", second[0].SourceCurrency.Code); + + // Assert + + // client called only once + clientMock.Verify(x => x.FetchExchangeRatesAsync( + It.Is(d => d == targetDate), + It.IsAny()), + Times.Once); + + // cache was written only once + cacheMock.Verify(c => c.CreateEntry(It.Is(k => k.Equals(expectedCacheKey))), Times.Once); + entryMock.VerifySet(e => e.Value = It.IsAny(), Times.Once); + entryMock.Verify(e => e.Dispose(), Times.Once); + + // TryGetValue called twice + cacheMock.Verify(c => c.TryGetValue(It.IsAny(), out cachedObject), Times.Exactly(2)); + } +} diff --git a/jobs/Backend/ExchangeRateUpdater.Test/ExchangeRateUpdater.Test.csproj b/jobs/Backend/ExchangeRateUpdater.Test/ExchangeRateUpdater.Test.csproj new file mode 100644 index 0000000000..89d913f342 --- /dev/null +++ b/jobs/Backend/ExchangeRateUpdater.Test/ExchangeRateUpdater.Test.csproj @@ -0,0 +1,28 @@ + + + + net8.0 + enable + enable + + false + true + + + + + + + + + + + + + + + + + + + diff --git a/jobs/Backend/Task/.gitignore b/jobs/Backend/Task/.gitignore new file mode 100644 index 0000000000..b962e89c7d --- /dev/null +++ b/jobs/Backend/Task/.gitignore @@ -0,0 +1,21 @@ +!.gitkeep +!.gitignore +!*.dll +[Oo]bj +[Bb]in +*.user +*.suo +*.[Cc]ache +*.bak +*.ncb +*.DS_Store +*.userprefs +*.iml +*.ncrunch* +.*crunch*.local.xml +.idea +[Tt]humbs.db +*.tgz +*.sublime-* + +.vs/ \ No newline at end of file diff --git a/jobs/Backend/Task/Constants.cs b/jobs/Backend/Task/Constants.cs new file mode 100644 index 0000000000..3975088692 --- /dev/null +++ b/jobs/Backend/Task/Constants.cs @@ -0,0 +1,6 @@ +namespace ExchangeRateUpdater; + +public static class Constants +{ + public const string CNBExchangeRateProviderName = "CNBExchangeRateProvider"; +} diff --git a/jobs/Backend/Task/Contracts/ICNBExchangeRateApiClient.cs b/jobs/Backend/Task/Contracts/ICNBExchangeRateApiClient.cs new file mode 100644 index 0000000000..7ab49dee51 --- /dev/null +++ b/jobs/Backend/Task/Contracts/ICNBExchangeRateApiClient.cs @@ -0,0 +1,11 @@ +using System; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; + +namespace ExchangeRateUpdater.Contracts; + +public interface ICNBExchangeRateApiClient +{ + Task> FetchExchangeRatesAsync(DateOnly date, CancellationToken ct = default); +} diff --git a/jobs/Backend/Task/Contracts/IExchangeRateProvider.cs b/jobs/Backend/Task/Contracts/IExchangeRateProvider.cs new file mode 100644 index 0000000000..3bcd87262f --- /dev/null +++ b/jobs/Backend/Task/Contracts/IExchangeRateProvider.cs @@ -0,0 +1,21 @@ +using System; +using System.Collections.Generic; +using System.Threading.Tasks; + +namespace ExchangeRateUpdater.Contracts; + +public interface IExchangeRateProvider +{ + /// + /// Returns exchange rates among the specified currencies that are defined by the source. But only those defined + /// by the source. It does not return calculated exchange rates. E.g. if the source contains "CZK/USD" but not "USD/CZK", + /// it does not return exchange rate "USD/CZK" with value calculated as 1 / "CZK/USD". If the source does not provide + /// some of the currencies, it will be ignored. + /// + /// Source currencies whose exchange rate is needed. + /// The date for which exchange rates are requested (null for the latest rates). + /// + /// Returns a collection of ExchangeRate objects containing the requested information. + /// + Task> GetExchangeRatesAsync(IEnumerable currencies); +} diff --git a/jobs/Backend/Task/Currency.cs b/jobs/Backend/Task/Currency.cs index f375776f25..7314b7a6e2 100644 --- a/jobs/Backend/Task/Currency.cs +++ b/jobs/Backend/Task/Currency.cs @@ -1,20 +1,19 @@ -namespace ExchangeRateUpdater +namespace ExchangeRateUpdater; + +public sealed record Currency { - public class Currency + public Currency(string code) { - public Currency(string code) - { - Code = code; - } + Code = code.ToUpperInvariant(); + } - /// - /// Three-letter ISO 4217 code of the currency. - /// - public string Code { get; } + /// + /// Three-letter ISO 4217 code of the currency. + /// + public string Code { get; } - public override string ToString() - { - return Code; - } + public override string ToString() + { + return Code; } } diff --git a/jobs/Backend/Task/Exceptions/ExchangeRateProviderException.cs b/jobs/Backend/Task/Exceptions/ExchangeRateProviderException.cs new file mode 100644 index 0000000000..7cbe6544f3 --- /dev/null +++ b/jobs/Backend/Task/Exceptions/ExchangeRateProviderException.cs @@ -0,0 +1,16 @@ +using System; + +namespace ExchangeRateUpdater.Exceptions; + +public sealed class ExchangeRateProviderException : Exception +{ + public ExchangeRateProviderException(string message) + : base(message) + { + } + + public ExchangeRateProviderException(string message, Exception innerException) + : base(message, innerException) + { + } +} diff --git a/jobs/Backend/Task/ExchangeRate.cs b/jobs/Backend/Task/ExchangeRate.cs index 58c5bb10e0..dc05cab458 100644 --- a/jobs/Backend/Task/ExchangeRate.cs +++ b/jobs/Backend/Task/ExchangeRate.cs @@ -1,23 +1,22 @@ -namespace ExchangeRateUpdater +namespace ExchangeRateUpdater; + +public class ExchangeRate { - public class ExchangeRate + public ExchangeRate(Currency sourceCurrency, Currency targetCurrency, decimal value) { - public ExchangeRate(Currency sourceCurrency, Currency targetCurrency, decimal value) - { - SourceCurrency = sourceCurrency; - TargetCurrency = targetCurrency; - Value = value; - } + SourceCurrency = sourceCurrency; + TargetCurrency = targetCurrency; + Value = value; + } - public Currency SourceCurrency { get; } + public Currency SourceCurrency { get; } - public Currency TargetCurrency { get; } + public Currency TargetCurrency { get; } - public decimal Value { get; } + public decimal Value { get; } - public override string ToString() - { - return $"{SourceCurrency}/{TargetCurrency}={Value}"; - } + public override string ToString() + { + return $"{SourceCurrency}/{TargetCurrency}={Value}"; } } diff --git a/jobs/Backend/Task/ExchangeRateProvider.cs b/jobs/Backend/Task/ExchangeRateProvider.cs deleted file mode 100644 index 6f82a97fbe..0000000000 --- a/jobs/Backend/Task/ExchangeRateProvider.cs +++ /dev/null @@ -1,19 +0,0 @@ -using System.Collections.Generic; -using System.Linq; - -namespace ExchangeRateUpdater -{ - public class ExchangeRateProvider - { - /// - /// Should return exchange rates among the specified currencies that are defined by the source. But only those defined - /// by the source, do not return calculated exchange rates. E.g. if the source contains "CZK/USD" but not "USD/CZK", - /// do not return exchange rate "USD/CZK" with value calculated as 1 / "CZK/USD". If the source does not provide - /// some of the currencies, ignore them. - /// - public IEnumerable GetExchangeRates(IEnumerable currencies) - { - return Enumerable.Empty(); - } - } -} diff --git a/jobs/Backend/Task/ExchangeRateUpdater.csproj b/jobs/Backend/Task/ExchangeRateUpdater.csproj index 2fc654a12b..bf0e91ab3f 100644 --- a/jobs/Backend/Task/ExchangeRateUpdater.csproj +++ b/jobs/Backend/Task/ExchangeRateUpdater.csproj @@ -2,7 +2,25 @@ Exe - net6.0 + net8.0 + + + + + + + + + + + + + + + Always + + + \ No newline at end of file diff --git a/jobs/Backend/Task/ExchangeRateUpdater.sln b/jobs/Backend/Task/ExchangeRateUpdater.sln index 89be84daff..4dbbd2df85 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.36429.23 d17.14 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.Test", "..\ExchangeRateUpdater.Test\ExchangeRateUpdater.Test.csproj", "{4F7B57E4-E166-4E4B-8EE8-3C7EF0729606}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -15,6 +17,10 @@ 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 + {4F7B57E4-E166-4E4B-8EE8-3C7EF0729606}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {4F7B57E4-E166-4E4B-8EE8-3C7EF0729606}.Debug|Any CPU.Build.0 = Debug|Any CPU + {4F7B57E4-E166-4E4B-8EE8-3C7EF0729606}.Release|Any CPU.ActiveCfg = Release|Any CPU + {4F7B57E4-E166-4E4B-8EE8-3C7EF0729606}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE diff --git a/jobs/Backend/Task/Program.cs b/jobs/Backend/Task/Program.cs index 379a69b1f8..36a1024ad5 100644 --- a/jobs/Backend/Task/Program.cs +++ b/jobs/Backend/Task/Program.cs @@ -1,6 +1,12 @@ -using System; +using ExchangeRateUpdater.Contracts; +using ExchangeRateUpdater.Providers.CNB; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using System; using System.Collections.Generic; +using System.IO; using System.Linq; +using System.Threading.Tasks; namespace ExchangeRateUpdater { @@ -19,12 +25,15 @@ public static class Program new Currency("XYZ") }; - public static void Main(string[] args) + public static async Task Main(string[] args) { + var serviceProvider = ServiceProviderConfiguration(); + try { - var provider = new ExchangeRateProvider(); - var rates = provider.GetExchangeRates(currencies); + var provider = serviceProvider.GetRequiredKeyedService(Constants.CNBExchangeRateProviderName); + + var rates = await provider.GetExchangeRatesAsync(currencies); Console.WriteLine($"Successfully retrieved {rates.Count()} exchange rates:"); foreach (var rate in rates) @@ -39,5 +48,21 @@ public static void Main(string[] args) Console.ReadLine(); } + + private static ServiceProvider ServiceProviderConfiguration() + { + var configuration = new ConfigurationBuilder() + .SetBasePath(Directory.GetCurrentDirectory()) + .AddJsonFile("appsettings.json", optional: false) + .Build(); + + var services = new ServiceCollection(); + + services.AddSingleton(configuration); + + services.AddCNBExchangeRateProvider(configuration); + + return services.BuildServiceProvider(); + } } } diff --git a/jobs/Backend/Task/Providers/CNB/CNBCurrencyExchangeApiClient.cs b/jobs/Backend/Task/Providers/CNB/CNBCurrencyExchangeApiClient.cs new file mode 100644 index 0000000000..78ad9c59c6 --- /dev/null +++ b/jobs/Backend/Task/Providers/CNB/CNBCurrencyExchangeApiClient.cs @@ -0,0 +1,59 @@ +using ExchangeRateUpdater.Contracts; +using ExchangeRateUpdater.Exceptions; +using ExchangeRateUpdater.Providers.CNB.Models; +using Microsoft.Extensions.Logging; +using System; +using System.Collections.Generic; +using System.Linq; +using System.Net.Http; +using System.Text.Json; +using System.Threading; +using System.Threading.Tasks; + +namespace ExchangeRateUpdater.Providers.CNB; + + +internal class CNBExchangeRateApiClient : ICNBExchangeRateApiClient +{ + private static readonly Currency _destinationCurrency = new("CZK"); + private static readonly JsonSerializerOptions _jsonSerializerOptions = new() { PropertyNameCaseInsensitive = true }; + private readonly HttpClient _httpClient; + private readonly ILogger _logger; + + public CNBExchangeRateApiClient(HttpClient httpClient, ILogger logger) + { + _httpClient = httpClient; + _logger = logger; + } + + public async Task> FetchExchangeRatesAsync(DateOnly date, CancellationToken ct = default) + { + try + { + var response = await _httpClient.GetAsync($"/cnbapi/exrates/daily?date={date:yyyy-MM-dd}&lang=EN", ct); + + response.EnsureSuccessStatusCode(); + + var responseContent = await response.Content.ReadAsStringAsync(ct); + + var cnbExchangeRates = JsonSerializer.Deserialize(responseContent, _jsonSerializerOptions) + ?? throw new ExchangeRateProviderException("Error getting exchange rates from source, bad expected data"); + + return cnbExchangeRates.Rates.Select(x => + new ExchangeRate( + sourceCurrency: new Currency(x.CurrencyCode), + targetCurrency: _destinationCurrency, + value: x.Rate)); + } + catch (HttpRequestException ex) + { + _logger.LogError(ex, "HTTP error fetching exchange rates from CNB for {Date}", date); + throw new ExchangeRateProviderException("Failed to fetch exchange rates from CNB", ex); + } + catch (JsonException ex) + { + _logger.LogError(ex, "Error deserializing CNB response for {Date}", date); + throw new ExchangeRateProviderException("Invalid response format from CNB", ex); + } + } +} diff --git a/jobs/Backend/Task/Providers/CNB/CNBCurrencyExchangeProvider.cs b/jobs/Backend/Task/Providers/CNB/CNBCurrencyExchangeProvider.cs new file mode 100644 index 0000000000..e9b90a4217 --- /dev/null +++ b/jobs/Backend/Task/Providers/CNB/CNBCurrencyExchangeProvider.cs @@ -0,0 +1,48 @@ +using ExchangeRateUpdater.Contracts; +using Microsoft.Extensions.Caching.Memory; +using Microsoft.Extensions.Options; +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; + +namespace ExchangeRateUpdater.Providers.CNB; +public class CNBCurrencyExchangeProvider : IExchangeRateProvider +{ + private readonly ICNBExchangeRateApiClient _client; + private readonly IMemoryCache _cache; + private readonly MemoryCacheEntryOptions _cacheOptions; + private readonly string _cacheKeyPrefix; + + public CNBCurrencyExchangeProvider( + ICNBExchangeRateApiClient client, + IMemoryCache cache, + IOptions options) + { + _client = client; + _cache = cache; + _cacheKeyPrefix = nameof(CNBCurrencyExchangeProvider); + _cacheOptions = new MemoryCacheEntryOptions + { + AbsoluteExpirationRelativeToNow = options.Value.CacheAbsoluteExpiration, + SlidingExpiration = options.Value.CacheSlidingExpiration + }; + } + + public async Task> GetExchangeRatesAsync(IEnumerable currencies) + { + var targetDate = DateOnly.FromDateTime(DateTime.UtcNow); + var cacheKey = $"{_cacheKeyPrefix}_{targetDate:yyyy-MM-dd}"; + + if (!_cache.TryGetValue>(cacheKey, out var cachedRates)) + { + cachedRates = (await _client.FetchExchangeRatesAsync(targetDate)).ToList(); + + _cache.Set(cacheKey, cachedRates, _cacheOptions); + } + + var sourceCurrencies = currencies.ToHashSet(); + + return cachedRates.Where(x => sourceCurrencies.Contains(x.SourceCurrency)); + } +} \ No newline at end of file diff --git a/jobs/Backend/Task/Providers/CNB/Configuration.cs b/jobs/Backend/Task/Providers/CNB/Configuration.cs new file mode 100644 index 0000000000..0df993cb1b --- /dev/null +++ b/jobs/Backend/Task/Providers/CNB/Configuration.cs @@ -0,0 +1,66 @@ +using ExchangeRateUpdater.Contracts; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Options; +using Polly; +using System; + +namespace ExchangeRateUpdater.Providers.CNB; + +public static class CNBServiceCollectionExtensions +{ + public static IServiceCollection AddCNBExchangeRateProvider(this IServiceCollection services, IConfiguration configuration) + { + services.AddOptions() + .Bind(configuration.GetSection("CNBExchangeRateProvider")) + .ValidateOnStart(); + + services.PostConfigure(opt => + { + if (!Uri.TryCreate(opt.BaseUrl, UriKind.Absolute, out _)) + throw new InvalidOperationException("CNB BaseUrl is not a valid absolute URI."); + }); + + services.AddMemoryCache(); + + services.AddScoped(); + + services.AddKeyedScoped(Constants.CNBExchangeRateProviderName); + + services.AddHttpClient() + .ConfigureHttpClient((serviceProvider, client) => + { + var options = serviceProvider + .GetRequiredService>() + .Value; + + client.BaseAddress = new Uri(options.BaseUrl); + }) + .AddStandardResilienceHandler(options => + { + options.Retry.MaxRetryAttempts = 3; + options.Retry.Delay = TimeSpan.FromSeconds(2); + options.Retry.BackoffType = DelayBackoffType.Exponential; + options.Retry.UseJitter = true; + + options.CircuitBreaker.SamplingDuration = TimeSpan.FromSeconds(30); + options.CircuitBreaker.FailureRatio = 0.5; + options.CircuitBreaker.MinimumThroughput = 10; + options.CircuitBreaker.BreakDuration = TimeSpan.FromSeconds(30); + + options.AttemptTimeout.Timeout = TimeSpan.FromSeconds(10); + options.TotalRequestTimeout.Timeout = TimeSpan.FromSeconds(60); + }); + + return services; + } +} + +public sealed record CNBExchangeRateProviderOptions +{ + public required string Name { get; init; } + public required string BaseUrl { get; init; } + public TimeSpan CacheAbsoluteExpiration { get; init; } = TimeSpan.FromHours(1); + public TimeSpan CacheSlidingExpiration { get; init; } = TimeSpan.FromMinutes(30); +} + diff --git a/jobs/Backend/Task/Providers/CNB/Models/CNBCurrencyExchangeProviderModels.cs b/jobs/Backend/Task/Providers/CNB/Models/CNBCurrencyExchangeProviderModels.cs new file mode 100644 index 0000000000..09acaecd9a --- /dev/null +++ b/jobs/Backend/Task/Providers/CNB/Models/CNBCurrencyExchangeProviderModels.cs @@ -0,0 +1,17 @@ +using System; +using System.Collections.Generic; + +namespace ExchangeRateUpdater.Providers.CNB.Models; + +internal sealed record CNBExchangeRateInformation +{ + public DateOnly ValidFor { get; init; } + public required string CurrencyCode { get; init; } + public required decimal Rate { get; init; } +} + +internal sealed record CNBExchangeRateResponse +{ + public IEnumerable Rates { get; init; } = []; +} + diff --git a/jobs/Backend/Task/appsettings.json b/jobs/Backend/Task/appsettings.json new file mode 100644 index 0000000000..0bf73b0107 --- /dev/null +++ b/jobs/Backend/Task/appsettings.json @@ -0,0 +1,8 @@ +{ + "CNBExchangeRateProvider": { + "Name": "CNB", + "BaseUrl": "https://api.cnb.cz", + "CacheAbsoluteExpiration": "01:00:00", + "CacheSlidingExpiration": "00:30:00" + } +} \ No newline at end of file