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
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,9 @@ 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", "Task/ExchangeRateUpdater.csproj", "{7B2695D6-D24C-4460-A58E-A10F08550CE0}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ExchangeRateUpdaterTests", "Tests\ExchangeRateUpdaterTests\ExchangeRateUpdaterTests.csproj", "{64D000B5-8127-4D22-8C9B-68280A728B97}"
EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Expand All @@ -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
{64D000B5-8127-4D22-8C9B-68280A728B97}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{64D000B5-8127-4D22-8C9B-68280A728B97}.Debug|Any CPU.Build.0 = Debug|Any CPU
{64D000B5-8127-4D22-8C9B-68280A728B97}.Release|Any CPU.ActiveCfg = Release|Any CPU
{64D000B5-8127-4D22-8C9B-68280A728B97}.Release|Any CPU.Build.0 = Release|Any CPU
EndGlobalSection
GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE
Expand Down
10 changes: 9 additions & 1 deletion jobs/Backend/Task/Currency.cs
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
namespace ExchangeRateUpdater
using System;

namespace ExchangeRateUpdater
{
public class Currency
{
Expand All @@ -16,5 +18,11 @@ public override string ToString()
{
return Code;
}

public override bool Equals(object? obj) => Equals(obj as Currency);

public override int GetHashCode() => Code.GetHashCode(StringComparison.OrdinalIgnoreCase);

private bool Equals(Currency? other) => other != null && Code == other.Code;
}
}
113 changes: 109 additions & 4 deletions jobs/Backend/Task/ExchangeRateProvider.cs
Original file line number Diff line number Diff line change
@@ -1,19 +1,124 @@
using System.Collections.Generic;
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Net.Http;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.Options;

namespace ExchangeRateUpdater
{
public class ExchangeRateProvider
public class ExchangeRateProvider : IExchangeRateProvider
{
private const string Headers = "Country|Currency|Amount|Code|Rate";
private const int IdxCurrency = 3;
private const int IdxAmount = 2;
private const int IdxRate = 4;

private static readonly Currency ExchangeProviderCurrency = new("CZK");

// Ideally this would come from HttpClientFactory rather than be created like this
private readonly HttpClient _httpClient;
private readonly IOptions<ExchangeRateProviderSettings> _settings;

public ExchangeRateProvider(HttpClient httpClient, IOptions<ExchangeRateProviderSettings> settings)
{
_httpClient = httpClient;
_settings = settings;
}

/// <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 IEnumerable<ExchangeRate> GetExchangeRates(IEnumerable<Currency> currencies)
public async Task<IReadOnlyList<ExchangeRate>> GetExchangeRatesAsync(IReadOnlySet<Currency> currencies, CancellationToken cancellationToken = default)
{
// https://www.cnb.cz/cs/financni_trhy/devizovy_trh/kurzy_devizoveho_trhu/denni_kurz.xml returns an XML file
// https://www.cnb.cz/en/financial-markets/foreign-exchange-market/central-bank-exchange-rate-fixing/central-bank-exchange-rate-fixing/daily.txt returns a txt file
// I used the TXT version, it is a bit less verbose wwand I assume the structure would not change often
// so we can save importing a library to parse simple text
// XML would make it more resistent to format changes in case they moved fields or added new ones
if (!currencies.Any()) return new List<ExchangeRate>();

// The rates are updated daily, only during working days, we could introduce some caching to prevent re-requesting the same data
var response = await _httpClient.GetAsync(_settings.Value.BankUrl, cancellationToken);
response.EnsureSuccessStatusCode();

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

return await ParseExchangeRatesAsync(currencies, content);
}

// this code could be moved to an helper method to help with testing
// I left it here because it simple enough and makes the class self contained
private async Task<IReadOnlyList<ExchangeRate>> ParseExchangeRatesAsync(IReadOnlySet<Currency> currencies, string content)
{
return Enumerable.Empty<ExchangeRate>();
using var reader = new StringReader(content);

// skip the line with date
string? line = await reader.ReadLineAsync();

// header line
line = await reader.ReadLineAsync();
if (line == null)
{
throw new InvalidOperationException("Missing header line");
}

if (!line.Equals(Headers, StringComparison.OrdinalIgnoreCase))
{
throw new InvalidOperationException($"Invalid header line. Expected: '{Headers}' Got: '{line}'");
}

var result = new List<ExchangeRate>();
while ((line = await reader.ReadLineAsync()) != null)
{
var rate = ParseExchangeRateLine(currencies, line);
if (rate != null)
{
result.Add(rate);
}
}

return result;
}

private ExchangeRate? ParseExchangeRateLine(IReadOnlySet<Currency> currencies, string line)
{
if (string.IsNullOrWhiteSpace(line))
{
return null;
}

var parts = line.Split('|');
if (parts.Length != 5)
{
throw new InvalidOperationException($"Invalid number of parts on line: {line}");
}

var currency = new Currency(parts[IdxCurrency]);
if (!currencies.Contains(currency))
{
return null;
}

var amountString = parts[IdxAmount];
var rateString = parts[IdxRate];

if (!decimal.TryParse(rateString, out var rate))
{
throw new InvalidOperationException($"Unable to parse rate for line: {line}");
}

if (!int.TryParse(amountString, out var amount))
{
throw new InvalidOperationException($"Unable to parse amount for line: {line}");
}

return new ExchangeRate(currency, ExchangeProviderCurrency, rate / amount);
}
}
}
6 changes: 6 additions & 0 deletions jobs/Backend/Task/ExchangeRateProviderSettings.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
namespace ExchangeRateUpdater;

