gRPC services (Grpc.AspNetCore)

No package: this is a recipe. It is compiled in examples/Nullean.Entities.Recipes/Grpc and tested in tests/Nullean.Entities.Recipes.Tests/GrpcInterceptorTests.cs, which makes real gRPC calls through ASP.NET Core's TestServer with Grpc.Net.Client.

A request message is a class generated from your .proto, so its rules go in a companion:

C#
[Constraints<SignUpRequest>]
public static partial class SignUpRequestRules
{
    public static readonly Constraint<SignUpRequest> HasEmail = Constraints<SignUpRequest>.Check(
        r => r.Email.Contains('@') ? [] : [Violation.Error(nameof(SignUpRequest.Email), "must be an email address")]
    );
}

A server interceptor validates every unary request whose type is registered and answers INVALID_ARGUMENT. With Grpc.StatusProto the violations travel as the standard google.rpc.BadRequest detail, one FieldViolation per violation:

C#
public sealed class ConstraintInterceptor(IConstraintRegistry registry) : Interceptor
{
    public override Task<TResponse> UnaryServerHandler<TRequest, TResponse>(
        TRequest request,
        ServerCallContext context,
        UnaryServerMethod<TRequest, TResponse> continuation
    )
    {
        if (!registry.TryValidate(request, out var violations) || !violations.Any(v => v.Severity == Severity.Error))
        {
            return continuation(request, context);
        }
        var badRequest = new BadRequest();
        foreach (var v in violations.Where(v => v.Severity == Severity.Error))
        {
            badRequest.FieldViolations.Add(new BadRequest.Types.FieldViolation { Field = v.Member ?? "", Description = v.Message });
        }
        throw new Google.Rpc.Status
        {
            Code = (int)Code.InvalidArgument,
            Message = "Request violates its constraints",
            Details = { Any.Pack(badRequest) },
        }.ToRpcException();
    }
}

services.AddConstraints(SignUpRequestRules.All);
services.AddGrpc(o => o.Interceptors.Add<ConstraintInterceptor>());

A .NET client reads the violations back with Grpc.StatusProto too:

C#
catch (RpcException e) when (e.StatusCode == StatusCode.InvalidArgument)
{
    var badRequest = e.GetRpcStatus()?.GetDetail<BadRequest>();   // badRequest.FieldViolations[0].Field == "Email"
}

Violation.Member uses the C# property name (Email), not the proto field name (email); map it if your clients expect the latter. Streaming calls need the same check in ClientStreamingServerHandler/DuplexStreamingServerHandler per message read, which this recipe leaves out.

Tested with Grpc.AspNetCore, Grpc.StatusProto and Grpc.Net.Client 2.85.0.