514 lines
13 KiB
Markdown
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/)
|