From baca08c8bbb8d5e65e9cb6dbbfcdc5d3ead644f3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marek=20Fi=C5=A1era?= Date: Sat, 2 Mar 2019 09:23:26 +0100 Subject: [PATCH] #218 - Move everything required for domain to the new API project. --- .../Domain/Bootstrap/BootstrapTask.cs | 216 ++++++++++++++++++ .../Domain/Controllers/ApiController.cs | 147 ++++++++++++ src/Money.Api/Domain/Hubs/ApiHub.cs | 171 ++++++++++++++ .../Commands/UserCommandDispatcher.cs | 48 ++++ .../Services/Converters/ColorConverter.cs | 58 +++++ .../Domain/Services/CurrencyCache.Model.cs | 17 ++ .../Domain/Services/CurrencyCache.cs | 87 +++++++ ...iceCalculator.ExchangeRateModelComparer.cs | 20 ++ .../Services/PriceCalculator.UserModel.cs | 19 ++ .../Domain/Services/PriceCalculator.cs | 115 ++++++++++ .../Services/Queries/UserQueryDispatcher.cs | 38 +++ src/Money.Api/Money.Api.csproj | 4 +- src/Money.Api/Startup.cs | 27 ++- .../Users/Commands/Handlers/UserHandler.cs | 98 ++++++++ 14 files changed, 1059 insertions(+), 6 deletions(-) create mode 100644 src/Money.Api/Domain/Bootstrap/BootstrapTask.cs create mode 100644 src/Money.Api/Domain/Controllers/ApiController.cs create mode 100644 src/Money.Api/Domain/Hubs/ApiHub.cs create mode 100644 src/Money.Api/Domain/Services/Commands/UserCommandDispatcher.cs create mode 100644 src/Money.Api/Domain/Services/Converters/ColorConverter.cs create mode 100644 src/Money.Api/Domain/Services/CurrencyCache.Model.cs create mode 100644 src/Money.Api/Domain/Services/CurrencyCache.cs create mode 100644 src/Money.Api/Domain/Services/PriceCalculator.ExchangeRateModelComparer.cs create mode 100644 src/Money.Api/Domain/Services/PriceCalculator.UserModel.cs create mode 100644 src/Money.Api/Domain/Services/PriceCalculator.cs create mode 100644 src/Money.Api/Domain/Services/Queries/UserQueryDispatcher.cs create mode 100644 src/Money.Api/Users/Commands/Handlers/UserHandler.cs diff --git a/src/Money.Api/Domain/Bootstrap/BootstrapTask.cs b/src/Money.Api/Domain/Bootstrap/BootstrapTask.cs new file mode 100644 index 00000000..1069e2f1 --- /dev/null +++ b/src/Money.Api/Domain/Bootstrap/BootstrapTask.cs @@ -0,0 +1,216 @@ +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Identity; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Money.Commands; +using Money.Commands.Handlers; +using Money.Data; +using Money.Hubs; +using Money.Models; +using Money.Services; +using Money.Users.Commands.Handlers; +using Money.Users.Models; +using Neptuo; +using Neptuo.Activators; +using Neptuo.Bootstrap; +using Neptuo.Commands; +using Neptuo.Converters; +using Neptuo.Data; +using Neptuo.Events; +using Neptuo.Exceptions.Handlers; +using Neptuo.Formatters; +using Neptuo.Formatters.Converters; +using Neptuo.Formatters.Metadata; +using Neptuo.Logging; +using Neptuo.Logging.Serialization; +using Neptuo.Models.Repositories; +using Neptuo.Models.Snapshots; +using Neptuo.Queries; +using System; +using System.Collections.Generic; +using System.Linq; +using System.Reflection; +using System.Text; +using System.Threading.Tasks; + +namespace Money.Bootstrap +{ + public class BootstrapTask : IBootstrapTask + { + private readonly IServiceCollection services; + private readonly ConnectionStrings connectionStrings; + + private ILogFactory logFactory; + private ILog errorLog; + private IFactory readModelContextFactory; + private IFactory eventSourcingContextFactory; + + private PriceCalculator priceCalculator; + private ICompositeTypeProvider typeProvider; + + private ExceptionHandlerBuilder exceptionHandlerBuilder; + + private DefaultQueryDispatcher queryDispatcher; + private PersistentCommandDispatcher commandDispatcher; + private PersistentEventDispatcher eventDispatcher; + + private EntityEventStore eventStore; + + private IFormatter commandFormatter; + private IFormatter eventFormatter; + private IFormatter queryFormatter; + private IFormatter exceptionFormatter; + + public BootstrapTask(IServiceCollection services, ConnectionStrings connectionStrings) + { + Ensure.NotNull(services, "services"); + Ensure.NotNull(connectionStrings, "connectionStrings"); + this.services = services; + this.connectionStrings = connectionStrings; + } + + public void Initialize() + { + logFactory = new DefaultLogFactory("Root").AddSerializer(new ConsoleSerializer()); + errorLog = logFactory.Scope("Error"); + + readModelContextFactory = Factory.Getter(() => new ReadModelContext(connectionStrings.ReadModel)); + eventSourcingContextFactory = Factory.Getter(() => new EventSourcingContext(connectionStrings.EventSourcing)); + CreateReadModelContext(); + CreateEventSourcingContext(); + + exceptionHandlerBuilder = new ExceptionHandlerBuilder(); + + services + .AddSingleton(readModelContextFactory) + .AddSingleton(eventSourcingContextFactory) + .AddSingleton(exceptionHandlerBuilder) + .AddSingleton(exceptionHandlerBuilder); + + Domain(); + + priceCalculator = new PriceCalculator(eventDispatcher.Handlers, queryDispatcher); + + services + .AddSingleton(priceCalculator) + .AddSingleton(new FormatterContainer(commandFormatter, eventFormatter, queryFormatter, exceptionFormatter)); + + ReadModels(); + + services + .AddSingleton(eventDispatcher.Handlers) + .AddScoped(provider => new UserCommandDispatcher(commandDispatcher, provider.GetService().HttpContext, provider.GetService())) + .AddScoped(provider => new UserQueryDispatcher(queryDispatcher, provider.GetService().HttpContext)); + + CurrencyCache currencyCache = new CurrencyCache(eventDispatcher.Handlers, queryDispatcher, queryDispatcher); + + services + .AddSingleton(currencyCache); + } + + private void Domain() + { + Converts.Repository + .AddStringTo(Int32.TryParse) + .AddStringTo(Boolean.TryParse) + .AddEnumSearchHandler(false) + .AddJsonEnumSearchHandler() + .AddJsonPrimitivesSearchHandler() + .AddJsonObjectSearchHandler() + .AddJsonKey() + .AddJsonTimeSpan() + .Add(new ColorConverter()) + .AddToStringSearchHandler(); + + eventStore = new EntityEventStore(eventSourcingContextFactory); + eventDispatcher = new PersistentEventDispatcher(new EmptyEventStore()); + eventDispatcher.DispatcherExceptionHandlers.Add(exceptionHandlerBuilder); + eventDispatcher.EventExceptionHandlers.Add(exceptionHandlerBuilder); + + IFactory compositeStorageFactory = Factory.Default(); + + typeProvider = new ReflectionCompositeTypeProvider( + new ReflectionCompositeDelegateFactory(), + BindingFlags.Instance | BindingFlags.NonPublic | BindingFlags.Public + ); + + commandFormatter = new CompositeCommandFormatter(typeProvider, compositeStorageFactory); + eventFormatter = new CompositeEventFormatter(typeProvider, compositeStorageFactory, new List() { new UserKeyEventExtender() }); + queryFormatter = new CompositeListFormatter(typeProvider, compositeStorageFactory, logFactory); + exceptionFormatter = new CompositeExceptionFormatter(typeProvider, compositeStorageFactory); + + commandDispatcher = new PersistentCommandDispatcher(new SerialCommandDistributor(), new EmptyCommandStore(), commandFormatter); + commandDispatcher.DispatcherExceptionHandlers.Add(exceptionHandlerBuilder); + commandDispatcher.CommandExceptionHandlers.Add(exceptionHandlerBuilder); + + queryDispatcher = new DefaultQueryDispatcher(); + + var outcomeRepository = new AggregateRootRepository( + eventStore, + eventFormatter, + new ReflectionAggregateRootFactory(), + eventDispatcher, + new NoSnapshotProvider(), + new EmptySnapshotStore() + ); + + var categoryRepository = new AggregateRootRepository( + eventStore, + eventFormatter, + new ReflectionAggregateRootFactory(), + eventDispatcher, + new NoSnapshotProvider(), + new EmptySnapshotStore() + ); + + var currencyListRepository = new AggregateRootRepository( + eventStore, + eventFormatter, + new ReflectionAggregateRootFactory(), + eventDispatcher, + new NoSnapshotProvider(), + new EmptySnapshotStore() + ); + + Money.BootstrapTask bootstrapTask = new Money.BootstrapTask( + commandDispatcher.Handlers, + Factory.Instance(outcomeRepository), + Factory.Instance(categoryRepository), + Factory.Instance(currencyListRepository) + ); + + bootstrapTask.Initialize(); + + UserHandler userHandler = new UserHandler(services.BuildServiceProvider().GetRequiredService>(), eventDispatcher); + commandDispatcher.Handlers.AddAll(userHandler); + queryDispatcher.AddAll(userHandler); + } + + private void ReadModels() + { + Models.Builders.BootstrapTask bootstrapTask = new Models.Builders.BootstrapTask( + queryDispatcher, + eventDispatcher.Handlers, + readModelContextFactory, + priceCalculator + ); + + bootstrapTask.Initialize(); + } + + private void CreateEventSourcingContext() + { + using (var eventSourcing = eventSourcingContextFactory.Create()) + eventSourcing.Database.EnsureCreated(); + } + + private void CreateReadModelContext() + { + using (var readModels = readModelContextFactory.Create()) + readModels.Database.EnsureCreated(); + } + + public void Handle(Exception exception) + => errorLog.Error(exception); + } +} diff --git a/src/Money.Api/Domain/Controllers/ApiController.cs b/src/Money.Api/Domain/Controllers/ApiController.cs new file mode 100644 index 00000000..0ea2e72b --- /dev/null +++ b/src/Money.Api/Domain/Controllers/ApiController.cs @@ -0,0 +1,147 @@ +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using Money.Models.Api; +using Money.Services; +using Neptuo; +using Neptuo.Commands; +using Neptuo.Formatters; +using Neptuo.Queries; +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Net.Mime; +using System.Reflection; +using System.Text; +using System.Threading.Tasks; + +namespace Money.Controllers +{ + [Authorize] + [Route("[controller]/[action]")] + public class ApiController : Controller + { + private readonly FormatterContainer formatters; + private readonly ICommandDispatcher commandDispatcher; + private readonly IQueryDispatcher queryDispatcher; + private readonly CommandMapper commandMapper; + private readonly QueryMapper queryMapper; + + public ApiController(FormatterContainer formatters, ICommandDispatcher commandDispatcher, IQueryDispatcher queryDispatcher, CommandMapper commandMapper, QueryMapper queryMapper) + { + Ensure.NotNull(formatters, "formatters"); + Ensure.NotNull(commandDispatcher, "commandDispatcher"); + Ensure.NotNull(queryDispatcher, "queryDispatcher"); + Ensure.NotNull(commandMapper, "commandMapper"); + Ensure.NotNull(queryMapper, "queryMapper"); + this.formatters = formatters; + this.commandDispatcher = commandDispatcher; + this.queryDispatcher = queryDispatcher; + this.commandMapper = commandMapper; + this.queryMapper = queryMapper; + } + + public string UserName() => HttpContext.User.Identity.Name; + + [HttpPost] + public ActionResult Query([FromBody] Request request) + { + Ensure.NotNull(request, "request"); + + string payload = request.Payload; + Type type = Type.GetType(request.Type); + + return Query(payload, type); + } + + [HttpPost] + [Route("{*url}")] + public ActionResult Query(string url, [FromBody] string payload) + { + Ensure.NotNullOrEmpty(url, "url"); + Ensure.NotNullOrEmpty(payload, "payload"); + + Type type = queryMapper.FindTypeByUrl(url); + return Query(payload, type); + } + + private ActionResult Query(string payload, Type type) + { + Ensure.NotNull(type, "type"); + + object query = formatters.Query.Deserialize(type, payload); + + MethodInfo methodInfo = queryDispatcher.GetType().GetMethod(nameof(queryDispatcher.QueryAsync)); + if (methodInfo != null) + { + methodInfo = methodInfo.MakeGenericMethod(type.GetInterfaces().First().GetGenericArguments().First()); + Task task = (Task)methodInfo.Invoke(queryDispatcher, new[] { query }); + task.Wait(); + + object output = task.GetType().GetProperty(nameof(Task.Result)).GetValue(task); + if (output != null) + { + ResponseType responseType = ResponseType.Composite; + type = output.GetType(); + + if (output is string || output is int || output is decimal || output is bool) + { + payload = output.ToString(); + responseType = ResponseType.Plain; + } + else + { + payload = formatters.Query.Serialize(output); + } + + HttpContext.Response.ContentType = "text/json"; + return Json(new Response() + { + Payload = payload, + Type = type.AssemblyQualifiedName, + ResponseType = responseType + }); + } + } + + return NotFound(); + } + + [HttpPost] + public ActionResult Command([FromBody] Request request) + { + string payload = request.Payload; + Type type = Type.GetType(request.Type); + + return Command(payload, type); + } + + [HttpPost] + [Route("{*url}")] + public ActionResult Command(string url, [FromBody] string payload) + { + Ensure.NotNullOrEmpty(url, "url"); + Ensure.NotNullOrEmpty(payload, "payload"); + + Type type = commandMapper.FindTypeByUrl(url); + return Command(payload, type); + } + + private ActionResult Command(string payload, Type type) + { + object command = formatters.Command.Deserialize(type, payload); + + MethodInfo methodInfo = commandDispatcher.GetType().GetMethod(nameof(commandDispatcher.HandleAsync)); + if (methodInfo != null) + { + methodInfo = methodInfo.MakeGenericMethod(type); + Task task = (Task)methodInfo.Invoke(commandDispatcher, new[] { command }); + task.Wait(); + + return Ok(); + } + + return StatusCode(500); + } + } +} diff --git a/src/Money.Api/Domain/Hubs/ApiHub.cs b/src/Money.Api/Domain/Hubs/ApiHub.cs new file mode 100644 index 00000000..9197a6b4 --- /dev/null +++ b/src/Money.Api/Domain/Hubs/ApiHub.cs @@ -0,0 +1,171 @@ +using Microsoft.AspNetCore.SignalR; +using Money.Events; +using Money.Models.Api; +using Money.Services; +using Neptuo; +using Neptuo.Events; +using Neptuo.Events.Handlers; +using Neptuo.Exceptions.Handlers; +using Neptuo.Formatters; +using Neptuo.Models; +using Neptuo.Models.Keys; +using Newtonsoft.Json; +using System; +using System.Collections.Concurrent; +using System.Collections.Generic; +using System.Linq; +using System.Security.Claims; +using System.Text; +using System.Threading.Tasks; + +namespace Money.Hubs +{ + public class ApiHub : Hub, + IExceptionHandler, + IEventHandler, IEventHandler, IEventHandler, IEventHandler, IEventHandler, IEventHandler, + IEventHandler, IEventHandler, IEventHandler, IEventHandler, + IEventHandler, IEventHandler, + IEventHandler, IEventHandler, IEventHandler, IEventHandler, IEventHandler, + IEventHandler, IEventHandler + { + private readonly FormatterContainer formatters; + + private readonly Dictionary> userKeyToConnectionId = new Dictionary>(); + private readonly object userKeyToConnectionIdLock = new object(); + + private readonly Dictionary commandKeyToUserKey = new Dictionary(); + private readonly object commandKeyToUserKeyLock = new object(); + + public ApiHub(IEventHandlerCollection eventHandlers, FormatterContainer formatters, ExceptionHandlerBuilder exceptionHandlerBuilder) + { + Ensure.NotNull(eventHandlers, "eventHandlers"); + Ensure.NotNull(formatters, "formatters"); + this.formatters = formatters; + eventHandlers.AddAll(this); + exceptionHandlerBuilder.Filter().Handler(this); + } + + private (string connectionId, IKey key) GetUserInfo() + { + string connectionId = Context.ConnectionId; + string userId = Context.User.FindFirstValue(ClaimTypes.NameIdentifier); + IKey userKey = StringKey.Create(userId, "User"); + return (connectionId, userKey); + } + + public async override Task OnConnectedAsync() + { + await base.OnConnectedAsync(); + + var userInfo = GetUserInfo(); + + lock (userKeyToConnectionIdLock) + { + if (!userKeyToConnectionId.TryGetValue(userInfo.key, out List value)) + userKeyToConnectionId[userInfo.key] = value = new List(); + + value.Add(userInfo.connectionId); + } + } + + public override Task OnDisconnectedAsync(Exception exception) + { + var userInfo = GetUserInfo(); + lock (userKeyToConnectionIdLock) + { + if (userKeyToConnectionId.TryGetValue(userInfo.key, out List value)) + { + if (value.Remove(userInfo.connectionId) && value.Count == 0) + userKeyToConnectionId.Remove(userInfo.key); + } + } + + return base.OnDisconnectedAsync(exception); + } + + private Task RaiseEvent(T payload) + { + string type = typeof(T).AssemblyQualifiedName; + string rawPayload = formatters.Event.Serialize(payload); + + IClientProxy target = null; + lock (userKeyToConnectionIdLock) + { + if (payload is UserEvent userEvent && userKeyToConnectionId.TryGetValue(userEvent.UserKey, out List value)) + target = Clients.Clients(value.ToList()); + } + + if (target != null) + { + return target.SendAsync("RaiseEvent", JsonConvert.SerializeObject(new Response() + { + Type = type, + Payload = rawPayload + })); + } + + return Task.CompletedTask; + } + + Task IEventHandler.HandleAsync(CategoryCreated payload) => RaiseEvent(payload); + Task IEventHandler.HandleAsync(CategoryDeleted payload) => RaiseEvent(payload); + Task IEventHandler.HandleAsync(CategoryRenamed payload) => RaiseEvent(payload); + Task IEventHandler.HandleAsync(CategoryDescriptionChanged payload) => RaiseEvent(payload); + Task IEventHandler.HandleAsync(CategoryIconChanged payload) => RaiseEvent(payload); + Task IEventHandler.HandleAsync(CategoryColorChanged payload) => RaiseEvent(payload); + + Task IEventHandler.HandleAsync(CurrencyCreated payload) => RaiseEvent(payload); + Task IEventHandler.HandleAsync(CurrencyDeleted payload) => RaiseEvent(payload); + Task IEventHandler.HandleAsync(CurrencyDefaultChanged payload) => RaiseEvent(payload); + Task IEventHandler.HandleAsync(CurrencySymbolChanged payload) => RaiseEvent(payload); + Task IEventHandler.HandleAsync(CurrencyExchangeRateSet payload) => RaiseEvent(payload); + Task IEventHandler.HandleAsync(CurrencyExchangeRateRemoved payload) => RaiseEvent(payload); + + Task IEventHandler.HandleAsync(OutcomeCreated payload) => RaiseEvent(payload); + Task IEventHandler.HandleAsync(OutcomeDeleted payload) => RaiseEvent(payload); + Task IEventHandler.HandleAsync(OutcomeAmountChanged payload) => RaiseEvent(payload); + Task IEventHandler.HandleAsync(OutcomeDescriptionChanged payload) => RaiseEvent(payload); + Task IEventHandler.HandleAsync(OutcomeWhenChanged payload) => RaiseEvent(payload); + + Task IEventHandler.HandleAsync(PasswordChanged payload) => RaiseEvent(payload); + Task IEventHandler.HandleAsync(EmailChanged payload) => RaiseEvent(payload); + + public void Handle(AggregateRootException exception) + { + string type = exception.GetType().AssemblyQualifiedName; + string rawPayload = formatters.Exception.Serialize(exception); + + IClientProxy target = null; + lock (commandKeyToUserKeyLock) + { + if (commandKeyToUserKey.TryGetValue(exception.FindOriginalCommandKey(), out IKey userKey)) + { + lock (userKeyToConnectionIdLock) + { + if (userKeyToConnectionId.TryGetValue(userKey, out List value)) + target = Clients.Clients(value.ToList()); + } + } + } + + if (target != null) + { + target.SendAsync("RaiseException", JsonConvert.SerializeObject(new Response() + { + Type = type, + Payload = rawPayload + })); + } + } + + public void AddCommand(IKey commandKey, IKey userKey) + { + Ensure.Condition.NotEmptyKey(commandKey); + Ensure.Condition.NotEmptyKey(userKey); + lock (commandKeyToUserKeyLock) + { + commandKeyToUserKey[commandKey] = userKey; + } + } + } +} diff --git a/src/Money.Api/Domain/Services/Commands/UserCommandDispatcher.cs b/src/Money.Api/Domain/Services/Commands/UserCommandDispatcher.cs new file mode 100644 index 00000000..6b992295 --- /dev/null +++ b/src/Money.Api/Domain/Services/Commands/UserCommandDispatcher.cs @@ -0,0 +1,48 @@ +using Microsoft.AspNetCore.Http; +using Money.Hubs; +using Neptuo.Models.Keys; +using System; +using System.Collections.Generic; +using System.Linq; +using System.Security.Claims; +using System.Text; +using System.Threading.Tasks; + +namespace Neptuo.Commands +{ + internal class UserCommandDispatcher : ICommandDispatcher + { + private readonly ICommandDispatcher inner; + private readonly HttpContext httpContext; + private readonly ApiHub hub; + + public UserCommandDispatcher(ICommandDispatcher inner, HttpContext httpContext, ApiHub hub) + { + Ensure.NotNull(inner, "inner"); + Ensure.NotNull(httpContext, "httpContext"); + Ensure.NotNull(hub, "hub"); + this.inner = inner; + this.httpContext = httpContext; + this.hub = hub; + } + + public Task HandleAsync(TCommand command) + { + Envelope envelope = command as Envelope; + if (envelope == null) + envelope = Envelope.Create(command); + + string userId = httpContext.User.FindFirstValue(ClaimTypes.NameIdentifier); + if (!String.IsNullOrEmpty(userId)) + { + IKey userKey = StringKey.Create(userId, "User"); + envelope.Metadata.Add("UserKey", userKey); + + if (command is ICommand esCommand) + hub.AddCommand(esCommand.Key, userKey); + } + + return inner.HandleAsync(envelope); + } + } +} diff --git a/src/Money.Api/Domain/Services/Converters/ColorConverter.cs b/src/Money.Api/Domain/Services/Converters/ColorConverter.cs new file mode 100644 index 00000000..b88f2a32 --- /dev/null +++ b/src/Money.Api/Domain/Services/Converters/ColorConverter.cs @@ -0,0 +1,58 @@ +using Money; +using Neptuo.Converters; +using Newtonsoft.Json.Linq; +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; + +namespace Neptuo.Converters +{ + public class ColorConverter : TwoWayConverter + { + public override bool TryConvertFromOneToTwo(JToken sourceValue, out Color targetValue) + { + JValue value = sourceValue as JValue; + if (value == null) + { + targetValue = Color.FromArgb(255, 255, 255, 255); + return false; + } + + string stringValue = value.ToString(); + if (String.IsNullOrEmpty(stringValue)) + { + targetValue = Color.FromArgb(255, 255, 255, 255); + return false; + } + + byte[] byteArray = stringValue + .Split(';') + .Select(v => Byte.Parse(v)) + .ToArray(); + + targetValue = Color.FromArgb( + byteArray[0], + byteArray[1], + byteArray[2], + byteArray[3] + ); + return true; + } + + public override bool TryConvertFromTwoToOne(Color sourceValue, out JToken targetValue) + { + targetValue = new JValue(String.Concat( + sourceValue.A, + ";", + sourceValue.R, + ";", + sourceValue.G, + ";", + sourceValue.B + )); + return true; + } + } +} diff --git a/src/Money.Api/Domain/Services/CurrencyCache.Model.cs b/src/Money.Api/Domain/Services/CurrencyCache.Model.cs new file mode 100644 index 00000000..5972b310 --- /dev/null +++ b/src/Money.Api/Domain/Services/CurrencyCache.Model.cs @@ -0,0 +1,17 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; + +namespace Money.Services +{ + partial class CurrencyCache + { + private class Model + { + public string UniqueCode { get; set; } + public string Symbol { get; set; } + } + } +} diff --git a/src/Money.Api/Domain/Services/CurrencyCache.cs b/src/Money.Api/Domain/Services/CurrencyCache.cs new file mode 100644 index 00000000..2ba353d9 --- /dev/null +++ b/src/Money.Api/Domain/Services/CurrencyCache.cs @@ -0,0 +1,87 @@ +using Money.Events; +using Money.Models; +using Money.Models.Queries; +using Neptuo; +using Neptuo.Events; +using Neptuo.Events.Handlers; +using Neptuo.Models.Keys; +using Neptuo.Queries; +using Neptuo.Queries.Handlers; +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; + +namespace Money.Services +{ + internal partial class CurrencyCache : IEventHandler, + IEventHandler, + IEventHandler, + IQueryHandler + { + private readonly Dictionary> storage = new Dictionary>(); + private readonly IQueryDispatcher queryDispatcher; + + public CurrencyCache(IEventHandlerCollection eventHandlers, IQueryHandlerCollection queryHandlers, IQueryDispatcher queryDispatcher) + { + Ensure.NotNull(eventHandlers, "eventHandlers"); + Ensure.NotNull(queryHandlers, "queryHandlers"); + Ensure.NotNull(queryDispatcher, "queryDispatcher"); + eventHandlers.AddAll(this); + queryHandlers.AddAll(this); + this.queryDispatcher = queryDispatcher; + } + + private async Task EnsureUserStorageAsync(IKey userKey) + { + if (!this.storage.TryGetValue(userKey, out Dictionary storage)) + { + this.storage[userKey] = storage = new Dictionary(); + + List models = await queryDispatcher.QueryAsync(new ListAllCurrency() { UserKey = userKey }); + foreach (CurrencyModel model in models) + { + storage[model.UniqueCode] = new Model() + { + UniqueCode = model.UniqueCode, + Symbol = model.Symbol + }; + } + } + } + + async Task IEventHandler.HandleAsync(CurrencyCreated payload) + { + await EnsureUserStorageAsync(payload.UserKey); + storage[payload.UserKey][payload.UniqueCode] = new Model() + { + UniqueCode = payload.UniqueCode, + Symbol = payload.Symbol + }; + } + + async Task IEventHandler.HandleAsync(CurrencySymbolChanged payload) + { + await EnsureUserStorageAsync(payload.UserKey); + if (storage[payload.UserKey].TryGetValue(payload.UniqueCode, out Model model)) + model.Symbol = payload.Symbol; + } + + async Task IEventHandler.HandleAsync(CurrencyDeleted payload) + { + await EnsureUserStorageAsync(payload.UserKey); + if (storage[payload.UserKey].ContainsKey(payload.UniqueCode)) + storage[payload.UserKey].Remove(payload.UniqueCode); + } + + async Task IQueryHandler.HandleAsync(GetCurrencySymbol query) + { + await EnsureUserStorageAsync(query.UserKey); + if (storage[query.UserKey].TryGetValue(query.UniqueCode, out Model model)) + return model.Symbol; + + throw new CurrencyDoesNotExistException(); + } + } +} diff --git a/src/Money.Api/Domain/Services/PriceCalculator.ExchangeRateModelComparer.cs b/src/Money.Api/Domain/Services/PriceCalculator.ExchangeRateModelComparer.cs new file mode 100644 index 00000000..9b179132 --- /dev/null +++ b/src/Money.Api/Domain/Services/PriceCalculator.ExchangeRateModelComparer.cs @@ -0,0 +1,20 @@ +using Money.Models; +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; + +namespace Money.Services +{ + partial class PriceCalculator + { + private class ExchangeRateModelComparer : IComparer + { + public int Compare(ExchangeRateModel x, ExchangeRateModel y) + { + return y.ValidFrom.CompareTo(x.ValidFrom); + } + } + } +} diff --git a/src/Money.Api/Domain/Services/PriceCalculator.UserModel.cs b/src/Money.Api/Domain/Services/PriceCalculator.UserModel.cs new file mode 100644 index 00000000..7e7e044b --- /dev/null +++ b/src/Money.Api/Domain/Services/PriceCalculator.UserModel.cs @@ -0,0 +1,19 @@ +using Money.Models; +using System; +using System.Collections.Concurrent; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; + +namespace Money.Services +{ + partial class PriceCalculator + { + private class UserModel + { + public ConcurrentDictionary> Currencies { get; } = new ConcurrentDictionary>(); + public string DefaultCurrencyUniqueCode { get; set; } + } + } +} diff --git a/src/Money.Api/Domain/Services/PriceCalculator.cs b/src/Money.Api/Domain/Services/PriceCalculator.cs new file mode 100644 index 00000000..1dc7bcb2 --- /dev/null +++ b/src/Money.Api/Domain/Services/PriceCalculator.cs @@ -0,0 +1,115 @@ +using Money.Events; +using Money.Models; +using Money.Models.Queries; +using Neptuo; +using Neptuo.Events; +using Neptuo.Events.Handlers; +using Neptuo.Models.Keys; +using Neptuo.Queries; +using Neptuo.Threading.Tasks; +using System; +using System.Collections.Concurrent; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; + +namespace Money.Services +{ + public partial class PriceCalculator : DisposableBase, + IPriceConverter, + IEventHandler, + IEventHandler, + IEventHandler, + IEventHandler + { + private readonly ExchangeRateModelComparer exchangeRateComparer = new ExchangeRateModelComparer(); + private readonly IQueryDispatcher queryDispatcher; + private readonly ConcurrentDictionary storage = new ConcurrentDictionary(); + + public PriceCalculator(IEventHandlerCollection eventHandlers, IQueryDispatcher queryDispatcher) + { + Ensure.NotNull(eventHandlers, "eventHandlers"); + Ensure.NotNull(queryDispatcher, "queryDispatcher"); + eventHandlers.AddAll(this); + this.queryDispatcher = queryDispatcher; + } + + private async Task EnsureUserStorageAsync(IKey userKey) + { + if (!storage.TryGetValue(userKey, out UserModel model)) + { + storage[userKey] = model = new UserModel(); + + model.DefaultCurrencyUniqueCode = await queryDispatcher.QueryAsync(new GetCurrencyDefault() { UserKey = userKey }); + + IEnumerable currencies = await queryDispatcher.QueryAsync(new ListAllCurrency() { UserKey = userKey }); + foreach (CurrencyModel currency in currencies) + { + List rates = await queryDispatcher.QueryAsync(new ListTargetCurrencyExchangeRates(currency.UniqueCode) { UserKey = userKey }); + rates.Sort(exchangeRateComparer); + model.Currencies[currency.UniqueCode] = rates; + } + } + } + + async Task IEventHandler.HandleAsync(CurrencyCreated payload) + { + await EnsureUserStorageAsync(payload.UserKey); + storage[payload.UserKey].Currencies.TryAdd(payload.UniqueCode, new List()); + } + + async Task IEventHandler.HandleAsync(CurrencyExchangeRateSet payload) + { + await EnsureUserStorageAsync(payload.UserKey); + List exchangeRates = storage[payload.UserKey].Currencies[payload.TargetUniqueCode]; + exchangeRates.Add(new ExchangeRateModel(payload.SourceUniqueCode, payload.Rate, payload.ValidFrom)); + exchangeRates.Sort(exchangeRateComparer); + } + + async Task IEventHandler.HandleAsync(CurrencyExchangeRateRemoved payload) + { + await EnsureUserStorageAsync(payload.UserKey); + List exchangeRates = storage[payload.UserKey].Currencies[payload.TargetUniqueCode]; + ExchangeRateModel exchangeRate = exchangeRates.FirstOrDefault(r => r.SourceCurrency == payload.SourceUniqueCode && r.Rate == payload.Rate && r.ValidFrom == payload.ValidFrom); + if (exchangeRate != null) + { + exchangeRates.Remove(exchangeRate); + exchangeRates.Sort(exchangeRateComparer); + } + } + + async Task IEventHandler.HandleAsync(CurrencyDefaultChanged payload) + { + await EnsureUserStorageAsync(payload.UserKey); + storage[payload.UserKey].DefaultCurrencyUniqueCode = payload.UniqueCode; + } + + public Price ZeroDefault(IKey userKey) + { + EnsureUserStorageAsync(userKey).Wait(); + return Price.Zero(storage[userKey].DefaultCurrencyUniqueCode); + } + + public Price ToDefault(IKey userKey, IPriceFixed price) + { + Ensure.NotNull(price, "price"); + + EnsureUserStorageAsync(userKey).Wait(); + + string defaultCurrencyUniqueCode = storage[userKey].DefaultCurrencyUniqueCode; + + if (price.Amount.Currency == defaultCurrencyUniqueCode) + return price.Amount; + + if (storage[userKey].Currencies.TryGetValue(defaultCurrencyUniqueCode, out List rates)) + { + ExchangeRateModel rate = rates.FirstOrDefault(e => e.SourceCurrency == price.Amount.Currency && e.ValidFrom <= price.When); + if (rate != null) + return new Price(price.Amount.Value * (decimal)rate.Rate, defaultCurrencyUniqueCode); + } + + return new Price(price.Amount.Value, defaultCurrencyUniqueCode); + } + } +} diff --git a/src/Money.Api/Domain/Services/Queries/UserQueryDispatcher.cs b/src/Money.Api/Domain/Services/Queries/UserQueryDispatcher.cs new file mode 100644 index 00000000..966b2e26 --- /dev/null +++ b/src/Money.Api/Domain/Services/Queries/UserQueryDispatcher.cs @@ -0,0 +1,38 @@ +using Microsoft.AspNetCore.Http; +using Money.Models.Queries; +using Neptuo.Models.Keys; +using System; +using System.Collections.Generic; +using System.Linq; +using System.Security.Claims; +using System.Text; +using System.Threading.Tasks; + +namespace Neptuo.Queries +{ + public class UserQueryDispatcher : IQueryDispatcher + { + private readonly IQueryDispatcher inner; + private readonly HttpContext httpContext; + + public UserQueryDispatcher(IQueryDispatcher inner, HttpContext httpContext) + { + Ensure.NotNull(inner, "inner"); + Ensure.NotNull(httpContext, "httpContext"); + this.inner = inner; + this.httpContext = httpContext; + } + + public Task QueryAsync(IQuery query) + { + if (query is UserQuery userQuery) + { + string userId = httpContext.User.FindFirstValue(ClaimTypes.NameIdentifier); + if (!String.IsNullOrEmpty(userId)) + userQuery.UserKey = StringKey.Create(userId, "User"); + } + + return inner.QueryAsync(query); + } + } +} diff --git a/src/Money.Api/Money.Api.csproj b/src/Money.Api/Money.Api.csproj index 4619a222..6401da61 100644 --- a/src/Money.Api/Money.Api.csproj +++ b/src/Money.Api/Money.Api.csproj @@ -1,9 +1,10 @@ - + netcoreapp2.2 InProcess Money + latest @@ -15,6 +16,7 @@ + diff --git a/src/Money.Api/Startup.cs b/src/Money.Api/Startup.cs index 05600011..08fd98bb 100644 --- a/src/Money.Api/Startup.cs +++ b/src/Money.Api/Startup.cs @@ -8,15 +8,15 @@ using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.Hosting; -using Microsoft.AspNetCore.Identity; +using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.DependencyInjection.Extensions; using Microsoft.IdentityModel.Tokens; -using Money.Data; +using Money.Hubs; using Money.Models; +using Money.Models.Api; using Money.Users.Data; using Money.Users.Models; @@ -84,17 +84,34 @@ public void ConfigureServices(IServiceCollection services) .AddRouting(options => options.LowercaseUrls = true) .AddMvc().SetCompatibilityVersion(CompatibilityVersion.Version_2_2); + services + .AddSignalR(); + services .AddTransient() .Configure(Configuration.GetSection("Jwt")); + + services + .AddSingleton() + .AddSingleton() + .AddSingleton() + .AddSingleton(); + + Bootstrap.BootstrapTask bootstrapTask = new Bootstrap.BootstrapTask(services, connectionStrings); + bootstrapTask.Initialize(); } public void Configure(IApplicationBuilder app, IHostingEnvironment env) { if (env.IsDevelopment()) - { app.UseDeveloperExceptionPage(); - } + else + app.UseStatusCodePages(); + + app.UseSignalR(routes => + { + routes.MapHub("/api"); + }); app.UseAuthentication(); app.UseMvc(); diff --git a/src/Money.Api/Users/Commands/Handlers/UserHandler.cs b/src/Money.Api/Users/Commands/Handlers/UserHandler.cs new file mode 100644 index 00000000..559171ac --- /dev/null +++ b/src/Money.Api/Users/Commands/Handlers/UserHandler.cs @@ -0,0 +1,98 @@ +using Microsoft.AspNetCore.Identity; +using Money.Commands; +using Money.Events; +using Money.Models; +using Money.Queries; +using Money.Users.Models; +using Neptuo; +using Neptuo.Collections.Specialized; +using Neptuo.Commands; +using Neptuo.Commands.Handlers; +using Neptuo.Events; +using Neptuo.Models; +using Neptuo.Models.Keys; +using Neptuo.Queries.Handlers; +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; + +namespace Money.Users.Commands.Handlers +{ + public class UserHandler : ICommandHandler>, ICommandHandler>, IQueryHandler + { + private readonly UserManager userManager; + private readonly IEventDispatcher eventDispatcher; + + public UserHandler(UserManager userManager, IEventDispatcher eventDispatcher) + { + Ensure.NotNull(userManager, "userManager"); + Ensure.NotNull(eventDispatcher, "eventDispatcher"); + this.userManager = userManager; + this.eventDispatcher = eventDispatcher; + } + + private T DecorateException(ICommand command, StringKey userKey, T ex) + where T : AggregateRootException + { + AggregateRootExceptionDecorator decorator = new AggregateRootExceptionDecorator(ex); + decorator.SetCommandKey(command.Key); + decorator.SetSourceCommandKey(command.Key); + decorator.SetKey(userKey); + return ex; + } + + private async Task<(ApplicationUser Model, StringKey Key)> GetUserAsync(Envelope envelope) + { + StringKey userKey = envelope.Metadata.Get("UserKey"); + return (await GetUserAsync(userKey), userKey); + } + + private async Task GetUserAsync(StringKey userKey) + { + ApplicationUser model = await userManager.FindByIdAsync(userKey.Identifier); + if (model == null) + throw new InvalidOperationException($"Unable to load user with ID '{userKey.Identifier}'."); + + return model; + } + + private void EnsureNotDemo(Envelope envelope, (ApplicationUser Model, StringKey Key) user) + where T : ICommand + { + if (user.Model.IsDemo()) + throw DecorateException(envelope.Body, user.Key, new DemoUserCantBeChangedException()); + } + + public async Task HandleAsync(Envelope command) + { + var user = await GetUserAsync(command); + EnsureNotDemo(command, user); + + var result = await userManager.ChangePasswordAsync(user.Model, command.Body.Current, command.Body.New); + if (!result.Succeeded) + throw DecorateException(command.Body, user.Key, new PasswordChangeFailedException(String.Join(Environment.NewLine, result.Errors.Select(e => e.Description)))); + + await eventDispatcher.PublishAsync(new PasswordChanged(GuidKey.Create(Guid.NewGuid(), "PasswordChanged"), user.Key)); + } + + public async Task HandleAsync(Envelope command) + { + var user = await GetUserAsync(command); + EnsureNotDemo(command, user); + + var result = await userManager.SetEmailAsync(user.Model, command.Body.Email); + if (!result.Succeeded) + throw DecorateException(command.Body, user.Key, new EmailChangeFailedException()); + + await eventDispatcher.PublishAsync(new EmailChanged(GuidKey.Create(Guid.NewGuid(), "EmailChanged"), user.Key, command.Body.Email)); + } + + public async Task HandleAsync(GetProfile query) + { + var user = await GetUserAsync(query.UserKey.AsStringKey()); + return new ProfileModel(user.UserName, user.Email); + } + } +}