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 30 dotnet run:*)",
|
||||||
"Bash(timeout 60 dotnet run:*)",
|
"Bash(timeout 60 dotnet run:*)",
|
||||||
"Bash(timeout 120 dotnet run:*)",
|
"Bash(timeout 120 dotnet run:*)",
|
||||||
"Bash(git add:*)"
|
"Bash(git add:*)",
|
||||||
|
"Bash(curl:*)",
|
||||||
|
"Bash(timeout 3 cmd:*)",
|
||||||
|
"Bash(timeout:*)",
|
||||||
|
"Bash(tasklist:*)"
|
||||||
],
|
],
|
||||||
"deny": [],
|
"deny": [],
|
||||||
"ask": []
|
"ask": []
|
||||||
|
|||||||
@ -720,6 +720,8 @@ namespace Svrnty.CQRS.Grpc.Generators
|
|||||||
sb.AppendLine("using System.Linq;");
|
sb.AppendLine("using System.Linq;");
|
||||||
sb.AppendLine("using Microsoft.Extensions.DependencyInjection;");
|
sb.AppendLine("using Microsoft.Extensions.DependencyInjection;");
|
||||||
sb.AppendLine("using FluentValidation;");
|
sb.AppendLine("using FluentValidation;");
|
||||||
|
sb.AppendLine("using Google.Rpc;");
|
||||||
|
sb.AppendLine("using Google.Protobuf.WellKnownTypes;");
|
||||||
sb.AppendLine($"using {rootNamespace}.Grpc;");
|
sb.AppendLine($"using {rootNamespace}.Grpc;");
|
||||||
sb.AppendLine("using Svrnty.CQRS.Abstractions;");
|
sb.AppendLine("using Svrnty.CQRS.Abstractions;");
|
||||||
sb.AppendLine();
|
sb.AppendLine();
|
||||||
@ -764,8 +766,25 @@ namespace Svrnty.CQRS.Grpc.Generators
|
|||||||
sb.AppendLine(" var validationResult = await validator.ValidateAsync(command, context.CancellationToken);");
|
sb.AppendLine(" var validationResult = await validator.ValidateAsync(command, context.CancellationToken);");
|
||||||
sb.AppendLine(" if (!validationResult.IsValid)");
|
sb.AppendLine(" if (!validationResult.IsValid)");
|
||||||
sb.AppendLine(" {");
|
sb.AppendLine(" {");
|
||||||
sb.AppendLine(" var errors = string.Join(\", \", validationResult.Errors.Select(e => e.ErrorMessage));");
|
sb.AppendLine(" // Create Rich Error Model with structured field violations");
|
||||||
sb.AppendLine(" throw new RpcException(new Status(StatusCode.InvalidArgument, $\"Validation failed: {errors}\"));");
|
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(" }");
|
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.Abstractions;
|
||||||
using Svrnty.CQRS.FluentValidation;
|
using Svrnty.CQRS.FluentValidation;
|
||||||
using Svrnty.CQRS.Grpc.Sample;
|
using Svrnty.CQRS.Grpc.Sample;
|
||||||
using Svrnty.CQRS.Grpc.Sample.Grpc.Extensions;
|
using Svrnty.CQRS.Grpc.Sample.Grpc.Extensions;
|
||||||
|
using Svrnty.CQRS.MinimalApi;
|
||||||
|
|
||||||
var builder = WebApplication.CreateBuilder(args);
|
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
|
// Register command handlers with CQRS and FluentValidation
|
||||||
builder.Services.AddCommand<AddUserCommand, int, AddUserCommandHandler, AddUserCommandValidator>();
|
builder.Services.AddCommand<AddUserCommand, int, AddUserCommandHandler, AddUserCommandValidator>();
|
||||||
builder.Services.AddCommand<RemoveUserCommand, RemoveUserCommandHandler>();
|
builder.Services.AddCommand<RemoveUserCommand, RemoveUserCommandHandler>();
|
||||||
@ -12,9 +24,17 @@ builder.Services.AddCommand<RemoveUserCommand, RemoveUserCommandHandler>();
|
|||||||
// Register query handlers with CQRS
|
// Register query handlers with CQRS
|
||||||
builder.Services.AddQuery<FetchUserQuery, User, FetchUserQueryHandler>();
|
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)
|
// Auto-generated: Register gRPC services for both commands and queries (includes reflection)
|
||||||
builder.Services.AddGrpcCommandsAndQueries();
|
builder.Services.AddGrpcCommandsAndQueries();
|
||||||
|
|
||||||
|
// Add Swagger/OpenAPI support
|
||||||
|
builder.Services.AddEndpointsApiExplorer();
|
||||||
|
builder.Services.AddSwaggerGen();
|
||||||
|
|
||||||
var app = builder.Build();
|
var app = builder.Build();
|
||||||
|
|
||||||
// Auto-generated: Map gRPC endpoints for both commands and queries
|
// Auto-generated: Map gRPC endpoints for both commands and queries
|
||||||
@ -23,7 +43,18 @@ app.MapGrpcCommandsAndQueries();
|
|||||||
// Map gRPC reflection service
|
// Map gRPC reflection service
|
||||||
app.MapGrpcReflectionService();
|
app.MapGrpcReflectionService();
|
||||||
|
|
||||||
Console.WriteLine("Auto-Generated gRPC Server with Reflection and Validation");
|
// Enable Swagger middleware
|
||||||
Console.WriteLine("http://localhost:5000");
|
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();
|
app.Run();
|
||||||
|
|||||||
@ -19,6 +19,8 @@
|
|||||||
<PrivateAssets>all</PrivateAssets>
|
<PrivateAssets>all</PrivateAssets>
|
||||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||||
</PackageReference>
|
</PackageReference>
|
||||||
|
<PackageReference Include="Grpc.StatusProto" Version="1.70.0" />
|
||||||
|
<PackageReference Include="Swashbuckle.AspNetCore" Version="9.0.6" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
@ -27,6 +29,7 @@
|
|||||||
<ProjectReference Include="..\Svrnty.CQRS.Grpc\Svrnty.CQRS.Grpc.csproj" />
|
<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.Grpc.Generators\Svrnty.CQRS.Grpc.Generators.csproj" OutputItemType="Analyzer" ReferenceOutputAssembly="false" />
|
||||||
<ProjectReference Include="..\Svrnty.CQRS.FluentValidation\Svrnty.CQRS.FluentValidation.csproj" />
|
<ProjectReference Include="..\Svrnty.CQRS.FluentValidation\Svrnty.CQRS.FluentValidation.csproj" />
|
||||||
|
<ProjectReference Include="..\Svrnty.CQRS.MinimalApi\Svrnty.CQRS.MinimalApi.csproj" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
</Project>
|
</Project>
|
||||||
|
|||||||
@ -16,7 +16,7 @@ namespace Svrnty.CQRS.MinimalApi;
|
|||||||
|
|
||||||
public static class EndpointRouteBuilderExtensions
|
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 queryDiscovery = endpoints.ServiceProvider.GetRequiredService<IQueryDiscovery>();
|
||||||
var authorizationService = endpoints.ServiceProvider.GetService<IQueryAuthorizationService>();
|
var authorizationService = endpoints.ServiceProvider.GetService<IQueryAuthorizationService>();
|
||||||
@ -55,8 +55,9 @@ public static class EndpointRouteBuilderExtensions
|
|||||||
return Results.Unauthorized();
|
return Results.Unauthorized();
|
||||||
}
|
}
|
||||||
|
|
||||||
var query = await context.Request.ReadFromJsonAsync(queryMeta.QueryType, cancellationToken);
|
// Retrieve already-deserialized and validated query from HttpContext.Items
|
||||||
if (query == null)
|
var query = context.Items[ValidationFilter<object>.ValidatedObjectKey];
|
||||||
|
if (query == null || !queryMeta.QueryType.IsInstanceOfType(query))
|
||||||
return Results.BadRequest("Invalid query payload");
|
return Results.BadRequest("Invalid query payload");
|
||||||
|
|
||||||
var handler = serviceProvider.GetRequiredService(handlerType);
|
var handler = serviceProvider.GetRequiredService(handlerType);
|
||||||
@ -72,8 +73,10 @@ public static class EndpointRouteBuilderExtensions
|
|||||||
|
|
||||||
return Results.Ok(result);
|
return Results.Ok(result);
|
||||||
})
|
})
|
||||||
|
.AddEndpointFilter((IEndpointFilter)Activator.CreateInstance(typeof(ValidationFilter<>).MakeGenericType(queryMeta.QueryType))!)
|
||||||
.WithName($"Query_{queryMeta.LowerCamelCaseName}_Post")
|
.WithName($"Query_{queryMeta.LowerCamelCaseName}_Post")
|
||||||
.WithTags("Queries")
|
.WithTags("Queries")
|
||||||
|
.Accepts(queryMeta.QueryType, "application/json")
|
||||||
.Produces(200, queryMeta.QueryResultType)
|
.Produces(200, queryMeta.QueryResultType)
|
||||||
.Produces(400)
|
.Produces(400)
|
||||||
.Produces(401)
|
.Produces(401)
|
||||||
@ -143,7 +146,7 @@ public static class EndpointRouteBuilderExtensions
|
|||||||
.Produces(403);
|
.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 commandDiscovery = endpoints.ServiceProvider.GetRequiredService<ICommandDiscovery>();
|
||||||
var authorizationService = endpoints.ServiceProvider.GetService<ICommandAuthorizationService>();
|
var authorizationService = endpoints.ServiceProvider.GetService<ICommandAuthorizationService>();
|
||||||
@ -188,8 +191,9 @@ public static class EndpointRouteBuilderExtensions
|
|||||||
return Results.Unauthorized();
|
return Results.Unauthorized();
|
||||||
}
|
}
|
||||||
|
|
||||||
var command = await context.Request.ReadFromJsonAsync(commandMeta.CommandType, cancellationToken);
|
// Retrieve already-deserialized and validated command from HttpContext.Items
|
||||||
if (command == null)
|
var command = context.Items[ValidationFilter<object>.ValidatedObjectKey];
|
||||||
|
if (command == null || !commandMeta.CommandType.IsInstanceOfType(command))
|
||||||
return Results.BadRequest("Invalid command payload");
|
return Results.BadRequest("Invalid command payload");
|
||||||
|
|
||||||
var handler = serviceProvider.GetRequiredService(handlerType);
|
var handler = serviceProvider.GetRequiredService(handlerType);
|
||||||
@ -200,8 +204,10 @@ public static class EndpointRouteBuilderExtensions
|
|||||||
await (Task)handleMethod.Invoke(handler, [command, cancellationToken])!;
|
await (Task)handleMethod.Invoke(handler, [command, cancellationToken])!;
|
||||||
return Results.Ok();
|
return Results.Ok();
|
||||||
})
|
})
|
||||||
|
.AddEndpointFilter((IEndpointFilter)Activator.CreateInstance(typeof(ValidationFilter<>).MakeGenericType(commandMeta.CommandType))!)
|
||||||
.WithName($"Command_{commandMeta.LowerCamelCaseName}")
|
.WithName($"Command_{commandMeta.LowerCamelCaseName}")
|
||||||
.WithTags("Commands")
|
.WithTags("Commands")
|
||||||
|
.Accepts(commandMeta.CommandType, "application/json")
|
||||||
.Produces(200)
|
.Produces(200)
|
||||||
.Produces(400)
|
.Produces(400)
|
||||||
.Produces(401)
|
.Produces(401)
|
||||||
@ -227,8 +233,9 @@ public static class EndpointRouteBuilderExtensions
|
|||||||
return Results.Unauthorized();
|
return Results.Unauthorized();
|
||||||
}
|
}
|
||||||
|
|
||||||
var command = await context.Request.ReadFromJsonAsync(commandMeta.CommandType, cancellationToken);
|
// Retrieve already-deserialized and validated command from HttpContext.Items
|
||||||
if (command == null)
|
var command = context.Items[ValidationFilter<object>.ValidatedObjectKey];
|
||||||
|
if (command == null || !commandMeta.CommandType.IsInstanceOfType(command))
|
||||||
return Results.BadRequest("Invalid command payload");
|
return Results.BadRequest("Invalid command payload");
|
||||||
|
|
||||||
var handler = serviceProvider.GetRequiredService(handlerType);
|
var handler = serviceProvider.GetRequiredService(handlerType);
|
||||||
@ -244,8 +251,10 @@ public static class EndpointRouteBuilderExtensions
|
|||||||
|
|
||||||
return Results.Ok(result);
|
return Results.Ok(result);
|
||||||
})
|
})
|
||||||
|
.AddEndpointFilter((IEndpointFilter)Activator.CreateInstance(typeof(ValidationFilter<>).MakeGenericType(commandMeta.CommandType))!)
|
||||||
.WithName($"Command_{commandMeta.LowerCamelCaseName}")
|
.WithName($"Command_{commandMeta.LowerCamelCaseName}")
|
||||||
.WithTags("Commands")
|
.WithTags("Commands")
|
||||||
|
.Accepts(commandMeta.CommandType, "application/json")
|
||||||
.Produces(200, commandMeta.CommandResultType)
|
.Produces(200, commandMeta.CommandResultType)
|
||||||
.Produces(400)
|
.Produces(400)
|
||||||
.Produces(401)
|
.Produces(401)
|
||||||
|
|||||||
@ -29,6 +29,10 @@
|
|||||||
<FrameworkReference Include="Microsoft.AspNetCore.App" />
|
<FrameworkReference Include="Microsoft.AspNetCore.App" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<PackageReference Include="FluentValidation" Version="11.11.0" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<ProjectReference Include="..\Svrnty.CQRS.Abstractions\Svrnty.CQRS.Abstractions.csproj" />
|
<ProjectReference Include="..\Svrnty.CQRS.Abstractions\Svrnty.CQRS.Abstractions.csproj" />
|
||||||
<ProjectReference Include="..\Svrnty.CQRS.AspNetCore.Abstractions\Svrnty.CQRS.AspNetCore.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