first grpc and minimal api preview :)

This commit is contained in:
Mathias Beaulieu-Duncan 2025-11-02 03:14:38 -05:00
parent f6dccf46d7
commit 4824c0d31d
Signed by: mathias
GPG Key ID: 1C16CF05BAF9162D
7 changed files with 120 additions and 13 deletions

View File

@ -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": []

View File

@ -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();

View File

@ -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<AddUserCommand, int, AddUserCommandHandler, AddUserCommandValidator>();
builder.Services.AddCommand<RemoveUserCommand, RemoveUserCommandHandler>();
@ -12,9 +24,17 @@ builder.Services.AddCommand<RemoveUserCommand, RemoveUserCommandHandler>();
// Register query handlers with CQRS
builder.Services.AddQuery<FetchUserQuery, User, FetchUserQueryHandler>();
// 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();

View File

@ -19,6 +19,8 @@
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
<PackageReference Include="Grpc.StatusProto" Version="1.70.0" />
<PackageReference Include="Swashbuckle.AspNetCore" Version="9.0.6" />
</ItemGroup>
<ItemGroup>
@ -27,6 +29,7 @@
<ProjectReference Include="..\Svrnty.CQRS.Grpc\Svrnty.CQRS.Grpc.csproj" />
<ProjectReference Include="..\Svrnty.CQRS.Grpc.Generators\Svrnty.CQRS.Grpc.Generators.csproj" OutputItemType="Analyzer" ReferenceOutputAssembly="false" />
<ProjectReference Include="..\Svrnty.CQRS.FluentValidation\Svrnty.CQRS.FluentValidation.csproj" />
<ProjectReference Include="..\Svrnty.CQRS.MinimalApi\Svrnty.CQRS.MinimalApi.csproj" />
</ItemGroup>
</Project>

View File

@ -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<IQueryDiscovery>();
var authorizationService = endpoints.ServiceProvider.GetService<IQueryAuthorizationService>();
@ -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<object>.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<ICommandDiscovery>();
var authorizationService = endpoints.ServiceProvider.GetService<ICommandAuthorizationService>();
@ -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<object>.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<object>.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)

View File

@ -29,6 +29,10 @@
<FrameworkReference Include="Microsoft.AspNetCore.App" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="FluentValidation" Version="11.11.0" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\Svrnty.CQRS.Abstractions\Svrnty.CQRS.Abstractions.csproj" />
<ProjectReference Include="..\Svrnty.CQRS.AspNetCore.Abstractions\Svrnty.CQRS.AspNetCore.Abstractions.csproj" />

View File

@ -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<T> : IEndpointFilter where T : class
{
public const string ValidatedObjectKey = "ValidatedObject";
public async ValueTask<object?> InvokeAsync(EndpointFilterInvocationContext context, EndpointFilterDelegate next)
{
// Deserialize the request body
var obj = await context.HttpContext.Request.ReadFromJsonAsync<T>(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<IValidator<T>>();
if (validator != null)
{
var validationResult = await validator.ValidateAsync(obj, context.HttpContext.RequestAborted);
if (!validationResult.IsValid)
{
return Results.ValidationProblem(validationResult.ToDictionary());
}
}
return await next(context);
}
}