Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[Sorin Stancu] - Backend Task #651

Open
wants to merge 3 commits into
base: master
Choose a base branch
from
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
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.

31 changes: 24 additions & 7 deletions jobs/Backend/Task/ExchangeRateUpdater.sln
Original file line number Diff line number Diff line change
@@ -1,22 +1,39 @@

Microsoft Visual Studio Solution File, Format Version 12.00
# Visual Studio 14
VisualStudioVersion = 14.0.25123.0
# Visual Studio Version 17
VisualStudioVersion = 17.11.35327.3
MinimumVisualStudioVersion = 10.0.40219.1
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ExchangeRateUpdater", "ExchangeRateUpdater.csproj", "{7B2695D6-D24C-4460-A58E-A10F08550CE0}"
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "src", "src", "{07118310-E948-4327-B32E-7CC4C834DDFE}"
EndProject
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "test", "test", "{A7FF1704-D668-4FEE-9A67-21FA34358614}"
EndProject
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "ExchangeRateUpdater.Tests", "test\ExchangeRateUpdater.Tests\ExchangeRateUpdater.Tests.csproj", "{C6753F8E-93E5-45FD-AACF-E8E920142A6F}"
EndProject
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "ExchangeRateUpdater", "src\ExchangeRateUpdater\ExchangeRateUpdater.csproj", "{A87805F0-0EB0-44B3-85DA-C70DD72AF0F2}"
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
{C6753F8E-93E5-45FD-AACF-E8E920142A6F}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{C6753F8E-93E5-45FD-AACF-E8E920142A6F}.Debug|Any CPU.Build.0 = Debug|Any CPU
{C6753F8E-93E5-45FD-AACF-E8E920142A6F}.Release|Any CPU.ActiveCfg = Release|Any CPU
{C6753F8E-93E5-45FD-AACF-E8E920142A6F}.Release|Any CPU.Build.0 = Release|Any CPU
{A87805F0-0EB0-44B3-85DA-C70DD72AF0F2}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{A87805F0-0EB0-44B3-85DA-C70DD72AF0F2}.Debug|Any CPU.Build.0 = Debug|Any CPU
{A87805F0-0EB0-44B3-85DA-C70DD72AF0F2}.Release|Any CPU.ActiveCfg = Release|Any CPU
{A87805F0-0EB0-44B3-85DA-C70DD72AF0F2}.Release|Any CPU.Build.0 = Release|Any CPU
EndGlobalSection
GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE
EndGlobalSection
GlobalSection(NestedProjects) = preSolution
{C6753F8E-93E5-45FD-AACF-E8E920142A6F} = {A7FF1704-D668-4FEE-9A67-21FA34358614}
{A87805F0-0EB0-44B3-85DA-C70DD72AF0F2} = {07118310-E948-4327-B32E-7CC4C834DDFE}
EndGlobalSection
GlobalSection(ExtensibilityGlobals) = postSolution
SolutionGuid = {56D2B0E7-2A0B-4082-A8C4-6BFDB5EDEC32}
EndGlobalSection
EndGlobal
43 changes: 0 additions & 43 deletions jobs/Backend/Task/Program.cs

This file was deleted.

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

## Running locally
You can run the ExchangeRateUpdater
1. using an IDE of your choice (Visual Studio, Visual Studio Code, Rider)
2. or if you want to use the provided Docker file you can use these commands in the folder with the dockerfile :
`docker build -t exchangerateupdater .`
`docker run --rm exchangerateupdater`
You need to have Docker installed and running for this to work

## How it works:
The list of currencies we are requesting exchange rates to be retrieved for is in `appsettings.json` file and it is loaded from there at runtime and injected into the AppService class.
We are retrieving the exchange rates for the day from the CNB api endpoint: `https://api.cnb.cz/cnbapi/exrates/daily`. Then we display the rates for all the valid currency codes requested that were present in the CNB source.

# Notes
Regarding the structure of the solution: I usually use Clean Architecture model for my solutions but since this is a very simple console app I chose to structure my project using basic folders named suggestively.

Keeping in mind that this was supposed to be implemented as a service that should be production ready, I chose to take advantage of the .NET Core 6 (I have not updated the project to the latest version) DI mechanism
so I moved the already provided code from the Program.cs class into the AppService class which is now the entry point for the app and it is registered and starts as an IHostedService.

