diff --git a/jobs/Backend/.gitignore b/jobs/Backend/.gitignore new file mode 100644 index 0000000000..5363b0a30e --- /dev/null +++ b/jobs/Backend/.gitignore @@ -0,0 +1,483 @@ +## Ignore Visual Studio temporary files, build results, and +## files generated by popular Visual Studio add-ons. +## +## Get latest from `dotnet new gitignore` + +# dotenv files +.env + +# User-specific files +*.rsuser +*.suo +*.user +*.userosscache +*.sln.docstates + +# User-specific files (MonoDevelop/Xamarin Studio) +*.userprefs + +# Mono auto generated files +mono_crash.* + +# Build results +[Dd]ebug/ +[Dd]ebugPublic/ +[Rr]elease/ +[Rr]eleases/ +x64/ +x86/ +[Ww][Ii][Nn]32/ +[Aa][Rr][Mm]/ +[Aa][Rr][Mm]64/ +bld/ +[Bb]in/ +[Oo]bj/ +[Ll]og/ +[Ll]ogs/ + +# Visual Studio 2015/2017 cache/options directory +.vs/ +# Uncomment if you have tasks that create the project's static files in wwwroot +#wwwroot/ + +# Visual Studio 2017 auto generated files +Generated\ Files/ + +# MSTest test Results +[Tt]est[Rr]esult*/ +[Bb]uild[Ll]og.* + +# NUnit +*.VisualState.xml +TestResult.xml +nunit-*.xml + +# Build Results of an ATL Project +[Dd]ebugPS/ +[Rr]eleasePS/ +dlldata.c + +# Benchmark Results +BenchmarkDotNet.Artifacts/ + +# .NET +project.lock.json +project.fragment.lock.json +artifacts/ + +# Tye +.tye/ + +# ASP.NET Scaffolding +ScaffoldingReadMe.txt + +# StyleCop +StyleCopReport.xml + +# Files built by Visual Studio +*_i.c +*_p.c +*_h.h +*.ilk +*.meta +*.obj +*.iobj +*.pch +*.pdb +*.ipdb +*.pgc +*.pgd +*.rsp +*.sbr +*.tlb +*.tli +*.tlh +*.tmp +*.tmp_proj +*_wpftmp.csproj +*.log +*.tlog +*.vspscc +*.vssscc +.builds +*.pidb +*.svclog +*.scc + +# Chutzpah Test files +_Chutzpah* + +# Visual C++ cache files +ipch/ +*.aps +*.ncb +*.opendb +*.opensdf +*.sdf +*.cachefile +*.VC.db +*.VC.VC.opendb + +# Visual Studio profiler +*.psess +*.vsp +*.vspx +*.sap + +# Visual Studio Trace Files +*.e2e + +# TFS 2012 Local Workspace +$tf/ + +# Guidance Automation Toolkit +*.gpState +# ReSharper is a .NET coding add-in +_ReSharper*/ +*.[Rr]e[Ss]harper +*.DotSettings.user + +# TeamCity is a build add-in +_TeamCity* + +# DotCover is a Code Coverage Tool +*.dotCover + +# AxoCover is a Code Coverage Tool +.axoCover/* +!.axoCover/settings.json + +# Coverlet is a free, cross platform Code Coverage Tool +coverage*.json +coverage*.xml +coverage*.info + +# Visual Studio code coverage results +*.coverage +*.coveragexml + +# NCrunch +_NCrunch_* +.*crunch*.local.xml +nCrunchTemp_* + +# MightyMoose +*.mm.* +AutoTest.Net/ + +# Web workbench (sass) +.sass-cache/ + +# Installshield output folder +[Ee]xpress/ + +# DocProject is a documentation generator add-in +DocProject/buildhelp/ +DocProject/Help/*.HxT +DocProject/Help/*.HxC +DocProject/Help/*.hhc +DocProject/Help/*.hhk +DocProject/Help/*.hhp +DocProject/Help/Html2 +DocProject/Help/html + +# Click-Once directory +publish/ + +# Publish Web Output +*.[Pp]ublish.xml +*.azurePubxml +# Note: Comment the next line if you want to checkin your web deploy settings, +# but database connection strings (with potential passwords) will be unencrypted +*.pubxml +*.publishproj + +# Microsoft Azure Web App publish settings. Comment the next line if you want to +# checkin your Azure Web App publish settings, but sensitive information contained +# in these scripts will be unencrypted +PublishScripts/ + +# NuGet Packages +*.nupkg +# NuGet Symbol Packages +*.snupkg +# The packages folder can be ignored because of Package Restore +**/[Pp]ackages/* +# except build/, which is used as an MSBuild target. +!**/[Pp]ackages/build/ +# Uncomment if necessary however generally it will be regenerated when needed +#!**/[Pp]ackages/repositories.config +# NuGet v3's project.json files produces more ignorable files +*.nuget.props +*.nuget.targets + +# Microsoft Azure Build Output +csx/ +*.build.csdef + +# Microsoft Azure Emulator +ecf/ +rcf/ + +# Windows Store app package directories and files +AppPackages/ +BundleArtifacts/ +Package.StoreAssociation.xml +_pkginfo.txt +*.appx +*.appxbundle +*.appxupload + +# Visual Studio cache files +# files ending in .cache can be ignored +*.[Cc]ache +# but keep track of directories ending in .cache +!?*.[Cc]ache/ + +# Others +ClientBin/ +~$* +*~ +*.dbmdl +*.dbproj.schemaview +*.jfm +*.pfx +*.publishsettings +orleans.codegen.cs + +# Including strong name files can present a security risk +# (https://github.com/github/gitignore/pull/2483#issue-259490424) +#*.snk + +# Since there are multiple workflows, uncomment next line to ignore bower_components +# (https://github.com/github/gitignore/pull/1529#issuecomment-104372622) +#bower_components/ + +# RIA/Silverlight projects +Generated_Code/ + +# Backup & report files from converting an old project file +# to a newer Visual Studio version. Backup files are not needed, +# because we have git ;-) +_UpgradeReport_Files/ +Backup*/ +UpgradeLog*.XML +UpgradeLog*.htm +ServiceFabricBackup/ +*.rptproj.bak + +# SQL Server files +*.mdf +*.ldf +*.ndf + +# Business Intelligence projects +*.rdl.data +*.bim.layout +*.bim_*.settings +*.rptproj.rsuser +*- [Bb]ackup.rdl +*- [Bb]ackup ([0-9]).rdl +*- [Bb]ackup ([0-9][0-9]).rdl + +# Microsoft Fakes +FakesAssemblies/ + +# GhostDoc plugin setting file +*.GhostDoc.xml + +# Node.js Tools for Visual Studio +.ntvs_analysis.dat +node_modules/ + +# Visual Studio 6 build log +*.plg + +# Visual Studio 6 workspace options file +*.opt + +# Visual Studio 6 auto-generated workspace file (contains which files were open etc.) +*.vbw + +# Visual Studio 6 auto-generated project file (contains which files were open etc.) +*.vbp + +# Visual Studio 6 workspace and project file (working project files containing files to include in project) +*.dsw +*.dsp + +# Visual Studio 6 technical files +*.ncb +*.aps + +# Visual Studio LightSwitch build output +**/*.HTMLClient/GeneratedArtifacts +**/*.DesktopClient/GeneratedArtifacts +**/*.DesktopClient/ModelManifest.xml +**/*.Server/GeneratedArtifacts +**/*.Server/ModelManifest.xml +_Pvt_Extensions + +# Paket dependency manager +.paket/paket.exe +paket-files/ + +# FAKE - F# Make +.fake/ + +# CodeRush personal settings +.cr/personal + +# Python Tools for Visual Studio (PTVS) +__pycache__/ +*.pyc + +# Cake - Uncomment if you are using it +# tools/** +# !tools/packages.config + +# Tabs Studio +*.tss + +# Telerik's JustMock configuration file +*.jmconfig + +# BizTalk build output +*.btp.cs +*.btm.cs +*.odx.cs +*.xsd.cs + +# OpenCover UI analysis results +OpenCover/ + +# Azure Stream Analytics local run output +ASALocalRun/ + +# MSBuild Binary and Structured Log +*.binlog + +# NVidia Nsight GPU debugger configuration file +*.nvuser + +# MFractors (Xamarin productivity tool) working folder +.mfractor/ + +# Local History for Visual Studio +.localhistory/ + +# Visual Studio History (VSHistory) files +.vshistory/ + +# BeatPulse healthcheck temp database +healthchecksdb + +# Backup folder for Package Reference Convert tool in Visual Studio 2017 +MigrationBackup/ + +# Ionide (cross platform F# VS Code tools) working folder +.ionide/ + +# Fody - auto-generated XML schema +FodyWeavers.xsd + +# VS Code files for those working on multiple tools +.vscode/* +!.vscode/settings.json +!.vscode/tasks.json +!.vscode/launch.json +!.vscode/extensions.json +*.code-workspace + +# Local History for Visual Studio Code +.history/ + +# Windows Installer files from build outputs +*.cab +*.msi +*.msix +*.msm +*.msp + +# JetBrains Rider +*.sln.iml +.idea/ + +## +## Visual studio for Mac +## + + +# globs +Makefile.in +*.userprefs +*.usertasks +config.make +config.status +aclocal.m4 +install-sh +autom4te.cache/ +*.tar.gz +tarballs/ +test-results/ + +# Mac bundle stuff +*.dmg +*.app + +# content below from: https://github.com/github/gitignore/blob/main/Global/macOS.gitignore +# General +.DS_Store +.AppleDouble +.LSOverride + +# Icon must end with two \r +Icon + + +# Thumbnails +._* + +# Files that might appear in the root of a volume +.DocumentRevisions-V100 +.fseventsd +.Spotlight-V100 +.TemporaryItems +.Trashes +.VolumeIcon.icns +.com.apple.timemachine.donotpresent + +# Directories potentially created on remote AFP share +.AppleDB +.AppleDesktop +Network Trash Folder +Temporary Items +.apdisk + +# content below from: https://github.com/github/gitignore/blob/main/Global/Windows.gitignore +# Windows thumbnail cache files +Thumbs.db +ehthumbs.db +ehthumbs_vista.db + +# Dump file +*.stackdump + +# Folder config file +[Dd]esktop.ini + +# Recycle Bin used on file shares +$RECYCLE.BIN/ + +# Windows Installer files +*.cab +*.msi +*.msix +*.msm +*.msp + +# Windows shortcuts +*.lnk + +# Vim temporary swap files +*.swp \ No newline at end of file diff --git a/jobs/Backend/ExchangeRateUpdater.Unit.Tests/ExchangeRateProviderTests.cs b/jobs/Backend/ExchangeRateUpdater.Unit.Tests/ExchangeRateProviderTests.cs new file mode 100644 index 0000000000..3bfa51c176 --- /dev/null +++ b/jobs/Backend/ExchangeRateUpdater.Unit.Tests/ExchangeRateProviderTests.cs @@ -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 { new("USD"), new("EUR"), new("CZK") }; + + var sourceRates = new List + { + 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 { new("USD"), new("CZK") }; + var sourceRates = new List(); + + // 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(); + var sourceRates = new List() + { + new() + { + CurrencyCode = "USD", + Rate = 22.5m + } + }; + + // Act + var results = provider.GetExchangeRates(currencies, sourceRates); + + // Assert + Assert.Empty(results); + } +} diff --git a/jobs/Backend/ExchangeRateUpdater.Unit.Tests/ExchangeRateUpdater.Unit.Tests.csproj b/jobs/Backend/ExchangeRateUpdater.Unit.Tests/ExchangeRateUpdater.Unit.Tests.csproj new file mode 100644 index 0000000000..a9999bf041 --- /dev/null +++ b/jobs/Backend/ExchangeRateUpdater.Unit.Tests/ExchangeRateUpdater.Unit.Tests.csproj @@ -0,0 +1,26 @@ + + + + net10.0 + enable + enable + false + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/jobs/Backend/Readme.md b/jobs/Backend/Readme.md index f2195e44dd..2f63d2aca6 100644 --- a/jobs/Backend/Readme.md +++ b/jobs/Backend/Readme.md @@ -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. \ No newline at end of file diff --git a/jobs/Backend/Task/Common/Exceptions/ExceptionHandler.cs b/jobs/Backend/Task/Common/Exceptions/ExceptionHandler.cs new file mode 100644 index 0000000000..14aeb28b27 --- /dev/null +++ b/jobs/Backend/Task/Common/Exceptions/ExceptionHandler.cs @@ -0,0 +1,58 @@ +using Microsoft.AspNetCore.Diagnostics; +using Microsoft.AspNetCore.Mvc; + +namespace ExchangeRateUpdater.Common.Exceptions; + +public sealed class ExceptionHandler : IExceptionHandler +{ + private readonly Dictionary> _exceptionHandlers; + + public ExceptionHandler() + { + _exceptionHandlers = new() + { + { typeof(ValidationException), HandleValidationException }, + { typeof(NotFoundException), HandleNotFoundException } + }; + } + + public async ValueTask 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 + }); + } +} diff --git a/jobs/Backend/Task/Common/Exceptions/NotFoundException.cs b/jobs/Backend/Task/Common/Exceptions/NotFoundException.cs new file mode 100644 index 0000000000..9eeeb7cd53 --- /dev/null +++ b/jobs/Backend/Task/Common/Exceptions/NotFoundException.cs @@ -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.") + { + } +} \ No newline at end of file diff --git a/jobs/Backend/Task/Common/Exceptions/ValidationException.cs b/jobs/Backend/Task/Common/Exceptions/ValidationException.cs new file mode 100644 index 0000000000..c4a4cbb606 --- /dev/null +++ b/jobs/Backend/Task/Common/Exceptions/ValidationException.cs @@ -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 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 Errors { get; } +} diff --git a/jobs/Backend/Task/Common/Models/ApiControllerBase.cs b/jobs/Backend/Task/Common/Models/ApiControllerBase.cs new file mode 100644 index 0000000000..2174a4ab72 --- /dev/null +++ b/jobs/Backend/Task/Common/Models/ApiControllerBase.cs @@ -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()!; +} \ No newline at end of file diff --git a/jobs/Backend/Task/Currency.cs b/jobs/Backend/Task/Common/Models/Currency.cs similarity index 54% rename from jobs/Backend/Task/Currency.cs rename to jobs/Backend/Task/Common/Models/Currency.cs index f375776f25..5d711e0560 100644 --- a/jobs/Backend/Task/Currency.cs +++ b/jobs/Backend/Task/Common/Models/Currency.cs @@ -1,4 +1,4 @@ -namespace ExchangeRateUpdater +namespace ExchangeRateUpdater.Common.Models { public class Currency { @@ -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); } } diff --git a/jobs/Backend/Task/Common/PipelineBehaviours/UnhandledExceptionPipelineBehaviour.cs b/jobs/Backend/Task/Common/PipelineBehaviours/UnhandledExceptionPipelineBehaviour.cs new file mode 100644 index 0000000000..1c1a0b9b20 --- /dev/null +++ b/jobs/Backend/Task/Common/PipelineBehaviours/UnhandledExceptionPipelineBehaviour.cs @@ -0,0 +1,29 @@ +using MediatR; + +namespace ExchangeRateUpdater.Common.PipelineBehaviours; + +public sealed class UnhandledExceptionPipelineBehaviour : IPipelineBehavior where TRequest : notnull +{ + private readonly ILogger _logger; + + public UnhandledExceptionPipelineBehaviour(ILogger logger) + { + _logger = logger; + } + + public async Task Handle(TRequest request, RequestHandlerDelegate 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; + } + } +} \ No newline at end of file diff --git a/jobs/Backend/Task/Common/PipelineBehaviours/ValidationPipelineBehaviour.cs b/jobs/Backend/Task/Common/PipelineBehaviours/ValidationPipelineBehaviour.cs new file mode 100644 index 0000000000..8a1e7ba788 --- /dev/null +++ b/jobs/Backend/Task/Common/PipelineBehaviours/ValidationPipelineBehaviour.cs @@ -0,0 +1,36 @@ +using FluentValidation; +using MediatR; + +namespace ExchangeRateUpdater.Common.PipelineBehaviours; + +public sealed class ValidationPipelineBehaviour + : IPipelineBehavior where TRequest : notnull +{ + private readonly IEnumerable> _validators; + public ValidationPipelineBehaviour(IEnumerable> validators) + { + _validators = validators; + } + public async Task Handle(TRequest request, RequestHandlerDelegate next, CancellationToken cancellationToken) + { + if (_validators.Any()) + { + var context = new ValidationContext(request); + + var validationResults = await Task.WhenAll( + _validators.Select(v => + v.ValidateAsync(context, cancellationToken))); + + var failures = validationResults + .Where(r => r.Errors.Count != 0) + .SelectMany(r => r.Errors) + .ToList(); + + if (failures.Count != 0) + { + throw new ValidationException(failures); + } + } + return await next(); + } +} diff --git a/jobs/Backend/Task/ExchangeRateProvider.cs b/jobs/Backend/Task/ExchangeRateProvider.cs deleted file mode 100644 index 6f82a97fbe..0000000000 --- a/jobs/Backend/Task/ExchangeRateProvider.cs +++ /dev/null @@ -1,19 +0,0 @@ -using System.Collections.Generic; -using System.Linq; - -namespace ExchangeRateUpdater -{ - public class ExchangeRateProvider - { - /// - /// 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. - /// - public IEnumerable GetExchangeRates(IEnumerable currencies) - { - return Enumerable.Empty(); - } - } -} diff --git a/jobs/Backend/Task/ExchangeRateUpdater.csproj b/jobs/Backend/Task/ExchangeRateUpdater.csproj index 2fc654a12b..2a39dbed99 100644 --- a/jobs/Backend/Task/ExchangeRateUpdater.csproj +++ b/jobs/Backend/Task/ExchangeRateUpdater.csproj @@ -1,8 +1,27 @@ - - - - Exe - net6.0 - - + + + + Exe + net10.0 + enable + enable + ExchangeRateUpdater + ExchangeRateUpdater + + + + + + + + + + + + + + Always + + + \ No newline at end of file diff --git a/jobs/Backend/Task/ExchangeRateUpdater.sln b/jobs/Backend/Task/ExchangeRateUpdater.sln index 89be84daff..fc62d6e79c 100644 --- a/jobs/Backend/Task/ExchangeRateUpdater.sln +++ b/jobs/Backend/Task/ExchangeRateUpdater.sln @@ -1,22 +1,31 @@ - -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}" -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 - EndGlobalSection - GlobalSection(SolutionProperties) = preSolution - HideSolutionNode = FALSE - EndGlobalSection -EndGlobal + +Microsoft Visual Studio Solution File, Format Version 12.00 +# Visual Studio Version 18 +VisualStudioVersion = 18.0.11205.157 d18.0 +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.Unit.Tests", "..\ExchangeRateUpdater.Unit.Tests\ExchangeRateUpdater.Unit.Tests.csproj", "{27C4061B-CD71-4AD9-B3E8-9675A4B0599A}" +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 + {27C4061B-CD71-4AD9-B3E8-9675A4B0599A}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {27C4061B-CD71-4AD9-B3E8-9675A4B0599A}.Debug|Any CPU.Build.0 = Debug|Any CPU + {27C4061B-CD71-4AD9-B3E8-9675A4B0599A}.Release|Any CPU.ActiveCfg = Release|Any CPU + {27C4061B-CD71-4AD9-B3E8-9675A4B0599A}.Release|Any CPU.Build.0 = Release|Any CPU + EndGlobalSection + GlobalSection(SolutionProperties) = preSolution + HideSolutionNode = FALSE + EndGlobalSection + GlobalSection(ExtensibilityGlobals) = postSolution + SolutionGuid = {C9B4B2DE-2E4A-4A7F-8DD2-45AD13560715} + EndGlobalSection +EndGlobal diff --git a/jobs/Backend/Task/ExchangeRate.cs b/jobs/Backend/Task/Features/ExchangeRates/Models/ExchangeRate.cs similarity index 83% rename from jobs/Backend/Task/ExchangeRate.cs rename to jobs/Backend/Task/Features/ExchangeRates/Models/ExchangeRate.cs index 58c5bb10e0..b50c3f23ca 100644 --- a/jobs/Backend/Task/ExchangeRate.cs +++ b/jobs/Backend/Task/Features/ExchangeRates/Models/ExchangeRate.cs @@ -1,4 +1,6 @@ -namespace ExchangeRateUpdater +using ExchangeRateUpdater.Common.Models; + +namespace ExchangeRateUpdater.Features.ExchangeRates.Models { public class ExchangeRate { diff --git a/jobs/Backend/Task/Features/ExchangeRates/Models/ExchangeRateResponse.cs b/jobs/Backend/Task/Features/ExchangeRates/Models/ExchangeRateResponse.cs new file mode 100644 index 0000000000..d9b4a86ac8 --- /dev/null +++ b/jobs/Backend/Task/Features/ExchangeRates/Models/ExchangeRateResponse.cs @@ -0,0 +1,12 @@ +namespace ExchangeRateUpdater.Features.ExchangeRates.Models; + +public sealed class ExchangeRateResponse +{ + public string? ValidFor { get; init; } + public int Order { get; init; } + public string? Country { get; init; } + public string? Currency { get; init; } + public decimal Amount { get; init; } + public string? CurrencyCode { get; init; } + public decimal Rate { get; init; } +} diff --git a/jobs/Backend/Task/Features/ExchangeRates/Queries/GetExchangeRates.cs b/jobs/Backend/Task/Features/ExchangeRates/Queries/GetExchangeRates.cs new file mode 100644 index 0000000000..c7cb2f95a5 --- /dev/null +++ b/jobs/Backend/Task/Features/ExchangeRates/Queries/GetExchangeRates.cs @@ -0,0 +1,46 @@ +using ExchangeRateUpdater.Common.Models; +using ExchangeRateUpdater.Features.ExchangeRates.Models; +using ExchangeRateUpdater.Services; +using MediatR; +using Microsoft.AspNetCore.Mvc; + +namespace ExchangeRateUpdater.Features.ExchangeRates.Queries; + +public sealed class GetExchangeRatesController : ApiControllerBase +{ + [HttpGet] + [Route("api/exchange-rates")] + public async Task GetAsync([FromQuery] DateOnly? date, [FromQuery] string? language, CancellationToken cancellationToken) + { + var results = await Mediator.Send(new GetExchangeRatesQuery(date, language), cancellationToken); + return Ok(results); + } +} + +public sealed record GetExchangeRatesQuery( + DateOnly? Date, + string? Language) : IRequest>; + +public sealed class GetExchangeRatesQueryHandler : IRequestHandler> +{ + private readonly IAPIClient _apiClient; + private readonly ILogger _logger; + private readonly IExchangeRateProvider _provider; + public GetExchangeRatesQueryHandler( + IAPIClient apiClient, + IExchangeRateProvider provider, + ILogger logger) + { + _apiClient = apiClient; + _provider = provider; + _logger = logger; + } + public async Task> Handle(GetExchangeRatesQuery request, CancellationToken cancellationToken) + { + _logger.LogInformation("Calling exchange rate api....."); + + var rawData = await _apiClient.GetExchangeRatesDailyAsync(request.Date, request.Language, cancellationToken); + + return _provider.GetExchangeRates(Utils.SourceCurrencies, rawData); + } +} \ No newline at end of file diff --git a/jobs/Backend/Task/Program.cs b/jobs/Backend/Task/Program.cs index 379a69b1f8..07757effc5 100644 --- a/jobs/Backend/Task/Program.cs +++ b/jobs/Backend/Task/Program.cs @@ -1,43 +1,74 @@ -using System; -using System.Collections.Generic; -using System.Linq; - -namespace ExchangeRateUpdater -{ - public static class Program - { - private static IEnumerable currencies = new[] - { - new Currency("USD"), - new Currency("EUR"), - new Currency("CZK"), - new Currency("JPY"), - new Currency("KES"), - new Currency("RUB"), - new Currency("THB"), - new Currency("TRY"), - new Currency("XYZ") - }; - - public static void Main(string[] args) - { - try - { - var provider = new ExchangeRateProvider(); - var rates = provider.GetExchangeRates(currencies); - - 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(); - } - } -} +using ExchangeRateUpdater.Common.Exceptions; +using ExchangeRateUpdater.Common.PipelineBehaviours; +using ExchangeRateUpdater.Services; +using FluentValidation; +using MediatR; +using Microsoft.AspNetCore.Mvc; +using Polly; +using Polly.Extensions.Http; +using System.Net.Http.Headers; +using System.Reflection; + +var builder = WebApplication.CreateBuilder(args); + +builder.Services.AddValidatorsFromAssembly(Assembly.GetExecutingAssembly()); +builder.Services.AddMediatR(cfg => +{ + cfg.RegisterServicesFromAssembly(Assembly.GetExecutingAssembly()); + cfg.AddBehavior(typeof(IPipelineBehavior<,>), typeof(UnhandledExceptionPipelineBehaviour<,>)); + cfg.AddBehavior(typeof(IPipelineBehavior<,>), typeof(ValidationPipelineBehaviour<,>)); +}); +builder.Services.AddControllers(); +builder.Services.AddExceptionHandler(); +builder.Services.AddProblemDetails(); +builder.Services.Configure(options => + options.SuppressModelStateInvalidFilter = true); + +builder.Services.AddMemoryCache(); +builder.Services.AddFusionCache(); + +// Retry policy (exponential backoff) +var retryPolicy = HttpPolicyExtensions + .HandleTransientHttpError() + .WaitAndRetryAsync(3, retryAttempt => + TimeSpan.FromSeconds(Math.Pow(2, retryAttempt))); + +// Circuit breaker policy +var circuitBreakerPolicy = HttpPolicyExtensions + .HandleTransientHttpError() + .CircuitBreakerAsync(2, TimeSpan.FromSeconds(30)); + +builder.Services.AddHttpClient( c => { + c.BaseAddress = new Uri(builder.Configuration["CnbExchangeRatesApi"] ?? throw new InvalidOperationException("Missing Configuration Value : CnbApi")); + c.DefaultRequestHeaders.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json")); +}) +.AddPolicyHandler(retryPolicy) +.AddPolicyHandler(circuitBreakerPolicy); + +builder.Services.AddSingleton(); + +builder.Services.AddHealthChecks(); + +//Swagger +builder.Services.AddEndpointsApiExplorer(); +builder.Services.AddSwaggerGen(); + +builder.Logging.ClearProviders(); +builder.Logging.AddConsole(); +builder.Logging.AddDebug(); + + +var app = builder.Build(); + +if (app.Environment.IsDevelopment()) +{ + app.UseSwagger(); + app.UseSwaggerUI(); +} + +app.UseExceptionHandler(); +app.UseHealthChecks("/hc"); +app.UseHttpsRedirection(); +app.UseRouting(); +app.MapControllers(); +app.Run(); \ No newline at end of file diff --git a/jobs/Backend/Task/Services/IAPIClient.cs b/jobs/Backend/Task/Services/IAPIClient.cs new file mode 100644 index 0000000000..d57a8a500f --- /dev/null +++ b/jobs/Backend/Task/Services/IAPIClient.cs @@ -0,0 +1,68 @@ +using ExchangeRateUpdater.Features.ExchangeRates.Models; +using Microsoft.AspNetCore.WebUtilities; +using ZiggyCreatures.Caching.Fusion; + +namespace ExchangeRateUpdater.Services; + +public interface IAPIClient +{ + Task> GetExchangeRatesDailyAsync(DateOnly? date, string? language, CancellationToken ct); +} + +public sealed class ExchangeRateResponseWrapper +{ + public List Rates { get; set; } = []; +} +public sealed class APIClient : IAPIClient +{ + private readonly HttpClient _httpClient; + private readonly IFusionCache _cache; + private readonly ILogger _logger; + public APIClient(HttpClient httpClient, IFusionCache cache, ILogger logger) + { + _httpClient = httpClient; + _cache = cache; + _logger = logger; + } + + public async Task> GetExchangeRatesDailyAsync(DateOnly? date, string? language, CancellationToken ct) + { + var queryParams = new Dictionary + { + {"date", DateTime.Now.ToString("yyyy-MM-dd")}, + {"lang", "CZ"} + }; + + if (date is not null) + { + queryParams["date"] = date.Value.ToString("yyyy-MM-dd"); + } + + if (!string.IsNullOrWhiteSpace(language)) + { + queryParams["lang"] = language; + } + + try + { + var cacheKey = $"exchangerates-date:{queryParams["date"]}-language:{queryParams["lang"]}"; + return await _cache.GetOrSetAsync(cacheKey, + async _ => + { + string url = queryParams.Any() ? QueryHelpers.AddQueryString("daily", queryParams) : "daily"; + var response = await _httpClient.GetAsync(url, ct); + response.EnsureSuccessStatusCode(); + + var wrapper = await response.Content.ReadFromJsonAsync(cancellationToken: ct); + return wrapper?.Rates ?? []; + }, + options => options.SetDuration(TimeSpan.FromHours(1)), + token: ct); + } + catch (Exception ex) + { + _logger.LogError($"Exception occured while fetching details from exchange api: {ex.Message}"); + throw; + } + } +} \ No newline at end of file diff --git a/jobs/Backend/Task/Services/IExchangeRateProvider.cs b/jobs/Backend/Task/Services/IExchangeRateProvider.cs new file mode 100644 index 0000000000..a9937ea9a2 --- /dev/null +++ b/jobs/Backend/Task/Services/IExchangeRateProvider.cs @@ -0,0 +1,31 @@ +using ExchangeRateUpdater.Common.Models; +using ExchangeRateUpdater.Features.ExchangeRates.Models; + +namespace ExchangeRateUpdater.Services; + +public interface IExchangeRateProvider +{ + /// + /// 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. + /// + IEnumerable GetExchangeRates(IEnumerable currencies, IEnumerable sourceRates); +} +public sealed class ExchangeRateProvider : IExchangeRateProvider +{ + public IEnumerable GetExchangeRates(IEnumerable currencies, IEnumerable sourceRates) + { + var currencyCodes = currencies.Select(x => x.Code).ToHashSet(StringComparer.OrdinalIgnoreCase); + + return sourceRates + .Where(rate => rate.CurrencyCode != null && currencyCodes.Contains(rate.CurrencyCode)) + .Select(rate => + new ExchangeRate( + new Currency(rate.CurrencyCode!), + new Currency("CZK"), + rate.Rate)) + .ToList(); + } +} diff --git a/jobs/Backend/Task/Services/Utils.cs b/jobs/Backend/Task/Services/Utils.cs new file mode 100644 index 0000000000..cf7b08cb45 --- /dev/null +++ b/jobs/Backend/Task/Services/Utils.cs @@ -0,0 +1,20 @@ +using ExchangeRateUpdater.Common.Models; + +namespace ExchangeRateUpdater.Services; + +public static class Utils +{ + public static List SourceCurrencies => + [ + new Currency("USD"), + new Currency("EUR"), + new Currency("CZK"), + new Currency("JPY"), + new Currency("KES"), + new Currency("RUB"), + new Currency("THB"), + new Currency("TRY"), + new Currency("XYZ") + ]; +} + diff --git a/jobs/Backend/Task/appsettings.json b/jobs/Backend/Task/appsettings.json new file mode 100644 index 0000000000..9d59548f26 --- /dev/null +++ b/jobs/Backend/Task/appsettings.json @@ -0,0 +1,11 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft": "Warning", + "Microsoft.Hosting.Lifetime": "Information" + } + }, + + "CnbExchangeRatesApi": "https://api.cnb.cz/cnbapi/exrates/" +}