diff --git a/docs/guide/grpc/contracts.md b/docs/guide/grpc/contracts.md index e043897fe..f076d5f79 100644 --- a/docs/guide/grpc/contracts.md +++ b/docs/guide/grpc/contracts.md @@ -26,9 +26,101 @@ scroll between them and compare. A common pragmatic split: **code-first for internal service-to-service calls** where both ends always deploy together, **proto-first for anything the outside world consumes**. +## Code-first codegen (generated implementation) + +Applying `[WolverineGrpcService]` to the **interface** itself alongside `[ServiceContract]` lets +Wolverine generate the concrete implementation for you. Wolverine discovers the interface at startup, +emits `{ServiceName}GrpcHandler` that injects `IMessageBus`, and maps it — no service class to write +or maintain. + +```csharp +[ServiceContract] +[WolverineGrpcService] +public interface IGreeterCodeFirstService +{ + Task Greet(GreetRequest request, CallContext context = default); + IAsyncEnumerable StreamGreetings(StreamGreetingsRequest request, CallContext context = default); +} + +// That's the whole contract. No service class needed. + +// Ordinary Wolverine handlers — no gRPC coupling +public static class GreeterHandler +{ + public static GreetReply Handle(GreetRequest request) + => new() { Message = $"Hello, {request.Name}!" }; + + public static async IAsyncEnumerable Handle( + StreamGreetingsRequest request, + [EnumeratorCancellation] CancellationToken cancellationToken) + { + for (var i = 1; i <= request.Count; i++) + { + cancellationToken.ThrowIfCancellationRequested(); + yield return new GreetReply { Message = $"Hello, {request.Name} [{i} of {request.Count}]" }; + await Task.Yield(); + } + } +} +``` + +Bootstrap is the same as the hand-written path, with one addition: if the annotated interface lives +in a separate assembly from the server (e.g. a shared `Messages` project), tell Wolverine to scan +that assembly so `GrpcGraph` can find it: + +```csharp +builder.Host.UseWolverine(opts => +{ + opts.ApplicationAssembly = typeof(Program).Assembly; + opts.Discovery.IncludeAssembly(typeof(IGreeterCodeFirstService).Assembly); +}); + +builder.Services.AddCodeFirstGrpc(); +builder.Services.AddWolverineGrpc(); + +// ... +app.MapWolverineGrpcServices(); +``` + +The generated class name follows the convention `{InterfaceNameWithoutLeadingI}GrpcHandler` — so +`IGreeterCodeFirstService` → `GreeterCodeFirstServiceGrpcHandler`. The generated type implements the +contract interface and is the class that protobuf-net.Grpc maps as the service endpoint. + +On the client side, use the same interface with `channel.CreateGrpcService()` — no stubs, no +`.proto` file, no code-gen step: + +```csharp +using var channel = GrpcChannel.ForAddress("https://greeter.example"); +var greeter = channel.CreateGrpcService(); + +var reply = await greeter.Greet(new GreetRequest { Name = "Erik" }); +await foreach (var item in greeter.StreamGreetings(new StreamGreetingsRequest { Name = "Erik", Count = 5 })) + Console.WriteLine(item.Message); +``` + +The [GreeterCodeFirstGrpc](https://github.com/JasperFx/wolverine/tree/main/src/Samples/GreeterCodeFirstGrpc) +sample demonstrates this end-to-end. See [Samples](./samples#greetercodefirstgrpc) for a walkthrough. + +::: warning Bidirectional streaming is not supported on the generated-implementation path +The generated implementation recognises **unary** (`Task`) and **server streaming** +(`IAsyncEnumerable`) method shapes. An interface method with an `IAsyncEnumerable` +*parameter* (bidirectional streaming) is silently skipped — no startup error, but the method will +not be mapped. Use a hand-written service class for bidi RPCs on code-first contracts. +::: + +::: warning No conflict allowed +`[WolverineGrpcService]` must appear on **either** the interface **or** a concrete implementing +class — not both. If Wolverine finds the attribute on both, it throws `InvalidOperationException` +at startup with a diagnostic identifying the conflict. This mirrors the proto-first rule that the +stub must be abstract. +::: + ## Unary RPC -### Code-first +### Code-first (hand-written service class) + +When you prefer explicit control over the service class — for example to add per-method logging or +to call multiple downstream services from one RPC — write the class yourself: ```csharp [ServiceContract] @@ -55,6 +147,14 @@ public static class PingHandler Any class whose name ends in `GrpcService` is picked up by `MapWolverineGrpcServices()`. If the suffix convention doesn't fit, apply `[WolverineGrpcService]` instead. +Wolverine generates a thin **delegation wrapper** around the class at startup (named +`{ClassName}GrpcHandler`). The wrapper implements the same `[ServiceContract]` interface, weaves +any `Validate` / `[WolverineBefore]` middleware defined on the service class, then calls into the +inner class — which Wolverine resolves from the DI container or constructs via +`ActivatorUtilities` if no explicit registration exists. This gives hand-written service classes +the same middleware and validation hooks available to the proto-first and generated-implementation +paths. + ### Proto-first ```proto @@ -88,9 +188,11 @@ type. Proto-first handlers must live on separate classes, not on the stub itself ## Server streaming Same handler shape on both sides — `IAsyncEnumerable` plus `[EnumeratorCancellation]`. The -difference is purely in how the contract is declared. +difference is purely in how the contract is declared. For the generated-implementation path, the +interface method simply returns `IAsyncEnumerable` — see the [Code-first codegen](#code-first-codegen-generated-implementation) +section above. -### Code-first +### Code-first (hand-written service class) Return `IAsyncEnumerable` from the contract method. protobuf-net.Grpc recognises the return type as a server-streaming RPC and wires the transport for you. diff --git a/docs/guide/grpc/errors.md b/docs/guide/grpc/errors.md index 8aeb78bc4..eb07cb8ef 100644 --- a/docs/guide/grpc/errors.md +++ b/docs/guide/grpc/errors.md @@ -16,7 +16,8 @@ through to the default table. `WolverineGrpcExceptionInterceptor` is registered automatically by `AddWolverineGrpc` and applies to both code-first and proto-first services. It translates ordinary .NET exceptions thrown by handlers -into `RpcException` with the canonical status code from the table below: +into `RpcException` with the canonical status code from the table below. See +[Overriding the default table](#overriding-the-default-table) if the defaults don't match your domain model. | Exception | gRPC Status Code | |-------------------------------|-------------------------| @@ -48,6 +49,31 @@ public static OrderReply Handle(GetOrder request, IOrderStore store) Throwing `RpcException` directly remains the escape hatch for status codes or trailers not in either table. +## Overriding the default table + +Call `MapException(StatusCode)` on `WolverineGrpcOptions` inside `AddWolverineGrpc` to +register application-specific overrides. User-registered mappings are checked after the opt-in +`google.rpc.Status` rich-error pipeline and before the built-in AIP-193 table, so they always win +over the defaults: + +```csharp +builder.Services.AddWolverineGrpc(opts => +{ + // Map a custom domain exception that the default table doesn't know about + opts.MapException(StatusCode.NotFound); + + // Override a default: treat TimeoutException as ResourceExhausted instead of DeadlineExceeded + opts.MapException(StatusCode.ResourceExhausted); +}); +``` + +Inheritance is respected — a mapping for a base class also matches derived types unless a more +specific mapping is registered. When multiple registrations target the same exception type, the +last one wins. + +For structured, field-level error payloads (validation failures, `google.rpc.Status` details), see +the [Rich error details](#rich-error-details-opt-in) section below. + ## Rich error details (opt-in) The default mapping table produces a single `StatusCode` and a message string — enough for diff --git a/docs/guide/grpc/handlers.md b/docs/guide/grpc/handlers.md index 5244f38da..43dfc9599 100644 --- a/docs/guide/grpc/handlers.md +++ b/docs/guide/grpc/handlers.md @@ -62,19 +62,33 @@ itself doesn't know which of the three invoked it. - **Middleware applies identically.** `UseFluentValidation()`, open/generic middleware, saga middleware, etc., all run on the gRPC path because Wolverine is invoking the handler through the same pipeline it always does. -- **The transport concerns stay at the edge.** You won't see `RpcException` inside a handler, and - you shouldn't throw one there either — throw the domain exception and let the interceptor - translate it. +- **The transport concerns stay at the edge.** You won't see `RpcException` inside a handler. + +::: tip Throw domain exceptions, not RpcException +Throw ordinary .NET exceptions from handlers — `ArgumentException`, `KeyNotFoundException`, your +own domain types. The interceptor translates them to the right `StatusCode` automatically. +Throwing `RpcException` directly from a handler does work (it passes through unchanged), but it +couples the handler to the gRPC transport and prevents it from being reused over messaging or HTTP. +::: ## Discovery and codegen -`MapWolverineGrpcServices()` does two passes when the host starts: +`MapWolverineGrpcServices()` does four passes when the host starts: -1. **Code-first**: any class whose name ends in `GrpcService` (or that carries - `[WolverineGrpcService]`) is picked up and mapped via protobuf-net.Grpc's routing. -2. **Proto-first**: any abstract class carrying `[WolverineGrpcService]` and subclassing a +1. **Code-first (hand-written with wrapper)**: any concrete class whose name ends in `GrpcService` (or + that carries `[WolverineGrpcService]`) and implements a `[ServiceContract]` interface gets a + generated **delegation wrapper**. Wolverine emits `{ClassName}GrpcHandler` that implements the same + contract interface, weaves any `Validate` / `[WolverineBefore]` middleware, then delegates each call + to the inner class via `ActivatorUtilities`. The inner class does not need an explicit DI registration. +2. **Code-first (generated implementation)**: any **interface** carrying both `[WolverineGrpcService]` + and `[ServiceContract]` triggers code generation. Wolverine emits a concrete + `{InterfaceNameWithoutLeadingI}GrpcHandler` that implements the interface, injects `IMessageBus`, + and forwards each RPC to `InvokeAsync` or `StreamAsync`. No service class is written by hand. +3. **Proto-first**: any abstract class carrying `[WolverineGrpcService]` and subclassing a generated `{Service}Base` triggers codegen. Wolverine emits a concrete `{ProtoServiceName}GrpcHandler` that overrides each RPC and forwards to `IMessageBus`. +4. **Direct mapping**: any remaining concrete `GrpcService`-named class that wasn't claimed by a + delegation wrapper in pass 1 is mapped directly via protobuf-net.Grpc's routing. Both paths feed into the same generated-code pipeline used by Wolverine's messaging and HTTP adapters, so your gRPC services show up in the standard diagnostics: @@ -92,22 +106,145 @@ shows the exact generated override and the handler method each RPC forwards to. [`codegen-preview`](/guide/command-line#codegen-preview) for the full set of accepted identifiers (bare proto service name, stub class name, or short `-g` alias). -## Observability +## Validate convention + +Proto-first stubs and hand-written code-first service classes support a lightweight +**pre-handler validation** hook. Add a static `Validate` (or `ValidateAsync`) method to your +class that accepts the request message and returns `Status?`. Wolverine weaves this into the +generated wrapper: if `Validate` returns a non-null `Status`, the call is rejected with an +`RpcException` **before** the handler (or inner service) runs. + +```csharp +// Works on both proto-first stubs and hand-written code-first classes. +public class OrderGrpcService : IOrderService +{ + // Return null → delegation continues normally. + // Return a Status → RpcException is thrown; inner service is never invoked. + public static Status? Validate(PlaceOrderRequest request) + { + if (string.IsNullOrWhiteSpace(request.CustomerId)) + return new Status(StatusCode.InvalidArgument, "CustomerId is required"); + + return null; + } + + public Task PlaceOrder(PlaceOrderRequest request, CallContext context = default) + => Bus.InvokeAsync(request, context.CancellationToken); +} +``` + +The generated wrapper looks roughly like this: + +```csharp +// Generated by Wolverine at startup +public Task PlaceOrder(PlaceOrderRequest request, CallContext context = default) +{ + var status = OrderGrpcService.Validate(request); + if (status.HasValue) + throw new RpcException(status.Value); + + var inner = ActivatorUtilities.GetServiceOrCreateInstance(_serviceProvider); + return inner.PlaceOrder(request, context); +} +``` + +### Rules + +- The method must be **static** and live directly on the service class (or proto-first stub). +- The return type must be `Grpc.Core.Status?` (nullable). A non-nullable `Status` return type is not + recognised as the validate hook. +- `ValidateAsync` returning `Task` is also supported when the check is asynchronous. +- Validate is matched **per request type**: a `Validate(PlaceOrderRequest)` does not fire for + RPC methods whose first parameter is a different request type on the same service class. +- Validate is not woven for **bidirectional streaming** methods — there is no single request + instance in scope before the streaming loop begins. +- Validation runs **before** any `[WolverineBefore]` middleware that is not itself a validate hook. + +::: tip +For `InvalidArgument` rejections that carry field-level detail, consider returning a +`google.rpc.BadRequest` via a rich-status provider instead of (or in addition to) the `Validate` +hook — that way the client can surface per-field errors. See [Error Handling](./errors). +::: + +## Middleware and Policies -Wolverine's gRPC adapter preserves `Activity.Current` across the boundary between the ASP.NET Core -gRPC pipeline and the Wolverine handler pipeline. Concretely: +There are three ways to attach cross-cutting behaviour to Wolverine gRPC chains — choose based on +how targeted or structural the concern is. -- ASP.NET Core's hosting diagnostics starts an inbound activity (`Microsoft.AspNetCore.Hosting.HttpRequestIn`) - and extracts any W3C `traceparent` header the client sent. -- The gRPC service method (code-first or proto-first generated wrapper) invokes - `IMessageBus.InvokeAsync` / `IMessageBus.StreamAsync` on the same ExecutionContext, so - `Activity.Current` is still the hosting activity when Wolverine starts its own handler span. -- Wolverine's `WolverineTracing.StartExecuting` inherits `Activity.Current` as parent, which means - every handler activity lives under the same TraceId as the inbound gRPC request. +### Inline methods on the service class (per-service) + +The lightest-weight option. Add a static `Validate`, `Before`, or `After` method directly to your +proto-first stub or hand-written service class. Wolverine weaves it into the generated wrapper for +that one service automatically — no registration required. See [Validate convention](#validate-convention) +for the full rules and an example. + +### `opts.AddMiddleware()` (gRPC-scoped, all chains) + +For middleware that should apply across multiple gRPC services but not leak into Wolverine's +messaging handler pipeline, register it via `AddWolverineGrpc`: + +```csharp +builder.Services.AddWolverineGrpc(grpc => +{ + // Applied to every gRPC chain (proto-first, code-first generated, hand-written) at codegen time. + grpc.AddMiddleware(); + + // Narrow to a single chain kind with the optional filter: + grpc.AddMiddleware( + c => c is GrpcServiceChain g && g.ProtoServiceName == "Orders"); +}); +``` + +The middleware class follows the same `Before` / `After` / `Finally` method-name conventions +as Wolverine's HTTP and messaging middleware. Wolverine weaves the calls into the generated service +wrapper at startup — no runtime overhead after boot. + +::: tip +`opts.Policies.AddMiddleware()` (the global Wolverine path) intentionally does **not** reach +gRPC chains — its filter is `HandlerChain`-only. Use `grpc.AddMiddleware()` inside +`AddWolverineGrpc(...)` for gRPC-targeted middleware. +::: + +### `opts.AddPolicy()` / `IGrpcChainPolicy` (structural customization) + +For changes that go beyond middleware weaving — inspecting service names, overriding idempotency +styles, or conditionally modifying chain configuration — implement `IGrpcChainPolicy` and register +it via `AddPolicy`: + +```csharp +public class IdempotentOrdersPolicy : IGrpcChainPolicy +{ + public void Apply( + IReadOnlyList protoFirstChains, + IReadOnlyList codeFirstChains, + IReadOnlyList handWrittenChains, + GenerationRules rules, + IServiceContainer container) + { + foreach (var chain in protoFirstChains.Where(c => c.ProtoServiceName == "Orders")) + chain.Idempotency = IdempotencyStyle.GetOrPost; + } +} + +builder.Services.AddWolverineGrpc(grpc => +{ + grpc.AddPolicy(); + // or directly: grpc.AddPolicy(new IdempotentOrdersPolicy()); +}); +``` + +`IGrpcChainPolicy.Apply` receives all three chain kinds as typed lists, so policy implementations +get full access to gRPC-specific properties (`ProtoServiceName`, `ServiceContractType`, etc.) +without casting. It is called after `AddMiddleware()` weaving, during the same bootstrapping +pass as handler and HTTP chain policies. + +## Observability -The result: a single trace covers the full request, from inbound gRPC to every Wolverine handler it -invokes, with no additional wiring. If you expose an OpenTelemetry pipeline for other Wolverine -transports, it will pick up gRPC traffic for free. +The service shim invokes `IMessageBus` on the same `ExecutionContext` as the inbound gRPC request, +so `Activity.Current` propagates naturally — every Wolverine handler span becomes a child of the +ASP.NET Core hosting span. A single trace covers the full call with no extra wiring. If you already +export an OpenTelemetry pipeline for Wolverine messaging handlers, it picks up gRPC traffic +automatically. ### Registering OpenTelemetry diff --git a/docs/guide/grpc/index.md b/docs/guide/grpc/index.md index 1ea6f0a85..1d6bfd888 100644 --- a/docs/guide/grpc/index.md +++ b/docs/guide/grpc/index.md @@ -26,7 +26,8 @@ Start here to get Wolverine's gRPC adapter running, then drill into the page tha building: - [How gRPC Handlers Work](./handlers) — the service → `IMessageBus` → handler flow, how it differs - from HTTP and messaging handlers, and how OpenTelemetry traces survive the hop. + from HTTP and messaging handlers, gRPC-scoped middleware and structural policies, and how + OpenTelemetry traces survive the hop. - [Code-First and Proto-First Contracts](./contracts) — the two contract styles side by side so you can pick (or mix) them. - [Error Handling](./errors) — the default AIP-193 exception → `StatusCode` table plus the opt-in @@ -73,7 +74,7 @@ From here, [How gRPC Handlers Work](./handlers) walks through what `MapWolverine wires up and why a gRPC handler is just an ordinary Wolverine handler with a thin service shim on top. ::: tip Runnable Samples -Six end-to-end samples live under `src/Samples/`. Five are the classic trio shape (server, client, +Eight end-to-end samples live under `src/Samples/`. Most are the classic trio shape (server, client, shared messages); `OrderChainWithGrpc` is a quartet because its proof-point is a chain between two Wolverine servers. `dotnet run` them side by side. See [Samples](./samples) for full walkthroughs and comparisons to the official `grpc-dotnet` examples. @@ -82,9 +83,11 @@ and comparisons to the official `grpc-dotnet` examples. |--------|-------|--------------| | [PingPongWithGrpc](https://github.com/JasperFx/wolverine/tree/main/src/Samples/PingPongWithGrpc) | Code-first **unary** | `[ServiceContract]` + `WolverineGrpcServiceBase` forwarding to a plain handler | | [PingPongWithGrpcStreaming](https://github.com/JasperFx/wolverine/tree/main/src/Samples/PingPongWithGrpcStreaming) | Code-first **server streaming** | Handler returning `IAsyncEnumerable`, forwarded via `Bus.StreamAsync` | +| [GreeterCodeFirstGrpc](https://github.com/JasperFx/wolverine/tree/main/src/Samples/GreeterCodeFirstGrpc) | Code-first **generated implementation** | `[WolverineGrpcService]` on an interface — Wolverine generates the service class; no concrete class written | | [GreeterProtoFirstGrpc](https://github.com/JasperFx/wolverine/tree/main/src/Samples/GreeterProtoFirstGrpc) | **Proto-first** unary + server streaming + exception mapping | Abstract `[WolverineGrpcService]` stub subclassing a generated `*Base` + handlers | | [RacerWithGrpc](https://github.com/JasperFx/wolverine/tree/main/src/Samples/RacerWithGrpc) | Code-first **bidirectional streaming** | Per-update bridge: client `IAsyncEnumerable` → `Bus.StreamAsync` for each item | | [GreeterWithGrpcErrors](https://github.com/JasperFx/wolverine/tree/main/src/Samples/GreeterWithGrpcErrors) | Code-first **rich error details** | FluentValidation → `BadRequest` plus inline `MapException` → `PreconditionFailure`, with a client that unpacks both | +| [ProgressTrackerWithGrpc](https://github.com/JasperFx/wolverine/tree/main/src/Samples/ProgressTrackerWithGrpc) | Code-first **server streaming + cancellation** | Realistic job-progress stream: handler yields `JobProgress` updates; client cancels mid-stream | | [OrderChainWithGrpc](https://github.com/JasperFx/wolverine/tree/main/src/Samples/OrderChainWithGrpc) | **Wolverine → Wolverine chain** via `AddWolverineGrpcClient()` | Typed client injected into a handler; envelope propagation + typed-exception round-trip with zero user plumbing | ::: @@ -95,9 +98,16 @@ and comparisons to the official `grpc-dotnet` examples. | `AddWolverineGrpc()` | Registers the interceptor, proto-first discovery graph, and codegen pipeline. | | `MapWolverineGrpcServices()` | Discovers and maps all gRPC services (code-first and proto-first). | | `WolverineGrpcServiceBase` | Optional base class exposing an `IMessageBus` property `Bus`. | -| `[WolverineGrpcService]` | Opt-in marker for classes that don't match the `GrpcService` suffix. | +| `[WolverineGrpcService]` | On an **interface**: Wolverine generates the concrete service class, reducing hand-written boilerplate. On a **class**: opt-in marker for concrete code-first services and abstract proto-first stubs that don't match the `GrpcService` suffix. | +| `opts.AddMiddleware(filter?)` | Register a middleware type applied to all Wolverine-managed gRPC chains (proto-first, code-first generated, and hand-written) at codegen time. Optional `Func` narrows which chains receive it — pattern-match on `GrpcServiceChain`, `CodeFirstGrpcServiceChain`, or `HandWrittenGrpcServiceChain` for kind-specific targeting. See [Middleware and Policies](./handlers#middleware-and-policies). | +| `opts.AddPolicy()` / `AddPolicy(policy)` | Register an `IGrpcChainPolicy` for structural chain customization. Receives all three chain kinds as typed lists. See [Middleware and Policies](./handlers#middleware-and-policies). | +| `IGrpcChainPolicy` | Interface for structural gRPC chain policies. `Apply(protoFirst, codeFirst, handWritten, rules, container)` — typed access to all chain kinds, no casting required. | +| `ModifyGrpcServiceChainAttribute` | Abstract attribute for per-chain customization of proto-first chains. Apply to the stub class. | +| `ModifyHandWrittenGrpcServiceChainAttribute` | Abstract attribute for per-chain customization of hand-written code-first service chains. Apply to the service class. | +| `ModifyCodeFirstGrpcServiceChainAttribute` | Abstract attribute for per-chain customization of generated code-first service chains. Apply to the `[ServiceContract]` interface. | | `WolverineGrpcExceptionMapper.Map(ex)` | The public mapping table — use directly in custom interceptors. | | `WolverineGrpcExceptionInterceptor` | The registered gRPC interceptor; exposed for diagnostics. | +| `opts.MapException(StatusCode)` | Override the server-side `Exception → StatusCode` mapping for a specific type — see [Error Handling](./errors#overriding-the-default-table). | | `opts.UseGrpcRichErrorDetails(...)` | Opt-in `google.rpc.Status` pipeline — see [Error Handling](./errors). | | `opts.UseFluentValidationGrpcErrorDetails()` | Bridge: `ValidationException` → `BadRequest` (from `WolverineFx.FluentValidation.Grpc`). | | `IGrpcStatusDetailsProvider` | Custom provider seam for building `google.rpc.Status` from an exception. | @@ -108,43 +118,15 @@ and comparisons to the official `grpc-dotnet` examples. ## Current Limitations -- **Client streaming** and **bidirectional streaming** have no out-of-the-box adapter path yet — - there is no `IMessageBus.StreamAsync` overload, and proto-first stubs with - these method shapes fail fast at startup with a clear error rather than silently skipping. In - code-first you can still implement bidi manually in the service by bridging each incoming item - through `Bus.StreamAsync(item, ct)` — see [Streaming](./streaming) for the pattern and the - [RacerWithGrpc](https://github.com/JasperFx/wolverine/tree/main/src/Samples/RacerWithGrpc) sample. -- **Exception mapping** of the canonical `Exception → StatusCode` table is not yet user-configurable - on the server side (follow-up item). Rich, structured responses are already available — see - [Error Handling](./errors). On the client side, `WolverineGrpcClientOptions.MapRpcException` - already allows per-client overrides — see [Typed gRPC Clients](./client#per-client-override). -- **`MiddlewareScoping.Grpc` middleware** — the enum value ships and is honored by Wolverine's - discovery primitives, but no code path yet *weaves* `[WolverineBefore(MiddlewareScoping.Grpc)]` - / `[WolverineAfter(MiddlewareScoping.Grpc)]` methods into the generated gRPC service wrappers. - The attribute is safe to apply — it compiles, it is correctly filtered away from message-handler - and HTTP chains, and it will start firing once the codegen path (tracked as M15) lands — but - today nothing runs at RPC time. Until then, middleware that needs to execute on gRPC calls - should live in a custom gRPC interceptor rather than rely on the attribute or on - `services.AddWolverineGrpc(g => g.AddMiddleware())` (both take effect together in M15). +- **Pure client streaming** (`stream TRequest → TResponse`) has no out-of-the-box adapter path yet. + Proto-first stubs that declare this shape fail fast at startup with a clear error rather than + silently skipping. Bidirectional streaming is fully supported — see [Streaming](./streaming). ## Roadmap The gRPC integration has a handful of deferred items that are known-good fits but haven't shipped yet. They're listed here so contributors can plan around them and consumers know what's coming. -- **`MiddlewareScoping.Grpc` codegen weaving (M15)** — attribute-based middleware on gRPC stubs - (see Current Limitations above). Phase 0 landed the discovery + options surface; Phase 1 will - wire execution into the generated `GrpcServiceChain` wrappers. -- **`Validate` convention → `Status?`** — HTTP handlers already support an opt-in `Validate` method - whose non-null return short-circuits the call. The gRPC equivalent would return - `Grpc.Core.Status?` (or a richer `google.rpc.Status`) so a handler could express "this call is - invalid, return `InvalidArgument` with these field violations" without throwing. Deferred because - it lands cleanest on top of the code-first codegen work below. -- **Code-first codegen parity** — proto-first services flow through a generated `GrpcServiceChain` - with the usual JasperFx codegen pipeline; code-first services (the `WolverineGrpcServiceBase` path) - currently resolve dependencies via service location inside each method. Generating per-method code - files for code-first services — matching the HTTP and message handler story — is the prerequisite - for the `Validate` convention above and for tighter Lamar/MSDI optimization. - **Hybrid handler shape (HTTP + gRPC + messaging on one type)** — open design question. The [hybrid HTTP/message handler](/guide/http/endpoints#http-endpoint-message-handler-combo) pattern works today for two protocols; extending it to three raises naming and scoping questions diff --git a/docs/guide/grpc/samples.md b/docs/guide/grpc/samples.md index f9597cfde..8b969c26d 100644 --- a/docs/guide/grpc/samples.md +++ b/docs/guide/grpc/samples.md @@ -1,9 +1,9 @@ # Samples -Six end-to-end samples live under -[`src/Samples/`](https://github.com/JasperFx/wolverine/tree/main/src/Samples). Five are the classic -trio shape (server, client, shared messages); the sixth, `OrderChainWithGrpc`, is a quartet because -its proof-point is a **chain** between two Wolverine servers. Each sample is a real Kestrel HTTP/2 +Eight end-to-end samples live under +[`src/Samples/`](https://github.com/JasperFx/wolverine/tree/main/src/Samples). Most follow the +classic trio shape (server, client, shared messages); `OrderChainWithGrpc` is a quartet because its +proof-point is a **chain** between two Wolverine servers. Each sample is a real Kestrel HTTP/2 host plus separate client / service projects — `dotnet run` them side by side. For each Wolverine sample below you'll find a pointer to the **closest equivalent** in the official @@ -21,12 +21,14 @@ changes is whether your business code knows about gRPC. | Wolverine sample | Shape | Style | Closest grpc-dotnet example | |---|---|---|---| -| [PingPongWithGrpc](#pingpongwithgrpc) | Unary | Code-first | [Greeter](https://github.com/grpc/grpc-dotnet/tree/master/examples#greeter) | -| [PingPongWithGrpcStreaming](#pingpongwithgrpcstreaming) | Server streaming | Code-first | [Counter](https://github.com/grpc/grpc-dotnet/tree/master/examples#counter) | +| [PingPongWithGrpc](#pingpongwithgrpc) | Unary | Code-first (hand-written) | [Greeter](https://github.com/grpc/grpc-dotnet/tree/master/examples#greeter) | +| [PingPongWithGrpcStreaming](#pingpongwithgrpcstreaming) | Server streaming | Code-first (hand-written) | [Counter](https://github.com/grpc/grpc-dotnet/tree/master/examples#counter) | +| [GreeterCodeFirstGrpc](#greetercodefirstgrpc) | Unary + server streaming | Code-first (generated) | [Coder](https://github.com/grpc/grpc-dotnet/tree/master/examples#coder) | | [GreeterProtoFirstGrpc](#greeterprotofirstgrpc) | Unary + server streaming + exception mapping | Proto-first | [Greeter](https://github.com/grpc/grpc-dotnet/tree/master/examples#greeter) | -| [RacerWithGrpc](#racerwithgrpc) | Bidirectional streaming | Code-first | [Racer](https://github.com/grpc/grpc-dotnet/tree/master/examples#racer) | -| [GreeterWithGrpcErrors](#greeterwithgrpcerrors) | Unary + rich error details | Code-first | (no direct equivalent — closest is Greeter + a custom interceptor) | -| [OrderChainWithGrpc](#orderchainwithgrpc) | Wolverine → Wolverine chain via typed client | Code-first | (no direct equivalent — grpc-dotnet assumes users hand-write propagation) | +| [RacerWithGrpc](#racerwithgrpc) | Bidirectional streaming | Code-first (hand-written) | [Racer](https://github.com/grpc/grpc-dotnet/tree/master/examples#racer) | +| [GreeterWithGrpcErrors](#greeterwithgrpcerrors) | Unary + rich error details | Code-first (hand-written) | (no direct equivalent — closest is Greeter + a custom interceptor) | +| [ProgressTrackerWithGrpc](#progresstrackerwithgrpc) | Server streaming + cancellation | Code-first (generated) | [Progressor](https://github.com/grpc/grpc-dotnet/tree/master/examples#progressor) | +| [OrderChainWithGrpc](#orderchainwithgrpc) | Wolverine → Wolverine chain via typed client | Code-first (hand-written) | (no direct equivalent — grpc-dotnet assumes users hand-write propagation) | ## PingPongWithGrpc @@ -85,6 +87,109 @@ no `WriteAsync` call. Wolverine's adapter does the `WriteAsync` for you under th The upshot: the exact same handler can also be invoked in-process via `IMessageBus.StreamAsync` for testing or for non-gRPC consumers. +## GreeterCodeFirstGrpc + +Layout: [`src/Samples/GreeterCodeFirstGrpc/`](https://github.com/JasperFx/wolverine/tree/main/src/Samples/GreeterCodeFirstGrpc) +with three projects — `Messages`, `Server`, `Client`. + +The generated-implementation showcase. The only artifacts in the server project are handlers. +No concrete service class is written — `[WolverineGrpcService]` on the interface in `Messages` +is the only instruction Wolverine needs: + +```csharp +[ServiceContract] +[WolverineGrpcService] +public interface IGreeterCodeFirstService +{ + Task Greet(GreetRequest request, CallContext context = default); + IAsyncEnumerable StreamGreetings(StreamGreetingsRequest request, CallContext context = default); +} +``` + +At startup, `MapWolverineGrpcServices()` discovers the interface, generates +`GreeterCodeFirstServiceGrpcHandler`, and maps it. The server project's `Program.cs` is three +lines beyond a standard Wolverine host — `AddCodeFirstGrpc()`, `AddWolverineGrpc()`, and an +`IncludeAssembly` call so the scan reaches the `Messages` project: + +```csharp +builder.Host.UseWolverine(opts => +{ + opts.ApplicationAssembly = typeof(Program).Assembly; + opts.Discovery.IncludeAssembly(typeof(IGreeterCodeFirstService).Assembly); +}); +builder.Services.AddCodeFirstGrpc(); +builder.Services.AddWolverineGrpc(); +``` + +### Compared to grpc-dotnet's Coder + +grpc-dotnet's **Coder** example is also code-first (protobuf-net.Grpc), but the service author +writes a concrete class that inherits from a base class and implements each method by hand. The +Wolverine sample produces the identical wire protocol with zero service class code — the generated +`GreeterCodeFirstServiceGrpcHandler` plays the role that the hand-written class plays in the +official example. + +## ProgressTrackerWithGrpc + +Layout: [`src/Samples/ProgressTrackerWithGrpc/`](https://github.com/JasperFx/wolverine/tree/main/src/Samples/ProgressTrackerWithGrpc) +with three projects — `Messages`, `Server`, `Client`. + +A realistic server-streaming sample built on the generated-implementation path. The client submits +a job description; the server streams back one `JobProgress` update per completed step. The handler +simulates work with a configurable per-step delay and yields progress as it goes: + +```csharp +public static async IAsyncEnumerable Handle( + RunJobRequest request, + [EnumeratorCancellation] CancellationToken cancellationToken) +{ + for (var step = 1; step <= request.Steps; step++) + { + cancellationToken.ThrowIfCancellationRequested(); + await Task.Delay(request.StepDelayMs, cancellationToken); + + var pct = (int)(step * 100.0 / request.Steps); + yield return new JobProgress + { + Step = step, + TotalSteps = request.Steps, + PercentComplete = pct, + Message = $"[{request.JobName}] Completed step {step}/{request.Steps}" + }; + } +} +``` + +The client demonstrates both the happy path (all steps complete) and mid-stream cancellation: + +```csharp +// Full job +await foreach (var p in tracker.RunJob(new RunJobRequest { JobName = "build", Steps = 10, StepDelayMs = 100 })) + Console.WriteLine($" [{p.PercentComplete,3}%] {p.Message}"); + +// Cancel mid-stream at step 3 +using var cts = new CancellationTokenSource(); +try +{ + await foreach (var p in tracker.RunJob(request, cts.Token)) + { + Console.WriteLine($" [{p.PercentComplete,3}%] {p.Message}"); + if (p.Step == 3) cts.Cancel(); + } +} +catch (RpcException ex) when (ex.StatusCode == StatusCode.Cancelled) +{ + Console.WriteLine(" Job cancelled by client."); +} +``` + +### Compared to grpc-dotnet's Progressor + +grpc-dotnet's **Progressor** example writes to `IServerStreamWriter` directly inside the service +override to report a countdown. The Wolverine sample keeps the service layer invisible — the handler +is a plain `IAsyncEnumerable` method that knows nothing about gRPC. The progress stream and the +cancellation story are identical on the wire; only the authoring model differs. + ## GreeterProtoFirstGrpc Layout: [`src/Samples/GreeterProtoFirstGrpc/`](https://github.com/JasperFx/wolverine/tree/main/src/Samples/GreeterProtoFirstGrpc) diff --git a/docs/guide/grpc/streaming.md b/docs/guide/grpc/streaming.md index d5b5f4e30..7166771e3 100644 --- a/docs/guide/grpc/streaming.md +++ b/docs/guide/grpc/streaming.md @@ -1,11 +1,10 @@ # Streaming -gRPC has four call shapes: unary, server streaming, client streaming, and bidirectional streaming. -Wolverine currently covers **unary** and **server streaming** out of the box, with a working pattern -for **bidirectional** via a per-item bridge. **Pure client streaming** doesn't have a clean adapter -yet and will fail fast at startup in proto-first mode. +Wolverine covers **unary**, **server streaming**, and **bidirectional streaming** natively. +**Pure client streaming** (`stream TRequest → TResponse`) has no adapter yet and fails fast at +startup in proto-first mode with a clear diagnostic error. -This page covers the shapes that work today, the one that doesn't, and how to cancel cleanly. +This page covers server and bidirectional streaming in depth, plus cancellation and current gaps. ## Server streaming (first-class) @@ -52,53 +51,124 @@ public static async IAsyncEnumerable Handle( } ``` -Each `yield return` corresponds to a gRPC message frame on the wire. Back-pressure happens at the -`WriteAsync` layer — if the client stops reading, `WriteAsync` will suspend, which in turn blocks -the handler's `MoveNextAsync`, which back-propagates through your `yield return`. +Each `yield return` is one gRPC message frame. Back-pressure from a slow client propagates +naturally through the `IAsyncEnumerable` chain — no extra plumbing needed. -## Bidirectional streaming (manual bridge) +::: tip Why `await Task.Yield()` appears in the examples +A streaming handler that only `yield return`s synchronous values can be compiled by the C# compiler +into a synchronous state machine, which blocks the calling thread on each item. `await Task.Yield()` +forces a genuine async yield point and prevents that. You can drop it when your handler already +awaits real I/O (a database call, an HTTP request, etc.). +::: + +## Bidirectional streaming + +Wolverine supports bidirectional streaming for both **proto-first** (generated wrapper) and +**code-first** (manual bridge) contracts. In both cases the handler shape is the same as server +streaming: `Handle(TRequest) → IAsyncEnumerable`. The bidi loop — read one request, +stream its responses, repeat — is handled for you. + +### Proto-first (generated wrapper) + +Declare a bidi RPC in your `.proto` file and mark your stub with `[WolverineGrpcService]`: + +```proto +service RacingService { + rpc Race (stream RacerUpdate) returns (stream RacePosition); +} +``` + +```csharp +[WolverineGrpcService] +public abstract class RacingServiceStub : RacingService.RacingServiceBase; +``` + +Wolverine generates the bridge at startup: + +```csharp +// Generated by Wolverine +public override async Task Race( + IAsyncStreamReader requestStream, + IServerStreamWriter responseStream, + ServerCallContext context) +{ + while (await requestStream.MoveNext(context.CancellationToken)) + { + var request = requestStream.Current; + await foreach (var item in _bus.StreamAsync(request, context.CancellationToken)) + { + await responseStream.WriteAsync(item, context.CancellationToken); + } + } +} +``` + +The handler shape is identical to server streaming — one request message in, a stream of response +messages out: -There is no `IMessageBus.StreamAsync` overload today, but you can compose -bidi on top of server streaming by reading one request item at a time and calling -`Bus.StreamAsync` per item. The +```csharp +public async IAsyncEnumerable Handle( + RacerUpdate update, + [EnumeratorCancellation] CancellationToken cancellationToken) +{ + foreach (var position in ComputeCurrentStandings(update)) + { + cancellationToken.ThrowIfCancellationRequested(); + yield return position; + } + await Task.Yield(); +} +``` + +::: info Before-middleware and Validate hooks +Before-frames (including the `Validate → Status?` short-circuit) are **not** woven into bidi +methods in the generated wrapper. They require a single `TRequest` instance to be in scope when +the method begins, which doesn't exist for a bidi RPC before the first item arrives. Use +code-first with a manual shim if you need per-stream authentication or validation before the loop. +::: + +### Code-first (manual bridge) + +Code-first services receive the `IAsyncEnumerable` directly from protobuf-net.Grpc. +The `WolverineGrpcServiceBase` path lets you write the outer loop by hand — useful when you need +control over per-request error handling or logging. The [RacerWithGrpc](https://github.com/JasperFx/wolverine/tree/main/src/Samples/RacerWithGrpc) sample uses this pattern: ```csharp -public async IAsyncEnumerable Race( - IAsyncEnumerable incoming, - [EnumeratorCancellation] CancellationToken cancellationToken) +[WolverineGrpcService] +public class RacingGrpcService(IMessageBus bus) : IRacingService { - await foreach (var command in incoming.WithCancellation(cancellationToken)) + public async IAsyncEnumerable RaceAsync( + IAsyncEnumerable updates, + CallContext context = default) { - // One Wolverine stream per incoming command. - await foreach (var update in Bus.StreamAsync(command, cancellationToken)) + await foreach (var update in updates.WithCancellation(context.CancellationToken)) { - yield return update; + await foreach (var position in bus.StreamAsync(update, context.CancellationToken)) + { + yield return position; + } } } } ``` -Conceptually: each inbound command opens a sub-stream that contributes updates to the outer bidi -stream. This works well when requests and responses have a **per-item correlation** (command × -updates). It's a poor fit when you need a long-lived session where any incoming message can affect -the response ordering globally — for that, a saga + outbound messaging stays the better model. +::: tip Per-item correlation +Both the generated and manual patterns work well when requests and responses have a **per-item +correlation** (one command → N response items). For long-lived sessions where any incoming message +can affect global response ordering, a saga + outbound messaging stays the better model. +::: ## Cancellation -Cancellation flows top-down from the client to the handler: - -1. Client disposes its `AsyncServerStreamingCall` (or cancels the call). -2. gRPC propagates cancellation via `ServerCallContext.CancellationToken` / `CallContext.CancellationToken`. -3. The service shim passes that token into `Bus.InvokeAsync` / `Bus.StreamAsync`. -4. Wolverine threads it through to the handler's `CancellationToken` parameter - (`[EnumeratorCancellation]` for streaming). - -In practice this means the handler's `cancellationToken.ThrowIfCancellationRequested()` or -`await someOp(ct)` will trip as soon as the client bails, and -`OperationCanceledException` is in turn mapped to `StatusCode.Cancelled` by the exception -interceptor (see [Error Handling](./errors)). +When a client cancels or disconnects, gRPC sets `ServerCallContext.CancellationToken` / +`CallContext.CancellationToken`. Wolverine's service shims pass that token into +`Bus.InvokeAsync` / `Bus.StreamAsync` and thread it through to the handler's `CancellationToken` +parameter (`[EnumeratorCancellation]` on streaming handlers). Any +`cancellationToken.ThrowIfCancellationRequested()` or awaited operation in your handler trips +promptly, and the resulting `OperationCanceledException` maps to `StatusCode.Cancelled` +automatically (see [Error Handling](./errors)). ::: warning If your handler spawns background work via `Task.Run(...)` without passing the `CancellationToken`, @@ -113,8 +183,9 @@ but your detached tasks keep running. Always thread the token through. error — it's not silently skipped. If you need this today, implement the service method by hand without the Wolverine shim, or reshape the contract to server streaming + a final summary response. -- **No `IMessageBus.StreamAsync` overload.** Until that exists, bidi goes - through the manual bridge above. Tracked as a follow-up. +- **Before-middleware and Validate hooks are not woven into bidi methods** in the proto-first + generated wrapper. Use code-first with a manual shim for per-stream authentication or + request-level validation before the loop begins. - **Back-pressure is cooperative, not flow-controlled by default.** HTTP/2 provides windowing, but if your handler produces faster than your client consumes and your DTOs are large, memory usage can spike before backpressure propagates. For large payloads, consider chunking at the contract @@ -132,5 +203,6 @@ but your detached tasks keep running. Always thread the token through. - [Handlers](./handlers) — where the `CancellationToken` comes from and how the service shim forwards. - [Error Handling](./errors) — how `OperationCanceledException` becomes `StatusCode.Cancelled` and how to attach rich details to errors that terminate a stream. -- [Samples](./samples) — `PingPongWithGrpcStreaming` and `RacerWithGrpc` are the canonical - streaming walkthroughs. +- [Samples](./samples) — `PingPongWithGrpcStreaming`, `ProgressTrackerWithGrpc`, and `RacerWithGrpc` + are the canonical streaming walkthroughs, covering server streaming (hand-written), server + streaming (generated + cancellation), and bidirectional streaming respectively. diff --git a/src/Samples/GreeterCodeFirstGrpc/Client/Client.csproj b/src/Samples/GreeterCodeFirstGrpc/Client/Client.csproj new file mode 100644 index 000000000..b7529bf2a --- /dev/null +++ b/src/Samples/GreeterCodeFirstGrpc/Client/Client.csproj @@ -0,0 +1,19 @@ + + + + Exe + net8.0;net9.0 + GreeterCodeFirstGrpc.Client + GreeterCodeFirstGrpc.Client + + + + + + + + + + + + diff --git a/src/Samples/GreeterCodeFirstGrpc/Client/Program.cs b/src/Samples/GreeterCodeFirstGrpc/Client/Program.cs new file mode 100644 index 000000000..582351794 --- /dev/null +++ b/src/Samples/GreeterCodeFirstGrpc/Client/Program.cs @@ -0,0 +1,24 @@ +using GreeterCodeFirstGrpc.Messages; +using Grpc.Net.Client; +using ProtoBuf.Grpc.Client; + +// Allow plain HTTP/2 for the local sample (no TLS cert required). +AppContext.SetSwitch("System.Net.Http.SocketsHttpHandler.Http2UnencryptedSupport", true); + +using var channel = GrpcChannel.ForAddress("http://localhost:5008"); + +// protobuf-net.Grpc synthesises a strongly-typed client directly from the +// [ServiceContract] interface — the same interface the server uses. +// No generated stub classes, no .proto file, no protoc invocation. +var greeter = channel.CreateGrpcService(); + +// Unary +var reply = await greeter.Greet(new GreetRequest { Name = "Erik" }); +Console.WriteLine($"Greet -> {reply.Message}"); + +// Server streaming +Console.WriteLine("StreamGreetings ->"); +await foreach (var item in greeter.StreamGreetings(new StreamGreetingsRequest { Name = "Erik", Count = 5 })) +{ + Console.WriteLine($" {item.Message}"); +} diff --git a/src/Samples/GreeterCodeFirstGrpc/Messages/IGreeterCodeFirstService.cs b/src/Samples/GreeterCodeFirstGrpc/Messages/IGreeterCodeFirstService.cs new file mode 100644 index 000000000..4a54fab66 --- /dev/null +++ b/src/Samples/GreeterCodeFirstGrpc/Messages/IGreeterCodeFirstService.cs @@ -0,0 +1,39 @@ +using System.ServiceModel; +using ProtoBuf; +using ProtoBuf.Grpc; +using Wolverine.Grpc; + +namespace GreeterCodeFirstGrpc.Messages; + +/// +/// Code-first gRPC contract. Annotating the interface with +/// [WolverineGrpcService] tells Wolverine to generate the concrete +/// implementation at startup — no service class is written by hand. +/// [ServiceContract] is required by protobuf-net.Grpc for routing. +/// +[ServiceContract] +[WolverineGrpcService] +public interface IGreeterCodeFirstService +{ + Task Greet(GreetRequest request, CallContext context = default); + IAsyncEnumerable StreamGreetings(StreamGreetingsRequest request, CallContext context = default); +} + +[ProtoContract] +public class GreetRequest +{ + [ProtoMember(1)] public string Name { get; set; } = string.Empty; +} + +[ProtoContract] +public class StreamGreetingsRequest +{ + [ProtoMember(1)] public string Name { get; set; } = string.Empty; + [ProtoMember(2)] public int Count { get; set; } +} + +[ProtoContract] +public class GreetReply +{ + [ProtoMember(1)] public string Message { get; set; } = string.Empty; +} diff --git a/src/Samples/GreeterCodeFirstGrpc/Messages/Messages.csproj b/src/Samples/GreeterCodeFirstGrpc/Messages/Messages.csproj new file mode 100644 index 000000000..ea6cb14ec --- /dev/null +++ b/src/Samples/GreeterCodeFirstGrpc/Messages/Messages.csproj @@ -0,0 +1,17 @@ + + + + net8.0;net9.0 + GreeterCodeFirstGrpc.Messages + GreeterCodeFirstGrpc.Messages + + + + + + + + + + + diff --git a/src/Samples/GreeterCodeFirstGrpc/Server/GreeterHandler.cs b/src/Samples/GreeterCodeFirstGrpc/Server/GreeterHandler.cs new file mode 100644 index 000000000..cf919c0e2 --- /dev/null +++ b/src/Samples/GreeterCodeFirstGrpc/Server/GreeterHandler.cs @@ -0,0 +1,29 @@ +using System.Runtime.CompilerServices; +using GreeterCodeFirstGrpc.Messages; + +namespace GreeterCodeFirstGrpc.Server; + +/// +/// Wolverine handlers for the code-first Greeter service. These are plain static +/// methods — no base class, no interface implementation, no service registration. +/// Wolverine discovers them by convention and the generated +/// GreeterCodeFirstServiceGrpcHandler forwards each RPC here via +/// IMessageBus.InvokeAsync / IMessageBus.StreamAsync. +/// +public static class GreeterHandler +{ + public static GreetReply Handle(GreetRequest request) + => new() { Message = $"Hello, {request.Name}!" }; + + public static async IAsyncEnumerable Handle( + StreamGreetingsRequest request, + [EnumeratorCancellation] CancellationToken cancellationToken) + { + for (var i = 1; i <= request.Count; i++) + { + cancellationToken.ThrowIfCancellationRequested(); + yield return new GreetReply { Message = $"Hello, {request.Name} [{i} of {request.Count}]" }; + await Task.Yield(); + } + } +} diff --git a/src/Samples/GreeterCodeFirstGrpc/Server/Program.cs b/src/Samples/GreeterCodeFirstGrpc/Server/Program.cs new file mode 100644 index 000000000..6c6117e6b --- /dev/null +++ b/src/Samples/GreeterCodeFirstGrpc/Server/Program.cs @@ -0,0 +1,37 @@ +using GreeterCodeFirstGrpc.Messages; +using JasperFx; +using Microsoft.AspNetCore.Server.Kestrel.Core; +using ProtoBuf.Grpc.Server; +using Wolverine; +using Wolverine.Grpc; + +var builder = WebApplication.CreateBuilder(args); + +// gRPC runs over HTTP/2. Listen on an unencrypted HTTP/2 endpoint so the +// sample runs without a trusted dev cert. +builder.WebHost.ConfigureKestrel(options => +{ + options.ListenLocalhost(5008, listen => listen.Protocols = HttpProtocols.Http2); +}); + +builder.Host.UseWolverine(opts => +{ + opts.ApplicationAssembly = typeof(Program).Assembly; + // The [WolverineGrpcService] interface lives in the shared Messages assembly; + // include it so GrpcGraph can discover IGreeterCodeFirstService at startup. + opts.Discovery.IncludeAssembly(typeof(IGreeterCodeFirstService).Assembly); +}); + +// Code-first gRPC requires AddCodeFirstGrpc() (protobuf-net.Grpc) rather than AddGrpc(). +// No concrete service class is registered — Wolverine discovers IGreeterCodeFirstService +// (annotated with [WolverineGrpcService]) and generates the implementation at startup. +builder.Services.AddCodeFirstGrpc(); +builder.Services.AddWolverineGrpc(); + +var app = builder.Build(); +app.UseRouting(); +app.MapWolverineGrpcServices(); + +return await app.RunJasperFxCommands(args); + +public partial class Program; diff --git a/src/Samples/GreeterCodeFirstGrpc/Server/Server.csproj b/src/Samples/GreeterCodeFirstGrpc/Server/Server.csproj new file mode 100644 index 000000000..690c05920 --- /dev/null +++ b/src/Samples/GreeterCodeFirstGrpc/Server/Server.csproj @@ -0,0 +1,18 @@ + + + + net8.0;net9.0 + GreeterCodeFirstGrpc.Server + GreeterCodeFirstGrpc.Server + + + + + + + + + + + + diff --git a/src/Samples/ProgressTrackerWithGrpc/Client/Client.csproj b/src/Samples/ProgressTrackerWithGrpc/Client/Client.csproj new file mode 100644 index 000000000..908dab15d --- /dev/null +++ b/src/Samples/ProgressTrackerWithGrpc/Client/Client.csproj @@ -0,0 +1,19 @@ + + + + Exe + net8.0;net9.0 + ProgressTrackerWithGrpc.Client + ProgressTrackerWithGrpc.Client + + + + + + + + + + + + diff --git a/src/Samples/ProgressTrackerWithGrpc/Client/Program.cs b/src/Samples/ProgressTrackerWithGrpc/Client/Program.cs new file mode 100644 index 000000000..524c9ce53 --- /dev/null +++ b/src/Samples/ProgressTrackerWithGrpc/Client/Program.cs @@ -0,0 +1,35 @@ +using Grpc.Core; +using Grpc.Net.Client; +using ProgressTrackerWithGrpc.Messages; +using ProtoBuf.Grpc.Client; + +// Allow plain HTTP/2 for the local sample (no TLS cert required). +AppContext.SetSwitch("System.Net.Http.SocketsHttpHandler.Http2UnencryptedSupport", true); + +using var channel = GrpcChannel.ForAddress("http://localhost:5009"); +var tracker = channel.CreateGrpcService(); + +// --- Full job: all steps complete --- +Console.WriteLine("=== Full job (10 steps) ==="); +await foreach (var p in tracker.RunJob(new RunJobRequest { JobName = "build", Steps = 10, StepDelayMs = 100 })) +{ + Console.WriteLine($" [{p.PercentComplete,3}%] {p.Message}"); +} +Console.WriteLine("Job complete.\n"); + +// --- Cancelled job: client cancels after step 3 --- +Console.WriteLine("=== Cancelled job (client cancels at step 3) ==="); +using var cts = new CancellationTokenSource(); +try +{ + await foreach (var p in tracker.RunJob( + new RunJobRequest { JobName = "deploy", Steps = 10, StepDelayMs = 100 }, cts.Token)) + { + Console.WriteLine($" [{p.PercentComplete,3}%] {p.Message}"); + if (p.Step == 3) cts.Cancel(); + } +} +catch (RpcException ex) when (ex.StatusCode == StatusCode.Cancelled) +{ + Console.WriteLine(" Job cancelled by client."); +} diff --git a/src/Samples/ProgressTrackerWithGrpc/Messages/IProgressTrackerService.cs b/src/Samples/ProgressTrackerWithGrpc/Messages/IProgressTrackerService.cs new file mode 100644 index 000000000..4be2e7dc7 --- /dev/null +++ b/src/Samples/ProgressTrackerWithGrpc/Messages/IProgressTrackerService.cs @@ -0,0 +1,36 @@ +using System.ServiceModel; +using ProtoBuf; +using ProtoBuf.Grpc; +using Wolverine.Grpc; + +namespace ProgressTrackerWithGrpc.Messages; + +/// +/// Code-first gRPC contract for a job-progress tracker. The single +/// server-streaming method accepts a job description and yields one +/// item per completed step until the job finishes +/// or the caller cancels. +/// +[ServiceContract] +[WolverineGrpcService] +public interface IProgressTrackerService +{ + IAsyncEnumerable RunJob(RunJobRequest request, CallContext context = default); +} + +[ProtoContract] +public class RunJobRequest +{ + [ProtoMember(1)] public string JobName { get; set; } = string.Empty; + [ProtoMember(2)] public int Steps { get; set; } + [ProtoMember(3)] public int StepDelayMs { get; set; } +} + +[ProtoContract] +public class JobProgress +{ + [ProtoMember(1)] public int Step { get; set; } + [ProtoMember(2)] public int TotalSteps { get; set; } + [ProtoMember(3)] public int PercentComplete { get; set; } + [ProtoMember(4)] public string Message { get; set; } = string.Empty; +} diff --git a/src/Samples/ProgressTrackerWithGrpc/Messages/Messages.csproj b/src/Samples/ProgressTrackerWithGrpc/Messages/Messages.csproj new file mode 100644 index 000000000..6b8059755 --- /dev/null +++ b/src/Samples/ProgressTrackerWithGrpc/Messages/Messages.csproj @@ -0,0 +1,17 @@ + + + + net8.0;net9.0 + ProgressTrackerWithGrpc.Messages + ProgressTrackerWithGrpc.Messages + + + + + + + + + + + diff --git a/src/Samples/ProgressTrackerWithGrpc/Server/JobHandler.cs b/src/Samples/ProgressTrackerWithGrpc/Server/JobHandler.cs new file mode 100644 index 000000000..b54c2dd38 --- /dev/null +++ b/src/Samples/ProgressTrackerWithGrpc/Server/JobHandler.cs @@ -0,0 +1,35 @@ +using System.Runtime.CompilerServices; +using ProgressTrackerWithGrpc.Messages; + +namespace ProgressTrackerWithGrpc.Server; + +/// +/// Wolverine handler that simulates a multi-step job and streams back one +/// update per completed step. The +/// IAsyncEnumerable<T> return shape is Wolverine's server-streaming +/// handler contract; the generated gRPC service forwards via +/// IMessageBus.StreamAsync<T> and passes the caller's cancellation +/// token so mid-stream cancellation propagates cleanly. +/// +public static class JobHandler +{ + public static async IAsyncEnumerable Handle( + RunJobRequest request, + [EnumeratorCancellation] CancellationToken cancellationToken) + { + for (var step = 1; step <= request.Steps; step++) + { + cancellationToken.ThrowIfCancellationRequested(); + await Task.Delay(request.StepDelayMs, cancellationToken); + + var pct = (int)(step * 100.0 / request.Steps); + yield return new JobProgress + { + Step = step, + TotalSteps = request.Steps, + PercentComplete = pct, + Message = $"[{request.JobName}] Completed step {step}/{request.Steps}" + }; + } + } +} diff --git a/src/Samples/ProgressTrackerWithGrpc/Server/Program.cs b/src/Samples/ProgressTrackerWithGrpc/Server/Program.cs new file mode 100644 index 000000000..3cb18b63e --- /dev/null +++ b/src/Samples/ProgressTrackerWithGrpc/Server/Program.cs @@ -0,0 +1,36 @@ +using JasperFx; +using Microsoft.AspNetCore.Server.Kestrel.Core; +using ProgressTrackerWithGrpc.Messages; +using ProtoBuf.Grpc.Server; +using Wolverine; +using Wolverine.Grpc; + +var builder = WebApplication.CreateBuilder(args); + +// gRPC runs over HTTP/2. Listen on an unencrypted HTTP/2 endpoint so the +// sample runs without a trusted dev cert. +builder.WebHost.ConfigureKestrel(options => +{ + options.ListenLocalhost(5009, listen => listen.Protocols = HttpProtocols.Http2); +}); + +builder.Host.UseWolverine(opts => +{ + opts.ApplicationAssembly = typeof(Program).Assembly; + // The [WolverineGrpcService] interface lives in the shared Messages assembly; + // include it so GrpcGraph can discover IProgressTrackerService at startup. + opts.Discovery.IncludeAssembly(typeof(IProgressTrackerService).Assembly); +}); + +// Code-first gRPC: Wolverine discovers IProgressTrackerService (annotated with +// [WolverineGrpcService]) and generates the concrete implementation at startup. +builder.Services.AddCodeFirstGrpc(); +builder.Services.AddWolverineGrpc(); + +var app = builder.Build(); +app.UseRouting(); +app.MapWolverineGrpcServices(); + +return await app.RunJasperFxCommands(args); + +public partial class Program; diff --git a/src/Samples/ProgressTrackerWithGrpc/Server/Server.csproj b/src/Samples/ProgressTrackerWithGrpc/Server/Server.csproj new file mode 100644 index 000000000..d15029fd4 --- /dev/null +++ b/src/Samples/ProgressTrackerWithGrpc/Server/Server.csproj @@ -0,0 +1,18 @@ + + + + net8.0;net9.0 + ProgressTrackerWithGrpc.Server + ProgressTrackerWithGrpc.Server + + + + + + + + + + + + diff --git a/src/Samples/RacerWithGrpc/RacerClient/Program.cs b/src/Samples/RacerWithGrpc/RacerClient/Program.cs index 68dde3c3b..3d5114217 100644 --- a/src/Samples/RacerWithGrpc/RacerClient/Program.cs +++ b/src/Samples/RacerWithGrpc/RacerClient/Program.cs @@ -48,10 +48,14 @@ async IAsyncEnumerable ProduceUpdates( } } -// Server → client: full standings streamed back on every update. +// Server → client: full standings snapshot streamed back on every racer update. +// Each snapshot starts with position=1; print a divider to separate them visually. await foreach (var position in racing.RaceAsync(ProduceUpdates(cts.Token), cts.Token)) { - Console.WriteLine($" {position.RacerId,-10} position={position.Position} speed={position.Speed,6:F1} km/h"); + if (position.Position == 1) + Console.WriteLine("\n--- Standings ---"); + + Console.WriteLine($" #{position.Position} {position.RacerId,-10} {position.Speed,6:F1} km/h"); } Console.WriteLine("\nRace finished."); diff --git a/src/Samples/RacerWithGrpc/RacerServer/RaceStreamHandler.cs b/src/Samples/RacerWithGrpc/RacerServer/RaceStreamHandler.cs index 721be6aab..f4a001d66 100644 --- a/src/Samples/RacerWithGrpc/RacerServer/RaceStreamHandler.cs +++ b/src/Samples/RacerWithGrpc/RacerServer/RaceStreamHandler.cs @@ -5,8 +5,8 @@ namespace RacerServer; /// /// Wolverine streaming handler — consumed by /// from RacingGrpcService.RaceAsync. Per-inbound , the -/// handler updates shared state and yields one per racer so -/// the client receives a full standings view on every tick. +/// handler records the racer's speed and yields one per racer +/// so the client receives a full standings snapshot on every tick. /// public class RaceStreamHandler(RaceState raceState) { @@ -14,38 +14,12 @@ public async IAsyncEnumerable Handle(RacerUpdate update) { raceState.UpdateSpeed(update.RacerId, update.Speed); - var standings = ComputeStandings(raceState.GetCurrentSpeeds()); - LogStandings(standings, update.RacerId); - - foreach (var position in standings) + var position = 1; + foreach (var (racerId, speed) in raceState.GetCurrentSpeeds().OrderByDescending(kv => kv.Value)) { - yield return position; + yield return new RacePosition { RacerId = racerId, Position = position++, Speed = speed }; } - // Keeps the method asynchronous — stand-in for an await on I/O (DB, external service). await Task.Yield(); } - - private static List ComputeStandings(IReadOnlyDictionary speeds) - { - return speeds - .OrderByDescending(kv => kv.Value) - .Select((kv, idx) => new RacePosition - { - RacerId = kv.Key, - Position = idx + 1, - Speed = kv.Value - }) - .ToList(); - } - - private static void LogStandings(List standings, string updatedRacerId) - { - var row = string.Join(" | ", standings.Select(p => - { - var marker = p.RacerId == updatedRacerId ? "*" : " "; - return $"{marker}#{p.Position} {p.RacerId} {p.Speed,6:F1} km/h"; - })); - Console.WriteLine(row); - } } diff --git a/src/Samples/RacerWithGrpc/RacerServer/RacingService.cs b/src/Samples/RacerWithGrpc/RacerServer/RacingGrpcService.cs similarity index 100% rename from src/Samples/RacerWithGrpc/RacerServer/RacingService.cs rename to src/Samples/RacerWithGrpc/RacerServer/RacingGrpcService.cs diff --git a/src/Testing/CoreTests/Acceptance/streaming_handler_support.cs b/src/Testing/CoreTests/Acceptance/streaming_handler_support.cs index 073f2221b..93b98127b 100644 --- a/src/Testing/CoreTests/Acceptance/streaming_handler_support.cs +++ b/src/Testing/CoreTests/Acceptance/streaming_handler_support.cs @@ -99,7 +99,7 @@ public async Task stream_items_from_local_handler() .UseWolverine() .StartAsync(); - var bus = host.Services.GetRequiredService(); + var bus = host.MessageBus(); var items = new List(); await foreach (var item in bus.StreamAsync(new StreamRequest(3))) @@ -118,7 +118,7 @@ public async Task stream_returns_empty_when_handler_yields_nothing() .UseWolverine() .StartAsync(); - var bus = host.Services.GetRequiredService(); + var bus = host.MessageBus(); var items = new List(); await foreach (var item in bus.StreamAsync(new StreamRequest(0))) @@ -136,7 +136,7 @@ public async Task cancellation_stops_iteration() .UseWolverine() .StartAsync(); - var bus = host.Services.GetRequiredService(); + var bus = host.MessageBus(); using var cts = new CancellationTokenSource(); var count = 0; @@ -187,7 +187,7 @@ public async Task handler_exception_after_partial_yield_surfaces_to_caller_with_ .UseWolverine() .StartAsync(); - var bus = host.Services.GetRequiredService(); + var bus = host.MessageBus(); var items = new List(); var ex = await Should.ThrowAsync(async () => @@ -218,7 +218,7 @@ public async Task mid_stream_throw_marks_activity_status_error() .UseWolverine() .StartAsync(); - var bus = host.Services.GetRequiredService(); + var bus = host.MessageBus(); await Should.ThrowAsync(async () => { @@ -240,7 +240,7 @@ public async Task stream_with_delivery_options() .UseWolverine() .StartAsync(); - var bus = host.Services.GetRequiredService(); + var bus = host.MessageBus(); var options = new DeliveryOptions(); var items = new List(); diff --git a/src/Wolverine.Grpc.Tests/CodeFirstCodegen/CodeFirstCodegenFixture.cs b/src/Wolverine.Grpc.Tests/CodeFirstCodegen/CodeFirstCodegenFixture.cs new file mode 100644 index 000000000..b91a3f184 --- /dev/null +++ b/src/Wolverine.Grpc.Tests/CodeFirstCodegen/CodeFirstCodegenFixture.cs @@ -0,0 +1,67 @@ +using Grpc.Net.Client; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.TestHost; +using Microsoft.Extensions.DependencyInjection; +using ProtoBuf.Grpc.Client; +using ProtoBuf.Grpc.Server; +using Xunit; + +namespace Wolverine.Grpc.Tests.CodeFirstCodegen; + +/// +/// Boots an in-process ASP.NET Core + Wolverine host for the code-first codegen path. +/// No concrete service class is registered — MapWolverineGrpcServices() discovers +/// (annotated with [WolverineGrpcService]), +/// generates the implementation at startup, and maps it. +/// +public class CodeFirstCodegenFixture : IAsyncLifetime +{ + private WebApplication? _app; + public GrpcChannel? Channel { get; private set; } + + public IServiceProvider Services => _app?.Services + ?? throw new InvalidOperationException("Fixture has not been initialized yet."); + + public async Task InitializeAsync() + { + var builder = WebApplication.CreateBuilder([]); + builder.WebHost.UseTestServer(); + + builder.Host.UseWolverine(opts => + { + // Scan the test assembly so the interface and handlers are both discovered. + opts.ApplicationAssembly = typeof(CodeFirstCodegenFixture).Assembly; + }); + + builder.Services.AddCodeFirstGrpc(); + builder.Services.AddWolverineGrpc(); + + _app = builder.Build(); + _app.UseRouting(); + + // Full codegen discovery path under test: MapWolverineGrpcServices must find + // ICodeFirstTestService, generate CodeFirstTestServiceGrpcHandler, and map it. + _app.MapWolverineGrpcServices(); + + await _app.StartAsync(); + + var handler = _app.GetTestServer().CreateHandler(); + Channel = GrpcChannel.ForAddress("http://localhost", new GrpcChannelOptions + { + HttpHandler = handler + }); + } + + public async Task DisposeAsync() + { + Channel?.Dispose(); + if (_app != null) + { + await _app.StopAsync(); + await _app.DisposeAsync(); + } + } + + public TService CreateClient() where TService : class + => Channel!.CreateGrpcService(); +} diff --git a/src/Wolverine.Grpc.Tests/CodeFirstCodegen/CodeFirstTestHandlers.cs b/src/Wolverine.Grpc.Tests/CodeFirstCodegen/CodeFirstTestHandlers.cs new file mode 100644 index 000000000..0327098d6 --- /dev/null +++ b/src/Wolverine.Grpc.Tests/CodeFirstCodegen/CodeFirstTestHandlers.cs @@ -0,0 +1,34 @@ +using System.Runtime.CompilerServices; +using Grpc.Core; + +namespace Wolverine.Grpc.Tests.CodeFirstCodegen; + +public static class EchoHandler +{ + public static CodeFirstReply Handle(CodeFirstRequest request) + => new() { Echo = request.Text }; +} + +public static class EchoStreamHandler +{ + public static async IAsyncEnumerable Handle( + CodeFirstStreamRequest request, + [EnumeratorCancellation] CancellationToken cancellationToken) + { + for (var i = 0; i < request.Count; i++) + { + cancellationToken.ThrowIfCancellationRequested(); + yield return new CodeFirstReply { Echo = $"{request.Text}:{i}" }; + await Task.Yield(); + } + } +} + +public static class SubmitHandler +{ + public static Status? Validate(CodeFirstValidateRequest request) + => request.Text == "bad" ? new Status(StatusCode.InvalidArgument, "bad input") : null; + + public static CodeFirstReply Handle(CodeFirstValidateRequest request) + => new() { Echo = request.Text }; +} diff --git a/src/Wolverine.Grpc.Tests/CodeFirstCodegen/ICodeFirstTestService.cs b/src/Wolverine.Grpc.Tests/CodeFirstCodegen/ICodeFirstTestService.cs new file mode 100644 index 000000000..3895330b5 --- /dev/null +++ b/src/Wolverine.Grpc.Tests/CodeFirstCodegen/ICodeFirstTestService.cs @@ -0,0 +1,58 @@ +using System.ServiceModel; +using ProtoBuf; +using ProtoBuf.Grpc; +using Wolverine.Grpc; + +namespace Wolverine.Grpc.Tests.CodeFirstCodegen; + +/// +/// Test-only code-first gRPC contract. Annotated with both +/// [ServiceContract] (required by protobuf-net.Grpc) and +/// [WolverineGrpcService] (tells Wolverine to generate the implementation). +/// No concrete class is written — Wolverine generates one at startup. +/// +[ServiceContract] +[WolverineGrpcService] +public interface ICodeFirstTestService +{ + Task Echo(CodeFirstRequest request, CallContext context = default); + IAsyncEnumerable EchoStream(CodeFirstStreamRequest request, CallContext context = default); +} + +[ProtoContract] +public class CodeFirstRequest +{ + [ProtoMember(1)] public string Text { get; set; } = string.Empty; +} + +[ProtoContract] +public class CodeFirstStreamRequest +{ + [ProtoMember(1)] public string Text { get; set; } = string.Empty; + [ProtoMember(2)] public int Count { get; set; } +} + +[ProtoContract] +public class CodeFirstReply +{ + [ProtoMember(1)] public string Echo { get; set; } = string.Empty; +} + +/// +/// Code-first service contract for the validate short-circuit test. +/// The static Validate method lives on — the Wolverine +/// handler for — following the same idiom as the +/// rest of the framework: middleware hooks belong on the handler class. +/// +[ServiceContract] +[WolverineGrpcService] +public interface ICodeFirstValidatedService +{ + Task Submit(CodeFirstValidateRequest request, CallContext context = default); +} + +[ProtoContract] +public class CodeFirstValidateRequest +{ + [ProtoMember(1)] public string Text { get; set; } = string.Empty; +} diff --git a/src/Wolverine.Grpc.Tests/CodeFirstCodegen/code_first_codegen_tests.cs b/src/Wolverine.Grpc.Tests/CodeFirstCodegen/code_first_codegen_tests.cs new file mode 100644 index 000000000..71453259f --- /dev/null +++ b/src/Wolverine.Grpc.Tests/CodeFirstCodegen/code_first_codegen_tests.cs @@ -0,0 +1,222 @@ +using Grpc.Core; +using Microsoft.Extensions.DependencyInjection; +using Shouldly; +using Wolverine.Attributes; +using Xunit; + +namespace Wolverine.Grpc.Tests.CodeFirstCodegen; + +[Collection("grpc-code-first-codegen")] +public class code_first_codegen_integration_tests : IClassFixture +{ + private readonly CodeFirstCodegenFixture _fixture; + + public code_first_codegen_integration_tests(CodeFirstCodegenFixture fixture) + { + _fixture = fixture; + } + + [Fact] + public async Task round_trip_unary_through_generated_implementation() + { + var client = _fixture.CreateClient(); + + var reply = await client.Echo(new CodeFirstRequest { Text = "hello" }); + + reply.Echo.ShouldBe("hello"); + } + + [Fact] + public async Task round_trip_server_streaming_through_generated_implementation() + { + var client = _fixture.CreateClient(); + + var replies = new List(); + await foreach (var reply in client.EchoStream(new CodeFirstStreamRequest { Text = "item", Count = 3 })) + { + replies.Add(reply.Echo); + } + + replies.ShouldBe(["item:0", "item:1", "item:2"]); + } + + [Fact] + public async Task cancellation_propagates_through_generated_unary() + { + var client = _fixture.CreateClient(); + using var cts = new CancellationTokenSource(); + cts.Cancel(); + + await Should.ThrowAsync(async () => + await client.Echo(new CodeFirstRequest { Text = "cancelled" }, cts.Token)); + } + + [Fact] + public async Task mid_stream_cancellation_stops_enumeration_early() + { + var client = _fixture.CreateClient(); + using var cts = new CancellationTokenSource(); + var received = 0; + + await Should.ThrowAsync(async () => + { + await foreach (var _ in client.EchoStream( + new CodeFirstStreamRequest { Text = "cancel", Count = 500 }, cts.Token)) + { + received++; + if (received == 2) cts.Cancel(); + } + }); + + received.ShouldBeLessThan(500); + } + + [Fact] + public void generated_type_name_follows_interface_name_grpc_handler_convention() + { + var graph = _fixture.Services.GetRequiredService(); + var chain = graph.CodeFirstChains.Single(c => c.ServiceContractType == typeof(ICodeFirstTestService)); + + chain.TypeName.ShouldBe("CodeFirstTestServiceGrpcHandler"); + chain.GeneratedType.ShouldNotBeNull(); + chain.GeneratedType!.Name.ShouldBe("CodeFirstTestServiceGrpcHandler"); + } + + [Fact] + public void generated_type_implements_the_service_contract_interface() + { + var graph = _fixture.Services.GetRequiredService(); + var chain = graph.CodeFirstChains.Single(c => c.ServiceContractType == typeof(ICodeFirstTestService)); + + chain.GeneratedType.ShouldNotBeNull(); + chain.GeneratedType!.GetInterfaces().ShouldContain(typeof(ICodeFirstTestService)); + } + + [Fact] + public void both_rpc_methods_are_classified_correctly() + { + var methods = CodeFirstGrpcServiceChain.DiscoverMethods(typeof(ICodeFirstTestService)).ToArray(); + + methods.Length.ShouldBe(2); + methods.Single(m => m.Method.Name == nameof(ICodeFirstTestService.Echo)).Kind + .ShouldBe(CodeFirstMethodKind.Unary); + methods.Single(m => m.Method.Name == nameof(ICodeFirstTestService.EchoStream)).Kind + .ShouldBe(CodeFirstMethodKind.ServerStreaming); + } + + [Fact] + public async Task validate_short_circuit_throws_rpc_exception_before_handler_runs() + { + var client = _fixture.CreateClient(); + + var ex = await Should.ThrowAsync( + () => client.Submit(new CodeFirstValidateRequest { Text = "bad" })); + + ex.StatusCode.ShouldBe(StatusCode.InvalidArgument); + } + + [Fact] + public async Task validate_returns_null_allows_request_through_to_handler() + { + var client = _fixture.CreateClient(); + + var reply = await client.Submit(new CodeFirstValidateRequest { Text = "good" }); + + reply.Echo.ShouldBe("good"); + } +} + +[Collection("grpc-code-first-codegen-unit")] +public class code_first_codegen_discovery_tests +{ + [Fact] + public void find_code_first_service_contracts_returns_annotated_interfaces() + { + var contracts = GrpcGraph.FindCodeFirstServiceContracts( + [typeof(ICodeFirstTestService).Assembly]).ToList(); + + contracts.ShouldContain(typeof(ICodeFirstTestService)); + } + + [Fact] + public void find_code_first_service_contracts_does_not_return_concrete_classes() + { + var contracts = GrpcGraph.FindCodeFirstServiceContracts( + [typeof(ICodeFirstTestService).Assembly]).ToList(); + + contracts.ShouldAllBe(t => t.IsInterface); + } + + [Fact] + public void resolve_type_name_strips_leading_i_from_interface() + { + CodeFirstGrpcServiceChain.ResolveTypeName(typeof(ICodeFirstTestService)) + .ShouldBe("CodeFirstTestServiceGrpcHandler"); + } + + [Fact] + public void resolve_type_name_does_not_strip_i_when_not_followed_by_uppercase() + { + // A type named "ImaginaryService" should not have its 'I' stripped since the + // next character is lowercase — only strip a conventional interface prefix. + CodeFirstGrpcServiceChain.ResolveTypeName(typeof(ImaginaryServiceContract)) + .ShouldBe("ImaginaryServiceContractGrpcHandler"); + } + + [Fact] + public void conflict_detection_throws_when_concrete_impl_also_has_attribute() + { + Should.Throw(() => + CodeFirstGrpcServiceChain.AssertNoConcreteImplementationConflicts( + typeof(IConflictingService), + [typeof(IConflictingService).Assembly])); + } + + [Fact] + public void chain_scoping_is_grpc_so_middleware_routes_only_to_grpc_chains() + { + // MiddlewareScoping.Grpc is what prevents handler-bus middleware from leaking into gRPC + // chains and gRPC middleware from leaking into handler chains. + var chain = new CodeFirstGrpcServiceChain(typeof(ICodeFirstTestService)); + chain.Scoping.ShouldBe(MiddlewareScoping.Grpc); + } + + [Fact] + public void application_assemblies_null_before_discovery() + { + // ApplicationAssemblies is set by GrpcGraph.DiscoverServices, not by the constructor. + // Chains constructed directly (unit tests, tooling) must not throw. + var chain = new CodeFirstGrpcServiceChain(typeof(ICodeFirstValidatedService)); + + chain.ApplicationAssemblies.ShouldBeNull(); + } + + [Fact] + public void validate_method_on_handler_is_discovered_via_assembly_scan() + { + // SubmitHandler.Validate lives on the handler class for CodeFirstValidateRequest. + // Confirm the assembly scan will find it and that it has the expected static shape. + var validateMethod = typeof(SubmitHandler).GetMethod(nameof(SubmitHandler.Validate))!; + + validateMethod.ShouldNotBeNull(); + validateMethod.IsStatic.ShouldBeTrue(); + validateMethod.ReturnType.ShouldBe(typeof(Status?)); + } +} + +// --- helpers for unit tests --- +// These types deliberately omit [ServiceContract] so they are NOT picked up by the fixture's +// assembly scan (FindCodeFirstServiceContracts requires both [ServiceContract] and [WolverineGrpcService]). +// The unit tests exercise the underlying methods directly rather than via the full pipeline. + +// A type whose name starts with 'I' followed by lowercase — the leading 'I' must NOT be stripped. +[WolverineGrpcService] +public interface ImaginaryServiceContract { } + +// Simulates a user who mistakenly marks BOTH the interface and its concrete implementation +// with [WolverineGrpcService] — the conflict guard must fire when called directly. +[WolverineGrpcService] +public interface IConflictingService { } + +[WolverineGrpcService] +public class ConflictingServiceImpl : IConflictingService { } diff --git a/src/Wolverine.Grpc.Tests/FaultExceptions.cs b/src/Wolverine.Grpc.Tests/FaultExceptions.cs index f881b360f..96281cd17 100644 --- a/src/Wolverine.Grpc.Tests/FaultExceptions.cs +++ b/src/Wolverine.Grpc.Tests/FaultExceptions.cs @@ -16,6 +16,7 @@ public static class FaultExceptions "invalid" => new InvalidOperationException("bad state"), "notimpl" => new NotImplementedException("not yet"), "timeout" => new TimeoutException("too slow"), + "domain-validation" => new DomainValidationException("invalid domain state"), _ => new Exception("generic") }; } diff --git a/src/Wolverine.Grpc.Tests/GrpcBidiStreaming/BidiEchoHandler.cs b/src/Wolverine.Grpc.Tests/GrpcBidiStreaming/BidiEchoHandler.cs new file mode 100644 index 000000000..a0d52b837 --- /dev/null +++ b/src/Wolverine.Grpc.Tests/GrpcBidiStreaming/BidiEchoHandler.cs @@ -0,0 +1,20 @@ +using System.Runtime.CompilerServices; +using Wolverine.Grpc.Tests.GrpcBidiStreaming.Generated; + +namespace Wolverine.Grpc.Tests.GrpcBidiStreaming; + +public static class BidiEchoHandler +{ + public static async IAsyncEnumerable Handle( + EchoRequest request, + [EnumeratorCancellation] CancellationToken cancellationToken) + { + for (var i = 0; i < request.RepeatCount; i++) + { + cancellationToken.ThrowIfCancellationRequested(); + yield return new EchoReply { Text = request.Text }; + } + + await Task.Yield(); + } +} diff --git a/src/Wolverine.Grpc.Tests/GrpcBidiStreaming/BidiEchoStub.cs b/src/Wolverine.Grpc.Tests/GrpcBidiStreaming/BidiEchoStub.cs new file mode 100644 index 000000000..58e2a7ea2 --- /dev/null +++ b/src/Wolverine.Grpc.Tests/GrpcBidiStreaming/BidiEchoStub.cs @@ -0,0 +1,10 @@ +using Wolverine.Grpc.Tests.GrpcBidiStreaming.Generated; + +namespace Wolverine.Grpc.Tests.GrpcBidiStreaming; + +/// +/// Proto-first stub for the bidirectional-streaming tests. Carries no extra methods — +/// the generated wrapper's Echo override is produced entirely by Wolverine codegen. +/// +[WolverineGrpcService] +public abstract class BidiEchoStub : BidiEchoTest.BidiEchoTestBase; diff --git a/src/Wolverine.Grpc.Tests/GrpcBidiStreaming/BidiStreamingFixture.cs b/src/Wolverine.Grpc.Tests/GrpcBidiStreaming/BidiStreamingFixture.cs new file mode 100644 index 000000000..0c4f51861 --- /dev/null +++ b/src/Wolverine.Grpc.Tests/GrpcBidiStreaming/BidiStreamingFixture.cs @@ -0,0 +1,60 @@ +using Grpc.Net.Client; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.TestHost; +using Microsoft.Extensions.DependencyInjection; +using Wolverine.Grpc.Tests.GrpcBidiStreaming.Generated; +using Xunit; + +namespace Wolverine.Grpc.Tests.GrpcBidiStreaming; + +/// +/// Boots an in-process gRPC host to exercise proto-first bidirectional-streaming code-gen +/// end-to-end. Isolated from other fixtures so bidi-specific assertions don't drift when +/// unary/server-streaming tests evolve. +/// +public class BidiStreamingFixture : IAsyncLifetime +{ + private WebApplication? _app; + public GrpcChannel? Channel { get; private set; } + public IServiceProvider Services => _app?.Services + ?? throw new InvalidOperationException("Fixture has not been initialized yet."); + + public async Task InitializeAsync() + { + var builder = WebApplication.CreateBuilder([]); + builder.WebHost.UseTestServer(); + + builder.Host.UseWolverine(opts => + { + opts.ApplicationAssembly = typeof(BidiStreamingFixture).Assembly; + }); + + builder.Services.AddGrpc(); + builder.Services.AddWolverineGrpc(); + + _app = builder.Build(); + _app.UseRouting(); + _app.MapWolverineGrpcServices(); + + await _app.StartAsync(); + + var handler = _app.GetTestServer().CreateHandler(); + Channel = GrpcChannel.ForAddress("http://localhost", new GrpcChannelOptions + { + HttpHandler = handler + }); + } + + public async Task DisposeAsync() + { + Channel?.Dispose(); + if (_app != null) + { + await _app.StopAsync(); + await _app.DisposeAsync(); + } + } + + public BidiEchoTest.BidiEchoTestClient CreateClient() + => new(Channel!); +} diff --git a/src/Wolverine.Grpc.Tests/GrpcBidiStreaming/Protos/grpc_bidi_test.proto b/src/Wolverine.Grpc.Tests/GrpcBidiStreaming/Protos/grpc_bidi_test.proto new file mode 100644 index 000000000..ce537f506 --- /dev/null +++ b/src/Wolverine.Grpc.Tests/GrpcBidiStreaming/Protos/grpc_bidi_test.proto @@ -0,0 +1,27 @@ +syntax = "proto3"; + +option csharp_namespace = "Wolverine.Grpc.Tests.GrpcBidiStreaming.Generated"; + +package wolverine.grpc.tests.bidi_streaming; + +// Test-only service exercising the bidirectional-streaming generated wrapper for proto-first +// Wolverine gRPC stubs. The server echoes each inbound EchoRequest back as repeat_count +// EchoReply messages so tests can assert per-item correlation across the bidi stream. +service BidiEchoTest { + rpc Echo (stream EchoRequest) returns (stream EchoReply); +} + +// Client-streaming service — used only by unit tests that verify the fail-fast error message +// when a proto-first stub declares a client-streaming RPC. Never registered with a host. +service ClientStreamTest { + rpc Collect (stream EchoRequest) returns (EchoReply); +} + +message EchoRequest { + string text = 1; + int32 repeat_count = 2; +} + +message EchoReply { + string text = 1; +} diff --git a/src/Wolverine.Grpc.Tests/GrpcBidiStreaming/grpc_bidi_streaming_tests.cs b/src/Wolverine.Grpc.Tests/GrpcBidiStreaming/grpc_bidi_streaming_tests.cs new file mode 100644 index 000000000..78b2a4b69 --- /dev/null +++ b/src/Wolverine.Grpc.Tests/GrpcBidiStreaming/grpc_bidi_streaming_tests.cs @@ -0,0 +1,159 @@ +using Grpc.Core; +using JasperFx; +using JasperFx.CodeGeneration; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using Shouldly; +using Wolverine.Grpc.Tests.GrpcBidiStreaming.Generated; +using Xunit; + +namespace Wolverine.Grpc.Tests.GrpcBidiStreaming; + +/// +/// End-to-end tests for the proto-first bidirectional-streaming generated wrapper. +/// Verifies that the generated code correctly loops each inbound +/// through IMessageBus.StreamAsync and writes all yielded +/// messages back to the client. +/// +public class grpc_bidi_streaming_tests : IClassFixture +{ + private readonly BidiStreamingFixture _fixture; + + public grpc_bidi_streaming_tests(BidiStreamingFixture fixture) + { + _fixture = fixture; + } + + [Fact] + public async Task single_request_yields_the_expected_number_of_replies() + { + using var call = _fixture.CreateClient().Echo(); + await call.RequestStream.WriteAsync(new EchoRequest { Text = "ping", RepeatCount = 3 }); + await call.RequestStream.CompleteAsync(); + + var replies = new List(); + await foreach (var reply in call.ResponseStream.ReadAllAsync()) + replies.Add(reply.Text); + + replies.ShouldBe(["ping", "ping", "ping"]); + } + + [Fact] + public async Task multiple_requests_each_produce_their_own_replies() + { + using var call = _fixture.CreateClient().Echo(); + + await call.RequestStream.WriteAsync(new EchoRequest { Text = "a", RepeatCount = 2 }); + await call.RequestStream.WriteAsync(new EchoRequest { Text = "b", RepeatCount = 1 }); + await call.RequestStream.WriteAsync(new EchoRequest { Text = "c", RepeatCount = 3 }); + await call.RequestStream.CompleteAsync(); + + var replies = new List(); + await foreach (var reply in call.ResponseStream.ReadAllAsync()) + replies.Add(reply.Text); + + replies.ShouldBe(["a", "a", "b", "c", "c", "c"]); + } + + [Fact] + public async Task zero_requests_produces_zero_replies() + { + using var call = _fixture.CreateClient().Echo(); + await call.RequestStream.CompleteAsync(); + + var replies = new List(); + await foreach (var reply in call.ResponseStream.ReadAllAsync()) + replies.Add(reply); + + replies.ShouldBeEmpty(); + } + + [Fact] + public async Task request_with_zero_repeat_count_produces_no_replies() + { + using var call = _fixture.CreateClient().Echo(); + await call.RequestStream.WriteAsync(new EchoRequest { Text = "nothing", RepeatCount = 0 }); + await call.RequestStream.CompleteAsync(); + + var replies = new List(); + await foreach (var reply in call.ResponseStream.ReadAllAsync()) + replies.Add(reply); + + replies.ShouldBeEmpty(); + } + + [Fact] + public void bidi_method_is_classified_as_bidirectional_streaming_by_the_chain() + { + var graph = _fixture.Services.GetRequiredService(); + var chain = graph.Chains.Single(c => c.StubType == typeof(BidiEchoStub)); + + chain.BidirectionalStreamingMethods.Count.ShouldBe(1); + chain.BidirectionalStreamingMethods[0].Name.ShouldBe("Echo"); + chain.UnaryMethods.ShouldBeEmpty(); + chain.ServerStreamingMethods.ShouldBeEmpty(); + } +} + +public class grpc_bidi_discovery_tests +{ + [Fact] + public void classifies_bidi_method_as_bidirectional_streaming() + { + var classified = GrpcServiceChain.DiscoverSupportedMethods(typeof(BidiEchoTest.BidiEchoTestBase)) + .ToDictionary(m => m.Method.Name, m => m.Kind); + + classified["Echo"].ShouldBe(GrpcMethodKind.BidirectionalStreaming); + } + + [Fact] + public void classifies_client_streaming_method_correctly() + { + var classified = GrpcServiceChain.DiscoverSupportedMethods(typeof(ClientStreamTest.ClientStreamTestBase)) + .ToDictionary(m => m.Method.Name, m => m.Kind); + + classified["Collect"].ShouldBe(GrpcMethodKind.ClientStreaming); + } +} + +/// +/// Verifies the fail-fast contract: constructing a from a +/// proto-first stub that declares a client-streaming RPC must throw +/// immediately, before any code generation runs. +/// This prevents silent no-ops at runtime (the generated wrapper would have no method to +/// delegate client-streaming requests to). +/// +[Collection("GrpcSerialTests")] +public class grpc_client_streaming_fail_fast_tests +{ + [Fact] + public async Task stub_with_client_streaming_method_throws_not_supported_at_chain_construction() + { + DynamicCodeBuilder.WithinCodegenCommand = true; + try + { + using var host = await Host.CreateDefaultBuilder() + .UseWolverine(opts => opts.ApplicationAssembly = typeof(BidiEchoStub).Assembly) + .ConfigureServices(services => services.AddWolverineGrpc()) + .StartAsync(); + + var graph = host.Services.GetRequiredService(); + + var ex = Should.Throw( + () => new GrpcServiceChain(typeof(ClientStreamingOnlyStub), graph)); + + // Message must name the unsupported shape and the offending method so + // the user can immediately identify what to fix. + ex.Message.ShouldContain("Client-streaming"); + ex.Message.ShouldContain("Collect"); + } + finally + { + DynamicCodeBuilder.WithinCodegenCommand = false; + } + } +} + +// Internal so GetExportedTypes() skips it — it must never land in the proto-first +// discovery scan and break the BidiStreamingFixture that shares this assembly. +internal abstract class ClientStreamingOnlyStub : ClientStreamTest.ClientStreamTestBase; diff --git a/src/Wolverine.Grpc.Tests/GrpcMiddlewareScoping/middleware_weaving_execution_tests.cs b/src/Wolverine.Grpc.Tests/GrpcMiddlewareScoping/middleware_weaving_execution_tests.cs new file mode 100644 index 000000000..f2724fc9b --- /dev/null +++ b/src/Wolverine.Grpc.Tests/GrpcMiddlewareScoping/middleware_weaving_execution_tests.cs @@ -0,0 +1,126 @@ +using Grpc.Core; +using Shouldly; +using Wolverine.Grpc.Tests.GrpcMiddlewareScoping.Generated; +using Xunit; + +namespace Wolverine.Grpc.Tests.GrpcMiddlewareScoping; + +/// +/// M15 Phase 1: verifies that -scoped +/// middleware methods on a proto-first stub are actually woven into the generated gRPC service +/// wrappers and fire at RPC time in the correct order. +/// +public class middleware_weaving_execution_tests : IClassFixture +{ + private readonly MiddlewareScopingFixture _fixture; + + public middleware_weaving_execution_tests(MiddlewareScopingFixture fixture) + { + _fixture = fixture; + _fixture.Sink.Clear(); + } + + // ── unary ────────────────────────────────────────────────────────────────── + + [Fact] + public async Task unary_fires_anywhere_scoped_before() + { + await _fixture.CreateClient().GreetAsync(new GreetRequest { Name = "test" }); + + _fixture.Sink.Contains(GreeterMiddlewareTestStub.AnywhereMarker).ShouldBeTrue( + "[WolverineBefore] (Anywhere scope) must execute on every unary gRPC call"); + } + + [Fact] + public async Task unary_fires_grpc_scoped_before() + { + await _fixture.CreateClient().GreetAsync(new GreetRequest { Name = "test" }); + + _fixture.Sink.Contains(GreeterMiddlewareTestStub.GrpcMarker).ShouldBeTrue( + "[WolverineBefore(MiddlewareScoping.Grpc)] must execute on unary gRPC calls"); + } + + [Fact] + public async Task unary_does_not_fire_message_handlers_scoped_before() + { + await _fixture.CreateClient().GreetAsync(new GreetRequest { Name = "test" }); + + _fixture.Sink.Contains(GreeterMiddlewareTestStub.MessageHandlersMarker).ShouldBeFalse( + "[WolverineBefore(MiddlewareScoping.MessageHandlers)] must not execute on gRPC calls"); + } + + [Fact] + public async Task unary_fires_anywhere_scoped_after() + { + await _fixture.CreateClient().GreetAsync(new GreetRequest { Name = "test" }); + + _fixture.Sink.Contains(GreeterMiddlewareTestStub.AnywhereMarker + ".After").ShouldBeTrue( + "[WolverineAfter] (Anywhere scope) must execute after a unary gRPC call"); + } + + [Fact] + public async Task unary_fires_grpc_scoped_after() + { + await _fixture.CreateClient().GreetAsync(new GreetRequest { Name = "test" }); + + _fixture.Sink.Contains(GreeterMiddlewareTestStub.GrpcMarker + ".After").ShouldBeTrue( + "[WolverineAfter(MiddlewareScoping.Grpc)] must execute after a unary gRPC call"); + } + + [Fact] + public async Task unary_does_not_fire_message_handlers_scoped_after() + { + await _fixture.CreateClient().GreetAsync(new GreetRequest { Name = "test" }); + + _fixture.Sink.Contains(GreeterMiddlewareTestStub.MessageHandlersMarker + ".After").ShouldBeFalse( + "[WolverineAfter(MiddlewareScoping.MessageHandlers)] must not execute on gRPC calls"); + } + + [Fact] + public async Task unary_before_fires_before_handler_and_after_fires_after_handler() + { + await _fixture.CreateClient().GreetAsync(new GreetRequest { Name = "test" }); + + var events = _fixture.Sink.Events; + var beforeGrpcIdx = events.ToList().IndexOf(GreeterMiddlewareTestStub.GrpcMarker); + var handlerIdx = events.ToList().IndexOf(GreetMessageHandler.Marker); + var afterGrpcIdx = events.ToList().IndexOf(GreeterMiddlewareTestStub.GrpcMarker + ".After"); + + beforeGrpcIdx.ShouldBeLessThan(handlerIdx, "before-middleware must run before the handler"); + handlerIdx.ShouldBeLessThan(afterGrpcIdx, "handler must run before after-middleware"); + } + + // ── server streaming ─────────────────────────────────────────────────────── + + [Fact] + public async Task server_streaming_fires_grpc_scoped_before() + { + using var call = _fixture.CreateClient().GreetMany(new GreetManyRequest { Name = "test" }); + await foreach (var _ in call.ResponseStream.ReadAllAsync()) { } + + _fixture.Sink.Contains(GreeterMiddlewareTestStub.GrpcMarker).ShouldBeTrue( + "[WolverineBefore(MiddlewareScoping.Grpc)] must execute on server-streaming gRPC calls"); + } + + [Fact] + public async Task server_streaming_fires_grpc_scoped_after() + { + using var call = _fixture.CreateClient().GreetMany(new GreetManyRequest { Name = "test" }); + await foreach (var _ in call.ResponseStream.ReadAllAsync()) { } + + _fixture.Sink.Contains(GreeterMiddlewareTestStub.GrpcMarker + ".After").ShouldBeTrue( + "[WolverineAfter(MiddlewareScoping.Grpc)] must execute after the server-streaming loop completes"); + } + + [Fact] + public async Task server_streaming_does_not_fire_message_handlers_scoped_middleware() + { + using var call = _fixture.CreateClient().GreetMany(new GreetManyRequest { Name = "test" }); + await foreach (var _ in call.ResponseStream.ReadAllAsync()) { } + + _fixture.Sink.Contains(GreeterMiddlewareTestStub.MessageHandlersMarker).ShouldBeFalse( + "[WolverineBefore(MiddlewareScoping.MessageHandlers)] must not execute on gRPC calls"); + _fixture.Sink.Contains(GreeterMiddlewareTestStub.MessageHandlersMarker + ".After").ShouldBeFalse( + "[WolverineAfter(MiddlewareScoping.MessageHandlers)] must not execute on gRPC calls"); + } +} diff --git a/src/Wolverine.Grpc.Tests/GrpcMiddlewareScoping/policy_leak_tests.cs b/src/Wolverine.Grpc.Tests/GrpcMiddlewareScoping/policy_leak_tests.cs index b390d20e0..0adf42d3c 100644 --- a/src/Wolverine.Grpc.Tests/GrpcMiddlewareScoping/policy_leak_tests.cs +++ b/src/Wolverine.Grpc.Tests/GrpcMiddlewareScoping/policy_leak_tests.cs @@ -19,6 +19,7 @@ namespace Wolverine.Grpc.Tests.GrpcMiddlewareScoping; /// global IChainPolicy against gRPC chains too), users would suddenly see their /// bus middleware run on every RPC call. Hence the explicit guard test here. /// +[Collection("GrpcSerialTests")] public class policy_leak_tests { [Fact] @@ -41,7 +42,8 @@ public async Task ipolicies_add_middleware_does_not_attach_to_grpc_chain() .StartAsync(); var graph = host.Services.GetRequiredService(); - graph.DiscoverServices(); + var grpcOptions = host.Services.GetRequiredService(); + graph.DiscoverServices(grpcOptions); var chain = graph.Chains.Single(c => c.StubType == typeof(GreeterMiddlewareTestStub)); var options = host.Services.GetRequiredService(); @@ -78,3 +80,243 @@ public sealed class HandlerOnlyMiddleware { public static void Before(MiddlewareInvocationSink sink) => sink.Record("HandlerOnlyMiddleware.Before"); } + +// ── Unit tests: WolverineGrpcOptions.AddPolicy ──────────────────────────────── + +/// +/// Pins the surface added in PR #2565. +/// These are pure unit tests — no host, no DI. +/// +public class wolverine_grpc_options_add_policy_tests +{ + [Fact] + public void add_policy_generic_registers_instance_in_policies_list() + { + var opts = new WolverineGrpcOptions(); + opts.AddPolicy(); + + opts.Policies.ShouldHaveSingleItem().ShouldBeOfType(); + } + + [Fact] + public void add_policy_instance_overload_registers_in_policies_list() + { + var opts = new WolverineGrpcOptions(); + var policy = new RecordingGrpcChainPolicy(); + opts.AddPolicy(policy); + + opts.Policies.ShouldHaveSingleItem().ShouldBeSameAs(policy); + } + + [Fact] + public void add_policy_returns_self_for_fluent_chaining() + { + var opts = new WolverineGrpcOptions(); + opts.AddPolicy().ShouldBeSameAs(opts); + } + + [Fact] + public void multiple_policies_accumulate_in_order() + { + var opts = new WolverineGrpcOptions(); + var first = new RecordingGrpcChainPolicy(); + var second = new RecordingGrpcChainPolicy(); + + opts.AddPolicy(first).AddPolicy(second); + + opts.Policies.Count.ShouldBe(2); + opts.Policies[0].ShouldBeSameAs(first); + opts.Policies[1].ShouldBeSameAs(second); + } +} + +// ── Integration: DiscoverServices calls all registered IGrpcChainPolicy ─────── + +/// +/// Verifies that actually invokes every +/// registered in +/// and passes the correctly-typed chain lists to Apply. +/// +[Collection("GrpcSerialTests")] +public class igpc_chain_policy_discover_services_tests +{ + [Fact] + public async Task registered_policy_is_called_during_discover_services() + { + DynamicCodeBuilder.WithinCodegenCommand = true; + try + { + var policy = new RecordingGrpcChainPolicy(); + + using var host = await Host.CreateDefaultBuilder() + .UseWolverine(opts => opts.ApplicationAssembly = typeof(GreeterMiddlewareTestStub).Assembly) + .ConfigureServices(services => + { + services.AddSingleton(new MiddlewareInvocationSink()); + services.AddWolverineGrpc(opts => opts.AddPolicy(policy)); + }) + .StartAsync(); + + var graph = host.Services.GetRequiredService(); + var grpcOptions = host.Services.GetRequiredService(); + graph.DiscoverServices(grpcOptions); + + policy.WasCalled.ShouldBeTrue( + "IGrpcChainPolicy.Apply must be invoked during GrpcGraph.DiscoverServices"); + } + finally + { + DynamicCodeBuilder.WithinCodegenCommand = false; + } + } + + [Fact] + public async Task policy_receives_all_three_chain_type_lists_with_discovered_chains() + { + DynamicCodeBuilder.WithinCodegenCommand = true; + try + { + var policy = new RecordingGrpcChainPolicy(); + + using var host = await Host.CreateDefaultBuilder() + .UseWolverine(opts => opts.ApplicationAssembly = typeof(GreeterMiddlewareTestStub).Assembly) + .ConfigureServices(services => + { + services.AddSingleton(new MiddlewareInvocationSink()); + services.AddWolverineGrpc(opts => opts.AddPolicy(policy)); + }) + .StartAsync(); + + var graph = host.Services.GetRequiredService(); + var grpcOptions = host.Services.GetRequiredService(); + graph.DiscoverServices(grpcOptions); + + // The test assembly contains stubs from all three discovery paths. + policy.ProtoFirstCount.ShouldBeGreaterThan(0, + "policy must receive the proto-first chains list populated with discovered stubs"); + policy.CodeFirstCount.ShouldBeGreaterThan(0, + "policy must receive the code-first chains list populated with discovered contracts"); + policy.HandWrittenCount.ShouldBeGreaterThan(0, + "policy must receive the hand-written chains list populated with discovered service classes"); + } + finally + { + DynamicCodeBuilder.WithinCodegenCommand = false; + } + } +} + +// ── Integration: AddMiddleware with a custom filter predicate ────────────── + +/// +/// Pins the filter parameter of . +/// The default (no filter) attaches to every gRPC chain; a custom predicate must limit +/// attachment to only the chains that match. +/// +[Collection("GrpcSerialTests")] +public class add_middleware_custom_filter_tests +{ + [Fact] + public async Task always_false_filter_prevents_middleware_from_attaching_to_any_chain() + { + DynamicCodeBuilder.WithinCodegenCommand = true; + try + { + using var host = await Host.CreateDefaultBuilder() + .UseWolverine(opts => opts.ApplicationAssembly = typeof(GreeterMiddlewareTestStub).Assembly) + .ConfigureServices(services => + { + services.AddSingleton(new MiddlewareInvocationSink()); + services.AddWolverineGrpc(opts => + opts.AddMiddleware(filter: _ => false)); + }) + .StartAsync(); + + var graph = host.Services.GetRequiredService(); + var grpcOptions = host.Services.GetRequiredService(); + graph.DiscoverServices(grpcOptions); + + graph.Chains.ShouldAllBe(c => c.Middleware.Count == 0, + "always-false filter must not attach middleware to any proto-first chain"); + graph.CodeFirstChains.ShouldAllBe(c => c.Middleware.Count == 0, + "always-false filter must not attach middleware to any code-first chain"); + graph.HandWrittenChains.ShouldAllBe(c => c.Middleware.Count == 0, + "always-false filter must not attach middleware to any hand-written chain"); + } + finally + { + DynamicCodeBuilder.WithinCodegenCommand = false; + } + } + + [Fact] + public async Task proto_first_only_filter_does_not_attach_to_code_first_or_hand_written_chains() + { + DynamicCodeBuilder.WithinCodegenCommand = true; + try + { + using var host = await Host.CreateDefaultBuilder() + .UseWolverine(opts => opts.ApplicationAssembly = typeof(GreeterMiddlewareTestStub).Assembly) + .ConfigureServices(services => + { + services.AddSingleton(new MiddlewareInvocationSink()); + services.AddWolverineGrpc(opts => + opts.AddMiddleware( + filter: c => c is GrpcServiceChain)); + }) + .StartAsync(); + + var graph = host.Services.GetRequiredService(); + var grpcOptions = host.Services.GetRequiredService(); + graph.DiscoverServices(grpcOptions); + + graph.Chains.ShouldAllBe(c => c.Middleware.Count > 0, + "proto-first-only filter must attach to every proto-first chain"); + graph.CodeFirstChains.ShouldAllBe(c => c.Middleware.Count == 0, + "proto-first-only filter must not attach to code-first chains"); + graph.HandWrittenChains.ShouldAllBe(c => c.Middleware.Count == 0, + "proto-first-only filter must not attach to hand-written chains"); + } + finally + { + DynamicCodeBuilder.WithinCodegenCommand = false; + } + } +} + +// ── Support types ───────────────────────────────────────────────────────────── + +/// +/// Records which chain-type lists it received so integration tests can assert +/// that IGrpcChainPolicy.Apply was invoked with the right populations. +/// +public sealed class RecordingGrpcChainPolicy : IGrpcChainPolicy +{ + public bool WasCalled { get; private set; } + public int ProtoFirstCount { get; private set; } + public int CodeFirstCount { get; private set; } + public int HandWrittenCount { get; private set; } + + public void Apply( + IReadOnlyList protoFirstChains, + IReadOnlyList codeFirstChains, + IReadOnlyList handWrittenChains, + GenerationRules rules, + IServiceContainer container) + { + WasCalled = true; + ProtoFirstCount = protoFirstChains.Count; + CodeFirstCount = codeFirstChains.Count; + HandWrittenCount = handWrittenChains.Count; + } +} + +/// +/// Trivial no-op middleware used by to +/// verify that the custom filter predicate is respected. No injected parameters so no +/// DI container wiring is needed. +/// +public sealed class GrpcFilterScopeMiddleware +{ + public static void Before() { } +} diff --git a/src/Wolverine.Grpc.Tests/GrpcMiddlewareScoping/scope_discovery_tests.cs b/src/Wolverine.Grpc.Tests/GrpcMiddlewareScoping/scope_discovery_tests.cs index 1c830f689..4b20653a1 100644 --- a/src/Wolverine.Grpc.Tests/GrpcMiddlewareScoping/scope_discovery_tests.cs +++ b/src/Wolverine.Grpc.Tests/GrpcMiddlewareScoping/scope_discovery_tests.cs @@ -14,6 +14,7 @@ namespace Wolverine.Grpc.Tests.GrpcMiddlewareScoping; /// Phase-1 codegen will rely on — get this wrong and the eventual weaving will silently /// attach (or skip) the wrong methods. /// +[Collection("GrpcSerialTests")] public class scope_discovery_tests { [Fact] @@ -119,7 +120,8 @@ private static async Task DiscoverStubChainAsync() .StartAsync(); var graph = host.Services.GetRequiredService(); - graph.DiscoverServices(); + var grpcOptions = host.Services.GetRequiredService(); + graph.DiscoverServices(grpcOptions); return graph.Chains.Single(c => c.StubType == typeof(GreeterMiddlewareTestStub)); } diff --git a/src/Wolverine.Grpc.Tests/GrpcMiddlewareScoping/type_name_disambiguation_tests.cs b/src/Wolverine.Grpc.Tests/GrpcMiddlewareScoping/type_name_disambiguation_tests.cs index 841c8a401..3b34801ac 100644 --- a/src/Wolverine.Grpc.Tests/GrpcMiddlewareScoping/type_name_disambiguation_tests.cs +++ b/src/Wolverine.Grpc.Tests/GrpcMiddlewareScoping/type_name_disambiguation_tests.cs @@ -13,6 +13,7 @@ namespace Wolverine.Grpc.Tests.GrpcMiddlewareScoping; /// would otherwise both emit GreeterGrpcHandler into the same WolverineHandlers child /// namespace, and AttachTypesSynchronously would non-deterministically pick one. /// +[Collection("GrpcSerialTests")] public class type_name_disambiguation_tests { [Fact] diff --git a/src/Wolverine.Grpc.Tests/GrpcTestCollections.cs b/src/Wolverine.Grpc.Tests/GrpcTestCollections.cs new file mode 100644 index 000000000..3f1ed2318 --- /dev/null +++ b/src/Wolverine.Grpc.Tests/GrpcTestCollections.cs @@ -0,0 +1,17 @@ +using Xunit; + +namespace Wolverine.Grpc.Tests; + +/// +/// Tests that set DynamicCodeBuilder.WithinCodegenCommand = true (a static flag) must +/// not run in parallel with tests that start real Wolverine hosts (e.g., transport compliance +/// tests). When the flag is set during another test's host startup, +/// applyMetadataOnlyModeIfDetected() forces DurabilityMode.MediatorOnly, +/// breaking transport operations like SendAsync and RequireResponse. +/// Placing all such tests in this collection serialises them. +/// +[CollectionDefinition(Name)] +public class GrpcSerialTestsCollection : ICollectionFixture +{ + public const string Name = "GrpcSerialTests"; +} diff --git a/src/Wolverine.Grpc.Tests/GrpcTransportCompliance.cs b/src/Wolverine.Grpc.Tests/GrpcTransportCompliance.cs index 1d14f2532..113de0e07 100644 --- a/src/Wolverine.Grpc.Tests/GrpcTransportCompliance.cs +++ b/src/Wolverine.Grpc.Tests/GrpcTransportCompliance.cs @@ -30,4 +30,5 @@ await SenderIs(opts => public new Task DisposeAsync() => Task.CompletedTask; } +[Collection("GrpcSerialTests")] public class GrpcTransportCompliance : TransportCompliance; diff --git a/src/Wolverine.Grpc.Tests/GrpcValidateConvention/Protos/grpc_validate_test.proto b/src/Wolverine.Grpc.Tests/GrpcValidateConvention/Protos/grpc_validate_test.proto new file mode 100644 index 000000000..69421a304 --- /dev/null +++ b/src/Wolverine.Grpc.Tests/GrpcValidateConvention/Protos/grpc_validate_test.proto @@ -0,0 +1,19 @@ +syntax = "proto3"; + +option csharp_namespace = "Wolverine.Grpc.Tests.GrpcValidateConvention.Generated"; + +package wolverine.grpc.tests.validate_convention; + +// Test-only service exercising the Validate/ValidateAsync → Status? short-circuit +// convention on proto-first Wolverine gRPC stubs. +service ValidatorGreeterTest { + rpc Greet (ValidateGreetRequest) returns (ValidateGreetReply); +} + +message ValidateGreetRequest { + string name = 1; +} + +message ValidateGreetReply { + string message = 1; +} diff --git a/src/Wolverine.Grpc.Tests/GrpcValidateConvention/ValidateConventionFixture.cs b/src/Wolverine.Grpc.Tests/GrpcValidateConvention/ValidateConventionFixture.cs new file mode 100644 index 000000000..3d89b233b --- /dev/null +++ b/src/Wolverine.Grpc.Tests/GrpcValidateConvention/ValidateConventionFixture.cs @@ -0,0 +1,61 @@ +using Grpc.Net.Client; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.TestHost; +using Microsoft.Extensions.DependencyInjection; +using Wolverine.Grpc.Tests.GrpcMiddlewareScoping; +using Wolverine.Grpc.Tests.GrpcValidateConvention.Generated; +using Xunit; + +namespace Wolverine.Grpc.Tests.GrpcValidateConvention; + +/// +/// Boots an in-process gRPC host to exercise the Validate → Status? short-circuit +/// convention end-to-end. Isolated from other fixtures so validate-specific assertions +/// don't drift when the middleware-scoping tests evolve. +/// +public class ValidateConventionFixture : IAsyncLifetime +{ + private WebApplication? _app; + public GrpcChannel? Channel { get; private set; } + public MiddlewareInvocationSink Sink { get; } = new(); + + public async Task InitializeAsync() + { + var builder = WebApplication.CreateBuilder([]); + builder.WebHost.UseTestServer(); + + builder.Host.UseWolverine(opts => + { + opts.ApplicationAssembly = typeof(ValidateConventionFixture).Assembly; + }); + + builder.Services.AddSingleton(Sink); + builder.Services.AddGrpc(); + builder.Services.AddWolverineGrpc(); + + _app = builder.Build(); + _app.UseRouting(); + _app.MapWolverineGrpcServices(); + + await _app.StartAsync(); + + var handler = _app.GetTestServer().CreateHandler(); + Channel = GrpcChannel.ForAddress("http://localhost", new GrpcChannelOptions + { + HttpHandler = handler + }); + } + + public async Task DisposeAsync() + { + Channel?.Dispose(); + if (_app != null) + { + await _app.StopAsync(); + await _app.DisposeAsync(); + } + } + + public ValidatorGreeterTest.ValidatorGreeterTestClient CreateClient() + => new(Channel!); +} diff --git a/src/Wolverine.Grpc.Tests/GrpcValidateConvention/ValidateGreetHandler.cs b/src/Wolverine.Grpc.Tests/GrpcValidateConvention/ValidateGreetHandler.cs new file mode 100644 index 000000000..6c93e63bb --- /dev/null +++ b/src/Wolverine.Grpc.Tests/GrpcValidateConvention/ValidateGreetHandler.cs @@ -0,0 +1,15 @@ +using Wolverine.Grpc.Tests.GrpcMiddlewareScoping; +using Wolverine.Grpc.Tests.GrpcValidateConvention.Generated; + +namespace Wolverine.Grpc.Tests.GrpcValidateConvention; + +public static class ValidateGreetHandler +{ + public const string Marker = "ValidateGreetHandler.Handle"; + + public static ValidateGreetReply Handle(ValidateGreetRequest request, MiddlewareInvocationSink sink) + { + sink.Record(Marker); + return new ValidateGreetReply { Message = $"Hello, {request.Name}" }; + } +} diff --git a/src/Wolverine.Grpc.Tests/GrpcValidateConvention/ValidateGreeterStub.cs b/src/Wolverine.Grpc.Tests/GrpcValidateConvention/ValidateGreeterStub.cs new file mode 100644 index 000000000..a4531684b --- /dev/null +++ b/src/Wolverine.Grpc.Tests/GrpcValidateConvention/ValidateGreeterStub.cs @@ -0,0 +1,28 @@ +using Grpc.Core; +using Wolverine.Grpc.Tests.GrpcValidateConvention.Generated; + +namespace Wolverine.Grpc.Tests.GrpcValidateConvention; + +/// +/// Proto-first stub for the Validate convention tests. The static Validate method +/// exercises the Status? short-circuit path that M15 Phase 2 weaves into the +/// generated gRPC service wrapper. +/// +[WolverineGrpcService] +public abstract class ValidateGreeterStub : ValidatorGreeterTest.ValidatorGreeterTestBase +{ + /// + /// Returns a non-null to reject the call before the Wolverine + /// handler runs. Returning null lets execution continue normally. + /// + public static Status? Validate(ValidateGreetRequest request) + { + if (string.IsNullOrWhiteSpace(request.Name)) + return new Status(StatusCode.InvalidArgument, "Name is required"); + + if (request.Name.StartsWith("forbidden:", StringComparison.OrdinalIgnoreCase)) + return new Status(StatusCode.PermissionDenied, "Name prefix is not allowed"); + + return null; + } +} diff --git a/src/Wolverine.Grpc.Tests/GrpcValidateConvention/grpc_validate_convention_tests.cs b/src/Wolverine.Grpc.Tests/GrpcValidateConvention/grpc_validate_convention_tests.cs new file mode 100644 index 000000000..854de5342 --- /dev/null +++ b/src/Wolverine.Grpc.Tests/GrpcValidateConvention/grpc_validate_convention_tests.cs @@ -0,0 +1,82 @@ +using Grpc.Core; +using Shouldly; +using Wolverine.Grpc.Tests.GrpcValidateConvention.Generated; +using Xunit; + +namespace Wolverine.Grpc.Tests.GrpcValidateConvention; + +/// +/// End-to-end tests for the proto-first Validate → Status? short-circuit +/// convention. Verifies that a static Validate method returning a non-null +/// throws before the Wolverine +/// handler runs, and that null-returning validate passes through to the handler. +/// +public class grpc_validate_convention_tests : IClassFixture +{ + private readonly ValidateConventionFixture _fixture; + + public grpc_validate_convention_tests(ValidateConventionFixture fixture) + { + _fixture = fixture; + _fixture.Sink.Clear(); + } + + [Fact] + public async Task valid_request_passes_through_to_handler() + { + var reply = await _fixture.CreateClient().GreetAsync(new ValidateGreetRequest { Name = "Erik" }); + + reply.Message.ShouldBe("Hello, Erik"); + _fixture.Sink.Contains(ValidateGreetHandler.Marker).ShouldBeTrue( + "handler must run when Validate returns null"); + } + + [Fact] + public async Task empty_name_is_short_circuited_with_invalid_argument() + { + var ex = await Should.ThrowAsync( + async () => await _fixture.CreateClient().GreetAsync(new ValidateGreetRequest { Name = "" })); + + ex.StatusCode.ShouldBe(StatusCode.InvalidArgument); + ex.Status.Detail.ShouldBe("Name is required"); + } + + [Fact] + public async Task blank_name_is_short_circuited_with_invalid_argument() + { + var ex = await Should.ThrowAsync( + async () => await _fixture.CreateClient().GreetAsync(new ValidateGreetRequest { Name = " " })); + + ex.StatusCode.ShouldBe(StatusCode.InvalidArgument); + } + + [Fact] + public async Task forbidden_prefix_is_short_circuited_with_permission_denied() + { + var ex = await Should.ThrowAsync( + async () => await _fixture.CreateClient().GreetAsync(new ValidateGreetRequest { Name = "forbidden:bob" })); + + ex.StatusCode.ShouldBe(StatusCode.PermissionDenied); + ex.Status.Detail.ShouldBe("Name prefix is not allowed"); + } + + [Fact] + public async Task handler_does_not_run_when_validate_rejects() + { + await Should.ThrowAsync( + async () => await _fixture.CreateClient().GreetAsync(new ValidateGreetRequest { Name = "" })); + + _fixture.Sink.Contains(ValidateGreetHandler.Marker).ShouldBeFalse( + "handler must NOT run when Validate returns a non-null Status"); + } + + [Fact] + public async Task validate_returning_null_does_not_pollute_sink_from_handler_invocation() + { + _fixture.Sink.Clear(); + await _fixture.CreateClient().GreetAsync(new ValidateGreetRequest { Name = "Alice" }); + + _fixture.Sink.CountOf(ValidateGreetHandler.Marker).ShouldBe(1, + "handler runs exactly once per valid call"); + } +} diff --git a/src/Wolverine.Grpc.Tests/HandWrittenChain/HandWrittenChainFixture.cs b/src/Wolverine.Grpc.Tests/HandWrittenChain/HandWrittenChainFixture.cs new file mode 100644 index 000000000..028a35180 --- /dev/null +++ b/src/Wolverine.Grpc.Tests/HandWrittenChain/HandWrittenChainFixture.cs @@ -0,0 +1,66 @@ +using Grpc.Net.Client; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.TestHost; +using Microsoft.Extensions.DependencyInjection; +using ProtoBuf.Grpc.Client; +using ProtoBuf.Grpc.Server; +using Wolverine.Grpc.Tests.GrpcMiddlewareScoping; +using Xunit; + +namespace Wolverine.Grpc.Tests.HandWrittenChain; + +/// +/// Boots an in-process ASP.NET Core + Wolverine host to exercise the hand-written-chain +/// codegen path end-to-end. is discovered via +/// the GrpcService suffix convention; Wolverine generates a delegation wrapper and +/// maps it via MapWolverineGrpcServices(). +/// +public class HandWrittenChainFixture : IAsyncLifetime +{ + private WebApplication? _app; + public GrpcChannel? Channel { get; private set; } + public MiddlewareInvocationSink Sink { get; } = new(); + + public IServiceProvider Services => _app?.Services + ?? throw new InvalidOperationException("Fixture has not been initialized yet."); + + public async Task InitializeAsync() + { + var builder = WebApplication.CreateBuilder([]); + builder.WebHost.UseTestServer(); + + builder.Host.UseWolverine(opts => + { + opts.ApplicationAssembly = typeof(HandWrittenChainFixture).Assembly; + }); + + builder.Services.AddSingleton(Sink); + builder.Services.AddCodeFirstGrpc(); + builder.Services.AddWolverineGrpc(); + + _app = builder.Build(); + _app.UseRouting(); + _app.MapWolverineGrpcServices(); + + await _app.StartAsync(); + + var handler = _app.GetTestServer().CreateHandler(); + Channel = GrpcChannel.ForAddress("http://localhost", new GrpcChannelOptions + { + HttpHandler = handler + }); + } + + public async Task DisposeAsync() + { + Channel?.Dispose(); + if (_app != null) + { + await _app.StopAsync(); + await _app.DisposeAsync(); + } + } + + public TService CreateClient() where TService : class + => Channel!.CreateGrpcService(); +} diff --git a/src/Wolverine.Grpc.Tests/HandWrittenChain/HandWrittenTestGrpcService.cs b/src/Wolverine.Grpc.Tests/HandWrittenChain/HandWrittenTestGrpcService.cs new file mode 100644 index 000000000..56a79e843 --- /dev/null +++ b/src/Wolverine.Grpc.Tests/HandWrittenChain/HandWrittenTestGrpcService.cs @@ -0,0 +1,52 @@ +using Grpc.Core; +using ProtoBuf.Grpc; +using Wolverine.Grpc.Tests.GrpcMiddlewareScoping; + +namespace Wolverine.Grpc.Tests.HandWrittenChain; + +/// +/// Hand-written code-first gRPC service under test. The GrpcService suffix +/// triggers Wolverine's hand-written-chain discovery; no [WolverineGrpcService] +/// attribute is needed. +/// +/// The Validate method exercises the Status? short-circuit hook that +/// Wolverine weaves into the generated delegation wrapper. +/// +public class HandWrittenTestGrpcService : IHandWrittenTestService +{ + public const string ValidateMarker = "HandWrittenTest.Validate"; + public const string BeforeMarker = "HandWrittenTest.Before"; + + private readonly MiddlewareInvocationSink _sink; + + public HandWrittenTestGrpcService(MiddlewareInvocationSink sink) + { + _sink = sink; + } + + // Validate is the short-circuit hook: returning non-null aborts the call. + public static Status? Validate(HandWrittenTestRequest request) + { + if (string.IsNullOrWhiteSpace(request.Text)) + return new Status(StatusCode.InvalidArgument, "Text is required"); + return null; + } + + public Task Echo(HandWrittenTestRequest request, CallContext context = default) + => Task.FromResult(new HandWrittenTestReply { Echo = request.Text }); + + public async IAsyncEnumerable EchoStream(HandWrittenTestStreamRequest request, CallContext context = default) + { + for (var i = 0; i < request.Count; i++) + { + await Task.Yield(); + yield return new HandWrittenTestReply { Echo = $"{request.Text}:{i}" }; + } + } + + public async IAsyncEnumerable EchoBidi(IAsyncEnumerable requests, CallContext context = default) + { + await foreach (var req in requests) + yield return new HandWrittenTestReply { Echo = req.Text }; + } +} diff --git a/src/Wolverine.Grpc.Tests/HandWrittenChain/IHandWrittenTestService.cs b/src/Wolverine.Grpc.Tests/HandWrittenChain/IHandWrittenTestService.cs new file mode 100644 index 000000000..7ebfa9e58 --- /dev/null +++ b/src/Wolverine.Grpc.Tests/HandWrittenChain/IHandWrittenTestService.cs @@ -0,0 +1,40 @@ +using ProtoBuf; +using ProtoBuf.Grpc; +using System.ServiceModel; + +namespace Wolverine.Grpc.Tests.HandWrittenChain; + +/// +/// Test-only code-first gRPC contract for hand-written chain tests. +/// Deliberately does NOT carry [WolverineGrpcService] — the concrete class +/// () is what Wolverine discovers as a hand-written +/// service (via the GrpcService suffix convention). +/// +[ServiceContract] +public interface IHandWrittenTestService +{ + Task Echo(HandWrittenTestRequest request, CallContext context = default); + + IAsyncEnumerable EchoStream(HandWrittenTestStreamRequest request, CallContext context = default); + + IAsyncEnumerable EchoBidi(IAsyncEnumerable requests, CallContext context = default); +} + +[ProtoContract] +public class HandWrittenTestRequest +{ + [ProtoMember(1)] public string Text { get; set; } = string.Empty; +} + +[ProtoContract] +public class HandWrittenTestStreamRequest +{ + [ProtoMember(1)] public string Text { get; set; } = string.Empty; + [ProtoMember(2)] public int Count { get; set; } +} + +[ProtoContract] +public class HandWrittenTestReply +{ + [ProtoMember(1)] public string Echo { get; set; } = string.Empty; +} diff --git a/src/Wolverine.Grpc.Tests/HandWrittenChain/hand_written_grpc_chain_tests.cs b/src/Wolverine.Grpc.Tests/HandWrittenChain/hand_written_grpc_chain_tests.cs new file mode 100644 index 000000000..a27e31816 --- /dev/null +++ b/src/Wolverine.Grpc.Tests/HandWrittenChain/hand_written_grpc_chain_tests.cs @@ -0,0 +1,218 @@ +using Grpc.Core; +using Microsoft.Extensions.DependencyInjection; +using Shouldly; +using System.ServiceModel; +using Wolverine.Attributes; +using Xunit; + +namespace Wolverine.Grpc.Tests.HandWrittenChain; + +[Collection("grpc-hand-written-chain")] +public class hand_written_chain_integration_tests : IClassFixture +{ + private readonly HandWrittenChainFixture _fixture; + + public hand_written_chain_integration_tests(HandWrittenChainFixture fixture) + { + _fixture = fixture; + } + + private static async IAsyncEnumerable ToAsyncEnumerable(IEnumerable items) + { + foreach (var item in items) + yield return item; + await Task.CompletedTask; + } + + // --- Codegen shape --- + + [Fact] + public void generated_wrapper_type_follows_grpc_handler_naming_convention() + { + var graph = _fixture.Services.GetRequiredService(); + var chain = graph.HandWrittenChains.Single(c => c.ServiceClassType == typeof(HandWrittenTestGrpcService)); + + chain.TypeName.ShouldBe("HandWrittenTestGrpcHandler"); + chain.GeneratedType.ShouldNotBeNull(); + chain.GeneratedType!.Name.ShouldBe("HandWrittenTestGrpcHandler"); + } + + [Fact] + public void generated_wrapper_implements_the_service_contract_interface() + { + var graph = _fixture.Services.GetRequiredService(); + var chain = graph.HandWrittenChains.Single(c => c.ServiceClassType == typeof(HandWrittenTestGrpcService)); + + chain.GeneratedType.ShouldNotBeNull(); + chain.GeneratedType!.GetInterfaces().ShouldContain(typeof(IHandWrittenTestService)); + } + + [Fact] + public void all_three_rpc_shapes_are_classified_correctly() + { + var methods = HandWrittenGrpcServiceChain.DiscoverMethods(typeof(IHandWrittenTestService)) + .ToDictionary(m => m.Method.Name, m => m.Kind); + + methods[nameof(IHandWrittenTestService.Echo)].ShouldBe(HandWrittenMethodKind.Unary); + methods[nameof(IHandWrittenTestService.EchoStream)].ShouldBe(HandWrittenMethodKind.ServerStreaming); + methods[nameof(IHandWrittenTestService.EchoBidi)].ShouldBe(HandWrittenMethodKind.BidirectionalStreaming); + } + + // --- Unary delegation --- + + [Fact] + public async Task unary_call_delegates_to_inner_service_and_returns_response() + { + var client = _fixture.CreateClient(); + + var reply = await client.Echo(new HandWrittenTestRequest { Text = "hello" }); + + reply.Echo.ShouldBe("hello"); + } + + // --- Server-streaming delegation --- + + [Fact] + public async Task server_streaming_call_delegates_to_inner_service() + { + var client = _fixture.CreateClient(); + + var replies = new List(); + await foreach (var reply in client.EchoStream(new HandWrittenTestStreamRequest { Text = "item", Count = 3 })) + replies.Add(reply.Echo); + + replies.ShouldBe(["item:0", "item:1", "item:2"]); + } + + // --- Bidi streaming delegation --- + + [Fact] + public async Task bidi_streaming_call_delegates_to_inner_service() + { + var client = _fixture.CreateClient(); + + var requests = ToAsyncEnumerable([ + new HandWrittenTestRequest { Text = "a" }, + new HandWrittenTestRequest { Text = "b" }, + new HandWrittenTestRequest { Text = "c" } + ]); + var replies = new List(); + await foreach (var reply in client.EchoBidi(requests)) + replies.Add(reply.Echo); + + replies.ShouldBe(["a", "b", "c"]); + } + + // --- Validate short-circuit --- + + [Fact] + public async Task valid_request_passes_through_to_inner_service() + { + var client = _fixture.CreateClient(); + + var reply = await client.Echo(new HandWrittenTestRequest { Text = "valid" }); + + reply.Echo.ShouldBe("valid"); + } + + [Fact] + public async Task empty_text_is_rejected_by_validate_before_inner_service_runs() + { + var client = _fixture.CreateClient(); + + var ex = await Should.ThrowAsync( + async () => await client.Echo(new HandWrittenTestRequest { Text = "" })); + + ex.StatusCode.ShouldBe(StatusCode.InvalidArgument); + ex.Status.Detail.ShouldBe("Text is required"); + } + + [Fact] + public async Task validate_does_not_apply_to_bidi_streaming_method() + { + // Validate frames are not woven for bidi — the wrapper delegates without short-circuiting. + var client = _fixture.CreateClient(); + + var requests = ToAsyncEnumerable([new HandWrittenTestRequest { Text = "x" }]); + var replies = new List(); + await foreach (var reply in client.EchoBidi(requests)) + replies.Add(reply.Echo); + + replies.ShouldBe(["x"]); + } +} + +[Collection("grpc-hand-written-chain-unit")] +public class hand_written_chain_discovery_tests +{ + [Fact] + public void find_hand_written_service_classes_discovers_the_test_service() + { + var found = GrpcGraph.FindHandWrittenServiceClasses([typeof(HandWrittenTestGrpcService).Assembly]) + .ToList(); + + found.ShouldContain(typeof(HandWrittenTestGrpcService)); + } + + [Fact] + public void find_hand_written_service_classes_excludes_abstract_types() + { + var found = GrpcGraph.FindHandWrittenServiceClasses([typeof(HandWrittenTestGrpcService).Assembly]) + .ToList(); + + found.ShouldAllBe(t => !t.IsAbstract); + } + + [Fact] + public void resolve_type_name_strips_grpc_service_suffix() + { + HandWrittenGrpcServiceChain.ResolveTypeName(typeof(HandWrittenTestGrpcService)) + .ShouldBe("HandWrittenTestGrpcHandler"); + } + + [Fact] + public void resolve_type_name_appends_grpc_handler_when_no_suffix_present() + { + HandWrittenGrpcServiceChain.ResolveTypeName(typeof(NoSuffixServiceStub)) + .ShouldBe("NoSuffixServiceStubGrpcHandler"); + } + + [Fact] + public void find_service_contract_interface_returns_service_contract_annotated_interface() + { + var contract = HandWrittenGrpcServiceChain.FindServiceContractInterface(typeof(HandWrittenTestGrpcService)); + + contract.ShouldBe(typeof(IHandWrittenTestService)); + } + + [Fact] + public void find_hand_written_service_classes_excludes_classes_whose_interface_carries_wolverine_grpc_service() + { + // CodeFirstGrpcServiceChain owns contracts where the interface itself has [WolverineGrpcService]. + var found = GrpcGraph.FindHandWrittenServiceClasses([typeof(HandWrittenTestGrpcService).Assembly]) + .ToList(); + + found.ShouldNotContain(t => t == typeof(ShouldBeExcludedBecauseInterfaceIsAnnotated)); + } + + [Fact] + public void chain_scoping_is_grpc_so_middleware_routes_only_to_grpc_chains() + { + // MiddlewareScoping.Grpc is what prevents handler-bus middleware from leaking into gRPC + // chains and gRPC middleware from leaking into handler chains. + var chain = new HandWrittenGrpcServiceChain(typeof(HandWrittenTestGrpcService)); + chain.Scoping.ShouldBe(MiddlewareScoping.Grpc); + } +} + +// --- Helper stubs for unit tests only --- +// Plain types with no GrpcService suffix and no [WolverineGrpcService] are not picked up by the +// full discovery scan, so these do not interfere with the fixture's chain list. + +public class NoSuffixServiceStub { } + +[ServiceContract] +[WolverineGrpcService] +public interface IAnnotatedContract { } + +public class ShouldBeExcludedBecauseInterfaceIsAnnotated : IAnnotatedContract { } diff --git a/src/Wolverine.Grpc.Tests/Wolverine.Grpc.Tests.csproj b/src/Wolverine.Grpc.Tests/Wolverine.Grpc.Tests.csproj index baf788a15..2d4408ad1 100644 --- a/src/Wolverine.Grpc.Tests/Wolverine.Grpc.Tests.csproj +++ b/src/Wolverine.Grpc.Tests/Wolverine.Grpc.Tests.csproj @@ -62,7 +62,9 @@ scope behavior on a proto-first stub — it would just be noise as a sample. Folder name avoids shadowing the Wolverine.Attributes.MiddlewareScoping enum from sibling tests. --> + + diff --git a/src/Wolverine.Grpc.Tests/codegen_preview_grpc_tests.cs b/src/Wolverine.Grpc.Tests/codegen_preview_grpc_tests.cs index 933609104..5ca1fa957 100644 --- a/src/Wolverine.Grpc.Tests/codegen_preview_grpc_tests.cs +++ b/src/Wolverine.Grpc.Tests/codegen_preview_grpc_tests.cs @@ -17,6 +17,7 @@ namespace Wolverine.Grpc.Tests; /// ICodeFileCollection DI seam — only exists when Wolverine.Grpc is referenced, /// so end-to-end coverage lives here rather than in CoreTests. /// +[Collection(GrpcSerialTestsCollection.Name)] public class codegen_preview_grpc_tests { [Fact] @@ -43,7 +44,8 @@ public async Task codegen_preview_generates_code_for_grpc_service() // services.GetServices() reaches it — exactly the seam // PreviewGrpcCode walks. var graph = host.Services.GetRequiredService(); - graph.DiscoverServices(); + var grpcOptions = host.Services.GetRequiredService(); + graph.DiscoverServices(grpcOptions); graph.Chains.ShouldNotBeEmpty("the Greeter proto-first stub should be discovered"); var supplemental = host.Services.GetRequiredService(); @@ -113,7 +115,8 @@ public async Task codegen_preview_reports_no_match_for_unknown_grpc_input() .StartAsync(); var graph = host.Services.GetRequiredService(); - graph.DiscoverServices(); + var grpcOptions = host.Services.GetRequiredService(); + graph.DiscoverServices(grpcOptions); var supplemental = host.Services.GetRequiredService(); if (!supplemental.Collections.Contains(graph)) diff --git a/src/Wolverine.Grpc.Tests/grpc_middleware_scoping_tests.cs b/src/Wolverine.Grpc.Tests/grpc_middleware_scoping_tests.cs index 963b9c96a..5bac8c5e7 100644 --- a/src/Wolverine.Grpc.Tests/grpc_middleware_scoping_tests.cs +++ b/src/Wolverine.Grpc.Tests/grpc_middleware_scoping_tests.cs @@ -16,6 +16,7 @@ namespace Wolverine.Grpc.Tests; /// [WolverineBefore(MiddlewareScoping.MessageHandlers)] no longer inadvertently attaches /// (the behavior correction introduced alongside the new enum value). /// +[Collection(GrpcSerialTestsCollection.Name)] public class grpc_middleware_scoping_tests { [Fact] @@ -64,7 +65,8 @@ private static async Task DiscoverGreeterChainAsync() .StartAsync(); var graph = host.Services.GetRequiredService(); - graph.DiscoverServices(); + var grpcOptions = host.Services.GetRequiredService(); + graph.DiscoverServices(grpcOptions); return graph.Chains.ShouldHaveSingleItem(); } finally diff --git a/src/Wolverine.Grpc.Tests/user_exception_mapping_tests.cs b/src/Wolverine.Grpc.Tests/user_exception_mapping_tests.cs new file mode 100644 index 000000000..0ee347ae0 --- /dev/null +++ b/src/Wolverine.Grpc.Tests/user_exception_mapping_tests.cs @@ -0,0 +1,177 @@ +using Grpc.Core; +using Grpc.Net.Client; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.TestHost; +using Microsoft.Extensions.DependencyInjection; +using PingPongWithGrpc.Ponger; +using ProtoBuf.Grpc.Client; +using ProtoBuf.Grpc.Server; +using Shouldly; +using Xunit; + +namespace Wolverine.Grpc.Tests; + +// ── Unit tests for TryMapException resolution logic ────────────────────────── + +public class wolverine_grpc_options_exception_mapping_tests +{ + [Fact] + public void returns_null_when_no_mappings_registered() + { + var opts = new WolverineGrpcOptions(); + opts.TryMapException(new ArgumentException("x")).ShouldBeNull(); + } + + [Fact] + public void exact_type_match_returns_registered_code() + { + var opts = new WolverineGrpcOptions(); + opts.MapException(StatusCode.InvalidArgument); + + opts.TryMapException(new ArgumentException("x")).ShouldBe(StatusCode.InvalidArgument); + } + + [Fact] + public void base_type_mapping_matches_derived_exception() + { + var opts = new WolverineGrpcOptions(); + opts.MapException(StatusCode.Unknown); + + opts.TryMapException(new KeyNotFoundException("x")).ShouldBe(StatusCode.Unknown); + } + + [Fact] + public void more_derived_type_wins_over_base_type() + { + var opts = new WolverineGrpcOptions(); + opts.MapException(StatusCode.Unknown); + opts.MapException(StatusCode.InvalidArgument); + + opts.TryMapException(new ArgumentNullException("p")) + .ShouldBe(StatusCode.InvalidArgument, + "ArgumentNullException inherits ArgumentException — its mapping wins over Exception"); + } + + [Fact] + public void later_registration_wins_for_same_type() + { + var opts = new WolverineGrpcOptions(); + opts.MapException(StatusCode.Internal); + opts.MapException(StatusCode.InvalidArgument); + + opts.TryMapException(new ArgumentException("x")).ShouldBe(StatusCode.InvalidArgument); + } + + [Fact] + public void unregistered_exception_returns_null() + { + var opts = new WolverineGrpcOptions(); + opts.MapException(StatusCode.InvalidArgument); + + opts.TryMapException(new TimeoutException()).ShouldBeNull(); + } + + [Fact] + public void non_exception_type_throws_argument_exception() + { + var opts = new WolverineGrpcOptions(); + Should.Throw(() => opts.MapException(typeof(string), StatusCode.Internal)); + } + + [Fact] + public void map_exception_returns_self_for_fluent_chaining() + { + var opts = new WolverineGrpcOptions(); + opts.MapException(StatusCode.InvalidArgument) + .MapException(StatusCode.DeadlineExceeded) + .ShouldBeSameAs(opts); + } +} + +// ── Integration test: full interceptor pipeline respects user mapping ───────── + +public class user_exception_mapping_integration_tests : IAsyncLifetime +{ + private WebApplication? _app; + private GrpcChannel? _channel; + + public async Task InitializeAsync() + { + var builder = WebApplication.CreateBuilder([]); + builder.WebHost.UseTestServer(); + + builder.Host.UseWolverine(opts => + { + opts.ApplicationAssembly = typeof(PingGrpcService).Assembly; + opts.Discovery.IncludeAssembly(typeof(user_exception_mapping_integration_tests).Assembly); + }); + + builder.Services.AddCodeFirstGrpc(); + builder.Services.AddWolverineGrpc(opts => + { + // Override default: TimeoutException → ResourceExhausted instead of DeadlineExceeded + opts.MapException(StatusCode.ResourceExhausted); + // Custom domain exception type + opts.MapException(StatusCode.FailedPrecondition); + }); + + builder.Services.AddSingleton(); + + _app = builder.Build(); + _app.UseRouting(); + _app.MapGrpcService(); + await _app.StartAsync(); + + var handler = _app.GetTestServer().CreateHandler(); + _channel = GrpcChannel.ForAddress("http://localhost", new GrpcChannelOptions { HttpHandler = handler }); + } + + public async Task DisposeAsync() + { + _channel?.Dispose(); + if (_app != null) + { + await _app.StopAsync(); + await _app.DisposeAsync(); + } + } + + [Fact] + public async Task user_mapping_overrides_default_table_for_timeout() + { + var client = _channel!.CreateGrpcService(); + + var ex = await Should.ThrowAsync( + () => client.Throw(new FaultCodeFirstRequest { Kind = "timeout" })); + + ex.StatusCode.ShouldBe(StatusCode.ResourceExhausted, + "user registered TimeoutException → ResourceExhausted, which should win over default DeadlineExceeded"); + } + + [Fact] + public async Task user_mapping_handles_custom_domain_exception() + { + var client = _channel!.CreateGrpcService(); + + var ex = await Should.ThrowAsync( + () => client.Throw(new FaultCodeFirstRequest { Kind = "domain-validation" })); + + ex.StatusCode.ShouldBe(StatusCode.FailedPrecondition, + "DomainValidationException was registered as FailedPrecondition"); + } + + [Fact] + public async Task unmapped_exception_still_uses_default_table() + { + var client = _channel!.CreateGrpcService(); + + var ex = await Should.ThrowAsync( + () => client.Throw(new FaultCodeFirstRequest { Kind = "key" })); + + ex.StatusCode.ShouldBe(StatusCode.NotFound, + "KeyNotFoundException has no user mapping — falls through to default AIP-193 table"); + } +} + +/// Domain exception used by the user-mapping integration tests. +public sealed class DomainValidationException(string message) : Exception(message); diff --git a/src/Wolverine.Grpc/CodeFirstGrpcServiceChain.cs b/src/Wolverine.Grpc/CodeFirstGrpcServiceChain.cs new file mode 100644 index 000000000..734c50444 --- /dev/null +++ b/src/Wolverine.Grpc/CodeFirstGrpcServiceChain.cs @@ -0,0 +1,541 @@ +using System.Reflection; +using System.ServiceModel; +using Grpc.Core; +using JasperFx.CodeGeneration; +using JasperFx.CodeGeneration.Frames; +using JasperFx.CodeGeneration.Model; +using JasperFx.Core.Reflection; +using ProtoBuf.Grpc; +using Wolverine.Attributes; +using Wolverine.Configuration; +using Wolverine.Middleware; +using Wolverine.Persistence; + +namespace Wolverine.Grpc; + +/// +/// Represents a code-first gRPC service contract (a [ServiceContract] interface marked +/// with ) for which Wolverine will generate a concrete +/// implementing class at startup. Each method on the interface that matches a supported +/// protobuf-net.Grpc signature is forwarded to the Wolverine message bus: +/// +/// Unary Task<TResponse> Name(TRequest[, CallContext]) → +/// +/// Server-streaming IAsyncEnumerable<TResponse> Name(TRequest[, CallContext]) → +/// +/// +/// +public class CodeFirstGrpcServiceChain : Chain, + ICodeFile +{ + private static readonly PropertyInfo CallContextCancellationTokenProperty = + typeof(CallContext).GetProperty(nameof(CallContext.CancellationToken))!; + + private GeneratedType? _generatedType; + private Type? _generatedRuntimeType; + private IEnumerable? _applicationAssemblies; + + /// + /// The [ServiceContract] interface annotated with + /// that this chain was built from. + /// + public Type ServiceContractType { get; } + + /// + /// The C# identifier for the generated implementation type. + /// Derived from the interface name with the leading I stripped and GrpcHandler appended: + /// IPingServicePingServiceGrpcHandler. + /// + public string TypeName { get; } + + /// + /// The code-first RPC methods discovered on and classified + /// as Wolverine-supported shapes. + /// + public IReadOnlyList SupportedMethods { get; } + + /// + /// The runtime of the generated implementation once compiled. Null before compilation. + /// + public Type? GeneratedType => _generatedRuntimeType; + + internal string? SourceCode => _generatedType?.SourceCode; + + public CodeFirstGrpcServiceChain(Type serviceContractType) + { + ServiceContractType = serviceContractType ?? throw new ArgumentNullException(nameof(serviceContractType)); + SupportedMethods = DiscoverMethods(serviceContractType).ToArray(); + TypeName = ResolveTypeName(serviceContractType); + Description = $"Generated code-first gRPC service implementation for {serviceContractType.FullNameInCode()}"; + } + + // --- Middleware discovery (scans handler types for each RPC's request message) --- + // + // In Wolverine, static Before/Validate/After hook methods live on the handler class — the same + // class that owns the Handle/HandleAsync/Consume/ConsumeAsync method for that message. Code-first + // gRPC chains follow this idiom: for each RPC method, the chain scans the application assemblies + // for types that handle the request type and checks them for middleware hooks. + // + // Assembly scanning is used instead of HandlerGraph because AssembleTypes fires before + // HandlerGraph.Compile() runs (MapWolverineGrpcServices is called in the middleware pipeline, + // before WolverineRuntime.StartAsync). ApplicationAssemblies is set by GrpcGraph.DiscoverServices + // immediately after the chain is constructed. If it is null (unit-test contexts that construct + // the chain directly), HandlerTypesFor yields nothing and the before/after path is a no-op. + + private static readonly HashSet HandlerMethodNames = + new(StringComparer.Ordinal) { "Handle", "HandleAsync", "Consume", "ConsumeAsync" }; + + /// + /// The application assemblies to scan for handler types. Set by + /// after construction. Null in unit-test contexts that build the chain directly. + /// + internal IEnumerable? ApplicationAssemblies + { + get => _applicationAssemblies; + set => _applicationAssemblies = value; + } + + /// + /// Scans for classes that handle + /// (i.e. have a Handle/HandleAsync/Consume/ConsumeAsync method whose first parameter is + /// ). Yields nothing when assemblies have not been set. + /// + private IEnumerable HandlerTypesFor(Type requestType) + { + if (_applicationAssemblies == null) yield break; + foreach (var assembly in _applicationAssemblies) + { + foreach (var type in assembly.GetExportedTypes()) + { + if (!type.IsClass) continue; + if (type.GetMethods().Any(m => + HandlerMethodNames.Contains(m.Name) + && m.GetParameters().Length > 0 + && m.GetParameters()[0].ParameterType == requestType)) + yield return type; + } + } + } + + // --- Chain<> abstract member implementations --- + + public override string Description { get; } + public override MiddlewareScoping Scoping => MiddlewareScoping.Grpc; + public override IdempotencyStyle Idempotency { get; set; } = IdempotencyStyle.None; + public override Type? InputType() => null; + public override bool ShouldFlushOutgoingMessages() => false; + public override bool RequiresOutbox() => false; + public override MethodCall[] HandlerCalls() => []; + public override bool HasAttribute() => ServiceContractType.HasAttribute(); + public override void ApplyParameterMatching(MethodCall call) { } + + public override bool TryInferMessageIdentity(out PropertyInfo? property) + { + property = null; + return false; + } + + public override bool TryFindVariable(string valueName, ValueSource source, Type valueType, out Variable variable) + { + variable = default!; + return false; + } + + public override Frame[] AddStopConditionIfNull(Variable variable) => []; + public override void UseForResponse(MethodCall methodCall) { } + + // --- + + string ICodeFile.FileName => TypeName; + + void ICodeFile.AssembleTypes(GeneratedAssembly assembly) + { + if (_generatedType != null) return; + + assembly.ReferenceAssembly(ServiceContractType.Assembly); + assembly.ReferenceAssembly(typeof(IMessageBus).Assembly); + assembly.ReferenceAssembly(typeof(CallContext).Assembly); + + // assembly.AddType detects that ServiceContractType is an interface and calls Implements() — + // the generated class will declare: public sealed class PingServiceGrpcHandler : IPingService + _generatedType = assembly.AddType(TypeName, ServiceContractType); + + // IMessageBus is injected directly. No WolverineGrpcServiceBase needed for generated types + // since users only extend that to get Bus exposed on hand-written services. + var busField = new InjectedField(typeof(IMessageBus), "bus"); + _generatedType.AllInjectedFields.Add(busField); + + foreach (var rpc in SupportedMethods) + { + var generatedMethod = _generatedType.MethodFor(rpc.Method.Name); + + // Register the IVariableSource so any frame that needs CancellationToken gets it + // from context.CancellationToken rather than hardcoding the property access. The source + // makes frames composable: they declare a dependency on CancellationToken and the source + // resolves it from the CallContext argument, regardless of that argument's local name. + var contextArg = generatedMethod.Arguments + .FirstOrDefault(a => a.VariableType == typeof(CallContext)); + + if (contextArg != null) + generatedMethod.Sources.Add(new CallContextCancellationTokenSource(contextArg)); + + // Global middleware befores (from grpc.AddMiddleware()) — cloned per method + // because Frame instances hold per-method mutable state (Next pointer, variable bindings). + foreach (var frame in CloneFrames(Middleware)) + generatedMethod.Frames.Add(frame); + + // Per-method handler-type discovery: find the Wolverine handler class(es) for this + // RPC's request type and scan them for [WolverineBefore]/[WolverineAfter] hooks. + // This is the same idiom as HandlerChain — middleware lives on the handler class. + var rpcRequestType = rpc.Method.GetParameters()[0].ParameterType; + var handlerTypes = HandlerTypesFor(rpcRequestType).ToArray(); + + var befores = handlerTypes + .SelectMany(ht => MiddlewarePolicy.FilterMethods( + this, ht.GetMethods(), MiddlewarePolicy.BeforeMethodNames)) + .OrderBy(m => m.Name, StringComparer.Ordinal) + .ToArray(); + + var afters = handlerTypes + .SelectMany(ht => MiddlewarePolicy.FilterMethods( + this, ht.GetMethods(), MiddlewarePolicy.AfterMethodNames)) + .OrderBy(m => m.Name, StringComparer.Ordinal) + .ToArray(); + + foreach (var before in befores) + { + if (!IsBeforeApplicable(before, rpcRequestType)) continue; + + var call = new MethodCall(before.DeclaringType!, before); + generatedMethod.Frames.Add(call); + + var statusVar = call.Creates.FirstOrDefault(v => v.VariableType == typeof(Status?)); + if (statusVar != null) + generatedMethod.Frames.Add(new GrpcValidateShortCircuitFrame(statusVar)); + } + + // AsyncMode must account for both discovered afters and global postprocessors. + var hasAfters = (afters.Length > 0 || Postprocessors.Count > 0) + && rpc.Kind == CodeFirstMethodKind.Unary; + if (hasAfters) + generatedMethod.AsyncMode = AsyncMode.AsyncTask; + + switch (rpc.Kind) + { + case CodeFirstMethodKind.Unary: + generatedMethod.Frames.Add(new ForwardCodeFirstUnaryFrame(rpc.Method, busField)); + break; + + case CodeFirstMethodKind.ServerStreaming: + generatedMethod.Frames.Add(new ForwardCodeFirstServerStreamFrame(rpc.Method, busField)); + break; + } + + // Discovered afters (unary only — streaming owns its own lifecycle). + if (rpc.Kind == CodeFirstMethodKind.Unary) + { + foreach (var after in afters) + generatedMethod.Frames.Add(new MethodCall(after.DeclaringType!, after)); + } + + // Global middleware afters (from grpc.AddMiddleware()) — cloned per method. + foreach (var frame in CloneFrames(Postprocessors)) + generatedMethod.Frames.Add(frame); + } + } + + Task ICodeFile.AttachTypes(GenerationRules rules, Assembly assembly, IServiceProvider? services, + string containingNamespace) + { + var found = this.As().AttachTypesSynchronously(rules, assembly, services, containingNamespace); + return Task.FromResult(found); + } + + bool ICodeFile.AttachTypesSynchronously(GenerationRules rules, Assembly assembly, IServiceProvider? services, + string containingNamespace) + { + _generatedRuntimeType = assembly.ExportedTypes.FirstOrDefault(x => x.Name == TypeName) + ?? assembly.GetTypes().FirstOrDefault(x => x.Name == TypeName); + + return _generatedRuntimeType != null; + } + + /// + /// Reconstructs fresh instances from so that + /// the same middleware registration can be woven into each of the N generated RPC methods + /// independently. Frame instances carry per-method mutable state (Next pointer, variable + /// bindings) and cannot be shared across method bodies — each method needs its own clones. + /// Supports (non-static middleware) and + /// (static or instance method calls), which are the two types + /// produces. Unknown frame types are added as-is (best-effort). + /// + internal static IEnumerable CloneFrames(IEnumerable source) + { + foreach (var frame in source) + { + yield return frame switch + { + ConstructorFrame cf => new ConstructorFrame(cf.BuiltType, cf.Ctor) { Mode = cf.Mode }, + MethodCall mc => new MethodCall(mc.HandlerType, mc.Method), + _ => frame + }; + } + } + + /// + /// Returns true when can fire in the context of an RPC method + /// whose first parameter is . A before method is applicable when + /// all of its non- parameters are assignable from + /// . This prevents a Validate(OrderRequest) from being + /// woven into an InvoiceRequest RPC where no OrderRequest variable is in scope. + /// + private static bool IsBeforeApplicable(MethodInfo before, Type rpcRequestType) + { + foreach (var p in before.GetParameters()) + { + if (p.ParameterType == typeof(CallContext)) continue; + if (!p.ParameterType.IsAssignableFrom(rpcRequestType)) return false; + } + return true; + } + + /// + /// Derives the generated type name from the service contract interface. + /// Strips the leading I from the interface name (if followed by an uppercase letter) and + /// appends GrpcHandler: IPingServicePingServiceGrpcHandler. + /// + public static string ResolveTypeName(Type serviceContractType) + { + var name = serviceContractType.Name; + if (name.Length > 1 && name[0] == 'I' && char.IsUpper(name[1])) + { + name = name[1..]; + } + + return name + "GrpcHandler"; + } + + /// + /// Discovers and classifies the RPC methods on . + /// Methods whose signatures don't match a supported code-first protobuf-net.Grpc shape are skipped. + /// Results are sorted by method name so generated source is byte-stable across runs. + /// + public static IEnumerable DiscoverMethods(Type serviceContractType) + { + var results = new List(); + + foreach (var method in serviceContractType.GetMethods()) + { + var kind = ClassifyMethod(method); + if (kind == null) continue; + results.Add(new CodeFirstRpcMethod(method, kind.Value)); + } + + results.Sort(static (a, b) => string.CompareOrdinal(a.Method.Name, b.Method.Name)); + return results; + } + + /// + /// Classifies a single interface method against the supported protobuf-net.Grpc code-first shapes. + /// Returns null if the method doesn't match any recognised shape. + /// + /// Supported shapes (CallContext parameter is optional): + /// + /// Unary: Task<TResponse> Name(TRequest[, CallContext]) + /// Server-streaming: IAsyncEnumerable<TResponse> Name(TRequest[, CallContext]) + /// + /// + /// + private static CodeFirstMethodKind? ClassifyMethod(MethodInfo method) + { + var parameters = method.GetParameters(); + if (parameters.Length == 0 || parameters.Length > 2) return null; + + // The first parameter must be a concrete message type (the request DTO). + var requestType = parameters[0].ParameterType; + if (!requestType.IsClass || requestType.IsAbstract) return null; + + // An optional second parameter must be CallContext. + if (parameters.Length == 2 && parameters[1].ParameterType != typeof(CallContext)) return null; + + if (!method.ReturnType.IsGenericType) return null; + + var returnOpen = method.ReturnType.GetGenericTypeDefinition(); + + // Unary: Task + if (returnOpen == typeof(Task<>)) return CodeFirstMethodKind.Unary; + + // Server-streaming: IAsyncEnumerable + if (returnOpen == typeof(IAsyncEnumerable<>)) return CodeFirstMethodKind.ServerStreaming; + + return null; + } + + /// + /// Guards against applying to both an interface + /// (the code-first codegen marker) and a concrete class that implements it (the hand-written + /// service marker). Both usages are valid independently; a conflict only arises when both are + /// present in the same assembly, which would produce two service registrations for the same contract. + /// + public static void AssertNoConcreteImplementationConflicts(Type serviceContractType, + IEnumerable assemblies) + { + var offenders = assemblies + .SelectMany(a => a.GetExportedTypes()) + .Where(t => t.IsClass && !t.IsAbstract + && serviceContractType.IsAssignableFrom(t) + && t.IsDefined(typeof(WolverineGrpcServiceAttribute), inherit: false)) + .ToList(); + + if (offenders.Count == 0) return; + + var details = offenders.Select(t => $" - {t.FullNameInCode()}").Aggregate((a, b) => a + "\n" + b); + + throw new InvalidOperationException( + $"Code-first gRPC service contract {serviceContractType.FullNameInCode()} is marked " + + "[WolverineGrpcService] for Wolverine codegen, but one or more concrete implementations " + + "of this interface are also marked [WolverineGrpcService] in the same assembly. " + + "Remove [WolverineGrpcService] from the concrete class(es) and let Wolverine generate " + + "the implementation, or remove it from the interface to keep the hand-written class." + + "\nConflicting type(s):\n" + details); + } +} + +/// +/// Classifies a code-first gRPC method based on its protobuf-net.Grpc C# signature. +/// +public enum CodeFirstMethodKind +{ + /// + /// Unary: Task<TResponse> Name(TRequest[, CallContext]). + /// Forwarded via . + /// + Unary, + + /// + /// Server-streaming: IAsyncEnumerable<TResponse> Name(TRequest[, CallContext]). + /// Forwarded via . + /// + ServerStreaming +} + +/// +/// A single code-first RPC method paired with its Wolverine-recognised . +/// +/// Reflection handle to the interface method. +/// The RPC shape Wolverine classified this method as. +public readonly record struct CodeFirstRpcMethod(MethodInfo Method, CodeFirstMethodKind Kind); + +/// +/// JasperFx that resolves from the +/// property on the code-first method's context +/// argument. Registered per-method in : +/// +/// generatedMethod.Sources.Add(new CallContextCancellationTokenSource(contextArg)); +/// +/// Any frame that declares a dependency on (via +/// ) will receive a whose +/// resolves to context.CancellationToken — regardless of what +/// the local parameter is named. This makes frames composable: they express a need for a +/// without knowing whether they are running in a code-first or +/// proto-first context. +/// +internal sealed class CallContextCancellationTokenSource : IVariableSource +{ + private readonly Variable _callContextArg; + + public CallContextCancellationTokenSource(Variable callContextArg) + { + _callContextArg = callContextArg; + } + + public bool Matches(Type type) => type == typeof(CancellationToken); + + public Variable Create(Type type) + => new MemberAccessVariable(_callContextArg, + typeof(CallContext).GetProperty(nameof(CallContext.CancellationToken))!); +} + +/// +/// Emits return _bus.InvokeAsync<TResponse>(request, context.CancellationToken); +/// for a code-first unary interface method. The is sourced via +/// registered on the method — this frame never +/// hardcodes the property access itself. +/// +internal sealed class ForwardCodeFirstUnaryFrame : SyncFrame +{ + private readonly MethodInfo _rpc; + private readonly InjectedField _busField; + private Variable? _cancellationToken; + + public ForwardCodeFirstUnaryFrame(MethodInfo rpc, InjectedField busField) + { + _rpc = rpc; + _busField = busField; + } + + public override IEnumerable FindVariables(IMethodVariables chain) + { + _cancellationToken = chain.FindVariable(typeof(CancellationToken)); + yield return _cancellationToken; + } + + public override void GenerateCode(GeneratedMethod method, ISourceWriter writer) + { + var parameters = _rpc.GetParameters(); + var requestName = parameters[0].Name ?? "arg0"; + var responseType = _rpc.ReturnType.GetGenericArguments()[0]; + + var busInvoke = + $"{_busField.Usage}.{nameof(IMessageBus.InvokeAsync)}<{responseType.FullNameInCode()}>({requestName}, {_cancellationToken!.Usage})"; + + if (Next == null) + { + writer.Write($"return {busInvoke};"); + } + else + { + // After-frames are present — await so they can run before the response is returned. + writer.Write($"var result = await {busInvoke};"); + Next.GenerateCode(method, writer); + writer.Write("return result;"); + } + } +} + +/// +/// Emits return _bus.StreamAsync<TResponse>(request, context.CancellationToken); +/// for a code-first server-streaming interface method. Because +/// returns synchronously, no async modifier is needed and +/// this is a . The is resolved via +/// — same composable pattern as the unary frame. +/// +internal sealed class ForwardCodeFirstServerStreamFrame : SyncFrame +{ + private readonly MethodInfo _rpc; + private readonly InjectedField _busField; + private Variable? _cancellationToken; + + public ForwardCodeFirstServerStreamFrame(MethodInfo rpc, InjectedField busField) + { + _rpc = rpc; + _busField = busField; + } + + public override IEnumerable FindVariables(IMethodVariables chain) + { + _cancellationToken = chain.FindVariable(typeof(CancellationToken)); + yield return _cancellationToken; + } + + public override void GenerateCode(GeneratedMethod method, ISourceWriter writer) + { + var parameters = _rpc.GetParameters(); + var requestName = parameters[0].Name ?? "arg0"; + var responseType = _rpc.ReturnType.GetGenericArguments()[0]; + + writer.Write( + $"return {_busField.Usage}.{nameof(IMessageBus.StreamAsync)}<{responseType.FullNameInCode()}>({requestName}, {_cancellationToken!.Usage});"); + Next?.GenerateCode(method, writer); + } +} diff --git a/src/Wolverine.Grpc/GrpcGraph.cs b/src/Wolverine.Grpc/GrpcGraph.cs index 7cc12df02..a7b1f0bc9 100644 --- a/src/Wolverine.Grpc/GrpcGraph.cs +++ b/src/Wolverine.Grpc/GrpcGraph.cs @@ -1,4 +1,5 @@ using System.Reflection; +using System.ServiceModel; using JasperFx; using JasperFx.CodeGeneration; using JasperFx.Core; @@ -6,18 +7,21 @@ using JasperFx.Descriptors; using Microsoft.Extensions.Logging; using Wolverine.Configuration; +using Wolverine.Middleware; using Wolverine.Runtime; namespace Wolverine.Grpc; /// -/// Discovers proto-first Wolverine gRPC services, builds instances -/// for them, and plugs their generated wrapper types into the Wolverine code-generation pipeline. +/// Discovers proto-first and code-first Wolverine gRPC services, builds chain instances for them, +/// and plugs their generated wrapper types into the Wolverine code-generation pipeline. /// Mirrors the role of HandlerGraph / HttpGraph for their respective chain types. /// public class GrpcGraph : ICodeFileCollectionWithServices, IDescribeMyself { private readonly List _chains = []; + private readonly List _codeFirstChains = []; + private readonly List _handWrittenChains = []; private readonly WolverineOptions _options; public GrpcGraph(WolverineOptions options, IServiceContainer container) @@ -34,15 +38,24 @@ public GrpcGraph(WolverineOptions options, IServiceContainer container) public string ChildNamespace => "WolverineHandlers"; + /// Proto-first service chains (abstract stub → generated wrapper). public IReadOnlyList Chains => _chains; - public IReadOnlyList BuildFiles() => _chains; + /// Code-first service chains ([ServiceContract] interface → generated implementation). + public IReadOnlyList CodeFirstChains => _codeFirstChains; + + /// Hand-written service chains (concrete service class → generated delegation wrapper). + public IReadOnlyList HandWrittenChains => _handWrittenChains; + + public IReadOnlyList BuildFiles() => [.._chains, .._codeFirstChains, .._handWrittenChains]; /// - /// Scans the assemblies already registered with Wolverine and builds a - /// for every discovered proto-first stub. + /// Scans the assemblies already registered with Wolverine and builds chains for every + /// discovered proto-first stub and code-first service contract. Applies any middleware + /// types and implementations registered in + /// and in . /// - public void DiscoverServices() + public void DiscoverServices(WolverineGrpcOptions grpcOptions) { var logger = Container.GetInstance>(); @@ -60,6 +73,48 @@ public void DiscoverServices() } DisambiguateCollidingTypeNames(_chains); + + var contracts = FindCodeFirstServiceContracts(_options.Assemblies).ToArray(); + logger.LogInformation( + "Found {Count} code-first Wolverine gRPC service contracts in assemblies {Assemblies}", + contracts.Length, + _options.Assemblies.Select(x => x.GetName().Name!).Join(", ")); + + foreach (var contract in contracts) + { + CodeFirstGrpcServiceChain.AssertNoConcreteImplementationConflicts(contract, _options.Assemblies); + var codeFirstChain = new CodeFirstGrpcServiceChain(contract) + { + ApplicationAssemblies = _options.Assemblies + }; + _codeFirstChains.Add(codeFirstChain); + } + + var handWritten = FindHandWrittenServiceClasses(_options.Assemblies).ToArray(); + logger.LogInformation( + "Found {Count} hand-written Wolverine gRPC service classes in assemblies {Assemblies}", + handWritten.Length, + _options.Assemblies.Select(x => x.GetName().Name!).Join(", ")); + + foreach (var serviceClass in handWritten) + { + _handWrittenChains.Add(new HandWrittenGrpcServiceChain(serviceClass)); + } + + // Apply policy-registered middleware and IChainPolicy implementations. + var chainableChains = (IReadOnlyList)[.._chains, .._codeFirstChains, .._handWrittenChains]; + + grpcOptions.Middleware.Apply(chainableChains, Rules, Container); + + foreach (var policy in _options.Policies.OfType()) + { + policy.Apply(chainableChains, Rules, Container); + } + + foreach (var policy in grpcOptions.Policies) + { + policy.Apply(_chains, _codeFirstChains, _handWrittenChains, Rules, Container); + } } /// @@ -162,11 +217,72 @@ private static bool IsProtoFirstStub(Type type) return GrpcServiceChain.FindProtoServiceBase(type) != null; } + /// + /// A code-first service contract is an interface annotated with both + /// (protobuf-net.Grpc) and + /// . Wolverine generates a concrete implementation + /// at startup that forwards each method to the message bus. + /// + public static IEnumerable FindCodeFirstServiceContracts(IEnumerable assemblies) + { + return assemblies + .SelectMany(a => a.GetExportedTypes()) + .Where(IsCodeFirstServiceContract); + } + + private static bool IsCodeFirstServiceContract(Type type) + { + if (!type.IsInterface) return false; + if (type.IsGenericTypeDefinition) return false; + if (!type.IsDefined(typeof(WolverineGrpcServiceAttribute), inherit: false)) return false; + + return type.IsDefined(typeof(ServiceContractAttribute), inherit: false); + } + + /// + /// A hand-written service class is a concrete, non-abstract type that matches the code-first + /// discovery predicate (name ends in GrpcService or carries + /// ) AND implements at least one + /// [ServiceContract] interface. Classes whose service contract interface is itself + /// annotated with are excluded — those are handled + /// by the generated-implementation path instead. + /// + public static IEnumerable FindHandWrittenServiceClasses(IEnumerable assemblies) + { + return assemblies + .SelectMany(a => a.GetExportedTypes()) + .Where(IsHandWrittenServiceClass); + } + + private static bool IsHandWrittenServiceClass(Type type) + { + if (!type.IsClass || type.IsAbstract) return false; + if (type.IsGenericTypeDefinition) return false; + + // Must match the code-first discovery predicate. + if (!type.Name.EndsWith("GrpcService", StringComparison.Ordinal) + && !type.IsDefined(typeof(WolverineGrpcServiceAttribute), inherit: false)) + return false; + + // Must implement a [ServiceContract] interface. + var contract = HandWrittenGrpcServiceChain.FindServiceContractInterface(type); + if (contract == null) return false; + + // If the contract interface itself carries [WolverineGrpcService], the generated-implementation + // path owns this contract — don't also create a hand-written chain for the concrete class. + if (contract.IsDefined(typeof(WolverineGrpcServiceAttribute), inherit: false)) return false; + + // Proto-first stubs (abstract classes inheriting a proto base) are handled separately. + // Concrete classes with a proto base are caught by AssertNoConcreteProtoStubs. + return true; + } + public OptionsDescription ToDescription() { var description = new OptionsDescription(this); - var list = description.AddChildSet("Services"); - list.SummaryColumns = ["StubType", "ProtoServiceBase", "UnaryMethodCount"]; + + var protoList = description.AddChildSet("Proto-First Services"); + protoList.SummaryColumns = ["StubType", "ProtoServiceBase", "UnaryMethodCount"]; foreach (var chain in _chains) { @@ -174,7 +290,32 @@ public OptionsDescription ToDescription() row.AddValue("StubType", chain.StubType.FullNameInCode()); row.AddValue("ProtoServiceBase", chain.ProtoServiceBase.FullNameInCode()); row.AddValue("UnaryMethodCount", chain.UnaryMethods.Count); - list.Rows.Add(row); + protoList.Rows.Add(row); + } + + var codeFirstList = description.AddChildSet("Code-First Services"); + codeFirstList.SummaryColumns = ["ContractType", "GeneratedTypeName", "MethodCount"]; + + foreach (var chain in _codeFirstChains) + { + var row = new OptionsDescription(chain); + row.AddValue("ContractType", chain.ServiceContractType.FullNameInCode()); + row.AddValue("GeneratedTypeName", chain.TypeName); + row.AddValue("MethodCount", chain.SupportedMethods.Count); + codeFirstList.Rows.Add(row); + } + + var handWrittenList = description.AddChildSet("Hand-Written Services"); + handWrittenList.SummaryColumns = ["ServiceClass", "ContractType", "WrapperTypeName", "MethodCount"]; + + foreach (var chain in _handWrittenChains) + { + var row = new OptionsDescription(chain); + row.AddValue("ServiceClass", chain.ServiceClassType.FullNameInCode()); + row.AddValue("ContractType", chain.ServiceContractType.FullNameInCode()); + row.AddValue("WrapperTypeName", chain.TypeName); + row.AddValue("MethodCount", chain.SupportedMethods.Count); + handWrittenList.Rows.Add(row); } return description; diff --git a/src/Wolverine.Grpc/GrpcServiceChain.cs b/src/Wolverine.Grpc/GrpcServiceChain.cs index 21bb89a8b..8e8656acb 100644 --- a/src/Wolverine.Grpc/GrpcServiceChain.cs +++ b/src/Wolverine.Grpc/GrpcServiceChain.cs @@ -1,4 +1,3 @@ -using System.Diagnostics; using System.Reflection; using Grpc.Core; using JasperFx; @@ -73,6 +72,10 @@ public GrpcServiceChain(Type stubType, GrpcGraph parent) .Where(m => m.Kind == GrpcMethodKind.ServerStreaming) .Select(m => m.Method) .ToArray(); + BidirectionalStreamingMethods = SupportedMethods + .Where(m => m.Kind == GrpcMethodKind.BidirectionalStreaming) + .Select(m => m.Method) + .ToArray(); ProtoServiceName = ResolveProtoServiceName(ProtoServiceBase); TypeName = ProtoServiceName + "GrpcHandler"; @@ -90,6 +93,14 @@ public GrpcServiceChain(Type stubType, GrpcGraph parent) /// public IReadOnlyList ServerStreamingMethods { get; } + /// + /// Bidirectional-streaming RPC methods + /// (Task Name(IAsyncStreamReader<TRequest>, IServerStreamWriter<TResponse>, ServerCallContext)) + /// that loop each inbound item through and + /// forward each yielded response to the stream writer. + /// + public IReadOnlyList BidirectionalStreamingMethods { get; } + /// /// The proto service name (e.g., Greeter) — used to derive the generated type's name /// as {ProtoServiceName}GrpcHandler. This is the stable identity of the service in @@ -202,13 +213,39 @@ void ICodeFile.AssembleTypes(GeneratedAssembly assembly) var busField = new InjectedField(typeof(IMessageBus), "bus"); _generatedType.AllInjectedFields.Add(busField); + var befores = DiscoveredBefores; + var afters = DiscoveredAfters; + foreach (var rpc in SupportedMethods) { var generatedMethod = _generatedType.MethodFor(rpc.Method.Name); + // Before-frames (including Validate short-circuit) require a concrete TRequest + // in scope when the method begins. Bidi methods start with an IAsyncStreamReader + // rather than a single T — per-call middleware is not woven for bidi methods. + if (rpc.Kind != GrpcMethodKind.BidirectionalStreaming) + { + // Registered middleware befores (from grpc.AddMiddleware()) — cloned per method. + foreach (var frame in CodeFirstGrpcServiceChain.CloneFrames(Middleware)) + generatedMethod.Frames.Add(frame); + + // Inline before-hooks declared directly on the stub class. + foreach (var before in befores) + { + var call = new MethodCall(StubType, before); + generatedMethod.Frames.Add(call); + + var statusVar = call.Creates.FirstOrDefault(v => v.VariableType == typeof(Status?)); + if (statusVar != null) + generatedMethod.Frames.Add(new GrpcValidateShortCircuitFrame(statusVar)); + } + } + switch (rpc.Kind) { case GrpcMethodKind.Unary: + if (afters.Count > 0 || Postprocessors.Count > 0) + generatedMethod.AsyncMode = AsyncMode.AsyncTask; generatedMethod.Frames.Add(new ForwardUnaryToMessageBusFrame(rpc.Method, busField)); break; @@ -216,6 +253,22 @@ void ICodeFile.AssembleTypes(GeneratedAssembly assembly) generatedMethod.AsyncMode = AsyncMode.AsyncTask; generatedMethod.Frames.Add(new ForwardServerStreamToMessageBusFrame(rpc.Method, busField)); break; + + case GrpcMethodKind.BidirectionalStreaming: + generatedMethod.AsyncMode = AsyncMode.AsyncTask; + generatedMethod.Frames.Add(new ForwardBidiStreamToMessageBusFrame(rpc.Method, busField)); + break; + } + + if (rpc.Kind != GrpcMethodKind.BidirectionalStreaming) + { + // Inline after-hooks declared directly on the stub class. + foreach (var after in afters) + generatedMethod.Frames.Add(new MethodCall(StubType, after)); + + // Registered middleware afters (from grpc.AddMiddleware()) — cloned per method. + foreach (var frame in CodeFirstGrpcServiceChain.CloneFrames(Postprocessors)) + generatedMethod.Frames.Add(frame); } } } @@ -230,8 +283,6 @@ Task ICodeFile.AttachTypes(GenerationRules rules, Assembly assembly, IServ bool ICodeFile.AttachTypesSynchronously(GenerationRules rules, Assembly assembly, IServiceProvider? services, string containingNamespace) { - Debug.WriteLine(_generatedType?.SourceCode); - _generatedRuntimeType = assembly.ExportedTypes.FirstOrDefault(x => x.Name == TypeName) ?? assembly.GetTypes().FirstOrDefault(x => x.Name == TypeName); @@ -372,7 +423,7 @@ private static bool IsGenericOf(Type t, Type openGeneric) private static void AssertNoUnsupportedStreamingKinds(Type stubType, IReadOnlyList methods) { var unsupported = methods - .Where(m => m.Kind is GrpcMethodKind.ClientStreaming or GrpcMethodKind.BidirectionalStreaming) + .Where(m => m.Kind is GrpcMethodKind.ClientStreaming) .ToList(); if (unsupported.Count == 0) return; @@ -383,9 +434,8 @@ private static void AssertNoUnsupportedStreamingKinds(Type stubType, IReadOnlyLi throw new NotSupportedException( $"Proto-first gRPC stub {stubType.FullNameInCode()} declares RPC method(s) whose shape " - + "Wolverine cannot yet code-generate. Supported today: unary and server-streaming. " - + "Client-streaming and bidirectional-streaming need a request-side IAsyncEnumerable overload on IMessageBus " - + "that has not been introduced yet." + + "Wolverine cannot yet code-generate. Supported today: unary, server-streaming, and bidirectional-streaming. " + + "Client-streaming (stream TRequest → TResponse) has no adapter path yet." + Environment.NewLine + "Unsupported method(s):" + Environment.NewLine @@ -423,7 +473,8 @@ public enum GrpcMethodKind /// /// Bidirectional-streaming RPC: Task Name(IAsyncStreamReader<TRequest>, IServerStreamWriter<TResponse>, ServerCallContext). - /// Not yet code-generated by Wolverine — detection fails fast at startup rather than silently skipping. + /// Each inbound item is forwarded to ; every yielded + /// response is written to the . /// BidirectionalStreaming } @@ -456,11 +507,21 @@ public override void GenerateCode(GeneratedMethod method, ISourceWriter writer) var requestName = ParameterName(parameters, 0); var contextName = ParameterName(parameters, 1); var responseType = _rpc.ReturnType.GetGenericArguments()[0]; + var cancellation = $"{contextName}.{nameof(ServerCallContext.CancellationToken)}"; + var busInvoke = + $"{_busField.Usage}.{nameof(IMessageBus.InvokeAsync)}<{responseType.FullNameInCode()}>({requestName}, {cancellation})"; - writer.Write( - $"return {_busField.Usage}.{nameof(IMessageBus.InvokeAsync)}<{responseType.FullNameInCode()}>({requestName}, {contextName}.{nameof(ServerCallContext.CancellationToken)});"); - - Next?.GenerateCode(method, writer); + if (Next == null) + { + writer.Write($"return {busInvoke};"); + } + else + { + // After-frames are present — await so they can run before the return. + writer.Write($"var result = await {busInvoke};"); + Next.GenerateCode(method, writer); + writer.Write("return result;"); + } } // Grpc.Tools always emits parameter names, but reflection over optimized assemblies can return null. @@ -504,3 +565,96 @@ public override void GenerateCode(GeneratedMethod method, ISourceWriter writer) Next?.GenerateCode(method, writer); } } + +/// +/// Emits a nested while/await foreach loop that bridges a gRPC bidirectional-streaming +/// RPC to Wolverine's . For each item the client +/// sends, the generated wrapper calls and pumps +/// every yielded response to the . +/// +/// +/// Generated code shape: +/// +/// while (await requestStream.MoveNext(context.CancellationToken)) +/// { +/// var request = requestStream.Current; +/// await foreach (var item in _bus.StreamAsync<TResponse>(request, context.CancellationToken)) +/// { +/// await responseStream.WriteAsync(item, context.CancellationToken); +/// } +/// } +/// +/// Before-frames (including the Validate short-circuit) are not woven into bidirectional methods: +/// they require a concrete TRequest in scope before the loop begins, which is not +/// available in the bidi signature. +/// +internal sealed class ForwardBidiStreamToMessageBusFrame : AsyncFrame +{ + private readonly MethodInfo _rpc; + private readonly InjectedField _busField; + + public ForwardBidiStreamToMessageBusFrame(MethodInfo rpc, InjectedField busField) + { + _rpc = rpc; + _busField = busField; + } + + public override void GenerateCode(GeneratedMethod method, ISourceWriter writer) + { + var parameters = _rpc.GetParameters(); + var readerName = ForwardUnaryToMessageBusFrame.ParameterName(parameters, 0); + var writerName = ForwardUnaryToMessageBusFrame.ParameterName(parameters, 1); + var contextName = ForwardUnaryToMessageBusFrame.ParameterName(parameters, 2); + + var responseType = parameters[1].ParameterType.GetGenericArguments()[0]; + var cancellation = $"{contextName}.{nameof(ServerCallContext.CancellationToken)}"; + + writer.Write( + $"BLOCK:while (await {readerName}.{nameof(IAsyncStreamReader.MoveNext)}({cancellation}))"); + writer.Write($"var request = {readerName}.{nameof(IAsyncStreamReader.Current)};"); + writer.Write( + $"BLOCK:await foreach (var item in {_busField.Usage}.{nameof(IMessageBus.StreamAsync)}<{responseType.FullNameInCode()}>(request, {cancellation}))"); + writer.Write($"await {writerName}.{nameof(IServerStreamWriter.WriteAsync)}(item, {cancellation});"); + writer.FinishBlock(); + writer.FinishBlock(); + + Next?.GenerateCode(method, writer); + } +} + +/// +/// Emits a Status? null-check that short-circuits RPC execution when a +/// Validate / ValidateAsync method on the proto-first stub returns +/// a non-null . Placed immediately after the +/// frame for the validate method in . +/// +/// +/// Generated code shape: +/// +/// if ({statusVar}.HasValue) +/// throw new Grpc.Core.RpcException({statusVar}.Value); +/// +/// When is the throw is +/// skipped and the chain continues to the bus-dispatch frame as normal. +/// +internal sealed class GrpcValidateShortCircuitFrame : SyncFrame +{ + private readonly Variable _statusVariable; + + public GrpcValidateShortCircuitFrame(Variable statusVariable) + { + _statusVariable = statusVariable; + uses.Add(statusVariable); + } + + public override void GenerateCode(GeneratedMethod method, ISourceWriter writer) + { + writer.Write($"BLOCK:if ({_statusVariable.Usage}.{nameof(Nullable.HasValue)})"); + writer.Write( + $"throw new {typeof(RpcException).FullNameInCode()}({_statusVariable.Usage}.{nameof(Nullable.Value)});"); + writer.FinishBlock(); + writer.BlankLine(); + + Next?.GenerateCode(method, writer); + } +} diff --git a/src/Wolverine.Grpc/HandWrittenGrpcServiceChain.cs b/src/Wolverine.Grpc/HandWrittenGrpcServiceChain.cs new file mode 100644 index 000000000..a819a505e --- /dev/null +++ b/src/Wolverine.Grpc/HandWrittenGrpcServiceChain.cs @@ -0,0 +1,389 @@ +using System.Reflection; +using System.ServiceModel; +using Grpc.Core; +using JasperFx; +using JasperFx.CodeGeneration; +using JasperFx.CodeGeneration.Frames; +using JasperFx.CodeGeneration.Model; +using JasperFx.Core.Reflection; +using ProtoBuf.Grpc; +using Wolverine.Attributes; +using Wolverine.Configuration; +using Wolverine.Middleware; +using Wolverine.Persistence; + +namespace Wolverine.Grpc; + +/// +/// Represents a hand-written code-first gRPC service class (a concrete class whose name ends in +/// GrpcService or that carries ) for which Wolverine +/// generates a thin delegation wrapper at startup. The wrapper implements the same +/// [ServiceContract] interface as the user's class, weaves any Validate / +/// [WolverineBefore] / [WolverineAfter] middleware, then delegates each call to +/// an injected instance of the user's class. +/// +public class HandWrittenGrpcServiceChain : Chain, + ICodeFile +{ + private GeneratedType? _generatedType; + private Type? _generatedRuntimeType; + private MethodInfo[]? _discoveredBefores; + private MethodInfo[]? _discoveredAfters; + + /// The user's concrete service class. + public Type ServiceClassType { get; } + + /// The [ServiceContract] interface the service class implements. + public Type ServiceContractType { get; } + + /// The C# identifier for the generated wrapper type. + public string TypeName { get; } + + /// The RPC methods discovered on and classified by shape. + public IReadOnlyList SupportedMethods { get; } + + /// The runtime of the generated wrapper once compiled. Null before compilation. + public Type? GeneratedType => _generatedRuntimeType; + + internal string? SourceCode => _generatedType?.SourceCode; + + public HandWrittenGrpcServiceChain(Type serviceClassType) + { + ServiceClassType = serviceClassType ?? throw new ArgumentNullException(nameof(serviceClassType)); + + ServiceContractType = FindServiceContractInterface(serviceClassType) + ?? throw new InvalidOperationException( + $"Hand-written gRPC service {serviceClassType.FullNameInCode()} must implement a " + + "[ServiceContract] interface so Wolverine can generate a delegation wrapper. " + + "Add a [ServiceContract] interface to the class or use [WolverineGrpcService] on " + + "the interface directly (code-first generated implementation path)."); + + SupportedMethods = DiscoverMethods(ServiceContractType).ToArray(); + TypeName = ResolveTypeName(serviceClassType); + Description = + $"Generated delegation wrapper for hand-written gRPC service {serviceClassType.FullNameInCode()} " + + $"(contract: {ServiceContractType.FullNameInCode()})"; + } + + // --- Chain<> abstract member implementations --- + + public override string Description { get; } + public override MiddlewareScoping Scoping => MiddlewareScoping.Grpc; + public override IdempotencyStyle Idempotency { get; set; } = IdempotencyStyle.None; + public override Type? InputType() => null; + public override bool ShouldFlushOutgoingMessages() => false; + public override bool RequiresOutbox() => false; + public override MethodCall[] HandlerCalls() => []; + public override bool HasAttribute() => ServiceClassType.HasAttribute(); + public override void ApplyParameterMatching(MethodCall call) { } + + public override bool TryInferMessageIdentity(out PropertyInfo? property) + { + property = null; + return false; + } + + public override bool TryFindVariable(string valueName, ValueSource source, Type valueType, + out Variable variable) + { + variable = default!; + return false; + } + + public override Frame[] AddStopConditionIfNull(Variable variable) => []; + public override void UseForResponse(MethodCall methodCall) { } + + // --- Middleware discovery (scans the service class, same pattern as GrpcServiceChain) --- + + /// + /// Static methods on matching Validate / Before + /// naming conventions or carrying [WolverineBefore]. Includes the Validate → Status? + /// short-circuit hook. Sorted ordinally for byte-stable generated source. + /// + public IReadOnlyList DiscoveredBefores + => _discoveredBefores ??= MiddlewarePolicy + .FilterMethods(this, ServiceClassType.GetMethods(), + MiddlewarePolicy.BeforeMethodNames) + .OrderBy(m => m.Name, StringComparer.Ordinal) + .ToArray(); + + /// + /// Static methods on matching After naming conventions + /// or carrying [WolverineAfter]. Same scope and sort rules as . + /// + public IReadOnlyList DiscoveredAfters + => _discoveredAfters ??= MiddlewarePolicy + .FilterMethods(this, ServiceClassType.GetMethods(), + MiddlewarePolicy.AfterMethodNames) + .OrderBy(m => m.Name, StringComparer.Ordinal) + .ToArray(); + + // --- ICodeFile --- + + string ICodeFile.FileName => TypeName; + + void ICodeFile.AssembleTypes(GeneratedAssembly assembly) + { + if (_generatedType != null) return; + + assembly.ReferenceAssembly(ServiceClassType.Assembly); + assembly.ReferenceAssembly(ServiceContractType.Assembly); + assembly.ReferenceAssembly(typeof(IMessageBus).Assembly); + assembly.ReferenceAssembly(typeof(CallContext).Assembly); + + // The generated class implements the service contract interface from scratch. + // The user's service class is injected as _inner; IMessageBus as _bus for middleware frames. + _generatedType = assembly.AddType(TypeName, ServiceContractType); + + // Inject IServiceProvider rather than the service class directly. The delegation frame + // resolves the inner instance via ActivatorUtilities.GetServiceOrCreateInstance, which + // works whether or not ServiceClassType is explicitly registered in DI — avoiding the need + // for callers to add a manual services.AddTransient() registration. + var spField = new InjectedField(typeof(IServiceProvider), "serviceProvider"); + _generatedType.AllInjectedFields.Add(spField); + + var befores = DiscoveredBefores; + var afters = DiscoveredAfters; + + foreach (var rpc in SupportedMethods) + { + var generatedMethod = _generatedType.MethodFor(rpc.Method.Name); + + // Before-frames require a concrete TRequest in scope. Skip for bidi methods where + // the first parameter is IAsyncEnumerable rather than a single message instance. + // Also skip any before whose non-CallContext parameters don't match this RPC method's + // request type — a Validate(OrderRequest) must not fire on an InvoiceRequest RPC method. + if (rpc.Kind != HandWrittenMethodKind.BidirectionalStreaming) + { + // Registered middleware befores (from grpc.AddMiddleware()) — cloned per method. + foreach (var frame in CodeFirstGrpcServiceChain.CloneFrames(Middleware)) + generatedMethod.Frames.Add(frame); + + var rpcRequestType = rpc.Method.GetParameters()[0].ParameterType; + + foreach (var before in befores) + { + if (!IsBeforeApplicable(before, rpcRequestType)) continue; + + var call = new MethodCall(ServiceClassType, before); + generatedMethod.Frames.Add(call); + + var statusVar = call.Creates.FirstOrDefault(v => v.VariableType == typeof(Status?)); + if (statusVar != null) + generatedMethod.Frames.Add(new GrpcValidateShortCircuitFrame(statusVar)); + } + } + + var hasAfters = (afters.Count > 0 || Postprocessors.Count > 0) && rpc.Kind == HandWrittenMethodKind.Unary; + if (hasAfters) + generatedMethod.AsyncMode = AsyncMode.AsyncTask; + + generatedMethod.Frames.Add(new DelegateToInnerServiceFrame(rpc.Method, spField, ServiceClassType, hasAfters)); + + if (hasAfters) + { + foreach (var after in afters) + generatedMethod.Frames.Add(new MethodCall(ServiceClassType, after)); + + // Registered middleware afters (from grpc.AddMiddleware()) — cloned per method. + foreach (var frame in CodeFirstGrpcServiceChain.CloneFrames(Postprocessors)) + generatedMethod.Frames.Add(frame); + } + } + } + + Task ICodeFile.AttachTypes(GenerationRules rules, Assembly assembly, IServiceProvider? services, + string containingNamespace) + { + var found = this.As().AttachTypesSynchronously(rules, assembly, services, containingNamespace); + return Task.FromResult(found); + } + + bool ICodeFile.AttachTypesSynchronously(GenerationRules rules, Assembly assembly, IServiceProvider? services, + string containingNamespace) + { + _generatedRuntimeType = assembly.ExportedTypes.FirstOrDefault(x => x.Name == TypeName) + ?? assembly.GetTypes().FirstOrDefault(x => x.Name == TypeName); + + return _generatedRuntimeType != null; + } + + // --- Static helpers --- + + /// + /// Returns true when can be called in the context of an RPC + /// method whose first parameter is . A before method is applicable + /// when all of its non- parameters are assignable from + /// . This prevents a Validate(OrderRequest) from firing + /// inside an InvoiceRequest RPC method where no OrderRequest variable is in scope. + /// + private static bool IsBeforeApplicable(MethodInfo before, Type rpcRequestType) + { + foreach (var p in before.GetParameters()) + { + if (p.ParameterType == typeof(CallContext)) continue; + if (!p.ParameterType.IsAssignableFrom(rpcRequestType)) return false; + } + return true; + } + + /// + /// Derives the wrapper type name from the service class. Strips the GrpcService suffix + /// (if present) and appends GrpcHandler: + /// OrderGrpcServiceOrderGrpcHandler, + /// MyOrderManagerMyOrderManagerGrpcHandler. + /// + public static string ResolveTypeName(Type serviceClassType) + { + var name = serviceClassType.Name; + if (name.EndsWith("GrpcService", StringComparison.Ordinal)) + return name[..^"GrpcService".Length] + "GrpcHandler"; + return name + "GrpcHandler"; + } + + /// + /// Returns the first [ServiceContract] interface on , + /// or null if none exists. + /// + public static Type? FindServiceContractInterface(Type serviceClassType) + => serviceClassType.GetInterfaces() + .FirstOrDefault(i => i.IsDefined(typeof(ServiceContractAttribute), inherit: false)); + + /// + /// Discovers and classifies the RPC methods on . + /// Methods whose signatures don't match a supported protobuf-net.Grpc shape are skipped. + /// Results are sorted by method name for byte-stable generated source. + /// + public static IEnumerable DiscoverMethods(Type contractInterface) + { + var results = new List(); + + foreach (var method in contractInterface.GetMethods()) + { + var kind = ClassifyMethod(method); + if (kind == null) continue; + results.Add(new HandWrittenRpcMethod(method, kind.Value)); + } + + results.Sort(static (a, b) => string.CompareOrdinal(a.Method.Name, b.Method.Name)); + return results; + } + + /// + /// Classifies a single interface method against the protobuf-net.Grpc code-first shapes. + /// Handles the bidi case (first parameter is IAsyncEnumerable<TRequest>) + /// in addition to the unary and server-streaming shapes that + /// already supports. + /// Returns null when the method doesn't match any recognised shape. + /// + private static HandWrittenMethodKind? ClassifyMethod(MethodInfo method) + { + var parameters = method.GetParameters(); + if (parameters.Length == 0 || parameters.Length > 2) return null; + + // Optional second parameter must be CallContext. + if (parameters.Length == 2 && parameters[1].ParameterType != typeof(CallContext)) return null; + + var firstParam = parameters[0].ParameterType; + + // Bidi: first param is IAsyncEnumerable + if (firstParam.IsGenericType && firstParam.GetGenericTypeDefinition() == typeof(IAsyncEnumerable<>)) + { + if (method.ReturnType == typeof(Task)) return HandWrittenMethodKind.BidirectionalStreaming; + if (method.ReturnType.IsGenericType && + method.ReturnType.GetGenericTypeDefinition() == typeof(IAsyncEnumerable<>)) + return HandWrittenMethodKind.BidirectionalStreaming; + return null; + } + + // Unary and server-streaming: first param must be a concrete message type. + if (!firstParam.IsClass || firstParam.IsAbstract) return null; + if (!method.ReturnType.IsGenericType) return null; + + var returnOpen = method.ReturnType.GetGenericTypeDefinition(); + if (returnOpen == typeof(Task<>)) return HandWrittenMethodKind.Unary; + if (returnOpen == typeof(IAsyncEnumerable<>)) return HandWrittenMethodKind.ServerStreaming; + + return null; + } +} + +/// +/// Classifies a hand-written code-first gRPC interface method by its protobuf-net.Grpc C# shape. +/// Extends the shapes supported by with +/// for the IAsyncEnumerable<TReq> → IAsyncEnumerable<TResp> +/// bidi pattern that only hand-written services implement directly. +/// +public enum HandWrittenMethodKind +{ + /// Unary: Task<TResponse> Name(TRequest[, CallContext]). + Unary, + + /// Server-streaming: IAsyncEnumerable<TResponse> Name(TRequest[, CallContext]). + ServerStreaming, + + /// + /// Bidi streaming: IAsyncEnumerable<TResponse> Name(IAsyncEnumerable<TRequest>[, CallContext]) + /// or Task Name(IAsyncEnumerable<TRequest>[, CallContext]). The hand-written service + /// implements the full bidi loop; Wolverine generates a pure delegation wrapper. Before-frames + /// are not woven for bidi — there is no single request instance in scope before the loop begins. + /// + BidirectionalStreaming +} + +/// A single hand-written RPC method paired with its . +/// Reflection handle to the interface method. +/// The RPC shape Wolverine classified this method as. +public readonly record struct HandWrittenRpcMethod(MethodInfo Method, HandWrittenMethodKind Kind); + +/// +/// Resolves the hand-written service instance via +/// ActivatorUtilities.GetServiceOrCreateInstance and delegates the RPC call to it. +/// Using rather than a direct constructor injection of the +/// service class avoids requiring an explicit DI registration for the inner type — the +/// activator will construct it from the request-scoped provider if it isn't already registered. +/// For unary methods with after-frames the return value is awaited so after-frames can run +/// before the response is sent. For server-streaming and bidi shapes the inner call is returned +/// directly — the inner service owns the streaming lifecycle. +/// +internal sealed class DelegateToInnerServiceFrame : SyncFrame +{ + private readonly MethodInfo _interfaceMethod; + private readonly InjectedField _spField; + private readonly Type _serviceClassType; + private readonly bool _awaitResult; + + public DelegateToInnerServiceFrame(MethodInfo interfaceMethod, InjectedField spField, + Type serviceClassType, bool awaitResult) + { + _interfaceMethod = interfaceMethod; + _spField = spField; + _serviceClassType = serviceClassType; + _awaitResult = awaitResult; + } + + public override void GenerateCode(GeneratedMethod method, ISourceWriter writer) + { + var parameters = _interfaceMethod.GetParameters(); + var args = string.Join(", ", parameters.Select((p, i) => p.Name ?? $"arg{i}")); + + var innerType = _serviceClassType.FullNameInCode(); + writer.Write( + $"var inner = {typeof(Microsoft.Extensions.DependencyInjection.ActivatorUtilities).FullNameInCode()}" + + $".GetServiceOrCreateInstance<{innerType}>({_spField.Usage});"); + + var call = $"inner.{_interfaceMethod.Name}({args})"; + + if (_awaitResult) + { + writer.Write($"var result = await {call};"); + Next?.GenerateCode(method, writer); + writer.Write("return result;"); + } + else + { + writer.Write($"return {call};"); + Next?.GenerateCode(method, writer); + } + } +} diff --git a/src/Wolverine.Grpc/IGrpcChainPolicy.cs b/src/Wolverine.Grpc/IGrpcChainPolicy.cs new file mode 100644 index 000000000..6c07580c7 --- /dev/null +++ b/src/Wolverine.Grpc/IGrpcChainPolicy.cs @@ -0,0 +1,58 @@ +using JasperFx; +using JasperFx.CodeGeneration; + +namespace Wolverine.Grpc; + +/// +/// Apply your own conventions or structural modifications to Wolverine-managed gRPC chains +/// during bootstrapping. Analogous to IHttpPolicy for HTTP endpoints. +/// Register via or +/// . +/// +/// +/// Receives all three chain kinds as typed lists so implementations can target a specific +/// chain type without casting. Code-first chains () +/// are included for inspection even though they do not yet participate in the +/// Chain<> middleware pipeline (P3). +/// +public interface IGrpcChainPolicy +{ + /// + /// Called during bootstrapping after all gRPC chains are discovered, immediately + /// before code generation runs. + /// + /// All proto-first gRPC chains (abstract stub → generated wrapper). + /// All code-first gRPC chains ([ServiceContract] interface → generated implementation). + /// All hand-written gRPC chains (concrete service class → generated delegation wrapper). + /// The active code-generation rules. + /// The application's IoC container. + void Apply( + IReadOnlyList protoFirstChains, + IReadOnlyList codeFirstChains, + IReadOnlyList handWrittenChains, + GenerationRules rules, + IServiceContainer container); +} + +internal sealed class LambdaGrpcChainPolicy : IGrpcChainPolicy +{ + private readonly Action, IReadOnlyList, + IReadOnlyList, GenerationRules, IServiceContainer> _action; + + internal LambdaGrpcChainPolicy( + Action, IReadOnlyList, + IReadOnlyList, GenerationRules, IServiceContainer> action) + { + _action = action; + } + + public void Apply( + IReadOnlyList protoFirstChains, + IReadOnlyList codeFirstChains, + IReadOnlyList handWrittenChains, + GenerationRules rules, + IServiceContainer container) + { + _action(protoFirstChains, codeFirstChains, handWrittenChains, rules, container); + } +} diff --git a/src/Wolverine.Grpc/ModifyCodeFirstGrpcServiceChainAttribute.cs b/src/Wolverine.Grpc/ModifyCodeFirstGrpcServiceChainAttribute.cs new file mode 100644 index 000000000..fcc6ad05c --- /dev/null +++ b/src/Wolverine.Grpc/ModifyCodeFirstGrpcServiceChainAttribute.cs @@ -0,0 +1,15 @@ +using JasperFx.CodeGeneration; +using Wolverine.Configuration; + +namespace Wolverine.Grpc; + +/// +/// Apply to a [ServiceContract] interface that carries +/// to customize how Wolverine configures the generated code-first gRPC service implementation. +/// Mirrors the role of for proto-first services. +/// +[AttributeUsage(AttributeTargets.Interface | AttributeTargets.Class, AllowMultiple = true)] +public abstract class ModifyCodeFirstGrpcServiceChainAttribute : Attribute, IModifyChain +{ + public abstract void Modify(CodeFirstGrpcServiceChain chain, GenerationRules rules); +} diff --git a/src/Wolverine.Grpc/ModifyHandWrittenGrpcServiceChainAttribute.cs b/src/Wolverine.Grpc/ModifyHandWrittenGrpcServiceChainAttribute.cs new file mode 100644 index 000000000..0a3cbdef0 --- /dev/null +++ b/src/Wolverine.Grpc/ModifyHandWrittenGrpcServiceChainAttribute.cs @@ -0,0 +1,15 @@ +using JasperFx.CodeGeneration; +using Wolverine.Configuration; + +namespace Wolverine.Grpc; + +/// +/// Base attribute for applying modifications to a . +/// Apply to a hand-written code-first gRPC service class or one of its methods to +/// customise the generated delegation wrapper before compilation. +/// +[AttributeUsage(AttributeTargets.Class | AttributeTargets.Method)] +public abstract class ModifyHandWrittenGrpcServiceChainAttribute : Attribute, IModifyChain +{ + public abstract void Modify(HandWrittenGrpcServiceChain chain, GenerationRules rules); +} diff --git a/src/Wolverine.Grpc/WolverineGrpcExceptionInterceptor.cs b/src/Wolverine.Grpc/WolverineGrpcExceptionInterceptor.cs index 9b47577dc..5fd063689 100644 --- a/src/Wolverine.Grpc/WolverineGrpcExceptionInterceptor.cs +++ b/src/Wolverine.Grpc/WolverineGrpcExceptionInterceptor.cs @@ -41,10 +41,14 @@ namespace Wolverine.Grpc; public sealed class WolverineGrpcExceptionInterceptor : Interceptor { private readonly ILogger _logger; + private readonly WolverineGrpcOptions _options; - public WolverineGrpcExceptionInterceptor(ILogger logger) + public WolverineGrpcExceptionInterceptor( + ILogger logger, + WolverineGrpcOptions options) { _logger = logger; + _options = options; } public override async Task UnaryServerHandler( @@ -93,6 +97,19 @@ private RpcException Translate(Exception exception, ServerCallContext context) return richStatus.ToRpcException(); } + var userCode = _options.TryMapException(exception); + if (userCode.HasValue) + { + var rpc = new RpcException(new global::Grpc.Core.Status(userCode.Value, exception.Message)); + _logger.LogWarning( + exception, + "Mapped {ExceptionType} thrown by {Method} to gRPC status {StatusCode} (user-configured mapping)", + exception.GetType().FullName, + context.Method, + userCode.Value); + return rpc; + } + var mapped = WolverineGrpcExceptionMapper.ToRpcException(exception); _logger.LogWarning( exception, diff --git a/src/Wolverine.Grpc/WolverineGrpcExtensions.cs b/src/Wolverine.Grpc/WolverineGrpcExtensions.cs index 70751a46c..8559a8568 100644 --- a/src/Wolverine.Grpc/WolverineGrpcExtensions.cs +++ b/src/Wolverine.Grpc/WolverineGrpcExtensions.cs @@ -123,33 +123,42 @@ public static IEndpointRouteBuilder MapWolverineGrpcServices(this IEndpointRoute var runtime = (WolverineRuntime)services.GetRequiredService(); var assemblies = runtime.Options.Assemblies; - foreach (var type in FindCodeFirstServiceTypes(assemblies)) + // Discover first so hand-written service classes that will receive generated wrappers + // are known before the direct-mapping loop runs, avoiding duplicate route registration. + var graph = services.GetService(); + if (graph != null && graph.Chains.Count == 0 && graph.CodeFirstChains.Count == 0 && + graph.HandWrittenChains.Count == 0) + { + var grpcOptions = services.GetRequiredService(); + graph.DiscoverServices(grpcOptions); + } + + // Map hand-written classes that have NO generated wrapper directly (i.e. those not + // claimed by a HandWrittenGrpcServiceChain in the graph). + foreach (var type in FindDirectMappedServiceTypes(assemblies, graph)) { MapGrpcServiceMethod.MakeGenericMethod(type).Invoke(null, [endpoints]); } - var graph = services.GetService(); if (graph != null) { - MapProtoFirstServices(endpoints, services, graph); + MapGeneratedServices(endpoints, services, graph); } return endpoints; } - private static void MapProtoFirstServices(IEndpointRouteBuilder endpoints, IServiceProvider services, GrpcGraph graph) + private static void MapGeneratedServices(IEndpointRouteBuilder endpoints, IServiceProvider services, + GrpcGraph graph) { - if (graph.Chains.Count == 0) - { - graph.DiscoverServices(); - } - - if (graph.Chains.Count == 0) return; + // Discovery already happened in MapWolverineGrpcServices before this call. + if (graph.Chains.Count == 0 && graph.CodeFirstChains.Count == 0 && + graph.HandWrittenChains.Count == 0) return; var runtime = (WolverineRuntime)services.GetRequiredService(); // Register with Options.Parts so CLI diagnostics ('dotnet run -- describe', - // 'wolverine-diagnostics describe-routing ') list proto-first gRPC services + // 'wolverine-diagnostics describe-routing ') list gRPC services // alongside handlers and HTTP endpoints. if (!runtime.Options.Parts.Contains(graph)) { @@ -177,23 +186,57 @@ private static void MapProtoFirstServices(IEndpointRouteBuilder endpoints, IServ MapGrpcServiceMethod.MakeGenericMethod(chain.GeneratedType).Invoke(null, [endpoints]); } + + foreach (var chain in graph.CodeFirstChains) + { + chain.As().InitializeSynchronously(graph.Rules, graph, services); + + if (chain.GeneratedType == null) + { + throw new InvalidOperationException( + $"Failed to resolve the generated implementation type for code-first gRPC contract {chain.ServiceContractType.FullNameInCode()}. " + + $"Generated source was:\n{chain.SourceCode}"); + } + + MapGrpcServiceMethod.MakeGenericMethod(chain.GeneratedType).Invoke(null, [endpoints]); + } + + foreach (var chain in graph.HandWrittenChains) + { + chain.As().InitializeSynchronously(graph.Rules, graph, services); + + if (chain.GeneratedType == null) + { + throw new InvalidOperationException( + $"Failed to resolve the generated wrapper type for hand-written gRPC service {chain.ServiceClassType.FullNameInCode()}. " + + $"Generated source was:\n{chain.SourceCode}"); + } + + MapGrpcServiceMethod.MakeGenericMethod(chain.GeneratedType).Invoke(null, [endpoints]); + } } /// - /// Returns all concrete, non-abstract types in that - /// qualify as hand-written (code-first / M3) Wolverine-managed gRPC services. - /// Proto-first stubs (abstract classes) are excluded and handled separately via . + /// Returns all concrete, non-abstract types in that qualify as + /// hand-written Wolverine-managed gRPC services. Proto-first stubs (abstract classes) and + /// types that have a in are + /// excluded — the former are handled by , the latter by the generated + /// wrapper mapping loop. /// - public static IEnumerable FindGrpcServiceTypes(IEnumerable assemblies) + public static IEnumerable FindGrpcServiceTypes(IEnumerable assemblies, + GrpcGraph? graph = null) { - return FindCodeFirstServiceTypes(assemblies); + return FindDirectMappedServiceTypes(assemblies, graph); } - private static IEnumerable FindCodeFirstServiceTypes(IEnumerable assemblies) + private static IEnumerable FindDirectMappedServiceTypes(IEnumerable assemblies, + GrpcGraph? graph) { return assemblies .SelectMany(a => a.GetExportedTypes()) - .Where(t => t.IsClass && !t.IsAbstract && IsCodeFirstGrpcServiceType(t)); + .Where(t => t.IsClass && !t.IsAbstract + && IsCodeFirstGrpcServiceType(t) + && (graph == null || graph.HandWrittenChains.All(c => c.ServiceClassType != t))); } private static bool IsCodeFirstGrpcServiceType(Type type) diff --git a/src/Wolverine.Grpc/WolverineGrpcOptions.cs b/src/Wolverine.Grpc/WolverineGrpcOptions.cs index 0b0373d8e..dfc607fd2 100644 --- a/src/Wolverine.Grpc/WolverineGrpcOptions.cs +++ b/src/Wolverine.Grpc/WolverineGrpcOptions.cs @@ -1,42 +1,137 @@ +using Grpc.Core; using Wolverine.Configuration; using Wolverine.Middleware; namespace Wolverine.Grpc; /// -/// Wolverine-side configuration for proto-first gRPC services. The gRPC counterpart to +/// Wolverine-side configuration for gRPC services. The gRPC counterpart to /// WolverineHttpOptions — exposes a dedicated to -/// s so that policy-registered middleware can target gRPC -/// services without leaking through the global opts.Policies.AddMiddleware path -/// (which is intentionally HandlerChain-only). +/// gRPC chains, a list for structural chain customizations, and +/// server-side exception-to-status-code mappings. Middleware registered here targets gRPC +/// chains exclusively and does not leak through the global opts.Policies.AddMiddleware +/// path (which is intentionally HandlerChain-only). /// public sealed class WolverineGrpcOptions { internal MiddlewarePolicy Middleware { get; } = new(); /// - /// Register a middleware type that will be applied to every - /// unless excludes it. + /// Structural policies applied to all discovered gRPC chains during bootstrapping. + /// Analogous to WolverineHttpOptions.Policies — use when you need typed access + /// to chain properties beyond what + /// provides (e.g., inspecting or + /// ). /// - /// Optional predicate restricting which gRPC service chains receive the middleware. + public List Policies { get; } = []; + + /// + /// Register an by type using its default constructor. + /// + public WolverineGrpcOptions AddPolicy() where T : IGrpcChainPolicy, new() + { + Policies.Add(new T()); + return this; + } + + /// + /// Register an instance directly. + /// + public WolverineGrpcOptions AddPolicy(IGrpcChainPolicy policy) + { + Policies.Add(policy); + return this; + } + + // Ordered list so the most-recently-registered entry wins on overlap; + // we walk it in reverse so callers can add more-specific entries after generic ones. + private readonly List<(Type ExceptionType, StatusCode StatusCode)> _exceptionMappings = []; + + /// + /// Register a middleware type that will be applied to all Wolverine-managed gRPC chains + /// (proto-first, code-first generated, and hand-written) unless + /// excludes a specific chain. + /// + /// + /// Optional predicate over . When null, middleware is applied to every + /// gRPC chain. Pattern-match on the concrete type to filter by chain kind, e.g. + /// c => c is GrpcServiceChain g && g.ProtoServiceName == "Greeter". + /// /// The middleware class (looked up by convention for Before/After/Finally methods). - public void AddMiddleware(Func? filter = null) + public void AddMiddleware(Func? filter = null) => AddMiddleware(typeof(T), filter); /// - /// Register a middleware type that will be applied to every - /// unless excludes it. + /// Register a middleware type that will be applied to all Wolverine-managed gRPC chains + /// unless excludes a specific chain. /// /// The middleware class. - /// Optional predicate restricting which gRPC service chains receive the middleware. - public void AddMiddleware(Type middlewareType, Func? filter = null) + /// + /// Optional predicate. When null, defaults to matching every proto-first and hand-written + /// gRPC chain. See for details. + /// + public void AddMiddleware(Type middlewareType, Func? filter = null) { - Func chainFilter = c => c is GrpcServiceChain; - if (filter != null) + Middleware.AddType(middlewareType, filter ?? IsGrpcChain); + } + + /// + /// Default chain predicate: matches every Wolverine gRPC chain type. + /// + private static bool IsGrpcChain(IChain chain) + => chain is GrpcServiceChain or CodeFirstGrpcServiceChain or HandWrittenGrpcServiceChain; + + /// + /// Override the server-side returned for a specific exception type. + /// Consulted after the opt-in google.rpc.Status rich-error pipeline and before the + /// built-in default table, so application-specific mappings always win over the defaults. + /// Inheritance is respected: a mapping for MyBaseException also matches + /// MyDerivedException unless a more-specific mapping exists. + /// + /// The exception type to intercept. + /// The gRPC status code to return when is thrown. + public WolverineGrpcOptions MapException(StatusCode statusCode) + where TException : Exception + => MapException(typeof(TException), statusCode); + + /// + /// Override the server-side for a specific exception type. + /// Non-generic overload for cases where the exception type is only known at runtime. + /// + /// Must be assignable to . + /// The gRPC status code to return. + public WolverineGrpcOptions MapException(Type exceptionType, StatusCode statusCode) + { + if (!typeof(Exception).IsAssignableFrom(exceptionType)) + throw new ArgumentException($"{exceptionType.FullName} must be assignable to Exception.", nameof(exceptionType)); + + _exceptionMappings.Add((exceptionType, statusCode)); + return this; + } + + /// + /// Returns the user-registered for the given exception, walking the + /// exception's inheritance chain from most-derived to least-derived. Later registrations win + /// over earlier ones for the same type. Returns null when no mapping matches so the + /// caller can fall through to the built-in default table. + /// + internal StatusCode? TryMapException(Exception exception) + { + if (_exceptionMappings.Count == 0) return null; + + var type = exception.GetType(); + while (type != null && type != typeof(object)) { - chainFilter = c => c is GrpcServiceChain g && filter(g); + // Walk registrations in reverse — last registration for a given type wins + for (var i = _exceptionMappings.Count - 1; i >= 0; i--) + { + if (_exceptionMappings[i].ExceptionType == type) + return _exceptionMappings[i].StatusCode; + } + + type = type.BaseType; } - Middleware.AddType(middlewareType, chainFilter); + return null; } } diff --git a/src/Wolverine.Grpc/WolverineGrpcServiceAttribute.cs b/src/Wolverine.Grpc/WolverineGrpcServiceAttribute.cs index 730596441..57d3595c9 100644 --- a/src/Wolverine.Grpc/WolverineGrpcServiceAttribute.cs +++ b/src/Wolverine.Grpc/WolverineGrpcServiceAttribute.cs @@ -1,9 +1,19 @@ namespace Wolverine.Grpc; /// -/// Marks a class as a Wolverine-managed gRPC service for automatic discovery -/// and registration. Use this attribute when the class name does not follow -/// the "GrpcService" suffix convention (e.g., on proto-generated types in M4+). +/// Marks a type as a Wolverine-managed gRPC service. +/// +/// +/// On a concrete class: opts the class into MapWolverineGrpcServices() discovery +/// when its name doesn't follow the GrpcService suffix convention. Also used on abstract +/// proto-first stubs to trigger Wolverine's code-generation pipeline. +/// +/// +/// On a [ServiceContract] interface: Wolverine generates a concrete implementation +/// of the interface at startup, forwarding each method to the Wolverine message bus. +/// No hand-written service class is required. +/// +/// /// -[AttributeUsage(AttributeTargets.Class, AllowMultiple = false, Inherited = false)] +[AttributeUsage(AttributeTargets.Class | AttributeTargets.Interface, AllowMultiple = false, Inherited = false)] public sealed class WolverineGrpcServiceAttribute : Attribute;