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
53 changes: 53 additions & 0 deletions jobs/Backend/Task/Clients/CnpApiClient.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
using ExchangeRateUpdater.Configuration;
using ExchangeRateUpdater.Exceptions;
using Microsoft.Extensions.Options;
using System.Net.Http;
using System.Threading.Tasks;
using System.Threading;
using System;
using System.Runtime.ConstrainedExecution;

namespace ExchangeRateUpdater.Clients
{
/// <summary>
/// Czech National Bank specific implementation of exchange rate API client.
/// Fetches data from CNB's daily exchange rate fixing endpoint.
/// </summary>
public class CnbApiClient : IExchangeRateApiClient
{
private readonly HttpClient _httpClient;
private readonly ExchangeRateApiSettings _settings;

public CnbApiClient(HttpClient httpClient, IOptions<ExchangeRateApiSettings> settings)
{
_httpClient = httpClient ?? throw new ArgumentNullException(nameof(httpClient));
_settings = settings?.Value ?? throw new ArgumentNullException(nameof(settings));

_httpClient.Timeout = TimeSpan.FromSeconds(_settings.TimeoutSeconds);
}

public async Task<string> GetDailyRatesAsync(CancellationToken cancellationToken = default)
{
try
{
var response = await _httpClient.GetAsync(_settings.DailyRatesUrl, cancellationToken);
response.EnsureSuccessStatusCode();

return await response.Content.ReadAsStringAsync(cancellationToken);
}
//Consistent error handling regardless of which client implementation is used
catch (HttpRequestException ex)
{
throw new ExchangeRateException(
"Failed to retrieve exchange rates from CNB API.",
ex);
}
catch (TaskCanceledException ex)
{
throw new ExchangeRateException(
"Request to CNB API timed out.",
ex);
}
}
}
}
17 changes: 17 additions & 0 deletions jobs/Backend/Task/Clients/IExchangeRateApiClient.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
using System.Threading.Tasks;
using System.Threading;

namespace ExchangeRateUpdater.Clients
{
/// <summary>
/// Generic contract for fetching exchange rate data from any source.
/// Implementation can target different central banks or financial data providers.
/// </summary>
public interface IExchangeRateApiClient
{
/// <summary>
/// Fetches raw exchange rate data from the source.
/// </summary>
Task<string> GetDailyRatesAsync(CancellationToken cancellationToken = default);
}
}
12 changes: 12 additions & 0 deletions jobs/Backend/Task/Configuration/ExchangeRateApiSettings.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
namespace ExchangeRateUpdater.Configuration
{
/// <summary>
/// Configuration settings for exchange rate API access - Generic structure usable for any exchange rate source.
/// Compile time binding via IOptions pattern better than raw string keys
/// </summary>
public class ExchangeRateApiSettings
{
public string DailyRatesUrl { get; set; } = string.Empty;
public int TimeoutSeconds { get; set; } = 30;
}
}
20 changes: 0 additions & 20 deletions jobs/Backend/Task/Currency.cs

This file was deleted.

20 changes: 20 additions & 0 deletions jobs/Backend/Task/Exceptions/ExchangeRateException.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Runtime.InteropServices;
using System.Text;
using System.Threading.Tasks;

namespace ExchangeRateUpdater.Exceptions
{
/// <summary>
/// Custom exception for exchange rate operations.
/// caller can distinguish domain errors from infrastructure errors
/// </summary>
public class ExchangeRateException : Exception
{
public ExchangeRateException(string message) : base(message) { }
public ExchangeRateException(string message, Exception innerException)
: base(message, innerException) { }
}
}
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.

22 changes: 22 additions & 0 deletions jobs/Backend/Task/ExchangeRateUpdater.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -5,4 +5,26 @@
<TargetFramework>net6.0</TargetFramework>
</PropertyGroup>

