first grpc and minimal api preview :)
This commit is contained in:
parent
f6dccf46d7
commit
4824c0d31d
@ -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": []
|
||||
|
||||
@ -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();
|
||||
|
||||
@ -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();
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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" />
|
||||
|
||||
37
Svrnty.CQRS.MinimalApi/ValidationFilter.cs
Normal file
37
Svrnty.CQRS.MinimalApi/ValidationFilter.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user