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
25 changes: 25 additions & 0 deletions jobs/Backend/Task/.dockerignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
**/.dockerignore
**/.env
**/.git
**/.gitignore
**/.project
**/.settings
**/.toolstarget
**/.vs
**/.vscode
**/.idea
**/*.*proj.user
**/*.dbmdl
**/*.jfm
**/azds.yaml
**/bin
**/charts
**/docker-compose*
**/Dockerfile*
**/node_modules
**/npm-debug.log
**/obj
**/secrets.dev.yaml
**/values.dev.yaml
LICENSE
README.md
20 changes: 0 additions & 20 deletions jobs/Backend/Task/Currency.cs

This file was deleted.

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.

Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
using ExchangeRateUpdater.Domain.ApiClients.Interfaces;
using Microsoft.Extensions.Logging;

namespace ExchangeRateUpdater.Domain.ApiClients;

public sealed class ExchangeRateApiClient(HttpClient httpClient, ILogger<ExchangeRateApiClient> logger)
: IExchangeRateApiClient
{
private readonly HttpClient _httpClient = httpClient ?? throw new ArgumentNullException(nameof(httpClient));
private readonly ILogger _logger = logger ?? throw new ArgumentNullException(nameof(logger));
private const string ErrorMessage = "Request to ExchangeRate source failed.";

public async Task<string> GetExchangeRatesXml(CancellationToken cancellationToken)
{
const string endpoint = "/cs/financni_trhy/devizovy_trh/kurzy_devizoveho_trhu/denni_kurz.xml";
var requestUri = new Uri(_httpClient.BaseAddress!, endpoint);

var requestMessage = new HttpRequestMessage(HttpMethod.Get, requestUri);

var response = await _httpClient.SendAsync(requestMessage, cancellationToken);

if (!response.IsSuccessStatusCode)
{
var errorContent = await response.Content.ReadAsStringAsync(cancellationToken);

_logger.LogError(
"HTTP GET {Url} failed with status {StatusCode}: {Content}",
requestUri,
response.StatusCode,
errorContent
);

throw new HttpRequestException(ErrorMessage);
}

var content = await response.Content.ReadAsStringAsync(cancellationToken);
return content;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
namespace ExchangeRateUpdater.Domain.ApiClients.Interfaces;

public interface IExchangeRateApiClient
{
Task<string> GetExchangeRatesXml(CancellationToken cancellationToken);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
using System.Globalization;
using System.Xml.Serialization;

namespace ExchangeRateUpdater.Domain.ApiClients.Models;

[XmlRoot("kurzy")]
public class ExchangeRatesResponse
{
[XmlElement("tabulka")]
public ExchangeRateTable[] Tables { get; set; } = [];
}

public class ExchangeRateTable
{
[XmlElement("radek")]
public ExchangeRateRow[] Rows { get; set; } = [];
}

public class ExchangeRateRow
{
[XmlAttribute("kod")]
public string CurrencyCode { get; set; } = string.Empty;

[XmlAttribute("mena")]
public string CurrencyName { get; set; } = string.Empty;

[XmlAttribute("kurz")]
public string RateString { get; set; } = string.Empty;

[XmlAttribute("mnozstvi")]
public string AmountString { get; set; } = "1";

[XmlIgnore]
public decimal Rate => decimal.Parse(RateString, NumberStyles.Number, new CultureInfo("cs-CZ"));

[XmlIgnore]
public decimal Amount => decimal.Parse(AmountString, NumberStyles.Number, new CultureInfo("cs-CZ"));
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
namespace ExchangeRateUpdater.Domain.Exceptions;

public sealed class InvalidExchangeRateDataException(string message) : Exception(message);
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
namespace ExchangeRateUpdater.Domain.Exceptions;

public sealed class UnknownCurrencyException(string code) : Exception($"Unknown currency code: {code}");
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
<Project Sdk="Microsoft.NET.Sdk">

<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>

<ItemGroup>
<PackageReference Include="Microsoft.Extensions.Caching.StackExchangeRedis" Version="6.0.10" />
<PackageReference Include="StackExchange.Redis" Version="2.6.122" />
<PackageReference Include="Swashbuckle.AspNetCore" Version="6.5.0" />
</ItemGroup>

</Project>
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
namespace ExchangeRateUpdater.Domain.Models;

public sealed class Currency(string code)
{
public string Code { get; } = code ?? throw new ArgumentNullException(nameof(code));

public override string ToString() => Code;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
namespace ExchangeRateUpdater.Domain.Models;

public sealed record ExchangeRate
{
public required Currency SourceCurrency { get; init; }
public required Currency TargetCurrency { get; init; }
public required decimal Value { get; init; }

public override string ToString() => $"{SourceCurrency}/{TargetCurrency}={Value}";
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
using System.Text.Json;
using ExchangeRateUpdater.Domain.ApiClients.Interfaces;
using ExchangeRateUpdater.Domain.Models;
using ExchangeRateUpdater.Domain.Services.Interfaces;
using Microsoft.Extensions.Caching.Distributed;
using Microsoft.Extensions.Logging;

namespace ExchangeRateUpdater.Domain.Services.Implementations
{
public sealed class ExchangeRateUpdaterProvider(
IExchangeRateApiClient apiClient,
IExchangeRateParser parser,
IDistributedCache cache,
ILogger<ExchangeRateUpdaterProvider> logger)
: IExchangeRateProvider
{
private const string CacheKey = "ExchangeRates";

public async Task<IEnumerable<ExchangeRate>> GetExchangeRatesForCurrenciesAsync(IEnumerable<Currency> currencies, CancellationToken cancellationToken)
{
List<ExchangeRate>? exchangeRatesPerCurrency = [];
var currenciesContainer = currencies.ToArray();
var targetCurrencyCodes = currenciesContainer.Select(x => x.Code).ToHashSet();

if (targetCurrencyCodes.Count == 0)
{
logger.LogWarning("Input currencies should contain codes. Currently none is existing");
return [];
}

try
{
var cached = await cache.GetStringAsync(CacheKey, token: cancellationToken);

if (!string.IsNullOrEmpty(cached))
exchangeRatesPerCurrency = JsonSerializer.Deserialize<List<ExchangeRate>>(cached);

if (exchangeRatesPerCurrency is not { Count: > 0 })
{
var xmlRates = await apiClient.GetExchangeRatesXml(cancellationToken);

var parsedRates = await parser.ParseAsync(xmlRates);

var exchangeRates = parsedRates as ExchangeRate[] ?? parsedRates.ToArray();
if (exchangeRates.Length <= 0)
{
logger.LogWarning("Parsed Rates yielding no values");
return [];
}

var exchangeRatesSerialized = JsonSerializer.Serialize(exchangeRates);

if (string.IsNullOrEmpty(exchangeRatesSerialized))
{
logger.LogWarning("Parsed Rates serialized is empty. Returning zero results");
return [];
}

await PersistDataIntoCache(exchangeRatesSerialized, cancellationToken);

return exchangeRates.Where(x => targetCurrencyCodes.Contains(x.TargetCurrency.Code));
}

}
catch (JsonException jsonException)
{
logger.LogError("Json Exception while deserializing Cached Entries : {JsonException}", jsonException);
throw;
}
catch (Exception ex)
{
logger.LogError("Unhandled exception : {Exception}", ex);
throw;
}

return exchangeRatesPerCurrency.Where(x => targetCurrencyCodes.Contains(x.TargetCurrency.Code));
}

private async Task PersistDataIntoCache(string exchangeRatesSerialized, CancellationToken cancellationToken)
{
await cache.SetStringAsync(CacheKey, exchangeRatesSerialized, new DistributedCacheEntryOptions
{
AbsoluteExpirationRelativeToNow = TimeSpan.FromHours(24),
}, token: cancellationToken);
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
using System.Xml.Serialization;
using ExchangeRateUpdater.Domain.ApiClients.Models;
using ExchangeRateUpdater.Domain.Models;
using ExchangeRateUpdater.Domain.Services.Interfaces;
using Microsoft.Extensions.Logging;

namespace ExchangeRateUpdater.Domain.Services.Implementations;
public sealed class XmlExchangeRatesParser(ILogger<XmlExchangeRatesParser> logger) : IExchangeRateParser
{
public Task<IEnumerable<ExchangeRate>> ParseAsync(string xmlContent)
{
var rates = new List<ExchangeRate>();
try
{
var serializer = new XmlSerializer(typeof(ExchangeRatesResponse));
using var reader = new StringReader(xmlContent);
var deserializedContent = serializer.Deserialize(reader) as ExchangeRatesResponse;

if (deserializedContent == null || deserializedContent.Tables.Length <= 0)
{
logger.LogWarning("Deserialized ExchangeRates Xml content is null or empty");
return Task.FromResult<IEnumerable<ExchangeRate>>([]);
}

foreach (var table in deserializedContent.Tables)
{
foreach (var row in table.Rows)
{
if(string.IsNullOrEmpty(row.CurrencyCode))
continue;

rates.Add(new ExchangeRate
{
SourceCurrency = new Currency("CZK"),
TargetCurrency = new Currency(row.CurrencyCode),
Value = row.Rate / row.Amount
});
}
}

}
catch (Exception ex)
{
logger.LogError(ex, "Failed to deserialize CNB XML.");
}

return Task.FromResult<IEnumerable<ExchangeRate>>(rates);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
using ExchangeRateUpdater.Domain.Models;

namespace ExchangeRateUpdater.Domain.Services.Interfaces;

public interface IExchangeRateParser
{
Task<IEnumerable<ExchangeRate>> ParseAsync(string xmlContent);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
using ExchangeRateUpdater.Domain.Models;

namespace ExchangeRateUpdater.Domain.Services.Interfaces;

public interface IExchangeRateProvider
{
Task<IEnumerable<ExchangeRate>> GetExchangeRatesForCurrenciesAsync(IEnumerable<Currency> currencies, CancellationToken cancellationToken);

}
Loading