public class ExchangeRateProviderSettings
{
public string BankUrl { get; set; } = string.Empty;
}
14 changes: 14 additions & 0 deletions jobs/Backend/Task/ExchangeRateUpdater.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,20 @@
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net6.0</TargetFramework>
<Nullable>enable</Nullable>
<WarningsAsErrors>true</WarningsAsErrors>
</PropertyGroup>

<ItemGroup>
<None Update="appsettings.json">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</None>
</ItemGroup>

<ItemGroup>
<PackageReference Include="Microsoft.Extensions.Configuration.Json" Version="6.0.1" />
<PackageReference Include="Microsoft.Extensions.Hosting" Version="6.0.1" />
<PackageReference Include="Microsoft.Extensions.Http" Version="6.0.1" />
</ItemGroup>

</Project>
19 changes: 19 additions & 0 deletions jobs/Backend/Task/HostHelper.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
using System;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;

namespace ExchangeRateUpdater;

public static class HostHelper
{
public static ServiceProvider CreateServiceProvider()
{
var configuration = new ConfigurationBuilder().AddJsonFile("appsettings.json", optional: false, reloadOnChange: true).Build();

var services = new ServiceCollection();
services.AddOptions<ExchangeRateProviderSettings>().Bind(configuration.GetSection(nameof(ExchangeRateProviderSettings)));
services.AddHttpClient<IExchangeRateProvider, ExchangeRateProvider>();
return services.BuildServiceProvider();
}
}
10 changes: 10 additions & 0 deletions jobs/Backend/Task/IExchangeRateProvider.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;

namespace ExchangeRateUpdater;

public interface IExchangeRateProvider
{
public Task<IReadOnlyList<ExchangeRate>> GetExchangeRatesAsync(IReadOnlySet<Currency> currencies, CancellationToken cancellationToken = default);
}
17 changes: 11 additions & 6 deletions jobs/Backend/Task/Program.cs
Original file line number Diff line number Diff line change
@@ -1,12 +1,16 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
using Microsoft.Extensions.DependencyInjection;

namespace ExchangeRateUpdater
{
public static class Program
{
private static IEnumerable<Currency> currencies = new[]
// I changed it from IEnumerable to HashSet because we do not need lazy iteration
// and the number of possible currencies is small, so there is no memory issue.
// Also HashSet is faster for searches
private static readonly HashSet<Currency> Currencies = new()
{
new Currency("USD"),
new Currency("EUR"),
Expand All @@ -19,14 +23,15 @@ public static class Program
new Currency("XYZ")
};

public static void Main(string[] args)
public static async Task Main(string[] args)
{
try
{
var provider = new ExchangeRateProvider();
var rates = provider.GetExchangeRates(currencies);
await using var serviceProvider = HostHelper.CreateServiceProvider();
var provider = serviceProvider.GetRequiredService<IExchangeRateProvider>();
var rates = await provider.GetExchangeRatesAsync(Currencies);

Console.WriteLine($"Successfully retrieved {rates.Count()} exchange rates:");
Console.WriteLine($"Successfully retrieved {rates.Count} exchange rates:");
foreach (var rate in rates)
{
Console.WriteLine(rate.ToString());
Expand Down
5 changes: 5 additions & 0 deletions jobs/Backend/Task/appsettings.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
{
"ExchangeRateProviderSettings": {
"BankUrl": "https://www.cnb.cz/en/financial-markets/foreign-exchange-market/central-bank-exchange-rate-fixing/central-bank-exchange-rate-fixing/daily.txt"
}
}
Loading