Skip to content

Exchange rate provider - Diego Fernandez Pandiello #726

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 16 commits into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
16 commits
Select commit Hold shift + click to select a range
4aac15d
chore: update target framework to .NET 9.0 and add .vs to .gitignore
diego-pandiello May 17, 2025
497774a
refactor: change Currency class to record and improve code validation
diego-pandiello May 17, 2025
59bc02d
docs: add README for Exchange Rate Updater project
diego-pandiello May 17, 2025
f313324
feat: implement CNBExchangeRateProvider and CNBOptions with dependenc…
diego-pandiello May 17, 2025
930ccf7
feat: refactor ExchangeRateProvider to use dependency injection and a…
diego-pandiello May 17, 2025
2ad84c8
feat: add IExchangeRateProvider interface for exchange rate retrieval
diego-pandiello May 17, 2025
b2fd90a
refactor: simplify ExchangeRate class constructor and properties
diego-pandiello May 17, 2025
6b0b97a
chore(config): update appsettings.json configuration
diego-pandiello May 17, 2025
ce842dd
refactor(options): change CNBOptions to a mutable class
diego-pandiello May 17, 2025
8213579
feat(provider): enhance CNB exchange rate fetching
diego-pandiello May 17, 2025
2b56761
feat(parser): add exchange rate parser and refactor provider
diego-pandiello May 17, 2025
e9c9eec
feat(domain): add currency and exchange rate models to domain folder
diego-pandiello May 18, 2025
4b6f5fb
feat(observability): add Metrics class and service registration
diego-pandiello May 18, 2025
d9e889d
refactor(core): improve currency handling and exchange rate fetching.
diego-pandiello May 18, 2025
7233db5
feat(tests): add unit tests for exchange rate functionality
diego-pandiello May 18, 2025
be491d7
docs: update README.md with new features and structure
diego-pandiello May 18, 2025
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
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -21,3 +21,5 @@
node_modules
bower_components
npm-debug.log
.vs/
**/.vs/
20 changes: 0 additions & 20 deletions jobs/Backend/Task/Currency.cs

This file was deleted.

24 changes: 24 additions & 0 deletions jobs/Backend/Task/Domain/Currency.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
using System;

namespace ExchangeRateUpdater.Domain
{
public record Currency
{
public string Code { get; }

public Currency(string code)
{
if (string.IsNullOrWhiteSpace(code))
{
throw new ArgumentException("Currency code cannot be null or empty.", nameof(code));
}

Code = code.Trim().ToUpperInvariant();
}

public override string ToString()
{
return Code;
}
}
}
10 changes: 10 additions & 0 deletions jobs/Backend/Task/Domain/ExchangeRate.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
namespace ExchangeRateUpdater.Domain
{
public record ExchangeRate(Currency SourceCurrency, Currency TargetCurrency, decimal Value)
{
public override string ToString()
{
return $"{SourceCurrency}/{TargetCurrency}={Value}";
}
}
}
25 changes: 25 additions & 0 deletions jobs/Backend/Task/Domain/ExchangeRateProvider.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;

namespace ExchangeRateUpdater.Domain
{
internal sealed class ExchangeRateProvider(IExchangeRateFetcher exchangeRateFetcher)
{
private readonly IExchangeRateFetcher exchangeRateFetcher = exchangeRateFetcher;

/// <summary>
/// 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.
/// </summary>
public async Task<IEnumerable<ExchangeRate>> GetExchangeRates(IEnumerable<Currency> currencies)
{
var currenciesSet = new HashSet<Currency>(currencies);

var rates = await exchangeRateFetcher.GetExchangeRates();
return rates.Where(r => currenciesSet.Contains(r.TargetCurrency));
}
}
}
11 changes: 11 additions & 0 deletions jobs/Backend/Task/Domain/IExchangeRateFetcher.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;

namespace ExchangeRateUpdater.Domain
{
internal interface IExchangeRateFetcher
{
Task<IEnumerable<ExchangeRate>> GetExchangeRates(CancellationToken cancellationToken = default);
}
}
23 changes: 0 additions & 23 deletions jobs/Backend/Task/ExchangeRate.cs

This file was deleted.

19 changes: 0 additions & 19 deletions jobs/Backend/Task/ExchangeRateProvider.cs

This file was deleted.

29 changes: 28 additions & 1 deletion jobs/Backend/Task/ExchangeRateUpdater.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,34 @@

<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net6.0</TargetFramework>
<TargetFramework>net9.0</TargetFramework>
<UserSecretsId>8bbe5d99-db8b-403b-9849-2f28ed7c8031</UserSecretsId>
</PropertyGroup>

