From 350ebf1edf5f35538d5ce2a1b202678e8b83711b Mon Sep 17 00:00:00 2001 From: "Jeremy D. Miller" Date: Sat, 15 Aug 2026 12:00:58 -0500 Subject: [PATCH] Add Wolverine.Http.Fisher, the missing third HTTP store integration (GH-3944) There is a Wolverine.Http.Marten and a Wolverine.Http.Polecat, and no Fisher equivalent -- so any Fisher application wanting the aggregate/document HTTP attributes had nothing to reference, and "the three flavours' csproj files are the same file with one word changed" stopped being true at that line. Wolverine.Fisher already carried every base class the HTTP package derives from -- WriteAggregateAttribute, ConsistentAggregateAttribute, ReadAggregateAttribute, IFisherOp/FisherOps -- and already declared InternalsVisibleTo("Wolverine.Http.Fisher"). So the port from Wolverine.Http.Polecat is close to verbatim: four files plus the csproj, PackageId WolverineFx.Http.Fisher to match its two siblings. Three pieces of wiring beyond the port: - Wolverine core grants InternalsVisibleTo to each HTTP store integration so it can read AggregateHandling off the chain tags (GH-3907). Fisher joins Marten and Polecat there; without it the ChainAggregateHandlingExtensions port does not compile. - The project joins the solution's /Persistence/Fisher/ folder, mirroring how Wolverine.Http.Polecat sits inside /Persistence/Polecat/. - It joins the hand-maintained Pack list in build/build.cs. The GH-3905 guard fails the build when a project declares a PackageId but is missing from that list, precisely so a package cannot silently never ship. Docs: docs/guide/http/fisher.md alongside its two siblings, plus the nav entry. The samples are written against the attributes that actually exist on Wolverine.Fisher; it deliberately does not link a /guide/durability/fisher/event-sourcing page, which does not exist yet. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_014PwvC24c5dNxv7DJR7RUjv --- build/build.cs | 1 + docs/.vitepress/config.mts | 1 + docs/guide/http/fisher.md | 143 ++++++++++++++++++ .../AggregateAttribute.cs | 19 +++ .../ChainAggregateHandlingExtensions.cs | 34 +++++ .../ConsistentAggregateAttribute.cs | 20 +++ .../DocumentAttribute.cs | 24 +++ .../Wolverine.Http.Fisher.csproj | 21 +++ src/Wolverine/AssemblyAttributes.cs | 6 +- wolverine.slnx | 1 + 10 files changed, 268 insertions(+), 2 deletions(-) create mode 100644 docs/guide/http/fisher.md create mode 100644 src/Http/Wolverine.Http.Fisher/AggregateAttribute.cs create mode 100644 src/Http/Wolverine.Http.Fisher/ChainAggregateHandlingExtensions.cs create mode 100644 src/Http/Wolverine.Http.Fisher/ConsistentAggregateAttribute.cs create mode 100644 src/Http/Wolverine.Http.Fisher/DocumentAttribute.cs create mode 100644 src/Http/Wolverine.Http.Fisher/Wolverine.Http.Fisher.csproj 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 @@ +