diff --git a/docs/guide/durability/marten/event-sourcing.md b/docs/guide/durability/marten/event-sourcing.md index 9a8928503..05cb44c2a 100644 --- a/docs/guide/durability/marten/event-sourcing.md +++ b/docs/guide/durability/marten/event-sourcing.md @@ -1,9 +1,9 @@ # Aggregate Handlers and Event Sourcing ::: tip -You can forgo the `[AggregateHandler]` attribute by instead naming your message handler type with the `AggregateHandler` suffix -if the Wolverine/Marten integration is applied to your application. Do note that you will still have to use the attribute to opt into -exclusive write locking. +Only use the "aggregate handler workflow" is you are wanting to potentially write new events to an existing event stream. If all you +need in a message handler or HTTP endpoint is a read-only copy of an event streamed aggregate from Marten, use the `[ReadAggregate]` attribute +instead that has a little bit lighter weight runtime within Marten. ::: See the [OrderEventSourcingSample project on GitHub](https://github.com/JasperFx/wolverine/tree/main/src/Persistence/OrderEventSourcingSample) for more samples. @@ -67,7 +67,7 @@ public class Order } } ``` -snippet source | anchor +snippet source | anchor At a minimum, we're going to want a command handler for this command message that marks an order item as ready to ship and then evaluates whether @@ -79,7 +79,7 @@ or not based on the current state of the `Order` aggregate whether or not the lo // OrderId refers to the identity of the Order aggregate public record MarkItemReady(Guid OrderId, string ItemName, int Version); ``` -snippet source | anchor +snippet source | anchor In the code above, we're also utilizing Wolverine's [outbox messaging](/guide/durability/) support to both order and guarantee the delivery of a `ShipOrder` message when @@ -139,7 +139,7 @@ public async Task Post( await session.SaveChangesAsync(); } ``` -snippet source | anchor +snippet source | anchor Hopefully, that code is easy to understand, but there's some potentially repetitive code @@ -179,7 +179,7 @@ public static IEnumerable Handle(MarkItemReady command, Order order) } } ``` -snippet source | anchor +snippet source | anchor In the case above, Wolverine is wrapping middleware around our basic command handler to @@ -228,6 +228,158 @@ public class MarkItemReadyHandler1442193977 : MessageHandler As you probably guessed, there are some naming conventions or other questions you need to be aware of before you use this middleware strategy. +Alternatively, there is also the newer `[WriteAttribute]` usage, with this example being a functional alternative +mark up: + + + +```cs +public static IEnumerable Handle( + // The command + MarkItemReady command, + + // This time we'll mark the parameter as the "aggregate" + [WriteAggregate] Order order) +{ + if (order.Items.TryGetValue(command.ItemName, out var item)) + { + // Not doing this in a purist way here, but just + // trying to illustrate the Wolverine mechanics + item.Ready = true; + + // Mark that the this item is ready + yield return new ItemReady(command.ItemName); + } + else + { + // Some crude validation + throw new InvalidOperationException($"Item {command.ItemName} does not exist in this order"); + } + + // If the order is ready to ship, also emit an OrderReady event + if (order.IsReadyToShip()) + { + yield return new OrderReady(); + } +} +``` +snippet source | anchor + + +The `[WriteAggregate]` attribute also opts into the "aggregate handler workflow", but is placed at the parameter level +instead of the class level. This was added to extend the "aggregate handler workflow" to operations that involve multiple +event streams in one transaction. + +::: tip +`[WriteAggregate]` works equally on message handlers as it does on HTTP endpoints. In fact, the older `[Aggregate]` attribute +in Wolverine.Http.Marten is now just a subclass of `[WriteAggregate]`. +::: + +## Validation on Stream Existence + +By default, the "aggregate handler workflow" does no validation on whether or not the identified event stream actually +exists at runtime, and it's possible to receive a null for the aggregate in this example if the aggregate does not exist: + + + +```cs +public static IEnumerable Handle( + // The command + MarkItemReady command, + + // This time we'll mark the parameter as the "aggregate" + [WriteAggregate] Order order) +{ + if (order.Items.TryGetValue(command.ItemName, out var item)) + { + // Not doing this in a purist way here, but just + // trying to illustrate the Wolverine mechanics + item.Ready = true; + + // Mark that the this item is ready + yield return new ItemReady(command.ItemName); + } + else + { + // Some crude validation + throw new InvalidOperationException($"Item {command.ItemName} does not exist in this order"); + } + + // If the order is ready to ship, also emit an OrderReady event + if (order.IsReadyToShip()) + { + yield return new OrderReady(); + } +} +``` +snippet source | anchor + + +As long as you handle the case where the requested is null, you can even effectively start a new stream by emitting events +from your handler or HTTP endpoint. + +If you do want to protect message handlers or HTTP endpoints from acting on missing streams because of bad user inputs +(or who knows what, it's a chaotic world and you should never trust your system is receiving valid input), you now have +some options to mark the aggregate itself as required and even control how Wolverine deals with the aggregate being missing +as shown in these sample signatures below: + + + +```cs +public static class ValidatedMarkItemReadyHandler +{ + public static IEnumerable Handle( + // The command + MarkItemReady command, + + // In HTTP this will return a 404 status code and stop + // the request if the Order is not found + + // In message handlers, this will log that the Order was not found, + // then stop processing. The message would be effectively + // discarded + [WriteAggregate(Required = true)] Order order) => []; + + [WolverineHandler] + public static IEnumerable Handle2( + // The command + MarkItemReady command, + + // In HTTP this will return a 400 status code and + // write out a ProblemDetails response with a default message explaining + // the data that could not be found + [WriteAggregate(Required = true, OnMissing = OnMissing.ProblemDetailsWith400)] Order order) => []; + + [WolverineHandler] + public static IEnumerable Handle3( + // The command + MarkItemReady command, + + // In HTTP this will return a 404 status code and + // write out a ProblemDetails response with a default message explaining + // the data that could not be found + [WriteAggregate(Required = true, OnMissing = OnMissing.ProblemDetailsWith404)] Order order) => []; + + + [WolverineHandler] + public static IEnumerable Handle4( + // The command + MarkItemReady command, + + // In HTTP this will return a 400 status code and + // write out a ProblemDetails response with a custom message. + // Wolverine will substitute in the order identity into the message for "{0}" + // In message handlers, Wolverine will log using your custom message then discard the message + [WriteAggregate(Required = true, OnMissing = OnMissing.ProblemDetailsWith404, MissingMessage = "Cannot find Order {0}")] Order order) => []; + +} +``` +snippet source | anchor + + +The `Required`, `OnMissing`, and `MissingMessage` properties behave consistently on all Wolverine attributes +like `[Entity]` or `[WriteAggregate]` or `[ReadAggregate]`. + ### Handler Method Signatures The Marten workflow command handler method signature needs to follow these rules: @@ -328,7 +480,7 @@ public static async Task<(Events, OutgoingMessages)> HandleAsync(MarkItemReady c return (events, messages); } ``` -snippet source | anchor +snippet source | anchor @@ -346,7 +498,7 @@ by appending "Id" to the aggregate type name (it's not case sensitive if you wer // OrderId refers to the identity of the Order aggregate public record MarkItemReady(Guid OrderId, string ItemName, int Version); ``` -snippet source | anchor +snippet source | anchor Or if you want to use a different member, bypass the convention, or just don't like conventional @@ -367,6 +519,32 @@ public class MarkItemReady ``` snippet source | anchor +~~~~ +## Validation + +Every possible attribute for triggering the "aggregate handler workflow" includes support for data requirements as +shown below with `[ReadAggregate]`: + + + +```cs +// Straight up 404 on missing +[WolverineGet("/letters1/{id}")] +public static LetterAggregate GetLetter1([ReadAggregate] LetterAggregate letters) => letters; + +// Not required +[WolverineGet("/letters2/{id}")] +public static string GetLetter2([ReadAggregate(Required = false)] LetterAggregate letters) +{ + return letters == null ? "No Letters" : "Got Letters"; +} + +// Straight up 404 & problem details on missing +[WolverineGet("/letters3/{id}")] +public static LetterAggregate GetLetter3([ReadAggregate(OnMissing = OnMissing.ProblemDetailsWith404)] LetterAggregate letters) => letters; +``` +snippet source | anchor + ## Forwarding Events @@ -531,9 +709,19 @@ public static class FindLettersHandler { return new LetterAggregateEnvelope(aggregate); } + + [WolverineHandler] + public static LetterAggregateEnvelope Handle2( + FindAggregate command, + + // Just showing you that you can disable the validation + [ReadAggregate(Required = false)] LetterAggregate aggregate) + { + return aggregate == null ? null : new LetterAggregateEnvelope(aggregate); + } } ``` -snippet source | anchor +snippet source | anchor If the aggregate doesn't exist, the HTTP request will stop with a 404 status code. @@ -542,3 +730,31 @@ The aggregate/stream identity is found with the same rules as the `[Entity]` or 1. You can specify a particular request body property name or route argument 2. Look for a request body property or route argument named "EntityTypeId" 3. Look for a request body property or route argument named "Id" or "id" + +You can override the validation rules for how Wolverine handles an aggregate / event stream not being found +by setting these properties on `[ReadAttribute]` (which is much more useful for HTTP endpoints): + + + +```cs +// Straight up 404 on missing +[WolverineGet("/letters1/{id}")] +public static LetterAggregate GetLetter1([ReadAggregate] LetterAggregate letters) => letters; + +// Not required +[WolverineGet("/letters2/{id}")] +public static string GetLetter2([ReadAggregate(Required = false)] LetterAggregate letters) +{ + return letters == null ? "No Letters" : "Got Letters"; +} + +// Straight up 404 & problem details on missing +[WolverineGet("/letters3/{id}")] +public static LetterAggregate GetLetter3([ReadAggregate(OnMissing = OnMissing.ProblemDetailsWith404)] LetterAggregate letters) => letters; +``` +snippet source | anchor + + +There is also an option with `OnMissing` to throw a `RequiredDataMissingException` exception if a required data element +is missing. This option is probably most useful with message handlers where you may want to key off the exception with custom +error handling rules. diff --git a/docs/guide/handlers/persistence.md b/docs/guide/handlers/persistence.md index 18e0844d5..a32b9c6d9 100644 --- a/docs/guide/handlers/persistence.md +++ b/docs/guide/handlers/persistence.md @@ -129,10 +129,15 @@ public enum ValueSource /// /// The value should be sourced by a route argument of an HTTP request /// - RouteValue + RouteValue, + + /// + /// The value should be sourced by a query string parameter of an HTTP request + /// + FromQueryString } ``` -snippet source | anchor +snippet source | anchor Some other facts to know about `[Entity]` usage: diff --git a/docs/guide/http/marten.md b/docs/guide/http/marten.md index 4e1fb997f..a235061de 100644 --- a/docs/guide/http/marten.md +++ b/docs/guide/http/marten.md @@ -96,7 +96,7 @@ public static Invoice GetSoftDeleted([Document(Required = true, MaybeSoftDeleted -## Marten Aggregate Workflow +## Marten Aggregate Workflow The http endpoints can play inside the full "critter stack" combination with [Marten](https://martendb.io) with Wolverine's [specific support for Event Sourcing and CQRS](/guide/durability/marten/event-sourcing). Originally this has been done @@ -105,6 +105,17 @@ Wolverine 1.10 added a more HTTP-centric approach using route arguments. ### Using Route Arguments +::: tip +The `[Aggregate]` attribute was originally meant for the "aggregate handler workflow" where Wolverine is interacting with +Marten with the assumption that it will be appending events to Marten streams and getting you ready for versioning assertions. + +If all you need is a read only copy of Marten aggregate data, the `[ReadAggregate]` is a lighter weight option. + +Also, the `[WriteAggregate]` attribute has the exact same behavior as the older `[Aggregate]`, but is available in both +message handlers and HTTP endpoints. You may want to prefer `[WriteAggregate]` just to be more clear in the code about +what's happening. +::: + To opt into the Wolverine + Marten "aggregate workflow", but use data from route arguments for the aggregate id, use the new `[Aggregate]` attribute from Wolverine.Http.Marten on endpoint method parameters like shown below: @@ -291,7 +302,7 @@ public static (OrderStatus, Events) Post(MarkItemReady command, Order order) return (new OrderStatus(order.Id, order.IsReadyToShip()), events); } ``` -snippet source | anchor +snippet source | anchor ### Responding with the Updated Aggregate @@ -317,7 +328,7 @@ public static (UpdatedAggregate, Events) ConfirmDifferent(ConfirmOrder command, ); } ``` -snippet source | anchor +snippet source | anchor ## Reading the Latest Version of an Aggregate @@ -336,7 +347,7 @@ an HTTP endpoint method, use the `[ReadAggregate]` attribute like this: [WolverineGet("/orders/latest/{id}")] public static Order GetLatest(Guid id, [ReadAggregate] Order order) => order; ``` -snippet source | anchor +snippet source | anchor If the aggregate doesn't exist, the HTTP request will stop with a 404 status code. diff --git a/docs/guide/http/querystring.md b/docs/guide/http/querystring.md index b8508da5b..2cf59819d 100644 --- a/docs/guide/http/querystring.md +++ b/docs/guide/http/querystring.md @@ -120,7 +120,7 @@ public static class QueryOrdersEndpoint } } ``` -snippet source | anchor +snippet source | anchor Because we've used the `[FromQuery]` attribute on a parameter argument that's not a simple type, Wolverine is trying to bind diff --git a/docs/guide/messaging/expiration.md b/docs/guide/messaging/expiration.md index 8c7a28197..d0b5d31d2 100644 --- a/docs/guide/messaging/expiration.md +++ b/docs/guide/messaging/expiration.md @@ -20,7 +20,7 @@ public DateTimeOffset? DeliverBy set => _deliverBy = value?.ToUniversalTime(); } ``` -snippet source | anchor +snippet source | anchor At runtime, Wolverine will: diff --git a/docs/guide/messaging/transports/kafka.md b/docs/guide/messaging/transports/kafka.md index 180071401..560bf214c 100644 --- a/docs/guide/messaging/transports/kafka.md +++ b/docs/guide/messaging/transports/kafka.md @@ -84,6 +84,8 @@ using var host = await Host.CreateDefaultBuilder() // Override the consumer configuration for only this // topic + // This is NOT combinatorial with the ConfigureConsumers() call above + // and completely replaces the parent configuration .ConfigureConsumer(config => { // This will also set the Envelope.GroupId for any @@ -102,7 +104,7 @@ using var host = await Host.CreateDefaultBuilder() opts.Services.AddResourceSetupOnStartup(); }).StartAsync(); ``` -snippet source | anchor +snippet source | anchor The various `Configure*****()` methods provide quick access to the full API of the Confluent Kafka library for security @@ -191,14 +193,39 @@ public static class KafkaInstrumentation } } ``` -snippet source | anchor +snippet source | anchor ## Connecting to Multiple Brokers Wolverine supports interacting with multiple Kafka brokers within one application like this: -snippet: sample_using_multiple_kafka_brokers + + +```cs +using var host = await Host.CreateDefaultBuilder() + .UseWolverine(opts => + { + opts.UseKafka("localhost:9092"); + opts.AddNamedKafkaBroker(new BrokerName("americas"), "americas-kafka:9092"); + opts.AddNamedKafkaBroker(new BrokerName("emea"), "emea-kafka:9092"); + + // Just publish all messages to Kafka topics + // based on the message type (or message attributes) + // This will get fancier in the near future + opts.PublishAllMessages().ToKafkaTopicsOnNamedBroker(new BrokerName("americas")); + + // Or explicitly make subscription rules + opts.PublishMessage() + .ToKafkaTopicOnNamedBroker(new BrokerName("emea"), "colors"); + + // Listen to topics + opts.ListenToKafkaTopicOnNamedBroker(new BrokerName("americas"), "red"); + // Other configuration + }).StartAsync(); +``` +snippet source | anchor + Note that the `Uri` scheme within Wolverine for any endpoints from a "named" Kafka broker is the name that you supply -for the broker. So in the example above, you might see `Uri` values for `emea://colors` or `americas://red`. \ No newline at end of file +for the broker. So in the example above, you might see `Uri` values for `emea://colors` or `americas://red`. diff --git a/docs/guide/messaging/transports/sqs/conventional-routing.md b/docs/guide/messaging/transports/sqs/conventional-routing.md index 89c43db81..62e41cc58 100644 --- a/docs/guide/messaging/transports/sqs/conventional-routing.md +++ b/docs/guide/messaging/transports/sqs/conventional-routing.md @@ -12,7 +12,7 @@ var host = await Host.CreateDefaultBuilder() .UseConventionalRouting(); }).StartAsync(); ``` -snippet source | anchor +snippet source | anchor In this case any outgoing message types that aren't handled locally or have an explicit subscription will be automatically routed diff --git a/docs/guide/messaging/transports/sqs/deadletterqueues.md b/docs/guide/messaging/transports/sqs/deadletterqueues.md index a8e2520d2..4c4dc55e9 100644 --- a/docs/guide/messaging/transports/sqs/deadletterqueues.md +++ b/docs/guide/messaging/transports/sqs/deadletterqueues.md @@ -26,7 +26,7 @@ var host = await Host.CreateDefaultBuilder() }); }).StartAsync(); ``` -snippet source | anchor +snippet source | anchor ## Disabling All Native Dead Letter Queueing diff --git a/docs/guide/messaging/transports/sqs/index.md b/docs/guide/messaging/transports/sqs/index.md index 6328ead32..5100138c2 100644 --- a/docs/guide/messaging/transports/sqs/index.md +++ b/docs/guide/messaging/transports/sqs/index.md @@ -28,7 +28,7 @@ var host = await Host.CreateDefaultBuilder() .AutoPurgeOnStartup(); }).StartAsync(); ``` -snippet source | anchor +snippet source | anchor @@ -58,7 +58,7 @@ builder.UseWolverine(opts => using var host = builder.Build(); await host.StartAsync(); ``` -snippet source | anchor +snippet source | anchor @@ -76,7 +76,7 @@ var host = await Host.CreateDefaultBuilder() opts.UseAmazonSqsTransportLocally(); }).StartAsync(); ``` -snippet source | anchor +snippet source | anchor And lastly, if you want to explicitly supply an access and secret key for your credentials to SQS, you can use this syntax: @@ -110,14 +110,45 @@ builder.UseWolverine(opts => using var host = builder.Build(); await host.StartAsync(); ``` -snippet source | anchor +snippet source | anchor ## Connecting to Multiple Brokers Wolverine supports interacting with multiple Amazon SQS brokers within one application like this: -snippet: sample_using_multiple_sqs_brokers + + +```cs +using var host = await Host.CreateDefaultBuilder() + .UseWolverine(opts => + { + opts.UseAmazonSqsTransport(config => + { + // Add configuration for connectivity + }); + + opts.AddNamedAmazonSqsBroker(new BrokerName("americas"), config => + { + // Add configuration for connectivity + }); + + opts.AddNamedAmazonSqsBroker(new BrokerName("emea"), config => + { + // Add configuration for connectivity + }); + + // Or explicitly make subscription rules + opts.PublishMessage() + .ToSqsQueueOnNamedBroker(new BrokerName("emea"), "colors"); + + // Listen to topics + opts.ListenToSqsQueueOnNamedBroker(new BrokerName("americas"), "red"); + // Other configuration + }).StartAsync(); +``` +snippet source | anchor + Note that the `Uri` scheme within Wolverine for any endpoints from a "named" Amazon SQS broker is the name that you supply -for the broker. So in the example above, you might see `Uri` values for `emea://colors` or `americas://red`. \ No newline at end of file +for the broker. So in the example above, you might see `Uri` values for `emea://colors` or `americas://red`. diff --git a/docs/guide/messaging/transports/sqs/interoperability.md b/docs/guide/messaging/transports/sqs/interoperability.md index b0c2fea66..5f0f24a01 100644 --- a/docs/guide/messaging/transports/sqs/interoperability.md +++ b/docs/guide/messaging/transports/sqs/interoperability.md @@ -24,7 +24,7 @@ using var host = await Host.CreateDefaultBuilder() o => { o.PropertyNamingPolicy = JsonNamingPolicy.CamelCase; }); }).StartAsync(); ``` -snippet source | anchor +snippet source | anchor Likewise, to send raw JSON to external systems, you have this option: @@ -45,7 +45,7 @@ using var host = await Host.CreateDefaultBuilder() o => { o.PropertyNamingPolicy = JsonNamingPolicy.CamelCase; }); }).StartAsync(); ``` -snippet source | anchor +snippet source | anchor ## Advanced Interoperability @@ -86,7 +86,7 @@ public class CustomSqsMapper : ISqsEnvelopeMapper } } ``` -snippet source | anchor +snippet source | anchor And apply this to any or all of your SQS endpoints with the configuration fluent interface as shown in this sample: @@ -104,7 +104,7 @@ using var host = await Host.CreateDefaultBuilder() .ConfigureSenders(s => s.InteropWith(new CustomSqsMapper())); }).StartAsync(); ``` -snippet source | anchor +snippet source | anchor ## Receive messages from Amazon SNS @@ -125,7 +125,7 @@ using var host = await Host.CreateDefaultBuilder() .ReceiveSnsTopicMessage(); }).StartAsync(); ``` -snippet source | anchor +snippet source | anchor It's possible for the original message that was sent to SNS to be in a different format. @@ -146,5 +146,5 @@ using var host = await Host.CreateDefaultBuilder() new RawJsonSqsEnvelopeMapper(typeof(Message1), new JsonSerializerOptions())); }).StartAsync(); ``` -snippet source | anchor +snippet source | anchor diff --git a/docs/guide/messaging/transports/sqs/listening.md b/docs/guide/messaging/transports/sqs/listening.md index 9fbc256c3..a884b7e4b 100644 --- a/docs/guide/messaging/transports/sqs/listening.md +++ b/docs/guide/messaging/transports/sqs/listening.md @@ -30,5 +30,5 @@ var host = await Host.CreateDefaultBuilder() .ListenerCount(5); }).StartAsync(); ``` -snippet source | anchor +snippet source | anchor diff --git a/docs/guide/messaging/transports/sqs/publishing.md b/docs/guide/messaging/transports/sqs/publishing.md index e2c343d2c..3bd6e0176 100644 --- a/docs/guide/messaging/transports/sqs/publishing.md +++ b/docs/guide/messaging/transports/sqs/publishing.md @@ -25,5 +25,5 @@ var host = await Host.CreateDefaultBuilder() }); }).StartAsync(); ``` -snippet source | anchor +snippet source | anchor diff --git a/src/Http/Wolverine.Http.Marten/AggregateAttribute.cs b/src/Http/Wolverine.Http.Marten/AggregateAttribute.cs index 7e1f7bf53..af4b9223f 100644 --- a/src/Http/Wolverine.Http.Marten/AggregateAttribute.cs +++ b/src/Http/Wolverine.Http.Marten/AggregateAttribute.cs @@ -1,200 +1,19 @@ -using System.Reflection; -using JasperFx.CodeGeneration.Frames; -using JasperFx.CodeGeneration.Model; -using JasperFx.Core; -using JasperFx.Core.Reflection; -using JasperFx.Events; -using Marten; -using Marten.Events; -using Microsoft.AspNetCore.Http; using Wolverine.Marten; -using Wolverine.Marten.Codegen; -using Wolverine.Marten.Publishing; -using Wolverine.Runtime; namespace Wolverine.Http.Marten; /// -/// Marks a parameter to an HTTP endpoint as being part of the Marten event sourcing -/// "aggregate handler" workflow +/// Marks a parameter to an HTTP endpoint as being part of the Marten event sourcing +/// "aggregate handler" workflow /// [AttributeUsage(AttributeTargets.Parameter)] -public class AggregateAttribute : HttpChainParameterAttribute +public class AggregateAttribute : WriteAggregateAttribute { - public static IResult ValidateAggregateExists(IEventStream stream) - { - return stream.Aggregate == null ? Results.NotFound() : WolverineContinue.Result(); - } - - public string? RouteOrParameterName { get; } - public AggregateAttribute() { } - public Variable IdVariable { get; private set; } - public Type? CommandType { get; private set; } - - /// - /// Specify exactly the route or parameter name that has the - /// identity for this aggregate argument - /// - /// - public AggregateAttribute(string routeOrParameterName) - { - RouteOrParameterName = routeOrParameterName; - } - - /// - /// Opt into exclusive locking or optimistic checks on the aggregate stream - /// version. Default is Optimistic - /// - public ConcurrencyStyle LoadStyle { get; set; } = ConcurrencyStyle.Optimistic; - - - public override Variable Modify(HttpChain chain, ParameterInfo parameter, IServiceContainer container) - { - if (chain.Method.Method.GetParameters().Count(x => x.HasAttribute()) > 1) - { - throw new InvalidOperationException( - "It is only possible (today) to use a single [Aggregate] attribute on an HTTP handler method. Maybe use [ReadAggregate] if all you need is the projected data"); - } - - chain.Metadata.Produces(404); - - AggregateType = parameter.ParameterType; - if (AggregateType.IsNullable()) - { - AggregateType = AggregateType.GetInnerTypeFromNullable(); - } - - var store = container.GetInstance(); - var idType = store.Options.FindOrResolveDocumentType(AggregateType).IdType; - - IdVariable = FindRouteVariable(idType, chain); - if (IdVariable == null) - { - throw new InvalidOperationException( - "Cannot determine an identity variable for this aggregate from the route arguments"); - } - - // Store information about the aggregate handling in the chain just in - // case they're using LatestAggregate - new AggregateHandling(AggregateType, IdVariable).Store(chain); - - VersionVariable = findVersionVariable(chain); - CommandType = chain.InputType(); - - var sessionCreator = MethodCall.For(x => x.OpenSession(null!)); - chain.Middleware.Add(sessionCreator); - - chain.Middleware.Add(new EventStoreFrame()); - var loader = typeof(LoadAggregateFrame<>).CloseAndBuildAs(this, AggregateType); - chain.Middleware.Add(loader); - - // Use the active document session as an IQuerySession instead of creating a new one - chain.Method.TrySetArgument(new Variable(typeof(IQuerySession), sessionCreator.ReturnVariable!.Usage)); - - AggregateHandlerAttribute.DetermineEventCaptureHandling(chain, chain.Method, AggregateType); - - AggregateHandlerAttribute.ValidateMethodSignatureForEmittedEvents(chain, chain.Method, chain); - - var aggregate = AggregateHandlerAttribute.RelayAggregateToHandlerMethod(loader.Creates.Single(), chain.Method, AggregateType); - - chain.Postprocessors.Add(MethodCall.For(x => x.SaveChangesAsync(default))); - - return aggregate; - } - - public Variable VersionVariable { get; private set; } - - internal Variable? findVersionVariable(HttpChain chain) - { - if (chain.FindRouteVariable(typeof(int), "version", out var routeVariable)) - { - return routeVariable; - } - - if (chain.InputType() != null) - { - var member = AggregateHandlerAttribute.DetermineVersionMember(chain.InputType()); - if (member != null) - { - return new MemberAccessFrame(chain.InputType(), member, "version").Variable; - } - } - - return null; - } - - internal Type AggregateType { get; set; } - - public Variable? FindRouteVariable(Type idType, HttpChain chain) - { - if (RouteOrParameterName.IsNotEmpty()) - { - if (chain.FindRouteVariable(idType, RouteOrParameterName, out var variable)) - { - return variable; - } - } - - if (chain.FindRouteVariable(idType, $"{AggregateType.Name.ToCamelCase()}Id", out var v2)) - { - return v2; - } - - if (chain.FindRouteVariable(idType, "id", out var v3)) - { - return v3; - } - - if (chain.FindQuerystringVariable(idType, "id", out var v4)) - { - return v4; - } - - if (chain.FindQuerystringVariable(idType, $"{AggregateType.Name.ToCamelCase()}Id", out var v5)) - { - return v5; - } - - return null; - } - - public static async Task<(IEventStream, IResult)> FetchForExclusiveWriting(Guid id, IDocumentSession session, CancellationToken cancellationToken) where T : class - { - var stream = await session.Events.FetchForExclusiveWriting(id, cancellationToken); - return (stream, stream.Aggregate == null ? Results.NotFound() : WolverineContinue.Result()); - } - - public static async Task<(IEventStream, IResult)> FetchForWriting(Guid id, IDocumentSession session, CancellationToken cancellationToken) where T : class - { - var stream = await session.Events.FetchForExclusiveWriting(id, cancellationToken); - return (stream, stream.Aggregate == null ? Results.NotFound() : WolverineContinue.Result()); - } - - public static async Task<(IEventStream, IResult)> FetchForWriting(Guid id, long version, IDocumentSession session, CancellationToken cancellationToken) where T : class - { - var stream = await session.Events.FetchForWriting(id, version, cancellationToken); - return (stream, stream.Aggregate == null ? Results.NotFound() : WolverineContinue.Result()); - } - - public static async Task<(IEventStream, IResult)> FetchForExclusiveWriting(string key, IDocumentSession session, CancellationToken cancellationToken) where T : class - { - var stream = await session.Events.FetchForExclusiveWriting(key, cancellationToken); - return (stream, stream.Aggregate == null ? Results.NotFound() : WolverineContinue.Result()); - } - - public static async Task<(IEventStream, IResult)> FetchForWriting(string key, IDocumentSession session, CancellationToken cancellationToken) where T : class - { - var stream = await session.Events.FetchForExclusiveWriting(key, cancellationToken); - return (stream, stream.Aggregate == null ? Results.NotFound() : WolverineContinue.Result()); - } - - public static async Task<(IEventStream, IResult)> FetchForWriting(string key, long version, IDocumentSession session, CancellationToken cancellationToken) where T : class + public AggregateAttribute(string? routeOrParameterName) : base(routeOrParameterName) { - var stream = await session.Events.FetchForWriting(key, version, cancellationToken); - return (stream, stream.Aggregate == null ? Results.NotFound() : WolverineContinue.Result()); } -} +} \ No newline at end of file diff --git a/src/Http/Wolverine.Http.Marten/DocumentAttribute.cs b/src/Http/Wolverine.Http.Marten/DocumentAttribute.cs index d65e36990..11d5998e8 100644 --- a/src/Http/Wolverine.Http.Marten/DocumentAttribute.cs +++ b/src/Http/Wolverine.Http.Marten/DocumentAttribute.cs @@ -1,99 +1,38 @@ using System.Reflection; +using JasperFx.CodeGeneration; using JasperFx.CodeGeneration.Frames; using JasperFx.CodeGeneration.Model; -using JasperFx.Core; +using JasperFx.Core.Reflection; using Marten; using Microsoft.AspNetCore.Http; -using Wolverine.Http.Policies; +using Wolverine.Attributes; +using Wolverine.Configuration; +using Wolverine.Persistence; +using Wolverine.Persistence.Sagas; using Wolverine.Runtime; namespace Wolverine.Http.Marten; /// -/// Marks a parameter to an HTTP endpoint as being loaded as a Marten -/// document identified by a route argument. If the route argument -/// is not specified, this would look for either "typeNameId" or "id" +/// Marks a parameter to an HTTP endpoint as being loaded as a Marten +/// document identified by a route argument. If the route argument +/// is not specified, this would look for either "typeNameId" or "id". +/// +/// This is 100% equivalent to the more generic [Entity] attribute now /// [AttributeUsage(AttributeTargets.Parameter)] -public class DocumentAttribute : HttpChainParameterAttribute +public class DocumentAttribute : EntityAttribute { - public string? RouteArgumentName { get; } - public DocumentAttribute() { + ValueSource = ValueSource.Anything; } - public DocumentAttribute(string routeArgumentName) + public DocumentAttribute(string routeArgumentName) : base(routeArgumentName) { - RouteArgumentName = routeArgumentName; - } - - /// - /// Should the absence of this document cause the endpoint to return a 404 Not Found response? - /// Default is true. - /// - public bool Required { get; set; } = true; - - /// - /// If the document is soft-deleted, whether the endpoint should receive the document (true) or NULL (false). - /// Set it to false and combine it with so a 404 will be returned for soft-deleted documents. - /// - public bool MaybeSoftDeleted { get; set; } = true; - - public override Variable Modify(HttpChain chain, ParameterInfo parameter, IServiceContainer container) - { - chain.Metadata.Produces(404); - - var store = container.GetInstance(); - var documentType = parameter.ParameterType; - var mapping = store.Options.FindOrResolveDocumentType(documentType); - var idType = mapping.IdType; - - var argument = FindRouteVariable(idType, documentType, chain); - - var loader = typeof(IQuerySession).GetMethods() - .FirstOrDefault(x => x.Name == nameof(IDocumentSession.LoadAsync) && x.GetParameters()[0].ParameterType == idType); - - var load = new MethodCall(typeof(IDocumentSession), loader.MakeGenericMethod(documentType)); - load.Arguments[0] = argument; - - chain.Middleware.Add(load); - - if (MaybeSoftDeleted is false && mapping.Metadata.IsSoftDeleted.Enabled) - { - var frame = new SetVariableToNullIfSoftDeletedFrame(parameter.ParameterType); - chain.Middleware.Add(frame); - } - if (Required) - { - var frame = new SetStatusCodeAndReturnIfEntityIsNullFrame(parameter.ParameterType); - chain.Middleware.Add(frame); - } - - return load.ReturnVariable; } - public Variable? FindRouteVariable(Type idType, Type documentType, HttpChain chain) - { - if (RouteArgumentName.IsNotEmpty()) - { - if (chain.FindRouteVariable(idType, RouteArgumentName, out var variable)) - { - return variable; - } - } - - if (chain.FindRouteVariable(idType, $"{documentType.Name.ToCamelCase()}Id", out var v2)) - { - return v2; - } - - if (chain.FindRouteVariable(idType, "id", out var v3)) - { - return v3; - } - - return null; - } + [Obsolete("Prefer the more generic ArgumentName")] + public string? RouteArgumentName => ArgumentName; } \ No newline at end of file diff --git a/src/Http/Wolverine.Http.Marten/LoadAggregateFrame.cs b/src/Http/Wolverine.Http.Marten/LoadAggregateFrame.cs deleted file mode 100644 index aba35bfcd..000000000 --- a/src/Http/Wolverine.Http.Marten/LoadAggregateFrame.cs +++ /dev/null @@ -1,62 +0,0 @@ -using JasperFx.CodeGeneration; -using JasperFx.CodeGeneration.Frames; -using JasperFx.CodeGeneration.Model; -using JasperFx.Core.Reflection; -using Marten; -using Marten.Events; -using Microsoft.AspNetCore.Http; -using Wolverine.Marten; - -namespace Wolverine.Http.Marten; - -internal class LoadAggregateFrame : AsyncFrame where T : class -{ - private Variable _id; - private Variable _session; - private Variable _cancellation; - private Variable _version; - private readonly string _methodName; - private readonly AggregateAttribute _att; - - public LoadAggregateFrame(AggregateAttribute att) - { - EventStream = new Variable(typeof(IEventStream), "eventStream", this); - _methodName = (att.LoadStyle == ConcurrencyStyle.Exclusive) ? nameof(IEventStoreOperations.FetchForExclusiveWriting) : nameof(IEventStoreOperations.FetchForWriting); - Type[] argTypes = null; - _version = att.VersionVariable; - _id = att.IdVariable; - - _att = att; - } - - public Variable EventStream { get; } - - public override void GenerateCode(GeneratedMethod method, ISourceWriter writer) - { - var versionArg = _version == null ? "" : $"{_version.Usage},"; - writer.Write($"var {EventStream.Usage} = await {_session.Usage}.Events.{_methodName}<{typeof(T).FullNameInCode()}>({_id.Usage}, {versionArg}{_cancellation.Usage});"); - writer.Write($"BLOCK:if ({EventStream.Usage}.Aggregate == null)"); - writer.Write($"await {typeof(Results).FullNameInCode()}.{nameof(Results.NotFound)}().{nameof(IResult.ExecuteAsync)}(httpContext);"); - writer.Write("return;"); - writer.FinishBlock(); - - Next?.GenerateCode(method, writer); - } - - public override IEnumerable FindVariables(IMethodVariables chain) - { - yield return _id; - - _session = chain.FindVariable(typeof(IDocumentSession)); - yield return _session; - - _cancellation = chain.FindVariable(typeof(CancellationToken)); - yield return _cancellation; - - if (_att.LoadStyle == ConcurrencyStyle.Optimistic && _att.VersionVariable != null) - { - _version = _att.VersionVariable; - yield return _version; - } - } -} \ No newline at end of file diff --git a/src/Http/Wolverine.Http.Marten/SetVariableToNullIfSoftDeletedFrame.cs b/src/Http/Wolverine.Http.Marten/SetVariableToNullIfSoftDeletedFrame.cs deleted file mode 100644 index 8f749237d..000000000 --- a/src/Http/Wolverine.Http.Marten/SetVariableToNullIfSoftDeletedFrame.cs +++ /dev/null @@ -1,47 +0,0 @@ -using JasperFx.CodeGeneration; -using JasperFx.CodeGeneration.Frames; -using JasperFx.CodeGeneration.Model; -using Marten; -using Marten.Storage.Metadata; - -namespace Wolverine.Http.Marten; - -internal class SetVariableToNullIfSoftDeletedFrame : AsyncFrame -{ - private readonly Type _entityType; - private Variable _entity; - private Variable _documentSession; - private Variable _entityMetadata; - - public SetVariableToNullIfSoftDeletedFrame(Type entityType) - { - _entityType = entityType; - } - - public override void GenerateCode(GeneratedMethod method, ISourceWriter writer) - { - writer.WriteComment("If the document is soft deleted, set the variable to null"); - - writer.Write($"var {_entityMetadata.Usage} = {_entity.Usage} != null"); - writer.Write($" ? await {_documentSession.Usage}.{nameof(IDocumentSession.MetadataForAsync)}({_entity.Usage}).ConfigureAwait(false)"); - writer.Write($" : null;"); - - writer.Write($"BLOCK:if ({_entityMetadata.Usage}?.{nameof(DocumentMetadata.Deleted)} == true)"); - writer.Write($"{_entity.Usage} = null;"); - writer.FinishBlock(); - - Next?.GenerateCode(method, writer); - } - - public override IEnumerable FindVariables(IMethodVariables chain) - { - _entity = chain.FindVariable(_entityType); - yield return _entity; - - _documentSession = chain.FindVariable(typeof(IDocumentSession)); - yield return _documentSession; - - _entityMetadata = new Variable(typeof(DocumentMetadata), _entity.Usage + "Metadata", this); - yield return _entityMetadata; - } -} \ No newline at end of file diff --git a/src/Http/Wolverine.Http.Tests/Marten/reacting_to_read_aggregate.cs b/src/Http/Wolverine.Http.Tests/Marten/reacting_to_read_aggregate.cs new file mode 100644 index 000000000..88c8886ec --- /dev/null +++ b/src/Http/Wolverine.Http.Tests/Marten/reacting_to_read_aggregate.cs @@ -0,0 +1,198 @@ +using Alba; +using IntegrationTests; +using Marten; +using Marten.Events.Projections; +using Microsoft.AspNetCore.Builder; +using Shouldly; +using Wolverine.Marten; +using Wolverine.Persistence; + +namespace Wolverine.Http.Tests.Marten; + +public class reacting_to_read_aggregate : IAsyncLifetime +{ + private IAlbaHost theHost; + + public async Task InitializeAsync() + { + var builder = WebApplication.CreateBuilder([]); + + // config + builder.Services.AddMarten(opts => + { + // Establish the connection string to your Marten database + opts.Connection(Servers.PostgresConnectionString); + opts.DatabaseSchemaName = "letter_aggregate"; + opts.Projections.Snapshot(SnapshotLifecycle.Inline); + }).IntegrateWithWolverine().UseLightweightSessions(); + + builder.Host.UseWolverine(opts => opts.Discovery.IncludeAssembly(GetType().Assembly)); + + builder.Services.AddWolverineHttp(); + + // This is using Alba, which uses WebApplicationFactory under the covers + theHost = await AlbaHost.For(builder, app => + { + app.MapWolverineEndpoints(); + }); + } + + async Task IAsyncLifetime.DisposeAsync() + { + if (theHost != null) + { + await theHost.StopAsync(); + } + } + + [Fact] + public async Task get_404_by_default_on_missing() + { + await theHost.Scenario(x => + { + x.Get.Url("/letters1/" + Guid.NewGuid()); + x.StatusCodeShouldBe(404); + }); + } + + [Fact] + public async Task not_required_still_functions() + { + var result = await theHost.Scenario(x => + { + x.Get.Url("/letters2/" + Guid.NewGuid()); + }); + + result.ReadAsText().ShouldBe("No Letters"); + } + + [Fact] + public async Task missing_with_problem_details() + { + var result = await theHost.Scenario(x => + { + x.Get.Url("/letters3/" + Guid.NewGuid()); + x.StatusCodeShouldBe(404); + x.ContentTypeShouldBe("application/problem+json"); + }); + } + + [Fact] + public async Task post_write_404_by_default_on_missing_on_write() + { + await theHost.Scenario(x => + { + x.Post.Url("/letters4/" + Guid.NewGuid()); + x.StatusCodeShouldBe(404); + }); + } + + [Fact] + public async Task not_required_still_functions_on_write() + { + var result = await theHost.Scenario(x => + { + x.Post.Url("/letters5/" + Guid.NewGuid()); + }); + + result.ReadAsText().ShouldBe("No Letters"); + } + + [Fact] + public async Task missing_with_problem_details_on_write() + { + var result = await theHost.Scenario(x => + { + x.Post.Url("/letters6/" + Guid.NewGuid()); + x.StatusCodeShouldBe(404); + x.ContentTypeShouldBe("application/problem+json"); + }); + } + +} + +public static class LetterAggregateEndpoint +{ + #region sample_read_aggregate_fine_grained_validation_control + + // Straight up 404 on missing + [WolverineGet("/letters1/{id}")] + public static LetterAggregate GetLetter1([ReadAggregate] LetterAggregate letters) => letters; + + // Not required + [WolverineGet("/letters2/{id}")] + public static string GetLetter2([ReadAggregate(Required = false)] LetterAggregate letters) + { + return letters == null ? "No Letters" : "Got Letters"; + } + + // Straight up 404 & problem details on missing + [WolverineGet("/letters3/{id}")] + public static LetterAggregate GetLetter3([ReadAggregate(OnMissing = OnMissing.ProblemDetailsWith404)] LetterAggregate letters) => letters; + + + + #endregion + // Straight up 404 on missing + [WolverinePost("/letters4/{id}")] + public static LetterAggregate PostLetter4([WriteAggregate(Required = true)] LetterAggregate letters) => letters; + + // Not required + [WolverinePost("/letters5/{id}")] + public static string PostLetter5([WriteAggregate(Required = false)] LetterAggregate letters) + { + return letters == null ? "No Letters" : "Got Letters"; + } + + // Straight up 404 & problem details on missing + [WolverinePost("/letters6/{id}")] + public static LetterAggregate PostLetter6([WriteAggregate(Required = true, OnMissing = OnMissing.ProblemDetailsWith404)] LetterAggregate letters) => letters; + +} + +public class LetterStarted; + +public class LetterAggregate +{ + public LetterAggregate() + { + } + + public LetterAggregate(LetterStarted started) + { + } + + public Guid Id { get; set; } + public int ACount { get; set; } + public int BCount { get; set; } + public int CCount { get; set; } + public int DCount { get; set; } + + public void Apply(AEvent e) + { + ACount++; + } + + public void Apply(BEvent e) + { + BCount++; + } + + public void Apply(CEvent e) + { + CCount++; + } + + public void Apply(DEvent e) + { + DCount++; + } +} + +public record AEvent; + +public record BEvent; + +public record CEvent; + +public record DEvent; diff --git a/src/Http/Wolverine.Http.Tests/Persistence/RequiredTodoEndpoint.cs b/src/Http/Wolverine.Http.Tests/Persistence/RequiredTodoEndpoint.cs new file mode 100644 index 000000000..3a193e7bd --- /dev/null +++ b/src/Http/Wolverine.Http.Tests/Persistence/RequiredTodoEndpoint.cs @@ -0,0 +1,42 @@ +using Wolverine.Persistence; +using WolverineWebApi.Todos; + +namespace Wolverine.Http.Tests.Persistence; + +public static class RequiredTodoEndpoint +{ + // Should 404 on missing + [WolverineGet("/required/todo404/{id}")] + public static Todo2 Get1([Entity] Todo2 todo) => todo; + + // Should 400 w/ ProblemDetails on missing + [WolverineGet("/required/todo400/{id}")] + public static Todo2 Get2([Entity(OnMissing = OnMissing.ProblemDetailsWith400)] Todo2 todo) + => todo; + + // Should 404 w/ ProblemDetails on missing + [WolverineGet("/required/todo3/{id}")] + public static Todo2 Get3([Entity(OnMissing = OnMissing.ProblemDetailsWith404)] Todo2 todo) + => todo; + + // Should throw an exception on missing + [WolverineGet("/required/todo4/{id}")] + public static Todo2 Get4([Entity(OnMissing = OnMissing.ThrowException)] Todo2 todo) + => todo; + + // Should 400 w/ ProblemDetails on missing & custom message + [WolverineGet("/required/todo5/{id}")] + public static Todo2 Get5([Entity(OnMissing = OnMissing.ProblemDetailsWith400, MissingMessage = "Wrong id man!")] Todo2 todo) + => todo; + + // Should 400 w/ ProblemDetails on missing & custom message + [WolverineGet("/required/todo6/{id}")] + public static Todo2 Get6([Entity(OnMissing = OnMissing.ProblemDetailsWith400, MissingMessage = "Id '{0}' is wrong!")] Todo2 todo) + => todo; + + // Should error & custom message + [WolverineGet("/required/todo7/{id}")] + public static Todo2 Get7([Entity(OnMissing = OnMissing.ThrowException, MissingMessage = "Id '{0}' is wrong!")] Todo2 todo) + => todo; + +} \ No newline at end of file diff --git a/src/Http/Wolverine.Http.Tests/Persistence/reacting_to_entity_attributes.cs b/src/Http/Wolverine.Http.Tests/Persistence/reacting_to_entity_attributes.cs new file mode 100644 index 000000000..de71f27d7 --- /dev/null +++ b/src/Http/Wolverine.Http.Tests/Persistence/reacting_to_entity_attributes.cs @@ -0,0 +1,152 @@ +using Alba; +using IntegrationTests; +using Marten; +using Microsoft.AspNetCore.Builder; +using Microsoft.Extensions.Hosting; +using Refit; +using Shouldly; +using Wolverine.Marten; +using Wolverine.Persistence; +using Xunit.Abstractions; + +namespace Wolverine.Http.Tests.Persistence; + + +// See RequiredTodoEndpoint +public class reacting_to_entity_attributes : IAsyncLifetime +{ + private readonly ITestOutputHelper _output; + private IAlbaHost theHost; + + public reacting_to_entity_attributes(ITestOutputHelper output) + { + _output = output; + } + + public async Task InitializeAsync() + { + var builder = WebApplication.CreateBuilder([]); + + // config + builder.Services.AddMarten(opts => + { + // Establish the connection string to your Marten database + opts.Connection(Servers.PostgresConnectionString); + opts.DatabaseSchemaName = "onmissing"; + }).IntegrateWithWolverine().UseLightweightSessions(); + + builder.Host.UseWolverine(opts => opts.Discovery.IncludeAssembly(GetType().Assembly)); + + builder.Services.AddWolverineHttp(); + + // This is using Alba, which uses WebApplicationFactory under the covers + theHost = await AlbaHost.For(builder, app => + { + app.MapWolverineEndpoints(); + }); + } + + async Task IAsyncLifetime.DisposeAsync() + { + if (theHost != null) + { + await theHost.StopAsync(); + } + } + + [Fact] + public async Task default_404_behavior_on_missing() + { + var tracked = await theHost.Scenario(x => + { + x.Get.Url("/required/todo404/nonexistent"); + x.StatusCodeShouldBe(404); + }); + + tracked.ReadAsText().ShouldBeEmpty(); + } + + [Fact] + public async Task problem_details_400_on_missing() + { + var tracked = await theHost.Scenario(x => + { + x.Get.Url("/required/todo400/nonexistent"); + x.StatusCodeShouldBe(400); + x.ContentTypeShouldBe("application/problem+json"); + }); + + var details = tracked.ReadAsJson(); + details.Detail.ShouldBe("Unknown Todo2 with identity nonexistent"); + } + + [Fact] + public async Task problem_details_404_on_missing() + { + var tracked = await theHost.Scenario(x => + { + x.Get.Url("/required/todo3/nonexistent"); + x.StatusCodeShouldBe(404); + x.ContentTypeShouldBe("application/problem+json"); + }); + + var details = tracked.ReadAsJson(); + details.Detail.ShouldBe("Unknown Todo2 with identity nonexistent"); + } + + [Fact] + public async Task throw_exception_on_missing() + { + var tracked = await theHost.Scenario(x => + { + x.Get.Url("/required/todo4/nonexistent"); + x.StatusCodeShouldBe(500); + }); + + var text = tracked.ReadAsText(); + text.ShouldContain(typeof(RequiredDataMissingException).FullName); + } + + [Fact] + public async Task problem_details_400_on_missing_with_custom_message() + { + var tracked = await theHost.Scenario(x => + { + x.Get.Url("/required/todo5/nonexistent"); + x.StatusCodeShouldBe(400); + x.ContentTypeShouldBe("application/problem+json"); + }); + + var details = tracked.ReadAsJson(); + details.Detail.ShouldBe("Wrong id man!"); + } + + [Fact] + public async Task problem_details_400_on_missing_with_custom_message_using_id() + { + var tracked = await theHost.Scenario(x => + { + x.Get.Url("/required/todo6/nonexistent"); + x.StatusCodeShouldBe(400); + x.ContentTypeShouldBe("application/problem+json"); + }); + + var details = tracked.ReadAsJson(); + details.Detail.ShouldBe("Id 'nonexistent' is wrong!"); + } + + [Fact] + public async Task throw_exception_on_missing_with_custom_message() + { + var tracked = await theHost.Scenario(x => + { + x.Get.Url("/required/todo7/nonexistent"); + x.StatusCodeShouldBe(500); + }); + + var text = tracked.ReadAsText(); + text.ShouldContain(typeof(RequiredDataMissingException).FullName); + text.ShouldContain("Id 'nonexistent' is wrong!"); + } + +} \ No newline at end of file diff --git a/src/Http/Wolverine.Http/HttpChain.cs b/src/Http/Wolverine.Http/HttpChain.cs index f32388251..07421bb3e 100644 --- a/src/Http/Wolverine.Http/HttpChain.cs +++ b/src/Http/Wolverine.Http/HttpChain.cs @@ -21,6 +21,7 @@ using Wolverine.Http.CodeGen; using Wolverine.Http.Metadata; using Wolverine.Http.Policies; +using Wolverine.Persistence; using Wolverine.Runtime; using ServiceContainer = Wolverine.Runtime.ServiceContainer; @@ -289,6 +290,29 @@ public override Frame[] AddStopConditionIfNull(Variable variable) return [new SetStatusCodeAndReturnIfEntityIsNullFrame(variable)]; } + public override Frame[] AddStopConditionIfNull(Variable data, Variable? identity, IDataRequirement requirement) + { + var message = requirement.MissingMessage ?? $"Unknown {data.VariableType.NameInCode()} with identity {{Id}}"; + + // TODO -- want to use WolverineOptions here for a default + switch (requirement.OnMissing) + { + case OnMissing.Simple404: + Metadata.Produces(404); + return [new SetStatusCodeAndReturnIfEntityIsNullFrame(data)]; + + case OnMissing.ProblemDetailsWith400: + Metadata.Produces(400, contentType: "application/problem+json"); + return [new WriteProblemDetailsIfNull(data, identity, message, 400)]; + case OnMissing.ProblemDetailsWith404: + Metadata.Produces(404, contentType: "application/problem+json"); + return [new WriteProblemDetailsIfNull(data, identity, message, 404)]; + + default: + return [new ThrowRequiredDataMissingExceptionFrame(data, identity, message)]; + } + } + public override string ToString() { return _fileName!; diff --git a/src/Http/Wolverine.Http/HttpHandler.cs b/src/Http/Wolverine.Http/HttpHandler.cs index cba3182ad..8a71bb180 100644 --- a/src/Http/Wolverine.Http/HttpHandler.cs +++ b/src/Http/Wolverine.Http/HttpHandler.cs @@ -36,6 +36,22 @@ public HttpHandler(WolverineHttpOptions wolverineHttpOptions) return tenantId; } + public Task WriteProblems(int statusCode, string message, HttpContext context, object? identity) + { + if (identity != null) + { + message = message.Replace("{Id}", identity.ToString()); + } + + var problems = new ProblemDetails + { + Status = statusCode, + Detail = message + }; + + return Results.Problem(problems).ExecuteAsync(context); + } + public Task WriteTenantIdNotFound(HttpContext context) { return Results.Problem(new ProblemDetails diff --git a/src/Http/Wolverine.Http/ModifyHttpChainAttribute.cs b/src/Http/Wolverine.Http/ModifyHttpChainAttribute.cs index 5951e191b..72f297b24 100644 --- a/src/Http/Wolverine.Http/ModifyHttpChainAttribute.cs +++ b/src/Http/Wolverine.Http/ModifyHttpChainAttribute.cs @@ -2,6 +2,7 @@ using System.Reflection; using JasperFx.CodeGeneration; using JasperFx.CodeGeneration.Model; +using Wolverine.Attributes; using Wolverine.Configuration; using Wolverine.Runtime; @@ -27,7 +28,7 @@ public abstract class ModifyHttpChainAttribute : Attribute, IModifyChain [AttributeUsage(AttributeTargets.Parameter)] -public abstract class HttpChainParameterAttribute : Attribute +public abstract class HttpChainParameterAttribute : WolverineParameterAttribute { /// /// Called by Wolverine during bootstrapping to modify the code generation diff --git a/src/Http/Wolverine.Http/Policies/RequiredEntityPolicy.cs b/src/Http/Wolverine.Http/Policies/RequiredEntityPolicy.cs index 27c632c05..07890c798 100644 --- a/src/Http/Wolverine.Http/Policies/RequiredEntityPolicy.cs +++ b/src/Http/Wolverine.Http/Policies/RequiredEntityPolicy.cs @@ -1,7 +1,5 @@ using System.ComponentModel.DataAnnotations; using JasperFx.CodeGeneration; -using JasperFx.CodeGeneration.Frames; -using JasperFx.CodeGeneration.Model; using JasperFx.Core.Reflection; using Microsoft.AspNetCore.Http; using Wolverine.Runtime; @@ -30,50 +28,4 @@ public void Apply(IReadOnlyList chains, GenerationRules rules, IServi } } } -} - -public class SetStatusCodeAndReturnIfEntityIsNullFrame : SyncFrame -{ - private readonly Type _entityType; - private Variable _httpResponse; - private Variable? _entity; - - public SetStatusCodeAndReturnIfEntityIsNullFrame(Type entityType) - { - _entityType = entityType; - } - - public SetStatusCodeAndReturnIfEntityIsNullFrame(Variable entity) - { - _entity = entity; - _entityType = entity.VariableType; - } - - public override void GenerateCode(GeneratedMethod method, ISourceWriter writer) - { - writer.WriteComment("404 if this required object is null"); - writer.Write($"BLOCK:if ({_entity.Usage} == null)"); - writer.Write($"{_httpResponse.Usage}.{nameof(HttpResponse.StatusCode)} = 404;"); - if (method.AsyncMode == AsyncMode.ReturnCompletedTask) - { - writer.Write($"return {typeof(Task).FullNameInCode()}.{nameof(Task.CompletedTask)};"); - } - else - { - writer.Write("return;"); - } - - writer.FinishBlock(); - - Next?.GenerateCode(method, writer); - } - - public override IEnumerable FindVariables(IMethodVariables chain) - { - _entity ??= chain.FindVariable(_entityType); - yield return _entity; - - _httpResponse = chain.FindVariable(typeof(HttpResponse)); - yield return _httpResponse; - } } \ No newline at end of file diff --git a/src/Http/Wolverine.Http/Policies/SetStatusCodeAndReturnIfEntityIsNullFrame.cs b/src/Http/Wolverine.Http/Policies/SetStatusCodeAndReturnIfEntityIsNullFrame.cs new file mode 100644 index 000000000..e646d906b --- /dev/null +++ b/src/Http/Wolverine.Http/Policies/SetStatusCodeAndReturnIfEntityIsNullFrame.cs @@ -0,0 +1,53 @@ +using JasperFx.CodeGeneration; +using JasperFx.CodeGeneration.Frames; +using JasperFx.CodeGeneration.Model; +using JasperFx.Core.Reflection; +using Microsoft.AspNetCore.Http; + +namespace Wolverine.Http.Policies; + +internal class SetStatusCodeAndReturnIfEntityIsNullFrame : SyncFrame +{ + private readonly Type _entityType; + private Variable _httpResponse; + private Variable? _entity; + + public SetStatusCodeAndReturnIfEntityIsNullFrame(Type entityType) + { + _entityType = entityType; + } + + public SetStatusCodeAndReturnIfEntityIsNullFrame(Variable entity) + { + _entity = entity; + _entityType = entity.VariableType; + } + + public override void GenerateCode(GeneratedMethod method, ISourceWriter writer) + { + writer.WriteComment("404 if this required object is null"); + writer.Write($"BLOCK:if ({_entity.Usage} == null)"); + writer.Write($"{_httpResponse.Usage}.{nameof(HttpResponse.StatusCode)} = 404;"); + if (method.AsyncMode == AsyncMode.ReturnCompletedTask) + { + writer.Write($"return {typeof(Task).FullNameInCode()}.{nameof(Task.CompletedTask)};"); + } + else + { + writer.Write("return;"); + } + + writer.FinishBlock(); + + Next?.GenerateCode(method, writer); + } + + public override IEnumerable FindVariables(IMethodVariables chain) + { + _entity ??= chain.FindVariable(_entityType); + yield return _entity; + + _httpResponse = chain.FindVariable(typeof(HttpResponse)); + yield return _httpResponse; + } +} \ No newline at end of file diff --git a/src/Http/Wolverine.Http/Policies/WriteProblemDetailsIfNull.cs b/src/Http/Wolverine.Http/Policies/WriteProblemDetailsIfNull.cs new file mode 100644 index 000000000..36b1015f4 --- /dev/null +++ b/src/Http/Wolverine.Http/Policies/WriteProblemDetailsIfNull.cs @@ -0,0 +1,57 @@ +using JasperFx.CodeGeneration; +using JasperFx.CodeGeneration.Frames; +using JasperFx.CodeGeneration.Model; +using Microsoft.AspNetCore.Http; + +namespace Wolverine.Http.Policies; + +internal class WriteProblemDetailsIfNull : AsyncFrame +{ + private Variable _httpContext; + + public WriteProblemDetailsIfNull(Variable entity, Variable identity, string message, int statusCode = 400) + { + Entity = entity; + Identity = identity; + Message = message; + StatusCode = statusCode; + + uses.Add(Entity); + uses.Add(Identity); + } + + public Variable Entity { get; } + public Variable Identity { get; } + public string Message { get; } + public int StatusCode { get; } + + public override void GenerateCode(GeneratedMethod method, ISourceWriter writer) + { + writer.WriteComment("Write ProblemDetails if this required object is null"); + writer.Write($"BLOCK:if ({Entity.Usage} == null)"); + + if (Message.Contains("{0}")) + { + writer.Write($"await {nameof(HttpHandler.WriteProblems)}({StatusCode}, string.Format(\"{Message}\", {Identity.Usage}), {_httpContext.Usage}, {Identity.Usage});"); + } + else + { + var constant = Constant.For(Message); + writer.Write($"await {nameof(HttpHandler.WriteProblems)}({StatusCode}, {constant.Usage}, {_httpContext.Usage}, {Identity.Usage});"); + } + + writer.Write("return;"); + + writer.FinishBlock(); + + Next?.GenerateCode(method, writer); + + } + + + public override IEnumerable FindVariables(IMethodVariables chain) + { + _httpContext = chain.FindVariable(typeof(HttpContext)); + yield return _httpContext; + } +} \ No newline at end of file diff --git a/src/Persistence/MartenTests/AggregateHandlerWorkflow/AggregateHandlerAttributeTests.cs b/src/Persistence/MartenTests/AggregateHandlerWorkflow/AggregateHandlerAttributeTests.cs index 0b8dc6291..3bb385f7a 100644 --- a/src/Persistence/MartenTests/AggregateHandlerWorkflow/AggregateHandlerAttributeTests.cs +++ b/src/Persistence/MartenTests/AggregateHandlerWorkflow/AggregateHandlerAttributeTests.cs @@ -1,4 +1,5 @@ using JasperFx.CodeGeneration; +using JasperFx.CodeGeneration.Model; using Marten.Schema; using NSubstitute; using Shouldly; @@ -14,24 +15,16 @@ public class AggregateHandlerAttributeTests [Fact] public void determine_version_member_for_aggregate() { - AggregateHandlerAttribute.DetermineVersionMember(typeof(Invoice)) + AggregateHandling.DetermineVersionMember(typeof(Invoice)) .Name.ShouldBe(nameof(Invoice.Version)); } - [Fact] - public void determine_aggregate_type_when_it_is_explicitly_passed_in() - { - new AggregateHandlerAttribute { AggregateType = typeof(Invoice) } - .DetermineAggregateType(Substitute.For()) - .ShouldBe(typeof(Invoice)); - } - [Fact] public void determine_aggregate_by_second_parameter() { var chain = HandlerChain.For(x => x.Handle(default(ApproveInvoice), default), new HandlerGraph()); - new AggregateHandlerAttribute().DetermineAggregateType(chain) + AggregateHandling.DetermineAggregateType(chain) .ShouldBe(typeof(Invoice)); } @@ -41,7 +34,7 @@ public void throw_if_aggregate_type_is_indeterminate() var chain = HandlerChain.For(x => x.Handle(default), new HandlerGraph()); Should.Throw(() => { - new AggregateHandlerAttribute().DetermineAggregateType(chain); + AggregateHandling.DetermineAggregateType(chain); }); } @@ -68,21 +61,21 @@ public void throw_if_return_is_Task_and_does_not_take_in_stream() [Fact] public void determine_aggregate_id_from_command_type() { - AggregateHandlerAttribute.DetermineAggregateIdMember(typeof(Invoice), typeof(ApproveInvoice)) + AggregateHandling.DetermineAggregateIdMember(typeof(Invoice), typeof(ApproveInvoice)) .Name.ShouldBe(nameof(ApproveInvoice.InvoiceId)); } [Fact] public void determine_aggregate_id_with_identity_attribute_help() { - AggregateHandlerAttribute.DetermineAggregateIdMember(typeof(Invoice), typeof(RejectInvoice)) + AggregateHandling.DetermineAggregateIdMember(typeof(Invoice), typeof(RejectInvoice)) .Name.ShouldBe(nameof(RejectInvoice.Something)); } [Fact] public void determine_aggregate_id_with_identity_attribute_bypass() { - AggregateHandlerAttribute.DetermineAggregateIdMember(typeof(Invoice), typeof(AggregateIdConventionBypassingCommand)) + AggregateHandling.DetermineAggregateIdMember(typeof(Invoice), typeof(AggregateIdConventionBypassingCommand)) .Name.ShouldBe(nameof(AggregateIdConventionBypassingCommand.StreamId)); } @@ -91,7 +84,7 @@ public void cannot_determine_aggregate_id() { Should.Throw(() => { - AggregateHandlerAttribute.DetermineAggregateIdMember(typeof(Invoice), typeof(BadCommand)); + AggregateHandling.DetermineAggregateIdMember(typeof(Invoice), typeof(BadCommand)); }); } } diff --git a/src/Persistence/MartenTests/missing_data_handling_with_entity_attributes.cs b/src/Persistence/MartenTests/missing_data_handling_with_entity_attributes.cs new file mode 100644 index 000000000..cc2638461 --- /dev/null +++ b/src/Persistence/MartenTests/missing_data_handling_with_entity_attributes.cs @@ -0,0 +1,139 @@ +using System.Diagnostics; +using IntegrationTests; +using Marten; +using Microsoft.Extensions.Hosting; +using Shouldly; +using Wolverine; +using Wolverine.Marten; +using Wolverine.Persistence; +using Wolverine.Tracking; + +namespace MartenTests; + +// This is really general Wolverine behavior, but it's easiest to do this +// with Marten, so it's here. +public class missing_data_handling_with_entity_attributes : IAsyncLifetime +{ + private IHost _host; + + public async Task InitializeAsync() + { + _host = await Host.CreateDefaultBuilder() + .UseWolverine(opts => + { + opts.Policies.AutoApplyTransactions(); + opts.Services.AddMarten(m => + { + m.DisableNpgsqlLogging = true; + m.Connection(Servers.PostgresConnectionString); + m.DatabaseSchemaName = "other_things"; + }).IntegrateWithWolverine().UseLightweightSessions(); + }).StartAsync(); + } + + public async Task DisposeAsync() + { + await _host.StopAsync(); + } + + [Fact] + public async Task just_swallow_the_exception_and_log() + { + // Point is no exceptions here at all + // Manually checking logs in Debug + await _host.InvokeAsync(new UseThing1(Guid.NewGuid().ToString())); + await _host.InvokeAsync(new UseThing2(Guid.NewGuid().ToString())); + await _host.InvokeAsync(new UseThing3(Guid.NewGuid().ToString())); + } + + [Fact] + public async Task missing_data_goes_nowhere() + { + var tracked = await _host.InvokeMessageAndWaitAsync(new UseThing1(Guid.NewGuid().ToString())); + + tracked.Sent.AllMessages().Any().ShouldBeFalse(); + } + + [Fact] + public async Task end_to_end_with_good_data() + { + var thing = new Thing(); + await _host.DocumentStore().BulkInsertDocumentsAsync([thing]); + + var tracked = await _host.InvokeMessageAndWaitAsync(new UseThing1(thing.Id)); + + tracked.Sent.SingleMessage() + .Id.ShouldBe(thing.Id); + } + + [Fact] + public async Task throw_exception_instead() + { + var ex = await Should.ThrowAsync(async () => + { + await _host.InvokeAsync(new UseThing4(Guid.NewGuid().ToString())); + }); + } + + [Fact] + public async Task throw_exception_instead_with_custom_message() + { + var ex = await Should.ThrowAsync(async () => + { + await _host.InvokeAsync(new UseThing5(Guid.NewGuid().ToString())); + }); + + ex.Message.ShouldContain("You stink!"); + } +} + +public class Thing +{ + public string Id { get; set; } = Guid.NewGuid().ToString(); +} + +public record UseThing1(string Id); + +public record UseThing2(string Id); + +public record UseThing3(string Id); + +public record UseThing4(string Id); + +public record UseThing5(string Id); + +public record UsedThing(string Id); + +public static class ThingHandler +{ + public static UsedThing Handle(UseThing1 command, [Entity] Thing thing) + { + return new UsedThing(thing.Id); + } + + public static UsedThing Handle(UseThing2 command, [Entity(OnMissing = OnMissing.ProblemDetailsWith400)] Thing thing) + { + return new UsedThing(thing.Id); + } + + public static UsedThing Handle(UseThing3 command, [Entity(OnMissing = OnMissing.ProblemDetailsWith404)] Thing thing) + { + return new UsedThing(thing.Id); + } + + public static UsedThing Handle(UseThing4 command, [Entity(OnMissing = OnMissing.ThrowException)] Thing thing) + { + return new UsedThing(thing.Id); + } + + public static UsedThing Handle(UseThing5 command, + [Entity(OnMissing = OnMissing.ThrowException, MissingMessage = "You stink!")] Thing thing) + { + return new UsedThing(thing.Id); + } + + public static void Handle(UsedThing msg) + { + Debug.WriteLine("Used thing " + msg.Id); + } +} \ No newline at end of file diff --git a/src/Persistence/MartenTests/read_aggregate_attribute_usage.cs b/src/Persistence/MartenTests/read_aggregate_attribute_usage.cs index e11338992..0fd06bfaf 100644 --- a/src/Persistence/MartenTests/read_aggregate_attribute_usage.cs +++ b/src/Persistence/MartenTests/read_aggregate_attribute_usage.cs @@ -7,6 +7,7 @@ using Microsoft.Extensions.Hosting; using Shouldly; using Wolverine; +using Wolverine.Attributes; using Wolverine.Marten; namespace MartenTests; @@ -88,6 +89,16 @@ public static LetterAggregateEnvelope Handle(FindAggregate command, [ReadAggrega { return new LetterAggregateEnvelope(aggregate); } + + [WolverineHandler] + public static LetterAggregateEnvelope Handle2( + FindAggregate command, + + // Just showing you that you can disable the validation + [ReadAggregate(Required = false)] LetterAggregate aggregate) + { + return aggregate == null ? null : new LetterAggregateEnvelope(aggregate); + } } #endregion \ No newline at end of file diff --git a/src/Persistence/OrderEventSourcingSample/Order.cs b/src/Persistence/OrderEventSourcingSample/Order.cs index 7720ca66d..2ec8e70d6 100644 --- a/src/Persistence/OrderEventSourcingSample/Order.cs +++ b/src/Persistence/OrderEventSourcingSample/Order.cs @@ -3,7 +3,9 @@ using Marten.Events; using Microsoft.AspNetCore.Mvc; using Wolverine; +using Wolverine.Attributes; using Wolverine.Marten; +using Wolverine.Persistence; namespace OrderEventSourcingSample; @@ -279,6 +281,42 @@ public static IEnumerable Handle(MarkItemReady command, Order order) #endregion } +public static class MarkItemReady2Handler +{ + #region sample_MarkItemReadyHandler_with_WriteAggregate + + public static IEnumerable Handle( + // The command + MarkItemReady command, + + // This time we'll mark the parameter as the "aggregate" + [WriteAggregate] Order order) + { + if (order.Items.TryGetValue(command.ItemName, out var item)) + { + // Not doing this in a purist way here, but just + // trying to illustrate the Wolverine mechanics + item.Ready = true; + + // Mark that the this item is ready + yield return new ItemReady(command.ItemName); + } + else + { + // Some crude validation + throw new InvalidOperationException($"Item {command.ItemName} does not exist in this order"); + } + + // If the order is ready to ship, also emit an OrderReady event + if (order.IsReadyToShip()) + { + yield return new OrderReady(); + } + } + + #endregion +} + public record Data; public interface ISomeService @@ -329,4 +367,56 @@ public static class MarkItemReadyHandler2 } #endregion -} \ No newline at end of file +} + +#region sample_validation_on_aggregate_being_missing_in_aggregate_handler_workflow + +public static class ValidatedMarkItemReadyHandler +{ + public static IEnumerable Handle( + // The command + MarkItemReady command, + + // In HTTP this will return a 404 status code and stop + // the request if the Order is not found + + // In message handlers, this will log that the Order was not found, + // then stop processing. The message would be effectively + // discarded + [WriteAggregate(Required = true)] Order order) => []; + + [WolverineHandler] + public static IEnumerable Handle2( + // The command + MarkItemReady command, + + // In HTTP this will return a 400 status code and + // write out a ProblemDetails response with a default message explaining + // the data that could not be found + [WriteAggregate(Required = true, OnMissing = OnMissing.ProblemDetailsWith400)] Order order) => []; + + [WolverineHandler] + public static IEnumerable Handle3( + // The command + MarkItemReady command, + + // In HTTP this will return a 404 status code and + // write out a ProblemDetails response with a default message explaining + // the data that could not be found + [WriteAggregate(Required = true, OnMissing = OnMissing.ProblemDetailsWith404)] Order order) => []; + + + [WolverineHandler] + public static IEnumerable Handle4( + // The command + MarkItemReady command, + + // In HTTP this will return a 400 status code and + // write out a ProblemDetails response with a custom message. + // Wolverine will substitute in the order identity into the message for "{0}" + // In message handlers, Wolverine will log using your custom message then discard the message + [WriteAggregate(Required = true, OnMissing = OnMissing.ProblemDetailsWith404, MissingMessage = "Cannot find Order {0}")] Order order) => []; + +} + +#endregion \ No newline at end of file diff --git a/src/Persistence/Wolverine.EntityFrameworkCore/Codegen/EFCorePersistenceFrameProvider.cs b/src/Persistence/Wolverine.EntityFrameworkCore/Codegen/EFCorePersistenceFrameProvider.cs index 5f0478930..937d76fb1 100644 --- a/src/Persistence/Wolverine.EntityFrameworkCore/Codegen/EFCorePersistenceFrameProvider.cs +++ b/src/Persistence/Wolverine.EntityFrameworkCore/Codegen/EFCorePersistenceFrameProvider.cs @@ -28,6 +28,8 @@ public bool CanPersist(Type entityType, IServiceContainer container, out Type pe persistenceService = dbContextType!; return dbContextType != null; } + + public Frame[] DetermineFrameToNullOutMaybeSoftDeleted(Variable entity) => []; public Type DetermineSagaIdType(Type sagaType, IServiceContainer container) { diff --git a/src/Persistence/Wolverine.Marten/AggregateHandlerAttribute.cs b/src/Persistence/Wolverine.Marten/AggregateHandlerAttribute.cs index 63a34662d..8962edd53 100644 --- a/src/Persistence/Wolverine.Marten/AggregateHandlerAttribute.cs +++ b/src/Persistence/Wolverine.Marten/AggregateHandlerAttribute.cs @@ -5,47 +5,28 @@ using JasperFx.Core; using JasperFx.Core.Reflection; using JasperFx.Events; -using JasperFx.Events.Aggregation; -using JasperFx.Events.Daemon; using Marten; using Marten.Events; -using Marten.Events.Aggregation; -using Marten.Linq.Members; -using Marten.Schema; using Microsoft.Extensions.DependencyInjection; using Wolverine.Attributes; using Wolverine.Codegen; using Wolverine.Configuration; using Wolverine.Marten.Codegen; -using Wolverine.Marten.Publishing; +using Wolverine.Marten.Persistence.Sagas; +using Wolverine.Persistence; using Wolverine.Runtime; using Wolverine.Runtime.Handlers; namespace Wolverine.Marten; -/// -/// Tells Wolverine handlers that this value contains a -/// list of events to be appended to the current stream -/// -public class Events : List, IWolverineReturnType -{ - public static Events operator +(Events events, object @event) - { - events.Add(@event); - return events; - } -} - /// /// Applies middleware to Wolverine message actions to apply a workflow with concurrency protections for /// "command" messages that use a Marten projected aggregate to "decide" what /// on new events to persist to the aggregate stream. /// [AttributeUsage(AttributeTargets.Class | AttributeTargets.Method)] -public class AggregateHandlerAttribute : ModifyChainAttribute +public class AggregateHandlerAttribute : ModifyChainAttribute, IDataRequirement { - private static readonly Type _versioningBaseType = typeof(AggregateVersioning<>); - public AggregateHandlerAttribute(ConcurrencyStyle loadStyle) { LoadStyle = loadStyle; @@ -70,9 +51,12 @@ public AggregateHandlerAttribute() : this(ConcurrencyStyle.Optimistic) public override void Modify(IChain chain, GenerationRules rules, IServiceContainer container) { // ReSharper disable once CanSimplifyDictionaryLookupWithTryAdd - if (chain.Tags.ContainsKey(nameof(AggregateHandlerAttribute))) return; - - chain.Tags.Add(nameof(AggregateHandlerAttribute),"true"); + if (chain.Tags.ContainsKey(nameof(AggregateHandlerAttribute))) + { + return; + } + + chain.Tags.Add(nameof(AggregateHandlerAttribute), "true"); CommandType = chain.InputType(); if (CommandType == null) @@ -80,185 +64,33 @@ public override void Modify(IChain chain, GenerationRules rules, IServiceContain throw new InvalidOperationException( $"Cannot apply Marten aggregate handler workflow to chain {chain} because it has no input type"); } - - AggregateType ??= DetermineAggregateType(chain); - if (CommandType.Closes(typeof(IEvent<>))) - { - var concreteEventType = typeof(Event<>).MakeGenericType(CommandType.GetGenericArguments()[0]); - - // This CANNOT work if you capture the version, because there's no way to know if the aggregate version - // has advanced - //VersionMember = concreteEventType.GetProperty(nameof(IEvent.Version)); - - var options = container.Services.GetRequiredService(); - AggregateIdMember = options.Events.StreamIdentity == StreamIdentity.AsGuid - ? concreteEventType.GetProperty(nameof(IEvent.StreamId), BindingFlags.Public | BindingFlags.Instance | BindingFlags.FlattenHierarchy) - : concreteEventType.GetProperty(nameof(IEvent.StreamKey), BindingFlags.Public | BindingFlags.Instance | BindingFlags.FlattenHierarchy); - } - else - { - AggregateIdMember = DetermineAggregateIdMember(AggregateType, CommandType); - VersionMember = DetermineVersionMember(CommandType); - } - - var sessionCreator = MethodCall.For(x => x.OpenSession(null!)); - chain.Middleware.Add(sessionCreator); - - var firstCall = chain.HandlerCalls().First(); - - var loader = generateLoadAggregateCode(chain); - if (AggregateType == firstCall.HandlerType) - { - chain.Middleware.Add(new MissingAggregateCheckFrame(AggregateType, CommandType, AggregateIdMember, - loader.ReturnVariable!)); - } - - // Use the active document session as an IQuerySession instead of creating a new one - firstCall.TrySetArgument(new Variable(typeof(IQuerySession), sessionCreator.ReturnVariable!.Usage)); - - DetermineEventCaptureHandling(chain, firstCall, AggregateType); - - ValidateMethodSignatureForEmittedEvents(chain, firstCall, chain); - RelayAggregateToHandlerMethod(loader.ReturnVariable, firstCall, AggregateType); + AggregateType ??= AggregateHandling.DetermineAggregateType(chain); - chain.Postprocessors.Add(MethodCall.For(x => x.SaveChangesAsync(default))); + (AggregateIdMember, VersionMember) = + AggregateHandling.DetermineAggregateIdAndVersion(AggregateType, CommandType, container); + - new AggregateHandling(AggregateType, new Variable(AggregateIdMember.GetRawMemberType(), "aggregateId")).Store(chain); - } - - internal static void DetermineEventCaptureHandling(IChain chain, MethodCall firstCall, Type aggregateType) - { - var asyncEnumerable = firstCall.Creates.FirstOrDefault(x => x.VariableType == typeof(IAsyncEnumerable)); - if (asyncEnumerable != null) - { - asyncEnumerable.UseReturnAction(_ => - { - return typeof(ApplyEventsFromAsyncEnumerableFrame<>).CloseAndBuildAs(asyncEnumerable, - aggregateType); - }); - - return; - } - - var eventsVariable = firstCall.Creates.FirstOrDefault(x => x.VariableType == typeof(Events)) ?? - firstCall.Creates.FirstOrDefault(x => - x.VariableType.CanBeCastTo>() && - !x.VariableType.CanBeCastTo()); - - if (eventsVariable != null) - { - eventsVariable.UseReturnAction( - v => typeof(RegisterEventsFrame<>).CloseAndBuildAs(eventsVariable, aggregateType) - .WrapIfNotNull(v), "Append events to the Marten event stream"); - - return; - } - - // If there's no return value of Events or IEnumerable, and there's also no parameter of IEventStream, - // then assume that the default behavior of each return value is to be an event - if (!firstCall.Method.GetParameters().Any(x => x.ParameterType.Closes(typeof(IEventStream<>)))) - { - chain.ReturnVariableActionSource = new EventCaptureActionSource(aggregateType); - } - } - - internal static Variable RelayAggregateToHandlerMethod(Variable eventStream, MethodCall firstCall, Type aggregateType) - { - var aggregateVariable = new MemberAccessVariable(eventStream, - typeof(IEventStream<>).MakeGenericType(aggregateType).GetProperty("Aggregate")); - - if (firstCall.HandlerType == aggregateType) - { - // If the handle method is on the aggregate itself - firstCall.Target = aggregateVariable; - } - else - { - firstCall.TrySetArgument(aggregateVariable); - } - - return aggregateVariable; - } - - internal static void ValidateMethodSignatureForEmittedEvents(IChain chain, MethodCall firstCall, - IChain handlerChain) - { - if (firstCall.Method.ReturnType == typeof(Task) || firstCall.Method.ReturnType == typeof(void)) - { - var parameters = chain.HandlerCalls().First().Method.GetParameters(); - var stream = parameters.FirstOrDefault(x => x.ParameterType.Closes(typeof(IEventStream<>))); - if (stream == null) - { - throw new InvalidOperationException( - $"No events are emitted from handler {handlerChain} even though it is marked as an action that would emit Marten events. Either return the events from the handler, or use the IEventStream service as an argument."); - } - } - } - - private MethodCall generateLoadAggregateCode(IChain chain) - { - chain.Middleware.Add(new EventStoreFrame()); - var loader = typeof(LoadAggregateFrame<>).CloseAndBuildAs(this, AggregateType!); - - - chain.Middleware.Add(loader); - return loader; - } - - internal static MemberInfo DetermineVersionMember(Type aggregateType) - { - // The first arg doesn't matter - var versioning = - _versioningBaseType.CloseAndBuildAs(AggregationScope.SingleStream, aggregateType); - return versioning.VersionMember; - } - - internal Type DetermineAggregateType(IChain chain) - { - if (AggregateType != null) - { - return AggregateType; - } - - var firstCall = chain.HandlerCalls().First(); - var parameters = firstCall.Method.GetParameters(); - var stream = parameters.FirstOrDefault(x => x.ParameterType.Closes(typeof(IEventStream<>))); - if (stream != null) - { - return stream.ParameterType.GetGenericArguments().Single(); - } - if (parameters.Length >= 2 && (parameters[1].ParameterType.IsConcrete() || parameters[1].ParameterType.Closes(typeof(IEvent<>)))) - { - return parameters[1].ParameterType; - } + var aggregateFrame = new MemberAccessFrame(CommandType, AggregateIdMember, + $"{Variable.DefaultArgName(AggregateType)}_Id"); + + var versionFrame = VersionMember == null ? null : new MemberAccessFrame(CommandType,VersionMember, $"{Variable.DefaultArgName(CommandType)}_Version"); - // Assume that the handler type itself is the aggregate - if (firstCall.HandlerType.HasAttribute()) + var handling = new AggregateHandling(this) { - return firstCall.HandlerType; - } - - throw new InvalidOperationException( - $"Unable to determine a Marten aggregate type for {chain}. You may need to explicitly specify the aggregate type in a {nameof(AggregateHandlerAttribute)} attribute"); + AggregateType = AggregateType, + AggregateId = aggregateFrame.Variable, + LoadStyle = LoadStyle, + Version = versionFrame?.Variable + }; + + handling.Apply(chain, container); } - internal static MemberInfo DetermineAggregateIdMember(Type aggregateType, Type commandType) - { - var conventionalMemberName = $"{aggregateType.Name}Id"; - var member = commandType.GetMembers().FirstOrDefault(x => x.HasAttribute()) - ?? commandType.GetMembers().FirstOrDefault(x => - x.Name.EqualsIgnoreCase(conventionalMemberName) || x.Name.EqualsIgnoreCase("Id")); - - if (member == null) - { - throw new InvalidOperationException( - $"Unable to determine the aggregate id for aggregate type {aggregateType.FullNameInCode()} on command type {commandType.FullNameInCode()}. Either make a property or field named '{conventionalMemberName}', or decorate a member with the {typeof(IdentityAttribute).FullNameInCode()} attribute"); - } - - return member; - } + public bool Required { get; set; } + public string MissingMessage { get; set; } + public OnMissing OnMissing { get; set; } } internal class ApplyEventsFromAsyncEnumerableFrame : AsyncFrame, IReturnVariableAction @@ -272,6 +104,18 @@ public ApplyEventsFromAsyncEnumerableFrame(Variable returnValue) uses.Add(_returnValue); } + public string Description => "Apply events to Marten event stream"; + + public new IEnumerable Dependencies() + { + yield break; + } + + public IEnumerable Frames() + { + yield return this; + } + public override IEnumerable FindVariables(IMethodVariables chain) { _stream = chain.FindVariable(typeof(IEventStream)); @@ -283,21 +127,10 @@ public override void GenerateCode(GeneratedMethod method, ISourceWriter writer) var variableName = (typeof(T).Name + "Event").ToCamelCase(); writer.WriteComment(Description); - writer.Write($"await foreach (var {variableName} in {_returnValue.Usage}) {_stream!.Usage}.{nameof(IEventStream.AppendOne)}({variableName});"); + writer.Write( + $"await foreach (var {variableName} in {_returnValue.Usage}) {_stream!.Usage}.{nameof(IEventStream.AppendOne)}({variableName});"); Next?.GenerateCode(method, writer); } - - public string Description => "Apply events to Marten event stream"; - - public new IEnumerable Dependencies() - { - yield break; - } - - public IEnumerable Frames() - { - yield return this; - } } internal class EventCaptureActionSource : IReturnVariableActionSource @@ -311,7 +144,6 @@ public EventCaptureActionSource(Type aggregateType) public IReturnVariableAction Build(IChain chain, Variable variable) { - return new ActionSource(_aggregateType, variable); } @@ -327,6 +159,7 @@ public ActionSource(Type aggregateType, Variable variable) } public string Description => "Append event to event stream for aggregate " + _aggregateType.FullNameInCode(); + public IEnumerable Dependencies() { yield break; diff --git a/src/Persistence/Wolverine.Marten/AggregateHandling.cs b/src/Persistence/Wolverine.Marten/AggregateHandling.cs new file mode 100644 index 000000000..06df0280d --- /dev/null +++ b/src/Persistence/Wolverine.Marten/AggregateHandling.cs @@ -0,0 +1,251 @@ +using System.Diagnostics; +using System.Reflection; +using JasperFx.CodeGeneration.Frames; +using JasperFx.CodeGeneration.Model; +using JasperFx.Core; +using JasperFx.Core.Reflection; +using JasperFx.Events; +using JasperFx.Events.Aggregation; +using JasperFx.Events.Daemon; +using Marten; +using Marten.Events; +using Marten.Schema; +using Microsoft.Extensions.DependencyInjection; +using Wolverine.Configuration; +using Wolverine.Marten.Codegen; +using Wolverine.Marten.Persistence.Sagas; +using Wolverine.Persistence; +using Wolverine.Runtime; +using Wolverine.Runtime.Handlers; + +namespace Wolverine.Marten; + +internal record AggregateHandling(IDataRequirement Requirement) +{ + private static readonly Type _versioningBaseType = typeof(AggregateVersioning<>); + + public Type AggregateType { get; init; } + public Variable AggregateId { get; init; } + + public ConcurrencyStyle LoadStyle { get; init; } + public Variable? Version { get; init; } + + public Variable Apply(IChain chain, IServiceContainer container) + { + Store(chain); + + new MartenPersistenceFrameProvider().ApplyTransactionSupport(chain, container); + + var loader = GenerateLoadAggregateCode(chain); + var firstCall = chain.HandlerCalls().First(); + + var eventStream = loader.ReturnVariable!; + + if (AggregateType == firstCall.HandlerType) + { + chain.Middleware.Add(new MissingAggregateCheckFrame(AggregateType, AggregateId, + eventStream)); + } + + DetermineEventCaptureHandling(chain, firstCall, AggregateType); + + ValidateMethodSignatureForEmittedEvents(chain, firstCall, chain); + var aggregate = RelayAggregateToHandlerMethod(eventStream, chain, firstCall, AggregateType); + + return aggregate; + } + + public void Store(IChain chain) + { + chain.Tags[nameof(AggregateHandling)] = this; + } + + public static bool TryLoad(IChain chain, out AggregateHandling handling) + { + if (chain.Tags.TryGetValue(nameof(AggregateHandling), out var raw)) + { + if (raw is AggregateHandling h) + { + handling = h; + return true; + } + } + + handling = default; + return false; + } + + public MethodCall GenerateLoadAggregateCode(IChain chain) + { + if (!chain.Middleware.OfType().Any()) + { + chain.Middleware.Add(new EventStoreFrame()); + } + + var loader = typeof(LoadAggregateFrame<>).CloseAndBuildAs(this, AggregateType!); + + chain.Middleware.Add(loader); + return loader; + } + + internal static (MemberInfo, MemberInfo?) DetermineAggregateIdAndVersion(Type aggregateType, Type commandType, + IServiceContainer container) + { + if (commandType.Closes(typeof(IEvent<>))) + { + var concreteEventType = typeof(Event<>).MakeGenericType(commandType.GetGenericArguments()[0]); + + // This CANNOT work if you capture the version, because there's no way to know if the aggregate version + // has advanced + //VersionMember = concreteEventType.GetProperty(nameof(IEvent.Version)); + + var options = container.Services.GetRequiredService(); + var flattenHierarchy = BindingFlags.Public | BindingFlags.Instance | BindingFlags.FlattenHierarchy; + var member = options.Events.StreamIdentity == StreamIdentity.AsGuid + ? concreteEventType.GetProperty(nameof(IEvent.StreamId), flattenHierarchy) + : concreteEventType.GetProperty(nameof(IEvent.StreamKey), flattenHierarchy); + + return (member!, null); + } + + var aggregateId = DetermineAggregateIdMember(aggregateType, commandType); + var version = DetermineVersionMember(commandType); + return (aggregateId, version); + } + + internal static void ValidateMethodSignatureForEmittedEvents(IChain chain, MethodCall firstCall, + IChain handlerChain) + { + if (firstCall.Method.ReturnType == typeof(Task) || firstCall.Method.ReturnType == typeof(void)) + { + var parameters = chain.HandlerCalls().First().Method.GetParameters(); + var stream = parameters.FirstOrDefault(x => x.ParameterType.Closes(typeof(IEventStream<>))); + if (stream == null) + { + throw new InvalidOperationException( + $"No events are emitted from handler {handlerChain} even though it is marked as an action that would emit Marten events. Either return the events from the handler, or use the IEventStream service as an argument."); + } + } + } + + internal static MemberInfo DetermineAggregateIdMember(Type aggregateType, Type commandType) + { + var conventionalMemberName = $"{aggregateType.Name}Id"; + var member = commandType.GetMembers().FirstOrDefault(x => x.HasAttribute()) + ?? commandType.GetMembers().FirstOrDefault(x => + x.Name.EqualsIgnoreCase(conventionalMemberName) || x.Name.EqualsIgnoreCase("Id")); + + if (member == null) + { + throw new InvalidOperationException( + $"Unable to determine the aggregate id for aggregate type {aggregateType.FullNameInCode()} on command type {commandType.FullNameInCode()}. Either make a property or field named '{conventionalMemberName}', or decorate a member with the {typeof(IdentityAttribute).FullNameInCode()} attribute"); + } + + return member; + } + + internal static void DetermineEventCaptureHandling(IChain chain, MethodCall firstCall, Type aggregateType) + { + var asyncEnumerable = firstCall.Creates.FirstOrDefault(x => x.VariableType == typeof(IAsyncEnumerable)); + if (asyncEnumerable != null) + { + asyncEnumerable.UseReturnAction(_ => + { + return typeof(ApplyEventsFromAsyncEnumerableFrame<>).CloseAndBuildAs(asyncEnumerable, + aggregateType); + }); + + return; + } + + var eventsVariable = firstCall.Creates.FirstOrDefault(x => x.VariableType == typeof(Events)) ?? + firstCall.Creates.FirstOrDefault(x => + x.VariableType.CanBeCastTo>() && + !x.VariableType.CanBeCastTo()); + + if (eventsVariable != null) + { + eventsVariable.UseReturnAction( + v => typeof(RegisterEventsFrame<>).CloseAndBuildAs(eventsVariable, aggregateType) + .WrapIfNotNull(v), "Append events to the Marten event stream"); + + return; + } + + // If there's no return value of Events or IEnumerable, and there's also no parameter of IEventStream, + // then assume that the default behavior of each return value is to be an event + if (!firstCall.Method.GetParameters().Any(x => x.ParameterType.Closes(typeof(IEventStream<>)))) + { + chain.ReturnVariableActionSource = new EventCaptureActionSource(aggregateType); + } + } + + internal Variable RelayAggregateToHandlerMethod(Variable eventStream, IChain chain, MethodCall firstCall, + Type aggregateType) + { + if (aggregateType.Name == "LetterAggregate") + { + Debug.WriteLine("Here"); + } + + Variable aggregateVariable = new MemberAccessVariable(eventStream, + typeof(IEventStream<>).MakeGenericType(aggregateType).GetProperty(nameof(IEventStream.Aggregate))); + + if (Requirement.Required) + { + var otherFrames = chain.AddStopConditionIfNull(aggregateVariable, AggregateId, Requirement); + + var block = new LoadEntityFrameBlock(aggregateVariable, otherFrames); + chain.Middleware.Add(block); + + aggregateVariable = block.Mirror; + } + + if (firstCall.HandlerType == aggregateType) + { + // If the handle method is on the aggregate itself + firstCall.Target = aggregateVariable; + } + else + { + firstCall.TrySetArgument(aggregateVariable); + } + + return aggregateVariable; + } + + internal static Type DetermineAggregateType(IChain chain) + { + var firstCall = chain.HandlerCalls().First(); + var parameters = firstCall.Method.GetParameters(); + var stream = parameters.FirstOrDefault(x => x.ParameterType.Closes(typeof(IEventStream<>))); + if (stream != null) + { + return stream.ParameterType.GetGenericArguments().Single(); + } + + if (parameters.Length >= 2 && (parameters[1].ParameterType.IsConcrete() || + parameters[1].ParameterType.Closes(typeof(IEvent<>)))) + { + return parameters[1].ParameterType; + } + + // Assume that the handler type itself is the aggregate + if (firstCall.HandlerType.HasAttribute()) + { + return firstCall.HandlerType; + } + + throw new InvalidOperationException( + $"Unable to determine a Marten aggregate type for {chain}. You may need to explicitly specify the aggregate type in a {nameof(AggregateHandlerAttribute)} attribute"); + } + + + internal static MemberInfo DetermineVersionMember(Type aggregateType) + { + // The first arg doesn't matter + var versioning = + _versioningBaseType.CloseAndBuildAs(AggregationScope.SingleStream, aggregateType); + return versioning.VersionMember; + } +} \ No newline at end of file diff --git a/src/Persistence/Wolverine.Marten/Codegen/LoadAggregateFrame.cs b/src/Persistence/Wolverine.Marten/Codegen/LoadAggregateFrame.cs index d7a035068..0aa205279 100644 --- a/src/Persistence/Wolverine.Marten/Codegen/LoadAggregateFrame.cs +++ b/src/Persistence/Wolverine.Marten/Codegen/LoadAggregateFrame.cs @@ -9,10 +9,9 @@ namespace Wolverine.Marten.Codegen; internal class LoadAggregateFrame : MethodCall where T : class { - private readonly AggregateHandlerAttribute _att; - private Variable? _command; + private readonly AggregateHandling _att; - public LoadAggregateFrame(AggregateHandlerAttribute att) : base(typeof(IEventStoreOperations), FindMethod(att)) + public LoadAggregateFrame(AggregateHandling att) : base(typeof(IEventStoreOperations), FindMethod(att)) { _att = att; CommentText = "Loading Marten aggregate"; @@ -23,27 +22,18 @@ public LoadAggregateFrame(AggregateHandlerAttribute att) : base(typeof(IEventSto public override IEnumerable FindVariables(IMethodVariables chain) { - _command = chain.FindVariable(_att.CommandType!); - yield return _command; - - Arguments[0] = new Variable(_att.AggregateIdMember.GetRawMemberType(),"aggregateId"); - if (_att.LoadStyle == ConcurrencyStyle.Optimistic && _att.VersionMember != null) + Arguments[0] = _att.AggregateId; + if (_att is { LoadStyle: ConcurrencyStyle.Optimistic, Version: not null }) { - Arguments[1] = new MemberAccessVariable(_command, _att.VersionMember); + Arguments[1] = _att.Version; } foreach (var variable in base.FindVariables(chain)) yield return variable; } - public override void GenerateCode(GeneratedMethod method, ISourceWriter writer) - { - writer.WriteLine($"var aggregateId = {_command.Usage}.{_att.AggregateIdMember.Name};"); - base.GenerateCode(method, writer); - } - - internal static MethodInfo FindMethod(AggregateHandlerAttribute att) + internal static MethodInfo FindMethod(AggregateHandling att) { - var isGuidIdentified = att.AggregateIdMember!.GetMemberType() == typeof(Guid); + var isGuidIdentified = att.AggregateId!.VariableType == typeof(Guid); if (att.LoadStyle == ConcurrencyStyle.Exclusive) { @@ -52,7 +42,7 @@ internal static MethodInfo FindMethod(AggregateHandlerAttribute att) : ReflectionHelper.GetMethod(x => x.FetchForExclusiveWriting(string.Empty, default))!; } - if (att.VersionMember == null) + if (att.Version == null) { return isGuidIdentified ? ReflectionHelper.GetMethod(x => x.FetchForWriting(Guid.Empty, default))! diff --git a/src/Persistence/Wolverine.Marten/Codegen/MissingAggregateCheckFrame.cs b/src/Persistence/Wolverine.Marten/Codegen/MissingAggregateCheckFrame.cs index 4d3cf08e1..364db51d8 100644 --- a/src/Persistence/Wolverine.Marten/Codegen/MissingAggregateCheckFrame.cs +++ b/src/Persistence/Wolverine.Marten/Codegen/MissingAggregateCheckFrame.cs @@ -11,17 +11,20 @@ internal class MissingAggregateCheckFrame : SyncFrame { private readonly MemberInfo _aggregateIdMember; private readonly Type _aggregateType; + private readonly Variable _identity; private readonly Type _commandType; private readonly Variable _eventStream; private Variable? _command; - public MissingAggregateCheckFrame(Type aggregateType, Type commandType, MemberInfo aggregateIdMember, + public MissingAggregateCheckFrame(Type aggregateType, Variable identity, Variable eventStream) { _aggregateType = aggregateType; - _commandType = commandType; - _aggregateIdMember = aggregateIdMember; + _identity = identity; _eventStream = eventStream; + + uses.Add(identity); + uses.Add(eventStream); } public override IEnumerable FindVariables(IMethodVariables chain) @@ -33,7 +36,7 @@ public override IEnumerable FindVariables(IMethodVariables chain) public override void GenerateCode(GeneratedMethod method, ISourceWriter writer) { writer.WriteLine( - $"if ({_eventStream.Usage}.{nameof(IEventStream.Aggregate)} == null) throw new {typeof(UnknownAggregateException).FullNameInCode()}(typeof({_aggregateType.FullNameInCode()}), {_command!.Usage}.{_aggregateIdMember.Name});"); + $"if ({_eventStream.Usage}.{nameof(IEventStream.Aggregate)} == null) throw new {typeof(UnknownAggregateException).FullNameInCode()}(typeof({_aggregateType.FullNameInCode()}), {_identity.Usage});"); Next?.GenerateCode(method, writer); } diff --git a/src/Persistence/Wolverine.Marten/Codegen/OpenMartenSessionFrame.cs b/src/Persistence/Wolverine.Marten/Codegen/OpenMartenSessionFrame.cs index 0adb675d4..687ac2106 100644 --- a/src/Persistence/Wolverine.Marten/Codegen/OpenMartenSessionFrame.cs +++ b/src/Persistence/Wolverine.Marten/Codegen/OpenMartenSessionFrame.cs @@ -10,20 +10,31 @@ namespace Wolverine.Marten.Codegen; internal class OpenMartenSessionFrame : AsyncFrame { + private readonly Type _sessionType; private Variable? _context; private Variable? _factory; private Variable? _martenFactory; private Variable _tenantId; + private bool _justCast; public OpenMartenSessionFrame(Type sessionType) { + _sessionType = sessionType; ReturnVariable = new Variable(sessionType, this); } public Variable ReturnVariable { get; } + + public override void GenerateCode(GeneratedMethod method, ISourceWriter writer) { + if (_justCast) + { + Next?.GenerateCode(method, writer); + return; + } + var methodName = ReturnVariable.VariableType == typeof(IQuerySession) ? nameof(OutboxedSessionFactory.QuerySession) : nameof(OutboxedSessionFactory.OpenSession); @@ -49,6 +60,18 @@ public override void GenerateCode(GeneratedMethod method, ISourceWriter writer) public override IEnumerable FindVariables(IMethodVariables chain) { + if (_sessionType == typeof(IQuerySession)) + { + _justCast = true; + var documentSession = chain.TryFindVariable(typeof(IDocumentSession), VariableSource.All); + if (documentSession != null) + { + yield return documentSession; + ReturnVariable.OverrideName($"(({typeof(IQuerySession)}){documentSession.Usage})"); + yield break; + } + } + // Honestly, this is mostly to get the ordering correct if (chain.TryFindVariableByName(typeof(string), PersistenceConstants.TenantIdVariableName, out var tenant)) { diff --git a/src/Persistence/Wolverine.Marten/Events.cs b/src/Persistence/Wolverine.Marten/Events.cs new file mode 100644 index 000000000..a95995e9a --- /dev/null +++ b/src/Persistence/Wolverine.Marten/Events.cs @@ -0,0 +1,16 @@ +using Wolverine.Configuration; + +namespace Wolverine.Marten; + +/// +/// Tells Wolverine handlers that this value contains a +/// list of events to be appended to the current stream +/// +public class Events : List, IWolverineReturnType +{ + public static Events operator +(Events events, object @event) + { + events.Add(@event); + return events; + } +} \ No newline at end of file diff --git a/src/Persistence/Wolverine.Marten/IAggregateHandling.cs b/src/Persistence/Wolverine.Marten/IAggregateHandling.cs deleted file mode 100644 index ae3bd5cec..000000000 --- a/src/Persistence/Wolverine.Marten/IAggregateHandling.cs +++ /dev/null @@ -1,27 +0,0 @@ -using JasperFx.CodeGeneration.Model; -using Wolverine.Configuration; - -namespace Wolverine.Marten; - -internal record AggregateHandling(Type AggregateType, Variable AggregateId) -{ - public void Store(IChain chain) - { - chain.Tags[nameof(AggregateHandling)] = this; - } - - public static bool TryLoad(IChain chain, out AggregateHandling handling) - { - if (chain.Tags.TryGetValue(nameof(AggregateHandling), out var raw)) - { - if (raw is AggregateHandling h) - { - handling = h; - return true; - } - } - - handling = default; - return false; - } -} diff --git a/src/Persistence/Wolverine.Marten/Persistence/Sagas/MartenPersistenceFrameProvider.cs b/src/Persistence/Wolverine.Marten/Persistence/Sagas/MartenPersistenceFrameProvider.cs index c02a5bebe..770c266da 100644 --- a/src/Persistence/Wolverine.Marten/Persistence/Sagas/MartenPersistenceFrameProvider.cs +++ b/src/Persistence/Wolverine.Marten/Persistence/Sagas/MartenPersistenceFrameProvider.cs @@ -1,10 +1,12 @@ using System.Reflection; +using JasperFx.CodeGeneration; using JasperFx.CodeGeneration.Frames; using JasperFx.CodeGeneration.Model; using JasperFx.Core.Reflection; using Marten; using Marten.Events; using Marten.Metadata; +using Marten.Storage.Metadata; using Wolverine.Configuration; using Wolverine.Marten.Codegen; using Wolverine.Persistence; @@ -24,7 +26,9 @@ public bool CanPersist(Type entityType, IServiceContainer container, out Type pe public Type DetermineSagaIdType(Type sagaType, IServiceContainer container) { var store = container.GetInstance(); - return store.Options.FindOrResolveDocumentType(sagaType).IdType; + var documentType = store.Options.FindOrResolveDocumentType(sagaType); + + return documentType.IdType; } public void ApplyTransactionSupport(IChain chain, IServiceContainer container) @@ -118,6 +122,46 @@ public Frame DetermineStorageActionFrame(Type entityType, Variable action, IServ return call; } + public Frame[] DetermineFrameToNullOutMaybeSoftDeleted(Variable entity) + { + return [new SetVariableToNullIfSoftDeletedFrame(entity)]; + } +} + +internal class SetVariableToNullIfSoftDeletedFrame : AsyncFrame +{ + private Variable _entity; + private Variable _documentSession; + private Variable _entityMetadata; + + public SetVariableToNullIfSoftDeletedFrame(Variable entity) + { + _entity = entity; + } + + public override void GenerateCode(GeneratedMethod method, ISourceWriter writer) + { + writer.WriteComment("If the document is soft deleted, set the variable to null"); + + writer.Write($"var {_entityMetadata.Usage} = {_entity.Usage} != null"); + writer.Write($" ? await {_documentSession.Usage}.{nameof(IDocumentSession.MetadataForAsync)}({_entity.Usage}).ConfigureAwait(false)"); + writer.Write($" : null;"); + + writer.Write($"BLOCK:if ({_entityMetadata.Usage}?.{nameof(DocumentMetadata.Deleted)} == true)"); + writer.Write($"{_entity.Usage} = null;"); + writer.FinishBlock(); + + Next?.GenerateCode(method, writer); + } + + public override IEnumerable FindVariables(IMethodVariables chain) + { + _documentSession = chain.FindVariable(typeof(IDocumentSession)); + yield return _documentSession; + + _entityMetadata = new Variable(typeof(DocumentMetadata), _entity.Usage + "Metadata", this); + yield return _entityMetadata; + } } public static class MartenStorageActionApplier diff --git a/src/Persistence/Wolverine.Marten/ReadAggregateAttribute.cs b/src/Persistence/Wolverine.Marten/ReadAggregateAttribute.cs index 6454de45c..5103f7e7d 100644 --- a/src/Persistence/Wolverine.Marten/ReadAggregateAttribute.cs +++ b/src/Persistence/Wolverine.Marten/ReadAggregateAttribute.cs @@ -16,7 +16,7 @@ namespace Wolverine.Marten; /// /// Use Marten's FetchLatest() API to retrieve the parameter value /// -public class ReadAggregateAttribute : WolverineParameterAttribute +public class ReadAggregateAttribute : WolverineParameterAttribute, IDataRequirement { public ReadAggregateAttribute() { @@ -34,6 +34,9 @@ public ReadAggregateAttribute(string argumentName) : base(argumentName) /// public bool Required { get; set; } = true; + public string MissingMessage { get; set; } + public OnMissing OnMissing { get; set; } + public override Variable Modify(IChain chain, ParameterInfo parameter, IServiceContainer container, GenerationRules rules) { // I know it's goofy that this refers to the saga, but it should work fine here too @@ -48,7 +51,7 @@ public override Variable Modify(IChain chain, ParameterInfo parameter, IServiceC if (Required) { - var otherFrames = chain.AddStopConditionIfNull(frame.Aggregate); + var otherFrames = chain.AddStopConditionIfNull(frame.Aggregate, identity, this); var block = new LoadEntityFrameBlock(frame.Aggregate, otherFrames); chain.Middleware.Add(block); diff --git a/src/Persistence/Wolverine.Marten/WriteAggregateAttribute.cs b/src/Persistence/Wolverine.Marten/WriteAggregateAttribute.cs new file mode 100644 index 000000000..7bafba5f0 --- /dev/null +++ b/src/Persistence/Wolverine.Marten/WriteAggregateAttribute.cs @@ -0,0 +1,131 @@ +using System.Reflection; +using JasperFx.CodeGeneration; +using JasperFx.CodeGeneration.Model; +using JasperFx.Core; +using JasperFx.Core.Reflection; +using Marten; +using Wolverine.Attributes; +using Wolverine.Configuration; +using Wolverine.Persistence; +using Wolverine.Runtime; + +namespace Wolverine.Marten; + +/// +/// Marks a parameter to a Wolverine HTTP endpoint or message handler method as being part of the Marten event sourcing +/// "aggregate handler" workflow +/// +[AttributeUsage(AttributeTargets.Parameter)] +public class WriteAggregateAttribute : WolverineParameterAttribute, IDataRequirement +{ + public WriteAggregateAttribute() + { + } + + public WriteAggregateAttribute(string? routeOrParameterName) + { + RouteOrParameterName = routeOrParameterName; + } + + public string? RouteOrParameterName { get; } + + public bool Required { get; set; } + public string MissingMessage { get; set; } + public OnMissing OnMissing { get; set; } + + /// + /// Opt into exclusive locking or optimistic checks on the aggregate stream + /// version. Default is Optimistic + /// + public ConcurrencyStyle LoadStyle { get; set; } = ConcurrencyStyle.Optimistic; + + public override Variable Modify(IChain chain, ParameterInfo parameter, IServiceContainer container, GenerationRules rules) + { + // TODO -- this goes away soon-ish + if (chain.HandlerCalls().First().Method.GetParameters().Count(x => x.HasAttribute()) > 1) + { + throw new InvalidOperationException( + "It is only possible (today) to use a single [Aggregate] attribute on an HTTP handler method. Maybe use [ReadAggregate] if all you need is the projected data"); + } + + var aggregateType = parameter.ParameterType; + if (aggregateType.IsNullable()) + { + aggregateType = aggregateType.GetInnerTypeFromNullable(); + } + + var store = container.GetInstance(); + var idType = store.Options.FindOrResolveDocumentType(aggregateType).IdType; + + var identity = FindIdentity(aggregateType, idType, chain) ?? throw new InvalidOperationException( + $"Unable to determine an aggregate id for the parameter '{parameter.Name}' on method {chain.HandlerCalls().First()}"); + + if (identity == null) + { + throw new InvalidOperationException( + "Cannot determine an identity variable for this aggregate from the route arguments"); + } + + var version = findVersionVariable(chain); + + // Store information about the aggregate handling in the chain just in + // case they're using LatestAggregate + var handling = new AggregateHandling(this) + { + AggregateType = aggregateType, + AggregateId = identity, + LoadStyle = LoadStyle, + Version = version + }; + + return handling.Apply(chain, container); + } + + internal Variable? findVersionVariable(IChain chain) + { + if (chain.TryFindVariable("version", ValueSource.Anything, typeof(long), out var variable)) + { + return variable; + } + + if (chain.TryFindVariable("version", ValueSource.Anything, typeof(int), out var v2)) + { + return v2; + } + + if (chain.TryFindVariable("version", ValueSource.Anything, typeof(uint), out var v3)) + { + return v3; + } + + if (chain.TryFindVariable("version", ValueSource.Anything, typeof(uint), out var v4)) + { + return v4; + } + + return null; + } + + public Variable? FindIdentity(Type aggregateType, Type idType, IChain chain) + { + if (RouteOrParameterName.IsNotEmpty()) + { + if (chain.TryFindVariable(RouteOrParameterName, ValueSource.Anything, idType, out var variable)) + { + return variable; + } + } + + if (chain.TryFindVariable($"{aggregateType.Name.ToCamelCase()}Id", ValueSource.Anything, idType, out var v2)) + { + return v2; + } + + if (chain.TryFindVariable("id", ValueSource.Anything, idType, out var v3)) + { + return v3; + } + + return null; + } +} \ No newline at end of file diff --git a/src/Persistence/Wolverine.RavenDb/Internals/RavenDbPersistenceFrameProvider.cs b/src/Persistence/Wolverine.RavenDb/Internals/RavenDbPersistenceFrameProvider.cs index 874d8ae83..a1812c900 100644 --- a/src/Persistence/Wolverine.RavenDb/Internals/RavenDbPersistenceFrameProvider.cs +++ b/src/Persistence/Wolverine.RavenDb/Internals/RavenDbPersistenceFrameProvider.cs @@ -13,6 +13,8 @@ namespace Wolverine.RavenDb.Internals; public class RavenDbPersistenceFrameProvider : IPersistenceFrameProvider { + public Frame[] DetermineFrameToNullOutMaybeSoftDeleted(Variable entity) => []; + public void ApplyTransactionSupport(IChain chain, IServiceContainer container) { if (!chain.Middleware.OfType().Any()) diff --git a/src/Wolverine/Attributes/WolverineParameterAttribute.cs b/src/Wolverine/Attributes/WolverineParameterAttribute.cs index 931b73873..40a7e1563 100644 --- a/src/Wolverine/Attributes/WolverineParameterAttribute.cs +++ b/src/Wolverine/Attributes/WolverineParameterAttribute.cs @@ -1,3 +1,4 @@ +using System.Diagnostics; using System.Reflection; using JasperFx.CodeGeneration; using JasperFx.CodeGeneration.Frames; diff --git a/src/Http/Wolverine.Http.Marten/MemberAccessFrame.cs b/src/Wolverine/Codegen/MemberAccessFrame.cs similarity index 80% rename from src/Http/Wolverine.Http.Marten/MemberAccessFrame.cs rename to src/Wolverine/Codegen/MemberAccessFrame.cs index 77768c7d9..1d3261621 100644 --- a/src/Http/Wolverine.Http.Marten/MemberAccessFrame.cs +++ b/src/Wolverine/Codegen/MemberAccessFrame.cs @@ -4,9 +4,13 @@ using JasperFx.CodeGeneration.Model; using JasperFx.Core.Reflection; -namespace Wolverine.Http.Marten; +namespace Wolverine.Codegen; -internal class MemberAccessFrame : SyncFrame +/// +/// Generates a variable for a value of a member off of the designated parent variable type +/// +// TODO -- move this to JasperFx +public class MemberAccessFrame : SyncFrame { private readonly Type _targetType; private readonly MemberInfo _member; diff --git a/src/Wolverine/Configuration/Chain.cs b/src/Wolverine/Configuration/Chain.cs index 6ad45033f..bdd99be97 100644 --- a/src/Wolverine/Configuration/Chain.cs +++ b/src/Wolverine/Configuration/Chain.cs @@ -9,6 +9,7 @@ using Wolverine.Attributes; using Wolverine.Logging; using Wolverine.Middleware; +using Wolverine.Persistence; using Wolverine.Runtime; using Wolverine.Runtime.Handlers; @@ -161,6 +162,11 @@ public IEnumerable ReturnVariablesOfType(Type interfaceType) public abstract bool TryFindVariable(string valueName, ValueSource source, Type valueType, out Variable variable); public abstract Frame[] AddStopConditionIfNull(Variable variable); + public virtual Frame[] AddStopConditionIfNull(Variable data, Variable? identity, IDataRequirement requirement) + { + return AddStopConditionIfNull(data); + } + private static Type[] _typesToIgnore = new Type[] { typeof(DateOnly), diff --git a/src/Wolverine/Configuration/IChain.cs b/src/Wolverine/Configuration/IChain.cs index 944142d52..f9d1419a6 100644 --- a/src/Wolverine/Configuration/IChain.cs +++ b/src/Wolverine/Configuration/IChain.cs @@ -5,6 +5,7 @@ using JasperFx.Core.Reflection; using Wolverine.Attributes; using Wolverine.Logging; +using Wolverine.Persistence; using Wolverine.Runtime; namespace Wolverine.Configuration; @@ -133,6 +134,12 @@ public interface IChain /// /// Frame[] AddStopConditionIfNull(Variable variable); + + /// + /// Used by code generation to add a middleware Frame that aborts the processing if the variable is null + /// + /// + Frame[] AddStopConditionIfNull(Variable data, Variable? identity, IDataRequirement requirement); } #endregion \ No newline at end of file diff --git a/src/Wolverine/Persistence/EntityAttribute.cs b/src/Wolverine/Persistence/EntityAttribute.cs index dbd838c44..f5b717b69 100644 --- a/src/Wolverine/Persistence/EntityAttribute.cs +++ b/src/Wolverine/Persistence/EntityAttribute.cs @@ -2,7 +2,6 @@ using JasperFx.CodeGeneration; using JasperFx.CodeGeneration.Frames; using JasperFx.CodeGeneration.Model; -using JasperFx.Core; using JasperFx.Core.Reflection; using Wolverine.Attributes; using Wolverine.Configuration; @@ -15,7 +14,7 @@ namespace Wolverine.Persistence; /// Use this when you absolutely have to keep a number of Frames together /// and not allowing the topological sort to break them up /// -internal class LoadEntityFrameBlock : Frame +public class LoadEntityFrameBlock : Frame { private readonly Frame[] _guardFrames; private readonly Frame _creator; @@ -33,14 +32,27 @@ public LoadEntityFrameBlock(Variable entity, params Frame[] guardFrames) : base( public override void GenerateCode(GeneratedMethod method, ISourceWriter writer) { - var previous = _creator; - foreach (var next in _guardFrames) + // The [WriteAggregate] somehow causes this + if (_creator.Next == this) { - previous.Next = next; - previous = next; + for (int i = 1; i < _guardFrames.Length; i++) + { + _guardFrames[i - 1].Next = _guardFrames[i]; + } + + _guardFrames[0].GenerateCode(method, writer); } + else + { + var previous = _creator; + foreach (var next in _guardFrames) + { + previous.Next = next; + previous = next; + } - _creator.GenerateCode(method, writer); + _creator.GenerateCode(method, writer); + } Next?.GenerateCode(method, writer); } @@ -64,7 +76,7 @@ public override bool CanReturnTask() /// Apply this on a message handler method, an HTTP endpoint method, or any "before" middleware method parameter /// to direct Wolverine to use a known persistence strategy to resolve the entity from the request or message /// -public class EntityAttribute : WolverineParameterAttribute +public class EntityAttribute : WolverineParameterAttribute, IDataRequirement { public EntityAttribute() { @@ -75,6 +87,8 @@ public EntityAttribute(string argumentName) : base(argumentName) { ValueSource = ValueSource.Anything; } + + /// /// Is the existence of this entity required for the rest of the handler action or HTTP endpoint @@ -82,6 +96,21 @@ public EntityAttribute(string argumentName) : base(argumentName) /// public bool Required { get; set; } = true; + public string MissingMessage { get; set; } + public OnMissing OnMissing { get; set; } = OnMissing.Simple404; + + /// + /// Should Wolverine consider soft-deleted entities to be missing if deleted. I.e., if an entity + /// can be found, but is marked as deleted, is this considered a "good" entity and the message handling + /// or HTTP execution should continue? + /// + /// If the document is soft-deleted, whether the endpoint should receive the document (true) or NULL ( + /// false). + /// Set it to false and combine it with so a 404 will be returned for soft-deleted + /// documents. + /// + public bool MaybeSoftDeleted { get; set; } = true; + public override Variable Modify(IChain chain, ParameterInfo parameter, IServiceContainer container, GenerationRules rules) { @@ -109,9 +138,15 @@ public override Variable Modify(IChain chain, ParameterInfo parameter, IServiceC var entity = frame.Creates.First(x => x.VariableType == parameter.ParameterType); + if (MaybeSoftDeleted is false) + { + var softDeleteFrames = provider.DetermineFrameToNullOutMaybeSoftDeleted(entity); + chain.Middleware.AddRange(softDeleteFrames); + } + if (Required) { - var otherFrames = chain.AddStopConditionIfNull(entity); + var otherFrames = chain.AddStopConditionIfNull(entity, identity, this); var block = new LoadEntityFrameBlock(entity, otherFrames); chain.Middleware.Add(block); diff --git a/src/Wolverine/Persistence/IDataRequirement.cs b/src/Wolverine/Persistence/IDataRequirement.cs new file mode 100644 index 000000000..e3bb1715d --- /dev/null +++ b/src/Wolverine/Persistence/IDataRequirement.cs @@ -0,0 +1,41 @@ +namespace Wolverine.Persistence; + +public enum OnMissing +{ + /// + /// Default behavior. In a message handler, the execution will just stop after logging that the data was missing. In an HTTP + /// endpoint the request will stop w/ an empty body and 404 status code + /// + Simple404, + + /// + /// In a message handler, the execution will log that the required data is missing and stop execution. In an HTTP + /// endpoint the request will stop w/ a 400 response and a ProblemDetails body describing the missing data + /// + ProblemDetailsWith400, + + /// + /// In a message handler, the execution will log that the required data is missing and stop execution. In an HTTP + /// endpoint the request will stop w/ a 404 status code response and a ProblemDetails body describing the missing data + /// + ProblemDetailsWith404, + + /// + /// Throws a RequiredDataMissingException using the MissingMessage + /// + ThrowException +} + +public class RequiredDataMissingException : Exception +{ + public RequiredDataMissingException(string? message) : base(message) + { + } +} + +public interface IDataRequirement +{ + bool Required { get; set; } + string MissingMessage { get; set; } + OnMissing OnMissing { get; set; } +} \ No newline at end of file diff --git a/src/Wolverine/Persistence/IPersistenceFrameProvider.cs b/src/Wolverine/Persistence/IPersistenceFrameProvider.cs index 007aae3c1..bd4f401e0 100644 --- a/src/Wolverine/Persistence/IPersistenceFrameProvider.cs +++ b/src/Wolverine/Persistence/IPersistenceFrameProvider.cs @@ -47,6 +47,8 @@ public interface IPersistenceFrameProvider Frame DetermineDeleteFrame(Variable variable, IServiceContainer container); Frame DetermineStorageActionFrame(Type entityType, Variable action, IServiceContainer container); + + Frame[] DetermineFrameToNullOutMaybeSoftDeleted(Variable entity); } diff --git a/src/Wolverine/Persistence/Sagas/InMemoryPersistenceFrameProvider.cs b/src/Wolverine/Persistence/Sagas/InMemoryPersistenceFrameProvider.cs index 166d9235a..a68e24952 100644 --- a/src/Wolverine/Persistence/Sagas/InMemoryPersistenceFrameProvider.cs +++ b/src/Wolverine/Persistence/Sagas/InMemoryPersistenceFrameProvider.cs @@ -111,6 +111,8 @@ public Frame DetermineStorageActionFrame(Type entityType, Variable action, IServ call.Arguments[0] = action; return call; } + + public Frame[] DetermineFrameToNullOutMaybeSoftDeleted(Variable entity) => []; } internal class InMemorySagaPersistorStore : MethodCall diff --git a/src/Wolverine/Persistence/Sagas/LightweightSagaPersistenceFrameProvider.cs b/src/Wolverine/Persistence/Sagas/LightweightSagaPersistenceFrameProvider.cs index 98cf494db..fe83b5d94 100644 --- a/src/Wolverine/Persistence/Sagas/LightweightSagaPersistenceFrameProvider.cs +++ b/src/Wolverine/Persistence/Sagas/LightweightSagaPersistenceFrameProvider.cs @@ -104,4 +104,6 @@ public Frame DetermineStorageActionFrame(Type entityType, Variable action, IServ { throw new NotSupportedException(); } + + public Frame[] DetermineFrameToNullOutMaybeSoftDeleted(Variable entity) => []; } \ No newline at end of file diff --git a/src/Wolverine/Persistence/ThrowRequiredDataMissingExceptionFrame.cs b/src/Wolverine/Persistence/ThrowRequiredDataMissingExceptionFrame.cs new file mode 100644 index 000000000..892577896 --- /dev/null +++ b/src/Wolverine/Persistence/ThrowRequiredDataMissingExceptionFrame.cs @@ -0,0 +1,42 @@ +using JasperFx.CodeGeneration; +using JasperFx.CodeGeneration.Frames; +using JasperFx.CodeGeneration.Model; +using JasperFx.Core.Reflection; + +namespace Wolverine.Persistence; + +internal class ThrowRequiredDataMissingExceptionFrame : SyncFrame +{ + public Variable Entity { get; } + public Variable Identity { get; } + public string Message { get; } + + public ThrowRequiredDataMissingExceptionFrame(Variable entity, Variable identity, string message) + { + Entity = entity; + Identity = identity; + Message = message; + + uses.Add(Entity); + uses.Add(Identity); + } + + public override void GenerateCode(GeneratedMethod method, ISourceWriter writer) + { + writer.WriteComment("Write ProblemDetails if this required object is null"); + writer.Write($"BLOCK:if ({Entity.Usage} == null)"); + + if (Message.Contains("{0}")) + { + writer.Write($"throw new {typeof(RequiredDataMissingException).FullNameInCode()}(string.Format(\"{Message}\", {Identity.Usage}));"); + } + else + { + var constant = Constant.For(Message); + writer.Write($"throw new {typeof(RequiredDataMissingException).FullNameInCode()}({constant.Usage});"); + } + + writer.FinishBlock(); + Next?.GenerateCode(method, writer); + } +} \ No newline at end of file diff --git a/src/Wolverine/Runtime/Handlers/HandlerChain.cs b/src/Wolverine/Runtime/Handlers/HandlerChain.cs index 704f15bbd..6261e8afa 100644 --- a/src/Wolverine/Runtime/Handlers/HandlerChain.cs +++ b/src/Wolverine/Runtime/Handlers/HandlerChain.cs @@ -15,6 +15,7 @@ using Wolverine.ErrorHandling; using Wolverine.Logging; using Wolverine.Middleware; +using Wolverine.Persistence; using Wolverine.Persistence.Sagas; using Wolverine.Runtime.Routing; using Wolverine.Transports.Local; @@ -379,6 +380,25 @@ public override Frame[] AddStopConditionIfNull(Variable variable) return [frame, new HandlerContinuationFrame(frame)]; } + public override Frame[] AddStopConditionIfNull(Variable data, Variable? identity, IDataRequirement requirement) + { + // TODO -- want to use WolverineOptions here for a default + switch (requirement.OnMissing) + { + case OnMissing.Simple404: + case OnMissing.ProblemDetailsWith400: + case OnMissing.ProblemDetailsWith404: + var frame = typeof(EntityIsNotNullGuardFrame<>).CloseAndBuildAs(data, data.VariableType); + if (frame is IEntityIsNotNullGuard guard) guard.Requirement = requirement; + + return [frame, new HandlerContinuationFrame(frame)]; + + default: + var message = requirement.MissingMessage ?? $"Unknown {data.VariableType.NameInCode()} with identity {{Id}}"; + return [new ThrowRequiredDataMissingExceptionFrame(data, identity, message)]; + } + } + public IEnumerable PublishedTypes() { var ignoredTypes = new[] @@ -557,13 +577,20 @@ internal TimeSpan DetermineMessageTimeout(WolverineOptions options) } } -internal class EntityIsNotNullGuardFrame : MethodCall +internal interface IEntityIsNotNullGuard +{ + IDataRequirement? Requirement { get; set; } +} + +internal class EntityIsNotNullGuardFrame : MethodCall, IEntityIsNotNullGuard { public EntityIsNotNullGuardFrame(Variable variable) : base(typeof(EntityIsNotNullGuard), "Assert") { Arguments[0] = variable; Arguments[2] = Constant.For(variable.Usage); } + + public IDataRequirement? Requirement { get; set; } } public static class EntityIsNotNullGuard