Skip to content
Open
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
60 changes: 60 additions & 0 deletions .claude/CLAUDE.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
# CLAUDE.md

Guidance for working in this repo. Keep changes consistent with the conventions below.

## What this is

`Avenue3.MinimalApiValidation` (`A3.MinimalApiValidation`) — middleware + binders that automatically
validate request **headers**, **query parameters**, and the **request body** in ASP.NET Core minimal
API endpoints, using FluentValidation and `ValidationAttribute`s. Targets `net10.0`.

## Build & test

```bash
dotnet build MinimalApiValidation.sln
dotnet test # full suite
dotnet test --filter "FullyQualifiedName~<Namespace>" # subset
```

CI (`.github/workflows/pr-workflow.yml`) builds and tests in `Release` and collects coverage.

## Layout

- `src/A3.MinimalApiValidation/` — the library (only shipped project).
- `examples/Example/` — runnable sample endpoints; each implements `IEndpoint` and self-registers.
- `test/A3.MinimalApiValidation.Tests/` — xUnit tests (mostly full-pipeline integration tests).
- `test/TestClient/` — minimal host exposing `Program` for `WebApplicationFactory<Program>`.

## Architecture — two validation paths

1. **Middleware** (`Internal/Middleware/ValidationMiddleware`) — runs before the endpoint. Reads
endpoint parameter metadata (`ParameterAttributeInfo`, cached) and validates any parameter
attributed `[FromBody]` / `[FromQuery]` / `[FromHeader]`. Dispatches to `Body` or `HeaderOrQuery`.
Un-attributed parameters are ignored.
2. **Custom binders** (`Binders/FromQuery<T>`, `QueryTypeInfo`) — bind a group of query params to a
model, validated via `Internal/Filter/RequestModelValidationFilter`. Use this path for per-element
collection rules (`RuleForEach`) and `List<T>`/`IEnumerable<T>` collection properties.

Supporting pieces:
- `Internal/Utils` — `TryCastValue` (scalar `TypeParsers` dict + a parse-convention fallback for
enums / `IParsable<T>` / static `TryParse`), `IsEnumerable` / `IsCollectionType`, DataAnnotations
fallback, JSON helpers.
- `Internal/Filter/ValidateOnlyFilter` — short-circuits with `202` when the validate-only header is set.
- `ValidationAttributes/` — custom attributes (`Min`, `Max`, `GreaterThan`, …).
- `EndpointValidatorOptions` — `FallbackToDataAnnotations`, `PreferExplicitRequestModelValidation`,
`ValidateOnlyHeader`, `JsonSerializerOptions`.

Wire-up: `services.AddEndpointValidation(...)` + `app.UseEndpointValidation()`.

## Conventions

- File-scoped namespaces; `using` directives **inside** the namespace; nullable + implicit usings on.
- Validation of query/header collections is **collection-level** (attributes apply to the materialised
collection, matching `System.ComponentModel.DataAnnotations`). Per-element rules go through
`FromQuery<T>` + FluentValidation. A parameter type is validatable if the framework can bind it
(scalar, enum, `IParsable<T>`, static `TryParse(string, out T)`, or an array of those).
- Tests: one `class : TestBase` per file; override `AddTestEndpoint`; use the per-instance unique
`Path`; assert with `response.EnsureSuccessStatusCode()` / `response.EnsureErrorFor("key")`;
snake_case test names; include `// Arrange` / `// Act` / `// Assert` markers.
- Keep the primitive/scalar validation path unchanged when extending — it is covered by a large
existing test matrix under `ApiIntegrationTests/`.
41 changes: 41 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -218,6 +218,47 @@ GET /test-query-model?name=John&hasCake=true&hasEatenIt=false

> You can also use the same validation options as with `FromBody` (see below), including using DataAnnotations instead of FluentValidation, and explicit validation rather than automatic validation.

#### Collection query parameters and headers

Collection-typed parameters (`string[]`, `int[]`, etc.) are bound from repeated keys (`?tag=a&tag=b`)
and validated per element. This works for both `[FromQuery]` and `[FromHeader]`:

```csharp
// each value is parsed and validated; ?tag=a&tag=b binds two elements
app.MapGet("/test-collection", ([FromQuery] string[]? tag) => new { tag });
```

Any `ValidationAttribute` on a collection parameter is applied to the **collection itself** — matching
`System.ComponentModel.DataAnnotations` semantics — so `[MinLength]`/`[MaxLength]`/`[Length]` constrain
the **item count**:

```csharp
// between 1 and 3 ids must be supplied
app.MapGet("/test-collection", ([FromQuery, MinLength(1), MaxLength(3)] int[] id) => new { id });
```

