dotnet-cqrs/docs/core-features/validation/grpc-validation.md

514 lines
13 KiB
Markdown

# 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
```protobuf
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
```bash
dotnet add package Grpc.StatusProto
```
### Proto File Configuration
Your `.proto` files must import the error model definitions:
```protobuf
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
```csharp
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:**
```csharp
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:**
```protobuf
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:**
```csharp
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:**
```protobuf
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
```csharp
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
```typescript
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
```go
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:
```csharp
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):
```protobuf
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:
```csharp
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
```csharp
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
```csharp
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) |
## See Also
- [Validation Overview](README.md)
- [FluentValidation Setup](fluentvalidation-setup.md)
- [HTTP Validation](http-validation.md)
- [Google Rich Error Model](https://cloud.google.com/apis/design/errors)
- [gRPC Status Codes](https://grpc.io/docs/guides/status-codes/)