From 958677323ec19ad7c966bbee535fad9c087749e7 Mon Sep 17 00:00:00 2001 From: "Jeremy D. Miller" Date: Tue, 31 Oct 2023 10:45:56 -0500 Subject: [PATCH] docs on Wolverine.Http.Marten --- docs/guide/http/marten.md | 134 +++++++++++++++++- .../AggregateAttribute.cs | 2 + .../DocumentAttribute.cs | 3 + src/Http/WolverineWebApi/Marten/Documents.cs | 31 +++- src/Http/WolverineWebApi/Marten/Orders.cs | 12 +- 5 files changed, 177 insertions(+), 5 deletions(-) diff --git a/docs/guide/http/marten.md b/docs/guide/http/marten.md index f6fa925fd..56645eab0 100644 --- a/docs/guide/http/marten.md +++ b/docs/guide/http/marten.md @@ -1,7 +1,136 @@ # 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: + +```bash +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: + + + +```cs +{ + [WolverineGet("/invoices/longhand/id")] + [ProducesResponseType(404)] + [ProducesResponseType(200, Type = typeof(Invoice))] + public static async Task GetInvoice( + Guid id, + IQuerySession session, + CancellationToken cancellationToken) + { + var invoice = await session.LoadAsync(id, cancellationToken); + if (invoice == null) return Results.NotFound(); + + return Results.Ok(invoice); + } +``` +snippet source | anchor + + +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: + + + +```cs +[WolverineGet("/invoices/{id}")] +public static Invoice Get([Document] Invoice invoice) +{ + return invoice; +} +``` +snippet source | anchor + + +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: + + + +```cs +[WolverinePost("/invoices/{number}/approve")] +public static IMartenOp Approve([Document("number")] Invoice invoice) +{ + invoice.Approved = true; + return MartenOps.Store(invoice); +} +``` +snippet source | anchor + + + +## 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). +support for Event Sourcing and CQRS](/guide/durability/marten/event-sourcing). 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: + + + +```cs +[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) +{ + return new OrderShipped(); +} +``` +snippet source | anchor + + +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: + + + +```cs +[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(); +} +``` +snippet source | anchor + + +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](https://martendb.io/events/projections/aggregate-projections.html#aggregate-by-stream) to model the workflow of an `Order`: @@ -116,5 +245,6 @@ public static (OrderStatus, Events) Post(MarkItemReady command, Order order) return (new OrderStatus(order.Id, order.IsReadyToShip()), events); } ``` -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 7ad9b1323..eace80de1 100644 --- a/src/Http/Wolverine.Http.Marten/AggregateAttribute.cs +++ b/src/Http/Wolverine.Http.Marten/AggregateAttribute.cs @@ -56,6 +56,8 @@ public AggregateAttribute(string routeOrParameterName) public override Variable Modify(HttpChain chain, ParameterInfo parameter, IContainer container) { + chain.Metadata.Produces(404); + AggregateType = parameter.ParameterType; var store = container.GetInstance(); var idType = store.Options.Events.StreamIdentity == StreamIdentity.AsGuid ? typeof(Guid) : typeof(string); diff --git a/src/Http/Wolverine.Http.Marten/DocumentAttribute.cs b/src/Http/Wolverine.Http.Marten/DocumentAttribute.cs index a3f557bf3..642f55c15 100644 --- a/src/Http/Wolverine.Http.Marten/DocumentAttribute.cs +++ b/src/Http/Wolverine.Http.Marten/DocumentAttribute.cs @@ -4,6 +4,7 @@ using JasperFx.Core; using Lamar; using Marten; +using Microsoft.AspNetCore.Http; namespace Wolverine.Http.Marten; @@ -28,6 +29,8 @@ public DocumentAttribute(string routeArgumentName) public override Variable Modify(HttpChain chain, ParameterInfo parameter, IContainer container) { + chain.Metadata.Produces(404); + var store = container.GetInstance(); var documentType = parameter.ParameterType; var mapping = store.Options.FindOrResolveDocumentType(documentType); diff --git a/src/Http/WolverineWebApi/Marten/Documents.cs b/src/Http/WolverineWebApi/Marten/Documents.cs index 93887e77f..14dd65fca 100644 --- a/src/Http/WolverineWebApi/Marten/Documents.cs +++ b/src/Http/WolverineWebApi/Marten/Documents.cs @@ -1,3 +1,5 @@ +using Marten; +using Microsoft.AspNetCore.Mvc; using Wolverine.Http; using Wolverine.Http.Marten; using Wolverine.Marten; @@ -5,26 +7,53 @@ namespace WolverineWebApi.Marten; public class InvoicesEndpoint + + #region sample_get_invoice_longhand + { + [WolverineGet("/invoices/longhand/id")] + [ProducesResponseType(404)] + [ProducesResponseType(200, Type = typeof(Invoice))] + public static async Task GetInvoice( + Guid id, + IQuerySession session, + CancellationToken cancellationToken) + { + var invoice = await session.LoadAsync(id, cancellationToken); + if (invoice == null) return Results.NotFound(); + + return Results.Ok(invoice); + } + + #endregion + + #region sample_using_document_attribute + [WolverineGet("/invoices/{id}")] public static Invoice Get([Document] Invoice invoice) { return invoice; } + #endregion + [WolverinePost("/invoices/{invoiceId}/pay")] public static IMartenOp Pay([Document] Invoice invoice) { invoice.Paid = true; return MartenOps.Store(invoice); } - + + #region sample_overriding_route_argument_with_document_attribute + [WolverinePost("/invoices/{number}/approve")] public static IMartenOp Approve([Document("number")] Invoice invoice) { invoice.Approved = true; return MartenOps.Store(invoice); } + + #endregion } public class Invoice diff --git a/src/Http/WolverineWebApi/Marten/Orders.cs b/src/Http/WolverineWebApi/Marten/Orders.cs index 8c47b7fe0..80821f124 100644 --- a/src/Http/WolverineWebApi/Marten/Orders.cs +++ b/src/Http/WolverineWebApi/Marten/Orders.cs @@ -90,7 +90,9 @@ public static OrderShipped Ship(ShipOrder command, Order order) } #endregion - + + #region sample_using_aggregate_attribute_1 + [WolverinePost("/orders/{orderId}/ship2"), EmptyResponse] // The OrderShipped return value is treated as an event being posted // to a Marten even stream @@ -100,7 +102,11 @@ public static OrderShipped Ship(ShipOrder2 command, [Aggregate] Order order) { return new OrderShipped(); } - + + #endregion + + #region sample_using_aggregate_attribute_2 + [WolverinePost("/orders/{orderId}/ship3"), EmptyResponse] // The OrderShipped return value is treated as an event being posted // to a Marten even stream @@ -110,6 +116,8 @@ public static OrderShipped Ship3([Aggregate] Order order) { return new OrderShipped(); } + + #endregion [WolverinePost("/orders/{orderId}/ship4"), EmptyResponse] // The OrderShipped return value is treated as an event being posted