> To validate **each element** against a rule (e.g. each string matches a pattern), use the
> `FromQuery<T>` binder with a FluentValidation `RuleForEach`. Note the minimal API framework only binds
> arrays / `StringValues` for a bare `[FromQuery]` parameter; use `FromQuery<T>` for `List<T>` /
> `IEnumerable<T>` collection properties.

#### Enums and custom parsable types

Any type the minimal API framework can bind from a string is also validated, including regular `enum`s
and custom "smart enum" types that expose a static `TryParse(string, out T)` (or implement
`IParsable<T>`). A value that fails to parse produces a `400`:

```csharp
// ?day=Monday is valid; ?day=Someday returns a 400
app.MapGet("/test-enum", ([FromQuery] DayOfWeek day) => new { day });

// combine with a collection to enforce "1-3 values, each from a closed set"
app.MapGet("/test-animals", ([FromQuery, MinLength(1), MaxLength(3)] Animal[] animal) => new { animal });
```

> For a custom enumeration to be used in a query string or header, it must be bindable by the framework —
> that means exposing a static `TryParse(string, out T)` or implementing `IParsable<T>`.

### Request Body

You can validate any request body automatically by using the `[FromBody]` attribute and registering a `FluentValidation` validator.
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
namespace Example.Endpoints.QueryCollection;

using System.ComponentModel.DataAnnotations;
using Microsoft.AspNetCore.Mvc;

