gRPC
Serve a Module's gRPC service from a Protobuf contract in its Contracts, answer a failed Result as a gRPC status with every Error in a trailer, and call another Host with the Operation Context.
PouryaKit.Grpc puts gRPC on the same conventions as the Kit's HTTP endpoints. The .proto contract lives in the Module's Contracts, which both sides compile: the Module's service and another Host's client. A service is declared in the Module's Composition Root, mapped once in the Host, and runs each call through the Module's Gateway with the Operation Context the call began with. A failed Result answers a gRPC status chosen as HTTP chooses its status, with every Error in a trailer (Failures).
On the calling side, a client registered with the Kit carries the Operation Context in its call metadata, with a deadline from validated options, so the called Host continues the caller's Operation when it trusts the caller as a peer.
gRPC without TLS needs an HTTP/2-only listener
Over TLS one listener serves gRPC and REST alike. Without TLS, a listener that serves both negotiates no HTTP/2, so every gRPC call fails with HTTP_1_1_REQUIRED: the Host needs a listener of HTTP/2 alone for gRPC, beside the one REST callers reach (Declaring and mapping a service).
When to use it
- Another Host asks a Module questions over gRPC rather than HTTP.
- You write a Module's gRPC service from a Protobuf contract, and map the services in the Host.
- A client must read every Error of a failed call, not only its status.
- A Host calls another Host with a client that carries the Operation Context and a deadline.
- You test two Hosts over gRPC.
How it works
Put the contract in the Contracts
The .proto file lives in the Module's Contracts project, which takes the code generator and the Protobuf runtime itself and still references nothing of the Kit.
Declare the service and map it once
AddPouryaKitGrpcService in the Composition Root declares the service, and MapPouryaKitGrpcServices in the Host maps every declared service on one authenticated route group. The service injects the call's Operation Context and its Module's Gateway, and turns the Result into a response with ToGrpcResponse.
Call another Host through a registered client
AddPouryaKitGrpcClient registers a generated client under a name, with an address and a deadline from options. Each call passes its context with WithOperationContext. A client never calls its own Host: a call between Modules of one Host goes through the Gateway.
In the Proving Sample
Catalog's Contracts declare the product service, compiled by Catalog's service and by any Host's client:
// The questions about products Catalog answers for another Host, each as an Operation through Catalog's Gateway. A
// failed answer is a status by the most severe Error kind, with every Error in the pouryakit-errors-bin trailer.
service ProductService {
rpc GetProduct (GetProductRequest) returns (ProductReply);
}
message GetProductRequest {
// The product's id, a Guid.
string product_id = 1;
}
message ProductReply {
string id = 1;
string category_id = 2;
string name = 3;
}The Kit's tests read a failed call back: a missing product answers NotFound with its Error in the trailer, and an empty id answers InvalidArgument naming its target.
[Fact]
public async Task A_failed_result_answers_its_errors_kind_with_every_error_in_the_trailer()
{
using var channel = Channel();
var person = Caller(
"grace",
new Metadata { { TestAuthentication.TenantHeader, AppFixture.TestTenant } }
);
var missing = await FailureAsync(channel, Guid.CreateVersion7().ToString(), person);
var empty = await FailureAsync(channel, Guid.Empty.ToString(), person);
Assert.Equal(
(StatusCode.NotFound, ProductErrors.NotFound.Message),
(missing.StatusCode, missing.Status.Detail)
);
Assert.Equal(
[(ProductErrors.NotFound.Code, ProductErrors.NotFound.Message, (string?)null)],
Errors(missing)
.Select(error =>
(
error.GetProperty("code").GetString(),
error.GetProperty("message").GetString(),
Target(error)
)
)
);
Assert.Equal(StatusCode.InvalidArgument, empty.StatusCode);
Assert.Equal("ProductId", Target(Assert.Single(Errors(empty))));
}Guarantees and failures
| You want to know | Read |
|---|---|
How the Contracts project compiles the .proto | The contract |
| How a service is mapped, authenticated and given its context, and how a listener must be set up | Declaring and mapping a service |
| Which status each Error kind answers, and how the trailer is read | Failures |
| What a client sends, and what its options require | Calling another Host |
| Why a client's address may never be its own Host | The loopback rule |
Next steps
Web
The HTTP conventions a gRPC service shares: authentication, the context and trusted peers.
Modules
Why Modules of one Host ask each other through the Gateway, never over gRPC.
Reference
PouryaKit.Grpc: a Module's gRPC services mapped on Web's conventions, a failed Result answered as a gRPC status with every Error in a trailer, and clients of another Host that carry the Operation Context through Web's one header codec. It references PouryaKit.Web, Grpc.AspNetCore.Server and Grpc.Net.ClientFactory. Namespace PouryaKit.Grpc. Contracts are Protobuf only.
- The contract
- Declaring and mapping a service
- Failures
- Calling another Host
- The loopback rule
- Limits
- Testing
The contract
The .proto lives in the Module's Contracts project, which both sides compile: the Module's service base and another Host's client. Contracts reference nothing of the Kit; they take the code generator and the Protobuf runtime themselves:
<ItemGroup>
<PackageReference Include="Google.Protobuf" />
<PackageReference Include="Grpc.Core.Api" />
<PackageReference Include="Grpc.Tools" PrivateAssets="all" />
</ItemGroup>
<ItemGroup>
<Protobuf Include="Products/products.proto" />
</ItemGroup>Set option csharp_namespace to the namespace the folder makes (Catalog.Contracts.Products), and name the service so its generated class clashes with no namespace (ProductService, not Products).
Declaring and mapping a service
In the Module's Composition Root, and once in the Host after MapPouryaKitModuleEndpoints:
module.AddPouryaKitGrpcService<ProductGrpcService>();
app.MapPouryaKitGrpcServices();internal sealed class ProductGrpcService(IGatewayProcessor<CatalogModule> catalog, OperationContext context)
: ProductService.ProductServiceBase
{
public override async Task<ProductReply> GetProduct(GetProductRequest request, ServerCallContext call) =>
Guid.TryParse(request.ProductId, CultureInfo.InvariantCulture, out var productId)
? (await catalog.QueryAsync(new GetProductQuery(productId), context, call.CancellationToken))
.ToGrpcResponse(product => new ProductReply { Id = product.Id.ToString(), Name = product.Name })
: throw new RpcException(new Status(StatusCode.InvalidArgument, "product_id is not a Guid."));
}MapPouryaKitGrpcServices()maps every declared service on one route group at the root, never inside a Module's prefixed or versioned group: a gRPC method's path is its Protobuf package, service and method. The group requires an authenticated caller and carries Web'sBeginOperationPerRequest(), so the call's Operation begins before the service is created, from the request's scope, and the service injects theOperationContextand passes it to its Module's Gateway, as an HTTP endpoint does (web.md). Called again, it maps no service twice: two identical endpoints would make every call to the service ambiguous.- The Actor is the authenticated caller. A caller authenticated as a peer in
PouryaKitTrustedPeerOptions.Peerscontinues its own Operation from the Kit headers in its call metadata; every other caller's Kit headers are ignored, as over HTTP. - A service of a Module that also maps HTTP endpoints answers beside them; nothing of the HTTP pipeline changes.
- Over TLS, one Kestrel listener serves gRPC and HTTP/1.1 alike: TLS negotiates the protocol. Without TLS a listener of both versions, Kestrel's default, negotiates nothing and speaks HTTP/1.1, so every gRPC call fails with
HTTP_1_1_REQUIRED: ASP.NET Core's troubleshooting guide says an insecure gRPC service must be hosted on an HTTP/2-only port. That is aKestrel:Endpoints:Grpcentry withKestrel:Endpoints:Grpc:Protocolsset toHttp2, and sinceKestrel:Endpointsreplaceurlsand the ports, the HTTP/1.1 listener becomes an entry of its own beside it: two entries, since the gRPC entry alone leaves REST with no listener.Kestrel:EndpointDefaults:Protocolsset toHttp2makes every listener HTTP/2 only, which a REST client over HTTP/1.1 cannot reach. - In development, the monolith's and the modular monolith's
httpslaunch profile listens onhttps://localhost:7080andhttp://localhost:5080with no such entry: the https URL serves REST and gRPC, and a gRPC call on the http URL fails withHTTP_1_1_REQUIRED(templates.md). - Under the Proving Sample's AppHost,
ModularMonolith.Hosthas the two entries as two Aspire endpoints:httpfor REST andgrpc, its listener of HTTP/2 alone. Aspire's endpoint proxy carries a gRPC call togrpcover cleartext, and onhttpthe call fails withHTTP_1_1_REQUIRED, through the proxy or directly (aspire.md). - In production, as a deployment setting and no template default: with a certificate on Kestrel, TLS serves REST and gRPC on one port, as ASP.NET Core's gRPC security guidance asks of production apps. Behind an ingress or proxy that ends TLS and forwards without it, such as a container on
ASPNETCORE_HTTP_PORTS=8080, a considered choice in that guidance, configure the two entries (Kestrel__Endpoints__Http__Url=http://*:8080,Kestrel__Endpoints__Grpc__Url=http://*:8081,Kestrel__Endpoints__Grpc__Protocols=Http2), and have the ingress send HTTP/2 to the gRPC port. - A Host that declares a service and never calls
MapPouryaKitGrpcServices()fails when it starts, naming the service: a startup check, as for unmapped endpoints.
Failures
One shape per transport: HTTP answers a problem response, gRPC a status.
| Error kind | Status |
|---|---|
| Validation | InvalidArgument |
| Not found | NotFound |
| Conflict | Aborted |
| Forbidden | PermissionDenied |
| Unexpected | Internal |
result.ToGrpcResponse(response)andresult.ToGrpcResponse(value => response)return the response on success and throw anRpcExceptionon failure. When the Errors are of several kinds the most severe decides, in HTTP's order: unexpected, forbidden, not found, conflict, validation. The status's message is the deciding Error's.- Every Error, in order, is in the binary trailer
ResultGrpcExtensions.ErrorsTrailer(pouryakit-errors-bin), a JSON array in the Kit's representation whose entries are those of an HTTP problem'serrorsmember:code,messageand, for a validation Error with one,target. A client reads it fromRpcException.Trailers. - A trusted peer's Kit headers the Kit cannot read answer
InvalidArgumentwith the rule they broke as the message, before the service runs, where HTTP answers a 400 problem response. - An unauthenticated call answers
Unauthenticated, from the HTTP 401 authorization writes. - A call to a service no Module declared answers
Unimplemented, from routing's HTTP 404: the Kit maps no catch-all endpoint for unknown services, since it would accept every method on any two-segment path and turn a Module's 405 and 415 into 404.
Calling another Host
On the calling Host, for each generated client, under a name of its own:
builder.Services.AddPouryaKitGrpcClient<ProductService.ProductServiceClient>("catalog");
builder.Services.Configure<PouryaKitGrpcClientOptions>("catalog", builder.Configuration.GetSection("PouryaKit:Grpc:Clients:catalog"));"PouryaKit": { "Grpc": { "Clients": { "catalog": { "Address": "https://catalog.internal", "Deadline": "00:00:10" } } } }var reply = await products.GetProductAsync(
new GetProductRequest { ProductId = id.ToString() },
new CallOptions(cancellationToken: cancellationToken).WithOperationContext(context)
);PouryaKitGrpcClientOptions:Address, an absolutehttporhttpsaddress, andDeadline, positive, both required. The Host fails when it starts, naming the client, when either is missing or invalid.- The client's interceptor writes the Operation Context each call is given with
WithOperationContext(context)as Web's Kit header pairs (OperationContextHttpHeaders.Pairs): the Actor, the tenant, the correlation, and the calling Operation'sOperationIdas the causation. Every Kit header the call's metadata already held is removed first, so a tenant given beside a context without one never reaches the peer. A call without a context throwsInvalidOperationExceptionbefore it is sent. A typed client injects theOperationContextof its Operation and passes it. - The options'
Deadlineapplies to every call that sets no deadline of its own. One that would end afterDateTime.MaxValue, such asTimeSpan.MaxValue, sends the call with no deadline: the interceptor gives itDateTime.MaxValue, which gRPC treats as none. - Registering the same client under the same name again adds nothing and returns the builder of the first registration, so the interceptor runs once per call; the same name for another client throws gRPC's client factory's
InvalidOperationException. - Nothing of an incoming request is copied, its bearer token least of all. A peer that must authenticate this Host gets the Host's own credentials, added on the builder
AddPouryaKitGrpcClientreturns; the receiving Host lists this Host's Actor claim in its trusted peers.
The loopback rule
A gRPC client never calls its own Host: a call between Modules of one Host goes through the Gateway, synchronously and inside the asking Operation (modules.md). The Host's start fails, naming the client and the URL, when a client's address is one of the URLs the Host listens on, chosen as ASP.NET Core chooses them: the addresses the application adds after Build (app.Urls), or else urls, or else http_ports and https_ports; each Kestrel:Endpoints:{name}:Url replaces those unless preferHostingUrls is set; a client there fails the start before any hosted service starts. Then, once every hosted service has started and only when the Host's server is Kestrel, the addresses Kestrel bound are compared the same way, so a listener set in code (ConfigureKestrel with Listen or ListenLocalhost), a port 0 and Kestrel's default http://localhost:5000, the one address ASP.NET Core 10 binds when nothing is configured (no HTTPS default; a launch profile's applicationUrl arrives as urls), are all seen; a client there fails the start too, but the Host had already bound its addresses, so it listened, and its other hosted services ran, before the check stopped the Host and the start failed: configure the addresses to have the refusal before anything starts. Kestrel binds its default only when nothing else is configured, so a client at http://localhost:5000 beside a listener set in code starts. A Host without a server, a client-only Generic Host, or one on another server such as the test server, gets only the first comparison. A URL the Host overrides, such as the image's http_ports 8080 under urls, is not the Host's, so a sidecar on it starts. An address matches on its scheme and port when its host is the same, or is loopback (localhost, 127.0.0.1, [::1]) while the URL is loopback or listens on every address: 0.0.0.0, [::], and, as Kestrel binds them, every host that is neither localhost nor an IP address (*, +, or a name such as catalog.local). Another host on the same port, such as a peer container on the 8080 every ASP.NET Core image listens on, is another Host and starts. The first comparison is a startup check because app.Urls exists only on the built application; the second waits for the bind because Kestrel keeps the listeners set in code to itself until then. Another name for the same machine is not seen and stays this rule's.
Limits
- Before the start, the loopback check reads the configured URLs even when listeners set in code override them: a client at a configured address that a
ListenorListenLocalhostcall replaced, such as aurlsentry, fails the start though the Host never listens there. The false refusal is narrow and hides no refusal, since the comparison after the bind sees the listeners set in code; Kestrel exposes no public reading of those listeners before it binds them, so the first comparison cannot leave the replaced addresses out. Configure no address that a listener set in code replaces.
Testing
- Two Hosts of the application run in one test process, each built with
AppFixture.ComposeHostand a client of the other:ConfigurePrimaryHttpMessageHandler(() => new Http2(peer.GetTestServer().CreateHandler())), whereHttp2is aDelegatingHandlerthat sets the response'sVersionto the request's, since the in-memory server answers HTTP/1.1 and a gRPC client refuses that. Add the peer credentials withAddHttpMessageHandler. - Across a process boundary,
ModularMonolithProcessruns the sample Host withdotnet runin Production, configured through its environment, and a Host of the test process calls the address it logs, authenticated with the peer's own bearer token. A Kestrel listener without TLS serves gRPC only with HTTP/2 alone (Kestrel:EndpointDefaults:Protocolsset toHttp2): without TLS no negotiation picks HTTP/2, so Kestrel's default listener of both versions speaks HTTP/1.1 and refuses the call withHTTP_1_1_REQUIRED; under the AppHost, a start test calls the service through Aspire's endpoint proxy (aspire.md). Read what the process ran from its JSON log entries with scopes (Logging:Console:FormatterNamejson,IncludeScopes): the dispatcher's scope names each Operation's id, correlation and Module. - A plain
GrpcChannelover the same handler sends exactly the metadata a call names, such as a raw peer's unreadable headers. - Read a failure's Errors with
JsonDocument.Parse(failure.Trailers.Get(ResultGrpcExtensions.ErrorsTrailer)!.ValueBytes).
API versions and OpenAPI
API versions a Module declares, served by URL segment and by header, with a configured sunset for a deprecated version and one OpenAPI document per version.
Testing
Test against the hosted application: PostgreSQL in a container, data reset between tests, Operations sent through a Gateway, deliveries awaited and asserted, dead letters read and replayed.