For retrieving the exchange rates I used the CNB api endpoint: `https://api.cnb.cz/cnbapi/exrates/daily` as it seemed most appropriate. I configured the http client to have a retry mechanism with exponential backoff for
transient issues and the number of retries can be configured in the appsettings. When implementing this, since the CNB documentation for their APIs doesn't mention this, I tested to see if the endpoint has some form of rate limiting.
I hit the endpoint a few thousand times in 2 minutes and I was not limited. Therefore I did not implement a circuit breaker for this.

Since this solution is for providing the exchange rates between different currencies and CZK I decided to keep this simple and not overengineer it at this time but should we want to extend this to support other conversions
we can make the ExchangeRateMapper and the ExchangeRateProvider more generic to accept the target currencies dinamically and make these passable from appsettings as well. For now I tried to follow the KISS and YAGNI principles
and keep the solution simpler.

Things that can be improved if this service was to run in real world:
1. Since the exchange rates are not changing very often we can implement a caching mechanism to store the rates and improve performance. The number of rates returned by the CNB api is relatively small so we could
even use an in memory cache and if the number increases we can use something else like Redis
2. Add some metrics for better observability (for example the calls duration to the external APIs etc)
3. Have the strings representing the log messages centralised in a static class so we would change them in one place if needed but for now I wanted to keep things simple.
4. Better unit test coverage, integration testing if there are other services in the pipeline, mutation testing, end-to-end testing.
19 changes: 19 additions & 0 deletions jobs/Backend/Task/dockerfile
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
FROM mcr.microsoft.com/dotnet/sdk:6.0-alpine AS build
WORKDIR /app

ARG BUILDCONFIG=Release
ARG VERSION=1.0.0

COPY ["./src/ExchangeRateUpdater/ExchangeRateUpdater.csproj", "./src/ExchangeRateUpdater/"]
COPY ["nuget.config", "./src/"]

RUN dotnet restore "./src/ExchangeRateUpdater/ExchangeRateUpdater.csproj" --configfile ./src/nuget.config

COPY . .
WORKDIR "./src/ExchangeRateUpdater/"
RUN dotnet publish -c ${BUILDCONFIG} -o out /p:Version=${VERSION}

FROM mcr.microsoft.com/dotnet/runtime:6.0-alpine AS runtime
WORKDIR /app
COPY --from=build /app/src/ExchangeRateUpdater/out ./
ENTRYPOINT ["dotnet", "ExchangeRateUpdater.dll"]
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" value="https://api.nuget.org/v3/index.json" />
</packageSources>
</configuration>
68 changes: 68 additions & 0 deletions jobs/Backend/Task/src/ExchangeRateUpdater/AppService.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
using ExchangeRateUpdater.Models;
using ExchangeRateUpdater.Services;
using ExchangeRateUpdater.Settings;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;

