Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
a706634
chore: add .plans/ to .gitignore for grpc-streaming planning artifacts
erikshafer Apr 16, 2026
b6575fa
feat: add StreamAsync<T> streaming handler support (M2)
erikshafer Apr 16, 2026
c8d52de
Add gRPC integration with proto-first and code-first support
erikshafer Apr 17, 2026
6c35b7b
Add OpenTelemetry activity propagation tests for gRPC integration
erikshafer Apr 17, 2026
537e2bf
Add NotSupportedException guards for StreamAsync on subscription invo…
erikshafer Apr 17, 2026
55e465c
Move gRPC sample apps from Wolverine.Http.Grpc.Tests to src/Samples
erikshafer Apr 17, 2026
8a1fb92
Fix ActivityCapture race condition and anchor OTel assertion on messa…
erikshafer Apr 17, 2026
288bab3
Add rich gRPC error details with google.rpc.Status support
erikshafer Apr 17, 2026
f78528c
Rename Wolverine.Http.Grpc to Wolverine.Grpc per PR #2525 review
erikshafer Apr 18, 2026
b8fceb4
Add comprehensive gRPC documentation covering contracts, handlers, er…
erikshafer Apr 18, 2026
a055758
Document gRPC roadmap covering MiddlewareScoping.Grpc, codegen-previe…
erikshafer Apr 18, 2026
2b5a5bc
Add codegen-preview --grpc flag for proto-first gRPC service inspection
erikshafer Apr 18, 2026
8113c8f
Correct GrpcServiceChain middleware scoping to Grpc enum value
erikshafer Apr 18, 2026
f3307c2
Document typed gRPC client extension as tentative roadmap item
erikshafer Apr 19, 2026
626ce45
Add typed gRPC client registration with envelope propagation and exce…
erikshafer Apr 19, 2026
77c73b3
Add OrderChainWithGrpc sample demonstrating envelope propagation and …
erikshafer Apr 19, 2026
e32c917
Add README files for all gRPC sample projects and enable JasperFx com…
erikshafer Apr 19, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -241,3 +241,6 @@ JasperSamples
/volume

/src/Transports/Wolverine.AzureServiceBus.Tests/connection.txt

