Skip to content

Code-first gRPC client streaming: generate the IAsyncEnumerable<TRequest> -> Task<TResponse> shape - #3500

Merged
jeremydmiller merged 2 commits into
JasperFx:mainfrom
erikshafer:grpc-code-first-client-streaming
Jul 19, 2026
Merged

jeremydmiller merged 2 commits into
JasperFx:mainfrom
erikshafer:grpc-code-first-client-streaming

Conversation

@erikshafer

Copy link
Copy Markdown
Contributor

Follow-up to #3459, which added proto-first client streaming (stream TRequest → TResponse) via the new ICommandBus.StreamAsync<TRequest, TResponse> overload and left one generated-path gap documented as "Not in scope": the code-first (protobuf-net.Grpc) implementation generator still skipped IAsyncEnumerable<TRequest>-parameter methods. This PR closes that gap — all three generatable code-first shapes (unary, server-streaming, client-streaming) are now code-generated, and the change is contained to Wolverine.Grpc plus docs/sample. No core changes: the bus primitive from #3459 already does everything the generated body needs.

The classifier

CodeFirstGrpcServiceChain.ClassifyMethod previously rejected any streamed first parameter via its IsClass check (interfaces aren't classes), which is why the shape was cleanly skipped rather than misclassified. The new rule branches on the first parameter before the concrete-DTO check:

  • Task<TResponse> Name(IAsyncEnumerable<TRequest>[, CallContext]) → new CodeFirstMethodKind.ClientStreaming (appended enum member, same append-only caution as GrpcRpcStreamKind in Proto-first gRPC client streaming via new StreamAsync<TRequest, TResponse> overload #3459)
  • IAsyncEnumerable<TResponse> Name(IAsyncEnumerable<TRequest>[, CallContext]) — the bidi shape — still returns null, and there's now a test pinning that it stays skipped, not misclassified.

The generated body

The pleasant surprise from #3459 repeats, but stronger: on the code-first path no stream adapter is needed at all. Proto-first receives IAsyncStreamReader<TRequest> and needs the WolverineGrpcStreamAdapters.ReadAllAsync shim; protobuf-net.Grpc hands a code-first method the inbound stream as IAsyncEnumerable<TRequest> directly — exactly what the bus overload consumes. The new ForwardCodeFirstClientStreamFrame emits a one-liner:

return _bus.StreamAsync<CodeFirstNumber, CodeFirstSumReply>(numbers, context.CancellationToken);

There's a test asserting ReadAllAsync does not appear in the generated source, so the adapter can't quietly creep in. CancellationToken resolves through the existing CallContextCancellationTokenSource variable source, same as the unary/server-streaming frames.

Tenant detection: woven, closing a parity gap

The old unary-only restriction on code-first tenant detection existed because server-streaming methods return IAsyncEnumerable<T> directly and can't await the detection call. Client streaming returns Task<TResponse>, so the generated body can await detection before the drain — this PR weaves DetectGrpcTenantIdFrame into client-streaming methods that declare a CallContext parameter, with the same ForceAwait forcing rule as unary. That gives code-first parity with proto-first (where every shape carries a ServerCallContext). Covered by a structural test (detection appears exactly once per RPC method in the generated source) and an end-to-end test that disables the runtime propagation interceptor so only the woven frame can move the header value.

Middleware and Validate: deliberately NOT woven

Matching the documented proto-first rationale — before-frames (including the Validate → Status? short-circuit) need a single TRequest in scope when the method begins, which the streaming signature doesn't provide. The weaving block in AssembleTypes now skips client-streaming methods entirely (befores, discovered afters, and global postprocessors). I considered a context-only-middleware carve-out (weaving befores whose parameters don't include the request) and rejected it: it would make hook applicability depend on parameter lists in a way neither path does today, and proto-first would then be the inconsistent one.

Everything around the edges

  • Endpoint manifest / capabilities: code-first client-streaming descriptors surface with GrpcRpcStreamKind.ClientStreaming, RequestType = the per-item element type (the actual bus message is IAsyncEnumerable<TRequest>), response unwrapped from Task<TResponse> — exact parity with what Proto-first gRPC client streaming via new StreamAsync<TRequest, TResponse> overload #3459 did for proto-first.
  • Exception interception: nothing to do — the ClientStreamingServerHandler override from Proto-first gRPC client streaming via new StreamAsync<TRequest, TResponse> overload #3459 is shape-based and applies to both contract styles; the mid-drain-fault test (InvalidOperationException → FailedPrecondition) proves it. Bidi interception stays deferred and its doc comment stays honest.
  • Client side: nothing to do — protobuf-net.Grpc's interface proxies handle client streaming natively; the e2e suite drives CreateGrpcService<T>() with a plain IAsyncEnumerable<TRequest>.
  • Two confirmed-stale doc comments fixed: IGrpcChainPolicy claimed code-first chains "do not yet participate in the Chain<> middleware pipeline (P3)" — false since the middleware weaving landed; WolverineGrpcOptions.AddMiddleware said the default filter matches "every proto-first and hand-written" chain while IsGrpcChain matches all three kinds.

Breaking surface

None breaking. New public surface: CodeFirstMethodKind.ClientStreaming (appended). Behavior change worth noting: a code-first contract method with the client-streaming shape that was previously ignored will now be generated and mapped — if such a method exists with no matching handler, startup still succeeds (pinned by test) and calls fail with Unimplemented (the bus's NotSupportedException through the default mapping) instead of gRPC's unmapped-method error.

Tests, sample, docs

  • Wolverine.Grpc.Tests grows 281 → 294, all passing: classification (client-streaming with/without CallContext, bidi-skip pin), e2e round-trip, empty stream, mid-drain fault, cancellation, no-handler Unimplemented, orphan-contract construction-succeeds, no-adapter source assertion, tenant detection (structural + e2e), manifest + capabilities descriptors. CoreTests unchanged (no core code touched) and green on net9.0 + net10.0; wolverine.slnx Release build clean.

  • GreeterCodeFirstGrpc gains CollectGreetings(IAsyncEnumerable<GreetRequest>) → GreetingSummary mirroring the proto-first sample, plus a README it never had. Verified by actually running server + client:

    Greet       -> Hello, Erik!
    StreamGreetings -> ... [5 of 5]
    CollectGreetings -> Hello, Erik & Ripley & Newt (3 greetings)
    
  • Docs: streaming.md restructures the client-streaming section into proto-first/code-first subsections and drops the "code-first not generated" warning; the "proto-first only" limitation bullets in index.md/streaming.md are replaced by the one remaining honest limitation (code-first bidi); contracts.md's shape-comparison warning now lists all three generated code-first shapes; multi-tenancy.md updates the unary-only claim; samples.md table + walkthrough; renamed-anchor fixes in contracts.md/samples.md/message-bus.md; CHANGELOG entry following the Proto-first gRPC client streaming via new StreamAsync<TRequest, TResponse> overload #3459 precedent.

🤖 Generated with Claude Code

…cEnumerable<TRequest>) methods

Follow-up to JasperFx#3459, closing its documented code-first gap. ClassifyMethod now
recognizes the client-streaming shape as CodeFirstMethodKind.ClientStreaming
(bidi stays cleanly skipped, pinned by test); the new
ForwardCodeFirstClientStreamFrame forwards the inbound stream straight to
IMessageBus.StreamAsync<TRequest, TResponse> with no stream adapter
(protobuf-net.Grpc already hands the method an IAsyncEnumerable<TRequest>).
Tenant detection is woven into client-streaming methods declaring a
CallContext, matching proto-first; middleware/Validate stay deliberately
unwoven. Manifest + capabilities surface the descriptors with the per-item
element type. Fixes two stale doc comments (IGrpcChainPolicy "P3" remark,
WolverineGrpcOptions default-filter doc), extends GreeterCodeFirstGrpc with
CollectGreetings (run-verified) plus a README, and updates the gRPC guide,
message-bus doc anchors, and CHANGELOG.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Copilot AI review requested due to automatic review settings July 19, 2026 15:06

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR closes the remaining code-first (protobuf-net.Grpc) generation gap for client-streaming gRPC methods by recognizing the IAsyncEnumerable<TRequest> -> Task<TResponse> contract shape, generating the forwarding implementation to IMessageBus.StreamAsync<TRequest, TResponse>, extending endpoint manifest/capabilities reporting, and updating docs/samples/tests accordingly — all within Wolverine.Grpc and its surrounding documentation/sample surface.

Changes:

  • Extend code-first method classification/codegen to support client-streaming (Task<TResponse> Name(IAsyncEnumerable<TRequest>[, CallContext])) and keep bidi explicitly skipped.
  • Add client-streaming coverage to endpoint manifest/capabilities, and weave tenant detection for code-first client streaming when CallContext is present (while continuing to skip before/after middleware + Validate for streaming shapes).
  • Expand tests and docs + sample updates to reflect the new generated shape and remaining limitation (code-first bidi still hand-written).

Reviewed changes

Copilot reviewed 22 out of 22 changed files in this pull request and generated 1 comment.

Show a summary per file
File Description
src/Wolverine.Grpc/WolverineGrpcOptions.cs Update middleware filter doc to include code-first chains.
src/Wolverine.Grpc/IGrpcChainPolicy.cs Correct policy doc to reflect code-first participation in the middleware pipeline.
src/Wolverine.Grpc/GrpcEndpointManifest.cs Surface code-first client-streaming endpoints with GrpcRpcStreamKind.ClientStreaming.
src/Wolverine.Grpc/CodeFirstGrpcServiceChain.cs Classify client-streaming shape; generate forwarding frame; weave tenant detection for client streaming; skip middleware weaving for that shape.
src/Wolverine.Grpc.Tests/MultiTenancy/TenantEchoContracts.cs Add code-first client-streaming contract + handler for tenant detection coverage.
src/Wolverine.Grpc.Tests/MultiTenancy/tenant_detection_codegen_tests.cs Add structural + e2e assertions for client-streaming tenant detection.
src/Wolverine.Grpc.Tests/grpc_endpoint_manifest_3265.cs Extend manifest assertions to include code-first client-streaming descriptor behavior.
src/Wolverine.Grpc.Tests/grpc_capabilities_descriptor_source_3267.cs Add capability assertions for code-first client-streaming origin reporting.
src/Wolverine.Grpc.Tests/CodeFirstCodegen/ICodeFirstTestService.cs Add code-first client-streaming RPC and related DTOs + orphan contract shape.
src/Wolverine.Grpc.Tests/CodeFirstCodegen/CodeFirstTestHandlers.cs Add handler that folds a streamed request into a single response and exercises fault/cancel paths.
src/Wolverine.Grpc.Tests/CodeFirstCodegen/code_first_codegen_tests.cs Add classification, e2e, cancellation/fault/empty-stream, orphan/unimplemented, and “no adapter in generated source” assertions.
src/Samples/GreeterCodeFirstGrpc/Server/GreeterHandler.cs Add client-streaming handler that folds a stream into a summary reply.
src/Samples/GreeterCodeFirstGrpc/README.md New README documenting generated code-first unary/server/client streaming sample.
src/Samples/GreeterCodeFirstGrpc/Messages/IGreeterCodeFirstService.cs Add code-first client-streaming contract method + summary DTO.
src/Samples/GreeterCodeFirstGrpc/Client/Program.cs Demonstrate client-streaming call from a plain IAsyncEnumerable<TRequest>.
docs/guide/messaging/message-bus.md Update streaming link anchor for client streaming section.
docs/guide/grpc/streaming.md Document code-first client-streaming generation path and remaining bidi limitation.
docs/guide/grpc/samples.md Update sample matrix and walkthrough to include code-first client streaming.
docs/guide/grpc/multi-tenancy.md Update tenant-detection limitations to include code-first client-streaming support.
docs/guide/grpc/index.md Update “Current Limitations” to reflect code-first bidi-only gap.
docs/guide/grpc/contracts.md Update contract-shape warning to bidi-only and fix client-streaming references.
CHANGELOG.md Add changelog entry for code-first client-streaming generation support.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

…lement type, not the bus message

The class doc claimed the request type is always the published Wolverine
message; for client-streaming the bus message is IAsyncEnumerable<TRequest>
and the manifest deliberately surfaces the per-item element type. Addresses
Copilot review feedback on PR 3500.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@erikshafer
erikshafer marked this pull request as ready for review July 19, 2026 16:38
@jeremydmiller
jeremydmiller merged commit f1832a6 into JasperFx:main Jul 19, 2026
29 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants