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
483 changes: 483 additions & 0 deletions jobs/Backend/.gitignore

Large diffs are not rendered by default.

Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@

using ExchangeRateUpdater.Common.Models;
using ExchangeRateUpdater.Features.ExchangeRates.Models;
using ExchangeRateUpdater.Services;

namespace ExchangeRateUpdater.Unit.Tests;
public class ExchangeRateProviderTests
{
[Fact]
public void GetExchangeRates_FiltersAndMapsCorrectly()
{
// Arrange
var provider = new ExchangeRateProvider();
var currencies = new List<Currency> { new("USD"), new("EUR"), new("CZK") };

var sourceRates = new List<ExchangeRateResponse>
{
new() { CurrencyCode = "USD", Rate = 22.5m },
new() { CurrencyCode = "EUR", Rate = 25.0m },
new() { CurrencyCode = "XYZ", Rate = 99m } // should be ignored
};

// Act
var results = provider.GetExchangeRates(currencies, sourceRates).ToList();

// Assert
Assert.Equal(2, results.Count);
Assert.Contains(results, r => r.SourceCurrency.Code == "USD" && r.TargetCurrency.Code == "CZK" && r.Value == 22.5m);
Assert.Contains(results, r => r.SourceCurrency.Code == "EUR" && r.TargetCurrency.Code == "CZK" && r.Value == 25.0m);
Assert.DoesNotContain(results, r => r.SourceCurrency.Code == "XYZ");
}

[Fact]
public void GetExchangeRates_EmptySourceRates_ReturnsEmpty()
{
// Arrange
var provider = new ExchangeRateProvider();
var currencies = new List<Currency> { new("USD"), new("CZK") };
var sourceRates = new List<ExchangeRateResponse>();

// Act
var results = provider.GetExchangeRates(currencies, sourceRates);

// Assert
Assert.Empty(results);
}

[Fact]
public void GetExchangeRates_EmptyCurrencies_ReturnsEmpty()
{
// Arrange
var provider = new ExchangeRateProvider();
var currencies = new List<Currency>();
var sourceRates = new List<ExchangeRateResponse>()
{
new()
{
CurrencyCode = "USD",
Rate = 22.5m
}
};

// Act
var results = provider.GetExchangeRates(currencies, sourceRates);

// Assert
Assert.Empty(results);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
<Project Sdk="Microsoft.NET.Sdk">

<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<IsPackable>false</IsPackable>
</PropertyGroup>

<ItemGroup>
<PackageReference Include="coverlet.collector" Version="6.0.4" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.14.1" />
<PackageReference Include="Moq" Version="4.20.72" />
<PackageReference Include="xunit" Version="2.9.3" />
<PackageReference Include="xunit.runner.visualstudio" Version="3.1.4" />
</ItemGroup>

<ItemGroup>
<ProjectReference Include="..\Task\ExchangeRateUpdater.csproj" />
</ItemGroup>

<ItemGroup>
<Using Include="Xunit" />
</ItemGroup>

</Project>
105 changes: 102 additions & 3 deletions jobs/Backend/Readme.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,105 @@
# Mews backend developer task

We are focused on multiple backend frameworks at Mews. Depending on the job position you are applying for, you can choose among the following:
# ExchangeRateUpdater
## Overview

* [.NET](DotNet.md)
* [Ruby on Rails](RoR.md)
ExchangeRateUpdater is a .NET backend service that fetches daily foreign exchange rates from the Czech National Bank (CNB) API and exposes them via a REST API. The service is built to be production-ready, with caching, error handling, logging, and a clean architecture using MediatR, FluentValidation, and FusionCache.

The service only returns exchange rates directly provided by CNB (no calculated inverse rates), and only for the set of predefined source currencies.

## Features

- Fetches real-time daily exchange rates from CNB.

- Caches API responses for 1 hour using FusionCache.

- Resilient HTTP requests with Polly (retry + circuit breaker).

- Implements CQRS + MediatR for query handling.

- Validation pipeline with FluentValidation.

- Centralized exception handling with logging of unhandled exceptions.

- Swagger API documentation for easy exploration.


## Endpoints


| Method | Route | Description |
|--------|----------------------------------|------------------------------------|
| GET | `/api/exchange-rates` | Returns the exchange rate of source currencies |

| Parameter | Type | Description |
| ---------- | ------------------- | ------------------------------------------------------------- |
| `date` | string (yyyy-MM-dd) | Optional. Fetch rates for a specific date. Defaults to today. |
| `language` | string | Optional. Language code for CNB API response. Defaults to CZ. |


## Design Decisions

- MediatR: Decouples request handling from controller logic.

- FusionCache: Caching improves performance and reduces API calls.

- Polly: Ensures resilience against transient network failures.

- Controllers: Keep thin; business logic is in the provider/service layer.

- ExchangeRateProvider: Filters only source currencies and does not calculate derived rates.


## Design Assumptions

- All CNB rates are relative to CZK.

- Only source currencies in Utils.SourceCurrencies are relevant.

- CNB API always provides numeric Rate and Amount.

- The service does not compute inverse rates (e.g., USD/CZK if CNB provides CZK/USD).

## Limitations

- Currently only fetches rates against CZK.

- No authentication implemented for API.

- No historical data beyond the CNB daily feed.

- Limited error handling for malformed API responses.

## Future Improvements

- Implement per-user API key or token-based authentication.

- Add historical data storage for analytics and trend tracking.

- Introduce OpenTelemetry / structured logging for observability.

- Add full integration tests with CNB API sandbox.

- Expand supported currencies dynamically from CNB API.

## External Libraries / Packages

- MediatR - CQRS / mediator pattern.

- FluentValidation - request validation pipeline.

- FusionCache - caching provider.

- Polly - resiliency (retry, circuit breaker).

- Polly.Extensions.Http - HTTP-specific policies.

- Microsoft.AspNetCore.Mvc - API framework.

## Next Steps

- Ensure pipeline logging captures all unhandled exceptions.

- Integrate with CI/CD pipeline to build and run tests automatically.

- Deploy to a cloud environment Azure App Service with environment-based configuration.
58 changes: 58 additions & 0 deletions jobs/Backend/Task/Common/Exceptions/ExceptionHandler.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
using Microsoft.AspNetCore.Diagnostics;
using Microsoft.AspNetCore.Mvc;

namespace ExchangeRateUpdater.Common.Exceptions;

public sealed class ExceptionHandler : IExceptionHandler
{
private readonly Dictionary<Type, Func<HttpContext, Exception, Task>> _exceptionHandlers;

public ExceptionHandler()
{
_exceptionHandlers = new()
{
{ typeof(ValidationException), HandleValidationException },
{ typeof(NotFoundException), HandleNotFoundException }
};
}

public async ValueTask<bool> TryHandleAsync(HttpContext httpContext, Exception exception, CancellationToken cancellationToken)
{
var exceptionType = exception.GetType();

if (_exceptionHandlers.TryGetValue(exceptionType, out var exceptionHandler))
{
await exceptionHandler.Invoke(httpContext, exception);
return true;
}

return false;
}

private async Task HandleValidationException(HttpContext httpContext, Exception ex)
{
var exception = (ValidationException)ex;

httpContext.Response.StatusCode = StatusCodes.Status400BadRequest;
await httpContext.Response.WriteAsJsonAsync(new ValidationProblemDetails(exception.Errors)
{
Status = StatusCodes.Status400BadRequest,
Type = "https://tools.ietf.org/html/rfc7231#section-6.5.1"
});
}

private async Task HandleNotFoundException(HttpContext httpContext, Exception ex)
{
var exception = (NotFoundException)ex;

httpContext.Response.StatusCode = StatusCodes.Status404NotFound;

await httpContext.Response.WriteAsJsonAsync(new ProblemDetails()
{
Status = StatusCodes.Status404NotFound,
Type = "https://tools.ietf.org/html/rfc7231#section-6.5.4",
Title = "The specified resource was not found.",
Detail = exception.Message
});
}
}
24 changes: 24 additions & 0 deletions jobs/Backend/Task/Common/Exceptions/NotFoundException.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
namespace ExchangeRateUpdater.Common.Exceptions;

public sealed class NotFoundException : Exception
{
public NotFoundException() :
base()
{
}

public NotFoundException(string message)
: base(message)
{
}

public NotFoundException(string message, Exception innerException)
: base(message, innerException)
{
}

public NotFoundException(string name, object key)
: base($"Entity \"{name}\" ({key}) was not found.")
{
}
}
28 changes: 28 additions & 0 deletions jobs/Backend/Task/Common/Exceptions/ValidationException.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
using FluentValidation.Results;

namespace ExchangeRateUpdater.Common.Exceptions;

public sealed class ValidationException : Exception
{
public ValidationException()
: base("One or more validation failures have occurred.")
{
Errors = [];
}

public ValidationException(IEnumerable<ValidationFailure> failures)
: this()
{
Errors = failures
.GroupBy(e => e.PropertyName, e => e.ErrorMessage)
.ToDictionary(failureGroup => failureGroup.Key, failureGroup => failureGroup.ToArray());
}

public ValidationException(string propertyName, string errorMessage)
: this([new ValidationFailure { PropertyName = propertyName, ErrorMessage = errorMessage }])
{

}

public Dictionary<string, string[]> Errors { get; }
}
12 changes: 12 additions & 0 deletions jobs/Backend/Task/Common/Models/ApiControllerBase.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
using MediatR;
using Microsoft.AspNetCore.Mvc;

namespace ExchangeRateUpdater.Common.Models;

[ApiController]
public abstract class ApiControllerBase : ControllerBase
{
private ISender? _mediator;

protected ISender Mediator => _mediator ??= HttpContext.RequestServices.GetService<ISender>()!;
}
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
namespace ExchangeRateUpdater
namespace ExchangeRateUpdater.Common.Models
{
public class Currency
{
Expand All @@ -16,5 +16,9 @@ public override string ToString()
{
return Code;
}

public override bool Equals(object? obj) => obj is Currency other && string.Equals(Code, other.Code, StringComparison.OrdinalIgnoreCase);

public override int GetHashCode() => Code.GetHashCode(StringComparison.OrdinalIgnoreCase);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
using MediatR;

namespace ExchangeRateUpdater.Common.PipelineBehaviours;

public sealed class UnhandledExceptionPipelineBehaviour<TRequest, TResponse> : IPipelineBehavior<TRequest, TResponse> where TRequest : notnull
{
private readonly ILogger<TRequest> _logger;

public UnhandledExceptionPipelineBehaviour(ILogger<TRequest> logger)
{
_logger = logger;
}

public async Task<TResponse> Handle(TRequest request, RequestHandlerDelegate<TResponse> next, CancellationToken cancellationToken)
{
try
{
return await next();
}
catch (Exception ex)
{
var requestName = typeof(TRequest).Name;

_logger.LogError(ex, "Unhandled Exception for Request {Name} {@Request}", requestName, request);

throw;
}
}
}
Loading