diff --git a/.claude/settings.local.json b/.claude/settings.local.json index 4ecb45a..054511c 100644 --- a/.claude/settings.local.json +++ b/.claude/settings.local.json @@ -18,7 +18,11 @@ "Bash(timeout 30 dotnet run:*)", "Bash(timeout 60 dotnet run:*)", "Bash(timeout 120 dotnet run:*)", - "Bash(git add:*)" + "Bash(git add:*)", + "Bash(curl:*)", + "Bash(timeout 3 cmd:*)", + "Bash(timeout:*)", + "Bash(tasklist:*)" ], "deny": [], "ask": [] diff --git a/Svrnty.CQRS.Grpc.Generators/GrpcGenerator.cs b/Svrnty.CQRS.Grpc.Generators/GrpcGenerator.cs index 1d1f9c1..af3f70f 100644 --- a/Svrnty.CQRS.Grpc.Generators/GrpcGenerator.cs +++ b/Svrnty.CQRS.Grpc.Generators/GrpcGenerator.cs @@ -720,6 +720,8 @@ namespace Svrnty.CQRS.Grpc.Generators sb.AppendLine("using System.Linq;"); sb.AppendLine("using Microsoft.Extensions.DependencyInjection;"); sb.AppendLine("using FluentValidation;"); + sb.AppendLine("using Google.Rpc;"); + sb.AppendLine("using Google.Protobuf.WellKnownTypes;"); sb.AppendLine($"using {rootNamespace}.Grpc;"); sb.AppendLine("using Svrnty.CQRS.Abstractions;"); sb.AppendLine(); @@ -764,8 +766,25 @@ namespace Svrnty.CQRS.Grpc.Generators sb.AppendLine(" var validationResult = await validator.ValidateAsync(command, context.CancellationToken);"); sb.AppendLine(" if (!validationResult.IsValid)"); sb.AppendLine(" {"); - sb.AppendLine(" var errors = string.Join(\", \", validationResult.Errors.Select(e => e.ErrorMessage));"); - sb.AppendLine(" throw new RpcException(new Status(StatusCode.InvalidArgument, $\"Validation failed: {errors}\"));"); + sb.AppendLine(" // Create Rich Error Model with structured field violations"); + sb.AppendLine(" var badRequest = new BadRequest();"); + sb.AppendLine(" foreach (var error in validationResult.Errors)"); + sb.AppendLine(" {"); + sb.AppendLine(" badRequest.FieldViolations.Add(new BadRequest.Types.FieldViolation"); + sb.AppendLine(" {"); + sb.AppendLine(" Field = error.PropertyName,"); + sb.AppendLine(" Description = error.ErrorMessage"); + sb.AppendLine(" });"); + sb.AppendLine(" }"); + sb.AppendLine(); + sb.AppendLine(" var status = new Google.Rpc.Status"); + sb.AppendLine(" {"); + sb.AppendLine(" Code = (int)Code.InvalidArgument,"); + sb.AppendLine(" Message = \"Validation failed\","); + sb.AppendLine(" Details = { Any.Pack(badRequest) }"); + sb.AppendLine(" };"); + sb.AppendLine(); + sb.AppendLine(" throw status.ToRpcException();"); sb.AppendLine(" }"); sb.AppendLine(" }"); sb.AppendLine(); diff --git a/Svrnty.CQRS.Grpc.Sample/Program.cs b/Svrnty.CQRS.Grpc.Sample/Program.cs index 95f1df3..59e412e 100644 --- a/Svrnty.CQRS.Grpc.Sample/Program.cs +++ b/Svrnty.CQRS.Grpc.Sample/Program.cs @@ -1,10 +1,22 @@ +using Microsoft.AspNetCore.Server.Kestrel.Core; +using Svrnty.CQRS; using Svrnty.CQRS.Abstractions; using Svrnty.CQRS.FluentValidation; using Svrnty.CQRS.Grpc.Sample; using Svrnty.CQRS.Grpc.Sample.Grpc.Extensions; +using Svrnty.CQRS.MinimalApi; var builder = WebApplication.CreateBuilder(args); +// Configure Kestrel to support both HTTP/1.1 (for REST APIs) and HTTP/2 (for gRPC) +builder.WebHost.ConfigureKestrel(options => +{ + // Port 6000: HTTP/2 for gRPC + options.ListenLocalhost(6000, o => o.Protocols = HttpProtocols.Http2); + // Port 6001: HTTP/1.1 for REST API + options.ListenLocalhost(6001, o => o.Protocols = HttpProtocols.Http1); +}); + // Register command handlers with CQRS and FluentValidation builder.Services.AddCommand(); builder.Services.AddCommand(); @@ -12,9 +24,17 @@ builder.Services.AddCommand(); // Register query handlers with CQRS builder.Services.AddQuery(); +// Register discovery services for MinimalApi +builder.Services.AddDefaultCommandDiscovery(); +builder.Services.AddDefaultQueryDiscovery(); + // Auto-generated: Register gRPC services for both commands and queries (includes reflection) builder.Services.AddGrpcCommandsAndQueries(); +// Add Swagger/OpenAPI support +builder.Services.AddEndpointsApiExplorer(); +builder.Services.AddSwaggerGen(); + var app = builder.Build(); // Auto-generated: Map gRPC endpoints for both commands and queries @@ -23,7 +43,18 @@ app.MapGrpcCommandsAndQueries(); // Map gRPC reflection service app.MapGrpcReflectionService(); -Console.WriteLine("Auto-Generated gRPC Server with Reflection and Validation"); -Console.WriteLine("http://localhost:5000"); +// Enable Swagger middleware +app.UseSwagger(); +app.UseSwaggerUI(); + +// Map MinimalApi endpoints for commands and queries +app.MapSvrntyCommands(); +app.MapSvrntyQueries(); + + +Console.WriteLine("Auto-Generated gRPC Server with Reflection, Validation, MinimalApi and Swagger"); +Console.WriteLine("gRPC (HTTP/2): http://localhost:6000"); +Console.WriteLine("HTTP API (HTTP/1.1): http://localhost:6001/api/command/* and http://localhost:6001/api/query/*"); +Console.WriteLine("Swagger UI: http://localhost:6001/swagger"); app.Run(); diff --git a/Svrnty.CQRS.Grpc.Sample/Svrnty.CQRS.Grpc.Sample.csproj b/Svrnty.CQRS.Grpc.Sample/Svrnty.CQRS.Grpc.Sample.csproj index f9b3c59..ab45392 100644 --- a/Svrnty.CQRS.Grpc.Sample/Svrnty.CQRS.Grpc.Sample.csproj +++ b/Svrnty.CQRS.Grpc.Sample/Svrnty.CQRS.Grpc.Sample.csproj @@ -19,6 +19,8 @@ all runtime; build; native; contentfiles; analyzers; buildtransitive + + @@ -27,6 +29,7 @@ + diff --git a/Svrnty.CQRS.MinimalApi/EndpointRouteBuilderExtensions.cs b/Svrnty.CQRS.MinimalApi/EndpointRouteBuilderExtensions.cs index 1d6c43b..7a16442 100644 --- a/Svrnty.CQRS.MinimalApi/EndpointRouteBuilderExtensions.cs +++ b/Svrnty.CQRS.MinimalApi/EndpointRouteBuilderExtensions.cs @@ -16,7 +16,7 @@ namespace Svrnty.CQRS.MinimalApi; public static class EndpointRouteBuilderExtensions { - public static IEndpointRouteBuilder MapOpenHarborQueries(this IEndpointRouteBuilder endpoints, string routePrefix = "api/query") + public static IEndpointRouteBuilder MapSvrntyQueries(this IEndpointRouteBuilder endpoints, string routePrefix = "api/query") { var queryDiscovery = endpoints.ServiceProvider.GetRequiredService(); var authorizationService = endpoints.ServiceProvider.GetService(); @@ -55,8 +55,9 @@ public static class EndpointRouteBuilderExtensions return Results.Unauthorized(); } - var query = await context.Request.ReadFromJsonAsync(queryMeta.QueryType, cancellationToken); - if (query == null) + // Retrieve already-deserialized and validated query from HttpContext.Items + var query = context.Items[ValidationFilter.ValidatedObjectKey]; + if (query == null || !queryMeta.QueryType.IsInstanceOfType(query)) return Results.BadRequest("Invalid query payload"); var handler = serviceProvider.GetRequiredService(handlerType); @@ -72,8 +73,10 @@ public static class EndpointRouteBuilderExtensions return Results.Ok(result); }) + .AddEndpointFilter((IEndpointFilter)Activator.CreateInstance(typeof(ValidationFilter<>).MakeGenericType(queryMeta.QueryType))!) .WithName($"Query_{queryMeta.LowerCamelCaseName}_Post") .WithTags("Queries") + .Accepts(queryMeta.QueryType, "application/json") .Produces(200, queryMeta.QueryResultType) .Produces(400) .Produces(401) @@ -143,7 +146,7 @@ public static class EndpointRouteBuilderExtensions .Produces(403); } - public static IEndpointRouteBuilder MapOpenHarborCommands(this IEndpointRouteBuilder endpoints, string routePrefix = "api/command") + public static IEndpointRouteBuilder MapSvrntyCommands(this IEndpointRouteBuilder endpoints, string routePrefix = "api/command") { var commandDiscovery = endpoints.ServiceProvider.GetRequiredService(); var authorizationService = endpoints.ServiceProvider.GetService(); @@ -188,8 +191,9 @@ public static class EndpointRouteBuilderExtensions return Results.Unauthorized(); } - var command = await context.Request.ReadFromJsonAsync(commandMeta.CommandType, cancellationToken); - if (command == null) + // Retrieve already-deserialized and validated command from HttpContext.Items + var command = context.Items[ValidationFilter.ValidatedObjectKey]; + if (command == null || !commandMeta.CommandType.IsInstanceOfType(command)) return Results.BadRequest("Invalid command payload"); var handler = serviceProvider.GetRequiredService(handlerType); @@ -200,8 +204,10 @@ public static class EndpointRouteBuilderExtensions await (Task)handleMethod.Invoke(handler, [command, cancellationToken])!; return Results.Ok(); }) + .AddEndpointFilter((IEndpointFilter)Activator.CreateInstance(typeof(ValidationFilter<>).MakeGenericType(commandMeta.CommandType))!) .WithName($"Command_{commandMeta.LowerCamelCaseName}") .WithTags("Commands") + .Accepts(commandMeta.CommandType, "application/json") .Produces(200) .Produces(400) .Produces(401) @@ -227,8 +233,9 @@ public static class EndpointRouteBuilderExtensions return Results.Unauthorized(); } - var command = await context.Request.ReadFromJsonAsync(commandMeta.CommandType, cancellationToken); - if (command == null) + // Retrieve already-deserialized and validated command from HttpContext.Items + var command = context.Items[ValidationFilter.ValidatedObjectKey]; + if (command == null || !commandMeta.CommandType.IsInstanceOfType(command)) return Results.BadRequest("Invalid command payload"); var handler = serviceProvider.GetRequiredService(handlerType); @@ -244,8 +251,10 @@ public static class EndpointRouteBuilderExtensions return Results.Ok(result); }) + .AddEndpointFilter((IEndpointFilter)Activator.CreateInstance(typeof(ValidationFilter<>).MakeGenericType(commandMeta.CommandType))!) .WithName($"Command_{commandMeta.LowerCamelCaseName}") .WithTags("Commands") + .Accepts(commandMeta.CommandType, "application/json") .Produces(200, commandMeta.CommandResultType) .Produces(400) .Produces(401) diff --git a/Svrnty.CQRS.MinimalApi/Svrnty.CQRS.MinimalApi.csproj b/Svrnty.CQRS.MinimalApi/Svrnty.CQRS.MinimalApi.csproj index 75c3a9e..c5e3cfe 100644 --- a/Svrnty.CQRS.MinimalApi/Svrnty.CQRS.MinimalApi.csproj +++ b/Svrnty.CQRS.MinimalApi/Svrnty.CQRS.MinimalApi.csproj @@ -29,6 +29,10 @@ + + + + diff --git a/Svrnty.CQRS.MinimalApi/ValidationFilter.cs b/Svrnty.CQRS.MinimalApi/ValidationFilter.cs new file mode 100644 index 0000000..0e983da --- /dev/null +++ b/Svrnty.CQRS.MinimalApi/ValidationFilter.cs @@ -0,0 +1,37 @@ +using System.Threading.Tasks; +using FluentValidation; +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.DependencyInjection; + +namespace Svrnty.CQRS.MinimalApi; + +public class ValidationFilter : IEndpointFilter where T : class +{ + public const string ValidatedObjectKey = "ValidatedObject"; + + public async ValueTask InvokeAsync(EndpointFilterInvocationContext context, EndpointFilterDelegate next) + { + // Deserialize the request body + var obj = await context.HttpContext.Request.ReadFromJsonAsync(context.HttpContext.RequestAborted); + + if (obj == null) + return Results.BadRequest("Invalid request payload"); + + // Store the deserialized object for the lambda to retrieve + context.HttpContext.Items[ValidatedObjectKey] = obj; + + // Validate if validator is registered + var validator = context.HttpContext.RequestServices.GetService>(); + if (validator != null) + { + var validationResult = await validator.ValidateAsync(obj, context.HttpContext.RequestAborted); + + if (!validationResult.IsValid) + { + return Results.ValidationProblem(validationResult.ToDictionary()); + } + } + + return await next(context); + } +}