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
6 changes: 6 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -21,3 +21,9 @@
node_modules
bower_components
npm-debug.log
/jobs/Backend/Task/.vs/ExchangeRateUpdater
/jobs/Backend/Task/.vs/ProjectEvaluation
/jobs/Backend/Task/.dockerignore
/jobs/Backend/Task/Dockerfile
/jobs/Backend/Task/EXCEPTION_HANDLING.md
/jobs/Backend/Task/README.md
19 changes: 19 additions & 0 deletions jobs/Backend/Task/Application/Interfaces/IExchangeRateService.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
using ExchangeRateUpdater.Domain;

namespace ExchangeRateUpdater.Application.Interfaces
{
/// <summary>
/// Application service interface for retrieving exchange rates.
/// Orchestrates multiple exchange rate providers.
/// </summary>
public interface IExchangeRateService
{
/// <summary>
/// Retrieves exchange rates for the requested currencies asynchronously.
/// Only returns rates explicitly provided by the underlying providers.
/// </summary>
/// <param name="requestedCurrencies">Currencies to fetch rates for.</param>
/// <returns>Collection of exchange rates.</returns>
Task<IEnumerable<ExchangeRate>> GetRatesAsync(IEnumerable<Currency> requestedCurrencies);
}
}
57 changes: 57 additions & 0 deletions jobs/Backend/Task/Application/Services/ExchangeRateService.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
using ExchangeRateUpdater.Application.Interfaces;
using ExchangeRateUpdater.Domain;

