diff --git a/src/Application/Common/Interfaces/Services/CurrencyConverterService.cs b/src/Application/Common/Interfaces/Services/CurrencyConverterService.cs new file mode 100644 index 0000000..4f47c30 --- /dev/null +++ b/src/Application/Common/Interfaces/Services/CurrencyConverterService.cs @@ -0,0 +1,12 @@ +using cuqmbr.TravelGuide.Domain.Enums; + +namespace cuqmbr.TravelGuide.Application.Common.Interfaces.Services; + +public interface CurrencyConverterService +{ + Task ConvertAsync(decimal amount, Currency from, Currency to, + CancellationToken cancellationToken); + + Task ConvertAsync(decimal amount, Currency from, Currency to, + DateTimeOffset time, CancellationToken cancellationToken); +} diff --git a/src/Application/Common/Interfaces/Services/SessionCurrencyService.cs b/src/Application/Common/Interfaces/Services/SessionCurrencyService.cs new file mode 100644 index 0000000..258a3c0 --- /dev/null +++ b/src/Application/Common/Interfaces/Services/SessionCurrencyService.cs @@ -0,0 +1,8 @@ +using cuqmbr.TravelGuide.Domain.Enums; + +namespace cuqmbr.TravelGuide.Application.Common.Interfaces.Services; + +public interface SessionCurrencyService +{ + public Currency Currency { get; } +} diff --git a/src/Application/VehicleEnrollments/Queries/GetVehicleEnrollment/GetVehicleEnrollmentQueryHandler.cs b/src/Application/VehicleEnrollments/Queries/GetVehicleEnrollment/GetVehicleEnrollmentQueryHandler.cs index 5ca9b2c..5cb3804 100644 --- a/src/Application/VehicleEnrollments/Queries/GetVehicleEnrollment/GetVehicleEnrollmentQueryHandler.cs +++ b/src/Application/VehicleEnrollments/Queries/GetVehicleEnrollment/GetVehicleEnrollmentQueryHandler.cs @@ -2,6 +2,8 @@ using MediatR; using cuqmbr.TravelGuide.Application.Common.Interfaces.Persistence; using cuqmbr.TravelGuide.Application.Common.Exceptions; using AutoMapper; +using cuqmbr.TravelGuide.Application.Common.Interfaces.Services; +using cuqmbr.TravelGuide.Domain.Enums; namespace cuqmbr.TravelGuide.Application.VehicleEnrollments .Queries.GetVehicleEnrollment; @@ -12,12 +14,19 @@ public class GetVehicleEnrollmentQueryHandler : private readonly UnitOfWork _unitOfWork; private readonly IMapper _mapper; + private readonly SessionCurrencyService _sessionCurrencyService; + private readonly CurrencyConverterService _currencyConverterService; + public GetVehicleEnrollmentQueryHandler( UnitOfWork unitOfWork, - IMapper mapper) + IMapper mapper, + SessionCurrencyService sessionCurrencyService, + CurrencyConverterService currencyConverterService) { _unitOfWork = unitOfWork; _mapper = mapper; + _sessionCurrencyService = sessionCurrencyService; + _currencyConverterService = currencyConverterService; } public async Task Handle( @@ -51,6 +60,23 @@ public class GetVehicleEnrollmentQueryHandler : .First(ra => ra.Id == rad.RouteAddressId); } + + // Convert currency + + // TODO: Replace with AutoMapper Resolver + + if (!_sessionCurrencyService.Currency.Equals(Currency.Default)) + { + foreach (var rad in entity.RouteAddressDetails) + { + rad.CostToNextAddress = await _currencyConverterService + .ConvertAsync(rad.CostToNextAddress, entity.Currency, + _sessionCurrencyService.Currency, cancellationToken); + } + entity.Currency = _sessionCurrencyService.Currency; + } + + _unitOfWork.Dispose(); return _mapper.Map(entity); diff --git a/src/Application/VehicleEnrollments/Queries/GetVehicleEnrollmentsPage/GetVehicleEnrollmentsPageQueryHandler.cs b/src/Application/VehicleEnrollments/Queries/GetVehicleEnrollmentsPage/GetVehicleEnrollmentsPageQueryHandler.cs index ee4f86c..114aa9d 100644 --- a/src/Application/VehicleEnrollments/Queries/GetVehicleEnrollmentsPage/GetVehicleEnrollmentsPageQueryHandler.cs +++ b/src/Application/VehicleEnrollments/Queries/GetVehicleEnrollmentsPage/GetVehicleEnrollmentsPageQueryHandler.cs @@ -3,6 +3,8 @@ using cuqmbr.TravelGuide.Application.Common.Interfaces.Persistence; using AutoMapper; using cuqmbr.TravelGuide.Application.Common.Models; using cuqmbr.TravelGuide.Application.Common.Extensions; +using cuqmbr.TravelGuide.Application.Common.Interfaces.Services; +using cuqmbr.TravelGuide.Domain.Enums; namespace cuqmbr.TravelGuide.Application.VehicleEnrollments .Queries.GetVehicleEnrollmentsPage; @@ -13,12 +15,19 @@ public class GetVehicleEnrollmentsPageQueryHandler : private readonly UnitOfWork _unitOfWork; private readonly IMapper _mapper; + private readonly SessionCurrencyService _sessionCurrencyService; + private readonly CurrencyConverterService _currencyConverterService; + public GetVehicleEnrollmentsPageQueryHandler( UnitOfWork unitOfWork, - IMapper mapper) + IMapper mapper, + SessionCurrencyService sessionCurrencyService, + CurrencyConverterService currencyConverterService) { _unitOfWork = unitOfWork; _mapper = mapper; + _sessionCurrencyService = sessionCurrencyService; + _currencyConverterService = currencyConverterService; } public async Task> Handle( @@ -55,13 +64,15 @@ public class GetVehicleEnrollmentsPageQueryHandler : (request.ArrivalTimeGreaterThanOrEqual != null ? e.DepartureTime.AddSeconds(e.RouteAddressDetails - .Sum(rad => rad.TimeToNextAddress.TotalSeconds + rad.CurrentAddressStopTime.TotalSeconds)) >= + .Sum(rad => rad.TimeToNextAddress.TotalSeconds + + rad.CurrentAddressStopTime.TotalSeconds)) >= request.ArrivalTimeGreaterThanOrEqual : true) && (request.ArrivalTimeLessThanOrEqual != null ? e.DepartureTime.AddSeconds(e.RouteAddressDetails - .Sum(rad => rad.TimeToNextAddress.TotalSeconds + rad.CurrentAddressStopTime.TotalSeconds)) <= + .Sum(rad => rad.TimeToNextAddress.TotalSeconds + + rad.CurrentAddressStopTime.TotalSeconds)) <= request.ArrivalTimeLessThanOrEqual : true) && (request.TravelTimeGreaterThanOrEqual != null @@ -141,6 +152,25 @@ public class GetVehicleEnrollmentsPageQueryHandler : } + // Convert currency + + // TODO: Replace with AutoMapper Resolver + + if (!_sessionCurrencyService.Currency.Equals(Currency.Default)) + { + foreach (var ve in paginatedList.Items) + { + foreach (var rad in ve.RouteAddressDetails) + { + rad.CostToNextAddress = await _currencyConverterService + .ConvertAsync(rad.CostToNextAddress, ve.Currency, + _sessionCurrencyService.Currency, cancellationToken); + } + ve.Currency = _sessionCurrencyService.Currency; + } + } + + var mappedItems = _mapper .ProjectTo(paginatedList.Items.AsQueryable()); diff --git a/src/Configuration/Identity/Configuration.cs b/src/Configuration/Identity/Configuration.cs index d066380..0aba448 100644 --- a/src/Configuration/Identity/Configuration.cs +++ b/src/Configuration/Identity/Configuration.cs @@ -11,6 +11,7 @@ using Microsoft.AspNetCore.Authentication.JwtBearer; using Microsoft.IdentityModel.Tokens; using System.Text; using cuqmbr.TravelGuide.Identity.Exceptions; +using Microsoft.EntityFrameworkCore.Diagnostics; namespace cuqmbr.TravelGuide.Configuration.Identity; @@ -38,6 +39,8 @@ public static class Configuration "ef_migrations_history", configuration.Datastore.PartitionName); }); + options.ConfigureWarnings(w => w.Ignore( + RelationalEventId.PendingModelChangesWarning)); }); services diff --git a/src/Configuration/Infrastructure/Configuration.cs b/src/Configuration/Infrastructure/Configuration.cs index ebbe6ce..7e8793c 100644 --- a/src/Configuration/Infrastructure/Configuration.cs +++ b/src/Configuration/Infrastructure/Configuration.cs @@ -1,19 +1,21 @@ -// using Microsoft.Extensions.Configuration; -// using Microsoft.Extensions.DependencyInjection; -// -// namespace cuqmbr.TravelGuide.Configuration.Infrastructure; -// -// public static class Configuration -// { -// public static IServiceCollection ConfigureInfrastructure( -// this IServiceCollection services, -// IConfiguration configuration) -// { -// services -// .AddIdentity(configuration) -// .AddAuthenticationWithJwt(configuration) -// .AddServices(); -// -// return services; -// } -// } +using cuqmbr.TravelGuide.Application.Common.Interfaces.Services; +using Microsoft.Extensions.DependencyInjection; + +namespace cuqmbr.TravelGuide.Configuration.Infrastructure; + +public static class Configuration +{ + public static IServiceCollection ConfigureInfrastructure( + this IServiceCollection services) + { + services + .AddHttpClient(); + + services + .AddScoped< + CurrencyConverterService, + ExchangeApiCurrencyConverterService>(); + + return services; + } +} diff --git a/src/Configuration/Persistence/Configuration.cs b/src/Configuration/Persistence/Configuration.cs index 396aeba..a506520 100644 --- a/src/Configuration/Persistence/Configuration.cs +++ b/src/Configuration/Persistence/Configuration.cs @@ -6,6 +6,7 @@ using cuqmbr.TravelGuide.Persistence.Exceptions; using Microsoft.EntityFrameworkCore; using cuqmbr.TravelGuide.Persistence.PostgreSql; using cuqmbr.TravelGuide.Persistence.InMemory; +using Microsoft.EntityFrameworkCore.Diagnostics; namespace cuqmbr.TravelGuide.Configuration.Persistence; @@ -34,6 +35,8 @@ public static class Configuration "ef_migrations_history", configuration.PartitionName); }); + options.ConfigureWarnings(w => w.Ignore( + RelationalEventId.PendingModelChangesWarning)); }); services diff --git a/src/Configuration/packages.lock.json b/src/Configuration/packages.lock.json index 7bd3fb8..47118f9 100644 --- a/src/Configuration/packages.lock.json +++ b/src/Configuration/packages.lock.json @@ -495,13 +495,23 @@ "resolved": "9.0.4", "contentHash": "ACtnvl3H3M/f8Z42980JxsNu7V9PPbzys4vBs83ZewnsgKd7JeYK18OMPo0g+MxAHrpgMrjmlinXDiaSRPcVnA==" }, + "Microsoft.Extensions.Diagnostics": { + "type": "Transitive", + "resolved": "9.0.4", + "contentHash": "1bCSQrGv9+bpF5MGKF6THbnRFUZqQDrWPA39NDeVW9djeHBmow8kX4SX6/8KkeKI8gmUDG7jsG/bVuNAcY/ATQ==", + "dependencies": { + "Microsoft.Extensions.Configuration": "9.0.4", + "Microsoft.Extensions.Diagnostics.Abstractions": "9.0.4", + "Microsoft.Extensions.Options.ConfigurationExtensions": "9.0.4" + } + }, "Microsoft.Extensions.Diagnostics.Abstractions": { "type": "Transitive", - "resolved": "8.0.1", - "contentHash": "elH2vmwNmsXuKmUeMQ4YW9ldXiF+gSGDgg1vORksob5POnpaI6caj1Hu8zaYbEuibhqCoWg0YRWDazBY3zjBfg==", + "resolved": "9.0.4", + "contentHash": "IAucBcHYtiCmMyFag+Vrp5m+cjGRlDttJk9Vx7Dqpq+Ama4BzVUOk0JARQakgFFr7ZTBSgLKlHmtY5MiItB7Cg==", "dependencies": { - "Microsoft.Extensions.DependencyInjection.Abstractions": "8.0.2", - "Microsoft.Extensions.Options": "8.0.2" + "Microsoft.Extensions.DependencyInjection.Abstractions": "9.0.4", + "Microsoft.Extensions.Options": "9.0.4" } }, "Microsoft.Extensions.FileProviders.Abstractions": { @@ -539,6 +549,19 @@ "Microsoft.Extensions.Logging.Abstractions": "8.0.2" } }, + "Microsoft.Extensions.Http": { + "type": "Transitive", + "resolved": "9.0.4", + "contentHash": "ezelU6HJgmq4862YoWuEbHGSV+JnfnonTSbNSJVh6n6wDehyiJn4hBtcK7rGbf2KO3QeSvK5y8E7uzn1oaRH5w==", + "dependencies": { + "Microsoft.Extensions.Configuration.Abstractions": "9.0.4", + "Microsoft.Extensions.DependencyInjection.Abstractions": "9.0.4", + "Microsoft.Extensions.Diagnostics": "9.0.4", + "Microsoft.Extensions.Logging": "9.0.4", + "Microsoft.Extensions.Logging.Abstractions": "9.0.4", + "Microsoft.Extensions.Options": "9.0.4" + } + }, "Microsoft.Extensions.Identity.Core": { "type": "Transitive", "resolved": "9.0.4", @@ -696,6 +719,11 @@ "System.Security.Principal.Windows": "4.5.0" } }, + "Newtonsoft.Json": { + "type": "Transitive", + "resolved": "13.0.3", + "contentHash": "HrC5BXdl00IP9zeV+0Z848QWPAoCr9P3bDEZguI+gkLcBKAOxix/tLEAAHC+UvDNPv4a2d18lOReHMOagPa+zQ==" + }, "Npgsql": { "type": "Transitive", "resolved": "9.0.3", @@ -832,7 +860,9 @@ "infrastructure": { "type": "Project", "dependencies": { - "Application": "[1.0.0, )" + "Application": "[1.0.0, )", + "Microsoft.Extensions.Http": "[9.0.4, )", + "Newtonsoft.Json": "[13.0.3, )" } }, "persistence": { diff --git a/src/Domain/Enums/Currency.cs b/src/Domain/Enums/Currency.cs index 4960808..fb5f312 100644 --- a/src/Domain/Enums/Currency.cs +++ b/src/Domain/Enums/Currency.cs @@ -3,16 +3,23 @@ namespace cuqmbr.TravelGuide.Domain.Enums; // Do not forget to update the schema of your database when changing // this class (if you use it with a database) -// ISO-4217 Country Codes dated 2025-03-31 +// ISO-4217 Currency Codes dated 2025-03-31 public abstract class Currency : Enumeration { + public static readonly Currency Default = new DefaultCurrency(); public static readonly Currency USD = new USDCurrency(); public static readonly Currency EUR = new EURCurrency(); public static readonly Currency UAH = new UAHCurrency(); protected Currency(int value, string name) : base(value, name) { } + // When no currency is specified + private sealed class DefaultCurrency : Currency + { + public DefaultCurrency() : base(Int32.MaxValue, "DEFAULT") { } + } + private sealed class USDCurrency : Currency { public USDCurrency() : base(840, "USD") { } diff --git a/src/HttpApi/HttpApi.csproj b/src/HttpApi/HttpApi.csproj index 8afd0fb..b85d0a5 100644 --- a/src/HttpApi/HttpApi.csproj +++ b/src/HttpApi/HttpApi.csproj @@ -13,7 +13,7 @@ - + runtime; build; native; contentfiles; analyzers; buildtransitive diff --git a/src/HttpApi/Program.cs b/src/HttpApi/Program.cs index 9f72a50..28b4764 100644 --- a/src/HttpApi/Program.cs +++ b/src/HttpApi/Program.cs @@ -1,5 +1,6 @@ using cuqmbr.TravelGuide.Configuration.Persistence; using cuqmbr.TravelGuide.Configuration.Application; +using cuqmbr.TravelGuide.Configuration.Infrastructure; using cuqmbr.TravelGuide.Configuration.Identity; using cuqmbr.TravelGuide.Configuration.Configuration; using cuqmbr.TravelGuide.Configuration.Logging; @@ -9,7 +10,7 @@ using cuqmbr.TravelGuide.HttpApi.Middlewares; using cuqmbr.TravelGuide.HttpApi.Swashbuckle.OperationFilters; using System.Net; using Swashbuckle.AspNetCore.SwaggerUI; -using MicroElements.Swashbuckle.FluentValidation.AspNetCore; +// using MicroElements.Swashbuckle.FluentValidation.AspNetCore; using Microsoft.OpenApi.Models; using System.Reflection; @@ -25,12 +26,13 @@ services.ConfigureLogging(); services.ConfigurePersistence(); services.ConfigureIdentity(); -// services.AddInfrastructure(); +services.ConfigureInfrastructure(); services.ConfigureApplication(); services.AddScoped(); -services.AddScoped(); services.AddScoped(); +services.AddScoped(); +services.AddScoped(); services.AddControllers(); @@ -82,8 +84,18 @@ services.AddSwaggerGen(options => "from IANA tz database (https://www.iana.org/time-zones).", Type = SecuritySchemeType.ApiKey }); + + // Set Accept-Currency header in Authorize window + options.OperationFilter(); + options.AddSecurityDefinition("Accept-Currency", new OpenApiSecurityScheme + { + In = ParameterLocation.Header, + Name = "Accept-Currency", + Description = "ISO-4217 Currency Code.", + Type = SecuritySchemeType.ApiKey + }); }); -services.AddFluentValidationRulesToSwagger(); +// services.AddFluentValidationRulesToSwagger(); services.AddScoped(); diff --git a/src/HttpApi/Services/AspNetSessionCurrencyService.cs b/src/HttpApi/Services/AspNetSessionCurrencyService.cs new file mode 100644 index 0000000..8b28990 --- /dev/null +++ b/src/HttpApi/Services/AspNetSessionCurrencyService.cs @@ -0,0 +1,38 @@ +using cuqmbr.TravelGuide.Application.Common.Interfaces.Services; +using cuqmbr.TravelGuide.Domain.Enums; + +namespace cuqmbr.TravelGuide.HttpApi.Services; + +public sealed class AspNetSessionCurrencyService : SessionCurrencyService +{ + private readonly HttpContext _httpContext; + + public AspNetSessionCurrencyService(IHttpContextAccessor httpContextAccessor) + { + _httpContext = httpContextAccessor.HttpContext; + } + + public Currency Currency + { + get + { + string? headerCurrencyCode = + _httpContext.Request.Headers["Accept-Currency"]; + + string currencyCode = + headerCurrencyCode?.ToUpper() ?? + cuqmbr.TravelGuide.Domain.Enums.Currency.Default.Name; + + var resultCurrency = + cuqmbr.TravelGuide.Domain.Enums.Currency.FromName(currencyCode); + + if (resultCurrency == null) + { + resultCurrency = + cuqmbr.TravelGuide.Domain.Enums.Currency.Default; + } + + return resultCurrency; + } + } +} diff --git a/src/HttpApi/Swashbuckle/OperationFilters/AcceptCurrencyHeaderOperationFilter.cs b/src/HttpApi/Swashbuckle/OperationFilters/AcceptCurrencyHeaderOperationFilter.cs new file mode 100644 index 0000000..a661bfa --- /dev/null +++ b/src/HttpApi/Swashbuckle/OperationFilters/AcceptCurrencyHeaderOperationFilter.cs @@ -0,0 +1,39 @@ +using Microsoft.OpenApi.Models; +using Swashbuckle.AspNetCore.SwaggerGen; + +namespace cuqmbr.TravelGuide.HttpApi.Swashbuckle.OperationFilters; + +public class AcceptCurrencyHeaderOperationFilter : IOperationFilter +{ + public void Apply(OpenApiOperation operation, OperationFilterContext context) + { + // TODO: Remove security requirements + operation.Security ??= new List(); + + var acceptCurrency = new OpenApiSecurityScheme + { + Reference = new OpenApiReference + { + Type = ReferenceType.SecurityScheme, + Id = "Accept-Currency" + } + }; + + operation.Security.Add(new OpenApiSecurityRequirement + { + [acceptCurrency] = new List() + }); + + if (operation.Parameters == null) + operation.Parameters = new List(); + + operation.Parameters.Add(new OpenApiParameter + { + Name = "Accept-Currency", + Description = "ISO-4217 Currency Code.", + In = ParameterLocation.Header, + Schema = new OpenApiSchema { Type = "String" }, + Required = false + }); + } +} diff --git a/src/HttpApi/packages.lock.json b/src/HttpApi/packages.lock.json index f7cac22..a8c38fc 100644 --- a/src/HttpApi/packages.lock.json +++ b/src/HttpApi/packages.lock.json @@ -13,17 +13,6 @@ "Microsoft.Extensions.Localization": "9.0.0" } }, - "MicroElements.Swashbuckle.FluentValidation": { - "type": "Direct", - "requested": "[6.1.0, )", - "resolved": "6.1.0", - "contentHash": "VzqApLPY8xIqXDvWqRuvoDYEoCHII42c4LgvLO3BikKoIVcECD+ZSG727I7yPZ/J07VEoa8aJddoqUtSm4E4gw==", - "dependencies": { - "FluentValidation": "[10.0.0, 12.0.0)", - "MicroElements.OpenApi.FluentValidation": "6.1.0", - "Swashbuckle.AspNetCore.SwaggerGen": "[6.3.0, 8.0.0)" - } - }, "Microsoft.AspNetCore.OpenApi": { "type": "Direct", "requested": "[9.0.4, )", @@ -159,17 +148,6 @@ "resolved": "2.0.1", "contentHash": "FYv95bNT4UwcNA+G/J1oX5OpRiSUxteXaUt2BJbRSdRNiIUNbggJF69wy6mnk2wYToaanpdXZdCwVylt96MpwQ==" }, - "MicroElements.OpenApi.FluentValidation": { - "type": "Transitive", - "resolved": "6.1.0", - "contentHash": "qJPAI3bL70ND6fIi4bGqQf/lpV9wUod23R7JVhVVYRonoT/ZGmPBjMbO//IPPy3yP2F21iMX05brYjyU4/WqwQ==", - "dependencies": { - "FluentValidation": "[10.0.0, 12.0.0)", - "Microsoft.AspNetCore.Http.Abstractions": "2.2.0", - "Microsoft.Extensions.Logging.Abstractions": "6.0.0", - "Microsoft.Extensions.Options": "6.0.0" - } - }, "Microsoft.AspNetCore.Authentication": { "type": "Transitive", "resolved": "2.3.0", @@ -634,13 +612,23 @@ "resolved": "9.0.4", "contentHash": "ACtnvl3H3M/f8Z42980JxsNu7V9PPbzys4vBs83ZewnsgKd7JeYK18OMPo0g+MxAHrpgMrjmlinXDiaSRPcVnA==" }, + "Microsoft.Extensions.Diagnostics": { + "type": "Transitive", + "resolved": "9.0.4", + "contentHash": "1bCSQrGv9+bpF5MGKF6THbnRFUZqQDrWPA39NDeVW9djeHBmow8kX4SX6/8KkeKI8gmUDG7jsG/bVuNAcY/ATQ==", + "dependencies": { + "Microsoft.Extensions.Configuration": "9.0.4", + "Microsoft.Extensions.Diagnostics.Abstractions": "9.0.4", + "Microsoft.Extensions.Options.ConfigurationExtensions": "9.0.4" + } + }, "Microsoft.Extensions.Diagnostics.Abstractions": { "type": "Transitive", - "resolved": "8.0.1", - "contentHash": "elH2vmwNmsXuKmUeMQ4YW9ldXiF+gSGDgg1vORksob5POnpaI6caj1Hu8zaYbEuibhqCoWg0YRWDazBY3zjBfg==", + "resolved": "9.0.4", + "contentHash": "IAucBcHYtiCmMyFag+Vrp5m+cjGRlDttJk9Vx7Dqpq+Ama4BzVUOk0JARQakgFFr7ZTBSgLKlHmtY5MiItB7Cg==", "dependencies": { - "Microsoft.Extensions.DependencyInjection.Abstractions": "8.0.2", - "Microsoft.Extensions.Options": "8.0.2" + "Microsoft.Extensions.DependencyInjection.Abstractions": "9.0.4", + "Microsoft.Extensions.Options": "9.0.4" } }, "Microsoft.Extensions.FileProviders.Abstractions": { @@ -678,6 +666,19 @@ "Microsoft.Extensions.Logging.Abstractions": "8.0.2" } }, + "Microsoft.Extensions.Http": { + "type": "Transitive", + "resolved": "9.0.4", + "contentHash": "ezelU6HJgmq4862YoWuEbHGSV+JnfnonTSbNSJVh6n6wDehyiJn4hBtcK7rGbf2KO3QeSvK5y8E7uzn1oaRH5w==", + "dependencies": { + "Microsoft.Extensions.Configuration.Abstractions": "9.0.4", + "Microsoft.Extensions.DependencyInjection.Abstractions": "9.0.4", + "Microsoft.Extensions.Diagnostics": "9.0.4", + "Microsoft.Extensions.Logging": "9.0.4", + "Microsoft.Extensions.Logging.Abstractions": "9.0.4", + "Microsoft.Extensions.Options": "9.0.4" + } + }, "Microsoft.Extensions.Identity.Core": { "type": "Transitive", "resolved": "9.0.4", @@ -873,6 +874,11 @@ "System.CodeDom": "6.0.0" } }, + "Newtonsoft.Json": { + "type": "Transitive", + "resolved": "13.0.3", + "contentHash": "HrC5BXdl00IP9zeV+0Z848QWPAoCr9P3bDEZguI+gkLcBKAOxix/tLEAAHC+UvDNPv4a2d18lOReHMOagPa+zQ==" + }, "Npgsql": { "type": "Transitive", "resolved": "9.0.3", @@ -1119,7 +1125,9 @@ "infrastructure": { "type": "Project", "dependencies": { - "Application": "[1.0.0, )" + "Application": "[1.0.0, )", + "Microsoft.Extensions.Http": "[9.0.4, )", + "Newtonsoft.Json": "[13.0.3, )" } }, "persistence": { diff --git a/src/Infrastructure/Infrastructure.csproj b/src/Infrastructure/Infrastructure.csproj index d704cae..2ae6a29 100644 --- a/src/Infrastructure/Infrastructure.csproj +++ b/src/Infrastructure/Infrastructure.csproj @@ -10,6 +10,11 @@ + + + + + true diff --git a/src/Infrastructure/Services/ExchangeApiCurrencyConverterService.cs b/src/Infrastructure/Services/ExchangeApiCurrencyConverterService.cs new file mode 100644 index 0000000..15d444d --- /dev/null +++ b/src/Infrastructure/Services/ExchangeApiCurrencyConverterService.cs @@ -0,0 +1,101 @@ +using System.Globalization; +using cuqmbr.TravelGuide.Application.Common.Interfaces.Services; +using cuqmbr.TravelGuide.Domain.Enums; +using Newtonsoft.Json; + +// https://github.com/fawazahmed0/exchange-api + +public sealed class ExchangeApiCurrencyConverterService : + CurrencyConverterService +{ + private readonly + IDictionary< + DateOnly, IDictionary< + Currency, IDictionary< + Currency, decimal>>> _cache; + + private const string urlFormat = "https://cdn.jsdelivr.net/" + + "npm/@fawazahmed0/currency-api@{0}/v1/currencies/{1}.json"; + + private readonly IHttpClientFactory _httpClientFactory; + + public ExchangeApiCurrencyConverterService( + IHttpClientFactory httpClientFactory) + { + _httpClientFactory = httpClientFactory; + + _cache = + new Dictionary< + DateOnly, IDictionary< + Currency, IDictionary< + Currency, decimal>>>(); + } + + public async Task ConvertAsync(decimal amount, Currency from, + Currency to, CancellationToken cancellationToken) + { + return await ConvertAsync(amount, from, to, + DateTimeOffset.UtcNow, cancellationToken); + } + + public async Task ConvertAsync(decimal amount, Currency from, + Currency to, DateTimeOffset time, CancellationToken cancellationToken) + { + if (from.Equals(to)) + { + return amount; + } + + var requestDate = DateOnly.FromDateTime(time.ToUniversalTime().Date); + + // Return cached value if available + if (_cache.Keys.Contains(requestDate) && + _cache[requestDate].Keys.Contains(from) && + _cache[requestDate][from].Keys.Contains(to)) + { + return amount * _cache[requestDate][from][to]; + } + + + // Retreive new value from api + + var httpClient = _httpClientFactory.CreateClient(); + + var requestDateString = + requestDate == DateOnly.FromDateTime(DateTime.UtcNow) ? + "latest" : + requestDate.ToString("yyyy-MM-dd", CultureInfo.InvariantCulture); + + var url = String.Format(urlFormat, + requestDateString, from.Name.ToLower()); + + var jsonString = + await httpClient.GetStringAsync(url, cancellationToken); + + var obj = JsonConvert.DeserializeObject(jsonString); + + decimal rate = obj[from.Name.ToLower()][to.Name.ToLower()]; + + + // Cache new value + + if (!_cache.Keys.Contains(requestDate)) + { + _cache.Add(requestDate, + new Dictionary>()); + } + + if (!_cache[requestDate].Keys.Contains(from)) + { + _cache[requestDate].Add(from, new Dictionary()); + } + + if (!_cache[requestDate][from].Keys.Contains(to)) + { + _cache[requestDate][from].Add(to, rate); + } + + + return amount * rate; + } +} diff --git a/src/Infrastructure/packages.lock.json b/src/Infrastructure/packages.lock.json index 83c6755..b89053f 100644 --- a/src/Infrastructure/packages.lock.json +++ b/src/Infrastructure/packages.lock.json @@ -2,6 +2,26 @@ "version": 1, "dependencies": { "net9.0": { + "Microsoft.Extensions.Http": { + "type": "Direct", + "requested": "[9.0.4, )", + "resolved": "9.0.4", + "contentHash": "ezelU6HJgmq4862YoWuEbHGSV+JnfnonTSbNSJVh6n6wDehyiJn4hBtcK7rGbf2KO3QeSvK5y8E7uzn1oaRH5w==", + "dependencies": { + "Microsoft.Extensions.Configuration.Abstractions": "9.0.4", + "Microsoft.Extensions.DependencyInjection.Abstractions": "9.0.4", + "Microsoft.Extensions.Diagnostics": "9.0.4", + "Microsoft.Extensions.Logging": "9.0.4", + "Microsoft.Extensions.Logging.Abstractions": "9.0.4", + "Microsoft.Extensions.Options": "9.0.4" + } + }, + "Newtonsoft.Json": { + "type": "Direct", + "requested": "[13.0.3, )", + "resolved": "13.0.3", + "contentHash": "HrC5BXdl00IP9zeV+0Z848QWPAoCr9P3bDEZguI+gkLcBKAOxix/tLEAAHC+UvDNPv4a2d18lOReHMOagPa+zQ==" + }, "AspNetCore.Localizer.Json": { "type": "Transitive", "resolved": "1.0.1", @@ -97,6 +117,31 @@ "Microsoft.Extensions.Primitives": "9.0.0" } }, + "Microsoft.Extensions.Configuration": { + "type": "Transitive", + "resolved": "9.0.4", + "contentHash": "KIVBrMbItnCJDd1RF4KEaE8jZwDJcDUJW5zXpbwQ05HNYTK1GveHxHK0B3SjgDJuR48GRACXAO+BLhL8h34S7g==", + "dependencies": { + "Microsoft.Extensions.Configuration.Abstractions": "9.0.4", + "Microsoft.Extensions.Primitives": "9.0.4" + } + }, + "Microsoft.Extensions.Configuration.Abstractions": { + "type": "Transitive", + "resolved": "9.0.4", + "contentHash": "0LN/DiIKvBrkqp7gkF3qhGIeZk6/B63PthAHjQsxymJfIBcz0kbf4/p/t4lMgggVxZ+flRi5xvTwlpPOoZk8fg==", + "dependencies": { + "Microsoft.Extensions.Primitives": "9.0.4" + } + }, + "Microsoft.Extensions.Configuration.Binder": { + "type": "Transitive", + "resolved": "9.0.4", + "contentHash": "cdrjcl9RIcwt3ECbnpP0Gt1+pkjdW90mq5yFYy8D9qRj2NqFFcv3yDp141iEamsd9E218sGxK8WHaIOcrqgDJg==", + "dependencies": { + "Microsoft.Extensions.Configuration.Abstractions": "9.0.4" + } + }, "Microsoft.Extensions.DependencyInjection": { "type": "Transitive", "resolved": "9.0.4", @@ -110,6 +155,25 @@ "resolved": "9.0.4", "contentHash": "UI0TQPVkS78bFdjkTodmkH0Fe8lXv9LnhGFKgKrsgUJ5a5FVdFRcgjIkBVLbGgdRhxWirxH/8IXUtEyYJx6GQg==" }, + "Microsoft.Extensions.Diagnostics": { + "type": "Transitive", + "resolved": "9.0.4", + "contentHash": "1bCSQrGv9+bpF5MGKF6THbnRFUZqQDrWPA39NDeVW9djeHBmow8kX4SX6/8KkeKI8gmUDG7jsG/bVuNAcY/ATQ==", + "dependencies": { + "Microsoft.Extensions.Configuration": "9.0.4", + "Microsoft.Extensions.Diagnostics.Abstractions": "9.0.4", + "Microsoft.Extensions.Options.ConfigurationExtensions": "9.0.4" + } + }, + "Microsoft.Extensions.Diagnostics.Abstractions": { + "type": "Transitive", + "resolved": "9.0.4", + "contentHash": "IAucBcHYtiCmMyFag+Vrp5m+cjGRlDttJk9Vx7Dqpq+Ama4BzVUOk0JARQakgFFr7ZTBSgLKlHmtY5MiItB7Cg==", + "dependencies": { + "Microsoft.Extensions.DependencyInjection.Abstractions": "9.0.4", + "Microsoft.Extensions.Options": "9.0.4" + } + }, "Microsoft.Extensions.Localization": { "type": "Transitive", "resolved": "9.0.0", @@ -153,6 +217,18 @@ "Microsoft.Extensions.Primitives": "9.0.4" } }, + "Microsoft.Extensions.Options.ConfigurationExtensions": { + "type": "Transitive", + "resolved": "9.0.4", + "contentHash": "aridVhAT3Ep+vsirR1pzjaOw0Jwiob6dc73VFQn2XmDfBA2X98M8YKO1GarvsXRX7gX1Aj+hj2ijMzrMHDOm0A==", + "dependencies": { + "Microsoft.Extensions.Configuration.Abstractions": "9.0.4", + "Microsoft.Extensions.Configuration.Binder": "9.0.4", + "Microsoft.Extensions.DependencyInjection.Abstractions": "9.0.4", + "Microsoft.Extensions.Options": "9.0.4", + "Microsoft.Extensions.Primitives": "9.0.4" + } + }, "Microsoft.Extensions.Primitives": { "type": "Transitive", "resolved": "9.0.4", diff --git a/tst/Application.IntegrationTests/packages.lock.json b/tst/Application.IntegrationTests/packages.lock.json index 017ffbc..4fd295d 100644 --- a/tst/Application.IntegrationTests/packages.lock.json +++ b/tst/Application.IntegrationTests/packages.lock.json @@ -497,13 +497,23 @@ "resolved": "9.0.4", "contentHash": "ACtnvl3H3M/f8Z42980JxsNu7V9PPbzys4vBs83ZewnsgKd7JeYK18OMPo0g+MxAHrpgMrjmlinXDiaSRPcVnA==" }, + "Microsoft.Extensions.Diagnostics": { + "type": "Transitive", + "resolved": "9.0.4", + "contentHash": "1bCSQrGv9+bpF5MGKF6THbnRFUZqQDrWPA39NDeVW9djeHBmow8kX4SX6/8KkeKI8gmUDG7jsG/bVuNAcY/ATQ==", + "dependencies": { + "Microsoft.Extensions.Configuration": "9.0.4", + "Microsoft.Extensions.Diagnostics.Abstractions": "9.0.4", + "Microsoft.Extensions.Options.ConfigurationExtensions": "9.0.4" + } + }, "Microsoft.Extensions.Diagnostics.Abstractions": { "type": "Transitive", - "resolved": "8.0.1", - "contentHash": "elH2vmwNmsXuKmUeMQ4YW9ldXiF+gSGDgg1vORksob5POnpaI6caj1Hu8zaYbEuibhqCoWg0YRWDazBY3zjBfg==", + "resolved": "9.0.4", + "contentHash": "IAucBcHYtiCmMyFag+Vrp5m+cjGRlDttJk9Vx7Dqpq+Ama4BzVUOk0JARQakgFFr7ZTBSgLKlHmtY5MiItB7Cg==", "dependencies": { - "Microsoft.Extensions.DependencyInjection.Abstractions": "8.0.2", - "Microsoft.Extensions.Options": "8.0.2" + "Microsoft.Extensions.DependencyInjection.Abstractions": "9.0.4", + "Microsoft.Extensions.Options": "9.0.4" } }, "Microsoft.Extensions.FileProviders.Abstractions": { @@ -541,6 +551,19 @@ "Microsoft.Extensions.Logging.Abstractions": "8.0.2" } }, + "Microsoft.Extensions.Http": { + "type": "Transitive", + "resolved": "9.0.4", + "contentHash": "ezelU6HJgmq4862YoWuEbHGSV+JnfnonTSbNSJVh6n6wDehyiJn4hBtcK7rGbf2KO3QeSvK5y8E7uzn1oaRH5w==", + "dependencies": { + "Microsoft.Extensions.Configuration.Abstractions": "9.0.4", + "Microsoft.Extensions.DependencyInjection.Abstractions": "9.0.4", + "Microsoft.Extensions.Diagnostics": "9.0.4", + "Microsoft.Extensions.Logging": "9.0.4", + "Microsoft.Extensions.Logging.Abstractions": "9.0.4", + "Microsoft.Extensions.Options": "9.0.4" + } + }, "Microsoft.Extensions.Identity.Core": { "type": "Transitive", "resolved": "9.0.4", @@ -772,8 +795,8 @@ }, "Newtonsoft.Json": { "type": "Transitive", - "resolved": "13.0.1", - "contentHash": "ppPFpBcvxdsfUonNcvITKqLl3bqxWbDCZIzDWHzjpdAHRFfZe0Dw9HmA0+za13IdyrgJwpkDTDA9fHaxOrt20A==" + "resolved": "13.0.3", + "contentHash": "HrC5BXdl00IP9zeV+0Z848QWPAoCr9P3bDEZguI+gkLcBKAOxix/tLEAAHC+UvDNPv4a2d18lOReHMOagPa+zQ==" }, "Npgsql": { "type": "Transitive", @@ -1005,7 +1028,9 @@ "infrastructure": { "type": "Project", "dependencies": { - "Application": "[1.0.0, )" + "Application": "[1.0.0, )", + "Microsoft.Extensions.Http": "[9.0.4, )", + "Newtonsoft.Json": "[13.0.3, )" } }, "persistence": {