13 KiB
13 KiB
gRPC Validation
gRPC validation with Google Rich Error Model for structured error responses.
Overview
When validation fails for gRPC endpoints, the framework returns structured errors following the Google Rich Error Model. This provides:
- ✅ Standardized format - Consistent with Google APIs
- ✅ Field violations - Know exactly which fields failed
- ✅ Status codes - gRPC status codes (INVALID_ARGUMENT)
- ✅ Error details - Additional context via google.rpc types
- ✅ Language-agnostic - Works across all gRPC implementations
Google Rich Error Model
Error Structure
google.rpc.Status {
code: 3 // INVALID_ARGUMENT
message: "Validation failed"
details: [
google.rpc.BadRequest {
field_violations: [
{ field: "name", description: "Name is required" },
{ field: "email", description: "Valid email address is required" },
{ field: "age", description: "User must be at least 18 years old" }
]
}
]
}
gRPC Status Codes
- INVALID_ARGUMENT (3) - Validation errors
- UNAUTHENTICATED (16) - Missing or invalid authentication
- PERMISSION_DENIED (7) - Authorization failures
- NOT_FOUND (5) - Entity not found
- ALREADY_EXISTS (6) - Duplicate entity
Setup
Install Required Packages
dotnet add package Grpc.StatusProto
Proto File Configuration
Your .proto files must import the error model definitions:
syntax = "proto3";
import "google/rpc/status.proto";
import "google/rpc/error_details.proto";
package yourapp;
service CommandService {
rpc CreateUser (CreateUserCommand) returns (CreateUserResponse);
}
message CreateUserCommand {
string name = 1;
string email = 2;
int32 age = 3;
}
message CreateUserResponse {
int32 user_id = 1;
}
Service Registration
var builder = WebApplication.CreateBuilder(args);
// Register CQRS services
builder.Services.AddSvrntyCQRS();
builder.Services.AddDefaultCommandDiscovery();
// Register commands with validators
builder.Services.AddCommand<CreateUserCommand, int, CreateUserCommandHandler>();
builder.Services.AddTransient<IValidator<CreateUserCommand>, CreateUserCommandValidator>();
// Add gRPC
builder.Services.AddGrpc();
var app = builder.Build();
// Map auto-generated service implementation
app.MapGrpcService<CommandServiceImpl>();
app.MapGrpcReflectionService();
app.Run();
Validation Flow
gRPC Request: CreateUser
│
▼
┌──────────────────┐
│ Deserialize │
│ Proto Message │
└──────┬───────────┘
│
▼
┌──────────────────┐
│ Run Validator │
└──────┬───────────┘
│
├─ Valid ──────────▶ Execute Handler ──▶ Success Response
│
└─ Invalid ────────▶ Return RpcException with BadRequest ──▶ INVALID_ARGUMENT
Example Validation Errors
Single Field Error
gRPC Request:
var client = new CommandService.CommandServiceClient(channel);
var request = new CreateUserCommand
{
Name = "", // Invalid
Email = "john@example.com"
};
try
{
var response = await client.CreateUserAsync(request);
}
catch (RpcException ex)
{
// ex.StatusCode = StatusCode.InvalidArgument
// ex.Message = "Validation failed"
var status = ex.GetRpcStatus();
var badRequest = status.GetDetail<BadRequest>();
foreach (var violation in badRequest.FieldViolations)
{
Console.WriteLine($"{violation.Field}: {violation.Description}");
}
}
Error Details:
google.rpc.Status {
code: 3
message: "Validation failed"
details: [
google.rpc.BadRequest {
field_violations: [
{ field: "name", description: "Name is required" }
]
}
]
}
Multiple Field Errors
gRPC Request:
var request = new CreateUserCommand
{
Name = "", // Invalid
Email = "invalid", // Invalid
Age = 16 // Invalid
};
try
{
var response = await client.CreateUserAsync(request);
}
catch (RpcException ex)
{
var status = ex.GetRpcStatus();
var badRequest = status.GetDetail<BadRequest>();
// badRequest.FieldViolations contains 3 violations
}
Error Details:
google.rpc.Status {
code: 3
message: "Validation failed"
details: [
google.rpc.BadRequest {
field_violations: [
{ field: "name", description: "Name is required" },
{ field: "email", description: "Valid email address is required" },
{ field: "age", description: "User must be at least 18 years old" }
]
}
]
}
Client-Side Handling
C# gRPC Client
using Grpc.Core;
using Google.Rpc;
public class UserGrpcClient
{
private readonly CommandService.CommandServiceClient _client;
public async Task<int?> CreateUserAsync(string name, string email, int age)
{
var request = new CreateUserCommand
{
Name = name,
Email = email,
Age = age
};
try
{
var response = await _client.CreateUserAsync(request);
return response.UserId;
}
catch (RpcException ex) when (ex.StatusCode == StatusCode.InvalidArgument)
{
var status = ex.GetRpcStatus();
var badRequest = status.GetDetail<BadRequest>();
if (badRequest != null)
{
foreach (var violation in badRequest.FieldViolations)
{
Console.WriteLine($"Validation error - {violation.Field}: {violation.Description}");
}
}
return null;
}
}
}
TypeScript gRPC-Web Client
import { RpcError } from 'grpc-web';
import { Status } from 'google-rpc/status_pb';
import { BadRequest } from 'google-rpc/error_details_pb';
async function createUser(name: string, email: string, age: number) {
const request = new CreateUserCommand();
request.setName(name);
request.setEmail(email);
request.setAge(age);
try {
const response = await client.createUser(request, {});
return response.getUserId();
} catch (error) {
const rpcError = error as RpcError;
if (rpcError.code === 3) { // INVALID_ARGUMENT
const status = Status.deserializeBinary(rpcError.metadata['grpc-status-details-bin']);
status.getDetailsList().forEach(detail => {
if (detail.getTypeUrl().includes('BadRequest')) {
const badRequest = BadRequest.deserializeBinary(detail.getValue_asU8());
badRequest.getFieldViolationsList().forEach(violation => {
console.error(`${violation.getField()}: ${violation.getDescription()}`);
});
}
});
}
return null;
}
}
Go gRPC Client
import (
"google.golang.org/grpc/codes"
"google.golang.org/grpc/status"
"google.golang.org/genproto/googleapis/rpc/errdetails"
)
func createUser(client pb.CommandServiceClient, name, email string, age int32) (int32, error) {
req := &pb.CreateUserCommand{
Name: name,
Email: email,
Age: age,
}
resp, err := client.CreateUser(context.Background(), req)
if err != nil {
if st, ok := status.FromError(err); ok {
if st.Code() == codes.InvalidArgument {
for _, detail := range st.Details() {
if br, ok := detail.(*errdetails.BadRequest); ok {
for _, violation := range br.GetFieldViolations() {
fmt.Printf("%s: %s\n", violation.Field, violation.Description)
}
}
}
}
}
return 0, err
}
return resp.UserId, nil
}
Validation Rules
FluentValidation Integration
The framework automatically converts FluentValidation errors to Google Rich Error Model:
public class CreateUserCommandValidator : AbstractValidator<CreateUserCommand>
{
public CreateUserCommandValidator()
{
RuleFor(x => x.Name)
.NotEmpty()
.WithMessage("Name is required")
.MaximumLength(100)
.WithMessage("Name must not exceed 100 characters");
RuleFor(x => x.Email)
.EmailAddress()
.WithMessage("Valid email address is required");
RuleFor(x => x.Age)
.GreaterThanOrEqualTo(18)
.WithMessage("User must be at least 18 years old");
}
}
Field Name Mapping
Field names in error responses match proto field names (case-insensitive):
message CreateUserCommand {
string name = 1; // Error field: "name"
string email = 2; // Error field: "email"
int32 age = 3; // Error field: "age"
}
C# property names are automatically converted to proto field names:
public class CreateUserCommand
{
public string Name { get; set; } // Maps to "name"
public string Email { get; set; } // Maps to "email"
public int Age { get; set; } // Maps to "age"
}
Testing Validation
Integration Tests
public class CreateUserCommandGrpcTests : IClassFixture<GrpcTestFixture>
{
private readonly CommandService.CommandServiceClient _client;
public CreateUserCommandGrpcTests(GrpcTestFixture fixture)
{
_client = fixture.CreateClient<CommandService.CommandServiceClient>();
}
[Fact]
public async Task CreateUser_WithMissingName_ReturnsInvalidArgument()
{
var request = new CreateUserCommand
{
Name = "",
Email = "john@example.com"
};
var ex = await Assert.ThrowsAsync<RpcException>(
() => _client.CreateUserAsync(request).ResponseAsync);
Assert.Equal(StatusCode.InvalidArgument, ex.StatusCode);
var status = ex.GetRpcStatus();
var badRequest = status.GetDetail<BadRequest>();
Assert.NotNull(badRequest);
Assert.Contains(badRequest.FieldViolations, v => v.Field == "name");
}
[Fact]
public async Task CreateUser_WithValidData_ReturnsUserId()
{
var request = new CreateUserCommand
{
Name = "John Doe",
Email = "john@example.com",
Age = 25
};
var response = await _client.CreateUserAsync(request);
Assert.True(response.UserId > 0);
}
}
Custom Error Details
Add Additional Context
public static class ValidationErrorHelper
{
public static RpcException CreateValidationException(ValidationResult validationResult)
{
var badRequest = new BadRequest();
foreach (var error in validationResult.Errors)
{
badRequest.FieldViolations.Add(new BadRequest.Types.FieldViolation
{
Field = ToCamelCase(error.PropertyName),
Description = error.ErrorMessage
});
}
var status = new Google.Rpc.Status
{
Code = (int)Code.InvalidArgument,
Message = "Validation failed",
Details = { Any.Pack(badRequest) }
};
return status.ToRpcException();
}
private static string ToCamelCase(string value)
{
if (string.IsNullOrEmpty(value) || char.IsLower(value[0]))
return value;
return char.ToLower(value[0]) + value.Substring(1);
}
}
Best Practices
✅ DO
- Use Google Rich Error Model for structured errors
- Return INVALID_ARGUMENT for validation errors
- Provide field-level error messages
- Use descriptive error messages
- Test validation scenarios
- Handle validation errors gracefully in clients
- Map C# property names to proto field names
❌ DON'T
- Don't return INTERNAL for validation errors
- Don't use generic error messages
- Don't expose internal details
- Don't skip validation
- Don't throw unhandled exceptions
- Don't return plain text error messages
Comparison: HTTP vs gRPC
| Feature | HTTP (RFC 7807) | gRPC (Rich Error Model) |
|---|---|---|
| Format | JSON | Protobuf |
| Status | HTTP 400 | INVALID_ARGUMENT (3) |
| Field Errors | errors object |
field_violations array |
| Structure | ProblemDetails |
google.rpc.Status |
| Details | Extensions | Typed details (BadRequest, etc.) |
| Size | Larger (JSON) | Smaller (binary) |