diff --git a/Eventuous.sln b/Eventuous.sln index 6df60900..3bb3781a 100644 --- a/Eventuous.sln +++ b/Eventuous.sln @@ -118,6 +118,8 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Eventuous.Tests.AspNetCore" EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Eventuous.Tests.AspNetCore.Web", "src\Extensions\test\Eventuous.Tests.AspNetCore.Web\Eventuous.Tests.AspNetCore.Web.csproj", "{B3F782EE-FBEF-47E2-8379-8A91B11363B8}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Eventuous.Sut.AspNetCore", "src\Extensions\test\Eventuous.Sut.AspNetCore\Eventuous.Sut.AspNetCore.csproj", "{1C1033D6-059B-4CEE-A7D8-9EE470053145}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -268,6 +270,10 @@ Global {B3F782EE-FBEF-47E2-8379-8A91B11363B8}.Debug|Any CPU.Build.0 = Debug|Any CPU {B3F782EE-FBEF-47E2-8379-8A91B11363B8}.Release|Any CPU.ActiveCfg = Release|Any CPU {B3F782EE-FBEF-47E2-8379-8A91B11363B8}.Release|Any CPU.Build.0 = Release|Any CPU + {1C1033D6-059B-4CEE-A7D8-9EE470053145}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {1C1033D6-059B-4CEE-A7D8-9EE470053145}.Debug|Any CPU.Build.0 = Debug|Any CPU + {1C1033D6-059B-4CEE-A7D8-9EE470053145}.Release|Any CPU.ActiveCfg = Release|Any CPU + {1C1033D6-059B-4CEE-A7D8-9EE470053145}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(NestedProjects) = preSolution {151A0839-2B1F-49D6-B5DD-199A5FAAB610} = {C60C6094-2A03-45B6-AB33-C514C35DF823} @@ -319,5 +325,6 @@ Global {5555BC0B-418C-49A3-BD68-ADCEBCC518E4} = {0E2520E7-B4A6-47E7-AED8-662C88441A84} {152E27CE-35F1-4F65-B53A-C7B710F1B310} = {CCD807D2-F4F2-4EC2-A03D-8943F73993A6} {B3F782EE-FBEF-47E2-8379-8A91B11363B8} = {CCD807D2-F4F2-4EC2-A03D-8943F73993A6} + {1C1033D6-059B-4CEE-A7D8-9EE470053145} = {CCD807D2-F4F2-4EC2-A03D-8943F73993A6} EndGlobalSection EndGlobal diff --git a/src/Core/test/Eventuous.Tests/Eventuous.Tests.csproj b/src/Core/test/Eventuous.Tests/Eventuous.Tests.csproj index ee6c5496..5bdc5091 100644 --- a/src/Core/test/Eventuous.Tests/Eventuous.Tests.csproj +++ b/src/Core/test/Eventuous.Tests/Eventuous.Tests.csproj @@ -2,6 +2,7 @@ true true + true preview diff --git a/src/Core/test/Eventuous.Tests/Fixtures/NaiveFixture.cs b/src/Core/test/Eventuous.Tests/Fixtures/NaiveFixture.cs index 83c05d17..88fa95e5 100644 --- a/src/Core/test/Eventuous.Tests/Fixtures/NaiveFixture.cs +++ b/src/Core/test/Eventuous.Tests/Fixtures/NaiveFixture.cs @@ -1,4 +1,4 @@ -using Eventuous.Tests.Fakes; +using Eventuous.TestHelpers.Fakes; namespace Eventuous.Tests.Fixtures; diff --git a/src/Extensions/src/Eventuous.AspNetCore.Web/ApplicationServiceRouteBuilder.cs b/src/Extensions/src/Eventuous.AspNetCore.Web/ApplicationServiceRouteBuilder.cs new file mode 100644 index 00000000..e9af7a71 --- /dev/null +++ b/src/Extensions/src/Eventuous.AspNetCore.Web/ApplicationServiceRouteBuilder.cs @@ -0,0 +1,39 @@ +using Microsoft.AspNetCore.Routing; + +namespace Eventuous.AspNetCore.Web; + +[PublicAPI] +public class ApplicationServiceRouteBuilder where T : Aggregate { + readonly IEndpointRouteBuilder _builder; + + public ApplicationServiceRouteBuilder(IEndpointRouteBuilder builder) => _builder = builder; + + /// + /// Maps the given command type to an HTTP endpoint. The command class can be annotated with + /// the if you need a custom route. + /// + /// A function to populate command props from HttpContext + /// Command class + /// + public ApplicationServiceRouteBuilder MapCommand( + EnrichCommandFromHttpContext? enrichCommand = null + ) where TCommand : class { + _builder.MapCommand(enrichCommand); + return this; + } + + /// + /// Maps the given command type to an HTTP endpoint using the specified route. + /// + /// HTTP route for the command + /// A function to populate command props from HttpContext + /// Command type + /// + public ApplicationServiceRouteBuilder MapCommand( + string route, + EnrichCommandFromHttpContext? enrichCommand = null + ) where TCommand : class { + _builder.MapCommand(route, enrichCommand); + return this; + } +} diff --git a/src/Extensions/src/Eventuous.AspNetCore.Web/Eventuous.AspNetCore.Web.csproj b/src/Extensions/src/Eventuous.AspNetCore.Web/Eventuous.AspNetCore.Web.csproj index a31fd7d1..539c9ee1 100644 --- a/src/Extensions/src/Eventuous.AspNetCore.Web/Eventuous.AspNetCore.Web.csproj +++ b/src/Extensions/src/Eventuous.AspNetCore.Web/Eventuous.AspNetCore.Web.csproj @@ -1,19 +1,20 @@ - - - - - - - + + + + + + + - + - - - + + + + diff --git a/src/Extensions/src/Eventuous.AspNetCore.Web/RouteBuilderExtensions.cs b/src/Extensions/src/Eventuous.AspNetCore.Web/RouteBuilderExtensions.cs index 4902daae..37dd3146 100644 --- a/src/Extensions/src/Eventuous.AspNetCore.Web/RouteBuilderExtensions.cs +++ b/src/Extensions/src/Eventuous.AspNetCore.Web/RouteBuilderExtensions.cs @@ -7,20 +7,25 @@ namespace Microsoft.AspNetCore.Routing; +public delegate TCommand EnrichCommandFromHttpContext(TCommand command, HttpContext httpContext); + public static class RouteBuilderExtensions { /// /// Allows to add an HTTP endpoint for controller-less apps /// /// Endpoint route builder instance + /// A function to populate command props from HttpContext /// Command type /// Aggregate type on which the command will operate /// [PublicAPI] - public static RouteHandlerBuilder MapCommand(this IEndpointRouteBuilder builder) - where TAggregate : Aggregate where TCommand : class { + public static RouteHandlerBuilder MapCommand( + this IEndpointRouteBuilder builder, + EnrichCommandFromHttpContext? enrichCommand = null + ) where TAggregate : Aggregate where TCommand : class { var attr = typeof(TCommand).GetAttribute(); var route = GetRoute(attr?.Route); - return builder.MapCommand(route); + return builder.MapCommand(route, enrichCommand); } /// @@ -28,15 +33,17 @@ public static RouteHandlerBuilder MapCommand(this IEndpoin /// /// Endpoint route builder instance /// HTTP API route + /// A function to populate command props from HttpContext /// Command type /// Aggregate type on which the command will operate /// [PublicAPI] public static RouteHandlerBuilder MapCommand( - this IEndpointRouteBuilder builder, - string route + this IEndpointRouteBuilder builder, + string route, + EnrichCommandFromHttpContext? enrichCommand = null ) where TAggregate : Aggregate where TCommand : class - => Map(builder, route); + => Map(builder, route, enrichCommand); /// /// Creates an instance of for a given aggregate type, so you @@ -79,7 +86,10 @@ void MapAssemblyCommands(Assembly assembly) { x => x.IsClass && x.CustomAttributes.Any(a => a.AttributeType == attributeType) ); - var method = typeof(RouteBuilderExtensions).GetMethod(nameof(Map), BindingFlags.Static | BindingFlags.NonPublic)!; + var method = typeof(RouteBuilderExtensions).GetMethod( + nameof(Map), + BindingFlags.Static | BindingFlags.NonPublic + )!; foreach (var type in decoratedTypes) { var attr = type.GetAttribute()!; @@ -91,15 +101,17 @@ void MapAssemblyCommands(Assembly assembly) { var genericMethod = method.MakeGenericMethod(typeof(TAggregate), type); genericMethod.Invoke(null, new object?[] { builder, attr.Route }); - // Map(builder, type, attr.Route); } } return builder; } - static RouteHandlerBuilder Map(IEndpointRouteBuilder builder, string? route) - where TAggregate : Aggregate where TCommand : notnull + static RouteHandlerBuilder Map( + IEndpointRouteBuilder builder, + string? route, + EnrichCommandFromHttpContext? enrichCommand = null + ) where TAggregate : Aggregate where TCommand : notnull => builder .MapPost( GetRoute(route), @@ -108,8 +120,9 @@ async Task(HttpContext context, IApplicationService service if (cmd == null) throw new InvalidOperationException("Failed to deserialize the command"); - var result = await service.Handle(cmd, context.RequestAborted); + if (enrichCommand != null) cmd = enrichCommand(cmd, context); + var result = await service.Handle(cmd, context.RequestAborted); return result.AsResult(); } ) @@ -196,32 +209,3 @@ string Generate() { } } } - -[PublicAPI] -public class ApplicationServiceRouteBuilder where T : Aggregate { - readonly IEndpointRouteBuilder _builder; - - public ApplicationServiceRouteBuilder(IEndpointRouteBuilder builder) => _builder = builder; - - /// - /// Maps the given command type to an HTTP endpoint. The command class can be annotated with - /// the if you need a custom route. - /// - /// Command class - /// - public ApplicationServiceRouteBuilder MapCommand() where TCommand : class { - _builder.MapCommand(); - return this; - } - - /// - /// Maps the given command type to an HTTP endpoint using the specified route. - /// - /// HTTP route for the command - /// Command type - /// - public ApplicationServiceRouteBuilder MapCommand(string route) where TCommand : class { - _builder.MapCommand(route); - return this; - } -} diff --git a/src/Extensions/test/Eventuous.Sut.AspNetCore/BookingService.cs b/src/Extensions/test/Eventuous.Sut.AspNetCore/BookingService.cs new file mode 100644 index 00000000..637a5e4b --- /dev/null +++ b/src/Extensions/test/Eventuous.Sut.AspNetCore/BookingService.cs @@ -0,0 +1,23 @@ +using Eventuous.AspNetCore.Web; +using Eventuous.Sut.Domain; +using NodaTime; + +namespace Eventuous.Sut.AspNetCore; + +public class BookingService : ApplicationService { + public BookingService(IAggregateStore store, StreamNameMap? streamNameMap = null) + : base(store, streamNameMap: streamNameMap) + => OnNew( + (booking, cmd) + => booking.BookRoom( + new BookingId(cmd.BookingId), + cmd.RoomId, + new StayPeriod(cmd.CheckIn, cmd.CheckOut), + cmd.Price, + cmd.GuestId + ) + ); +} + +[HttpCommand(Route = "book")] +record BookRoom(string BookingId, string RoomId, LocalDate CheckIn, LocalDate CheckOut, decimal Price, string? GuestId); diff --git a/src/Extensions/test/Eventuous.Sut.AspNetCore/Eventuous.Sut.AspNetCore.csproj b/src/Extensions/test/Eventuous.Sut.AspNetCore/Eventuous.Sut.AspNetCore.csproj new file mode 100644 index 00000000..3e465551 --- /dev/null +++ b/src/Extensions/test/Eventuous.Sut.AspNetCore/Eventuous.Sut.AspNetCore.csproj @@ -0,0 +1,18 @@ + + + net6.0 + true + false + + + + + + + + + + + + + diff --git a/src/Extensions/test/Eventuous.Sut.AspNetCore/Program.cs b/src/Extensions/test/Eventuous.Sut.AspNetCore/Program.cs new file mode 100644 index 00000000..c0c69689 --- /dev/null +++ b/src/Extensions/test/Eventuous.Sut.AspNetCore/Program.cs @@ -0,0 +1,27 @@ +using System.Text.Json; +using Eventuous.Sut.AspNetCore; +using Eventuous.Sut.Domain; +using Microsoft.AspNetCore.Http.Json; +using NodaTime; +using NodaTime.Serialization.SystemTextJson; +using BookingService = Eventuous.Sut.AspNetCore.BookingService; + +DefaultEventSerializer.SetDefaultSerializer( + new DefaultEventSerializer( + new JsonSerializerOptions(JsonSerializerDefaults.Web).ConfigureForNodaTime(DateTimeZoneProviders.Tzdb) + ) +); + +var builder = WebApplication.CreateBuilder(args); +builder.Services.AddApplicationService(); + +builder.Services.Configure( + options => options.SerializerOptions.ConfigureForNodaTime(DateTimeZoneProviders.Tzdb) +); + +var app = builder.Build(); + +app.MapAggregateCommands() + .MapCommand((cmd, _) => cmd with { GuestId = TestData.GuestId }); + +app.Run(); diff --git a/src/Extensions/test/Eventuous.Sut.AspNetCore/TestData.cs b/src/Extensions/test/Eventuous.Sut.AspNetCore/TestData.cs new file mode 100644 index 00000000..e095a944 --- /dev/null +++ b/src/Extensions/test/Eventuous.Sut.AspNetCore/TestData.cs @@ -0,0 +1,5 @@ +namespace Eventuous.Sut.AspNetCore; + +public static class TestData { + public const string GuestId = "test guest"; +} diff --git a/src/Extensions/test/Eventuous.Sut.AspNetCore/appsettings.json b/src/Extensions/test/Eventuous.Sut.AspNetCore/appsettings.json new file mode 100644 index 00000000..10f68b8c --- /dev/null +++ b/src/Extensions/test/Eventuous.Sut.AspNetCore/appsettings.json @@ -0,0 +1,9 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + }, + "AllowedHosts": "*" +} diff --git a/src/Extensions/test/Eventuous.Tests.AspNetCore.Web/Eventuous.Tests.AspNetCore.Web.csproj b/src/Extensions/test/Eventuous.Tests.AspNetCore.Web/Eventuous.Tests.AspNetCore.Web.csproj index ee18a8e5..aa20e8b2 100644 --- a/src/Extensions/test/Eventuous.Tests.AspNetCore.Web/Eventuous.Tests.AspNetCore.Web.csproj +++ b/src/Extensions/test/Eventuous.Tests.AspNetCore.Web/Eventuous.Tests.AspNetCore.Web.csproj @@ -1,15 +1,19 @@ - net6.0 + net6.0 true true + true false + + - - + + + diff --git a/src/Extensions/test/Eventuous.Tests.AspNetCore.Web/HttpCommandTests.cs b/src/Extensions/test/Eventuous.Tests.AspNetCore.Web/HttpCommandTests.cs index 1bcd304f..83cf3311 100644 --- a/src/Extensions/test/Eventuous.Tests.AspNetCore.Web/HttpCommandTests.cs +++ b/src/Extensions/test/Eventuous.Tests.AspNetCore.Web/HttpCommandTests.cs @@ -1,22 +1,85 @@ -using Eventuous.AspNetCore.Web; +using System.Net; +using System.Text.Json; +using Eventuous.Sut.AspNetCore; using Eventuous.Sut.Domain; -using Microsoft.AspNetCore.Builder; -using Microsoft.AspNetCore.Routing; +using Eventuous.TestHelpers; +using Eventuous.TestHelpers.Fakes; +using Microsoft.AspNetCore.Mvc.Testing; +using NodaTime; +using NodaTime.Serialization.SystemTextJson; +using RestSharp; +using RestSharp.Serializers.Json; namespace Eventuous.Tests.AspNetCore.Web; -public class HttpCommandTests { +public class HttpCommandTests : IDisposable { + readonly TestEventListener _listener; + + public HttpCommandTests(ITestOutputHelper output) => _listener = new TestEventListener(output); + [Fact] public void RegisterAggregateCommands() { var builder = WebApplication.CreateBuilder(); - var app = builder.Build(); + using var app = builder.Build(); var b = app.MapDiscoveredCommands(typeof(BookRoom).Assembly); b.DataSources.First().Endpoints[0].DisplayName.Should().Be("HTTP: POST book"); } - [HttpCommand(Route = "book")] - public record BookRoom(string BookingId, string RoomId, string GuestId); + [Fact] + public async Task MapEnrichedCommand() { + var store = new InMemoryEventStore(); + + var app = new WebApplicationFactory() + .WithWebHostBuilder( + builder => builder.ConfigureServices(services => services.AddAggregateStore(_ => store)) + ); + + var httpClient = app.CreateClient(); + + using var client = new RestClient(httpClient, disposeHttpClient: true).UseSerializer( + () => new SystemTextJsonSerializer( + new JsonSerializerOptions(JsonSerializerDefaults.Web).ConfigureForNodaTime(DateTimeZoneProviders.Tzdb) + ) + ); + + var cmd = new BookRoom( + "123", + "123", + LocalDate.FromDateTime(DateTime.Now), + LocalDate.FromDateTime(DateTime.Now.AddDays(1)), + 100 + ); + + var response = await client.PostJsonAsync("/book", cmd); + response.Should().Be(HttpStatusCode.OK); + + var storedEvents = await store.ReadEvents( + StreamName.For(cmd.BookingId), + StreamReadPosition.Start, + 100, + default + ); + + var actual = storedEvents.FirstOrDefault(); + actual.Should().NotBeNull(); + + actual!.Payload.Should() + .BeEquivalentTo( + new BookingEvents.RoomBooked( + cmd.BookingId, + cmd.RoomId, + cmd.CheckIn, + cmd.CheckOut, + cmd.Price, + TestData.GuestId + ) + ); + } + + public void Dispose() => _listener.Dispose(); } + +record BookRoom(string BookingId, string RoomId, LocalDate CheckIn, LocalDate CheckOut, decimal Price); diff --git a/src/Extensions/test/Eventuous.Tests.AspNetCore.Web/UnitTest1.cs b/src/Extensions/test/Eventuous.Tests.AspNetCore.Web/UnitTest1.cs deleted file mode 100644 index 15d227b7..00000000 --- a/src/Extensions/test/Eventuous.Tests.AspNetCore.Web/UnitTest1.cs +++ /dev/null @@ -1,8 +0,0 @@ -using Xunit; - -namespace Eventuous.Tests.AspNetCore.Web; - -public class UnitTest1 { - [Fact] - public void Test1() { } -} diff --git a/test/Eventuous.Sut.Domain/Booking.cs b/test/Eventuous.Sut.Domain/Booking.cs index 4efeeb35..7dad9bc0 100644 --- a/test/Eventuous.Sut.Domain/Booking.cs +++ b/test/Eventuous.Sut.Domain/Booking.cs @@ -3,10 +3,10 @@ namespace Eventuous.Sut.Domain; public class Booking : Aggregate { - public void BookRoom(BookingId id, string roomId, StayPeriod period, decimal price) { + public void BookRoom(BookingId id, string roomId, StayPeriod period, decimal price, string? guestId = null) { EnsureDoesntExist(); - Apply(new RoomBooked(id, roomId, period.CheckIn, period.CheckOut, price)); + Apply(new RoomBooked(id, roomId, period.CheckIn, period.CheckOut, price, guestId)); } public void Import(BookingId id, string roomId, StayPeriod period) { diff --git a/test/Eventuous.Sut.Domain/BookingEvents.cs b/test/Eventuous.Sut.Domain/BookingEvents.cs index 9e3fcb7c..8e749ca6 100644 --- a/test/Eventuous.Sut.Domain/BookingEvents.cs +++ b/test/Eventuous.Sut.Domain/BookingEvents.cs @@ -1,4 +1,5 @@ using NodaTime; + // ReSharper disable MemberHidesStaticFromOuterClass namespace Eventuous.Sut.Domain; @@ -10,7 +11,8 @@ public record RoomBooked( string RoomId, LocalDate CheckIn, LocalDate CheckOut, - decimal Price + decimal Price, + string? GuestId = null ); [EventType("PaymentRegistered")] @@ -40,4 +42,4 @@ public static class TypeNames { } public static void MapBookingEvents() => TypeMap.RegisterKnownEventTypes(); -} \ No newline at end of file +} diff --git a/src/Core/test/Eventuous.Tests/Fakes/InMemoryEventStore.cs b/test/Eventuous.TestHelpers/Fakes/InMemoryEventStore.cs similarity index 99% rename from src/Core/test/Eventuous.Tests/Fakes/InMemoryEventStore.cs rename to test/Eventuous.TestHelpers/Fakes/InMemoryEventStore.cs index c1ae04d5..a73ef082 100644 --- a/src/Core/test/Eventuous.Tests/Fakes/InMemoryEventStore.cs +++ b/test/Eventuous.TestHelpers/Fakes/InMemoryEventStore.cs @@ -1,4 +1,4 @@ -namespace Eventuous.Tests.Fakes; +namespace Eventuous.TestHelpers.Fakes; public class InMemoryEventStore : IEventStore { readonly Dictionary _storage = new();