From b038b01014b3ce859a8b17ccf49833b81d8e61ee Mon Sep 17 00:00:00 2001 From: Paul Welter Date: Tue, 9 Dec 2025 10:00:11 -0600 Subject: [PATCH 1/6] refactor progress --- .../Arbiter.Dispatcher.Endpoints.csproj | 22 ++++ .../DispatcherEndpoint.cs | 0 Arbiter.slnx | 2 + .../EndpointRouteExtensions.cs | 3 - .../Arbiter.CommandQuery.csproj | 2 +- .../CommandQueryExtensions.cs | 92 --------------- .../MediatorJsonContext.cs | 2 - .../Arbiter.Dispatcher.csproj | 22 ++++ .../DispatchRequest.cs | 0 .../DispatcherDataService.cs | 0 .../DispatcherOptions.cs | 0 .../IDispatcher.cs | 0 .../IDispatcherDataService.cs | 0 .../MediatorDispatcher.cs | 0 .../RemoteDispatcher.cs | 0 .../ServiceCollectionExtensions.cs | 109 ++++++++++++++++++ .../State/ModelStateEditor.cs | 0 .../State/ModelStateLoader.cs | 0 .../State/ModelStateManager.cs | 0 19 files changed, 156 insertions(+), 98 deletions(-) create mode 100644 Arbiter.Dispatcher.Endpoints/Arbiter.Dispatcher.Endpoints.csproj rename {src/Arbiter.CommandQuery.Endpoints => Arbiter.Dispatcher.Endpoints}/DispatcherEndpoint.cs (100%) create mode 100644 src/Arbiter.Dispatcher/Arbiter.Dispatcher.csproj rename src/{Arbiter.CommandQuery/Dispatcher => Arbiter.Dispatcher}/DispatchRequest.cs (100%) rename src/{Arbiter.CommandQuery/Dispatcher => Arbiter.Dispatcher}/DispatcherDataService.cs (100%) rename src/{Arbiter.CommandQuery/Dispatcher => Arbiter.Dispatcher}/DispatcherOptions.cs (100%) rename src/{Arbiter.CommandQuery/Dispatcher => Arbiter.Dispatcher}/IDispatcher.cs (100%) rename src/{Arbiter.CommandQuery/Dispatcher => Arbiter.Dispatcher}/IDispatcherDataService.cs (100%) rename src/{Arbiter.CommandQuery/Dispatcher => Arbiter.Dispatcher}/MediatorDispatcher.cs (100%) rename src/{Arbiter.CommandQuery/Dispatcher => Arbiter.Dispatcher}/RemoteDispatcher.cs (100%) create mode 100644 src/Arbiter.Dispatcher/ServiceCollectionExtensions.cs rename src/{Arbiter.CommandQuery => Arbiter.Dispatcher}/State/ModelStateEditor.cs (100%) rename src/{Arbiter.CommandQuery => Arbiter.Dispatcher}/State/ModelStateLoader.cs (100%) rename src/{Arbiter.CommandQuery => Arbiter.Dispatcher}/State/ModelStateManager.cs (100%) diff --git a/Arbiter.Dispatcher.Endpoints/Arbiter.Dispatcher.Endpoints.csproj b/Arbiter.Dispatcher.Endpoints/Arbiter.Dispatcher.Endpoints.csproj new file mode 100644 index 0000000..4d5752c --- /dev/null +++ b/Arbiter.Dispatcher.Endpoints/Arbiter.Dispatcher.Endpoints.csproj @@ -0,0 +1,22 @@ + + + + net8.0;net9.0;net10.0 + enable + enable + Arbiter Dispatcher Endpoint + + + + + + + + + + + + + + + diff --git a/src/Arbiter.CommandQuery.Endpoints/DispatcherEndpoint.cs b/Arbiter.Dispatcher.Endpoints/DispatcherEndpoint.cs similarity index 100% rename from src/Arbiter.CommandQuery.Endpoints/DispatcherEndpoint.cs rename to Arbiter.Dispatcher.Endpoints/DispatcherEndpoint.cs diff --git a/Arbiter.slnx b/Arbiter.slnx index c72e297..4758e4b 100644 --- a/Arbiter.slnx +++ b/Arbiter.slnx @@ -41,11 +41,13 @@ + + diff --git a/src/Arbiter.CommandQuery.Endpoints/EndpointRouteExtensions.cs b/src/Arbiter.CommandQuery.Endpoints/EndpointRouteExtensions.cs index 35ea42d..fd486bf 100644 --- a/src/Arbiter.CommandQuery.Endpoints/EndpointRouteExtensions.cs +++ b/src/Arbiter.CommandQuery.Endpoints/EndpointRouteExtensions.cs @@ -22,9 +22,6 @@ public static IServiceCollection AddEndpointRoutes(this IServiceCollection servi services.AddHttpContextAccessor(); services.TryAddScoped(); - // allow duplicates - services.AddSingleton(); - return services; } diff --git a/src/Arbiter.CommandQuery/Arbiter.CommandQuery.csproj b/src/Arbiter.CommandQuery/Arbiter.CommandQuery.csproj index 4ae9aa1..a32582a 100644 --- a/src/Arbiter.CommandQuery/Arbiter.CommandQuery.csproj +++ b/src/Arbiter.CommandQuery/Arbiter.CommandQuery.csproj @@ -1,4 +1,4 @@ - + net8.0;net9.0;net10.0 diff --git a/src/Arbiter.CommandQuery/CommandQueryExtensions.cs b/src/Arbiter.CommandQuery/CommandQueryExtensions.cs index 7181e03..ab417b7 100644 --- a/src/Arbiter.CommandQuery/CommandQueryExtensions.cs +++ b/src/Arbiter.CommandQuery/CommandQueryExtensions.cs @@ -1,16 +1,11 @@ using Arbiter.CommandQuery.Behaviors; using Arbiter.CommandQuery.Commands; using Arbiter.CommandQuery.Definitions; -using Arbiter.CommandQuery.Dispatcher; using Arbiter.CommandQuery.Extensions; using Arbiter.CommandQuery.Mapping; using Arbiter.CommandQuery.Queries; using Arbiter.CommandQuery.Services; -using Arbiter.CommandQuery.State; -using Microsoft.Extensions.Caching.Distributed; -using Microsoft.Extensions.Caching.Hybrid; -using Microsoft.Extensions.Caching.Memory; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection.Extensions; @@ -62,93 +57,6 @@ public static IServiceCollection AddCommandValidation(this IServiceCollection se } - /// - /// Adds the remote dispatcher to the service collection with configuration for the HTTP client. - /// - /// The to add services to. - /// The action to configure the HTTP client with service provider. - /// The for further configuration of the HTTP client. - /// - /// This overload allows configuration of the HTTP client using both the service provider and HTTP client instance. - /// - public static IHttpClientBuilder AddRemoteDispatcher(this IServiceCollection services, Action configureClient) - { - ArgumentNullException.ThrowIfNull(services); - - services.AddRemoteDispatcher(); - return services.AddHttpClient(configureClient); - } - - /// - /// Adds the remote dispatcher to the service collection with configuration for the HTTP client. - /// - /// The to add services to. - /// The action to configure the HTTP client. - /// The for further configuration of the HTTP client. - /// - /// This overload allows configuration of the HTTP client using the HTTP client instance only. - /// - public static IHttpClientBuilder AddRemoteDispatcher(this IServiceCollection services, Action configureClient) - { - ArgumentNullException.ThrowIfNull(services); - - services.AddRemoteDispatcher(); - return services.AddHttpClient(configureClient); - } - - /// - /// Adds the remote dispatcher to the service collection without HTTP client configuration. - /// - /// The to add services to. - /// The so that additional calls can be chained. - /// - /// This method registers the remote dispatcher without configuring the HTTP client. - /// The client must register the with the correct separately. - /// - public static IServiceCollection AddRemoteDispatcher(this IServiceCollection services) - { - ArgumentNullException.ThrowIfNull(services); - - // up to client to register RemoteDispatcher with correct HttpClient - services.TryAddTransient(sp => sp.GetRequiredService()); - services.AddOptions(); - - services.TryAddTransient(); - - // Model State Open Generic Registrations - services.Add(ServiceDescriptor.Scoped(typeof(ModelStateManager<>), typeof(ModelStateManager<>))); - services.Add(ServiceDescriptor.Scoped(typeof(ModelStateLoader<,>), typeof(ModelStateLoader<,>))); - services.Add(ServiceDescriptor.Scoped(typeof(ModelStateEditor<,,>), typeof(ModelStateEditor<,,>))); - - return services; - } - - /// - /// Adds the server dispatcher to the service collection. - /// - /// The to add services to. - /// The so that additional calls can be chained. - /// - /// The server dispatcher uses the mediator pattern to dispatch commands and queries locally. - /// - public static IServiceCollection AddServerDispatcher(this IServiceCollection services) - { - ArgumentNullException.ThrowIfNull(services); - - services.TryAddTransient(); - services.AddOptions(); - - services.TryAddTransient(); - - // Model State Open Generic Registrations - services.Add(ServiceDescriptor.Scoped(typeof(ModelStateManager<>), typeof(ModelStateManager<>))); - services.Add(ServiceDescriptor.Scoped(typeof(ModelStateLoader<,>), typeof(ModelStateLoader<,>))); - services.Add(ServiceDescriptor.Scoped(typeof(ModelStateEditor<,,>), typeof(ModelStateEditor<,,>))); - - return services; - } - - /// /// Adds the hybrid cache behaviors for entity commands and queries to the service collection. /// diff --git a/src/Arbiter.CommandQuery/MediatorJsonContext.cs b/src/Arbiter.CommandQuery/MediatorJsonContext.cs index 2436c30..5392814 100644 --- a/src/Arbiter.CommandQuery/MediatorJsonContext.cs +++ b/src/Arbiter.CommandQuery/MediatorJsonContext.cs @@ -1,6 +1,5 @@ using System.Text.Json.Serialization; -using Arbiter.CommandQuery.Dispatcher; using Arbiter.CommandQuery.Models; using Arbiter.CommandQuery.Queries; @@ -15,7 +14,6 @@ namespace Arbiter.CommandQuery; /// and types in Blazor WebAssembly and other .NET applications. /// [JsonSerializable(typeof(CompleteModel))] -[JsonSerializable(typeof(DispatchRequest))] [JsonSerializable(typeof(EntityFilter))] [JsonSerializable(typeof(EntityQuery))] [JsonSerializable(typeof(EntitySort))] diff --git a/src/Arbiter.Dispatcher/Arbiter.Dispatcher.csproj b/src/Arbiter.Dispatcher/Arbiter.Dispatcher.csproj new file mode 100644 index 0000000..6c9a800 --- /dev/null +++ b/src/Arbiter.Dispatcher/Arbiter.Dispatcher.csproj @@ -0,0 +1,22 @@ + + + + net8.0;net9.0;net10.0 + enable + enable + Arbiter Dispatcher + + + + + + + + + + + + + + + diff --git a/src/Arbiter.CommandQuery/Dispatcher/DispatchRequest.cs b/src/Arbiter.Dispatcher/DispatchRequest.cs similarity index 100% rename from src/Arbiter.CommandQuery/Dispatcher/DispatchRequest.cs rename to src/Arbiter.Dispatcher/DispatchRequest.cs diff --git a/src/Arbiter.CommandQuery/Dispatcher/DispatcherDataService.cs b/src/Arbiter.Dispatcher/DispatcherDataService.cs similarity index 100% rename from src/Arbiter.CommandQuery/Dispatcher/DispatcherDataService.cs rename to src/Arbiter.Dispatcher/DispatcherDataService.cs diff --git a/src/Arbiter.CommandQuery/Dispatcher/DispatcherOptions.cs b/src/Arbiter.Dispatcher/DispatcherOptions.cs similarity index 100% rename from src/Arbiter.CommandQuery/Dispatcher/DispatcherOptions.cs rename to src/Arbiter.Dispatcher/DispatcherOptions.cs diff --git a/src/Arbiter.CommandQuery/Dispatcher/IDispatcher.cs b/src/Arbiter.Dispatcher/IDispatcher.cs similarity index 100% rename from src/Arbiter.CommandQuery/Dispatcher/IDispatcher.cs rename to src/Arbiter.Dispatcher/IDispatcher.cs diff --git a/src/Arbiter.CommandQuery/Dispatcher/IDispatcherDataService.cs b/src/Arbiter.Dispatcher/IDispatcherDataService.cs similarity index 100% rename from src/Arbiter.CommandQuery/Dispatcher/IDispatcherDataService.cs rename to src/Arbiter.Dispatcher/IDispatcherDataService.cs diff --git a/src/Arbiter.CommandQuery/Dispatcher/MediatorDispatcher.cs b/src/Arbiter.Dispatcher/MediatorDispatcher.cs similarity index 100% rename from src/Arbiter.CommandQuery/Dispatcher/MediatorDispatcher.cs rename to src/Arbiter.Dispatcher/MediatorDispatcher.cs diff --git a/src/Arbiter.CommandQuery/Dispatcher/RemoteDispatcher.cs b/src/Arbiter.Dispatcher/RemoteDispatcher.cs similarity index 100% rename from src/Arbiter.CommandQuery/Dispatcher/RemoteDispatcher.cs rename to src/Arbiter.Dispatcher/RemoteDispatcher.cs diff --git a/src/Arbiter.Dispatcher/ServiceCollectionExtensions.cs b/src/Arbiter.Dispatcher/ServiceCollectionExtensions.cs new file mode 100644 index 0000000..2c24480 --- /dev/null +++ b/src/Arbiter.Dispatcher/ServiceCollectionExtensions.cs @@ -0,0 +1,109 @@ +using Arbiter.CommandQuery.Behaviors; +using Arbiter.CommandQuery.Commands; +using Arbiter.CommandQuery.Definitions; +using Arbiter.CommandQuery.Dispatcher; +using Arbiter.CommandQuery.Extensions; +using Arbiter.CommandQuery.Mapping; +using Arbiter.CommandQuery.Queries; +using Arbiter.CommandQuery.Services; +using Arbiter.CommandQuery.State; + +using Microsoft.Extensions.Caching.Distributed; +using Microsoft.Extensions.Caching.Hybrid; +using Microsoft.Extensions.Caching.Memory; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.DependencyInjection.Extensions; + +namespace Arbiter.CommandQuery; + +/// +/// Extension methods for adding command query services to the service collection. +/// +public static class ServiceCollectionExtensions +{ + /// + /// Adds the remote dispatcher to the service collection with configuration for the HTTP client. + /// + /// The to add services to. + /// The action to configure the HTTP client with service provider. + /// The for further configuration of the HTTP client. + /// + /// This overload allows configuration of the HTTP client using both the service provider and HTTP client instance. + /// + public static IHttpClientBuilder AddRemoteDispatcher(this IServiceCollection services, Action configureClient) + { + ArgumentNullException.ThrowIfNull(services); + + services.AddRemoteDispatcher(); + return services.AddHttpClient(configureClient); + } + + /// + /// Adds the remote dispatcher to the service collection with configuration for the HTTP client. + /// + /// The to add services to. + /// The action to configure the HTTP client. + /// The for further configuration of the HTTP client. + /// + /// This overload allows configuration of the HTTP client using the HTTP client instance only. + /// + public static IHttpClientBuilder AddRemoteDispatcher(this IServiceCollection services, Action configureClient) + { + ArgumentNullException.ThrowIfNull(services); + + services.AddRemoteDispatcher(); + return services.AddHttpClient(configureClient); + } + + /// + /// Adds the remote dispatcher to the service collection without HTTP client configuration. + /// + /// The to add services to. + /// The so that additional calls can be chained. + /// + /// This method registers the remote dispatcher without configuring the HTTP client. + /// The client must register the with the correct separately. + /// + public static IServiceCollection AddRemoteDispatcher(this IServiceCollection services) + { + ArgumentNullException.ThrowIfNull(services); + + // up to client to register RemoteDispatcher with correct HttpClient + services.TryAddTransient(sp => sp.GetRequiredService()); + services.AddOptions(); + + services.TryAddTransient(); + + // Model State Open Generic Registrations + services.Add(ServiceDescriptor.Scoped(typeof(ModelStateManager<>), typeof(ModelStateManager<>))); + services.Add(ServiceDescriptor.Scoped(typeof(ModelStateLoader<,>), typeof(ModelStateLoader<,>))); + services.Add(ServiceDescriptor.Scoped(typeof(ModelStateEditor<,,>), typeof(ModelStateEditor<,,>))); + + return services; + } + + /// + /// Adds the server dispatcher to the service collection. + /// + /// The to add services to. + /// The so that additional calls can be chained. + /// + /// The server dispatcher uses the mediator pattern to dispatch commands and queries locally. + /// + public static IServiceCollection AddServerDispatcher(this IServiceCollection services) + { + ArgumentNullException.ThrowIfNull(services); + + services.TryAddTransient(); + services.AddOptions(); + + services.TryAddTransient(); + + // Model State Open Generic Registrations + services.Add(ServiceDescriptor.Scoped(typeof(ModelStateManager<>), typeof(ModelStateManager<>))); + services.Add(ServiceDescriptor.Scoped(typeof(ModelStateLoader<,>), typeof(ModelStateLoader<,>))); + services.Add(ServiceDescriptor.Scoped(typeof(ModelStateEditor<,,>), typeof(ModelStateEditor<,,>))); + + return services; + } +} diff --git a/src/Arbiter.CommandQuery/State/ModelStateEditor.cs b/src/Arbiter.Dispatcher/State/ModelStateEditor.cs similarity index 100% rename from src/Arbiter.CommandQuery/State/ModelStateEditor.cs rename to src/Arbiter.Dispatcher/State/ModelStateEditor.cs diff --git a/src/Arbiter.CommandQuery/State/ModelStateLoader.cs b/src/Arbiter.Dispatcher/State/ModelStateLoader.cs similarity index 100% rename from src/Arbiter.CommandQuery/State/ModelStateLoader.cs rename to src/Arbiter.Dispatcher/State/ModelStateLoader.cs diff --git a/src/Arbiter.CommandQuery/State/ModelStateManager.cs b/src/Arbiter.Dispatcher/State/ModelStateManager.cs similarity index 100% rename from src/Arbiter.CommandQuery/State/ModelStateManager.cs rename to src/Arbiter.Dispatcher/State/ModelStateManager.cs From 061d9dd2f40bc0ff0de0407b6e80e922ad2a82f9 Mon Sep 17 00:00:00 2001 From: Paul Welter Date: Thu, 18 Dec 2025 09:07:24 -0600 Subject: [PATCH 2/6] progress --- .../DispatcherEndpoint.cs | 95 ----------- Arbiter.slnx | 5 +- Directory.Packages.props | 6 + .../Components/Abstracts/StorePageBase.cs | 2 +- .../Components/EntitySelect.razor | 1 - .../Tracker.Client/Services/DataService.cs | 3 +- .../Services/ServiceRegistration.cs | 9 +- .../src/Tracker.Client/Tracker.Client.csproj | 1 + .../src/Tracker.Web/Program.cs | 14 +- .../src/Tracker.Web/Tracker.Web.csproj | 1 + .../Arbiter.CommandQuery.csproj | 1 + .../Commands/EntityCreateCommand.cs | 11 +- .../Commands/EntityModelBase.cs | 1 + .../Commands/PrincipalCommandBase.cs | 6 + .../MediatorJsonContext.cs | 4 - .../Arbiter.Dispatcher.Client.csproj | 28 ++++ .../Client}/IDispatcher.cs | 4 +- .../Client/RemoteDispatcher.cs | 103 ++++++++++++ .../Client/ServerDispatcher.cs} | 18 +- .../DispatcherDataService.cs | 3 +- .../DispatcherServiceExtensions.cs | 105 ++++++++++++ src/Arbiter.Dispatcher.Client/IDispatcher.cs | 41 +++++ .../IDispatcherDataService.cs | 3 +- .../RemoteDispatcher.cs | 110 ++++++++++++ .../ServerDispatcher.cs | 41 +++++ .../State/ModelStateEditor.cs | 4 +- .../State/ModelStateLoader.cs | 3 +- .../State/ModelStateManager.cs | 2 +- .../Arbiter.Dispatcher.Server.csproj | 19 +++ .../DispatcherMethod.cs | 24 +++ .../DispatcherService.cs | 126 ++++++++++++++ .../DispatcherServiceExtensions.cs | 34 ++++ src/Arbiter.Dispatcher/DispatchRequest.cs | 18 -- src/Arbiter.Dispatcher/DispatcherOptions.cs | 22 --- src/Arbiter.Dispatcher/RemoteDispatcher.cs | 157 ------------------ src/Directory.Build.props | 4 +- ....CommandQuery.EntityFramework.Tests.csproj | 1 + .../Dispatcher/DispatcherDataServiceTests.cs | 4 +- .../TestApplication.cs | 2 + .../Arbiter.CommandQuery.Tests.csproj | 1 + .../MockDataService.cs | 2 +- .../State/ModelStateEditorTests.cs | 2 +- .../State/ModelStateLoaderTests.cs | 2 +- .../State/ModelStateManagerTests.cs | 2 +- .../Arbiter.Dispatcher.Client.Tests.csproj | 30 ++++ .../Fakes/LocationCreateModelFaker.cs | 25 +++ .../Fakes/LocationFaker.cs | 25 +++ .../Fakes/LocationReadModelFaker.cs | 25 +++ .../Fakes/LocationUpdateModelFaker.cs | 22 +++ .../MockPrincipal.cs | 23 +++ .../Models/Address.cs | 18 ++ .../Models/Company.cs | 14 ++ .../Models/Department.cs | 17 ++ .../Models/Fruit.cs | 32 ++++ .../Models/Location.cs | 23 +++ .../Models/LocationCreateModel.cs | 19 +++ .../Models/LocationReadModel.cs | 17 ++ .../Models/LocationUpdateModel.cs | 17 ++ .../Models/Person.cs | 18 ++ .../Models/PersonModel.cs | 16 ++ .../Models/PersonRecord.cs | 12 ++ .../SerializationTests.cs | 32 ++++ 62 files changed, 1097 insertions(+), 333 deletions(-) delete mode 100644 Arbiter.Dispatcher.Endpoints/DispatcherEndpoint.cs create mode 100644 src/Arbiter.Dispatcher.Client/Arbiter.Dispatcher.Client.csproj rename src/{Arbiter.Dispatcher => Arbiter.Dispatcher.Client/Client}/IDispatcher.cs (96%) create mode 100644 src/Arbiter.Dispatcher.Client/Client/RemoteDispatcher.cs rename src/{Arbiter.Dispatcher/MediatorDispatcher.cs => Arbiter.Dispatcher.Client/Client/ServerDispatcher.cs} (64%) rename src/{Arbiter.Dispatcher => Arbiter.Dispatcher.Client}/DispatcherDataService.cs (99%) create mode 100644 src/Arbiter.Dispatcher.Client/DispatcherServiceExtensions.cs create mode 100644 src/Arbiter.Dispatcher.Client/IDispatcher.cs rename src/{Arbiter.Dispatcher => Arbiter.Dispatcher.Client}/IDispatcherDataService.cs (99%) create mode 100644 src/Arbiter.Dispatcher.Client/RemoteDispatcher.cs create mode 100644 src/Arbiter.Dispatcher.Client/ServerDispatcher.cs rename src/{Arbiter.Dispatcher => Arbiter.Dispatcher.Client}/State/ModelStateEditor.cs (99%) rename src/{Arbiter.Dispatcher => Arbiter.Dispatcher.Client}/State/ModelStateLoader.cs (99%) rename src/{Arbiter.Dispatcher => Arbiter.Dispatcher.Client}/State/ModelStateManager.cs (99%) create mode 100644 src/Arbiter.Dispatcher.Server/Arbiter.Dispatcher.Server.csproj create mode 100644 src/Arbiter.Dispatcher.Server/DispatcherMethod.cs create mode 100644 src/Arbiter.Dispatcher.Server/DispatcherService.cs create mode 100644 src/Arbiter.Dispatcher.Server/DispatcherServiceExtensions.cs delete mode 100644 src/Arbiter.Dispatcher/DispatchRequest.cs delete mode 100644 src/Arbiter.Dispatcher/DispatcherOptions.cs delete mode 100644 src/Arbiter.Dispatcher/RemoteDispatcher.cs create mode 100644 test/Arbiter.Dispatcher.Client.Tests/Arbiter.Dispatcher.Client.Tests.csproj create mode 100644 test/Arbiter.Dispatcher.Client.Tests/Fakes/LocationCreateModelFaker.cs create mode 100644 test/Arbiter.Dispatcher.Client.Tests/Fakes/LocationFaker.cs create mode 100644 test/Arbiter.Dispatcher.Client.Tests/Fakes/LocationReadModelFaker.cs create mode 100644 test/Arbiter.Dispatcher.Client.Tests/Fakes/LocationUpdateModelFaker.cs create mode 100644 test/Arbiter.Dispatcher.Client.Tests/MockPrincipal.cs create mode 100644 test/Arbiter.Dispatcher.Client.Tests/Models/Address.cs create mode 100644 test/Arbiter.Dispatcher.Client.Tests/Models/Company.cs create mode 100644 test/Arbiter.Dispatcher.Client.Tests/Models/Department.cs create mode 100644 test/Arbiter.Dispatcher.Client.Tests/Models/Fruit.cs create mode 100644 test/Arbiter.Dispatcher.Client.Tests/Models/Location.cs create mode 100644 test/Arbiter.Dispatcher.Client.Tests/Models/LocationCreateModel.cs create mode 100644 test/Arbiter.Dispatcher.Client.Tests/Models/LocationReadModel.cs create mode 100644 test/Arbiter.Dispatcher.Client.Tests/Models/LocationUpdateModel.cs create mode 100644 test/Arbiter.Dispatcher.Client.Tests/Models/Person.cs create mode 100644 test/Arbiter.Dispatcher.Client.Tests/Models/PersonModel.cs create mode 100644 test/Arbiter.Dispatcher.Client.Tests/Models/PersonRecord.cs create mode 100644 test/Arbiter.Dispatcher.Client.Tests/SerializationTests.cs diff --git a/Arbiter.Dispatcher.Endpoints/DispatcherEndpoint.cs b/Arbiter.Dispatcher.Endpoints/DispatcherEndpoint.cs deleted file mode 100644 index 85851f9..0000000 --- a/Arbiter.Dispatcher.Endpoints/DispatcherEndpoint.cs +++ /dev/null @@ -1,95 +0,0 @@ -using System.Security.Claims; - -using Arbiter.CommandQuery.Definitions; -using Arbiter.CommandQuery.Dispatcher; - -using Microsoft.AspNetCore.Builder; -using Microsoft.AspNetCore.Http; -using Microsoft.AspNetCore.Mvc; -using Microsoft.AspNetCore.Routing; -using Microsoft.Extensions.Logging; -using Microsoft.Extensions.Options; - -namespace Arbiter.CommandQuery.Endpoints; - -/// -/// Defines an endpoint for dispatching commands using the Mediator pattern. -/// -public partial class DispatcherEndpoint : IEndpointRoute -{ - private readonly DispatcherOptions _dispatcherOptions; - private readonly ILogger _logger; - - /// - /// Initializes a new instance of the class. - /// - /// The logger for this feature endpoint - /// The configuration options for the dispatcher - /// When or are - public DispatcherEndpoint(ILogger logger, IOptions dispatcherOptions) - { - ArgumentNullException.ThrowIfNull(logger); - ArgumentNullException.ThrowIfNull(dispatcherOptions); - - _dispatcherOptions = dispatcherOptions.Value; - _logger = logger; - } - - - /// - public void AddRoutes(IEndpointRouteBuilder endpoints) - { - var group = endpoints - .MapGroup(_dispatcherOptions.DispatcherPrefix); - - group - .MapPost(_dispatcherOptions.SendRoute, Send) - .WithEntityMetadata("Dispatcher") - .WithName("Send") - .WithSummary("Send Mediator command") - .WithDescription("Send Mediator command") - .ExcludeFromDescription(); - } - - /// - /// Dispatches a request to the appropriate handler using the Mediator pattern. - /// - /// The incoming dispatcher request - /// The to send request to. - /// The current security claims principal - /// The cancellation token - /// Awaitable task returning the mediator response - protected virtual async Task Send( - [FromBody] DispatchRequest dispatchRequest, - [FromServices] IMediator mediator, - ClaimsPrincipal? user = default, - CancellationToken cancellationToken = default) - { - var request = dispatchRequest.Request; - - // Apply current user principal if supported - if (request is IRequestPrincipal requestPrincipal) - requestPrincipal.ApplyPrincipal(user); - - try - { - var result = await mediator.Send(request, cancellationToken).ConfigureAwait(false); - return TypedResults.Ok(result); - } - catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) - { - return TypedResults.Problem("Request was canceled", statusCode: 499); - } - catch (Exception ex) - { - LogDispatchError(_logger, request?.GetType()?.FullName, ex.Message, ex); - - var details = ex.ToProblemDetails(); - return TypedResults.Problem(details); - } - } - - - [LoggerMessage(1, LogLevel.Error, "Error dispatching request '{RequestType}': {ErrorMessage}")] - static partial void LogDispatchError(ILogger logger, string? requestType, string errorMessage, Exception exception); -} diff --git a/Arbiter.slnx b/Arbiter.slnx index 4758e4b..33bd248 100644 --- a/Arbiter.slnx +++ b/Arbiter.slnx @@ -38,16 +38,17 @@ + - - + + diff --git a/Directory.Packages.props b/Directory.Packages.props index be8bdb3..288bcee 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -20,10 +20,16 @@ + + + + + + diff --git a/samples/EntityFramework/src/Tracker.Client/Components/Abstracts/StorePageBase.cs b/samples/EntityFramework/src/Tracker.Client/Components/Abstracts/StorePageBase.cs index 3832192..38be23a 100644 --- a/samples/EntityFramework/src/Tracker.Client/Components/Abstracts/StorePageBase.cs +++ b/samples/EntityFramework/src/Tracker.Client/Components/Abstracts/StorePageBase.cs @@ -1,5 +1,5 @@ using Arbiter.CommandQuery.Definitions; -using Arbiter.CommandQuery.State; +using Arbiter.Dispatcher.State; using LoreSoft.Blazor.Controls; diff --git a/samples/EntityFramework/src/Tracker.Client/Components/EntitySelect.razor b/samples/EntityFramework/src/Tracker.Client/Components/EntitySelect.razor index c143196..3c5d5f5 100644 --- a/samples/EntityFramework/src/Tracker.Client/Components/EntitySelect.razor +++ b/samples/EntityFramework/src/Tracker.Client/Components/EntitySelect.razor @@ -1,6 +1,5 @@ @using System.Linq.Expressions @using Arbiter.CommandQuery.Definitions -@using Arbiter.CommandQuery.Dispatcher @using Arbiter.CommandQuery.Queries @typeparam TModel where TModel : class, IHaveIdentifier, ISupportSearch diff --git a/samples/EntityFramework/src/Tracker.Client/Services/DataService.cs b/samples/EntityFramework/src/Tracker.Client/Services/DataService.cs index 34ee26c..506d13b 100644 --- a/samples/EntityFramework/src/Tracker.Client/Services/DataService.cs +++ b/samples/EntityFramework/src/Tracker.Client/Services/DataService.cs @@ -1,6 +1,7 @@ using System.Security.Claims; -using Arbiter.CommandQuery.Dispatcher; +using Arbiter.Dispatcher; +using Arbiter.Dispatcher.Client; using Microsoft.AspNetCore.Components.Authorization; diff --git a/samples/EntityFramework/src/Tracker.Client/Services/ServiceRegistration.cs b/samples/EntityFramework/src/Tracker.Client/Services/ServiceRegistration.cs index c7cdf68..cce79e0 100644 --- a/samples/EntityFramework/src/Tracker.Client/Services/ServiceRegistration.cs +++ b/samples/EntityFramework/src/Tracker.Client/Services/ServiceRegistration.cs @@ -1,6 +1,6 @@ using System.Text.Json; -using Arbiter.CommandQuery; +using Arbiter.Dispatcher; using LoreSoft.Blazor.Controls; @@ -33,12 +33,11 @@ public static void Register(IServiceCollection services, ISet tags) if (tags.Contains("WebAssembly")) { services - .AddRemoteDispatcher((sp, client) => + .AddRemoteDispatcher(static sp => { var hostEnvironment = sp.GetRequiredService>(); - client.BaseAddress = new Uri(hostEnvironment.Value.BaseAddress); - }) - .AddHttpMessageHandler(); + return hostEnvironment.Value.BaseAddress; + }); } if (tags.Contains("Server")) diff --git a/samples/EntityFramework/src/Tracker.Client/Tracker.Client.csproj b/samples/EntityFramework/src/Tracker.Client/Tracker.Client.csproj index 2ca8ab4..f3081f7 100644 --- a/samples/EntityFramework/src/Tracker.Client/Tracker.Client.csproj +++ b/samples/EntityFramework/src/Tracker.Client/Tracker.Client.csproj @@ -18,6 +18,7 @@ + diff --git a/samples/EntityFramework/src/Tracker.Web/Program.cs b/samples/EntityFramework/src/Tracker.Web/Program.cs index 64d0420..c85eb00 100644 --- a/samples/EntityFramework/src/Tracker.Web/Program.cs +++ b/samples/EntityFramework/src/Tracker.Web/Program.cs @@ -1,4 +1,5 @@ using Arbiter.CommandQuery.Endpoints; +using Arbiter.Dispatcher.Server; using Arbiter.Mediation.OpenTelemetry; using Microsoft.AspNetCore.Authentication.Cookies; @@ -101,7 +102,8 @@ private static void ConfigureServices(WebApplicationBuilder builder) .AddTrackerWeb(); services - .AddEndpointRoutes(); + .AddEndpointRoutes() + .AddDispatcherService(); services .ConfigureHttpJsonOptions(options => options.SerializerOptions.AddDomainOptions()); @@ -139,7 +141,14 @@ private static void ConfigureMiddleware(WebApplication app) app.UseResponseCompression(); } - app.UseRequestLogging(config => config.IncludeRequestBody = true); + app.UseDispatchService(); + + app.UseRequestLogging(config => + { + config.IncludeRequestBody = true; + config.IgnorePath("/_framework/**"); + config.IgnorePath("/_content/**"); + }); app.UseHttpsRedirection(); @@ -153,6 +162,7 @@ private static void ConfigureMiddleware(WebApplication app) .AddAdditionalAssemblies(typeof(Client.Routes).Assembly); app.MapEndpointRoutes(); + app.MapDispatchService(); } } diff --git a/samples/EntityFramework/src/Tracker.Web/Tracker.Web.csproj b/samples/EntityFramework/src/Tracker.Web/Tracker.Web.csproj index ea168ee..9bb4bd7 100644 --- a/samples/EntityFramework/src/Tracker.Web/Tracker.Web.csproj +++ b/samples/EntityFramework/src/Tracker.Web/Tracker.Web.csproj @@ -10,6 +10,7 @@ + diff --git a/src/Arbiter.CommandQuery/Arbiter.CommandQuery.csproj b/src/Arbiter.CommandQuery/Arbiter.CommandQuery.csproj index a32582a..1190e85 100644 --- a/src/Arbiter.CommandQuery/Arbiter.CommandQuery.csproj +++ b/src/Arbiter.CommandQuery/Arbiter.CommandQuery.csproj @@ -11,6 +11,7 @@ + diff --git a/src/Arbiter.CommandQuery/Commands/EntityCreateCommand.cs b/src/Arbiter.CommandQuery/Commands/EntityCreateCommand.cs index 0bf05e8..3e52e4b 100644 --- a/src/Arbiter.CommandQuery/Commands/EntityCreateCommand.cs +++ b/src/Arbiter.CommandQuery/Commands/EntityCreateCommand.cs @@ -5,6 +5,8 @@ using Arbiter.CommandQuery.Definitions; using Arbiter.Services; +using MessagePack; + namespace Arbiter.CommandQuery.Commands; /// @@ -82,7 +84,8 @@ namespace Arbiter.CommandQuery.Commands; /// /// /// -public record EntityCreateCommand +[MessagePackObject(keyAsPropertyName: true)] +public partial record EntityCreateCommand : EntityModelBase, ICacheExpire { /// @@ -113,7 +116,11 @@ public record EntityCreateCommand /// /// /// - public EntityCreateCommand(ClaimsPrincipal? principal, [NotNull] TCreateModel model, string? filterName = null) + [SerializationConstructor] + public EntityCreateCommand( + [IgnoreMember] ClaimsPrincipal? principal, + [NotNull] TCreateModel model, + string? filterName = null) : base(principal, model) { FilterName = filterName; diff --git a/src/Arbiter.CommandQuery/Commands/EntityModelBase.cs b/src/Arbiter.CommandQuery/Commands/EntityModelBase.cs index f559b00..8c36fce 100644 --- a/src/Arbiter.CommandQuery/Commands/EntityModelBase.cs +++ b/src/Arbiter.CommandQuery/Commands/EntityModelBase.cs @@ -1,3 +1,4 @@ + using System.Diagnostics.CodeAnalysis; using System.Security.Claims; using System.Text.Json.Serialization; diff --git a/src/Arbiter.CommandQuery/Commands/PrincipalCommandBase.cs b/src/Arbiter.CommandQuery/Commands/PrincipalCommandBase.cs index b1e733c..9e4aca7 100644 --- a/src/Arbiter.CommandQuery/Commands/PrincipalCommandBase.cs +++ b/src/Arbiter.CommandQuery/Commands/PrincipalCommandBase.cs @@ -1,8 +1,11 @@ +using System.Runtime.Serialization; using System.Security.Claims; using System.Text.Json.Serialization; using Arbiter.CommandQuery.Definitions; +using MessagePack; + namespace Arbiter.CommandQuery.Commands; /// @@ -52,6 +55,7 @@ protected PrincipalCommandBase(ClaimsPrincipal? principal) /// The representing the user executing the command. /// [JsonIgnore] + [IgnoreMember] public ClaimsPrincipal? Principal { get; private set; } /// @@ -61,6 +65,7 @@ protected PrincipalCommandBase(ClaimsPrincipal? principal) /// The timestamp indicating when this command was activated. /// [JsonIgnore] + [IgnoreMember] public DateTimeOffset Activated { get; private set; } /// @@ -75,6 +80,7 @@ protected PrincipalCommandBase(ClaimsPrincipal? principal) /// /// [JsonIgnore] + [IgnoreMember] public string? ActivatedBy { get; private set; } /// diff --git a/src/Arbiter.CommandQuery/MediatorJsonContext.cs b/src/Arbiter.CommandQuery/MediatorJsonContext.cs index 5392814..799dbbe 100644 --- a/src/Arbiter.CommandQuery/MediatorJsonContext.cs +++ b/src/Arbiter.CommandQuery/MediatorJsonContext.cs @@ -9,10 +9,6 @@ namespace Arbiter.CommandQuery; /// Provides a for source generation of JSON serialization metadata /// for types used in the Arbiter Command/Query pipeline. /// -/// -/// This context enables efficient serialization and deserialization of -/// and types in Blazor WebAssembly and other .NET applications. -/// [JsonSerializable(typeof(CompleteModel))] [JsonSerializable(typeof(EntityFilter))] [JsonSerializable(typeof(EntityQuery))] diff --git a/src/Arbiter.Dispatcher.Client/Arbiter.Dispatcher.Client.csproj b/src/Arbiter.Dispatcher.Client/Arbiter.Dispatcher.Client.csproj new file mode 100644 index 0000000..2c581a8 --- /dev/null +++ b/src/Arbiter.Dispatcher.Client/Arbiter.Dispatcher.Client.csproj @@ -0,0 +1,28 @@ + + + + net10.0 + enable + enable + Arbiter.Dispatcher + + + + + + + + + + + + + + + + + + + + + diff --git a/src/Arbiter.Dispatcher/IDispatcher.cs b/src/Arbiter.Dispatcher.Client/Client/IDispatcher.cs similarity index 96% rename from src/Arbiter.Dispatcher/IDispatcher.cs rename to src/Arbiter.Dispatcher.Client/Client/IDispatcher.cs index b96ac29..4b047e1 100644 --- a/src/Arbiter.Dispatcher/IDispatcher.cs +++ b/src/Arbiter.Dispatcher.Client/Client/IDispatcher.cs @@ -1,6 +1,8 @@ using System.Diagnostics.CodeAnalysis; -namespace Arbiter.CommandQuery.Dispatcher; +using Arbiter.Mediation; + +namespace Arbiter.Dispatcher.Client; /// /// An to represent a dispatcher for sending request messages. diff --git a/src/Arbiter.Dispatcher.Client/Client/RemoteDispatcher.cs b/src/Arbiter.Dispatcher.Client/Client/RemoteDispatcher.cs new file mode 100644 index 0000000..951efd5 --- /dev/null +++ b/src/Arbiter.Dispatcher.Client/Client/RemoteDispatcher.cs @@ -0,0 +1,103 @@ +using Arbiter.CommandQuery.Definitions; +using Arbiter.Dispatcher.Server; +using Arbiter.Mediation; + +using Grpc.Core; +using Grpc.Net.Client; +using Grpc.Net.Client.Web; + +using MessagePack; + +using Microsoft.Extensions.Caching.Hybrid; + +namespace Arbiter.Dispatcher.Client; + +public class RemoteDispatcher : IDispatcher, IDisposable +{ + private readonly GrpcChannel _channel; + private readonly CallInvoker _invoker; + private readonly HybridCache? _hybridCache; + + private static readonly MessagePackSerializerOptions Options = MessagePackSerializerOptions.Standard + .WithResolver(MessagePack.Resolvers.TypelessContractlessStandardResolver.Instance) + .WithCompression(MessagePackCompression.Lz4BlockArray); + + public RemoteDispatcher(GrpcChannel channel) + { + _channel = channel; + _invoker = _channel.CreateCallInvoker(); + } + + public ValueTask Send(TRequest request, CancellationToken cancellationToken = default) + where TRequest : IRequest + { + return Send(request, cancellationToken); + } + + + public async ValueTask Send(IRequest request, CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(request); + + // cache only if implements interface + var cacheRequest = request as ICacheResult; + if (_hybridCache is null || cacheRequest?.IsCacheable() != true) + return await SendCore(request, cancellationToken).ConfigureAwait(false); + + var cacheKey = cacheRequest.GetCacheKey(); + var cacheTag = cacheRequest.GetCacheTag(); + var cacheOptions = new HybridCacheEntryOptions + { + Expiration = cacheRequest.SlidingExpiration(), + }; + + return await _hybridCache + .GetOrCreateAsync( + key: cacheKey, + factory: async token => await SendCore(request, token).ConfigureAwait(false), + options: cacheOptions, + tags: string.IsNullOrEmpty(cacheTag) ? null : [cacheTag], + cancellationToken: cancellationToken) + .ConfigureAwait(false); + } + + private async ValueTask SendCore( + IRequest request, + CancellationToken cancellationToken = default) + { + // Single serialization - directly serialize the request + var type = request.GetType(); + var requestBytes = MessagePackSerializer.Serialize(type, request, Options, cancellationToken); + + var requestType = request.GetType(); + // Add type information to gRPC metadata + var metadata = new Metadata + { + { DispatcherMethod.TypeHeader, requestType.AssemblyQualifiedName ?? requestType.FullName! }, + }; + + // Call the single generic gRPC endpoint + var callOptions = new CallOptions(headers: metadata, cancellationToken: cancellationToken); + + var responseBytes = await _invoker + .AsyncUnaryCall( + method: DispatcherMethod.Execute, + host: null, + options: callOptions, + request: requestBytes) + .ConfigureAwait(false); + + // Single deserialization - directly to response type + return MessagePackSerializer.Deserialize(responseBytes, Options, cancellationToken); + } + + /// + /// Releases the resources used by the instance. + /// + public void Dispose() + { + _channel?.Dispose(); + GC.SuppressFinalize(this); + } +} + diff --git a/src/Arbiter.Dispatcher/MediatorDispatcher.cs b/src/Arbiter.Dispatcher.Client/Client/ServerDispatcher.cs similarity index 64% rename from src/Arbiter.Dispatcher/MediatorDispatcher.cs rename to src/Arbiter.Dispatcher.Client/Client/ServerDispatcher.cs index 1245d0c..a81d506 100644 --- a/src/Arbiter.Dispatcher/MediatorDispatcher.cs +++ b/src/Arbiter.Dispatcher.Client/Client/ServerDispatcher.cs @@ -1,39 +1,41 @@ using System.Diagnostics.CodeAnalysis; -namespace Arbiter.CommandQuery.Dispatcher; +using Arbiter.Mediation; + +namespace Arbiter.Dispatcher.Client; /// /// A dispatcher that uses to send requests. Use for Blazor Interactive Server rendering mode. /// -public class MediatorDispatcher : IDispatcher +public class ServerDispatcher : IDispatcher { private readonly IMediator _mediator; /// - /// Initializes a new instance of the class. + /// Initializes a new instance of the class. /// /// The to send request to. /// When is null - public MediatorDispatcher(IMediator mediator) + public ServerDispatcher(IMediator mediator) { _mediator = mediator ?? throw new ArgumentNullException(nameof(mediator)); } /// - public async ValueTask Send( + public ValueTask Send( TRequest request, CancellationToken cancellationToken = default) where TRequest : IRequest { - return await _mediator.Send(request, cancellationToken).ConfigureAwait(false); + return _mediator.Send(request, cancellationToken); } /// [RequiresUnreferencedCode("This overload relies on reflection over types that may be removed when trimming.")] - public async ValueTask Send( + public ValueTask Send( IRequest request, CancellationToken cancellationToken = default) { - return await _mediator.Send(request, cancellationToken).ConfigureAwait(false); + return _mediator.Send(request, cancellationToken); } } diff --git a/src/Arbiter.Dispatcher/DispatcherDataService.cs b/src/Arbiter.Dispatcher.Client/DispatcherDataService.cs similarity index 99% rename from src/Arbiter.Dispatcher/DispatcherDataService.cs rename to src/Arbiter.Dispatcher.Client/DispatcherDataService.cs index c1da874..fef3a00 100644 --- a/src/Arbiter.Dispatcher/DispatcherDataService.cs +++ b/src/Arbiter.Dispatcher.Client/DispatcherDataService.cs @@ -3,8 +3,9 @@ using Arbiter.CommandQuery.Commands; using Arbiter.CommandQuery.Definitions; using Arbiter.CommandQuery.Queries; +using Arbiter.Dispatcher.Client; -namespace Arbiter.CommandQuery.Dispatcher; +namespace Arbiter.Dispatcher; /// /// Provides a data service for dispatching common data requests to a data store. diff --git a/src/Arbiter.Dispatcher.Client/DispatcherServiceExtensions.cs b/src/Arbiter.Dispatcher.Client/DispatcherServiceExtensions.cs new file mode 100644 index 0000000..68e1cdb --- /dev/null +++ b/src/Arbiter.Dispatcher.Client/DispatcherServiceExtensions.cs @@ -0,0 +1,105 @@ +using System.Threading.Channels; + +using Arbiter.Dispatcher.Client; +using Arbiter.Dispatcher.State; + +using Grpc.Net.Client; +using Grpc.Net.Client.Web; + +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.DependencyInjection.Extensions; + +namespace Arbiter.Dispatcher; + +public static class DispatcherServiceExtensions +{ + public static IServiceCollection AddRemoteDispatcher(this IServiceCollection services, string serviceAddress) + { + ArgumentNullException.ThrowIfNull(services); + ArgumentNullException.ThrowIfNull(serviceAddress); + + return services.AddRemoteDispatcher(_ => + { + var httpHandler = new GrpcWebHandler(GrpcWebMode.GrpcWeb, new HttpClientHandler()); + var channelOptions = new GrpcChannelOptions() + { + HttpHandler = httpHandler, + MaxReceiveMessageSize = 10 * 1024 * 1024, // 10 MB + MaxSendMessageSize = 10 * 1024 * 1024, // 10 MB + }; + + return GrpcChannel.ForAddress(serviceAddress, channelOptions); + }); + } + + public static IServiceCollection AddRemoteDispatcher(this IServiceCollection services, Func addressFactory) + { + ArgumentNullException.ThrowIfNull(services); + ArgumentNullException.ThrowIfNull(addressFactory); + + return services.AddRemoteDispatcher(sp => + { + var serviceAddress = addressFactory(sp); + + var httpHandler = new GrpcWebHandler(GrpcWebMode.GrpcWeb, new HttpClientHandler()); + var channelOptions = new GrpcChannelOptions() + { + HttpHandler = httpHandler, + MaxReceiveMessageSize = 10 * 1024 * 1024, // 10 MB + MaxSendMessageSize = 10 * 1024 * 1024, // 10 MB + }; + + return GrpcChannel.ForAddress(serviceAddress, channelOptions); + }); + } + + public static IServiceCollection AddRemoteDispatcher(this IServiceCollection services, Func channelFactory) + { + ArgumentNullException.ThrowIfNull(services); + ArgumentNullException.ThrowIfNull(channelFactory); + + // Remote Dispatcher Registration + services.TryAddTransient(sp => + { + var channel = channelFactory(sp); + return new RemoteDispatcher(channel); + }); + + // IDispatcher Registration + services.TryAddTransient(sp => sp.GetRequiredService()); + + // Dispatcher Data Service Registration + services.TryAddTransient(); + + // Model State Open Generic Registrations + services.TryAdd(ServiceDescriptor.Scoped(typeof(ModelStateManager<>), typeof(ModelStateManager<>))); + services.TryAdd(ServiceDescriptor.Scoped(typeof(ModelStateLoader<,>), typeof(ModelStateLoader<,>))); + services.TryAdd(ServiceDescriptor.Scoped(typeof(ModelStateEditor<,,>), typeof(ModelStateEditor<,,>))); + + return services; + } + + + /// + /// Adds the server dispatcher to the service collection. + /// + /// The to add services to. + /// The so that additional calls can be chained. + /// + /// The server dispatcher uses the mediator pattern to dispatch commands and queries locally. + /// + public static IServiceCollection AddServerDispatcher(this IServiceCollection services) + { + ArgumentNullException.ThrowIfNull(services); + + services.TryAddTransient(); + services.TryAddTransient(); + + // Model State Open Generic Registrations + services.Add(ServiceDescriptor.Scoped(typeof(ModelStateManager<>), typeof(ModelStateManager<>))); + services.Add(ServiceDescriptor.Scoped(typeof(ModelStateLoader<,>), typeof(ModelStateLoader<,>))); + services.Add(ServiceDescriptor.Scoped(typeof(ModelStateEditor<,,>), typeof(ModelStateEditor<,,>))); + + return services; + } +} diff --git a/src/Arbiter.Dispatcher.Client/IDispatcher.cs b/src/Arbiter.Dispatcher.Client/IDispatcher.cs new file mode 100644 index 0000000..4b047e1 --- /dev/null +++ b/src/Arbiter.Dispatcher.Client/IDispatcher.cs @@ -0,0 +1,41 @@ +using System.Diagnostics.CodeAnalysis; + +using Arbiter.Mediation; + +namespace Arbiter.Dispatcher.Client; + +/// +/// An to represent a dispatcher for sending request messages. +/// +/// +/// Dispatcher is an abstraction over the pattern, allowing for sending of requests over +/// HTTP for remote scenarios and directly to for server side scenarios. Use this abstraction +/// when using the Blazor Interactive Auto rendering mode. +/// +public interface IDispatcher +{ + /// + /// Sends a request to the message dispatcher. + /// + /// The type of request being sent + /// The type of response from the dispatcher + /// The request being sent + /// Cancellation token + /// Awaitable task returning the + ValueTask Send( + TRequest request, + CancellationToken cancellationToken = default) + where TRequest : IRequest; + + /// + /// Sends a request to the message dispatcher. + /// + /// The type of response from the dispatcher + /// The request being sent + /// Cancellation token + /// Awaitable task returning the + [RequiresUnreferencedCode("This overload relies on reflection over types that may be removed when trimming.")] + ValueTask Send( + IRequest request, + CancellationToken cancellationToken = default); +} diff --git a/src/Arbiter.Dispatcher/IDispatcherDataService.cs b/src/Arbiter.Dispatcher.Client/IDispatcherDataService.cs similarity index 99% rename from src/Arbiter.Dispatcher/IDispatcherDataService.cs rename to src/Arbiter.Dispatcher.Client/IDispatcherDataService.cs index 85bc2b2..49341f3 100644 --- a/src/Arbiter.Dispatcher/IDispatcherDataService.cs +++ b/src/Arbiter.Dispatcher.Client/IDispatcherDataService.cs @@ -2,8 +2,9 @@ using Arbiter.CommandQuery.Definitions; using Arbiter.CommandQuery.Queries; +using Arbiter.Dispatcher.Client; -namespace Arbiter.CommandQuery.Dispatcher; +namespace Arbiter.Dispatcher; /// /// A data service for dispatching common data requests to a data store. diff --git a/src/Arbiter.Dispatcher.Client/RemoteDispatcher.cs b/src/Arbiter.Dispatcher.Client/RemoteDispatcher.cs new file mode 100644 index 0000000..0615e17 --- /dev/null +++ b/src/Arbiter.Dispatcher.Client/RemoteDispatcher.cs @@ -0,0 +1,110 @@ +using Arbiter.CommandQuery.Definitions; +using Arbiter.Dispatcher.Server; +using Arbiter.Mediation; + +using Grpc.Core; +using Grpc.Net.Client; +using Grpc.Net.Client.Web; + +using MessagePack; + +using Microsoft.Extensions.Caching.Hybrid; + +namespace Arbiter.Dispatcher.Client; + +public class RemoteDispatcher : IDispatcher, IDisposable +{ + private readonly GrpcChannel _channel; + private readonly CallInvoker _invoker; + private readonly HybridCache? _hybridCache; + + private static readonly MessagePackSerializerOptions Options = MessagePackSerializerOptions.Standard + .WithResolver(MessagePack.Resolvers.ContractlessStandardResolver.Instance) + .WithCompression(MessagePackCompression.Lz4BlockArray); + + public RemoteDispatcher(string serverAddress) + { + var httpHandler = new GrpcWebHandler(GrpcWebMode.GrpcWeb, new HttpClientHandler()); + + _channel = GrpcChannel.ForAddress(serverAddress, new GrpcChannelOptions + { + HttpHandler = httpHandler, + MaxReceiveMessageSize = 10 * 1024 * 1024, + MaxSendMessageSize = 10 * 1024 * 1024, + }); + + _invoker = _channel.CreateCallInvoker(); + } + + public ValueTask Send(TRequest request, CancellationToken cancellationToken = default) + where TRequest : IRequest + { + return Send(request, cancellationToken); + } + + + public async ValueTask Send(IRequest request, CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(request); + + // cache only if implements interface + var cacheRequest = request as ICacheResult; + if (_hybridCache is null || cacheRequest?.IsCacheable() != true) + return await SendCore(request, cancellationToken).ConfigureAwait(false); + + var cacheKey = cacheRequest.GetCacheKey(); + var cacheTag = cacheRequest.GetCacheTag(); + var cacheOptions = new HybridCacheEntryOptions + { + Expiration = cacheRequest.SlidingExpiration(), + }; + + return await _hybridCache + .GetOrCreateAsync( + key: cacheKey, + factory: async token => await SendCore(request, token).ConfigureAwait(false), + options: cacheOptions, + tags: string.IsNullOrEmpty(cacheTag) ? null : [cacheTag], + cancellationToken: cancellationToken) + .ConfigureAwait(false); + } + + private async ValueTask SendCore( + IRequest request, + CancellationToken cancellationToken = default) + { + // Single serialization - directly serialize the request + var requestBytes = MessagePackSerializer.Serialize(request, Options, cancellationToken); + + var requestType = request.GetType(); + // Add type information to gRPC metadata + var metadata = new Metadata + { + { DispatcherMethod.TypeHeader, requestType.AssemblyQualifiedName ?? requestType.FullName! }, + }; + + // Call the single generic gRPC endpoint + var callOptions = new CallOptions(headers: metadata, cancellationToken: cancellationToken); + + var responseBytes = await _invoker + .AsyncUnaryCall( + method: DispatcherMethod.Execute, + host: null, + options: callOptions, + request: requestBytes) + .ConfigureAwait(false); + + // Single deserialization - directly to response type + return MessagePackSerializer.Deserialize(responseBytes, Options, cancellationToken); + } + + /// + /// Releases the resources used by the instance. + /// + public void Dispose() + { + _channel?.Dispose(); + GC.SuppressFinalize(this); + } +} + diff --git a/src/Arbiter.Dispatcher.Client/ServerDispatcher.cs b/src/Arbiter.Dispatcher.Client/ServerDispatcher.cs new file mode 100644 index 0000000..a81d506 --- /dev/null +++ b/src/Arbiter.Dispatcher.Client/ServerDispatcher.cs @@ -0,0 +1,41 @@ +using System.Diagnostics.CodeAnalysis; + +using Arbiter.Mediation; + +namespace Arbiter.Dispatcher.Client; + +/// +/// A dispatcher that uses to send requests. Use for Blazor Interactive Server rendering mode. +/// +public class ServerDispatcher : IDispatcher +{ + private readonly IMediator _mediator; + + /// + /// Initializes a new instance of the class. + /// + /// The to send request to. + /// When is null + public ServerDispatcher(IMediator mediator) + { + _mediator = mediator ?? throw new ArgumentNullException(nameof(mediator)); + } + + /// + public ValueTask Send( + TRequest request, + CancellationToken cancellationToken = default) + where TRequest : IRequest + { + return _mediator.Send(request, cancellationToken); + } + + /// + [RequiresUnreferencedCode("This overload relies on reflection over types that may be removed when trimming.")] + public ValueTask Send( + IRequest request, + CancellationToken cancellationToken = default) + { + return _mediator.Send(request, cancellationToken); + } +} diff --git a/src/Arbiter.Dispatcher/State/ModelStateEditor.cs b/src/Arbiter.Dispatcher.Client/State/ModelStateEditor.cs similarity index 99% rename from src/Arbiter.Dispatcher/State/ModelStateEditor.cs rename to src/Arbiter.Dispatcher.Client/State/ModelStateEditor.cs index 02f30ce..0ef5310 100644 --- a/src/Arbiter.Dispatcher/State/ModelStateEditor.cs +++ b/src/Arbiter.Dispatcher.Client/State/ModelStateEditor.cs @@ -1,7 +1,7 @@ using Arbiter.CommandQuery.Definitions; -using Arbiter.CommandQuery.Dispatcher; +using Arbiter.Dispatcher.Client; -namespace Arbiter.CommandQuery.State; +namespace Arbiter.Dispatcher.State; /// /// Provides state management functionality for editable models that supports full CRUD operations. diff --git a/src/Arbiter.Dispatcher/State/ModelStateLoader.cs b/src/Arbiter.Dispatcher.Client/State/ModelStateLoader.cs similarity index 99% rename from src/Arbiter.Dispatcher/State/ModelStateLoader.cs rename to src/Arbiter.Dispatcher.Client/State/ModelStateLoader.cs index b3f259c..59f1afe 100644 --- a/src/Arbiter.Dispatcher/State/ModelStateLoader.cs +++ b/src/Arbiter.Dispatcher.Client/State/ModelStateLoader.cs @@ -1,7 +1,6 @@ using Arbiter.CommandQuery.Definitions; -using Arbiter.CommandQuery.Dispatcher; -namespace Arbiter.CommandQuery.State; +namespace Arbiter.Dispatcher.State; /// /// Provides state management functionality for read-only operations on models that implement . diff --git a/src/Arbiter.Dispatcher/State/ModelStateManager.cs b/src/Arbiter.Dispatcher.Client/State/ModelStateManager.cs similarity index 99% rename from src/Arbiter.Dispatcher/State/ModelStateManager.cs rename to src/Arbiter.Dispatcher.Client/State/ModelStateManager.cs index 104e3cd..bf92b1c 100644 --- a/src/Arbiter.Dispatcher/State/ModelStateManager.cs +++ b/src/Arbiter.Dispatcher.Client/State/ModelStateManager.cs @@ -1,4 +1,4 @@ -namespace Arbiter.CommandQuery.State; +namespace Arbiter.Dispatcher.State; /// /// Provides state management functionality for a model of type . diff --git a/src/Arbiter.Dispatcher.Server/Arbiter.Dispatcher.Server.csproj b/src/Arbiter.Dispatcher.Server/Arbiter.Dispatcher.Server.csproj new file mode 100644 index 0000000..90faf33 --- /dev/null +++ b/src/Arbiter.Dispatcher.Server/Arbiter.Dispatcher.Server.csproj @@ -0,0 +1,19 @@ + + + + net10.0 + enable + enable + + + + + + + + + + + + + diff --git a/src/Arbiter.Dispatcher.Server/DispatcherMethod.cs b/src/Arbiter.Dispatcher.Server/DispatcherMethod.cs new file mode 100644 index 0000000..3f69d90 --- /dev/null +++ b/src/Arbiter.Dispatcher.Server/DispatcherMethod.cs @@ -0,0 +1,24 @@ +using Grpc.Core; + +namespace Arbiter.Dispatcher.Server; + +public static class DispatcherMethod +{ + public const string ServiceName = "DispatcherService"; + public const string TypeHeader = "x-message-type"; + + // Single generic method that handles ALL types + public static readonly Method Execute = new( + type: MethodType.Unary, + serviceName: ServiceName, + name: nameof(Execute), + requestMarshaller: Marshallers.Create( + serializer: bytes => bytes, + deserializer: bytes => bytes + ), + responseMarshaller: Marshallers.Create( + serializer: bytes => bytes, + deserializer: bytes => bytes + ) + ); +} diff --git a/src/Arbiter.Dispatcher.Server/DispatcherService.cs b/src/Arbiter.Dispatcher.Server/DispatcherService.cs new file mode 100644 index 0000000..0907011 --- /dev/null +++ b/src/Arbiter.Dispatcher.Server/DispatcherService.cs @@ -0,0 +1,126 @@ +using Arbiter.CommandQuery.Definitions; +using Arbiter.Mediation; + +using Grpc.Core; + +using MessagePack; + +using Microsoft.Extensions.Logging; + +namespace Arbiter.Dispatcher.Server; + +/// +/// gRPC service that dispatches requests to the mediator for processing. +/// +/// +/// This service receives serialized requests via gRPC, deserializes them using MessagePack, +/// applies the current user principal if supported, and dispatches them to the mediator for processing. +/// The response is then serialized and returned to the caller. +/// +[BindServiceMethod(typeof(DispatcherService), nameof(BindService))] +public class DispatcherService +{ + /// + /// Binds the service methods to the gRPC server. + /// + /// The dispatcher service instance to bind. + /// A containing the bound service methods. + public static ServerServiceDefinition BindService(DispatcherService service) + { + return ServerServiceDefinition.CreateBuilder() + .AddMethod(DispatcherMethod.Execute, service.Execute) + .Build(); + } + + private readonly ILogger _logger; + private readonly IMediator _mediator; + private readonly MessagePackSerializerOptions _options; + + /// + /// Initializes a new instance of the class. + /// + /// The logger instance for diagnostic logging. + /// The mediator instance for processing requests. + /// The MessagePack serialization options. + public DispatcherService( + ILogger logger, + IMediator mediator, + MessagePackSerializerOptions options) + { + _logger = logger; + _mediator = mediator; + _options = options; + } + + /// + /// Executes a request by deserializing it, applying the current user principal if supported, + /// dispatching it to the mediator, and returning the serialized response. + /// + /// The serialized request bytes. + /// The server call context containing request metadata and cancellation token. + /// The serialized response bytes. + /// + /// Thrown when the x-message-type header is missing, the message type cannot be resolved, + /// or the request payload cannot be deserialized. + /// + /// + /// The method expects the message type name to be provided in the 'x-message-type' header. + /// If the request implements , the current user principal from + /// the HTTP context will be applied to the request before processing. + /// + public async Task Execute( + byte[] requestBytes, + ServerCallContext context) + { + try + { + // Get type from metadata header + var messageTypeName = context.RequestHeaders.GetValue(DispatcherMethod.TypeHeader); + if (string.IsNullOrEmpty(messageTypeName)) + { + _logger.LogWarning("Missing x-message-type header"); + throw new RpcException(new Status(StatusCode.InvalidArgument, + $"Required header '{DispatcherMethod.TypeHeader}' is missing. Please include the message type name in the request headers.")); + } + + // Resolve request message type + var requestType = Type.GetType(messageTypeName); + if (requestType == null) + { + _logger.LogWarning("Unknown message type: {MessageTypeName}", messageTypeName); + throw new RpcException(new Status(StatusCode.InvalidArgument, + $"Unable to resolve message type '{messageTypeName}'. Ensure the type is available in the current context and the assembly-qualified name is correct.")); + } + + // Single deserialization directly to the actual type + var request = MessagePackSerializer.Deserialize(requestType, requestBytes, _options, context.CancellationToken); + if (request == null) + { + _logger.LogWarning("Failed to deserialize request of type: {MessageTypeName}", messageTypeName); + throw new RpcException(new Status(StatusCode.InvalidArgument, + $"Failed to deserialize request payload to type '{messageTypeName}'. The message format may be invalid or incompatible.")); + } + + // Apply current user principal if supported + if (request is IRequestPrincipal requestPrincipal) + { + // get current user + var httpContext = context.GetHttpContext(); + var user = httpContext?.User; + + requestPrincipal.ApplyPrincipal(user); + } + + // Send to Mediator + var response = await _mediator.Send(request, context.CancellationToken).ConfigureAwait(false); + + // Single serialization of response + return MessagePackSerializer.Serialize(response, _options, context.CancellationToken); + } + catch (Exception ex) when (ex is not RpcException) + { + _logger.LogError(ex, "Error processing request"); + throw; + } + } +} diff --git a/src/Arbiter.Dispatcher.Server/DispatcherServiceExtensions.cs b/src/Arbiter.Dispatcher.Server/DispatcherServiceExtensions.cs new file mode 100644 index 0000000..f64342b --- /dev/null +++ b/src/Arbiter.Dispatcher.Server/DispatcherServiceExtensions.cs @@ -0,0 +1,34 @@ +using MessagePack; + +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Routing; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.DependencyInjection.Extensions; + +namespace Arbiter.Dispatcher.Server; + +public static class DispatcherServiceExtensions +{ + public static IServiceCollection AddDispatcherService(this IServiceCollection services) + { + services.AddGrpc(); + + services.TryAddSingleton(MessagePackSerializerOptions.Standard + .WithResolver(MessagePack.Resolvers.TypelessContractlessStandardResolver.Instance) + .WithCompression(MessagePackCompression.Lz4BlockArray)); + + return services; + } + + public static IApplicationBuilder UseDispatchService(this IApplicationBuilder app) + { + return app.UseGrpcWeb(new GrpcWebOptions { DefaultEnabled = true }); + } + + public static IEndpointConventionBuilder MapDispatchService(this IEndpointRouteBuilder endpoints) + { + return endpoints + .MapGrpcService() + .EnableGrpcWeb(); + } +} diff --git a/src/Arbiter.Dispatcher/DispatchRequest.cs b/src/Arbiter.Dispatcher/DispatchRequest.cs deleted file mode 100644 index c2079eb..0000000 --- a/src/Arbiter.Dispatcher/DispatchRequest.cs +++ /dev/null @@ -1,18 +0,0 @@ -using System.Text.Json.Serialization; - -using Arbiter.CommandQuery.Converters; - -namespace Arbiter.CommandQuery.Dispatcher; - -/// -/// A request to be dispatched. -/// -public class DispatchRequest -{ - /// - /// The request to be dispatched. - /// - [JsonPropertyName("request")] - [JsonConverter(typeof(PolymorphicConverter))] - public IRequest Request { get; set; } = null!; -} diff --git a/src/Arbiter.Dispatcher/DispatcherOptions.cs b/src/Arbiter.Dispatcher/DispatcherOptions.cs deleted file mode 100644 index 6bbd3f5..0000000 --- a/src/Arbiter.Dispatcher/DispatcherOptions.cs +++ /dev/null @@ -1,22 +0,0 @@ -namespace Arbiter.CommandQuery.Dispatcher; - -/// -/// Options for the dispatcher. -/// -public class DispatcherOptions -{ - /// - /// The prefix for the feature routes. - /// - public string FeaturePrefix { get; set; } = "/api"; - - /// - /// The prefix for the dispatcher routes. - /// - public string DispatcherPrefix { get; set; } = "/dispatcher"; - - /// - /// The route for the send method. - /// - public string SendRoute { get; set; } = "/send"; -} diff --git a/src/Arbiter.Dispatcher/RemoteDispatcher.cs b/src/Arbiter.Dispatcher/RemoteDispatcher.cs deleted file mode 100644 index fd1b95b..0000000 --- a/src/Arbiter.Dispatcher/RemoteDispatcher.cs +++ /dev/null @@ -1,157 +0,0 @@ -using System.Net.Http.Json; -using System.Text.Json; - -using Arbiter.CommandQuery.Definitions; -using Arbiter.CommandQuery.Extensions; -using Arbiter.CommandQuery.Models; - -using Microsoft.Extensions.Caching.Hybrid; -using Microsoft.Extensions.Options; - -namespace Arbiter.CommandQuery.Dispatcher; - -/// -/// A dispatcher that sends requests to a remote service over HTTP. Use for Blazor Interactive WebAssembly rendering mode. -/// -public class RemoteDispatcher : IDispatcher -{ - private readonly HttpClient _httpClient; - private readonly JsonSerializerOptions _serializerOptions; - private readonly DispatcherOptions _dispatcherOptions; - private readonly HybridCache? _hybridCache; - - /// - /// Initializes a new instance of the class. - /// - /// The HTTP client to send request over - /// The JSON options to use for serialization - /// The dispatcher options to use - /// The optional hybrid cache to use. - public RemoteDispatcher( - HttpClient httpClient, - JsonSerializerOptions serializerOptions, - IOptions dispatcherOptions, - HybridCache? hybridCache = null) - { - ArgumentNullException.ThrowIfNull(httpClient); - ArgumentNullException.ThrowIfNull(serializerOptions); - ArgumentNullException.ThrowIfNull(dispatcherOptions); - - _httpClient = httpClient; - _serializerOptions = serializerOptions; - _dispatcherOptions = dispatcherOptions.Value; - _hybridCache = hybridCache; - } - - /// - public ValueTask Send( - TRequest request, - CancellationToken cancellationToken = default) - where TRequest : IRequest - { - return Send(request, cancellationToken); - } - - /// - public async ValueTask Send( - IRequest request, - CancellationToken cancellationToken = default) - { - ArgumentNullException.ThrowIfNull(request); - - // cache only if implements interface - var cacheRequest = request as ICacheResult; - if (_hybridCache is null || cacheRequest?.IsCacheable() != true) - return await SendCore(request, cancellationToken).ConfigureAwait(false); - - var cacheKey = cacheRequest.GetCacheKey(); - var cacheTag = cacheRequest.GetCacheTag(); - var cacheOptions = new HybridCacheEntryOptions - { - Expiration = cacheRequest.SlidingExpiration(), - }; - - return await _hybridCache - .GetOrCreateAsync( - key: cacheKey, - factory: async token => await SendCore(request, token).ConfigureAwait(false), - options: cacheOptions, - tags: string.IsNullOrEmpty(cacheTag) ? null : [cacheTag], - cancellationToken: cancellationToken) - .ConfigureAwait(false); - } - - private async ValueTask SendCore(IRequest request, CancellationToken cancellationToken) - { - var requestUri = _dispatcherOptions.FeaturePrefix - .Combine(_dispatcherOptions.DispatcherPrefix) - .Combine(_dispatcherOptions.SendRoute); - - var dispatchRequest = new DispatchRequest { Request = request }; - - var responseMessage = await _httpClient - .PostAsJsonAsync( - requestUri: requestUri, - value: dispatchRequest, - options: _serializerOptions, - cancellationToken: cancellationToken) - .ConfigureAwait(false); - - await EnsureSuccessStatusCode(responseMessage, cancellationToken).ConfigureAwait(false); - - using var stream = await responseMessage.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false); - - // no content, return null - if (stream.Length == 0) - return default; - - var response = await JsonSerializer.DeserializeAsync(stream, _serializerOptions, cancellationToken).ConfigureAwait(false); - - // expire cache - if (_hybridCache is null || request is not ICacheExpire cacheRequest) - return response; - - var cacheTag = cacheRequest.GetCacheTag(); - if (!string.IsNullOrEmpty(cacheTag)) - await _hybridCache.RemoveByTagAsync(cacheTag, cancellationToken).ConfigureAwait(false); - - return response; - } - - private async ValueTask EnsureSuccessStatusCode(HttpResponseMessage responseMessage, CancellationToken cancellationToken = default) - { - if (responseMessage.IsSuccessStatusCode) - return; - - var message = $"Response status code does not indicate success: {responseMessage.StatusCode} ({responseMessage.ReasonPhrase})."; - - var mediaType = responseMessage.Content.Headers.ContentType?.MediaType; - if (!string.Equals(mediaType, "application/problem+json", StringComparison.OrdinalIgnoreCase)) - throw new HttpRequestException(message, inner: null, responseMessage.StatusCode); - - var problemDetails = await responseMessage.Content - .ReadFromJsonAsync( - options: _serializerOptions, - cancellationToken: cancellationToken) - .ConfigureAwait(false); - - if (problemDetails == null) - throw new HttpRequestException(message, inner: null, responseMessage.StatusCode); - - var status = (System.Net.HttpStatusCode?)problemDetails.Status; - status ??= responseMessage.StatusCode; - - var problemMessage = problemDetails.Title - ?? responseMessage.ReasonPhrase - ?? "Internal Server Error"; - - if (!string.IsNullOrEmpty(problemDetails.Detail)) - problemMessage = $"{problemMessage} {problemDetails.Detail}"; - - throw new HttpRequestException( - message: problemMessage, - inner: null, - statusCode: status); - } - -} diff --git a/src/Directory.Build.props b/src/Directory.Build.props index 14178d7..ff3b73b 100644 --- a/src/Directory.Build.props +++ b/src/Directory.Build.props @@ -14,11 +14,11 @@ true - + true diff --git a/test/Arbiter.CommandQuery.EntityFramework.Tests/Arbiter.CommandQuery.EntityFramework.Tests.csproj b/test/Arbiter.CommandQuery.EntityFramework.Tests/Arbiter.CommandQuery.EntityFramework.Tests.csproj index 5039c36..2a866ec 100644 --- a/test/Arbiter.CommandQuery.EntityFramework.Tests/Arbiter.CommandQuery.EntityFramework.Tests.csproj +++ b/test/Arbiter.CommandQuery.EntityFramework.Tests/Arbiter.CommandQuery.EntityFramework.Tests.csproj @@ -34,6 +34,7 @@ + diff --git a/test/Arbiter.CommandQuery.EntityFramework.Tests/Dispatcher/DispatcherDataServiceTests.cs b/test/Arbiter.CommandQuery.EntityFramework.Tests/Dispatcher/DispatcherDataServiceTests.cs index 2927ef0..dde46b6 100644 --- a/test/Arbiter.CommandQuery.EntityFramework.Tests/Dispatcher/DispatcherDataServiceTests.cs +++ b/test/Arbiter.CommandQuery.EntityFramework.Tests/Dispatcher/DispatcherDataServiceTests.cs @@ -1,5 +1,7 @@ -using Arbiter.CommandQuery.Dispatcher; + + using Arbiter.CommandQuery.EntityFramework.Tests.Domain.Models; +using Arbiter.Dispatcher; using Microsoft.Extensions.DependencyInjection; diff --git a/test/Arbiter.CommandQuery.EntityFramework.Tests/TestApplication.cs b/test/Arbiter.CommandQuery.EntityFramework.Tests/TestApplication.cs index c8abf85..7130571 100644 --- a/test/Arbiter.CommandQuery.EntityFramework.Tests/TestApplication.cs +++ b/test/Arbiter.CommandQuery.EntityFramework.Tests/TestApplication.cs @@ -1,3 +1,5 @@ +using Arbiter.Dispatcher; + using Microsoft.Data.SqlClient; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; diff --git a/test/Arbiter.CommandQuery.Tests/Arbiter.CommandQuery.Tests.csproj b/test/Arbiter.CommandQuery.Tests/Arbiter.CommandQuery.Tests.csproj index d678dc3..87c5703 100644 --- a/test/Arbiter.CommandQuery.Tests/Arbiter.CommandQuery.Tests.csproj +++ b/test/Arbiter.CommandQuery.Tests/Arbiter.CommandQuery.Tests.csproj @@ -24,6 +24,7 @@ + diff --git a/test/Arbiter.CommandQuery.Tests/MockDataService.cs b/test/Arbiter.CommandQuery.Tests/MockDataService.cs index b345938..fbbb338 100644 --- a/test/Arbiter.CommandQuery.Tests/MockDataService.cs +++ b/test/Arbiter.CommandQuery.Tests/MockDataService.cs @@ -1,4 +1,4 @@ -using Arbiter.CommandQuery.Dispatcher; +using Arbiter.Dispatcher; using Rocks; diff --git a/test/Arbiter.CommandQuery.Tests/State/ModelStateEditorTests.cs b/test/Arbiter.CommandQuery.Tests/State/ModelStateEditorTests.cs index 5418565..a65a9e4 100644 --- a/test/Arbiter.CommandQuery.Tests/State/ModelStateEditorTests.cs +++ b/test/Arbiter.CommandQuery.Tests/State/ModelStateEditorTests.cs @@ -1,5 +1,5 @@ using Arbiter.CommandQuery.Definitions; -using Arbiter.CommandQuery.State; +using Arbiter.Dispatcher.State; using Rocks; diff --git a/test/Arbiter.CommandQuery.Tests/State/ModelStateLoaderTests.cs b/test/Arbiter.CommandQuery.Tests/State/ModelStateLoaderTests.cs index b1c9f62..38258ad 100644 --- a/test/Arbiter.CommandQuery.Tests/State/ModelStateLoaderTests.cs +++ b/test/Arbiter.CommandQuery.Tests/State/ModelStateLoaderTests.cs @@ -1,5 +1,5 @@ using Arbiter.CommandQuery.Definitions; -using Arbiter.CommandQuery.State; +using Arbiter.Dispatcher.State; namespace Arbiter.CommandQuery.Tests.State; diff --git a/test/Arbiter.CommandQuery.Tests/State/ModelStateManagerTests.cs b/test/Arbiter.CommandQuery.Tests/State/ModelStateManagerTests.cs index 1108d88..ee03b2c 100644 --- a/test/Arbiter.CommandQuery.Tests/State/ModelStateManagerTests.cs +++ b/test/Arbiter.CommandQuery.Tests/State/ModelStateManagerTests.cs @@ -1,4 +1,4 @@ -using Arbiter.CommandQuery.State; +using Arbiter.Dispatcher.State; namespace Arbiter.CommandQuery.Tests.State; diff --git a/test/Arbiter.Dispatcher.Client.Tests/Arbiter.Dispatcher.Client.Tests.csproj b/test/Arbiter.Dispatcher.Client.Tests/Arbiter.Dispatcher.Client.Tests.csproj new file mode 100644 index 0000000..2a8803f --- /dev/null +++ b/test/Arbiter.Dispatcher.Client.Tests/Arbiter.Dispatcher.Client.Tests.csproj @@ -0,0 +1,30 @@ + + + + net10.0 + Exe + enable + enable + false + + + + + + + + + + + + + + + + + + + + + + diff --git a/test/Arbiter.Dispatcher.Client.Tests/Fakes/LocationCreateModelFaker.cs b/test/Arbiter.Dispatcher.Client.Tests/Fakes/LocationCreateModelFaker.cs new file mode 100644 index 0000000..7e5f5f5 --- /dev/null +++ b/test/Arbiter.Dispatcher.Client.Tests/Fakes/LocationCreateModelFaker.cs @@ -0,0 +1,25 @@ +using Arbiter.Dispatcher.Client.Tests.Models; + +namespace Arbiter.Dispatcher.Client.Tests.Fakes; + +public class LocationCreateModelFaker : Faker +{ + public LocationCreateModelFaker() + { + RuleFor(p => p.Id, f => f.IndexFaker + 1); + RuleFor(p => p.Name, f => f.Company.CompanyName()); + RuleFor(p => p.Description, f => f.Lorem.Sentence()); + RuleFor(p => p.AddressLine1, f => f.Address.StreetAddress()); + RuleFor(p => p.AddressLine2, f => f.Address.SecondaryAddress()); + RuleFor(p => p.AddressLine3, f => f.Address.StreetAddress()); + RuleFor(p => p.City, f => f.Address.City()); + RuleFor(p => p.StateProvince, f => f.Address.State()); + RuleFor(p => p.PostalCode, f => f.Address.ZipCode()); + RuleFor(p => p.Latitude, f => f.Address.Latitude()); + RuleFor(p => p.Longitude, f => f.Address.Longitude()); + RuleFor(p => p.Created, f => f.Date.Past()); + RuleFor(p => p.CreatedBy, f => f.Internet.UserName()); + RuleFor(p => p.Updated, f => f.Date.Past()); + RuleFor(p => p.UpdatedBy, f => f.Internet.UserName()); + } +} diff --git a/test/Arbiter.Dispatcher.Client.Tests/Fakes/LocationFaker.cs b/test/Arbiter.Dispatcher.Client.Tests/Fakes/LocationFaker.cs new file mode 100644 index 0000000..92a1651 --- /dev/null +++ b/test/Arbiter.Dispatcher.Client.Tests/Fakes/LocationFaker.cs @@ -0,0 +1,25 @@ +using Arbiter.Dispatcher.Client.Tests.Models; + +namespace Arbiter.Dispatcher.Client.Tests.Fakes; + +public class LocationFaker : Faker +{ + public LocationFaker() + { + RuleFor(p => p.Id, f => f.IndexFaker + 1); + RuleFor(p => p.Name, f => f.Company.CompanyName()); + RuleFor(p => p.Description, f => f.Lorem.Sentence()); + RuleFor(p => p.AddressLine1, f => f.Address.StreetAddress()); + RuleFor(p => p.AddressLine2, f => f.Address.SecondaryAddress()); + RuleFor(p => p.AddressLine3, f => f.Address.StreetAddress()); + RuleFor(p => p.City, f => f.Address.City()); + RuleFor(p => p.StateProvince, f => f.Address.State()); + RuleFor(p => p.PostalCode, f => f.Address.ZipCode()); + RuleFor(p => p.Latitude, f => f.Address.Latitude()); + RuleFor(p => p.Longitude, f => f.Address.Longitude()); + RuleFor(p => p.Created, f => f.Date.Past()); + RuleFor(p => p.CreatedBy, f => f.Internet.UserName()); + RuleFor(p => p.Updated, f => f.Date.Past()); + RuleFor(p => p.UpdatedBy, f => f.Internet.UserName()); + } +} diff --git a/test/Arbiter.Dispatcher.Client.Tests/Fakes/LocationReadModelFaker.cs b/test/Arbiter.Dispatcher.Client.Tests/Fakes/LocationReadModelFaker.cs new file mode 100644 index 0000000..88701fb --- /dev/null +++ b/test/Arbiter.Dispatcher.Client.Tests/Fakes/LocationReadModelFaker.cs @@ -0,0 +1,25 @@ +using Arbiter.Dispatcher.Client.Tests.Models; + +namespace Arbiter.Dispatcher.Client.Tests.Fakes; + +public class LocationReadModelFaker : Faker +{ + public LocationReadModelFaker() + { + RuleFor(p => p.Id, f => f.IndexFaker + 1); + RuleFor(p => p.Name, f => f.Company.CompanyName()); + RuleFor(p => p.Description, f => f.Lorem.Sentence()); + RuleFor(p => p.AddressLine1, f => f.Address.StreetAddress()); + RuleFor(p => p.AddressLine2, f => f.Address.SecondaryAddress()); + RuleFor(p => p.AddressLine3, f => f.Address.StreetAddress()); + RuleFor(p => p.City, f => f.Address.City()); + RuleFor(p => p.StateProvince, f => f.Address.State()); + RuleFor(p => p.PostalCode, f => f.Address.ZipCode()); + RuleFor(p => p.Latitude, f => f.Address.Latitude()); + RuleFor(p => p.Longitude, f => f.Address.Longitude()); + RuleFor(p => p.Created, f => f.Date.Past()); + RuleFor(p => p.CreatedBy, f => f.Internet.UserName()); + RuleFor(p => p.Updated, f => f.Date.Past()); + RuleFor(p => p.UpdatedBy, f => f.Internet.UserName()); + } +} diff --git a/test/Arbiter.Dispatcher.Client.Tests/Fakes/LocationUpdateModelFaker.cs b/test/Arbiter.Dispatcher.Client.Tests/Fakes/LocationUpdateModelFaker.cs new file mode 100644 index 0000000..1a00caf --- /dev/null +++ b/test/Arbiter.Dispatcher.Client.Tests/Fakes/LocationUpdateModelFaker.cs @@ -0,0 +1,22 @@ +using Arbiter.Dispatcher.Client.Tests.Models; + +namespace Arbiter.Dispatcher.Client.Tests.Fakes; + +public class LocationUpdateModelFaker : Faker +{ + public LocationUpdateModelFaker() + { + RuleFor(p => p.Name, f => f.Company.CompanyName()); + RuleFor(p => p.Description, f => f.Lorem.Sentence()); + RuleFor(p => p.AddressLine1, f => f.Address.StreetAddress()); + RuleFor(p => p.AddressLine2, f => f.Address.SecondaryAddress()); + RuleFor(p => p.AddressLine3, f => f.Address.StreetAddress()); + RuleFor(p => p.City, f => f.Address.City()); + RuleFor(p => p.StateProvince, f => f.Address.State()); + RuleFor(p => p.PostalCode, f => f.Address.ZipCode()); + RuleFor(p => p.Latitude, f => f.Address.Latitude()); + RuleFor(p => p.Longitude, f => f.Address.Longitude()); + RuleFor(p => p.Updated, f => f.Date.Past()); + RuleFor(p => p.UpdatedBy, f => f.Internet.UserName()); + } +} diff --git a/test/Arbiter.Dispatcher.Client.Tests/MockPrincipal.cs b/test/Arbiter.Dispatcher.Client.Tests/MockPrincipal.cs new file mode 100644 index 0000000..05b60a2 --- /dev/null +++ b/test/Arbiter.Dispatcher.Client.Tests/MockPrincipal.cs @@ -0,0 +1,23 @@ +using System.Security.Claims; + +namespace Arbiter.Dispatcher.Client.Tests; + +public static class MockPrincipal +{ + static MockPrincipal() + { + Default = CreatePrincipal("william.adama@battlestar.com", "William Adama"); + } + + public static ClaimsPrincipal Default { get; } + + + public static ClaimsPrincipal CreatePrincipal(string email, string name) + { + var claimsIdentity = new ClaimsIdentity("Identity.Application", ClaimTypes.Name, ClaimTypes.Role); + claimsIdentity.AddClaim(new Claim(ClaimTypes.Name, name)); + claimsIdentity.AddClaim(new Claim(ClaimTypes.Email, email)); + + return new ClaimsPrincipal(claimsIdentity); + } +} diff --git a/test/Arbiter.Dispatcher.Client.Tests/Models/Address.cs b/test/Arbiter.Dispatcher.Client.Tests/Models/Address.cs new file mode 100644 index 0000000..337e3b8 --- /dev/null +++ b/test/Arbiter.Dispatcher.Client.Tests/Models/Address.cs @@ -0,0 +1,18 @@ +namespace Arbiter.Dispatcher.Client.Tests.Models; + +public class Address +{ + public int Id { get; set; } + public string Street { get; set; } = string.Empty; + public string City { get; set; } = string.Empty; + public string State { get; set; } = string.Empty; + public string ZipCode { get; set; } = string.Empty; + public string Country { get; set; } = string.Empty; + public bool IsPrimary { get; set; } + + // Foreign key + public int PersonId { get; set; } + + // Navigation property + public Person Person { get; set; } = null!; +} diff --git a/test/Arbiter.Dispatcher.Client.Tests/Models/Company.cs b/test/Arbiter.Dispatcher.Client.Tests/Models/Company.cs new file mode 100644 index 0000000..f4e2aba --- /dev/null +++ b/test/Arbiter.Dispatcher.Client.Tests/Models/Company.cs @@ -0,0 +1,14 @@ +namespace Arbiter.Dispatcher.Client.Tests.Models; + +public class Company +{ + public int Id { get; set; } + public string Name { get; set; } = string.Empty; + public string Industry { get; set; } = string.Empty; + public DateTime FoundedDate { get; set; } + public int EmployeeCount { get; set; } + public string Website { get; set; } = string.Empty; + + // Navigation properties + public ICollection Departments { get; set; } = new List(); +} diff --git a/test/Arbiter.Dispatcher.Client.Tests/Models/Department.cs b/test/Arbiter.Dispatcher.Client.Tests/Models/Department.cs new file mode 100644 index 0000000..6ec2c7c --- /dev/null +++ b/test/Arbiter.Dispatcher.Client.Tests/Models/Department.cs @@ -0,0 +1,17 @@ +namespace Arbiter.Dispatcher.Client.Tests.Models; + +public class Department +{ + public int Id { get; set; } + public string Name { get; set; } = string.Empty; + public string Description { get; set; } = string.Empty; + public DateTime CreatedDate { get; set; } + public decimal Budget { get; set; } + + // Foreign key + public int? CompanyId { get; set; } + + // Navigation properties + public Company? Company { get; set; } + public ICollection Employees { get; set; } = new List(); +} diff --git a/test/Arbiter.Dispatcher.Client.Tests/Models/Fruit.cs b/test/Arbiter.Dispatcher.Client.Tests/Models/Fruit.cs new file mode 100644 index 0000000..3f53252 --- /dev/null +++ b/test/Arbiter.Dispatcher.Client.Tests/Models/Fruit.cs @@ -0,0 +1,32 @@ +namespace Arbiter.Dispatcher.Client.Tests.Models; + +public class Fruit +{ + public Guid Id { get; set; } + + public string Name { get; set; } = null!; + + public int Rank { get; set; } + + public override string ToString() + { + return $"{nameof(Name)}: {Name}, {nameof(Rank)}: {Rank}"; + } + + public static List Data() + { + return new List + { + new Fruit{ Id = new Guid("3a1ec4ee-239c-41e5-b934-fbe4ce8113df"), Name = "Pear", Rank = 1 }, + new Fruit{ Id = new Guid("1109c1a7-65e3-4006-9611-c359f3d1f086"), Name = "Pineapple", Rank = 4 }, + new Fruit{ Id = new Guid("0d830fec-e023-438f-bcf6-1b3cba1245e6"), Name = "Peach", Rank = 2 }, + new Fruit{ Id = new Guid("bb7aa825-bdbb-4cda-9c12-131c13e02bea"), Name = "Apple", Rank = 3 }, + new Fruit{ Id = new Guid("5fef330b-6f30-461d-95e2-d526b4669e76"), Name = "Grape", Rank = 5 }, + new Fruit{ Id = new Guid("50611bc9-dac8-4552-b556-f252b1cff0d3"), Name = "Orange", Rank = 6}, + new Fruit{ Id = new Guid("5f467286-e321-44df-8a27-2c52a5ceed64"), Name = "Strawberry", Rank = 7 }, + new Fruit{ Id = new Guid("ef628e60-500a-4663-8b06-548b0f5857de"), Name = "Blueberry", Rank = 7 }, + new Fruit{ Id = new Guid("dc0a7dc7-40de-430d-a8fc-e17370c2a773"), Name = "Banana", Rank = 8 }, + new Fruit{ Id = new Guid("98620233-75c5-4213-966c-9c6bfcf9e8d5"), Name = "Raspberry", Rank = 7 } + }; + } +} diff --git a/test/Arbiter.Dispatcher.Client.Tests/Models/Location.cs b/test/Arbiter.Dispatcher.Client.Tests/Models/Location.cs new file mode 100644 index 0000000..9e067c5 --- /dev/null +++ b/test/Arbiter.Dispatcher.Client.Tests/Models/Location.cs @@ -0,0 +1,23 @@ +using Arbiter.CommandQuery.Definitions; + +namespace Arbiter.Dispatcher.Client.Tests.Models; + +public class Location : IHaveIdentifier, ITrackCreated, ITrackUpdated +{ + public int Id { get; set; } + public string Name { get; set; } = null!; + public string? Description { get; set; } + public string? AddressLine1 { get; set; } + public string? AddressLine2 { get; set; } + public string? AddressLine3 { get; set; } + public string? City { get; set; } + public string? StateProvince { get; set; } + public string? PostalCode { get; set; } + public double? Latitude { get; set; } + public double? Longitude { get; set; } + + public DateTimeOffset Created { get; set; } = DateTimeOffset.UtcNow; + public string? CreatedBy { get; set; } + public DateTimeOffset Updated { get; set; } = DateTimeOffset.UtcNow; + public string? UpdatedBy { get; set; } +} diff --git a/test/Arbiter.Dispatcher.Client.Tests/Models/LocationCreateModel.cs b/test/Arbiter.Dispatcher.Client.Tests/Models/LocationCreateModel.cs new file mode 100644 index 0000000..11a5fbc --- /dev/null +++ b/test/Arbiter.Dispatcher.Client.Tests/Models/LocationCreateModel.cs @@ -0,0 +1,19 @@ +using Arbiter.CommandQuery.Models; + +namespace Arbiter.Dispatcher.Client.Tests.Models; + +public class LocationCreateModel : EntityCreateModel +{ + public string Name { get; set; } = null!; + public string? Description { get; set; } + public string? AddressLine1 { get; set; } + public string? AddressLine2 { get; set; } + public string? AddressLine3 { get; set; } + public string? City { get; set; } + public string? StateProvince { get; set; } + public string? PostalCode { get; set; } + public double? Latitude { get; set; } + public double? Longitude { get; set; } +} + + diff --git a/test/Arbiter.Dispatcher.Client.Tests/Models/LocationReadModel.cs b/test/Arbiter.Dispatcher.Client.Tests/Models/LocationReadModel.cs new file mode 100644 index 0000000..1c1b1a6 --- /dev/null +++ b/test/Arbiter.Dispatcher.Client.Tests/Models/LocationReadModel.cs @@ -0,0 +1,17 @@ +using Arbiter.CommandQuery.Models; + +namespace Arbiter.Dispatcher.Client.Tests.Models; + +public class LocationReadModel : EntityReadModel +{ + public string Name { get; set; } = null!; + public string? Description { get; set; } + public string? AddressLine1 { get; set; } + public string? AddressLine2 { get; set; } + public string? AddressLine3 { get; set; } + public string? City { get; set; } + public string? StateProvince { get; set; } + public string? PostalCode { get; set; } + public double? Latitude { get; set; } + public double? Longitude { get; set; } +} diff --git a/test/Arbiter.Dispatcher.Client.Tests/Models/LocationUpdateModel.cs b/test/Arbiter.Dispatcher.Client.Tests/Models/LocationUpdateModel.cs new file mode 100644 index 0000000..c1b0270 --- /dev/null +++ b/test/Arbiter.Dispatcher.Client.Tests/Models/LocationUpdateModel.cs @@ -0,0 +1,17 @@ +using Arbiter.CommandQuery.Models; + +namespace Arbiter.Dispatcher.Client.Tests.Models; + +public class LocationUpdateModel : EntityUpdateModel +{ + public string Name { get; set; } = null!; + public string? Description { get; set; } + public string? AddressLine1 { get; set; } + public string? AddressLine2 { get; set; } + public string? AddressLine3 { get; set; } + public string? City { get; set; } + public string? StateProvince { get; set; } + public string? PostalCode { get; set; } + public double? Latitude { get; set; } + public double? Longitude { get; set; } +} diff --git a/test/Arbiter.Dispatcher.Client.Tests/Models/Person.cs b/test/Arbiter.Dispatcher.Client.Tests/Models/Person.cs new file mode 100644 index 0000000..ddfb89d --- /dev/null +++ b/test/Arbiter.Dispatcher.Client.Tests/Models/Person.cs @@ -0,0 +1,18 @@ +namespace Arbiter.Dispatcher.Client.Tests.Models; + + +public class Person +{ + public int Id { get; set; } + public string FirstName { get; set; } = string.Empty; + public string LastName { get; set; } = string.Empty; + public string Email { get; set; } = string.Empty; + public DateTime BirthDate { get; set; } + + // Foreign key for Department + public int? DepartmentId { get; set; } + + // Navigation properties + public Department? Department { get; set; } + public ICollection
Addresses { get; set; } = new List
(); +} diff --git a/test/Arbiter.Dispatcher.Client.Tests/Models/PersonModel.cs b/test/Arbiter.Dispatcher.Client.Tests/Models/PersonModel.cs new file mode 100644 index 0000000..94c500d --- /dev/null +++ b/test/Arbiter.Dispatcher.Client.Tests/Models/PersonModel.cs @@ -0,0 +1,16 @@ +namespace Arbiter.Dispatcher.Client.Tests.Models; + +public class PersonModel +{ + public int Id { get; set; } + public string FirstName { get; set; } = string.Empty; + public string LastName { get; set; } = string.Empty; + public string Email { get; set; } = string.Empty; + public string FullName { get; set; } = string.Empty; + public int Age { get; set; } + + // Navigation properties + public string? DepartmentName { get; set; } + public int AddressCount { get; set; } + +} diff --git a/test/Arbiter.Dispatcher.Client.Tests/Models/PersonRecord.cs b/test/Arbiter.Dispatcher.Client.Tests/Models/PersonRecord.cs new file mode 100644 index 0000000..6da1444 --- /dev/null +++ b/test/Arbiter.Dispatcher.Client.Tests/Models/PersonRecord.cs @@ -0,0 +1,12 @@ +namespace Arbiter.Dispatcher.Client.Tests.Models; + +public record PersonRecord( + int Id, + string FirstName, + string LastName, + string Email, + string FullName, + int Age, + string? DepartmentName, + int AddressCount +); diff --git a/test/Arbiter.Dispatcher.Client.Tests/SerializationTests.cs b/test/Arbiter.Dispatcher.Client.Tests/SerializationTests.cs new file mode 100644 index 0000000..b838039 --- /dev/null +++ b/test/Arbiter.Dispatcher.Client.Tests/SerializationTests.cs @@ -0,0 +1,32 @@ +using System.Threading; + +using Arbiter.CommandQuery.Commands; +using Arbiter.Dispatcher.Client.Tests.Fakes; +using Arbiter.Dispatcher.Client.Tests.Models; + +using MessagePack; + +namespace Arbiter.Dispatcher.Client.Tests; + +public class SerializationTests +{ + private static readonly MessagePackSerializerOptions Options = MessagePackSerializerOptions.Standard + .WithResolver(MessagePack.Resolvers.TypelessContractlessStandardResolver.Instance) + .WithCompression(MessagePackCompression.Lz4BlockArray); + + [Test] + public void EntityCreateCommandSerialize() + { + // Create Entity + var generator = new LocationCreateModelFaker(); + generator.UseSeed(1); + + var createModel = generator.Generate(); + + var createCommand = new EntityCreateCommand(MockPrincipal.Default, createModel); + + var requestBytes = MessagePackSerializer.Serialize(createCommand, Options); + requestBytes.Should().NotBeNullOrEmpty(); + } + +} From 851dfd037b2aa547bd39c547fe967b1ee57790ff Mon Sep 17 00:00:00 2001 From: Paul Welter Date: Sat, 3 Jan 2026 07:35:09 -0600 Subject: [PATCH 3/6] progress --- Directory.Packages.props | 13 ++- .../src/Tracker.Web/Program.cs | 4 +- .../Commands/CacheableQueryBase.cs | 1 + .../Commands/EntityCreateCommand.cs | 21 +++- .../Commands/EntityDeleteCommand.cs | 20 +++- .../Commands/EntityIdentifierBase.cs | 3 + .../Commands/EntityIdentifierQuery.cs | 31 ++++- .../Commands/EntityIdentifiersBase.cs | 3 + .../Commands/EntityIdentifiersQuery.cs | 20 +++- .../Commands/EntityKeyQuery.cs | 30 ++++- .../Commands/EntityModelBase.cs | 3 + .../Commands/EntityPagedQuery.cs | 25 +++- .../Commands/EntityPatchCommand.cs | 23 +++- .../Commands/EntityUpdateCommand.cs | 29 ++++- .../Commands/PrincipalCommandBase.cs | 1 - .../Models/CompleteModel.cs | 7 +- .../Models/EntityCreateModel.cs | 9 +- .../Models/EntityIdentifierModel.cs | 6 +- .../Models/EntityIdentifiersModel.cs | 6 +- .../Models/EntityReadModel.cs | 10 +- .../Models/EntityUpdateModel.cs | 8 +- .../Models/ProblemDetails.cs | 12 +- .../Models/ValidationResult.cs | 7 +- .../Queries/EntityFilter.cs | 10 +- .../Queries/EntityPagedResult.cs | 10 +- .../Queries/EntityQuery.cs | 11 +- .../Queries/EntitySort.cs | 7 +- .../Arbiter.Dispatcher.Client.csproj | 11 +- .../Client/RemoteDispatcher.cs | 64 +++++----- .../DispatcherServiceExtensions.cs | 39 ++++--- src/Arbiter.Dispatcher.Client/IDispatcher.cs | 41 ------- .../Protos/dispatcher.proto | 15 +++ .../RemoteDispatcher.cs | 110 ------------------ .../ServerDispatcher.cs | 41 ------- .../Arbiter.Dispatcher.Server.csproj | 6 +- .../DispatcherMethod.cs | 24 ---- .../DispatcherService.cs | 78 ++++++------- .../DispatcherServiceExtensions.cs | 4 + .../Protos/dispatcher.proto | 15 +++ 39 files changed, 426 insertions(+), 352 deletions(-) delete mode 100644 src/Arbiter.Dispatcher.Client/IDispatcher.cs create mode 100644 src/Arbiter.Dispatcher.Client/Protos/dispatcher.proto delete mode 100644 src/Arbiter.Dispatcher.Client/RemoteDispatcher.cs delete mode 100644 src/Arbiter.Dispatcher.Client/ServerDispatcher.cs delete mode 100644 src/Arbiter.Dispatcher.Server/DispatcherMethod.cs create mode 100644 src/Arbiter.Dispatcher.Server/Protos/dispatcher.proto diff --git a/Directory.Packages.props b/Directory.Packages.props index 288bcee..84ab154 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -20,15 +20,18 @@ - + + - - - + + + + + @@ -81,4 +84,4 @@ - \ No newline at end of file + diff --git a/samples/EntityFramework/src/Tracker.Web/Program.cs b/samples/EntityFramework/src/Tracker.Web/Program.cs index c85eb00..b99a77e 100644 --- a/samples/EntityFramework/src/Tracker.Web/Program.cs +++ b/samples/EntityFramework/src/Tracker.Web/Program.cs @@ -146,8 +146,8 @@ private static void ConfigureMiddleware(WebApplication app) app.UseRequestLogging(config => { config.IncludeRequestBody = true; - config.IgnorePath("/_framework/**"); - config.IgnorePath("/_content/**"); + //config.IgnorePath("/_framework/**"); + //config.IgnorePath("/_content/**"); }); app.UseHttpsRedirection(); diff --git a/src/Arbiter.CommandQuery/Commands/CacheableQueryBase.cs b/src/Arbiter.CommandQuery/Commands/CacheableQueryBase.cs index 5240e9b..6316db2 100644 --- a/src/Arbiter.CommandQuery/Commands/CacheableQueryBase.cs +++ b/src/Arbiter.CommandQuery/Commands/CacheableQueryBase.cs @@ -15,6 +15,7 @@ namespace Arbiter.CommandQuery.Commands; public abstract record CacheableQueryBase : PrincipalCommandBase, ICacheResult { private DateTimeOffset? _absoluteExpiration; + private TimeSpan? _slidingExpiration; /// diff --git a/src/Arbiter.CommandQuery/Commands/EntityCreateCommand.cs b/src/Arbiter.CommandQuery/Commands/EntityCreateCommand.cs index 3e52e4b..ed4e5c8 100644 --- a/src/Arbiter.CommandQuery/Commands/EntityCreateCommand.cs +++ b/src/Arbiter.CommandQuery/Commands/EntityCreateCommand.cs @@ -84,7 +84,7 @@ namespace Arbiter.CommandQuery.Commands; /// /// /// -[MessagePackObject(keyAsPropertyName: true)] +[MessagePackObject] public partial record EntityCreateCommand : EntityModelBase, ICacheExpire { @@ -116,9 +116,8 @@ public partial record EntityCreateCommand /// /// /// - [SerializationConstructor] public EntityCreateCommand( - [IgnoreMember] ClaimsPrincipal? principal, + ClaimsPrincipal? principal, [NotNull] TCreateModel model, string? filterName = null) : base(principal, model) @@ -126,6 +125,21 @@ public EntityCreateCommand( FilterName = filterName; } + /// + /// Initializes a new instance of the class. + /// + /// The create model containing the data for the new entity. This value cannot be . + /// Optional name of a specific filter pipeline to apply during the creation operation. This allows different creation strategies or validation rules to be applied based on context. + /// Thrown when is . + [JsonConstructor] + [SerializationConstructor] + public EntityCreateCommand( + [NotNull] TCreateModel model, + string? filterName = null) + : this(principal: null, model, filterName) + { } + + /// /// Gets the optional name of a specific filter pipeline to apply during the creation operation. /// @@ -162,6 +176,7 @@ public EntityCreateCommand( /// filterName: "bulk-import"); /// /// + [Key(1)] [JsonPropertyName("filterName")] public string? FilterName { get; } diff --git a/src/Arbiter.CommandQuery/Commands/EntityDeleteCommand.cs b/src/Arbiter.CommandQuery/Commands/EntityDeleteCommand.cs index 1edec13..beee402 100644 --- a/src/Arbiter.CommandQuery/Commands/EntityDeleteCommand.cs +++ b/src/Arbiter.CommandQuery/Commands/EntityDeleteCommand.cs @@ -5,6 +5,8 @@ using Arbiter.CommandQuery.Definitions; using Arbiter.Services; +using MessagePack; + namespace Arbiter.CommandQuery.Commands; /// @@ -58,7 +60,8 @@ namespace Arbiter.CommandQuery.Commands; /// /// /// -public record EntityDeleteCommand +[MessagePackObject] +public partial record EntityDeleteCommand : EntityIdentifierBase, ICacheExpire { /// @@ -91,6 +94,20 @@ public EntityDeleteCommand( FilterName = filterName; } + /// + /// Initializes a new instance of the class. + /// + /// The identifier of the entity to be deleted. This value cannot be . + /// Optional name of a specific filter pipeline to apply during the delete operation. This allows different deletion strategies or security policies to be applied based on context. + /// Thrown when is . + [JsonConstructor] + [SerializationConstructor] + public EntityDeleteCommand( + [NotNull] TKey id, + string? filterName = null) + : this(principal: null, id, filterName) + { } + /// /// Gets the optional name of a specific filter pipeline to apply during the delete operation. /// @@ -120,6 +137,7 @@ public EntityDeleteCommand( /// filterName: "hard-delete"); /// /// + [Key(1)] [JsonPropertyName("filterName")] public string? FilterName { get; } diff --git a/src/Arbiter.CommandQuery/Commands/EntityIdentifierBase.cs b/src/Arbiter.CommandQuery/Commands/EntityIdentifierBase.cs index 0078e76..40ce037 100644 --- a/src/Arbiter.CommandQuery/Commands/EntityIdentifierBase.cs +++ b/src/Arbiter.CommandQuery/Commands/EntityIdentifierBase.cs @@ -2,6 +2,8 @@ using System.Security.Claims; using System.Text.Json.Serialization; +using MessagePack; + namespace Arbiter.CommandQuery.Commands; /// @@ -55,6 +57,7 @@ protected EntityIdentifierBase(ClaimsPrincipal? principal, [NotNull] TKey id) /// /// The identifier of the entity for this command. /// + [Key(0)] [NotNull] [JsonPropertyName("id")] public TKey Id { get; } diff --git a/src/Arbiter.CommandQuery/Commands/EntityIdentifierQuery.cs b/src/Arbiter.CommandQuery/Commands/EntityIdentifierQuery.cs index 3ea0875..68757ef 100644 --- a/src/Arbiter.CommandQuery/Commands/EntityIdentifierQuery.cs +++ b/src/Arbiter.CommandQuery/Commands/EntityIdentifierQuery.cs @@ -4,6 +4,8 @@ using Arbiter.Services; +using MessagePack; + namespace Arbiter.CommandQuery.Commands; /// @@ -37,7 +39,7 @@ namespace Arbiter.CommandQuery.Commands; /// /// // Send the query to the mediator instance /// var result = await mediator.Send(query); -/// +/// /// if (result != null) /// { /// Console.WriteLine($"Product Name: {result.Name}"); @@ -50,8 +52,8 @@ namespace Arbiter.CommandQuery.Commands; /// /// var principal = new ClaimsPrincipal(new ClaimsIdentity(new[] { new Claim(ClaimTypes.Role, "Admin") })); /// var query = new EntityIdentifierQuery<int, ProductReadModel>( -/// principal, -/// 456, +/// principal, +/// 456, /// filterName: "admin-view"); /// /// // This might include additional fields or bypass certain security filters @@ -61,7 +63,8 @@ namespace Arbiter.CommandQuery.Commands; /// /// /// -public record EntityIdentifierQuery : CacheableQueryBase +[MessagePackObject] +public partial record EntityIdentifierQuery : CacheableQueryBase { /// /// Initializes a new instance of the class. @@ -94,6 +97,20 @@ public EntityIdentifierQuery(ClaimsPrincipal? principal, [NotNull] TKey id, stri FilterName = filterName; } + /// + /// Initializes a new instance of the class. + /// + /// The identifier of the entity to retrieve. This value cannot be . + /// Optional name of a specific filter pipeline to apply during query execution. This allows different query modification strategies to be applied based on context. + /// Thrown when is . + [JsonConstructor] + [SerializationConstructor] + public EntityIdentifierQuery([NotNull] TKey id, string? filterName = null) + : this(principal: null, id, filterName) + { + } + + /// /// Gets the identifier of the entity to retrieve. /// @@ -104,6 +121,7 @@ public EntityIdentifierQuery(ClaimsPrincipal? principal, [NotNull] TKey id, stri /// This identifier is used to locate the specific entity instance and is also incorporated into the cache key /// to ensure that each entity is cached independently. /// + [Key(0)] [NotNull] [JsonPropertyName("id")] public TKey Id { get; } @@ -132,11 +150,12 @@ public EntityIdentifierQuery(ClaimsPrincipal? principal, [NotNull] TKey id, stri /// /// // Using a named pipeline for detailed view /// var query = new EntityIdentifierQuery<int, ProductReadModel>( - /// userPrincipal, - /// productId, + /// userPrincipal, + /// productId, /// filterName: "detailed-view"); /// /// + [Key(1)] [JsonPropertyName("filterName")] public string? FilterName { get; } diff --git a/src/Arbiter.CommandQuery/Commands/EntityIdentifiersBase.cs b/src/Arbiter.CommandQuery/Commands/EntityIdentifiersBase.cs index 5f5feeb..b46c657 100644 --- a/src/Arbiter.CommandQuery/Commands/EntityIdentifiersBase.cs +++ b/src/Arbiter.CommandQuery/Commands/EntityIdentifiersBase.cs @@ -2,6 +2,8 @@ using System.Security.Claims; using System.Text.Json.Serialization; +using MessagePack; + namespace Arbiter.CommandQuery.Commands; /// @@ -55,6 +57,7 @@ protected EntityIdentifiersBase(ClaimsPrincipal? principal, [NotNull] IReadOnlyL /// /// The collection of identifiers for this command. /// + [Key(0)] [JsonPropertyName("ids")] public IReadOnlyList Ids { get; } } diff --git a/src/Arbiter.CommandQuery/Commands/EntityIdentifiersQuery.cs b/src/Arbiter.CommandQuery/Commands/EntityIdentifiersQuery.cs index c4681b1..60f8b52 100644 --- a/src/Arbiter.CommandQuery/Commands/EntityIdentifiersQuery.cs +++ b/src/Arbiter.CommandQuery/Commands/EntityIdentifiersQuery.cs @@ -4,6 +4,8 @@ using Arbiter.Services; +using MessagePack; + namespace Arbiter.CommandQuery.Commands; /// @@ -73,7 +75,8 @@ namespace Arbiter.CommandQuery.Commands; /// /// /// -public record EntityIdentifiersQuery : CacheableQueryBase> +[MessagePackObject] +public partial record EntityIdentifiersQuery : CacheableQueryBase> { /// /// Initializes a new instance of the class. @@ -110,6 +113,19 @@ public EntityIdentifiersQuery(ClaimsPrincipal? principal, [NotNull] IReadOnlyLis FilterName = filterName; } + /// + /// Initializes a new instance of the class. + /// + /// The list of identifiers for the entities to retrieve. This collection cannot be . + /// Optional name of a specific filter pipeline to apply during query execution. This allows different query modification strategies to be applied based on context. + /// Thrown when is . + [JsonConstructor] + [SerializationConstructor] + public EntityIdentifiersQuery([NotNull] IReadOnlyList ids, string? filterName = null) + : this(principal: null, ids, filterName) + { + } + /// /// Gets the list of identifiers for the entities to retrieve. /// @@ -130,6 +146,7 @@ public EntityIdentifiersQuery(ClaimsPrincipal? principal, [NotNull] IReadOnlyLis /// different sets of identifiers produce different cache entries. /// /// + [Key(0)] [NotNull] [JsonPropertyName("ids")] public IReadOnlyList Ids { get; } @@ -163,6 +180,7 @@ public EntityIdentifiersQuery(ClaimsPrincipal? principal, [NotNull] IReadOnlyLis /// filterName: "bulk-admin"); /// /// + [Key(1)] [JsonPropertyName("filterName")] public string? FilterName { get; } diff --git a/src/Arbiter.CommandQuery/Commands/EntityKeyQuery.cs b/src/Arbiter.CommandQuery/Commands/EntityKeyQuery.cs index 499380a..d233265 100644 --- a/src/Arbiter.CommandQuery/Commands/EntityKeyQuery.cs +++ b/src/Arbiter.CommandQuery/Commands/EntityKeyQuery.cs @@ -4,6 +4,8 @@ using Arbiter.Services; +using MessagePack; + namespace Arbiter.CommandQuery.Commands; /// @@ -70,7 +72,8 @@ namespace Arbiter.CommandQuery.Commands; /// /// /// -public record EntityKeyQuery : CacheableQueryBase +[MessagePackObject] +public partial record EntityKeyQuery : CacheableQueryBase { /// /// Initializes a new instance of the class. @@ -110,6 +113,29 @@ public EntityKeyQuery(ClaimsPrincipal? principal, Guid key, string? filterName = FilterName = filterName; } + /// + /// Initializes a new instance of the class. + /// + /// The globally unique alternate key () of the entity to retrieve. + /// Optional name of a specific filter pipeline to apply during query execution. This allows different query modification strategies to be applied based on context. + /// + /// + /// The parameter is a globally unique identifier that serves as an alternate way to + /// identify the entity, independent of the primary key. This is commonly used in scenarios where: + /// + /// The entity needs a stable, public identifier for external API consumption + /// The primary key should remain hidden for security reasons + /// The entity participates in distributed systems requiring globally unique identifiers + /// + /// + /// + [JsonConstructor] + [SerializationConstructor] + public EntityKeyQuery(Guid key, string? filterName = null) + : this(principal: null, key, filterName) + { + } + /// /// Gets the globally unique alternate key of the entity to retrieve. /// @@ -125,6 +151,7 @@ public EntityKeyQuery(ClaimsPrincipal? principal, Guid key, string? filterName = /// The key is incorporated into the cache key generation to ensure that each unique entity is cached separately. /// /// + [Key(0)] [NotNull] [JsonPropertyName("key")] public Guid Key { get; } @@ -158,6 +185,7 @@ public EntityKeyQuery(ClaimsPrincipal? principal, Guid key, string? filterName = /// filterName: "public-api"); /// /// + [Key(1)] [JsonPropertyName("filterName")] public string? FilterName { get; } diff --git a/src/Arbiter.CommandQuery/Commands/EntityModelBase.cs b/src/Arbiter.CommandQuery/Commands/EntityModelBase.cs index 8c36fce..c023f2d 100644 --- a/src/Arbiter.CommandQuery/Commands/EntityModelBase.cs +++ b/src/Arbiter.CommandQuery/Commands/EntityModelBase.cs @@ -3,6 +3,8 @@ using System.Security.Claims; using System.Text.Json.Serialization; +using MessagePack; + namespace Arbiter.CommandQuery.Commands; /// @@ -63,6 +65,7 @@ protected EntityModelBase(ClaimsPrincipal? principal, [NotNull] TEntityModel mod /// /// The view model containing the data for the operation. /// + [Key(0)] [NotNull] [JsonPropertyName("model")] public TEntityModel Model { get; } diff --git a/src/Arbiter.CommandQuery/Commands/EntityPagedQuery.cs b/src/Arbiter.CommandQuery/Commands/EntityPagedQuery.cs index 67021a5..47ca0ee 100644 --- a/src/Arbiter.CommandQuery/Commands/EntityPagedQuery.cs +++ b/src/Arbiter.CommandQuery/Commands/EntityPagedQuery.cs @@ -4,6 +4,8 @@ using Arbiter.CommandQuery.Queries; using Arbiter.Services; +using MessagePack; + namespace Arbiter.CommandQuery.Commands; /// @@ -47,7 +49,8 @@ namespace Arbiter.CommandQuery.Commands; /// /// /// -public record EntityPagedQuery : CacheableQueryBase> +[MessagePackObject] +public partial record EntityPagedQuery : CacheableQueryBase> { /// /// Initializes a new instance of the class. @@ -72,6 +75,24 @@ public EntityPagedQuery(ClaimsPrincipal? principal, EntityQuery? query, string? FilterName = filterName; } + /// + /// Initializes a new instance of the class. + /// + /// The defining the filter, sort, and pagination criteria for the query. If , a new empty will be created. + /// Optional name of a specific filter pipeline to apply during query execution. This allows different query modification strategies to be applied based on context. + /// + /// + /// If is , a default empty is initialized, + /// which can be useful for retrieving all entities with default pagination settings. + /// + /// + [JsonConstructor] + [SerializationConstructor] + public EntityPagedQuery(EntityQuery? query, string? filterName = null) + : this(principal: null, query, filterName) + { + } + /// /// Gets the defining the filter, sort, and pagination criteria for the query. /// @@ -79,6 +100,7 @@ public EntityPagedQuery(ClaimsPrincipal? principal, EntityQuery? query, string? /// An object containing the filtering, sorting, and pagination configuration. /// This property is never as it is initialized in the constructor. /// + [Key(0)] [JsonPropertyName("query")] public EntityQuery Query { get; } @@ -93,6 +115,7 @@ public EntityPagedQuery(ClaimsPrincipal? principal, EntityQuery? query, string? /// security policies, or data transformations based on the execution context. The specific behavior depends /// on the registered query pipeline modifiers in the application. /// + [Key(1)] [JsonPropertyName("filterName")] public string? FilterName { get; } diff --git a/src/Arbiter.CommandQuery/Commands/EntityPatchCommand.cs b/src/Arbiter.CommandQuery/Commands/EntityPatchCommand.cs index 2a6039c..467a659 100644 --- a/src/Arbiter.CommandQuery/Commands/EntityPatchCommand.cs +++ b/src/Arbiter.CommandQuery/Commands/EntityPatchCommand.cs @@ -5,6 +5,8 @@ using Arbiter.CommandQuery.Definitions; using Arbiter.Services; +using MessagePack; + using SystemTextJsonPatch; namespace Arbiter.CommandQuery.Commands; @@ -33,7 +35,7 @@ namespace Arbiter.CommandQuery.Commands; /// Console.WriteLine($"Updated product name: {result?.Name}"); /// /// -public record EntityPatchCommand +public partial record EntityPatchCommand : EntityIdentifierBase, ICacheExpire { /// @@ -57,6 +59,25 @@ public EntityPatchCommand( FilterName = filterName; } + /// + /// Initializes a new instance of the class. + /// + /// The identifier of the entity to which the JSON patch will be applied. + /// The JSON patch document containing the updates to apply. + /// Optional name of a specific filter pipeline to apply during query execution. This allows different query modification strategies to be applied based on context. + /// + /// + /// If is , an will be thrown. + /// + /// + [JsonConstructor] + public EntityPatchCommand( + [NotNull] TKey id, + [NotNull] JsonPatchDocument patch, + string? filterName = null) + : this(principal: null, id, patch, filterName: filterName) + { } + /// /// Gets the JSON patch document to apply to the entity with the specified identifier. /// diff --git a/src/Arbiter.CommandQuery/Commands/EntityUpdateCommand.cs b/src/Arbiter.CommandQuery/Commands/EntityUpdateCommand.cs index c6a88ec..f33bebb 100644 --- a/src/Arbiter.CommandQuery/Commands/EntityUpdateCommand.cs +++ b/src/Arbiter.CommandQuery/Commands/EntityUpdateCommand.cs @@ -5,6 +5,8 @@ using Arbiter.CommandQuery.Definitions; using Arbiter.Services; +using MessagePack; + namespace Arbiter.CommandQuery.Commands; /// @@ -80,7 +82,8 @@ namespace Arbiter.CommandQuery.Commands; /// /// /// -public record EntityUpdateCommand +[MessagePackObject] +public partial record EntityUpdateCommand : EntityModelBase, ICacheExpire { /// @@ -123,7 +126,8 @@ public EntityUpdateCommand( [NotNull] TKey id, TUpdateModel model, bool upsert = false, - string? filterName = null) : base(principal, model) + string? filterName = null) + : base(principal, model) { ArgumentNullException.ThrowIfNull(id); @@ -132,6 +136,24 @@ public EntityUpdateCommand( FilterName = filterName; } + /// + /// Initializes a new instance of the class. + /// + /// The update model containing the data for the update operation. + /// The identifier of the entity to update. + /// Whether to insert the entity if it does not exist. + /// Optional name of a specific filter pipeline to apply during the update operation. + /// Thrown when or is . + [JsonConstructor] + [SerializationConstructor] + public EntityUpdateCommand( + TUpdateModel model, + [NotNull] TKey id, + bool upsert = false, + string? filterName = null) + : this(principal: null, id, model, upsert, filterName) + { } + /// /// Gets the identifier of the entity to update. /// @@ -142,6 +164,7 @@ public EntityUpdateCommand( /// This identifier is used to locate the specific entity instance to update. If is /// and no entity with this identifier exists, a new entity will be created with this identifier. /// + [Key(1)] [NotNull] [JsonPropertyName("id")] public TKey Id { get; } @@ -187,6 +210,7 @@ public EntityUpdateCommand( /// principal, productId, updateModel, upsert: true); /// /// + [Key(2)] [JsonPropertyName("upsert")] public bool Upsert { get; } @@ -221,6 +245,7 @@ public EntityUpdateCommand( /// filterName: "bulk-update"); /// /// + [Key(3)] [JsonPropertyName("filterName")] public string? FilterName { get; } diff --git a/src/Arbiter.CommandQuery/Commands/PrincipalCommandBase.cs b/src/Arbiter.CommandQuery/Commands/PrincipalCommandBase.cs index 9e4aca7..b8cbdba 100644 --- a/src/Arbiter.CommandQuery/Commands/PrincipalCommandBase.cs +++ b/src/Arbiter.CommandQuery/Commands/PrincipalCommandBase.cs @@ -1,4 +1,3 @@ -using System.Runtime.Serialization; using System.Security.Claims; using System.Text.Json.Serialization; diff --git a/src/Arbiter.CommandQuery/Models/CompleteModel.cs b/src/Arbiter.CommandQuery/Models/CompleteModel.cs index 48a5a66..ae42e56 100644 --- a/src/Arbiter.CommandQuery/Models/CompleteModel.cs +++ b/src/Arbiter.CommandQuery/Models/CompleteModel.cs @@ -1,11 +1,14 @@ using System.Text.Json.Serialization; +using MessagePack; + namespace Arbiter.CommandQuery.Models; /// /// Operation complete result model /// -public class CompleteModel +[MessagePackObject] +public partial class CompleteModel { /// /// Gets or sets a value indicating whether operation was successful. @@ -13,6 +16,7 @@ public class CompleteModel /// /// if was successful; otherwise, . /// + [Key(0)] [JsonPropertyName("successful")] public bool Successful { get; set; } @@ -22,6 +26,7 @@ public class CompleteModel /// /// The operation result message. /// + [Key(1)] [JsonPropertyName("message")] [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] public string? Message { get; set; } diff --git a/src/Arbiter.CommandQuery/Models/EntityCreateModel.cs b/src/Arbiter.CommandQuery/Models/EntityCreateModel.cs index 6233162..8ab0dd2 100644 --- a/src/Arbiter.CommandQuery/Models/EntityCreateModel.cs +++ b/src/Arbiter.CommandQuery/Models/EntityCreateModel.cs @@ -2,6 +2,8 @@ using Arbiter.CommandQuery.Definitions; +using MessagePack; + namespace Arbiter.CommandQuery.Models; /// @@ -11,25 +13,30 @@ namespace Arbiter.CommandQuery.Models; /// /// /// -public class EntityCreateModel : EntityIdentifierModel, ITrackCreated, ITrackUpdated +[MessagePackObject] +public partial class EntityCreateModel : EntityIdentifierModel, ITrackCreated, ITrackUpdated { /// + [Key(1)] [JsonPropertyName("created")] [JsonPropertyOrder(9990)] public DateTimeOffset Created { get; set; } = DateTimeOffset.UtcNow; /// + [Key(2)] [JsonPropertyName("createdBy")] [JsonPropertyOrder(9991)] [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] public string? CreatedBy { get; set; } /// + [Key(3)] [JsonPropertyName("updated")] [JsonPropertyOrder(9992)] public DateTimeOffset Updated { get; set; } = DateTimeOffset.UtcNow; /// + [Key(4)] [JsonPropertyName("updatedBy")] [JsonPropertyOrder(9993)] [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] diff --git a/src/Arbiter.CommandQuery/Models/EntityIdentifierModel.cs b/src/Arbiter.CommandQuery/Models/EntityIdentifierModel.cs index 4aeabc9..eae97e5 100644 --- a/src/Arbiter.CommandQuery/Models/EntityIdentifierModel.cs +++ b/src/Arbiter.CommandQuery/Models/EntityIdentifierModel.cs @@ -3,6 +3,8 @@ using Arbiter.CommandQuery.Definitions; +using MessagePack; + namespace Arbiter.CommandQuery.Models; /// @@ -10,9 +12,11 @@ namespace Arbiter.CommandQuery.Models; /// /// The type of the key. /// -public class EntityIdentifierModel : IHaveIdentifier +[MessagePackObject] +public partial class EntityIdentifierModel : IHaveIdentifier { /// + [Key(0)] [NotNull] [JsonPropertyName("id")] [JsonPropertyOrder(-9999)] diff --git a/src/Arbiter.CommandQuery/Models/EntityIdentifiersModel.cs b/src/Arbiter.CommandQuery/Models/EntityIdentifiersModel.cs index a40424a..19d37b6 100644 --- a/src/Arbiter.CommandQuery/Models/EntityIdentifiersModel.cs +++ b/src/Arbiter.CommandQuery/Models/EntityIdentifiersModel.cs @@ -1,13 +1,16 @@ using System.Diagnostics.CodeAnalysis; using System.Text.Json.Serialization; +using MessagePack; + namespace Arbiter.CommandQuery.Models; /// /// An identifiers base model /// /// The type of the key. -public class EntityIdentifiersModel +[MessagePackObject] +public partial class EntityIdentifiersModel { /// /// Gets or sets the list of identifiers. @@ -15,6 +18,7 @@ public class EntityIdentifiersModel /// /// The list of identifiers. /// + [Key(0)] [NotNull] [JsonPropertyName("ids")] public IReadOnlyCollection Ids { get; set; } = null!; diff --git a/src/Arbiter.CommandQuery/Models/EntityReadModel.cs b/src/Arbiter.CommandQuery/Models/EntityReadModel.cs index 140947e..051c761 100644 --- a/src/Arbiter.CommandQuery/Models/EntityReadModel.cs +++ b/src/Arbiter.CommandQuery/Models/EntityReadModel.cs @@ -2,6 +2,8 @@ using Arbiter.CommandQuery.Definitions; +using MessagePack; + namespace Arbiter.CommandQuery.Models; /// @@ -12,31 +14,37 @@ namespace Arbiter.CommandQuery.Models; /// /// /// -public class EntityReadModel : EntityIdentifierModel, ITrackCreated, ITrackUpdated, ITrackConcurrency +[MessagePackObject] +public partial class EntityReadModel : EntityIdentifierModel, ITrackCreated, ITrackUpdated, ITrackConcurrency { /// + [Key(1)] [JsonPropertyName("created")] [JsonPropertyOrder(9990)] public DateTimeOffset Created { get; set; } = DateTimeOffset.UtcNow; /// + [Key(2)] [JsonPropertyName("createdBy")] [JsonPropertyOrder(9991)] [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] public string? CreatedBy { get; set; } /// + [Key(3)] [JsonPropertyName("updated")] [JsonPropertyOrder(9992)] public DateTimeOffset Updated { get; set; } = DateTimeOffset.UtcNow; /// + [Key(4)] [JsonPropertyName("updatedBy")] [JsonPropertyOrder(9993)] [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] public string? UpdatedBy { get; set; } /// + [Key(5)] [JsonPropertyName("rowVersion")] [JsonPropertyOrder(9999)] public long RowVersion { get; set; } diff --git a/src/Arbiter.CommandQuery/Models/EntityUpdateModel.cs b/src/Arbiter.CommandQuery/Models/EntityUpdateModel.cs index 51ab788..03084f0 100644 --- a/src/Arbiter.CommandQuery/Models/EntityUpdateModel.cs +++ b/src/Arbiter.CommandQuery/Models/EntityUpdateModel.cs @@ -2,6 +2,8 @@ using Arbiter.CommandQuery.Definitions; +using MessagePack; + namespace Arbiter.CommandQuery.Models; /// @@ -9,20 +11,24 @@ namespace Arbiter.CommandQuery.Models; /// /// /// -public class EntityUpdateModel : ITrackUpdated, ITrackConcurrency +[MessagePackObject] +public partial class EntityUpdateModel : ITrackUpdated, ITrackConcurrency { /// + [Key(0)] [JsonPropertyName("updated")] [JsonPropertyOrder(9992)] public DateTimeOffset Updated { get; set; } = DateTimeOffset.UtcNow; /// + [Key(1)] [JsonPropertyName("updatedBy")] [JsonPropertyOrder(9993)] [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] public string? UpdatedBy { get; set; } /// + [Key(2)] [JsonPropertyName("rowVersion")] [JsonPropertyOrder(9999)] public long RowVersion { get; set; } diff --git a/src/Arbiter.CommandQuery/Models/ProblemDetails.cs b/src/Arbiter.CommandQuery/Models/ProblemDetails.cs index a300762..19f5f56 100644 --- a/src/Arbiter.CommandQuery/Models/ProblemDetails.cs +++ b/src/Arbiter.CommandQuery/Models/ProblemDetails.cs @@ -1,11 +1,14 @@ using System.Text.Json.Serialization; +using MessagePack; + namespace Arbiter.CommandQuery.Models; /// /// A machine-readable format for specifying errors in HTTP API responses based on https://tools.ietf.org/html/rfc7807. /// -public class ProblemDetails +[MessagePackObject] +public partial class ProblemDetails { /// /// The content-type for a problem json response @@ -18,6 +21,7 @@ public class ProblemDetails /// (e.g., using HTML [W3C.REC-html5-20141028]). When this member is not present, its value is assumed to be /// "about:blank". /// + [Key(0)] [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] [JsonPropertyOrder(-5)] [JsonPropertyName("type")] @@ -28,6 +32,7 @@ public class ProblemDetails /// of the problem, except for purposes of localization(e.g., using proactive content negotiation; /// see[RFC7231], Section 3.4). /// + [Key(1)] [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] [JsonPropertyOrder(-4)] [JsonPropertyName("title")] @@ -36,6 +41,7 @@ public class ProblemDetails /// /// The HTTP status code([RFC7231], Section 6) generated by the origin server for this occurrence of the problem. /// + [Key(2)] [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] [JsonPropertyOrder(-3)] [JsonPropertyName("status")] @@ -44,6 +50,7 @@ public class ProblemDetails /// /// A human-readable explanation specific to this occurrence of the problem. /// + [Key(3)] [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] [JsonPropertyOrder(-2)] [JsonPropertyName("detail")] @@ -52,6 +59,7 @@ public class ProblemDetails /// /// A URI reference that identifies the specific occurrence of the problem. It may or may not yield further information if dereferenced. /// + [Key(4)] [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] [JsonPropertyOrder(-1)] [JsonPropertyName("instance")] @@ -60,6 +68,7 @@ public class ProblemDetails /// /// Gets the validation errors associated with this instance of problem details /// + [Key(5)] [JsonPropertyName("errors")] public IDictionary Errors { get; set; } = new Dictionary(StringComparer.Ordinal); @@ -74,6 +83,7 @@ public class ProblemDetails /// The round-tripping behavior for is determined by the implementation of the Input \ Output formatters. /// In particular, complex types or collection types may not round-trip to the original type when using the built-in JSON or XML formatters. /// + [IgnoreMember] [JsonExtensionData] public IDictionary Extensions { get; set; } = new Dictionary(StringComparer.Ordinal); } diff --git a/src/Arbiter.CommandQuery/Models/ValidationResult.cs b/src/Arbiter.CommandQuery/Models/ValidationResult.cs index 8fb9d58..0e66224 100644 --- a/src/Arbiter.CommandQuery/Models/ValidationResult.cs +++ b/src/Arbiter.CommandQuery/Models/ValidationResult.cs @@ -1,21 +1,26 @@ using System.Text.Json.Serialization; +using MessagePack; + namespace Arbiter.CommandQuery.Models; /// /// A class that represents the result of a validation. /// -public class ValidationResult +[MessagePackObject] +public partial class ValidationResult { /// /// Gets or sets whether the validation was successful. /// + [Key(0)] [JsonPropertyName("isValid")] public bool IsValid => Errors.Count == 0; /// /// Gets or sets the validation errors. The dictionary key is the property name, and the value is an array of error messages. /// + [Key(1)] [JsonPropertyName("errors")] public IDictionary Errors { get; set; } = new Dictionary(StringComparer.Ordinal); diff --git a/src/Arbiter.CommandQuery/Queries/EntityFilter.cs b/src/Arbiter.CommandQuery/Queries/EntityFilter.cs index 6e66a3c..757e5e1 100644 --- a/src/Arbiter.CommandQuery/Queries/EntityFilter.cs +++ b/src/Arbiter.CommandQuery/Queries/EntityFilter.cs @@ -2,6 +2,8 @@ using Arbiter.CommandQuery.Converters; +using MessagePack; + namespace Arbiter.CommandQuery.Queries; /// @@ -34,8 +36,9 @@ namespace Arbiter.CommandQuery.Queries; /// }; /// /// +[MessagePackObject] [JsonConverter(typeof(EntityFilterConverter))] -public class EntityFilter +public partial class EntityFilter { /// /// Gets or sets the name of the field or property to filter on. @@ -43,6 +46,7 @@ public class EntityFilter /// /// The name of the field or property to filter on. /// + [Key(0)] [JsonPropertyName("name")] public string? Name { get; set; } @@ -52,6 +56,7 @@ public class EntityFilter /// /// The value to filter on. /// + [Key(1)] [JsonPropertyName("value")] public object? Value { get; set; } @@ -63,6 +68,7 @@ public class EntityFilter /// The operator to use for the filter. /// /// + [Key(2)] [JsonPropertyName("operator")] [JsonConverter(typeof(JsonStringEnumConverter))] public FilterOperators? Operator { get; set; } @@ -75,6 +81,7 @@ public class EntityFilter /// The logical operator to use for combining filters. /// /// + [Key(3)] [JsonPropertyName("logic")] [JsonConverter(typeof(JsonStringEnumConverter))] public FilterLogic? Logic { get; set; } @@ -85,6 +92,7 @@ public class EntityFilter /// /// The list of nested filters to apply to the query. /// + [Key(4)] [JsonPropertyName("filters")] public IList? Filters { get; set; } diff --git a/src/Arbiter.CommandQuery/Queries/EntityPagedResult.cs b/src/Arbiter.CommandQuery/Queries/EntityPagedResult.cs index dd190f6..766d9d6 100644 --- a/src/Arbiter.CommandQuery/Queries/EntityPagedResult.cs +++ b/src/Arbiter.CommandQuery/Queries/EntityPagedResult.cs @@ -1,16 +1,21 @@ +using System.Diagnostics.CodeAnalysis; using System.Text.Json.Serialization; +using MessagePack; + namespace Arbiter.CommandQuery.Queries; /// /// A paged result for an entity query. /// /// The type of the read model. -public class EntityPagedResult +[MessagePackObject] +public partial class EntityPagedResult { /// /// Gets an empty instance of the class. /// + [SuppressMessage("Design", "MA0018:Do not declare static members on generic types", Justification = "")] public static EntityPagedResult Empty { get; } = new(); /// @@ -20,6 +25,7 @@ public class EntityPagedResult /// A string token that can be used in subsequent queries to fetch the next set of results, /// or if there are no more results. /// + [Key(0)] [JsonPropertyName("continuationToken")] [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] public string? ContinuationToken { get; set; } @@ -27,6 +33,7 @@ public class EntityPagedResult /// /// The total number of the results for the query. /// + [Key(1)] [JsonPropertyName("total")] [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] public long? Total { get; set; } @@ -34,6 +41,7 @@ public class EntityPagedResult /// /// The current page of data for the query. /// + [Key(2)] [JsonPropertyName("data")] [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] public IReadOnlyList? Data { get; set; } diff --git a/src/Arbiter.CommandQuery/Queries/EntityQuery.cs b/src/Arbiter.CommandQuery/Queries/EntityQuery.cs index 686834b..5d5522e 100644 --- a/src/Arbiter.CommandQuery/Queries/EntityQuery.cs +++ b/src/Arbiter.CommandQuery/Queries/EntityQuery.cs @@ -1,5 +1,7 @@ using System.Text.Json.Serialization; +using MessagePack; + namespace Arbiter.CommandQuery.Queries; /// @@ -50,7 +52,8 @@ namespace Arbiter.CommandQuery.Queries; /// .AddSort("CreatedDate:desc"); /// /// -public class EntityQuery +[MessagePackObject] +public partial class EntityQuery { /// /// Gets or sets the raw query expression to search for entities. @@ -59,6 +62,7 @@ public class EntityQuery /// A string containing the raw query expression, or if no raw query is specified. /// This can be used for full-text search or custom query expressions depending on the underlying data provider. /// + [Key(0)] [JsonPropertyName("query")] [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] public string? Query { get; set; } @@ -74,6 +78,7 @@ public class EntityQuery /// Sort expressions are applied in sequence, allowing for multi-level sorting (e.g., sort by category, then by name within each category). /// Use the or methods to add sort expressions fluently. /// + [Key(1)] [JsonPropertyName("sort")] [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] public IList? Sort { get; set; } @@ -88,6 +93,7 @@ public class EntityQuery /// The filter can be a simple property-based filter or a complex group filter containing multiple nested conditions /// combined with logical operators (AND/OR). Use to determine if the filter contains nested filters. /// + [Key(2)] [JsonPropertyName("filter")] [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] public EntityFilter? Filter { get; set; } @@ -103,6 +109,7 @@ public class EntityQuery /// This property is used in conjunction with for traditional page-based pagination. /// When both and are specified, the query will return the specified page of results. /// + [Key(3)] [JsonPropertyName("page")] [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] public int? Page { get; set; } @@ -118,6 +125,7 @@ public class EntityQuery /// This property controls the maximum number of entities returned in a single query execution. /// It is used in conjunction with for page-based pagination or independently to limit result set size. /// + [Key(4)] [JsonPropertyName("pageSize")] [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] public int? PageSize { get; set; } @@ -139,6 +147,7 @@ public class EntityQuery /// from a previous query result and passed to subsequent queries to retrieve the next set of results. /// /// + [Key(5)] [JsonPropertyName("continuationToken")] [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] public string? ContinuationToken { get; set; } diff --git a/src/Arbiter.CommandQuery/Queries/EntitySort.cs b/src/Arbiter.CommandQuery/Queries/EntitySort.cs index 450c961..c71a75e 100644 --- a/src/Arbiter.CommandQuery/Queries/EntitySort.cs +++ b/src/Arbiter.CommandQuery/Queries/EntitySort.cs @@ -1,5 +1,7 @@ using System.Text.Json.Serialization; +using MessagePack; + namespace Arbiter.CommandQuery.Queries; /// @@ -44,7 +46,8 @@ namespace Arbiter.CommandQuery.Queries; /// /// /// -public class EntitySort +[MessagePackObject] +public partial class EntitySort { /// /// Gets or sets the name of the property to sort by. @@ -62,6 +65,7 @@ public class EntitySort /// or entity property expressions. /// /// + [Key(0)] [JsonPropertyName("name")] public string Name { get; set; } = null!; @@ -83,6 +87,7 @@ public class EntitySort /// /// /// + [Key(1)] [JsonPropertyName("direction")] [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] [JsonConverter(typeof(JsonStringEnumConverter))] diff --git a/src/Arbiter.Dispatcher.Client/Arbiter.Dispatcher.Client.csproj b/src/Arbiter.Dispatcher.Client/Arbiter.Dispatcher.Client.csproj index 2c581a8..5b40602 100644 --- a/src/Arbiter.Dispatcher.Client/Arbiter.Dispatcher.Client.csproj +++ b/src/Arbiter.Dispatcher.Client/Arbiter.Dispatcher.Client.csproj @@ -8,12 +8,13 @@ - - - - + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + @@ -22,7 +23,7 @@ - + diff --git a/src/Arbiter.Dispatcher.Client/Client/RemoteDispatcher.cs b/src/Arbiter.Dispatcher.Client/Client/RemoteDispatcher.cs index 951efd5..b12e4eb 100644 --- a/src/Arbiter.Dispatcher.Client/Client/RemoteDispatcher.cs +++ b/src/Arbiter.Dispatcher.Client/Client/RemoteDispatcher.cs @@ -1,10 +1,9 @@ using Arbiter.CommandQuery.Definitions; -using Arbiter.Dispatcher.Server; using Arbiter.Mediation; +using Google.Protobuf; + using Grpc.Core; -using Grpc.Net.Client; -using Grpc.Net.Client.Web; using MessagePack; @@ -12,20 +11,22 @@ namespace Arbiter.Dispatcher.Client; -public class RemoteDispatcher : IDispatcher, IDisposable +public class RemoteDispatcher : IDispatcher { - private readonly GrpcChannel _channel; - private readonly CallInvoker _invoker; - private readonly HybridCache? _hybridCache; + public const string TypeHeader = "x-message-type"; - private static readonly MessagePackSerializerOptions Options = MessagePackSerializerOptions.Standard - .WithResolver(MessagePack.Resolvers.TypelessContractlessStandardResolver.Instance) - .WithCompression(MessagePackCompression.Lz4BlockArray); + private readonly DispatcherRpc.DispatcherRpcClient _dispatcherClient = null!; + private readonly HybridCache? _hybridCache; + private readonly MessagePackSerializerOptions _options; - public RemoteDispatcher(GrpcChannel channel) + public RemoteDispatcher( + DispatcherRpc.DispatcherRpcClient dispatcherClient, + MessagePackSerializerOptions options, + HybridCache? hybridCache = null) { - _channel = channel; - _invoker = _channel.CreateCallInvoker(); + _options = options; + _dispatcherClient = dispatcherClient; + _hybridCache = hybridCache; } public ValueTask Send(TRequest request, CancellationToken cancellationToken = default) @@ -34,7 +35,6 @@ public RemoteDispatcher(GrpcChannel channel) return Send(request, cancellationToken); } - public async ValueTask Send(IRequest request, CancellationToken cancellationToken = default) { ArgumentNullException.ThrowIfNull(request); @@ -67,37 +67,29 @@ public RemoteDispatcher(GrpcChannel channel) { // Single serialization - directly serialize the request var type = request.GetType(); - var requestBytes = MessagePackSerializer.Serialize(type, request, Options, cancellationToken); + var requestBytes = MessagePackSerializer.Serialize(type, request, _options, cancellationToken); var requestType = request.GetType(); + var requestName = requestType.AssemblyQualifiedName ?? requestType.FullName!; + // Add type information to gRPC metadata - var metadata = new Metadata - { - { DispatcherMethod.TypeHeader, requestType.AssemblyQualifiedName ?? requestType.FullName! }, - }; + var metadata = new Metadata { { TypeHeader, requestName } }; // Call the single generic gRPC endpoint var callOptions = new CallOptions(headers: metadata, cancellationToken: cancellationToken); - var responseBytes = await _invoker - .AsyncUnaryCall( - method: DispatcherMethod.Execute, - host: null, - options: callOptions, - request: requestBytes) - .ConfigureAwait(false); + var dispatcherRequest = new DispatcherRequest() { Payload = ByteString.CopyFrom(requestBytes) }; + var response = await _dispatcherClient.ExecuteAsync(dispatcherRequest, callOptions).ConfigureAwait(false); - // Single deserialization - directly to response type - return MessagePackSerializer.Deserialize(responseBytes, Options, cancellationToken); - } + if (response == null || response.Payload == null) + return default; - /// - /// Releases the resources used by the instance. - /// - public void Dispose() - { - _channel?.Dispose(); - GC.SuppressFinalize(this); + var responseBytes = response.Payload.ToByteArray(); + if (responseBytes == null || responseBytes.Length == 0) + return default; + + // Single deserialization - directly to response type + return MessagePackSerializer.Deserialize(responseBytes, _options, cancellationToken); } } diff --git a/src/Arbiter.Dispatcher.Client/DispatcherServiceExtensions.cs b/src/Arbiter.Dispatcher.Client/DispatcherServiceExtensions.cs index 68e1cdb..25b2489 100644 --- a/src/Arbiter.Dispatcher.Client/DispatcherServiceExtensions.cs +++ b/src/Arbiter.Dispatcher.Client/DispatcherServiceExtensions.cs @@ -6,6 +6,8 @@ using Grpc.Net.Client; using Grpc.Net.Client.Web; +using MessagePack; + using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection.Extensions; @@ -18,25 +20,25 @@ public static IServiceCollection AddRemoteDispatcher(this IServiceCollection ser ArgumentNullException.ThrowIfNull(services); ArgumentNullException.ThrowIfNull(serviceAddress); - return services.AddRemoteDispatcher(_ => - { - var httpHandler = new GrpcWebHandler(GrpcWebMode.GrpcWeb, new HttpClientHandler()); - var channelOptions = new GrpcChannelOptions() - { - HttpHandler = httpHandler, - MaxReceiveMessageSize = 10 * 1024 * 1024, // 10 MB - MaxSendMessageSize = 10 * 1024 * 1024, // 10 MB - }; - - return GrpcChannel.ForAddress(serviceAddress, channelOptions); - }); + return services; + //return services.AddRemoteDispatcher(_ => + //{ + // var httpHandler = new GrpcWebHandler(GrpcWebMode.GrpcWeb, new HttpClientHandler()); + // var channelOptions = new GrpcChannelOptions() + // { + // HttpHandler = httpHandler, + // MaxReceiveMessageSize = 10 * 1024 * 1024, // 10 MB + // MaxSendMessageSize = 10 * 1024 * 1024, // 10 MB + // }; + + // return GrpcChannel.ForAddress(serviceAddress, channelOptions); + //}); } public static IServiceCollection AddRemoteDispatcher(this IServiceCollection services, Func addressFactory) { ArgumentNullException.ThrowIfNull(services); ArgumentNullException.ThrowIfNull(addressFactory); - return services.AddRemoteDispatcher(sp => { var serviceAddress = addressFactory(sp); @@ -58,15 +60,22 @@ public static IServiceCollection AddRemoteDispatcher(this IServiceCollection ser ArgumentNullException.ThrowIfNull(services); ArgumentNullException.ThrowIfNull(channelFactory); + // MessagePack Serializer Options Registration + services.TryAddSingleton(MessagePackSerializerOptions.Standard + .WithResolver(MessagePack.Resolvers.TypelessContractlessStandardResolver.Instance) + .WithCompression(MessagePackCompression.Lz4BlockArray)); + // Remote Dispatcher Registration services.TryAddTransient(sp => { var channel = channelFactory(sp); - return new RemoteDispatcher(channel); + var options = sp.GetRequiredService(); + + return new DispatcherRpc.DispatcherRpcClient(channel); }); // IDispatcher Registration - services.TryAddTransient(sp => sp.GetRequiredService()); + services.TryAddTransient(); // Dispatcher Data Service Registration services.TryAddTransient(); diff --git a/src/Arbiter.Dispatcher.Client/IDispatcher.cs b/src/Arbiter.Dispatcher.Client/IDispatcher.cs deleted file mode 100644 index 4b047e1..0000000 --- a/src/Arbiter.Dispatcher.Client/IDispatcher.cs +++ /dev/null @@ -1,41 +0,0 @@ -using System.Diagnostics.CodeAnalysis; - -using Arbiter.Mediation; - -namespace Arbiter.Dispatcher.Client; - -/// -/// An to represent a dispatcher for sending request messages. -/// -/// -/// Dispatcher is an abstraction over the pattern, allowing for sending of requests over -/// HTTP for remote scenarios and directly to for server side scenarios. Use this abstraction -/// when using the Blazor Interactive Auto rendering mode. -/// -public interface IDispatcher -{ - /// - /// Sends a request to the message dispatcher. - /// - /// The type of request being sent - /// The type of response from the dispatcher - /// The request being sent - /// Cancellation token - /// Awaitable task returning the - ValueTask Send( - TRequest request, - CancellationToken cancellationToken = default) - where TRequest : IRequest; - - /// - /// Sends a request to the message dispatcher. - /// - /// The type of response from the dispatcher - /// The request being sent - /// Cancellation token - /// Awaitable task returning the - [RequiresUnreferencedCode("This overload relies on reflection over types that may be removed when trimming.")] - ValueTask Send( - IRequest request, - CancellationToken cancellationToken = default); -} diff --git a/src/Arbiter.Dispatcher.Client/Protos/dispatcher.proto b/src/Arbiter.Dispatcher.Client/Protos/dispatcher.proto new file mode 100644 index 0000000..e2cca20 --- /dev/null +++ b/src/Arbiter.Dispatcher.Client/Protos/dispatcher.proto @@ -0,0 +1,15 @@ +syntax = "proto3"; + +option csharp_namespace = "Arbiter.Dispatcher"; + +service DispatcherRpc { + rpc Execute (DispatcherRequest) returns (DispatcherResponse); +} + +message DispatcherRequest { + bytes payload = 1; +} + +message DispatcherResponse { + bytes payload = 1; +} diff --git a/src/Arbiter.Dispatcher.Client/RemoteDispatcher.cs b/src/Arbiter.Dispatcher.Client/RemoteDispatcher.cs deleted file mode 100644 index 0615e17..0000000 --- a/src/Arbiter.Dispatcher.Client/RemoteDispatcher.cs +++ /dev/null @@ -1,110 +0,0 @@ -using Arbiter.CommandQuery.Definitions; -using Arbiter.Dispatcher.Server; -using Arbiter.Mediation; - -using Grpc.Core; -using Grpc.Net.Client; -using Grpc.Net.Client.Web; - -using MessagePack; - -using Microsoft.Extensions.Caching.Hybrid; - -namespace Arbiter.Dispatcher.Client; - -public class RemoteDispatcher : IDispatcher, IDisposable -{ - private readonly GrpcChannel _channel; - private readonly CallInvoker _invoker; - private readonly HybridCache? _hybridCache; - - private static readonly MessagePackSerializerOptions Options = MessagePackSerializerOptions.Standard - .WithResolver(MessagePack.Resolvers.ContractlessStandardResolver.Instance) - .WithCompression(MessagePackCompression.Lz4BlockArray); - - public RemoteDispatcher(string serverAddress) - { - var httpHandler = new GrpcWebHandler(GrpcWebMode.GrpcWeb, new HttpClientHandler()); - - _channel = GrpcChannel.ForAddress(serverAddress, new GrpcChannelOptions - { - HttpHandler = httpHandler, - MaxReceiveMessageSize = 10 * 1024 * 1024, - MaxSendMessageSize = 10 * 1024 * 1024, - }); - - _invoker = _channel.CreateCallInvoker(); - } - - public ValueTask Send(TRequest request, CancellationToken cancellationToken = default) - where TRequest : IRequest - { - return Send(request, cancellationToken); - } - - - public async ValueTask Send(IRequest request, CancellationToken cancellationToken = default) - { - ArgumentNullException.ThrowIfNull(request); - - // cache only if implements interface - var cacheRequest = request as ICacheResult; - if (_hybridCache is null || cacheRequest?.IsCacheable() != true) - return await SendCore(request, cancellationToken).ConfigureAwait(false); - - var cacheKey = cacheRequest.GetCacheKey(); - var cacheTag = cacheRequest.GetCacheTag(); - var cacheOptions = new HybridCacheEntryOptions - { - Expiration = cacheRequest.SlidingExpiration(), - }; - - return await _hybridCache - .GetOrCreateAsync( - key: cacheKey, - factory: async token => await SendCore(request, token).ConfigureAwait(false), - options: cacheOptions, - tags: string.IsNullOrEmpty(cacheTag) ? null : [cacheTag], - cancellationToken: cancellationToken) - .ConfigureAwait(false); - } - - private async ValueTask SendCore( - IRequest request, - CancellationToken cancellationToken = default) - { - // Single serialization - directly serialize the request - var requestBytes = MessagePackSerializer.Serialize(request, Options, cancellationToken); - - var requestType = request.GetType(); - // Add type information to gRPC metadata - var metadata = new Metadata - { - { DispatcherMethod.TypeHeader, requestType.AssemblyQualifiedName ?? requestType.FullName! }, - }; - - // Call the single generic gRPC endpoint - var callOptions = new CallOptions(headers: metadata, cancellationToken: cancellationToken); - - var responseBytes = await _invoker - .AsyncUnaryCall( - method: DispatcherMethod.Execute, - host: null, - options: callOptions, - request: requestBytes) - .ConfigureAwait(false); - - // Single deserialization - directly to response type - return MessagePackSerializer.Deserialize(responseBytes, Options, cancellationToken); - } - - /// - /// Releases the resources used by the instance. - /// - public void Dispose() - { - _channel?.Dispose(); - GC.SuppressFinalize(this); - } -} - diff --git a/src/Arbiter.Dispatcher.Client/ServerDispatcher.cs b/src/Arbiter.Dispatcher.Client/ServerDispatcher.cs deleted file mode 100644 index a81d506..0000000 --- a/src/Arbiter.Dispatcher.Client/ServerDispatcher.cs +++ /dev/null @@ -1,41 +0,0 @@ -using System.Diagnostics.CodeAnalysis; - -using Arbiter.Mediation; - -namespace Arbiter.Dispatcher.Client; - -/// -/// A dispatcher that uses to send requests. Use for Blazor Interactive Server rendering mode. -/// -public class ServerDispatcher : IDispatcher -{ - private readonly IMediator _mediator; - - /// - /// Initializes a new instance of the class. - /// - /// The to send request to. - /// When is null - public ServerDispatcher(IMediator mediator) - { - _mediator = mediator ?? throw new ArgumentNullException(nameof(mediator)); - } - - /// - public ValueTask Send( - TRequest request, - CancellationToken cancellationToken = default) - where TRequest : IRequest - { - return _mediator.Send(request, cancellationToken); - } - - /// - [RequiresUnreferencedCode("This overload relies on reflection over types that may be removed when trimming.")] - public ValueTask Send( - IRequest request, - CancellationToken cancellationToken = default) - { - return _mediator.Send(request, cancellationToken); - } -} diff --git a/src/Arbiter.Dispatcher.Server/Arbiter.Dispatcher.Server.csproj b/src/Arbiter.Dispatcher.Server/Arbiter.Dispatcher.Server.csproj index 90faf33..a85d596 100644 --- a/src/Arbiter.Dispatcher.Server/Arbiter.Dispatcher.Server.csproj +++ b/src/Arbiter.Dispatcher.Server/Arbiter.Dispatcher.Server.csproj @@ -7,7 +7,7 @@ - + @@ -16,4 +16,8 @@ + + + + diff --git a/src/Arbiter.Dispatcher.Server/DispatcherMethod.cs b/src/Arbiter.Dispatcher.Server/DispatcherMethod.cs deleted file mode 100644 index 3f69d90..0000000 --- a/src/Arbiter.Dispatcher.Server/DispatcherMethod.cs +++ /dev/null @@ -1,24 +0,0 @@ -using Grpc.Core; - -namespace Arbiter.Dispatcher.Server; - -public static class DispatcherMethod -{ - public const string ServiceName = "DispatcherService"; - public const string TypeHeader = "x-message-type"; - - // Single generic method that handles ALL types - public static readonly Method Execute = new( - type: MethodType.Unary, - serviceName: ServiceName, - name: nameof(Execute), - requestMarshaller: Marshallers.Create( - serializer: bytes => bytes, - deserializer: bytes => bytes - ), - responseMarshaller: Marshallers.Create( - serializer: bytes => bytes, - deserializer: bytes => bytes - ) - ); -} diff --git a/src/Arbiter.Dispatcher.Server/DispatcherService.cs b/src/Arbiter.Dispatcher.Server/DispatcherService.cs index 0907011..ef8d2d1 100644 --- a/src/Arbiter.Dispatcher.Server/DispatcherService.cs +++ b/src/Arbiter.Dispatcher.Server/DispatcherService.cs @@ -1,6 +1,8 @@ using Arbiter.CommandQuery.Definitions; using Arbiter.Mediation; +using Google.Protobuf; + using Grpc.Core; using MessagePack; @@ -17,20 +19,9 @@ namespace Arbiter.Dispatcher.Server; /// applies the current user principal if supported, and dispatches them to the mediator for processing. /// The response is then serialized and returned to the caller. /// -[BindServiceMethod(typeof(DispatcherService), nameof(BindService))] -public class DispatcherService +public class DispatcherService : DispatcherRpc.DispatcherRpcBase { - /// - /// Binds the service methods to the gRPC server. - /// - /// The dispatcher service instance to bind. - /// A containing the bound service methods. - public static ServerServiceDefinition BindService(DispatcherService service) - { - return ServerServiceDefinition.CreateBuilder() - .AddMethod(DispatcherMethod.Execute, service.Execute) - .Build(); - } + public const string TypeHeader = "x-message-type"; private readonly ILogger _logger; private readonly IMediator _mediator; @@ -52,35 +43,24 @@ public DispatcherService( _options = options; } - /// - /// Executes a request by deserializing it, applying the current user principal if supported, - /// dispatching it to the mediator, and returning the serialized response. - /// - /// The serialized request bytes. - /// The server call context containing request metadata and cancellation token. - /// The serialized response bytes. - /// - /// Thrown when the x-message-type header is missing, the message type cannot be resolved, - /// or the request payload cannot be deserialized. - /// - /// - /// The method expects the message type name to be provided in the 'x-message-type' header. - /// If the request implements , the current user principal from - /// the HTTP context will be applied to the request before processing. - /// - public async Task Execute( - byte[] requestBytes, - ServerCallContext context) + + public override async Task Execute(DispatcherRequest request, ServerCallContext context) { try { + var requestBytes = request.Payload.ToByteArray(); + + _logger.LogInformation("Execute method called with {ByteCount} bytes", requestBytes?.Length ?? 0); + // Get type from metadata header - var messageTypeName = context.RequestHeaders.GetValue(DispatcherMethod.TypeHeader); + var messageTypeName = context.RequestHeaders.GetValue(TypeHeader); + _logger.LogInformation("Message type header value: {MessageTypeName}", messageTypeName ?? "NULL"); + if (string.IsNullOrEmpty(messageTypeName)) { _logger.LogWarning("Missing x-message-type header"); - throw new RpcException(new Status(StatusCode.InvalidArgument, - $"Required header '{DispatcherMethod.TypeHeader}' is missing. Please include the message type name in the request headers.")); + throw new RpcException(new Status(StatusCode.InvalidArgument, + $"Required header '{TypeHeader}' is missing. Please include the message type name in the request headers.")); } // Resolve request message type @@ -88,21 +68,29 @@ public async Task Execute( if (requestType == null) { _logger.LogWarning("Unknown message type: {MessageTypeName}", messageTypeName); - throw new RpcException(new Status(StatusCode.InvalidArgument, + throw new RpcException(new Status(StatusCode.InvalidArgument, $"Unable to resolve message type '{messageTypeName}'. Ensure the type is available in the current context and the assembly-qualified name is correct.")); } - // Single deserialization directly to the actual type - var request = MessagePackSerializer.Deserialize(requestType, requestBytes, _options, context.CancellationToken); - if (request == null) + // Validate request payload + if (requestBytes == null || requestBytes.Length == 0) + { + _logger.LogWarning("Empty request payload for message type: {MessageTypeName}", messageTypeName); + throw new RpcException(new Status(StatusCode.InvalidArgument, + $"The request payload is empty for message type '{messageTypeName}'. A valid serialized request is required.")); + } + + // deserialization of request + var requestMessage = MessagePackSerializer.Deserialize(requestType, requestBytes, _options, context.CancellationToken); + if (requestMessage == null) { _logger.LogWarning("Failed to deserialize request of type: {MessageTypeName}", messageTypeName); - throw new RpcException(new Status(StatusCode.InvalidArgument, + throw new RpcException(new Status(StatusCode.InvalidArgument, $"Failed to deserialize request payload to type '{messageTypeName}'. The message format may be invalid or incompatible.")); } // Apply current user principal if supported - if (request is IRequestPrincipal requestPrincipal) + if (requestMessage is IRequestPrincipal requestPrincipal) { // get current user var httpContext = context.GetHttpContext(); @@ -112,10 +100,14 @@ public async Task Execute( } // Send to Mediator - var response = await _mediator.Send(request, context.CancellationToken).ConfigureAwait(false); + var responseMessage = await _mediator.Send(requestMessage, context.CancellationToken).ConfigureAwait(false); + if (responseMessage == null) + return new DispatcherResponse { Payload = ByteString.Empty }; // Single serialization of response - return MessagePackSerializer.Serialize(response, _options, context.CancellationToken); + var responseBytes = MessagePackSerializer.Serialize(responseMessage, _options, context.CancellationToken); + return new DispatcherResponse { Payload = ByteString.CopyFrom(responseBytes) }; + } catch (Exception ex) when (ex is not RpcException) { diff --git a/src/Arbiter.Dispatcher.Server/DispatcherServiceExtensions.cs b/src/Arbiter.Dispatcher.Server/DispatcherServiceExtensions.cs index f64342b..3736ce0 100644 --- a/src/Arbiter.Dispatcher.Server/DispatcherServiceExtensions.cs +++ b/src/Arbiter.Dispatcher.Server/DispatcherServiceExtensions.cs @@ -13,6 +13,10 @@ public static IServiceCollection AddDispatcherService(this IServiceCollection se { services.AddGrpc(); + // Register the DispatcherService itself + services.TryAddSingleton(); + + // MessagePack Serializer Options Registration services.TryAddSingleton(MessagePackSerializerOptions.Standard .WithResolver(MessagePack.Resolvers.TypelessContractlessStandardResolver.Instance) .WithCompression(MessagePackCompression.Lz4BlockArray)); diff --git a/src/Arbiter.Dispatcher.Server/Protos/dispatcher.proto b/src/Arbiter.Dispatcher.Server/Protos/dispatcher.proto new file mode 100644 index 0000000..e2cca20 --- /dev/null +++ b/src/Arbiter.Dispatcher.Server/Protos/dispatcher.proto @@ -0,0 +1,15 @@ +syntax = "proto3"; + +option csharp_namespace = "Arbiter.Dispatcher"; + +service DispatcherRpc { + rpc Execute (DispatcherRequest) returns (DispatcherResponse); +} + +message DispatcherRequest { + bytes payload = 1; +} + +message DispatcherResponse { + bytes payload = 1; +} From e9488a8b279794b3d0b3c4822e97f5a306977805 Mon Sep 17 00:00:00 2001 From: Paul Welter Date: Wed, 14 Jan 2026 11:11:44 -0600 Subject: [PATCH 4/6] Remove Arbiter.Dispatcher.Endpoints project file --- .../Arbiter.Dispatcher.Endpoints.csproj | 22 ------------------- 1 file changed, 22 deletions(-) delete mode 100644 Arbiter.Dispatcher.Endpoints/Arbiter.Dispatcher.Endpoints.csproj diff --git a/Arbiter.Dispatcher.Endpoints/Arbiter.Dispatcher.Endpoints.csproj b/Arbiter.Dispatcher.Endpoints/Arbiter.Dispatcher.Endpoints.csproj deleted file mode 100644 index 4d5752c..0000000 --- a/Arbiter.Dispatcher.Endpoints/Arbiter.Dispatcher.Endpoints.csproj +++ /dev/null @@ -1,22 +0,0 @@ - - - - net8.0;net9.0;net10.0 - enable - enable - Arbiter Dispatcher Endpoint - - - - - - - - - - - - - - - From 873feb75af981bce2c8a79cd75aadf8f4dd93962 Mon Sep 17 00:00:00 2001 From: Paul Welter Date: Wed, 14 Jan 2026 21:07:09 -0600 Subject: [PATCH 5/6] working prototype --- Directory.Packages.props | 77 +++---- .../Services/ServiceRegistration.cs | 9 +- .../Priority/Models/PriorityCreateModel.cs | 4 +- .../Priority/Models/PriorityReadModel.cs | 3 + .../Priority/Models/PriorityUpdateModel.cs | 3 + .../Domain/Status/Models/StatusCreateModel.cs | 3 + .../Domain/Status/Models/StatusReadModel.cs | 3 + .../Domain/Status/Models/StatusUpdateModel.cs | 3 + .../Domain/Task/Models/TaskCreateModel.cs | 3 + .../Domain/Task/Models/TaskReadModel.cs | 3 + .../Domain/Task/Models/TaskUpdateModel.cs | 3 + .../Domain/Tenant/Models/TenantCreateModel.cs | 3 + .../Domain/Tenant/Models/TenantReadModel.cs | 3 + .../Domain/Tenant/Models/TenantUpdateModel.cs | 3 + .../Domain/User/Models/UserCreateModel.cs | 3 + .../Domain/User/Models/UserReadModel.cs | 3 + .../Domain/User/Models/UserUpdateModel.cs | 3 + .../src/Tracker.Web/Program.cs | 3 +- .../ProblemDetailsCustomizer.cs | 4 +- ...rbiter.CommandQuery.EntityFramework.csproj | 4 +- .../Arbiter.CommandQuery.csproj | 8 +- .../Commands/EntityCreateCommand.cs | 5 +- .../Commands/EntityDeleteCommand.cs | 5 +- .../Commands/EntityIdentifierBase.cs | 1 - .../Commands/EntityIdentifierQuery.cs | 6 +- .../Commands/EntityIdentifiersBase.cs | 1 - .../Commands/EntityIdentifiersQuery.cs | 6 +- .../Commands/EntityKeyQuery.cs | 6 +- .../Commands/EntityModelBase.cs | 1 - .../Commands/EntityPagedQuery.cs | 6 +- .../Commands/EntityPatchCommand.cs | 2 +- .../Commands/EntityUpdateCommand.cs | 7 +- .../Extensions/EnumerableExtensions.cs | 2 +- .../Extensions/ExceptionExtensions.cs | 106 +++++++++ .../Extensions/TypeExtensions.cs | 79 +++++++ .../Models/CompleteModel.cs | 6 +- .../Models/EntityCreateModel.cs | 8 +- .../Models/EntityIdentifierModel.cs | 5 +- .../Models/EntityIdentifiersModel.cs | 5 +- .../Models/EntityReadModel.cs | 9 +- .../Models/EntityUpdateModel.cs | 7 +- .../Models/ProblemDetails.cs | 10 +- .../Models/ValidationResult.cs | 6 +- .../Queries/EntityFilter.cs | 9 +- .../Queries/EntityPagedResult.cs | 7 +- .../Queries/EntityQuery.cs | 10 +- .../Queries/EntitySort.cs | 6 +- .../Arbiter.Communication.csproj | 12 +- .../Arbiter.Dispatcher.Client.csproj | 17 +- .../Client/RemoteDispatcher.cs | 194 +++++++++++++--- .../DispatcherClientExtensions.cs | 134 +++++++++++ .../DispatcherServiceExtensions.cs | 114 ---------- .../Protos/dispatcher.proto | 15 -- .../Arbiter.Dispatcher.Server.csproj | 4 +- .../DispatcherConstants.cs | 64 ++++++ .../DispatcherEndpoint.cs | 160 +++++++++++++ .../DispatcherService.cs | 118 ---------- .../DispatcherServiceExtensions.cs | 36 +-- .../MessagePackResult.cs | 123 ++++++++++ .../Protos/dispatcher.proto | 15 -- .../Arbiter.Mediation.csproj | 4 +- ...rbiter.CommandQuery.Endpoints.Tests.csproj | 2 - ....CommandQuery.EntityFramework.Tests.csproj | 2 - .../Arbiter.CommandQuery.MongoDB.Tests.csproj | 2 - .../Arbiter.CommandQuery.Tests.csproj | 2 - .../Commands/EntityCreateCommandTests.cs | 2 +- .../Extensions/TypeExtensionsTests.cs | 210 ++++++++++++++++++ .../Arbiter.Communication.Tests.csproj | 2 - .../Arbiter.Dispatcher.Client.Tests.csproj | 2 - .../Arbiter.Mediation.Tests.csproj | 2 - .../Arbiter.Services.Tests.csproj | 2 - 71 files changed, 1204 insertions(+), 506 deletions(-) create mode 100644 src/Arbiter.CommandQuery/Extensions/ExceptionExtensions.cs create mode 100644 src/Arbiter.Dispatcher.Client/DispatcherClientExtensions.cs delete mode 100644 src/Arbiter.Dispatcher.Client/DispatcherServiceExtensions.cs delete mode 100644 src/Arbiter.Dispatcher.Client/Protos/dispatcher.proto create mode 100644 src/Arbiter.Dispatcher.Server/DispatcherConstants.cs create mode 100644 src/Arbiter.Dispatcher.Server/DispatcherEndpoint.cs delete mode 100644 src/Arbiter.Dispatcher.Server/DispatcherService.cs create mode 100644 src/Arbiter.Dispatcher.Server/MessagePackResult.cs delete mode 100644 src/Arbiter.Dispatcher.Server/Protos/dispatcher.proto create mode 100644 test/Arbiter.CommandQuery.Tests/Extensions/TypeExtensionsTests.cs diff --git a/Directory.Packages.props b/Directory.Packages.props index 28af296..479fbc0 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -13,7 +13,7 @@ - + @@ -31,53 +31,28 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + - - @@ -90,20 +65,20 @@ - + - + - + - + - + diff --git a/samples/EntityFramework/src/Tracker.Client/Services/ServiceRegistration.cs b/samples/EntityFramework/src/Tracker.Client/Services/ServiceRegistration.cs index cce79e0..46c2280 100644 --- a/samples/EntityFramework/src/Tracker.Client/Services/ServiceRegistration.cs +++ b/samples/EntityFramework/src/Tracker.Client/Services/ServiceRegistration.cs @@ -1,6 +1,7 @@ using System.Text.Json; using Arbiter.Dispatcher; +using Arbiter.Dispatcher.Client; using LoreSoft.Blazor.Controls; @@ -33,11 +34,13 @@ public static void Register(IServiceCollection services, ISet tags) if (tags.Contains("WebAssembly")) { services - .AddRemoteDispatcher(static sp => + .AddRemoteDispatcher((sp, client) => { var hostEnvironment = sp.GetRequiredService>(); - return hostEnvironment.Value.BaseAddress; - }); + client.BaseAddress = new Uri(hostEnvironment.Value.BaseAddress); + }) + .AddHttpMessageHandler(); + } if (tags.Contains("Server")) diff --git a/samples/EntityFramework/src/Tracker.Shared/Domain/Priority/Models/PriorityCreateModel.cs b/samples/EntityFramework/src/Tracker.Shared/Domain/Priority/Models/PriorityCreateModel.cs index 06fef4f..8a1c527 100644 --- a/samples/EntityFramework/src/Tracker.Shared/Domain/Priority/Models/PriorityCreateModel.cs +++ b/samples/EntityFramework/src/Tracker.Shared/Domain/Priority/Models/PriorityCreateModel.cs @@ -1,9 +1,9 @@ -using System; -using System.Collections.Generic; +using MessagePack; namespace Tracker.Domain.Models; [Equatable] +[MessagePackObject(true)] public partial class PriorityCreateModel : EntityCreateModel { diff --git a/samples/EntityFramework/src/Tracker.Shared/Domain/Priority/Models/PriorityReadModel.cs b/samples/EntityFramework/src/Tracker.Shared/Domain/Priority/Models/PriorityReadModel.cs index a3921fd..66c36b4 100644 --- a/samples/EntityFramework/src/Tracker.Shared/Domain/Priority/Models/PriorityReadModel.cs +++ b/samples/EntityFramework/src/Tracker.Shared/Domain/Priority/Models/PriorityReadModel.cs @@ -3,9 +3,12 @@ using Arbiter.CommandQuery.Definitions; +using MessagePack; + namespace Tracker.Domain.Models; [Equatable] +[MessagePackObject(true)] public partial class PriorityReadModel : EntityReadModel, ISupportSearch { diff --git a/samples/EntityFramework/src/Tracker.Shared/Domain/Priority/Models/PriorityUpdateModel.cs b/samples/EntityFramework/src/Tracker.Shared/Domain/Priority/Models/PriorityUpdateModel.cs index f5d4d00..95fb462 100644 --- a/samples/EntityFramework/src/Tracker.Shared/Domain/Priority/Models/PriorityUpdateModel.cs +++ b/samples/EntityFramework/src/Tracker.Shared/Domain/Priority/Models/PriorityUpdateModel.cs @@ -1,9 +1,12 @@ using System; using System.Collections.Generic; +using MessagePack; + namespace Tracker.Domain.Models; [Equatable] +[MessagePackObject(true)] public partial class PriorityUpdateModel : EntityUpdateModel { diff --git a/samples/EntityFramework/src/Tracker.Shared/Domain/Status/Models/StatusCreateModel.cs b/samples/EntityFramework/src/Tracker.Shared/Domain/Status/Models/StatusCreateModel.cs index 22f4926..03400a8 100644 --- a/samples/EntityFramework/src/Tracker.Shared/Domain/Status/Models/StatusCreateModel.cs +++ b/samples/EntityFramework/src/Tracker.Shared/Domain/Status/Models/StatusCreateModel.cs @@ -1,9 +1,12 @@ using System; using System.Collections.Generic; +using MessagePack; + namespace Tracker.Domain.Models; [Equatable] +[MessagePackObject(true)] public partial class StatusCreateModel : EntityCreateModel { diff --git a/samples/EntityFramework/src/Tracker.Shared/Domain/Status/Models/StatusReadModel.cs b/samples/EntityFramework/src/Tracker.Shared/Domain/Status/Models/StatusReadModel.cs index c5b620e..afc1331 100644 --- a/samples/EntityFramework/src/Tracker.Shared/Domain/Status/Models/StatusReadModel.cs +++ b/samples/EntityFramework/src/Tracker.Shared/Domain/Status/Models/StatusReadModel.cs @@ -3,9 +3,12 @@ using Arbiter.CommandQuery.Definitions; +using MessagePack; + namespace Tracker.Domain.Models; [Equatable] +[MessagePackObject(true)] public partial class StatusReadModel : EntityReadModel, ISupportSearch { diff --git a/samples/EntityFramework/src/Tracker.Shared/Domain/Status/Models/StatusUpdateModel.cs b/samples/EntityFramework/src/Tracker.Shared/Domain/Status/Models/StatusUpdateModel.cs index 0b4fbfe..62b2797 100644 --- a/samples/EntityFramework/src/Tracker.Shared/Domain/Status/Models/StatusUpdateModel.cs +++ b/samples/EntityFramework/src/Tracker.Shared/Domain/Status/Models/StatusUpdateModel.cs @@ -1,9 +1,12 @@ using System; using System.Collections.Generic; +using MessagePack; + namespace Tracker.Domain.Models; [Equatable] +[MessagePackObject(true)] public partial class StatusUpdateModel : EntityUpdateModel { diff --git a/samples/EntityFramework/src/Tracker.Shared/Domain/Task/Models/TaskCreateModel.cs b/samples/EntityFramework/src/Tracker.Shared/Domain/Task/Models/TaskCreateModel.cs index 009dccc..6e12ab0 100644 --- a/samples/EntityFramework/src/Tracker.Shared/Domain/Task/Models/TaskCreateModel.cs +++ b/samples/EntityFramework/src/Tracker.Shared/Domain/Task/Models/TaskCreateModel.cs @@ -1,9 +1,12 @@ using System; using System.Collections.Generic; +using MessagePack; + namespace Tracker.Domain.Models; [Equatable] +[MessagePackObject(true)] public partial class TaskCreateModel : EntityCreateModel { diff --git a/samples/EntityFramework/src/Tracker.Shared/Domain/Task/Models/TaskReadModel.cs b/samples/EntityFramework/src/Tracker.Shared/Domain/Task/Models/TaskReadModel.cs index 892d80a..e0e7e07 100644 --- a/samples/EntityFramework/src/Tracker.Shared/Domain/Task/Models/TaskReadModel.cs +++ b/samples/EntityFramework/src/Tracker.Shared/Domain/Task/Models/TaskReadModel.cs @@ -2,9 +2,12 @@ using Arbiter.CommandQuery.Definitions; +using MessagePack; + namespace Tracker.Domain.Models; [Equatable] +[MessagePackObject(true)] public partial class TaskReadModel : EntityReadModel, ISupportSearch { diff --git a/samples/EntityFramework/src/Tracker.Shared/Domain/Task/Models/TaskUpdateModel.cs b/samples/EntityFramework/src/Tracker.Shared/Domain/Task/Models/TaskUpdateModel.cs index 7622d2e..b8fb6c4 100644 --- a/samples/EntityFramework/src/Tracker.Shared/Domain/Task/Models/TaskUpdateModel.cs +++ b/samples/EntityFramework/src/Tracker.Shared/Domain/Task/Models/TaskUpdateModel.cs @@ -1,9 +1,12 @@ using System; using System.Collections.Generic; +using MessagePack; + namespace Tracker.Domain.Models; [Equatable] +[MessagePackObject(true)] public partial class TaskUpdateModel : EntityUpdateModel { diff --git a/samples/EntityFramework/src/Tracker.Shared/Domain/Tenant/Models/TenantCreateModel.cs b/samples/EntityFramework/src/Tracker.Shared/Domain/Tenant/Models/TenantCreateModel.cs index ffdd858..e412d1c 100644 --- a/samples/EntityFramework/src/Tracker.Shared/Domain/Tenant/Models/TenantCreateModel.cs +++ b/samples/EntityFramework/src/Tracker.Shared/Domain/Tenant/Models/TenantCreateModel.cs @@ -1,9 +1,12 @@ using System; using System.Collections.Generic; +using MessagePack; + namespace Tracker.Domain.Models; [Equatable] +[MessagePackObject(true)] public partial class TenantCreateModel : EntityCreateModel { diff --git a/samples/EntityFramework/src/Tracker.Shared/Domain/Tenant/Models/TenantReadModel.cs b/samples/EntityFramework/src/Tracker.Shared/Domain/Tenant/Models/TenantReadModel.cs index 83b9849..b0120d8 100644 --- a/samples/EntityFramework/src/Tracker.Shared/Domain/Tenant/Models/TenantReadModel.cs +++ b/samples/EntityFramework/src/Tracker.Shared/Domain/Tenant/Models/TenantReadModel.cs @@ -3,9 +3,12 @@ using Arbiter.CommandQuery.Definitions; +using MessagePack; + namespace Tracker.Domain.Models; [Equatable] +[MessagePackObject(true)] public partial class TenantReadModel : EntityReadModel, ISupportSearch { diff --git a/samples/EntityFramework/src/Tracker.Shared/Domain/Tenant/Models/TenantUpdateModel.cs b/samples/EntityFramework/src/Tracker.Shared/Domain/Tenant/Models/TenantUpdateModel.cs index 6ab2deb..d6da5a8 100644 --- a/samples/EntityFramework/src/Tracker.Shared/Domain/Tenant/Models/TenantUpdateModel.cs +++ b/samples/EntityFramework/src/Tracker.Shared/Domain/Tenant/Models/TenantUpdateModel.cs @@ -1,9 +1,12 @@ using System; using System.Collections.Generic; +using MessagePack; + namespace Tracker.Domain.Models; [Equatable] +[MessagePackObject(true)] public partial class TenantUpdateModel : EntityUpdateModel { diff --git a/samples/EntityFramework/src/Tracker.Shared/Domain/User/Models/UserCreateModel.cs b/samples/EntityFramework/src/Tracker.Shared/Domain/User/Models/UserCreateModel.cs index 06e3de3..5895103 100644 --- a/samples/EntityFramework/src/Tracker.Shared/Domain/User/Models/UserCreateModel.cs +++ b/samples/EntityFramework/src/Tracker.Shared/Domain/User/Models/UserCreateModel.cs @@ -1,9 +1,12 @@ using System; using System.Collections.Generic; +using MessagePack; + namespace Tracker.Domain.Models; [Equatable] +[MessagePackObject(true)] public partial class UserCreateModel : EntityCreateModel { diff --git a/samples/EntityFramework/src/Tracker.Shared/Domain/User/Models/UserReadModel.cs b/samples/EntityFramework/src/Tracker.Shared/Domain/User/Models/UserReadModel.cs index 3a806ab..65f51d1 100644 --- a/samples/EntityFramework/src/Tracker.Shared/Domain/User/Models/UserReadModel.cs +++ b/samples/EntityFramework/src/Tracker.Shared/Domain/User/Models/UserReadModel.cs @@ -4,9 +4,12 @@ using Arbiter.CommandQuery.Definitions; +using MessagePack; + namespace Tracker.Domain.Models; [Equatable] +[MessagePackObject(true)] public partial class UserReadModel : EntityReadModel, ISupportSearch { diff --git a/samples/EntityFramework/src/Tracker.Shared/Domain/User/Models/UserUpdateModel.cs b/samples/EntityFramework/src/Tracker.Shared/Domain/User/Models/UserUpdateModel.cs index 033cb09..44ec1cf 100644 --- a/samples/EntityFramework/src/Tracker.Shared/Domain/User/Models/UserUpdateModel.cs +++ b/samples/EntityFramework/src/Tracker.Shared/Domain/User/Models/UserUpdateModel.cs @@ -1,9 +1,12 @@ using System; using System.Collections.Generic; +using MessagePack; + namespace Tracker.Domain.Models; [Equatable] +[MessagePackObject(true)] public partial class UserUpdateModel : EntityUpdateModel { diff --git a/samples/EntityFramework/src/Tracker.Web/Program.cs b/samples/EntityFramework/src/Tracker.Web/Program.cs index 60ee03b..631d59b 100644 --- a/samples/EntityFramework/src/Tracker.Web/Program.cs +++ b/samples/EntityFramework/src/Tracker.Web/Program.cs @@ -160,7 +160,8 @@ private static void ConfigureMiddleware(WebApplication app) .AddAdditionalAssemblies(typeof(Client.Routes).Assembly); app.MapEndpointRoutes(); - app.MapDispatchService(); + + app.MapDispatcherService().RequireAuthorization(); } } diff --git a/src/Arbiter.CommandQuery.Endpoints/ProblemDetailsCustomizer.cs b/src/Arbiter.CommandQuery.Endpoints/ProblemDetailsCustomizer.cs index 435c8da..d6fa2b7 100644 --- a/src/Arbiter.CommandQuery.Endpoints/ProblemDetailsCustomizer.cs +++ b/src/Arbiter.CommandQuery.Endpoints/ProblemDetailsCustomizer.cs @@ -99,7 +99,7 @@ private static void AddValidationErrors(ProblemDetailsContext context, IDictiona public static ProblemDetails ToProblemDetails(this Exception exception) { var problemDetails = new ProblemDetails(); - switch (exception) + switch (exception.Flatten()) { case System.ComponentModel.DataAnnotations.ValidationException validationException: { @@ -116,7 +116,7 @@ public static ProblemDetails ToProblemDetails(this Exception exception) problemDetails.Extensions.Add("errors", errors); break; } - case Arbiter.CommandQuery.DomainException domainException: + case DomainException domainException: { var reasonPhrase = ReasonPhrases.GetReasonPhrase(domainException.StatusCode); if (reasonPhrase.IsNullOrWhiteSpace()) diff --git a/src/Arbiter.CommandQuery.EntityFramework/Arbiter.CommandQuery.EntityFramework.csproj b/src/Arbiter.CommandQuery.EntityFramework/Arbiter.CommandQuery.EntityFramework.csproj index 1bb6ee9..1a74ad4 100644 --- a/src/Arbiter.CommandQuery.EntityFramework/Arbiter.CommandQuery.EntityFramework.csproj +++ b/src/Arbiter.CommandQuery.EntityFramework/Arbiter.CommandQuery.EntityFramework.csproj @@ -11,11 +11,11 @@ - + - + diff --git a/src/Arbiter.CommandQuery/Arbiter.CommandQuery.csproj b/src/Arbiter.CommandQuery/Arbiter.CommandQuery.csproj index 1190e85..055a86f 100644 --- a/src/Arbiter.CommandQuery/Arbiter.CommandQuery.csproj +++ b/src/Arbiter.CommandQuery/Arbiter.CommandQuery.csproj @@ -22,13 +22,13 @@ - - + + - - + + diff --git a/src/Arbiter.CommandQuery/Commands/EntityCreateCommand.cs b/src/Arbiter.CommandQuery/Commands/EntityCreateCommand.cs index ed4e5c8..fdbc525 100644 --- a/src/Arbiter.CommandQuery/Commands/EntityCreateCommand.cs +++ b/src/Arbiter.CommandQuery/Commands/EntityCreateCommand.cs @@ -84,8 +84,8 @@ namespace Arbiter.CommandQuery.Commands; /// /// /// -[MessagePackObject] -public partial record EntityCreateCommand +[MessagePackObject(true)] +public record EntityCreateCommand : EntityModelBase, ICacheExpire { /// @@ -176,7 +176,6 @@ public EntityCreateCommand( /// filterName: "bulk-import"); /// /// - [Key(1)] [JsonPropertyName("filterName")] public string? FilterName { get; } diff --git a/src/Arbiter.CommandQuery/Commands/EntityDeleteCommand.cs b/src/Arbiter.CommandQuery/Commands/EntityDeleteCommand.cs index beee402..0ea17b5 100644 --- a/src/Arbiter.CommandQuery/Commands/EntityDeleteCommand.cs +++ b/src/Arbiter.CommandQuery/Commands/EntityDeleteCommand.cs @@ -60,8 +60,8 @@ namespace Arbiter.CommandQuery.Commands; /// /// /// -[MessagePackObject] -public partial record EntityDeleteCommand +[MessagePackObject(true)] +public record EntityDeleteCommand : EntityIdentifierBase, ICacheExpire { /// @@ -137,7 +137,6 @@ public EntityDeleteCommand( /// filterName: "hard-delete"); /// /// - [Key(1)] [JsonPropertyName("filterName")] public string? FilterName { get; } diff --git a/src/Arbiter.CommandQuery/Commands/EntityIdentifierBase.cs b/src/Arbiter.CommandQuery/Commands/EntityIdentifierBase.cs index 40ce037..7618b4a 100644 --- a/src/Arbiter.CommandQuery/Commands/EntityIdentifierBase.cs +++ b/src/Arbiter.CommandQuery/Commands/EntityIdentifierBase.cs @@ -57,7 +57,6 @@ protected EntityIdentifierBase(ClaimsPrincipal? principal, [NotNull] TKey id) /// /// The identifier of the entity for this command. /// - [Key(0)] [NotNull] [JsonPropertyName("id")] public TKey Id { get; } diff --git a/src/Arbiter.CommandQuery/Commands/EntityIdentifierQuery.cs b/src/Arbiter.CommandQuery/Commands/EntityIdentifierQuery.cs index 68757ef..54a3128 100644 --- a/src/Arbiter.CommandQuery/Commands/EntityIdentifierQuery.cs +++ b/src/Arbiter.CommandQuery/Commands/EntityIdentifierQuery.cs @@ -63,8 +63,8 @@ namespace Arbiter.CommandQuery.Commands; /// /// /// -[MessagePackObject] -public partial record EntityIdentifierQuery : CacheableQueryBase +[MessagePackObject(true)] +public record EntityIdentifierQuery : CacheableQueryBase { /// /// Initializes a new instance of the class. @@ -121,7 +121,6 @@ public EntityIdentifierQuery([NotNull] TKey id, string? filterName = null) /// This identifier is used to locate the specific entity instance and is also incorporated into the cache key /// to ensure that each entity is cached independently. /// - [Key(0)] [NotNull] [JsonPropertyName("id")] public TKey Id { get; } @@ -155,7 +154,6 @@ public EntityIdentifierQuery([NotNull] TKey id, string? filterName = null) /// filterName: "detailed-view"); /// /// - [Key(1)] [JsonPropertyName("filterName")] public string? FilterName { get; } diff --git a/src/Arbiter.CommandQuery/Commands/EntityIdentifiersBase.cs b/src/Arbiter.CommandQuery/Commands/EntityIdentifiersBase.cs index b46c657..2fcb565 100644 --- a/src/Arbiter.CommandQuery/Commands/EntityIdentifiersBase.cs +++ b/src/Arbiter.CommandQuery/Commands/EntityIdentifiersBase.cs @@ -57,7 +57,6 @@ protected EntityIdentifiersBase(ClaimsPrincipal? principal, [NotNull] IReadOnlyL /// /// The collection of identifiers for this command. /// - [Key(0)] [JsonPropertyName("ids")] public IReadOnlyList Ids { get; } } diff --git a/src/Arbiter.CommandQuery/Commands/EntityIdentifiersQuery.cs b/src/Arbiter.CommandQuery/Commands/EntityIdentifiersQuery.cs index 60f8b52..516c1e5 100644 --- a/src/Arbiter.CommandQuery/Commands/EntityIdentifiersQuery.cs +++ b/src/Arbiter.CommandQuery/Commands/EntityIdentifiersQuery.cs @@ -75,8 +75,8 @@ namespace Arbiter.CommandQuery.Commands; /// /// /// -[MessagePackObject] -public partial record EntityIdentifiersQuery : CacheableQueryBase> +[MessagePackObject(true)] +public record EntityIdentifiersQuery : CacheableQueryBase> { /// /// Initializes a new instance of the class. @@ -146,7 +146,6 @@ public EntityIdentifiersQuery([NotNull] IReadOnlyList ids, string? filterN /// different sets of identifiers produce different cache entries. /// /// - [Key(0)] [NotNull] [JsonPropertyName("ids")] public IReadOnlyList Ids { get; } @@ -180,7 +179,6 @@ public EntityIdentifiersQuery([NotNull] IReadOnlyList ids, string? filterN /// filterName: "bulk-admin"); /// /// - [Key(1)] [JsonPropertyName("filterName")] public string? FilterName { get; } diff --git a/src/Arbiter.CommandQuery/Commands/EntityKeyQuery.cs b/src/Arbiter.CommandQuery/Commands/EntityKeyQuery.cs index d233265..a3d17a2 100644 --- a/src/Arbiter.CommandQuery/Commands/EntityKeyQuery.cs +++ b/src/Arbiter.CommandQuery/Commands/EntityKeyQuery.cs @@ -72,8 +72,8 @@ namespace Arbiter.CommandQuery.Commands; /// /// /// -[MessagePackObject] -public partial record EntityKeyQuery : CacheableQueryBase +[MessagePackObject(true)] +public record EntityKeyQuery : CacheableQueryBase { /// /// Initializes a new instance of the class. @@ -151,7 +151,6 @@ public EntityKeyQuery(Guid key, string? filterName = null) /// The key is incorporated into the cache key generation to ensure that each unique entity is cached separately. /// /// - [Key(0)] [NotNull] [JsonPropertyName("key")] public Guid Key { get; } @@ -185,7 +184,6 @@ public EntityKeyQuery(Guid key, string? filterName = null) /// filterName: "public-api"); /// /// - [Key(1)] [JsonPropertyName("filterName")] public string? FilterName { get; } diff --git a/src/Arbiter.CommandQuery/Commands/EntityModelBase.cs b/src/Arbiter.CommandQuery/Commands/EntityModelBase.cs index c023f2d..f1def92 100644 --- a/src/Arbiter.CommandQuery/Commands/EntityModelBase.cs +++ b/src/Arbiter.CommandQuery/Commands/EntityModelBase.cs @@ -65,7 +65,6 @@ protected EntityModelBase(ClaimsPrincipal? principal, [NotNull] TEntityModel mod /// /// The view model containing the data for the operation. /// - [Key(0)] [NotNull] [JsonPropertyName("model")] public TEntityModel Model { get; } diff --git a/src/Arbiter.CommandQuery/Commands/EntityPagedQuery.cs b/src/Arbiter.CommandQuery/Commands/EntityPagedQuery.cs index 47ca0ee..b069f15 100644 --- a/src/Arbiter.CommandQuery/Commands/EntityPagedQuery.cs +++ b/src/Arbiter.CommandQuery/Commands/EntityPagedQuery.cs @@ -49,8 +49,8 @@ namespace Arbiter.CommandQuery.Commands; /// /// /// -[MessagePackObject] -public partial record EntityPagedQuery : CacheableQueryBase> +[MessagePackObject(true)] +public record EntityPagedQuery : CacheableQueryBase> { /// /// Initializes a new instance of the class. @@ -100,7 +100,6 @@ public EntityPagedQuery(EntityQuery? query, string? filterName = null) /// An object containing the filtering, sorting, and pagination configuration. /// This property is never as it is initialized in the constructor. /// - [Key(0)] [JsonPropertyName("query")] public EntityQuery Query { get; } @@ -115,7 +114,6 @@ public EntityPagedQuery(EntityQuery? query, string? filterName = null) /// security policies, or data transformations based on the execution context. The specific behavior depends /// on the registered query pipeline modifiers in the application. /// - [Key(1)] [JsonPropertyName("filterName")] public string? FilterName { get; } diff --git a/src/Arbiter.CommandQuery/Commands/EntityPatchCommand.cs b/src/Arbiter.CommandQuery/Commands/EntityPatchCommand.cs index 467a659..5a6e164 100644 --- a/src/Arbiter.CommandQuery/Commands/EntityPatchCommand.cs +++ b/src/Arbiter.CommandQuery/Commands/EntityPatchCommand.cs @@ -35,7 +35,7 @@ namespace Arbiter.CommandQuery.Commands; /// Console.WriteLine($"Updated product name: {result?.Name}"); /// /// -public partial record EntityPatchCommand +public record EntityPatchCommand : EntityIdentifierBase, ICacheExpire { /// diff --git a/src/Arbiter.CommandQuery/Commands/EntityUpdateCommand.cs b/src/Arbiter.CommandQuery/Commands/EntityUpdateCommand.cs index f33bebb..d4e1a09 100644 --- a/src/Arbiter.CommandQuery/Commands/EntityUpdateCommand.cs +++ b/src/Arbiter.CommandQuery/Commands/EntityUpdateCommand.cs @@ -82,8 +82,8 @@ namespace Arbiter.CommandQuery.Commands; /// /// /// -[MessagePackObject] -public partial record EntityUpdateCommand +[MessagePackObject(true)] +public record EntityUpdateCommand : EntityModelBase, ICacheExpire { /// @@ -164,7 +164,6 @@ public EntityUpdateCommand( /// This identifier is used to locate the specific entity instance to update. If is /// and no entity with this identifier exists, a new entity will be created with this identifier. /// - [Key(1)] [NotNull] [JsonPropertyName("id")] public TKey Id { get; } @@ -210,7 +209,6 @@ public EntityUpdateCommand( /// principal, productId, updateModel, upsert: true); /// /// - [Key(2)] [JsonPropertyName("upsert")] public bool Upsert { get; } @@ -245,7 +243,6 @@ public EntityUpdateCommand( /// filterName: "bulk-update"); /// /// - [Key(3)] [JsonPropertyName("filterName")] public string? FilterName { get; } diff --git a/src/Arbiter.CommandQuery/Extensions/EnumerableExtensions.cs b/src/Arbiter.CommandQuery/Extensions/EnumerableExtensions.cs index 517511d..b264614 100644 --- a/src/Arbiter.CommandQuery/Extensions/EnumerableExtensions.cs +++ b/src/Arbiter.CommandQuery/Extensions/EnumerableExtensions.cs @@ -3,7 +3,7 @@ namespace Arbiter.CommandQuery.Extensions; /// /// Extension methods for /// -public static partial class EnumerableExtensions +public static class EnumerableExtensions { /// /// Converts an IEnumerable of values to a delimited string. diff --git a/src/Arbiter.CommandQuery/Extensions/ExceptionExtensions.cs b/src/Arbiter.CommandQuery/Extensions/ExceptionExtensions.cs new file mode 100644 index 0000000..457fd4b --- /dev/null +++ b/src/Arbiter.CommandQuery/Extensions/ExceptionExtensions.cs @@ -0,0 +1,106 @@ +using System.Diagnostics.CodeAnalysis; +using System.Net; + +using Arbiter.CommandQuery.Models; + +namespace Arbiter.CommandQuery.Extensions; + +/// +/// Extension methods for . +/// +public static class ExceptionExtensions +{ + /// + /// Flattens an into a single instance. + /// + [return: NotNullIfNotNull(nameof(exception))] + public static Exception? Flatten(this Exception? exception) + { + if (exception == null) + return null; + + if (exception is not AggregateException aggregateException) + return exception; + + // flatten aggregate exceptions with a single inner exception + aggregateException = aggregateException.Flatten(); + + return aggregateException.InnerExceptions?.Count == 1 + ? aggregateException.InnerExceptions[0] + : aggregateException; + } + + /// + /// Converts an exception to a object. + /// + /// The exception to convert + /// A instance based on the exception + public static ProblemDetails ToProblemDetails(this Exception exception) + { + var problemDetails = new ProblemDetails(); + switch (Flatten(exception)) + { + case System.ComponentModel.DataAnnotations.ValidationException validationException: + { + var errors = new Dictionary(StringComparer.Ordinal); + + if (validationException.ValidationResult.ErrorMessage != null) + { + foreach (var memberName in validationException.ValidationResult.MemberNames) + errors[memberName] = [validationException.ValidationResult.ErrorMessage]; + } + + problemDetails.Title = "One or more validation errors occurred."; + problemDetails.Status = (int)HttpStatusCode.BadRequest; + problemDetails.Extensions.Add("errors", errors); + break; + } + case DomainException domainException: + { + var reasonPhrase = GetReasonPhrase(domainException.StatusCode); + if (reasonPhrase.IsNullOrWhiteSpace()) + reasonPhrase = "Internal Server Error"; + + problemDetails.Title = reasonPhrase; + problemDetails.Status = domainException.StatusCode; + + if (domainException.Errors != null) + problemDetails.Extensions.Add("errors", domainException.Errors); + + break; + } + default: + { + problemDetails.Title = "Internal Server Error."; + problemDetails.Status = 500; + break; + } + } + + if (exception != null) + { + problemDetails.Detail = exception.Message; + problemDetails.Extensions.Add("exception", exception.ToString()); + } + + return problemDetails; + } + + /// + /// Gets the HTTP reason phrase for the specified status code. + /// + /// The HTTP status code. + /// The reason phrase for common status codes, or a generic "Status {code}" for others. + private static string GetReasonPhrase(int statusCode) + { + return statusCode switch + { + (int)HttpStatusCode.BadRequest => "Bad Request", + (int)HttpStatusCode.Unauthorized => "Unauthorized", + (int)HttpStatusCode.Forbidden => "Forbidden", + (int)HttpStatusCode.NotFound => "Not Found", + (int)HttpStatusCode.InternalServerError => "Internal Server Error", + _ => $"Status {statusCode}", + }; + } +} diff --git a/src/Arbiter.CommandQuery/Extensions/TypeExtensions.cs b/src/Arbiter.CommandQuery/Extensions/TypeExtensions.cs index b23d1b7..6a18284 100644 --- a/src/Arbiter.CommandQuery/Extensions/TypeExtensions.cs +++ b/src/Arbiter.CommandQuery/Extensions/TypeExtensions.cs @@ -1,3 +1,9 @@ +using System.Net; + +using Arbiter.CommandQuery; +using Arbiter.CommandQuery.Models; +using Arbiter.Services; + namespace Arbiter.CommandQuery.Extensions; /// @@ -24,4 +30,77 @@ public static bool Implements(this Type type) return interfaceType.IsAssignableFrom(type); } + + /// + /// Gets a portable fully qualified name for a Type, suitable for serialization headers. + /// Removes Version, Culture, and PublicKeyToken from the assembly qualified name. + /// Supports nested generic types with minimal allocations. + /// + public static string GetPortableName(this Type type) + { + ArgumentNullException.ThrowIfNull(type); + + var handler = new ValueStringBuilder(stackalloc char[256]); + try + { + AppendTypeName(ref handler, type); + return handler.ToString(); + } + finally + { + handler.Dispose(); + } + } + + private static void AppendTypeName(ref ValueStringBuilder builder, Type type) + { + // Handle generic type definitions + if (!type.IsGenericType) + { + // Non-generic type + builder.Append(type.FullName ?? type.Name); + builder.Append(", "); + + AppendAssemblyName(ref builder, type.Assembly.FullName); + return; + } + + var genericTypeDef = type.GetGenericTypeDefinition(); + var fullName = genericTypeDef.FullName ?? genericTypeDef.Name; + + builder.Append(fullName); + + // Append generic arguments + builder.Append('['); + var genericArgs = type.GetGenericArguments(); + for (int i = 0; i < genericArgs.Length; i++) + { + if (i > 0) + builder.Append(','); + + builder.Append('['); + AppendTypeName(ref builder, genericArgs[i]); + builder.Append(']'); + } + builder.Append(']'); + + // Append assembly name (portable version) + builder.Append(", "); + AppendAssemblyName(ref builder, genericTypeDef.Assembly.FullName); + } + + private static void AppendAssemblyName(ref ValueStringBuilder builder, string? assemblyFullName) + { + if (assemblyFullName == null) + return; + + var span = assemblyFullName.AsSpan(); + + // Find the first comma (separates name from version/culture/token) + var commaIndex = span.IndexOf(','); + if (commaIndex > 0) + builder.Append(span[..commaIndex]); + else + builder.Append(span); + } } diff --git a/src/Arbiter.CommandQuery/Models/CompleteModel.cs b/src/Arbiter.CommandQuery/Models/CompleteModel.cs index ae42e56..96241ac 100644 --- a/src/Arbiter.CommandQuery/Models/CompleteModel.cs +++ b/src/Arbiter.CommandQuery/Models/CompleteModel.cs @@ -7,8 +7,8 @@ namespace Arbiter.CommandQuery.Models; /// /// Operation complete result model /// -[MessagePackObject] -public partial class CompleteModel +[MessagePackObject(true)] +public class CompleteModel { /// /// Gets or sets a value indicating whether operation was successful. @@ -16,7 +16,6 @@ public partial class CompleteModel /// /// if was successful; otherwise, . /// - [Key(0)] [JsonPropertyName("successful")] public bool Successful { get; set; } @@ -26,7 +25,6 @@ public partial class CompleteModel /// /// The operation result message. /// - [Key(1)] [JsonPropertyName("message")] [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] public string? Message { get; set; } diff --git a/src/Arbiter.CommandQuery/Models/EntityCreateModel.cs b/src/Arbiter.CommandQuery/Models/EntityCreateModel.cs index 8ab0dd2..9cbf0ec 100644 --- a/src/Arbiter.CommandQuery/Models/EntityCreateModel.cs +++ b/src/Arbiter.CommandQuery/Models/EntityCreateModel.cs @@ -13,30 +13,26 @@ namespace Arbiter.CommandQuery.Models; /// /// /// -[MessagePackObject] -public partial class EntityCreateModel : EntityIdentifierModel, ITrackCreated, ITrackUpdated +[MessagePackObject(true)] +public class EntityCreateModel : EntityIdentifierModel, ITrackCreated, ITrackUpdated { /// - [Key(1)] [JsonPropertyName("created")] [JsonPropertyOrder(9990)] public DateTimeOffset Created { get; set; } = DateTimeOffset.UtcNow; /// - [Key(2)] [JsonPropertyName("createdBy")] [JsonPropertyOrder(9991)] [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] public string? CreatedBy { get; set; } /// - [Key(3)] [JsonPropertyName("updated")] [JsonPropertyOrder(9992)] public DateTimeOffset Updated { get; set; } = DateTimeOffset.UtcNow; /// - [Key(4)] [JsonPropertyName("updatedBy")] [JsonPropertyOrder(9993)] [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] diff --git a/src/Arbiter.CommandQuery/Models/EntityIdentifierModel.cs b/src/Arbiter.CommandQuery/Models/EntityIdentifierModel.cs index eae97e5..5e93657 100644 --- a/src/Arbiter.CommandQuery/Models/EntityIdentifierModel.cs +++ b/src/Arbiter.CommandQuery/Models/EntityIdentifierModel.cs @@ -12,11 +12,10 @@ namespace Arbiter.CommandQuery.Models; /// /// The type of the key. /// -[MessagePackObject] -public partial class EntityIdentifierModel : IHaveIdentifier +[MessagePackObject(true)] +public class EntityIdentifierModel : IHaveIdentifier { /// - [Key(0)] [NotNull] [JsonPropertyName("id")] [JsonPropertyOrder(-9999)] diff --git a/src/Arbiter.CommandQuery/Models/EntityIdentifiersModel.cs b/src/Arbiter.CommandQuery/Models/EntityIdentifiersModel.cs index 19d37b6..75dcb37 100644 --- a/src/Arbiter.CommandQuery/Models/EntityIdentifiersModel.cs +++ b/src/Arbiter.CommandQuery/Models/EntityIdentifiersModel.cs @@ -9,8 +9,8 @@ namespace Arbiter.CommandQuery.Models; /// An identifiers base model /// /// The type of the key. -[MessagePackObject] -public partial class EntityIdentifiersModel +[MessagePackObject(true)] +public class EntityIdentifiersModel { /// /// Gets or sets the list of identifiers. @@ -18,7 +18,6 @@ public partial class EntityIdentifiersModel /// /// The list of identifiers. /// - [Key(0)] [NotNull] [JsonPropertyName("ids")] public IReadOnlyCollection Ids { get; set; } = null!; diff --git a/src/Arbiter.CommandQuery/Models/EntityReadModel.cs b/src/Arbiter.CommandQuery/Models/EntityReadModel.cs index 051c761..45b4bfd 100644 --- a/src/Arbiter.CommandQuery/Models/EntityReadModel.cs +++ b/src/Arbiter.CommandQuery/Models/EntityReadModel.cs @@ -14,37 +14,32 @@ namespace Arbiter.CommandQuery.Models; /// /// /// -[MessagePackObject] -public partial class EntityReadModel : EntityIdentifierModel, ITrackCreated, ITrackUpdated, ITrackConcurrency +[MessagePackObject(true)] +public class EntityReadModel : EntityIdentifierModel, ITrackCreated, ITrackUpdated, ITrackConcurrency { /// - [Key(1)] [JsonPropertyName("created")] [JsonPropertyOrder(9990)] public DateTimeOffset Created { get; set; } = DateTimeOffset.UtcNow; /// - [Key(2)] [JsonPropertyName("createdBy")] [JsonPropertyOrder(9991)] [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] public string? CreatedBy { get; set; } /// - [Key(3)] [JsonPropertyName("updated")] [JsonPropertyOrder(9992)] public DateTimeOffset Updated { get; set; } = DateTimeOffset.UtcNow; /// - [Key(4)] [JsonPropertyName("updatedBy")] [JsonPropertyOrder(9993)] [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] public string? UpdatedBy { get; set; } /// - [Key(5)] [JsonPropertyName("rowVersion")] [JsonPropertyOrder(9999)] public long RowVersion { get; set; } diff --git a/src/Arbiter.CommandQuery/Models/EntityUpdateModel.cs b/src/Arbiter.CommandQuery/Models/EntityUpdateModel.cs index 03084f0..55e7f74 100644 --- a/src/Arbiter.CommandQuery/Models/EntityUpdateModel.cs +++ b/src/Arbiter.CommandQuery/Models/EntityUpdateModel.cs @@ -11,24 +11,21 @@ namespace Arbiter.CommandQuery.Models; /// /// /// -[MessagePackObject] -public partial class EntityUpdateModel : ITrackUpdated, ITrackConcurrency +[MessagePackObject(true)] +public class EntityUpdateModel : ITrackUpdated, ITrackConcurrency { /// - [Key(0)] [JsonPropertyName("updated")] [JsonPropertyOrder(9992)] public DateTimeOffset Updated { get; set; } = DateTimeOffset.UtcNow; /// - [Key(1)] [JsonPropertyName("updatedBy")] [JsonPropertyOrder(9993)] [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] public string? UpdatedBy { get; set; } /// - [Key(2)] [JsonPropertyName("rowVersion")] [JsonPropertyOrder(9999)] public long RowVersion { get; set; } diff --git a/src/Arbiter.CommandQuery/Models/ProblemDetails.cs b/src/Arbiter.CommandQuery/Models/ProblemDetails.cs index 19f5f56..829392a 100644 --- a/src/Arbiter.CommandQuery/Models/ProblemDetails.cs +++ b/src/Arbiter.CommandQuery/Models/ProblemDetails.cs @@ -7,8 +7,8 @@ namespace Arbiter.CommandQuery.Models; /// /// A machine-readable format for specifying errors in HTTP API responses based on https://tools.ietf.org/html/rfc7807. /// -[MessagePackObject] -public partial class ProblemDetails +[MessagePackObject(true)] +public class ProblemDetails { /// /// The content-type for a problem json response @@ -21,7 +21,6 @@ public partial class ProblemDetails /// (e.g., using HTML [W3C.REC-html5-20141028]). When this member is not present, its value is assumed to be /// "about:blank". /// - [Key(0)] [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] [JsonPropertyOrder(-5)] [JsonPropertyName("type")] @@ -32,7 +31,6 @@ public partial class ProblemDetails /// of the problem, except for purposes of localization(e.g., using proactive content negotiation; /// see[RFC7231], Section 3.4). /// - [Key(1)] [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] [JsonPropertyOrder(-4)] [JsonPropertyName("title")] @@ -41,7 +39,6 @@ public partial class ProblemDetails /// /// The HTTP status code([RFC7231], Section 6) generated by the origin server for this occurrence of the problem. /// - [Key(2)] [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] [JsonPropertyOrder(-3)] [JsonPropertyName("status")] @@ -50,7 +47,6 @@ public partial class ProblemDetails /// /// A human-readable explanation specific to this occurrence of the problem. /// - [Key(3)] [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] [JsonPropertyOrder(-2)] [JsonPropertyName("detail")] @@ -59,7 +55,6 @@ public partial class ProblemDetails /// /// A URI reference that identifies the specific occurrence of the problem. It may or may not yield further information if dereferenced. /// - [Key(4)] [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] [JsonPropertyOrder(-1)] [JsonPropertyName("instance")] @@ -68,7 +63,6 @@ public partial class ProblemDetails /// /// Gets the validation errors associated with this instance of problem details /// - [Key(5)] [JsonPropertyName("errors")] public IDictionary Errors { get; set; } = new Dictionary(StringComparer.Ordinal); diff --git a/src/Arbiter.CommandQuery/Models/ValidationResult.cs b/src/Arbiter.CommandQuery/Models/ValidationResult.cs index 0e66224..bf442e0 100644 --- a/src/Arbiter.CommandQuery/Models/ValidationResult.cs +++ b/src/Arbiter.CommandQuery/Models/ValidationResult.cs @@ -7,20 +7,18 @@ namespace Arbiter.CommandQuery.Models; /// /// A class that represents the result of a validation. /// -[MessagePackObject] -public partial class ValidationResult +[MessagePackObject(true)] +public class ValidationResult { /// /// Gets or sets whether the validation was successful. /// - [Key(0)] [JsonPropertyName("isValid")] public bool IsValid => Errors.Count == 0; /// /// Gets or sets the validation errors. The dictionary key is the property name, and the value is an array of error messages. /// - [Key(1)] [JsonPropertyName("errors")] public IDictionary Errors { get; set; } = new Dictionary(StringComparer.Ordinal); diff --git a/src/Arbiter.CommandQuery/Queries/EntityFilter.cs b/src/Arbiter.CommandQuery/Queries/EntityFilter.cs index 757e5e1..919c9df 100644 --- a/src/Arbiter.CommandQuery/Queries/EntityFilter.cs +++ b/src/Arbiter.CommandQuery/Queries/EntityFilter.cs @@ -36,9 +36,9 @@ namespace Arbiter.CommandQuery.Queries; /// }; /// /// -[MessagePackObject] +[MessagePackObject(true)] [JsonConverter(typeof(EntityFilterConverter))] -public partial class EntityFilter +public class EntityFilter { /// /// Gets or sets the name of the field or property to filter on. @@ -46,7 +46,6 @@ public partial class EntityFilter /// /// The name of the field or property to filter on. /// - [Key(0)] [JsonPropertyName("name")] public string? Name { get; set; } @@ -56,7 +55,6 @@ public partial class EntityFilter /// /// The value to filter on. /// - [Key(1)] [JsonPropertyName("value")] public object? Value { get; set; } @@ -68,7 +66,6 @@ public partial class EntityFilter /// The operator to use for the filter. /// /// - [Key(2)] [JsonPropertyName("operator")] [JsonConverter(typeof(JsonStringEnumConverter))] public FilterOperators? Operator { get; set; } @@ -81,7 +78,6 @@ public partial class EntityFilter /// The logical operator to use for combining filters. /// /// - [Key(3)] [JsonPropertyName("logic")] [JsonConverter(typeof(JsonStringEnumConverter))] public FilterLogic? Logic { get; set; } @@ -92,7 +88,6 @@ public partial class EntityFilter /// /// The list of nested filters to apply to the query. /// - [Key(4)] [JsonPropertyName("filters")] public IList? Filters { get; set; } diff --git a/src/Arbiter.CommandQuery/Queries/EntityPagedResult.cs b/src/Arbiter.CommandQuery/Queries/EntityPagedResult.cs index 766d9d6..3fee178 100644 --- a/src/Arbiter.CommandQuery/Queries/EntityPagedResult.cs +++ b/src/Arbiter.CommandQuery/Queries/EntityPagedResult.cs @@ -9,8 +9,8 @@ namespace Arbiter.CommandQuery.Queries; /// A paged result for an entity query. /// /// The type of the read model. -[MessagePackObject] -public partial class EntityPagedResult +[MessagePackObject(true)] +public class EntityPagedResult { /// /// Gets an empty instance of the class. @@ -25,7 +25,6 @@ public partial class EntityPagedResult /// A string token that can be used in subsequent queries to fetch the next set of results, /// or if there are no more results. /// - [Key(0)] [JsonPropertyName("continuationToken")] [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] public string? ContinuationToken { get; set; } @@ -33,7 +32,6 @@ public partial class EntityPagedResult /// /// The total number of the results for the query. /// - [Key(1)] [JsonPropertyName("total")] [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] public long? Total { get; set; } @@ -41,7 +39,6 @@ public partial class EntityPagedResult /// /// The current page of data for the query. /// - [Key(2)] [JsonPropertyName("data")] [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] public IReadOnlyList? Data { get; set; } diff --git a/src/Arbiter.CommandQuery/Queries/EntityQuery.cs b/src/Arbiter.CommandQuery/Queries/EntityQuery.cs index 5d5522e..21383ff 100644 --- a/src/Arbiter.CommandQuery/Queries/EntityQuery.cs +++ b/src/Arbiter.CommandQuery/Queries/EntityQuery.cs @@ -52,8 +52,8 @@ namespace Arbiter.CommandQuery.Queries; /// .AddSort("CreatedDate:desc"); /// /// -[MessagePackObject] -public partial class EntityQuery +[MessagePackObject(true)] +public class EntityQuery { /// /// Gets or sets the raw query expression to search for entities. @@ -62,7 +62,6 @@ public partial class EntityQuery /// A string containing the raw query expression, or if no raw query is specified. /// This can be used for full-text search or custom query expressions depending on the underlying data provider. /// - [Key(0)] [JsonPropertyName("query")] [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] public string? Query { get; set; } @@ -78,7 +77,6 @@ public partial class EntityQuery /// Sort expressions are applied in sequence, allowing for multi-level sorting (e.g., sort by category, then by name within each category). /// Use the or methods to add sort expressions fluently. /// - [Key(1)] [JsonPropertyName("sort")] [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] public IList? Sort { get; set; } @@ -93,7 +91,6 @@ public partial class EntityQuery /// The filter can be a simple property-based filter or a complex group filter containing multiple nested conditions /// combined with logical operators (AND/OR). Use to determine if the filter contains nested filters. /// - [Key(2)] [JsonPropertyName("filter")] [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] public EntityFilter? Filter { get; set; } @@ -109,7 +106,6 @@ public partial class EntityQuery /// This property is used in conjunction with for traditional page-based pagination. /// When both and are specified, the query will return the specified page of results. /// - [Key(3)] [JsonPropertyName("page")] [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] public int? Page { get; set; } @@ -125,7 +121,6 @@ public partial class EntityQuery /// This property controls the maximum number of entities returned in a single query execution. /// It is used in conjunction with for page-based pagination or independently to limit result set size. /// - [Key(4)] [JsonPropertyName("pageSize")] [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] public int? PageSize { get; set; } @@ -147,7 +142,6 @@ public partial class EntityQuery /// from a previous query result and passed to subsequent queries to retrieve the next set of results. /// /// - [Key(5)] [JsonPropertyName("continuationToken")] [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] public string? ContinuationToken { get; set; } diff --git a/src/Arbiter.CommandQuery/Queries/EntitySort.cs b/src/Arbiter.CommandQuery/Queries/EntitySort.cs index c71a75e..c601a01 100644 --- a/src/Arbiter.CommandQuery/Queries/EntitySort.cs +++ b/src/Arbiter.CommandQuery/Queries/EntitySort.cs @@ -46,8 +46,8 @@ namespace Arbiter.CommandQuery.Queries; /// /// /// -[MessagePackObject] -public partial class EntitySort +[MessagePackObject(true)] +public class EntitySort { /// /// Gets or sets the name of the property to sort by. @@ -65,7 +65,6 @@ public partial class EntitySort /// or entity property expressions. /// /// - [Key(0)] [JsonPropertyName("name")] public string Name { get; set; } = null!; @@ -87,7 +86,6 @@ public partial class EntitySort /// /// /// - [Key(1)] [JsonPropertyName("direction")] [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] [JsonConverter(typeof(JsonStringEnumConverter))] diff --git a/src/Arbiter.Communication/Arbiter.Communication.csproj b/src/Arbiter.Communication/Arbiter.Communication.csproj index 1a525fc..fbd8bd6 100644 --- a/src/Arbiter.Communication/Arbiter.Communication.csproj +++ b/src/Arbiter.Communication/Arbiter.Communication.csproj @@ -19,15 +19,15 @@ - - - + + + - - - + + + diff --git a/src/Arbiter.Dispatcher.Client/Arbiter.Dispatcher.Client.csproj b/src/Arbiter.Dispatcher.Client/Arbiter.Dispatcher.Client.csproj index 5b40602..582568e 100644 --- a/src/Arbiter.Dispatcher.Client/Arbiter.Dispatcher.Client.csproj +++ b/src/Arbiter.Dispatcher.Client/Arbiter.Dispatcher.Client.csproj @@ -7,23 +7,20 @@ Arbiter.Dispatcher + + $(DefineConstants);DISPATCHER_CLIENT + + - - - - - all - runtime; build; native; contentfiles; analyzers; buildtransitive - - + - + - + diff --git a/src/Arbiter.Dispatcher.Client/Client/RemoteDispatcher.cs b/src/Arbiter.Dispatcher.Client/Client/RemoteDispatcher.cs index b12e4eb..fa7b383 100644 --- a/src/Arbiter.Dispatcher.Client/Client/RemoteDispatcher.cs +++ b/src/Arbiter.Dispatcher.Client/Client/RemoteDispatcher.cs @@ -1,40 +1,73 @@ +using System.Diagnostics.CodeAnalysis; +using System.Net; +using System.Net.Http.Json; + using Arbiter.CommandQuery.Definitions; +using Arbiter.CommandQuery.Extensions; +using Arbiter.CommandQuery.Models; using Arbiter.Mediation; -using Google.Protobuf; - -using Grpc.Core; - using MessagePack; using Microsoft.Extensions.Caching.Hybrid; namespace Arbiter.Dispatcher.Client; +/// +/// Implements a remote dispatcher that sends requests to a remote HTTP endpoint using MessagePack serialization. +/// Supports optional hybrid caching for cacheable requests. +/// public class RemoteDispatcher : IDispatcher { - public const string TypeHeader = "x-message-type"; - - private readonly DispatcherRpc.DispatcherRpcClient _dispatcherClient = null!; + private readonly HttpClient _httpClient; private readonly HybridCache? _hybridCache; private readonly MessagePackSerializerOptions _options; + /// + /// Initializes a new instance of the class. + /// + /// The HTTP client used to send requests to the remote endpoint. + /// The MessagePack serialization options. + /// Optional hybrid cache for caching responses. When provided, requests implementing will be cached. + /// Thrown when or is null. public RemoteDispatcher( - DispatcherRpc.DispatcherRpcClient dispatcherClient, + HttpClient httpClient, MessagePackSerializerOptions options, HybridCache? hybridCache = null) { + ArgumentNullException.ThrowIfNull(httpClient); + ArgumentNullException.ThrowIfNull(options); + + _httpClient = httpClient; _options = options; - _dispatcherClient = dispatcherClient; _hybridCache = hybridCache; } - public ValueTask Send(TRequest request, CancellationToken cancellationToken = default) + /// + /// Sends a request to the remote dispatcher and returns the response. + /// + /// The type of the request. + /// The type of the response. + /// The request to send. + /// A cancellation token to cancel the operation. + /// A task that represents the asynchronous operation. The task result contains the response. + public ValueTask Send( + TRequest request, + CancellationToken cancellationToken = default) where TRequest : IRequest - { - return Send(request, cancellationToken); - } - + => Send(request, cancellationToken); + + /// + /// Sends a request to the remote dispatcher and returns the response. + /// If the request implements and caching is enabled, the response will be cached. + /// If the request implements , the cache will be invalidated by tag after sending. + /// + /// The type of the response. + /// The request to send. + /// A cancellation token to cancel the operation. + /// A task that represents the asynchronous operation. The task result contains the response. + /// Thrown when is null. + /// Thrown when the HTTP request fails or returns a non-success status code. public async ValueTask Send(IRequest request, CancellationToken cancellationToken = default) { ArgumentNullException.ThrowIfNull(request); @@ -66,30 +99,133 @@ public RemoteDispatcher( CancellationToken cancellationToken = default) { // Single serialization - directly serialize the request - var type = request.GetType(); - var requestBytes = MessagePackSerializer.Serialize(type, request, _options, cancellationToken); - var requestType = request.GetType(); - var requestName = requestType.AssemblyQualifiedName ?? requestType.FullName!; + var requestName = requestType.GetPortableName(); + + var requestBytes = MessagePackSerializer.Serialize(requestType, request, _options, cancellationToken); - // Add type information to gRPC metadata - var metadata = new Metadata { { TypeHeader, requestName } }; + using ByteArrayContent httpContent = new(requestBytes); + httpContent.Headers.ContentType = DispatcherConstants.MessagePackMediaTypeHeader; - // Call the single generic gRPC endpoint - var callOptions = new CallOptions(headers: metadata, cancellationToken: cancellationToken); + using HttpRequestMessage httpRequest = new(HttpMethod.Post, DispatcherConstants.DispatcherEndpoint); + httpRequest.Content = httpContent; + httpRequest.Version = HttpVersion.Version20; + httpRequest.VersionPolicy = HttpVersionPolicy.RequestVersionOrLower; + httpRequest.Headers.Add(DispatcherConstants.RequestTypeHeader, requestName); - var dispatcherRequest = new DispatcherRequest() { Payload = ByteString.CopyFrom(requestBytes) }; - var response = await _dispatcherClient.ExecuteAsync(dispatcherRequest, callOptions).ConfigureAwait(false); + using var httpResponse = await _httpClient + .SendAsync(httpRequest, cancellationToken) + .ConfigureAwait(false); - if (response == null || response.Payload == null) + await EnsureSuccessStatusCode(httpResponse, cancellationToken).ConfigureAwait(false); + + if (httpResponse.StatusCode == HttpStatusCode.NoContent) return default; - var responseBytes = response.Payload.ToByteArray(); - if (responseBytes == null || responseBytes.Length == 0) + var responseBytes = await httpResponse.Content + .ReadAsByteArrayAsync(cancellationToken) + .ConfigureAwait(false); + + if (responseBytes.Length == 0) return default; - // Single deserialization - directly to response type - return MessagePackSerializer.Deserialize(responseBytes, _options, cancellationToken); + var response = MessagePackSerializer.Deserialize(responseBytes, _options, cancellationToken); + + // expire cache + if (_hybridCache is null || request is not ICacheExpire cacheRequest) + return response; + + var cacheTag = cacheRequest.GetCacheTag(); + if (!string.IsNullOrEmpty(cacheTag)) + await _hybridCache.RemoveByTagAsync(cacheTag, cancellationToken).ConfigureAwait(false); + + return response; + } + + + private async ValueTask EnsureSuccessStatusCode(HttpResponseMessage responseMessage, CancellationToken cancellationToken = default) + { + if (responseMessage.IsSuccessStatusCode) + return; + + await ThrowHttpExeption(responseMessage, cancellationToken).ConfigureAwait(false); + } + + [DoesNotReturn] + private async ValueTask ThrowHttpExeption(HttpResponseMessage responseMessage, CancellationToken cancellationToken) + { + var message = $"Response status code does not indicate success: {responseMessage.StatusCode} ({responseMessage.ReasonPhrase})."; + + // try to get problem details + await TryJsonProblem(responseMessage, message, cancellationToken).ConfigureAwait(false); + + // try messagepack problem details + await TryMessagePackProblem(responseMessage, message, cancellationToken).ConfigureAwait(false); + + // fallback - throw generic exception + throw new HttpRequestException(message, inner: null, responseMessage.StatusCode); + } + + private async ValueTask TryMessagePackProblem(HttpResponseMessage responseMessage, string message, CancellationToken cancellationToken) + { + // check content type is messagepack + var mediaType = responseMessage.Content.Headers.ContentType?.MediaType; + if (!string.Equals(mediaType, DispatcherConstants.MessagePackContentType, StringComparison.OrdinalIgnoreCase)) + return; + + // make sure it's a problem details response + responseMessage.Headers.TryGetValues(DispatcherConstants.ResponseTypeHeader, out var responseTypeValues); + var responseType = responseTypeValues?.FirstOrDefault(); + if (string.IsNullOrEmpty(responseType) || !string.Equals(responseType, typeof(ProblemDetails).GetPortableName(), StringComparison.OrdinalIgnoreCase)) + return; + + // deserialize problem details + var responseBytes = await responseMessage.Content.ReadAsByteArrayAsync(cancellationToken).ConfigureAwait(false); + var problemDetails = MessagePackSerializer.Deserialize(responseBytes, _options, cancellationToken); + + // throw problem details + if (problemDetails == null) + throw new HttpRequestException(message, inner: null, responseMessage.StatusCode); + + ThrowProblemDetails(responseMessage, problemDetails); + } + + private static async ValueTask TryJsonProblem(HttpResponseMessage responseMessage, string message, CancellationToken cancellationToken) + { + // check content type is problem details + var mediaType = responseMessage.Content.Headers.ContentType?.MediaType; + if (!string.Equals(mediaType, ProblemDetails.ContentType, StringComparison.OrdinalIgnoreCase)) + return; + + // deserialize problem details + var problemDetails = await responseMessage.Content + .ReadFromJsonAsync(cancellationToken: cancellationToken) + .ConfigureAwait(false); + + // throw problem details + if (problemDetails == null) + throw new HttpRequestException(message, inner: null, responseMessage.StatusCode); + + ThrowProblemDetails(responseMessage, problemDetails); + } + + [DoesNotReturn] + private static void ThrowProblemDetails(HttpResponseMessage responseMessage, ProblemDetails problemDetails) + { + var status = (HttpStatusCode?)problemDetails.Status; + status ??= responseMessage.StatusCode; + + var problemMessage = problemDetails.Title + ?? responseMessage.ReasonPhrase + ?? "Internal Server Error"; + + if (!string.IsNullOrEmpty(problemDetails.Detail)) + problemMessage = $"{problemMessage} {problemDetails.Detail}"; + + throw new HttpRequestException( + message: problemMessage, + inner: null, + statusCode: status); } } diff --git a/src/Arbiter.Dispatcher.Client/DispatcherClientExtensions.cs b/src/Arbiter.Dispatcher.Client/DispatcherClientExtensions.cs new file mode 100644 index 0000000..ad1067a --- /dev/null +++ b/src/Arbiter.Dispatcher.Client/DispatcherClientExtensions.cs @@ -0,0 +1,134 @@ +using System.Net; + +using Arbiter.Dispatcher.Client; +using Arbiter.Dispatcher.State; + +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.DependencyInjection.Extensions; + +namespace Arbiter.Dispatcher; + +/// +/// Provides extension methods for registering dispatcher services with the dependency injection container. +/// +public static class DispatcherClientExtensions +{ + /// + /// Adds the remote dispatcher to the service collection with configuration for the HTTP client. + /// + /// The to add services to. + /// The action to configure the HTTP client with service provider. + /// The for further configuration of the HTTP client. + /// Thrown when is null. + /// + /// This overload allows configuration of the HTTP client using both the service provider and HTTP client instance. + /// The HTTP client is configured to use HTTP/2 by default with a fallback policy. + /// + public static IHttpClientBuilder AddRemoteDispatcher(this IServiceCollection services, Action configureClient) + { + ArgumentNullException.ThrowIfNull(services); + + services.AddRemoteDispatcher(); + return services.AddHttpClient((sp, client) => + { + // Set HTTP/2 by default + client.DefaultRequestVersion = HttpVersion.Version20; + client.DefaultVersionPolicy = HttpVersionPolicy.RequestVersionOrLower; + + configureClient(sp, client); + }); + } + + /// + /// Adds the remote dispatcher to the service collection with configuration for the HTTP client. + /// + /// The to add services to. + /// The action to configure the HTTP client. + /// The for further configuration of the HTTP client. + /// Thrown when is null. + /// + /// This overload allows configuration of the HTTP client using the HTTP client instance only. + /// The HTTP client is configured to use HTTP/2 by default with a fallback policy. + /// + public static IHttpClientBuilder AddRemoteDispatcher(this IServiceCollection services, Action configureClient) + { + ArgumentNullException.ThrowIfNull(services); + + services.AddRemoteDispatcher(); + return services.AddHttpClient(client => + { + // Set HTTP/2 by default + client.DefaultRequestVersion = HttpVersion.Version20; + client.DefaultVersionPolicy = HttpVersionPolicy.RequestVersionOrLower; + + configureClient(client); + }); + } + + /// + /// Adds the remote dispatcher to the service collection without HTTP client configuration. + /// + /// The to add services to. + /// The so that additional calls can be chained. + /// Thrown when is null. + /// + /// This method registers the remote dispatcher without configuring the HTTP client. + /// The client must register the with the correct separately. + /// Registered services include: + /// + /// MessagePack serializer options (singleton) + /// implementation (transient) + /// implementation (transient) + /// Model state management services (scoped) + /// + /// + public static IServiceCollection AddRemoteDispatcher(this IServiceCollection services) + { + ArgumentNullException.ThrowIfNull(services); + + // MessagePack Serializer Options Registration + services.TryAddSingleton(DispatcherConstants.DefaultSerializerOptions); + + // up to client to register RemoteDispatcher with correct HttpClient + services.TryAddTransient(sp => sp.GetRequiredService()); + services.TryAddTransient(); + + // Model State Open Generic Registrations + services.Add(ServiceDescriptor.Scoped(typeof(ModelStateManager<>), typeof(ModelStateManager<>))); + services.Add(ServiceDescriptor.Scoped(typeof(ModelStateLoader<,>), typeof(ModelStateLoader<,>))); + services.Add(ServiceDescriptor.Scoped(typeof(ModelStateEditor<,,>), typeof(ModelStateEditor<,,>))); + + return services; + } + + + /// + /// Adds the server dispatcher to the service collection. + /// + /// The to add services to. + /// The so that additional calls can be chained. + /// Thrown when is null. + /// + /// The server dispatcher uses the mediator pattern to dispatch commands and queries locally. + /// Registered services include: + /// + /// implementation (transient) + /// implementation (transient) + /// Model state management services (scoped) + /// + /// + public static IServiceCollection AddServerDispatcher(this IServiceCollection services) + { + ArgumentNullException.ThrowIfNull(services); + + services.TryAddTransient(); + services.TryAddTransient(); + + // Model State Open Generic Registrations + services.Add(ServiceDescriptor.Scoped(typeof(ModelStateManager<>), typeof(ModelStateManager<>))); + services.Add(ServiceDescriptor.Scoped(typeof(ModelStateLoader<,>), typeof(ModelStateLoader<,>))); + services.Add(ServiceDescriptor.Scoped(typeof(ModelStateEditor<,,>), typeof(ModelStateEditor<,,>))); + + return services; + } +} diff --git a/src/Arbiter.Dispatcher.Client/DispatcherServiceExtensions.cs b/src/Arbiter.Dispatcher.Client/DispatcherServiceExtensions.cs deleted file mode 100644 index 25b2489..0000000 --- a/src/Arbiter.Dispatcher.Client/DispatcherServiceExtensions.cs +++ /dev/null @@ -1,114 +0,0 @@ -using System.Threading.Channels; - -using Arbiter.Dispatcher.Client; -using Arbiter.Dispatcher.State; - -using Grpc.Net.Client; -using Grpc.Net.Client.Web; - -using MessagePack; - -using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.DependencyInjection.Extensions; - -namespace Arbiter.Dispatcher; - -public static class DispatcherServiceExtensions -{ - public static IServiceCollection AddRemoteDispatcher(this IServiceCollection services, string serviceAddress) - { - ArgumentNullException.ThrowIfNull(services); - ArgumentNullException.ThrowIfNull(serviceAddress); - - return services; - //return services.AddRemoteDispatcher(_ => - //{ - // var httpHandler = new GrpcWebHandler(GrpcWebMode.GrpcWeb, new HttpClientHandler()); - // var channelOptions = new GrpcChannelOptions() - // { - // HttpHandler = httpHandler, - // MaxReceiveMessageSize = 10 * 1024 * 1024, // 10 MB - // MaxSendMessageSize = 10 * 1024 * 1024, // 10 MB - // }; - - // return GrpcChannel.ForAddress(serviceAddress, channelOptions); - //}); - } - - public static IServiceCollection AddRemoteDispatcher(this IServiceCollection services, Func addressFactory) - { - ArgumentNullException.ThrowIfNull(services); - ArgumentNullException.ThrowIfNull(addressFactory); - return services.AddRemoteDispatcher(sp => - { - var serviceAddress = addressFactory(sp); - - var httpHandler = new GrpcWebHandler(GrpcWebMode.GrpcWeb, new HttpClientHandler()); - var channelOptions = new GrpcChannelOptions() - { - HttpHandler = httpHandler, - MaxReceiveMessageSize = 10 * 1024 * 1024, // 10 MB - MaxSendMessageSize = 10 * 1024 * 1024, // 10 MB - }; - - return GrpcChannel.ForAddress(serviceAddress, channelOptions); - }); - } - - public static IServiceCollection AddRemoteDispatcher(this IServiceCollection services, Func channelFactory) - { - ArgumentNullException.ThrowIfNull(services); - ArgumentNullException.ThrowIfNull(channelFactory); - - // MessagePack Serializer Options Registration - services.TryAddSingleton(MessagePackSerializerOptions.Standard - .WithResolver(MessagePack.Resolvers.TypelessContractlessStandardResolver.Instance) - .WithCompression(MessagePackCompression.Lz4BlockArray)); - - // Remote Dispatcher Registration - services.TryAddTransient(sp => - { - var channel = channelFactory(sp); - var options = sp.GetRequiredService(); - - return new DispatcherRpc.DispatcherRpcClient(channel); - }); - - // IDispatcher Registration - services.TryAddTransient(); - - // Dispatcher Data Service Registration - services.TryAddTransient(); - - // Model State Open Generic Registrations - services.TryAdd(ServiceDescriptor.Scoped(typeof(ModelStateManager<>), typeof(ModelStateManager<>))); - services.TryAdd(ServiceDescriptor.Scoped(typeof(ModelStateLoader<,>), typeof(ModelStateLoader<,>))); - services.TryAdd(ServiceDescriptor.Scoped(typeof(ModelStateEditor<,,>), typeof(ModelStateEditor<,,>))); - - return services; - } - - - /// - /// Adds the server dispatcher to the service collection. - /// - /// The to add services to. - /// The so that additional calls can be chained. - /// - /// The server dispatcher uses the mediator pattern to dispatch commands and queries locally. - /// - public static IServiceCollection AddServerDispatcher(this IServiceCollection services) - { - ArgumentNullException.ThrowIfNull(services); - - services.TryAddTransient(); - services.TryAddTransient(); - - // Model State Open Generic Registrations - services.Add(ServiceDescriptor.Scoped(typeof(ModelStateManager<>), typeof(ModelStateManager<>))); - services.Add(ServiceDescriptor.Scoped(typeof(ModelStateLoader<,>), typeof(ModelStateLoader<,>))); - services.Add(ServiceDescriptor.Scoped(typeof(ModelStateEditor<,,>), typeof(ModelStateEditor<,,>))); - - return services; - } -} diff --git a/src/Arbiter.Dispatcher.Client/Protos/dispatcher.proto b/src/Arbiter.Dispatcher.Client/Protos/dispatcher.proto deleted file mode 100644 index e2cca20..0000000 --- a/src/Arbiter.Dispatcher.Client/Protos/dispatcher.proto +++ /dev/null @@ -1,15 +0,0 @@ -syntax = "proto3"; - -option csharp_namespace = "Arbiter.Dispatcher"; - -service DispatcherRpc { - rpc Execute (DispatcherRequest) returns (DispatcherResponse); -} - -message DispatcherRequest { - bytes payload = 1; -} - -message DispatcherResponse { - bytes payload = 1; -} diff --git a/src/Arbiter.Dispatcher.Server/Arbiter.Dispatcher.Server.csproj b/src/Arbiter.Dispatcher.Server/Arbiter.Dispatcher.Server.csproj index a85d596..c5b9000 100644 --- a/src/Arbiter.Dispatcher.Server/Arbiter.Dispatcher.Server.csproj +++ b/src/Arbiter.Dispatcher.Server/Arbiter.Dispatcher.Server.csproj @@ -7,8 +7,6 @@ - - @@ -17,7 +15,7 @@ - + diff --git a/src/Arbiter.Dispatcher.Server/DispatcherConstants.cs b/src/Arbiter.Dispatcher.Server/DispatcherConstants.cs new file mode 100644 index 0000000..236dbbe --- /dev/null +++ b/src/Arbiter.Dispatcher.Server/DispatcherConstants.cs @@ -0,0 +1,64 @@ +using System.Net.Http.Headers; + +using MessagePack; +using MessagePack.Resolvers; + +#if DISPATCHER_CLIENT +namespace Arbiter.Dispatcher.Client; +#else +namespace Arbiter.Dispatcher.Server; +#endif + +/// +/// Defines constants used by the dispatcher message system. +/// +public static class DispatcherConstants +{ + /// + /// The HTTP header name for the message request type. + /// + public const string RequestTypeHeader = "X-Message-Request-Type"; + + /// + /// The HTTP header name for the message response type. + /// + public const string ResponseTypeHeader = "X-Message-Response-Type"; + + /// + /// The endpoint path for sending dispatcher messages. + /// + public const string DispatcherEndpoint = "/api/dispatcher/send"; + + /// + /// The content type for MessagePack serialized data. + /// + public const string MessagePackContentType = "application/x-msgpack"; + + /// + /// The media type header value for MessagePack content. + /// + public static readonly MediaTypeHeaderValue MessagePackMediaTypeHeader = new(MessagePackContentType); + + /// + /// The default serializer options for MessagePack. + /// + public static readonly MessagePackSerializerOptions DefaultSerializerOptions = + MessagePackSerializerOptions.Standard + .WithResolver( + CompositeResolver.Create( + [ + // 0) Uses the Roslyn source generator output produced automatically. + SourceGeneratedFormatterResolver.Instance, + + // 1) Attribute-based + built-ins (enums, primitives, etc.) + StandardResolver.Instance, + + // 2) Typeless (for object-typed or unknown static types) + TypelessObjectResolver.Instance, + + // 3) Contractless fallback for POCOs without attributes + ContractlessStandardResolver.Instance, + ]) + ) + .WithCompression(MessagePackCompression.Lz4BlockArray); +} diff --git a/src/Arbiter.Dispatcher.Server/DispatcherEndpoint.cs b/src/Arbiter.Dispatcher.Server/DispatcherEndpoint.cs new file mode 100644 index 0000000..38520f0 --- /dev/null +++ b/src/Arbiter.Dispatcher.Server/DispatcherEndpoint.cs @@ -0,0 +1,160 @@ +using System.Security.Claims; + +using Arbiter.CommandQuery.Definitions; +using Arbiter.CommandQuery.Extensions; +using Arbiter.Mediation; + +using MessagePack; + +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Routing; +using Microsoft.Extensions.Logging; + +using ProblemDetails = Arbiter.CommandQuery.Models.ProblemDetails; + +namespace Arbiter.Dispatcher.Server; + +/// +/// Provides an HTTP endpoint for dispatching mediator commands and queries. +/// +public class DispatcherEndpoint +{ + private readonly ILogger _logger; + + /// + /// Initializes a new instance of the class. + /// + /// The logger instance for diagnostic logging. + public DispatcherEndpoint(ILogger logger) + { + _logger = logger; + } + + /// + /// Adds the dispatcher endpoint route to the application's endpoint route builder. + /// + /// The endpoint route builder to add the route to. + /// An that can be used to further customize the endpoint. + public IEndpointConventionBuilder AddRoute(IEndpointRouteBuilder endpoints) + { + return endpoints + .MapPost(DispatcherConstants.DispatcherEndpoint, HandleSend) + .WithTags("Dispatcher") + .WithName("Send") + .WithSummary("Send Mediator command") + .WithDescription("Send Mediator command") + .Produces(StatusCodes.Status200OK) + .Produces(StatusCodes.Status400BadRequest) + .Produces(StatusCodes.Status500InternalServerError) + .ExcludeFromDescription(); + } + + /// + /// Handles incoming HTTP POST requests for dispatching mediator commands and queries. + /// + /// The HTTP context for the current request. + /// The mediator instance used to send the deserialized request. + /// The MessagePack serializer options for deserialization. + /// The claims principal representing the current user. + /// A token to monitor for cancellation requests. + /// An containing the mediator response or problem details. + [System.Diagnostics.CodeAnalysis.SuppressMessage("Design", "MA0051:Method is too long", Justification = "")] + private async Task HandleSend( + HttpContext context, + [FromServices] IMediator mediator, + [FromServices] MessagePackSerializerOptions options, + ClaimsPrincipal? user = default, + CancellationToken cancellationToken = default) + { + try + { + var httpRequest = context.Request; + + if (!httpRequest.Headers.TryGetValue(DispatcherConstants.RequestTypeHeader, out var requestTypeHeader)) + { + _logger.LogWarning("Missing or empty {Header} header", DispatcherConstants.RequestTypeHeader); + return BadRequestProblem($"Missing {DispatcherConstants.RequestTypeHeader} header"); + } + + var requestTypeName = requestTypeHeader.FirstOrDefault(); + if (string.IsNullOrEmpty(requestTypeName)) + { + _logger.LogWarning("Missing or empty {Header} header", DispatcherConstants.RequestTypeHeader); + return BadRequestProblem($"Empty {DispatcherConstants.RequestTypeHeader} header"); + } + + // Resolve the request type + var requestType = Type.GetType(requestTypeName, throwOnError: false, ignoreCase: true); + if (requestType is null) + { + _logger.LogWarning("Unable to resolve request type: {RequestType}", requestTypeName); + return BadRequestProblem($"Unknown request type: {requestTypeName}"); + } + + var request = await MessagePackSerializer + .DeserializeAsync( + type: requestType, + stream: httpRequest.Body, + options: options, + cancellationToken: cancellationToken + ) + .ConfigureAwait(false); + + if (request == null) + { + _logger.LogWarning("Failed to deserialize request of type: {RequestType}", requestTypeName); + return BadRequestProblem($"Failed to deserialize request of type: {requestTypeName}"); + } + + // Apply current user principal if supported + if (request is IRequestPrincipal requestPrincipal) + requestPrincipal.ApplyPrincipal(user); + + var response = await mediator + .Send(request, cancellationToken) + .ConfigureAwait(false); + + if (response == null) + { + context.Response.StatusCode = StatusCodes.Status204NoContent; + return Results.Empty; + } + + return new MessagePackResult(response); + } + catch (Exception ex) + { + return ExceptionProblem(ex); + } + } + + /// + /// Creates a bad request (400) problem details response. + /// + /// The detailed error message describing the bad request. + /// A containing the problem details. + private static MessagePackResult BadRequestProblem(string detail) + { + var problemDetails = new ProblemDetails + { + Status = StatusCodes.Status400BadRequest, + Title = "Bad Request", + Detail = detail, + }; + + return new MessagePackResult(problemDetails, statusCode: problemDetails.Status); + } + + /// + /// Creates a problem details response from an exception. + /// + /// The exception to convert to problem details. + /// A containing the problem details derived from the exception. + private static MessagePackResult ExceptionProblem(Exception exception) + { + var problemDetails = exception.ToProblemDetails(); + return new MessagePackResult(problemDetails, statusCode: problemDetails.Status); + } +} diff --git a/src/Arbiter.Dispatcher.Server/DispatcherService.cs b/src/Arbiter.Dispatcher.Server/DispatcherService.cs deleted file mode 100644 index ef8d2d1..0000000 --- a/src/Arbiter.Dispatcher.Server/DispatcherService.cs +++ /dev/null @@ -1,118 +0,0 @@ -using Arbiter.CommandQuery.Definitions; -using Arbiter.Mediation; - -using Google.Protobuf; - -using Grpc.Core; - -using MessagePack; - -using Microsoft.Extensions.Logging; - -namespace Arbiter.Dispatcher.Server; - -/// -/// gRPC service that dispatches requests to the mediator for processing. -/// -/// -/// This service receives serialized requests via gRPC, deserializes them using MessagePack, -/// applies the current user principal if supported, and dispatches them to the mediator for processing. -/// The response is then serialized and returned to the caller. -/// -public class DispatcherService : DispatcherRpc.DispatcherRpcBase -{ - public const string TypeHeader = "x-message-type"; - - private readonly ILogger _logger; - private readonly IMediator _mediator; - private readonly MessagePackSerializerOptions _options; - - /// - /// Initializes a new instance of the class. - /// - /// The logger instance for diagnostic logging. - /// The mediator instance for processing requests. - /// The MessagePack serialization options. - public DispatcherService( - ILogger logger, - IMediator mediator, - MessagePackSerializerOptions options) - { - _logger = logger; - _mediator = mediator; - _options = options; - } - - - public override async Task Execute(DispatcherRequest request, ServerCallContext context) - { - try - { - var requestBytes = request.Payload.ToByteArray(); - - _logger.LogInformation("Execute method called with {ByteCount} bytes", requestBytes?.Length ?? 0); - - // Get type from metadata header - var messageTypeName = context.RequestHeaders.GetValue(TypeHeader); - _logger.LogInformation("Message type header value: {MessageTypeName}", messageTypeName ?? "NULL"); - - if (string.IsNullOrEmpty(messageTypeName)) - { - _logger.LogWarning("Missing x-message-type header"); - throw new RpcException(new Status(StatusCode.InvalidArgument, - $"Required header '{TypeHeader}' is missing. Please include the message type name in the request headers.")); - } - - // Resolve request message type - var requestType = Type.GetType(messageTypeName); - if (requestType == null) - { - _logger.LogWarning("Unknown message type: {MessageTypeName}", messageTypeName); - throw new RpcException(new Status(StatusCode.InvalidArgument, - $"Unable to resolve message type '{messageTypeName}'. Ensure the type is available in the current context and the assembly-qualified name is correct.")); - } - - // Validate request payload - if (requestBytes == null || requestBytes.Length == 0) - { - _logger.LogWarning("Empty request payload for message type: {MessageTypeName}", messageTypeName); - throw new RpcException(new Status(StatusCode.InvalidArgument, - $"The request payload is empty for message type '{messageTypeName}'. A valid serialized request is required.")); - } - - // deserialization of request - var requestMessage = MessagePackSerializer.Deserialize(requestType, requestBytes, _options, context.CancellationToken); - if (requestMessage == null) - { - _logger.LogWarning("Failed to deserialize request of type: {MessageTypeName}", messageTypeName); - throw new RpcException(new Status(StatusCode.InvalidArgument, - $"Failed to deserialize request payload to type '{messageTypeName}'. The message format may be invalid or incompatible.")); - } - - // Apply current user principal if supported - if (requestMessage is IRequestPrincipal requestPrincipal) - { - // get current user - var httpContext = context.GetHttpContext(); - var user = httpContext?.User; - - requestPrincipal.ApplyPrincipal(user); - } - - // Send to Mediator - var responseMessage = await _mediator.Send(requestMessage, context.CancellationToken).ConfigureAwait(false); - if (responseMessage == null) - return new DispatcherResponse { Payload = ByteString.Empty }; - - // Single serialization of response - var responseBytes = MessagePackSerializer.Serialize(responseMessage, _options, context.CancellationToken); - return new DispatcherResponse { Payload = ByteString.CopyFrom(responseBytes) }; - - } - catch (Exception ex) when (ex is not RpcException) - { - _logger.LogError(ex, "Error processing request"); - throw; - } - } -} diff --git a/src/Arbiter.Dispatcher.Server/DispatcherServiceExtensions.cs b/src/Arbiter.Dispatcher.Server/DispatcherServiceExtensions.cs index 3736ce0..9b717c1 100644 --- a/src/Arbiter.Dispatcher.Server/DispatcherServiceExtensions.cs +++ b/src/Arbiter.Dispatcher.Server/DispatcherServiceExtensions.cs @@ -1,5 +1,3 @@ -using MessagePack; - using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.Routing; using Microsoft.Extensions.DependencyInjection; @@ -7,32 +5,38 @@ namespace Arbiter.Dispatcher.Server; +/// +/// Provides extension methods for configuring dispatcher services and endpoints. +/// public static class DispatcherServiceExtensions { + /// + /// Adds the dispatcher service and its dependencies to the service collection. + /// + /// The service collection to add services to. + /// The service collection for chaining. public static IServiceCollection AddDispatcherService(this IServiceCollection services) { - services.AddGrpc(); - // Register the DispatcherService itself - services.TryAddSingleton(); + services.TryAddSingleton(); // MessagePack Serializer Options Registration - services.TryAddSingleton(MessagePackSerializerOptions.Standard - .WithResolver(MessagePack.Resolvers.TypelessContractlessStandardResolver.Instance) - .WithCompression(MessagePackCompression.Lz4BlockArray)); + services.TryAddSingleton(DispatcherConstants.DefaultSerializerOptions); return services; } - public static IApplicationBuilder UseDispatchService(this IApplicationBuilder app) + /// + /// Maps the dispatcher service endpoint to the application's request pipeline. + /// + /// The endpoint route builder to configure. + /// An endpoint convention builder that can be used to further customize the endpoint. + public static IEndpointConventionBuilder MapDispatcherService(this IEndpointRouteBuilder endpoints) { - return app.UseGrpcWeb(new GrpcWebOptions { DefaultEnabled = true }); - } + // Resolve the DispatcherEndpoint from the service provider + var dispatcherEndpoint = endpoints.ServiceProvider.GetRequiredService(); - public static IEndpointConventionBuilder MapDispatchService(this IEndpointRouteBuilder endpoints) - { - return endpoints - .MapGrpcService() - .EnableGrpcWeb(); + // Map the route using the DispatcherEndpoint + return dispatcherEndpoint.AddRoute(endpoints); } } diff --git a/src/Arbiter.Dispatcher.Server/MessagePackResult.cs b/src/Arbiter.Dispatcher.Server/MessagePackResult.cs new file mode 100644 index 0000000..07b719a --- /dev/null +++ b/src/Arbiter.Dispatcher.Server/MessagePackResult.cs @@ -0,0 +1,123 @@ +using System.Reflection; + +using Arbiter.CommandQuery.Extensions; + +using MessagePack; + +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Http.Metadata; +using Microsoft.Extensions.DependencyInjection; + +namespace Arbiter.Dispatcher.Server; + +/// +/// An that serializes a value to MessagePack format and writes it to the HTTP response. +/// +/// The type of the value to serialize. +public class MessagePackResult : + IResult, + IValueHttpResult, + IValueHttpResult, + IStatusCodeHttpResult, + IContentTypeHttpResult, + IEndpointMetadataProvider +{ + /// + /// Initializes a new instance of the class. + /// + /// The value to serialize. If null, a 204 No Content response is returned. + /// The HTTP status code. If not specified, defaults to 200 OK for non-null values and 204 No Content for null values. + /// The content type. If not specified, defaults to the MessagePack content type. + public MessagePackResult(TValue? value, int? statusCode = null, string? contentType = null) + { + Value = value; + StatusCode = statusCode; + ContentType = contentType; + } + + /// + /// Gets the value to be serialized to the response. + /// + /// + /// The value to serialize, or null if no content should be returned. + /// + public TValue? Value { get; } + + /// + object? IValueHttpResult.Value => Value; + + /// + /// Gets the content type for the response. + /// + /// + /// The content type, or null to use the default MessagePack content type. + /// + public string? ContentType { get; } + + /// + /// Gets the HTTP status code for the response. + /// + /// + /// The status code, or null to use the default (200 OK for non-null values, 204 No Content for null values). + /// + public int? StatusCode { get; } + + + /// + /// Executes the result operation, serializing the value to MessagePack format and writing it to the HTTP response. + /// + /// The for the current request. + /// A task that represents the asynchronous execute operation. + public Task ExecuteAsync(HttpContext httpContext) + { + ArgumentNullException.ThrowIfNull(httpContext); + + httpContext.Response.ContentType = ContentType + ?? DispatcherConstants.MessagePackContentType; + + if (Value is null) + { + httpContext.Response.StatusCode = StatusCodes.Status204NoContent; + return Task.CompletedTask; + } + + if (StatusCode is { } statusCode) + httpContext.Response.StatusCode = statusCode; + + var valueType = Value.GetType(); + var responseType = valueType.GetPortableName(); + + var options = httpContext.RequestServices.GetService() + ?? DispatcherConstants.DefaultSerializerOptions; + + if (responseType is not null) + httpContext.Response.Headers[DispatcherConstants.ResponseTypeHeader] = responseType; + + return MessagePackSerializer.SerializeAsync(valueType, httpContext.Response.Body, Value, options, httpContext.RequestAborted); + } + + /// + /// Populates metadata for the endpoint, documenting the possible response types and status codes. + /// + /// The method info for the endpoint handler. + /// The endpoint builder to populate metadata for. + static void IEndpointMetadataProvider.PopulateMetadata(MethodInfo method, EndpointBuilder builder) + { + ArgumentNullException.ThrowIfNull(method); + ArgumentNullException.ThrowIfNull(builder); + + // Document 200 OK with MessagePack content + builder.Metadata.Add(new ProducesResponseTypeMetadata( + statusCode: StatusCodes.Status200OK, + type: typeof(TValue), + contentTypes: [DispatcherConstants.MessagePackContentType])); + + // Document 204 No Content for null values + builder.Metadata.Add(new ProducesResponseTypeMetadata( + statusCode: StatusCodes.Status204NoContent, + type: typeof(void), + contentTypes: [])); + } +} + diff --git a/src/Arbiter.Dispatcher.Server/Protos/dispatcher.proto b/src/Arbiter.Dispatcher.Server/Protos/dispatcher.proto deleted file mode 100644 index e2cca20..0000000 --- a/src/Arbiter.Dispatcher.Server/Protos/dispatcher.proto +++ /dev/null @@ -1,15 +0,0 @@ -syntax = "proto3"; - -option csharp_namespace = "Arbiter.Dispatcher"; - -service DispatcherRpc { - rpc Execute (DispatcherRequest) returns (DispatcherResponse); -} - -message DispatcherRequest { - bytes payload = 1; -} - -message DispatcherResponse { - bytes payload = 1; -} diff --git a/src/Arbiter.Mediation/Arbiter.Mediation.csproj b/src/Arbiter.Mediation/Arbiter.Mediation.csproj index f998df7..b160db0 100644 --- a/src/Arbiter.Mediation/Arbiter.Mediation.csproj +++ b/src/Arbiter.Mediation/Arbiter.Mediation.csproj @@ -11,11 +11,11 @@ - + - + diff --git a/test/Arbiter.CommandQuery.Endpoints.Tests/Arbiter.CommandQuery.Endpoints.Tests.csproj b/test/Arbiter.CommandQuery.Endpoints.Tests/Arbiter.CommandQuery.Endpoints.Tests.csproj index 88f11dd..5a981b7 100644 --- a/test/Arbiter.CommandQuery.Endpoints.Tests/Arbiter.CommandQuery.Endpoints.Tests.csproj +++ b/test/Arbiter.CommandQuery.Endpoints.Tests/Arbiter.CommandQuery.Endpoints.Tests.csproj @@ -11,8 +11,6 @@ - - diff --git a/test/Arbiter.CommandQuery.EntityFramework.Tests/Arbiter.CommandQuery.EntityFramework.Tests.csproj b/test/Arbiter.CommandQuery.EntityFramework.Tests/Arbiter.CommandQuery.EntityFramework.Tests.csproj index 2a866ec..0ccb602 100644 --- a/test/Arbiter.CommandQuery.EntityFramework.Tests/Arbiter.CommandQuery.EntityFramework.Tests.csproj +++ b/test/Arbiter.CommandQuery.EntityFramework.Tests/Arbiter.CommandQuery.EntityFramework.Tests.csproj @@ -25,8 +25,6 @@ - - diff --git a/test/Arbiter.CommandQuery.MongoDB.Tests/Arbiter.CommandQuery.MongoDB.Tests.csproj b/test/Arbiter.CommandQuery.MongoDB.Tests/Arbiter.CommandQuery.MongoDB.Tests.csproj index 58d9493..a40af6a 100644 --- a/test/Arbiter.CommandQuery.MongoDB.Tests/Arbiter.CommandQuery.MongoDB.Tests.csproj +++ b/test/Arbiter.CommandQuery.MongoDB.Tests/Arbiter.CommandQuery.MongoDB.Tests.csproj @@ -13,8 +13,6 @@ - - diff --git a/test/Arbiter.CommandQuery.Tests/Arbiter.CommandQuery.Tests.csproj b/test/Arbiter.CommandQuery.Tests/Arbiter.CommandQuery.Tests.csproj index 87c5703..b39ae09 100644 --- a/test/Arbiter.CommandQuery.Tests/Arbiter.CommandQuery.Tests.csproj +++ b/test/Arbiter.CommandQuery.Tests/Arbiter.CommandQuery.Tests.csproj @@ -15,8 +15,6 @@ - - diff --git a/test/Arbiter.CommandQuery.Tests/Commands/EntityCreateCommandTests.cs b/test/Arbiter.CommandQuery.Tests/Commands/EntityCreateCommandTests.cs index 04ae0f8..d1b309c 100644 --- a/test/Arbiter.CommandQuery.Tests/Commands/EntityCreateCommandTests.cs +++ b/test/Arbiter.CommandQuery.Tests/Commands/EntityCreateCommandTests.cs @@ -8,7 +8,7 @@ public class EntityCreateCommandTests [Test] public void ConstructorNullModel() { - Action act = () => new EntityCreateCommand(null, null!); + Action act = () => new EntityCreateCommand(null!, null!); act.Should().Throw(); } diff --git a/test/Arbiter.CommandQuery.Tests/Extensions/TypeExtensionsTests.cs b/test/Arbiter.CommandQuery.Tests/Extensions/TypeExtensionsTests.cs new file mode 100644 index 0000000..4260d03 --- /dev/null +++ b/test/Arbiter.CommandQuery.Tests/Extensions/TypeExtensionsTests.cs @@ -0,0 +1,210 @@ +using Arbiter.CommandQuery.Extensions; + +namespace Arbiter.CommandQuery.Tests.Extensions; + +public class TypeExtensionsTests +{ + [Test] + public void GetPortableName_ShouldThrowArgumentNullException_WhenTypeIsNull() + { + // Arrange + Type? type = null; + + // Act + var act = () => type!.GetPortableName(); + + // Assert + act.Should().Throw(); + } + + [Test] + public void GetPortableName_ShouldReturnPortableName_ForSimpleType() + { + // Arrange + var type = typeof(string); + + // Act + var result = type.GetPortableName(); + + // Assert + result.Should().Be("System.String, System.Private.CoreLib"); + + var resolvedType = Type.GetType(result); + resolvedType.Should().Be(type); + } + + [Test] + public void GetPortableName_ShouldReturnPortableName_ForIntType() + { + // Arrange + var type = typeof(int); + + // Act + var result = type.GetPortableName(); + + // Assert + result.Should().Be("System.Int32, System.Private.CoreLib"); + + var resolvedType = Type.GetType(result); + resolvedType.Should().Be(type); + } + + [Test] + public void GetPortableName_ShouldReturnPortableName_ForCustomType() + { + // Arrange + var type = typeof(TypeExtensionsTests); + + // Act + var result = type.GetPortableName(); + + // Assert + result.Should().Be("Arbiter.CommandQuery.Tests.Extensions.TypeExtensionsTests, Arbiter.CommandQuery.Tests"); + + var resolvedType = Type.GetType(result); + resolvedType.Should().Be(type); + } + + [Test] + public void GetPortableName_ShouldReturnPortableName_ForGenericTypeWithOneArgument() + { + // Arrange + var type = typeof(List); + + // Act + var result = type.GetPortableName(); + + // Assert + result.Should().Be("System.Collections.Generic.List`1[[System.String, System.Private.CoreLib]], System.Private.CoreLib"); + + var resolvedType = Type.GetType(result); + resolvedType.Should().Be(type); + } + + [Test] + public void GetPortableName_ShouldReturnPortableName_ForGenericTypeWithTwoArguments() + { + // Arrange + var type = typeof(Dictionary); + + // Act + var result = type.GetPortableName(); + + // Assert + result.Should().Be("System.Collections.Generic.Dictionary`2[[System.String, System.Private.CoreLib],[System.Int32, System.Private.CoreLib]], System.Private.CoreLib"); + + var resolvedType = Type.GetType(result); + resolvedType.Should().Be(type); + } + + [Test] + public void GetPortableName_ShouldReturnPortableName_ForNestedGenericTypes() + { + // Arrange + var type = typeof(List>); + + // Act + var result = type.GetPortableName(); + + // Assert + result.Should().Be("System.Collections.Generic.List`1[[System.Collections.Generic.List`1[[System.String, System.Private.CoreLib]], System.Private.CoreLib]], System.Private.CoreLib"); + + var resolvedType = Type.GetType(result); + resolvedType.Should().Be(type); + } + + [Test] + public void GetPortableName_ShouldReturnPortableName_ForComplexNestedGenerics() + { + // Arrange + var type = typeof(Dictionary>); + + // Act + var result = type.GetPortableName(); + + // Assert + result.Should().Be("System.Collections.Generic.Dictionary`2[[System.String, System.Private.CoreLib],[System.Collections.Generic.List`1[[System.Int32, System.Private.CoreLib]], System.Private.CoreLib]], System.Private.CoreLib"); + + var resolvedType = Type.GetType(result); + resolvedType.Should().Be(type); + } + + [Test] + public void GetPortableName_ShouldReturnPortableName_ForTuple() + { + // Arrange + var type = typeof(Tuple); + + // Act + var result = type.GetPortableName(); + + // Assert + result.Should().Be("System.Tuple`2[[System.String, System.Private.CoreLib],[System.Int32, System.Private.CoreLib]], System.Private.CoreLib"); + + var resolvedType = Type.GetType(result); + resolvedType.Should().Be(type); + } + + [Test] + public void GetPortableName_ShouldReturnPortableName_ForValueTuple() + { + // Arrange + var type = typeof(ValueTuple); + + // Act + var result = type.GetPortableName(); + + // Assert + result.Should().Be("System.ValueTuple`2[[System.String, System.Private.CoreLib],[System.Int32, System.Private.CoreLib]], System.Private.CoreLib"); + + var resolvedType = Type.GetType(result); + resolvedType.Should().Be(type); + } + + [Test] + public void GetPortableName_ShouldNotIncludeVersionOrCulture() + { + // Arrange + var type = typeof(string); + + // Act + var result = type.GetPortableName(); + + // Assert + result.Should().NotContain("Version="); + result.Should().NotContain("Culture="); + result.Should().NotContain("PublicKeyToken="); + } + + [Test] + public void GetPortableName_ShouldReturnPortableName_ForNullable() + { + // Arrange + var type = typeof(int?); + + // Act + var result = type.GetPortableName(); + + // Assert + result.Should().Be("System.Nullable`1[[System.Int32, System.Private.CoreLib]], System.Private.CoreLib"); + + var resolvedType = Type.GetType(result); + resolvedType.Should().Be(type); + } + + [Test] + public void GetPortableName_ShouldReturnPortableName_ForArray() + { + // Arrange + var type = typeof(string[]); + + // Act + var result = type.GetPortableName(); + + // Assert + result.Should().Be("System.String[], System.Private.CoreLib"); + + var resolvedType = Type.GetType(result); + resolvedType.Should().Be(type); + } +} diff --git a/test/Arbiter.Communication.Tests/Arbiter.Communication.Tests.csproj b/test/Arbiter.Communication.Tests/Arbiter.Communication.Tests.csproj index e6e1d43..cedf823 100644 --- a/test/Arbiter.Communication.Tests/Arbiter.Communication.Tests.csproj +++ b/test/Arbiter.Communication.Tests/Arbiter.Communication.Tests.csproj @@ -18,8 +18,6 @@ - - diff --git a/test/Arbiter.Dispatcher.Client.Tests/Arbiter.Dispatcher.Client.Tests.csproj b/test/Arbiter.Dispatcher.Client.Tests/Arbiter.Dispatcher.Client.Tests.csproj index 2a8803f..2a55987 100644 --- a/test/Arbiter.Dispatcher.Client.Tests/Arbiter.Dispatcher.Client.Tests.csproj +++ b/test/Arbiter.Dispatcher.Client.Tests/Arbiter.Dispatcher.Client.Tests.csproj @@ -11,8 +11,6 @@ - - diff --git a/test/Arbiter.Mediation.Tests/Arbiter.Mediation.Tests.csproj b/test/Arbiter.Mediation.Tests/Arbiter.Mediation.Tests.csproj index 4f3f871..3f436e8 100644 --- a/test/Arbiter.Mediation.Tests/Arbiter.Mediation.Tests.csproj +++ b/test/Arbiter.Mediation.Tests/Arbiter.Mediation.Tests.csproj @@ -10,8 +10,6 @@ - - diff --git a/test/Arbiter.Services.Tests/Arbiter.Services.Tests.csproj b/test/Arbiter.Services.Tests/Arbiter.Services.Tests.csproj index 39daaf4..d52b1d3 100644 --- a/test/Arbiter.Services.Tests/Arbiter.Services.Tests.csproj +++ b/test/Arbiter.Services.Tests/Arbiter.Services.Tests.csproj @@ -11,8 +11,6 @@ - - From 4ad4a8d81569a7685c80496125e005a9705bb269 Mon Sep 17 00:00:00 2001 From: Paul Welter Date: Wed, 14 Jan 2026 21:19:00 -0600 Subject: [PATCH 6/6] clean up --- Directory.Packages.props | 7 -- .../Commands/EntityIdentifierBase.cs | 2 - .../Commands/EntityIdentifiersBase.cs | 2 - .../Commands/EntityModelBase.cs | 2 - .../Commands/EntityPatchCommand.cs | 2 - .../Extensions/QueryExtensions.cs | 1 - .../Extensions/TypeExtensions.cs | 4 - .../Arbiter.Dispatcher.csproj | 22 ---- .../ServiceCollectionExtensions.cs | 109 ------------------ src/Directory.Build.props | 4 +- 10 files changed, 2 insertions(+), 153 deletions(-) delete mode 100644 src/Arbiter.Dispatcher/Arbiter.Dispatcher.csproj delete mode 100644 src/Arbiter.Dispatcher/ServiceCollectionExtensions.cs diff --git a/Directory.Packages.props b/Directory.Packages.props index 479fbc0..f184627 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -20,13 +20,6 @@ - - - - - - - diff --git a/src/Arbiter.CommandQuery/Commands/EntityIdentifierBase.cs b/src/Arbiter.CommandQuery/Commands/EntityIdentifierBase.cs index 7618b4a..0078e76 100644 --- a/src/Arbiter.CommandQuery/Commands/EntityIdentifierBase.cs +++ b/src/Arbiter.CommandQuery/Commands/EntityIdentifierBase.cs @@ -2,8 +2,6 @@ using System.Security.Claims; using System.Text.Json.Serialization; -using MessagePack; - namespace Arbiter.CommandQuery.Commands; /// diff --git a/src/Arbiter.CommandQuery/Commands/EntityIdentifiersBase.cs b/src/Arbiter.CommandQuery/Commands/EntityIdentifiersBase.cs index 2fcb565..5f5feeb 100644 --- a/src/Arbiter.CommandQuery/Commands/EntityIdentifiersBase.cs +++ b/src/Arbiter.CommandQuery/Commands/EntityIdentifiersBase.cs @@ -2,8 +2,6 @@ using System.Security.Claims; using System.Text.Json.Serialization; -using MessagePack; - namespace Arbiter.CommandQuery.Commands; /// diff --git a/src/Arbiter.CommandQuery/Commands/EntityModelBase.cs b/src/Arbiter.CommandQuery/Commands/EntityModelBase.cs index f1def92..8c36fce 100644 --- a/src/Arbiter.CommandQuery/Commands/EntityModelBase.cs +++ b/src/Arbiter.CommandQuery/Commands/EntityModelBase.cs @@ -3,8 +3,6 @@ using System.Security.Claims; using System.Text.Json.Serialization; -using MessagePack; - namespace Arbiter.CommandQuery.Commands; /// diff --git a/src/Arbiter.CommandQuery/Commands/EntityPatchCommand.cs b/src/Arbiter.CommandQuery/Commands/EntityPatchCommand.cs index 5a6e164..c471f53 100644 --- a/src/Arbiter.CommandQuery/Commands/EntityPatchCommand.cs +++ b/src/Arbiter.CommandQuery/Commands/EntityPatchCommand.cs @@ -5,8 +5,6 @@ using Arbiter.CommandQuery.Definitions; using Arbiter.Services; -using MessagePack; - using SystemTextJsonPatch; namespace Arbiter.CommandQuery.Commands; diff --git a/src/Arbiter.CommandQuery/Extensions/QueryExtensions.cs b/src/Arbiter.CommandQuery/Extensions/QueryExtensions.cs index 0fef8a6..a58c284 100644 --- a/src/Arbiter.CommandQuery/Extensions/QueryExtensions.cs +++ b/src/Arbiter.CommandQuery/Extensions/QueryExtensions.cs @@ -2,7 +2,6 @@ using Arbiter.CommandQuery.Definitions; using Arbiter.CommandQuery.Queries; -using Arbiter.CommandQuery.Services; using Arbiter.Services; namespace Arbiter.CommandQuery.Extensions; diff --git a/src/Arbiter.CommandQuery/Extensions/TypeExtensions.cs b/src/Arbiter.CommandQuery/Extensions/TypeExtensions.cs index 6a18284..b57554c 100644 --- a/src/Arbiter.CommandQuery/Extensions/TypeExtensions.cs +++ b/src/Arbiter.CommandQuery/Extensions/TypeExtensions.cs @@ -1,7 +1,3 @@ -using System.Net; - -using Arbiter.CommandQuery; -using Arbiter.CommandQuery.Models; using Arbiter.Services; namespace Arbiter.CommandQuery.Extensions; diff --git a/src/Arbiter.Dispatcher/Arbiter.Dispatcher.csproj b/src/Arbiter.Dispatcher/Arbiter.Dispatcher.csproj deleted file mode 100644 index 6c9a800..0000000 --- a/src/Arbiter.Dispatcher/Arbiter.Dispatcher.csproj +++ /dev/null @@ -1,22 +0,0 @@ - - - - net8.0;net9.0;net10.0 - enable - enable - Arbiter Dispatcher - - - - - - - - - - - - - - - diff --git a/src/Arbiter.Dispatcher/ServiceCollectionExtensions.cs b/src/Arbiter.Dispatcher/ServiceCollectionExtensions.cs deleted file mode 100644 index 2c24480..0000000 --- a/src/Arbiter.Dispatcher/ServiceCollectionExtensions.cs +++ /dev/null @@ -1,109 +0,0 @@ -using Arbiter.CommandQuery.Behaviors; -using Arbiter.CommandQuery.Commands; -using Arbiter.CommandQuery.Definitions; -using Arbiter.CommandQuery.Dispatcher; -using Arbiter.CommandQuery.Extensions; -using Arbiter.CommandQuery.Mapping; -using Arbiter.CommandQuery.Queries; -using Arbiter.CommandQuery.Services; -using Arbiter.CommandQuery.State; - -using Microsoft.Extensions.Caching.Distributed; -using Microsoft.Extensions.Caching.Hybrid; -using Microsoft.Extensions.Caching.Memory; -using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.DependencyInjection.Extensions; - -namespace Arbiter.CommandQuery; - -/// -/// Extension methods for adding command query services to the service collection. -/// -public static class ServiceCollectionExtensions -{ - /// - /// Adds the remote dispatcher to the service collection with configuration for the HTTP client. - /// - /// The to add services to. - /// The action to configure the HTTP client with service provider. - /// The for further configuration of the HTTP client. - /// - /// This overload allows configuration of the HTTP client using both the service provider and HTTP client instance. - /// - public static IHttpClientBuilder AddRemoteDispatcher(this IServiceCollection services, Action configureClient) - { - ArgumentNullException.ThrowIfNull(services); - - services.AddRemoteDispatcher(); - return services.AddHttpClient(configureClient); - } - - /// - /// Adds the remote dispatcher to the service collection with configuration for the HTTP client. - /// - /// The to add services to. - /// The action to configure the HTTP client. - /// The for further configuration of the HTTP client. - /// - /// This overload allows configuration of the HTTP client using the HTTP client instance only. - /// - public static IHttpClientBuilder AddRemoteDispatcher(this IServiceCollection services, Action configureClient) - { - ArgumentNullException.ThrowIfNull(services); - - services.AddRemoteDispatcher(); - return services.AddHttpClient(configureClient); - } - - /// - /// Adds the remote dispatcher to the service collection without HTTP client configuration. - /// - /// The to add services to. - /// The so that additional calls can be chained. - /// - /// This method registers the remote dispatcher without configuring the HTTP client. - /// The client must register the with the correct separately. - /// - public static IServiceCollection AddRemoteDispatcher(this IServiceCollection services) - { - ArgumentNullException.ThrowIfNull(services); - - // up to client to register RemoteDispatcher with correct HttpClient - services.TryAddTransient(sp => sp.GetRequiredService()); - services.AddOptions(); - - services.TryAddTransient(); - - // Model State Open Generic Registrations - services.Add(ServiceDescriptor.Scoped(typeof(ModelStateManager<>), typeof(ModelStateManager<>))); - services.Add(ServiceDescriptor.Scoped(typeof(ModelStateLoader<,>), typeof(ModelStateLoader<,>))); - services.Add(ServiceDescriptor.Scoped(typeof(ModelStateEditor<,,>), typeof(ModelStateEditor<,,>))); - - return services; - } - - /// - /// Adds the server dispatcher to the service collection. - /// - /// The to add services to. - /// The so that additional calls can be chained. - /// - /// The server dispatcher uses the mediator pattern to dispatch commands and queries locally. - /// - public static IServiceCollection AddServerDispatcher(this IServiceCollection services) - { - ArgumentNullException.ThrowIfNull(services); - - services.TryAddTransient(); - services.AddOptions(); - - services.TryAddTransient(); - - // Model State Open Generic Registrations - services.Add(ServiceDescriptor.Scoped(typeof(ModelStateManager<>), typeof(ModelStateManager<>))); - services.Add(ServiceDescriptor.Scoped(typeof(ModelStateLoader<,>), typeof(ModelStateLoader<,>))); - services.Add(ServiceDescriptor.Scoped(typeof(ModelStateEditor<,,>), typeof(ModelStateEditor<,,>))); - - return services; - } -} diff --git a/src/Directory.Build.props b/src/Directory.Build.props index ff3b73b..14178d7 100644 --- a/src/Directory.Build.props +++ b/src/Directory.Build.props @@ -14,11 +14,11 @@ true - + true