From da3ea9c1b2d2868479f6a3142725a4a323d27551 Mon Sep 17 00:00:00 2001 From: Nik Lebn Date: Sun, 24 Aug 2025 16:18:38 +0200 Subject: [PATCH] solution has benn created --- .vs/VSWorkspaceState.json | 7 + jobs/Backend/Task/.dockerignore | 30 ++ jobs/Backend/Task/.gitattributes | 63 +++ jobs/Backend/Task/.gitignore | 366 ++++++++++++++++++ jobs/Backend/Task/Currency.cs | 20 - jobs/Backend/Task/Directory.Build.props | 6 + jobs/Backend/Task/Directory.Packages.props | 36 ++ jobs/Backend/Task/ExchangeRate.cs | 23 -- jobs/Backend/Task/ExchangeRateProvider.cs | 19 - jobs/Backend/Task/ExchangeRateUpdater.csproj | 8 - jobs/Backend/Task/ExchangeRateUpdater.sln | 22 -- .../Abstractions/IApiEndpoint.cs | 15 + .../DependencyInjection.cs | 131 +++++++ .../ErrorHandling/GlobalExceptionHandler.cs | 38 ++ .../Extensions/EndpointResultsExtensions.cs | 39 ++ .../Extensions/MapEndpointExtensions.cs | 46 +++ ...Mews.ExchangeRateMonitor.Common.API.csproj | 21 + .../RateLimiting/RateLimiting.Constants.cs | 6 + .../HandlerRegistrationExtensions.cs | 42 ++ .../Extensions/ValidationExtensions.cs | 28 ++ ...hangeRateMonitor.Common.Application.csproj | 19 + .../Handlers/IHandler.cs | 6 + ...s.ExchangeRateMonitor.Common.Domain.csproj | 9 + .../Results/Error.cs | 74 ++++ .../Results/ErrorType.cs | 14 + .../Results/IAppResult.cs | 35 ++ .../Results/Result.ImplicitConverters.cs | 50 +++ .../Results/Result.cs | 99 +++++ .../DependencyInjection.cs | 40 ++ ...geRateMonitor.Common.Infrastructure.csproj | 21 + .../ErrorTests.cs | 210 ++++++++++ ...changeRateMonitor.Common.Tests.Unit.csproj | 25 ++ .../ResultImplicitConversionTests.cs | 70 ++++ .../ResultTests.cs | 128 ++++++ .../Currency.cs | 19 + .../CurrencyExchangeRate.cs | 25 ++ ...angeRateMonitor.ExchangeRate.Domain.csproj | 9 + .../ExchangeRatesModuleRegistration.cs | 75 ++++ .../Features/ExchangeRateProvider.cs | 81 ++++ .../GetExratesDaily.Contracts.cs | 5 + .../GetExratesDaily.Endpoint.cs | 31 ++ .../GetExratesDaily.ExternalContracts.cs | 12 + .../GetExratesDaily.Handler.cs | 38 ++ .../GetExratesDaily.Mappings.cs | 13 + .../GetExratesDaily.Validators.cs | 16 + ...geRateMonitor.ExchangeRate.Features.csproj | 22 ++ .../Options/CnbExratesOptions.cs | 7 + .../Options/ExchangeRateModuleOptions.cs | 6 + .../Shared/HttpClient/HttpClientConsts.cs | 6 + .../Shared/Routes/RouteConsts.cs | 8 + ...Monitor.ExchangeRate.Infrastructure.csproj | 14 + .../Features/ExchangeRateProviderTests.cs | 109 ++++++ .../GetExratesDailyHandlerTests.cs | 73 ++++ ...RateMonitor.ExchangeRate.Tests.Unit.csproj | 28 ++ .../TestHelpers/StaticResponseHandler.cs | 11 + .../Mews.ExchangeRateMonitor.Host/Dockerfile | 30 ++ .../Mews.ExchangeRateMonitor.Host.csproj | 22 ++ .../Mews.ExchangeRateMonitor.Host/Program.cs | 18 + .../Properties/launchSettings.json | 31 ++ .../appsettings.Development.json | 39 ++ .../appsettings.json | 9 + .../Backend/Task/Mews.ExchangeRateMonitor.sln | 117 ++++++ jobs/Backend/Task/Program.cs | 43 -- jobs/Backend/Task/README.md | 53 +++ jobs/Backend/Task/docker-compose.dcproj | 16 + jobs/Backend/Task/docker-compose.override.yml | 25 ++ jobs/Backend/Task/docker-compose.yml | 46 +++ jobs/Backend/Task/launchSettings.json | 11 + 68 files changed, 2699 insertions(+), 135 deletions(-) create mode 100644 .vs/VSWorkspaceState.json create mode 100644 jobs/Backend/Task/.dockerignore create mode 100644 jobs/Backend/Task/.gitattributes create mode 100644 jobs/Backend/Task/.gitignore delete mode 100644 jobs/Backend/Task/Currency.cs create mode 100644 jobs/Backend/Task/Directory.Build.props create mode 100644 jobs/Backend/Task/Directory.Packages.props delete mode 100644 jobs/Backend/Task/ExchangeRate.cs delete mode 100644 jobs/Backend/Task/ExchangeRateProvider.cs delete mode 100644 jobs/Backend/Task/ExchangeRateUpdater.csproj delete mode 100644 jobs/Backend/Task/ExchangeRateUpdater.sln create mode 100644 jobs/Backend/Task/Mews.ExchangeRateMonitor.Common.API/Abstractions/IApiEndpoint.cs create mode 100644 jobs/Backend/Task/Mews.ExchangeRateMonitor.Common.API/DependencyInjection.cs create mode 100644 jobs/Backend/Task/Mews.ExchangeRateMonitor.Common.API/ErrorHandling/GlobalExceptionHandler.cs create mode 100644 jobs/Backend/Task/Mews.ExchangeRateMonitor.Common.API/Extensions/EndpointResultsExtensions.cs create mode 100644 jobs/Backend/Task/Mews.ExchangeRateMonitor.Common.API/Extensions/MapEndpointExtensions.cs create mode 100644 jobs/Backend/Task/Mews.ExchangeRateMonitor.Common.API/Mews.ExchangeRateMonitor.Common.API.csproj create mode 100644 jobs/Backend/Task/Mews.ExchangeRateMonitor.Common.API/RateLimiting/RateLimiting.Constants.cs create mode 100644 jobs/Backend/Task/Mews.ExchangeRateMonitor.Common.Application/Extensions/HandlerRegistrationExtensions.cs create mode 100644 jobs/Backend/Task/Mews.ExchangeRateMonitor.Common.Application/Extensions/ValidationExtensions.cs create mode 100644 jobs/Backend/Task/Mews.ExchangeRateMonitor.Common.Application/Mews.ExchangeRateMonitor.Common.Application.csproj create mode 100644 jobs/Backend/Task/Mews.ExchangeRateMonitor.Common.Domain/Handlers/IHandler.cs create mode 100644 jobs/Backend/Task/Mews.ExchangeRateMonitor.Common.Domain/Mews.ExchangeRateMonitor.Common.Domain.csproj create mode 100644 jobs/Backend/Task/Mews.ExchangeRateMonitor.Common.Domain/Results/Error.cs create mode 100644 jobs/Backend/Task/Mews.ExchangeRateMonitor.Common.Domain/Results/ErrorType.cs create mode 100644 jobs/Backend/Task/Mews.ExchangeRateMonitor.Common.Domain/Results/IAppResult.cs create mode 100644 jobs/Backend/Task/Mews.ExchangeRateMonitor.Common.Domain/Results/Result.ImplicitConverters.cs create mode 100644 jobs/Backend/Task/Mews.ExchangeRateMonitor.Common.Domain/Results/Result.cs create mode 100644 jobs/Backend/Task/Mews.ExchangeRateMonitor.Common.Infrastructure/DependencyInjection.cs create mode 100644 jobs/Backend/Task/Mews.ExchangeRateMonitor.Common.Infrastructure/Mews.ExchangeRateMonitor.Common.Infrastructure.csproj create mode 100644 jobs/Backend/Task/Mews.ExchangeRateMonitor.Common.Tests.Unit/ErrorTests.cs create mode 100644 jobs/Backend/Task/Mews.ExchangeRateMonitor.Common.Tests.Unit/Mews.ExchangeRateMonitor.Common.Tests.Unit.csproj create mode 100644 jobs/Backend/Task/Mews.ExchangeRateMonitor.Common.Tests.Unit/ResultImplicitConversionTests.cs create mode 100644 jobs/Backend/Task/Mews.ExchangeRateMonitor.Common.Tests.Unit/ResultTests.cs create mode 100644 jobs/Backend/Task/Mews.ExchangeRateMonitor.ExchangeRate.Domain/Currency.cs create mode 100644 jobs/Backend/Task/Mews.ExchangeRateMonitor.ExchangeRate.Domain/CurrencyExchangeRate.cs create mode 100644 jobs/Backend/Task/Mews.ExchangeRateMonitor.ExchangeRate.Domain/Mews.ExchangeRateMonitor.ExchangeRate.Domain.csproj create mode 100644 jobs/Backend/Task/Mews.ExchangeRateMonitor.ExchangeRate.Features/ExchangeRatesModuleRegistration.cs create mode 100644 jobs/Backend/Task/Mews.ExchangeRateMonitor.ExchangeRate.Features/Features/ExchangeRateProvider.cs create mode 100644 jobs/Backend/Task/Mews.ExchangeRateMonitor.ExchangeRate.Features/Features/GetExratesDaily/GetExratesDaily.Contracts.cs create mode 100644 jobs/Backend/Task/Mews.ExchangeRateMonitor.ExchangeRate.Features/Features/GetExratesDaily/GetExratesDaily.Endpoint.cs create mode 100644 jobs/Backend/Task/Mews.ExchangeRateMonitor.ExchangeRate.Features/Features/GetExratesDaily/GetExratesDaily.ExternalContracts.cs create mode 100644 jobs/Backend/Task/Mews.ExchangeRateMonitor.ExchangeRate.Features/Features/GetExratesDaily/GetExratesDaily.Handler.cs create mode 100644 jobs/Backend/Task/Mews.ExchangeRateMonitor.ExchangeRate.Features/Features/GetExratesDaily/GetExratesDaily.Mappings.cs create mode 100644 jobs/Backend/Task/Mews.ExchangeRateMonitor.ExchangeRate.Features/Features/GetExratesDaily/GetExratesDaily.Validators.cs create mode 100644 jobs/Backend/Task/Mews.ExchangeRateMonitor.ExchangeRate.Features/Mews.ExchangeRateMonitor.ExchangeRate.Features.csproj create mode 100644 jobs/Backend/Task/Mews.ExchangeRateMonitor.ExchangeRate.Features/Options/CnbExratesOptions.cs create mode 100644 jobs/Backend/Task/Mews.ExchangeRateMonitor.ExchangeRate.Features/Options/ExchangeRateModuleOptions.cs create mode 100644 jobs/Backend/Task/Mews.ExchangeRateMonitor.ExchangeRate.Features/Shared/HttpClient/HttpClientConsts.cs create mode 100644 jobs/Backend/Task/Mews.ExchangeRateMonitor.ExchangeRate.Features/Shared/Routes/RouteConsts.cs create mode 100644 jobs/Backend/Task/Mews.ExchangeRateMonitor.ExchangeRate.Infrastructure/Mews.ExchangeRateMonitor.ExchangeRate.Infrastructure.csproj create mode 100644 jobs/Backend/Task/Mews.ExchangeRateMonitor.ExchangeRate.Tests.Unit/Features/ExchangeRateProviderTests.cs create mode 100644 jobs/Backend/Task/Mews.ExchangeRateMonitor.ExchangeRate.Tests.Unit/Features/GetExratesDaily/GetExratesDailyHandlerTests.cs create mode 100644 jobs/Backend/Task/Mews.ExchangeRateMonitor.ExchangeRate.Tests.Unit/Mews.ExchangeRateMonitor.ExchangeRate.Tests.Unit.csproj create mode 100644 jobs/Backend/Task/Mews.ExchangeRateMonitor.ExchangeRate.Tests.Unit/TestHelpers/StaticResponseHandler.cs create mode 100644 jobs/Backend/Task/Mews.ExchangeRateMonitor.Host/Dockerfile create mode 100644 jobs/Backend/Task/Mews.ExchangeRateMonitor.Host/Mews.ExchangeRateMonitor.Host.csproj create mode 100644 jobs/Backend/Task/Mews.ExchangeRateMonitor.Host/Program.cs create mode 100644 jobs/Backend/Task/Mews.ExchangeRateMonitor.Host/Properties/launchSettings.json create mode 100644 jobs/Backend/Task/Mews.ExchangeRateMonitor.Host/appsettings.Development.json create mode 100644 jobs/Backend/Task/Mews.ExchangeRateMonitor.Host/appsettings.json create mode 100644 jobs/Backend/Task/Mews.ExchangeRateMonitor.sln delete mode 100644 jobs/Backend/Task/Program.cs create mode 100644 jobs/Backend/Task/README.md create mode 100644 jobs/Backend/Task/docker-compose.dcproj create mode 100644 jobs/Backend/Task/docker-compose.override.yml create mode 100644 jobs/Backend/Task/docker-compose.yml create mode 100644 jobs/Backend/Task/launchSettings.json diff --git a/.vs/VSWorkspaceState.json b/.vs/VSWorkspaceState.json new file mode 100644 index 000000000..1480c76ac --- /dev/null +++ b/.vs/VSWorkspaceState.json @@ -0,0 +1,7 @@ +{ + "ExpandedNodes": [ + "" + ], + "SelectedNode": "\\Mews.ExchangeRateMonitor.sln", + "PreviewInSolutionExplorer": false +} \ No newline at end of file diff --git a/jobs/Backend/Task/.dockerignore b/jobs/Backend/Task/.dockerignore new file mode 100644 index 000000000..fe1152bdb --- /dev/null +++ b/jobs/Backend/Task/.dockerignore @@ -0,0 +1,30 @@ +**/.classpath +**/.dockerignore +**/.env +**/.git +**/.gitignore +**/.project +**/.settings +**/.toolstarget +**/.vs +**/.vscode +**/*.*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 +!**/.gitignore +!.git/HEAD +!.git/config +!.git/packed-refs +!.git/refs/heads/** \ No newline at end of file diff --git a/jobs/Backend/Task/.gitattributes b/jobs/Backend/Task/.gitattributes new file mode 100644 index 000000000..1ff0c4230 --- /dev/null +++ b/jobs/Backend/Task/.gitattributes @@ -0,0 +1,63 @@ +############################################################################### +# Set default behavior to automatically normalize line endings. +############################################################################### +* text=auto + +############################################################################### +# Set default behavior for command prompt diff. +# +# This is need for earlier builds of msysgit that does not have it on by +# default for csharp files. +# Note: This is only used by command line +############################################################################### +#*.cs diff=csharp + +############################################################################### +# Set the merge driver for project and solution files +# +# Merging from the command prompt will add diff markers to the files if there +# are conflicts (Merging from VS is not affected by the settings below, in VS +# the diff markers are never inserted). Diff markers may cause the following +# file extensions to fail to load in VS. An alternative would be to treat +# these files as binary and thus will always conflict and require user +# intervention with every merge. To do so, just uncomment the entries below +############################################################################### +#*.sln merge=binary +#*.csproj merge=binary +#*.vbproj merge=binary +#*.vcxproj merge=binary +#*.vcproj merge=binary +#*.dbproj merge=binary +#*.fsproj merge=binary +#*.lsproj merge=binary +#*.wixproj merge=binary +#*.modelproj merge=binary +#*.sqlproj merge=binary +#*.wwaproj merge=binary + +############################################################################### +# behavior for image files +# +# image files are treated as binary by default. +############################################################################### +#*.jpg binary +#*.png binary +#*.gif binary + +############################################################################### +# diff behavior for common document formats +# +# Convert binary document formats to text before diffing them. This feature +# is only available from the command line. Turn it on by uncommenting the +# entries below. +############################################################################### +#*.doc diff=astextplain +#*.DOC diff=astextplain +#*.docx diff=astextplain +#*.DOCX diff=astextplain +#*.dot diff=astextplain +#*.DOT diff=astextplain +#*.pdf diff=astextplain +#*.PDF diff=astextplain +#*.rtf diff=astextplain +#*.RTF diff=astextplain diff --git a/jobs/Backend/Task/.gitignore b/jobs/Backend/Task/.gitignore new file mode 100644 index 000000000..283883a3b --- /dev/null +++ b/jobs/Backend/Task/.gitignore @@ -0,0 +1,366 @@ +## Ignore Visual Studio temporary files, build results, and +## files generated by popular Visual Studio add-ons. +## +## Get latest from https://github.com/github/gitignore/blob/master/VisualStudio.gitignore + +# 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/ +[Oo]ut/ +[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 Core +project.lock.json +project.fragment.lock.json +artifacts/ + +# 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 +*.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 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/ + +# 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 + +# dockerdata +docker_data/ \ No newline at end of file diff --git a/jobs/Backend/Task/Currency.cs b/jobs/Backend/Task/Currency.cs deleted file mode 100644 index f375776f2..000000000 --- a/jobs/Backend/Task/Currency.cs +++ /dev/null @@ -1,20 +0,0 @@ -namespace ExchangeRateUpdater -{ - public class Currency - { - public Currency(string code) - { - Code = code; - } - - /// - /// Three-letter ISO 4217 code of the currency. - /// - public string Code { get; } - - public override string ToString() - { - return Code; - } - } -} diff --git a/jobs/Backend/Task/Directory.Build.props b/jobs/Backend/Task/Directory.Build.props new file mode 100644 index 000000000..1a065d6d4 --- /dev/null +++ b/jobs/Backend/Task/Directory.Build.props @@ -0,0 +1,6 @@ + + + enable + true + + \ No newline at end of file diff --git a/jobs/Backend/Task/Directory.Packages.props b/jobs/Backend/Task/Directory.Packages.props new file mode 100644 index 000000000..731bd3b54 --- /dev/null +++ b/jobs/Backend/Task/Directory.Packages.props @@ -0,0 +1,36 @@ + + + true + false + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/jobs/Backend/Task/ExchangeRate.cs b/jobs/Backend/Task/ExchangeRate.cs deleted file mode 100644 index 58c5bb10e..000000000 --- a/jobs/Backend/Task/ExchangeRate.cs +++ /dev/null @@ -1,23 +0,0 @@ -namespace ExchangeRateUpdater -{ - public class ExchangeRate - { - public ExchangeRate(Currency sourceCurrency, Currency targetCurrency, decimal value) - { - SourceCurrency = sourceCurrency; - TargetCurrency = targetCurrency; - Value = value; - } - - public Currency SourceCurrency { get; } - - public Currency TargetCurrency { get; } - - public decimal Value { get; } - - public override string ToString() - { - return $"{SourceCurrency}/{TargetCurrency}={Value}"; - } - } -} diff --git a/jobs/Backend/Task/ExchangeRateProvider.cs b/jobs/Backend/Task/ExchangeRateProvider.cs deleted file mode 100644 index 6f82a97fb..000000000 --- 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 deleted file mode 100644 index 2fc654a12..000000000 --- a/jobs/Backend/Task/ExchangeRateUpdater.csproj +++ /dev/null @@ -1,8 +0,0 @@ - - - - Exe - net6.0 - - - \ No newline at end of file diff --git a/jobs/Backend/Task/ExchangeRateUpdater.sln b/jobs/Backend/Task/ExchangeRateUpdater.sln deleted file mode 100644 index 89be84daf..000000000 --- a/jobs/Backend/Task/ExchangeRateUpdater.sln +++ /dev/null @@ -1,22 +0,0 @@ - -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 diff --git a/jobs/Backend/Task/Mews.ExchangeRateMonitor.Common.API/Abstractions/IApiEndpoint.cs b/jobs/Backend/Task/Mews.ExchangeRateMonitor.Common.API/Abstractions/IApiEndpoint.cs new file mode 100644 index 000000000..42f06be2c --- /dev/null +++ b/jobs/Backend/Task/Mews.ExchangeRateMonitor.Common.API/Abstractions/IApiEndpoint.cs @@ -0,0 +1,15 @@ +using Microsoft.AspNetCore.Builder; + +namespace Mews.ExchangeRateMonitor.Common.API.Abstractions; + +/// +/// Represents an interface defining the contract for mapping API endpoints into a web application. +/// +public interface IApiEndpoint +{ + /// + /// Maps the API endpoint for the specified web application instance. + /// + /// The web application instance where the API endpoint will be mapped. + void MapEndpoint(WebApplication app); +} \ No newline at end of file diff --git a/jobs/Backend/Task/Mews.ExchangeRateMonitor.Common.API/DependencyInjection.cs b/jobs/Backend/Task/Mews.ExchangeRateMonitor.Common.API/DependencyInjection.cs new file mode 100644 index 000000000..0054f3f28 --- /dev/null +++ b/jobs/Backend/Task/Mews.ExchangeRateMonitor.Common.API/DependencyInjection.cs @@ -0,0 +1,131 @@ +using Mews.ExchangeRateMonitor.Common.API.ErrorHandling; +using Mews.ExchangeRateMonitor.Common.API.RateLimiting; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Http.Json; +using Microsoft.AspNetCore.RateLimiting; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.OpenApi.Models; +using Serilog; +using System.Text.Json.Serialization; +using System.Threading.RateLimiting; + +namespace Mews.ExchangeRateMonitor.Common.API; + +public static class DependencyInjection +{ + /// + /// Adds core web API infrastructure services + /// + /// + /// + public static IServiceCollection AddCoreWebApiInfrastructure(this IServiceCollection services) + { + services.AddSwagger(); + services.AddRateLimiting(); + services.AddGlobalExceptionHadnler(); + services.AddJsonConfigurations(); + services.AddCors(o => o.AddPolicy("dev", p => p.AllowAnyOrigin().AllowAnyHeader().AllowAnyMethod())); + return services; + } + + /// + /// Adds global exception handler and problem details services + /// + /// + /// + public static IServiceCollection AddGlobalExceptionHadnler(this IServiceCollection services) + { + services + .AddExceptionHandler() + .AddProblemDetails(); + + return services; + + } + + /// + /// Adds rate limiting policy + /// + /// + /// + public static IServiceCollection AddRateLimiting(this IServiceCollection services) + { + services.AddRateLimiter(options => + { + options.AddFixedWindowLimiter(AppRateLimiting.GlobalRateLimitPolicy, opt => + { + opt.Window = TimeSpan.FromMinutes(1); // Time window of 1 minute + opt.PermitLimit = 100; // Allow 100 requests per minute + opt.QueueLimit = 2; // Queues 2 additional requests if the limit is reached + opt.QueueProcessingOrder = QueueProcessingOrder.OldestFirst; + }); + }); + + return services; + } + + /// + /// Configures Swagger generation + /// + /// + /// + public static IServiceCollection AddSwagger(this IServiceCollection services) + { + services + .AddEndpointsApiExplorer() + .AddSwaggerGen(c => + { + c.SwaggerDoc("v1", new OpenApiInfo { Title = "API", Version = "v1" }); + }); + + return services; + } + + /// + /// REgiser JSON configurations, like enum to string conversion + /// + /// + /// + public static IServiceCollection AddJsonConfigurations(this IServiceCollection services) + { + services.Configure(opt => + { + opt.SerializerOptions.Converters.Add(new JsonStringEnumConverter()); + }); + + return services; + } + + /// + /// Adds logging to the web application builder. + /// + /// + public static void AddCoreHostLogging(this WebApplicationBuilder builder) + { + builder.Host.UseSerilog((context, loggerConfig) => + loggerConfig.ReadFrom.Configuration(context.Configuration)); + } + + /// + /// Adds swagger and swagger UI + /// + /// + /// + public static IApplicationBuilder UseSwaggerExt(this IApplicationBuilder app) + { + app.UseSwagger(); + app.UseSwaggerUI(); + return app; + } + + /// + /// Adds rate limiting + /// + /// + /// + public static IApplicationBuilder UseAppRateLimiter(this IApplicationBuilder app) + { + app.UseRateLimiter(); + return app; + } +} diff --git a/jobs/Backend/Task/Mews.ExchangeRateMonitor.Common.API/ErrorHandling/GlobalExceptionHandler.cs b/jobs/Backend/Task/Mews.ExchangeRateMonitor.Common.API/ErrorHandling/GlobalExceptionHandler.cs new file mode 100644 index 000000000..6bbcc32f2 --- /dev/null +++ b/jobs/Backend/Task/Mews.ExchangeRateMonitor.Common.API/ErrorHandling/GlobalExceptionHandler.cs @@ -0,0 +1,38 @@ +using Microsoft.AspNetCore.Diagnostics; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using Microsoft.Extensions.Logging; + +namespace Mews.ExchangeRateMonitor.Common.API.ErrorHandling; + +/// +/// Global exception handler that logs unhandled exceptions and returns a 500 Internal Server Error response with problem details. +/// +/// +/// +internal sealed class GlobalExceptionHandler( + IProblemDetailsService problemDetailsService, + ILogger logger) : IExceptionHandler +{ + public async ValueTask TryHandleAsync( + HttpContext httpContext, + Exception exception, + CancellationToken cancellationToken) + { + logger.LogError(exception, "Unhandled exception occurred while processing request"); + + httpContext.Response.StatusCode = StatusCodes.Status500InternalServerError; + + return await problemDetailsService.TryWriteAsync(new ProblemDetailsContext + { + HttpContext = httpContext, + Exception = exception, + ProblemDetails = new ProblemDetails + { + Type = exception.GetType().Name, + Title = "An error occured while processing your request.", + Detail = exception.Message + } + }); + } +} \ No newline at end of file diff --git a/jobs/Backend/Task/Mews.ExchangeRateMonitor.Common.API/Extensions/EndpointResultsExtensions.cs b/jobs/Backend/Task/Mews.ExchangeRateMonitor.Common.API/Extensions/EndpointResultsExtensions.cs new file mode 100644 index 000000000..512e3799d --- /dev/null +++ b/jobs/Backend/Task/Mews.ExchangeRateMonitor.Common.API/Extensions/EndpointResultsExtensions.cs @@ -0,0 +1,39 @@ +using Mews.ExchangeRateMonitor.Common.Domain.Results; +using Microsoft.AspNetCore.Http; +using IResult = Microsoft.AspNetCore.Http.IResult; + +namespace Mews.ExchangeRateMonitor.Common.API.Extensions; + +public static class EndpointResultsExtensions +{ + public static IResult ToProblem(this List errors) + { + if (errors.Count is 0) + { + return Results.Problem(); + } + + return CreateProblem(errors); + } + + private static IResult CreateProblem(List errors) + { + var statusCode = errors.First().Type switch + { + ErrorType.Conflict => StatusCodes.Status409Conflict, + ErrorType.Validation => StatusCodes.Status400BadRequest, + ErrorType.NotFound => StatusCodes.Status404NotFound, + ErrorType.Unauthorized => StatusCodes.Status401Unauthorized, + ErrorType.Forbidden => StatusCodes.Status403Forbidden, + + ErrorType.Failure => StatusCodes.Status400BadRequest, + ErrorType.Unexpected => StatusCodes.Status400BadRequest, + ErrorType.Custom => StatusCodes.Status400BadRequest, + + _ => StatusCodes.Status500InternalServerError + }; + + return Results.ValidationProblem(errors.ToDictionary(k => k.Code, v => new[] { v.Description }), + statusCode: statusCode); + } +} \ No newline at end of file diff --git a/jobs/Backend/Task/Mews.ExchangeRateMonitor.Common.API/Extensions/MapEndpointExtensions.cs b/jobs/Backend/Task/Mews.ExchangeRateMonitor.Common.API/Extensions/MapEndpointExtensions.cs new file mode 100644 index 000000000..6cfb913ef --- /dev/null +++ b/jobs/Backend/Task/Mews.ExchangeRateMonitor.Common.API/Extensions/MapEndpointExtensions.cs @@ -0,0 +1,46 @@ +using Mews.ExchangeRateMonitor.Common.API.Abstractions; +using Microsoft.AspNetCore.Builder; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.DependencyInjection.Extensions; + +namespace Mews.ExchangeRateMonitor.Common.API.Extensions; + +public static class MapEndpointExtensions +{ + /// + /// Registers all API endpoints implementing from the assembly containing the specified type into the service collection. + /// + /// The service collection to register the endpoints into. + /// A type whose assembly will be scanned for implementations. + /// The modified service collection with registered endpoints. + public static IServiceCollection RegisterApiEndpointsFromAssemblyContaining(this IServiceCollection services, Type marker) + { + var assembly = marker.Assembly; + var endpointTypes = assembly.GetTypes() + .Where(t => t.IsAssignableTo(typeof(IApiEndpoint)) && t is { IsClass: true, IsAbstract: false, IsInterface: false }); + + var serviceDescriptors = endpointTypes + .Select(type => ServiceDescriptor.Transient(typeof(IApiEndpoint), type)) + .ToArray(); + + services.TryAddEnumerable(serviceDescriptors); + return services; + } + + /// + /// Maps all registered API endpoints implementing the interface to the specified web application. + /// + /// The instance to which the endpoints will be mapped. + /// The same instance to allow for method chaining. + public static WebApplication MapApiEndpoints(this WebApplication app) + { + var endpoints = app.Services.GetRequiredService>(); + + foreach (var endpoint in endpoints) + { + endpoint.MapEndpoint(app); + } + + return app; + } +} diff --git a/jobs/Backend/Task/Mews.ExchangeRateMonitor.Common.API/Mews.ExchangeRateMonitor.Common.API.csproj b/jobs/Backend/Task/Mews.ExchangeRateMonitor.Common.API/Mews.ExchangeRateMonitor.Common.API.csproj new file mode 100644 index 000000000..cf102b94c --- /dev/null +++ b/jobs/Backend/Task/Mews.ExchangeRateMonitor.Common.API/Mews.ExchangeRateMonitor.Common.API.csproj @@ -0,0 +1,21 @@ + + + + net9.0 + enable + enable + + + + + + + + + + + + + + + diff --git a/jobs/Backend/Task/Mews.ExchangeRateMonitor.Common.API/RateLimiting/RateLimiting.Constants.cs b/jobs/Backend/Task/Mews.ExchangeRateMonitor.Common.API/RateLimiting/RateLimiting.Constants.cs new file mode 100644 index 000000000..23368e157 --- /dev/null +++ b/jobs/Backend/Task/Mews.ExchangeRateMonitor.Common.API/RateLimiting/RateLimiting.Constants.cs @@ -0,0 +1,6 @@ +namespace Mews.ExchangeRateMonitor.Common.API.RateLimiting; + +public static class AppRateLimiting +{ + public const string GlobalRateLimitPolicy = "GlobalRateLimitPolicy"; +} diff --git a/jobs/Backend/Task/Mews.ExchangeRateMonitor.Common.Application/Extensions/HandlerRegistrationExtensions.cs b/jobs/Backend/Task/Mews.ExchangeRateMonitor.Common.Application/Extensions/HandlerRegistrationExtensions.cs new file mode 100644 index 000000000..a22c576b7 --- /dev/null +++ b/jobs/Backend/Task/Mews.ExchangeRateMonitor.Common.Application/Extensions/HandlerRegistrationExtensions.cs @@ -0,0 +1,42 @@ +using Mews.ExchangeRateMonitor.Common.Domain.Handlers; +using Microsoft.Extensions.DependencyInjection; +using System.Reflection; + +namespace Mews.ExchangeRateMonitor.Common.Application.Extensions; + +public static class HandlerRegistrationExtensions +{ + /// + /// Registers all handlers from the assembly containing the specified type + /// + /// The service collection + /// A type from the assembly where handlers are located + /// The service collection for chaining + public static IServiceCollection RegisterHandlersFromAssemblyContaining(this IServiceCollection services, Type marker) + { + var assembly = marker.Assembly; + + RegisterCommandHandlers(services, assembly); + + return services; + } + + private static void RegisterCommandHandlers(IServiceCollection services, Assembly assembly) + { + var handlerTypes = assembly.GetTypes() + .Where(t => t is { IsClass: true, IsAbstract: false } + && t.IsAssignableTo(typeof(IHandler))) + .ToList(); + + foreach (var implementationType in handlerTypes) + { + var interfaceType = implementationType.GetInterfaces() + .FirstOrDefault(i => i != typeof(IHandler) && i.IsAssignableTo(typeof(IHandler))); + + if (interfaceType is not null) + { + services.AddScoped(interfaceType, implementationType); + } + } + } +} diff --git a/jobs/Backend/Task/Mews.ExchangeRateMonitor.Common.Application/Extensions/ValidationExtensions.cs b/jobs/Backend/Task/Mews.ExchangeRateMonitor.Common.Application/Extensions/ValidationExtensions.cs new file mode 100644 index 000000000..20e8bb0a8 --- /dev/null +++ b/jobs/Backend/Task/Mews.ExchangeRateMonitor.Common.Application/Extensions/ValidationExtensions.cs @@ -0,0 +1,28 @@ +using Microsoft.Extensions.Logging; +using FluentValidation.Results; +using Mews.ExchangeRateMonitor.Common.Domain.Results; + +namespace Mews.ExchangeRateMonitor.Common.Application.Extensions; + +public static class ValidationExtensions +{ + public static List ToFormattedErrorMessages(this ValidationResult validationResult) + { + return validationResult.Errors + .Select(e => $"{e.PropertyName}: {e.ErrorMessage}") + .ToList(); + } + public static List ToDomainErrors(this ValidationResult validationResult, string errorPrefix) + { + return validationResult.Errors + .Select(x => Error.Validation(errorPrefix, $"{x.PropertyName}:{x.ErrorMessage}")) + .ToList(); + } + + + public static void LogValidationErrors(this ILogger logger, ValidationResult validationResult, string contextMessage) + { + var validationErrors = validationResult.ToFormattedErrorMessages(); + logger.LogWarning("{ContextMessage}: {Errors}", contextMessage, string.Join(", ", validationErrors)); + } +} \ No newline at end of file diff --git a/jobs/Backend/Task/Mews.ExchangeRateMonitor.Common.Application/Mews.ExchangeRateMonitor.Common.Application.csproj b/jobs/Backend/Task/Mews.ExchangeRateMonitor.Common.Application/Mews.ExchangeRateMonitor.Common.Application.csproj new file mode 100644 index 000000000..9c4511ba7 --- /dev/null +++ b/jobs/Backend/Task/Mews.ExchangeRateMonitor.Common.Application/Mews.ExchangeRateMonitor.Common.Application.csproj @@ -0,0 +1,19 @@ + + + + net9.0 + enable + enable + + + + + + + + + + + + + diff --git a/jobs/Backend/Task/Mews.ExchangeRateMonitor.Common.Domain/Handlers/IHandler.cs b/jobs/Backend/Task/Mews.ExchangeRateMonitor.Common.Domain/Handlers/IHandler.cs new file mode 100644 index 000000000..adcad23e5 --- /dev/null +++ b/jobs/Backend/Task/Mews.ExchangeRateMonitor.Common.Domain/Handlers/IHandler.cs @@ -0,0 +1,6 @@ +namespace Mews.ExchangeRateMonitor.Common.Domain.Handlers; + +/// +/// Marker interface for all handlers in the application +/// +public interface IHandler; diff --git a/jobs/Backend/Task/Mews.ExchangeRateMonitor.Common.Domain/Mews.ExchangeRateMonitor.Common.Domain.csproj b/jobs/Backend/Task/Mews.ExchangeRateMonitor.Common.Domain/Mews.ExchangeRateMonitor.Common.Domain.csproj new file mode 100644 index 000000000..125f4c93b --- /dev/null +++ b/jobs/Backend/Task/Mews.ExchangeRateMonitor.Common.Domain/Mews.ExchangeRateMonitor.Common.Domain.csproj @@ -0,0 +1,9 @@ + + + + net9.0 + enable + enable + + + diff --git a/jobs/Backend/Task/Mews.ExchangeRateMonitor.Common.Domain/Results/Error.cs b/jobs/Backend/Task/Mews.ExchangeRateMonitor.Common.Domain/Results/Error.cs new file mode 100644 index 000000000..3f445efa3 --- /dev/null +++ b/jobs/Backend/Task/Mews.ExchangeRateMonitor.Common.Domain/Results/Error.cs @@ -0,0 +1,74 @@ +namespace Mews.ExchangeRateMonitor.Common.Domain.Results; + +public readonly record struct Error +{ + private Error(string code, string description, ErrorType type) + { + Code = code; + Description = description; + Type = type; + } + + private Error(string code, string description, int numericType) + { + Code = code; + Description = description; + NumericType = numericType; + + Type = ErrorType.Custom; + } + + /// + /// Gets the unique error code. + /// + public string Code { get; } + + /// + /// Gets the error description. + /// + public string Description { get; } + + /// + /// Gets the error type. + /// + public ErrorType Type { get; } + + /// + /// Gets the numeric value of the type. + /// + public int NumericType { get; } + + public static Error Failure(string code, string description) => + new(code, description, ErrorType.Failure); + + public static Error Unexpected(string code, string description) => + new(code, description, ErrorType.Unexpected); + + public static Error Validation(string code, string description) => + new(code, description, ErrorType.Validation); + + public static Error Conflict(string code, string description) => + new(code, description, ErrorType.Conflict); + + public static Error NotFound(string code, string description) => + new(code, description, ErrorType.NotFound); + + public static Error Unauthorized(string code, string description) => + new(code, description, ErrorType.Unauthorized); + + public static Error Forbidden(string code, string description) => + new(code, description, ErrorType.Forbidden); + + public static Error Custom(int type, string code, string description) => + new(code, description, type); + + public bool Equals(Error other) + { + return Type == other.Type && + NumericType == other.NumericType && + Code == other.Code && + Description == other.Description; + } + + public override int GetHashCode() => HashCode.Combine(Code, Description, Type, NumericType); +} diff --git a/jobs/Backend/Task/Mews.ExchangeRateMonitor.Common.Domain/Results/ErrorType.cs b/jobs/Backend/Task/Mews.ExchangeRateMonitor.Common.Domain/Results/ErrorType.cs new file mode 100644 index 000000000..63dfda703 --- /dev/null +++ b/jobs/Backend/Task/Mews.ExchangeRateMonitor.Common.Domain/Results/ErrorType.cs @@ -0,0 +1,14 @@ +namespace Mews.ExchangeRateMonitor.Common.Domain.Results; + +public enum ErrorType +{ + Failure, + Unexpected, + Validation, + Conflict, + NotFound, + Unauthorized, + Forbidden, + Canceled, + Custom +} \ No newline at end of file diff --git a/jobs/Backend/Task/Mews.ExchangeRateMonitor.Common.Domain/Results/IAppResult.cs b/jobs/Backend/Task/Mews.ExchangeRateMonitor.Common.Domain/Results/IAppResult.cs new file mode 100644 index 000000000..4c9cc12b5 --- /dev/null +++ b/jobs/Backend/Task/Mews.ExchangeRateMonitor.Common.Domain/Results/IAppResult.cs @@ -0,0 +1,35 @@ +namespace Mews.ExchangeRateMonitor.Common.Domain.Results; + +/// +/// Represents a result of an operation which encapsulates either a success or an error state. +/// +public interface IAppResult +{ + /// + /// Gets the collection of errors associated with the operation result. + /// + List? Errors { get; } + + /// + /// Indicates whether the operation result represents a success state. + /// + bool IsSuccess { get; } + + /// + /// Gets a value indicating whether the result represents an error state. + /// + bool IsError { get; } +} + +/// +/// Represents the result of an operation with the ability to define a value type +/// and encapsulate success or error states. +/// +/// The type of the value returned on a successful result. +public interface IAppResult : IAppResult +{ + /// + /// Gets the value associated with the operation result. This can represent the successful outcome of the operation. + /// + TValue? Value { get; } +} \ No newline at end of file diff --git a/jobs/Backend/Task/Mews.ExchangeRateMonitor.Common.Domain/Results/Result.ImplicitConverters.cs b/jobs/Backend/Task/Mews.ExchangeRateMonitor.Common.Domain/Results/Result.ImplicitConverters.cs new file mode 100644 index 000000000..5e79572f3 --- /dev/null +++ b/jobs/Backend/Task/Mews.ExchangeRateMonitor.Common.Domain/Results/Result.ImplicitConverters.cs @@ -0,0 +1,50 @@ +namespace Mews.ExchangeRateMonitor.Common.Domain.Results; + +/// +/// Represents the outcome of an operation, encapsulating either a value of type +/// or one or more errors indicating failure. +/// +/// The type of the value held by the result when the operation is successful. +/// +/// The type provides a mechanism to represent both successful outcomes, +/// which carry a value of type , and errors, which can be encapsulated in +/// , a collection of , or both. This abstraction aids in +/// avoiding exceptions for flow control and makes error handling explicit and structured. +/// +public readonly partial record struct Result +{ + /// + /// Defines an implicit conversion operator that constructs a + /// from a value of type . + /// + /// The value of type used to create a successful result. + /// A new instance representing a successful outcome with the provided value. + public static implicit operator Result(TValue value) => new(value); + + /// + /// Defines an implicit conversion operator that constructs a + /// from an instance. + /// + /// The instance encapsulating the error information + /// used to create a failure result. + /// A new instance representing a failure with the provided error. + public static implicit operator Result(Error error) => new(error); + + /// + /// Defines implicit conversion operators for the struct. + /// + /// + /// These operators allow for implicit conversion between , , + /// lists of , and arrays of into a object. + /// + /// + /// This conversion simplifies the process of creating instances of + /// by allowing direct assignment from supported types. + /// + public static implicit operator Result(List errors) => new(errors); + + /// + /// Provides implicit conversion operators for the type. + /// + public static implicit operator Result(Error[] errors) => new([.. errors]); +} diff --git a/jobs/Backend/Task/Mews.ExchangeRateMonitor.Common.Domain/Results/Result.cs b/jobs/Backend/Task/Mews.ExchangeRateMonitor.Common.Domain/Results/Result.cs new file mode 100644 index 000000000..9c4a2b451 --- /dev/null +++ b/jobs/Backend/Task/Mews.ExchangeRateMonitor.Common.Domain/Results/Result.cs @@ -0,0 +1,99 @@ +namespace Mews.ExchangeRateMonitor.Common.Domain.Results; + + +/// +/// Represents the success result of an operation, typically used to indicate that a process has completed successfully without returning a specific value or data. +/// +public readonly record struct Success; + +/// +/// Represents a class containing utility methods for handling operation results. +/// +public static class Result +{ + /// + /// Represents a successful operation result. + /// + public static Success Success => default; +} + +public readonly partial record struct Result : IAppResult +{ + private readonly TValue? _value = default; + + private Result(TValue value) + { + if (value is null) + { + throw new ArgumentNullException(nameof(value)); + } + + _value = value; + } + + private Result(Error error) + { + Errors = [error]; + } + + private Result(List errors) + { + ArgumentNullException.ThrowIfNull(errors); + + if (errors.Count == 0) + { + throw new ArgumentException("Cannot create an Result from an empty collection of errors. Provide at least one error.", nameof(errors)); + } + + Errors = errors; + } + + /// + /// Gets a value indicating whether the state is a success. + /// + public bool IsSuccess => Errors is null or []; + + /// + /// Gets a value indicating whether the state is error. + /// + public bool IsError => Errors.Count > 0; + + /// + /// Gets the collection of errors. + /// + public List Errors { get; } = []; + + /// + /// Gets the value. + /// + /// Thrown when no value is present. + public TValue? Value + { + get + { + if (IsError) + { + throw new InvalidOperationException("The Value property cannot be accessed when Errors property is not empty. Check IsSuccess or IsError before accessing the Value."); + } + + return _value; + } + } + + /// + /// Gets the first error. + /// + /// Thrown when no errors are present. + public Error FirstError + { + get + { + if (!IsError) + { + throw new InvalidOperationException("The FirstError property cannot be accessed when Errors property is empty. Check IsError before accessing FirstError."); + } + + return Errors[0]; + } + } +} \ No newline at end of file diff --git a/jobs/Backend/Task/Mews.ExchangeRateMonitor.Common.Infrastructure/DependencyInjection.cs b/jobs/Backend/Task/Mews.ExchangeRateMonitor.Common.Infrastructure/DependencyInjection.cs new file mode 100644 index 000000000..a91e2dddd --- /dev/null +++ b/jobs/Backend/Task/Mews.ExchangeRateMonitor.Common.Infrastructure/DependencyInjection.cs @@ -0,0 +1,40 @@ +using Microsoft.Extensions.DependencyInjection; +using OpenTelemetry.Resources; +using OpenTelemetry.Trace; + +namespace Mews.ExchangeRateMonitor.Common.Infrastructure; + +public static class DependencyInjection +{ + + public static IServiceCollection AddCoreInfrastructure(this IServiceCollection services) + { + services.AddMemoryCache(); + services.AddHostOpenTelemetry(); + + return services; + } + + private static IServiceCollection AddHostOpenTelemetry( + this IServiceCollection services, + params string[] activityModuleNames) + { + services + .AddOpenTelemetry() + .ConfigureResource(resource => resource.AddService("Mews.ExchangeRateMonitor")) + .WithTracing(tracing => + { + tracing + //creates spans(operation activities) for incoming http requests + .AddAspNetCoreInstrumentation() + //creates spans(operation activities) for outgoing http requests + .AddHttpClientInstrumentation() + //Sends trace data to an OTLP endpoint (usually an OpenTelemetry Collector). + //The collector then forwards it to Jaeger + //OTLP exporter config(endpoint, protocol, headers) comes from appsettings.json or env vars + .AddOtlpExporter(); + }); + + return services; + } +} diff --git a/jobs/Backend/Task/Mews.ExchangeRateMonitor.Common.Infrastructure/Mews.ExchangeRateMonitor.Common.Infrastructure.csproj b/jobs/Backend/Task/Mews.ExchangeRateMonitor.Common.Infrastructure/Mews.ExchangeRateMonitor.Common.Infrastructure.csproj new file mode 100644 index 000000000..7cd85d319 --- /dev/null +++ b/jobs/Backend/Task/Mews.ExchangeRateMonitor.Common.Infrastructure/Mews.ExchangeRateMonitor.Common.Infrastructure.csproj @@ -0,0 +1,21 @@ + + + + net9.0 + enable + enable + + + + + + + + + + + + + + + diff --git a/jobs/Backend/Task/Mews.ExchangeRateMonitor.Common.Tests.Unit/ErrorTests.cs b/jobs/Backend/Task/Mews.ExchangeRateMonitor.Common.Tests.Unit/ErrorTests.cs new file mode 100644 index 000000000..f6e28d43b --- /dev/null +++ b/jobs/Backend/Task/Mews.ExchangeRateMonitor.Common.Tests.Unit/ErrorTests.cs @@ -0,0 +1,210 @@ +using Mews.ExchangeRateMonitor.Common.Domain.Results; + +namespace Mews.ExchangeRateMonitor.Common.Tests.Unit; + +public class ErrorTests +{ + [Fact] + public void Create_ShouldCreateFailureError_WhenUsingFailureFactory() + { + // Arrange + const string code = "Test.Failure"; + const string description = "Test failure description"; + + // Act + var error = Error.Failure(code, description); + + // Assert + Assert.Equal(code, error.Code); + Assert.Equal(description, error.Description); + Assert.Equal(ErrorType.Failure, error.Type); + } + + [Fact] + public void Create_ShouldCreateUnexpectedError_WhenUsingUnexpectedFactory() + { + // Arrange + const string code = "Test.Unexpected"; + const string description = "Test unexpected description"; + + // Act + var error = Error.Unexpected(code, description); + + // Assert + Assert.Equal(code, error.Code); + Assert.Equal(description, error.Description); + Assert.Equal(ErrorType.Unexpected, error.Type); + } + + [Fact] + public void Create_ShouldCreateValidationError_WhenUsingValidationFactory() + { + // Arrange + const string code = "Test.Validation"; + const string description = "Test validation description"; + + // Act + var error = Error.Validation(code, description); + + // Assert + Assert.Equal(code, error.Code); + Assert.Equal(description, error.Description); + Assert.Equal(ErrorType.Validation, error.Type); + } + + [Fact] + public void Create_ShouldCreateConflictError_WhenUsingConflictFactory() + { + // Arrange + const string code = "Test.Conflict"; + const string description = "Test conflict description"; + + // Act + var error = Error.Conflict(code, description); + + // Assert + Assert.Equal(code, error.Code); + Assert.Equal(description, error.Description); + Assert.Equal(ErrorType.Conflict, error.Type); + } + + [Fact] + public void Create_ShouldCreateNotFoundError_WhenUsingNotFoundFactory() + { + // Arrange + const string code = "Test.NotFound"; + const string description = "Test not found description"; + + // Act + var error = Error.NotFound(code, description); + + // Assert + Assert.Equal(code, error.Code); + Assert.Equal(description, error.Description); + Assert.Equal(ErrorType.NotFound, error.Type); + } + + [Fact] + public void Create_ShouldCreateUnauthorizedError_WhenUsingUnauthorizedFactory() + { + // Arrange + const string code = "Test.Unauthorized"; + const string description = "Test unauthorized description"; + + // Act + var error = Error.Unauthorized(code, description); + + // Assert + Assert.Equal(code, error.Code); + Assert.Equal(description, error.Description); + Assert.Equal(ErrorType.Unauthorized, error.Type); + } + + [Fact] + public void Create_ShouldCreateForbiddenError_WhenUsingForbiddenFactory() + { + // Arrange + const string code = "Test.Forbidden"; + const string description = "Test forbidden description"; + + // Act + var error = Error.Forbidden(code, description); + + // Assert + Assert.Equal(code, error.Code); + Assert.Equal(description, error.Description); + Assert.Equal(ErrorType.Forbidden, error.Type); + } + + [Fact] + public void Create_ShouldCreateCustomError_WhenUsingCustomFactory() + { + // Arrange + const string code = "Test.Custom"; + const string description = "Test custom description"; + const int customType = 100; + + // Act + var error = Error.Custom(customType, code, description); + + // Assert + Assert.Equal(code, error.Code); + Assert.Equal(description, error.Description); + Assert.Equal(ErrorType.Custom, error.Type); + Assert.Equal(customType, error.NumericType); + } + + [Fact] + public void Equals_ShouldReturnTrue_WhenErrorsAreEqual() + { + // Arrange + var error1 = Error.Validation("Test.Code", "Test description"); + var error2 = Error.Validation("Test.Code", "Test description"); + + // Act & Assert + Assert.Equal(error1, error2); + } + + [Fact] + public void Equals_ShouldReturnFalse_WhenErrorCodesAreDifferent() + { + // Arrange + var error1 = Error.Validation("Test.Code1", "Test description"); + var error2 = Error.Validation("Test.Code2", "Test description"); + + // Act & Assert + Assert.NotEqual(error1, error2); + } + + [Fact] + public void Equals_ShouldReturnFalse_WhenErrorDescriptionsAreDifferent() + { + // Arrange + var error1 = Error.Validation("Test.Code", "Test description 1"); + var error2 = Error.Validation("Test.Code", "Test description 2"); + + // Act & Assert + Assert.NotEqual(error1, error2); + } + + [Fact] + public void Equals_ShouldReturnFalse_WhenErrorTypesAreDifferent() + { + // Arrange + var error1 = Error.Validation("Test.Code", "Test description"); + var error2 = Error.NotFound("Test.Code", "Test description"); + + // Act & Assert + Assert.NotEqual(error1, error2); + } + + [Fact] + public void GetHashCode_ShouldReturnSameValue_WhenErrorsAreEqual() + { + // Arrange + var error1 = Error.Validation("Test.Code", "Test description"); + var error2 = Error.Validation("Test.Code", "Test description"); + + // Act + var hashCode1 = error1.GetHashCode(); + var hashCode2 = error2.GetHashCode(); + + // Assert + Assert.Equal(hashCode1, hashCode2); + } + + [Fact] + public void GetHashCode_ShouldReturnDifferentValues_WhenErrorsAreDifferent() + { + // Arrange + var error1 = Error.Validation("Test.Code1", "Test description"); + var error2 = Error.Validation("Test.Code2", "Test description"); + + // Act + var hashCode1 = error1.GetHashCode(); + var hashCode2 = error2.GetHashCode(); + + // Assert + Assert.NotEqual(hashCode1, hashCode2); + } +} diff --git a/jobs/Backend/Task/Mews.ExchangeRateMonitor.Common.Tests.Unit/Mews.ExchangeRateMonitor.Common.Tests.Unit.csproj b/jobs/Backend/Task/Mews.ExchangeRateMonitor.Common.Tests.Unit/Mews.ExchangeRateMonitor.Common.Tests.Unit.csproj new file mode 100644 index 000000000..2291ba74f --- /dev/null +++ b/jobs/Backend/Task/Mews.ExchangeRateMonitor.Common.Tests.Unit/Mews.ExchangeRateMonitor.Common.Tests.Unit.csproj @@ -0,0 +1,25 @@ + + + + net9.0 + enable + enable + false + + + + + + + + + + + + + + + + + + diff --git a/jobs/Backend/Task/Mews.ExchangeRateMonitor.Common.Tests.Unit/ResultImplicitConversionTests.cs b/jobs/Backend/Task/Mews.ExchangeRateMonitor.Common.Tests.Unit/ResultImplicitConversionTests.cs new file mode 100644 index 000000000..a036675b3 --- /dev/null +++ b/jobs/Backend/Task/Mews.ExchangeRateMonitor.Common.Tests.Unit/ResultImplicitConversionTests.cs @@ -0,0 +1,70 @@ +using Mews.ExchangeRateMonitor.Common.Domain.Results; + +namespace Mews.ExchangeRateMonitor.Common.Tests.Unit; + +public class ResultImplicitConversionTests +{ + [Fact] + public void ImplicitConversion_ShouldCreateSuccessResult_FromValue() + { + // Arrange + const int value = 42; + + // Act + Result result = value; + + // Assert + Assert.True(result.IsSuccess); + Assert.Equal(value, result.Value); + } + + [Fact] + public void ImplicitConversion_ShouldCreateErrorResult_FromError() + { + // Arrange + var error = Error.Validation("Test.Error", "Test error description"); + + // Act + Result result = error; + + // Assert + Assert.True(result.IsError); + Assert.Equal(error, result.FirstError); + } + + [Fact] + public void ImplicitConversion_ShouldCreateErrorResult_FromErrorList() + { + // Arrange + var error1 = Error.Validation("Test.Error1", "Test error description 1"); + var error2 = Error.Validation("Test.Error2", "Test error description 2"); + var errors = new List { error1, error2 }; + + // Act + Result result = errors; + + // Assert + Assert.True(result.IsError); + Assert.Equal(2, result.Errors.Count); + Assert.Equal(error1, result.FirstError); + } + + [Fact] + public void ImplicitConversion_ShouldCreateErrorResult_FromErrorArray() + { + // Arrange + var error1 = Error.Validation("Test.Error1", "Test error description 1"); + var error2 = Error.Validation("Test.Error2", "Test error description 2"); + var errors = new[] { error1, error2 }; + + // Act + Result result = errors; + + // Assert + Assert.True(result.IsError); + Assert.Equal(2, result.Errors.Count); + Assert.Equal(error1, result.FirstError); + Assert.Contains(error1, result.Errors); + Assert.Contains(error2, result.Errors); + } +} \ No newline at end of file diff --git a/jobs/Backend/Task/Mews.ExchangeRateMonitor.Common.Tests.Unit/ResultTests.cs b/jobs/Backend/Task/Mews.ExchangeRateMonitor.Common.Tests.Unit/ResultTests.cs new file mode 100644 index 000000000..7c2dd89bd --- /dev/null +++ b/jobs/Backend/Task/Mews.ExchangeRateMonitor.Common.Tests.Unit/ResultTests.cs @@ -0,0 +1,128 @@ +using Mews.ExchangeRateMonitor.Common.Domain.Results; + +namespace Mews.ExchangeRateMonitor.Common.Tests.Unit; + +public class ResultTests +{ + [Fact] + public void Success_ShouldReturnSuccessInstance() + { + // Act + var success = Result.Success; + + // Assert + Assert.IsType(success); + } + + [Fact] + public void Result_ShouldImplicitlyConvertValueToResult() + { + // Arrange + const string value = "test value"; + + // Act + Result result = value; + + // Assert + Assert.True(result.IsSuccess); + Assert.False(result.IsError); + Assert.Equal(value, result.Value); + Assert.Empty(result.Errors); + } + + [Fact] + public void Result_ShouldImplicitlyConvertErrorToResult() + { + // Arrange + var error = Error.Validation("Test.Error", "Test error description"); + + // Act + Result result = error; + + // Assert + Assert.False(result.IsSuccess); + Assert.True(result.IsError); + Assert.Equal(error, result.FirstError); + Assert.Single(result.Errors); + Assert.Contains(error, result.Errors); + } + + [Fact] + public void Result_ShouldImplicitlyConvertErrorListToResult() + { + // Arrange + var error1 = Error.Validation("Test.Error1", "Test error description 1"); + var error2 = Error.Validation("Test.Error2", "Test error description 2"); + var errors = new List { error1, error2 }; + + // Act + Result result = errors; + + // Assert + Assert.False(result.IsSuccess); + Assert.True(result.IsError); + Assert.Equal(error1, result.FirstError); + Assert.Equal(2, result.Errors.Count); + Assert.Contains(error1, result.Errors); + Assert.Contains(error2, result.Errors); + } + + [Fact] + public void Result_ShouldImplicitlyConvertErrorArrayToResult() + { + // Arrange + var error1 = Error.Validation("Test.Error1", "Test error description 1"); + var error2 = Error.Validation("Test.Error2", "Test error description 2"); + var errors = new[] { error1, error2 }; + + // Act + Result result = errors; + + // Assert + Assert.False(result.IsSuccess); + Assert.True(result.IsError); + Assert.Equal(error1, result.FirstError); + Assert.Equal(2, result.Errors.Count); + Assert.Contains(error1, result.Errors); + Assert.Contains(error2, result.Errors); + } + + [Fact] + public void Result_ShouldThrowArgumentNullException_WhenValueIsNull() + { + // Arrange + string? nullValue = null; + + // Act & Assert + Assert.Throws(() => + { + Result result = nullValue!; + }); + } + + [Fact] + public void Result_ShouldThrowArgumentNullException_WhenErrorListIsNull() + { + // Arrange + List? nullErrors = null; + + // Act & Assert + Assert.Throws(() => + { + Result result = nullErrors!; + }); + } + + [Fact] + public void Result_ShouldThrowArgumentException_WhenErrorListIsEmpty() + { + // Arrange + var emptyErrors = new List(); + + // Act & Assert + Assert.Throws(() => + { + Result result = emptyErrors; + }); + } +} diff --git a/jobs/Backend/Task/Mews.ExchangeRateMonitor.ExchangeRate.Domain/Currency.cs b/jobs/Backend/Task/Mews.ExchangeRateMonitor.ExchangeRate.Domain/Currency.cs new file mode 100644 index 000000000..26b860de2 --- /dev/null +++ b/jobs/Backend/Task/Mews.ExchangeRateMonitor.ExchangeRate.Domain/Currency.cs @@ -0,0 +1,19 @@ +namespace Mews.ExchangeRateMonitor.ExchangeRate.Domain; + +public class Currency +{ + public Currency(string code) + { + Code = code; + } + + /// + /// Three-letter ISO 4217 code of the currency. + /// + public string Code { get; } + + public override string ToString() + { + return Code; + } +} \ No newline at end of file diff --git a/jobs/Backend/Task/Mews.ExchangeRateMonitor.ExchangeRate.Domain/CurrencyExchangeRate.cs b/jobs/Backend/Task/Mews.ExchangeRateMonitor.ExchangeRate.Domain/CurrencyExchangeRate.cs new file mode 100644 index 000000000..0c3af6cc6 --- /dev/null +++ b/jobs/Backend/Task/Mews.ExchangeRateMonitor.ExchangeRate.Domain/CurrencyExchangeRate.cs @@ -0,0 +1,25 @@ +namespace Mews.ExchangeRateMonitor.ExchangeRate.Domain; + +public class CurrencyExchangeRate +{ + public CurrencyExchangeRate( + Currency sourceCurrency, + Currency targetCurrency, + decimal sourceCurrencyAmount, + decimal targetCurrencyRate) + { + SourceCurrency = sourceCurrency; + TargetCurrency = targetCurrency; + SourceCurrencyAmount = sourceCurrencyAmount; + TargetCurrencyRate = targetCurrencyRate; + } + + public Currency SourceCurrency { get; } + public decimal SourceCurrencyAmount { get; } + + public Currency TargetCurrency { get; } + public decimal TargetCurrencyRate { get; } + + public override string ToString() => + $"{SourceCurrencyAmount} {SourceCurrency} = {TargetCurrencyRate} {TargetCurrency} "; +} \ No newline at end of file diff --git a/jobs/Backend/Task/Mews.ExchangeRateMonitor.ExchangeRate.Domain/Mews.ExchangeRateMonitor.ExchangeRate.Domain.csproj b/jobs/Backend/Task/Mews.ExchangeRateMonitor.ExchangeRate.Domain/Mews.ExchangeRateMonitor.ExchangeRate.Domain.csproj new file mode 100644 index 000000000..125f4c93b --- /dev/null +++ b/jobs/Backend/Task/Mews.ExchangeRateMonitor.ExchangeRate.Domain/Mews.ExchangeRateMonitor.ExchangeRate.Domain.csproj @@ -0,0 +1,9 @@ + + + + net9.0 + enable + enable + + + diff --git a/jobs/Backend/Task/Mews.ExchangeRateMonitor.ExchangeRate.Features/ExchangeRatesModuleRegistration.cs b/jobs/Backend/Task/Mews.ExchangeRateMonitor.ExchangeRate.Features/ExchangeRatesModuleRegistration.cs new file mode 100644 index 000000000..11f645a57 --- /dev/null +++ b/jobs/Backend/Task/Mews.ExchangeRateMonitor.ExchangeRate.Features/ExchangeRatesModuleRegistration.cs @@ -0,0 +1,75 @@ +using FluentValidation; +using Mews.ExchangeRateMonitor.Common.API.Extensions; +using Mews.ExchangeRateMonitor.Common.Application.Extensions; +using Mews.ExchangeRateMonitor.ExchangeRate.Features.Features; +using Mews.ExchangeRateMonitor.ExchangeRate.Features.Options; +using Mews.ExchangeRateMonitor.ExchangeRate.Features.Shared.HttpClient; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Options; + +namespace Mews.ExchangeRateMonitor.ExchangeRate.Features; + +public static class ExchangeRatesModuleRegistration +{ + public static IServiceCollection AddExchangeRatesModule(this IServiceCollection services, IConfiguration configuration) + { + services.AddCustomAppOptions(configuration); + services.AddHttpClients(); + services.AddCustomServices(); + services.AddExchangeRatesModuleApi(); + return services; + } + + private static IServiceCollection AddCustomAppOptions(this IServiceCollection services, IConfiguration configuration) + { + var msg = $"{nameof(ExchangeRateModuleOptions)} is not configured properly."; + services.AddOptions() + .Bind(configuration.GetSection(nameof(ExchangeRateModuleOptions))) + .ValidateDataAnnotations() + .ValidateOnStart() + .Validate(x => !string.IsNullOrEmpty(x.CnbExratesOptions.BaseCnbApiUri), $"{msg} Base url shouldn't been empty or null"); + + return services; + } + + private static IServiceCollection AddCustomServices(this IServiceCollection services) + { + services.AddScoped(); + return services; + } + + private static IServiceCollection AddHttpClients(this IServiceCollection services) + { + services.AddHttpClient(HttpClientConsts.HttpCnbClient, (serviceProvider, httpClient) => + { + var cnbExrateOptions = serviceProvider + .GetRequiredService>().Value.CnbExratesOptions; + + var baseUri = cnbExrateOptions.BaseCnbApiUri.EndsWith("/") + ? cnbExrateOptions.BaseCnbApiUri + : cnbExrateOptions.BaseCnbApiUri + "/"; + + httpClient.BaseAddress = new Uri(cnbExrateOptions.BaseCnbApiUri); + }) + //recycle each individual TCP connection every 5 minutes + .ConfigurePrimaryHttpMessageHandler(() => + new SocketsHttpHandler + { + PooledConnectionLifetime = TimeSpan.FromMinutes(5), + }) + //keep the HttpClient handler alive forever, so its connection pool isn’t thrown away every 2 minutes + .SetHandlerLifetime(Timeout.InfiniteTimeSpan); + + return services; + } + + private static IServiceCollection AddExchangeRatesModuleApi(this IServiceCollection services) + { + services.RegisterApiEndpointsFromAssemblyContaining(typeof(ExchangeRatesModuleRegistration)); + services.RegisterHandlersFromAssemblyContaining(typeof(ExchangeRatesModuleRegistration)); + services.AddValidatorsFromAssembly(typeof(ExchangeRatesModuleRegistration).Assembly, includeInternalTypes: true); + + return services; + } +} diff --git a/jobs/Backend/Task/Mews.ExchangeRateMonitor.ExchangeRate.Features/Features/ExchangeRateProvider.cs b/jobs/Backend/Task/Mews.ExchangeRateMonitor.ExchangeRate.Features/Features/ExchangeRateProvider.cs new file mode 100644 index 000000000..babc20427 --- /dev/null +++ b/jobs/Backend/Task/Mews.ExchangeRateMonitor.ExchangeRate.Features/Features/ExchangeRateProvider.cs @@ -0,0 +1,81 @@ +using Mews.ExchangeRateMonitor.Common.Domain.Results; +using Mews.ExchangeRateMonitor.ExchangeRate.Domain; +using Mews.ExchangeRateMonitor.ExchangeRate.Features.Features.GetExratesDaily; +using Mews.ExchangeRateMonitor.ExchangeRate.Features.Options; +using Mews.ExchangeRateMonitor.ExchangeRate.Features.Shared.HttpClient; +using Microsoft.Extensions.Caching.Memory; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; +using System.Net.Http.Json; + +namespace Mews.ExchangeRateMonitor.ExchangeRate.Features.Features; + +public interface IExchangeRateProvider +{ + Task>> GetDailyRatesAsync(DateOnly date, CancellationToken ct); +} + +public sealed class ExchangeRateProvider( + ILogger logger, + IOptions opts, + IMemoryCache cache, + IHttpClientFactory httpClientFactory) : IExchangeRateProvider +{ + private const string TargetCurrencyCode = "CZK"; + private static string CnbDailyKey(DateOnly d) => $"cnb:daily:{d:yyyy-MM-dd}"; + + public async Task>> GetDailyRatesAsync(DateOnly date, CancellationToken ct) + { + var key = CnbDailyKey(date); + if (cache.TryGetValue>(key, out var cached) && cached is not null) + return cached; + + var client = httpClientFactory.CreateClient(HttpClientConsts.HttpCnbClient); + var url = $"exrates/daily?date={date:yyyy-MM-dd}"; + + try + { + var cnbDailyRates = await client.GetFromJsonAsync(url, ct); + if (cnbDailyRates?.Rates is null) + { + var msg = $"CNB response was null or missing 'rates' for ${url}"; + logger.LogWarning(msg); + return Error.Failure($"{nameof(GetDailyRatesAsync)}", msg); + } + + var reqCurr = opts.Value.CnbExratesOptions.RequiredCurrencies.ToHashSet(); + var filteredRates = cnbDailyRates.Rates + .Where(cnbRate => reqCurr.Any(c => c == cnbRate.CurrencyCode)) + .Select(x => x.ToCurrencyExchangeRate(TargetCurrencyCode)) + .ToList(); + + cache.Set(key, filteredRates, BuildCacheOptions(date, key)); + + return filteredRates; + } + catch (HttpRequestException ex) + { + var msg = $"HTTP error calling CNB for {url}"; + logger.LogError(ex, msg); + return Error.Failure(nameof(GetDailyRatesAsync), msg); + } + catch (Exception ex) + { + var msg = $"Unexpected error calling CNB for {url}"; + logger.LogError(ex, msg); + return Error.Failure(nameof(GetDailyRatesAsync), msg); + } + } + + + private MemoryCacheEntryOptions BuildCacheOptions(DateOnly date, string key) => + new MemoryCacheEntryOptions + { + AbsoluteExpirationRelativeToNow = date < DateOnly.FromDateTime(DateTime.UtcNow) + ? TimeSpan.FromDays(30) // historical = long TTL + : TimeSpan.FromMinutes(10) // today = short TTL + } + .SetSize(1) + .RegisterPostEvictionCallback((_, _, reason, _) => + logger.LogDebug($"Cache evicted {key} due to {reason}")); +} \ No newline at end of file diff --git a/jobs/Backend/Task/Mews.ExchangeRateMonitor.ExchangeRate.Features/Features/GetExratesDaily/GetExratesDaily.Contracts.cs b/jobs/Backend/Task/Mews.ExchangeRateMonitor.ExchangeRate.Features/Features/GetExratesDaily/GetExratesDaily.Contracts.cs new file mode 100644 index 000000000..8a7afdb2e --- /dev/null +++ b/jobs/Backend/Task/Mews.ExchangeRateMonitor.ExchangeRate.Features/Features/GetExratesDaily/GetExratesDaily.Contracts.cs @@ -0,0 +1,5 @@ +namespace Mews.ExchangeRateMonitor.ExchangeRate.Features.Features.GetExratesDaily; + +public sealed record GetExratesDailyRequest(DateOnly Date); +public sealed record GetExratesDailyResponse(IEnumerable Rates); +public sealed record CurrencyExchangeRateDto(string SourceCurrency, string TargetCurrency, decimal Amount, decimal Rate); \ No newline at end of file diff --git a/jobs/Backend/Task/Mews.ExchangeRateMonitor.ExchangeRate.Features/Features/GetExratesDaily/GetExratesDaily.Endpoint.cs b/jobs/Backend/Task/Mews.ExchangeRateMonitor.ExchangeRate.Features/Features/GetExratesDaily/GetExratesDaily.Endpoint.cs new file mode 100644 index 000000000..e0e2a78f1 --- /dev/null +++ b/jobs/Backend/Task/Mews.ExchangeRateMonitor.ExchangeRate.Features/Features/GetExratesDaily/GetExratesDaily.Endpoint.cs @@ -0,0 +1,31 @@ +using Mews.ExchangeRateMonitor.Common.API.Abstractions; +using Mews.ExchangeRateMonitor.Common.API.Extensions; +using Mews.ExchangeRateMonitor.Common.API.RateLimiting; +using Mews.ExchangeRateMonitor.ExchangeRate.Features.Shared.Routes; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; + +namespace Mews.ExchangeRateMonitor.ExchangeRate.Features.Features.GetExratesDaily; + +public class GetExratesDailyEndpoint : IApiEndpoint +{ + public void MapEndpoint(WebApplication app) + { + app.MapGet(RouteConsts.ExratesDailyRoute, Handle) + .WithDescription($"Returns last valid data for selected day") + .RequireRateLimiting(AppRateLimiting.GlobalRateLimitPolicy); + } + + private static async Task Handle( + [AsParameters] GetExratesDailyRequest request, + [FromServices] IGetExratesDailyHandler handler, + CancellationToken cancellationToken) + { + var response = await handler.HandleAsync(request, cancellationToken); + if (response.IsError) + return response.Errors.ToProblem(); + + return Results.Ok(response.Value); + } +} diff --git a/jobs/Backend/Task/Mews.ExchangeRateMonitor.ExchangeRate.Features/Features/GetExratesDaily/GetExratesDaily.ExternalContracts.cs b/jobs/Backend/Task/Mews.ExchangeRateMonitor.ExchangeRate.Features/Features/GetExratesDaily/GetExratesDaily.ExternalContracts.cs new file mode 100644 index 000000000..0e2fe6cd2 --- /dev/null +++ b/jobs/Backend/Task/Mews.ExchangeRateMonitor.ExchangeRate.Features/Features/GetExratesDaily/GetExratesDaily.ExternalContracts.cs @@ -0,0 +1,12 @@ +namespace Mews.ExchangeRateMonitor.ExchangeRate.Features.Features.GetExratesDaily; + +public sealed record CnbApiDailyRatesResponseDto(IEnumerable Rates); +public sealed record CnbApiDailyRateDto( + decimal Amount, + string Country, + string Currency, + string CurrencyCode, + int Order, + decimal Rate, + DateTime ValidFor); + diff --git a/jobs/Backend/Task/Mews.ExchangeRateMonitor.ExchangeRate.Features/Features/GetExratesDaily/GetExratesDaily.Handler.cs b/jobs/Backend/Task/Mews.ExchangeRateMonitor.ExchangeRate.Features/Features/GetExratesDaily/GetExratesDaily.Handler.cs new file mode 100644 index 000000000..ec2c98f36 --- /dev/null +++ b/jobs/Backend/Task/Mews.ExchangeRateMonitor.ExchangeRate.Features/Features/GetExratesDaily/GetExratesDaily.Handler.cs @@ -0,0 +1,38 @@ +using FluentValidation; +using Mews.ExchangeRateMonitor.Common.Application.Extensions; +using Mews.ExchangeRateMonitor.Common.Domain.Handlers; +using Mews.ExchangeRateMonitor.Common.Domain.Results; +using Microsoft.Extensions.Logging; + +namespace Mews.ExchangeRateMonitor.ExchangeRate.Features.Features.GetExratesDaily; + +public interface IGetExratesDailyHandler : IHandler +{ + Task>> HandleAsync(GetExratesDailyRequest request, CancellationToken ct); +} + +public sealed class GetExratesDailyHandler( + ILogger logger, + IExchangeRateProvider exchangeRateProvider, + IValidator validator) : IGetExratesDailyHandler +{ + public async Task>> HandleAsync(GetExratesDailyRequest request, CancellationToken ct) + { + logger.LogInformation($"{nameof(GetExratesDailyHandler)}.{nameof(HandleAsync)} started"); + + var validationResult = await validator.ValidateAsync(request, ct); + if (!validationResult.IsValid) + return validationResult.ToDomainErrors(nameof(GetExratesDailyRequest)); + + var dailyRatesRes = await exchangeRateProvider.GetDailyRatesAsync(request.Date, ct); + if (dailyRatesRes.IsError) + return dailyRatesRes.Errors; + + var dailyRates = dailyRatesRes.Value ?? []; + var rateDtos = dailyRates.Select(x => x.ToRateDto()).ToList(); + + logger.LogInformation($"{nameof(GetExratesDailyHandler)}.{nameof(HandleAsync)} is successfull"); + + return rateDtos; + } +} \ No newline at end of file diff --git a/jobs/Backend/Task/Mews.ExchangeRateMonitor.ExchangeRate.Features/Features/GetExratesDaily/GetExratesDaily.Mappings.cs b/jobs/Backend/Task/Mews.ExchangeRateMonitor.ExchangeRate.Features/Features/GetExratesDaily/GetExratesDaily.Mappings.cs new file mode 100644 index 000000000..6f48c151b --- /dev/null +++ b/jobs/Backend/Task/Mews.ExchangeRateMonitor.ExchangeRate.Features/Features/GetExratesDaily/GetExratesDaily.Mappings.cs @@ -0,0 +1,13 @@ +using Mews.ExchangeRateMonitor.ExchangeRate.Domain; + +namespace Mews.ExchangeRateMonitor.ExchangeRate.Features.Features.GetExratesDaily; + +public static class GetExratesDailyMappings +{ + public static CurrencyExchangeRate ToCurrencyExchangeRate(this CnbApiDailyRateDto cnbRate, string targerCurrency) => + new CurrencyExchangeRate(new(cnbRate.CurrencyCode), new(targerCurrency), cnbRate.Amount, cnbRate.Rate); + + public static CurrencyExchangeRateDto ToRateDto(this CurrencyExchangeRate rate) => + new(rate.SourceCurrency.Code, rate.TargetCurrency.Code, rate.SourceCurrencyAmount, rate.TargetCurrencyRate); +} + diff --git a/jobs/Backend/Task/Mews.ExchangeRateMonitor.ExchangeRate.Features/Features/GetExratesDaily/GetExratesDaily.Validators.cs b/jobs/Backend/Task/Mews.ExchangeRateMonitor.ExchangeRate.Features/Features/GetExratesDaily/GetExratesDaily.Validators.cs new file mode 100644 index 000000000..bf40e6ef5 --- /dev/null +++ b/jobs/Backend/Task/Mews.ExchangeRateMonitor.ExchangeRate.Features/Features/GetExratesDaily/GetExratesDaily.Validators.cs @@ -0,0 +1,16 @@ +using FluentValidation; +using Mews.ExchangeRateMonitor.ExchangeRate.Features.Options; +using Microsoft.Extensions.Options; + +namespace Mews.ExchangeRateMonitor.ExchangeRate.Features.Features.GetExratesDaily; + +public class GetExratesDailyRequestValidator : AbstractValidator +{ + public GetExratesDailyRequestValidator(IOptions opts) + { + RuleFor(req => req.Date) + .NotNull().WithMessage("Date is required.") + .LessThanOrEqualTo(DateOnly.FromDateTime(DateTime.UtcNow)) + .WithMessage("Date cannot be in the future."); + } +} diff --git a/jobs/Backend/Task/Mews.ExchangeRateMonitor.ExchangeRate.Features/Mews.ExchangeRateMonitor.ExchangeRate.Features.csproj b/jobs/Backend/Task/Mews.ExchangeRateMonitor.ExchangeRate.Features/Mews.ExchangeRateMonitor.ExchangeRate.Features.csproj new file mode 100644 index 000000000..75bf1a9d0 --- /dev/null +++ b/jobs/Backend/Task/Mews.ExchangeRateMonitor.ExchangeRate.Features/Mews.ExchangeRateMonitor.ExchangeRate.Features.csproj @@ -0,0 +1,22 @@ + + + + net9.0 + enable + enable + + + + + + + + + + + + + + + + diff --git a/jobs/Backend/Task/Mews.ExchangeRateMonitor.ExchangeRate.Features/Options/CnbExratesOptions.cs b/jobs/Backend/Task/Mews.ExchangeRateMonitor.ExchangeRate.Features/Options/CnbExratesOptions.cs new file mode 100644 index 000000000..05889bcd7 --- /dev/null +++ b/jobs/Backend/Task/Mews.ExchangeRateMonitor.ExchangeRate.Features/Options/CnbExratesOptions.cs @@ -0,0 +1,7 @@ +namespace Mews.ExchangeRateMonitor.ExchangeRate.Features.Options; + +public sealed record CnbExratesOptions +{ + public string BaseCnbApiUri { get; init; } = null!; + public IEnumerable RequiredCurrencies { get; set; } = []; +} diff --git a/jobs/Backend/Task/Mews.ExchangeRateMonitor.ExchangeRate.Features/Options/ExchangeRateModuleOptions.cs b/jobs/Backend/Task/Mews.ExchangeRateMonitor.ExchangeRate.Features/Options/ExchangeRateModuleOptions.cs new file mode 100644 index 000000000..2663ca15b --- /dev/null +++ b/jobs/Backend/Task/Mews.ExchangeRateMonitor.ExchangeRate.Features/Options/ExchangeRateModuleOptions.cs @@ -0,0 +1,6 @@ +namespace Mews.ExchangeRateMonitor.ExchangeRate.Features.Options; + +public sealed record ExchangeRateModuleOptions +{ + public CnbExratesOptions CnbExratesOptions { get; init; } = new(); +} diff --git a/jobs/Backend/Task/Mews.ExchangeRateMonitor.ExchangeRate.Features/Shared/HttpClient/HttpClientConsts.cs b/jobs/Backend/Task/Mews.ExchangeRateMonitor.ExchangeRate.Features/Shared/HttpClient/HttpClientConsts.cs new file mode 100644 index 000000000..0206196d0 --- /dev/null +++ b/jobs/Backend/Task/Mews.ExchangeRateMonitor.ExchangeRate.Features/Shared/HttpClient/HttpClientConsts.cs @@ -0,0 +1,6 @@ +namespace Mews.ExchangeRateMonitor.ExchangeRate.Features.Shared.HttpClient; + +public static class HttpClientConsts +{ + public const string HttpCnbClient = "CnbClient"; +} diff --git a/jobs/Backend/Task/Mews.ExchangeRateMonitor.ExchangeRate.Features/Shared/Routes/RouteConsts.cs b/jobs/Backend/Task/Mews.ExchangeRateMonitor.ExchangeRate.Features/Shared/Routes/RouteConsts.cs new file mode 100644 index 000000000..c34471727 --- /dev/null +++ b/jobs/Backend/Task/Mews.ExchangeRateMonitor.ExchangeRate.Features/Shared/Routes/RouteConsts.cs @@ -0,0 +1,8 @@ +namespace Mews.ExchangeRateMonitor.ExchangeRate.Features.Shared.Routes; + +public static class RouteConsts +{ + public const string BaseRoute = "/api/exrates"; + public const string GetTestItem = $"{BaseRoute}/gettestitem"; + public const string ExratesDailyRoute = $"{BaseRoute}/daily"; +} diff --git a/jobs/Backend/Task/Mews.ExchangeRateMonitor.ExchangeRate.Infrastructure/Mews.ExchangeRateMonitor.ExchangeRate.Infrastructure.csproj b/jobs/Backend/Task/Mews.ExchangeRateMonitor.ExchangeRate.Infrastructure/Mews.ExchangeRateMonitor.ExchangeRate.Infrastructure.csproj new file mode 100644 index 000000000..954beb6a8 --- /dev/null +++ b/jobs/Backend/Task/Mews.ExchangeRateMonitor.ExchangeRate.Infrastructure/Mews.ExchangeRateMonitor.ExchangeRate.Infrastructure.csproj @@ -0,0 +1,14 @@ + + + + net9.0 + enable + enable + + + + + + + + diff --git a/jobs/Backend/Task/Mews.ExchangeRateMonitor.ExchangeRate.Tests.Unit/Features/ExchangeRateProviderTests.cs b/jobs/Backend/Task/Mews.ExchangeRateMonitor.ExchangeRate.Tests.Unit/Features/ExchangeRateProviderTests.cs new file mode 100644 index 000000000..a4aea4b7e --- /dev/null +++ b/jobs/Backend/Task/Mews.ExchangeRateMonitor.ExchangeRate.Tests.Unit/Features/ExchangeRateProviderTests.cs @@ -0,0 +1,109 @@ +using FluentAssertions; +using Mews.ExchangeRateMonitor.ExchangeRate.Domain; +using Mews.ExchangeRateMonitor.ExchangeRate.Features.Features; +using Mews.ExchangeRateMonitor.ExchangeRate.Features.Options; +using Mews.ExchangeRateMonitor.ExchangeRate.Features.Shared.HttpClient; +using Mews.ExchangeRateMonitor.ExchangeRate.Tests.Unit.TestHelpers; +using Microsoft.Extensions.Caching.Memory; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; +using NSubstitute; +using System.Net; + +namespace Mews.ExchangeRateMonitor.ExchangeRate.Tests.Unit.Features; + +public class ExchangeRateProviderTests +{ + private readonly IEnumerable _requiredCurrencies = ["USD", "EUR"]; + private (int CurAmount, string Json) ReturnedJsonWithCurAmount => (3, """ + {"rates":[ + {"currencyCode":"USD","amount":1,"rate":22.00}, + {"currencyCode":"EUR","amount":1,"rate":25.00}, + {"currencyCode":"PLN","amount":1,"rate":5.00} + ]} + """); + + private readonly DateOnly _date1 = new DateOnly(2024, 12, 24); + + private static IOptions Opts(IEnumerable reqCurs) => + Options.Create( + new ExchangeRateModuleOptions + { + CnbExratesOptions = new CnbExratesOptions() + { + BaseCnbApiUri = "https://cnb.local/", + RequiredCurrencies = reqCurs + } + }); + + private static IHttpClientFactory HttpFactory(HttpMessageHandler handler) + { + var client = new HttpClient(handler) { BaseAddress = new Uri("https://cnb.local/") }; + var f = Substitute.For(); + f.CreateClient(HttpClientConsts.HttpCnbClient).Returns(client); + return f; + } + + [Fact] + public async Task Returns_Value_From_HttpClient_When_There_Is_No_Chache() + { + var handler = new StaticResponseHandler( + new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new StringContent(ReturnedJsonWithCurAmount.Json, System.Text.Encoding.UTF8, "application/json") + }); + + var cache = Substitute.For(); + var logger = Substitute.For>(); + var exchangeRateProvider = new ExchangeRateProvider(logger, Opts(_requiredCurrencies), cache, HttpFactory(handler)); + + var res = await exchangeRateProvider.GetDailyRatesAsync(_date1, default); + + res.IsError.Should().BeFalse(); + res.Value!.Count().Should().Be(_requiredCurrencies.Count()); + } + + [Fact] + public async Task Returns_Cached_Value_And_Skips_Http_On_Cache_Hit() + { + var key = $"cnb:daily:{_date1:yyyy-MM-dd}"; + var cached = new List { new(new("CNY"), new("CZK"), 1m, 3.334m) }; + + var cache = Substitute.For(); + object boxed = cached; + cache.TryGetValue(key, out Arg.Any()).Returns(ci => { ci[1] = boxed; return true; }); + + var logger = Substitute.For>(); + var handler = new StaticResponseHandler(CreateResponseMessage(HttpStatusCode.OK, """{"rates":null}""")); + var httpFactory = HttpFactory(handler); + var exchangeRateProvider = new ExchangeRateProvider(logger, Opts(_requiredCurrencies), cache, httpFactory); + + var res = await exchangeRateProvider.GetDailyRatesAsync(_date1, default); + + res.IsError.Should().BeFalse(); + res.Value.Should().BeEquivalentTo(cached); + } + + [Fact] + public async Task Returns_Failure_When_Cnb_Response_Missing_Rates() + { + var handler = new StaticResponseHandler(CreateResponseMessage(HttpStatusCode.OK, """{"rates":null}""")); + var cache = Substitute.For(); + cache.TryGetValue(Arg.Any(), out Arg.Any()).Returns(ci => { ci[1] = null; return false; }); + + var logger = Substitute.For>(); + var exchangeRateProvider = new ExchangeRateProvider(logger, Opts(_requiredCurrencies), cache, HttpFactory(handler)); + + var res = await exchangeRateProvider.GetDailyRatesAsync(_date1, default); + + res.IsError.Should().BeTrue(); + } + + private HttpResponseMessage CreateResponseMessage(HttpStatusCode code, string returnedJson) + { + return new HttpResponseMessage(code) + { + Content = new StringContent(returnedJson, System.Text.Encoding.UTF8, "application/json"), + }; + } +} diff --git a/jobs/Backend/Task/Mews.ExchangeRateMonitor.ExchangeRate.Tests.Unit/Features/GetExratesDaily/GetExratesDailyHandlerTests.cs b/jobs/Backend/Task/Mews.ExchangeRateMonitor.ExchangeRate.Tests.Unit/Features/GetExratesDaily/GetExratesDailyHandlerTests.cs new file mode 100644 index 000000000..630c4251a --- /dev/null +++ b/jobs/Backend/Task/Mews.ExchangeRateMonitor.ExchangeRate.Tests.Unit/Features/GetExratesDaily/GetExratesDailyHandlerTests.cs @@ -0,0 +1,73 @@ +using FluentAssertions; +using FluentValidation; +using FluentValidation.Results; +using Mews.ExchangeRateMonitor.Common.Domain.Results; +using Mews.ExchangeRateMonitor.ExchangeRate.Domain; +using Mews.ExchangeRateMonitor.ExchangeRate.Features.Features; +using Mews.ExchangeRateMonitor.ExchangeRate.Features.Features.GetExratesDaily; +using Microsoft.Extensions.Logging; +using NSubstitute; + +namespace Mews.ExchangeRateMonitor.ExchangeRate.Tests.Unit.Features.GetExratesDaily +{ + public class GetExratesDailyHandlerTests + { + [Fact] + public async Task Returns_Errors_When_DateValidation_Fails() + { + var logger = Substitute.For>(); + var provider = Substitute.For(); + var validator = Substitute.For>(); + validator.ValidateAsync(Arg.Any(), Arg.Any()) + .Returns(new ValidationResult([new ValidationFailure("Date", "invalid")])); + var sut = new GetExratesDailyHandler(logger, provider, validator); + + var res = await sut.HandleAsync(new GetExratesDailyRequest(DateOnly.FromDateTime(DateTime.UtcNow.AddDays(1))), default); + res.IsError.Should().BeTrue(); + await provider.DidNotReceiveWithAnyArgs().GetDailyRatesAsync(default, default); + } + + [Fact] + public async Task Bubbles_Provider_Errors() + { + var logger = Substitute.For>(); + var provider = Substitute.For(); + var validator = Substitute.For>(); + validator.ValidateAsync(Arg.Any(), Arg.Any()) + .Returns(new ValidationResult()); + provider.GetDailyRatesAsync(Arg.Any(), Arg.Any()) + .Returns(Error.Failure("Provider", "CNB down")); + + var sut = new GetExratesDailyHandler(logger, provider, validator); + + var res = await sut.HandleAsync(new GetExratesDailyRequest(new DateOnly(2025, 8, 22)), default); + + res.IsError.Should().BeTrue(); + res.Errors.Should().ContainSingle(e => e.Description.Contains("CNB down")); + } + + [Fact] + public async Task Maps_Domain_Rates_To_Dtos_On_Success() + { + var logger = Substitute.For>(); + var provider = Substitute.For(); + var validator = Substitute.For>(); + validator.ValidateAsync(Arg.Any(), Arg.Any()) + .Returns(new ValidationResult()); + provider.GetDailyRatesAsync(Arg.Any(), Arg.Any()) + .Returns(new List + { + new(new("USD"),new("CZK"),1m, 22m), + new(new("EUR"),new("CZK"),1m, 26m) + }); + + var sut = new GetExratesDailyHandler(logger, provider, validator); + + var res = await sut.HandleAsync(new GetExratesDailyRequest(new DateOnly(2025, 8, 22)), default); + + res.IsError.Should().BeFalse(); + res.Value!.Select(x => x.SourceCurrency).Should().BeEquivalentTo("USD", "EUR"); + res.Value!.All(x => x.TargetCurrency == "CZK").Should().BeTrue(); + } + } +} diff --git a/jobs/Backend/Task/Mews.ExchangeRateMonitor.ExchangeRate.Tests.Unit/Mews.ExchangeRateMonitor.ExchangeRate.Tests.Unit.csproj b/jobs/Backend/Task/Mews.ExchangeRateMonitor.ExchangeRate.Tests.Unit/Mews.ExchangeRateMonitor.ExchangeRate.Tests.Unit.csproj new file mode 100644 index 000000000..d5f790362 --- /dev/null +++ b/jobs/Backend/Task/Mews.ExchangeRateMonitor.ExchangeRate.Tests.Unit/Mews.ExchangeRateMonitor.ExchangeRate.Tests.Unit.csproj @@ -0,0 +1,28 @@ + + + + net9.0 + enable + enable + false + + + + + + + + + + + + + + + + + + + + + diff --git a/jobs/Backend/Task/Mews.ExchangeRateMonitor.ExchangeRate.Tests.Unit/TestHelpers/StaticResponseHandler.cs b/jobs/Backend/Task/Mews.ExchangeRateMonitor.ExchangeRate.Tests.Unit/TestHelpers/StaticResponseHandler.cs new file mode 100644 index 000000000..e02fa7ec3 --- /dev/null +++ b/jobs/Backend/Task/Mews.ExchangeRateMonitor.ExchangeRate.Tests.Unit/TestHelpers/StaticResponseHandler.cs @@ -0,0 +1,11 @@ +namespace Mews.ExchangeRateMonitor.ExchangeRate.Tests.Unit.TestHelpers; + +/// +/// Immitation of HttpMessageHandler that always returns the same response. +/// +/// +public class StaticResponseHandler(HttpResponseMessage response) : HttpMessageHandler +{ + protected override Task SendAsync(HttpRequestMessage request, CancellationToken ct) + => Task.FromResult(response); +} diff --git a/jobs/Backend/Task/Mews.ExchangeRateMonitor.Host/Dockerfile b/jobs/Backend/Task/Mews.ExchangeRateMonitor.Host/Dockerfile new file mode 100644 index 000000000..e6dd0d33e --- /dev/null +++ b/jobs/Backend/Task/Mews.ExchangeRateMonitor.Host/Dockerfile @@ -0,0 +1,30 @@ +# See https://aka.ms/customizecontainer to learn how to customize your debug container and how Visual Studio uses this Dockerfile to build your images for faster debugging. + +# This stage is used when running from VS in fast mode (Default for Debug configuration) +FROM mcr.microsoft.com/dotnet/aspnet:9.0 AS base +USER $APP_UID +WORKDIR /app +EXPOSE 8080 +EXPOSE 8081 + + +# This stage is used to build the service project +FROM mcr.microsoft.com/dotnet/sdk:9.0 AS build +ARG BUILD_CONFIGURATION=Release +WORKDIR /src +COPY ["Mews.ExchangeRateMonitor.Host/Mews.ExchangeRateMonitor.Host.csproj", "Mews.ExchangeRateMonitor.Host/"] +RUN dotnet restore "./Mews.ExchangeRateMonitor.Host/Mews.ExchangeRateMonitor.Host.csproj" +COPY . . +WORKDIR "/src/Mews.ExchangeRateMonitor.Host" +RUN dotnet build "./Mews.ExchangeRateMonitor.Host.csproj" -c $BUILD_CONFIGURATION -o /app/build + +# This stage is used to publish the service project to be copied to the final stage +FROM build AS publish +ARG BUILD_CONFIGURATION=Release +RUN dotnet publish "./Mews.ExchangeRateMonitor.Host.csproj" -c $BUILD_CONFIGURATION -o /app/publish /p:UseAppHost=false + +# This stage is used in production or when running from VS in regular mode (Default when not using the Debug configuration) +FROM base AS final +WORKDIR /app +COPY --from=publish /app/publish . +ENTRYPOINT ["dotnet", "Mews.ExchangeRateMonitor.Host.dll"] \ No newline at end of file diff --git a/jobs/Backend/Task/Mews.ExchangeRateMonitor.Host/Mews.ExchangeRateMonitor.Host.csproj b/jobs/Backend/Task/Mews.ExchangeRateMonitor.Host/Mews.ExchangeRateMonitor.Host.csproj new file mode 100644 index 000000000..bcbce0c0e --- /dev/null +++ b/jobs/Backend/Task/Mews.ExchangeRateMonitor.Host/Mews.ExchangeRateMonitor.Host.csproj @@ -0,0 +1,22 @@ + + + + net9.0 + enable + enable + 33ea9262-d378-4002-a3f4-e5ad71b54e4d + Linux + ..\docker-compose.dcproj + + + + + + + + + + + + + diff --git a/jobs/Backend/Task/Mews.ExchangeRateMonitor.Host/Program.cs b/jobs/Backend/Task/Mews.ExchangeRateMonitor.Host/Program.cs new file mode 100644 index 000000000..292e9e577 --- /dev/null +++ b/jobs/Backend/Task/Mews.ExchangeRateMonitor.Host/Program.cs @@ -0,0 +1,18 @@ +using Mews.ExchangeRateMonitor.Common.API; +using Mews.ExchangeRateMonitor.Common.API.Extensions; +using Mews.ExchangeRateMonitor.Common.Infrastructure; +using Mews.ExchangeRateMonitor.ExchangeRate.Features; + +var builder = WebApplication.CreateBuilder(args); + +builder.AddCoreHostLogging(); +builder.Services.AddCoreWebApiInfrastructure(); +builder.Services.AddCoreInfrastructure(); +builder.Services.AddExchangeRatesModule(builder.Configuration); + +var app = builder.Build(); + +app.UseSwaggerExt(); +app.UseAppRateLimiter(); +app.MapApiEndpoints(); +app.Run(); diff --git a/jobs/Backend/Task/Mews.ExchangeRateMonitor.Host/Properties/launchSettings.json b/jobs/Backend/Task/Mews.ExchangeRateMonitor.Host/Properties/launchSettings.json new file mode 100644 index 000000000..77fc1038c --- /dev/null +++ b/jobs/Backend/Task/Mews.ExchangeRateMonitor.Host/Properties/launchSettings.json @@ -0,0 +1,31 @@ +{ + "profiles": { + //"http": { + // "commandName": "Project", + // "environmentVariables": { + // "ASPNETCORE_ENVIRONMENT": "Development" + // }, + // "dotnetRunMessages": true, + // "applicationUrl": "http://localhost:5148" + //}, + //"https": { + // "commandName": "Project", + // "environmentVariables": { + // "ASPNETCORE_ENVIRONMENT": "Development" + // }, + // "dotnetRunMessages": true, + // "applicationUrl": "https://localhost:7033;http://localhost:5148" + //}, + "Container (Dockerfile)": { + "commandName": "Docker", + "launchUrl": "{Scheme}://{ServiceHost}:{ServicePort}", + "environmentVariables": { + "ASPNETCORE_HTTPS_PORTS": "8081", + "ASPNETCORE_HTTP_PORTS": "8080" + }, + "publishAllPorts": false, + "useSSL": true + } + }, + "$schema": "https://json.schemastore.org/launchsettings.json" +} \ No newline at end of file diff --git a/jobs/Backend/Task/Mews.ExchangeRateMonitor.Host/appsettings.Development.json b/jobs/Backend/Task/Mews.ExchangeRateMonitor.Host/appsettings.Development.json new file mode 100644 index 000000000..e8bff195e --- /dev/null +++ b/jobs/Backend/Task/Mews.ExchangeRateMonitor.Host/appsettings.Development.json @@ -0,0 +1,39 @@ +{ + "Serilog": { + "Using": [ + "Serilog.Sinks.Console", + "Serilog.Sinks.Seq" + ], + "MinimumLevel": { + "Default": "Debug", + "Override": { + "Microsoft": "Information" + } + }, + "WriteTo": [ + { "Name": "Console" }, + { + "Name": "Seq", + "Args": { + "serverUrl": "http://seq:5341", + "apiKey": "abcde123" + } + } + ], + "Enrich": [ "FromLogContext", "WithMachineName", "WithThreadId" ], + "Properties": { + "Application": "Mews.ExchangeRateMonitor" + } + }, + + "OTEL_EXPORTER_OTLP_ENDPOINT": "http://jaeger:4318", + "OTEL_EXPORTER_OTLP_PROTOCOL": "http/protobuf", + "OTEL_EXPORTER_OTLP_HEADERS": "X-Seq-ApiKey=abcde12345", + + "ExchangeRateModuleOptions": { + "CnbExratesOptions": { + "BaseCnbApiUri": "https://api.cnb.cz/cnbapi/", + "RequiredCurrencies": [ "USD", "EUR", "JPY", "KES", "RUB", "THB", "TRY", "XYZ" ] + } + } +} diff --git a/jobs/Backend/Task/Mews.ExchangeRateMonitor.Host/appsettings.json b/jobs/Backend/Task/Mews.ExchangeRateMonitor.Host/appsettings.json new file mode 100644 index 000000000..10f68b8c8 --- /dev/null +++ b/jobs/Backend/Task/Mews.ExchangeRateMonitor.Host/appsettings.json @@ -0,0 +1,9 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + }, + "AllowedHosts": "*" +} diff --git a/jobs/Backend/Task/Mews.ExchangeRateMonitor.sln b/jobs/Backend/Task/Mews.ExchangeRateMonitor.sln new file mode 100644 index 000000000..8cb8ed088 --- /dev/null +++ b/jobs/Backend/Task/Mews.ExchangeRateMonitor.sln @@ -0,0 +1,117 @@ + +Microsoft Visual Studio Solution File, Format Version 12.00 +# Visual Studio Version 17 +VisualStudioVersion = 17.13.36105.23 +MinimumVisualStudioVersion = 10.0.40219.1 +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Mews.ExchangeRateMonitor.Host", "Mews.ExchangeRateMonitor.Host\Mews.ExchangeRateMonitor.Host.csproj", "{CA5E6A1E-E732-4F85-9310-022E44ADF857}" +EndProject +Project("{E53339B2-1760-4266-BCC7-CA923CBCF16C}") = "docker-compose", "docker-compose.dcproj", "{81DDED9D-158B-E303-5F62-77A2896D2A5A}" +EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Common", "Common", "{02EA681E-C7D8-13C7-8484-4AC65E1B71E8}" +EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Modules", "Modules", "{3D2EB323-77C2-417C-A97C-12871F3A8C30}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Mews.ExchangeRateMonitor.Common.API", "Mews.ExchangeRateMonitor.Common.API\Mews.ExchangeRateMonitor.Common.API.csproj", "{85A48359-BB1B-4056-8D2E-2AAAE649CBD5}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Mews.ExchangeRateMonitor.Common.Application", "Mews.ExchangeRateMonitor.Common.Application\Mews.ExchangeRateMonitor.Common.Application.csproj", "{9CA7079A-918F-477C-9586-418A1126AE1F}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Mews.ExchangeRateMonitor.Common.Domain", "Mews.ExchangeRateMonitor.Common.Domain\Mews.ExchangeRateMonitor.Common.Domain.csproj", "{73FC9C90-A35F-4A2E-88F2-549D37C346A6}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Mews.ExchangeRateMonitor.Common.Infrastructure", "Mews.ExchangeRateMonitor.Common.Infrastructure\Mews.ExchangeRateMonitor.Common.Infrastructure.csproj", "{E1883949-9906-467F-BAD3-7ADD700A6CE6}" +EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "ExchangeRate", "ExchangeRate", "{DA03B45D-8EF7-47F7-8BD8-D4A873EF47DF}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Mews.ExchangeRateMonitor.ExchangeRate.Domain", "Mews.ExchangeRateMonitor.ExchangeRate.Domain\Mews.ExchangeRateMonitor.ExchangeRate.Domain.csproj", "{AC1772EB-E8E9-4010-81F8-BE0FB869BF6E}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Mews.ExchangeRateMonitor.ExchangeRate.Features", "Mews.ExchangeRateMonitor.ExchangeRate.Features\Mews.ExchangeRateMonitor.ExchangeRate.Features.csproj", "{3FFF9A49-E26C-467B-8A60-70F25AE1AD67}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Mews.ExchangeRateMonitor.ExchangeRate.Infrastructure", "Mews.ExchangeRateMonitor.ExchangeRate.Infrastructure\Mews.ExchangeRateMonitor.ExchangeRate.Infrastructure.csproj", "{AD3AD6AE-6597-4978-8D4B-569371D7FA6F}" +EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Solution Items", "Solution Items", "{8EC462FD-D22E-90A8-E5CE-7E832BA40C5D}" + ProjectSection(SolutionItems) = preProject + .gitignore = .gitignore + Directory.Build.props = Directory.Build.props + Directory.Packages.props = Directory.Packages.props + README.md = README.md + EndProjectSection +EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Tests", "Tests", "{43602225-F79E-4440-8D91-CC96C9EED4DF}" +EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Tests", "Tests", "{9B27C500-8A19-4D89-A76B-AB74A43C05CE}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Mews.ExchangeRateMonitor.Common.Tests.Unit", "Mews.ExchangeRateMonitor.Common.Tests.Unit\Mews.ExchangeRateMonitor.Common.Tests.Unit.csproj", "{6441DCA1-93D3-45CC-B945-6064827C2745}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Mews.ExchangeRateMonitor.ExchangeRate.Tests.Unit", "Mews.ExchangeRateMonitor.ExchangeRate.Tests.Unit\Mews.ExchangeRateMonitor.ExchangeRate.Tests.Unit.csproj", "{780826E6-B96A-4845-B1B0-18AA2221AD8E}" +EndProject +Global + GlobalSection(SolutionConfigurationPlatforms) = preSolution + Debug|Any CPU = Debug|Any CPU + Release|Any CPU = Release|Any CPU + EndGlobalSection + GlobalSection(ProjectConfigurationPlatforms) = postSolution + {CA5E6A1E-E732-4F85-9310-022E44ADF857}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {CA5E6A1E-E732-4F85-9310-022E44ADF857}.Debug|Any CPU.Build.0 = Debug|Any CPU + {CA5E6A1E-E732-4F85-9310-022E44ADF857}.Release|Any CPU.ActiveCfg = Release|Any CPU + {CA5E6A1E-E732-4F85-9310-022E44ADF857}.Release|Any CPU.Build.0 = Release|Any CPU + {81DDED9D-158B-E303-5F62-77A2896D2A5A}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {81DDED9D-158B-E303-5F62-77A2896D2A5A}.Debug|Any CPU.Build.0 = Debug|Any CPU + {81DDED9D-158B-E303-5F62-77A2896D2A5A}.Release|Any CPU.ActiveCfg = Release|Any CPU + {81DDED9D-158B-E303-5F62-77A2896D2A5A}.Release|Any CPU.Build.0 = Release|Any CPU + {85A48359-BB1B-4056-8D2E-2AAAE649CBD5}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {85A48359-BB1B-4056-8D2E-2AAAE649CBD5}.Debug|Any CPU.Build.0 = Debug|Any CPU + {85A48359-BB1B-4056-8D2E-2AAAE649CBD5}.Release|Any CPU.ActiveCfg = Release|Any CPU + {85A48359-BB1B-4056-8D2E-2AAAE649CBD5}.Release|Any CPU.Build.0 = Release|Any CPU + {9CA7079A-918F-477C-9586-418A1126AE1F}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {9CA7079A-918F-477C-9586-418A1126AE1F}.Debug|Any CPU.Build.0 = Debug|Any CPU + {9CA7079A-918F-477C-9586-418A1126AE1F}.Release|Any CPU.ActiveCfg = Release|Any CPU + {9CA7079A-918F-477C-9586-418A1126AE1F}.Release|Any CPU.Build.0 = Release|Any CPU + {73FC9C90-A35F-4A2E-88F2-549D37C346A6}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {73FC9C90-A35F-4A2E-88F2-549D37C346A6}.Debug|Any CPU.Build.0 = Debug|Any CPU + {73FC9C90-A35F-4A2E-88F2-549D37C346A6}.Release|Any CPU.ActiveCfg = Release|Any CPU + {73FC9C90-A35F-4A2E-88F2-549D37C346A6}.Release|Any CPU.Build.0 = Release|Any CPU + {E1883949-9906-467F-BAD3-7ADD700A6CE6}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {E1883949-9906-467F-BAD3-7ADD700A6CE6}.Debug|Any CPU.Build.0 = Debug|Any CPU + {E1883949-9906-467F-BAD3-7ADD700A6CE6}.Release|Any CPU.ActiveCfg = Release|Any CPU + {E1883949-9906-467F-BAD3-7ADD700A6CE6}.Release|Any CPU.Build.0 = Release|Any CPU + {AC1772EB-E8E9-4010-81F8-BE0FB869BF6E}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {AC1772EB-E8E9-4010-81F8-BE0FB869BF6E}.Debug|Any CPU.Build.0 = Debug|Any CPU + {AC1772EB-E8E9-4010-81F8-BE0FB869BF6E}.Release|Any CPU.ActiveCfg = Release|Any CPU + {AC1772EB-E8E9-4010-81F8-BE0FB869BF6E}.Release|Any CPU.Build.0 = Release|Any CPU + {3FFF9A49-E26C-467B-8A60-70F25AE1AD67}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {3FFF9A49-E26C-467B-8A60-70F25AE1AD67}.Debug|Any CPU.Build.0 = Debug|Any CPU + {3FFF9A49-E26C-467B-8A60-70F25AE1AD67}.Release|Any CPU.ActiveCfg = Release|Any CPU + {3FFF9A49-E26C-467B-8A60-70F25AE1AD67}.Release|Any CPU.Build.0 = Release|Any CPU + {AD3AD6AE-6597-4978-8D4B-569371D7FA6F}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {AD3AD6AE-6597-4978-8D4B-569371D7FA6F}.Debug|Any CPU.Build.0 = Debug|Any CPU + {AD3AD6AE-6597-4978-8D4B-569371D7FA6F}.Release|Any CPU.ActiveCfg = Release|Any CPU + {AD3AD6AE-6597-4978-8D4B-569371D7FA6F}.Release|Any CPU.Build.0 = Release|Any CPU + {6441DCA1-93D3-45CC-B945-6064827C2745}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {6441DCA1-93D3-45CC-B945-6064827C2745}.Debug|Any CPU.Build.0 = Debug|Any CPU + {6441DCA1-93D3-45CC-B945-6064827C2745}.Release|Any CPU.ActiveCfg = Release|Any CPU + {6441DCA1-93D3-45CC-B945-6064827C2745}.Release|Any CPU.Build.0 = Release|Any CPU + {780826E6-B96A-4845-B1B0-18AA2221AD8E}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {780826E6-B96A-4845-B1B0-18AA2221AD8E}.Debug|Any CPU.Build.0 = Debug|Any CPU + {780826E6-B96A-4845-B1B0-18AA2221AD8E}.Release|Any CPU.ActiveCfg = Release|Any CPU + {780826E6-B96A-4845-B1B0-18AA2221AD8E}.Release|Any CPU.Build.0 = Release|Any CPU + EndGlobalSection + GlobalSection(SolutionProperties) = preSolution + HideSolutionNode = FALSE + EndGlobalSection + GlobalSection(NestedProjects) = preSolution + {85A48359-BB1B-4056-8D2E-2AAAE649CBD5} = {02EA681E-C7D8-13C7-8484-4AC65E1B71E8} + {9CA7079A-918F-477C-9586-418A1126AE1F} = {02EA681E-C7D8-13C7-8484-4AC65E1B71E8} + {73FC9C90-A35F-4A2E-88F2-549D37C346A6} = {02EA681E-C7D8-13C7-8484-4AC65E1B71E8} + {E1883949-9906-467F-BAD3-7ADD700A6CE6} = {02EA681E-C7D8-13C7-8484-4AC65E1B71E8} + {DA03B45D-8EF7-47F7-8BD8-D4A873EF47DF} = {3D2EB323-77C2-417C-A97C-12871F3A8C30} + {AC1772EB-E8E9-4010-81F8-BE0FB869BF6E} = {DA03B45D-8EF7-47F7-8BD8-D4A873EF47DF} + {3FFF9A49-E26C-467B-8A60-70F25AE1AD67} = {DA03B45D-8EF7-47F7-8BD8-D4A873EF47DF} + {AD3AD6AE-6597-4978-8D4B-569371D7FA6F} = {DA03B45D-8EF7-47F7-8BD8-D4A873EF47DF} + {43602225-F79E-4440-8D91-CC96C9EED4DF} = {DA03B45D-8EF7-47F7-8BD8-D4A873EF47DF} + {9B27C500-8A19-4D89-A76B-AB74A43C05CE} = {02EA681E-C7D8-13C7-8484-4AC65E1B71E8} + {6441DCA1-93D3-45CC-B945-6064827C2745} = {9B27C500-8A19-4D89-A76B-AB74A43C05CE} + {780826E6-B96A-4845-B1B0-18AA2221AD8E} = {43602225-F79E-4440-8D91-CC96C9EED4DF} + EndGlobalSection + GlobalSection(ExtensibilityGlobals) = postSolution + SolutionGuid = {BB066C85-D678-4BFC-AC34-AC98BF216429} + EndGlobalSection +EndGlobal diff --git a/jobs/Backend/Task/Program.cs b/jobs/Backend/Task/Program.cs deleted file mode 100644 index 379a69b1f..000000000 --- a/jobs/Backend/Task/Program.cs +++ /dev/null @@ -1,43 +0,0 @@ -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(); - } - } -} diff --git a/jobs/Backend/Task/README.md b/jobs/Backend/Task/README.md new file mode 100644 index 000000000..0702e67b6 --- /dev/null +++ b/jobs/Backend/Task/README.md @@ -0,0 +1,53 @@ +# Mews.ExchangeRateMonitor — Backend task (CNB exchange rates) + +A compact, production-grade implementation of the Mews backend task: fetch daily exchange rates from the Czech National Bank (CNB), cache them, and expose them via a clean application slice. + +This repo is organized into small projects with clear boundaries (Domain / Features / Infrastructure / Host) and uses .NET 9, Minimal APIs, FluentValidation, Serilog, central package management, and in-memory caching. The solution also includes a ready-to-use Seq instance via Docker for structured logging. + +# Solution structure + +- Mews.ExchangeRateMonitor.Host/**: Entry point for the application that configures and runs modules +- Modules/ExchangeRate/**: Exchange rate feature module with API endpoints, handlers, and validation +- Common/**: Shared abstractions, extensions, and utilities + +# Tech stack + +- .NET 9, C# 13 +- ASP.NET Core Minimal APIs +- FluentValidation for input validation +- IHttpClientFactory for outbound calls (typed/named client to CNB) +- IMemoryCache for response caching with date-aware TTL +- Serilog with optional Seq sink (Docker) +- Central Package Management (Directory.Packages.props) for consistent versions + +# Running the project + +1. Ensure Docker and Docker Compose are installed +2. Navigate to the project root directory +3. Run `docker-compose up -d` +4. Access the API at http://localhost:5000/swagger +5. Access Seq dashboard at http://localhost:8081 +6. Access Jaeger UI at http://localhost:16686 +7. Ensure your dockercompose runs + +## Architecture + +### Vertical Slices with Clean Architecture + +The project uses a combination of Vertical Slices and Clean Architecture + +- **Vertical Slices**: Features are organized by business functionality rather than technical layers +- **Clean Architecture**: Core domain is isolated from infrastructure concerns + +### Key Architecture Decisions +- **Manual Handlers**: Simple handler pattern used instead of MediatR to reduce unnecessary abstractions +- **Manual Mapping**: Direct mapping between DTOs and domain objects for simplicity and control +- **Result Pattern**: Custom implementation that returns success or detailed errors without exceptions +- **Fluent Validation**: Provides clear, strongly-typed validation rules for all inputs + +### Observability +- **OpenTelemetry (OTEL)**: Instrument code for metrics, logs, and traces - export to Jaeger for distributed tracing +- **Seq**: Centralized structured logging with persistent volume mounted at ./docker_data/seq +- **Jaeger**: Visualize request flows + + diff --git a/jobs/Backend/Task/docker-compose.dcproj b/jobs/Backend/Task/docker-compose.dcproj new file mode 100644 index 000000000..87d20c077 --- /dev/null +++ b/jobs/Backend/Task/docker-compose.dcproj @@ -0,0 +1,16 @@ + + + + 2.1 + Linux + False + 81dded9d-158b-e303-5f62-77a2896d2a5a + + + + docker-compose.yml + + + + + \ No newline at end of file diff --git a/jobs/Backend/Task/docker-compose.override.yml b/jobs/Backend/Task/docker-compose.override.yml new file mode 100644 index 000000000..e8bf1b692 --- /dev/null +++ b/jobs/Backend/Task/docker-compose.override.yml @@ -0,0 +1,25 @@ +services: + mews.exchangeratemonitor.host: + environment: + - ASPNETCORE_ENVIRONMENT=Development + - ASPNETCORE_HTTP_PORTS=8080 + - ASPNETCORE_HTTPS_PORTS=8081 + # - ASPNETCORE_ENVIRONMENT=Development + # - ASPNETCORE_URLS=http://+:8080 + # - ASPNETCORE_URLS=http://+:8080;https://+:8081 + # Uncomment If you want to have https - put you certificate here: ${USERPROFILE}/.aspnet/https. + # - Kestrel__Certificates__Default__Path=/https/aspnetapp.pfx + # - Kestrel__Certificates__Default__Password=DevPass123! + ports: + - "8080" + - "8081" + volumes: + - ${APPDATA}/Microsoft/UserSecrets:/home/app/.microsoft/usersecrets:ro + - ${APPDATA}/Microsoft/UserSecrets:/root/.microsoft/usersecrets:ro + - ${APPDATA}/ASP.NET/Https:/home/app/.aspnet/https:ro + - ${APPDATA}/ASP.NET/Https:/root/.aspnet/https:ro + # Uncomment If you want to have https + # - "5001:8081" + # volumes: + # Windows: host folder -> C:\Users\YourName\.aspnet\https. Inside the container that folder will appear as /https (ro - readonly mount) + # - ${USERPROFILE}/.aspnet/https:/https:ro \ No newline at end of file diff --git a/jobs/Backend/Task/docker-compose.yml b/jobs/Backend/Task/docker-compose.yml new file mode 100644 index 000000000..626d11d67 --- /dev/null +++ b/jobs/Backend/Task/docker-compose.yml @@ -0,0 +1,46 @@ +services: + mews.exchangeratemonitor.host: + image: ${DOCKER_REGISTRY-}mewsexchangeratemonitorhost + build: + context: . + dockerfile: Mews.ExchangeRateMonitor.Host/Dockerfile + ports: + - "5000:8080" + - "5001:8081" + environment: + - ASPNETCORE_ENVIRONMENT=Development + - OTEL_EXPORTER_OTLP_ENDPOINT=http://jaeger:4318 + - OTEL_EXPORTER_OTLP_PROTOCOL=http/protobuf + - OTEL_EXPORTER_OTLP_HEADERS=X-Seq-ApiKey=abcde12345 + restart: always + networks: + - docker-web + + seq: + image: datalust/seq:2024.3 + container_name: seq + restart: always + environment: + - ACCEPT_EULA=Y + volumes: + - ./docker_data/seq:/data + ports: + - "5341:5341" + - "8081:80" + networks: + - docker-web + + jaeger: + image: jaegertracing/all-in-one:latest + container_name: jaeger + restart: always + ports: + - 4317:4317 + - 4318:4318 + - 16686:16686 + networks: + - docker-web + +networks: + docker-web: + driver: bridge \ No newline at end of file diff --git a/jobs/Backend/Task/launchSettings.json b/jobs/Backend/Task/launchSettings.json new file mode 100644 index 000000000..8ff676c1a --- /dev/null +++ b/jobs/Backend/Task/launchSettings.json @@ -0,0 +1,11 @@ +{ + "profiles": { + "Docker Compose": { + "commandName": "DockerCompose", + "commandVersion": "1.0", + "serviceActions": { + "mews.exchangeratemonitor.host": "StartDebugging" + } + } + } +} \ No newline at end of file