diff --git a/build/build.cs b/build/build.cs index f6697699d..04ff27971 100644 --- a/build/build.cs +++ b/build/build.cs @@ -329,6 +329,7 @@ partial class Build : NukeBuild Solution.Http.Wolverine_Http_FluentValidation, Solution.Http.Wolverine_Http_Marten, Solution.Persistence.Polecat.Wolverine_Http_Polecat, + Solution.Persistence.Fisher.Wolverine_Http_Fisher, Solution.Testing.Wolverine_ComplianceTests, Solution.Transports.Redis.Wolverine_Redis, Solution.Transports.SignalR.Wolverine_SignalR, diff --git a/docs/.vitepress/config.mts b/docs/.vitepress/config.mts index 8e5a0919a..162845995 100644 --- a/docs/.vitepress/config.mts +++ b/docs/.vitepress/config.mts @@ -285,6 +285,7 @@ const config: UserConfig = { {text: 'Integration with Sagas', link: '/guide/http/sagas'}, {text: 'Integration with Marten', link: '/guide/http/marten'}, {text: 'Integration with Polecat', link: '/guide/http/polecat'}, + {text: 'Integration with Fisher', link: '/guide/http/fisher'}, {text: 'Validation', link: '/guide/http/validation'}, {text: 'Fluent Validation', link: '/guide/http/fluentvalidation'}, {text: 'Problem Details', link: '/guide/http/problemdetails'}, diff --git a/docs/guide/http/fisher.md b/docs/guide/http/fisher.md new file mode 100644 index 000000000..2109803b7 --- /dev/null +++ b/docs/guide/http/fisher.md @@ -0,0 +1,143 @@ +# Integration with Fisher + +The `Wolverine.Http.Fisher` library adds the ability to more deeply integrate Fisher +into Wolverine.HTTP by utilizing information from route arguments. + +To install that library, use: + +```bash +dotnet add package WolverineFx.Http.Fisher +``` + +This is the Fisher counterpart to [Wolverine.Http.Marten](/guide/http/marten) and +[Wolverine.Http.Polecat](/guide/http/polecat) — the three packages carry the same attributes over +their respective stores, so the usage below will look familiar if you have used either sibling. + +## Passing Fisher Documents to Endpoint Parameters + +::: tip +The `[Entity]` attribute is supported by both message handlers and HTTP endpoints for loading documents by identity. +::: + +Consider a common case: an HTTP endpoint that works on a Fisher document loaded by the value of one +of the route arguments. Longhand, that is: + +```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); +} +``` + +Using the `[Entity]` attribute, this becomes much simpler: + +```cs +[WolverineGet("/invoices/{id}")] +public static Invoice Get([Entity] Invoice invoice) +{ + return invoice; +} +``` + +The `[Entity]` attribute was able to use the "id" route parameter. By default, Wolverine looks first +for a route variable named "invoiceId" (the document type name + "Id"), then falls back to "id". You +can override the matching explicitly: + +```cs +[WolverinePost("/invoices/{number}/approve")] +public static IFisherOp Approve([Entity("number")] Invoice invoice) +{ + invoice.Approved = true; + return FisherOps.Store(invoice); +} +``` + +If the `Invoice` document does not exist, the route stops and returns a 404. Set `Required` to +`false` to have your handler execute anyway, or use the `OnMissing` property for any other answer — +a `ProblemDetails` body, a thrown exception, or an empty `204` with `OnMissing.EmptyContentWith204`. +See [the full `OnMissing` table](/guide/handlers/persistence#using-entity-for-message-handlers-and-http-endpoints). + +## Fisher Aggregate Workflow + +HTTP endpoints can play inside the full Wolverine + Fisher combination described in +[the Fisher integration guide](/guide/durability/fisher/). + +To opt into the aggregate workflow using a route argument for the aggregate id, use the +`[Aggregate]` attribute on an endpoint method parameter: + +```cs +[WolverinePost("/orders/{orderId}/ship"), EmptyResponse] +public static OrderShipped Ship(ShipOrder command, [Aggregate] Order order) +{ + if (order.HasShipped) + throw new InvalidOperationException("This has already shipped!"); + + return new OrderShipped(); +} +``` + +You do not have to supply a command in the request body at all: + +```cs +[WolverinePost("/orders/{orderId}/ship2"), EmptyResponse] +public static OrderShipped Ship2([Aggregate] Order order) +{ + return new OrderShipped(); +} +``` + +A couple of notes: + +* Return value handling for events follows the same rules as the message handler workflow +* The endpoint returns a 404 response code if the aggregate does not exist +* The aggregate id can be set explicitly, like `[Aggregate("number")]` +* This usage automatically applies the transactional middleware + +### Always Enforcing Consistency + +`[ConsistentAggregate]` behaves exactly as `[Aggregate]` except that it sets +`AlwaysEnforceConsistency`, so Fisher enforces an optimistic concurrency check on the referenced +stream even when the endpoint appends no events: + +```cs +[WolverinePost("/orders/{orderId}/confirm"), EmptyResponse] +public static OrderConfirmed Confirm([ConsistentAggregate] Order order) +{ + return new OrderConfirmed(); +} +``` + +### Overriding Version Discovery + +By default, Wolverine looks for a variable named `version` for optimistic concurrency checks. Use +`VersionSource` to point at a different one: + +```cs +[WolverinePost("/orders/{orderId}/ship/{expectedVersion}")] +[EmptyResponse] +public static OrderShipped Ship( + ShipOrder command, + [Aggregate(VersionSource = "expectedVersion")] Order order) +{ + return new OrderShipped(); +} +``` + +## Reading the Latest Version of an Aggregate + +To inject the current state of an event sourced aggregate as a parameter without opting into the +write workflow, use the `[ReadAggregate]` attribute: + +```cs +[WolverineGet("/orders/latest/{id}")] +public static Order GetLatest(Guid id, [ReadAggregate] Order order) => order; +``` diff --git a/src/Http/Wolverine.Http.Fisher/AggregateAttribute.cs b/src/Http/Wolverine.Http.Fisher/AggregateAttribute.cs new file mode 100644 index 000000000..0cb7bb679 --- /dev/null +++ b/src/Http/Wolverine.Http.Fisher/AggregateAttribute.cs @@ -0,0 +1,19 @@ +using Wolverine.Fisher; + +namespace Wolverine.Http.Fisher; + +/// +/// Marks a parameter to an HTTP endpoint as being part of the Fisher event sourcing +/// "aggregate handler" workflow +/// +[AttributeUsage(AttributeTargets.Parameter)] +public class AggregateAttribute : WriteAggregateAttribute +{ + public AggregateAttribute() + { + } + + public AggregateAttribute(string? routeOrParameterName) : base(routeOrParameterName) + { + } +} diff --git a/src/Http/Wolverine.Http.Fisher/ChainAggregateHandlingExtensions.cs b/src/Http/Wolverine.Http.Fisher/ChainAggregateHandlingExtensions.cs new file mode 100644 index 000000000..ea5274e21 --- /dev/null +++ b/src/Http/Wolverine.Http.Fisher/ChainAggregateHandlingExtensions.cs @@ -0,0 +1,34 @@ +using System.Diagnostics.CodeAnalysis; +using JasperFx.CodeGeneration.Model; +using Wolverine.Configuration; + +// GH-3907: AggregateHandling moved into Wolverine core with the rest of the shared aggregate handler +// workflow. The chain tag key is unchanged, so these extensions read exactly what they always did. +using Wolverine.Persistence.EventSourcing; + +namespace Wolverine.Http.Fisher; + +public static class ChainAggregateHandlingExtensions +{ + public static Variable? GetAggregateIdVariable(this IChain chain) + => chain.Tags.TryGetValue(nameof(AggregateHandling), out var obj) && obj is AggregateHandling aggregateHandling + ? aggregateHandling.AggregateId + : null; + + public static bool TryGetAggregateIdVariable(this IChain chain, [MaybeNullWhen(false)] out Variable variable) + { + variable = chain.GetAggregateIdVariable(); + return variable != null; + } + + public static Type? GetAggregateType(this IChain chain) + => chain.Tags.TryGetValue(nameof(AggregateHandling), out var obj) && obj is AggregateHandling aggregateHandling + ? aggregateHandling.AggregateType + : null; + + public static bool TryGetAggregateType(this IChain chain, [MaybeNullWhen(false)] out Type type) + { + type = chain.GetAggregateType(); + return type != null; + } +} diff --git a/src/Http/Wolverine.Http.Fisher/ConsistentAggregateAttribute.cs b/src/Http/Wolverine.Http.Fisher/ConsistentAggregateAttribute.cs new file mode 100644 index 000000000..68dce33c2 --- /dev/null +++ b/src/Http/Wolverine.Http.Fisher/ConsistentAggregateAttribute.cs @@ -0,0 +1,20 @@ +using Wolverine.Fisher; + +namespace Wolverine.Http.Fisher; + +/// +/// Marks a parameter to an HTTP endpoint as being part of the Fisher event sourcing +/// "aggregate handler" workflow with set to true, +/// meaning Fisher will enforce an optimistic concurrency check on referenced streams even if no events are appended. +/// +[AttributeUsage(AttributeTargets.Parameter)] +public class ConsistentAggregateAttribute : Wolverine.Fisher.ConsistentAggregateAttribute +{ + public ConsistentAggregateAttribute() + { + } + + public ConsistentAggregateAttribute(string? routeOrParameterName) : base(routeOrParameterName) + { + } +} diff --git a/src/Http/Wolverine.Http.Fisher/DocumentAttribute.cs b/src/Http/Wolverine.Http.Fisher/DocumentAttribute.cs new file mode 100644 index 000000000..0cd9fdf43 --- /dev/null +++ b/src/Http/Wolverine.Http.Fisher/DocumentAttribute.cs @@ -0,0 +1,24 @@ +using Wolverine.Attributes; +using Wolverine.Persistence; + +namespace Wolverine.Http.Fisher; + +/// +/// Marks a parameter to an HTTP endpoint as being loaded as a Fisher +/// 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 : EntityAttribute +{ + public DocumentAttribute() + { + ValueSource = ValueSource.Anything; + } + + public DocumentAttribute(string argumentName) : base(argumentName) + { + } +} diff --git a/src/Http/Wolverine.Http.Fisher/Wolverine.Http.Fisher.csproj b/src/Http/Wolverine.Http.Fisher/Wolverine.Http.Fisher.csproj new file mode 100644 index 000000000..6d48d0fd5 --- /dev/null +++ b/src/Http/Wolverine.Http.Fisher/Wolverine.Http.Fisher.csproj @@ -0,0 +1,21 @@ + + + + net9.0;net10.0 + $(NoWarn);NU1202;NETSDK1005 + Fisher middleware and other helpers for Wolverine HTTP Endpoints + WolverineFx.Http.Fisher + false + false + false + false + false + true + + + + + + + + diff --git a/src/Wolverine/AssemblyAttributes.cs b/src/Wolverine/AssemblyAttributes.cs index 5a81afb14..7364a8924 100644 --- a/src/Wolverine/AssemblyAttributes.cs +++ b/src/Wolverine/AssemblyAttributes.cs @@ -47,10 +47,12 @@ [assembly: InternalsVisibleTo("Wolverine.Http")] [assembly: InternalsVisibleTo("Wolverine.Http.Tests")] -// GH-3907: the shared aggregate handler workflow's AggregateHandling moved into Wolverine core, and both -// HTTP store integrations read it off the chain's tags to describe the workflow to OpenAPI. +// GH-3907: the shared aggregate handler workflow's AggregateHandling moved into Wolverine core, and each +// HTTP store integration reads it off the chain's tags to describe the workflow to OpenAPI. +// GH-3944: Fisher joined them, so the list is now all three flavours rather than "both". [assembly: InternalsVisibleTo("Wolverine.Http.Marten")] [assembly: InternalsVisibleTo("Wolverine.Http.Polecat")] +[assembly: InternalsVisibleTo("Wolverine.Http.Fisher")] [assembly: InternalsVisibleTo("Wolverine.Core.FSharpTests")] [assembly: InternalsVisibleTo("Wolverine.Grpc")] [assembly: InternalsVisibleTo("Wolverine.Grpc.Tests")] diff --git a/wolverine.slnx b/wolverine.slnx index 085a49028..33b778f40 100644 --- a/wolverine.slnx +++ b/wolverine.slnx @@ -120,6 +120,7 @@ +