From 492450477e26277ba773d6a1dc445fee9a851ee5 Mon Sep 17 00:00:00 2001 From: duongdt Date: Tue, 18 Aug 2026 10:28:48 +0700 Subject: [PATCH 1/2] Templates: fix NU5017 breaking every publish since SourceLink was added MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Directory.Build.props' global IncludeSymbols=true/SymbolPackageFormat=snupkg tried to produce a .snupkg for DuonDevKit.Templates too, but it has no compiled output (IncludeBuildOutput=false, content-only package) — an empty symbols package fails dotnet pack with NU5017 even though the main .nupkg packs fine, silently blocking the entire publish job (confirmed: this is why tag v0.12.0's publish run failed at the Pack DuonDevKit.Templates step, before ever reaching NuGet push or GitHub Release). Overrides IncludeSymbols back to false for this project only. Reproduced and verified fixed using the exact 'dotnet build --no-restore && dotnet pack --no-build' sequence publish.yml runs. --- DuonDevKit.Templates/DuonDevKit.Templates.csproj | 5 +++++ 1 file changed, 5 insertions(+) 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 From de15fb4d671ebff094509eb2578413fd779cb03a Mon Sep 17 00:00:00 2001 From: duongdt Date: Tue, 18 Aug 2026 10:28:49 +0700 Subject: [PATCH 2/2] AspNetCore: add FluentValidationFilter, the FluentValidation counterpart to ValidationFilter MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Resolves an IValidator from DI and produces the exact same 400 field-level ValidationProblem shape as the DataAnnotations filter, so both validation styles are interchangeable at the Minimal API boundary (Option B from the earlier validation-shape discussion). Trade-off accepted deliberately: DuonDevKit.AspNetCore now references DuonDevKit.Validation (and transitively FluentValidation) unconditionally, so every AspNetCore consumer gets that dependency even when only using DataAnnotations — confirmed via a local NuGet feed that even the template's default (no --validation) scaffold now carries FluentValidation transitively; it still builds clean, just with a heavier dependency footprint. Bumps DuonDevKit.AspNetCore 0.4.1 -> 0.5.0 (minor: additive API + dependency footprint change) and updates the template's pinned reference to match. --- .../Validation/FluentValidationFilterTests.cs | 109 ++++++++++++++++++ .../DuonDevKit.AspNetCore.csproj | 9 +- DuonDevKit.AspNetCore/README.md | 33 +++++- .../Validation/EndpointFilterExtensions.cs | 14 +++ .../Validation/FluentValidationFilter.cs | 51 ++++++++ .../Validation/ValidationFilter.cs | 6 +- .../DuonDevKit.ApiTemplate.csproj | 2 +- DuonDevKit.Validation/ValidatorExtensions.cs | 9 +- 8 files changed, 218 insertions(+), 15 deletions(-) create mode 100644 DuonDevKit.AspNetCore.Tests/Validation/FluentValidationFilterTests.cs create mode 100644 DuonDevKit.AspNetCore/Validation/FluentValidationFilter.cs 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/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 {