public class EndpointWithQueryCollection : IEndpoint
{
public static void Add(IEndpointRouteBuilder app)
{
// Repeated query keys (?tag=a&tag=b) bind to the collection and are validated per element.
// Validation attributes apply to the collection itself, so [MaxLength] limits the item count.
app.MapGet("with-query-collection", (
[FromQuery] string[]? tag,
[FromQuery, MaxLength(3)] int[]? id
) => TypedResults.Ok(new
{
tag,
id,
}));
}
}
66 changes: 64 additions & 2 deletions src/A3.MinimalApiValidation/Internal/Middleware/HeaderOrQuery.cs
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,15 @@ internal static class HeaderOrQuery
{
public static IEnumerable<ValidationFailure> Handle(ParameterAttributeInfo arg, HttpContext context)
{
var castingType = arg.UnderlyingType ?? arg.ParameterType;

// collection-typed parameters (e.g. string[], int[], List<T>) are read and validated
// per element, with any validation attributes applied to the materialised collection.
if (Utils.IsCollectionType(castingType, out var elementType))
{
return HandleCollection(arg, context, elementType);
}

var value = arg.IsHeader
? context.Request.Headers[arg.Name].FirstOrDefault()
: context.Request.Query[arg.Name].FirstOrDefault();
Expand All @@ -33,8 +42,7 @@ public static IEnumerable<ValidationFailure> Handle(ParameterAttributeInfo arg,
{
return [new ValidationFailure(arg.Name, message)];
}

var castingType = arg.UnderlyingType ?? arg.ParameterType;

var didCastValue = Utils.TryCastValue(value, castingType, out var castValue, out _);

if (!didCastValue || castValue is null)
Expand All @@ -55,4 +63,58 @@ public static IEnumerable<ValidationFailure> Handle(ParameterAttributeInfo arg,

return errors;
}

private static IEnumerable<ValidationFailure> HandleCollection(
ParameterAttributeInfo arg,
HttpContext context,
Type elementType)
{
var values = arg.IsHeader
? context.Request.Headers[arg.Name]
: context.Request.Query[arg.Name];

var message = arg.IsHeader
? $"{arg.Name} is a required header."
: $"{arg.Name} is a required query string parameter.";

// an absent collection is treated the same as an absent scalar: allowed when nullable.
if (values.Count == 0)
{
return arg.IsNullable
? []
: [new ValidationFailure(arg.Name, message)];
}

var errors = new List<ValidationFailure>();
var parsed = new List<object?>();

for (var i = 0; i < values.Count; i++)
{
if (!Utils.TryCastValue(values[i], elementType, out var castValue, out _) || castValue is null)
{
errors.Add(new ValidationFailure(
$"{arg.Name}[{i}]",
$"Could not cast {arg.Name}[{i}] to {elementType.Name}."));
continue;
}

parsed.Add(castValue);
}

// materialise into a strongly-typed array so that collection-level validation attributes
// (e.g. [MinLength], [MaxLength], [Length], [Required]) see the real element type and count.
var collection = Array.CreateInstance(elementType, parsed.Count);
for (var i = 0; i < parsed.Count; i++)
{
collection.SetValue(parsed[i], i);
}

var validationContext = new ValidationContext(collection, context.RequestServices, items: null);

errors.AddRange(arg.ValidationAttributes
.Where(x => x.GetValidationResult(collection, validationContext) is not null)
.Select(x => new ValidationFailure(arg.Name, x.FormatErrorMessage(arg.Name))));

return errors;
}
}
120 changes: 114 additions & 6 deletions src/A3.MinimalApiValidation/Internal/Utils.cs
Original file line number Diff line number Diff line change
@@ -1,8 +1,10 @@
namespace A3.MinimalApiValidation.Internal;

using System.Collections;
using System.Collections.Concurrent;
using System.Diagnostics.CodeAnalysis;
using System.Globalization;
using System.Reflection;
using System.Text.Json;
using A3.MinimalApiValidation.Internal.Middleware;
using FluentValidation;
Expand Down Expand Up @@ -116,6 +118,22 @@ public static bool TryDeserialize(
}
}

/// <summary>
/// Determines whether <paramref name="type"/> should be treated as a collection of values for
/// header/query validation. <see cref="string"/> is intentionally excluded (it is an
/// <see cref="IEnumerable{Char}"/> but must be validated as a scalar).
/// </summary>
public static bool IsCollectionType(Type type, [NotNullWhen(true)] out Type? elementType)
{
if (type == typeof(string))
{
elementType = null;
return false;
}

return IsEnumerable(type, out elementType);
}

public static bool IsEnumerable(Type type, [NotNullWhen(true)] out Type? firstUnderlyingType)
{
if (!typeof(IEnumerable).IsAssignableFrom(type))
Expand Down Expand Up @@ -180,24 +198,114 @@ public static bool TryCastValue(string? value, Type type, out object? castValue,
castValue = null;
defaultValue = null;

if (!TypeParsers.TryGetValue(type, out var parser))
if (TypeParsers.TryGetValue(type, out var parser))
{
var (success, result, def) = parser(value);
defaultValue = def;

if (success)
{
castValue = result;
return true;
}

castValue = defaultValue;
return false;
}

var (success, result, def) = parser(value);
defaultValue = def;
// fall back to a parse convention (enums, IParsable<T>, or a static TryParse method).
// this lets us validate any type the minimal api framework can bind, e.g. custom
// "smart enum" types that expose a static TryParse(string, out T).
var conventionParser = ConventionParsers.GetOrAdd(type, ResolveConventionParser);
if (conventionParser is null)
{
return false;
}

if (success)
var (ok, parsed) = conventionParser(value);
if (ok)
{
castValue = result;
castValue = parsed;
return true;
}

castValue = defaultValue;
return false;
}

private static ConcurrentDictionary<Type, Func<string?, (bool Success, object? Value)>?> ConventionParsers { get; } = new();

private static Func<string?, (bool Success, object? Value)>? ResolveConventionParser(Type type)
{
// 1. real enums
if (type.IsEnum)
{
return v =>
{
if (v is null)
{
return (false, null);
}

var ok = Enum.TryParse(type, v, ignoreCase: false, out var result);
return (ok, ok ? result : null);
};
}

// 2. IParsable<T>: static bool TryParse(string?, IFormatProvider?, out T)
var parsable = type.GetMethod(
"TryParse",
BindingFlags.Public | BindingFlags.Static | BindingFlags.FlattenHierarchy,
binder: null,
types: [typeof(string), typeof(IFormatProvider), type.MakeByRefType()],
modifiers: null);

if (parsable is not null && parsable.ReturnType == typeof(bool))
{
return v =>
{
var args = new object?[] { v, null, null };
var ok = (bool) parsable.Invoke(null, args)!;
return (ok, ok ? args[2] : null);
};
}

// 3. legacy convention: static bool TryParse(string, out T)
// walk the hierarchy so statics declared on a generic base (e.g. SmartEnum<T>) are found.
var legacy = FindLegacyTryParse(type);
if (legacy is not null)
{
return v =>
{
var args = new object?[] { v, null };
var ok = (bool) legacy.Invoke(null, args)!;
return (ok, ok ? args[1] : null);
};
}

return null;
}

private static MethodInfo? FindLegacyTryParse(Type type)
{
for (var current = type; current is not null && current != typeof(object); current = current.BaseType)
{
var method = current
.GetMethods(BindingFlags.Public | BindingFlags.Static | BindingFlags.DeclaredOnly)
.FirstOrDefault(m =>
m is { Name: "TryParse", ReturnType.Name: nameof(Boolean) }
&& m.GetParameters() is [{ } p0, { IsOut: true } p1]
&& p0.ParameterType == typeof(string)
&& p1.ParameterType.GetElementType() == type);

if (method is not null)
{
return method;
}
}

return null;
}

private static Dictionary<Type, Func<string?, (bool Success, object? CastValue, object? DefaultValue)>> TypeParsers { get; } = new()
{
{ typeof(bool), v => (bool.TryParse(v, out var result), result, false) },
Expand Down
Loading