Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 0 additions & 19 deletions jobs/Backend/Task/ExchangeRateProvider.cs

This file was deleted.

8 changes: 0 additions & 8 deletions jobs/Backend/Task/ExchangeRateUpdater.csproj

This file was deleted.

28 changes: 27 additions & 1 deletion jobs/Backend/Task/ExchangeRateUpdater.sln
Original file line number Diff line number Diff line change
Expand Up @@ -3,18 +3,44 @@ 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}"
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ExchangeRateUpdater", "ExchangeRateUpdater\ExchangeRateUpdater.csproj", "{7B2695D6-D24C-4460-A58E-A10F08550CE0}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ExchangeRateUpdaterTests", "ExchangeRateUpdaterTests\ExchangeRateUpdaterTests.csproj", "{AADC1E9A-EE54-4BB2-B24D-8E048DD38F9A}"
EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
Debug|x64 = Debug|x64
Debug|x86 = Debug|x86
Release|Any CPU = Release|Any CPU
Release|x64 = Release|x64
Release|x86 = Release|x86
EndGlobalSection
GlobalSection(ProjectConfigurationPlatforms) = postSolution
{7B2695D6-D24C-4460-A58E-A10F08550CE0}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{7B2695D6-D24C-4460-A58E-A10F08550CE0}.Debug|Any CPU.Build.0 = Debug|Any CPU
{7B2695D6-D24C-4460-A58E-A10F08550CE0}.Debug|x64.ActiveCfg = Debug|Any CPU
{7B2695D6-D24C-4460-A58E-A10F08550CE0}.Debug|x64.Build.0 = Debug|Any CPU
{7B2695D6-D24C-4460-A58E-A10F08550CE0}.Debug|x86.ActiveCfg = Debug|Any CPU
{7B2695D6-D24C-4460-A58E-A10F08550CE0}.Debug|x86.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
{7B2695D6-D24C-4460-A58E-A10F08550CE0}.Release|x64.ActiveCfg = Release|Any CPU
{7B2695D6-D24C-4460-A58E-A10F08550CE0}.Release|x64.Build.0 = Release|Any CPU
{7B2695D6-D24C-4460-A58E-A10F08550CE0}.Release|x86.ActiveCfg = Release|Any CPU
{7B2695D6-D24C-4460-A58E-A10F08550CE0}.Release|x86.Build.0 = Release|Any CPU
{AADC1E9A-EE54-4BB2-B24D-8E048DD38F9A}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{AADC1E9A-EE54-4BB2-B24D-8E048DD38F9A}.Debug|Any CPU.Build.0 = Debug|Any CPU
{AADC1E9A-EE54-4BB2-B24D-8E048DD38F9A}.Debug|x64.ActiveCfg = Debug|Any CPU
{AADC1E9A-EE54-4BB2-B24D-8E048DD38F9A}.Debug|x64.Build.0 = Debug|Any CPU
{AADC1E9A-EE54-4BB2-B24D-8E048DD38F9A}.Debug|x86.ActiveCfg = Debug|Any CPU
{AADC1E9A-EE54-4BB2-B24D-8E048DD38F9A}.Debug|x86.Build.0 = Debug|Any CPU
{AADC1E9A-EE54-4BB2-B24D-8E048DD38F9A}.Release|Any CPU.ActiveCfg = Release|Any CPU
{AADC1E9A-EE54-4BB2-B24D-8E048DD38F9A}.Release|Any CPU.Build.0 = Release|Any CPU
{AADC1E9A-EE54-4BB2-B24D-8E048DD38F9A}.Release|x64.ActiveCfg = Release|Any CPU
{AADC1E9A-EE54-4BB2-B24D-8E048DD38F9A}.Release|x64.Build.0 = Release|Any CPU
{AADC1E9A-EE54-4BB2-B24D-8E048DD38F9A}.Release|x86.ActiveCfg = Release|Any CPU
{AADC1E9A-EE54-4BB2-B24D-8E048DD38F9A}.Release|x86.Build.0 = Release|Any CPU
EndGlobalSection
GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
using System;

namespace ExchangeRateUpdater.Contracts;