<ItemGroup>
<PackageReference Include="Microsoft.Extensions.Configuration" Version="9.0.10" />
<PackageReference Include="Microsoft.Extensions.Configuration.Binder" Version="9.0.10" />
<PackageReference Include="Microsoft.Extensions.Configuration.EnvironmentVariables" Version="9.0.10" />
<PackageReference Include="Microsoft.Extensions.Configuration.Json" Version="9.0.10" />
<PackageReference Include="Microsoft.Extensions.DependencyInjection" Version="9.0.10" />
<PackageReference Include="Microsoft.Extensions.Http" Version="9.0.10" />
<PackageReference Include="Microsoft.Extensions.Options.ConfigurationExtensions" Version="9.0.10" />
</ItemGroup>

<ItemGroup>
<None Update="appsettings.Production.json">
<CopyToOutputDirectory>Always</CopyToOutputDirectory>
</None>
<None Update="appsettings.Development.json">
<CopyToOutputDirectory>Always</CopyToOutputDirectory>
</None>
<None Update="appsettings.json">
<CopyToOutputDirectory>Always</CopyToOutputDirectory>
</None>
</ItemGroup>

</Project>
15 changes: 12 additions & 3 deletions jobs/Backend/Task/ExchangeRateUpdater.sln
Original file line number Diff line number Diff line change
@@ -1,9 +1,11 @@

Microsoft Visual Studio Solution File, Format Version 12.00
# Visual Studio 14
VisualStudioVersion = 14.0.25123.0
# Visual Studio Version 17
VisualStudioVersion = 17.10.35122.118
MinimumVisualStudioVersion = 10.0.40219.1
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ExchangeRateUpdater", "ExchangeRateUpdater.csproj", "{7B2695D6-D24C-4460-A58E-A10F08550CE0}"
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "ExchangeRateUpdater", "ExchangeRateUpdater.csproj", "{7B2695D6-D24C-4460-A58E-A10F08550CE0}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ExchangeRateUpdater.XUnitTests", "..\ExchangeRateUpdater.XUnitTests\ExchangeRateUpdater.XUnitTests.csproj", "{ABAEC4CE-7FFB-4DAE-AFED-CBCD6D699BF4}"
EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
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
{ABAEC4CE-7FFB-4DAE-AFED-CBCD6D699BF4}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{ABAEC4CE-7FFB-4DAE-AFED-CBCD6D699BF4}.Debug|Any CPU.Build.0 = Debug|Any CPU
{ABAEC4CE-7FFB-4DAE-AFED-CBCD6D699BF4}.Release|Any CPU.ActiveCfg = Release|Any CPU
{ABAEC4CE-7FFB-4DAE-AFED-CBCD6D699BF4}.Release|Any CPU.Build.0 = Release|Any CPU
EndGlobalSection
GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE
EndGlobalSection
GlobalSection(ExtensibilityGlobals) = postSolution
SolutionGuid = {7A601B6F-E9B2-4165-8DB1-8AB0DD43684F}
EndGlobalSection
EndGlobal
37 changes: 37 additions & 0 deletions jobs/Backend/Task/Models/Currency.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
using System;

namespace ExchangeRateUpdater.Models
{
/// <summary>
/// Immutable value object representing a currency.
/// </summary>
public class Currency
{
//Validate in constructor. Don't allow invalid states.
public Currency(string code)
{
if (string.IsNullOrWhiteSpace(code))
throw new ArgumentException("Currency code cannot be null or empty.", nameof(code));

//Normalize to uppercase (usd : USD) for consistency
Code = code.ToUpperInvariant();
}

/// <summary>
/// Three-letter ISO 4217 code of the currency.
/// </summary>
public string Code { get; }

public override string ToString()
{
return Code;
}

// Equality based on Code to use it in HashSet/Dictionary
public override bool Equals(object? obj) =>
obj is Currency other && Code == other.Code;

// Required when overriding Equals: Ensures Dictionary/HashSet work correctly
public override int GetHashCode() => Code.GetHashCode();
}
}
31 changes: 31 additions & 0 deletions jobs/Backend/Task/Models/ExchangeRate.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
using System;
using System.Runtime.ConstrainedExecution;

namespace ExchangeRateUpdater.Models
{
/// <summary>
/// Immutable value object representing an exchange rate between two currencies.
/// </summary>
public class ExchangeRate
{
//validations in constructor
public ExchangeRate(Currency sourceCurrency, Currency targetCurrency, decimal value)
{
SourceCurrency = sourceCurrency ?? throw new ArgumentNullException(nameof(sourceCurrency));
TargetCurrency = targetCurrency ?? throw new ArgumentNullException(nameof(targetCurrency));

if (value <= 0)
throw new ArgumentException("Exchange rate value must be positive.", nameof(value));

Value = value;
}

public Currency SourceCurrency { get; }
public Currency TargetCurrency { get; }
public decimal Value { get; }

//consistent output(4 decimal places)
public override string ToString() =>
$"{SourceCurrency}/{TargetCurrency}={Value:F4}";
}
}
86 changes: 86 additions & 0 deletions jobs/Backend/Task/Parsers/CnbDataParser.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
using ExchangeRateUpdater.Exceptions;
using ExchangeRateUpdater.Models;
using System.Collections.Generic;
using System.Linq;
using System;

namespace ExchangeRateUpdater.Parsers
{
/// <summary>
/// Parses CNB daily exchange rates text format.
/// Format: Country|Currency|Amount|Code|Rate
/// Example: USA|dollar|1|USD|24.123
/// </summary>
public class CnbDataParser : IExchangeRateDataParser
{
private const int ExpectedColumnCount = 5;
private const int AmountIndex = 2;
private const int CodeIndex = 3;
private const int RateIndex = 4;
private static readonly Currency CzkCurrency = new("CZK");

public IEnumerable<ExchangeRate> Parse(string rawData, IEnumerable<Currency> targetCurrencies)
{
if (string.IsNullOrWhiteSpace(rawData))
throw new ExchangeRateException("CNB data is empty.");

// enhances performance and ensures that the filtering process is accurate and efficient.
// HashSet for O(1) lookup performance instead of O(n) with List.Contains--> Checking if currency is requested happens for every parsed line
var currencyCodesSet = new HashSet<string>(
targetCurrencies.Select(c => c.Code),
StringComparer.OrdinalIgnoreCase);

//Parse all lines and then filter
return ParseLines(rawData)
.Where(rate => currencyCodesSet.Contains(rate.SourceCurrency.Code))
.ToList();
}

private IEnumerable<ExchangeRate> ParseLines(string rawData)
{
var lines = rawData.Split(new[] { '\r', '\n' }, StringSplitOptions.RemoveEmptyEntries);

// first 2 lines: date and column headers
return lines.Skip(2)
.Select(ParseLine)
.Where(rate => rate != null)
.Cast<ExchangeRate>();
}

private ExchangeRate? ParseLine(string line)
{
try
{
var columns = line.Split('|');

if (columns.Length != ExpectedColumnCount)
return null;

var currencyCode = columns[CodeIndex].Trim();
var amount = ParseDecimal(columns[AmountIndex]);
var rate = ParseDecimal(columns[RateIndex]);

// CNB rates are: {amount} {currency} = {rate} CZK
// We want: 1 {currency} = {rate/amount} CZK. We normalize it to 1 unit.
var normalizedRate = rate / amount;

return new ExchangeRate(
sourceCurrency: new Currency(currencyCode),
targetCurrency: CzkCurrency,
value: normalizedRate);
}
catch
{
// Skip malformed lines
return null;
}
}

private decimal ParseDecimal(string value)
{
// CNB uses comma as decimal separator
var normalized = value.Trim().Replace(',', '.');
return decimal.Parse(normalized, System.Globalization.CultureInfo.InvariantCulture);
}
}
}
Loading