Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
109 changes: 109 additions & 0 deletions DuonDevKit.AspNetCore.Tests/Validation/FluentValidationFilterTests.cs
Original file line number Diff line number Diff line change
@@ -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<CreateOrderRequest>
{
public CreateOrderRequestValidator()
{
RuleFor(r => r.CustomerName).NotEmpty().MaximumLength(50);
RuleFor(r => r.Quantity).InclusiveBetween(1, 100);
}
}

private static async Task<HttpResponseMessage> PostAsync(CreateOrderRequest body)
{
var builder = WebApplication.CreateBuilder();
builder.WebHost.UseTestServer();
builder.Services.AddScoped<IValidator<CreateOrderRequest>, CreateOrderRequestValidator>();
var app = builder.Build();

app.MapPost("/orders", (CreateOrderRequest request) => Results.Ok("created"))
.WithDuonDevKitFluentValidation<CreateOrderRequest>();

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<CreateOrderRequest> registration.
var app = builder.Build();

app.MapPost("/orders", (CreateOrderRequest request) => Results.Ok("created"))
.WithDuonDevKitFluentValidation<CreateOrderRequest>();

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<InvalidOperationException>(
() => client.PostAsJsonAsync("/orders", new CreateOrderRequest { CustomerName = "Alice", Quantity = 2 }));
}
}
}
9 changes: 7 additions & 2 deletions DuonDevKit.AspNetCore/DuonDevKit.AspNetCore.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,8 @@

<PropertyGroup>
<PackageId>DuonDevKit.AspNetCore</PackageId>
<Version>0.4.1</Version>
<Description>ASP.NET Core integration for DuonDevKit.Core's Result pattern — maps Result/Result&lt;T&gt; to IActionResult (MVC) and IResult (Minimal APIs) using Error.ToHttpStatusCode(), as ProblemDetails on failure, plus automatic DataAnnotations request validation for Minimal APIs.</Description>
<Version>0.5.0</Version>
<Description>ASP.NET Core integration for DuonDevKit.Core's Result pattern — maps Result/Result&lt;T&gt; to IActionResult (MVC) and IResult (Minimal APIs) using Error.ToHttpStatusCode(), as ProblemDetails on failure, plus automatic DataAnnotations and FluentValidation request validation for Minimal APIs.</Description>
<PackageTags>aspnetcore;result;railway-oriented-programming;problemdetails;minimal-api;dotnet</PackageTags>
<IsPackable>true</IsPackable>
</PropertyGroup>
Expand All @@ -20,6 +20,11 @@

<ItemGroup>
<ProjectReference Include="..\DuonDevKit.Core\DuonDevKit.Core.csproj" />
<!-- Pulls in FluentValidation transitively for every DuonDevKit.AspNetCore consumer, even those
only using DataAnnotations validation — a deliberate, discussed trade-off to give
FluentValidationFilter<T> the same zero-glue-code ergonomics as the DataAnnotations
ValidationFilter<T>, at the cost of package-optionality purity. -->
<ProjectReference Include="..\DuonDevKit.Validation\DuonDevKit.Validation.csproj" />
</ItemGroup>

<ItemGroup>
Expand Down
33 changes: 29 additions & 4 deletions DuonDevKit.AspNetCore/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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<T>()` instead — it validates against a DI-resolved
FluentValidation `IValidator<T>` (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<CreateOrderRequest>
{
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<CreateOrderRequest>();
```

Note: referencing `DuonDevKit.AspNetCore` pulls in `DuonDevKit.Validation` (and FluentValidation)
transitively, even if you only ever use `WithDuonDevKitValidation<T>()`'s DataAnnotations path — a
deliberate trade-off to give both filters the same zero-glue-code ergonomics.
14 changes: 14 additions & 0 deletions DuonDevKit.AspNetCore/Validation/EndpointFilterExtensions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -18,5 +18,19 @@ public static class EndpointFilterExtensions
/// </example>
public static RouteHandlerBuilder WithDuonDevKitValidation<T>(this RouteHandlerBuilder builder) where T : class
=> builder.AddEndpointFilter<ValidationFilter<T>>();