<ItemGroup>
<PackageReference Include="CsvHelper" Version="33.0.1" />
<PackageReference Include="Microsoft.Extensions.Caching.Memory" Version="9.0.5" />
<PackageReference Include="Microsoft.Extensions.Configuration.Abstractions" Version="9.0.5" />
<PackageReference Include="Microsoft.Extensions.Configuration.Json" Version="9.0.5" />
<PackageReference Include="Microsoft.Extensions.DependencyInjection" Version="9.0.5" />
<PackageReference Include="Microsoft.Extensions.Hosting" Version="9.0.5" />
<PackageReference Include="Microsoft.Extensions.Http" Version="9.0.5" />
<PackageReference Include="Microsoft.Extensions.Http.Polly" Version="9.0.5" />
<PackageReference Include="Microsoft.Extensions.Logging" Version="9.0.5" />
<PackageReference Include="OpenTelemetry" Version="1.12.0" />
<PackageReference Include="OpenTelemetry.Exporter.Console" Version="1.12.0" />
<PackageReference Include="OpenTelemetry.Extensions.Hosting" Version="1.12.0" />
<PackageReference Include="OpenTelemetry.Instrumentation.Http" Version="1.12.0" />
</ItemGroup>
<ItemGroup>
<None Update="appsettings.json">
<CopyToOutputDirectory>Always</CopyToOutputDirectory>
</None>
</ItemGroup>

<ItemGroup>
<InternalsVisibleTo Include="ExchangeRateUpdaterTests" />
<InternalsVisibleTo Include="DynamicProxyGenAssembly2" />
</ItemGroup>

</Project>
13 changes: 11 additions & 2 deletions jobs/Backend/Task/ExchangeRateUpdater.sln
Original file line number Diff line number Diff line change
@@ -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.13.35931.197 d17.13
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}") = "ExchangeRateUpdaterTests", "..\Tests\ExchangeRateUpdaterTests\ExchangeRateUpdaterTests.csproj", "{1E427E08-DC0D-4B92-9B2F-2B880C2B1809}"
EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
Expand All @@ -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
{1E427E08-DC0D-4B92-9B2F-2B880C2B1809}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{1E427E08-DC0D-4B92-9B2F-2B880C2B1809}.Debug|Any CPU.Build.0 = Debug|Any CPU
{1E427E08-DC0D-4B92-9B2F-2B880C2B1809}.Release|Any CPU.ActiveCfg = Release|Any CPU
{1E427E08-DC0D-4B92-9B2F-2B880C2B1809}.Release|Any CPU.Build.0 = Release|Any CPU
EndGlobalSection
GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE
EndGlobalSection
GlobalSection(ExtensibilityGlobals) = postSolution
SolutionGuid = {02958319-B4BB-49EE-92B0-3B8A23C3A4EE}
EndGlobalSection
EndGlobal
44 changes: 44 additions & 0 deletions jobs/Backend/Task/Infrastructure/CNB/CNBExchangeRateFetcher.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
using System.Collections.Generic;
using System.Net.Http;
using System.Threading;
using System.Threading.Tasks;
using ExchangeRateUpdater.Domain;
using Microsoft.Extensions.Logging;
using Polly.CircuitBreaker;

namespace ExchangeRateUpdater.Infrastructure.CNB
{
internal sealed class CNBExchangeRateFetcher(
HttpClient httpClient,
IExchangeRateParser parser,
ILogger<CNBExchangeRateFetcher> logger) : IExchangeRateFetcher
{
private const string RatesSegment = "daily.txt";

private readonly HttpClient httpClient = httpClient;
private readonly IExchangeRateParser parser = parser;

public async Task<IEnumerable<ExchangeRate>> GetExchangeRates(CancellationToken cancellationToken = default)
{
logger.LogTrace("Getting exchange rates from CNB");

try
{
var response = await httpClient.GetAsync(RatesSegment, cancellationToken);
response.EnsureSuccessStatusCode();

var content = await response.Content.ReadAsStringAsync(cancellationToken);

return parser.Parse(content).Records;
}
catch (BrokenCircuitException)
{
// We would want to retrieve the info from other source or use "old" data.
// Returning empty collection for simplicy here.
logger.LogWarning("Circuit open when fetching data. Using alternative path");

return [];
}
}
}
}
77 changes: 77 additions & 0 deletions jobs/Backend/Task/Infrastructure/CNB/CNBExchangeRateParser.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
using System;
using System.Collections.Generic;
using System.Globalization;
using System.IO;
using System.Linq;
using CsvHelper;
using CsvHelper.Configuration;
using ExchangeRateUpdater.Domain;
using Microsoft.Extensions.Logging;

