diff --git a/src/Catalog/Catalog.Api/Registrations.cs b/src/Catalog/Catalog.Api/Registrations.cs index a98e7cf..4a7f940 100644 --- a/src/Catalog/Catalog.Api/Registrations.cs +++ b/src/Catalog/Catalog.Api/Registrations.cs @@ -89,6 +89,8 @@ public static void AddEventuous(this IServiceCollection services, IConfiguration .AddEventHandler() .WithPartitioningByStream(2)); + // TODO: add additional mongo, postgresql, and other custom projections + // services.AddSubscription( // "ProductDraftsProjections", // builder => builder diff --git a/src/Retail/ShoppingCart.Api/HttpApi/Carts/CartCommandApi.cs b/src/Retail/ShoppingCart.Api/HttpApi/Carts/CartCommandApi.cs index 0ab2aa1..1343c74 100644 --- a/src/Retail/ShoppingCart.Api/HttpApi/Carts/CartCommandApi.cs +++ b/src/Retail/ShoppingCart.Api/HttpApi/Carts/CartCommandApi.cs @@ -39,14 +39,6 @@ public async Task> OpenCart([FromBody] CartCommands.V1.Remo return Ok(result); } - [HttpPost] - [Route("prepare-checkout")] - public async Task> PrepareForCheckout([FromBody] CartCommands.V1.ConfirmCartForCheckout cmd, CancellationToken ct) - { - var result = await _service.Handle(cmd, ct); - return Ok(result); - } - [HttpPost] [Route("confirm")] public async Task> OpenCart([FromBody] CartCommands.V1.ConfirmCart cmd, CancellationToken ct) diff --git a/src/Retail/ShoppingCart.Api/Infrastructure/Mongo.cs b/src/Retail/ShoppingCart.Api/Infrastructure/Mongo.cs new file mode 100644 index 0000000..d57d722 --- /dev/null +++ b/src/Retail/ShoppingCart.Api/Infrastructure/Mongo.cs @@ -0,0 +1,35 @@ +using MongoDb.Bson.NodaTime; +using MongoDB.Driver; +using MongoDB.Driver.Core.Extensions.DiagnosticSources; + +namespace ShoppingCart.Api.Infrastructure; + +public static class Mongo +{ + public static IMongoDatabase ConfigureMongo(IConfiguration configuration) + { + NodaTimeSerializers.Register(); + var config = configuration.GetSection("Mongo").Get(); + + var settings = MongoClientSettings.FromConnectionString(config!.ConnectionString); + + if (config.User != null && config.Password != null) { + settings.Credential = new MongoCredential( + null, + new MongoInternalIdentity("admin", config.User), + new PasswordEvidence(config.Password) + ); + } + + settings.ClusterConfigurator = cb => cb.Subscribe(new DiagnosticsActivityEventSubscriber()); + return new MongoClient(settings).GetDatabase(config.Database); + } + + public record MongoSettings + { + public string ConnectionString { get; init; } = null!; + public string Database { get; init; } = null!; + public string? User { get; init; } + public string? Password { get; init; } + } +} diff --git a/src/Retail/ShoppingCart.Api/Queries/Carts/UserCartDocument.cs b/src/Retail/ShoppingCart.Api/Queries/Carts/UserCartDocument.cs new file mode 100644 index 0000000..62b90ad --- /dev/null +++ b/src/Retail/ShoppingCart.Api/Queries/Carts/UserCartDocument.cs @@ -0,0 +1,13 @@ +using Eventuous.Projections.MongoDB.Tools; + +namespace ShoppingCart.Api.Queries.Carts; + +public record UserCartDocument : ProjectedDocument +{ + public UserCartDocument(string Id) : base(Id) + { + } + + public string CustomerId { get; set; } = null!; + public string Status { get; set; } = null!; +} diff --git a/src/Retail/ShoppingCart.Api/Queries/Carts/UserCartProjection.cs b/src/Retail/ShoppingCart.Api/Queries/Carts/UserCartProjection.cs new file mode 100644 index 0000000..73a7f8b --- /dev/null +++ b/src/Retail/ShoppingCart.Api/Queries/Carts/UserCartProjection.cs @@ -0,0 +1,38 @@ +using Eventuous.Projections.MongoDB; +using Eventuous.Subscriptions.Context; +using MongoDB.Driver; +using ShoppingCart.Carts; + +namespace ShoppingCart.Api.Queries.Carts; + +[Obsolete("Obsolete per Eventuous; use new API instead (TODO)")] +public class UserCartProjection : MongoProjection +{ + public UserCartProjection(IMongoDatabase database) : base(database) + { + On(stream => stream.GetId(), Handle); + + On(builder => builder + .UpdateOne + .DefaultId() + .Update((evt, update) => + update.Set(x => x.Status, nameof(CartStatus.Confirmed)))); + + On(builder => builder + .UpdateOne + .DefaultId() + .Update((evt, update) => + update.Set(x => x.Status, nameof(CartStatus.Cancelled)))); + } + + private static UpdateDefinition Handle( + IMessageConsumeContext ctx, + UpdateDefinitionBuilder update) + { + var evt = ctx.Message; + + return update.SetOnInsert(x => x.Id, ctx.Stream.GetId()) + .Set(x => x.CustomerId, evt.CustomerId) + .Set(x => x.Status, nameof(CartStatus.Opened)); + } +} diff --git a/src/Retail/ShoppingCart.Api/Registrations.cs b/src/Retail/ShoppingCart.Api/Registrations.cs index e270624..aa47721 100644 --- a/src/Retail/ShoppingCart.Api/Registrations.cs +++ b/src/Retail/ShoppingCart.Api/Registrations.cs @@ -3,13 +3,19 @@ using Eventuous; using Eventuous.Diagnostics.OpenTelemetry; using Eventuous.EventStore; +using Eventuous.EventStore.Subscriptions; +using Eventuous.Projections.MongoDB; +using Eventuous.Subscriptions.Registrations; using Microsoft.Extensions.Diagnostics.HealthChecks; using OpenTelemetry.Metrics; using OpenTelemetry.Resources; using OpenTelemetry.Trace; +using ShoppingCart.Api.Infrastructure; +using ShoppingCart.Api.Queries.Carts; using ShoppingCart.Carts; using ShoppingCart.Inventories; using ShoppingCart.Prices; +using ShoppingCart.Products; #pragma warning disable CS0618 // Type or member is obsolete @@ -18,7 +24,6 @@ namespace ShoppingCart.Api; public static class Registrations { private const string OTelServiceName = "shoppingcart"; - private const string PostgresSchemaName = "shoppingcart"; public static void AddEventuous(this IServiceCollection services, IConfiguration configuration) { @@ -37,8 +42,23 @@ public static void AddEventuous(this IServiceCollection services, IConfiguration // other internal and core services services.AddSingleton(); - services.AddSingleton(); - services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + + // subscriptions: checkpoint stores + services.AddSingleton(Mongo.ConfigureMongo(configuration)); + services.AddCheckpointStore(); + + // subscriptions: projections + services.AddSubscription( + "UserCartProjections", + builder => builder + .UseCheckpointStore() + .AddEventHandler() + .WithPartitioningByStream(2)); + + // TODO: add additional mongo, postgresql, and other custom projections // health checks for subscription service services diff --git a/src/Retail/ShoppingCart.Api/ShoppingCart.Api.csproj b/src/Retail/ShoppingCart.Api/ShoppingCart.Api.csproj index edea4fd..869ade7 100644 --- a/src/Retail/ShoppingCart.Api/ShoppingCart.Api.csproj +++ b/src/Retail/ShoppingCart.Api/ShoppingCart.Api.csproj @@ -9,9 +9,12 @@ + + + diff --git a/src/Retail/ShoppingCart.Api/appsettings.Development.json b/src/Retail/ShoppingCart.Api/appsettings.Development.json index e3c3837..8707552 100644 --- a/src/Retail/ShoppingCart.Api/appsettings.Development.json +++ b/src/Retail/ShoppingCart.Api/appsettings.Development.json @@ -11,5 +11,16 @@ }, "EventStore": { "ConnectionString": "esdb://localhost:2113?tls=false" + }, + "Mongo": { + "ConnectionString": "mongodb://localhost:27017", + "User": "mongoadmin", + "Password": "secret", + "Database": "ShoppingCart" + }, + "Postgres": { + "ConnectionString": "Server=127.0.0.1;Port=5432;Database=postgres;User Id=postgres;Password=Password123!;Include Error Detail=true;", + "Schema": "shopping_cart", + "InitializeDatabase": true } } diff --git a/src/Retail/ShoppingCart/Carts/CartCommands.cs b/src/Retail/ShoppingCart/Carts/CartCommands.cs index e512997..9d039d9 100644 --- a/src/Retail/ShoppingCart/Carts/CartCommands.cs +++ b/src/Retail/ShoppingCart/Carts/CartCommands.cs @@ -20,11 +20,11 @@ public record RemoveProductFromCart( int Quantity ); - public record ConfirmCartForCheckout( + public record ConfirmCart( string CartId ); - public record ConfirmCart( + public record CancelCart( string CartId ); } diff --git a/src/Retail/ShoppingCart/Carts/CartEvents.cs b/src/Retail/ShoppingCart/Carts/CartEvents.cs index cf7a885..5a50c53 100644 --- a/src/Retail/ShoppingCart/Carts/CartEvents.cs +++ b/src/Retail/ShoppingCart/Carts/CartEvents.cs @@ -31,6 +31,11 @@ public record CartConfirmed( string CartId ); + [EventType("V1.CartCancelled")] + public record CartCancelled( + string CartId + ); + [EventType("V1.EmptyCartDetected")] public record EmptyCartDetected( string CartId diff --git a/src/Retail/ShoppingCart/Carts/CartFuncService.cs b/src/Retail/ShoppingCart/Carts/CartFuncService.cs index e96cbdc..401573c 100644 --- a/src/Retail/ShoppingCart/Carts/CartFuncService.cs +++ b/src/Retail/ShoppingCart/Carts/CartFuncService.cs @@ -24,8 +24,11 @@ public CartFuncService( OnExisting(cmd => GetStream(cmd.CartId), RemoveProductFromCart); - OnExisting(cmd - => GetStream(cmd.CartId), ConfirmCartForCheckout); + OnExisting(cmd + => GetStream(cmd.CartId), ConfirmCart); + + OnExisting(cmd + => GetStream(cmd.CartId), CancelCart); static StreamName GetStream(string id) => new($"Cart-{id}"); @@ -66,13 +69,21 @@ static IEnumerable RemoveProductFromCart( yield return new Events.EmptyCartDetected(cmd.CartId); } - static IEnumerable ConfirmCartForCheckout( + static IEnumerable ConfirmCart( CartState state, object[] originalEvents, - Commands.ConfirmCartForCheckout cmd) + Commands.ConfirmCart cmd) { if (state.CanProceedToCheckout()) yield return new Events.CartConfirmed(cmd.CartId); } + + static IEnumerable CancelCart( + CartState state, + object[] originalEvents, + Commands.CancelCart cmd) + { + yield return new Events.CartCancelled(cmd.CartId); + } } } diff --git a/src/Retail/ShoppingCart/Carts/CartState.cs b/src/Retail/ShoppingCart/Carts/CartState.cs index 4498209..af4339c 100644 --- a/src/Retail/ShoppingCart/Carts/CartState.cs +++ b/src/Retail/ShoppingCart/Carts/CartState.cs @@ -1,5 +1,6 @@ using Ecommerce.Eventuous.Exceptions; using Eventuous; +using ShoppingCart.Products; using static ShoppingCart.Carts.CartEvents.V1; namespace ShoppingCart.Carts; @@ -19,6 +20,7 @@ public CartState() On(Handle); On(Handle); On(Handle); + On(Handle); } private static CartState Handle(CartState state, CartOpened @event) => state with @@ -37,19 +39,27 @@ private static CartState Handle(CartState state, CartOpened @event) => state wit private static CartState Handle(CartState state, ProductRemovedFromCart @event) => state.Status switch { - CartStatus.Confirmed => throw InvalidStateChangeException.For(state.Id, CartStatus.Confirmed), + CartStatus.Confirmed => throw InvalidStateChangeException.For(state.Id, CartStatus.Confirmed), _ => state with { ProductItems = state.ProductItems.Remove(ProductItem.From(@event.ProductId, @event.Quantity)) } }; private static CartState Handle(CartState state, CartConfirmed @event) => state.Status switch { - CartStatus.Confirmed => throw InvalidStateChangeException.For(state.Id, CartStatus.Confirmed), + CartStatus.Confirmed => throw InvalidStateChangeException.For(state.Id, CartStatus.Confirmed), _ => state with { Status = CartStatus.Confirmed } }; + private static CartState Handle(CartState state, CartCancelled @event) => state.Status switch + { + CartStatus.Confirmed => throw InvalidStateChangeException.For(state.Id, CartStatus.Confirmed), + CartStatus.Cancelled => throw InvalidStateChangeException.For(state.Id, CartStatus.Cancelled), + _ => state with { Status = CartStatus.Cancelled } + }; + public bool CanProceedToCheckout() => Status switch { - CartStatus.Unset or CartStatus.Opened => false, + CartStatus.Unset => false, + CartStatus.Confirmed => false, _ => HasProductItems }; } diff --git a/src/Retail/ShoppingCart/Carts/CartStatus.cs b/src/Retail/ShoppingCart/Carts/CartStatus.cs index 5e7bbad..db8226a 100644 --- a/src/Retail/ShoppingCart/Carts/CartStatus.cs +++ b/src/Retail/ShoppingCart/Carts/CartStatus.cs @@ -4,5 +4,6 @@ public enum CartStatus { Unset = 0, Opened = 1, - Confirmed = 2 + Confirmed = 2, + Cancelled = 4 } diff --git a/src/Retail/ShoppingCart/Inventories/ICheckInventoryService.cs b/src/Retail/ShoppingCart/Inventories/IInventoryChecker.cs similarity index 62% rename from src/Retail/ShoppingCart/Inventories/ICheckInventoryService.cs rename to src/Retail/ShoppingCart/Inventories/IInventoryChecker.cs index 611bcf2..8ba8f61 100644 --- a/src/Retail/ShoppingCart/Inventories/ICheckInventoryService.cs +++ b/src/Retail/ShoppingCart/Inventories/IInventoryChecker.cs @@ -1,8 +1,8 @@ -using ShoppingCart.Carts; +using ShoppingCart.Products; namespace ShoppingCart.Inventories; -public interface ICheckInventoryService +public interface IInventoryChecker { IReadOnlyList Check(params ProductId[] productIds); } diff --git a/src/Retail/ShoppingCart/Inventories/CheckInventoryService.cs b/src/Retail/ShoppingCart/Inventories/InventoryChecker.cs similarity index 83% rename from src/Retail/ShoppingCart/Inventories/CheckInventoryService.cs rename to src/Retail/ShoppingCart/Inventories/InventoryChecker.cs index 1fb3cc9..2f42f61 100644 --- a/src/Retail/ShoppingCart/Inventories/CheckInventoryService.cs +++ b/src/Retail/ShoppingCart/Inventories/InventoryChecker.cs @@ -1,8 +1,8 @@ -using ShoppingCart.Carts; +using ShoppingCart.Products; namespace ShoppingCart.Inventories; -public class CheckInventoryService : ICheckInventoryService +public class InventoryChecker : IInventoryChecker { public IReadOnlyList Check(params ProductId[] productIds) { diff --git a/src/Retail/ShoppingCart/Prices/IQuotePriceService.cs b/src/Retail/ShoppingCart/Prices/IPriceQuoter.cs similarity index 65% rename from src/Retail/ShoppingCart/Prices/IQuotePriceService.cs rename to src/Retail/ShoppingCart/Prices/IPriceQuoter.cs index a7e0498..d872448 100644 --- a/src/Retail/ShoppingCart/Prices/IQuotePriceService.cs +++ b/src/Retail/ShoppingCart/Prices/IPriceQuoter.cs @@ -1,8 +1,8 @@ -using ShoppingCart.Carts; +using ShoppingCart.Products; namespace ShoppingCart.Prices; -public interface IQuotePriceService +public interface IPriceQuoter { IReadOnlyList Quote(params ProductItem[] productItems); } diff --git a/src/Retail/ShoppingCart/Prices/QuotePriceService.cs b/src/Retail/ShoppingCart/Prices/PriceQuoter.cs similarity index 86% rename from src/Retail/ShoppingCart/Prices/QuotePriceService.cs rename to src/Retail/ShoppingCart/Prices/PriceQuoter.cs index 515fba9..7927fd8 100644 --- a/src/Retail/ShoppingCart/Prices/QuotePriceService.cs +++ b/src/Retail/ShoppingCart/Prices/PriceQuoter.cs @@ -1,8 +1,8 @@ -using ShoppingCart.Carts; +using ShoppingCart.Products; namespace ShoppingCart.Prices; -public class QuotePriceService : IQuotePriceService +public class PriceQuoter : IPriceQuoter { public IReadOnlyList Quote(params ProductItem[] productItems) { diff --git a/src/Retail/ShoppingCart/Products/IProductValidator.cs b/src/Retail/ShoppingCart/Products/IProductValidator.cs new file mode 100644 index 0000000..5d577a0 --- /dev/null +++ b/src/Retail/ShoppingCart/Products/IProductValidator.cs @@ -0,0 +1,6 @@ +namespace ShoppingCart.Products; + +public interface IProductValidator +{ + IReadOnlyList<(ProductId productId, bool isValid)> Quote(params ProductId[] productIds); +} diff --git a/src/Retail/ShoppingCart/Carts/PricedProductItem.cs b/src/Retail/ShoppingCart/Products/PricedProductItem.cs similarity index 98% rename from src/Retail/ShoppingCart/Carts/PricedProductItem.cs rename to src/Retail/ShoppingCart/Products/PricedProductItem.cs index 6a6669b..2c85c48 100644 --- a/src/Retail/ShoppingCart/Carts/PricedProductItem.cs +++ b/src/Retail/ShoppingCart/Products/PricedProductItem.cs @@ -1,4 +1,4 @@ -namespace ShoppingCart.Carts; +namespace ShoppingCart.Products; public class PricedProductItem { diff --git a/src/Retail/ShoppingCart/Carts/ProductId.cs b/src/Retail/ShoppingCart/Products/ProductId.cs similarity index 67% rename from src/Retail/ShoppingCart/Carts/ProductId.cs rename to src/Retail/ShoppingCart/Products/ProductId.cs index 78af650..0041ebe 100644 --- a/src/Retail/ShoppingCart/Carts/ProductId.cs +++ b/src/Retail/ShoppingCart/Products/ProductId.cs @@ -1,5 +1,5 @@ using Eventuous; -namespace ShoppingCart.Carts; +namespace ShoppingCart.Products; public record ProductId(string Value) : Id(Value); diff --git a/src/Retail/ShoppingCart/Carts/ProductItem.cs b/src/Retail/ShoppingCart/Products/ProductItem.cs similarity index 98% rename from src/Retail/ShoppingCart/Carts/ProductItem.cs rename to src/Retail/ShoppingCart/Products/ProductItem.cs index 83bbfa5..e5e4298 100644 --- a/src/Retail/ShoppingCart/Carts/ProductItem.cs +++ b/src/Retail/ShoppingCart/Products/ProductItem.cs @@ -1,4 +1,4 @@ -namespace ShoppingCart.Carts; +namespace ShoppingCart.Products; public record ProductItem { diff --git a/src/Retail/ShoppingCart/Products/ProductValidator.cs b/src/Retail/ShoppingCart/Products/ProductValidator.cs new file mode 100644 index 0000000..4bc0e70 --- /dev/null +++ b/src/Retail/ShoppingCart/Products/ProductValidator.cs @@ -0,0 +1,16 @@ +namespace ShoppingCart.Products; + +public class ProductValidator : IProductValidator +{ + public IReadOnlyList<(ProductId productId, bool isValid)> Quote(params ProductId[] productIds) + { + if (productIds.Length == 0) + throw new ArgumentOutOfRangeException(nameof(productIds.Length)); + + // TODO: access cache or make a gRPC call + + return productIds + .Select(pi => new ValueTuple(pi, true)) + .ToList(); + } +}