# Planning artifacts (not for PR)
.plans/
10 changes: 9 additions & 1 deletion Directory.Packages.props
Original file line number Diff line number Diff line change
Expand Up @@ -17,10 +17,16 @@
<PackageVersion Include="FluentValidation" Version="12.0.0" />
<PackageVersion Include="FSharp.Core" Version="9.0.303" />
<PackageVersion Include="GitHubActionsTestLogger" Version="2.4.1" />
<PackageVersion Include="Google.Api.CommonProtos" Version="2.16.0" />
<PackageVersion Include="Google.Cloud.PubSub.V1" Version="3.24.0" />
<PackageVersion Include="Google.Protobuf" Version="3.31.1" />
<PackageVersion Include="Grpc.AspNetCore" Version="2.76.0" />
<PackageVersion Include="Grpc.Core" Version="2.46.6" />
<PackageVersion Include="Grpc.Tools" Version="2.72.0" />
<PackageVersion Include="Grpc.Core.Api" Version="2.76.0" />
<PackageVersion Include="Grpc.Net.Client" Version="2.76.0" />
<PackageVersion Include="Grpc.Net.ClientFactory" Version="2.76.0" />
<PackageVersion Include="Grpc.StatusProto" Version="2.76.0" />
<PackageVersion Include="Grpc.Tools" Version="2.76.0" />
<PackageVersion Include="HtmlTags" Version="9.0.0" />
<PackageVersion Include="JasperFx" Version="1.24.1" />
<PackageVersion Include="JasperFx.Events" Version="1.27.0" />
Expand Down Expand Up @@ -67,6 +73,8 @@
<PackageVersion Include="OpenTelemetry.Instrumentation.Http" Version="1.14.0" />
<PackageVersion Include="Oracle.ManagedDataAccess.Core" Version="23.7.0" />
<PackageVersion Include="protobuf-net.BuildTools" Version="3.2.52" />
<PackageVersion Include="protobuf-net.Grpc" Version="1.2.2" />
<PackageVersion Include="protobuf-net.Grpc.AspNetCore" Version="1.2.2" />
<PackageVersion Include="RabbitMQ.Client" Version="7.1.2" />
<PackageVersion Include="RavenDB.Client" Version="7.0.2" />
<PackageVersion Include="RavenDB.DependencyInjection" Version="5.0.1" />
Expand Down
11 changes: 10 additions & 1 deletion docs/.vitepress/config.mts
Original file line number Diff line number Diff line change
Expand Up @@ -279,7 +279,16 @@ const config: UserConfig<DefaultTheme.Config> = {
{text: 'Rate Limiting', link: '/guide/http/rate-limiting'},
{text: 'Streaming and SSE', link: '/guide/http/streaming'},
{text: 'HTTP Messaging Transport', link: '/guide/http/transport'},
{text: 'Integration Testing with Alba', link: '/guide/http/integration-testing'}
{text: 'Integration Testing with Alba', link: '/guide/http/integration-testing'},
{text: 'gRPC Services', link: '/guide/grpc/', collapsed: true, items: [
{text: 'How gRPC Handlers Work', link: '/guide/grpc/handlers'},
{text: 'Code-First and Proto-First Contracts', link: '/guide/grpc/contracts'},
{text: 'Error Handling', link: '/guide/grpc/errors'},
{text: 'Streaming', link: '/guide/grpc/streaming'},
{text: 'Typed gRPC Clients', link: '/guide/grpc/client'},
{text: 'Samples', link: '/guide/grpc/samples'}
]
}
]
},
{
Expand Down
21 changes: 18 additions & 3 deletions docs/guide/command-line.md
Original file line number Diff line number Diff line change
Expand Up @@ -163,9 +163,10 @@ Wolverine automatically detects CLI codegen mode and stubs out persistence and t

### codegen-preview

Preview the full generated adapter code for a **specific** message handler or HTTP endpoint without
generating all handlers at once. This is useful when you want to understand exactly what middleware,
dependency resolution, or transaction wrapping Wolverine applies to a single entry point.
Preview the full generated adapter code for a **specific** message handler, HTTP endpoint, or
proto-first gRPC service without generating all handlers at once. This is useful when you want to
understand exactly what middleware, dependency resolution, or transaction wrapping Wolverine
applies to a single entry point.

**Preview a message handler** (accepts fully-qualified name, short class name, or handler class name):

Expand All @@ -187,6 +188,20 @@ dotnet run -- wolverine-diagnostics codegen-preview --route "POST /api/orders"
dotnet run -- wolverine-diagnostics codegen-preview --route "GET /api/orders/{id}"
```

**Preview a proto-first gRPC service wrapper** (requires Wolverine.Grpc; accepts the proto service
name, the stub class name, or the generated file name):

```bash
# Bare proto service name (as it appears in the .proto file)
dotnet run -- wolverine-diagnostics codegen-preview --grpc Greeter

# Stub class name
dotnet run -- wolverine-diagnostics codegen-preview --grpc GreeterGrpcService

# Short alias
dotnet run -- wolverine-diagnostics codegen-preview -g Greeter
```

The output includes the full generated class — the `Handle` or `HandleAsync` override, all
middleware calls in order, dependency resolution from the IoC container, and any
transaction-wrapping frames. This is identical to what `codegen preview` outputs, but scoped to
Expand Down
234 changes: 234 additions & 0 deletions docs/guide/grpc/client.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,234 @@
# Typed gRPC Clients

`AddWolverineGrpcClient<T>()` is a thin Wolverine-flavored wrapper over the Microsoft gRPC client factory
(`Grpc.Net.ClientFactory.AddGrpcClient<T>()`). It layers three conveniences onto the standard path without
replacing it:

1. **Envelope-header propagation** — `correlation-id`, `tenant-id`, `parent-id`, `conversation-id`, and
`message-id` are stamped on outgoing calls whenever an `IMessageContext` is resolvable from the current
DI scope. The wire vocabulary is the same [`EnvelopeConstants`](https://github.com/JasperFx/wolverine/blob/main/src/Wolverine/EnvelopeConstants.cs)
every other Wolverine transport uses, so a call routed through the gRPC client preserves the same
correlation identity a message-bus hop would.
2. **`RpcException` → typed-exception translation** — the client-side inverse of
[`WolverineGrpcExceptionInterceptor`](./errors). An inbound `RpcException` with `StatusCode.NotFound`
surfaces as `KeyNotFoundException`, `InvalidArgument` as `ArgumentException`, and so on — with the
original `RpcException` preserved on `InnerException` so rich error details (trailers, status-detail
payloads) are never lost.
3. **Uniform registration for both contract styles** — a single entry point handles
`protobuf-net.Grpc` code-first `[ServiceContract]` interfaces *and* proto-first `Grpc.Tools`-generated
concrete clients. Wolverine detects the style automatically and routes each through the correct
substrate.

::: tip Still-supported alternative
Raw `GrpcChannel` + generated stubs — the pattern used by the samples in this section — remains a
first-class path. `AddWolverineGrpcClient<T>()` is adoption-driven sugar, not a replacement. If a
`GrpcChannel` in `Program.cs` works for your project, you can keep it.
:::

## Registration

Call `AddWolverineGrpcClient<T>()` against an `IServiceCollection` and set, at a minimum, the
`Address`. The extension returns a `WolverineGrpcClientBuilder` that exposes further configuration
without revealing which substrate was chosen:

```csharp
builder.Services.AddWolverineGrpcClient<IPingService>(o =>
{
o.Address = new Uri("https://ponger.example");
});
```

From there, inject the typed client into any Wolverine handler, minimal-API endpoint, or background
service the same way you would a generated client:

```csharp
public static async Task<PongReply> Handle(PingRequest request, IPingService ping, CancellationToken ct)
{
return await ping.Ping(request);
}
```

### Code-first vs proto-first

`IsCodeFirstContract` classifies `TClient` by whether it is an interface decorated with
`[ServiceContract]` (the `protobuf-net.Grpc` convention). The two cases route differently:

| Kind | Detected when `TClient` is… | Substrate |
|--------------|------------------------------------------------------------|-------------------------------------------------------------------|
| `CodeFirst` | an interface with `[ServiceContract]` | Wolverine's own channel factory (`WolverineGrpcCodeFirstChannelFactory`) |
| `ProtoFirst` | a concrete class (e.g. the generated `Greeter.GreeterClient`) | `Grpc.Net.ClientFactory.AddGrpcClient<T>()` (Microsoft's factory) |

Proto-first registrations also expose the underlying `IHttpClientBuilder` via
`builder.HttpClientBuilder` so you can wire Polly, `IHttpMessageHandlerBuilderFilter`, or any other
`IHttpClientFactory` extension point. Code-first registrations do not ride on `IHttpClientFactory`,
so `builder.HttpClientBuilder` is `null` — use `ConfigureChannel` instead.

### The address is required

`WolverineGrpcClientOptions.Address` is intentionally nullable at the type level so the builder can
compose across multiple `Configure(...)` calls. Resolution-time validation throws a clear
`InvalidOperationException` if it was never set by the time a client is pulled out of the container,
naming the contract type. This mirrors the server-side AIP-193 mapping philosophy — loud, early
errors over silent misconfiguration.

## Envelope-header propagation

`WolverineGrpcClientPropagationInterceptor` runs on every call shape (unary, server-streaming,
client-streaming, duplex-streaming, blocking unary). On each call it resolves `IMessageContext` from
the current DI scope and stamps the five envelope identifiers onto the outgoing call's `Metadata`:

| Header | Source |
|--------------------|--------------------------------------------|
| `correlation-id` | `IMessageContext.CorrelationId` |
| `tenant-id` | `IMessageContext.TenantId` |
| `message-id` | `IMessageContext.Envelope.Id` |
| `parent-id` | `IMessageContext.Envelope.ParentId` |
| `conversation-id` | `IMessageContext.Envelope.ConversationId` |

The design notes worth knowing:

- The interceptor **never overwrites** a header the caller stamped themselves. Per-call
`Metadata` passed through `CallOptions` wins. This keeps explicit overrides (e.g. impersonating
a specific tenant for a background job) idiomatic.
- If there is **no `IMessageContext` in scope** (a bare `Program.cs` caller, a test harness without
the Wolverine bus, etc.) the interceptor silently no-ops. The call still goes through — just
without Wolverine-specific headers.
- Propagation can be **disabled per client** by setting
`WolverineGrpcClientOptions.PropagateEnvelopeHeaders = false`. Rarely needed, but occasionally
useful when the server is a third-party service that does not understand Wolverine's metadata
vocabulary.

```csharp
builder.Services.AddWolverineGrpcClient<IPingService>(o =>
{
o.Address = new Uri("https://ponger.example");
o.PropagateEnvelopeHeaders = false; // opt out
});
```

On the server side of a Wolverine→Wolverine hop, the envelope headers are read back in the
`WolverineGrpcServicePropagationInterceptor` already shipped with the adapter, so a call chain
spanning multiple Wolverine services keeps a single correlation identity without any user wiring.

## `RpcException` → typed-exception translation

`WolverineGrpcClientExceptionInterceptor` catches `RpcException` before it surfaces to your handler
code and substitutes a typed .NET exception using the inverse of the server-side AIP-193 table:

| gRPC Status Code | .NET Exception |
|--------------------------------------------|--------------------------------------|
| `Cancelled` | `OperationCanceledException` |
| `DeadlineExceeded` | `TimeoutException` |
| `InvalidArgument` | `ArgumentException` |
| `NotFound` | `KeyNotFoundException` |
| `PermissionDenied`, `Unauthenticated` | `UnauthorizedAccessException` |
| `FailedPrecondition` | `InvalidOperationException` |
| `Unimplemented` | `NotImplementedException` |
| *anything else* (`Internal`, `Unknown`, …) | *original `RpcException`, unchanged* |

The original `RpcException` is always preserved on `InnerException` so `grpc-status-details-bin`
trailers, `Status.Detail`, and the full gRPC diagnostic surface remain reachable:

```csharp
try
{
var reply = await client.GetOrder(new GetOrderRequest { Id = 42 });
}
catch (KeyNotFoundException ex)
{
// ex.Message → Status.Detail from the server
// ex.InnerException is RpcException — inspect trailers / rich details here
var rpc = (RpcException)ex.InnerException!;
}
```

Streaming responses are translated per `MoveNextAsync`: an `RpcException` raised after the first
yielded item surfaces as the typed exception from inside the `await foreach` loop, not from the
outer `client.StreamCall(...)` invocation.

### Per-client override

Some integrations need bespoke mapping — translating a specific `StatusCode` to a domain-specific
exception, or mapping trailers onto a richer exception type. Supply a
`MapRpcException` callback on the options:

```csharp
builder.Services.AddWolverineGrpcClient<ITenantService>(o =>
{
o.Address = new Uri("https://tenant.example");
o.MapRpcException = ex => ex.StatusCode == StatusCode.NotFound
? new TenantNotFoundException(ex.Status.Detail, ex)
: null; // null → fall through to the default table
});
```

The override is consulted first; returning `null` forwards to the default mapping so you only need
to cover the status codes you care about.

## Escape hatches

### `ConfigureChannel`

For any knob exposed by `GrpcChannelOptions` but not by `WolverineGrpcClientOptions`:

```csharp
builder.Services
.AddWolverineGrpcClient<IPingService>(o => o.Address = new Uri("https://ponger.example"))
.ConfigureChannel(channel =>
{
channel.MaxReceiveMessageSize = 16 * 1024 * 1024;
channel.Credentials = ChannelCredentials.SecureSsl;
});
```

`ConfigureChannel` works across both code-first and proto-first registrations — for proto-first it
is applied via the factory's `ChannelOptionsActions`; for code-first it is applied when Wolverine
materializes the channel inside `WolverineGrpcCodeFirstChannelFactory`.

### `HttpClientBuilder` (proto-first only)

If you need `IHttpClientFactory` extension points directly — Polly resilience, primary handler
replacement, per-environment message handlers — `builder.HttpClientBuilder` is non-null on the
proto-first path:

```csharp
builder.Services
.AddWolverineGrpcClient<Greeter.GreeterClient>(o => o.Address = new Uri("https://greeter.example"))
.HttpClientBuilder!
.AddStandardResilienceHandler();
```

The Wolverine exception interceptor is registered *outermost* in the pipeline on purpose: when you
add `AddStandardResilienceHandler` (or other Polly-based handlers), retries run *inside* the
exception catch, so the final exception surfaced to your code still reflects the final outcome
after retries — not the first transient failure translated into `TimeoutException`.

## Ordering and composition

The interceptor stack is constructed so that:

1. **Exception translation** is the outermost concern. Retries and other Polly policies live
underneath, and their final outcome is what the typed-exception mapper sees.
2. **Propagation** sits inside the exception interceptor. A retry that Polly issues gets a fresh
stamp of the current `IMessageContext` — not stale headers captured before the retry.

If you add your own interceptor via `builder.HttpClientBuilder!.AddInterceptor(...)` (proto-first)
it lands inside both Wolverine interceptors, which is what you almost always want.

## API Reference

| Type / Member | Purpose |
|-------------------------------------------------------|----------------------------------------------------------------------------------------|
| `AddWolverineGrpcClient<TClient>()` | Registers a typed gRPC client with Wolverine propagation + exception translation. |
| `WolverineGrpcClientOptions` | Named options for a registered client — `Address`, `PropagateEnvelopeHeaders`, `MapRpcException`. |
| `WolverineGrpcClientBuilder` | Return value: `Kind`, `HttpClientBuilder` (proto-first only), `ConfigureChannel(...)`. |
| `WolverineGrpcClientKind` | `CodeFirst` / `ProtoFirst` — exposed on the builder for discovery. |
| `WolverineGrpcClientPropagationInterceptor` | Stamps envelope headers on each call. |
| `WolverineGrpcClientExceptionInterceptor` | Translates `RpcException` to typed .NET exceptions per `MapRpcException` + the default table. |
| `WolverineGrpcExceptionMapper.MapToException(rpc)` | Public default mapping table; use from custom interceptors if needed. |

## See also

- [Error Handling](./errors) — the server-side mapping the client-side `MapToException` table mirrors.
- [How gRPC Handlers Work](./handlers) — the server-side propagation interceptor that reads back
the headers stamped here.
Loading
Loading