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