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();