namespace ExchangeRateUpdater
{
public class AppService : IHostedService
{
private readonly ILogger<AppService> _logger;
private readonly IExchangeRateProvider _exchangeRateProvider;
private readonly IEnumerable<string> _supportedCurrencyCodes;

public AppService(IExchangeRateProvider exchangeRateProvider, IOptions<CurrencySettings> currencyOptions, ILogger<AppService> logger)
{
_exchangeRateProvider = exchangeRateProvider;
_supportedCurrencyCodes = currencyOptions.Value.SupportedCurrencies;
_logger = logger;
}

public async Task StartAsync(CancellationToken cancellationToken)
{
_logger.LogInformation("Starting application.");
await DisplayExchangeRatesAsync(cancellationToken);
}

public async Task StopAsync(CancellationToken cancellationToken)
{
_logger.LogInformation("Stopping application.");
}

public async Task DisplayExchangeRatesAsync(CancellationToken cancellationToken)
{
try
{
var currencies = _supportedCurrencyCodes.Select(c => new Currency(c));

var rates = await _exchangeRateProvider.GetExchangeRatesAsync(currencies, date: DateTime.UtcNow, cancellationToken: cancellationToken);

if (!rates.Any())
{
Console.WriteLine("There are no rates to display");
return;
}

Console.WriteLine($"Successfully retrieved {rates.Count()} exchange rates:");
foreach (var rate in rates)
{
Console.WriteLine(rate.ToString());
}
}
catch (Exception e)
{
Console.WriteLine($"Could not retrieve exchange rates: '{e.Message}'.");
}

Console.ReadLine();
}

}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
using ExchangeRateUpdater.Settings;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using System;
using Polly;

namespace ExchangeRateUpdater.DependencyInjection
{
public static class ServiceCollectionExtensions
{
public static void AddApiClients(this IServiceCollection services, IConfiguration configuration, string settingsSectionName, string clientName)
{
var random = new Random();
var apiClientSettings = new ApiClientSettings();
configuration.GetSection(settingsSectionName).Bind(apiClientSettings);

services.AddHttpClient(clientName,
client => { client.BaseAddress = new Uri(apiClientSettings.BaseAddress); })
.AddTransientHttpErrorPolicy(x =>
x.WaitAndRetryAsync(apiClientSettings.RetrySettings.MaxRetries,
times => TimeSpan.FromSeconds(Math.Pow(2, times)) + TimeSpan.FromMilliseconds(random.Next(0, 1000))));
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
<Project Sdk="Microsoft.NET.Sdk">

<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net6.0</TargetFramework>
</PropertyGroup>

<ItemGroup>
<PackageReference Include="Microsoft.Extensions.Hosting" Version="8.0.1" />
<PackageReference Include="Microsoft.Extensions.Http" Version="8.0.1" />
<PackageReference Include="Microsoft.Extensions.Http.Polly" Version="8.0.10" />
<PackageReference Include="Polly" Version="8.4.2" />
</ItemGroup>

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

</Project>
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
using ExchangeRateUpdater.Models;
using System.Collections.Generic;
using System.Linq;

namespace ExchangeRateUpdater.Mappers
{
public static class ExchangeRateMapper
{
public static IEnumerable<ExchangeRate> MapApiFxRatesToExchangeRates(IEnumerable<FxRate> apiFxRates)
{
return apiFxRates?.Select(x => new ExchangeRate(new Currency(x.CurrencyCode), new Currency("CZK"), (decimal)x.Rate))
?? Enumerable.Empty<ExchangeRate>();
}
}
}
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
namespace ExchangeRateUpdater
namespace ExchangeRateUpdater.Models
{
public class Currency
{
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
using System.Collections.Generic;

namespace ExchangeRateUpdater.Models
{
public class ExchangeApiResponse
{
public List<FxRate> Rates { get; set; }
}
}

Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
namespace ExchangeRateUpdater
namespace ExchangeRateUpdater.Models
{
public class ExchangeRate
{
Expand Down
16 changes: 16 additions & 0 deletions jobs/Backend/Task/src/ExchangeRateUpdater/Models/FxRate.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
using System;

namespace ExchangeRateUpdater.Models
{
public class FxRate
{
public DateTime ValidFor { get; set; }
public int Order { get; set; }
public string Country { get; set; }
public string Currency { get; set; }
public decimal Amount { get; set; }
public string CurrencyCode { get; set; }
public double Rate { get; set; }
}
}

28 changes: 28 additions & 0 deletions jobs/Backend/Task/src/ExchangeRateUpdater/Program.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
using System.Threading.Tasks;
using ExchangeRateUpdater.DependencyInjection;
using ExchangeRateUpdater.Services;
using ExchangeRateUpdater.Settings;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;

namespace ExchangeRateUpdater
{
public static class Program
{
public static async Task Main(string[] args)
{
await CreateHostBuilder(args).Build().RunAsync();
}

private static IHostBuilder CreateHostBuilder(string[] args) =>
Host.CreateDefaultBuilder()
.ConfigureServices((context, services) =>
{
services.Configure<CurrencySettings>(context.Configuration.GetSection("CurrencySettings"));
services.AddApiClients(context.Configuration, "CNBExchangeRatesApiSettings", "CNBExchangeRatesApi");
services.AddTransient<IFxRateService, CNBFxRateService>();
services.AddTransient<IExchangeRateProvider, ExchangeRateProvider>();
services.AddHostedService<AppService>();
});
}
}
Loading