From dea62c2434cad913d7d9136a57525849dbe431c0 Mon Sep 17 00:00:00 2001 From: Mathias Beaulieu-Duncan Date: Sat, 8 Nov 2025 13:29:03 -0500 Subject: [PATCH] added roadmap and plans --- .claude/settings.local.json | 3 +- README.md | 9 +- roadmap-2026/compression.md | 640 ++++++++++++++++++ .../metadata-and-authorization-draft.md | 610 +++++++++++++++++ 4 files changed, 1260 insertions(+), 2 deletions(-) create mode 100644 roadmap-2026/compression.md create mode 100644 roadmap-2026/metadata-and-authorization-draft.md diff --git a/.claude/settings.local.json b/.claude/settings.local.json index 5b1f498..74814c8 100644 --- a/.claude/settings.local.json +++ b/.claude/settings.local.json @@ -41,7 +41,8 @@ "WebFetch(domain:www.kenmuse.com)", "WebFetch(domain:blog.rsuter.com)", "WebFetch(domain:natemcmaster.com)", - "WebFetch(domain:www.nuget.org)" + "WebFetch(domain:www.nuget.org)", + "Bash(mkdir:*)" ], "deny": [], "ask": [] diff --git a/README.md b/README.md index dc8d04e..60dce70 100644 --- a/README.md +++ b/README.md @@ -264,4 +264,11 @@ builder.Services.AddTransient, EchoCommandValidator>(); | 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. | ✅ | | 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. | ⬜️ | \ No newline at end of file +| 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. | ⬜️ | \ No newline at end of file diff --git a/roadmap-2026/compression.md b/roadmap-2026/compression.md new file mode 100644 index 0000000..475edcf --- /dev/null +++ b/roadmap-2026/compression.md @@ -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 _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(); + var compressAlways = handlerType.GetCustomAttribute(); + var customLevel = handlerType.GetCustomAttribute(); + + 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 _logger; + + public CompressionInterceptor( + IOptions options, + CompressionMetadataProvider metadataProvider, + ILogger logger) + { + _options = options.Value; + _metadataProvider = metadataProvider; + _logger = logger; + } + + public override async Task UnaryServerHandler( + TRequest request, + ServerCallContext context, + UnaryServerMethod continuation) + { + var response = await continuation(request, context); + + if (_options.EnableAutomaticCompression) + { + ApplyCompressionPolicy(context, response); + } + + return response; + } + + private void ApplyCompressionPolicy(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( + 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 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? configure = null) + { + CompressionEnabled = true; + + _services.AddSingleton(); + + if (configure != null) + { + _services.Configure(configure); + } + + _services.AddGrpc(options => + { + options.Interceptors.Add(); + }); + + 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 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? 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 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(); +builder.Services.AddQuery(); + +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 +{ + public async Task 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 +{ + public async Task HandleAsync(ExportDataQuery query, CancellationToken ct) + { + return await GenerateCsvExport(); + } +} +``` + +### Example 5: Default Automatic Compression + +```csharp +public class GetUsersQueryHandler : IQueryHandler> +{ + public async Task> 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 diff --git a/roadmap-2026/metadata-and-authorization-draft.md b/roadmap-2026/metadata-and-authorization-draft.md new file mode 100644 index 0000000..52406b9 --- /dev/null +++ b/roadmap-2026/metadata-and-authorization-draft.md @@ -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 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 +{ + private readonly ICqrsContextAccessor _context; + + public AddUserCommandHandler(ICqrsContextAccessor context) + { + _context = context; + } + + public async Task 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 +{ + Task HandleAsync(TCommand command, CancellationToken cancellationToken = default, ServerCallContext? context = null); +} + +public interface ICommandHandler +{ + Task HandleAsync(TCommand command, CancellationToken cancellationToken = default, ServerCallContext? context = null); +} + +public interface IQueryHandler +{ + Task 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 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 Items { get; } + CancellationToken CancellationToken { get; } + IUserContext? User { get; } +} + +public interface IUserContext +{ + bool IsAuthenticated { get; } + string? UserId { get; } + IEnumerable Roles { get; } + IEnumerable Claims { get; } +} + +public interface ICommandHandler +{ + 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 +{ + Task HandleAsync(TCommand command, ServerCallContext context, CancellationToken ct = default); +} + +public interface IGrpcCommandHandler +{ + Task HandleAsync(TCommand command, ServerCallContext context, CancellationToken ct = default); +} + +public interface IGrpcQueryHandler +{ + Task 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 +{ + 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 AddUser( + AddUserRequest request, + ServerCallContext context) +{ + var command = new AddUserCommand + { + Name = request.Name, + Email = request.Email + }; + + // AUTHORIZATION CHECK + var authService = _serviceProvider.GetService(); + 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>(); + if (validator != null) + { + var validationResult = await validator.ValidateAsync(command, context.CancellationToken); + if (!validationResult.IsValid) + { + // ... existing validation error handling + } + } + + // Execute handler + var handler = _serviceProvider.GetRequiredService>(); + 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 IsAllowedAsync(Type commandType, CancellationToken ct = default); +} +``` + +**Proposed Interface:** + +```csharp +public interface ICommandAuthorizationService +{ + Task 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 IsAllowedAsync(Type commandType, CancellationToken ct = default); + + // New method with context + Task IsAllowedAsync(Type commandType, object? requestContext, CancellationToken ct = default); +} +``` + +Default implementation: +```csharp +public abstract class CommandAuthorizationServiceBase : ICommandAuthorizationService +{ + public virtual Task IsAllowedAsync(Type commandType, CancellationToken ct = default) + { + return IsAllowedAsync(commandType, null, ct); + } + + public abstract Task 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` 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 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