namespace ExchangeRateUpdater.Infrastructure.CNB
{
internal interface IExchangeRateParser
{
(ExchangeRateMetadata Metadata, IEnumerable<ExchangeRate> Records) Parse(string exchangeRate);
}

internal record ExchangeRateMetadata(DateTime DateTime, int Identifier);

internal sealed class CNBExchangeRateParser(ILogger<CNBExchangeRateParser> logger) : IExchangeRateParser
{
private const string CheckCurrencyCode = "CZK";
private readonly CsvConfiguration config = new CsvConfiguration(CultureInfo.InvariantCulture)
{
PrepareHeaderForMatch = args => args.Header.Trim().ToLower(),
Delimiter = "|",
};
private readonly ILogger<CNBExchangeRateParser> logger = logger;

public (ExchangeRateMetadata Metadata, IEnumerable<ExchangeRate> Records) Parse(string exchangeRate)
{
logger.LogTrace("Parsing exchange rate data");

using (var reader = new StringReader(exchangeRate))
using (var csvReader = new CsvReader(reader, config))
{
var dateInfo = reader.ReadLine();
var metadata = ParseMetadata(dateInfo);

logger.LogTrace("Parsed metadata: {Date} {Identifier}", metadata.DateTime, metadata.Identifier);

var records = csvReader.GetRecords<ExchangeRateRecord>();
return (metadata, records.Select(records => new ExchangeRate(
new Currency(CheckCurrencyCode),
new Currency(records.Code),
records.Rate / records.Amount))
.ToArray());
}
}

private static ExchangeRateMetadata ParseMetadata(string dateInfo)
{
// Example: dateInfo = "16 May 2025 #93"
DateTime? parsedDate = null;
int? identifier = null;

if (!string.IsNullOrWhiteSpace(dateInfo))
{
var parts = dateInfo.Split('#');
var datePart = parts[0].Trim();

if (DateTime.TryParseExact(datePart, "d MMM yyyy", CultureInfo.InvariantCulture, DateTimeStyles.None, out var dt))
parsedDate = dt;

if (parts.Length > 1 && int.TryParse(parts[1].Trim(), out var id))
identifier = id;

return new ExchangeRateMetadata(parsedDate ?? DateTime.MinValue, identifier ?? 0);
}

return new ExchangeRateMetadata(DateTime.MinValue, 0);

}

private record class ExchangeRateRecord(string Country, string Currency, decimal Amount, string Code, decimal Rate);
}
}
11 changes: 11 additions & 0 deletions jobs/Backend/Task/Infrastructure/CNB/CNBOptions.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
using System;

namespace ExchangeRateUpdater.Infrastructure.CNB
{
internal record CNBOptions
{
public string BaseUrl { get; set; }

public TimeOnly NewDataSchedule { get; set; }
}
}
46 changes: 46 additions & 0 deletions jobs/Backend/Task/Infrastructure/CNB/CachedExchangeRateFetcher.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
using System;
using System.Collections.Generic;
using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;
using ExchangeRateUpdater.Domain;
using Microsoft.Extensions.Caching.Distributed;

namespace ExchangeRateUpdater.Infrastructure.CNB
{
// We could use a basic dictionary for caching, but we will use a distributed cache
// to allow for better scalability and to avoid issues with multiple instances of the application.
// We assumed 5 minutes of sliding expiration, but this could be changed to a more suitable value.
internal sealed class CachedExchangeRateFetcher(
IDistributedCache cache,
IExchangeRateFetcher underlying) : IExchangeRateFetcher
{
private readonly IDistributedCache cache = cache;
private readonly IExchangeRateFetcher underlying = underlying;

public async Task<IEnumerable<ExchangeRate>> GetExchangeRates(CancellationToken cancellationToken = default)
{
const string cacheKey = "exchange-rates";
var cached = await cache.GetStringAsync(cacheKey, cancellationToken);

if (!string.IsNullOrEmpty(cached))
{
return JsonSerializer.Deserialize<IEnumerable<ExchangeRate>>(cached)!;
}

var rates = await underlying.GetExchangeRates(cancellationToken);
var serialized = JsonSerializer.Serialize(rates);

await cache.SetStringAsync(
cacheKey,
serialized,
new DistributedCacheEntryOptions
{
SlidingExpiration = TimeSpan.FromMinutes(5),
},
cancellationToken);

return rates;
}
}
}
Loading