Compare commits
4 Commits
10.0.0-pre
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| dea62c2434 | |||
| e72cbe4319 | |||
| 467e700885 | |||
| 898aca0905 |
@ -32,7 +32,17 @@
|
|||||||
"Bash(lsof:*)",
|
"Bash(lsof:*)",
|
||||||
"Bash(xargs kill -9)",
|
"Bash(xargs kill -9)",
|
||||||
"Bash(dotnet run:*)",
|
"Bash(dotnet run:*)",
|
||||||
"Bash(find:*)"
|
"Bash(find:*)",
|
||||||
|
"Bash(dotnet pack:*)",
|
||||||
|
"Bash(unzip:*)",
|
||||||
|
"WebFetch(domain:andrewlock.net)",
|
||||||
|
"WebFetch(domain:github.com)",
|
||||||
|
"WebFetch(domain:stackoverflow.com)",
|
||||||
|
"WebFetch(domain:www.kenmuse.com)",
|
||||||
|
"WebFetch(domain:blog.rsuter.com)",
|
||||||
|
"WebFetch(domain:natemcmaster.com)",
|
||||||
|
"WebFetch(domain:www.nuget.org)",
|
||||||
|
"Bash(mkdir:*)"
|
||||||
],
|
],
|
||||||
"deny": [],
|
"deny": [],
|
||||||
"ask": []
|
"ask": []
|
||||||
|
|||||||
213
README.md
213
README.md
@ -12,10 +12,9 @@ Our implementation of query and command responsibility segregation (CQRS).
|
|||||||
|-----------------------------------------| ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |-----------------------------------------------------------------------:|
|
|-----------------------------------------| ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |-----------------------------------------------------------------------:|
|
||||||
| Svrnty.CQRS | [](https://www.nuget.org/packages/Svrnty.CQRS/) | ```dotnet add package Svrnty.CQRS ``` |
|
| Svrnty.CQRS | [](https://www.nuget.org/packages/Svrnty.CQRS/) | ```dotnet add package Svrnty.CQRS ``` |
|
||||||
| Svrnty.CQRS.MinimalApi | [](https://www.nuget.org/packages/Svrnty.CQRS.MinimalApi/) | ```dotnet add package Svrnty.CQRS.MinimalApi ``` |
|
| Svrnty.CQRS.MinimalApi | [](https://www.nuget.org/packages/Svrnty.CQRS.MinimalApi/) | ```dotnet add package Svrnty.CQRS.MinimalApi ``` |
|
||||||
| Svrnty.CQRS.AspNetCore | [](https://www.nuget.org/packages/Svrnty.CQRS.AspNetCore/) | ```dotnet add package Svrnty.CQRS.AspNetCore ``` |
|
|
||||||
| Svrnty.CQRS.FluentValidation | [](https://www.nuget.org/packages/Svrnty.CQRS.FluentValidation/) | ```dotnet add package Svrnty.CQRS.FluentValidation ``` |
|
| Svrnty.CQRS.FluentValidation | [](https://www.nuget.org/packages/Svrnty.CQRS.FluentValidation/) | ```dotnet add package Svrnty.CQRS.FluentValidation ``` |
|
||||||
| Svrnty.CQRS.DynamicQuery | [](https://www.nuget.org/packages/Svrnty.CQRS.DynamicQuery/) | ```dotnet add package Svrnty.CQRS.DynamicQuery ``` |
|
| Svrnty.CQRS.DynamicQuery | [](https://www.nuget.org/packages/Svrnty.CQRS.DynamicQuery/) | ```dotnet add package Svrnty.CQRS.DynamicQuery ``` |
|
||||||
| Svrnty.CQRS.DynamicQuery.AspNetCore | [](https://www.nuget.org/packages/Svrnty.CQRS.DynamicQuery.AspNetCore/) | ```dotnet add package Svrnty.CQRS.DynamicQuery.AspNetCore ``` |
|
| Svrnty.CQRS.DynamicQuery.MinimalApi | [](https://www.nuget.org/packages/Svrnty.CQRS.DynamicQuery.MinimalApi/) | ```dotnet add package Svrnty.CQRS.DynamicQuery.MinimalApi ``` |
|
||||||
| Svrnty.CQRS.Grpc | [](https://www.nuget.org/packages/Svrnty.CQRS.Grpc/) | ```dotnet add package Svrnty.CQRS.Grpc ``` |
|
| Svrnty.CQRS.Grpc | [](https://www.nuget.org/packages/Svrnty.CQRS.Grpc/) | ```dotnet add package Svrnty.CQRS.Grpc ``` |
|
||||||
| Svrnty.CQRS.Grpc.Generators | [](https://www.nuget.org/packages/Svrnty.CQRS.Grpc.Generators/) | ```dotnet add package Svrnty.CQRS.Grpc.Generators ``` |
|
| Svrnty.CQRS.Grpc.Generators | [](https://www.nuget.org/packages/Svrnty.CQRS.Grpc.Generators/) | ```dotnet add package Svrnty.CQRS.Grpc.Generators ``` |
|
||||||
|
|
||||||
@ -31,28 +30,33 @@ Our implementation of query and command responsibility segregation (CQRS).
|
|||||||
## Sample of startup code for gRPC (Recommended)
|
## Sample of startup code for gRPC (Recommended)
|
||||||
|
|
||||||
```csharp
|
```csharp
|
||||||
|
using Svrnty.CQRS;
|
||||||
|
using Svrnty.CQRS.FluentValidation;
|
||||||
|
using Svrnty.CQRS.Grpc;
|
||||||
|
|
||||||
var builder = WebApplication.CreateBuilder(args);
|
var builder = WebApplication.CreateBuilder(args);
|
||||||
|
|
||||||
// Register CQRS core services
|
// Register your commands with validators
|
||||||
builder.Services.AddSvrntyCQRS();
|
builder.Services.AddCommand<AddUserCommand, int, AddUserCommandHandler, AddUserCommandValidator>();
|
||||||
builder.Services.AddDefaultCommandDiscovery();
|
builder.Services.AddCommand<RemoveUserCommand, RemoveUserCommandHandler>();
|
||||||
builder.Services.AddDefaultQueryDiscovery();
|
|
||||||
|
|
||||||
// Add your commands and queries
|
// Register your queries
|
||||||
AddQueries(builder.Services);
|
builder.Services.AddQuery<FetchUserQuery, User, FetchUserQueryHandler>();
|
||||||
AddCommands(builder.Services);
|
|
||||||
|
|
||||||
// Add gRPC support
|
// Configure CQRS with gRPC support
|
||||||
builder.Services.AddGrpc();
|
builder.Services.AddSvrntyCqrs(cqrs =>
|
||||||
|
{
|
||||||
|
// Enable gRPC endpoints with reflection
|
||||||
|
cqrs.AddGrpc(grpc =>
|
||||||
|
{
|
||||||
|
grpc.EnableReflection();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
var app = builder.Build();
|
var app = builder.Build();
|
||||||
|
|
||||||
// Map auto-generated gRPC service implementations
|
// Map all configured CQRS endpoints
|
||||||
app.MapGrpcService<CommandServiceImpl>();
|
app.UseSvrntyCqrs();
|
||||||
app.MapGrpcService<QueryServiceImpl>();
|
|
||||||
|
|
||||||
// Enable gRPC reflection for tools like grpcurl
|
|
||||||
app.MapGrpcReflectionService();
|
|
||||||
|
|
||||||
app.Run();
|
app.Run();
|
||||||
```
|
```
|
||||||
@ -75,31 +79,9 @@ dotnet add package Grpc.StatusProto # For Rich Error Model validation
|
|||||||
dotnet add package Svrnty.CQRS.Grpc.Generators
|
dotnet add package Svrnty.CQRS.Grpc.Generators
|
||||||
```
|
```
|
||||||
|
|
||||||
The source generator is automatically configured as an analyzer when installed via NuGet and will generate the gRPC service implementations at compile time.
|
The source generator is automatically configured as an analyzer when installed via NuGet and will generate both the `.proto` files and gRPC service implementations at compile time.
|
||||||
|
|
||||||
#### 3. Define your proto files in `Protos/` directory:
|
#### 3. Define your C# commands and queries:
|
||||||
|
|
||||||
```protobuf
|
|
||||||
syntax = "proto3";
|
|
||||||
import "google/protobuf/empty.proto";
|
|
||||||
|
|
||||||
service CommandService {
|
|
||||||
rpc AddUser(AddUserCommandRequest) returns (AddUserCommandResponse);
|
|
||||||
rpc RemoveUser(RemoveUserCommandRequest) returns (google.protobuf.Empty);
|
|
||||||
}
|
|
||||||
|
|
||||||
message AddUserCommandRequest {
|
|
||||||
string name = 1;
|
|
||||||
string email = 2;
|
|
||||||
int32 age = 3;
|
|
||||||
}
|
|
||||||
|
|
||||||
message AddUserCommandResponse {
|
|
||||||
int32 result = 1;
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
#### 4. Define your C# commands matching the proto structure:
|
|
||||||
|
|
||||||
```csharp
|
```csharp
|
||||||
public record AddUserCommand
|
public record AddUserCommand
|
||||||
@ -116,28 +98,38 @@ public record RemoveUserCommand
|
|||||||
```
|
```
|
||||||
|
|
||||||
**Notes:**
|
**Notes:**
|
||||||
- The source generator automatically creates `CommandServiceImpl` and `QueryServiceImpl` implementations
|
- The source generator automatically creates:
|
||||||
- Property names in C# commands must match proto field names (case-insensitive)
|
- `.proto` files in the `Protos/` directory from your C# commands and queries
|
||||||
|
- `CommandServiceImpl` and `QueryServiceImpl` implementations
|
||||||
- FluentValidation is automatically integrated with **Google Rich Error Model** for structured validation errors
|
- FluentValidation is automatically integrated with **Google Rich Error Model** for structured validation errors
|
||||||
- Validation errors return `google.rpc.Status` with `BadRequest` containing `FieldViolations`
|
- Validation errors return `google.rpc.Status` with `BadRequest` containing `FieldViolations`
|
||||||
- Use `record` types for commands/queries (immutable, value-based equality, more concise)
|
- Use `record` types for commands/queries (immutable, value-based equality, more concise)
|
||||||
- No need for protobuf-net attributes
|
- No need for protobuf-net attributes - just define your C# types
|
||||||
|
|
||||||
## Sample of startup code for Minimal API (HTTP)
|
## Sample of startup code for Minimal API (HTTP)
|
||||||
|
|
||||||
For HTTP scenarios (web browsers, public APIs), you can use the Minimal API approach:
|
For HTTP scenarios (web browsers, public APIs), you can use the Minimal API approach:
|
||||||
|
|
||||||
```csharp
|
```csharp
|
||||||
|
using Svrnty.CQRS;
|
||||||
|
using Svrnty.CQRS.FluentValidation;
|
||||||
|
using Svrnty.CQRS.MinimalApi;
|
||||||
|
|
||||||
var builder = WebApplication.CreateBuilder(args);
|
var builder = WebApplication.CreateBuilder(args);
|
||||||
|
|
||||||
// Register CQRS core services
|
// Register your commands with validators
|
||||||
builder.Services.AddSvrntyCQRS();
|
builder.Services.AddCommand<CreatePersonCommand, CreatePersonCommandHandler, CreatePersonCommandValidator>();
|
||||||
builder.Services.AddDefaultCommandDiscovery();
|
builder.Services.AddCommand<EchoCommand, string, EchoCommandHandler, EchoCommandValidator>();
|
||||||
builder.Services.AddDefaultQueryDiscovery();
|
|
||||||
|
|
||||||
// Add your commands and queries
|
// Register your queries
|
||||||
AddQueries(builder.Services);
|
builder.Services.AddQuery<PersonQuery, IQueryable<Person>, PersonQueryHandler>();
|
||||||
AddCommands(builder.Services);
|
|
||||||
|
// Configure CQRS with Minimal API support
|
||||||
|
builder.Services.AddSvrntyCqrs(cqrs =>
|
||||||
|
{
|
||||||
|
// Enable Minimal API endpoints
|
||||||
|
cqrs.AddMinimalApi();
|
||||||
|
});
|
||||||
|
|
||||||
// Add Swagger (optional)
|
// Add Swagger (optional)
|
||||||
builder.Services.AddEndpointsApiExplorer();
|
builder.Services.AddEndpointsApiExplorer();
|
||||||
@ -151,9 +143,8 @@ if (app.Environment.IsDevelopment())
|
|||||||
app.UseSwaggerUI();
|
app.UseSwaggerUI();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Map CQRS endpoints - automatically creates routes for all commands and queries
|
// Map all configured CQRS endpoints (automatically creates POST /api/command/* and POST/GET /api/query/*)
|
||||||
app.MapSvrntyCommands(); // Creates POST /api/command/{commandName} endpoints
|
app.UseSvrntyCqrs();
|
||||||
app.MapSvrntyQueries(); // Creates POST/GET /api/query/{queryName} endpoints
|
|
||||||
|
|
||||||
app.Run();
|
app.Run();
|
||||||
```
|
```
|
||||||
@ -169,19 +160,32 @@ app.Run();
|
|||||||
You can enable both gRPC and traditional HTTP endpoints simultaneously, allowing clients to choose their preferred protocol:
|
You can enable both gRPC and traditional HTTP endpoints simultaneously, allowing clients to choose their preferred protocol:
|
||||||
|
|
||||||
```csharp
|
```csharp
|
||||||
|
using Svrnty.CQRS;
|
||||||
|
using Svrnty.CQRS.FluentValidation;
|
||||||
|
using Svrnty.CQRS.Grpc;
|
||||||
|
using Svrnty.CQRS.MinimalApi;
|
||||||
|
|
||||||
var builder = WebApplication.CreateBuilder(args);
|
var builder = WebApplication.CreateBuilder(args);
|
||||||
|
|
||||||
// Register CQRS core services
|
// Register your commands with validators
|
||||||
builder.Services.AddSvrntyCQRS();
|
builder.Services.AddCommand<AddUserCommand, int, AddUserCommandHandler, AddUserCommandValidator>();
|
||||||
builder.Services.AddDefaultCommandDiscovery();
|
builder.Services.AddCommand<RemoveUserCommand, RemoveUserCommandHandler>();
|
||||||
builder.Services.AddDefaultQueryDiscovery();
|
|
||||||
|
|
||||||
// Add your commands and queries
|
// Register your queries
|
||||||
AddQueries(builder.Services);
|
builder.Services.AddQuery<FetchUserQuery, User, FetchUserQueryHandler>();
|
||||||
AddCommands(builder.Services);
|
|
||||||
|
|
||||||
// Add gRPC support
|
// Configure CQRS with both gRPC and Minimal API support
|
||||||
builder.Services.AddGrpc();
|
builder.Services.AddSvrntyCqrs(cqrs =>
|
||||||
|
{
|
||||||
|
// Enable gRPC endpoints with reflection
|
||||||
|
cqrs.AddGrpc(grpc =>
|
||||||
|
{
|
||||||
|
grpc.EnableReflection();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Enable Minimal API endpoints
|
||||||
|
cqrs.AddMinimalApi();
|
||||||
|
});
|
||||||
|
|
||||||
// Add HTTP support with Swagger
|
// Add HTTP support with Swagger
|
||||||
builder.Services.AddEndpointsApiExplorer();
|
builder.Services.AddEndpointsApiExplorer();
|
||||||
@ -195,14 +199,8 @@ if (app.Environment.IsDevelopment())
|
|||||||
app.UseSwaggerUI();
|
app.UseSwaggerUI();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Map gRPC endpoints
|
// Map all configured CQRS endpoints (both gRPC and HTTP)
|
||||||
app.MapGrpcService<CommandServiceImpl>();
|
app.UseSvrntyCqrs();
|
||||||
app.MapGrpcService<QueryServiceImpl>();
|
|
||||||
app.MapGrpcReflectionService();
|
|
||||||
|
|
||||||
// Map HTTP endpoints
|
|
||||||
app.MapSvrntyCommands();
|
|
||||||
app.MapSvrntyQueries();
|
|
||||||
|
|
||||||
app.Run();
|
app.Run();
|
||||||
```
|
```
|
||||||
@ -214,47 +212,10 @@ app.Run();
|
|||||||
- Same commands, queries, and validation logic for both protocols
|
- Same commands, queries, and validation logic for both protocols
|
||||||
- Swagger UI available for HTTP endpoints, gRPC reflection for gRPC clients
|
- Swagger UI available for HTTP endpoints, gRPC reflection for gRPC clients
|
||||||
|
|
||||||
> Example how to add your queries and commands.
|
|
||||||
|
|
||||||
```csharp
|
|
||||||
private void AddCommands(IServiceCollection services)
|
|
||||||
{
|
|
||||||
services.AddCommand<CreatePersonCommand, CreatePersonCommandHandler>();
|
|
||||||
services.AddTransient<IValidator<CreatePersonCommand>, CreatePersonCommandValidator>();
|
|
||||||
|
|
||||||
services.AddCommand<EchoCommand, string, EchoCommandHandler>();
|
|
||||||
services.AddTransient<IValidator<EchoCommand>, EchoCommandValidator>();
|
|
||||||
}
|
|
||||||
|
|
||||||
private void AddQueries(IServiceCollection services)
|
|
||||||
{
|
|
||||||
services.AddQuery<PersonQuery, IQueryable<Person>, PersonQueryHandler>();
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
# Fluent Validation
|
# Fluent Validation
|
||||||
|
|
||||||
FluentValidation is optional but recommended for command and query validation. The `Svrnty.CQRS.FluentValidation` package provides extension methods to simplify validator registration.
|
FluentValidation is optional but recommended for command and query validation. The `Svrnty.CQRS.FluentValidation` package provides extension methods to simplify validator registration.
|
||||||
|
|
||||||
## Without Svrnty.CQRS.FluentValidation
|
|
||||||
|
|
||||||
You need to register commands and validators separately:
|
|
||||||
|
|
||||||
```csharp
|
|
||||||
using Microsoft.Extensions.DependencyInjection;
|
|
||||||
using FluentValidation;
|
|
||||||
using Svrnty.CQRS;
|
|
||||||
|
|
||||||
private void AddCommands(IServiceCollection services)
|
|
||||||
{
|
|
||||||
// Register command handler
|
|
||||||
services.AddCommand<EchoCommand, string, EchoCommandHandler>();
|
|
||||||
|
|
||||||
// Manually register validator
|
|
||||||
services.AddTransient<IValidator<EchoCommand>, EchoCommandValidator>();
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
## With Svrnty.CQRS.FluentValidation (Recommended)
|
## With Svrnty.CQRS.FluentValidation (Recommended)
|
||||||
|
|
||||||
The package exposes extension method overloads that accept the validator as a generic parameter:
|
The package exposes extension method overloads that accept the validator as a generic parameter:
|
||||||
@ -264,17 +225,13 @@ dotnet add package Svrnty.CQRS.FluentValidation
|
|||||||
```
|
```
|
||||||
|
|
||||||
```csharp
|
```csharp
|
||||||
using Microsoft.Extensions.DependencyInjection;
|
|
||||||
using Svrnty.CQRS.FluentValidation; // Extension methods for validator registration
|
using Svrnty.CQRS.FluentValidation; // Extension methods for validator registration
|
||||||
|
|
||||||
private void AddCommands(IServiceCollection services)
|
// Command with result - validator as last generic parameter
|
||||||
{
|
builder.Services.AddCommand<EchoCommand, string, EchoCommandHandler, EchoCommandValidator>();
|
||||||
// Command without result - validator included in generics
|
|
||||||
services.AddCommand<EchoCommand, string, EchoCommandHandler, EchoCommandValidator>();
|
|
||||||
|
|
||||||
// Command with result - validator as last generic parameter
|
// Command without result - validator included in generics
|
||||||
services.AddCommand<CreatePersonCommand, CreatePersonCommandHandler, CreatePersonCommandValidator>();
|
builder.Services.AddCommand<CreatePersonCommand, CreatePersonCommandHandler, CreatePersonCommandValidator>();
|
||||||
}
|
|
||||||
```
|
```
|
||||||
|
|
||||||
**Benefits:**
|
**Benefits:**
|
||||||
@ -283,6 +240,21 @@ private void AddCommands(IServiceCollection services)
|
|||||||
- **Less boilerplate** - No need for separate `AddTransient<IValidator<T>>()` calls
|
- **Less boilerplate** - No need for separate `AddTransient<IValidator<T>>()` calls
|
||||||
- **Cleaner code** - Clear intent that validation is part of command pipeline
|
- **Cleaner code** - Clear intent that validation is part of command pipeline
|
||||||
|
|
||||||
|
## Without Svrnty.CQRS.FluentValidation
|
||||||
|
|
||||||
|
If you prefer not to use the FluentValidation package, you need to register commands and validators separately:
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
using FluentValidation;
|
||||||
|
using Svrnty.CQRS;
|
||||||
|
|
||||||
|
// Register command handler
|
||||||
|
builder.Services.AddCommand<EchoCommand, string, EchoCommandHandler>();
|
||||||
|
|
||||||
|
// Manually register validator
|
||||||
|
builder.Services.AddTransient<IValidator<EchoCommand>, EchoCommandValidator>();
|
||||||
|
```
|
||||||
|
|
||||||
# 2024-2025 Roadmap
|
# 2024-2025 Roadmap
|
||||||
|
|
||||||
| Task | Description | Status |
|
| Task | Description | Status |
|
||||||
@ -292,4 +264,11 @@ private void AddCommands(IServiceCollection services)
|
|||||||
| Update FluentValidation | Upgrade FluentValidation to version 11.x for .NET 10 compatibility. | ✅ |
|
| Update FluentValidation | Upgrade FluentValidation to version 11.x for .NET 10 compatibility. | ✅ |
|
||||||
| Add gRPC Support with source generators | Implement gRPC endpoints with source generators and Google Rich Error Model for validation. | ✅ |
|
| Add gRPC Support with source generators | Implement gRPC endpoints with source generators and Google Rich Error Model for validation. | ✅ |
|
||||||
| Create a demo project (Svrnty.CQRS.Grpc.Sample) | Develop a comprehensive demo project showcasing gRPC and HTTP endpoints. | ✅ |
|
| Create a demo project (Svrnty.CQRS.Grpc.Sample) | Develop a comprehensive demo project showcasing gRPC and HTTP endpoints. | ✅ |
|
||||||
| Create a website for the Framework | Develop a website to host comprehensive documentation for the framework. | ⬜️ |
|
| Create a website for the Framework | Develop a website to host comprehensive documentation for the framework. | ⬜️ |
|
||||||
|
|
||||||
|
# 2026 Roadmap
|
||||||
|
|
||||||
|
| Task | Description | Status |
|
||||||
|
|----------------------------------------------------------------------------------|------------------------------------------------------------------------------------------------------|--------|
|
||||||
|
| gRPC Compression Support | Smart message compression with automatic threshold detection and per-handler control. | ⬜️ |
|
||||||
|
| gRPC Metadata & Authorization Support | Expose ServerCallContext to handlers and integrate authorization services for gRPC endpoints. | ⬜️ |
|
||||||
@ -7,7 +7,10 @@
|
|||||||
<EnforceExtendedAnalyzerRules>true</EnforceExtendedAnalyzerRules>
|
<EnforceExtendedAnalyzerRules>true</EnforceExtendedAnalyzerRules>
|
||||||
<IsPackable>true</IsPackable>
|
<IsPackable>true</IsPackable>
|
||||||
<DevelopmentDependency>true</DevelopmentDependency>
|
<DevelopmentDependency>true</DevelopmentDependency>
|
||||||
|
<!-- Don't include build output in lib/ - this is an analyzer/generator package -->
|
||||||
<IncludeBuildOutput>false</IncludeBuildOutput>
|
<IncludeBuildOutput>false</IncludeBuildOutput>
|
||||||
|
<NoPackageAnalysis>true</NoPackageAnalysis>
|
||||||
|
<NoWarn>$(NoWarn);NU5128</NoWarn>
|
||||||
<SuppressDependenciesWhenPacking>true</SuppressDependenciesWhenPacking>
|
<SuppressDependenciesWhenPacking>true</SuppressDependenciesWhenPacking>
|
||||||
|
|
||||||
<Company>Svrnty</Company>
|
<Company>Svrnty</Company>
|
||||||
@ -20,11 +23,8 @@
|
|||||||
<PackageLicenseExpression>MIT</PackageLicenseExpression>
|
<PackageLicenseExpression>MIT</PackageLicenseExpression>
|
||||||
<Description>Source Generator for Svrnty.CQRS.Grpc - generates .proto files and gRPC service implementations from commands and queries</Description>
|
<Description>Source Generator for Svrnty.CQRS.Grpc - generates .proto files and gRPC service implementations from commands and queries</Description>
|
||||||
|
|
||||||
<DebugType>portable</DebugType>
|
<!-- Disable symbol packages for analyzer/generator packages (prevents NU5017 error) -->
|
||||||
<DebugSymbols>true</DebugSymbols>
|
<IncludeSymbols>false</IncludeSymbols>
|
||||||
<IncludeSymbols>true</IncludeSymbols>
|
|
||||||
<IncludeSource>true</IncludeSource>
|
|
||||||
<SymbolPackageFormat>snupkg</SymbolPackageFormat>
|
|
||||||
</PropertyGroup>
|
</PropertyGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
@ -39,11 +39,24 @@
|
|||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<!-- Package as analyzer -->
|
<!-- Include targets and props files in both build and buildTransitive for proper dependency flow -->
|
||||||
<None Include="$(OutputPath)\$(AssemblyName).dll" Pack="true" PackagePath="analyzers/dotnet/cs" Visible="false" />
|
|
||||||
|
|
||||||
<!-- Also package as build task -->
|
|
||||||
<None Include="$(OutputPath)\$(AssemblyName).dll" Pack="true" PackagePath="build" Visible="false" />
|
|
||||||
<None Include="build\Svrnty.CQRS.Grpc.Generators.targets" Pack="true" PackagePath="build" />
|
<None Include="build\Svrnty.CQRS.Grpc.Generators.targets" Pack="true" PackagePath="build" />
|
||||||
|
<None Include="build\Svrnty.CQRS.Grpc.Generators.targets" Pack="true" PackagePath="buildTransitive" />
|
||||||
|
<None Include="build\Svrnty.CQRS.Grpc.Generators.props" Pack="true" PackagePath="build" />
|
||||||
|
<None Include="build\Svrnty.CQRS.Grpc.Generators.props" Pack="true" PackagePath="buildTransitive" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
|
<!-- Use the recommended pattern to include the generator DLL in the package -->
|
||||||
|
<PropertyGroup>
|
||||||
|
<TargetsForTfmSpecificContentInPackage>$(TargetsForTfmSpecificContentInPackage);IncludeGeneratorAssemblyInPackage</TargetsForTfmSpecificContentInPackage>
|
||||||
|
</PropertyGroup>
|
||||||
|
|
||||||
|
<Target Name="IncludeGeneratorAssemblyInPackage">
|
||||||
|
<ItemGroup>
|
||||||
|
<!-- Include in analyzers folder for Roslyn source generator -->
|
||||||
|
<TfmSpecificPackageFile Include="$(OutputPath)$(AssemblyName).dll" PackagePath="analyzers/dotnet/cs" />
|
||||||
|
<!-- Include in build folder for MSBuild task (WriteProtoFileTask) -->
|
||||||
|
<TfmSpecificPackageFile Include="$(OutputPath)$(AssemblyName).dll" PackagePath="build" />
|
||||||
|
</ItemGroup>
|
||||||
|
</Target>
|
||||||
</Project>
|
</Project>
|
||||||
|
|||||||
@ -0,0 +1,6 @@
|
|||||||
|
<Project>
|
||||||
|
<PropertyGroup>
|
||||||
|
<!-- Marker to indicate Svrnty.CQRS.Grpc.Generators is referenced -->
|
||||||
|
<SvrntyCqrsGrpcGeneratorsVersion>$(SvrntyCqrsGrpcGeneratorsVersion)</SvrntyCqrsGrpcGeneratorsVersion>
|
||||||
|
</PropertyGroup>
|
||||||
|
</Project>
|
||||||
@ -24,14 +24,14 @@ builder.Services.AddTransient<PoweredSoft.Data.Core.IAsyncQueryableService, Simp
|
|||||||
builder.Services.AddTransient<PoweredSoft.DynamicQuery.Core.IQueryHandlerAsync, PoweredSoft.DynamicQuery.QueryHandlerAsync>();
|
builder.Services.AddTransient<PoweredSoft.DynamicQuery.Core.IQueryHandlerAsync, PoweredSoft.DynamicQuery.QueryHandlerAsync>();
|
||||||
builder.Services.AddDynamicQueryWithProvider<User, UserQueryableProvider>();
|
builder.Services.AddDynamicQueryWithProvider<User, UserQueryableProvider>();
|
||||||
|
|
||||||
|
// Register commands and queries with validators
|
||||||
|
builder.Services.AddCommand<AddUserCommand, int, AddUserCommandHandler, AddUserCommandValidator>();
|
||||||
|
builder.Services.AddCommand<RemoveUserCommand, RemoveUserCommandHandler>();
|
||||||
|
builder.Services.AddQuery<FetchUserQuery, User, FetchUserQueryHandler>();
|
||||||
|
|
||||||
// Configure CQRS with fluent API
|
// Configure CQRS with fluent API
|
||||||
builder.Services.AddSvrntyCqrs(cqrs =>
|
builder.Services.AddSvrntyCqrs(cqrs =>
|
||||||
{
|
{
|
||||||
// Register commands and queries with validators
|
|
||||||
cqrs.AddCommand<AddUserCommand, int, AddUserCommandHandler, AddUserCommandValidator>();
|
|
||||||
cqrs.AddCommand<RemoveUserCommand, RemoveUserCommandHandler>();
|
|
||||||
cqrs.AddQuery<FetchUserQuery, User, FetchUserQueryHandler>();
|
|
||||||
|
|
||||||
// Enable gRPC endpoints with reflection
|
// Enable gRPC endpoints with reflection
|
||||||
cqrs.AddGrpc(grpc =>
|
cqrs.AddGrpc(grpc =>
|
||||||
{
|
{
|
||||||
|
|||||||
640
roadmap-2026/compression.md
Normal file
640
roadmap-2026/compression.md
Normal file
@ -0,0 +1,640 @@
|
|||||||
|
# gRPC Compression Support - Implementation Plan
|
||||||
|
|
||||||
|
**Status:** Planned
|
||||||
|
**Target:** Q1 2026
|
||||||
|
**Priority:** High
|
||||||
|
**Complexity:** Medium
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
Implement intelligent message compression for gRPC services with automatic threshold detection, per-handler control via attributes, and configurable compression levels. This will reduce bandwidth costs, improve performance over slow networks, and provide fine-grained control for developers.
|
||||||
|
|
||||||
|
## Goals
|
||||||
|
|
||||||
|
1. **Automatic Compression**: Compress messages larger than a configurable threshold (default: 1KB)
|
||||||
|
2. **Attribute-Based Control**: Allow developers to opt-out (`[NoCompression]`) or opt-in (`[CompressAlways]`) per handler
|
||||||
|
3. **Configurable Levels**: Support Fastest, Optimal, and SmallestSize compression levels
|
||||||
|
4. **Smart Defaults**: Don't compress small messages or already-compressed data
|
||||||
|
5. **Backward Compatible**: No breaking changes to existing APIs
|
||||||
|
6. **Zero Configuration**: Works out-of-the-box with sensible defaults
|
||||||
|
|
||||||
|
## Technical Design
|
||||||
|
|
||||||
|
### Architecture Components
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─────────────────────────────────────────────────────────┐
|
||||||
|
│ gRPC Service Layer │
|
||||||
|
│ (Generated CommandServiceImpl/QueryServiceImpl) │
|
||||||
|
└─────────────────┬───────────────────────────────────────┘
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
┌─────────────────────────────────────────────────────────┐
|
||||||
|
│ Compression Interceptor │
|
||||||
|
│ - Automatically registered via UseCompression() │
|
||||||
|
│ - Checks handler metadata for compression attributes │
|
||||||
|
│ - Measures message size │
|
||||||
|
│ - Applies compression based on policy │
|
||||||
|
└─────────────────┬───────────────────────────────────────┘
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
┌─────────────────────────────────────────────────────────┐
|
||||||
|
│ Handler Execution │
|
||||||
|
│ ICommandHandler / IQueryHandler │
|
||||||
|
└─────────────────────────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
### New Abstractions
|
||||||
|
|
||||||
|
#### 1. Compression Attributes
|
||||||
|
|
||||||
|
**File:** `Svrnty.CQRS.Grpc.Abstractions/Attributes/CompressionAttribute.cs`
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
namespace Svrnty.CQRS.Grpc.Abstractions;
|
||||||
|
|
||||||
|
[AttributeUsage(AttributeTargets.Class, AllowMultiple = false, Inherited = true)]
|
||||||
|
public sealed class NoCompressionAttribute : Attribute
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
[AttributeUsage(AttributeTargets.Class, AllowMultiple = false, Inherited = true)]
|
||||||
|
public sealed class CompressAlwaysAttribute : Attribute
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
[AttributeUsage(AttributeTargets.Class, AllowMultiple = false, Inherited = true)]
|
||||||
|
public sealed class CompressionLevelAttribute : Attribute
|
||||||
|
{
|
||||||
|
public CompressionLevel Level { get; }
|
||||||
|
|
||||||
|
public CompressionLevelAttribute(CompressionLevel level)
|
||||||
|
{
|
||||||
|
Level = level;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public enum CompressionLevel
|
||||||
|
{
|
||||||
|
Fastest = 0,
|
||||||
|
Optimal = 1,
|
||||||
|
SmallestSize = 2
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 2. Compression Configuration
|
||||||
|
|
||||||
|
**File:** `Svrnty.CQRS.Grpc/Configuration/GrpcCompressionOptions.cs`
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
namespace Svrnty.CQRS.Grpc;
|
||||||
|
|
||||||
|
public class GrpcCompressionOptions
|
||||||
|
{
|
||||||
|
public bool EnableAutomaticCompression { get; set; } = true;
|
||||||
|
|
||||||
|
public int CompressionThresholdBytes { get; set; } = 1024;
|
||||||
|
|
||||||
|
public CompressionLevel DefaultCompressionLevel { get; set; } = CompressionLevel.Optimal;
|
||||||
|
|
||||||
|
public string CompressionAlgorithm { get; set; } = "gzip";
|
||||||
|
|
||||||
|
public bool EnableCompressionMetrics { get; set; } = false;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 3. Compression Metadata
|
||||||
|
|
||||||
|
**File:** `Svrnty.CQRS.Grpc/Metadata/ICompressionMetadata.cs`
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
namespace Svrnty.CQRS.Grpc;
|
||||||
|
|
||||||
|
public interface ICompressionMetadata
|
||||||
|
{
|
||||||
|
Type HandlerType { get; }
|
||||||
|
CompressionPolicy Policy { get; }
|
||||||
|
CompressionLevel? CustomLevel { get; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public enum CompressionPolicy
|
||||||
|
{
|
||||||
|
Automatic,
|
||||||
|
Never,
|
||||||
|
Always
|
||||||
|
}
|
||||||
|
|
||||||
|
internal class CompressionMetadata : ICompressionMetadata
|
||||||
|
{
|
||||||
|
public Type HandlerType { get; init; }
|
||||||
|
public CompressionPolicy Policy { get; init; }
|
||||||
|
public CompressionLevel? CustomLevel { get; init; }
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Implementation Details
|
||||||
|
|
||||||
|
#### 1. Metadata Discovery
|
||||||
|
|
||||||
|
**File:** `Svrnty.CQRS.Grpc/Discovery/CompressionMetadataProvider.cs`
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
namespace Svrnty.CQRS.Grpc;
|
||||||
|
|
||||||
|
internal class CompressionMetadataProvider
|
||||||
|
{
|
||||||
|
private readonly Dictionary<Type, ICompressionMetadata> _cache = new();
|
||||||
|
private readonly object _lock = new();
|
||||||
|
|
||||||
|
public ICompressionMetadata GetMetadata(Type handlerType)
|
||||||
|
{
|
||||||
|
if (_cache.TryGetValue(handlerType, out var metadata))
|
||||||
|
return metadata;
|
||||||
|
|
||||||
|
lock (_lock)
|
||||||
|
{
|
||||||
|
if (_cache.TryGetValue(handlerType, out metadata))
|
||||||
|
return metadata;
|
||||||
|
|
||||||
|
metadata = BuildMetadata(handlerType);
|
||||||
|
_cache[handlerType] = metadata;
|
||||||
|
return metadata;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private ICompressionMetadata BuildMetadata(Type handlerType)
|
||||||
|
{
|
||||||
|
var noCompression = handlerType.GetCustomAttribute<NoCompressionAttribute>();
|
||||||
|
var compressAlways = handlerType.GetCustomAttribute<CompressAlwaysAttribute>();
|
||||||
|
var customLevel = handlerType.GetCustomAttribute<CompressionLevelAttribute>();
|
||||||
|
|
||||||
|
if (noCompression != null && compressAlways != null)
|
||||||
|
{
|
||||||
|
throw new InvalidOperationException(
|
||||||
|
$"Handler {handlerType.Name} cannot have both [NoCompression] and [CompressAlways] attributes.");
|
||||||
|
}
|
||||||
|
|
||||||
|
var policy = CompressionPolicy.Automatic;
|
||||||
|
if (noCompression != null)
|
||||||
|
policy = CompressionPolicy.Never;
|
||||||
|
else if (compressAlways != null)
|
||||||
|
policy = CompressionPolicy.Always;
|
||||||
|
|
||||||
|
return new CompressionMetadata
|
||||||
|
{
|
||||||
|
HandlerType = handlerType,
|
||||||
|
Policy = policy,
|
||||||
|
CustomLevel = customLevel?.Level
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 2. Compression Interceptor
|
||||||
|
|
||||||
|
**File:** `Svrnty.CQRS.Grpc/Interceptors/CompressionInterceptor.cs`
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
namespace Svrnty.CQRS.Grpc;
|
||||||
|
|
||||||
|
internal class CompressionInterceptor : Interceptor
|
||||||
|
{
|
||||||
|
private readonly GrpcCompressionOptions _options;
|
||||||
|
private readonly CompressionMetadataProvider _metadataProvider;
|
||||||
|
private readonly ILogger<CompressionInterceptor> _logger;
|
||||||
|
|
||||||
|
public CompressionInterceptor(
|
||||||
|
IOptions<GrpcCompressionOptions> options,
|
||||||
|
CompressionMetadataProvider metadataProvider,
|
||||||
|
ILogger<CompressionInterceptor> logger)
|
||||||
|
{
|
||||||
|
_options = options.Value;
|
||||||
|
_metadataProvider = metadataProvider;
|
||||||
|
_logger = logger;
|
||||||
|
}
|
||||||
|
|
||||||
|
public override async Task<TResponse> UnaryServerHandler<TRequest, TResponse>(
|
||||||
|
TRequest request,
|
||||||
|
ServerCallContext context,
|
||||||
|
UnaryServerMethod<TRequest, TResponse> continuation)
|
||||||
|
{
|
||||||
|
var response = await continuation(request, context);
|
||||||
|
|
||||||
|
if (_options.EnableAutomaticCompression)
|
||||||
|
{
|
||||||
|
ApplyCompressionPolicy(context, response);
|
||||||
|
}
|
||||||
|
|
||||||
|
return response;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void ApplyCompressionPolicy<TResponse>(ServerCallContext context, TResponse response)
|
||||||
|
{
|
||||||
|
if (!context.UserState.TryGetValue("HandlerType", out var handlerTypeObj)
|
||||||
|
|| handlerTypeObj is not Type handlerType)
|
||||||
|
{
|
||||||
|
ApplyAutomaticCompression(context, response);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var metadata = _metadataProvider.GetMetadata(handlerType);
|
||||||
|
|
||||||
|
switch (metadata.Policy)
|
||||||
|
{
|
||||||
|
case CompressionPolicy.Never:
|
||||||
|
break;
|
||||||
|
|
||||||
|
case CompressionPolicy.Always:
|
||||||
|
SetCompression(context, metadata.CustomLevel ?? _options.DefaultCompressionLevel);
|
||||||
|
break;
|
||||||
|
|
||||||
|
case CompressionPolicy.Automatic:
|
||||||
|
ApplyAutomaticCompression(context, response, metadata.CustomLevel);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void ApplyAutomaticCompression<TResponse>(
|
||||||
|
ServerCallContext context,
|
||||||
|
TResponse response,
|
||||||
|
CompressionLevel? customLevel = null)
|
||||||
|
{
|
||||||
|
var messageSize = EstimateMessageSize(response);
|
||||||
|
|
||||||
|
if (messageSize >= _options.CompressionThresholdBytes)
|
||||||
|
{
|
||||||
|
SetCompression(context, customLevel ?? _options.DefaultCompressionLevel);
|
||||||
|
|
||||||
|
if (_options.EnableCompressionMetrics)
|
||||||
|
{
|
||||||
|
_logger.LogDebug(
|
||||||
|
"Compressing response of {Size} bytes (threshold: {Threshold})",
|
||||||
|
messageSize, _options.CompressionThresholdBytes);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void SetCompression(ServerCallContext context, CompressionLevel level)
|
||||||
|
{
|
||||||
|
var grpcLevel = level switch
|
||||||
|
{
|
||||||
|
CompressionLevel.Fastest => System.IO.Compression.CompressionLevel.Fastest,
|
||||||
|
CompressionLevel.Optimal => System.IO.Compression.CompressionLevel.Optimal,
|
||||||
|
CompressionLevel.SmallestSize => System.IO.Compression.CompressionLevel.SmallestSize,
|
||||||
|
_ => System.IO.Compression.CompressionLevel.Optimal
|
||||||
|
};
|
||||||
|
|
||||||
|
context.WriteOptions = new WriteOptions(flags: WriteFlags.NoCompress);
|
||||||
|
context.ResponseTrailers.Add("grpc-encoding", _options.CompressionAlgorithm);
|
||||||
|
}
|
||||||
|
|
||||||
|
private int EstimateMessageSize<TResponse>(TResponse response)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var json = System.Text.Json.JsonSerializer.Serialize(response);
|
||||||
|
return System.Text.Encoding.UTF8.GetByteCount(json);
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 3. gRPC Configuration Builder
|
||||||
|
|
||||||
|
**File:** `Svrnty.CQRS.Grpc/Configuration/GrpcOptionsBuilder.cs`
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
namespace Svrnty.CQRS.Grpc;
|
||||||
|
|
||||||
|
public class GrpcOptionsBuilder
|
||||||
|
{
|
||||||
|
private readonly IServiceCollection _services;
|
||||||
|
internal bool CompressionEnabled { get; private set; }
|
||||||
|
|
||||||
|
internal GrpcOptionsBuilder(IServiceCollection services)
|
||||||
|
{
|
||||||
|
_services = services;
|
||||||
|
}
|
||||||
|
|
||||||
|
public GrpcOptionsBuilder UseCompression(Action<GrpcCompressionOptions>? configure = null)
|
||||||
|
{
|
||||||
|
CompressionEnabled = true;
|
||||||
|
|
||||||
|
_services.AddSingleton<CompressionMetadataProvider>();
|
||||||
|
|
||||||
|
if (configure != null)
|
||||||
|
{
|
||||||
|
_services.Configure(configure);
|
||||||
|
}
|
||||||
|
|
||||||
|
_services.AddGrpc(options =>
|
||||||
|
{
|
||||||
|
options.Interceptors.Add<CompressionInterceptor>();
|
||||||
|
});
|
||||||
|
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public GrpcOptionsBuilder EnableReflection()
|
||||||
|
{
|
||||||
|
_services.AddGrpcReflection();
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 4. CQRS Configuration Builder
|
||||||
|
|
||||||
|
**File:** `Svrnty.CQRS/Configuration/CqrsOptionsBuilder.cs`
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
namespace Svrnty.CQRS;
|
||||||
|
|
||||||
|
public class CqrsOptionsBuilder
|
||||||
|
{
|
||||||
|
private readonly IServiceCollection _services;
|
||||||
|
|
||||||
|
internal CqrsOptionsBuilder(IServiceCollection services)
|
||||||
|
{
|
||||||
|
_services = services;
|
||||||
|
}
|
||||||
|
|
||||||
|
public CqrsOptionsBuilder AddGrpc(Action<GrpcOptionsBuilder> configure)
|
||||||
|
{
|
||||||
|
var grpcBuilder = new GrpcOptionsBuilder(_services);
|
||||||
|
configure(grpcBuilder);
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public CqrsOptionsBuilder AddMinimalApi()
|
||||||
|
{
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 5. Service Registration
|
||||||
|
|
||||||
|
**File:** `Svrnty.CQRS/ServiceCollectionExtensions.cs`
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
namespace Svrnty.CQRS;
|
||||||
|
|
||||||
|
public static class ServiceCollectionExtensions
|
||||||
|
{
|
||||||
|
public static IServiceCollection AddSvrntyCqrs(
|
||||||
|
this IServiceCollection services,
|
||||||
|
Action<CqrsOptionsBuilder>? configure = null)
|
||||||
|
{
|
||||||
|
services.AddDefaultCommandDiscovery();
|
||||||
|
services.AddDefaultQueryDiscovery();
|
||||||
|
|
||||||
|
if (configure != null)
|
||||||
|
{
|
||||||
|
var builder = new CqrsOptionsBuilder(services);
|
||||||
|
configure(builder);
|
||||||
|
}
|
||||||
|
|
||||||
|
return services;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 6. Source Generator Updates
|
||||||
|
|
||||||
|
The source generator needs to be updated to pass handler type information to the service implementation.
|
||||||
|
|
||||||
|
**File:** `Svrnty.CQRS.Grpc.Generators/ServiceImplementationGenerator.cs`
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
// In the generated service implementation, add handler type to context:
|
||||||
|
|
||||||
|
public override async Task<ExecuteCommandResponse> ExecuteCommand(
|
||||||
|
ExecuteCommandRequest request,
|
||||||
|
ServerCallContext context)
|
||||||
|
{
|
||||||
|
context.UserState["HandlerType"] = typeof(THandler);
|
||||||
|
|
||||||
|
// ... rest of implementation
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Usage Examples
|
||||||
|
|
||||||
|
### Example 1: Basic Setup with Default Compression
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
using Svrnty.CQRS;
|
||||||
|
using Svrnty.CQRS.Grpc;
|
||||||
|
|
||||||
|
var builder = WebApplication.CreateBuilder(args);
|
||||||
|
|
||||||
|
builder.Services.AddCommand<AddUserCommand, int, AddUserCommandHandler>();
|
||||||
|
builder.Services.AddQuery<FetchUserQuery, User, FetchUserQueryHandler>();
|
||||||
|
|
||||||
|
builder.Services.AddSvrntyCqrs(cqrs =>
|
||||||
|
{
|
||||||
|
cqrs.AddGrpc(grpc =>
|
||||||
|
{
|
||||||
|
grpc.UseCompression();
|
||||||
|
grpc.EnableReflection();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
var app = builder.Build();
|
||||||
|
|
||||||
|
app.UseSvrntyCqrs();
|
||||||
|
|
||||||
|
app.Run();
|
||||||
|
```
|
||||||
|
|
||||||
|
### Example 2: Custom Compression Configuration
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
builder.Services.AddSvrntyCqrs(cqrs =>
|
||||||
|
{
|
||||||
|
cqrs.AddGrpc(grpc =>
|
||||||
|
{
|
||||||
|
grpc.UseCompression(compression =>
|
||||||
|
{
|
||||||
|
compression.CompressionThresholdBytes = 5 * 1024;
|
||||||
|
compression.DefaultCompressionLevel = CompressionLevel.Fastest;
|
||||||
|
compression.EnableCompressionMetrics = true;
|
||||||
|
});
|
||||||
|
|
||||||
|
grpc.EnableReflection();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
### Example 3: Handler with No Compression (Binary Data)
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
using Svrnty.CQRS.Grpc.Abstractions;
|
||||||
|
|
||||||
|
[NoCompression]
|
||||||
|
public class GetProfileImageQueryHandler : IQueryHandler<GetProfileImageQuery, byte[]>
|
||||||
|
{
|
||||||
|
public async Task<byte[]> HandleAsync(GetProfileImageQuery query, CancellationToken ct)
|
||||||
|
{
|
||||||
|
return await LoadImage(query.UserId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Example 4: Handler with Always Compress (Large Text)
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
[CompressAlways]
|
||||||
|
[CompressionLevel(CompressionLevel.SmallestSize)]
|
||||||
|
public class ExportDataQueryHandler : IQueryHandler<ExportDataQuery, string>
|
||||||
|
{
|
||||||
|
public async Task<string> HandleAsync(ExportDataQuery query, CancellationToken ct)
|
||||||
|
{
|
||||||
|
return await GenerateCsvExport();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Example 5: Default Automatic Compression
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
public class GetUsersQueryHandler : IQueryHandler<GetUsersQuery, List<User>>
|
||||||
|
{
|
||||||
|
public async Task<List<User>> HandleAsync(GetUsersQuery query, CancellationToken ct)
|
||||||
|
{
|
||||||
|
return await GetUsers();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Example 6: Both gRPC and HTTP (Compression Only for gRPC)
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
builder.Services.AddSvrntyCqrs(cqrs =>
|
||||||
|
{
|
||||||
|
cqrs.AddGrpc(grpc =>
|
||||||
|
{
|
||||||
|
grpc.UseCompression(compression =>
|
||||||
|
{
|
||||||
|
compression.CompressionThresholdBytes = 2 * 1024;
|
||||||
|
});
|
||||||
|
grpc.EnableReflection();
|
||||||
|
});
|
||||||
|
|
||||||
|
cqrs.AddMinimalApi();
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
## Migration & Backward Compatibility
|
||||||
|
|
||||||
|
### Breaking Changes
|
||||||
|
**None.** This feature is fully backward compatible.
|
||||||
|
|
||||||
|
### Default Behavior
|
||||||
|
- Compression is NOT enabled by default
|
||||||
|
- Must call `.UseCompression()` to enable
|
||||||
|
- Once enabled, uses automatic compression with 1KB threshold
|
||||||
|
- Existing handlers work without modification
|
||||||
|
|
||||||
|
### Migration Path
|
||||||
|
1. Update to new version
|
||||||
|
2. Add `.UseCompression()` to gRPC configuration
|
||||||
|
3. Optionally add `[NoCompression]` to handlers with binary data
|
||||||
|
4. Optionally add `[CompressAlways]` to handlers with large text
|
||||||
|
5. Optionally configure custom threshold/levels
|
||||||
|
|
||||||
|
## Success Criteria
|
||||||
|
|
||||||
|
### Functional Requirements
|
||||||
|
- ✅ Automatic compression for messages > 1KB (configurable)
|
||||||
|
- ✅ `[NoCompression]` attribute prevents compression
|
||||||
|
- ✅ `[CompressAlways]` attribute forces compression
|
||||||
|
- ✅ `[CompressionLevel]` attribute sets custom level
|
||||||
|
- ✅ Configuration via fluent API: `AddSvrntyCqrs().AddGrpc().UseCompression()`
|
||||||
|
- ✅ No breaking changes to existing APIs
|
||||||
|
- ✅ Interceptor automatically registered when calling `UseCompression()`
|
||||||
|
|
||||||
|
### Performance Requirements
|
||||||
|
- ✅ Compression overhead < 5ms for 100KB messages
|
||||||
|
- ✅ 50-80% size reduction for text-heavy payloads
|
||||||
|
- ✅ No measurable impact for small messages (< 1KB)
|
||||||
|
|
||||||
|
## Documentation Requirements
|
||||||
|
|
||||||
|
### User Documentation
|
||||||
|
- Update README.md with compression feature in 2026 roadmap
|
||||||
|
- Update CLAUDE.md with compression configuration details
|
||||||
|
- Add compression examples to sample project
|
||||||
|
|
||||||
|
## Implementation Checklist
|
||||||
|
|
||||||
|
### Phase 1: Core Implementation
|
||||||
|
- [ ] Create compression attributes in `Svrnty.CQRS.Grpc.Abstractions`
|
||||||
|
- [ ] Create `GrpcCompressionOptions` configuration class
|
||||||
|
- [ ] Create `GrpcOptionsBuilder` with `UseCompression()` method
|
||||||
|
- [ ] Update `CqrsOptionsBuilder` to support `AddGrpc()`
|
||||||
|
- [ ] Implement `CompressionMetadataProvider` for discovering attributes
|
||||||
|
- [ ] Implement `CompressionInterceptor` for applying compression
|
||||||
|
- [ ] Ensure interceptor is automatically registered via `UseCompression()`
|
||||||
|
|
||||||
|
### Phase 2: Source Generator
|
||||||
|
- [ ] Update source generator to pass handler type to context
|
||||||
|
- [ ] Set `HandlerType` in `ServerCallContext.UserState`
|
||||||
|
|
||||||
|
### Phase 3: Documentation
|
||||||
|
- [ ] Update README.md with 2026 roadmap item
|
||||||
|
- [ ] Update CLAUDE.md with compression details
|
||||||
|
- [ ] Update sample project with compression examples
|
||||||
|
- [ ] Create migration guide
|
||||||
|
|
||||||
|
### Phase 4: Release
|
||||||
|
- [ ] Code review and feedback
|
||||||
|
- [ ] Merge to main branch
|
||||||
|
- [ ] Release as part of next minor version
|
||||||
|
- [ ] Publish NuGet packages
|
||||||
|
|
||||||
|
## Dependencies
|
||||||
|
|
||||||
|
### NuGet Packages
|
||||||
|
- `Grpc.AspNetCore` >= 2.68.0 (already referenced)
|
||||||
|
- `System.IO.Compression` (part of .NET runtime)
|
||||||
|
|
||||||
|
### Internal Dependencies
|
||||||
|
- `Svrnty.CQRS` (for configuration builders)
|
||||||
|
- `Svrnty.CQRS.Grpc.Abstractions` (for attributes)
|
||||||
|
- `Svrnty.CQRS.Grpc` (for implementation)
|
||||||
|
- `Svrnty.CQRS.Grpc.Generators` (source generator updates)
|
||||||
|
|
||||||
|
## Open Questions
|
||||||
|
|
||||||
|
1. **Message Size Estimation**: Should we use protobuf serialization size or JSON estimation?
|
||||||
|
- **Proposed**: Use protobuf for accuracy, fallback to JSON estimate if not available
|
||||||
|
|
||||||
|
2. **HTTP Compression**: Should we also support compression for Minimal API endpoints?
|
||||||
|
- **Proposed**: Phase 2 - HTTP already has built-in compression via ASP.NET Core
|
||||||
|
|
||||||
|
3. **Compression Metrics**: What metrics should we expose?
|
||||||
|
- **Proposed**: Compressed vs uncompressed size, compression ratio, compression time
|
||||||
|
|
||||||
|
4. **Per-Message Override**: Should clients be able to request no compression via metadata?
|
||||||
|
- **Proposed**: Phase 2 - respect `grpc-accept-encoding` header
|
||||||
|
|
||||||
|
## Future Enhancements (Beyond 2026)
|
||||||
|
|
||||||
|
1. **Multiple Compression Algorithms**: Support Brotli, LZ4, Snappy
|
||||||
|
2. **Adaptive Compression**: Machine learning to predict best compression strategy
|
||||||
|
3. **Client Hints**: Honor client compression preferences
|
||||||
|
4. **Compression Metrics Dashboard**: Visual metrics for compression effectiveness
|
||||||
|
5. **HTTP Compression**: Extend to Minimal API endpoints with same attribute model
|
||||||
|
6. **Streaming Compression**: Apply compression to streaming RPCs
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**Document Version:** 1.0
|
||||||
|
**Last Updated:** 2025-01-08
|
||||||
|
**Author:** Svrnty Team
|
||||||
610
roadmap-2026/metadata-and-authorization-draft.md
Normal file
610
roadmap-2026/metadata-and-authorization-draft.md
Normal file
@ -0,0 +1,610 @@
|
|||||||
|
# gRPC Metadata & Authorization Support - Implementation Plan (DRAFT)
|
||||||
|
|
||||||
|
**Status:** Draft (Option 4 Preferred)
|
||||||
|
**Target:** Q1 2026
|
||||||
|
**Priority:** High
|
||||||
|
**Complexity:** Medium
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
Expose gRPC metadata and `ServerCallContext` to handlers, and integrate the existing `ICommandAuthorizationService` and `IQueryAuthorizationService` with gRPC endpoints. This will enable authentication, authorization, distributed tracing, multi-tenancy, and other context-aware features in gRPC services.
|
||||||
|
|
||||||
|
## Problem Statement
|
||||||
|
|
||||||
|
### Current Limitations
|
||||||
|
|
||||||
|
1. **No Authorization in gRPC:**
|
||||||
|
- `ICommandAuthorizationService` and `IQueryAuthorizationService` exist but are only used by Minimal API
|
||||||
|
- gRPC services skip authorization checks entirely
|
||||||
|
- Security gap for gRPC endpoints
|
||||||
|
|
||||||
|
2. **No Context Access:**
|
||||||
|
- Handlers cannot access `ServerCallContext`
|
||||||
|
- Cannot read request metadata (headers, auth tokens, correlation IDs)
|
||||||
|
- Cannot set response metadata
|
||||||
|
- Cannot access user identity/claims
|
||||||
|
- Cannot determine caller information
|
||||||
|
|
||||||
|
3. **Authorization Interface Limitation:**
|
||||||
|
```csharp
|
||||||
|
Task<AuthorizationResult> IsAllowedAsync(Type commandType, CancellationToken ct);
|
||||||
|
```
|
||||||
|
- No access to user context
|
||||||
|
- No access to request metadata
|
||||||
|
- Cannot implement context-aware authorization
|
||||||
|
|
||||||
|
## Goals
|
||||||
|
|
||||||
|
1. **Expose Context to Handlers:** Allow handlers to access `ServerCallContext` via accessor service
|
||||||
|
2. **Integrate Authorization:** Call authorization services before executing handlers in gRPC
|
||||||
|
3. **Maintain Backward Compatibility:** Existing handlers continue to work without changes
|
||||||
|
4. **Enable Advanced Scenarios:** Multi-tenancy, distributed tracing, custom authentication
|
||||||
|
5. **Familiar Pattern:** Use accessor pattern similar to ASP.NET Core's `IHttpContextAccessor`
|
||||||
|
|
||||||
|
## Recommended Approach: Context Accessor Pattern (Option 4)
|
||||||
|
|
||||||
|
**PREFERRED APPROACH** - Use ambient context accessor similar to `IHttpContextAccessor` in ASP.NET Core.
|
||||||
|
|
||||||
|
### Why Option 4?
|
||||||
|
|
||||||
|
- ✅ **Zero breaking changes** - No handler interface modifications
|
||||||
|
- ✅ **Opt-in** - Only inject accessor when context is needed
|
||||||
|
- ✅ **Familiar pattern** - Mirrors `IHttpContextAccessor` developers already know
|
||||||
|
- ✅ **Full protocol access** - Can use raw `ServerCallContext` or `HttpContext` features
|
||||||
|
- ✅ **Flexible** - Handlers that don't need context remain simple
|
||||||
|
|
||||||
|
### Implementation Overview
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
// New accessor interface
|
||||||
|
public interface ICqrsContextAccessor
|
||||||
|
{
|
||||||
|
ServerCallContext? GrpcContext { get; }
|
||||||
|
HttpContext? HttpContext { get; }
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handlers opt-in by injecting the accessor
|
||||||
|
public class AddUserCommandHandler : ICommandHandler<AddUserCommand, int>
|
||||||
|
{
|
||||||
|
private readonly ICqrsContextAccessor _context;
|
||||||
|
|
||||||
|
public AddUserCommandHandler(ICqrsContextAccessor context)
|
||||||
|
{
|
||||||
|
_context = context;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<int> HandleAsync(AddUserCommand command, CancellationToken ct)
|
||||||
|
{
|
||||||
|
// Access gRPC metadata when available
|
||||||
|
if (_context.GrpcContext != null)
|
||||||
|
{
|
||||||
|
var token = _context.GrpcContext.RequestHeaders.GetValue("authorization");
|
||||||
|
var userId = ExtractUserIdFromToken(token);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Or access HTTP context when available
|
||||||
|
if (_context.HttpContext != null)
|
||||||
|
{
|
||||||
|
var userId = _context.HttpContext.User?.Identity?.Name;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handler logic...
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
See full implementation details in [Option 4 section](#option-4-context-accessor-pattern-ambient-context) below.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Alternative Design Options (For Reference)
|
||||||
|
|
||||||
|
### Option 1: Optional ServerCallContext Parameter
|
||||||
|
|
||||||
|
**Approach:** Add optional `ServerCallContext` parameter to handler interface methods.
|
||||||
|
|
||||||
|
**Handler Interface Changes:**
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
namespace Svrnty.CQRS.Abstractions;
|
||||||
|
|
||||||
|
public interface ICommandHandler<TCommand>
|
||||||
|
{
|
||||||
|
Task HandleAsync(TCommand command, CancellationToken cancellationToken = default, ServerCallContext? context = null);
|
||||||
|
}
|
||||||
|
|
||||||
|
public interface ICommandHandler<TCommand, TResult>
|
||||||
|
{
|
||||||
|
Task<TResult> HandleAsync(TCommand command, CancellationToken cancellationToken = default, ServerCallContext? context = null);
|
||||||
|
}
|
||||||
|
|
||||||
|
public interface IQueryHandler<TQuery, TResult>
|
||||||
|
{
|
||||||
|
Task<TResult> HandleAsync(TQuery query, CancellationToken cancellationToken = default, ServerCallContext? context = null);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Pros:**
|
||||||
|
- ✅ Simple and straightforward
|
||||||
|
- ✅ Backward compatible (optional parameter with default null)
|
||||||
|
- ✅ Handlers can explicitly access context when needed
|
||||||
|
- ✅ No dependency on HTTP types in core abstractions
|
||||||
|
|
||||||
|
**Cons:**
|
||||||
|
- ❌ Couples abstractions to gRPC (requires `Grpc.Core` reference)
|
||||||
|
- ❌ Minimal API handlers won't have access to `HttpContext` through same parameter
|
||||||
|
- ❌ Different context types for HTTP vs gRPC
|
||||||
|
|
||||||
|
**Authorization Service Changes:**
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
namespace Svrnty.CQRS.Abstractions.Security;
|
||||||
|
|
||||||
|
public interface ICommandAuthorizationService
|
||||||
|
{
|
||||||
|
Task<AuthorizationResult> IsAllowedAsync(Type commandType, object? context, CancellationToken ct = default);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Where `context` is:
|
||||||
|
- `ServerCallContext` for gRPC
|
||||||
|
- `HttpContext` for Minimal API
|
||||||
|
- `null` if no context available
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Option 2: Unified Context Abstraction
|
||||||
|
|
||||||
|
**Approach:** Create abstraction over both `ServerCallContext` and `HttpContext`.
|
||||||
|
|
||||||
|
**New Abstraction:**
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
namespace Svrnty.CQRS.Abstractions;
|
||||||
|
|
||||||
|
public interface IRequestContext
|
||||||
|
{
|
||||||
|
string? GetHeader(string name);
|
||||||
|
void SetHeader(string name, string value);
|
||||||
|
IDictionary<string, object> Items { get; }
|
||||||
|
CancellationToken CancellationToken { get; }
|
||||||
|
IUserContext? User { get; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public interface IUserContext
|
||||||
|
{
|
||||||
|
bool IsAuthenticated { get; }
|
||||||
|
string? UserId { get; }
|
||||||
|
IEnumerable<string> Roles { get; }
|
||||||
|
IEnumerable<Claim> Claims { get; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public interface ICommandHandler<TCommand>
|
||||||
|
{
|
||||||
|
Task HandleAsync(TCommand command, IRequestContext? context = null, CancellationToken ct = default);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Implementation Wrappers:**
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
internal class GrpcRequestContext : IRequestContext
|
||||||
|
{
|
||||||
|
private readonly ServerCallContext _grpcContext;
|
||||||
|
|
||||||
|
public GrpcRequestContext(ServerCallContext context)
|
||||||
|
{
|
||||||
|
_grpcContext = context;
|
||||||
|
}
|
||||||
|
|
||||||
|
public string? GetHeader(string name) => _grpcContext.RequestHeaders.GetValue(name);
|
||||||
|
public CancellationToken CancellationToken => _grpcContext.CancellationToken;
|
||||||
|
// ... map other properties
|
||||||
|
}
|
||||||
|
|
||||||
|
internal class HttpRequestContext : IRequestContext
|
||||||
|
{
|
||||||
|
private readonly HttpContext _httpContext;
|
||||||
|
// ... similar mapping
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Pros:**
|
||||||
|
- ✅ Protocol-agnostic handlers
|
||||||
|
- ✅ Unified authorization interface
|
||||||
|
- ✅ No coupling to gRPC or HTTP in abstractions
|
||||||
|
- ✅ Can support future protocols (WebSockets, etc.)
|
||||||
|
|
||||||
|
**Cons:**
|
||||||
|
- ❌ Additional abstraction layer (more complexity)
|
||||||
|
- ❌ Cannot access protocol-specific features (e.g., gRPC deadlines, HTTP response codes)
|
||||||
|
- ❌ Mapping overhead between contexts
|
||||||
|
- ❌ Leaky abstraction if protocol-specific needs arise
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Option 3: Separate gRPC-Aware Handler Interfaces
|
||||||
|
|
||||||
|
**Approach:** Keep existing handlers unchanged, add new gRPC-specific interfaces.
|
||||||
|
|
||||||
|
**New Interfaces:**
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
namespace Svrnty.CQRS.Grpc.Abstractions;
|
||||||
|
|
||||||
|
public interface IGrpcCommandHandler<TCommand>
|
||||||
|
{
|
||||||
|
Task HandleAsync(TCommand command, ServerCallContext context, CancellationToken ct = default);
|
||||||
|
}
|
||||||
|
|
||||||
|
public interface IGrpcCommandHandler<TCommand, TResult>
|
||||||
|
{
|
||||||
|
Task<TResult> HandleAsync(TCommand command, ServerCallContext context, CancellationToken ct = default);
|
||||||
|
}
|
||||||
|
|
||||||
|
public interface IGrpcQueryHandler<TQuery, TResult>
|
||||||
|
{
|
||||||
|
Task<TResult> HandleAsync(TQuery query, ServerCallContext context, CancellationToken ct = default);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Pros:**
|
||||||
|
- ✅ No changes to core abstractions
|
||||||
|
- ✅ Explicit opt-in for context access
|
||||||
|
- ✅ Can access full `ServerCallContext` API
|
||||||
|
- ✅ Clear separation between gRPC and HTTP handlers
|
||||||
|
|
||||||
|
**Cons:**
|
||||||
|
- ❌ Handler duplication (need to implement both interfaces if supporting HTTP + gRPC)
|
||||||
|
- ❌ Discovery system needs to handle multiple handler types
|
||||||
|
- ❌ More complex registration (which handler for which protocol?)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Option 4: Context Accessor Pattern (Ambient Context)
|
||||||
|
|
||||||
|
**Approach:** Use service locator/accessor pattern for context access.
|
||||||
|
|
||||||
|
**Implementation:**
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
namespace Svrnty.CQRS.Abstractions;
|
||||||
|
|
||||||
|
public interface ICommandContextAccessor
|
||||||
|
{
|
||||||
|
ServerCallContext? GrpcContext { get; }
|
||||||
|
HttpContext? HttpContext { get; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public class MyCommandHandler : ICommandHandler<MyCommand>
|
||||||
|
{
|
||||||
|
private readonly ICommandContextAccessor _contextAccessor;
|
||||||
|
|
||||||
|
public MyCommandHandler(ICommandContextAccessor contextAccessor)
|
||||||
|
{
|
||||||
|
_contextAccessor = contextAccessor;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task HandleAsync(MyCommand command, CancellationToken ct)
|
||||||
|
{
|
||||||
|
var grpcContext = _contextAccessor.GrpcContext;
|
||||||
|
if (grpcContext != null)
|
||||||
|
{
|
||||||
|
// Access gRPC-specific features
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Pros:**
|
||||||
|
- ✅ No changes to handler interfaces
|
||||||
|
- ✅ Fully backward compatible
|
||||||
|
- ✅ Handlers can access context only when needed
|
||||||
|
|
||||||
|
**Cons:**
|
||||||
|
- ❌ Ambient context is an anti-pattern (hidden dependency)
|
||||||
|
- ❌ Harder to test (need to mock accessor)
|
||||||
|
- ❌ AsyncLocal overhead for context flow
|
||||||
|
- ❌ Not explicit in handler signature
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Authorization Integration
|
||||||
|
|
||||||
|
Regardless of which option is chosen, authorization needs to be integrated into the gRPC source generator.
|
||||||
|
|
||||||
|
### Source Generator Changes
|
||||||
|
|
||||||
|
**File:** `Svrnty.CQRS.Grpc.Generators/ServiceImplementationGenerator.cs`
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
public override async Task<AddUserResponse> AddUser(
|
||||||
|
AddUserRequest request,
|
||||||
|
ServerCallContext context)
|
||||||
|
{
|
||||||
|
var command = new AddUserCommand
|
||||||
|
{
|
||||||
|
Name = request.Name,
|
||||||
|
Email = request.Email
|
||||||
|
};
|
||||||
|
|
||||||
|
// AUTHORIZATION CHECK
|
||||||
|
var authService = _serviceProvider.GetService<ICommandAuthorizationService>();
|
||||||
|
if (authService != null)
|
||||||
|
{
|
||||||
|
var authResult = await authService.IsAllowedAsync(typeof(AddUserCommand), context, context.CancellationToken);
|
||||||
|
|
||||||
|
if (authResult == AuthorizationResult.Unauthorized)
|
||||||
|
{
|
||||||
|
throw new RpcException(new Status(StatusCode.Unauthenticated, "Authentication required"));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (authResult == AuthorizationResult.Forbidden)
|
||||||
|
{
|
||||||
|
throw new RpcException(new Status(StatusCode.PermissionDenied, "Insufficient permissions"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validation (existing)
|
||||||
|
var validator = _serviceProvider.GetService<IValidator<AddUserCommand>>();
|
||||||
|
if (validator != null)
|
||||||
|
{
|
||||||
|
var validationResult = await validator.ValidateAsync(command, context.CancellationToken);
|
||||||
|
if (!validationResult.IsValid)
|
||||||
|
{
|
||||||
|
// ... existing validation error handling
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Execute handler
|
||||||
|
var handler = _serviceProvider.GetRequiredService<ICommandHandler<AddUserCommand, int>>();
|
||||||
|
var result = await handler.HandleAsync(command, context.CancellationToken, context); // Option 1
|
||||||
|
|
||||||
|
return new AddUserResponse { Result = result };
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Authorization Service Update
|
||||||
|
|
||||||
|
**Current Interface:**
|
||||||
|
```csharp
|
||||||
|
public interface ICommandAuthorizationService
|
||||||
|
{
|
||||||
|
Task<AuthorizationResult> IsAllowedAsync(Type commandType, CancellationToken ct = default);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Proposed Interface:**
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
public interface ICommandAuthorizationService
|
||||||
|
{
|
||||||
|
Task<AuthorizationResult> IsAllowedAsync(
|
||||||
|
Type commandType,
|
||||||
|
object? requestContext = null,
|
||||||
|
CancellationToken ct = default);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Where `requestContext` can be:
|
||||||
|
- `ServerCallContext` for gRPC
|
||||||
|
- `HttpContext` for HTTP
|
||||||
|
- Custom context for other scenarios
|
||||||
|
- `null` if no context
|
||||||
|
|
||||||
|
**Breaking Change Mitigation:**
|
||||||
|
|
||||||
|
Add new method with default parameter to maintain backward compatibility:
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
public interface ICommandAuthorizationService
|
||||||
|
{
|
||||||
|
// Old method (deprecated but still supported)
|
||||||
|
Task<AuthorizationResult> IsAllowedAsync(Type commandType, CancellationToken ct = default);
|
||||||
|
|
||||||
|
// New method with context
|
||||||
|
Task<AuthorizationResult> IsAllowedAsync(Type commandType, object? requestContext, CancellationToken ct = default);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Default implementation:
|
||||||
|
```csharp
|
||||||
|
public abstract class CommandAuthorizationServiceBase : ICommandAuthorizationService
|
||||||
|
{
|
||||||
|
public virtual Task<AuthorizationResult> IsAllowedAsync(Type commandType, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
return IsAllowedAsync(commandType, null, ct);
|
||||||
|
}
|
||||||
|
|
||||||
|
public abstract Task<AuthorizationResult> IsAllowedAsync(Type commandType, object? requestContext, CancellationToken ct);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Implementation Phases
|
||||||
|
|
||||||
|
### Phase 1: Context Access (Choose Design Option)
|
||||||
|
- [ ] Decide on design option (1, 2, 3, or 4)
|
||||||
|
- [ ] Update handler interfaces (if needed)
|
||||||
|
- [ ] Update source generator to pass context to handlers
|
||||||
|
- [ ] Create context wrappers/abstractions (if needed)
|
||||||
|
- [ ] Update Minimal API to work with new approach
|
||||||
|
|
||||||
|
### Phase 2: Authorization Service Update
|
||||||
|
- [ ] Update `ICommandAuthorizationService` and `IQueryAuthorizationService` interfaces
|
||||||
|
- [ ] Provide backward-compatible base class
|
||||||
|
- [ ] Update Minimal API to pass context to authorization services
|
||||||
|
|
||||||
|
### Phase 3: gRPC Authorization Integration
|
||||||
|
- [ ] Update source generator to call authorization services
|
||||||
|
- [ ] Map authorization results to gRPC status codes
|
||||||
|
- [ ] Handle `Unauthenticated` (401) → `StatusCode.Unauthenticated`
|
||||||
|
- [ ] Handle `Forbidden` (403) → `StatusCode.PermissionDenied`
|
||||||
|
|
||||||
|
### Phase 4: Sample Implementation
|
||||||
|
- [ ] Create sample authorization service implementation
|
||||||
|
- [ ] Demonstrate JWT token validation in gRPC
|
||||||
|
- [ ] Demonstrate role-based authorization
|
||||||
|
- [ ] Update sample project with authorization examples
|
||||||
|
|
||||||
|
### Phase 5: Documentation
|
||||||
|
- [ ] Update CLAUDE.md with authorization details
|
||||||
|
- [ ] Document how to implement custom authorization services
|
||||||
|
- [ ] Document how to access context in handlers
|
||||||
|
- [ ] Create migration guide
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Open Questions
|
||||||
|
|
||||||
|
### 1. Which design option should we use?
|
||||||
|
**Question:** Option 1 (optional parameter), Option 2 (unified abstraction), Option 3 (separate interfaces), or Option 4 (accessor pattern)?
|
||||||
|
|
||||||
|
**Decision:** ✅ **Option 4 (Context Accessor Pattern)** - Chosen for backward compatibility and familiar ASP.NET Core pattern.
|
||||||
|
|
||||||
|
### 2. Should we support accessing HttpContext in handlers?
|
||||||
|
**Question:** If a handler wants to access HTTP-specific features, should we support that through the same mechanism?
|
||||||
|
|
||||||
|
**Decision:** ✅ **Yes** - `ICqrsContextAccessor` will expose both `GrpcContext` and `HttpContext` properties. Handlers can check which is non-null to determine the protocol.
|
||||||
|
|
||||||
|
### 3. Breaking changes to authorization interface?
|
||||||
|
**Question:** Is it acceptable to add a parameter to the authorization interface?
|
||||||
|
|
||||||
|
**Options:**
|
||||||
|
- Add new method, keep old one (backward compatible but verbose)
|
||||||
|
- Add optional parameter with default `null` (semi-breaking for implementations)
|
||||||
|
- Create v2 interfaces (`ICommandAuthorizationServiceV2`)
|
||||||
|
|
||||||
|
### 4. How should we handle multi-protocol handlers?
|
||||||
|
**Question:** If a handler is used by both HTTP and gRPC, how does it differentiate?
|
||||||
|
|
||||||
|
**Decision:** ✅ **Check which context is non-null** - Simple null check: `if (_context.GrpcContext != null)` vs `if (_context.HttpContext != null)`
|
||||||
|
|
||||||
|
### 5. Should metadata access be strongly typed?
|
||||||
|
**Question:** Should we provide typed accessors for common metadata (JWT, correlation ID, etc.)?
|
||||||
|
|
||||||
|
**Options:**
|
||||||
|
- Raw string access only: `context.GetHeader("authorization")`
|
||||||
|
- Typed extensions: `context.GetAuthorizationToken()`, `context.GetCorrelationId()`
|
||||||
|
- Dedicated metadata services
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Success Criteria
|
||||||
|
|
||||||
|
### Functional Requirements
|
||||||
|
- ✅ Handlers can access `ServerCallContext` when needed
|
||||||
|
- ✅ Authorization services are called before gRPC handler execution
|
||||||
|
- ✅ Authorization failures return appropriate gRPC status codes
|
||||||
|
- ✅ Backward compatibility maintained for existing handlers
|
||||||
|
- ✅ Works with both HTTP and gRPC (if unified approach)
|
||||||
|
|
||||||
|
### Security Requirements
|
||||||
|
- ✅ Unauthorized requests return `StatusCode.Unauthenticated`
|
||||||
|
- ✅ Forbidden requests return `StatusCode.PermissionDenied`
|
||||||
|
- ✅ Authorization decisions can access user context
|
||||||
|
- ✅ JWT tokens can be validated from gRPC metadata
|
||||||
|
|
||||||
|
### Performance Requirements
|
||||||
|
- ✅ Minimal overhead for context passing
|
||||||
|
- ✅ No performance regression for handlers that don't use context
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Migration & Backward Compatibility
|
||||||
|
|
||||||
|
### Breaking Changes
|
||||||
|
|
||||||
|
**With Option 4:** ✅ **NO BREAKING CHANGES**
|
||||||
|
|
||||||
|
- Handler interfaces remain unchanged
|
||||||
|
- Existing handlers work without modification
|
||||||
|
- Authorization interface adds optional parameter (backward compatible)
|
||||||
|
|
||||||
|
### Migration Path
|
||||||
|
|
||||||
|
**For Option 4 (Chosen):**
|
||||||
|
1. Update to new version
|
||||||
|
2. All existing handlers continue to work unchanged
|
||||||
|
3. Handlers that need context inject `ICqrsContextAccessor` via constructor
|
||||||
|
4. Authorization service implementations can optionally use the new context parameter
|
||||||
|
5. No code changes required for basic upgrade
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Dependencies
|
||||||
|
|
||||||
|
### NuGet Packages
|
||||||
|
- `Grpc.AspNetCore` >= 2.68.0 (already referenced)
|
||||||
|
- `Microsoft.AspNetCore.Http.Abstractions` (for `HttpContext`, if using unified abstraction)
|
||||||
|
|
||||||
|
### Internal Dependencies
|
||||||
|
- `Svrnty.CQRS.Abstractions` (handler interfaces, authorization interfaces)
|
||||||
|
- `Svrnty.CQRS.Grpc` (context wrappers, if needed)
|
||||||
|
- `Svrnty.CQRS.Grpc.Generators` (source generator updates)
|
||||||
|
- `Svrnty.CQRS.MinimalApi` (authorization integration updates)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Final Recommended Approach
|
||||||
|
|
||||||
|
Based on preference for **Option 4 (Context Accessor Pattern)**:
|
||||||
|
|
||||||
|
### Implementation Summary
|
||||||
|
|
||||||
|
1. **Create `ICqrsContextAccessor` interface:**
|
||||||
|
```csharp
|
||||||
|
public interface ICqrsContextAccessor
|
||||||
|
{
|
||||||
|
ServerCallContext? GrpcContext { get; }
|
||||||
|
HttpContext? HttpContext { get; }
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
2. **Implement accessor with AsyncLocal:**
|
||||||
|
- Use `AsyncLocal<T>` to flow context through async calls
|
||||||
|
- Similar to ASP.NET Core's `IHttpContextAccessor` implementation
|
||||||
|
- Set context in gRPC interceptor and HTTP middleware
|
||||||
|
|
||||||
|
3. **Update authorization interface** with context parameter:
|
||||||
|
```csharp
|
||||||
|
Task<AuthorizationResult> IsAllowedAsync(
|
||||||
|
Type commandType,
|
||||||
|
object? requestContext = null,
|
||||||
|
CancellationToken ct = default);
|
||||||
|
```
|
||||||
|
|
||||||
|
4. **Update source generator** to:
|
||||||
|
- Set context in accessor before calling handler
|
||||||
|
- Call authorization service with `ServerCallContext`
|
||||||
|
- Map authorization results to gRPC status codes
|
||||||
|
|
||||||
|
5. **Provide sample implementations** for:
|
||||||
|
- JWT token validation from gRPC metadata
|
||||||
|
- Role-based authorization
|
||||||
|
- Multi-tenancy with tenant ID from headers
|
||||||
|
- Correlation ID extraction and propagation
|
||||||
|
|
||||||
|
### Advantages
|
||||||
|
|
||||||
|
- ✅ **Zero breaking changes** - No handler signature modifications required
|
||||||
|
- ✅ **Familiar to ASP.NET Core developers** - Same pattern as `IHttpContextAccessor`
|
||||||
|
- ✅ **Opt-in complexity** - Simple handlers remain simple
|
||||||
|
- ✅ **Full protocol support** - Access raw gRPC and HTTP contexts
|
||||||
|
- ✅ **Testable** - Can mock `ICqrsContextAccessor` in tests
|
||||||
|
|
||||||
|
### Next Steps
|
||||||
|
|
||||||
|
1. Finalize accessor interface design
|
||||||
|
2. Implement AsyncLocal-based accessor
|
||||||
|
3. Update source generator for context setting
|
||||||
|
4. Integrate authorization checks
|
||||||
|
5. Create comprehensive samples
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**Document Version:** 0.2 (DRAFT - Option 4 Selected)
|
||||||
|
**Last Updated:** 2025-01-08
|
||||||
|
**Author:** Svrnty Team
|
||||||
|
**Status:** Ready for Implementation Planning
|
||||||
Loading…
Reference in New Issue
Block a user