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
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.

8 changes: 0 additions & 8 deletions jobs/Backend/Task/ExchangeRateUpdater.csproj

This file was deleted.

16 changes: 11 additions & 5 deletions jobs/Backend/Task/ExchangeRateUpdater.sln
Original file line number Diff line number Diff line change
Expand Up @@ -3,18 +3,24 @@ 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.Api", "src\ExchangeRateUpdater.Api\ExchangeRateUpdater.Api.csproj", "{24FD0841-D796-4953-A583-773A6FD11A01}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ExchangeRateUpdater.Api.Tests", "tests\ExchangeRateUpdater.Api.Tests\ExchangeRateUpdater.Api.Tests.csproj", "{128355E8-210A-4942-92F9-7AC7EEE07118}"
EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
Release|Any CPU = Release|Any CPU
EndGlobalSection
GlobalSection(ProjectConfigurationPlatforms) = postSolution
{7B2695D6-D24C-4460-A58E-A10F08550CE0}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{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
{24FD0841-D796-4953-A583-773A6FD11A01}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{24FD0841-D796-4953-A583-773A6FD11A01}.Debug|Any CPU.Build.0 = Debug|Any CPU
{24FD0841-D796-4953-A583-773A6FD11A01}.Release|Any CPU.ActiveCfg = Release|Any CPU
{24FD0841-D796-4953-A583-773A6FD11A01}.Release|Any CPU.Build.0 = Release|Any CPU
{128355E8-210A-4942-92F9-7AC7EEE07118}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{128355E8-210A-4942-92F9-7AC7EEE07118}.Debug|Any CPU.Build.0 = Debug|Any CPU
{128355E8-210A-4942-92F9-7AC7EEE07118}.Release|Any CPU.ActiveCfg = Release|Any CPU
{128355E8-210A-4942-92F9-7AC7EEE07118}.Release|Any CPU.Build.0 = Release|Any CPU
EndGlobalSection
GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE
Expand Down
43 changes: 0 additions & 43 deletions jobs/Backend/Task/Program.cs

This file was deleted.

94 changes: 94 additions & 0 deletions jobs/Backend/Task/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
# Exchange Rate API

A REST API service that provides daily currency exchange rates from the Czech National Bank. Built with clean architecture principles, the service fetches, caches, and serves exchange rate data with support for filtering by currency and date.

**Purpose:** Simplify currency conversion by providing a reliable, fast, and well-documented API for accessing real-time and historical exchange rates against the Czech Koruna (CZK).

## Tech Stack

- **.NET 10.0** - Latest cross-platform framework
- **ASP.NET Core** - Web API
- **DataAnnotation** - Request validation
- **Microsoft.Extensions.Http.Resilience** - HTTP resilience (retry, circuit breaker, timeout)
- **Scalar** - Interactive API documentation
- **xUnit v3** - Unit testing
- **NSubstitute** - Mocking

## Quick Start

### Prerequisites

- [.NET 10.0 SDK](https://dotnet.microsoft.com/download)

### Run the API

```bash
cd jobs/Backend/Task
dotnet run --project src/ExchangeRateUpdater.Api
```

Access the API at:
- **API Docs**: http://localhost:5213/scalar/v1
- **Swagger**: http://localhost:5213/openapi/v1.json

### Run Tests

```bash
dotnet test
```

All tests should pass.

### Response Format

**Success (200 OK):**
```json
{
"data": [
{
"sourceCurrency": "USD",
"targetCurrency": "CZK",
"rate": 23.45
}
]
}
```

**Error (400/404/502):**
```json
{
"errorCode": "ExchangeRate.CurrencyNotFound",
"errorMessage": "Exchange rate for currency 'XYZ' was not found in the source data."
}
```

## Architecture

Clean architecture with 4 layers:

```
Domain/ # Entities, value objects (Currency, ExchangeRate)
Application/ # Business logic, caching decorator
Infrastructure/ # External APIs, HTTP clients, CNB provider
Presentation/ # Controllers, validation, API responses
```

## Adding More Providers

To add a new exchange rate provider (e.g., ECB, Bank of England):

1. **Create provider folder:** `Infrastructure/Providers/Ecb/`
2. **Implement HTTP client:** Create `EcbApiClient` with provider-specific logic
3. **Create adapter:** Implement `IExternalExchangeRateClient` to map to common format
4. **Register in DI:** Add to `Infrastructure/Extensions/ServiceCollectionExtensions.cs`
5. **Configure:** Add provider settings to `appsettings.json`

**Run with hot reload:**
```bash
dotnet watch --project src/ExchangeRateUpdater.Api
```

**Test with coverage:**
```bash
dotnet test --collect:"XPlat Code Coverage"
```
7 changes: 7 additions & 0 deletions jobs/Backend/Task/nuget.config
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
<?xml version="1.0" encoding="utf-8"?>
<configuration>
<packageSources>
<clear />
<add key="nuget.org" value="https://api.nuget.org/v3/index.json" protocolVersion="3" />
</packageSources>
</configuration>
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
using ExchangeRateUpdater.Api.Application.Interfaces;
using ExchangeRateUpdater.Api.Application.Services;
using ExchangeRateUpdater.Api.Infrastructure.Providers.Cnb;
using Microsoft.Extensions.Caching.Memory;
using Microsoft.Extensions.Options;

namespace ExchangeRateUpdater.Api.Application.Extensions;

public static class ServiceCollectionExtensions
{
public static IServiceCollection AddApplication(this IServiceCollection services)
{
services.AddScoped<ExchangeRateProvider>();

services.AddScoped<IExchangeRateProvider>(sp =>
{
var inner = sp.GetRequiredService<ExchangeRateProvider>();
var cache = sp.GetRequiredService<IMemoryCache>();
var logger = sp.GetRequiredService<ILogger<ExchangeRateProviderDecorator>>();
var options = sp.GetRequiredService<IOptions<CnbApiOptions>>().Value;

return new ExchangeRateProviderDecorator(
inner, cache, logger,
TimeSpan.FromMinutes(options.CacheDurationMinutes),
TimeSpan.FromMinutes(options.HistoricalCacheDurationMinutes));
});

return services;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
using ExchangeRateUpdater.Api.Domain;
using ExchangeRateUpdater.Api.Domain.Common;

namespace ExchangeRateUpdater.Api.Application.Interfaces;

public interface IExchangeRateProvider
{
Task<Result<IReadOnlyCollection<ExchangeRate>>> GetDailyRatesAsync(
DateOnly? date = null,
CancellationToken cancellationToken = default);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
using ExchangeRateUpdater.Api.Application.Interfaces;
using ExchangeRateUpdater.Api.Domain;
using ExchangeRateUpdater.Api.Domain.Common;
using ExchangeRateUpdater.Api.Infrastructure.Interfaces;
using ExchangeRateUpdater.Api.Infrastructure.Providers.Cnb.Models;

namespace ExchangeRateUpdater.Api.Application.Services;

public sealed class ExchangeRateProvider(
IExternalExchangeRateClient externalClient,
ILogger<ExchangeRateProvider> logger) : IExchangeRateProvider
{
private const string TargetCurrencyCode = "CZK";

public async Task<Result<IReadOnlyCollection<ExchangeRate>>> GetDailyRatesAsync(
DateOnly? date = null,
CancellationToken cancellationToken = default)
{
var externalResult = await externalClient.GetDailyRatesAsync(date, cancellationToken);

if (externalResult.IsFailure)
return Result<IReadOnlyCollection<ExchangeRate>>.Failure(externalResult.Error);

var domainRates = new List<ExchangeRate>();

foreach (var external in externalResult.Value.Rates)
{
var result = MapToDomainModel(external);
if (result.IsFailure)
return Result<IReadOnlyCollection<ExchangeRate>>.Failure(result.Error);

domainRates.Add(result.Value);
}

logger.LogInformation(
"Mapped {Count} exchange rates for date {Date}",
domainRates.Count,
date?.ToString("yyyy-MM-dd") ?? "latest");

return Result<IReadOnlyCollection<ExchangeRate>>.Success(domainRates.ToArray());
}

private static Result<ExchangeRate> MapToDomainModel(CnbExRateDaily external) =>
ExchangeRate.Create(
external.CurrencyCode,
TargetCurrencyCode,
external.Rate,
external.Amount,
DateOnly.Parse(external.ValidFor));
}
Loading