/// <summary>
/// Validates the endpoint's bound parameter of type <typeparamref name="T"/> against a DI-resolved
/// FluentValidation <c>IValidator&lt;T&gt;</c> before the handler runs — short-circuits with the
/// same <c>400</c> field-level <c>ValidationProblem</c> shape as <see cref="WithDuonDevKitValidation{T}"/>,
/// so both validation styles are interchangeable at this boundary.
/// </summary>
/// <example>
/// <code>
/// app.MapPost("/orders", Handler).WithDuonDevKitFluentValidation&lt;CreateOrderRequest&gt;();
/// </code>
/// </example>
public static RouteHandlerBuilder WithDuonDevKitFluentValidation<T>(this RouteHandlerBuilder builder) where T : class
=> builder.AddEndpointFilter<FluentValidationFilter<T>>();
}
}
51 changes: 51 additions & 0 deletions DuonDevKit.AspNetCore/Validation/FluentValidationFilter.cs
Original file line number Diff line number Diff line change
@@ -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
{
/// <summary>
/// Minimal API <see cref="IEndpointFilter"/> that runs a DI-resolved FluentValidation
/// <see cref="IValidator{T}"/> against a bound parameter of type <typeparamref name="T"/> before the
/// endpoint handler runs, short-circuiting with a <c>400</c> <see cref="ValidationProblem"/> (field
/// name → messages) if invalid — the same response shape <see cref="ValidationFilter{T}"/> produces
/// for DataAnnotations, so either validation style is interchangeable at the Minimal API boundary.
/// Register via <see cref="EndpointFilterExtensions.WithDuonDevKitFluentValidation{T}"/>.
/// </summary>
/// <remarks>
/// Requires an <see cref="IValidator{T}"/> registered in DI (e.g. via
/// <c>DuonDevKit.Validation.DependencyInjection.ServiceCollectionExtensions.AddDuonDevKitValidators</c>)
/// — 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
/// <see cref="ValidationFilter{T}"/>, if no bound argument of type <typeparamref name="T"/> is found
/// at all, the request passes through unvalidated.
/// </remarks>
public sealed class FluentValidationFilter<T> : IEndpointFilter where T : class
{
/// <inheritdoc />
public async ValueTask<object?> InvokeAsync(EndpointFilterInvocationContext context, EndpointFilterDelegate next)
{
var argument = context.Arguments.OfType<T>().FirstOrDefault();
if (argument is null)
return await next(context);

var validator = context.HttpContext.RequestServices.GetRequiredService<IValidator<T>>();
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<string, object?> { [ResultExtensions.ErrorCodeExtensionKey] = ErrorCodes.ValidationFailed });
}
}
}
6 changes: 3 additions & 3 deletions DuonDevKit.AspNetCore/Validation/ValidationFilter.cs
Original file line number Diff line number Diff line change
Expand Up @@ -10,9 +10,9 @@ namespace DuonDevKit.AspNetCore.Validation
/// validation against a bound parameter of type <typeparamref name="T"/> before the endpoint handler
/// runs, short-circuiting with a <c>400</c> <see cref="ValidationProblem"/> (field name → messages) if
/// invalid. Register via <see cref="EndpointFilterExtensions.WithDuonDevKitValidation{T}"/> rather than
/// directly — no dependency beyond the base class library, unlike <c>DuonDevKit.Validation</c>'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 <see cref="FluentValidationFilter{T}"/>
/// for the equivalent Minimal API filter, which produces the same response shape as this one.
/// </summary>
/// <remarks>
/// Only <typeparamref name="T"/>'s own properties are checked, not nested complex properties or
Expand Down
5 changes: 5 additions & 0 deletions DuonDevKit.Templates/DuonDevKit.Templates.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,11 @@
<!-- NU5128: normal for a template package — it intentionally ships no lib/ assembly. -->
<NoWarn>$(NoWarn);NU5128</NoWarn>
<IsPackable>true</IsPackable>
<!-- Overrides Directory.Build.props' global IncludeSymbols=true: this project has no compiled
output at all (IncludeBuildOutput=false above), so there's no PDB for a .snupkg to carry —
attempting one fails the whole `dotnet pack` with NU5017 ("no dependencies nor content"),
even though the main .nupkg packs fine. -->
<IncludeSymbols>false</IncludeSymbols>
</PropertyGroup>

<PropertyGroup>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@
<PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="9.0.18" />
<PackageReference Include="DuonDevKit.Core" Version="0.9.0" />
<PackageReference Include="DuonDevKit.EntityFrameworkCore" Version="0.7.2" />
<PackageReference Include="DuonDevKit.AspNetCore" Version="0.4.1" />
<PackageReference Include="DuonDevKit.AspNetCore" Version="0.5.0" />
<!--#if (dapper)
<PackageReference Include="DuonDevKit.Dapper" Version="0.2.1" />
#endif-->
Expand Down
9 changes: 4 additions & 5 deletions DuonDevKit.Validation/ValidatorExtensions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -13,11 +13,10 @@ namespace DuonDevKit.Validation
/// <remarks>
/// The resulting <see cref="Error"/> carries every blocking (<see cref="Severity.Error"/>) failure
/// joined into one message (<c>"PropertyName: ErrorMessage; ..."</c>) — <see cref="Error"/> has no
/// field-level structure to preserve a per-property error list. For an HTTP endpoint that needs a
/// field-level <c>{ "PropertyName": ["message"] }</c> response body, validate directly against
/// <see cref="IValidator{T}"/>/<see cref="ValidationResult"/> instead of going through this
/// extension — see <c>DuonDevKit.AspNetCore</c>'s <c>WithDuonDevKitValidation&lt;T&gt;()</c> 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 <c>{ "PropertyName": ["message"] }</c> response body instead, use
/// <c>DuonDevKit.AspNetCore</c>'s <c>WithDuonDevKitFluentValidation&lt;T&gt;()</c>, which validates
/// directly against this same <see cref="IValidator{T}"/> without going through this extension.
/// </remarks>
public static class ValidatorExtensions
{
Expand Down
Loading