diff --git a/Core/Validation/ValidationExtensions.cs b/Core/Validation/ValidationExtensions.cs index 460b5bdc0..11585bc1d 100644 --- a/Core/Validation/ValidationExtensions.cs +++ b/Core/Validation/ValidationExtensions.cs @@ -1,29 +1,44 @@ -using System.Runtime.CompilerServices; +using System.Diagnostics.CodeAnalysis; +using System.Numerics; +using System.Runtime.CompilerServices; namespace Core.Validation; public static class ValidationExtensions { - public static T AssertNotNull(this T? value, [CallerArgumentExpression("value")] string? paramName = null) + public static T NotNull(this T? value, [CallerArgumentExpression("value")] string? paramName = null) where T : struct { if (value == null) throw new ArgumentNullException(paramName); - return (T)value; + return value.Value; } - public static string AssertNotEmpty(this string? value, [CallerArgumentExpression("value")] string? paramName = null) + public static T NotNull(this T? value, [CallerArgumentExpression("value")] string? paramName = null) + where T : class + { + if (value == null) + throw new ArgumentNullException(paramName); + + return value; + } + + public static string NotEmpty(this string? value, [CallerArgumentExpression("value")] string? paramName = null) => !string.IsNullOrWhiteSpace(value) ? value : throw new ArgumentOutOfRangeException(paramName); - public static T AssertNotEmpty(this T value, [CallerArgumentExpression("value")] string? paramName = null) + public static Guid NotEmpty(this Guid? value, [CallerArgumentExpression("value")] string? paramName = null) + => value!= null && value != Guid.Empty ? value.Value : throw new ArgumentOutOfRangeException(paramName); + + + public static T NotEmpty(this T value, [CallerArgumentExpression("value")] string? paramName = null) where T : struct - => AssertNotEmpty((T?)value, paramName); + => NotEmpty((T?)value, paramName); - public static T AssertNotEmpty(this T? value, [CallerArgumentExpression("value")] string? paramName = null) + public static T NotEmpty(this T? value, [CallerArgumentExpression("value")] string? paramName = null) where T : struct { - var notNullValue = value.AssertNotNull(paramName); + var notNullValue = value.NotNull(paramName); if (Equals(notNullValue, default(T))) throw new ArgumentOutOfRangeException(paramName); @@ -31,12 +46,22 @@ public static T AssertNotEmpty(this T? value, [CallerArgumentExpression("valu return notNullValue; } - public static T AssertGreaterOrEqualThan(this T value, T valueToCompare, [CallerArgumentExpression("value")] string? paramName = null) + public static T GreaterOrEqualThan(this T value, T valueToCompare, [CallerArgumentExpression("value")] string? paramName = null) where T : IComparable { if (value.CompareTo(valueToCompare) < 0) throw new ArgumentOutOfRangeException(paramName); return value; + + } + + public static T Positive(this T value, [CallerArgumentExpression("value")] string? paramName = null) + where T : INumber + { + if (value == null || value.CompareTo(Convert.ChangeType(0, typeof(T))) <= 0) + throw new ArgumentOutOfRangeException(paramName); + + return value; } } diff --git a/EventSourcing.NetCore.sln b/EventSourcing.NetCore.sln index 5bde10108..b9762dc9c 100644 --- a/EventSourcing.NetCore.sln +++ b/EventSourcing.NetCore.sln @@ -451,6 +451,10 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "PointOfSales.Api", "Sample\ EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "PointOfSales.Api.Tests", "Sample\ClosingTheBooks\PointOfSales.Api.Tests\PointOfSales.Api.Tests.csproj", "{42510FFD-04F5-4580-9B02-9CBA260718DC}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "08-ApplicationLogic.Marten", "Workshops\IntroductionToEventSourcing\08-ApplicationLogic.Marten\08-ApplicationLogic.Marten.csproj", "{48B5E0BE-81C3-4ECF-85C1-B22D9A245C5B}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "08-ApplicationLogic.Marten.Tests", "Workshops\IntroductionToEventSourcing\08-ApplicationLogic.Marten.Tests\08-ApplicationLogic.Marten.Tests.csproj", "{A31878C3-8079-4A4E-AAAE-1000C5F34122}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -997,6 +1001,14 @@ Global {42510FFD-04F5-4580-9B02-9CBA260718DC}.Debug|Any CPU.Build.0 = Debug|Any CPU {42510FFD-04F5-4580-9B02-9CBA260718DC}.Release|Any CPU.ActiveCfg = Release|Any CPU {42510FFD-04F5-4580-9B02-9CBA260718DC}.Release|Any CPU.Build.0 = Release|Any CPU + {48B5E0BE-81C3-4ECF-85C1-B22D9A245C5B}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {48B5E0BE-81C3-4ECF-85C1-B22D9A245C5B}.Debug|Any CPU.Build.0 = Debug|Any CPU + {48B5E0BE-81C3-4ECF-85C1-B22D9A245C5B}.Release|Any CPU.ActiveCfg = Release|Any CPU + {48B5E0BE-81C3-4ECF-85C1-B22D9A245C5B}.Release|Any CPU.Build.0 = Release|Any CPU + {A31878C3-8079-4A4E-AAAE-1000C5F34122}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {A31878C3-8079-4A4E-AAAE-1000C5F34122}.Debug|Any CPU.Build.0 = Debug|Any CPU + {A31878C3-8079-4A4E-AAAE-1000C5F34122}.Release|Any CPU.ActiveCfg = Release|Any CPU + {A31878C3-8079-4A4E-AAAE-1000C5F34122}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE @@ -1180,6 +1192,8 @@ Global {F40DF11A-354C-4438-B674-B95B9A401C33} = {C8F02DB9-5FEA-46C8-95E3-BB4255CB0667} {CE15C7EC-85CA-44B8-B13B-206E308E8EF8} = {C8F02DB9-5FEA-46C8-95E3-BB4255CB0667} {42510FFD-04F5-4580-9B02-9CBA260718DC} = {C8F02DB9-5FEA-46C8-95E3-BB4255CB0667} + {48B5E0BE-81C3-4ECF-85C1-B22D9A245C5B} = {14C7B928-9D6C-441A-8A1F-0C49173E73EB} + {A31878C3-8079-4A4E-AAAE-1000C5F34122} = {14C7B928-9D6C-441A-8A1F-0C49173E73EB} EndGlobalSection GlobalSection(ExtensibilityGlobals) = postSolution SolutionGuid = {A5F55604-2FF3-43B7-B657-4F18E6E95D3B} diff --git a/Sample/HotelManagement/Reservations/Guests/Guest.cs b/Sample/HotelManagement/Reservations/Guests/Guest.cs index 120d58202..aa05c7e69 100644 --- a/Sample/HotelManagement/Reservations/Guests/Guest.cs +++ b/Sample/HotelManagement/Reservations/Guests/Guest.cs @@ -5,7 +5,7 @@ namespace Reservations.Guests; public record GuestExternalId(string Value) { public static GuestExternalId FromPrefix(string prefix, string externalId) => - new($"{prefix.AssertNotEmpty()}/{externalId.AssertNotEmpty()}"); + new($"{prefix.NotEmpty()}/{externalId.NotEmpty()}"); } public record GuestId(string Value); diff --git a/Sample/HotelManagement/Reservations/RoomReservations/GettingRoomTypeAvailability/GetRoomTypeAvailabilityForPeriod.cs b/Sample/HotelManagement/Reservations/RoomReservations/GettingRoomTypeAvailability/GetRoomTypeAvailabilityForPeriod.cs index 5a2cc2e6f..7ec53d029 100644 --- a/Sample/HotelManagement/Reservations/RoomReservations/GettingRoomTypeAvailability/GetRoomTypeAvailabilityForPeriod.cs +++ b/Sample/HotelManagement/Reservations/RoomReservations/GettingRoomTypeAvailability/GetRoomTypeAvailabilityForPeriod.cs @@ -15,9 +15,9 @@ public static GetRoomTypeAvailabilityForPeriod Of( DateOnly to ) => new( - roomType.AssertNotEmpty(), - from.AssertNotEmpty(), - to.AssertNotEmpty().AssertGreaterOrEqualThan(from) + roomType.NotEmpty(), + from.NotEmpty(), + to.NotEmpty().GreaterOrEqualThan(from) ); } diff --git a/Sample/HotelManagement/Reservations/RoomReservations/ReservingRoom/ReserveRoom.cs b/Sample/HotelManagement/Reservations/RoomReservations/ReservingRoom/ReserveRoom.cs index 6066977c8..c52b40f08 100644 --- a/Sample/HotelManagement/Reservations/RoomReservations/ReservingRoom/ReserveRoom.cs +++ b/Sample/HotelManagement/Reservations/RoomReservations/ReservingRoom/ReserveRoom.cs @@ -50,13 +50,13 @@ public static ReserveRoom FromApi( IReadOnlyList dailyAvailability ) => new( - id.AssertNotEmpty(), - roomType.AssertNotEmpty(), - from.AssertNotEmpty(), - to.AssertNotEmpty().AssertGreaterOrEqualThan(from), - guestId.AssertNotEmpty(), - numberOfPeople.AssertNotEmpty(), - now.AssertNotEmpty(), + id.NotEmpty(), + roomType.NotEmpty(), + from.NotEmpty(), + to.NotEmpty().GreaterOrEqualThan(from), + guestId.NotEmpty(), + numberOfPeople.NotEmpty(), + now.NotEmpty(), ReservationSource.Api, dailyAvailability, null @@ -73,15 +73,15 @@ public static ReserveRoom FromExternal( DateTimeOffset now ) => new( - id.AssertNotEmpty(), - roomType.AssertNotEmpty(), - from.AssertNotEmpty(), - to.AssertNotEmpty().AssertGreaterOrEqualThan(from), - guestId.AssertNotEmpty(), - numberOfPeople.AssertNotEmpty(), - now.AssertNotEmpty(), + id.NotEmpty(), + roomType.NotEmpty(), + from.NotEmpty(), + to.NotEmpty().GreaterOrEqualThan(from), + guestId.NotEmpty(), + numberOfPeople.NotEmpty(), + now.NotEmpty(), ReservationSource.External, Array.Empty(), - externalId.AssertNotEmpty() + externalId.NotEmpty() ); } diff --git a/Sample/MeetingsManagement/MeetingsSearch.IntegrationTests/MeetingsSearch.IntegrationTests.csproj b/Sample/MeetingsManagement/MeetingsSearch.IntegrationTests/MeetingsSearch.IntegrationTests.csproj index ef1818740..8b0256c93 100644 --- a/Sample/MeetingsManagement/MeetingsSearch.IntegrationTests/MeetingsSearch.IntegrationTests.csproj +++ b/Sample/MeetingsManagement/MeetingsSearch.IntegrationTests/MeetingsSearch.IntegrationTests.csproj @@ -1,5 +1,4 @@  - net8.0 diff --git a/Workshops/IntroductionToEventSourcing/08-ApplicationLogic.Marten.Tests/08-ApplicationLogic.Marten.Tests.csproj b/Workshops/IntroductionToEventSourcing/08-ApplicationLogic.Marten.Tests/08-ApplicationLogic.Marten.Tests.csproj new file mode 100644 index 000000000..6a1f4ff9e --- /dev/null +++ b/Workshops/IntroductionToEventSourcing/08-ApplicationLogic.Marten.Tests/08-ApplicationLogic.Marten.Tests.csproj @@ -0,0 +1,32 @@ + + + + net8.0 + ApplicationLogic.Marten.Tests + ApplicationLogic.Marten.Tests + + + + + + + + + + + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + + + + + + diff --git a/Workshops/IntroductionToEventSourcing/08-ApplicationLogic.Marten.Tests/Incidents/AddProductItemToShoppingCartTests.cs b/Workshops/IntroductionToEventSourcing/08-ApplicationLogic.Marten.Tests/Incidents/AddProductItemToShoppingCartTests.cs new file mode 100644 index 000000000..2693b9ae4 --- /dev/null +++ b/Workshops/IntroductionToEventSourcing/08-ApplicationLogic.Marten.Tests/Incidents/AddProductItemToShoppingCartTests.cs @@ -0,0 +1,107 @@ +using System.Net; +using ApplicationLogic.Marten.Immutable.ShoppingCarts; +using Bogus; +using Ogooreck.API; +using Xunit; +using static Ogooreck.API.ApiSpecification; +using static ApplicationLogic.Marten.Tests.Incidents.Scenarios; +using static ApplicationLogic.Marten.Tests.Incidents.Fixtures; + +namespace ApplicationLogic.Marten.Tests.Incidents; + +public class AddProductItemToShoppingCartTests(ApiSpecification api): + IClassFixture> +{ + [Theory] + [InlineData("immutable")] + [InlineData("mutable")] + [InlineData("mixed")] + public Task CantAddProductItemToNotExistingShoppingCart(string apiPrefix) => + api.Given() + .When( + POST, + URI(ShoppingCartProductItems(apiPrefix, ClientId, NotExistingShoppingCartId)), + BODY(new AddProductRequest(ProductItem)) + ) + .Then(NOT_FOUND); + + [Theory] + [InlineData("immutable")] + [InlineData("mutable")] + [InlineData("mixed")] + public Task AddsProductItemToEmptyShoppingCart(string apiPrefix) => + api.Given(OpenedShoppingCart(apiPrefix, ClientId)) + .When( + POST, + URI(ctx => ShoppingCartProductItems(apiPrefix, ClientId, ctx.GetCreatedId())), + BODY(new AddProductRequest(ProductItem)) + ) + .Then(NO_CONTENT); + + [Theory] + [InlineData("immutable")] + [InlineData("mutable")] + [InlineData("mixed")] + public Task AddsProductItemToNonEmptyShoppingCart(string apiPrefix) => + api.Given( + OpenedShoppingCart(apiPrefix, ClientId), + WithProductItem(apiPrefix, ClientId, ProductItem) + ) + .When( + POST, + URI(ctx => ShoppingCartProductItems(apiPrefix, ClientId, ctx.GetCreatedId())), + BODY(new AddProductRequest(ProductItem)) + ) + .Then(NO_CONTENT); + + [Theory] + [InlineData("immutable")] + [InlineData("mutable")] + [InlineData("mixed")] + public Task CantAddProductItemToConfirmedShoppingCart(string apiPrefix) => + api.Given( + OpenedShoppingCart(apiPrefix, ClientId), + WithProductItem(apiPrefix, ClientId, ProductItem), + ThenConfirmed(apiPrefix, ClientId) + ) + .When( + POST, + URI(ctx => ShoppingCartProductItems(apiPrefix, ClientId, ctx.GetCreatedId())), + BODY(new AddProductRequest(ProductItem)) + ) + .Then(CONFLICT); + + [Theory] + [InlineData("immutable")] + [InlineData("mutable")] + [InlineData("mixed")] + public Task CantAddProductItemToCanceledShoppingCart(string apiPrefix) => + api.Given( + OpenedShoppingCart(apiPrefix, ClientId), + WithProductItem(apiPrefix, ClientId, ProductItem), + ThenCanceled(apiPrefix, ClientId) + ) + .When( + POST, + URI(ctx => ShoppingCartProductItems(apiPrefix, ClientId, ctx.GetCreatedId())), + BODY(new AddProductRequest(ProductItem)) + ) + .Then(CONFLICT); + + [Theory] + [InlineData("immutable")] + [InlineData("mutable")] + [InlineData("mixed")] + public Task ReturnsNonEmptyShoppingCart(string apiPrefix) => + api.Given( + OpenedShoppingCart(apiPrefix, ClientId), + WithProductItem(apiPrefix, ClientId, ProductItem) + ) + .When(GET, URI(ctx => ShoppingCart(apiPrefix, ClientId, ctx.GetCreatedId()))) + .Then(OK); + + private static readonly Faker Faker = new(); + private readonly Guid NotExistingShoppingCartId = Guid.NewGuid(); + private readonly Guid ClientId = Guid.NewGuid(); + private readonly ProductItemRequest ProductItem = new(Guid.NewGuid(), Faker.Random.Number(1, 500)); +} diff --git a/Workshops/IntroductionToEventSourcing/08-ApplicationLogic.Marten.Tests/Incidents/CancelShoppingCartTests.cs b/Workshops/IntroductionToEventSourcing/08-ApplicationLogic.Marten.Tests/Incidents/CancelShoppingCartTests.cs new file mode 100644 index 000000000..8224ded90 --- /dev/null +++ b/Workshops/IntroductionToEventSourcing/08-ApplicationLogic.Marten.Tests/Incidents/CancelShoppingCartTests.cs @@ -0,0 +1,91 @@ +using System.Net; +using ApplicationLogic.Marten.Immutable.ShoppingCarts; +using Bogus; +using Ogooreck.API; +using Xunit; +using static Ogooreck.API.ApiSpecification; +using static ApplicationLogic.Marten.Tests.Incidents.Scenarios; +using static ApplicationLogic.Marten.Tests.Incidents.Fixtures; + +namespace ApplicationLogic.Marten.Tests.Incidents; + +public class CancelShoppingCartTests(ApiSpecification api): + IClassFixture> +{ + [Theory] + [InlineData("immutable")] + [InlineData("mutable")] + [InlineData("mixed")] + public Task CantCancelNotExistingShoppingCart(string apiPrefix) => + api.Given() + .When( + DELETE, + URI(ShoppingCart(apiPrefix, ClientId, NotExistingShoppingCartId)) + ) + .Then(NOT_FOUND); + + [Theory] + [InlineData("immutable")] + [InlineData("mutable")] + [InlineData("mixed")] + public Task CancelsNonEmptyShoppingCart(string apiPrefix) => + api.Given( + OpenedShoppingCart(apiPrefix, ClientId), + WithProductItem(apiPrefix, ClientId, ProductItem) + ) + .When( + DELETE, + URI(ctx => ShoppingCart(apiPrefix, ClientId, ctx.GetCreatedId())) + ) + .Then(NO_CONTENT); + + [Theory] + [InlineData("immutable")] + [InlineData("mutable")] + [InlineData("mixed")] + public Task CantCancelAlreadyCanceledShoppingCart(string apiPrefix) => + api.Given( + OpenedShoppingCart(apiPrefix, ClientId), + WithProductItem(apiPrefix, ClientId, ProductItem), + ThenCanceled(apiPrefix, ClientId) + ) + .When( + DELETE, + URI(ctx => ShoppingCart(apiPrefix, ClientId, ctx.GetCreatedId())) + ) + .Then(CONFLICT); + + [Theory] + [InlineData("immutable")] + [InlineData("mutable")] + [InlineData("mixed")] + public Task CantCancelConfirmedShoppingCart(string apiPrefix) => + api.Given( + OpenedShoppingCart(apiPrefix, ClientId), + WithProductItem(apiPrefix, ClientId, ProductItem), + ThenConfirmed(apiPrefix, ClientId) + ) + .When( + DELETE, + URI(ctx => ShoppingCart(apiPrefix, ClientId, ctx.GetCreatedId())) + ) + .Then(CONFLICT); + + [Theory] + [InlineData("immutable")] + [InlineData("mutable")] + [InlineData("mixed")] + public Task ReturnsNonEmptyShoppingCart(string apiPrefix) => + api.Given( + OpenedShoppingCart(apiPrefix, ClientId), + WithProductItem(apiPrefix, ClientId, ProductItem), + ThenCanceled(apiPrefix, ClientId) + ) + .When(GET, URI(ctx => ShoppingCart(apiPrefix, ClientId, ctx.GetCreatedId()))) + .Then(OK); + + private static readonly Faker Faker = new(); + private readonly Guid NotExistingShoppingCartId = Guid.NewGuid(); + private readonly Guid ClientId = Guid.NewGuid(); + private readonly ProductItemRequest ProductItem = new(Guid.NewGuid(), Faker.Random.Number(1, 500)); +} diff --git a/Workshops/IntroductionToEventSourcing/08-ApplicationLogic.Marten.Tests/Incidents/ConfirmShoppingCartTests.cs b/Workshops/IntroductionToEventSourcing/08-ApplicationLogic.Marten.Tests/Incidents/ConfirmShoppingCartTests.cs new file mode 100644 index 000000000..1bcb97343 --- /dev/null +++ b/Workshops/IntroductionToEventSourcing/08-ApplicationLogic.Marten.Tests/Incidents/ConfirmShoppingCartTests.cs @@ -0,0 +1,103 @@ +using System.Net; +using ApplicationLogic.Marten.Immutable.ShoppingCarts; +using Bogus; +using Ogooreck.API; +using Xunit; +using static Ogooreck.API.ApiSpecification; +using static ApplicationLogic.Marten.Tests.Incidents.Scenarios; +using static ApplicationLogic.Marten.Tests.Incidents.Fixtures; + +namespace ApplicationLogic.Marten.Tests.Incidents; + +public class ConfirmShoppingCartTests(ApiSpecification api): + IClassFixture> +{ + [Theory] + [InlineData("immutable")] + [InlineData("mutable")] + [InlineData("mixed")] + public Task CantConfirmNotExistingShoppingCart(string apiPrefix) => + api.Given() + .When( + POST, + URI(ConfirmShoppingCart(apiPrefix, ClientId, NotExistingShoppingCartId)) + ) + .Then(NOT_FOUND); + + [Theory] + [InlineData("immutable")] + [InlineData("mutable")] + [InlineData("mixed")] + public Task CantConfirmEmptyShoppingCart(string apiPrefix) => + api.Given(OpenedShoppingCart(apiPrefix, ClientId)) + .When( + POST, + URI(ctx => ConfirmShoppingCart(apiPrefix, ClientId, ctx.GetCreatedId())) + ) + .Then(CONFLICT); + + [Theory] + [InlineData("immutable")] + [InlineData("mutable")] + [InlineData("mixed")] + public Task ConfirmsNonEmptyShoppingCart(string apiPrefix) => + api.Given( + OpenedShoppingCart(apiPrefix, ClientId), + WithProductItem(apiPrefix, ClientId, ProductItem) + ) + .When( + POST, + URI(ctx => ConfirmShoppingCart(apiPrefix, ClientId, ctx.GetCreatedId())) + ) + .Then(NO_CONTENT); + + [Theory] + [InlineData("immutable")] + [InlineData("mutable")] + [InlineData("mixed")] + public Task CantConfirmAlreadyConfirmedShoppingCart(string apiPrefix) => + api.Given( + OpenedShoppingCart(apiPrefix, ClientId), + WithProductItem(apiPrefix, ClientId, ProductItem), + ThenConfirmed(apiPrefix, ClientId) + ) + .When( + POST, + URI(ctx => ConfirmShoppingCart(apiPrefix, ClientId, ctx.GetCreatedId())) + ) + .Then(CONFLICT); + + [Theory] + [InlineData("immutable")] + [InlineData("mutable")] + [InlineData("mixed")] + public Task CantConfirmCanceledShoppingCart(string apiPrefix) => + api.Given( + OpenedShoppingCart(apiPrefix, ClientId), + WithProductItem(apiPrefix, ClientId, ProductItem), + ThenCanceled(apiPrefix, ClientId) + ) + .When( + POST, + URI(ctx => ConfirmShoppingCart(apiPrefix, ClientId, ctx.GetCreatedId())) + ) + .Then(CONFLICT); + + [Theory] + [InlineData("immutable")] + [InlineData("mutable")] + [InlineData("mixed")] + public Task ReturnsNonEmptyShoppingCart(string apiPrefix) => + api.Given( + OpenedShoppingCart(apiPrefix, ClientId), + WithProductItem(apiPrefix, ClientId, ProductItem), + ThenConfirmed(apiPrefix, ClientId) + ) + .When(GET, URI(ctx => ShoppingCart(apiPrefix, ClientId, ctx.GetCreatedId()))) + .Then(OK); + + private static readonly Faker Faker = new(); + private readonly Guid NotExistingShoppingCartId = Guid.NewGuid(); + private readonly Guid ClientId = Guid.NewGuid(); + private readonly ProductItemRequest ProductItem = new(Guid.NewGuid(), Faker.Random.Number(1, 500)); +} diff --git a/Workshops/IntroductionToEventSourcing/08-ApplicationLogic.Marten.Tests/Incidents/Fixtures.cs b/Workshops/IntroductionToEventSourcing/08-ApplicationLogic.Marten.Tests/Incidents/Fixtures.cs new file mode 100644 index 000000000..f15700bcc --- /dev/null +++ b/Workshops/IntroductionToEventSourcing/08-ApplicationLogic.Marten.Tests/Incidents/Fixtures.cs @@ -0,0 +1,51 @@ +using ApplicationLogic.Marten.Immutable.ShoppingCarts; +using Ogooreck.API; +using static Ogooreck.API.ApiSpecification; + +namespace ApplicationLogic.Marten.Tests.Incidents; + +public static class Fixtures +{ + public static string Clients(string apiPrefix, Guid clientId) => $"/api/{apiPrefix}/clients/{clientId}/"; + + public static string ShoppingCarts(string apiPrefix, Guid clientId) => + $"{Clients(apiPrefix, clientId)}shopping-carts/"; + + public static string ShoppingCart(string apiPrefix, Guid clientId, Guid shoppingCartId) => + $"{ShoppingCarts(apiPrefix, clientId)}{shoppingCartId}/"; + + public static string ConfirmShoppingCart(string apiPrefix, Guid clientId, Guid shoppingCartId) => + $"{ShoppingCart(apiPrefix, clientId, shoppingCartId)}confirm"; + + public static string ShoppingCartProductItems(string apiPrefix, Guid clientId, Guid shoppingCartId) => + $"{ShoppingCart(apiPrefix, clientId, shoppingCartId)}product-items/"; + + public static string ShoppingCartProductItem(string apiPrefix, Guid clientId, Guid shoppingCartId, Guid productId, + int quantity = 1, decimal unitPrice = 100) => + $"{ShoppingCartProductItems(apiPrefix, clientId, shoppingCartId)}{productId}?unitPrice={unitPrice}&quantity={quantity}"; +} + +public static class Scenarios +{ + public static RequestDefinition OpenedShoppingCart(string apiPrefix, Guid clientId) => + SEND(POST, URI(Fixtures.ShoppingCarts(apiPrefix, clientId))); + + public static RequestDefinition WithProductItem(string apiPrefix, Guid clientId, ProductItemRequest productItem) => + SEND( + POST, + URI(ctx => Fixtures.ShoppingCartProductItems(apiPrefix, clientId, ctx.GetCreatedId())), + BODY(new AddProductRequest(productItem)) + ); + + public static RequestDefinition ThenConfirmed(string apiPrefix, Guid clientId) => + SEND( + POST, + URI(ctx => Fixtures.ConfirmShoppingCart(apiPrefix, clientId, ctx.GetCreatedId())) + ); + + public static RequestDefinition ThenCanceled(string apiPrefix, Guid clientId) => + SEND( + DELETE, + URI(ctx => Fixtures.ShoppingCart(apiPrefix, clientId, ctx.GetCreatedId())) + ); +} diff --git a/Workshops/IntroductionToEventSourcing/08-ApplicationLogic.Marten.Tests/Incidents/OpenShoppingCartTests.cs b/Workshops/IntroductionToEventSourcing/08-ApplicationLogic.Marten.Tests/Incidents/OpenShoppingCartTests.cs new file mode 100644 index 000000000..fb8f7778a --- /dev/null +++ b/Workshops/IntroductionToEventSourcing/08-ApplicationLogic.Marten.Tests/Incidents/OpenShoppingCartTests.cs @@ -0,0 +1,31 @@ +using Ogooreck.API; +using Xunit; +using static Ogooreck.API.ApiSpecification; +using static ApplicationLogic.Marten.Tests.Incidents.Scenarios; +using static ApplicationLogic.Marten.Tests.Incidents.Fixtures; + +namespace ApplicationLogic.Marten.Tests.Incidents; + +public class OpenShoppingCartTests(ApiSpecification api): + IClassFixture> +{ + [Theory] + [InlineData("immutable")] + [InlineData("mutable")] + [InlineData("mixed")] + public Task OpensShoppingCart(string apiPrefix) => + api.Given() + .When(POST, URI(ShoppingCarts(apiPrefix, ClientId))) + .Then(CREATED_WITH_DEFAULT_HEADERS(locationHeaderPrefix: ShoppingCarts(apiPrefix, ClientId))); + + [Theory] + [InlineData("immutable")] + [InlineData("mutable")] + [InlineData("mixed")] + public Task ReturnsOpenedShoppingCart(string apiPrefix) => + api.Given(OpenedShoppingCart(apiPrefix, ClientId)) + .When(GET, URI(ctx => ShoppingCart(apiPrefix, ClientId, ctx.GetCreatedId()))) + .Then(OK); + + private readonly Guid ClientId = Guid.NewGuid(); +} diff --git a/Workshops/IntroductionToEventSourcing/08-ApplicationLogic.Marten.Tests/Incidents/RemoveProductItemFromShoppingCartTests.cs b/Workshops/IntroductionToEventSourcing/08-ApplicationLogic.Marten.Tests/Incidents/RemoveProductItemFromShoppingCartTests.cs new file mode 100644 index 000000000..6b1503465 --- /dev/null +++ b/Workshops/IntroductionToEventSourcing/08-ApplicationLogic.Marten.Tests/Incidents/RemoveProductItemFromShoppingCartTests.cs @@ -0,0 +1,118 @@ +using System.Net; +using ApplicationLogic.Marten.Immutable.ShoppingCarts; +using Bogus; +using Ogooreck.API; +using Xunit; +using static Ogooreck.API.ApiSpecification; +using static ApplicationLogic.Marten.Tests.Incidents.Scenarios; +using static ApplicationLogic.Marten.Tests.Incidents.Fixtures; + +namespace ApplicationLogic.Marten.Tests.Incidents; + +public class RemoveProductItemFromShoppingCartTests(ApiSpecification api): + IClassFixture> +{ + [Theory] + [InlineData("immutable")] + [InlineData("mutable")] + [InlineData("mixed")] + public Task CantRemoveProductItemFromNotExistingShoppingCart(string apiPrefix) => + api.Given() + .When( + DELETE, + URI(ShoppingCartProductItem(apiPrefix, ClientId, NotExistingShoppingCartId, ProductItem.ProductId!.Value)) + ) + .Then(NOT_FOUND); + + [Theory] + [InlineData("immutable")] + [InlineData("mutable")] + [InlineData("mixed")] + public Task CantRemoveProductItemFromEmptyShoppingCart(string apiPrefix) => + api.Given(OpenedShoppingCart(apiPrefix, ClientId)) + .When( + DELETE, + URI(ctx => ShoppingCartProductItem(apiPrefix, ClientId, ctx.GetCreatedId(), ProductItem.ProductId!.Value)) + ) + .Then(CONFLICT); + + [Theory] + [InlineData("immutable")] + [InlineData("mutable")] + [InlineData("mixed")] + public Task CanRemoveExistingProductItemFromShoppingCart(string apiPrefix) => + api.Given( + OpenedShoppingCart(apiPrefix, ClientId), + WithProductItem(apiPrefix, ClientId, ProductItem) + ) + .When( + DELETE, + URI(ctx => ShoppingCartProductItem(apiPrefix, ClientId, ctx.GetCreatedId(), ProductItem.ProductId!.Value)) + ) + .Then(NO_CONTENT); + + [Theory] + [InlineData("immutable")] + [InlineData("mutable")] + [InlineData("mixed")] + public Task CantRemoveNonExistingProductItemFromEmptyShoppingCart(string apiPrefix) => + api.Given( + OpenedShoppingCart(apiPrefix, ClientId), + WithProductItem(apiPrefix, ClientId, ProductItem) + ) + .When( + DELETE, + URI(ctx => ShoppingCartProductItem(apiPrefix, ClientId, ctx.GetCreatedId(), NotExistingProductItem.ProductId!.Value)) + ) + .Then(CONFLICT); + + [Theory] + [InlineData("immutable")] + [InlineData("mutable")] + [InlineData("mixed")] + public Task CantRemoveExistingProductItemFromCanceledShoppingCart(string apiPrefix) => + api.Given( + OpenedShoppingCart(apiPrefix, ClientId), + WithProductItem(apiPrefix, ClientId, ProductItem), + ThenCanceled(apiPrefix, ClientId) + ) + .When( + DELETE, + URI(ctx => ShoppingCartProductItem(apiPrefix, ClientId, ctx.GetCreatedId(), ProductItem.ProductId!.Value)) + ) + .Then(CONFLICT); + + [Theory] + [InlineData("immutable")] + [InlineData("mutable")] + [InlineData("mixed")] + public Task CantRemoveExistingProductItemFromConfirmedShoppingCart(string apiPrefix) => + api.Given( + OpenedShoppingCart(apiPrefix, ClientId), + WithProductItem(apiPrefix, ClientId, ProductItem), + ThenConfirmed(apiPrefix, ClientId) + ) + .When( + DELETE, + URI(ctx => ShoppingCartProductItem(apiPrefix, ClientId, ctx.GetCreatedId(), ProductItem.ProductId!.Value)) + ) + .Then(CONFLICT); + + [Theory] + [InlineData("immutable")] + [InlineData("mutable")] + [InlineData("mixed")] + public Task ReturnsNonEmptyShoppingCart(string apiPrefix) => + api.Given( + OpenedShoppingCart(apiPrefix, ClientId), + WithProductItem(apiPrefix, ClientId, ProductItem) + ) + .When(GET, URI(ctx => ShoppingCart(apiPrefix, ClientId, ctx.GetCreatedId()))) + .Then(OK); + + private static readonly Faker Faker = new(); + private readonly Guid NotExistingShoppingCartId = Guid.NewGuid(); + private readonly Guid ClientId = Guid.NewGuid(); + private readonly ProductItemRequest ProductItem = new(Guid.NewGuid(), Faker.Random.Number(1, 500)); + private readonly ProductItemRequest NotExistingProductItem = new(Guid.NewGuid(), 1); +} diff --git a/Workshops/IntroductionToEventSourcing/08-ApplicationLogic.Marten.Tests/Settings.cs b/Workshops/IntroductionToEventSourcing/08-ApplicationLogic.Marten.Tests/Settings.cs new file mode 100644 index 000000000..68bc06e08 --- /dev/null +++ b/Workshops/IntroductionToEventSourcing/08-ApplicationLogic.Marten.Tests/Settings.cs @@ -0,0 +1,19 @@ +using Oakton; +using Xunit; +using Xunit.Abstractions; +using Xunit.Sdk; + +[assembly: CollectionBehavior(DisableTestParallelization = true)] + +[assembly: TestFramework("ApplicationLogic.Marten.Tests.AssemblyFixture", "ApplicationLogic.Marten.Tests")] + +namespace ApplicationLogic.Marten.Tests; + +public sealed class AssemblyFixture : XunitTestFramework +{ + public AssemblyFixture(IMessageSink messageSink) + :base(messageSink) + { + OaktonEnvironment.AutoStartHost = true; + } +} diff --git a/Workshops/IntroductionToEventSourcing/08-ApplicationLogic.Marten/08-ApplicationLogic.Marten.csproj b/Workshops/IntroductionToEventSourcing/08-ApplicationLogic.Marten/08-ApplicationLogic.Marten.csproj new file mode 100644 index 000000000..9e9376a6e --- /dev/null +++ b/Workshops/IntroductionToEventSourcing/08-ApplicationLogic.Marten/08-ApplicationLogic.Marten.csproj @@ -0,0 +1,21 @@ + + + + net8.0 + enable + true + enable + ApplicationLogic.Marten + + + + + + + + + + + + + diff --git a/Workshops/IntroductionToEventSourcing/08-ApplicationLogic.Marten/Core/Entities/Aggregate.cs b/Workshops/IntroductionToEventSourcing/08-ApplicationLogic.Marten/Core/Entities/Aggregate.cs new file mode 100644 index 000000000..d5d146210 --- /dev/null +++ b/Workshops/IntroductionToEventSourcing/08-ApplicationLogic.Marten/Core/Entities/Aggregate.cs @@ -0,0 +1,39 @@ +namespace ApplicationLogic.Marten.Core.Entities; + +public interface IAggregate +{ + public void Apply(object @event); + + object[] DequeueUncommittedEvents(); +} + +public abstract class Aggregate: IAggregate +{ + public Guid Id { get; protected set; } = default!; + + private readonly Queue uncommittedEvents = new(); + + protected virtual void Apply(TEvent @event) { } + + public object[] DequeueUncommittedEvents() + { + var dequeuedEvents = uncommittedEvents.ToArray(); + + uncommittedEvents.Clear(); + + return dequeuedEvents; + } + + protected void Enqueue(object @event) + { + uncommittedEvents.Enqueue(@event); + } + + public void Apply(object @event) + { + if(@event is not TEvent typed) + return; + + Apply(typed); + } +} diff --git a/Workshops/IntroductionToEventSourcing/08-ApplicationLogic.Marten/Core/Exceptions/NotFoundException.cs b/Workshops/IntroductionToEventSourcing/08-ApplicationLogic.Marten/Core/Exceptions/NotFoundException.cs new file mode 100644 index 000000000..77586df02 --- /dev/null +++ b/Workshops/IntroductionToEventSourcing/08-ApplicationLogic.Marten/Core/Exceptions/NotFoundException.cs @@ -0,0 +1,13 @@ +namespace ApplicationLogic.Marten.Core.Exceptions; + +public class NotFoundException: Exception +{ + private NotFoundException(string typeName, string id): base($"{typeName} with id '{id}' was not found") + { + } + + public static NotFoundException For(Guid id) => + For(id.ToString()); + + public static NotFoundException For(string id) => new(typeof(T).Name, id); +} diff --git a/Workshops/IntroductionToEventSourcing/08-ApplicationLogic.Marten/Core/Http/ETagExtensions.cs b/Workshops/IntroductionToEventSourcing/08-ApplicationLogic.Marten/Core/Http/ETagExtensions.cs new file mode 100644 index 000000000..51bf3a7a7 --- /dev/null +++ b/Workshops/IntroductionToEventSourcing/08-ApplicationLogic.Marten/Core/Http/ETagExtensions.cs @@ -0,0 +1,29 @@ +using Microsoft.AspNetCore.Mvc; +using Microsoft.Net.Http.Headers; + +namespace ApplicationLogic.Marten.Core.Http; + +public static class ETagExtensions +{ + public static int ToExpectedVersion(string? eTag) + { + if (eTag is null) + throw new ArgumentNullException(nameof(eTag)); + + var value = EntityTagHeaderValue.Parse(eTag).Tag.Value; + + if (value is null) + throw new ArgumentNullException(nameof(eTag)); + + return int.Parse(value.Substring(1, value.Length - 2)); + } +} + +[AttributeUsage(AttributeTargets.Parameter | AttributeTargets.Property, AllowMultiple = false, Inherited = true)] +public class FromIfMatchHeaderAttribute: FromHeaderAttribute +{ + public FromIfMatchHeaderAttribute() + { + Name = "If-Match"; + } +} diff --git a/Workshops/IntroductionToEventSourcing/08-ApplicationLogic.Marten/Core/Marten/DocumentSessionExtensions.cs b/Workshops/IntroductionToEventSourcing/08-ApplicationLogic.Marten/Core/Marten/DocumentSessionExtensions.cs new file mode 100644 index 000000000..d51da9ae2 --- /dev/null +++ b/Workshops/IntroductionToEventSourcing/08-ApplicationLogic.Marten/Core/Marten/DocumentSessionExtensions.cs @@ -0,0 +1,44 @@ +using ApplicationLogic.Marten.Core.Entities; +using ApplicationLogic.Marten.Core.Exceptions; +using Marten; + +namespace ApplicationLogic.Marten.Core.Marten; + +public static class DocumentSessionExtensions +{ + public static Task Add(this IDocumentSession documentSession, Guid id, object @event, CancellationToken ct) + where T : class + { + documentSession.Events.StartStream(id, @event); + return documentSession.SaveChangesAsync(token: ct); + } + + public static Task Add(this IDocumentSession documentSession, Guid id, object[] events, CancellationToken ct) + where T : class + { + documentSession.Events.StartStream(id, events); + return documentSession.SaveChangesAsync(token: ct); + } + + public static Task GetAndUpdate( + this IDocumentSession documentSession, + Guid id, + Func handle, + CancellationToken ct + ) where T : class => + documentSession.Events.WriteToAggregate(id, stream => + stream.AppendMany(handle(stream.Aggregate ?? throw NotFoundException.For(id))), ct); + + public static Task GetAndUpdate( + this IDocumentSession documentSession, + Guid id, + Action handle, + CancellationToken ct + ) where T : class, IAggregate => + documentSession.Events.WriteToAggregate(id, stream => + { + var aggregate = stream.Aggregate ?? throw NotFoundException.For(id); + handle(aggregate); + stream.AppendMany(aggregate.DequeueUncommittedEvents()); + }, ct); +} diff --git a/Workshops/IntroductionToEventSourcing/08-ApplicationLogic.Marten/Core/Marten/EventMappings.cs b/Workshops/IntroductionToEventSourcing/08-ApplicationLogic.Marten/Core/Marten/EventMappings.cs new file mode 100644 index 000000000..53865724d --- /dev/null +++ b/Workshops/IntroductionToEventSourcing/08-ApplicationLogic.Marten/Core/Marten/EventMappings.cs @@ -0,0 +1,12 @@ +using Marten; + +namespace ApplicationLogic.Marten.Core.Marten; + +public static class EventMappings +{ + public static StoreOptions MapEventWithPrefix(this StoreOptions options, string prefix) where T : class + { + options.Events.MapEventType($"{prefix}-${typeof(T).FullName}"); + return options; + } +} diff --git a/Workshops/IntroductionToEventSourcing/08-ApplicationLogic.Marten/Immutable/Pricing/PricingCalculator.cs b/Workshops/IntroductionToEventSourcing/08-ApplicationLogic.Marten/Immutable/Pricing/PricingCalculator.cs new file mode 100644 index 000000000..c999ee055 --- /dev/null +++ b/Workshops/IntroductionToEventSourcing/08-ApplicationLogic.Marten/Immutable/Pricing/PricingCalculator.cs @@ -0,0 +1,26 @@ +using ApplicationLogic.Marten.Immutable.ShoppingCarts; + +namespace ApplicationLogic.Marten.Immutable.Pricing; + +public interface IProductPriceCalculator +{ + PricedProductItem Calculate(ProductItem productItems); +} + +public class FakeProductPriceCalculator: IProductPriceCalculator +{ + private readonly int value; + + private FakeProductPriceCalculator(int value) + { + this.value = value; + } + + public static FakeProductPriceCalculator Returning(int value) => new(value); + + public PricedProductItem Calculate(ProductItem productItem) + { + var (productId, quantity) = productItem; + return new PricedProductItem(productId, quantity, value); + } +} diff --git a/Workshops/IntroductionToEventSourcing/08-ApplicationLogic.Marten/Immutable/Pricing/ProductItems.cs b/Workshops/IntroductionToEventSourcing/08-ApplicationLogic.Marten/Immutable/Pricing/ProductItems.cs new file mode 100644 index 000000000..1fc2ef71e --- /dev/null +++ b/Workshops/IntroductionToEventSourcing/08-ApplicationLogic.Marten/Immutable/Pricing/ProductItems.cs @@ -0,0 +1,13 @@ +namespace ApplicationLogic.Marten.Immutable.Pricing; + +public record PricedProductItem( + Guid ProductId, + int Quantity, + decimal UnitPrice +) +{ + public decimal TotalPrice => Quantity * UnitPrice; +} + +public record ProductItem(Guid ProductId, int Quantity); + diff --git a/Workshops/IntroductionToEventSourcing/08-ApplicationLogic.Marten/Immutable/ShoppingCarts/Api.cs b/Workshops/IntroductionToEventSourcing/08-ApplicationLogic.Marten/Immutable/ShoppingCarts/Api.cs new file mode 100644 index 000000000..e027e0639 --- /dev/null +++ b/Workshops/IntroductionToEventSourcing/08-ApplicationLogic.Marten/Immutable/ShoppingCarts/Api.cs @@ -0,0 +1,133 @@ +using ApplicationLogic.Marten.Core.Marten; +using ApplicationLogic.Marten.Immutable.Pricing; +using Core.Validation; +using Marten; +using Marten.Schema.Identity; +using Microsoft.AspNetCore.Mvc; +using static Microsoft.AspNetCore.Http.TypedResults; +using static System.DateTimeOffset; + +namespace ApplicationLogic.Marten.Immutable.ShoppingCarts; + +using static ShoppingCartService; +using static ShoppingCartCommand; + +public static class Api +{ + public static WebApplication ConfigureImmutableShoppingCarts(this WebApplication app) + { + var clients = app.MapGroup("/api/immutable/clients/{clientId:guid}/"); + var shoppingCarts = clients.MapGroup("shopping-carts"); + var shoppingCart = shoppingCarts.MapGroup("{shoppingCartId:guid}"); + var productItems = shoppingCart.MapGroup("product-items"); + + shoppingCarts.MapPost("", + async (IDocumentSession session, + Guid clientId, + CancellationToken ct) => + { + var shoppingCartId = CombGuidIdGeneration.NewGuid(); + + await session.Add(shoppingCartId, + [Handle(new OpenShoppingCart(shoppingCartId, clientId.NotEmpty(), Now))], ct); + + return Created($"/api/immutable/clients/{clientId}/shopping-carts/{shoppingCartId}", shoppingCartId); + } + ); + + productItems.MapPost("", + async ( + IProductPriceCalculator pricingCalculator, + IDocumentSession session, + Guid shoppingCartId, + AddProductRequest body, + CancellationToken ct) => + { + var productItem = body.ProductItem.NotNull().ToProductItem(); + + await session.GetAndUpdate(shoppingCartId, + state => + [ + Handle(pricingCalculator, + new AddProductItemToShoppingCart(shoppingCartId, productItem, Now), + state) + ], ct); + + return NoContent(); + } + ); + + productItems.MapDelete("{productId:guid}", + async ( + IDocumentSession session, + Guid shoppingCartId, + [FromRoute] Guid productId, + [FromQuery] int? quantity, + [FromQuery] decimal? unitPrice, + CancellationToken ct) => + { + var productItem = new PricedProductItem( + productId.NotEmpty(), + quantity.NotNull().Positive(), + unitPrice.NotNull().Positive() + ); + + await session.GetAndUpdate(shoppingCartId, + state => [Handle(new RemoveProductItemFromShoppingCart(shoppingCartId, productItem, Now), state)], + ct); + + return NoContent(); + } + ); + + shoppingCart.MapPost("confirm", + async (IDocumentSession session, + Guid shoppingCartId, + CancellationToken ct) => + { + await session.GetAndUpdate(shoppingCartId, + state => [Handle(new ConfirmShoppingCart(shoppingCartId, Now), state)], ct); + + return NoContent(); + } + ); + + shoppingCart.MapDelete("", + async (IDocumentSession session, + Guid shoppingCartId, + CancellationToken ct) => + { + await session.GetAndUpdate(shoppingCartId, + state => [Handle(new CancelShoppingCart(shoppingCartId, Now), state)], ct); + + return NoContent(); + } + ); + + shoppingCart.MapGet("", + async Task ( + IQuerySession session, + Guid shoppingCartId, + CancellationToken ct) => + { + var result = await session.Events.AggregateStreamAsync(shoppingCartId, token: ct); + + return result is not null ? Ok(result) : NotFound(); + } + ); + + return app; + } +} + +public record ProductItemRequest( + Guid? ProductId, + int? Quantity +) +{ + public ProductItem ToProductItem() => new(ProductId.NotEmpty(), Quantity.NotNull().Positive()); +} + +public record AddProductRequest( + ProductItemRequest? ProductItem +); diff --git a/Workshops/IntroductionToEventSourcing/08-ApplicationLogic.Marten/Immutable/ShoppingCarts/Configure.cs b/Workshops/IntroductionToEventSourcing/08-ApplicationLogic.Marten/Immutable/ShoppingCarts/Configure.cs new file mode 100644 index 000000000..c79e79181 --- /dev/null +++ b/Workshops/IntroductionToEventSourcing/08-ApplicationLogic.Marten/Immutable/ShoppingCarts/Configure.cs @@ -0,0 +1,30 @@ +using ApplicationLogic.Marten.Core.Marten; +using ApplicationLogic.Marten.Immutable.Pricing; +using Marten; + +namespace ApplicationLogic.Marten.Immutable.ShoppingCarts; +using static ShoppingCartEvent; + +public static class Configure +{ + private const string ModulePrefix = "immutable"; + public static IServiceCollection AddImmutableShoppingCarts(this IServiceCollection services) + { + services.AddSingleton(FakeProductPriceCalculator.Returning(100)); + + return services; + } + public static StoreOptions ConfigureImmutableShoppingCarts(this StoreOptions options) + { + options.Projections.LiveStreamAggregation(); + + // this is needed as we're sharing document store and have event types with the same name + options.MapEventWithPrefix(ModulePrefix); + options.MapEventWithPrefix(ModulePrefix); + options.MapEventWithPrefix(ModulePrefix); + options.MapEventWithPrefix(ModulePrefix); + options.MapEventWithPrefix(ModulePrefix); + + return options; + } +} diff --git a/Workshops/IntroductionToEventSourcing/08-ApplicationLogic.Marten/Immutable/ShoppingCarts/ShoppingCart.cs b/Workshops/IntroductionToEventSourcing/08-ApplicationLogic.Marten/Immutable/ShoppingCarts/ShoppingCart.cs new file mode 100644 index 000000000..b8356e553 --- /dev/null +++ b/Workshops/IntroductionToEventSourcing/08-ApplicationLogic.Marten/Immutable/ShoppingCarts/ShoppingCart.cs @@ -0,0 +1,132 @@ +using ApplicationLogic.Marten.Immutable.Pricing; + +namespace ApplicationLogic.Marten.Immutable.ShoppingCarts; +using static ShoppingCartEvent; + +// EVENTS +public abstract record ShoppingCartEvent +{ + public record ShoppingCartOpened( + Guid ShoppingCartId, + Guid ClientId, + DateTimeOffset OpenedAt + ): ShoppingCartEvent; + + public record ProductItemAddedToShoppingCart( + Guid ShoppingCartId, + PricedProductItem ProductItem, + DateTimeOffset AddedAt + ): ShoppingCartEvent; + + public record ProductItemRemovedFromShoppingCart( + Guid ShoppingCartId, + PricedProductItem ProductItem, + DateTimeOffset RemovedAt + ): ShoppingCartEvent; + + public record ShoppingCartConfirmed( + Guid ShoppingCartId, + DateTimeOffset ConfirmedAt + ): ShoppingCartEvent; + + public record ShoppingCartCanceled( + Guid ShoppingCartId, + DateTimeOffset CanceledAt + ): ShoppingCartEvent; + + // This won't allow external inheritance + private ShoppingCartEvent(){} +} + +// ENTITY +public record ShoppingCart( + Guid Id, + Guid ClientId, + ShoppingCartStatus Status, + PricedProductItem[] ProductItems, + DateTimeOffset OpenedAt, + DateTimeOffset? ConfirmedAt = null, + DateTimeOffset? CanceledAt = null +) +{ + public bool IsClosed => ShoppingCartStatus.Closed.HasFlag(Status); + + public bool HasEnough(PricedProductItem productItem) + { + var (productId, quantity, _) = productItem; + var currentQuantity = ProductItems.Where(pi => pi.ProductId == productId) + .Select(pi => pi.Quantity) + .FirstOrDefault(); + + return currentQuantity >= quantity; + } + + public static ShoppingCart Default() => + new (default, default, default, [], default); + + public ShoppingCart Apply(ShoppingCartEvent @event) + { + return @event switch + { + ShoppingCartOpened(var shoppingCartId, var clientId, _) => + this with + { + Id = shoppingCartId, + ClientId = clientId, + Status = ShoppingCartStatus.Pending + }, + ProductItemAddedToShoppingCart(_, var pricedProductItem, _) => + this with + { + ProductItems = ProductItems + .Concat(new [] { pricedProductItem }) + .GroupBy(pi => pi.ProductId) + .Select(group => group.Count() == 1? + group.First() + : new PricedProductItem( + group.Key, + group.Sum(pi => pi.Quantity), + group.First().UnitPrice + ) + ) + .ToArray() + }, + ProductItemRemovedFromShoppingCart(_, var pricedProductItem, _) => + this with + { + ProductItems = ProductItems + .Select(pi => pi.ProductId == pricedProductItem.ProductId? + pi with { Quantity = pi.Quantity - pricedProductItem.Quantity } + :pi + ) + .Where(pi => pi.Quantity > 0) + .ToArray() + }, + ShoppingCartConfirmed(_, var confirmedAt) => + this with + { + Status = ShoppingCartStatus.Confirmed, + ConfirmedAt = confirmedAt + }, + ShoppingCartCanceled(_, var canceledAt) => + this with + { + Status = ShoppingCartStatus.Canceled, + CanceledAt = canceledAt + }, + _ => this + }; + } + + // let's make Marten happy + private ShoppingCart():this(Guid.Empty, Guid.Empty, ShoppingCartStatus.Pending, [], default){} +} + +public enum ShoppingCartStatus +{ + Pending = 1, + Confirmed = 2, + Canceled = 4, + + Closed = Confirmed | Canceled +} diff --git a/Workshops/IntroductionToEventSourcing/08-ApplicationLogic.Marten/Immutable/ShoppingCarts/ShoppingCartService.cs b/Workshops/IntroductionToEventSourcing/08-ApplicationLogic.Marten/Immutable/ShoppingCarts/ShoppingCartService.cs new file mode 100644 index 000000000..d19cde607 --- /dev/null +++ b/Workshops/IntroductionToEventSourcing/08-ApplicationLogic.Marten/Immutable/ShoppingCarts/ShoppingCartService.cs @@ -0,0 +1,120 @@ +using ApplicationLogic.Marten.Immutable.Pricing; +using Core.Validation; + +namespace ApplicationLogic.Marten.Immutable.ShoppingCarts; +using static ShoppingCartEvent; +using static ShoppingCartCommand; + +public abstract record ShoppingCartCommand +{ + public record OpenShoppingCart( + Guid ShoppingCartId, + Guid ClientId, + DateTimeOffset Now + ): ShoppingCartCommand; + + public record AddProductItemToShoppingCart( + Guid ShoppingCartId, + ProductItem ProductItem, + DateTimeOffset Now + ); + + public record RemoveProductItemFromShoppingCart( + Guid ShoppingCartId, + PricedProductItem ProductItem, + DateTimeOffset Now + ); + + public record ConfirmShoppingCart( + Guid ShoppingCartId, + DateTimeOffset Now + ); + + public record CancelShoppingCart( + Guid ShoppingCartId, + DateTimeOffset Now + ): ShoppingCartCommand; + + private ShoppingCartCommand() {} +} + +public static class ShoppingCartService +{ + public static ShoppingCartOpened Handle(OpenShoppingCart command) + { + var (shoppingCartId, clientId, now) = command; + + return new ShoppingCartOpened( + shoppingCartId, + clientId, + now + ); + } + + public static ProductItemAddedToShoppingCart Handle( + IProductPriceCalculator priceCalculator, + AddProductItemToShoppingCart command, + ShoppingCart shoppingCart + ) + { + var (cartId, productItem, now) = command; + + if (shoppingCart.IsClosed) + throw new InvalidOperationException( + $"Adding product item for cart in '{shoppingCart.Status}' status is not allowed."); + + var pricedProductItem = priceCalculator.Calculate(productItem); + + return new ProductItemAddedToShoppingCart( + cartId, + pricedProductItem, + now + ); + } + + public static ProductItemRemovedFromShoppingCart Handle( + RemoveProductItemFromShoppingCart command, + ShoppingCart shoppingCart + ) + { + var (cartId, productItem, now) = command; + + if (shoppingCart.IsClosed) + throw new InvalidOperationException( + $"Adding product item for cart in '{shoppingCart.Status}' status is not allowed."); + + if (!shoppingCart.HasEnough(productItem)) + throw new InvalidOperationException("Not enough product items to remove"); + + return new ProductItemRemovedFromShoppingCart( + cartId, + productItem, + now + ); + } + + public static ShoppingCartConfirmed Handle(ConfirmShoppingCart command, ShoppingCart shoppingCart) + { + if (shoppingCart.IsClosed) + throw new InvalidOperationException($"Confirming cart in '{shoppingCart.Status}' status is not allowed."); + + if(shoppingCart.ProductItems.Length == 0) + throw new InvalidOperationException($"Cannot confirm empty shopping cart"); + + return new ShoppingCartConfirmed( + shoppingCart.Id, + DateTime.UtcNow + ); + } + + public static ShoppingCartCanceled Handle(CancelShoppingCart command, ShoppingCart shoppingCart) + { + if (shoppingCart.IsClosed) + throw new InvalidOperationException($"Canceling cart in '{shoppingCart.Status}' status is not allowed."); + + return new ShoppingCartCanceled( + shoppingCart.Id, + command.Now + ); + } +} diff --git a/Workshops/IntroductionToEventSourcing/08-ApplicationLogic.Marten/Mixed/Pricing/PricingCalculator.cs b/Workshops/IntroductionToEventSourcing/08-ApplicationLogic.Marten/Mixed/Pricing/PricingCalculator.cs new file mode 100644 index 000000000..304900448 --- /dev/null +++ b/Workshops/IntroductionToEventSourcing/08-ApplicationLogic.Marten/Mixed/Pricing/PricingCalculator.cs @@ -0,0 +1,21 @@ +namespace ApplicationLogic.Marten.Mixed.Pricing; + +public interface IProductPriceCalculator +{ + PricedProductItem Calculate(ProductItem productItems); +} + +public class FakeProductPriceCalculator: IProductPriceCalculator +{ + private readonly int value; + + private FakeProductPriceCalculator(int value) + { + this.value = value; + } + + public static FakeProductPriceCalculator Returning(int value) => new(value); + + public PricedProductItem Calculate(ProductItem productItem) => + new() { ProductId = productItem.ProductId, Quantity = productItem.Quantity, UnitPrice = value }; +} diff --git a/Workshops/IntroductionToEventSourcing/08-ApplicationLogic.Marten/Mixed/Pricing/ProductItems.cs b/Workshops/IntroductionToEventSourcing/08-ApplicationLogic.Marten/Mixed/Pricing/ProductItems.cs new file mode 100644 index 000000000..108702ca9 --- /dev/null +++ b/Workshops/IntroductionToEventSourcing/08-ApplicationLogic.Marten/Mixed/Pricing/ProductItems.cs @@ -0,0 +1,16 @@ +namespace ApplicationLogic.Marten.Mixed.Pricing; + +public class PricedProductItem +{ + public Guid ProductId { get; set; } + public decimal UnitPrice { get; set; } + public int Quantity { get; set; } + public decimal TotalPrice => Quantity * UnitPrice; +} + +public class ProductItem +{ + public Guid ProductId { get; set; } + public int Quantity { get; set; } +} + diff --git a/Workshops/IntroductionToEventSourcing/08-ApplicationLogic.Marten/Mixed/ShoppingCarts/Api.cs b/Workshops/IntroductionToEventSourcing/08-ApplicationLogic.Marten/Mixed/ShoppingCarts/Api.cs new file mode 100644 index 000000000..340061a2a --- /dev/null +++ b/Workshops/IntroductionToEventSourcing/08-ApplicationLogic.Marten/Mixed/ShoppingCarts/Api.cs @@ -0,0 +1,125 @@ +using ApplicationLogic.Marten.Core.Marten; +using ApplicationLogic.Marten.Mixed.Pricing; +using Core.Validation; +using Marten; +using Marten.Schema.Identity; +using Microsoft.AspNetCore.Mvc; +using static Microsoft.AspNetCore.Http.TypedResults; +using static System.DateTimeOffset; + +namespace ApplicationLogic.Marten.Mixed.ShoppingCarts; +public static class Api +{ + public static WebApplication ConfigureMixedShoppingCarts(this WebApplication app) + { + var clients = app.MapGroup("/api/mixed/clients/{clientId:guid}/"); + var shoppingCarts = clients.MapGroup("shopping-carts"); + var shoppingCart = shoppingCarts.MapGroup("{shoppingCartId:guid}"); + var productItems = shoppingCart.MapGroup("product-items"); + + shoppingCarts.MapPost("", + async (IDocumentSession session, + Guid clientId, + CancellationToken ct) => + { + var shoppingCartId = CombGuidIdGeneration.NewGuid(); + + await session.Add(shoppingCartId, + [MixedShoppingCart.Open(shoppingCartId, clientId.NotEmpty(), Now).Item1], ct); + + return Created($"/api/mixed/clients/{clientId}/shopping-carts/{shoppingCartId}", shoppingCartId); + } + ); + + productItems.MapPost("", + async ( + IProductPriceCalculator pricingCalculator, + IDocumentSession session, + Guid shoppingCartId, + AddProductRequest body, + CancellationToken ct) => + { + var productItem = body.ProductItem.NotNull().ToProductItem(); + + await session.GetAndUpdate(shoppingCartId, + state => [state.AddProduct(pricingCalculator, productItem, Now)], ct); + + return NoContent(); + } + ); + + productItems.MapDelete("{productId:guid}", + async ( + IDocumentSession session, + Guid shoppingCartId, + [FromRoute] Guid productId, + [FromQuery] int? quantity, + [FromQuery] decimal? unitPrice, + CancellationToken ct) => + { + var productItem = new PricedProductItem + { + ProductId = productId.NotEmpty(), + Quantity = quantity.NotNull().Positive(), + UnitPrice = unitPrice.NotNull().Positive() + }; + + await session.GetAndUpdate(shoppingCartId, + state => [state.RemoveProduct(productItem, Now)], + ct); + + return NoContent(); + } + ); + + shoppingCart.MapPost("confirm", + async (IDocumentSession session, + Guid shoppingCartId, + CancellationToken ct) => + { + await session.GetAndUpdate(shoppingCartId, + state => [state.Confirm(Now)], ct); + + return NoContent(); + } + ); + + shoppingCart.MapDelete("", + async (IDocumentSession session, + Guid shoppingCartId, + CancellationToken ct) => + { + await session.GetAndUpdate(shoppingCartId, + state => [state.Cancel(Now)], ct); + + return NoContent(); + } + ); + + shoppingCart.MapGet("", + async Task ( + IQuerySession session, + Guid shoppingCartId, + CancellationToken ct) => + { + var result = await session.Events.AggregateStreamAsync(shoppingCartId, token: ct); + + return result is not null ? Ok(result) : NotFound(); + } + ); + + return app; + } +} + +public record ProductItemRequest( + Guid? ProductId, + int? Quantity +) +{ + public ProductItem ToProductItem() => new() { ProductId = ProductId.NotEmpty(), Quantity = Quantity.NotNull().Positive()}; +} + +public record AddProductRequest( + ProductItemRequest? ProductItem +); diff --git a/Workshops/IntroductionToEventSourcing/08-ApplicationLogic.Marten/Mixed/ShoppingCarts/Configure.cs b/Workshops/IntroductionToEventSourcing/08-ApplicationLogic.Marten/Mixed/ShoppingCarts/Configure.cs new file mode 100644 index 000000000..8d90d4700 --- /dev/null +++ b/Workshops/IntroductionToEventSourcing/08-ApplicationLogic.Marten/Mixed/ShoppingCarts/Configure.cs @@ -0,0 +1,31 @@ +using ApplicationLogic.Marten.Core.Marten; +using ApplicationLogic.Marten.Mixed.Pricing; +using Marten; + +namespace ApplicationLogic.Marten.Mixed.ShoppingCarts; +using static ShoppingCartEvent; + +public static class Configure +{ + private const string ModulePrefix = "Mixed"; + + public static IServiceCollection AddMixedShoppingCarts(this IServiceCollection services) + { + services.AddSingleton(FakeProductPriceCalculator.Returning(100)); + + return services; + } + public static StoreOptions ConfigureMixedShoppingCarts(this StoreOptions options) + { + options.Projections.LiveStreamAggregation(); + + // this is needed as we're sharing document store and have event types with the same name + options.MapEventWithPrefix(ModulePrefix); + options.MapEventWithPrefix(ModulePrefix); + options.MapEventWithPrefix(ModulePrefix); + options.MapEventWithPrefix(ModulePrefix); + options.MapEventWithPrefix(ModulePrefix); + + return options; + } +} diff --git a/Workshops/IntroductionToEventSourcing/08-ApplicationLogic.Marten/Mixed/ShoppingCarts/MixedShoppingCart.cs b/Workshops/IntroductionToEventSourcing/08-ApplicationLogic.Marten/Mixed/ShoppingCarts/MixedShoppingCart.cs new file mode 100644 index 000000000..c16e18260 --- /dev/null +++ b/Workshops/IntroductionToEventSourcing/08-ApplicationLogic.Marten/Mixed/ShoppingCarts/MixedShoppingCart.cs @@ -0,0 +1,232 @@ +using ApplicationLogic.Marten.Mixed.Pricing; + +namespace ApplicationLogic.Marten.Mixed.ShoppingCarts; + +using static ShoppingCartEvent; + +// EVENTS +public abstract record ShoppingCartEvent +{ + public record ShoppingCartOpened( + Guid ShoppingCartId, + Guid ClientId, + DateTimeOffset OpenedAt + ): ShoppingCartEvent; + + public record ProductItemAddedToShoppingCart( + Guid ShoppingCartId, + PricedProductItem ProductItem, + DateTimeOffset AddedAt + ): ShoppingCartEvent; + + public record ProductItemRemovedFromShoppingCart( + Guid ShoppingCartId, + PricedProductItem ProductItem, + DateTimeOffset RemovedAt + ): ShoppingCartEvent; + + public record ShoppingCartConfirmed( + Guid ShoppingCartId, + DateTimeOffset ConfirmedAt + ): ShoppingCartEvent; + + public record ShoppingCartCanceled( + Guid ShoppingCartId, + DateTimeOffset CanceledAt + ): ShoppingCartEvent; + + // This won't allow external inheritance + private ShoppingCartEvent() { } +} + +// ENTITY +// Note: We need to have prefix to be able to register multiple streams with the same name +public class MixedShoppingCart +{ + public Guid Id { get; private set; } + public Guid ClientId { get; private set; } + public ShoppingCartStatus Status { get; private set; } + public IList ProductItems { get; } = new List(); + public DateTimeOffset? ConfirmedAt { get; private set; } + public DateTimeOffset? CanceledAt { get; private set; } + + public bool IsClosed => ShoppingCartStatus.Closed.HasFlag(Status); + + public void Apply(ShoppingCartEvent @event) + { + switch (@event) + { + case ShoppingCartOpened opened: + Apply(opened); + break; + case ProductItemAddedToShoppingCart productItemAdded: + Apply(productItemAdded); + break; + case ProductItemRemovedFromShoppingCart productItemRemoved: + Apply(productItemRemoved); + break; + case ShoppingCartConfirmed confirmed: + Apply(confirmed); + break; + case ShoppingCartCanceled canceled: + Apply(canceled); + break; + } + } + + public static (ShoppingCartOpened, MixedShoppingCart) Open( + Guid cartId, + Guid clientId, + DateTimeOffset now + ) + { + var @event = new ShoppingCartOpened( + cartId, + clientId, + now + ); + + return (@event, new MixedShoppingCart(@event)); + } + + public static MixedShoppingCart Initial() => new(); + + private MixedShoppingCart(ShoppingCartOpened @event) => + Apply(@event); + + //just for default creation of empty object + private MixedShoppingCart() { } + + private void Apply(ShoppingCartOpened opened) + { + Id = opened.ShoppingCartId; + ClientId = opened.ClientId; + Status = ShoppingCartStatus.Pending; + } + + public ProductItemAddedToShoppingCart AddProduct( + IProductPriceCalculator productPriceCalculator, + ProductItem productItem, + DateTimeOffset now + ) + { + if (IsClosed) + throw new InvalidOperationException( + $"Adding product item for cart in '{Status}' status is not allowed."); + + var pricedProductItem = productPriceCalculator.Calculate(productItem); + + var @event = new ProductItemAddedToShoppingCart(Id, pricedProductItem, now); + + Apply(@event); + + return @event; + } + + private void Apply(ProductItemAddedToShoppingCart productItemAdded) + { + var pricedProductItem = productItemAdded.ProductItem; + var productId = pricedProductItem.ProductId; + var quantityToAdd = pricedProductItem.Quantity; + + var current = ProductItems.SingleOrDefault( + pi => pi.ProductId == productId + ); + + if (current == null) + ProductItems.Add(pricedProductItem); + else + ProductItems[ProductItems.IndexOf(current)].Quantity += quantityToAdd; + } + + public ProductItemRemovedFromShoppingCart RemoveProduct(PricedProductItem productItemToBeRemoved, DateTimeOffset now) + { + if (IsClosed) + throw new InvalidOperationException( + $"Removing product item for cart in '{Status}' status is not allowed."); + + if (!HasEnough(productItemToBeRemoved)) + throw new InvalidOperationException("Not enough product items to remove"); + + var @event = new ProductItemRemovedFromShoppingCart(Id, productItemToBeRemoved, now); + + Apply(@event); + + return @event; + } + + private bool HasEnough(PricedProductItem productItem) + { + var currentQuantity = ProductItems.Where(pi => pi.ProductId == productItem.ProductId) + .Select(pi => pi.Quantity) + .FirstOrDefault(); + + return currentQuantity >= productItem.Quantity; + } + + private void Apply(ProductItemRemovedFromShoppingCart productItemRemoved) + { + var pricedProductItem = productItemRemoved.ProductItem; + var productId = pricedProductItem.ProductId; + var quantityToRemove = pricedProductItem.Quantity; + + var current = ProductItems.Single( + pi => pi.ProductId == productId + ); + + if (current.Quantity == quantityToRemove) + ProductItems.Remove(current); + else + ProductItems[ProductItems.IndexOf(current)].Quantity -= quantityToRemove; + } + + public ShoppingCartConfirmed Confirm(DateTimeOffset now) + { + if (IsClosed) + throw new InvalidOperationException( + $"Confirming cart in '{Status}' status is not allowed."); + + if (ProductItems.Count == 0) + throw new InvalidOperationException($"Cannot confirm empty shopping cart"); + + var @event = new ShoppingCartConfirmed(Id, now); + + Apply(@event); + + return @event; + } + + private void Apply(ShoppingCartConfirmed confirmed) + { + Status = ShoppingCartStatus.Confirmed; + ConfirmedAt = confirmed.ConfirmedAt; + } + + public ShoppingCartCanceled Cancel(DateTimeOffset now) + { + if (IsClosed) + throw new InvalidOperationException( + $"Canceling cart in '{Status}' status is not allowed."); + + var @event = new ShoppingCartCanceled(Id, now); + + Apply(@event); + + return @event; + } + + private void Apply(ShoppingCartCanceled canceled) + { + Status = ShoppingCartStatus.Canceled; + CanceledAt = canceled.CanceledAt; + } +} + +public enum ShoppingCartStatus +{ + Pending = 1, + Confirmed = 2, + Canceled = 4, + + Closed = Confirmed | Canceled +} diff --git a/Workshops/IntroductionToEventSourcing/08-ApplicationLogic.Marten/Mutable/Pricing/PricingCalculator.cs b/Workshops/IntroductionToEventSourcing/08-ApplicationLogic.Marten/Mutable/Pricing/PricingCalculator.cs new file mode 100644 index 000000000..c459f7bb4 --- /dev/null +++ b/Workshops/IntroductionToEventSourcing/08-ApplicationLogic.Marten/Mutable/Pricing/PricingCalculator.cs @@ -0,0 +1,21 @@ +namespace ApplicationLogic.Marten.Mutable.Pricing; + +public interface IProductPriceCalculator +{ + PricedProductItem Calculate(ProductItem productItems); +} + +public class FakeProductPriceCalculator: IProductPriceCalculator +{ + private readonly int value; + + private FakeProductPriceCalculator(int value) + { + this.value = value; + } + + public static FakeProductPriceCalculator Returning(int value) => new(value); + + public PricedProductItem Calculate(ProductItem productItem) => + new() { ProductId = productItem.ProductId, Quantity = productItem.Quantity, UnitPrice = value }; +} diff --git a/Workshops/IntroductionToEventSourcing/08-ApplicationLogic.Marten/Mutable/Pricing/ProductItems.cs b/Workshops/IntroductionToEventSourcing/08-ApplicationLogic.Marten/Mutable/Pricing/ProductItems.cs new file mode 100644 index 000000000..8c98934c7 --- /dev/null +++ b/Workshops/IntroductionToEventSourcing/08-ApplicationLogic.Marten/Mutable/Pricing/ProductItems.cs @@ -0,0 +1,16 @@ +namespace ApplicationLogic.Marten.Mutable.Pricing; + +public class PricedProductItem +{ + public Guid ProductId { get; set; } + public decimal UnitPrice { get; set; } + public int Quantity { get; set; } + public decimal TotalPrice => Quantity * UnitPrice; +} + +public class ProductItem +{ + public Guid ProductId { get; set; } + public int Quantity { get; set; } +} + diff --git a/Workshops/IntroductionToEventSourcing/08-ApplicationLogic.Marten/Mutable/ShoppingCarts/Api.cs b/Workshops/IntroductionToEventSourcing/08-ApplicationLogic.Marten/Mutable/ShoppingCarts/Api.cs new file mode 100644 index 000000000..be7b18493 --- /dev/null +++ b/Workshops/IntroductionToEventSourcing/08-ApplicationLogic.Marten/Mutable/ShoppingCarts/Api.cs @@ -0,0 +1,126 @@ +using ApplicationLogic.Marten.Core.Marten; +using ApplicationLogic.Marten.Mutable.Pricing; +using Core.Validation; +using Marten; +using Marten.Schema.Identity; +using Microsoft.AspNetCore.Mvc; +using static Microsoft.AspNetCore.Http.TypedResults; +using static System.DateTimeOffset; + +namespace ApplicationLogic.Marten.Mutable.ShoppingCarts; + +public static class Api +{ + public static WebApplication ConfigureMutableShoppingCarts(this WebApplication app) + { + var clients = app.MapGroup("/api/mutable/clients/{clientId:guid}/"); + var shoppingCarts = clients.MapGroup("shopping-carts"); + var shoppingCart = shoppingCarts.MapGroup("{shoppingCartId:guid}"); + var productItems = shoppingCart.MapGroup("product-items"); + + shoppingCarts.MapPost("", + async (IDocumentSession session, + Guid clientId, + CancellationToken ct) => + { + var shoppingCartId = CombGuidIdGeneration.NewGuid(); + + await session.Add(shoppingCartId, + MutableShoppingCart.Open(shoppingCartId, clientId.NotEmpty(), Now).DequeueUncommittedEvents(), ct); + + return Created($"/api/mutable/clients/{clientId}/shopping-carts/{shoppingCartId}", shoppingCartId); + } + ); + + productItems.MapPost("", + async ( + IProductPriceCalculator pricingCalculator, + IDocumentSession session, + Guid shoppingCartId, + AddProductRequest body, + CancellationToken ct) => + { + var productItem = body.ProductItem.NotNull().ToProductItem(); + + await session.GetAndUpdate(shoppingCartId, + state => state.AddProduct(pricingCalculator, productItem, Now), ct); + + return NoContent(); + } + ); + + productItems.MapDelete("{productId:guid}", + async ( + IDocumentSession session, + Guid shoppingCartId, + [FromRoute] Guid productId, + [FromQuery] int? quantity, + [FromQuery] decimal? unitPrice, + CancellationToken ct) => + { + var productItem = new PricedProductItem + { + ProductId = productId.NotEmpty(), + Quantity = quantity.NotNull().Positive(), + UnitPrice = unitPrice.NotNull().Positive() + }; + + await session.GetAndUpdate(shoppingCartId, + state => state.RemoveProduct(productItem, Now), + ct); + + return NoContent(); + } + ); + + shoppingCart.MapPost("confirm", + async (IDocumentSession session, + Guid shoppingCartId, + CancellationToken ct) => + { + await session.GetAndUpdate(shoppingCartId, + state => state.Confirm(Now), ct); + + return NoContent(); + } + ); + + shoppingCart.MapDelete("", + async (IDocumentSession session, + Guid shoppingCartId, + CancellationToken ct) => + { + await session.GetAndUpdate(shoppingCartId, + state => state.Cancel(Now), ct); + + return NoContent(); + } + ); + + shoppingCart.MapGet("", + async Task ( + IQuerySession session, + Guid shoppingCartId, + CancellationToken ct) => + { + var result = await session.Events.AggregateStreamAsync(shoppingCartId, token: ct); + + return result is not null ? Ok(result) : NotFound(); + } + ); + + return app; + } +} + +public record ProductItemRequest( + Guid? ProductId, + int? Quantity +) +{ + public ProductItem ToProductItem() => new() { ProductId = ProductId.NotEmpty(), Quantity = Quantity.NotNull().Positive()}; +} + +public record AddProductRequest( + ProductItemRequest? ProductItem +); diff --git a/Workshops/IntroductionToEventSourcing/08-ApplicationLogic.Marten/Mutable/ShoppingCarts/Configure.cs b/Workshops/IntroductionToEventSourcing/08-ApplicationLogic.Marten/Mutable/ShoppingCarts/Configure.cs new file mode 100644 index 000000000..0632d3777 --- /dev/null +++ b/Workshops/IntroductionToEventSourcing/08-ApplicationLogic.Marten/Mutable/ShoppingCarts/Configure.cs @@ -0,0 +1,31 @@ +using ApplicationLogic.Marten.Core.Marten; +using ApplicationLogic.Marten.Mutable.Pricing; +using Marten; + +namespace ApplicationLogic.Marten.Mutable.ShoppingCarts; +using static ShoppingCartEvent; + +public static class Configure +{ + private const string ModulePrefix = "mutable"; + + public static IServiceCollection AddMutableShoppingCarts(this IServiceCollection services) + { + services.AddSingleton(FakeProductPriceCalculator.Returning(100)); + + return services; + } + public static StoreOptions ConfigureMutableShoppingCarts(this StoreOptions options) + { + options.Projections.LiveStreamAggregation(); + + // this is needed as we're sharing document store and have event types with the same name + options.MapEventWithPrefix(ModulePrefix); + options.MapEventWithPrefix(ModulePrefix); + options.MapEventWithPrefix(ModulePrefix); + options.MapEventWithPrefix(ModulePrefix); + options.MapEventWithPrefix(ModulePrefix); + + return options; + } +} diff --git a/Workshops/IntroductionToEventSourcing/08-ApplicationLogic.Marten/Mutable/ShoppingCarts/MutableShoppingCart.cs b/Workshops/IntroductionToEventSourcing/08-ApplicationLogic.Marten/Mutable/ShoppingCarts/MutableShoppingCart.cs new file mode 100644 index 000000000..24f8814cd --- /dev/null +++ b/Workshops/IntroductionToEventSourcing/08-ApplicationLogic.Marten/Mutable/ShoppingCarts/MutableShoppingCart.cs @@ -0,0 +1,235 @@ +using ApplicationLogic.Marten.Core.Entities; +using ApplicationLogic.Marten.Mutable.Pricing; + +namespace ApplicationLogic.Marten.Mutable.ShoppingCarts; + +using static ShoppingCartEvent; + +// EVENTS +public abstract record ShoppingCartEvent +{ + public record ShoppingCartOpened( + Guid ShoppingCartId, + Guid ClientId, + DateTimeOffset OpenedAt + ): ShoppingCartEvent; + + public record ProductItemAddedToShoppingCart( + Guid ShoppingCartId, + PricedProductItem ProductItem, + DateTimeOffset AddedAt + ): ShoppingCartEvent; + + public record ProductItemRemovedFromShoppingCart( + Guid ShoppingCartId, + PricedProductItem ProductItem, + DateTimeOffset RemovedAt + ): ShoppingCartEvent; + + public record ShoppingCartConfirmed( + Guid ShoppingCartId, + DateTimeOffset ConfirmedAt + ): ShoppingCartEvent; + + public record ShoppingCartCanceled( + Guid ShoppingCartId, + DateTimeOffset CanceledAt + ): ShoppingCartEvent; + + // This won't allow external inheritance + private ShoppingCartEvent() { } +} + +// ENTITY +// Note: We need to have prefix to be able to register multiple streams with the same name +public class MutableShoppingCart: Aggregate +{ + public Guid ClientId { get; private set; } + public ShoppingCartStatus Status { get; private set; } + public IList ProductItems { get; } = new List(); + public DateTimeOffset? ConfirmedAt { get; private set; } + public DateTimeOffset? CanceledAt { get; private set; } + + public bool IsClosed => ShoppingCartStatus.Closed.HasFlag(Status); + + protected override void Apply(ShoppingCartEvent @event) + { + switch (@event) + { + case ShoppingCartOpened opened: + Apply(opened); + break; + case ProductItemAddedToShoppingCart productItemAdded: + Apply(productItemAdded); + break; + case ProductItemRemovedFromShoppingCart productItemRemoved: + Apply(productItemRemoved); + break; + case ShoppingCartConfirmed confirmed: + Apply(confirmed); + break; + case ShoppingCartCanceled canceled: + Apply(canceled); + break; + } + } + + public static MutableShoppingCart Open( + Guid cartId, + Guid clientId, + DateTimeOffset now + ) + { + return new MutableShoppingCart(cartId, clientId, now); + } + + public static MutableShoppingCart Initial() => new(); + + private MutableShoppingCart( + Guid id, + Guid clientId, + DateTimeOffset now + ) + { + var @event = new ShoppingCartOpened( + id, + clientId, + now + ); + + Enqueue(@event); + Apply(@event); + } + + //just for default creation of empty object + private MutableShoppingCart() { } + + private void Apply(ShoppingCartOpened opened) + { + Id = opened.ShoppingCartId; + ClientId = opened.ClientId; + Status = ShoppingCartStatus.Pending; + } + + public void AddProduct( + IProductPriceCalculator productPriceCalculator, + ProductItem productItem, + DateTimeOffset now + ) + { + if (IsClosed) + throw new InvalidOperationException( + $"Adding product item for cart in '{Status}' status is not allowed."); + + var pricedProductItem = productPriceCalculator.Calculate(productItem); + + var @event = new ProductItemAddedToShoppingCart(Id, pricedProductItem, now); + + Enqueue(@event); + Apply(@event); + } + + private void Apply(ProductItemAddedToShoppingCart productItemAdded) + { + var pricedProductItem = productItemAdded.ProductItem; + var productId = pricedProductItem.ProductId; + var quantityToAdd = pricedProductItem.Quantity; + + var current = ProductItems.SingleOrDefault( + pi => pi.ProductId == productId + ); + + if (current == null) + ProductItems.Add(pricedProductItem); + else + current.Quantity += quantityToAdd; + } + + public void RemoveProduct(PricedProductItem productItemToBeRemoved, DateTimeOffset now) + { + if (IsClosed) + throw new InvalidOperationException( + $"Removing product item for cart in '{Status}' status is not allowed."); + + if (!HasEnough(productItemToBeRemoved)) + throw new InvalidOperationException("Not enough product items to remove"); + + var @event = new ProductItemRemovedFromShoppingCart(Id, productItemToBeRemoved, now); + + Enqueue(@event); + Apply(@event); + } + + private bool HasEnough(PricedProductItem productItem) + { + var currentQuantity = ProductItems.Where(pi => pi.ProductId == productItem.ProductId) + .Select(pi => pi.Quantity) + .FirstOrDefault(); + + return currentQuantity >= productItem.Quantity; + } + + private void Apply(ProductItemRemovedFromShoppingCart productItemRemoved) + { + var pricedProductItem = productItemRemoved.ProductItem; + var productId = pricedProductItem.ProductId; + var quantityToRemove = pricedProductItem.Quantity; + + var current = ProductItems.Single( + pi => pi.ProductId == productId + ); + + if (current.Quantity == quantityToRemove) + ProductItems.Remove(current); + else + current.Quantity -= quantityToRemove; + } + + public void Confirm(DateTimeOffset now) + { + if (IsClosed) + throw new InvalidOperationException( + $"Confirming cart in '{Status}' status is not allowed."); + + if (ProductItems.Count == 0) + throw new InvalidOperationException($"Cannot confirm empty shopping cart"); + + var @event = new ShoppingCartConfirmed(Id, now); + + Enqueue(@event); + Apply(@event); + } + + private void Apply(ShoppingCartConfirmed confirmed) + { + Status = ShoppingCartStatus.Confirmed; + ConfirmedAt = confirmed.ConfirmedAt; + } + + public void Cancel(DateTimeOffset now) + { + if (IsClosed) + throw new InvalidOperationException( + $"Canceling cart in '{Status}' status is not allowed."); + + var @event = new ShoppingCartCanceled(Id, now); + + Enqueue(@event); + Apply(@event); + } + + private void Apply(ShoppingCartCanceled canceled) + { + Status = ShoppingCartStatus.Canceled; + CanceledAt = canceled.CanceledAt; + } +} + +public enum ShoppingCartStatus +{ + Pending = 1, + Confirmed = 2, + Canceled = 4, + + Closed = Confirmed | Canceled +} diff --git a/Workshops/IntroductionToEventSourcing/08-ApplicationLogic.Marten/Program.cs b/Workshops/IntroductionToEventSourcing/08-ApplicationLogic.Marten/Program.cs new file mode 100644 index 000000000..949cb73fb --- /dev/null +++ b/Workshops/IntroductionToEventSourcing/08-ApplicationLogic.Marten/Program.cs @@ -0,0 +1,71 @@ +using ApplicationLogic.Marten.Core.Exceptions; +using ApplicationLogic.Marten.Immutable.ShoppingCarts; +using ApplicationLogic.Marten.Mixed.ShoppingCarts; +using ApplicationLogic.Marten.Mutable.ShoppingCarts; +using Marten; +using Microsoft.AspNetCore.Diagnostics; +using Oakton; + +var builder = WebApplication.CreateBuilder(args); + +builder.Services + .AddRouting() + .AddEndpointsApiExplorer() + .AddSwaggerGen() + .AddMutableShoppingCarts() + .AddMixedShoppingCarts() + .AddImmutableShoppingCarts() + .AddMarten(options => + { + var schemaName = Environment.GetEnvironmentVariable("SchemaName") ?? "Workshop_ShoppingCarts"; + options.Events.DatabaseSchemaName = schemaName; + options.DatabaseSchemaName = schemaName; + options.Connection(builder.Configuration.GetConnectionString("ShoppingCarts") ?? + throw new InvalidOperationException()); + + options.ConfigureImmutableShoppingCarts() + .ConfigureMutableShoppingCarts() + .ConfigureMixedShoppingCarts(); + }) + .OptimizeArtifactWorkflow() + .UseLightweightSessions(); + +builder.Host.ApplyOaktonExtensions(); + +var app = builder.Build(); + +app.UseExceptionHandler(new ExceptionHandlerOptions + { + AllowStatusCode404Response = true, + ExceptionHandler = context => + { + var exception = context.Features.Get()?.Error; + + context.Response.StatusCode = exception switch + { + ArgumentException => StatusCodes.Status400BadRequest, + NotFoundException => StatusCodes.Status404NotFound, + InvalidOperationException => StatusCodes.Status409Conflict, + _ => StatusCodes.Status500InternalServerError, + }; + + return Task.CompletedTask; + } + }) + .UseRouting(); + +app.ConfigureImmutableShoppingCarts() + .ConfigureMutableShoppingCarts() + .ConfigureMixedShoppingCarts(); + +if (app.Environment.IsDevelopment()) +{ + app.UseSwagger() + .UseSwaggerUI(); +} + +return await app.RunOaktonCommands(args); + +public partial class Program +{ +} diff --git a/Workshops/IntroductionToEventSourcing/08-ApplicationLogic.Marten/Properties/launchSettings.json b/Workshops/IntroductionToEventSourcing/08-ApplicationLogic.Marten/Properties/launchSettings.json new file mode 100644 index 000000000..01dd421e9 --- /dev/null +++ b/Workshops/IntroductionToEventSourcing/08-ApplicationLogic.Marten/Properties/launchSettings.json @@ -0,0 +1,14 @@ +{ + "profiles": { + "Helpdesk.Api": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": true, + "launchUrl": "swagger/index.html", + "applicationUrl": "http://localhost:5248", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + } + } +} diff --git a/Workshops/IntroductionToEventSourcing/08-ApplicationLogic.Marten/appsettings.json b/Workshops/IntroductionToEventSourcing/08-ApplicationLogic.Marten/appsettings.json new file mode 100644 index 000000000..26bccde8b --- /dev/null +++ b/Workshops/IntroductionToEventSourcing/08-ApplicationLogic.Marten/appsettings.json @@ -0,0 +1,18 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + }, + "AllowedHosts": "*", + "ConnectionStrings": { + "ShoppingCarts": "PORT = 5432; HOST = localhost; TIMEOUT = 15; POOLING = True; MINPOOLSIZE = 1; MAXPOOLSIZE = 100; COMMANDTIMEOUT = 20; DATABASE = 'postgres'; PASSWORD = 'Password12!'; USER ID = 'postgres'" + }, + "KafkaProducer": { + "ProducerConfig": { + "BootstrapServers": "localhost:9092" + }, + "Topic": "Incidents" + } +} diff --git a/Workshops/IntroductionToEventSourcing/Solved/07-BusinessLogic/Immutable/ShoppingCartService.cs b/Workshops/IntroductionToEventSourcing/Solved/07-BusinessLogic/Immutable/ShoppingCartService.cs index e75cf2c0a..29d76e187 100644 --- a/Workshops/IntroductionToEventSourcing/Solved/07-BusinessLogic/Immutable/ShoppingCartService.cs +++ b/Workshops/IntroductionToEventSourcing/Solved/07-BusinessLogic/Immutable/ShoppingCartService.cs @@ -87,6 +87,9 @@ public static ShoppingCartConfirmed Handle(ConfirmShoppingCart command, Shopping if (shoppingCart.IsClosed) throw new InvalidOperationException($"Confirming cart in '{shoppingCart.Status}' status is not allowed."); + if(shoppingCart.ProductItems.Length == 0) + throw new InvalidOperationException($"Cannot confirm empty shopping cart"); + return new ShoppingCartConfirmed( shoppingCart.Id, DateTime.UtcNow diff --git a/Workshops/IntroductionToEventSourcing/Solved/07-BusinessLogic/Mutable/Solution1/ShoppingCart.cs b/Workshops/IntroductionToEventSourcing/Solved/07-BusinessLogic/Mutable/Solution1/ShoppingCart.cs index 5fbf60b84..2b49af511 100644 --- a/Workshops/IntroductionToEventSourcing/Solved/07-BusinessLogic/Mutable/Solution1/ShoppingCart.cs +++ b/Workshops/IntroductionToEventSourcing/Solved/07-BusinessLogic/Mutable/Solution1/ShoppingCart.cs @@ -192,6 +192,9 @@ public void Confirm() throw new InvalidOperationException( $"Confirming cart in '{Status}' status is not allowed."); + if(ProductItems.Count == 0) + throw new InvalidOperationException($"Cannot confirm empty shopping cart"); + var @event = new ShoppingCartConfirmed(Id, DateTime.UtcNow); Enqueue(@event); diff --git a/Workshops/IntroductionToEventSourcing/Solved/07-BusinessLogic/Mutable/Solution2/ShoppingCart.cs b/Workshops/IntroductionToEventSourcing/Solved/07-BusinessLogic/Mutable/Solution2/ShoppingCart.cs index c40912965..c2ac7395c 100644 --- a/Workshops/IntroductionToEventSourcing/Solved/07-BusinessLogic/Mutable/Solution2/ShoppingCart.cs +++ b/Workshops/IntroductionToEventSourcing/Solved/07-BusinessLogic/Mutable/Solution2/ShoppingCart.cs @@ -190,6 +190,9 @@ public ShoppingCartConfirmed Confirm() throw new InvalidOperationException( $"Confirming cart in '{Status}' status is not allowed."); + if(ProductItems.Count == 0) + throw new InvalidOperationException($"Cannot confirm empty shopping cart"); + var @event = new ShoppingCartConfirmed(Id, DateTime.UtcNow); Apply(@event);