From 541dc079bdc9813c547d81e757d027ce9402853f Mon Sep 17 00:00:00 2001 From: "Jeremy D. Miller" Date: Sat, 27 Jun 2026 08:25:12 -0500 Subject: [PATCH] feat(grpc): surface every bus-forwarding RPC kind in the gRPC endpoint manifest (GH-3265) The IGrpcEndpointManifest (GH-3235) only surfaced unary proto-first/code-first endpoints. But server-streaming and bidirectional-streaming RPCs also forward their request to the message bus via IMessageBus.StreamAsync, so they are equally valid PublisherKind.GrpcEndpoint origins for CritterWatch's event model. Extend the manifest to surface every RPC kind Wolverine actually forwards to the bus, and add a GrpcRpcStreamKind discriminator (Unary / ServerStreaming / BidirectionalStreaming) to GrpcEndpointDescriptor so consumers can render the right cardinality. For bidirectional RPCs the published message is the per-item element type of the inbound request stream (unwrapped from IAsyncStreamReader), not the reader wrapper. Hand-written and direct-mapped services remain excluded by design: Wolverine delegates those to the user's own implementation rather than forwarding to the bus, so there is no reliable message-publishing origin to surface. This resolves the "or decide it's N/A" half of GH-3265 for those two modes. Adds comprehensive coverage across every supported gRPC configuration: proto-first unary/server-streaming/bidirectional, code-first unary/server-streaming, and the hand-written/direct-mapped exclusions. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../grpc_endpoint_manifest_3235.cs | 13 +- .../grpc_endpoint_manifest_3265.cs | 273 ++++++++++++++++++ src/Wolverine.Grpc/GrpcEndpointManifest.cs | 89 ++++-- .../Configuration/GrpcEndpointManifest.cs | 45 ++- 4 files changed, 385 insertions(+), 35 deletions(-) create mode 100644 src/Wolverine.Grpc.Tests/grpc_endpoint_manifest_3265.cs diff --git a/src/Wolverine.Grpc.Tests/grpc_endpoint_manifest_3235.cs b/src/Wolverine.Grpc.Tests/grpc_endpoint_manifest_3235.cs index 2514c2452..32f5029de 100644 --- a/src/Wolverine.Grpc.Tests/grpc_endpoint_manifest_3235.cs +++ b/src/Wolverine.Grpc.Tests/grpc_endpoint_manifest_3235.cs @@ -36,7 +36,8 @@ public async Task projects_proto_first_and_code_first_unary_endpoints() endpoints.ShouldNotBeEmpty(); - // v1 surfaces only the unary, bus-forwarding flavors. + // The manifest surfaces only the proto-first + code-first bus-forwarding flavors (hand-written and + // direct-mapped are excluded by design). endpoints.ShouldAllBe(e => e.Mode == GrpcServiceDiscoveryMode.ProtoFirst || e.Mode == GrpcServiceDiscoveryMode.CodeFirst); @@ -48,13 +49,18 @@ public async Task projects_proto_first_and_code_first_unary_endpoints() sayHello.RequestType.ShouldBe(typeof(HelloRequest)); sayHello.ResponseType.ShouldBe(typeof(HelloReply)); sayHello.HandlerType.ShouldBe(typeof(GreeterGrpcService)); + sayHello.StreamKind.ShouldBe(GrpcRpcStreamKind.Unary); // The other proto-first unary RPCs are present... endpoints.ShouldContain(e => e.Mode == GrpcServiceDiscoveryMode.ProtoFirst && e.MethodName == "SayGoodbye"); - // ...but the server-streaming RPC (StreamGreetings, in both the proto and the code-first contract) is excluded. - endpoints.ShouldNotContain(e => e.MethodName == "StreamGreetings"); + // ...and the server-streaming RPC (StreamGreetings) is now surfaced too (GH-3265): it forwards its request to + // the bus via StreamAsync, so it is a genuine message-publishing origin. There is a proto-first one (response + // HelloReply) and a code-first one (response GreetReply). + endpoints.ShouldContain(e => + e.Mode == GrpcServiceDiscoveryMode.ProtoFirst && e.MethodName == "StreamGreetings" + && e.StreamKind == GrpcRpcStreamKind.ServerStreaming); // Code-first: IGreeterCodeFirstService.Greet forwards GreetRequest to the bus. var greet = endpoints.Single(e => @@ -63,6 +69,7 @@ public async Task projects_proto_first_and_code_first_unary_endpoints() greet.ResponseType.ShouldBe(typeof(GreetReply)); greet.ServiceName.ShouldBe("GreeterCodeFirstService"); greet.HandlerType.ShouldBe(typeof(IGreeterCodeFirstService)); + greet.StreamKind.ShouldBe(GrpcRpcStreamKind.Unary); } [Fact] diff --git a/src/Wolverine.Grpc.Tests/grpc_endpoint_manifest_3265.cs b/src/Wolverine.Grpc.Tests/grpc_endpoint_manifest_3265.cs new file mode 100644 index 000000000..63f845ae4 --- /dev/null +++ b/src/Wolverine.Grpc.Tests/grpc_endpoint_manifest_3265.cs @@ -0,0 +1,273 @@ +using GreeterCodeFirstGrpc.Messages; +using GreeterProtoFirstGrpc.Messages; +using GreeterProtoFirstGrpc.Server; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using Shouldly; +using Wolverine.Configuration; +using Wolverine.Grpc.Tests.GrpcBidiStreaming; +using Wolverine.Grpc.Tests.GrpcBidiStreaming.Generated; +using Wolverine.Grpc.Tests.HandWrittenChain; +using Xunit; +using ProtoStreamGreetingsRequest = GreeterProtoFirstGrpc.Messages.StreamGreetingsRequest; +using CodeFirstStreamGreetingsRequest = GreeterCodeFirstGrpc.Messages.StreamGreetingsRequest; + +namespace Wolverine.Grpc.Tests; + +// GH-3265: comprehensive coverage of IGrpcEndpointManifest across every gRPC endpoint configuration Wolverine +// supports, so CritterWatch can populate PublisherKind.GrpcEndpoint for each message-publishing origin. +// +// The manifest surfaces every RPC kind whose Wolverine-generated wrapper forwards the request to the message bus: +// * proto-first — unary (InvokeAsync), server-streaming (StreamAsync), bidirectional-streaming (StreamAsync) +// * code-first — unary (InvokeAsync), server-streaming (StreamAsync) +// and deliberately EXCLUDES the two discovery modes Wolverine does not forward to the bus: +// * hand-written — the generated wrapper delegates to the user's own service class +// * direct-mapped — mapped with no Wolverine chain at all +// For those two there is no reliable message-publishing origin, so they must never appear in the manifest. + +/// +/// Shared host discovering the proto-first Greeter sample (unary + server-streaming) and the code-first +/// IGreeterCodeFirstService contract (unary + server-streaming). Built once for the whole class so the +/// per-fact assertions stay fast and isolated from the other gRPC services living in the test assembly. +/// +public sealed class GreeterManifestFixture : IAsyncLifetime +{ + private IHost? _host; + + public IReadOnlyList Endpoints { get; private set; } = []; + + public async Task InitializeAsync() + { + _host = await Host.CreateDefaultBuilder() + .UseWolverine(opts => + { + // Proto-first Greeter stub lives in the GreeterProtoFirstGrpc.Server assembly. + opts.ApplicationAssembly = typeof(GreeterGrpcService).Assembly; + // Pull in the code-first contract assembly so it is discovered too. + opts.Discovery.IncludeAssembly(typeof(IGreeterCodeFirstService).Assembly); + }) + .ConfigureServices(services => services.AddWolverineGrpc()) + .StartAsync(); + + // Reading Endpoints self-triggers discovery (no MapWolverineGrpcServices required). + Endpoints = _host.Services.GetRequiredService().Endpoints; + } + + public async Task DisposeAsync() + { + if (_host is not null) + { + await _host.StopAsync(); + _host.Dispose(); + } + } +} + +[Collection(GrpcSerialTestsCollection.Name)] +public class grpc_endpoint_manifest_3265 : IClassFixture +{ + private readonly IReadOnlyList _endpoints; + + public grpc_endpoint_manifest_3265(GreeterManifestFixture fixture) + { + _endpoints = fixture.Endpoints; + } + + private GrpcEndpointDescriptor protoFirst(string methodName) + => _endpoints.Single(e => e.Mode == GrpcServiceDiscoveryMode.ProtoFirst && e.MethodName == methodName); + + private GrpcEndpointDescriptor codeFirst(string methodName) + => _endpoints.Single(e => e.Mode == GrpcServiceDiscoveryMode.CodeFirst && e.MethodName == methodName); + + [Fact] + public void manifest_is_populated() + { + _endpoints.ShouldNotBeEmpty(); + } + + // --- proto-first: unary ---------------------------------------------------------------------------------------- + + [Fact] + public void proto_first_unary_say_hello() + { + var e = protoFirst("SayHello"); + e.StreamKind.ShouldBe(GrpcRpcStreamKind.Unary); + e.ServiceName.ShouldBe("Greeter"); + e.RequestType.ShouldBe(typeof(HelloRequest)); + e.ResponseType.ShouldBe(typeof(HelloReply)); + e.HandlerType.ShouldBe(typeof(GreeterGrpcService)); + } + + [Fact] + public void proto_first_unary_say_goodbye() + { + var e = protoFirst("SayGoodbye"); + e.StreamKind.ShouldBe(GrpcRpcStreamKind.Unary); + e.ServiceName.ShouldBe("Greeter"); + e.RequestType.ShouldBe(typeof(GoodbyeRequest)); + e.ResponseType.ShouldBe(typeof(GoodbyeReply)); + e.HandlerType.ShouldBe(typeof(GreeterGrpcService)); + } + + [Fact] + public void proto_first_unary_fault() + { + // The Fault RPC is unary too — it forwards FaultRequest to the bus like any other unary method. + var e = protoFirst("Fault"); + e.StreamKind.ShouldBe(GrpcRpcStreamKind.Unary); + e.RequestType.ShouldBe(typeof(FaultRequest)); + e.ResponseType.ShouldBe(typeof(FaultReply)); + } + + // --- proto-first: server-streaming ----------------------------------------------------------------------------- + + [Fact] + public void proto_first_server_streaming_stream_greetings() + { + var e = protoFirst("StreamGreetings"); + e.StreamKind.ShouldBe(GrpcRpcStreamKind.ServerStreaming); + e.ServiceName.ShouldBe("Greeter"); + // The request is the single request DTO forwarded to StreamAsync. + e.RequestType.ShouldBe(typeof(ProtoStreamGreetingsRequest)); + // The response is the element type of the outbound IServerStreamWriter, NOT the writer itself. + e.ResponseType.ShouldBe(typeof(HelloReply)); + e.HandlerType.ShouldBe(typeof(GreeterGrpcService)); + } + + // --- code-first: unary ----------------------------------------------------------------------------------------- + + [Fact] + public void code_first_unary_greet() + { + var e = codeFirst("Greet"); + e.StreamKind.ShouldBe(GrpcRpcStreamKind.Unary); + e.ServiceName.ShouldBe("GreeterCodeFirstService"); + e.RequestType.ShouldBe(typeof(GreetRequest)); + e.ResponseType.ShouldBe(typeof(GreetReply)); + e.HandlerType.ShouldBe(typeof(IGreeterCodeFirstService)); + } + + // --- code-first: server-streaming ------------------------------------------------------------------------------ + + [Fact] + public void code_first_server_streaming_stream_greetings() + { + var e = codeFirst("StreamGreetings"); + e.StreamKind.ShouldBe(GrpcRpcStreamKind.ServerStreaming); + e.ServiceName.ShouldBe("GreeterCodeFirstService"); + e.RequestType.ShouldBe(typeof(CodeFirstStreamGreetingsRequest)); + // Code-first server-streaming returns IAsyncEnumerable; the response is its element type. + e.ResponseType.ShouldBe(typeof(GreetReply)); + e.HandlerType.ShouldBe(typeof(IGreeterCodeFirstService)); + } + + // --- invariants across the whole manifest ---------------------------------------------------------------------- + + [Fact] + public void every_descriptor_is_a_bus_forwarding_mode() + { + _endpoints.ShouldAllBe(e => + e.Mode == GrpcServiceDiscoveryMode.ProtoFirst || e.Mode == GrpcServiceDiscoveryMode.CodeFirst); + } + + [Fact] + public void every_descriptor_has_a_request_message() + { + // RequestType is the published Wolverine message — it must always be present. + _endpoints.ShouldAllBe(e => e.RequestType != null); + } + + [Fact] + public void every_descriptor_has_service_and_method_names() + { + _endpoints.ShouldAllBe(e => !string.IsNullOrWhiteSpace(e.ServiceName) && !string.IsNullOrWhiteSpace(e.MethodName)); + } + + [Fact] + public void greeter_host_has_no_bidirectional_streaming() + { + // The Greeter contracts declare no bidi RPC, so none should be surfaced here (bidi is covered separately). + _endpoints.ShouldNotContain(e => e.StreamKind == GrpcRpcStreamKind.BidirectionalStreaming); + } + + [Fact] + public void stream_greetings_is_surfaced_once_per_discovery_mode() + { + // Both the proto-first and code-first contracts declare a StreamGreetings RPC; each is its own origin. + var streamGreetings = _endpoints.Where(e => e.MethodName == "StreamGreetings").ToArray(); + streamGreetings.Length.ShouldBe(2); + streamGreetings.ShouldAllBe(e => e.StreamKind == GrpcRpcStreamKind.ServerStreaming); + streamGreetings.Select(e => e.Mode).OrderBy(m => m) + .ShouldBe([GrpcServiceDiscoveryMode.ProtoFirst, GrpcServiceDiscoveryMode.CodeFirst]); + } +} + +/// +/// Covers the two configurations that share the test assembly host: the proto-first bidirectional-streaming +/// wrapper (surfaced) and the hand-written / direct-mapped services (excluded). Reuses the live +/// host, which discovers the entire test assembly — a realistic +/// "many gRPC configurations in one application" surface. +/// +public class grpc_endpoint_manifest_3265_bidi_and_exclusions : IClassFixture +{ + private readonly BidiStreamingFixture _fixture; + + public grpc_endpoint_manifest_3265_bidi_and_exclusions(BidiStreamingFixture fixture) + { + _fixture = fixture; + } + + private IReadOnlyList Endpoints + => _fixture.Services.GetRequiredService().Endpoints; + + [Fact] + public void proto_first_bidirectional_streaming_echo_is_surfaced() + { + var echo = Endpoints.Single(e => + e.Mode == GrpcServiceDiscoveryMode.ProtoFirst + && e.ServiceName == "BidiEchoTest" + && e.MethodName == "Echo"); + + echo.StreamKind.ShouldBe(GrpcRpcStreamKind.BidirectionalStreaming); + // The published message is the per-item element type of the inbound request stream — EchoRequest — NOT the + // IAsyncStreamReader wrapper. This guards the bidi request-type unwrap. + echo.RequestType.ShouldBe(typeof(EchoRequest)); + // The response is the element type of the outbound IServerStreamWriter. + echo.ResponseType.ShouldBe(typeof(EchoReply)); + echo.HandlerType.ShouldBe(typeof(BidiEchoStub)); + } + + [Fact] + public void hand_written_services_are_discovered_but_excluded_from_the_manifest() + { + var graph = _fixture.Services.GetRequiredService(); + + // Sanity: the hand-written service IS discovered as a hand-written chain (so the exclusion below is a + // deliberate manifest decision, not merely the absence of discovery). + graph.HandWrittenChains.ShouldContain(c => c.ServiceClassType == typeof(HandWrittenTestGrpcService)); + + // ...yet none of its methods (Echo / EchoStream / EchoBidi) leak into the message-origin manifest, because + // Wolverine delegates them to the user's own implementation rather than forwarding to the bus. + Endpoints.ShouldNotContain(e => e.HandlerType == typeof(HandWrittenTestGrpcService)); + Endpoints.ShouldNotContain(e => e.HandlerType == typeof(IHandWrittenTestService)); + } + + [Fact] + public void manifest_never_surfaces_hand_written_or_direct_mapped_modes() + { + // Even in an application packed with every discovery flavor, the manifest only ever reports the two + // bus-forwarding modes. Hand-written and direct-mapped have no message-publishing origin. + Endpoints.ShouldAllBe(e => + e.Mode == GrpcServiceDiscoveryMode.ProtoFirst || e.Mode == GrpcServiceDiscoveryMode.CodeFirst); + } + + [Fact] + public void every_surfaced_endpoint_carries_a_request_message_and_known_stream_kind() + { + Endpoints.ShouldAllBe(e => + e.RequestType != null + && (e.StreamKind == GrpcRpcStreamKind.Unary + || e.StreamKind == GrpcRpcStreamKind.ServerStreaming + || e.StreamKind == GrpcRpcStreamKind.BidirectionalStreaming)); + } +} diff --git a/src/Wolverine.Grpc/GrpcEndpointManifest.cs b/src/Wolverine.Grpc/GrpcEndpointManifest.cs index ae7ac4f73..fd83b52ed 100644 --- a/src/Wolverine.Grpc/GrpcEndpointManifest.cs +++ b/src/Wolverine.Grpc/GrpcEndpointManifest.cs @@ -6,9 +6,11 @@ namespace Wolverine.Grpc; /// /// Projects 's discovered proto-first and code-first service chains into the core -/// abstraction. Only unary RPCs are included — those are the methods whose -/// generated wrapper forwards the request to IMessageBus.InvokeAsync, so the request type is genuinely the -/// published Wolverine message. Streaming methods, hand-written and direct-mapped services are excluded from v1. +/// abstraction. Every RPC whose generated wrapper forwards the request to the +/// message bus is included — unary (via IMessageBus.InvokeAsync) plus server- and bidirectional-streaming +/// (via IMessageBus.StreamAsync) — so the request type is genuinely the published Wolverine message +/// (GH-3265). Hand-written and direct-mapped services are excluded: Wolverine delegates those to the user's own +/// implementation rather than forwarding to the bus, so there is no reliable message-publishing origin to surface. /// internal sealed class GrpcEndpointManifest : IGrpcEndpointManifest { @@ -57,54 +59,93 @@ private static IReadOnlyList build(GrpcGraph graph) { var descriptors = new List(); - // Proto-first: chain.UnaryMethods already excludes streaming RPCs. + // Proto-first: every RPC kind Wolverine forwards to the bus. The chain pre-classifies its methods into + // unary / server-streaming / bidirectional-streaming lists (client-streaming is rejected at construction, + // so it never reaches here). foreach (var chain in graph.Chains) { + // Unary: Task Name(TRequest, ServerCallContext) → InvokeAsync(request). foreach (var method in chain.UnaryMethods) { + var p = method.GetParameters(); descriptors.Add(new GrpcEndpointDescriptor( chain.ProtoServiceName, method.Name, - method.GetParameters()[0].ParameterType, - unwrapResponse(method.ReturnType), + p[0].ParameterType, + genericArgument(method.ReturnType), chain.StubType, - GrpcServiceDiscoveryMode.ProtoFirst)); + GrpcServiceDiscoveryMode.ProtoFirst, + GrpcRpcStreamKind.Unary)); + } + + // Server-streaming: Task Name(TRequest, IServerStreamWriter, ServerCallContext) → StreamAsync(request). + foreach (var method in chain.ServerStreamingMethods) + { + var p = method.GetParameters(); + descriptors.Add(new GrpcEndpointDescriptor( + chain.ProtoServiceName, + method.Name, + p[0].ParameterType, + genericArgument(p[1].ParameterType), + chain.StubType, + GrpcServiceDiscoveryMode.ProtoFirst, + GrpcRpcStreamKind.ServerStreaming)); + } + + // Bidirectional: Task Name(IAsyncStreamReader, IServerStreamWriter, ServerCallContext). + // Each inbound item is forwarded individually via StreamAsync, so the published message is the per-item + // element type of the request stream — not the IAsyncStreamReader wrapper. + foreach (var method in chain.BidirectionalStreamingMethods) + { + var p = method.GetParameters(); + var requestType = genericArgument(p[0].ParameterType); + if (requestType == null) continue; // defensive: a bidi reader always has an element type + + descriptors.Add(new GrpcEndpointDescriptor( + chain.ProtoServiceName, + method.Name, + requestType, + genericArgument(p[1].ParameterType), + chain.StubType, + GrpcServiceDiscoveryMode.ProtoFirst, + GrpcRpcStreamKind.BidirectionalStreaming)); } } - // Code-first: only the unary methods are bus-invoked. + // Code-first: unary and server-streaming are bus-forwarded (no bidi shape in the code-first model). foreach (var chain in graph.CodeFirstChains) { var serviceName = serviceNameFor(chain.ServiceContractType); - foreach (var rpc in chain.SupportedMethods.Where(m => m.Kind == CodeFirstMethodKind.Unary)) + foreach (var rpc in chain.SupportedMethods) { + // Unary returns Task; server-streaming returns IAsyncEnumerable. Either way the + // response type is the single generic argument of the return type, and the request is the first param. + var streamKind = rpc.Kind == CodeFirstMethodKind.ServerStreaming + ? GrpcRpcStreamKind.ServerStreaming + : GrpcRpcStreamKind.Unary; + descriptors.Add(new GrpcEndpointDescriptor( serviceName, rpc.Method.Name, rpc.Method.GetParameters()[0].ParameterType, - unwrapResponse(rpc.Method.ReturnType), + genericArgument(rpc.Method.ReturnType), chain.ServiceContractType, - GrpcServiceDiscoveryMode.CodeFirst)); + GrpcServiceDiscoveryMode.CodeFirst, + streamKind)); } } return descriptors; } - private static Type? unwrapResponse(Type returnType) - { - if (returnType.IsGenericType) - { - var definition = returnType.GetGenericTypeDefinition(); - if (definition == typeof(Task<>) || definition == typeof(ValueTask<>)) - { - return returnType.GetGenericArguments()[0]; - } - } - - return null; - } + /// + /// The single generic type argument of — used to unwrap Task<T>, + /// IAsyncEnumerable<T>, IServerStreamWriter<T>, and IAsyncStreamReader<T> + /// to their payload type. Returns null for a non-generic type (e.g. a bare Task). + /// + private static Type? genericArgument(Type type) + => type.IsGenericType ? type.GetGenericArguments()[0] : null; // Best-effort wire service name for a code-first contract: the [ServiceContract] Name if set, otherwise the // interface's simple name with a leading 'I' stripped. diff --git a/src/Wolverine/Configuration/GrpcEndpointManifest.cs b/src/Wolverine/Configuration/GrpcEndpointManifest.cs index 94a2de40d..3d4beec87 100644 --- a/src/Wolverine/Configuration/GrpcEndpointManifest.cs +++ b/src/Wolverine/Configuration/GrpcEndpointManifest.cs @@ -21,24 +21,51 @@ public enum GrpcServiceDiscoveryMode } /// -/// A discovered unary gRPC endpoint and the Wolverine message type it forwards to the message bus. For proto-first -/// and code-first services the generated wrapper forwards the request (the first parameter) to -/// IMessageBus.InvokeAsync, so is the published Wolverine message. +/// The RPC cardinality of a discovered gRPC endpoint — the dimension CritterWatch needs to render the right +/// chain-detail affordance for a gRPC origin. Only the kinds whose generated wrapper forwards the request to the +/// Wolverine message bus are represented; client-streaming has no Wolverine forwarding path and never appears. +/// +public enum GrpcRpcStreamKind +{ + /// Unary RPC — a single request forwarded via IMessageBus.InvokeAsync. + Unary, + + /// Server-streaming RPC — a single request forwarded via IMessageBus.StreamAsync. + ServerStreaming, + + /// + /// Bidirectional-streaming RPC — each inbound request-stream item is forwarded via IMessageBus.StreamAsync. + /// Only proto-first services reach this shape today. + /// + BidirectionalStreaming +} + +/// +/// A discovered gRPC endpoint and the Wolverine message type it forwards to the message bus. For proto-first and +/// code-first services the generated wrapper forwards the request to the bus, so is the +/// published Wolverine message. Unary RPCs forward via IMessageBus.InvokeAsync; server- and +/// bidirectional-streaming RPCs forward via IMessageBus.StreamAsync. Hand-written and direct-mapped services +/// are excluded — Wolverine does not own their dispatch, so there is no reliable message-publishing origin to surface. /// /// The service name — the proto service's simple name for proto-first, or the contract's /// service name for code-first (not necessarily package-qualified). -/// The unary RPC method name. -/// The request type — the Wolverine message forwarded to the bus. -/// The response type, or null for a method with no typed response. +/// The RPC method name. +/// The request type — the Wolverine message forwarded to the bus. For unary and +/// server-streaming RPCs this is the request parameter; for bidirectional-streaming it is the per-item element type of +/// the inbound request stream (each item is forwarded individually). +/// The response type — unwrapped from Task<T> for unary RPCs, or the element +/// type of the outbound response stream for streaming RPCs; null for a method with no typed response. /// The identity of the discovered service (stub or contract type). /// How the service was discovered. +/// The RPC cardinality (unary, server-streaming, or bidirectional-streaming). public sealed record GrpcEndpointDescriptor( string ServiceName, string MethodName, Type RequestType, Type? ResponseType, Type HandlerType, - GrpcServiceDiscoveryMode Mode); + GrpcServiceDiscoveryMode Mode, + GrpcRpcStreamKind StreamKind); /// /// Exposes Wolverine's discovered gRPC unary endpoint → message-type mapping so monitoring/diagnostic consumers @@ -48,7 +75,9 @@ public sealed record GrpcEndpointDescriptor( public interface IGrpcEndpointManifest { /// - /// The discovered unary gRPC endpoints. Empty when gRPC is enabled but no services were discovered. + /// The discovered gRPC endpoints whose generated wrapper forwards the request to the message bus — unary, + /// server-streaming, and bidirectional-streaming RPCs across proto-first and code-first services. Empty when + /// gRPC is enabled but no such services were discovered. /// IReadOnlyList Endpoints { get; } }