namespace ExchangeRateUpdater.Application.Services
{
/// <summary>
/// Application service to orchestrate exchange rate retrieval.
/// Uses one or more IExchangeRateProvider instances.
/// </summary>
public class ExchangeRateService : IExchangeRateService
{
private readonly IEnumerable<IExchangeRateProvider> _providers;
private readonly ILogger<ExchangeRateService> _logger;

/// <summary>
/// Initializes the service with one or more exchange rate providers.
/// </summary>
/// <param name="providers">Injected exchange rate providers.</param>
public ExchangeRateService(IEnumerable<IExchangeRateProvider> providers, ILogger<ExchangeRateService> logger)
{
_providers = providers ?? throw new ArgumentNullException(nameof(providers));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
}

/// <summary>
/// Retrieves exchange rates for the requested currencies asynchronously.
/// Only returns rates explicitly provided by the underlying providers.
/// </summary>
public Task<IEnumerable<ExchangeRate>> GetRatesAsync(IEnumerable<Currency> requestedCurrencies)
{
if (requestedCurrencies == null)
throw new ArgumentNullException(nameof(requestedCurrencies));

var results = new List<ExchangeRate>();

foreach (var provider in _providers)
{
try
{
var rates = provider.GetExchangeRates(requestedCurrencies);
if (rates != null)
results.AddRange(rates);
}
catch (Exception ex) {
_logger.LogError(ex, "Exchange rate provider '{Provider}' failed.", provider.GetType().Name);
throw new InvalidOperationException(
$"Failed to retrieve exchange rates from provider '{provider.GetType().Name}'.", ex);
}
}
var distinctRates = results
.GroupBy(r => new { SourceCode= r.Source.Code, TargetCode = r.Target.Code })
.Select(g => g.First());

return Task.FromResult(distinctRates.AsEnumerable());
}
}
}
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
namespace ExchangeRateUpdater
namespace ExchangeRateUpdater.Domain
{
public class Currency
{
Expand Down
37 changes: 37 additions & 0 deletions jobs/Backend/Task/Domain/ExchangeRate.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
namespace ExchangeRateUpdater.Domain
{
/// <summary>
/// Represents an exchange rate between two currencies.
/// Immutable and validated.
/// </summary>
public class ExchangeRate
{
/// <summary>
/// Source currency of the exchange rate.
/// </summary>
public Currency Source { get; }

/// <summary>
/// Target currency of the exchange rate.
/// </summary>
public Currency Target { get; }

/// <summary>
/// Exchange rate value (e.g., 1 USD = 23.5 CZK -> Value = 23.5).
/// </summary>
public decimal Value { get; }

/// <summary>
/// Creates a new ExchangeRate instance.
/// </summary>
/// <param name="source">Source currency (required).</param>
/// <param name="target">Target currency (required).</param>
/// <param name="value">Exchange rate value.</param>
public ExchangeRate(Currency source, Currency target, decimal value)
{
Source = source ?? throw new ArgumentNullException(nameof(source));
Target = target ?? throw new ArgumentNullException(nameof(target));
Value = value;
}
}
}
18 changes: 18 additions & 0 deletions jobs/Backend/Task/Domain/IExchangeRateProvider.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
namespace ExchangeRateUpdater.Domain
{
/// <summary>
/// Defines a contract for exchange rate providers.
/// Returns only explicitly defined rates from the source.
/// </summary>
public interface IExchangeRateProvider
{
/// <summary>
/// Returns exchange rates for the requested currencies.
/// Only returns rates explicitly defined by the source,
/// does not calculate inverted rates.
/// </summary>
/// <param name="requestedCurrencies">Currencies to retrieve rates for.</param>
/// <returns>Collection of exchange rates.</returns>
IEnumerable<ExchangeRate> GetExchangeRates(IEnumerable<Currency> requestedCurrencies);
}
}
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.

49 changes: 47 additions & 2 deletions jobs/Backend/Task/ExchangeRateUpdater.csproj
Original file line number Diff line number Diff line change
@@ -1,8 +1,53 @@
<Project Sdk="Microsoft.NET.Sdk">
<Project Sdk="Microsoft.NET.Sdk.Web">

<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net6.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<GenerateAssemblyInfo>false</GenerateAssemblyInfo>
<GenerateDocumentationFile>false</GenerateDocumentationFile>
<DockerDefaultTargetOS>Windows</DockerDefaultTargetOS>
<DockerfileContext>.</DockerfileContext>
</PropertyGroup>

<ItemGroup>
<Compile Remove="Tests\ExchangeRateUpdater.Tests\bin\**" />
<Compile Remove="Tests\ExchangeRateUpdater.Tests\obj\**" />
<Content Remove="Tests\ExchangeRateUpdater.Tests\bin\**" />
<Content Remove="Tests\ExchangeRateUpdater.Tests\obj\**" />
<EmbeddedResource Remove="Tests\ExchangeRateUpdater.Tests\bin\**" />
<EmbeddedResource Remove="Tests\ExchangeRateUpdater.Tests\obj\**" />
<None Remove="Tests\ExchangeRateUpdater.Tests\bin\**" />
<None Remove="Tests\ExchangeRateUpdater.Tests\obj\**" />
</ItemGroup>

<ItemGroup>
<PackageReference Include="DotNetEnv" Version="3.1.1" />
<PackageReference Include="Microsoft.Extensions.Hosting" Version="10.0.0" />
<PackageReference Include="Microsoft.Extensions.Hosting.Abstractions" Version="10.0.0" />
</ItemGroup>

<ItemGroup>
<EditorConfigFiles Remove="E:\Projects\developers\jobs\Backend\Task\.editorconfig" />
</ItemGroup>

<ItemGroup>
<PackageReference Include="Microsoft.VisualStudio.Azure.Containers.Tools.Targets" Version="1.22.1" />
<PackageReference Include="Swashbuckle.AspNetCore" Version="6.5.0" />
<PackageReference Include="xunit.assert" Version="2.6.2" />
<PackageReference Include="xunit.extensibility.core" Version="2.9.3" />
</ItemGroup>

<ItemGroup>
<None Update="appsettings.development.json">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</None>
<None Update="appsettings.json">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</None>
<None Update="appsettings.production.json">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</None>
</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.14.36121.58 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.Tests", "Tests\ExchangeRateUpdater.Tests\ExchangeRateUpdater.Tests.csproj", "{D3F6F8E4-8A7F-4B2C-94A2-0D9B6A2E2B10}"
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
{D3F6F8E4-8A7F-4B2C-94A2-0D9B6A2E2B10}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{D3F6F8E4-8A7F-4B2C-94A2-0D9B6A2E2B10}.Debug|Any CPU.Build.0 = Debug|Any CPU
{D3F6F8E4-8A7F-4B2C-94A2-0D9B6A2E2B10}.Release|Any CPU.ActiveCfg = Release|Any CPU
{D3F6F8E4-8A7F-4B2C-94A2-0D9B6A2E2B10}.Release|Any CPU.Build.0 = Release|Any CPU
EndGlobalSection
GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE
EndGlobalSection
GlobalSection(ExtensibilityGlobals) = postSolution
SolutionGuid = {F71216DE-D6B5-4542-B482-F54372660740}
EndGlobalSection
EndGlobal
114 changes: 114 additions & 0 deletions jobs/Backend/Task/Infrastructure/Sources/CnbExchangeRateProvider.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,114 @@
using ExchangeRateUpdater.Domain;
using System.Globalization;

namespace ExchangeRateUpdater.Infrastructure.Sources
{
public class CnbExchangeRateProvider : IExchangeRateProvider
{
private const int HttpTimeoutSeconds = 10;
private const string TargetCurrency = "CZK";
private readonly List<string> _endpoints = new();
private readonly ILogger<CnbExchangeRateProvider> _logger;

public CnbExchangeRateProvider(IConfiguration configuration, ILogger<CnbExchangeRateProvider> logger)
{
_endpoints.Add(configuration["ExchangeRateApi:exchangeRateEN"]);
_endpoints.Add(configuration["ExchangeRateApi:exchangeRateSC"]);
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
}
/// <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)
{
if (currencies == null) throw new ArgumentNullException(nameof(currencies));

var requestedCurrencies = ExtractRequestedCurrencies(currencies);

var rawData = FetchRatesFromEndpoints();
if (string.IsNullOrWhiteSpace(rawData))
return Enumerable.Empty<ExchangeRate>();

return ParseRates(rawData, requestedCurrencies);
}

private HashSet<string> ExtractRequestedCurrencies(IEnumerable<Currency> currencies)
{
return new HashSet<string>(
currencies.Select(c => c?.Code?.ToUpperInvariant())
.Where(s => !string.IsNullOrEmpty(s))
);
}

private string FetchRatesFromEndpoints()
{
using var http = new HttpClient { Timeout = TimeSpan.FromSeconds(HttpTimeoutSeconds) };
foreach (var url in _endpoints)
{
try
{
var response = http.GetAsync(url).GetAwaiter().GetResult();
if (response.IsSuccessStatusCode)
return response.Content.ReadAsStringAsync().GetAwaiter().GetResult();
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Failed requesting CNB rate endpoint {Url}", url);
}
}
_logger.LogError("All CNB rate endpoints failed.");
return null;
}

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

foreach (var line in lines)
{
var rate = ParseLine(line, requestedCurrencies);
if (rate != null)
results.Add(rate);
}
return results;
}
private ExchangeRate ParseLine(string line, HashSet<string> requestedCurrencies)
{
try
{
if (!line.Contains("|"))
return null;

var parts = line.Split('|');
if (parts.Length < 5)
return null;

var code = parts[3].Trim().ToUpperInvariant();
if (!requestedCurrencies.Contains(code))
return null;
var amountText = parts[2].Trim();
var rateText = parts[4].Trim().Replace(',', '.');

if (!decimal.TryParse(amountText, NumberStyles.Number, CultureInfo.InvariantCulture, out var amount))
amount = 1m;

if (!decimal.TryParse(rateText, NumberStyles.Number, CultureInfo.InvariantCulture, out var rate))
return null;

if (amount != 0m && amount != 1m)
rate = rate / amount;

return new ExchangeRate(new Currency(code), new Currency(TargetCurrency), rate);
}
catch (Exception ex)
{
_logger.LogWarning(ex, "CNB line parsing failed for line: {Line}", line);
return null;
}
}
}
}
Loading