public interface IDateTimeService
{
DateTime GetUtcNow();

DateTime GetNow();

DateTime GetToday();
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
using System;
using System.Collections.Generic;
using System.Threading.Tasks;
using ExchangeRateUpdater.Models;

namespace ExchangeRateUpdater.Contracts;

public interface IExchangeRateLoader
{
IRateRefreshScheduler RateRefreshSchedule { get; }
Task<IEnumerable<ExchangeRate>> GetExchangeRatesAsync(IEnumerable<string> currencies, DateTime date);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
using System;

namespace ExchangeRateUpdater.Contracts;

public interface IRateRefreshScheduler
{
DateTimeOffset GetNextRefreshTime();
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.Globalization;
using System.Linq;
using System.Net.Http;
using System.Text.Json;
using System.Threading.Tasks;
using ExchangeRateUpdater.Contracts;
using ExchangeRateUpdater.DataSource.Cnb.Dto;
using ExchangeRateUpdater.DataSources;
using ExchangeRateUpdater.Infrastructure;
using ExchangeRateUpdater.Infrastructure.Config;
using ExchangeRateUpdater.Models;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;

namespace ExchangeRateUpdater.DataSource.Cnb;

internal class CnbExchangeRateLoader : IExchangeRateLoader
{
private const string DailyExchangeRateUrlPath = "/cnbapi/exrates/daily?date={0}&lang=EN";
private readonly IRequestHandler requestHandler;
private readonly CnbRateConverter converter;
private readonly ILogger<CnbExchangeRateLoader> logger;
private readonly IDateTimeService dateTimeService;
private readonly Uri baseApiUri;
private readonly JsonSerializerOptions serializerOptions = new JsonSerializerOptions(JsonSerializerDefaults.Web);

public CnbExchangeRateLoader(IRequestHandler requestHandler,
CnbRateConverter converter,
ILogger<CnbExchangeRateLoader> logger,
IRefreshScheduleFactory refreshScheduleFactory,
IDateTimeService dateTimeService,
[FromKeyedServices(Constants.CnbServiceKey)] ExchangeRateLoaderConfig config)
{
this.requestHandler = requestHandler;
this.converter = converter;
this.logger = logger;
this.dateTimeService = dateTimeService;
baseApiUri = new Uri(config.BaseApiUrl);
RateRefreshSchedule = refreshScheduleFactory.CreateRefreshSchedule(config.RefreshScheduleConfig);
logger.LogInformation("Initialized data source with URL {} and refresh schedule: {}", baseApiUri, RateRefreshSchedule);
}

public IRateRefreshScheduler RateRefreshSchedule { get; }

// discussable: can consider adding "retry" logic inside this method. In this case the method will need CancellationToken parameter to stop pending retries.
public async Task<IEnumerable<ExchangeRate>> GetExchangeRatesAsync(IEnumerable<string> currencies, DateTime date)
{
ArgumentNullException.ThrowIfNull(currencies);

if (date >= dateTimeService.GetToday().AddDays(1))
throw new ArgumentException("The date should not be in the future", nameof(date));

if (!currencies.Any())
{
return [];
}

try
{
var dateParam = date.ToString("yyyy-MM-dd", CultureInfo.InvariantCulture);
var requestUri = new Uri(baseApiUri, string.Format(DailyExchangeRateUrlPath, dateParam)).ToString();

var stopwatch = new Stopwatch();
stopwatch.Start();

using var stream = await requestHandler.GetStreamAsync(requestUri);

CnbRateResponse response = await JsonSerializer.DeserializeAsync<CnbRateResponse>(stream, serializerOptions);

stopwatch.Stop(); // Further the elapsed time can be put into metrics
logger.LogInformation("Loaded {} rates in {} ms", response.Rates.Length, stopwatch.ElapsedMilliseconds);

var requestedCurrencies = new HashSet<string>(currencies);
var result = response.Rates
.Where(r => requestedCurrencies.Contains(r.CurrencyCode))
.Select(converter.Convert)
.Where(r => r != null)
.ToList();

return result;
}
catch (HttpRequestException ex)
{
throw new RateLoaderException($"API responded with error status code: {ex.StatusCode}", ex);
}
catch (Exception ex)
{
throw new RateLoaderException("Unable load exchange rates from CNB", ex);
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
using System;
using ExchangeRateUpdater.DataSource.Cnb.Dto;
using ExchangeRateUpdater.Models;
using Microsoft.Extensions.Logging;

namespace ExchangeRateUpdater.DataSource.Cnb;

internal class CnbRateConverter
{
private readonly ILogger<CnbRateConverter> logger;

public CnbRateConverter( ILogger<CnbRateConverter> logger)
{
this.logger = logger;
}

public ExchangeRate Convert(CnbRate rate)
{
IsoCurrencyCode sourceCurrency;
// poor man validation
if (!Enum.TryParse(rate.CurrencyCode, out sourceCurrency) || rate.Amount <= 0)
{
logger.LogWarning("Invalid rate for {} received from CNB, ignoring", rate.CurrencyCode);
return null;
}

return new ExchangeRate(new Currency(sourceCurrency), new Currency(IsoCurrencyCode.CZK), rate.Rate / rate.Amount);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
using System;
using System.Text.Json.Serialization;

namespace ExchangeRateUpdater.DataSource.Cnb.Dto;

internal sealed record CnbRate
{
[JsonPropertyName("amount")]
public long Amount { get; init; }

[JsonPropertyName("country")]
public string Country { get; init; }

[JsonPropertyName("currency")]
public string Currency { get; init; }

[JsonPropertyName("currencyCode")]
public string CurrencyCode { get; init; }

[JsonPropertyName("order")]
public int Order { get; init; }

[JsonPropertyName("rate")]
public Decimal Rate { get; init; }

[JsonPropertyName("validFor")]
public string ValidFor { get; init; }
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
using System.Text.Json.Serialization;

namespace ExchangeRateUpdater.DataSource.Cnb.Dto;

internal sealed record CnbRateResponse
{
[JsonPropertyName("rates")]
public CnbRate[] Rates { get; init; }
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
using System;

namespace ExchangeRateUpdater.DataSources;

public class RateLoaderException(string message, Exception inner) : Exception(message, inner)
{
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
using System;
using ExchangeRateUpdater.Contracts;

namespace ExchangeRateUpdater.DataSources.RefreshSchedule;

internal class DefaultRateRefreshScheduler : IRateRefreshScheduler
{
private readonly TimeOnly time;
private readonly TimeZoneInfo timeZone;
private readonly IDateTimeService dateTimeService;

public DefaultRateRefreshScheduler(TimeOnly time, TimeZoneInfo timeZone, IDateTimeService dateTimeService)
{
this.time = time;
this.timeZone = timeZone;
this.dateTimeService = dateTimeService;
}

public DateTimeOffset GetNextRefreshTime()
{
var refreshTimeForCurrentDateInUtc =
TimeZoneInfo.ConvertTimeToUtc(new DateTime(DateOnly.FromDateTime(dateTimeService.GetToday()), time, DateTimeKind.Unspecified), timeZone);

DateTime nextRefreshDate = dateTimeService.GetUtcNow() < refreshTimeForCurrentDateInUtc ?
refreshTimeForCurrentDateInUtc :
refreshTimeForCurrentDateInUtc.AddDays(1);

return nextRefreshDate;
}

public override string ToString() => $"{time:t} {timeZone.StandardName}";
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
using System;
using ExchangeRateUpdater.Contracts;

namespace ExchangeRateUpdater.DataSources.RefreshSchedule;

internal sealed class NoRefreshScheduler : IRateRefreshScheduler
{
public DateTimeOffset GetNextRefreshTime() => DateTimeOffset.MaxValue;

public override string ToString() => "No refresh";
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
using System;
using ExchangeRateUpdater.Contracts;
using ExchangeRateUpdater.Infrastructure;
using ExchangeRateUpdater.Infrastructure.Config;

namespace ExchangeRateUpdater.DataSources.RefreshSchedule;

internal class RefreshScheduleFactory : IRefreshScheduleFactory
{
private readonly IDateTimeService dateTimeService;

public RefreshScheduleFactory(IDateTimeService dateTimeService)
{
this.dateTimeService = dateTimeService;
}

public IRateRefreshScheduler CreateRefreshSchedule(RefreshScheduleConfig config)
{
return config == null
? new NoRefreshScheduler()
: new DefaultRateRefreshScheduler(
TimeOnly.Parse(config.Time),
TimeZoneInfo.FindSystemTimeZoneById(config.TimeZone),
dateTimeService);

}
}
Loading