From b76abcb5f4b31030b8a06ba62d4842cbd6246a52 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jakub=20Fija=C5=82kowski?= Date: Sat, 6 Jan 2024 14:06:50 +0100 Subject: [PATCH 01/18] Preliminary implementation of local execution of CQRS (commands only) --- .../CQRSEndpointRouteBuilderExtensions.cs | 5 + .../Local/ILocalCommandExecutor.cs | 10 + .../Local/LocalFeatureCollection.cs | 36 ++++ .../Local/LocalHttpContext.cs | 63 ++++++ .../MiddlewareBasedLocalCommandExecutor.cs | 86 ++++++++ .../ServiceCollectionCQRSExtensions.cs | 9 + ...iddlewareBasedLocalCommandExecutorTests.cs | 190 ++++++++++++++++++ 7 files changed, 399 insertions(+) create mode 100644 src/CQRS/LeanCode.CQRS.AspNetCore/Local/ILocalCommandExecutor.cs create mode 100644 src/CQRS/LeanCode.CQRS.AspNetCore/Local/LocalFeatureCollection.cs create mode 100644 src/CQRS/LeanCode.CQRS.AspNetCore/Local/LocalHttpContext.cs create mode 100644 src/CQRS/LeanCode.CQRS.AspNetCore/Local/MiddlewareBasedLocalCommandExecutor.cs create mode 100644 test/CQRS/LeanCode.CQRS.AspNetCore.Tests/Local/MiddlewareBasedLocalCommandExecutorTests.cs diff --git a/src/CQRS/LeanCode.CQRS.AspNetCore/CQRSEndpointRouteBuilderExtensions.cs b/src/CQRS/LeanCode.CQRS.AspNetCore/CQRSEndpointRouteBuilderExtensions.cs index 0d5d44cd..6ac451e0 100644 --- a/src/CQRS/LeanCode.CQRS.AspNetCore/CQRSEndpointRouteBuilderExtensions.cs +++ b/src/CQRS/LeanCode.CQRS.AspNetCore/CQRSEndpointRouteBuilderExtensions.cs @@ -33,6 +33,11 @@ Action config operationsPipeline: pipelineBuilder.PreparePipeline(pipelineBuilder.Operations) ); builder.DataSources.Add(dataSource); + + builder + .ServiceProvider + .GetService() + ?.Configure(pipelineBuilder.Commands); } } diff --git a/src/CQRS/LeanCode.CQRS.AspNetCore/Local/ILocalCommandExecutor.cs b/src/CQRS/LeanCode.CQRS.AspNetCore/Local/ILocalCommandExecutor.cs new file mode 100644 index 00000000..8bbfa722 --- /dev/null +++ b/src/CQRS/LeanCode.CQRS.AspNetCore/Local/ILocalCommandExecutor.cs @@ -0,0 +1,10 @@ +using LeanCode.Contracts; +using Microsoft.AspNetCore.Http; + +namespace LeanCode.CQRS.AspNetCore.Local; + +public interface ILocalCommandExecutor +{ + Task RunAsync(HttpContext context, T command, CancellationToken cancellationToken = default) + where T : ICommand; +} diff --git a/src/CQRS/LeanCode.CQRS.AspNetCore/Local/LocalFeatureCollection.cs b/src/CQRS/LeanCode.CQRS.AspNetCore/Local/LocalFeatureCollection.cs new file mode 100644 index 00000000..17b85955 --- /dev/null +++ b/src/CQRS/LeanCode.CQRS.AspNetCore/Local/LocalFeatureCollection.cs @@ -0,0 +1,36 @@ +using System.Collections; +using Microsoft.AspNetCore.Http.Features; + +namespace LeanCode.CQRS.AspNetCore.Local; + +internal class LocalFeatureCollection : IFeatureCollection +{ + private readonly IFeatureCollection inner; + private readonly FeatureCollection overrides; + + public LocalFeatureCollection(IFeatureCollection inner) + { + this.inner = inner; + this.overrides = new FeatureCollection(5); + } + + public TFeature? Get() + { + return overrides.Get() ?? inner.Get(); + } + + public object? this[Type key] + { + get => overrides[key] ?? inner[key]; + set => overrides[key] = value; + } + public bool IsReadOnly => overrides.IsReadOnly; + public int Revision => overrides.Revision; + + public void Set(TFeature? instance) => overrides.Set(instance); + + // TODO: support properly + public IEnumerator> GetEnumerator() => inner.GetEnumerator(); + + IEnumerator IEnumerable.GetEnumerator() => ((IEnumerable)inner).GetEnumerator(); +} diff --git a/src/CQRS/LeanCode.CQRS.AspNetCore/Local/LocalHttpContext.cs b/src/CQRS/LeanCode.CQRS.AspNetCore/Local/LocalHttpContext.cs new file mode 100644 index 00000000..ad5693fb --- /dev/null +++ b/src/CQRS/LeanCode.CQRS.AspNetCore/Local/LocalHttpContext.cs @@ -0,0 +1,63 @@ +using System.Security.Claims; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Http.Features; + +namespace LeanCode.CQRS.AspNetCore.Local; + +internal class LocalHttpContext : HttpContext +{ + private readonly HttpContext inner; + private readonly LocalFeatureCollection features; + + private IServiceProvider requestServices; + private CancellationToken requestAborted; + + public LocalHttpContext(HttpContext inner, IServiceProvider requestServices, CancellationToken requestAborted) + { + this.inner = inner; + this.requestServices = requestServices; + this.requestAborted = requestAborted; + + features = new LocalFeatureCollection(inner.Features); + } + + public override HttpResponse Response => + throw new NotSupportedException("Accessing response is not supported for Local calls."); + public override IFeatureCollection Features => features; + public override IServiceProvider RequestServices + { + get => requestServices; + set => requestServices = value; + } + + public override HttpRequest Request => inner.Request; + public override ConnectionInfo Connection => inner.Connection; + public override WebSocketManager WebSockets => inner.WebSockets; + public override ClaimsPrincipal User + { + get => inner.User; + set => inner.User = value; + } + public override IDictionary Items + { + get => inner.Items; + set => inner.Items = value; + } + public override CancellationToken RequestAborted + { + get => requestAborted; + set => requestAborted = value; + } + public override string TraceIdentifier + { + get => inner.TraceIdentifier; + set => inner.TraceIdentifier = value; + } + public override ISession Session + { + get => inner.Session; + set => inner.Session = value; + } + + public override void Abort() => throw new InvalidOperationException("Not supported for now."); +} diff --git a/src/CQRS/LeanCode.CQRS.AspNetCore/Local/MiddlewareBasedLocalCommandExecutor.cs b/src/CQRS/LeanCode.CQRS.AspNetCore/Local/MiddlewareBasedLocalCommandExecutor.cs new file mode 100644 index 00000000..7eb48019 --- /dev/null +++ b/src/CQRS/LeanCode.CQRS.AspNetCore/Local/MiddlewareBasedLocalCommandExecutor.cs @@ -0,0 +1,86 @@ +using LeanCode.Contracts; +using LeanCode.CQRS.Execution; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Http.Features; +using Microsoft.Extensions.DependencyInjection; + +namespace LeanCode.CQRS.AspNetCore.Local; + +public class MiddlewareBasedLocalCommandExecutor : ILocalCommandExecutor +{ + private readonly IServiceProvider serviceProvider; + + private RequestDelegate? pipeline; + + public MiddlewareBasedLocalCommandExecutor(IServiceProvider serviceProvider) + { + this.serviceProvider = serviceProvider; + } + + public void Configure(Action configure) + { + var app = new CQRSApplicationBuilder(new ApplicationBuilder(serviceProvider)); + configure(app); + app.Run(LocalCommandExecutor.HandleAsync); + pipeline = app.Build(); + } + + public async Task RunAsync( + HttpContext context, + T command, + CancellationToken cancellationToken = default + ) + where T : ICommand + { + if (pipeline is null) + { + throw new InvalidOperationException("`Configure` first."); + } + + await using var scope = serviceProvider.CreateAsyncScope(); + + var localContext = new LocalHttpContext(context, scope.ServiceProvider, cancellationToken); + + localContext.SetCQRSRequestPayload(command); + localContext.Features.Set(new LocalCommandPayload(command)); + + await pipeline(localContext); + + cancellationToken.ThrowIfCancellationRequested(); + + return (CommandResult)localContext.GetCQRSRequestPayload().Result!.Value.Payload!; + } +} + +internal class LocalCommandExecutor +{ + public static Task HandleAsync(HttpContext context) + { + var localPayload = context.Features.GetRequiredFeature(); + return localPayload.ExecuteAsync(context); + } +} + +internal interface ILocalCommandPayload +{ + Task ExecuteAsync(HttpContext context); +} + +internal class LocalCommandPayload : ILocalCommandPayload + where T : ICommand +{ + private readonly T cmd; + + public LocalCommandPayload(T cmd) + { + this.cmd = cmd; + } + + public async Task ExecuteAsync(HttpContext context) + { + var payload = context.GetCQRSRequestPayload(); + await context.RequestServices.GetRequiredService>().ExecuteAsync(context, cmd); + payload.SetResult(ExecutionResult.WithPayload(CommandResult.Success)); + } +} diff --git a/src/CQRS/LeanCode.CQRS.AspNetCore/ServiceCollectionCQRSExtensions.cs b/src/CQRS/LeanCode.CQRS.AspNetCore/ServiceCollectionCQRSExtensions.cs index d76fb0d6..51306c2f 100644 --- a/src/CQRS/LeanCode.CQRS.AspNetCore/ServiceCollectionCQRSExtensions.cs +++ b/src/CQRS/LeanCode.CQRS.AspNetCore/ServiceCollectionCQRSExtensions.cs @@ -88,4 +88,13 @@ public CQRSServicesBuilder AddOperation() objectsSource.AddCQRSObject(CQRSObjectKind.Operation, typeof(TOperation), typeof(TResult), typeof(THandler)); return this; } + + public CQRSServicesBuilder WithLocalExecutor() + { + Services.AddSingleton(); + Services.AddSingleton( + s => s.GetRequiredService() + ); + return this; + } } diff --git a/test/CQRS/LeanCode.CQRS.AspNetCore.Tests/Local/MiddlewareBasedLocalCommandExecutorTests.cs b/test/CQRS/LeanCode.CQRS.AspNetCore.Tests/Local/MiddlewareBasedLocalCommandExecutorTests.cs new file mode 100644 index 00000000..d4457a32 --- /dev/null +++ b/test/CQRS/LeanCode.CQRS.AspNetCore.Tests/Local/MiddlewareBasedLocalCommandExecutorTests.cs @@ -0,0 +1,190 @@ +using System.Net; +using System.Net.Http.Json; +using System.Security.Claims; +using FluentAssertions; +using LeanCode.Components; +using LeanCode.Contracts; +using LeanCode.CQRS.AspNetCore.Local; +using LeanCode.CQRS.Execution; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Hosting; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.TestHost; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using Xunit; + +namespace LeanCode.CQRS.AspNetCore.Tests.Local; + +public class MiddlewareBasedLocalCommandExecutorTests : IDisposable, IAsyncLifetime +{ + private const string IsAuthenticatedHeader = "is-authenticated"; + private static readonly TypesCatalog ThisCatalog = TypesCatalog.Of(); + + private readonly IHost host; + private readonly TestServer server; + + public MiddlewareBasedLocalCommandExecutorTests() + { + host = new HostBuilder() + .ConfigureWebHost(webHost => + { + webHost + .UseTestServer() + .ConfigureServices(services => + { + services.AddRouting(); + services.AddCQRS(ThisCatalog, ThisCatalog).WithLocalExecutor(); + + services.AddScoped(); + services.AddSingleton(); + }) + .Configure(app => + { + app.UseRouting(); + app.Use(MockAuthorization); + app.UseEndpoints(e => + { + e.MapRemoteCqrs( + "/cqrs", + cqrs => + { + cqrs.Commands = p => p.UseMiddleware(); + } + ); + }); + }); + }) + .Build(); + + server = host.GetTestServer(); + } + + [Fact] + public async Task Runs_both_remote_and_local_commands_successfully() + { + var storage = host.Services.GetRequiredService(); + var result = await SendAsync(new RemoteCommand()); + + result.Should().Be(HttpStatusCode.OK); + + storage.RemoteUser.Should().NotBeNullOrEmpty(); + storage.LocalUsers.Should().AllBe(storage.RemoteUser).And.HaveCount(3); + + storage.Middlewares.ToHashSet().Should().HaveSameCount(storage.Middlewares); + storage.RunHandlers.ToHashSet().Should().HaveSameCount(storage.RunHandlers); + } + + protected async Task SendAsync(ICommand cmd, bool isAuthenticated = true) + { + var path = $"/cqrs/command/{cmd.GetType().FullName}"; + using var msg = new HttpRequestMessage(HttpMethod.Post, path); + msg.Content = JsonContent.Create(cmd); + msg.Headers.Add(IsAuthenticatedHeader, isAuthenticated.ToString()); + + var response = await host.GetTestClient().SendAsync(msg); + return response.StatusCode; + } + + private static Task MockAuthorization(HttpContext httpContext, RequestDelegate next) + { + if ( + httpContext.Request.Headers.TryGetValue(IsAuthenticatedHeader, out var isAuthenticated) + && isAuthenticated == bool.TrueString + ) + { + httpContext.User = new ClaimsPrincipal( + new ClaimsIdentity(new Claim[] { new("id", Guid.NewGuid().ToString()) }, "Test Identity") + ); + } + + return next(httpContext); + } + + public void Dispose() + { + Dispose(true); + GC.SuppressFinalize(this); + } + + protected virtual void Dispose(bool disposing) + { + server.Dispose(); + host.Dispose(); + } + + public Task InitializeAsync() => host.StartAsync(); + + public Task DisposeAsync() => host.StopAsync(); +} + +public class DataStorage +{ + public string? RemoteUser { get; set; } + + public List Middlewares { get; } = [ ]; + public List LocalUsers { get; } = [ ]; + public List RunHandlers { get; } = [ ]; +} + +public record RemoteCommand() : ICommand; + +public record LocalCommand(string Value, bool Fail) : ICommand; + +public class RemoteCommandHandler : ICommandHandler +{ + private readonly DataStorage storage; + private readonly ILocalCommandExecutor localCommand; + + public RemoteCommandHandler(DataStorage storage, ILocalCommandExecutor localCommand) + { + this.storage = storage; + this.localCommand = localCommand; + } + + public async Task ExecuteAsync(HttpContext context, RemoteCommand command) + { + storage.RemoteUser = context.User?.FindFirst("id")?.Value; + + await localCommand.RunAsync(context, new LocalCommand("Test Val 1", false)); + try + { + await localCommand.RunAsync(context, new LocalCommand("Test Val 2", true)); + } + catch { } + await localCommand.RunAsync(context, new LocalCommand("Test Val 3", false)); + } +} + +public class LocalCommandHandler : ICommandHandler +{ + private readonly DataStorage storage; + + public LocalCommand? Command { get; private set; } + + public LocalCommandHandler(DataStorage storage) + { + this.storage = storage; + } + + public async Task ExecuteAsync(HttpContext context, LocalCommand command) + { + Command = command; + storage.RunHandlers.Add(this); + storage.LocalUsers.Add(context.User?.FindFirst("id")?.Value); + + if (command.Fail) + { + throw new InvalidOperationException("Requested."); + } + } +} + +public class TestMiddleware : IMiddleware +{ + public Task InvokeAsync(HttpContext context, RequestDelegate next) + { + context.RequestServices.GetRequiredService().Middlewares.Add(this); + return next(context); + } +} From 4a2099288ff798675d5b6789f3eab4616be95878 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jakub=20Fija=C5=82kowski?= Date: Sat, 6 Jan 2024 14:17:52 +0100 Subject: [PATCH 02/18] Do not re-use the `MapRemoteCqrs` pipeline config for local exec --- .../CQRSEndpointRouteBuilderExtensions.cs | 5 ----- .../Local/MiddlewareBasedLocalCommandExecutor.cs | 15 +++++---------- .../ServiceCollectionCQRSExtensions.cs | 5 ++--- .../MiddlewareBasedLocalCommandExecutorTests.cs | 4 +++- 4 files changed, 10 insertions(+), 19 deletions(-) diff --git a/src/CQRS/LeanCode.CQRS.AspNetCore/CQRSEndpointRouteBuilderExtensions.cs b/src/CQRS/LeanCode.CQRS.AspNetCore/CQRSEndpointRouteBuilderExtensions.cs index 6ac451e0..0d5d44cd 100644 --- a/src/CQRS/LeanCode.CQRS.AspNetCore/CQRSEndpointRouteBuilderExtensions.cs +++ b/src/CQRS/LeanCode.CQRS.AspNetCore/CQRSEndpointRouteBuilderExtensions.cs @@ -33,11 +33,6 @@ Action config operationsPipeline: pipelineBuilder.PreparePipeline(pipelineBuilder.Operations) ); builder.DataSources.Add(dataSource); - - builder - .ServiceProvider - .GetService() - ?.Configure(pipelineBuilder.Commands); } } diff --git a/src/CQRS/LeanCode.CQRS.AspNetCore/Local/MiddlewareBasedLocalCommandExecutor.cs b/src/CQRS/LeanCode.CQRS.AspNetCore/Local/MiddlewareBasedLocalCommandExecutor.cs index 7eb48019..fc0209c4 100644 --- a/src/CQRS/LeanCode.CQRS.AspNetCore/Local/MiddlewareBasedLocalCommandExecutor.cs +++ b/src/CQRS/LeanCode.CQRS.AspNetCore/Local/MiddlewareBasedLocalCommandExecutor.cs @@ -11,15 +11,15 @@ public class MiddlewareBasedLocalCommandExecutor : ILocalCommandExecutor { private readonly IServiceProvider serviceProvider; - private RequestDelegate? pipeline; + private readonly RequestDelegate pipeline; - public MiddlewareBasedLocalCommandExecutor(IServiceProvider serviceProvider) + public MiddlewareBasedLocalCommandExecutor( + IServiceProvider serviceProvider, + Action configure + ) { this.serviceProvider = serviceProvider; - } - public void Configure(Action configure) - { var app = new CQRSApplicationBuilder(new ApplicationBuilder(serviceProvider)); configure(app); app.Run(LocalCommandExecutor.HandleAsync); @@ -33,11 +33,6 @@ public async Task RunAsync( ) where T : ICommand { - if (pipeline is null) - { - throw new InvalidOperationException("`Configure` first."); - } - await using var scope = serviceProvider.CreateAsyncScope(); var localContext = new LocalHttpContext(context, scope.ServiceProvider, cancellationToken); diff --git a/src/CQRS/LeanCode.CQRS.AspNetCore/ServiceCollectionCQRSExtensions.cs b/src/CQRS/LeanCode.CQRS.AspNetCore/ServiceCollectionCQRSExtensions.cs index 51306c2f..9a5b7b45 100644 --- a/src/CQRS/LeanCode.CQRS.AspNetCore/ServiceCollectionCQRSExtensions.cs +++ b/src/CQRS/LeanCode.CQRS.AspNetCore/ServiceCollectionCQRSExtensions.cs @@ -89,11 +89,10 @@ public CQRSServicesBuilder AddOperation() return this; } - public CQRSServicesBuilder WithLocalExecutor() + public CQRSServicesBuilder WithLocalCommands(Action configure) { - Services.AddSingleton(); Services.AddSingleton( - s => s.GetRequiredService() + s => new Local.MiddlewareBasedLocalCommandExecutor(s, configure) ); return this; } diff --git a/test/CQRS/LeanCode.CQRS.AspNetCore.Tests/Local/MiddlewareBasedLocalCommandExecutorTests.cs b/test/CQRS/LeanCode.CQRS.AspNetCore.Tests/Local/MiddlewareBasedLocalCommandExecutorTests.cs index d4457a32..35bb048f 100644 --- a/test/CQRS/LeanCode.CQRS.AspNetCore.Tests/Local/MiddlewareBasedLocalCommandExecutorTests.cs +++ b/test/CQRS/LeanCode.CQRS.AspNetCore.Tests/Local/MiddlewareBasedLocalCommandExecutorTests.cs @@ -34,7 +34,9 @@ public MiddlewareBasedLocalCommandExecutorTests() .ConfigureServices(services => { services.AddRouting(); - services.AddCQRS(ThisCatalog, ThisCatalog).WithLocalExecutor(); + services + .AddCQRS(ThisCatalog, ThisCatalog) + .WithLocalCommands(p => p.UseMiddleware()); services.AddScoped(); services.AddSingleton(); From 8cc75a4b0d74fc1f1f6fe13bf6ea9aca74c67076 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jakub=20Fija=C5=82kowski?= Date: Sun, 28 Jan 2024 00:11:20 +0100 Subject: [PATCH 03/18] Unify "normal" execution with "local" execution internals as much as possible --- .../MiddlewareBasedLocalCommandExecutor.cs | 46 ++++++------------- .../CQRSObjectsRegistrationSource.cs | 19 +++++++- .../Registration/ICQRSObjectSource.cs | 8 ++++ .../ServiceCollectionCQRSExtensions.cs | 3 +- .../HttpContextExtensions.cs | 6 +++ ...iddlewareBasedLocalCommandExecutorTests.cs | 2 +- 6 files changed, 49 insertions(+), 35 deletions(-) create mode 100644 src/CQRS/LeanCode.CQRS.AspNetCore/Registration/ICQRSObjectSource.cs diff --git a/src/CQRS/LeanCode.CQRS.AspNetCore/Local/MiddlewareBasedLocalCommandExecutor.cs b/src/CQRS/LeanCode.CQRS.AspNetCore/Local/MiddlewareBasedLocalCommandExecutor.cs index fc0209c4..8981e7b4 100644 --- a/src/CQRS/LeanCode.CQRS.AspNetCore/Local/MiddlewareBasedLocalCommandExecutor.cs +++ b/src/CQRS/LeanCode.CQRS.AspNetCore/Local/MiddlewareBasedLocalCommandExecutor.cs @@ -1,4 +1,6 @@ using LeanCode.Contracts; +using LeanCode.CQRS.AspNetCore.Middleware; +using LeanCode.CQRS.AspNetCore.Registration; using LeanCode.CQRS.Execution; using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.Http; @@ -9,20 +11,21 @@ namespace LeanCode.CQRS.AspNetCore.Local; public class MiddlewareBasedLocalCommandExecutor : ILocalCommandExecutor { - private readonly IServiceProvider serviceProvider; + private readonly ICQRSObjectSource objectSource; private readonly RequestDelegate pipeline; public MiddlewareBasedLocalCommandExecutor( IServiceProvider serviceProvider, + ICQRSObjectSource objectSource, Action configure ) { - this.serviceProvider = serviceProvider; + this.objectSource = objectSource; var app = new CQRSApplicationBuilder(new ApplicationBuilder(serviceProvider)); configure(app); - app.Run(LocalCommandExecutor.HandleAsync); + app.Run(CQRSPipelineFinalizer.HandleAsync); pipeline = app.Build(); } @@ -33,12 +36,14 @@ public async Task RunAsync( ) where T : ICommand { - await using var scope = serviceProvider.CreateAsyncScope(); + await using var scope = context.RequestServices.CreateAsyncScope(); + var metadata = objectSource.MetadataFor(typeof(T)); var localContext = new LocalHttpContext(context, scope.ServiceProvider, cancellationToken); localContext.SetCQRSRequestPayload(command); - localContext.Features.Set(new LocalCommandPayload(command)); + localContext.SetCQRSObjectMetadataForLocalExecution(metadata); + localContext.Features.Set(new StubEndpointFeature()); await pipeline(localContext); @@ -48,34 +53,11 @@ public async Task RunAsync( } } -internal class LocalCommandExecutor +internal class StubEndpointFeature : IEndpointFeature { - public static Task HandleAsync(HttpContext context) + public Endpoint? Endpoint { - var localPayload = context.Features.GetRequiredFeature(); - return localPayload.ExecuteAsync(context); - } -} - -internal interface ILocalCommandPayload -{ - Task ExecuteAsync(HttpContext context); -} - -internal class LocalCommandPayload : ILocalCommandPayload - where T : ICommand -{ - private readonly T cmd; - - public LocalCommandPayload(T cmd) - { - this.cmd = cmd; - } - - public async Task ExecuteAsync(HttpContext context) - { - var payload = context.GetCQRSRequestPayload(); - await context.RequestServices.GetRequiredService>().ExecuteAsync(context, cmd); - payload.SetResult(ExecutionResult.WithPayload(CommandResult.Success)); + get => null; + set { } } } diff --git a/src/CQRS/LeanCode.CQRS.AspNetCore/Registration/CQRSObjectsRegistrationSource.cs b/src/CQRS/LeanCode.CQRS.AspNetCore/Registration/CQRSObjectsRegistrationSource.cs index a95a9789..84943b56 100644 --- a/src/CQRS/LeanCode.CQRS.AspNetCore/Registration/CQRSObjectsRegistrationSource.cs +++ b/src/CQRS/LeanCode.CQRS.AspNetCore/Registration/CQRSObjectsRegistrationSource.cs @@ -1,3 +1,4 @@ +using System.Collections.Frozen; using System.Reflection; using LeanCode.Components; using LeanCode.Contracts; @@ -6,11 +7,12 @@ namespace LeanCode.CQRS.AspNetCore.Registration; -internal class CQRSObjectsRegistrationSource +internal class CQRSObjectsRegistrationSource : ICQRSObjectSource { private readonly IServiceCollection services; private readonly IObjectExecutorFactory executorFactory; private readonly HashSet objects = new(new CQRSObjectMetadataEqualityComparer()); + private readonly Lazy> cachedMetadata; public IReadOnlySet Objects => objects; @@ -18,8 +20,12 @@ public CQRSObjectsRegistrationSource(IServiceCollection services, IObjectExecuto { this.services = services; this.executorFactory = executorFactory; + + cachedMetadata = new(BuildMetadata, LazyThreadSafetyMode.PublicationOnly); } + public CQRSObjectMetadata MetadataFor(Type type) => cachedMetadata.Value[type]; + public void AddCQRSObjects(TypesCatalog contractsCatalog, TypesCatalog handlersCatalog) { var contracts = contractsCatalog @@ -44,6 +50,7 @@ public void AddCQRSObjects(TypesCatalog contractsCatalog, TypesCatalog handlersC if (handlerCandidates.Count() != 1) { + // TODO: shouldn't we throw here? continue; } @@ -76,6 +83,11 @@ public void AddCQRSObject(CQRSObjectKind kind, Type objectType, Type resultType, public void AddCQRSObject(CQRSObjectMetadata metadata) { + if (cachedMetadata.IsValueCreated) + { + throw new InvalidOperationException("Cannot add another CQRS object after the source has been frozen."); + } + if (!objects.Add(metadata)) { throw new InvalidOperationException( @@ -86,6 +98,11 @@ public void AddCQRSObject(CQRSObjectMetadata metadata) services.AddCQRSHandler(metadata); } + private FrozenDictionary BuildMetadata() + { + return objects.ToFrozenDictionary(m => m.ObjectType); + } + private static bool ValidateContractType(TypeInfo type) { var implementedContractInterfaces = type.ImplementedInterfaces.Where( diff --git a/src/CQRS/LeanCode.CQRS.AspNetCore/Registration/ICQRSObjectSource.cs b/src/CQRS/LeanCode.CQRS.AspNetCore/Registration/ICQRSObjectSource.cs new file mode 100644 index 00000000..8965cd8a --- /dev/null +++ b/src/CQRS/LeanCode.CQRS.AspNetCore/Registration/ICQRSObjectSource.cs @@ -0,0 +1,8 @@ +using LeanCode.CQRS.Execution; + +namespace LeanCode.CQRS.AspNetCore.Registration; + +public interface ICQRSObjectSource +{ + CQRSObjectMetadata MetadataFor(Type type); +} diff --git a/src/CQRS/LeanCode.CQRS.AspNetCore/ServiceCollectionCQRSExtensions.cs b/src/CQRS/LeanCode.CQRS.AspNetCore/ServiceCollectionCQRSExtensions.cs index 9a5b7b45..56c1a373 100644 --- a/src/CQRS/LeanCode.CQRS.AspNetCore/ServiceCollectionCQRSExtensions.cs +++ b/src/CQRS/LeanCode.CQRS.AspNetCore/ServiceCollectionCQRSExtensions.cs @@ -25,6 +25,7 @@ TypesCatalog handlersCatalog var objectsSource = new CQRSObjectsRegistrationSource(serviceCollection, new ObjectExecutorFactory()); objectsSource.AddCQRSObjects(contractsCatalog, handlersCatalog); + serviceCollection.AddSingleton(objectsSource); serviceCollection.AddSingleton(); serviceCollection.AddSingleton(objectsSource); @@ -92,7 +93,7 @@ public CQRSServicesBuilder AddOperation() public CQRSServicesBuilder WithLocalCommands(Action configure) { Services.AddSingleton( - s => new Local.MiddlewareBasedLocalCommandExecutor(s, configure) + s => new Local.MiddlewareBasedLocalCommandExecutor(s, s.GetRequiredService(), configure) ); return this; } diff --git a/src/CQRS/LeanCode.CQRS.Execution/HttpContextExtensions.cs b/src/CQRS/LeanCode.CQRS.Execution/HttpContextExtensions.cs index 120c8144..0233389c 100644 --- a/src/CQRS/LeanCode.CQRS.Execution/HttpContextExtensions.cs +++ b/src/CQRS/LeanCode.CQRS.Execution/HttpContextExtensions.cs @@ -8,6 +8,7 @@ public static class HttpContextExtensions public static CQRSObjectMetadata GetCQRSObjectMetadata(this HttpContext httpContext) { return httpContext.GetEndpoint()?.Metadata.GetMetadata() + ?? httpContext.Features.Get() ?? throw new InvalidOperationException("Request does not contain CQRSObjectMetadata."); } @@ -20,4 +21,9 @@ public static void SetCQRSRequestPayload(this HttpContext httpContext, object pa { httpContext.Features.Set(new CQRSRequestPayload(payload)); } + + public static void SetCQRSObjectMetadataForLocalExecution(this HttpContext httpContext, CQRSObjectMetadata metadata) + { + httpContext.Features.Set(metadata); + } } diff --git a/test/CQRS/LeanCode.CQRS.AspNetCore.Tests/Local/MiddlewareBasedLocalCommandExecutorTests.cs b/test/CQRS/LeanCode.CQRS.AspNetCore.Tests/Local/MiddlewareBasedLocalCommandExecutorTests.cs index 35bb048f..f221eb9f 100644 --- a/test/CQRS/LeanCode.CQRS.AspNetCore.Tests/Local/MiddlewareBasedLocalCommandExecutorTests.cs +++ b/test/CQRS/LeanCode.CQRS.AspNetCore.Tests/Local/MiddlewareBasedLocalCommandExecutorTests.cs @@ -47,7 +47,7 @@ public MiddlewareBasedLocalCommandExecutorTests() app.Use(MockAuthorization); app.UseEndpoints(e => { - e.MapRemoteCqrs( + e.MapRemoteCQRS( "/cqrs", cqrs => { From 81e7be2bc58035e26c70c67712148bb28e1358cb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jakub=20Fija=C5=82kowski?= Date: Wed, 31 Jan 2024 19:56:50 +0100 Subject: [PATCH 04/18] Begin implementing fully local `HttpContext` --- .../Local/Features/LocalCallIdentifier.cs | 13 +++ .../Features/LocalCallLifetimeFeature.cs | 26 ++++++ .../LocalCallServiceProvidersFeature.cs | 13 +++ .../Local/LocalCallContext.cs | 83 +++++++++++++++++++ .../MiddlewareBasedLocalCommandExecutor.cs | 1 + 5 files changed, 136 insertions(+) create mode 100644 src/CQRS/LeanCode.CQRS.AspNetCore/Local/Features/LocalCallIdentifier.cs create mode 100644 src/CQRS/LeanCode.CQRS.AspNetCore/Local/Features/LocalCallLifetimeFeature.cs create mode 100644 src/CQRS/LeanCode.CQRS.AspNetCore/Local/Features/LocalCallServiceProvidersFeature.cs create mode 100644 src/CQRS/LeanCode.CQRS.AspNetCore/Local/LocalCallContext.cs diff --git a/src/CQRS/LeanCode.CQRS.AspNetCore/Local/Features/LocalCallIdentifier.cs b/src/CQRS/LeanCode.CQRS.AspNetCore/Local/Features/LocalCallIdentifier.cs new file mode 100644 index 00000000..2f9cf004 --- /dev/null +++ b/src/CQRS/LeanCode.CQRS.AspNetCore/Local/Features/LocalCallIdentifier.cs @@ -0,0 +1,13 @@ +using Microsoft.AspNetCore.Http.Features; + +namespace LeanCode.CQRS.AspNetCore.Local; + +internal class LocalCallIdentifier : IHttpRequestIdentifierFeature +{ + public string TraceIdentifier { get; set; } + + public LocalCallIdentifier(string traceIdentifier) + { + TraceIdentifier = traceIdentifier; + } +} diff --git a/src/CQRS/LeanCode.CQRS.AspNetCore/Local/Features/LocalCallLifetimeFeature.cs b/src/CQRS/LeanCode.CQRS.AspNetCore/Local/Features/LocalCallLifetimeFeature.cs new file mode 100644 index 00000000..9ab27f7d --- /dev/null +++ b/src/CQRS/LeanCode.CQRS.AspNetCore/Local/Features/LocalCallLifetimeFeature.cs @@ -0,0 +1,26 @@ +using Microsoft.AspNetCore.Http.Features; + +namespace LeanCode.CQRS.AspNetCore.Local; + +internal class LocalCallLifetimeFeature : IHttpRequestLifetimeFeature +{ + private readonly CancellationTokenSource source; + + private CancellationToken requestAborted; + + public CancellationToken RequestAborted + { + get => requestAborted; + set => requestAborted = value; + } + + public CancellationToken CallAborted => source.Token; + + public LocalCallLifetimeFeature(CancellationToken cancellationToken) + { + source = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); + requestAborted = source.Token; + } + + public void Abort() => source.Cancel(); +} diff --git a/src/CQRS/LeanCode.CQRS.AspNetCore/Local/Features/LocalCallServiceProvidersFeature.cs b/src/CQRS/LeanCode.CQRS.AspNetCore/Local/Features/LocalCallServiceProvidersFeature.cs new file mode 100644 index 00000000..7950dad6 --- /dev/null +++ b/src/CQRS/LeanCode.CQRS.AspNetCore/Local/Features/LocalCallServiceProvidersFeature.cs @@ -0,0 +1,13 @@ +using Microsoft.AspNetCore.Http.Features; + +namespace LeanCode.CQRS.AspNetCore.Local; + +internal class LocalCallServiceProvidersFeature : IServiceProvidersFeature +{ + public IServiceProvider RequestServices { get; set; } + + public LocalCallServiceProvidersFeature(IServiceProvider requestServices) + { + RequestServices = requestServices; + } +} diff --git a/src/CQRS/LeanCode.CQRS.AspNetCore/Local/LocalCallContext.cs b/src/CQRS/LeanCode.CQRS.AspNetCore/Local/LocalCallContext.cs new file mode 100644 index 00000000..dc8d819f --- /dev/null +++ b/src/CQRS/LeanCode.CQRS.AspNetCore/Local/LocalCallContext.cs @@ -0,0 +1,83 @@ +using System.Security.Claims; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Http.Features; +using Microsoft.AspNetCore.Http.Features.Authentication; + +namespace LeanCode.CQRS.AspNetCore.Local; + +internal class LocalCallContext : HttpContext +{ + private const int DefaultFeatureCollectionSize = 5; + + private readonly FeatureCollection features; + + private readonly ItemsFeature itemsFeature; + private readonly LocalCallServiceProvidersFeature serviceProvidersFeature; + private readonly HttpAuthenticationFeature httpAuthenticationFeature; + private readonly LocalCallLifetimeFeature callLifetimeFeature; + private readonly LocalCallIdentifier callIdentifier; + + public LocalCallContext( + IServiceProvider requestServices, + ClaimsPrincipal? claimsPrincipal, + string activityIdentifier, + CancellationToken cancellationToken + ) + { + features = new(DefaultFeatureCollectionSize); + + itemsFeature = new(); + serviceProvidersFeature = new(requestServices); + httpAuthenticationFeature = new() { User = claimsPrincipal }; + callLifetimeFeature = new(cancellationToken); + callIdentifier = new(activityIdentifier ?? ""); + + features.Set(itemsFeature); + features.Set(serviceProvidersFeature); + features.Set(httpAuthenticationFeature); + features.Set(callLifetimeFeature); + features.Set(callIdentifier); + } + + public override IFeatureCollection Features => features; + public override HttpRequest Request => throw new NotImplementedException(); + public override HttpResponse Response => throw new NotImplementedException(); + + public override ClaimsPrincipal User + { + get => httpAuthenticationFeature.User!; + set => httpAuthenticationFeature.User = value ?? new(); + } + public override IDictionary Items + { + get => itemsFeature.Items; + set => itemsFeature.Items = value; + } + public override IServiceProvider RequestServices + { + get => serviceProvidersFeature.RequestServices; + set => serviceProvidersFeature.RequestServices = value; + } + public override CancellationToken RequestAborted + { + get => callLifetimeFeature.RequestAborted; + set => callLifetimeFeature.RequestAborted = value; + } + public override string TraceIdentifier + { + get => callIdentifier.TraceIdentifier; + set => callIdentifier.TraceIdentifier = value; + } + + public override ConnectionInfo Connection => + throw new NotSupportedException("There is no Connection in local CQRS calls."); + public override WebSocketManager WebSockets => + throw new NotSupportedException("WebSockets are not supported in local CQRS calls."); + public override ISession Session + { + get => throw new NotSupportedException("There is no Session in local CQRS calls."); + set => throw new NotSupportedException("There is no Session in local CQRS calls."); + } + + public override void Abort() => callLifetimeFeature.Abort(); +} diff --git a/src/CQRS/LeanCode.CQRS.AspNetCore/Local/MiddlewareBasedLocalCommandExecutor.cs b/src/CQRS/LeanCode.CQRS.AspNetCore/Local/MiddlewareBasedLocalCommandExecutor.cs index 8981e7b4..99a15282 100644 --- a/src/CQRS/LeanCode.CQRS.AspNetCore/Local/MiddlewareBasedLocalCommandExecutor.cs +++ b/src/CQRS/LeanCode.CQRS.AspNetCore/Local/MiddlewareBasedLocalCommandExecutor.cs @@ -1,3 +1,4 @@ +using System.Diagnostics; using LeanCode.Contracts; using LeanCode.CQRS.AspNetCore.Middleware; using LeanCode.CQRS.AspNetCore.Registration; From 327f9f3ac803cbff4ed93256ef1f7d8fa135e4af Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jakub=20Fija=C5=82kowski?= Date: Thu, 1 Feb 2024 13:28:49 +0100 Subject: [PATCH 05/18] Switch to `null` objects in local call context --- .../Local/{ => Context}/LocalCallContext.cs | 83 +++++++------- .../LocalCallLifetimeFeature.cs | 3 +- .../LocalCallServiceProvidersFeature.cs | 2 +- .../Local/Context/LocalHttpRequest.cs | 104 ++++++++++++++++++ .../Local/Context/NullConnectionInfo.cs | 49 +++++++++ .../Local/Context/NullEndpointFeature.cs | 15 +++ .../Local/Context/NullHeaderDictionary.cs | 57 ++++++++++ .../Local/Context/NullHttpResponse.cs | 47 ++++++++ .../Context/NullRequestCookieCollection.cs | 29 +++++ .../Local/Context/NullResponseCookies.cs | 16 +++ .../Local/Context/NullSession.cs | 31 ++++++ .../Local/Context/NullWebSocketManager.cs | 16 +++ .../Local/Features/LocalCallIdentifier.cs | 13 --- .../Local/ILocalCommandExecutor.cs | 11 +- .../Local/LocalFeatureCollection.cs | 36 ------ .../Local/LocalHttpContext.cs | 63 ----------- .../MiddlewareBasedLocalCommandExecutor.cs | 54 ++++++--- ...iddlewareBasedLocalCommandExecutorTests.cs | 6 +- 18 files changed, 454 insertions(+), 181 deletions(-) rename src/CQRS/LeanCode.CQRS.AspNetCore/Local/{ => Context}/LocalCallContext.cs (50%) rename src/CQRS/LeanCode.CQRS.AspNetCore/Local/{Features => Context}/LocalCallLifetimeFeature.cs (92%) rename src/CQRS/LeanCode.CQRS.AspNetCore/Local/{Features => Context}/LocalCallServiceProvidersFeature.cs (86%) create mode 100644 src/CQRS/LeanCode.CQRS.AspNetCore/Local/Context/LocalHttpRequest.cs create mode 100644 src/CQRS/LeanCode.CQRS.AspNetCore/Local/Context/NullConnectionInfo.cs create mode 100644 src/CQRS/LeanCode.CQRS.AspNetCore/Local/Context/NullEndpointFeature.cs create mode 100644 src/CQRS/LeanCode.CQRS.AspNetCore/Local/Context/NullHeaderDictionary.cs create mode 100644 src/CQRS/LeanCode.CQRS.AspNetCore/Local/Context/NullHttpResponse.cs create mode 100644 src/CQRS/LeanCode.CQRS.AspNetCore/Local/Context/NullRequestCookieCollection.cs create mode 100644 src/CQRS/LeanCode.CQRS.AspNetCore/Local/Context/NullResponseCookies.cs create mode 100644 src/CQRS/LeanCode.CQRS.AspNetCore/Local/Context/NullSession.cs create mode 100644 src/CQRS/LeanCode.CQRS.AspNetCore/Local/Context/NullWebSocketManager.cs delete mode 100644 src/CQRS/LeanCode.CQRS.AspNetCore/Local/Features/LocalCallIdentifier.cs delete mode 100644 src/CQRS/LeanCode.CQRS.AspNetCore/Local/LocalFeatureCollection.cs delete mode 100644 src/CQRS/LeanCode.CQRS.AspNetCore/Local/LocalHttpContext.cs diff --git a/src/CQRS/LeanCode.CQRS.AspNetCore/Local/LocalCallContext.cs b/src/CQRS/LeanCode.CQRS.AspNetCore/Local/Context/LocalCallContext.cs similarity index 50% rename from src/CQRS/LeanCode.CQRS.AspNetCore/Local/LocalCallContext.cs rename to src/CQRS/LeanCode.CQRS.AspNetCore/Local/Context/LocalCallContext.cs index dc8d819f..5fedb617 100644 --- a/src/CQRS/LeanCode.CQRS.AspNetCore/Local/LocalCallContext.cs +++ b/src/CQRS/LeanCode.CQRS.AspNetCore/Local/Context/LocalCallContext.cs @@ -1,82 +1,77 @@ using System.Security.Claims; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Http.Features; -using Microsoft.AspNetCore.Http.Features.Authentication; -namespace LeanCode.CQRS.AspNetCore.Local; +namespace LeanCode.CQRS.AspNetCore.Local.Context; internal class LocalCallContext : HttpContext { - private const int DefaultFeatureCollectionSize = 5; + private const int DefaultFeatureCollectionSize = 6; // 4 internal, 2 external (set in local executors) private readonly FeatureCollection features; private readonly ItemsFeature itemsFeature; private readonly LocalCallServiceProvidersFeature serviceProvidersFeature; - private readonly HttpAuthenticationFeature httpAuthenticationFeature; private readonly LocalCallLifetimeFeature callLifetimeFeature; - private readonly LocalCallIdentifier callIdentifier; - - public LocalCallContext( - IServiceProvider requestServices, - ClaimsPrincipal? claimsPrincipal, - string activityIdentifier, - CancellationToken cancellationToken - ) - { - features = new(DefaultFeatureCollectionSize); - - itemsFeature = new(); - serviceProvidersFeature = new(requestServices); - httpAuthenticationFeature = new() { User = claimsPrincipal }; - callLifetimeFeature = new(cancellationToken); - callIdentifier = new(activityIdentifier ?? ""); - - features.Set(itemsFeature); - features.Set(serviceProvidersFeature); - features.Set(httpAuthenticationFeature); - features.Set(callLifetimeFeature); - features.Set(callIdentifier); - } public override IFeatureCollection Features => features; - public override HttpRequest Request => throw new NotImplementedException(); - public override HttpResponse Response => throw new NotImplementedException(); - public override ClaimsPrincipal User - { - get => httpAuthenticationFeature.User!; - set => httpAuthenticationFeature.User = value ?? new(); - } + public override ClaimsPrincipal User { get; set; } + public override string TraceIdentifier { get; set; } + public override HttpRequest Request { get; } + public override HttpResponse Response { get; } + public override IDictionary Items { get => itemsFeature.Items; set => itemsFeature.Items = value; } + public override IServiceProvider RequestServices { get => serviceProvidersFeature.RequestServices; set => serviceProvidersFeature.RequestServices = value; } + public override CancellationToken RequestAborted { get => callLifetimeFeature.RequestAborted; set => callLifetimeFeature.RequestAborted = value; } - public override string TraceIdentifier + + public CancellationToken CallAborted => callLifetimeFeature.CallAborted; + + public override ConnectionInfo Connection => NullConnectionInfo.Empty; + public override WebSocketManager WebSockets => NullWebSocketManager.Empty; + public override ISession Session { - get => callIdentifier.TraceIdentifier; - set => callIdentifier.TraceIdentifier = value; + get => NullSession.Empty; + set { } } - public override ConnectionInfo Connection => - throw new NotSupportedException("There is no Connection in local CQRS calls."); - public override WebSocketManager WebSockets => - throw new NotSupportedException("WebSockets are not supported in local CQRS calls."); - public override ISession Session + public LocalCallContext( + IServiceProvider requestServices, + ClaimsPrincipal user, + string? activityIdentifier, + IHeaderDictionary? headers, + CancellationToken cancellationToken + ) { - get => throw new NotSupportedException("There is no Session in local CQRS calls."); - set => throw new NotSupportedException("There is no Session in local CQRS calls."); + features = new(DefaultFeatureCollectionSize); + + User = user; + TraceIdentifier = activityIdentifier ?? ""; + Request = new LocalHttpRequest(this, headers); + Response = new NullHttpResponse(this); + + itemsFeature = new(); + serviceProvidersFeature = new(requestServices); + callLifetimeFeature = new(cancellationToken); + + features.Set(itemsFeature); + features.Set(serviceProvidersFeature); + features.Set(callLifetimeFeature); + features.Set(NullEndpointFeature.Empty); } public override void Abort() => callLifetimeFeature.Abort(); diff --git a/src/CQRS/LeanCode.CQRS.AspNetCore/Local/Features/LocalCallLifetimeFeature.cs b/src/CQRS/LeanCode.CQRS.AspNetCore/Local/Context/LocalCallLifetimeFeature.cs similarity index 92% rename from src/CQRS/LeanCode.CQRS.AspNetCore/Local/Features/LocalCallLifetimeFeature.cs rename to src/CQRS/LeanCode.CQRS.AspNetCore/Local/Context/LocalCallLifetimeFeature.cs index 9ab27f7d..d854decc 100644 --- a/src/CQRS/LeanCode.CQRS.AspNetCore/Local/Features/LocalCallLifetimeFeature.cs +++ b/src/CQRS/LeanCode.CQRS.AspNetCore/Local/Context/LocalCallLifetimeFeature.cs @@ -1,11 +1,10 @@ using Microsoft.AspNetCore.Http.Features; -namespace LeanCode.CQRS.AspNetCore.Local; +namespace LeanCode.CQRS.AspNetCore.Local.Context; internal class LocalCallLifetimeFeature : IHttpRequestLifetimeFeature { private readonly CancellationTokenSource source; - private CancellationToken requestAborted; public CancellationToken RequestAborted diff --git a/src/CQRS/LeanCode.CQRS.AspNetCore/Local/Features/LocalCallServiceProvidersFeature.cs b/src/CQRS/LeanCode.CQRS.AspNetCore/Local/Context/LocalCallServiceProvidersFeature.cs similarity index 86% rename from src/CQRS/LeanCode.CQRS.AspNetCore/Local/Features/LocalCallServiceProvidersFeature.cs rename to src/CQRS/LeanCode.CQRS.AspNetCore/Local/Context/LocalCallServiceProvidersFeature.cs index 7950dad6..ff0b55e1 100644 --- a/src/CQRS/LeanCode.CQRS.AspNetCore/Local/Features/LocalCallServiceProvidersFeature.cs +++ b/src/CQRS/LeanCode.CQRS.AspNetCore/Local/Context/LocalCallServiceProvidersFeature.cs @@ -1,6 +1,6 @@ using Microsoft.AspNetCore.Http.Features; -namespace LeanCode.CQRS.AspNetCore.Local; +namespace LeanCode.CQRS.AspNetCore.Local.Context; internal class LocalCallServiceProvidersFeature : IServiceProvidersFeature { diff --git a/src/CQRS/LeanCode.CQRS.AspNetCore/Local/Context/LocalHttpRequest.cs b/src/CQRS/LeanCode.CQRS.AspNetCore/Local/Context/LocalHttpRequest.cs new file mode 100644 index 00000000..ab42b8a0 --- /dev/null +++ b/src/CQRS/LeanCode.CQRS.AspNetCore/Local/Context/LocalHttpRequest.cs @@ -0,0 +1,104 @@ +using Microsoft.AspNetCore.Http; + +namespace LeanCode.CQRS.AspNetCore.Local.Context; + +internal class LocalHttpRequest : HttpRequest +{ + public override HttpContext HttpContext { get; } + public override IHeaderDictionary Headers { get; } + + public override bool HasFormContentType => false; + + public override string Method + { + get => ""; + set { } + } + + public override string Scheme + { + get => ""; + set { } + } + + public override bool IsHttps + { + get => false; + set { } + } + + public override HostString Host + { + get => default; + set { } + } + + public override PathString PathBase + { + get => PathString.Empty; + set { } + } + + public override PathString Path + { + get => PathString.Empty; + set { } + } + + public override QueryString QueryString + { + get => QueryString.Empty; + set { } + } + + public override IQueryCollection Query + { + get => QueryCollection.Empty; + set { } + } + + public override string Protocol + { + get => ""; + set { } + } + + public override IRequestCookieCollection Cookies + { + get => NullRequestCookieCollection.Empty; + set { } + } + + public override long? ContentLength + { + get => null; + set { } + } + + public override string? ContentType + { + get => null; + set { } + } + + public override Stream Body + { + get => Stream.Null; + set { } + } + + public override IFormCollection Form + { + get => FormCollection.Empty; + set { } + } + + public LocalHttpRequest(HttpContext httpContext, IHeaderDictionary? headers) + { + HttpContext = httpContext; + Headers = headers ?? new HeaderDictionary(); + } + + public override Task ReadFormAsync(CancellationToken cancellationToken = default) => + Task.FromResult(FormCollection.Empty); +} diff --git a/src/CQRS/LeanCode.CQRS.AspNetCore/Local/Context/NullConnectionInfo.cs b/src/CQRS/LeanCode.CQRS.AspNetCore/Local/Context/NullConnectionInfo.cs new file mode 100644 index 00000000..d9dc4be2 --- /dev/null +++ b/src/CQRS/LeanCode.CQRS.AspNetCore/Local/Context/NullConnectionInfo.cs @@ -0,0 +1,49 @@ +using System.Net; +using System.Security.Cryptography.X509Certificates; +using Microsoft.AspNetCore.Http; + +namespace LeanCode.CQRS.AspNetCore.Local.Context; + +internal class NullConnectionInfo : ConnectionInfo +{ + public static readonly NullConnectionInfo Empty = new(); + + public override string Id + { + get => ""; + set { } + } + + public override IPAddress? RemoteIpAddress + { + get => null; + set { } + } + + public override int RemotePort + { + get => 0; + set { } + } + + public override IPAddress? LocalIpAddress + { + get => null; + set { } + } + + public override int LocalPort + { + get => 0; + set { } + } + + public override X509Certificate2? ClientCertificate + { + get => null; + set { } + } + + public override Task GetClientCertificateAsync(CancellationToken cancellationToken = default) => + Task.FromResult(null); +} diff --git a/src/CQRS/LeanCode.CQRS.AspNetCore/Local/Context/NullEndpointFeature.cs b/src/CQRS/LeanCode.CQRS.AspNetCore/Local/Context/NullEndpointFeature.cs new file mode 100644 index 00000000..a93de569 --- /dev/null +++ b/src/CQRS/LeanCode.CQRS.AspNetCore/Local/Context/NullEndpointFeature.cs @@ -0,0 +1,15 @@ +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Http.Features; + +namespace LeanCode.CQRS.AspNetCore.Local.Context; + +internal class NullEndpointFeature : IEndpointFeature +{ + public static readonly NullEndpointFeature Empty = new(); + + public Endpoint? Endpoint + { + get => null; + set { } + } +} diff --git a/src/CQRS/LeanCode.CQRS.AspNetCore/Local/Context/NullHeaderDictionary.cs b/src/CQRS/LeanCode.CQRS.AspNetCore/Local/Context/NullHeaderDictionary.cs new file mode 100644 index 00000000..801a7949 --- /dev/null +++ b/src/CQRS/LeanCode.CQRS.AspNetCore/Local/Context/NullHeaderDictionary.cs @@ -0,0 +1,57 @@ +using System.Collections; +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.Primitives; + +namespace LeanCode.CQRS.AspNetCore.Local.Context; + +internal class NullHeaderDictionary : IHeaderDictionary +{ + public static readonly NullHeaderDictionary Empty = new(); + + public StringValues this[string key] + { + get => StringValues.Empty; + set { } + } + + public long? ContentLength + { + get => null; + set { } + } + + public ICollection Keys => [ ]; + + public ICollection Values => [ ]; + + public int Count => 0; + + public bool IsReadOnly => true; + + public void Add(string key, StringValues value) { } + + public void Add(KeyValuePair item) { } + + public void Clear() { } + + public bool Contains(KeyValuePair item) => false; + + public bool ContainsKey(string key) => false; + + public void CopyTo(KeyValuePair[] array, int arrayIndex) { } + + public bool Remove(string key) => false; + + public bool Remove(KeyValuePair item) => false; + + public bool TryGetValue(string key, out StringValues value) + { + value = StringValues.Empty; + return false; + } + + public IEnumerator> GetEnumerator() => + Enumerable.Empty>().GetEnumerator(); + + IEnumerator IEnumerable.GetEnumerator() => GetEnumerator(); +} diff --git a/src/CQRS/LeanCode.CQRS.AspNetCore/Local/Context/NullHttpResponse.cs b/src/CQRS/LeanCode.CQRS.AspNetCore/Local/Context/NullHttpResponse.cs new file mode 100644 index 00000000..c2cc76bc --- /dev/null +++ b/src/CQRS/LeanCode.CQRS.AspNetCore/Local/Context/NullHttpResponse.cs @@ -0,0 +1,47 @@ +using Microsoft.AspNetCore.Http; + +namespace LeanCode.CQRS.AspNetCore.Local.Context; + +internal class NullHttpResponse : HttpResponse +{ + public override HttpContext HttpContext { get; } + public override IHeaderDictionary Headers => NullHeaderDictionary.Empty; + public override IResponseCookies Cookies => NullResponseCookies.Empty; + + public override bool HasStarted => false; + + public override long? ContentLength + { + get => null; + set { } + } + + public override int StatusCode + { + get => 0; + set { } + } + + public override string? ContentType + { + get => null; + set { } + } + + public override Stream Body + { + get => Stream.Null; + set { } + } + + public NullHttpResponse(HttpContext context) + { + HttpContext = context; + } + + public override void OnCompleted(Func callback, object state) { } + + public override void Redirect(string location, bool permanent) { } + + public override void OnStarting(Func callback, object state) { } +} diff --git a/src/CQRS/LeanCode.CQRS.AspNetCore/Local/Context/NullRequestCookieCollection.cs b/src/CQRS/LeanCode.CQRS.AspNetCore/Local/Context/NullRequestCookieCollection.cs new file mode 100644 index 00000000..6c328900 --- /dev/null +++ b/src/CQRS/LeanCode.CQRS.AspNetCore/Local/Context/NullRequestCookieCollection.cs @@ -0,0 +1,29 @@ +using System.Collections; +using System.Diagnostics.CodeAnalysis; +using Microsoft.AspNetCore.Http; + +namespace LeanCode.CQRS.AspNetCore.Local.Context; + +internal class NullRequestCookieCollection : IRequestCookieCollection +{ + public static readonly NullRequestCookieCollection Empty = new(); + + public string? this[string key] => null; + + public int Count => 0; + + public ICollection Keys => [ ]; + + public bool ContainsKey(string key) => false; + + public IEnumerator> GetEnumerator() => + Enumerable.Empty>().GetEnumerator(); + + public bool TryGetValue(string key, [NotNullWhen(true)] out string? value) + { + value = null; + return false; + } + + IEnumerator IEnumerable.GetEnumerator() => GetEnumerator(); +} diff --git a/src/CQRS/LeanCode.CQRS.AspNetCore/Local/Context/NullResponseCookies.cs b/src/CQRS/LeanCode.CQRS.AspNetCore/Local/Context/NullResponseCookies.cs new file mode 100644 index 00000000..ee45965b --- /dev/null +++ b/src/CQRS/LeanCode.CQRS.AspNetCore/Local/Context/NullResponseCookies.cs @@ -0,0 +1,16 @@ +using Microsoft.AspNetCore.Http; + +namespace LeanCode.CQRS.AspNetCore.Local.Context; + +internal class NullResponseCookies : IResponseCookies +{ + public static readonly NullResponseCookies Empty = new(); + + public void Append(string key, string value) { } + + public void Append(string key, string value, CookieOptions options) { } + + public void Delete(string key) { } + + public void Delete(string key, CookieOptions options) { } +} diff --git a/src/CQRS/LeanCode.CQRS.AspNetCore/Local/Context/NullSession.cs b/src/CQRS/LeanCode.CQRS.AspNetCore/Local/Context/NullSession.cs new file mode 100644 index 00000000..8490ab42 --- /dev/null +++ b/src/CQRS/LeanCode.CQRS.AspNetCore/Local/Context/NullSession.cs @@ -0,0 +1,31 @@ +using System.Diagnostics.CodeAnalysis; +using Microsoft.AspNetCore.Http; + +namespace LeanCode.CQRS.AspNetCore.Local.Context; + +internal class NullSession : ISession +{ + public static readonly NullSession Empty = new(); + + public bool IsAvailable => false; + + public string Id => ""; + + public IEnumerable Keys => [ ]; + + public void Clear() { } + + public Task CommitAsync(CancellationToken cancellationToken = default) => Task.CompletedTask; + + public Task LoadAsync(CancellationToken cancellationToken = default) => Task.CompletedTask; + + public void Remove(string key) { } + + public void Set(string key, byte[] value) { } + + public bool TryGetValue(string key, [NotNullWhen(true)] out byte[]? value) + { + value = null; + return false; + } +} diff --git a/src/CQRS/LeanCode.CQRS.AspNetCore/Local/Context/NullWebSocketManager.cs b/src/CQRS/LeanCode.CQRS.AspNetCore/Local/Context/NullWebSocketManager.cs new file mode 100644 index 00000000..59dbe01c --- /dev/null +++ b/src/CQRS/LeanCode.CQRS.AspNetCore/Local/Context/NullWebSocketManager.cs @@ -0,0 +1,16 @@ +using System.Net.WebSockets; +using Microsoft.AspNetCore.Http; + +namespace LeanCode.CQRS.AspNetCore.Local.Context; + +internal class NullWebSocketManager : WebSocketManager +{ + public static readonly NullWebSocketManager Empty = new(); + + public override bool IsWebSocketRequest => false; + + public override IList WebSocketRequestedProtocols => [ ]; + + public override Task AcceptWebSocketAsync(string? subProtocol) => + throw new NotSupportedException("WebSockets are not supported in local CQRS calls."); +} diff --git a/src/CQRS/LeanCode.CQRS.AspNetCore/Local/Features/LocalCallIdentifier.cs b/src/CQRS/LeanCode.CQRS.AspNetCore/Local/Features/LocalCallIdentifier.cs deleted file mode 100644 index 2f9cf004..00000000 --- a/src/CQRS/LeanCode.CQRS.AspNetCore/Local/Features/LocalCallIdentifier.cs +++ /dev/null @@ -1,13 +0,0 @@ -using Microsoft.AspNetCore.Http.Features; - -namespace LeanCode.CQRS.AspNetCore.Local; - -internal class LocalCallIdentifier : IHttpRequestIdentifierFeature -{ - public string TraceIdentifier { get; set; } - - public LocalCallIdentifier(string traceIdentifier) - { - TraceIdentifier = traceIdentifier; - } -} diff --git a/src/CQRS/LeanCode.CQRS.AspNetCore/Local/ILocalCommandExecutor.cs b/src/CQRS/LeanCode.CQRS.AspNetCore/Local/ILocalCommandExecutor.cs index 8bbfa722..d81f4457 100644 --- a/src/CQRS/LeanCode.CQRS.AspNetCore/Local/ILocalCommandExecutor.cs +++ b/src/CQRS/LeanCode.CQRS.AspNetCore/Local/ILocalCommandExecutor.cs @@ -1,3 +1,4 @@ +using System.Security.Claims; using LeanCode.Contracts; using Microsoft.AspNetCore.Http; @@ -5,6 +6,14 @@ namespace LeanCode.CQRS.AspNetCore.Local; public interface ILocalCommandExecutor { - Task RunAsync(HttpContext context, T command, CancellationToken cancellationToken = default) + Task RunAsync(T command, ClaimsPrincipal user, CancellationToken cancellationToken = default) + where T : ICommand; + + Task RunAsync( + T command, + ClaimsPrincipal user, + IHeaderDictionary headers, + CancellationToken cancellationToken = default + ) where T : ICommand; } diff --git a/src/CQRS/LeanCode.CQRS.AspNetCore/Local/LocalFeatureCollection.cs b/src/CQRS/LeanCode.CQRS.AspNetCore/Local/LocalFeatureCollection.cs deleted file mode 100644 index 17b85955..00000000 --- a/src/CQRS/LeanCode.CQRS.AspNetCore/Local/LocalFeatureCollection.cs +++ /dev/null @@ -1,36 +0,0 @@ -using System.Collections; -using Microsoft.AspNetCore.Http.Features; - -namespace LeanCode.CQRS.AspNetCore.Local; - -internal class LocalFeatureCollection : IFeatureCollection -{ - private readonly IFeatureCollection inner; - private readonly FeatureCollection overrides; - - public LocalFeatureCollection(IFeatureCollection inner) - { - this.inner = inner; - this.overrides = new FeatureCollection(5); - } - - public TFeature? Get() - { - return overrides.Get() ?? inner.Get(); - } - - public object? this[Type key] - { - get => overrides[key] ?? inner[key]; - set => overrides[key] = value; - } - public bool IsReadOnly => overrides.IsReadOnly; - public int Revision => overrides.Revision; - - public void Set(TFeature? instance) => overrides.Set(instance); - - // TODO: support properly - public IEnumerator> GetEnumerator() => inner.GetEnumerator(); - - IEnumerator IEnumerable.GetEnumerator() => ((IEnumerable)inner).GetEnumerator(); -} diff --git a/src/CQRS/LeanCode.CQRS.AspNetCore/Local/LocalHttpContext.cs b/src/CQRS/LeanCode.CQRS.AspNetCore/Local/LocalHttpContext.cs deleted file mode 100644 index ad5693fb..00000000 --- a/src/CQRS/LeanCode.CQRS.AspNetCore/Local/LocalHttpContext.cs +++ /dev/null @@ -1,63 +0,0 @@ -using System.Security.Claims; -using Microsoft.AspNetCore.Http; -using Microsoft.AspNetCore.Http.Features; - -namespace LeanCode.CQRS.AspNetCore.Local; - -internal class LocalHttpContext : HttpContext -{ - private readonly HttpContext inner; - private readonly LocalFeatureCollection features; - - private IServiceProvider requestServices; - private CancellationToken requestAborted; - - public LocalHttpContext(HttpContext inner, IServiceProvider requestServices, CancellationToken requestAborted) - { - this.inner = inner; - this.requestServices = requestServices; - this.requestAborted = requestAborted; - - features = new LocalFeatureCollection(inner.Features); - } - - public override HttpResponse Response => - throw new NotSupportedException("Accessing response is not supported for Local calls."); - public override IFeatureCollection Features => features; - public override IServiceProvider RequestServices - { - get => requestServices; - set => requestServices = value; - } - - public override HttpRequest Request => inner.Request; - public override ConnectionInfo Connection => inner.Connection; - public override WebSocketManager WebSockets => inner.WebSockets; - public override ClaimsPrincipal User - { - get => inner.User; - set => inner.User = value; - } - public override IDictionary Items - { - get => inner.Items; - set => inner.Items = value; - } - public override CancellationToken RequestAborted - { - get => requestAborted; - set => requestAborted = value; - } - public override string TraceIdentifier - { - get => inner.TraceIdentifier; - set => inner.TraceIdentifier = value; - } - public override ISession Session - { - get => inner.Session; - set => inner.Session = value; - } - - public override void Abort() => throw new InvalidOperationException("Not supported for now."); -} diff --git a/src/CQRS/LeanCode.CQRS.AspNetCore/Local/MiddlewareBasedLocalCommandExecutor.cs b/src/CQRS/LeanCode.CQRS.AspNetCore/Local/MiddlewareBasedLocalCommandExecutor.cs index 99a15282..9ce47fb8 100644 --- a/src/CQRS/LeanCode.CQRS.AspNetCore/Local/MiddlewareBasedLocalCommandExecutor.cs +++ b/src/CQRS/LeanCode.CQRS.AspNetCore/Local/MiddlewareBasedLocalCommandExecutor.cs @@ -1,17 +1,18 @@ -using System.Diagnostics; +using System.Security.Claims; using LeanCode.Contracts; using LeanCode.CQRS.AspNetCore.Middleware; using LeanCode.CQRS.AspNetCore.Registration; using LeanCode.CQRS.Execution; +using LeanCode.OpenTelemetry; using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.Http; -using Microsoft.AspNetCore.Http.Features; using Microsoft.Extensions.DependencyInjection; namespace LeanCode.CQRS.AspNetCore.Local; public class MiddlewareBasedLocalCommandExecutor : ILocalCommandExecutor { + private readonly IServiceProvider serviceProvider; private readonly ICQRSObjectSource objectSource; private readonly RequestDelegate pipeline; @@ -22,6 +23,7 @@ public MiddlewareBasedLocalCommandExecutor( Action configure ) { + this.serviceProvider = serviceProvider; this.objectSource = objectSource; var app = new CQRSApplicationBuilder(new ApplicationBuilder(serviceProvider)); @@ -30,35 +32,51 @@ Action configure pipeline = app.Build(); } - public async Task RunAsync( - HttpContext context, + public Task RunAsync( T command, + ClaimsPrincipal user, CancellationToken cancellationToken = default + ) + where T : ICommand => RunInternalAsync(command, user, null, cancellationToken); + + public Task RunAsync( + T command, + ClaimsPrincipal user, + IHeaderDictionary headers, + CancellationToken cancellationToken = default + ) + where T : ICommand => RunInternalAsync(command, user, headers, cancellationToken); + + private async Task RunInternalAsync( + T command, + ClaimsPrincipal user, + IHeaderDictionary? headers, + CancellationToken cancellationToken ) where T : ICommand { - await using var scope = context.RequestServices.CreateAsyncScope(); - var metadata = objectSource.MetadataFor(typeof(T)); - var localContext = new LocalHttpContext(context, scope.ServiceProvider, cancellationToken); + + using var activity = LeanCodeActivitySource.ActivitySource.StartActivity("pipeline.action.local"); + activity?.AddTag("object", metadata.ObjectType.FullName); + + await using var scope = serviceProvider.CreateAsyncScope(); + + var localContext = new Context.LocalCallContext( + scope.ServiceProvider, + user, + activity?.Id, + headers, + cancellationToken + ); localContext.SetCQRSRequestPayload(command); localContext.SetCQRSObjectMetadataForLocalExecution(metadata); - localContext.Features.Set(new StubEndpointFeature()); await pipeline(localContext); - cancellationToken.ThrowIfCancellationRequested(); + localContext.CallAborted.ThrowIfCancellationRequested(); return (CommandResult)localContext.GetCQRSRequestPayload().Result!.Value.Payload!; } } - -internal class StubEndpointFeature : IEndpointFeature -{ - public Endpoint? Endpoint - { - get => null; - set { } - } -} diff --git a/test/CQRS/LeanCode.CQRS.AspNetCore.Tests/Local/MiddlewareBasedLocalCommandExecutorTests.cs b/test/CQRS/LeanCode.CQRS.AspNetCore.Tests/Local/MiddlewareBasedLocalCommandExecutorTests.cs index f221eb9f..6b5ddd4b 100644 --- a/test/CQRS/LeanCode.CQRS.AspNetCore.Tests/Local/MiddlewareBasedLocalCommandExecutorTests.cs +++ b/test/CQRS/LeanCode.CQRS.AspNetCore.Tests/Local/MiddlewareBasedLocalCommandExecutorTests.cs @@ -148,13 +148,13 @@ public async Task ExecuteAsync(HttpContext context, RemoteCommand command) { storage.RemoteUser = context.User?.FindFirst("id")?.Value; - await localCommand.RunAsync(context, new LocalCommand("Test Val 1", false)); + await localCommand.RunAsync(new LocalCommand("Test Val 1", false), context.User!); try { - await localCommand.RunAsync(context, new LocalCommand("Test Val 2", true)); + await localCommand.RunAsync(new LocalCommand("Test Val 2", true), context.User!); } catch { } - await localCommand.RunAsync(context, new LocalCommand("Test Val 3", false)); + await localCommand.RunAsync(new LocalCommand("Test Val 3", false), context.User!); } } From 926dc5350ec330af2f02adee013419b222ae7d1d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jakub=20Fija=C5=82kowski?= Date: Wed, 7 Feb 2024 12:48:02 +0100 Subject: [PATCH 06/18] Test the dummy context classes --- .../Local/Context/LocalCallContext.cs | 4 +- .../Local/Context/LocalCallLifetimeFeature.cs | 4 +- .../Local/Context/NullConnectionInfo.cs | 2 + .../Local/Context/NullEndpointFeature.cs | 2 + .../Local/Context/NullHeaderDictionary.cs | 7 +- .../Context/NullRequestCookieCollection.cs | 2 + .../Local/Context/NullResponseCookies.cs | 2 + .../Local/Context/NullSession.cs | 2 + .../Local/Context/NullWebSocketManager.cs | 5 +- .../MiddlewareBasedLocalCommandExecutor.cs | 2 +- .../Context/LocalCallLifetimeFeatureTests.cs | 51 +++++++ .../LocalCallServiceProvidersFeatureTests.cs | 21 +++ .../Local/Context/LocalHttpRequestTests.cs | 137 ++++++++++++++++++ .../Local/Context/NullConnectionInfoTests.cs | 68 +++++++++ .../Local/Context/NullEndpointFeatureTests.cs | 18 +++ .../Context/NullHeaderDictionaryTests.cs | 118 +++++++++++++++ .../Local/Context/NullHttpResponseTests.cs | 88 +++++++++++ .../NullRequestCookieCollectionTests.cs | 33 +++++ .../Local/Context/NullResponseCookiesTests.cs | 36 +++++ .../Local/Context/NullSessionTests.cs | 63 ++++++++ .../Context/NullWebSocketManagerTests.cs | 31 ++++ 21 files changed, 690 insertions(+), 6 deletions(-) create mode 100644 test/CQRS/LeanCode.CQRS.AspNetCore.Tests/Local/Context/LocalCallLifetimeFeatureTests.cs create mode 100644 test/CQRS/LeanCode.CQRS.AspNetCore.Tests/Local/Context/LocalCallServiceProvidersFeatureTests.cs create mode 100644 test/CQRS/LeanCode.CQRS.AspNetCore.Tests/Local/Context/LocalHttpRequestTests.cs create mode 100644 test/CQRS/LeanCode.CQRS.AspNetCore.Tests/Local/Context/NullConnectionInfoTests.cs create mode 100644 test/CQRS/LeanCode.CQRS.AspNetCore.Tests/Local/Context/NullEndpointFeatureTests.cs create mode 100644 test/CQRS/LeanCode.CQRS.AspNetCore.Tests/Local/Context/NullHeaderDictionaryTests.cs create mode 100644 test/CQRS/LeanCode.CQRS.AspNetCore.Tests/Local/Context/NullHttpResponseTests.cs create mode 100644 test/CQRS/LeanCode.CQRS.AspNetCore.Tests/Local/Context/NullRequestCookieCollectionTests.cs create mode 100644 test/CQRS/LeanCode.CQRS.AspNetCore.Tests/Local/Context/NullResponseCookiesTests.cs create mode 100644 test/CQRS/LeanCode.CQRS.AspNetCore.Tests/Local/Context/NullSessionTests.cs create mode 100644 test/CQRS/LeanCode.CQRS.AspNetCore.Tests/Local/Context/NullWebSocketManagerTests.cs diff --git a/src/CQRS/LeanCode.CQRS.AspNetCore/Local/Context/LocalCallContext.cs b/src/CQRS/LeanCode.CQRS.AspNetCore/Local/Context/LocalCallContext.cs index 5fedb617..1c054208 100644 --- a/src/CQRS/LeanCode.CQRS.AspNetCore/Local/Context/LocalCallContext.cs +++ b/src/CQRS/LeanCode.CQRS.AspNetCore/Local/Context/LocalCallContext.cs @@ -4,7 +4,7 @@ namespace LeanCode.CQRS.AspNetCore.Local.Context; -internal class LocalCallContext : HttpContext +internal class LocalCallContext : HttpContext, IDisposable { private const int DefaultFeatureCollectionSize = 6; // 4 internal, 2 external (set in local executors) @@ -75,4 +75,6 @@ CancellationToken cancellationToken } public override void Abort() => callLifetimeFeature.Abort(); + + public void Dispose() => callLifetimeFeature.Dispose(); } diff --git a/src/CQRS/LeanCode.CQRS.AspNetCore/Local/Context/LocalCallLifetimeFeature.cs b/src/CQRS/LeanCode.CQRS.AspNetCore/Local/Context/LocalCallLifetimeFeature.cs index d854decc..608658d0 100644 --- a/src/CQRS/LeanCode.CQRS.AspNetCore/Local/Context/LocalCallLifetimeFeature.cs +++ b/src/CQRS/LeanCode.CQRS.AspNetCore/Local/Context/LocalCallLifetimeFeature.cs @@ -2,7 +2,7 @@ namespace LeanCode.CQRS.AspNetCore.Local.Context; -internal class LocalCallLifetimeFeature : IHttpRequestLifetimeFeature +internal class LocalCallLifetimeFeature : IHttpRequestLifetimeFeature, IDisposable { private readonly CancellationTokenSource source; private CancellationToken requestAborted; @@ -22,4 +22,6 @@ public LocalCallLifetimeFeature(CancellationToken cancellationToken) } public void Abort() => source.Cancel(); + + public void Dispose() => source.Dispose(); } diff --git a/src/CQRS/LeanCode.CQRS.AspNetCore/Local/Context/NullConnectionInfo.cs b/src/CQRS/LeanCode.CQRS.AspNetCore/Local/Context/NullConnectionInfo.cs index d9dc4be2..346e5cfc 100644 --- a/src/CQRS/LeanCode.CQRS.AspNetCore/Local/Context/NullConnectionInfo.cs +++ b/src/CQRS/LeanCode.CQRS.AspNetCore/Local/Context/NullConnectionInfo.cs @@ -44,6 +44,8 @@ public override X509Certificate2? ClientCertificate set { } } + private NullConnectionInfo() { } + public override Task GetClientCertificateAsync(CancellationToken cancellationToken = default) => Task.FromResult(null); } diff --git a/src/CQRS/LeanCode.CQRS.AspNetCore/Local/Context/NullEndpointFeature.cs b/src/CQRS/LeanCode.CQRS.AspNetCore/Local/Context/NullEndpointFeature.cs index a93de569..78168d36 100644 --- a/src/CQRS/LeanCode.CQRS.AspNetCore/Local/Context/NullEndpointFeature.cs +++ b/src/CQRS/LeanCode.CQRS.AspNetCore/Local/Context/NullEndpointFeature.cs @@ -12,4 +12,6 @@ public Endpoint? Endpoint get => null; set { } } + + private NullEndpointFeature() { } } diff --git a/src/CQRS/LeanCode.CQRS.AspNetCore/Local/Context/NullHeaderDictionary.cs b/src/CQRS/LeanCode.CQRS.AspNetCore/Local/Context/NullHeaderDictionary.cs index 801a7949..b7824e34 100644 --- a/src/CQRS/LeanCode.CQRS.AspNetCore/Local/Context/NullHeaderDictionary.cs +++ b/src/CQRS/LeanCode.CQRS.AspNetCore/Local/Context/NullHeaderDictionary.cs @@ -1,4 +1,5 @@ using System.Collections; +using System.Collections.ObjectModel; using Microsoft.AspNetCore.Http; using Microsoft.Extensions.Primitives; @@ -20,14 +21,16 @@ public long? ContentLength set { } } - public ICollection Keys => [ ]; + public ICollection Keys { get; } = new ReadOnlyCollection([ ]); - public ICollection Values => [ ]; + public ICollection Values { get; } = new ReadOnlyCollection([ ]); public int Count => 0; public bool IsReadOnly => true; + private NullHeaderDictionary() { } + public void Add(string key, StringValues value) { } public void Add(KeyValuePair item) { } diff --git a/src/CQRS/LeanCode.CQRS.AspNetCore/Local/Context/NullRequestCookieCollection.cs b/src/CQRS/LeanCode.CQRS.AspNetCore/Local/Context/NullRequestCookieCollection.cs index 6c328900..254bc9f5 100644 --- a/src/CQRS/LeanCode.CQRS.AspNetCore/Local/Context/NullRequestCookieCollection.cs +++ b/src/CQRS/LeanCode.CQRS.AspNetCore/Local/Context/NullRequestCookieCollection.cs @@ -14,6 +14,8 @@ internal class NullRequestCookieCollection : IRequestCookieCollection public ICollection Keys => [ ]; + private NullRequestCookieCollection() { } + public bool ContainsKey(string key) => false; public IEnumerator> GetEnumerator() => diff --git a/src/CQRS/LeanCode.CQRS.AspNetCore/Local/Context/NullResponseCookies.cs b/src/CQRS/LeanCode.CQRS.AspNetCore/Local/Context/NullResponseCookies.cs index ee45965b..1858660e 100644 --- a/src/CQRS/LeanCode.CQRS.AspNetCore/Local/Context/NullResponseCookies.cs +++ b/src/CQRS/LeanCode.CQRS.AspNetCore/Local/Context/NullResponseCookies.cs @@ -6,6 +6,8 @@ internal class NullResponseCookies : IResponseCookies { public static readonly NullResponseCookies Empty = new(); + private NullResponseCookies() { } + public void Append(string key, string value) { } public void Append(string key, string value, CookieOptions options) { } diff --git a/src/CQRS/LeanCode.CQRS.AspNetCore/Local/Context/NullSession.cs b/src/CQRS/LeanCode.CQRS.AspNetCore/Local/Context/NullSession.cs index 8490ab42..84cd9bc3 100644 --- a/src/CQRS/LeanCode.CQRS.AspNetCore/Local/Context/NullSession.cs +++ b/src/CQRS/LeanCode.CQRS.AspNetCore/Local/Context/NullSession.cs @@ -13,6 +13,8 @@ internal class NullSession : ISession public IEnumerable Keys => [ ]; + private NullSession() { } + public void Clear() { } public Task CommitAsync(CancellationToken cancellationToken = default) => Task.CompletedTask; diff --git a/src/CQRS/LeanCode.CQRS.AspNetCore/Local/Context/NullWebSocketManager.cs b/src/CQRS/LeanCode.CQRS.AspNetCore/Local/Context/NullWebSocketManager.cs index 59dbe01c..5fcf46cf 100644 --- a/src/CQRS/LeanCode.CQRS.AspNetCore/Local/Context/NullWebSocketManager.cs +++ b/src/CQRS/LeanCode.CQRS.AspNetCore/Local/Context/NullWebSocketManager.cs @@ -1,3 +1,4 @@ +using System.Collections.ObjectModel; using System.Net.WebSockets; using Microsoft.AspNetCore.Http; @@ -9,7 +10,9 @@ internal class NullWebSocketManager : WebSocketManager public override bool IsWebSocketRequest => false; - public override IList WebSocketRequestedProtocols => [ ]; + public override IList WebSocketRequestedProtocols { get; } = new ReadOnlyCollection([ ]); + + private NullWebSocketManager() { } public override Task AcceptWebSocketAsync(string? subProtocol) => throw new NotSupportedException("WebSockets are not supported in local CQRS calls."); diff --git a/src/CQRS/LeanCode.CQRS.AspNetCore/Local/MiddlewareBasedLocalCommandExecutor.cs b/src/CQRS/LeanCode.CQRS.AspNetCore/Local/MiddlewareBasedLocalCommandExecutor.cs index 9ce47fb8..710e0b2c 100644 --- a/src/CQRS/LeanCode.CQRS.AspNetCore/Local/MiddlewareBasedLocalCommandExecutor.cs +++ b/src/CQRS/LeanCode.CQRS.AspNetCore/Local/MiddlewareBasedLocalCommandExecutor.cs @@ -62,7 +62,7 @@ CancellationToken cancellationToken await using var scope = serviceProvider.CreateAsyncScope(); - var localContext = new Context.LocalCallContext( + using var localContext = new Context.LocalCallContext( scope.ServiceProvider, user, activity?.Id, diff --git a/test/CQRS/LeanCode.CQRS.AspNetCore.Tests/Local/Context/LocalCallLifetimeFeatureTests.cs b/test/CQRS/LeanCode.CQRS.AspNetCore.Tests/Local/Context/LocalCallLifetimeFeatureTests.cs new file mode 100644 index 00000000..c7ebb842 --- /dev/null +++ b/test/CQRS/LeanCode.CQRS.AspNetCore.Tests/Local/Context/LocalCallLifetimeFeatureTests.cs @@ -0,0 +1,51 @@ +using FluentAssertions; +using LeanCode.CQRS.AspNetCore.Local.Context; +using Xunit; + +namespace LeanCode.CQRS.AspNetCore.Tests.Local.Context; + +public class LocalCallLifetimeFeatureTests +{ + [Fact] + public void CallAborted_and_RequestAborted_are_linked_to_the_passed_cancellation_token() + { + using var cts = new CancellationTokenSource(); + using var feature = new LocalCallLifetimeFeature(cts.Token); + + feature.CallAborted.IsCancellationRequested.Should().BeFalse(); + feature.RequestAborted.IsCancellationRequested.Should().BeFalse(); + + cts.Cancel(); + + feature.CallAborted.IsCancellationRequested.Should().BeTrue(); + feature.RequestAborted.IsCancellationRequested.Should().BeTrue(); + } + + [Fact] + public void Aborting_cancels_both_tokens() + { + using var cts = new CancellationTokenSource(); + using var feature = new LocalCallLifetimeFeature(cts.Token); + + feature.CallAborted.IsCancellationRequested.Should().BeFalse(); + feature.RequestAborted.IsCancellationRequested.Should().BeFalse(); + + feature.Abort(); + + feature.CallAborted.IsCancellationRequested.Should().BeTrue(); + feature.RequestAborted.IsCancellationRequested.Should().BeTrue(); + } + + [Fact] + public void RequestAborted_can_be_changed() + { + using var cts = new CancellationTokenSource(); + using var feature = new LocalCallLifetimeFeature(cts.Token); + + feature.RequestAborted = new(); + feature.Abort(); + + feature.RequestAborted.IsCancellationRequested.Should().BeFalse(); + feature.CallAborted.IsCancellationRequested.Should().BeTrue(); + } +} diff --git a/test/CQRS/LeanCode.CQRS.AspNetCore.Tests/Local/Context/LocalCallServiceProvidersFeatureTests.cs b/test/CQRS/LeanCode.CQRS.AspNetCore.Tests/Local/Context/LocalCallServiceProvidersFeatureTests.cs new file mode 100644 index 00000000..6e3d10d5 --- /dev/null +++ b/test/CQRS/LeanCode.CQRS.AspNetCore.Tests/Local/Context/LocalCallServiceProvidersFeatureTests.cs @@ -0,0 +1,21 @@ +using FluentAssertions; +using LeanCode.CQRS.AspNetCore.Local.Context; +using Microsoft.Extensions.DependencyInjection; +using Xunit; + +namespace LeanCode.CQRS.AspNetCore.Tests.Local.Context; + +public class LocalCallServiceProvidersFeatureTests +{ + private readonly IServiceProvider serviceProvider = new ServiceCollection().BuildServiceProvider(); + + [Fact] + public void RequestServices_can_be_changed() + { + var feature = new LocalCallServiceProvidersFeature(serviceProvider); + feature.RequestServices.Should().BeSameAs(serviceProvider); + + feature.RequestServices = new ServiceCollection().BuildServiceProvider(); + feature.RequestServices.Should().NotBeSameAs(serviceProvider); + } +} diff --git a/test/CQRS/LeanCode.CQRS.AspNetCore.Tests/Local/Context/LocalHttpRequestTests.cs b/test/CQRS/LeanCode.CQRS.AspNetCore.Tests/Local/Context/LocalHttpRequestTests.cs new file mode 100644 index 00000000..fe8e6c56 --- /dev/null +++ b/test/CQRS/LeanCode.CQRS.AspNetCore.Tests/Local/Context/LocalHttpRequestTests.cs @@ -0,0 +1,137 @@ +using FluentAssertions; +using LeanCode.CQRS.AspNetCore.Local.Context; +using Microsoft.AspNetCore.Http; +using NSubstitute; +using Xunit; + +namespace LeanCode.CQRS.AspNetCore.Tests.Local.Context; + +public class LocalHttpRequestTests +{ + private readonly LocalHttpRequest request = new(Substitute.For(), null); + + [Fact] + public void Headers_are_empty() + { + request.Headers.Should().BeEmpty(); + } + + [Fact] + public void HasFormContentType_is_always_false() + { + request.HasFormContentType.Should().BeFalse(); + } + + [Fact] + public void Method_is_empty_and_cannot_be_changed() + { + request.Method.Should().BeEmpty(); + request.Method = "GET"; + request.Method.Should().BeEmpty(); + } + + [Fact] + public void Scheme_is_empty_and_cannot_be_changed() + { + request.Scheme.Should().BeEmpty(); + request.Scheme = "http"; + request.Scheme.Should().BeEmpty(); + } + + [Fact] + public void IsHttps_is_false_and_cannot_be_changed() + { + request.IsHttps.Should().BeFalse(); + request.IsHttps = true; + request.IsHttps.Should().BeFalse(); + } + + [Fact] + public void Host_is_empty_and_cannot_be_changed() + { + request.Host.Should().Be(default); + request.Host = new HostString("localhost"); + request.Host.Should().Be(default); + } + + [Fact] + public void PathBase_is_empty_and_cannot_be_changed() + { + request.PathBase.Should().Be(PathString.Empty); + request.PathBase = new("other"); + request.PathBase.Should().Be(PathString.Empty); + } + + [Fact] + public void Path_is_empty_and_cannot_be_changed() + { + request.Path.Should().Be(PathString.Empty); + request.Path = new("other"); + request.Path.Should().Be(PathString.Empty); + } + + [Fact] + public void QueryString_is_empty_and_cannot_be_changed() + { + request.QueryString.Should().Be(QueryString.Empty); + request.QueryString = new("other"); + request.QueryString.Should().Be(QueryString.Empty); + } + + [Fact] + public void Query_is_empty_query_collection_and_cannot_be_changed() + { + request.Query.Should().BeSameAs(QueryCollection.Empty); + request.Query = new QueryCollection(); + request.Query.Should().BeSameAs(QueryCollection.Empty); + } + + [Fact] + public void Protocol_is_empty_and_cannot_be_changed() + { + request.Protocol.Should().BeEmpty(); + request.Protocol = "HTTP/1.1"; + request.Protocol.Should().BeEmpty(); + } + + [Fact] + public void ContentLength_is_null_and_cannot_be_changed() + { + request.ContentLength.Should().BeNull(); + request.ContentLength = 10; + request.ContentLength.Should().BeNull(); + } + + [Fact] + public void ContentType_is_null_and_cannot_be_changed() + { + request.ContentType.Should().BeNull(); + request.ContentType = "application/json"; + request.ContentType.Should().BeNull(); + } + + [Fact] + public void Body_is_null_stream_and_cannot_be_changed() + { + request.Body.Should().BeSameAs(Stream.Null); + request.Body = Substitute.For(); + request.Body.Should().BeSameAs(Stream.Null); + } + + [Fact] + public void Form_is_empty_form_collection_and_cannot_be_changed() + { + request.Form.Should().BeSameAs(FormCollection.Empty); + request.Form = new FormCollection(null); + request.Form.Should().BeSameAs(FormCollection.Empty); + } + + [Fact] + public void Headers_dictionary_is_passed_further() + { + var headers = new HeaderDictionary { ["X-TEST"] = "test" }; + var request = new LocalHttpRequest(Substitute.For(), headers); + + request.Headers.Should().BeSameAs(headers); + } +} diff --git a/test/CQRS/LeanCode.CQRS.AspNetCore.Tests/Local/Context/NullConnectionInfoTests.cs b/test/CQRS/LeanCode.CQRS.AspNetCore.Tests/Local/Context/NullConnectionInfoTests.cs new file mode 100644 index 00000000..327cf1aa --- /dev/null +++ b/test/CQRS/LeanCode.CQRS.AspNetCore.Tests/Local/Context/NullConnectionInfoTests.cs @@ -0,0 +1,68 @@ +using System.Net; +using System.Security.Cryptography.X509Certificates; +using FluentAssertions; +using LeanCode.CQRS.AspNetCore.Local.Context; +using NSubstitute; +using Xunit; + +namespace LeanCode.CQRS.AspNetCore.Tests.Local.Context; + +public class NullConnectionInfoTests +{ + private readonly NullConnectionInfo connectionInfo = NullConnectionInfo.Empty; + + [Fact] + public void Id_is_always_empty() + { + connectionInfo.Id.Should().BeEmpty(); + connectionInfo.Id = "other"; + connectionInfo.Id.Should().BeEmpty(); + } + + [Fact] + public void RemoteIpAddress_always_returns_null() + { + connectionInfo.RemoteIpAddress.Should().BeNull(); + connectionInfo.RemoteIpAddress = Substitute.For(); + connectionInfo.RemoteIpAddress.Should().BeNull(); + } + + [Fact] + public void RemotePort_always_returns_zero() + { + connectionInfo.RemotePort.Should().Be(0); + connectionInfo.RemotePort = 123; + connectionInfo.RemotePort.Should().Be(0); + } + + [Fact] + public void LocalIpAddress_always_returns_null() + { + connectionInfo.LocalIpAddress.Should().BeNull(); + connectionInfo.LocalIpAddress = Substitute.For(); + connectionInfo.LocalIpAddress.Should().BeNull(); + } + + [Fact] + public void LocalPort_always_returns_zero() + { + connectionInfo.LocalPort.Should().Be(0); + connectionInfo.LocalPort = 123; + connectionInfo.LocalPort.Should().Be(0); + } + + [Fact] + public void ClientCertificate_always_returns_null() + { + connectionInfo.ClientCertificate.Should().BeNull(); + connectionInfo.ClientCertificate = Substitute.For(); + connectionInfo.ClientCertificate.Should().BeNull(); + } + + [Fact] + public async Task GetClientCertificateAsync_always_returns_null() + { + var result = await connectionInfo.GetClientCertificateAsync(); + result.Should().BeNull(); + } +} diff --git a/test/CQRS/LeanCode.CQRS.AspNetCore.Tests/Local/Context/NullEndpointFeatureTests.cs b/test/CQRS/LeanCode.CQRS.AspNetCore.Tests/Local/Context/NullEndpointFeatureTests.cs new file mode 100644 index 00000000..58bebe64 --- /dev/null +++ b/test/CQRS/LeanCode.CQRS.AspNetCore.Tests/Local/Context/NullEndpointFeatureTests.cs @@ -0,0 +1,18 @@ +using FluentAssertions; +using LeanCode.CQRS.AspNetCore.Local.Context; +using Microsoft.AspNetCore.Http; +using NSubstitute; +using Xunit; + +namespace LeanCode.CQRS.AspNetCore.Tests.Local.Context; + +public class NullEndpointFeatureTests +{ + [Fact] + public void Endpoint_always_returns_null() + { + NullEndpointFeature.Empty.Endpoint.Should().BeNull(); + NullEndpointFeature.Empty.Endpoint = Substitute.For(); + NullEndpointFeature.Empty.Endpoint.Should().BeNull(); + } +} diff --git a/test/CQRS/LeanCode.CQRS.AspNetCore.Tests/Local/Context/NullHeaderDictionaryTests.cs b/test/CQRS/LeanCode.CQRS.AspNetCore.Tests/Local/Context/NullHeaderDictionaryTests.cs new file mode 100644 index 00000000..84673b71 --- /dev/null +++ b/test/CQRS/LeanCode.CQRS.AspNetCore.Tests/Local/Context/NullHeaderDictionaryTests.cs @@ -0,0 +1,118 @@ +using FluentAssertions; +using LeanCode.CQRS.AspNetCore.Local.Context; +using Microsoft.Extensions.Primitives; +using Xunit; + +namespace LeanCode.CQRS.AspNetCore.Tests.Local.Context; + +public class NullHeaderDictionaryTests +{ + private readonly NullHeaderDictionary headers = NullHeaderDictionary.Empty; + + [Fact] + public void Indexer_always_returns_empty() + { + headers["key"].Should().BeEmpty(); + headers["key"] = "value"; + headers["key"].Should().BeEmpty(); + } + + [Fact] + public void ContentLength_always_returns_null() + { + headers.ContentLength.Should().BeNull(); + + headers.ContentLength = 123; + + headers.ContentLength.Should().BeNull(); + } + + [Fact] + public void Keys_is_empty_and_readonly() + { + headers.Keys.Should().BeEmpty(); + headers.Keys.IsReadOnly.Should().BeTrue(); + } + + [Fact] + public void Values_is_empty() + { + headers.Values.Should().BeEmpty(); + headers.Values.IsReadOnly.Should().BeTrue(); + } + + [Fact] + public void Count_is_zero() + { + headers.Count.Should().Be(0); + } + + [Fact] + public void IsReadOnly_is_true() + { + headers.IsReadOnly.Should().BeTrue(); + } + + [Fact] + public void Add_does_nothing() + { + headers.Add("key", "value"); + headers.Keys.Should().BeEmpty(); + headers.Values.Should().BeEmpty(); + headers.Count.Should().Be(0); + } + + [Fact] + public void Add_with_key_value_pair_does_nothing() + { + headers.Add(new KeyValuePair("key", StringValues.Empty)); + headers.Keys.Should().BeEmpty(); + headers.Values.Should().BeEmpty(); + headers.Count.Should().Be(0); + } + + [Fact] + public void Clear_does_nothing() + { + headers.Clear(); + } + + [Fact] + public void Contains_always_returns_false() + { + headers.Contains(new KeyValuePair("key", StringValues.Empty)).Should().BeFalse(); + } + + [Fact] + public void ContainsKey_always_returns_false() + { + headers.ContainsKey("key").Should().BeFalse(); + } + + [Fact] + public void CopyTo_does_nothing() + { + var output = Array.Empty>(); + headers.CopyTo(output, 0); + output.Should().BeEmpty(); + } + + [Fact] + public void Remove_always_returns_false() + { + headers.Remove("key").Should().BeFalse(); + } + + [Fact] + public void Remove_with_key_value_pair_always_returns_false() + { + headers.Remove(new KeyValuePair("key", StringValues.Empty)).Should().BeFalse(); + } + + [Fact] + public void TryGetValue_always_returns_false_and_empty() + { + headers.TryGetValue("key", out var value).Should().BeFalse(); + value.Should().BeEmpty(); + } +} diff --git a/test/CQRS/LeanCode.CQRS.AspNetCore.Tests/Local/Context/NullHttpResponseTests.cs b/test/CQRS/LeanCode.CQRS.AspNetCore.Tests/Local/Context/NullHttpResponseTests.cs new file mode 100644 index 00000000..8a97dfa7 --- /dev/null +++ b/test/CQRS/LeanCode.CQRS.AspNetCore.Tests/Local/Context/NullHttpResponseTests.cs @@ -0,0 +1,88 @@ +using FluentAssertions; +using LeanCode.CQRS.AspNetCore.Local.Context; +using Microsoft.AspNetCore.Http; +using NSubstitute; +using Xunit; + +namespace LeanCode.CQRS.AspNetCore.Tests.Local.Context; + +public class NullHttpResponseTests +{ + private readonly NullHttpResponse response = new(Substitute.For()); + + [Fact] + public void Headers_are_NullHeaderDictionary() + { + response.Headers.Should().BeSameAs(NullHeaderDictionary.Empty); + } + + [Fact] + public void Cookies_are_NullResponseCookies() + { + response.Cookies.Should().BeSameAs(NullResponseCookies.Empty); + } + + [Fact] + public void HasStarted_should_always_be_false() + { + response.HasStarted.Should().BeFalse(); + } + + [Fact] + public void ContentLength_should_always_be_null() + { + response.ContentLength.Should().BeNull(); + + response.ContentLength = 123; + + response.ContentLength.Should().BeNull(); + } + + [Fact] + public void StatusCode_should_always_be_zero() + { + response.StatusCode.Should().Be(0); + + response.StatusCode = 123; + + response.StatusCode.Should().Be(0); + } + + [Fact] + public void ContentType_should_always_be_null() + { + response.ContentType.Should().BeNull(); + + response.ContentType = "text/plain"; + + response.ContentType.Should().BeNull(); + } + + [Fact] + public void Body_should_alaways_be_a_null_stream() + { + response.Body.Should().BeSameAs(Stream.Null); + + response.Body = Substitute.For(); + + response.Body.Should().BeSameAs(Stream.Null); + } + + [Fact] + public void OnCompleted_does_nothing() + { + response.Invoking(r => r.OnCompleted(_ => Task.CompletedTask, new())).Should().NotThrow(); + } + + [Fact] + public void OnStarting_does_nothing() + { + response.Invoking(r => r.OnStarting(_ => Task.CompletedTask, new())).Should().NotThrow(); + } + + [Fact] + public void Redirect_does_nothing() + { + response.Invoking(r => r.Redirect("", false)).Should().NotThrow(); + } +} diff --git a/test/CQRS/LeanCode.CQRS.AspNetCore.Tests/Local/Context/NullRequestCookieCollectionTests.cs b/test/CQRS/LeanCode.CQRS.AspNetCore.Tests/Local/Context/NullRequestCookieCollectionTests.cs new file mode 100644 index 00000000..ee8bb4c0 --- /dev/null +++ b/test/CQRS/LeanCode.CQRS.AspNetCore.Tests/Local/Context/NullRequestCookieCollectionTests.cs @@ -0,0 +1,33 @@ +using FluentAssertions; +using LeanCode.CQRS.AspNetCore.Local.Context; +using Xunit; + +namespace LeanCode.CQRS.AspNetCore.Tests.Local.Context; + +public class NullRequestCookieCollectionTests +{ + [Fact] + public void Count_should_be_zero() + { + NullRequestCookieCollection.Empty.Count.Should().Be(0); + } + + [Fact] + public void ContainsKey_should_always_return_false() + { + NullRequestCookieCollection.Empty.ContainsKey("").Should().BeFalse(); + } + + [Fact] + public void GetEnumerator_should_return_empty_enumerable() + { + NullRequestCookieCollection.Empty.GetEnumerator().MoveNext().Should().BeFalse(); + } + + [Fact] + public void TryGetValue_should_always_return_false_and_null() + { + NullRequestCookieCollection.Empty.TryGetValue("", out var value).Should().BeFalse(); + value.Should().BeNull(); + } +} diff --git a/test/CQRS/LeanCode.CQRS.AspNetCore.Tests/Local/Context/NullResponseCookiesTests.cs b/test/CQRS/LeanCode.CQRS.AspNetCore.Tests/Local/Context/NullResponseCookiesTests.cs new file mode 100644 index 00000000..5ecf06d8 --- /dev/null +++ b/test/CQRS/LeanCode.CQRS.AspNetCore.Tests/Local/Context/NullResponseCookiesTests.cs @@ -0,0 +1,36 @@ +using LeanCode.CQRS.AspNetCore.Local.Context; +using Xunit; + +namespace LeanCode.CQRS.AspNetCore.Tests.Local.Context; + +public class NullResponseCookiesTests +{ + [Fact] + [System.Diagnostics.CodeAnalysis.SuppressMessage( + "Security", + "CA5382:Use Secure Cookies In ASP.NET Core", + Justification = "Tests." + )] + public void Append_should_not_throw() + { + NullResponseCookies.Empty.Append("", ""); + } + + [Fact] + public void Append_with_options_should_not_throw() + { + NullResponseCookies.Empty.Append("", "", new()); + } + + [Fact] + public void Delete_should_not_throw() + { + NullResponseCookies.Empty.Delete(""); + } + + [Fact] + public void Delete_with_options_should_not_throw() + { + NullResponseCookies.Empty.Delete("", new()); + } +} diff --git a/test/CQRS/LeanCode.CQRS.AspNetCore.Tests/Local/Context/NullSessionTests.cs b/test/CQRS/LeanCode.CQRS.AspNetCore.Tests/Local/Context/NullSessionTests.cs new file mode 100644 index 00000000..e40c1573 --- /dev/null +++ b/test/CQRS/LeanCode.CQRS.AspNetCore.Tests/Local/Context/NullSessionTests.cs @@ -0,0 +1,63 @@ +using FluentAssertions; +using LeanCode.CQRS.AspNetCore.Local.Context; +using Xunit; + +namespace LeanCode.CQRS.AspNetCore.Tests.Local.Context; + +public class NullSessionTests +{ + [Fact] + public void IsAvailable_should_always_be_false() + { + NullSession.Empty.IsAvailable.Should().BeFalse(); + } + + [Fact] + public void Id_should_always_be_empty() + { + NullSession.Empty.Id.Should().BeEmpty(); + } + + [Fact] + public void Keys_should_be_empty() + { + NullSession.Empty.Keys.Should().BeEmpty(); + } + + [Fact] + public void Clear_should_not_throw() + { + NullSession.Empty.Clear(); + } + + [Fact] + public async Task Commit_should_not_throw() + { + await NullSession.Empty.CommitAsync(); + } + + [Fact] + public async Task Load_should_not_throw() + { + await NullSession.Empty.LoadAsync(); + } + + [Fact] + public void Remove_should_not_throw() + { + NullSession.Empty.Remove(""); + } + + [Fact] + public void Set_should_not_throw() + { + NullSession.Empty.Set("", Array.Empty()); + } + + [Fact] + public void TryGetValue_should_always_return_false_and_null() + { + NullSession.Empty.TryGetValue("", out var value).Should().BeFalse(); + value.Should().BeNull(); + } +} diff --git a/test/CQRS/LeanCode.CQRS.AspNetCore.Tests/Local/Context/NullWebSocketManagerTests.cs b/test/CQRS/LeanCode.CQRS.AspNetCore.Tests/Local/Context/NullWebSocketManagerTests.cs new file mode 100644 index 00000000..d26e8c0d --- /dev/null +++ b/test/CQRS/LeanCode.CQRS.AspNetCore.Tests/Local/Context/NullWebSocketManagerTests.cs @@ -0,0 +1,31 @@ +using FluentAssertions; +using LeanCode.CQRS.AspNetCore.Local.Context; +using Xunit; + +namespace LeanCode.CQRS.AspNetCore.Tests.Local.Context; + +public class NullWebSocketManagerTests +{ + [Fact] + public void IsWebSocketRequest_should_always_be_false() + { + NullWebSocketManager.Empty.IsWebSocketRequest.Should().BeFalse(); + } + + [Fact] + public void Request_protocols_should_be_empty_and_readonly() + { + NullWebSocketManager.Empty.WebSocketRequestedProtocols.Should().BeEmpty(); + NullWebSocketManager.Empty.WebSocketRequestedProtocols.IsReadOnly.Should().BeTrue(); + + var act = () => NullWebSocketManager.Empty.WebSocketRequestedProtocols.Add(""); + act.Should().Throw(); + } + + [Fact] + public async Task Accept_should_always_throw() + { + var act = () => NullWebSocketManager.Empty.AcceptWebSocketAsync((string?)null); + await act.Should().ThrowAsync(); + } +} From 176ad8ea44c088e245813392ed7abad89405c7ef Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jakub=20Fija=C5=82kowski?= Date: Wed, 7 Feb 2024 16:12:43 +0100 Subject: [PATCH 07/18] Fix wrong assumed defaults in tests --- .../Local/Context/LocalHttpRequestTests.cs | 10 +++++----- .../Local/Context/NullConnectionInfoTests.cs | 4 ++-- .../Local/Context/NullEndpointFeatureTests.cs | 4 +--- 3 files changed, 8 insertions(+), 10 deletions(-) diff --git a/test/CQRS/LeanCode.CQRS.AspNetCore.Tests/Local/Context/LocalHttpRequestTests.cs b/test/CQRS/LeanCode.CQRS.AspNetCore.Tests/Local/Context/LocalHttpRequestTests.cs index fe8e6c56..d56b54e3 100644 --- a/test/CQRS/LeanCode.CQRS.AspNetCore.Tests/Local/Context/LocalHttpRequestTests.cs +++ b/test/CQRS/LeanCode.CQRS.AspNetCore.Tests/Local/Context/LocalHttpRequestTests.cs @@ -49,16 +49,16 @@ public void IsHttps_is_false_and_cannot_be_changed() [Fact] public void Host_is_empty_and_cannot_be_changed() { - request.Host.Should().Be(default); + request.Host.Should().Be(default(HostString)); request.Host = new HostString("localhost"); - request.Host.Should().Be(default); + request.Host.Should().Be(default(HostString)); } [Fact] public void PathBase_is_empty_and_cannot_be_changed() { request.PathBase.Should().Be(PathString.Empty); - request.PathBase = new("other"); + request.PathBase = new("/other"); request.PathBase.Should().Be(PathString.Empty); } @@ -66,7 +66,7 @@ public void PathBase_is_empty_and_cannot_be_changed() public void Path_is_empty_and_cannot_be_changed() { request.Path.Should().Be(PathString.Empty); - request.Path = new("other"); + request.Path = new("/other"); request.Path.Should().Be(PathString.Empty); } @@ -74,7 +74,7 @@ public void Path_is_empty_and_cannot_be_changed() public void QueryString_is_empty_and_cannot_be_changed() { request.QueryString.Should().Be(QueryString.Empty); - request.QueryString = new("other"); + request.QueryString = new("?other"); request.QueryString.Should().Be(QueryString.Empty); } diff --git a/test/CQRS/LeanCode.CQRS.AspNetCore.Tests/Local/Context/NullConnectionInfoTests.cs b/test/CQRS/LeanCode.CQRS.AspNetCore.Tests/Local/Context/NullConnectionInfoTests.cs index 327cf1aa..63713e8e 100644 --- a/test/CQRS/LeanCode.CQRS.AspNetCore.Tests/Local/Context/NullConnectionInfoTests.cs +++ b/test/CQRS/LeanCode.CQRS.AspNetCore.Tests/Local/Context/NullConnectionInfoTests.cs @@ -23,7 +23,7 @@ public void Id_is_always_empty() public void RemoteIpAddress_always_returns_null() { connectionInfo.RemoteIpAddress.Should().BeNull(); - connectionInfo.RemoteIpAddress = Substitute.For(); + connectionInfo.RemoteIpAddress = new(0x2414188f); connectionInfo.RemoteIpAddress.Should().BeNull(); } @@ -39,7 +39,7 @@ public void RemotePort_always_returns_zero() public void LocalIpAddress_always_returns_null() { connectionInfo.LocalIpAddress.Should().BeNull(); - connectionInfo.LocalIpAddress = Substitute.For(); + connectionInfo.LocalIpAddress = new(0x2414188f); connectionInfo.LocalIpAddress.Should().BeNull(); } diff --git a/test/CQRS/LeanCode.CQRS.AspNetCore.Tests/Local/Context/NullEndpointFeatureTests.cs b/test/CQRS/LeanCode.CQRS.AspNetCore.Tests/Local/Context/NullEndpointFeatureTests.cs index 58bebe64..f94e05c1 100644 --- a/test/CQRS/LeanCode.CQRS.AspNetCore.Tests/Local/Context/NullEndpointFeatureTests.cs +++ b/test/CQRS/LeanCode.CQRS.AspNetCore.Tests/Local/Context/NullEndpointFeatureTests.cs @@ -1,7 +1,5 @@ using FluentAssertions; using LeanCode.CQRS.AspNetCore.Local.Context; -using Microsoft.AspNetCore.Http; -using NSubstitute; using Xunit; namespace LeanCode.CQRS.AspNetCore.Tests.Local.Context; @@ -12,7 +10,7 @@ public class NullEndpointFeatureTests public void Endpoint_always_returns_null() { NullEndpointFeature.Empty.Endpoint.Should().BeNull(); - NullEndpointFeature.Empty.Endpoint = Substitute.For(); + NullEndpointFeature.Empty.Endpoint = new(null, null, null); NullEndpointFeature.Empty.Endpoint.Should().BeNull(); } } From bed2704f9a94161def7c5e1a1862eac80bb8c254 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jakub=20Fija=C5=82kowski?= Date: Wed, 7 Feb 2024 16:12:54 +0100 Subject: [PATCH 08/18] Add `LocalCallContext` tests --- .../Local/Context/LocalCallContextTests.cs | 181 ++++++++++++++++++ 1 file changed, 181 insertions(+) create mode 100644 test/CQRS/LeanCode.CQRS.AspNetCore.Tests/Local/Context/LocalCallContextTests.cs diff --git a/test/CQRS/LeanCode.CQRS.AspNetCore.Tests/Local/Context/LocalCallContextTests.cs b/test/CQRS/LeanCode.CQRS.AspNetCore.Tests/Local/Context/LocalCallContextTests.cs new file mode 100644 index 00000000..b97ae3f0 --- /dev/null +++ b/test/CQRS/LeanCode.CQRS.AspNetCore.Tests/Local/Context/LocalCallContextTests.cs @@ -0,0 +1,181 @@ +using System.Security.Claims; +using FluentAssertions; +using LeanCode.CQRS.AspNetCore.Local.Context; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Http.Features; +using Microsoft.Extensions.DependencyInjection; +using NSubstitute; +using Xunit; + +namespace LeanCode.CQRS.AspNetCore.Tests.Local.Context; + +public class LocalCallContextTests +{ + [Fact] + public void Keeps_user_and_allows_changing_it() + { + var user1 = new ClaimsPrincipal(); + var user2 = new ClaimsPrincipal(); + using var context = Create(user: user1); + + context.User.Should().BeSameAs(user1); + + context.User = user2; + + context.User.Should().BeSameAs(user2); + } + + [Fact] + public void Keeps_TraceIdentifier_and_allows_changing_it() + { + const string Id1 = "id1"; + const string Id2 = "id2"; + using var context = Create(activityId: Id1); + + context.TraceIdentifier.Should().Be(Id1); + + context.TraceIdentifier = Id2; + + context.TraceIdentifier.Should().Be(Id2); + } + + [Fact] + public void Provides_items_and_items_feature() + { + using var context = Create(); + var feature = context.Features.GetRequiredFeature(); + + context.Items.Should().NotBeNull().And.BeSameAs(feature.Items); + + context.Items = new Dictionary(); + + context.Items.Should().NotBeNull().And.BeSameAs(feature.Items); + } + + [Fact] + public void Keeps_RequestServices_inside_feature() + { + var services1 = new ServiceCollection().BuildServiceProvider(); + var services2 = new ServiceCollection().BuildServiceProvider(); + using var context = Create(serviceProvider: services1); + var feature = context.Features.GetRequiredFeature(); + + context.RequestServices.Should().BeSameAs(services1); + feature.RequestServices.Should().BeSameAs(services1); + + context.RequestServices = services2; + + context.RequestServices.Should().BeSameAs(services2); + feature.RequestServices.Should().BeSameAs(services2); + } + + [Fact] + public void Keeps_RequestAborted_inside_feature() + { + using var context = Create(); + var feature = context.Features.GetRequiredFeature(); + + context.RequestAborted.Should().Be(feature.RequestAborted); + + using var cts = new CancellationTokenSource(); + context.RequestAborted = cts.Token; + + context.RequestAborted.Should().Be(cts.Token); + context.RequestAborted.Should().Be(feature.RequestAborted); + } + + [Fact] + public void Abort_cancels_both_tokens() + { + using var context = Create(); + + context.Abort(); + + context.RequestAborted.IsCancellationRequested.Should().BeTrue(); + context.CallAborted.IsCancellationRequested.Should().BeTrue(); + } + + [Fact] + public void Connection_is_NullConnectionInfo() + { + using var context = Create(); + + context.Connection.Should().BeSameAs(NullConnectionInfo.Empty); + } + + [Fact] + public void WebSockets_is_NullWebSocketManager() + { + using var context = Create(); + + context.WebSockets.Should().BeSameAs(NullWebSocketManager.Empty); + } + + [Fact] + public void Session_is_NullSession_and_cannot_be_changed() + { + using var context = Create(); + + context.Session.Should().BeSameAs(NullSession.Empty); + + context.Session = Substitute.For(); + + context.Session.Should().BeSameAs(NullSession.Empty); + } + + [Fact] + public void Response_is_NullHttpResponse() + { + using var context = Create(); + + context.Response.Should().BeOfType(); + context.Response.HttpContext.Should().BeSameAs(context); + } + + [Fact] + public void Request_is_LocalHttpRequest() + { + using var context = Create(); + + context.Request.Should().BeOfType(); + context.Request.HttpContext.Should().BeSameAs(context); + } + + [Fact] + public void Stores_the_passed_headers_in_request() + { + var headers = new HeaderDictionary(); + using var context = Create(headers: headers); + + context.Request.Headers.Should().BeSameAs(headers); + } + + [Fact] + public void Provides_minimum_set_of_features() + { + using var context = Create(); + + context.Features.Should().HaveCount(4); + context.Features.Get().Should().NotBeNull(); + context.Features.Get().Should().NotBeNull(); + context.Features.Get().Should().NotBeNull(); + context.Features.Get().Should().NotBeNull(); + } + + private static LocalCallContext Create( + IServiceProvider? serviceProvider = null, + ClaimsPrincipal? user = null, + string? activityId = null, + IHeaderDictionary? headers = null, + CancellationToken cancellationToken = default + ) + { + return new( + serviceProvider ?? new ServiceCollection().BuildServiceProvider(), + user ?? new ClaimsPrincipal(), + activityId, + headers, + cancellationToken + ); + } +} From 5801e70cf38ac650c630ee58ef2f0ebfff8010fd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jakub=20Fija=C5=82kowski?= Date: Wed, 7 Feb 2024 16:47:53 +0100 Subject: [PATCH 09/18] Rewrite `MiddlewareBasedLocalCommandExecutorTests` to a version without WebHost --- ...iddlewareBasedLocalCommandExecutorTests.cs | 203 +++++++----------- 1 file changed, 81 insertions(+), 122 deletions(-) diff --git a/test/CQRS/LeanCode.CQRS.AspNetCore.Tests/Local/MiddlewareBasedLocalCommandExecutorTests.cs b/test/CQRS/LeanCode.CQRS.AspNetCore.Tests/Local/MiddlewareBasedLocalCommandExecutorTests.cs index 6b5ddd4b..8ab12f0d 100644 --- a/test/CQRS/LeanCode.CQRS.AspNetCore.Tests/Local/MiddlewareBasedLocalCommandExecutorTests.cs +++ b/test/CQRS/LeanCode.CQRS.AspNetCore.Tests/Local/MiddlewareBasedLocalCommandExecutorTests.cs @@ -1,192 +1,151 @@ -using System.Net; -using System.Net.Http.Json; using System.Security.Claims; using FluentAssertions; using LeanCode.Components; using LeanCode.Contracts; using LeanCode.CQRS.AspNetCore.Local; +using LeanCode.CQRS.AspNetCore.Registration; using LeanCode.CQRS.Execution; using Microsoft.AspNetCore.Builder; -using Microsoft.AspNetCore.Hosting; using Microsoft.AspNetCore.Http; -using Microsoft.AspNetCore.TestHost; using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.Hosting; using Xunit; namespace LeanCode.CQRS.AspNetCore.Tests.Local; -public class MiddlewareBasedLocalCommandExecutorTests : IDisposable, IAsyncLifetime +public class MiddlewareBasedLocalCommandExecutorTests { - private const string IsAuthenticatedHeader = "is-authenticated"; private static readonly TypesCatalog ThisCatalog = TypesCatalog.Of(); - private readonly IHost host; - private readonly TestServer server; + private readonly LocalDataStorage storage = new(); + private readonly IServiceProvider serviceProvider; + private readonly ICQRSObjectSource objectSource; + + private readonly MiddlewareBasedLocalCommandExecutor executor; public MiddlewareBasedLocalCommandExecutorTests() { - host = new HostBuilder() - .ConfigureWebHost(webHost => - { - webHost - .UseTestServer() - .ConfigureServices(services => - { - services.AddRouting(); - services - .AddCQRS(ThisCatalog, ThisCatalog) - .WithLocalCommands(p => p.UseMiddleware()); - - services.AddScoped(); - services.AddSingleton(); - }) - .Configure(app => - { - app.UseRouting(); - app.Use(MockAuthorization); - app.UseEndpoints(e => - { - e.MapRemoteCQRS( - "/cqrs", - cqrs => - { - cqrs.Commands = p => p.UseMiddleware(); - } - ); - }); - }); - }) - .Build(); - - server = host.GetTestServer(); + var serviceCollection = new ServiceCollection(); + serviceCollection.AddSingleton(storage); + serviceCollection.AddScoped(sp => new MiddlewareFactory(sp)); + serviceCollection.AddScoped(); + + var registrationSource = new CQRSObjectsRegistrationSource(serviceCollection, new ObjectExecutorFactory()); + registrationSource.AddCQRSObjects(ThisCatalog, ThisCatalog); + + serviceProvider = serviceCollection.BuildServiceProvider(); + objectSource = registrationSource; + + executor = new(serviceProvider, objectSource, app => app.UseMiddleware()); } [Fact] - public async Task Runs_both_remote_and_local_commands_successfully() + public async Task Runs_the_command() { - var storage = host.Services.GetRequiredService(); - var result = await SendAsync(new RemoteCommand()); - - result.Should().Be(HttpStatusCode.OK); + var command = new LocalCommand(); + var result = await executor.RunAsync(command, new ClaimsPrincipal()); - storage.RemoteUser.Should().NotBeNullOrEmpty(); - storage.LocalUsers.Should().AllBe(storage.RemoteUser).And.HaveCount(3); + result.Should().Be(CommandResult.Success); - storage.Middlewares.ToHashSet().Should().HaveSameCount(storage.Middlewares); - storage.RunHandlers.ToHashSet().Should().HaveSameCount(storage.RunHandlers); + storage.Commands.Should().Contain(command); + storage.Handlers.Should().ContainSingle(); } - protected async Task SendAsync(ICommand cmd, bool isAuthenticated = true) + [Fact] + public async Task Runs_the_middleware() { - var path = $"/cqrs/command/{cmd.GetType().FullName}"; - using var msg = new HttpRequestMessage(HttpMethod.Post, path); - msg.Content = JsonContent.Create(cmd); - msg.Headers.Add(IsAuthenticatedHeader, isAuthenticated.ToString()); + var command = new LocalCommand(); + await executor.RunAsync(command, new ClaimsPrincipal()); - var response = await host.GetTestClient().SendAsync(msg); - return response.StatusCode; + storage.Middlewares.Should().ContainSingle(); } - private static Task MockAuthorization(HttpContext httpContext, RequestDelegate next) + [Fact] + public async Task Each_run_opens_separate_DI_scope() { - if ( - httpContext.Request.Headers.TryGetValue(IsAuthenticatedHeader, out var isAuthenticated) - && isAuthenticated == bool.TrueString - ) - { - httpContext.User = new ClaimsPrincipal( - new ClaimsIdentity(new Claim[] { new("id", Guid.NewGuid().ToString()) }, "Test Identity") - ); - } - - return next(httpContext); + var command1 = new LocalCommand(); + var command2 = new LocalCommand(); + await executor.RunAsync(command1, new ClaimsPrincipal()); + await executor.RunAsync(command2, new ClaimsPrincipal()); + + storage.Commands.Should().BeEquivalentTo([ command1, command2 ]); + storage.Handlers.Should().HaveCount(2); + storage.Handlers.Should().HaveCount(2); } - public void Dispose() + [Fact] + public async Task Exceptions_are_not_catched() { - Dispose(true); - GC.SuppressFinalize(this); + var command = new LocalCommand(Fail: true); + + await Assert.ThrowsAsync(() => executor.RunAsync(command, new ClaimsPrincipal())); } - protected virtual void Dispose(bool disposing) + [Fact] + public async Task Calls_can_be_aborted() { - server.Dispose(); - host.Dispose(); + var command = new LocalCommand(Cancel: true); + + await Assert.ThrowsAsync(() => executor.RunAsync(command, new ClaimsPrincipal())); } - public Task InitializeAsync() => host.StartAsync(); + [Fact] + public async Task Object_metadata_is_set() + { + var command = new LocalCommand(CheckMetadata: true); - public Task DisposeAsync() => host.StopAsync(); + await executor.RunAsync(command, new ClaimsPrincipal()); + } } -public class DataStorage +public class LocalDataStorage { - public string? RemoteUser { get; set; } - - public List Middlewares { get; } = [ ]; - public List LocalUsers { get; } = [ ]; - public List RunHandlers { get; } = [ ]; + public List Middlewares { get; } = [ ]; + public List Handlers { get; } = [ ]; + public List Commands { get; } = [ ]; } -public record RemoteCommand() : ICommand; - -public record LocalCommand(string Value, bool Fail) : ICommand; +public record LocalCommand(bool Fail = false, bool Cancel = false, bool CheckMetadata = false) : ICommand; -public class RemoteCommandHandler : ICommandHandler +public class LocalCommandHandler : ICommandHandler { - private readonly DataStorage storage; - private readonly ILocalCommandExecutor localCommand; + private readonly LocalDataStorage storage; - public RemoteCommandHandler(DataStorage storage, ILocalCommandExecutor localCommand) + public LocalCommandHandler(LocalDataStorage storage) { this.storage = storage; - this.localCommand = localCommand; } - public async Task ExecuteAsync(HttpContext context, RemoteCommand command) + public Task ExecuteAsync(HttpContext context, LocalCommand command) { - storage.RemoteUser = context.User?.FindFirst("id")?.Value; + storage.Commands.Add(command); + storage.Handlers.Add(this); - await localCommand.RunAsync(new LocalCommand("Test Val 1", false), context.User!); - try + if (command.Fail) { - await localCommand.RunAsync(new LocalCommand("Test Val 2", true), context.User!); + throw new InvalidOperationException("Requested."); } - catch { } - await localCommand.RunAsync(new LocalCommand("Test Val 3", false), context.User!); - } -} -public class LocalCommandHandler : ICommandHandler -{ - private readonly DataStorage storage; - - public LocalCommand? Command { get; private set; } - - public LocalCommandHandler(DataStorage storage) - { - this.storage = storage; - } - - public async Task ExecuteAsync(HttpContext context, LocalCommand command) - { - Command = command; - storage.RunHandlers.Add(this); - storage.LocalUsers.Add(context.User?.FindFirst("id")?.Value); + if (command.Cancel) + { + context.Abort(); + } - if (command.Fail) + if (command.CheckMetadata) { - throw new InvalidOperationException("Requested."); + context.GetCQRSObjectMetadata().ObjectKind.Should().Be(CQRSObjectKind.Command); + context.GetCQRSObjectMetadata().ObjectType.Should().Be(typeof(LocalCommand)); + context.GetCQRSObjectMetadata().HandlerType.Should().Be(typeof(LocalCommandHandler)); } + + return Task.CompletedTask; } } -public class TestMiddleware : IMiddleware +public class LocalHandlerMiddleware : IMiddleware { public Task InvokeAsync(HttpContext context, RequestDelegate next) { - context.RequestServices.GetRequiredService().Middlewares.Add(this); + context.RequestServices.GetRequiredService().Middlewares.Add(this); return next(context); } } From dfdb8f8ee4b2086a63b60f08b783a1d53345898a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jakub=20Fija=C5=82kowski?= Date: Wed, 7 Feb 2024 17:27:59 +0100 Subject: [PATCH 10/18] Add local query executor --- .../Local/ILocalQueryExecutor.cs | 21 ++++++ .../MiddlewareBasedLocalCommandExecutor.cs | 63 ++---------------- .../Local/MiddlewareBasedLocalExecutor.cs | 66 +++++++++++++++++++ .../MiddlewareBasedLocalQueryExecutor.cs | 29 ++++++++ .../ServiceCollectionCQRSExtensions.cs | 8 +++ ...iddlewareBasedLocalCommandExecutorTests.cs | 7 +- 6 files changed, 135 insertions(+), 59 deletions(-) create mode 100644 src/CQRS/LeanCode.CQRS.AspNetCore/Local/ILocalQueryExecutor.cs create mode 100644 src/CQRS/LeanCode.CQRS.AspNetCore/Local/MiddlewareBasedLocalExecutor.cs create mode 100644 src/CQRS/LeanCode.CQRS.AspNetCore/Local/MiddlewareBasedLocalQueryExecutor.cs diff --git a/src/CQRS/LeanCode.CQRS.AspNetCore/Local/ILocalQueryExecutor.cs b/src/CQRS/LeanCode.CQRS.AspNetCore/Local/ILocalQueryExecutor.cs new file mode 100644 index 00000000..a1b20ec2 --- /dev/null +++ b/src/CQRS/LeanCode.CQRS.AspNetCore/Local/ILocalQueryExecutor.cs @@ -0,0 +1,21 @@ +using System.Security.Claims; +using LeanCode.Contracts; +using Microsoft.AspNetCore.Http; + +namespace LeanCode.CQRS.AspNetCore.Local; + +public interface ILocalQueryExecutor +{ + Task GetAsync( + IQuery query, + ClaimsPrincipal user, + CancellationToken cancellationToken = default + ); + + Task GetAsync( + IQuery query, + ClaimsPrincipal user, + IHeaderDictionary headers, + CancellationToken cancellationToken = default + ); +} diff --git a/src/CQRS/LeanCode.CQRS.AspNetCore/Local/MiddlewareBasedLocalCommandExecutor.cs b/src/CQRS/LeanCode.CQRS.AspNetCore/Local/MiddlewareBasedLocalCommandExecutor.cs index 710e0b2c..f0017322 100644 --- a/src/CQRS/LeanCode.CQRS.AspNetCore/Local/MiddlewareBasedLocalCommandExecutor.cs +++ b/src/CQRS/LeanCode.CQRS.AspNetCore/Local/MiddlewareBasedLocalCommandExecutor.cs @@ -1,82 +1,31 @@ using System.Security.Claims; using LeanCode.Contracts; -using LeanCode.CQRS.AspNetCore.Middleware; using LeanCode.CQRS.AspNetCore.Registration; -using LeanCode.CQRS.Execution; -using LeanCode.OpenTelemetry; -using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.Http; -using Microsoft.Extensions.DependencyInjection; namespace LeanCode.CQRS.AspNetCore.Local; -public class MiddlewareBasedLocalCommandExecutor : ILocalCommandExecutor +public class MiddlewareBasedLocalCommandExecutor : MiddlewareBasedLocalExecutor, ILocalCommandExecutor { - private readonly IServiceProvider serviceProvider; - private readonly ICQRSObjectSource objectSource; - - private readonly RequestDelegate pipeline; - public MiddlewareBasedLocalCommandExecutor( IServiceProvider serviceProvider, ICQRSObjectSource objectSource, Action configure ) - { - this.serviceProvider = serviceProvider; - this.objectSource = objectSource; + : base(serviceProvider, objectSource, configure) { } - var app = new CQRSApplicationBuilder(new ApplicationBuilder(serviceProvider)); - configure(app); - app.Run(CQRSPipelineFinalizer.HandleAsync); - pipeline = app.Build(); - } - - public Task RunAsync( + public async Task RunAsync( T command, ClaimsPrincipal user, CancellationToken cancellationToken = default ) - where T : ICommand => RunInternalAsync(command, user, null, cancellationToken); + where T : ICommand => (CommandResult)(await RunInternalAsync(command, user, null, cancellationToken))!; - public Task RunAsync( + public async Task RunAsync( T command, ClaimsPrincipal user, IHeaderDictionary headers, CancellationToken cancellationToken = default ) - where T : ICommand => RunInternalAsync(command, user, headers, cancellationToken); - - private async Task RunInternalAsync( - T command, - ClaimsPrincipal user, - IHeaderDictionary? headers, - CancellationToken cancellationToken - ) - where T : ICommand - { - var metadata = objectSource.MetadataFor(typeof(T)); - - using var activity = LeanCodeActivitySource.ActivitySource.StartActivity("pipeline.action.local"); - activity?.AddTag("object", metadata.ObjectType.FullName); - - await using var scope = serviceProvider.CreateAsyncScope(); - - using var localContext = new Context.LocalCallContext( - scope.ServiceProvider, - user, - activity?.Id, - headers, - cancellationToken - ); - - localContext.SetCQRSRequestPayload(command); - localContext.SetCQRSObjectMetadataForLocalExecution(metadata); - - await pipeline(localContext); - - localContext.CallAborted.ThrowIfCancellationRequested(); - - return (CommandResult)localContext.GetCQRSRequestPayload().Result!.Value.Payload!; - } + where T : ICommand => (CommandResult)(await RunInternalAsync(command, user, headers, cancellationToken))!; } diff --git a/src/CQRS/LeanCode.CQRS.AspNetCore/Local/MiddlewareBasedLocalExecutor.cs b/src/CQRS/LeanCode.CQRS.AspNetCore/Local/MiddlewareBasedLocalExecutor.cs new file mode 100644 index 00000000..1ca98b35 --- /dev/null +++ b/src/CQRS/LeanCode.CQRS.AspNetCore/Local/MiddlewareBasedLocalExecutor.cs @@ -0,0 +1,66 @@ +using System.Security.Claims; +using LeanCode.Contracts; +using LeanCode.CQRS.AspNetCore.Middleware; +using LeanCode.CQRS.AspNetCore.Registration; +using LeanCode.CQRS.Execution; +using LeanCode.OpenTelemetry; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.DependencyInjection; + +namespace LeanCode.CQRS.AspNetCore.Local; + +public abstract class MiddlewareBasedLocalExecutor +{ + private readonly IServiceProvider serviceProvider; + private readonly ICQRSObjectSource objectSource; + + private readonly RequestDelegate pipeline; + + protected MiddlewareBasedLocalExecutor( + IServiceProvider serviceProvider, + ICQRSObjectSource objectSource, + Action configure + ) + { + this.serviceProvider = serviceProvider; + this.objectSource = objectSource; + + var app = new CQRSApplicationBuilder(new ApplicationBuilder(serviceProvider)); + configure(app); + app.Run(CQRSPipelineFinalizer.HandleAsync); + pipeline = app.Build(); + } + + protected async Task RunInternalAsync( + object obj, + ClaimsPrincipal user, + IHeaderDictionary? headers, + CancellationToken cancellationToken + ) + { + var metadata = objectSource.MetadataFor(obj.GetType()); + + using var activity = LeanCodeActivitySource.ActivitySource.StartActivity("pipeline.action.local"); + activity?.AddTag("object", metadata.ObjectType.FullName); + + await using var scope = serviceProvider.CreateAsyncScope(); + + using var localContext = new Context.LocalCallContext( + scope.ServiceProvider, + user, + activity?.Id, + headers, + cancellationToken + ); + + localContext.SetCQRSRequestPayload(obj); + localContext.SetCQRSObjectMetadataForLocalExecution(metadata); + + await pipeline(localContext); + + localContext.CallAborted.ThrowIfCancellationRequested(); + + return localContext.GetCQRSRequestPayload().Result!.Value.Payload; + } +} diff --git a/src/CQRS/LeanCode.CQRS.AspNetCore/Local/MiddlewareBasedLocalQueryExecutor.cs b/src/CQRS/LeanCode.CQRS.AspNetCore/Local/MiddlewareBasedLocalQueryExecutor.cs new file mode 100644 index 00000000..1a6f840a --- /dev/null +++ b/src/CQRS/LeanCode.CQRS.AspNetCore/Local/MiddlewareBasedLocalQueryExecutor.cs @@ -0,0 +1,29 @@ +using System.Security.Claims; +using LeanCode.Contracts; +using LeanCode.CQRS.AspNetCore.Registration; +using Microsoft.AspNetCore.Http; + +namespace LeanCode.CQRS.AspNetCore.Local; + +public class MiddlewareBasedLocalQueryExecutor : MiddlewareBasedLocalExecutor, ILocalQueryExecutor +{ + public MiddlewareBasedLocalQueryExecutor( + IServiceProvider serviceProvider, + ICQRSObjectSource objectSource, + Action configure + ) + : base(serviceProvider, objectSource, configure) { } + + public async Task GetAsync( + IQuery query, + ClaimsPrincipal user, + CancellationToken cancellationToken = default + ) => (TResult)(await RunInternalAsync(query, user, null, cancellationToken))!; + + public async Task GetAsync( + IQuery query, + ClaimsPrincipal user, + IHeaderDictionary headers, + CancellationToken cancellationToken = default + ) => (TResult)(await RunInternalAsync(query, user, headers, cancellationToken))!; +} diff --git a/src/CQRS/LeanCode.CQRS.AspNetCore/ServiceCollectionCQRSExtensions.cs b/src/CQRS/LeanCode.CQRS.AspNetCore/ServiceCollectionCQRSExtensions.cs index 56c1a373..66e5758c 100644 --- a/src/CQRS/LeanCode.CQRS.AspNetCore/ServiceCollectionCQRSExtensions.cs +++ b/src/CQRS/LeanCode.CQRS.AspNetCore/ServiceCollectionCQRSExtensions.cs @@ -97,4 +97,12 @@ public CQRSServicesBuilder WithLocalCommands(Action con ); return this; } + + public CQRSServicesBuilder WithLocalQueries(Action configure) + { + Services.AddSingleton( + s => new Local.MiddlewareBasedLocalQueryExecutor(s, s.GetRequiredService(), configure) + ); + return this; + } } diff --git a/test/CQRS/LeanCode.CQRS.AspNetCore.Tests/Local/MiddlewareBasedLocalCommandExecutorTests.cs b/test/CQRS/LeanCode.CQRS.AspNetCore.Tests/Local/MiddlewareBasedLocalCommandExecutorTests.cs index 8ab12f0d..bd9fa459 100644 --- a/test/CQRS/LeanCode.CQRS.AspNetCore.Tests/Local/MiddlewareBasedLocalCommandExecutorTests.cs +++ b/test/CQRS/LeanCode.CQRS.AspNetCore.Tests/Local/MiddlewareBasedLocalCommandExecutorTests.cs @@ -12,7 +12,10 @@ namespace LeanCode.CQRS.AspNetCore.Tests.Local; -public class MiddlewareBasedLocalCommandExecutorTests +/// +/// Tests the class, but uses the as a test bench. +/// +public class MiddlewareBasedLocalExecutorTests { private static readonly TypesCatalog ThisCatalog = TypesCatalog.Of(); @@ -22,7 +25,7 @@ public class MiddlewareBasedLocalCommandExecutorTests private readonly MiddlewareBasedLocalCommandExecutor executor; - public MiddlewareBasedLocalCommandExecutorTests() + public MiddlewareBasedLocalExecutorTests() { var serviceCollection = new ServiceCollection(); serviceCollection.AddSingleton(storage); From 67f499cd0cc5851b127059a3ee5c231eac5935d3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jakub=20Fija=C5=82kowski?= Date: Wed, 7 Feb 2024 17:28:06 +0100 Subject: [PATCH 11/18] Add integration test that runs on top on test WebHost --- ...MiddlewareBasedExecutorIntegrationTests.cs | 143 ++++++++++++++++++ 1 file changed, 143 insertions(+) create mode 100644 test/CQRS/LeanCode.CQRS.AspNetCore.Tests/Local/MiddlewareBasedExecutorIntegrationTests.cs diff --git a/test/CQRS/LeanCode.CQRS.AspNetCore.Tests/Local/MiddlewareBasedExecutorIntegrationTests.cs b/test/CQRS/LeanCode.CQRS.AspNetCore.Tests/Local/MiddlewareBasedExecutorIntegrationTests.cs new file mode 100644 index 00000000..ee80fac5 --- /dev/null +++ b/test/CQRS/LeanCode.CQRS.AspNetCore.Tests/Local/MiddlewareBasedExecutorIntegrationTests.cs @@ -0,0 +1,143 @@ +using System.Net.Http.Json; +using System.Security.Claims; +using FluentAssertions; +using LeanCode.Components; +using LeanCode.Contracts; +using LeanCode.CQRS.AspNetCore.Local; +using LeanCode.CQRS.Execution; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Hosting; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.TestHost; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using Xunit; + +namespace LeanCode.CQRS.AspNetCore.Tests.Local; + +public class MiddlewareBasedExecutorIntegrationTests : IDisposable, IAsyncLifetime +{ + private static readonly TypesCatalog ThisCatalog = TypesCatalog.Of(); + + private readonly IHost host; + private readonly TestServer server; + + public MiddlewareBasedExecutorIntegrationTests() + { + host = new HostBuilder() + .ConfigureWebHost(webHost => + { + webHost + .UseTestServer() + .ConfigureServices(services => + { + services.AddRouting(); + services.AddCQRS(ThisCatalog, ThisCatalog).WithLocalQueries(q => { }); + }) + .Configure(app => + { + app.UseRouting() + .UseEndpoints(e => + { + e.MapRemoteCQRS("/cqrs", cqrs => { }); + }); + }); + }) + .Build(); + + server = host.GetTestServer(); + } + + [Fact] + public async Task Runs_LocalQuery_locally() + { + var arg = Guid.NewGuid(); + + using var scope = server.Services.CreateScope(); + + var executor = scope.ServiceProvider.GetRequiredService(); + + var result = await executor.GetAsync(new LocalQuery(arg), new ClaimsPrincipal()); + + result.Should().Be($"local {arg}"); + } + + [Fact] + public async Task Runs_RemoteQuery_locally() + { + var arg = Guid.NewGuid(); + + using var scope = server.Services.CreateScope(); + + var executor = scope.ServiceProvider.GetRequiredService(); + + var result = await executor.GetAsync(new RemoteQuery(arg), new ClaimsPrincipal()); + + result.Should().Be($"remote {arg}: local {arg}"); + } + + [Fact] + public async Task Runs_LocalQuery_remotely() + { + var arg = Guid.NewGuid(); + + var result = await HttpGetAsync(new LocalQuery(arg)); + + result.Should().Be($"local {arg}"); + } + + [Fact] + public async Task Runs_RemoteQuery_remotely() + { + var arg = Guid.NewGuid(); + + var result = await HttpGetAsync(new RemoteQuery(arg)); + + result.Should().Be($"remote {arg}: local {arg}"); + } + + protected async Task HttpGetAsync(IQuery query) + { + var path = $"/cqrs/query/{query.GetType().FullName}"; + using var msg = new HttpRequestMessage(HttpMethod.Post, path); + using var content = JsonContent.Create(query, query.GetType(), options: new() { PropertyNamingPolicy = null }); + msg.Content = content; + + var response = await host.GetTestClient().SendAsync(msg); + return (await response.Content.ReadFromJsonAsync())!; + } + + public void Dispose() + { + Dispose(true); + GC.SuppressFinalize(this); + } + + protected virtual void Dispose(bool disposing) + { + server.Dispose(); + host.Dispose(); + } + + public Task InitializeAsync() => host.StartAsync(); + + public Task DisposeAsync() => host.StopAsync(); +} + +public record LocalQuery(Guid Arg) : IQuery; + +public record RemoteQuery(Guid Arg) : IQuery; + +public class LocalQueryHandler : IQueryHandler +{ + public Task ExecuteAsync(HttpContext context, LocalQuery query) => Task.FromResult($"local {query.Arg}"); +} + +public class RemoteQueryHandler(ILocalQueryExecutor localQuery) : IQueryHandler +{ + public async Task ExecuteAsync(HttpContext context, RemoteQuery query) + { + var local = await localQuery.GetAsync(new LocalQuery(query.Arg), context.User); + return $"remote {query.Arg}: {local}"; + } +} From d3fd9a742feb5c2ab71b2001d4f4e1646564b6a9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jakub=20Fija=C5=82kowski?= Date: Wed, 7 Feb 2024 17:30:04 +0100 Subject: [PATCH 12/18] Add local opertions executor --- .../Local/ILocalOperationExecutor.cs | 21 ++++++++++++++ .../MiddlewareBasedLocalOperationExecutor.cs | 29 +++++++++++++++++++ .../ServiceCollectionCQRSExtensions.cs | 9 ++++++ 3 files changed, 59 insertions(+) create mode 100644 src/CQRS/LeanCode.CQRS.AspNetCore/Local/ILocalOperationExecutor.cs create mode 100644 src/CQRS/LeanCode.CQRS.AspNetCore/Local/MiddlewareBasedLocalOperationExecutor.cs diff --git a/src/CQRS/LeanCode.CQRS.AspNetCore/Local/ILocalOperationExecutor.cs b/src/CQRS/LeanCode.CQRS.AspNetCore/Local/ILocalOperationExecutor.cs new file mode 100644 index 00000000..20e1680e --- /dev/null +++ b/src/CQRS/LeanCode.CQRS.AspNetCore/Local/ILocalOperationExecutor.cs @@ -0,0 +1,21 @@ +using System.Security.Claims; +using LeanCode.Contracts; +using Microsoft.AspNetCore.Http; + +namespace LeanCode.CQRS.AspNetCore.Local; + +public interface ILocalOperationExecutor +{ + Task ExecuteAsync( + IOperation query, + ClaimsPrincipal user, + CancellationToken cancellationToken = default + ); + + Task ExecuteAsync( + IOperation query, + ClaimsPrincipal user, + IHeaderDictionary headers, + CancellationToken cancellationToken = default + ); +} diff --git a/src/CQRS/LeanCode.CQRS.AspNetCore/Local/MiddlewareBasedLocalOperationExecutor.cs b/src/CQRS/LeanCode.CQRS.AspNetCore/Local/MiddlewareBasedLocalOperationExecutor.cs new file mode 100644 index 00000000..cb573b80 --- /dev/null +++ b/src/CQRS/LeanCode.CQRS.AspNetCore/Local/MiddlewareBasedLocalOperationExecutor.cs @@ -0,0 +1,29 @@ +using System.Security.Claims; +using LeanCode.Contracts; +using LeanCode.CQRS.AspNetCore.Registration; +using Microsoft.AspNetCore.Http; + +namespace LeanCode.CQRS.AspNetCore.Local; + +public class MiddlewareBasedLocalOperationExecutor : MiddlewareBasedLocalExecutor, ILocalOperationExecutor +{ + public MiddlewareBasedLocalOperationExecutor( + IServiceProvider serviceProvider, + ICQRSObjectSource objectSource, + Action configure + ) + : base(serviceProvider, objectSource, configure) { } + + public async Task ExecuteAsync( + IOperation query, + ClaimsPrincipal user, + CancellationToken cancellationToken = default + ) => (TResult)(await RunInternalAsync(query, user, null, cancellationToken))!; + + public async Task ExecuteAsync( + IOperation query, + ClaimsPrincipal user, + IHeaderDictionary headers, + CancellationToken cancellationToken = default + ) => (TResult)(await RunInternalAsync(query, user, null, cancellationToken))!; +} diff --git a/src/CQRS/LeanCode.CQRS.AspNetCore/ServiceCollectionCQRSExtensions.cs b/src/CQRS/LeanCode.CQRS.AspNetCore/ServiceCollectionCQRSExtensions.cs index 66e5758c..7c77ef91 100644 --- a/src/CQRS/LeanCode.CQRS.AspNetCore/ServiceCollectionCQRSExtensions.cs +++ b/src/CQRS/LeanCode.CQRS.AspNetCore/ServiceCollectionCQRSExtensions.cs @@ -105,4 +105,13 @@ public CQRSServicesBuilder WithLocalQueries(Action conf ); return this; } + + public CQRSServicesBuilder WithLocalOperations(Action configure) + { + Services.AddSingleton( + s => + new Local.MiddlewareBasedLocalOperationExecutor(s, s.GetRequiredService(), configure) + ); + return this; + } } From 3297b27e3c0747dd39476fa6030da3f48444603f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jakub=20Fija=C5=82kowski?= Date: Wed, 7 Feb 2024 17:59:48 +0100 Subject: [PATCH 13/18] Add keyed local executors support --- .../ServiceCollectionCQRSExtensions.cs | 30 +++++++++++++++++++ 1 file changed, 30 insertions(+) diff --git a/src/CQRS/LeanCode.CQRS.AspNetCore/ServiceCollectionCQRSExtensions.cs b/src/CQRS/LeanCode.CQRS.AspNetCore/ServiceCollectionCQRSExtensions.cs index 7c77ef91..2c1297f2 100644 --- a/src/CQRS/LeanCode.CQRS.AspNetCore/ServiceCollectionCQRSExtensions.cs +++ b/src/CQRS/LeanCode.CQRS.AspNetCore/ServiceCollectionCQRSExtensions.cs @@ -114,4 +114,34 @@ public CQRSServicesBuilder WithLocalOperations(Action c ); return this; } + + public CQRSServicesBuilder WithKeyedLocalCommands(object? serviceKey, Action configure) + { + Services.AddKeyedSingleton( + serviceKey, + (s, _) => + new Local.MiddlewareBasedLocalCommandExecutor(s, s.GetRequiredService(), configure) + ); + return this; + } + + public CQRSServicesBuilder WithKeyedLocalQueries(object? serviceKey, Action configure) + { + Services.AddKeyedSingleton( + serviceKey, + (s, _) => + new Local.MiddlewareBasedLocalQueryExecutor(s, s.GetRequiredService(), configure) + ); + return this; + } + + public CQRSServicesBuilder WithKeyedLocalOperations(object? serviceKey, Action configure) + { + Services.AddKeyedSingleton( + serviceKey, + (s, _) => + new Local.MiddlewareBasedLocalOperationExecutor(s, s.GetRequiredService(), configure) + ); + return this; + } } From f581a823b1c2e260675aefea0749a8a59ae6dfeb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jakub=20Fija=C5=82kowski?= Date: Wed, 7 Feb 2024 18:00:02 +0100 Subject: [PATCH 14/18] Test the `ServiceProviderRegistrationExtensions` --- .../ServiceCollectionCQRSExtensions.cs | 1 - ...cs => ServiceCollectionExtensionsTests.cs} | 3 +- ...viceProviderRegistrationExtensionsTests.cs | 161 ++++++++++++++++++ 3 files changed, 162 insertions(+), 3 deletions(-) rename test/CQRS/LeanCode.CQRS.AspNetCore.Tests/{ServiceProviderRegistrationExtensions.cs => ServiceCollectionExtensionsTests.cs} (92%) create mode 100644 test/CQRS/LeanCode.CQRS.AspNetCore.Tests/ServiceProviderRegistrationExtensionsTests.cs diff --git a/src/CQRS/LeanCode.CQRS.AspNetCore/ServiceCollectionCQRSExtensions.cs b/src/CQRS/LeanCode.CQRS.AspNetCore/ServiceCollectionCQRSExtensions.cs index 2c1297f2..c618dab6 100644 --- a/src/CQRS/LeanCode.CQRS.AspNetCore/ServiceCollectionCQRSExtensions.cs +++ b/src/CQRS/LeanCode.CQRS.AspNetCore/ServiceCollectionCQRSExtensions.cs @@ -27,7 +27,6 @@ TypesCatalog handlersCatalog serviceCollection.AddSingleton(objectsSource); serviceCollection.AddSingleton(); - serviceCollection.AddSingleton(objectsSource); serviceCollection.AddSingleton(); serviceCollection.AddScoped(); diff --git a/test/CQRS/LeanCode.CQRS.AspNetCore.Tests/ServiceProviderRegistrationExtensions.cs b/test/CQRS/LeanCode.CQRS.AspNetCore.Tests/ServiceCollectionExtensionsTests.cs similarity index 92% rename from test/CQRS/LeanCode.CQRS.AspNetCore.Tests/ServiceProviderRegistrationExtensions.cs rename to test/CQRS/LeanCode.CQRS.AspNetCore.Tests/ServiceCollectionExtensionsTests.cs index 627623c0..bdb74307 100644 --- a/test/CQRS/LeanCode.CQRS.AspNetCore.Tests/ServiceProviderRegistrationExtensions.cs +++ b/test/CQRS/LeanCode.CQRS.AspNetCore.Tests/ServiceCollectionExtensionsTests.cs @@ -1,5 +1,4 @@ using LeanCode.Components; -using LeanCode.CQRS.AspNetCore.Registration; using Microsoft.Extensions.DependencyInjection; using Xunit; @@ -17,7 +16,7 @@ internal sealed class Type1Type2Service : IGenericService, IGenericServic internal sealed class Type3Service : IGenericService { } -public class ServiceCollectionRegistrationExtensionsTests +public class ServiceCollectionExtensionsTests { [Fact] public void Registers_implementations_of_generic_type() diff --git a/test/CQRS/LeanCode.CQRS.AspNetCore.Tests/ServiceProviderRegistrationExtensionsTests.cs b/test/CQRS/LeanCode.CQRS.AspNetCore.Tests/ServiceProviderRegistrationExtensionsTests.cs new file mode 100644 index 00000000..e2d097d5 --- /dev/null +++ b/test/CQRS/LeanCode.CQRS.AspNetCore.Tests/ServiceProviderRegistrationExtensionsTests.cs @@ -0,0 +1,161 @@ +using FluentAssertions; +using FluentAssertions.Execution; +using FluentAssertions.Primitives; +using LeanCode.Components; +using LeanCode.Contracts.Security; +using LeanCode.CQRS.AspNetCore.Local; +using LeanCode.CQRS.AspNetCore.Registration; +using LeanCode.CQRS.AspNetCore.Serialization; +using LeanCode.CQRS.Security; +using LeanCode.CQRS.Validation; +using Microsoft.Extensions.DependencyInjection; +using Xunit; + +namespace LeanCode.CQRS.AspNetCore.Tests; + +public class ServiceProviderRegistrationExtensionsTests +{ + private static readonly TypesCatalog ThisCatalog = TypesCatalog.Of(); + + [Fact] + public void Registers_base_services() + { + var sp = BuildProvider(); + + sp.Should().HaveService(); + sp.Should().HaveService(); + sp.Should().HaveService(); + sp.Should().HaveService(); + sp.Should().HaveService(); + sp.Should().HaveService(); + } + + [Fact] + public void Registers_handlers() + { + var sp = BuildProvider(); + + sp.Should().HaveService(); + sp.Should().HaveService(); + sp.Should().HaveService(); + } + + [Fact] + public void Does_not_register_local_executors_by_default() + { + var sp = BuildProvider(); + + sp.Should().NotHaveService(); + sp.Should().NotHaveService(); + sp.Should().NotHaveService(); + } + + [Fact] + public void Registers_local_executors_on_request() + { + var sp = BuildProvider( + c => c.WithLocalCommands(_ => { }).WithLocalQueries(_ => { }).WithLocalOperations(_ => { }) + ); + + sp.Should().HaveService(); + sp.Should().HaveService(); + sp.Should().HaveService(); + } + + [Fact] + public void Registers_keyed_local_executors_on_request() + { + var sp = BuildProvider( + c => + c.WithKeyedLocalCommands("commands", _ => { }) + .WithKeyedLocalQueries("queries", _ => { }) + .WithKeyedLocalOperations("operations", _ => { }) + ); + + sp.Should().HaveKeyedService("commands"); + sp.Should().HaveKeyedService("queries"); + sp.Should().HaveKeyedService("operations"); + } + + [Fact] + public void Replaces_serializer_service() + { + var serializer = new CustomSerializer(); + var sp = BuildProvider(c => c.WithSerializer(serializer)); + + sp.GetRequiredService().Should().BeSameAs(serializer); + } + + private static ServiceProvider BuildProvider(Action? configure = null) + { + var collection = new ServiceCollection(); + var builder = collection.AddCQRS(ThisCatalog, ThisCatalog); + configure?.Invoke(builder); + return collection.BuildServiceProvider(); + } + + internal class CustomSerializer : ISerializer + { + public ValueTask DeserializeAsync( + Stream utf8Json, + Type returnType, + CancellationToken cancellationToken + ) => throw new NotImplementedException(); + + public Task SerializeAsync( + Stream utf8Json, + object value, + Type inputType, + CancellationToken cancellationToken + ) => throw new NotImplementedException(); + } +} + +file class ServiceProviderAssertions : ReferenceTypeAssertions +{ + protected override string Identifier => "services"; + + public ServiceProviderAssertions(ServiceProvider subject) + : base(subject) { } + + public AndConstraint HaveService(string because = "", params object[] becauseArgs) + { + Execute + .Assertion + .BecauseOf(because, becauseArgs) + .ForCondition(Subject.GetRequiredService().IsService(typeof(T))) + .FailWith("Expected to have {0} registered{reason}", typeof(T)); + return new AndConstraint(this); + } + + public AndConstraint HaveKeyedService( + object? serviceKey, + string because = "", + params object[] becauseArgs + ) + { + Execute + .Assertion + .BecauseOf(because, becauseArgs) + .ForCondition( + Subject.GetRequiredService().IsKeyedService(typeof(T), serviceKey) + ) + .FailWith("Expected to have {0} registered as key {1}{reason}", typeof(T), serviceKey); + return new AndConstraint(this); + } + + public AndConstraint NotHaveService(string because = "", params object[] becauseArgs) + { + Execute + .Assertion + .BecauseOf(because, becauseArgs) + .ForCondition(!Subject.GetRequiredService().IsService(typeof(T))) + .FailWith("Expected to have {0} registered{reason}", typeof(T)); + return new AndConstraint(this); + } +} + +file static class ServiceProviderExtensions +{ + public static ServiceProviderAssertions Should(this ServiceProvider sp) => new(sp); +} From 6f71a4940b8749b9ed36995739b460ab546b26bb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jakub=20Fija=C5=82kowski?= Date: Wed, 7 Feb 2024 18:18:09 +0100 Subject: [PATCH 15/18] Test middlewares with local execution --- .../Middleware/ResponseLoggerMiddleware.cs | 11 +- .../MiddlewaresForLocalExecutionTests.cs | 139 ++++++++++++++++++ 2 files changed, 143 insertions(+), 7 deletions(-) create mode 100644 test/CQRS/LeanCode.CQRS.AspNetCore.Tests/Local/MiddlewaresForLocalExecutionTests.cs diff --git a/src/CQRS/LeanCode.CQRS.AspNetCore/Middleware/ResponseLoggerMiddleware.cs b/src/CQRS/LeanCode.CQRS.AspNetCore/Middleware/ResponseLoggerMiddleware.cs index ea85c7e5..53ccaf98 100644 --- a/src/CQRS/LeanCode.CQRS.AspNetCore/Middleware/ResponseLoggerMiddleware.cs +++ b/src/CQRS/LeanCode.CQRS.AspNetCore/Middleware/ResponseLoggerMiddleware.cs @@ -1,5 +1,4 @@ using LeanCode.CQRS.Execution; -using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.Http; using Serilog; @@ -8,18 +7,16 @@ namespace LeanCode.CQRS.AspNetCore.Middleware; public class ResponseLoggerMiddleware { private readonly ILogger logger; + private readonly RequestDelegate next; - public ResponseLoggerMiddleware() + public ResponseLoggerMiddleware(RequestDelegate next) { logger = Log.ForContext(); - } - public ResponseLoggerMiddleware(ILogger logger) - { - this.logger = logger; + this.next = next; } - public async Task InvokeAsync(HttpContext httpContext, RequestDelegate next) + public async Task InvokeAsync(HttpContext httpContext) { await next(httpContext); var result = httpContext.GetCQRSRequestPayload().Result; diff --git a/test/CQRS/LeanCode.CQRS.AspNetCore.Tests/Local/MiddlewaresForLocalExecutionTests.cs b/test/CQRS/LeanCode.CQRS.AspNetCore.Tests/Local/MiddlewaresForLocalExecutionTests.cs new file mode 100644 index 00000000..b6605cc9 --- /dev/null +++ b/test/CQRS/LeanCode.CQRS.AspNetCore.Tests/Local/MiddlewaresForLocalExecutionTests.cs @@ -0,0 +1,139 @@ +using FluentAssertions; +using LeanCode.Components; +using LeanCode.Contracts; +using LeanCode.Contracts.Security; +using LeanCode.Contracts.Validation; +using LeanCode.CQRS.AspNetCore.Local; +using LeanCode.CQRS.AspNetCore.Registration; +using LeanCode.CQRS.Execution; +using LeanCode.CQRS.Validation; +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.DependencyInjection; +using Xunit; + +namespace LeanCode.CQRS.AspNetCore.Tests.Local; + +public class MiddlewaresForLocalExecutionTests +{ + private static readonly TypesCatalog ThisCatalog = TypesCatalog.Of(); + + [Fact] + public async Task Tracing_middleware_works() + { + var executor = BuildWith(c => c.CQRSTrace()); + + await executor.RunAsync(new DummyCommand(), new()); + } + + [Fact] + public async Task Response_logging_middleware_works() + { + var executor = BuildWith(c => c.LogCQRSResponses()); + + await executor.RunAsync(new DummyCommand(), new()); + } + + [Fact] + public async Task Exception_translation_middleware_works() + { + var executor = BuildWith(c => c.TranslateExceptions()); + + var result = await executor.RunAsync(new ExceptionTranslationCommand(), new()); + result.WasSuccessful.Should().BeFalse(); + result + .ValidationErrors + .Should() + .ContainSingle() + .Which + .Should() + .BeEquivalentTo(new ValidationError("", "Message", 100)); + } + + [Fact] + public async Task Validation_middleware_works() + { + var executor = BuildWith(c => c.Validate()); + + var result = await executor.RunAsync(new ValidatedCommand(), new()); + result.WasSuccessful.Should().BeFalse(); + result + .ValidationErrors + .Should() + .ContainSingle() + .Which + .Should() + .BeEquivalentTo(new ValidationError("", "FromValidator", 101)); + } + + [Fact] + public async Task Security_middleware_works() + { + var executor = BuildWith(c => c.Secure()); + + var result = await executor.RunAsync(new SecuredCommand(), new()); + result.WasSuccessful.Should().BeFalse(); + } + + public static ILocalCommandExecutor BuildWith(Action configure) + { + var serviceCollection = new ServiceCollection(); + serviceCollection.AddMetrics(); + serviceCollection.AddScoped(sp => new MiddlewareFactory(sp)); + serviceCollection.AddScoped(); + serviceCollection.AddScoped(); + serviceCollection.AddScoped(); + + var registrationSource = new CQRSObjectsRegistrationSource(serviceCollection, new ObjectExecutorFactory()); + registrationSource.AddCQRSObjects(ThisCatalog, ThisCatalog); + + var serviceProvider = serviceCollection.BuildServiceProvider(); + + return new MiddlewareBasedLocalCommandExecutor(serviceProvider, registrationSource, configure); + } +} + +public record DummyCommand() : ICommand; + +public record ExceptionTranslationCommand() : ICommand; + +public record ValidatedCommand() : ICommand; + +[AuthorizeWhenHasAnyOf("invalid")] +public record SecuredCommand() : ICommand; + +public class DummyCommandHandler : ICommandHandler +{ + public Task ExecuteAsync(HttpContext context, DummyCommand command) => Task.CompletedTask; +} + +public class ExceptionTranslationCommandHandler : ICommandHandler +{ + public Task ExecuteAsync(HttpContext context, ExceptionTranslationCommand command) + { + throw new CommandExecutionInvalidException(100, "Message"); + } +} + +public class ValidatedCommandValidator : ICommandValidator, ICommandValidatorWrapper +{ + public Task ValidateAsync(HttpContext httpContext, ValidatedCommand command) => + Task.FromResult(new ValidationResult([ new ValidationError("", "FromValidator", 101) ])); + + public Task ValidateAsync(HttpContext appContext, ICommand command) => + ValidateAsync(appContext, (ValidatedCommand)command); +} + +public class ValidatedCommandHandler : ICommandHandler +{ + public Task ExecuteAsync(HttpContext context, ValidatedCommand command) => Task.CompletedTask; +} + +public class CommandValidatorResolver : ICommandValidatorResolver +{ + public ICommandValidatorWrapper? FindCommandValidator(Type commandType) => new ValidatedCommandValidator(); +} + +public class SecureCommandHandler : ICommandHandler +{ + public Task ExecuteAsync(HttpContext context, SecuredCommand command) => Task.CompletedTask; +} From 59da577fa51a21f884fe74a262bb1a1daf31b0e3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jakub=20Fija=C5=82kowski?= Date: Wed, 7 Feb 2024 20:22:53 +0100 Subject: [PATCH 16/18] Restore the previously-removed `CQRSObjectsRegistrationSource` registration --- .../LeanCode.CQRS.AspNetCore/ServiceCollectionCQRSExtensions.cs | 1 + 1 file changed, 1 insertion(+) diff --git a/src/CQRS/LeanCode.CQRS.AspNetCore/ServiceCollectionCQRSExtensions.cs b/src/CQRS/LeanCode.CQRS.AspNetCore/ServiceCollectionCQRSExtensions.cs index c618dab6..49be9776 100644 --- a/src/CQRS/LeanCode.CQRS.AspNetCore/ServiceCollectionCQRSExtensions.cs +++ b/src/CQRS/LeanCode.CQRS.AspNetCore/ServiceCollectionCQRSExtensions.cs @@ -26,6 +26,7 @@ TypesCatalog handlersCatalog objectsSource.AddCQRSObjects(contractsCatalog, handlersCatalog); serviceCollection.AddSingleton(objectsSource); + serviceCollection.AddSingleton(objectsSource); serviceCollection.AddSingleton(); serviceCollection.AddSingleton(); From 124155f238a13711042c7e970a85c5d3a9eac3a9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jakub=20Fija=C5=82kowski?= Date: Wed, 7 Feb 2024 21:35:11 +0100 Subject: [PATCH 17/18] Decode known status code for local execution --- .../MiddlewareBasedLocalCommandExecutor.cs | 8 ++-- .../Local/MiddlewareBasedLocalExecutor.cs | 17 +++++-- .../MiddlewareBasedLocalOperationExecutor.cs | 8 ++-- .../MiddlewareBasedLocalQueryExecutor.cs | 8 ++-- .../UnauthenticatedCQRSRequestException.cs | 12 +++++ .../Local/UnauthorizedCQRSRequestException.cs | 12 +++++ .../Local/UnknownStatusCodeException.cs | 14 ++++++ ...s => MiddlewareBasedLocalExecutorTests.cs} | 44 ++++++++++++++++++- .../MiddlewaresForLocalExecutionTests.cs | 4 +- 9 files changed, 108 insertions(+), 19 deletions(-) create mode 100644 src/CQRS/LeanCode.CQRS.AspNetCore/Local/UnauthenticatedCQRSRequestException.cs create mode 100644 src/CQRS/LeanCode.CQRS.AspNetCore/Local/UnauthorizedCQRSRequestException.cs create mode 100644 src/CQRS/LeanCode.CQRS.AspNetCore/Local/UnknownStatusCodeException.cs rename test/CQRS/LeanCode.CQRS.AspNetCore.Tests/Local/{MiddlewareBasedLocalCommandExecutorTests.cs => MiddlewareBasedLocalExecutorTests.cs} (74%) diff --git a/src/CQRS/LeanCode.CQRS.AspNetCore/Local/MiddlewareBasedLocalCommandExecutor.cs b/src/CQRS/LeanCode.CQRS.AspNetCore/Local/MiddlewareBasedLocalCommandExecutor.cs index f0017322..ae25ffc5 100644 --- a/src/CQRS/LeanCode.CQRS.AspNetCore/Local/MiddlewareBasedLocalCommandExecutor.cs +++ b/src/CQRS/LeanCode.CQRS.AspNetCore/Local/MiddlewareBasedLocalCommandExecutor.cs @@ -14,18 +14,18 @@ Action configure ) : base(serviceProvider, objectSource, configure) { } - public async Task RunAsync( + public Task RunAsync( T command, ClaimsPrincipal user, CancellationToken cancellationToken = default ) - where T : ICommand => (CommandResult)(await RunInternalAsync(command, user, null, cancellationToken))!; + where T : ICommand => RunInternalAsync(command, user, null, cancellationToken); - public async Task RunAsync( + public Task RunAsync( T command, ClaimsPrincipal user, IHeaderDictionary headers, CancellationToken cancellationToken = default ) - where T : ICommand => (CommandResult)(await RunInternalAsync(command, user, headers, cancellationToken))!; + where T : ICommand => RunInternalAsync(command, user, headers, cancellationToken); } diff --git a/src/CQRS/LeanCode.CQRS.AspNetCore/Local/MiddlewareBasedLocalExecutor.cs b/src/CQRS/LeanCode.CQRS.AspNetCore/Local/MiddlewareBasedLocalExecutor.cs index 1ca98b35..b7d5b9bb 100644 --- a/src/CQRS/LeanCode.CQRS.AspNetCore/Local/MiddlewareBasedLocalExecutor.cs +++ b/src/CQRS/LeanCode.CQRS.AspNetCore/Local/MiddlewareBasedLocalExecutor.cs @@ -1,11 +1,11 @@ using System.Security.Claims; -using LeanCode.Contracts; using LeanCode.CQRS.AspNetCore.Middleware; using LeanCode.CQRS.AspNetCore.Registration; using LeanCode.CQRS.Execution; using LeanCode.OpenTelemetry; using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc.Infrastructure; using Microsoft.Extensions.DependencyInjection; namespace LeanCode.CQRS.AspNetCore.Local; @@ -32,7 +32,7 @@ Action configure pipeline = app.Build(); } - protected async Task RunInternalAsync( + protected async Task RunInternalAsync( object obj, ClaimsPrincipal user, IHeaderDictionary? headers, @@ -61,6 +61,17 @@ CancellationToken cancellationToken localContext.CallAborted.ThrowIfCancellationRequested(); - return localContext.GetCQRSRequestPayload().Result!.Value.Payload; + return Decode(obj, localContext.GetCQRSRequestPayload().Result!.Value); + } + + private static T Decode(object payload, ExecutionResult result) + { + return result.StatusCode switch + { + StatusCodes.Status200OK or StatusCodes.Status422UnprocessableEntity => (T)result.Payload!, + StatusCodes.Status401Unauthorized => throw new UnauthenticatedCQRSRequestException(payload.GetType()), + StatusCodes.Status403Forbidden => throw new UnauthorizedCQRSRequestException(payload.GetType()), + var e => throw new UnknownStatusCodeException(e, payload.GetType()), + }; } } diff --git a/src/CQRS/LeanCode.CQRS.AspNetCore/Local/MiddlewareBasedLocalOperationExecutor.cs b/src/CQRS/LeanCode.CQRS.AspNetCore/Local/MiddlewareBasedLocalOperationExecutor.cs index cb573b80..d9dd8d6f 100644 --- a/src/CQRS/LeanCode.CQRS.AspNetCore/Local/MiddlewareBasedLocalOperationExecutor.cs +++ b/src/CQRS/LeanCode.CQRS.AspNetCore/Local/MiddlewareBasedLocalOperationExecutor.cs @@ -14,16 +14,16 @@ Action configure ) : base(serviceProvider, objectSource, configure) { } - public async Task ExecuteAsync( + public Task ExecuteAsync( IOperation query, ClaimsPrincipal user, CancellationToken cancellationToken = default - ) => (TResult)(await RunInternalAsync(query, user, null, cancellationToken))!; + ) => RunInternalAsync(query, user, null, cancellationToken); - public async Task ExecuteAsync( + public Task ExecuteAsync( IOperation query, ClaimsPrincipal user, IHeaderDictionary headers, CancellationToken cancellationToken = default - ) => (TResult)(await RunInternalAsync(query, user, null, cancellationToken))!; + ) => RunInternalAsync(query, user, null, cancellationToken); } diff --git a/src/CQRS/LeanCode.CQRS.AspNetCore/Local/MiddlewareBasedLocalQueryExecutor.cs b/src/CQRS/LeanCode.CQRS.AspNetCore/Local/MiddlewareBasedLocalQueryExecutor.cs index 1a6f840a..43b349bf 100644 --- a/src/CQRS/LeanCode.CQRS.AspNetCore/Local/MiddlewareBasedLocalQueryExecutor.cs +++ b/src/CQRS/LeanCode.CQRS.AspNetCore/Local/MiddlewareBasedLocalQueryExecutor.cs @@ -14,16 +14,16 @@ Action configure ) : base(serviceProvider, objectSource, configure) { } - public async Task GetAsync( + public Task GetAsync( IQuery query, ClaimsPrincipal user, CancellationToken cancellationToken = default - ) => (TResult)(await RunInternalAsync(query, user, null, cancellationToken))!; + ) => RunInternalAsync(query, user, null, cancellationToken); - public async Task GetAsync( + public Task GetAsync( IQuery query, ClaimsPrincipal user, IHeaderDictionary headers, CancellationToken cancellationToken = default - ) => (TResult)(await RunInternalAsync(query, user, headers, cancellationToken))!; + ) => RunInternalAsync(query, user, headers, cancellationToken); } diff --git a/src/CQRS/LeanCode.CQRS.AspNetCore/Local/UnauthenticatedCQRSRequestException.cs b/src/CQRS/LeanCode.CQRS.AspNetCore/Local/UnauthenticatedCQRSRequestException.cs new file mode 100644 index 00000000..a213c74a --- /dev/null +++ b/src/CQRS/LeanCode.CQRS.AspNetCore/Local/UnauthenticatedCQRSRequestException.cs @@ -0,0 +1,12 @@ +namespace LeanCode.CQRS.AspNetCore.Local; + +public class UnauthenticatedCQRSRequestException : Exception +{ + public Type ObjectType { get; } + + public UnauthenticatedCQRSRequestException(Type objectType) + : base($"The request {objectType.FullName} was not authenticated.") + { + ObjectType = objectType; + } +} diff --git a/src/CQRS/LeanCode.CQRS.AspNetCore/Local/UnauthorizedCQRSRequestException.cs b/src/CQRS/LeanCode.CQRS.AspNetCore/Local/UnauthorizedCQRSRequestException.cs new file mode 100644 index 00000000..d0dcb2f1 --- /dev/null +++ b/src/CQRS/LeanCode.CQRS.AspNetCore/Local/UnauthorizedCQRSRequestException.cs @@ -0,0 +1,12 @@ +namespace LeanCode.CQRS.AspNetCore.Local; + +public class UnauthorizedCQRSRequestException : Exception +{ + public Type ObjectType { get; } + + public UnauthorizedCQRSRequestException(Type objectType) + : base($"The request {objectType.FullName} was not authorized.") + { + ObjectType = objectType; + } +} diff --git a/src/CQRS/LeanCode.CQRS.AspNetCore/Local/UnknownStatusCodeException.cs b/src/CQRS/LeanCode.CQRS.AspNetCore/Local/UnknownStatusCodeException.cs new file mode 100644 index 00000000..68ca30ad --- /dev/null +++ b/src/CQRS/LeanCode.CQRS.AspNetCore/Local/UnknownStatusCodeException.cs @@ -0,0 +1,14 @@ +namespace LeanCode.CQRS.AspNetCore.Local; + +public class UnknownStatusCodeException : Exception +{ + public int StatusCode { get; } + public Type ObjectType { get; } + + public UnknownStatusCodeException(int statusCode, Type objectType) + : base($"Unknown status code {statusCode} for request {objectType.FullName}.") + { + StatusCode = statusCode; + ObjectType = objectType; + } +} diff --git a/test/CQRS/LeanCode.CQRS.AspNetCore.Tests/Local/MiddlewareBasedLocalCommandExecutorTests.cs b/test/CQRS/LeanCode.CQRS.AspNetCore.Tests/Local/MiddlewareBasedLocalExecutorTests.cs similarity index 74% rename from test/CQRS/LeanCode.CQRS.AspNetCore.Tests/Local/MiddlewareBasedLocalCommandExecutorTests.cs rename to test/CQRS/LeanCode.CQRS.AspNetCore.Tests/Local/MiddlewareBasedLocalExecutorTests.cs index bd9fa459..0b63e530 100644 --- a/test/CQRS/LeanCode.CQRS.AspNetCore.Tests/Local/MiddlewareBasedLocalCommandExecutorTests.cs +++ b/test/CQRS/LeanCode.CQRS.AspNetCore.Tests/Local/MiddlewareBasedLocalExecutorTests.cs @@ -1,3 +1,4 @@ +using System.Globalization; using System.Security.Claims; using FluentAssertions; using LeanCode.Components; @@ -98,6 +99,34 @@ public async Task Object_metadata_is_set() await executor.RunAsync(command, new ClaimsPrincipal()); } + + [Fact] + public async Task Decodes_401_status_code() + { + var headers = new HeaderDictionary { [LocalHandlerMiddleware.StatusHeader] = "401", }; + + var act = () => executor.RunAsync(new LocalCommand(), new ClaimsPrincipal(), headers); + await act.Should().ThrowAsync(); + } + + [Fact] + public async Task Decodes_403_status_code() + { + var headers = new HeaderDictionary { [LocalHandlerMiddleware.StatusHeader] = "403", }; + + var act = () => executor.RunAsync(new LocalCommand(), new ClaimsPrincipal(), headers); + await act.Should().ThrowAsync(); + } + + [Fact] + public async Task Decodes_499_status_code_as_unknown() + { + var headers = new HeaderDictionary { [LocalHandlerMiddleware.StatusHeader] = "499", }; + + var act = () => executor.RunAsync(new LocalCommand(), new ClaimsPrincipal(), headers); + var exc = await act.Should().ThrowAsync(); + exc.Which.StatusCode.Should().Be(499); + } } public class LocalDataStorage @@ -146,9 +175,20 @@ public Task ExecuteAsync(HttpContext context, LocalCommand command) public class LocalHandlerMiddleware : IMiddleware { + public const string StatusHeader = "X-Status"; + public Task InvokeAsync(HttpContext context, RequestDelegate next) { - context.RequestServices.GetRequiredService().Middlewares.Add(this); - return next(context); + if (context.Request.Headers.TryGetValue(StatusHeader, out var value)) + { + var code = int.Parse(value!, CultureInfo.InvariantCulture); + context.GetCQRSRequestPayload().SetResult(ExecutionResult.Empty(code)); + return Task.CompletedTask; + } + else + { + context.RequestServices.GetRequiredService().Middlewares.Add(this); + return next(context); + } } } diff --git a/test/CQRS/LeanCode.CQRS.AspNetCore.Tests/Local/MiddlewaresForLocalExecutionTests.cs b/test/CQRS/LeanCode.CQRS.AspNetCore.Tests/Local/MiddlewaresForLocalExecutionTests.cs index b6605cc9..ecfdf053 100644 --- a/test/CQRS/LeanCode.CQRS.AspNetCore.Tests/Local/MiddlewaresForLocalExecutionTests.cs +++ b/test/CQRS/LeanCode.CQRS.AspNetCore.Tests/Local/MiddlewaresForLocalExecutionTests.cs @@ -70,8 +70,8 @@ public async Task Security_middleware_works() { var executor = BuildWith(c => c.Secure()); - var result = await executor.RunAsync(new SecuredCommand(), new()); - result.WasSuccessful.Should().BeFalse(); + var act = () => executor.RunAsync(new SecuredCommand(), new()); + await act.Should().ThrowAsync(); } public static ILocalCommandExecutor BuildWith(Action configure) From 925267dc56a260069057f0148808cbf3bf30fe04 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jakub=20Fija=C5=82kowski?= Date: Thu, 8 Feb 2024 16:43:35 +0100 Subject: [PATCH 18/18] Seal classes that can be sealed --- .editorconfig | 1 - .../Registration/CQRSApiDescriptionProviderTests.cs | 2 +- .../ServiceProviderRegistrationExtensionsTests.cs | 4 ++-- 3 files changed, 3 insertions(+), 4 deletions(-) diff --git a/.editorconfig b/.editorconfig index c3e6de50..b447d1e1 100644 --- a/.editorconfig +++ b/.editorconfig @@ -35,7 +35,6 @@ dotnet_style_predefined_type_for_member_access = true dotnet_style_readonly_field = true : suggestion dotnet_style_require_accessibility_modifiers = always : warning -csharp_preferred_modifier_order = public,private,protected,internal,static,extern,new,virtual,abstract,sealed,override,readonly,unsafe,volatile,async : warning dotnet_style_object_initializer = true : suggestion dotnet_style_collection_initializer = true : suggestion diff --git a/test/CQRS/LeanCode.CQRS.AspNetCore.Tests/Registration/CQRSApiDescriptionProviderTests.cs b/test/CQRS/LeanCode.CQRS.AspNetCore.Tests/Registration/CQRSApiDescriptionProviderTests.cs index 4a54b474..21aa35eb 100644 --- a/test/CQRS/LeanCode.CQRS.AspNetCore.Tests/Registration/CQRSApiDescriptionProviderTests.cs +++ b/test/CQRS/LeanCode.CQRS.AspNetCore.Tests/Registration/CQRSApiDescriptionProviderTests.cs @@ -241,7 +241,7 @@ public Task ExecuteAsync(HttpContext context, Operation oper throw new NotImplementedException(); } -internal class DummyEndpointDataSource : EndpointDataSource +internal sealed class DummyEndpointDataSource : EndpointDataSource { public override IReadOnlyList Endpoints => throw new NotImplementedException(); diff --git a/test/CQRS/LeanCode.CQRS.AspNetCore.Tests/ServiceProviderRegistrationExtensionsTests.cs b/test/CQRS/LeanCode.CQRS.AspNetCore.Tests/ServiceProviderRegistrationExtensionsTests.cs index e2d097d5..ec6dcbdb 100644 --- a/test/CQRS/LeanCode.CQRS.AspNetCore.Tests/ServiceProviderRegistrationExtensionsTests.cs +++ b/test/CQRS/LeanCode.CQRS.AspNetCore.Tests/ServiceProviderRegistrationExtensionsTests.cs @@ -94,7 +94,7 @@ private static ServiceProvider BuildProvider(Action? config return collection.BuildServiceProvider(); } - internal class CustomSerializer : ISerializer + internal sealed class CustomSerializer : ISerializer { public ValueTask DeserializeAsync( Stream utf8Json, @@ -111,7 +111,7 @@ CancellationToken cancellationToken } } -file class ServiceProviderAssertions : ReferenceTypeAssertions +file sealed class ServiceProviderAssertions : ReferenceTypeAssertions { protected override string Identifier => "services";