diff --git a/DuonDevKit.AspNetCore.Tests/Validation/FluentValidationFilterTests.cs b/DuonDevKit.AspNetCore.Tests/Validation/FluentValidationFilterTests.cs new file mode 100644 index 0000000..58663aa --- /dev/null +++ b/DuonDevKit.AspNetCore.Tests/Validation/FluentValidationFilterTests.cs @@ -0,0 +1,109 @@ +using System.Net.Http.Json; +using System.Text.Json; +using DuonDevKit.AspNetCore.Validation; +using FluentValidation; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.TestHost; +using Microsoft.Extensions.DependencyInjection; + +namespace DuonDevKit.AspNetCore.Tests.Validation +{ + public class FluentValidationFilterTests + { + private class CreateOrderRequest + { + public string? CustomerName { get; set; } + public int Quantity { get; set; } + } + + private class CreateOrderRequestValidator : AbstractValidator + { + public CreateOrderRequestValidator() + { + RuleFor(r => r.CustomerName).NotEmpty().MaximumLength(50); + RuleFor(r => r.Quantity).InclusiveBetween(1, 100); + } + } + + private static async Task PostAsync(CreateOrderRequest body) + { + var builder = WebApplication.CreateBuilder(); + builder.WebHost.UseTestServer(); + builder.Services.AddScoped, CreateOrderRequestValidator>(); + var app = builder.Build(); + + app.MapPost("/orders", (CreateOrderRequest request) => Results.Ok("created")) + .WithDuonDevKitFluentValidation(); + + await app.StartAsync(); + + var client = app.GetTestServer().CreateClient(); + return await client.PostAsJsonAsync("/orders", body); + } + + [Fact] + public async Task WithDuonDevKitFluentValidation_ValidRequest_PassesThroughToHandler() + { + var response = await PostAsync(new CreateOrderRequest { CustomerName = "Alice", Quantity = 2 }); + + Assert.Equal(200, (int)response.StatusCode); + Assert.Equal("\"created\"", await response.Content.ReadAsStringAsync()); + } + + [Fact] + public async Task WithDuonDevKitFluentValidation_InvalidRequest_ShortCircuitsWith400ValidationProblem() + { + var response = await PostAsync(new CreateOrderRequest { CustomerName = null, Quantity = 0 }); + + Assert.Equal(400, (int)response.StatusCode); + Assert.Equal("application/problem+json", response.Content.Headers.ContentType?.MediaType); + + var body = await response.Content.ReadAsStringAsync(); + using var json = JsonDocument.Parse(body); + + Assert.Equal(400, json.RootElement.GetProperty("status").GetInt32()); + Assert.Equal("Validation", json.RootElement.GetProperty("title").GetString()); + Assert.Equal(ErrorCodes.ValidationFailed, json.RootElement.GetProperty("errorCode").GetString()); + + var errors = json.RootElement.GetProperty("errors"); + Assert.True(errors.TryGetProperty(nameof(CreateOrderRequest.CustomerName), out _)); + Assert.True(errors.TryGetProperty(nameof(CreateOrderRequest.Quantity), out _)); + } + + [Fact] + public async Task WithDuonDevKitFluentValidation_PartiallyInvalidRequest_OnlyReportsTheViolatedField() + { + var response = await PostAsync(new CreateOrderRequest { CustomerName = "Bob", Quantity = 0 }); + + var body = await response.Content.ReadAsStringAsync(); + using var json = JsonDocument.Parse(body); + var errors = json.RootElement.GetProperty("errors"); + + Assert.True(errors.TryGetProperty(nameof(CreateOrderRequest.Quantity), out _)); + Assert.False(errors.TryGetProperty(nameof(CreateOrderRequest.CustomerName), out _)); + } + + [Fact] + public async Task WithDuonDevKitFluentValidation_NoValidatorRegisteredInDi_ThrowsInsteadOfSkippingValidation() + { + var builder = WebApplication.CreateBuilder(); + builder.WebHost.UseTestServer(); + // Deliberately no IValidator registration. + var app = builder.Build(); + + app.MapPost("/orders", (CreateOrderRequest request) => Results.Ok("created")) + .WithDuonDevKitFluentValidation(); + + await app.StartAsync(); + var client = app.GetTestServer().CreateClient(); + + // No exception-handling middleware is registered in this minimal test app, so TestServer + // surfaces the filter's GetRequiredService failure as a real exception on the client call + // rather than converting it to a response — proving the missing registration is never + // silently swallowed into "validation passed." + await Assert.ThrowsAsync( + () => client.PostAsJsonAsync("/orders", new CreateOrderRequest { CustomerName = "Alice", Quantity = 2 })); + } + } +} diff --git a/DuonDevKit.AspNetCore/DuonDevKit.AspNetCore.csproj b/DuonDevKit.AspNetCore/DuonDevKit.AspNetCore.csproj index a8f6383..b13c261 100644 --- a/DuonDevKit.AspNetCore/DuonDevKit.AspNetCore.csproj +++ b/DuonDevKit.AspNetCore/DuonDevKit.AspNetCore.csproj @@ -8,8 +8,8 @@ DuonDevKit.AspNetCore - 0.4.1 - ASP.NET Core integration for DuonDevKit.Core's Result pattern — maps Result/Result<T> to IActionResult (MVC) and IResult (Minimal APIs) using Error.ToHttpStatusCode(), as ProblemDetails on failure, plus automatic DataAnnotations request validation for Minimal APIs. + 0.5.0 + ASP.NET Core integration for DuonDevKit.Core's Result pattern — maps Result/Result<T> to IActionResult (MVC) and IResult (Minimal APIs) using Error.ToHttpStatusCode(), as ProblemDetails on failure, plus automatic DataAnnotations and FluentValidation request validation for Minimal APIs. aspnetcore;result;railway-oriented-programming;problemdetails;minimal-api;dotnet true @@ -20,6 +20,11 @@ + + diff --git a/DuonDevKit.AspNetCore/README.md b/DuonDevKit.AspNetCore/README.md index 28db378..45b1494 100644 --- a/DuonDevKit.AspNetCore/README.md +++ b/DuonDevKit.AspNetCore/README.md @@ -67,7 +67,32 @@ app.MapPost("/orders", (CreateOrderRequest request) => Results.Ok()) ``` An invalid request never reaches the handler; the response body is a standard -`{ "errors": { "Quantity": ["..."] }, "errorCode": "VALIDATION001" }` shape. For rules that need to -be conditional, compare properties against each other, or call out to a database/service, use -`DuonDevKit.Validation`'s FluentValidation integration directly in the handler instead — it composes -with the same `Result`-to-HTTP mapping shown above. +`{ "errors": { "Quantity": ["..."] }, "errorCode": "VALIDATION001" }` shape. + +For rules that need to be conditional, compare properties against each other, or call out to a +database/service, use `WithDuonDevKitFluentValidation()` instead — it validates against a DI-resolved +FluentValidation `IValidator` (register one with `DuonDevKit.Validation`'s +`AddDuonDevKitValidators(...)`) and produces the exact same `400` field-level response shape, so the two +filters are interchangeable at this boundary: + +```csharp +using DuonDevKit.AspNetCore.Validation; + +public class CreateOrderRequestValidator : AbstractValidator +{ + public CreateOrderRequestValidator() + { + RuleFor(r => r.CustomerName).NotEmpty().MaximumLength(100); + RuleFor(r => r.Quantity).InclusiveBetween(1, 1000); + } +} + +builder.Services.AddDuonDevKitValidators(typeof(Program).Assembly); + +app.MapPost("/orders", (CreateOrderRequest request) => Results.Ok()) + .WithDuonDevKitFluentValidation(); +``` + +Note: referencing `DuonDevKit.AspNetCore` pulls in `DuonDevKit.Validation` (and FluentValidation) +transitively, even if you only ever use `WithDuonDevKitValidation()`'s DataAnnotations path — a +deliberate trade-off to give both filters the same zero-glue-code ergonomics. diff --git a/DuonDevKit.AspNetCore/Validation/EndpointFilterExtensions.cs b/DuonDevKit.AspNetCore/Validation/EndpointFilterExtensions.cs index 842fdb7..d5e874d 100644 --- a/DuonDevKit.AspNetCore/Validation/EndpointFilterExtensions.cs +++ b/DuonDevKit.AspNetCore/Validation/EndpointFilterExtensions.cs @@ -18,5 +18,19 @@ public static class EndpointFilterExtensions /// public static RouteHandlerBuilder WithDuonDevKitValidation(this RouteHandlerBuilder builder) where T : class => builder.AddEndpointFilter>(); + + /// + /// Validates the endpoint's bound parameter of type against a DI-resolved + /// FluentValidation IValidator<T> before the handler runs — short-circuits with the + /// same 400 field-level ValidationProblem shape as , + /// so both validation styles are interchangeable at this boundary. + /// + /// + /// + /// app.MapPost("/orders", Handler).WithDuonDevKitFluentValidation<CreateOrderRequest>(); + /// + /// + public static RouteHandlerBuilder WithDuonDevKitFluentValidation(this RouteHandlerBuilder builder) where T : class + => builder.AddEndpointFilter>(); } } diff --git a/DuonDevKit.AspNetCore/Validation/FluentValidationFilter.cs b/DuonDevKit.AspNetCore/Validation/FluentValidationFilter.cs new file mode 100644 index 0000000..f024556 --- /dev/null +++ b/DuonDevKit.AspNetCore/Validation/FluentValidationFilter.cs @@ -0,0 +1,51 @@ +using FluentValidation; +using FluentValidation.Results; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Http.HttpResults; +using Microsoft.Extensions.DependencyInjection; + +namespace DuonDevKit.AspNetCore.Validation +{ + /// + /// Minimal API that runs a DI-resolved FluentValidation + /// against a bound parameter of type before the + /// endpoint handler runs, short-circuiting with a 400 (field + /// name → messages) if invalid — the same response shape produces + /// for DataAnnotations, so either validation style is interchangeable at the Minimal API boundary. + /// Register via . + /// + /// + /// Requires an registered in DI (e.g. via + /// DuonDevKit.Validation.DependencyInjection.ServiceCollectionExtensions.AddDuonDevKitValidators) + /// — throws at request time if none is registered, rather than silently skipping validation, since a + /// missing registration is a setup mistake and not a legitimate "nothing to validate" case. As with + /// , if no bound argument of type is found + /// at all, the request passes through unvalidated. + /// + public sealed class FluentValidationFilter : IEndpointFilter where T : class + { + /// + public async ValueTask InvokeAsync(EndpointFilterInvocationContext context, EndpointFilterDelegate next) + { + var argument = context.Arguments.OfType().FirstOrDefault(); + if (argument is null) + return await next(context); + + var validator = context.HttpContext.RequestServices.GetRequiredService>(); + var validationResult = await validator.ValidateAsync(argument, context.HttpContext.RequestAborted); + + var errors = validationResult.Errors + .Where(e => e.Severity == Severity.Error) + .GroupBy(e => e.PropertyName, e => e.ErrorMessage) + .ToDictionary(g => g.Key, g => g.ToArray()); + + if (errors.Count == 0) + return await next(context); + + return TypedResults.ValidationProblem( + errors, + title: "Validation", + extensions: new Dictionary { [ResultExtensions.ErrorCodeExtensionKey] = ErrorCodes.ValidationFailed }); + } + } +} diff --git a/DuonDevKit.AspNetCore/Validation/ValidationFilter.cs b/DuonDevKit.AspNetCore/Validation/ValidationFilter.cs index 1b8b7de..57d5b6f 100644 --- a/DuonDevKit.AspNetCore/Validation/ValidationFilter.cs +++ b/DuonDevKit.AspNetCore/Validation/ValidationFilter.cs @@ -10,9 +10,9 @@ namespace DuonDevKit.AspNetCore.Validation /// validation against a bound parameter of type before the endpoint handler /// runs, short-circuiting with a 400 (field name → messages) if /// invalid. Register via rather than - /// directly — no dependency beyond the base class library, unlike DuonDevKit.Validation's - /// FluentValidation integration; use that instead for rules that need to be conditional, compare - /// properties against each other, or call out to a database/service. + /// directly. For rules that need to be conditional, compare properties against each other, or call out + /// to a database/service, use FluentValidation instead — see + /// for the equivalent Minimal API filter, which produces the same response shape as this one. /// /// /// Only 's own properties are checked, not nested complex properties or diff --git a/DuonDevKit.Templates/DuonDevKit.Templates.csproj b/DuonDevKit.Templates/DuonDevKit.Templates.csproj index 9350840..89d373a 100644 --- a/DuonDevKit.Templates/DuonDevKit.Templates.csproj +++ b/DuonDevKit.Templates/DuonDevKit.Templates.csproj @@ -9,6 +9,11 @@ $(NoWarn);NU5128 true + + false diff --git a/DuonDevKit.Templates/templates/duondevkit-api/DuonDevKit.ApiTemplate.csproj b/DuonDevKit.Templates/templates/duondevkit-api/DuonDevKit.ApiTemplate.csproj index 8c8ba1b..553f976 100644 --- a/DuonDevKit.Templates/templates/duondevkit-api/DuonDevKit.ApiTemplate.csproj +++ b/DuonDevKit.Templates/templates/duondevkit-api/DuonDevKit.ApiTemplate.csproj @@ -20,7 +20,7 @@ - + diff --git a/DuonDevKit.Validation/ValidatorExtensions.cs b/DuonDevKit.Validation/ValidatorExtensions.cs index da33dc5..858c3b7 100644 --- a/DuonDevKit.Validation/ValidatorExtensions.cs +++ b/DuonDevKit.Validation/ValidatorExtensions.cs @@ -13,11 +13,10 @@ namespace DuonDevKit.Validation /// /// The resulting carries every blocking () failure /// joined into one message ("PropertyName: ErrorMessage; ...") — has no - /// field-level structure to preserve a per-property error list. For an HTTP endpoint that needs a - /// field-level { "PropertyName": ["message"] } response body, validate directly against - /// / instead of going through this - /// extension — see DuonDevKit.AspNetCore's WithDuonDevKitValidation<T>() for that - /// case (DataAnnotations-based, so it needs no dependency on this package). + /// field-level structure to preserve a per-property error list. For a Minimal API endpoint that needs + /// a field-level { "PropertyName": ["message"] } response body instead, use + /// DuonDevKit.AspNetCore's WithDuonDevKitFluentValidation<T>(), which validates + /// directly against this same without going through this extension. /// public static class ValidatorExtensions {