Integration with Marten
New in Wolverine 1.10.0 is the Wolverine.Http.Marten
library that adds the ability to more deeply integrate Marten into Wolverine.HTTP by utilizing information from route arguments.
To install that library, use:
dotnet add package WolverineFx.Http.Marten
Passing Marten Documents to Endpoint Parameters
Consider this very common use case, you have an HTTP endpoint that needs to work on a Marten document that will be loaded using the value of one of the route arguments as that document's identity. In a long hand way, that could look like this:
{
[WolverineGet("/invoices/longhand/id")]
[ProducesResponseType(404)]
[ProducesResponseType(200, Type = typeof(Invoice))]
public static async Task<IResult> GetInvoice(
Guid id,
IQuerySession session,
CancellationToken cancellationToken)
{
var invoice = await session.LoadAsync<Invoice>(id, cancellationToken);
if (invoice == null) return Results.NotFound();
return Results.Ok(invoice);
}
Pretty straightforward, but it's a little annoying to have to scatter in all the attributes for OpenAPI and there's definitely some repetitive code. So let's introduce the new [Document]
parameter and look at an exact equivalent for both the actual functionality and for the OpenAPI metadata:
[WolverineGet("/invoices/{id}")]
public static Invoice Get([Document] Invoice invoice)
{
return invoice;
}
Notice that the [Document]
attribute was able to use the "id" route parameter. By default, Wolverine is looking first for a route variable named "invoiceId" (the document type name + "Id"), then falling back to looking for "id". You can of course explicitly override the matching of route argument like so:
[WolverinePost("/invoices/{number}/approve")]
public static IMartenOp Approve([Document("number")] Invoice invoice)
{
invoice.Approved = true;
return MartenOps.Store(invoice);
}
In the code above, if the Invoice
document does not exist, the route will stop and return a status code 404 for Not Found.
If you, for whatever reason, want your handler executed even if the document does not exist, then you can set the DocumentAttribute.Required
property to false
.
INFO
Starting with Wolverine 3 DocumentAttribute.Required = true
is the default behavior. In previous versions the default value was false
.
However, if the document is soft-deleted your endpoint will still be executed.
If you want soft-deleted documents to be treated as NULL
for a endpoint, you can set MaybeSoftDeleted
to false
.
In combination with Required = true
that means the endpoint will return 404 for missing and soft-deleted documents.
[WolverineGet("/invoices/soft-delete/{id}")]
public static Invoice GetSoftDeleted([Document(Required = true, MaybeSoftDeleted = false)] Invoice invoice)
{
return invoice;
}
Marten Aggregate Workflow
The http endpoints can play inside the full "critter stack" combination with Marten with Wolverine's specific support for Event Sourcing and CQRS. Originally this has been done by just mimicking the command handler mechanism and having all the inputs come in through the request body (aggregate id, version). Wolverine 1.10 added a more HTTP-centric approach using route arguments.
Using Route Arguments
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:
[WolverinePost("/orders/{orderId}/ship2"), EmptyResponse]
// The OrderShipped return value is treated as an event being posted
// to a Marten even stream
// instead of as the HTTP response body because of the presence of
// the [EmptyResponse] attribute
public static OrderShipped Ship(ShipOrder2 command, [Aggregate] Order order)
{
if (order.HasShipped)
throw new InvalidOperationException("This has already shipped!");
return new OrderShipped();
}
Using this version of the "aggregate workflow", you no longer have to supply a command in the request body, so you could have an endpoint signature like this:
[WolverinePost("/orders/{orderId}/ship3"), EmptyResponse]
// The OrderShipped return value is treated as an event being posted
// to a Marten even stream
// instead of as the HTTP response body because of the presence of
// the [EmptyResponse] attribute
public static OrderShipped Ship3([Aggregate] Order order)
{
return new OrderShipped();
}
A couple other notes:
- The return value handling for events follows the same rules as shown in the next section
- The endpoints will return a 404 response code if the aggregate in question does not exist
- The aggregate id can be set explicitly like
[Aggregate("number")]
to match against a route argument named "number", or by default the behavior will try to match first on "{camel case name of aggregate type}Id", then a route argument named "id" - This usage will automatically apply the transactional middleware for Marten
Using Request Body
TIP
This usage only requires Wolverine.Marten and does not require the Wolverine.Http.Marten library because there's nothing happening here in regards to Marten that is using AspNetCore
For some context, let's say that we have the following events and Marten aggregate to model the workflow of an Order
:
// OrderId refers to the identity of the Order aggregate
public record MarkItemReady(Guid OrderId, string ItemName, int Version);
public record OrderShipped;
public record OrderCreated(Item[] Items);
public record OrderReady;
public record OrderConfirmed;
public interface IShipOrder
{
Guid OrderId { init; }
}
public record ShipOrder(Guid OrderId) : IShipOrder;
public record ShipOrder2(string Description);
public record ItemReady(string Name);
public class Item
{
public string Name { get; set; }
public bool Ready { get; set; }
}
public class Order
{
// For JSON serialization
public Order(){}
public Order(OrderCreated created)
{
foreach (var item in created.Items) Items[item.Name] = item;
}
// This would be the stream id
public Guid Id { get; set; }
// This is important, by Marten convention this would
// be the
public int Version { get; set; }
public DateTimeOffset? Shipped { get; private set; }
public Dictionary<string, Item> Items { get; set; } = new();
public bool HasShipped { get; set; }
// These methods are used by Marten to update the aggregate
// from the raw events
public void Apply(IEvent<OrderShipped> shipped)
{
Shipped = shipped.Timestamp;
}
public void Apply(ItemReady ready)
{
Items[ready.Name].Ready = true;
}
public void Apply(OrderConfirmed confirmed)
{
IsConfirmed = true;
}
public bool IsConfirmed { get; set; }
public bool IsReadyToShip()
{
return Shipped == null && Items.Values.All(x => x.Ready);
}
public bool IsShipped() => Shipped.HasValue;
}
To append a single event to an event stream from an HTTP endpoint, you can use a return value like so:
[AggregateHandler]
[WolverinePost("/orders/ship"), EmptyResponse]
// The OrderShipped return value is treated as an event being posted
// to a Marten even stream
// instead of as the HTTP response body because of the presence of
// the [EmptyResponse] attribute
public static OrderShipped Ship(ShipOrder command, Order order)
{
return new OrderShipped();
}
Or potentially append multiple events using the Events
type as a return value like this sample:
[AggregateHandler]
[WolverinePost("/orders/itemready")]
public static (OrderStatus, Events) Post(MarkItemReady command, Order order)
{
var events = new Events();
if (order.Items.TryGetValue(command.ItemName, out var item))
{
item.Ready = true;
// Mark that the this item is ready
events += 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())
{
events += new OrderReady();
}
return (new OrderStatus(order.Id, order.IsReadyToShip()), events);
}
Responding with the Updated Aggregate
See the documentation from the message handlers on using UpdatedAggregate for more background on this topic.
To return the updated state of a projected aggregate from Marten as the HTTP response from an endpoint using the aggregate handler workflow, return the UpdatedAggregate
marker type as the first "response value" of your HTTP endpoint like so:
[AggregateHandler]
[WolverinePost("/orders/{id}/confirm2")]
// The updated version of the Order aggregate will be returned as the response body
// from requesting this endpoint at runtime
public static (UpdatedAggregate, Events) ConfirmDifferent(ConfirmOrder command, Order order)
{
return (
new UpdatedAggregate(),
[new OrderConfirmed()]
);
}
Compiled Query Resource Writer Policy
Marten integration comes with an IResourceWriterPolicy
policy that handles compiled queries as return types. Register it in WolverineHttpOptions
like this:
opts.UseMartenCompiledQueryResultPolicy();
If you now return a compiled query from an Endpoint the result will get directly streamed to the client as JSON. Short circuiting JSON deserialization.
[WolverineGet("/invoices/approved")]
public static ApprovedInvoicedCompiledQuery GetApproved()
{
return new ApprovedInvoicedCompiledQuery();
}
public class ApprovedInvoicedCompiledQuery : ICompiledListQuery<Invoice>
{
public Expression<Func<IMartenQueryable<Invoice>, IEnumerable<Invoice>>> QueryIs()
{
return q => q.Where(x => x.Approved);
}
}