From 3598e84c9bc242cd372683d9a2f1f206e88abd5a Mon Sep 17 00:00:00 2001 From: Graham Mawji-Bull Date: Fri, 17 Jul 2026 12:20:32 +0100 Subject: [PATCH 1/3] feat: validate collection and parsable query/header parameters Header/query validation previously rejected any collection-typed parameter (string[], int[], ...) with a 400, and could not validate types outside a fixed scalar set. Two changes close both gaps: - HeaderOrQuery reads the full StringValues for collection parameters and validates each element, applying any ValidationAttribute to the materialised collection (matching DataAnnotations semantics, so [MinLength]/[MaxLength]/[Length] constrain item count). - Utils.TryCastValue falls back to a parse convention (enum, IParsable, or a static TryParse(string, out T)) for types not in the scalar set, so any type the framework can bind is also validated. Includes support for string-backed "smart enum" types. Adds integration tests covering collections (query + header), closed/open smart enums, and a regression guard for un-attributed parameters. --- .../Internal/Middleware/HeaderOrQuery.cs | 66 +++++++++- src/A3.MinimalApiValidation/Internal/Utils.cs | 120 +++++++++++++++++- .../Collections/IntArrayQueryParam.cs | 52 ++++++++ .../MaxLengthStringArrayQueryParam.cs | 44 +++++++ .../RequiredStringArrayQueryParam.cs | 44 +++++++ .../Collections/StringArrayHeader.cs | 45 +++++++ .../Collections/StringArrayQueryParam.cs | 52 ++++++++ .../UnattributedStringArrayQueryParam.cs | 31 +++++ .../SmartEnums/ClosedEnumArrayQueryParam.cs | 68 ++++++++++ .../SmartEnums/ClosedEnumScalarQueryParam.cs | 55 ++++++++ .../SmartEnums/OpenEnumArrayQueryParam.cs | 30 +++++ .../SmartEnums/OpenEnumQueryParam.cs | 45 +++++++ .../SmartEnums/RealEnumQueryParam.cs | 42 ++++++ .../_utils/SmartEnums.cs | 92 ++++++++++++++ 14 files changed, 778 insertions(+), 8 deletions(-) create mode 100644 test/A3.MinimalApiValidation.Tests/ApiIntegrationTests/Collections/IntArrayQueryParam.cs create mode 100644 test/A3.MinimalApiValidation.Tests/ApiIntegrationTests/Collections/MaxLengthStringArrayQueryParam.cs create mode 100644 test/A3.MinimalApiValidation.Tests/ApiIntegrationTests/Collections/RequiredStringArrayQueryParam.cs create mode 100644 test/A3.MinimalApiValidation.Tests/ApiIntegrationTests/Collections/StringArrayHeader.cs create mode 100644 test/A3.MinimalApiValidation.Tests/ApiIntegrationTests/Collections/StringArrayQueryParam.cs create mode 100644 test/A3.MinimalApiValidation.Tests/ApiIntegrationTests/Collections/UnattributedStringArrayQueryParam.cs create mode 100644 test/A3.MinimalApiValidation.Tests/ApiIntegrationTests/SmartEnums/ClosedEnumArrayQueryParam.cs create mode 100644 test/A3.MinimalApiValidation.Tests/ApiIntegrationTests/SmartEnums/ClosedEnumScalarQueryParam.cs create mode 100644 test/A3.MinimalApiValidation.Tests/ApiIntegrationTests/SmartEnums/OpenEnumArrayQueryParam.cs create mode 100644 test/A3.MinimalApiValidation.Tests/ApiIntegrationTests/SmartEnums/OpenEnumQueryParam.cs create mode 100644 test/A3.MinimalApiValidation.Tests/ApiIntegrationTests/SmartEnums/RealEnumQueryParam.cs create mode 100644 test/A3.MinimalApiValidation.Tests/_utils/SmartEnums.cs diff --git a/src/A3.MinimalApiValidation/Internal/Middleware/HeaderOrQuery.cs b/src/A3.MinimalApiValidation/Internal/Middleware/HeaderOrQuery.cs index e907a06..01b6af0 100644 --- a/src/A3.MinimalApiValidation/Internal/Middleware/HeaderOrQuery.cs +++ b/src/A3.MinimalApiValidation/Internal/Middleware/HeaderOrQuery.cs @@ -8,6 +8,15 @@ internal static class HeaderOrQuery { public static IEnumerable Handle(ParameterAttributeInfo arg, HttpContext context) { + var castingType = arg.UnderlyingType ?? arg.ParameterType; + + // collection-typed parameters (e.g. string[], int[], List) 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(); @@ -33,8 +42,7 @@ public static IEnumerable 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) @@ -55,4 +63,58 @@ public static IEnumerable Handle(ParameterAttributeInfo arg, return errors; } + + private static IEnumerable 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(); + var parsed = new List(); + + 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; + } } diff --git a/src/A3.MinimalApiValidation/Internal/Utils.cs b/src/A3.MinimalApiValidation/Internal/Utils.cs index 25de1db..f4f847c 100644 --- a/src/A3.MinimalApiValidation/Internal/Utils.cs +++ b/src/A3.MinimalApiValidation/Internal/Utils.cs @@ -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; @@ -116,6 +118,22 @@ public static bool TryDeserialize( } } + /// + /// Determines whether should be treated as a collection of values for + /// header/query validation. is intentionally excluded (it is an + /// but must be validated as a scalar). + /// + 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)) @@ -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, 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?> ConventionParsers { get; } = new(); + + private static Func? 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: 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) 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> TypeParsers { get; } = new() { { typeof(bool), v => (bool.TryParse(v, out var result), result, false) }, diff --git a/test/A3.MinimalApiValidation.Tests/ApiIntegrationTests/Collections/IntArrayQueryParam.cs b/test/A3.MinimalApiValidation.Tests/ApiIntegrationTests/Collections/IntArrayQueryParam.cs new file mode 100644 index 0000000..4c92eff --- /dev/null +++ b/test/A3.MinimalApiValidation.Tests/ApiIntegrationTests/Collections/IntArrayQueryParam.cs @@ -0,0 +1,52 @@ +namespace A3.MinimalApiValidation.Tests.ApiIntegrationTests.Collections; + +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc.Testing; +using Microsoft.AspNetCore.Routing; + +public class IntArrayQueryParam : TestBase +{ + public IntArrayQueryParam(WebApplicationFactory factory) : base(factory) + { + } + + protected override void AddTestEndpoint(IEndpointRouteBuilder app) + { + app.MapGet(Path, ([FromQuery] int[]? number = null) => TypedResults.Ok(number)); + } + + [Fact] + public async Task returns_ok_when_absent() + { + // Arrange + // Act + var response = await Client.GetAsync($"{Path}"); + + // Assert + response.EnsureSuccessStatusCode(); + } + + [Fact] + public async Task returns_ok_for_repeated_valid_values() + { + // Arrange + // Act + var response = await Client.GetAsync($"{Path}?number=1&number=2&number=3"); + + // Assert + response.EnsureSuccessStatusCode(); + } + + [Fact] + public async Task returns_bad_request_when_an_element_is_not_an_int() + { + // Arrange + // Act + var response = await Client.GetAsync($"{Path}?number=1&number=abc"); + + // Assert + await response.EnsureErrorFor("number[1]"); + } +} diff --git a/test/A3.MinimalApiValidation.Tests/ApiIntegrationTests/Collections/MaxLengthStringArrayQueryParam.cs b/test/A3.MinimalApiValidation.Tests/ApiIntegrationTests/Collections/MaxLengthStringArrayQueryParam.cs new file mode 100644 index 0000000..2a547c5 --- /dev/null +++ b/test/A3.MinimalApiValidation.Tests/ApiIntegrationTests/Collections/MaxLengthStringArrayQueryParam.cs @@ -0,0 +1,44 @@ +namespace A3.MinimalApiValidation.Tests.ApiIntegrationTests.Collections; + +using System.ComponentModel.DataAnnotations; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc.Testing; +using Microsoft.AspNetCore.Routing; + +// Validation attributes on a collection parameter are applied to the collection itself +// (matching System.ComponentModel.DataAnnotations semantics), so [MaxLength] limits the item count. +public class MaxLengthStringArrayQueryParam : TestBase +{ + public MaxLengthStringArrayQueryParam(WebApplicationFactory factory) : base(factory) + { + } + + protected override void AddTestEndpoint(IEndpointRouteBuilder app) + { + app.MapGet(Path, ([FromQuery, MaxLength(2)] string[]? tag = null) => TypedResults.Ok(tag)); + } + + [Fact] + public async Task returns_ok_when_within_the_limit() + { + // Arrange + // Act + var response = await Client.GetAsync($"{Path}?tag=a&tag=b"); + + // Assert + response.EnsureSuccessStatusCode(); + } + + [Fact] + public async Task returns_bad_request_when_too_many_items() + { + // Arrange + // Act + var response = await Client.GetAsync($"{Path}?tag=a&tag=b&tag=c"); + + // Assert + await response.EnsureErrorFor("tag"); + } +} diff --git a/test/A3.MinimalApiValidation.Tests/ApiIntegrationTests/Collections/RequiredStringArrayQueryParam.cs b/test/A3.MinimalApiValidation.Tests/ApiIntegrationTests/Collections/RequiredStringArrayQueryParam.cs new file mode 100644 index 0000000..2ac6262 --- /dev/null +++ b/test/A3.MinimalApiValidation.Tests/ApiIntegrationTests/Collections/RequiredStringArrayQueryParam.cs @@ -0,0 +1,44 @@ +namespace A3.MinimalApiValidation.Tests.ApiIntegrationTests.Collections; + +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc.Testing; +using Microsoft.AspNetCore.Routing; + +// A non-nullable collection parameter is required: an absent value is an error. +// (Note: the minimal api framework only binds arrays / StringValues for [FromQuery]; use the +// FromQuery model binder for List/IEnumerable collection properties.) +public class RequiredStringArrayQueryParam : TestBase +{ + public RequiredStringArrayQueryParam(WebApplicationFactory factory) : base(factory) + { + } + + protected override void AddTestEndpoint(IEndpointRouteBuilder app) + { + app.MapGet(Path, ([FromQuery] string[] tag) => TypedResults.Ok(tag)); + } + + [Fact] + public async Task returns_ok_for_repeated_keys() + { + // Arrange + // Act + var response = await Client.GetAsync($"{Path}?tag=a&tag=b"); + + // Assert + response.EnsureSuccessStatusCode(); + } + + [Fact] + public async Task returns_bad_request_when_absent() + { + // Arrange + // Act + var response = await Client.GetAsync($"{Path}"); + + // Assert + await response.EnsureErrorFor("tag"); + } +} diff --git a/test/A3.MinimalApiValidation.Tests/ApiIntegrationTests/Collections/StringArrayHeader.cs b/test/A3.MinimalApiValidation.Tests/ApiIntegrationTests/Collections/StringArrayHeader.cs new file mode 100644 index 0000000..ef2e868 --- /dev/null +++ b/test/A3.MinimalApiValidation.Tests/ApiIntegrationTests/Collections/StringArrayHeader.cs @@ -0,0 +1,45 @@ +namespace A3.MinimalApiValidation.Tests.ApiIntegrationTests.Collections; + +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc.Testing; +using Microsoft.AspNetCore.Routing; + +public class StringArrayHeader : TestBase +{ + public StringArrayHeader(WebApplicationFactory factory) : base(factory) + { + } + + protected override void AddTestEndpoint(IEndpointRouteBuilder app) + { + app.MapGet(Path, ([FromHeader(Name = "x-tag")] string[]? tag = null) => TypedResults.Ok(tag)); + } + + [Fact] + public async Task returns_ok_when_absent() + { + // Arrange + // Act + var response = await Client.GetAsync($"{Path}"); + + // Assert + response.EnsureSuccessStatusCode(); + } + + [Fact] + public async Task returns_ok_for_multiple_header_values() + { + // Arrange + var request = new HttpRequestMessage(HttpMethod.Get, Path); + request.Headers.Add("x-tag", "a"); + request.Headers.Add("x-tag", "b"); + + // Act + var response = await Client.SendAsync(request); + + // Assert + response.EnsureSuccessStatusCode(); + } +} diff --git a/test/A3.MinimalApiValidation.Tests/ApiIntegrationTests/Collections/StringArrayQueryParam.cs b/test/A3.MinimalApiValidation.Tests/ApiIntegrationTests/Collections/StringArrayQueryParam.cs new file mode 100644 index 0000000..8f2e2f2 --- /dev/null +++ b/test/A3.MinimalApiValidation.Tests/ApiIntegrationTests/Collections/StringArrayQueryParam.cs @@ -0,0 +1,52 @@ +namespace A3.MinimalApiValidation.Tests.ApiIntegrationTests.Collections; + +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc.Testing; +using Microsoft.AspNetCore.Routing; + +public class StringArrayQueryParam : TestBase +{ + public StringArrayQueryParam(WebApplicationFactory factory) : base(factory) + { + } + + protected override void AddTestEndpoint(IEndpointRouteBuilder app) + { + app.MapGet(Path, ([FromQuery] string[]? tag = null) => TypedResults.Ok(tag)); + } + + [Fact] + public async Task returns_ok_when_absent() + { + // Arrange + // Act + var response = await Client.GetAsync($"{Path}"); + + // Assert + response.EnsureSuccessStatusCode(); + } + + [Fact] + public async Task returns_ok_for_a_single_value() + { + // Arrange + // Act + var response = await Client.GetAsync($"{Path}?tag=a"); + + // Assert + response.EnsureSuccessStatusCode(); + } + + [Fact] + public async Task returns_ok_for_repeated_keys() + { + // Arrange + // Act + var response = await Client.GetAsync($"{Path}?tag=a&tag=b"); + + // Assert + response.EnsureSuccessStatusCode(); + } +} diff --git a/test/A3.MinimalApiValidation.Tests/ApiIntegrationTests/Collections/UnattributedStringArrayQueryParam.cs b/test/A3.MinimalApiValidation.Tests/ApiIntegrationTests/Collections/UnattributedStringArrayQueryParam.cs new file mode 100644 index 0000000..1248c4f --- /dev/null +++ b/test/A3.MinimalApiValidation.Tests/ApiIntegrationTests/Collections/UnattributedStringArrayQueryParam.cs @@ -0,0 +1,31 @@ +namespace A3.MinimalApiValidation.Tests.ApiIntegrationTests.Collections; + +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc.Testing; +using Microsoft.AspNetCore.Routing; + +// Regression guard: parameters without an explicit [FromQuery]/[FromHeader] attribute are ignored +// by the middleware, so the consumer's "drop the attribute" workaround keeps working. +public class UnattributedStringArrayQueryParam : TestBase +{ + public UnattributedStringArrayQueryParam(WebApplicationFactory factory) : base(factory) + { + } + + protected override void AddTestEndpoint(IEndpointRouteBuilder app) + { + app.MapGet(Path, (string[]? tag) => TypedResults.Ok(tag)); + } + + [Fact] + public async Task returns_ok_for_repeated_keys() + { + // Arrange + // Act + var response = await Client.GetAsync($"{Path}?tag=a&tag=b"); + + // Assert + response.EnsureSuccessStatusCode(); + } +} diff --git a/test/A3.MinimalApiValidation.Tests/ApiIntegrationTests/SmartEnums/ClosedEnumArrayQueryParam.cs b/test/A3.MinimalApiValidation.Tests/ApiIntegrationTests/SmartEnums/ClosedEnumArrayQueryParam.cs new file mode 100644 index 0000000..649f1fa --- /dev/null +++ b/test/A3.MinimalApiValidation.Tests/ApiIntegrationTests/SmartEnums/ClosedEnumArrayQueryParam.cs @@ -0,0 +1,68 @@ +namespace A3.MinimalApiValidation.Tests.ApiIntegrationTests.SmartEnums; + +using System.ComponentModel.DataAnnotations; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc.Testing; +using Microsoft.AspNetCore.Routing; + +// The "1-3 items, each one of cat/dog/bird" scenario, end to end: +// - cardinality via [MinLength]/[MaxLength] on the collection +// - the closed set via the element type failing to parse unknown values +public class ClosedEnumArrayQueryParam : TestBase +{ + public ClosedEnumArrayQueryParam(WebApplicationFactory factory) : base(factory) + { + } + + protected override void AddTestEndpoint(IEndpointRouteBuilder app) + { + app.MapGet(Path, ([FromQuery, MinLength(1), MaxLength(3)] Animal[]? animal = null) + => TypedResults.Ok(animal)); + } + + [Fact] + public async Task returns_ok_for_defined_values_within_the_limit() + { + // Arrange + // Act + var response = await Client.GetAsync($"{Path}?animal=cat&animal=dog"); + + // Assert + response.EnsureSuccessStatusCode(); + } + + [Fact] + public async Task returns_bad_request_when_an_element_is_unknown() + { + // Arrange + // Act + var response = await Client.GetAsync($"{Path}?animal=cat&animal=fish"); + + // Assert + await response.EnsureErrorFor("animal[1]"); + } + + [Fact] + public async Task returns_bad_request_when_too_many_items() + { + // Arrange + // Act + var response = await Client.GetAsync($"{Path}?animal=cat&animal=dog&animal=bird&animal=cat"); + + // Assert + await response.EnsureErrorFor("animal"); + } + + [Fact] + public async Task returns_ok_when_absent() + { + // Arrange + // Act + var response = await Client.GetAsync($"{Path}"); + + // Assert + response.EnsureSuccessStatusCode(); + } +} diff --git a/test/A3.MinimalApiValidation.Tests/ApiIntegrationTests/SmartEnums/ClosedEnumScalarQueryParam.cs b/test/A3.MinimalApiValidation.Tests/ApiIntegrationTests/SmartEnums/ClosedEnumScalarQueryParam.cs new file mode 100644 index 0000000..dae89e9 --- /dev/null +++ b/test/A3.MinimalApiValidation.Tests/ApiIntegrationTests/SmartEnums/ClosedEnumScalarQueryParam.cs @@ -0,0 +1,55 @@ +namespace A3.MinimalApiValidation.Tests.ApiIntegrationTests.SmartEnums; + +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc.Testing; +using Microsoft.AspNetCore.Routing; + +public class ClosedEnumScalarQueryParam : TestBase +{ + public ClosedEnumScalarQueryParam(WebApplicationFactory factory) : base(factory) + { + } + + protected override void AddTestEndpoint(IEndpointRouteBuilder app) + { + app.MapGet(Path, ([FromQuery] Animal? animal = null) => TypedResults.Ok(animal?.Value)); + } + + [Theory] + [InlineData("cat")] + [InlineData("dog")] + [InlineData("bird")] + public async Task returns_ok_for_a_defined_value(string animal) + { + // Arrange + // Act + var response = await Client.GetAsync($"{Path}?animal={animal}"); + + // Assert + response.EnsureSuccessStatusCode(); + } + + [Fact] + public async Task returns_ok_when_absent() + { + // Arrange + // Act + var response = await Client.GetAsync($"{Path}"); + + // Assert + response.EnsureSuccessStatusCode(); + } + + [Fact] + public async Task returns_bad_request_for_an_unknown_value() + { + // Arrange + // Act + var response = await Client.GetAsync($"{Path}?animal=fish"); + + // Assert + await response.EnsureErrorFor("animal"); + } +} diff --git a/test/A3.MinimalApiValidation.Tests/ApiIntegrationTests/SmartEnums/OpenEnumArrayQueryParam.cs b/test/A3.MinimalApiValidation.Tests/ApiIntegrationTests/SmartEnums/OpenEnumArrayQueryParam.cs new file mode 100644 index 0000000..4d2b05e --- /dev/null +++ b/test/A3.MinimalApiValidation.Tests/ApiIntegrationTests/SmartEnums/OpenEnumArrayQueryParam.cs @@ -0,0 +1,30 @@ +namespace A3.MinimalApiValidation.Tests.ApiIntegrationTests.SmartEnums; + +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc.Testing; +using Microsoft.AspNetCore.Routing; + +public class OpenEnumArrayQueryParam : TestBase +{ + public OpenEnumArrayQueryParam(WebApplicationFactory factory) : base(factory) + { + } + + protected override void AddTestEndpoint(IEndpointRouteBuilder app) + { + app.MapGet(Path, ([FromQuery] Status[]? status = null) => TypedResults.Ok(status)); + } + + [Fact] + public async Task returns_ok_for_repeated_keys_including_unknown_values() + { + // Arrange + // Act + var response = await Client.GetAsync($"{Path}?status=active&status=something-unknown"); + + // Assert + response.EnsureSuccessStatusCode(); + } +} diff --git a/test/A3.MinimalApiValidation.Tests/ApiIntegrationTests/SmartEnums/OpenEnumQueryParam.cs b/test/A3.MinimalApiValidation.Tests/ApiIntegrationTests/SmartEnums/OpenEnumQueryParam.cs new file mode 100644 index 0000000..58b35c5 --- /dev/null +++ b/test/A3.MinimalApiValidation.Tests/ApiIntegrationTests/SmartEnums/OpenEnumQueryParam.cs @@ -0,0 +1,45 @@ +namespace A3.MinimalApiValidation.Tests.ApiIntegrationTests.SmartEnums; + +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc.Testing; +using Microsoft.AspNetCore.Routing; + +// Open enumerations accept any non-empty value, so validation never rejects an unknown value. +public class OpenEnumQueryParam : TestBase +{ + public OpenEnumQueryParam(WebApplicationFactory factory) : base(factory) + { + } + + protected override void AddTestEndpoint(IEndpointRouteBuilder app) + { + app.MapGet(Path, ([FromQuery] Status? status = null) => TypedResults.Ok(status?.Value)); + } + + [Theory] + [InlineData("active")] + [InlineData("archived")] + [InlineData("something-unknown")] + public async Task returns_ok_for_any_value(string status) + { + // Arrange + // Act + var response = await Client.GetAsync($"{Path}?status={status}"); + + // Assert + response.EnsureSuccessStatusCode(); + } + + [Fact] + public async Task returns_ok_when_absent() + { + // Arrange + // Act + var response = await Client.GetAsync($"{Path}"); + + // Assert + response.EnsureSuccessStatusCode(); + } +} diff --git a/test/A3.MinimalApiValidation.Tests/ApiIntegrationTests/SmartEnums/RealEnumQueryParam.cs b/test/A3.MinimalApiValidation.Tests/ApiIntegrationTests/SmartEnums/RealEnumQueryParam.cs new file mode 100644 index 0000000..165da01 --- /dev/null +++ b/test/A3.MinimalApiValidation.Tests/ApiIntegrationTests/SmartEnums/RealEnumQueryParam.cs @@ -0,0 +1,42 @@ +namespace A3.MinimalApiValidation.Tests.ApiIntegrationTests.SmartEnums; + +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc.Testing; +using Microsoft.AspNetCore.Routing; + +// A regular C# enum is validated via the Enum.TryParse convention. +public class RealEnumQueryParam : TestBase +{ + public RealEnumQueryParam(WebApplicationFactory factory) : base(factory) + { + } + + protected override void AddTestEndpoint(IEndpointRouteBuilder app) + { + app.MapGet(Path, ([FromQuery] DayOfWeek? day = null) => TypedResults.Ok(day)); + } + + [Fact] + public async Task returns_ok_for_a_valid_value() + { + // Arrange + // Act + var response = await Client.GetAsync($"{Path}?day=Monday"); + + // Assert + response.EnsureSuccessStatusCode(); + } + + [Fact] + public async Task returns_bad_request_for_an_invalid_value() + { + // Arrange + // Act + var response = await Client.GetAsync($"{Path}?day=NotADay"); + + // Assert + await response.EnsureErrorFor("day"); + } +} diff --git a/test/A3.MinimalApiValidation.Tests/_utils/SmartEnums.cs b/test/A3.MinimalApiValidation.Tests/_utils/SmartEnums.cs new file mode 100644 index 0000000..1d8281e --- /dev/null +++ b/test/A3.MinimalApiValidation.Tests/_utils/SmartEnums.cs @@ -0,0 +1,92 @@ +namespace A3.MinimalApiValidation.Tests._utils; + +using System.Diagnostics.CodeAnalysis; +using System.Reflection; + +// Simplified stand-ins for the consumer's string-backed "smart enum" patterns. Both expose the +// static TryParse(string, out T) convention on the generic base (inherited by the concrete type), +// which is what the minimal api framework uses to bind and what A3 uses to validate. + +/// +/// A closed string-backed enumeration: only pre-defined values are valid, unknown values fail to parse. +/// +public abstract class ClosedEnumeration where T : ClosedEnumeration +{ + private static IEnumerable AllItems { get; } = typeof(T) + .GetProperties(BindingFlags.Public | BindingFlags.Static | BindingFlags.DeclaredOnly) + .Select(p => p.GetValue(null)) + .Cast() + .ToArray(); + + public static bool TryParse(string? value, [NotNullWhen(true)] out T? result) + { + result = AllItems.FirstOrDefault(x => x.Value == value); + return result is not null; + } + + protected ClosedEnumeration(string value) => Value = value; + + public string Value { get; } + + public override string ToString() => Value; +} + +public sealed class Animal : ClosedEnumeration +{ + public static Animal Cat { get; } = new("cat"); + public static Animal Dog { get; } = new("dog"); + public static Animal Bird { get; } = new("bird"); + + private Animal(string value) : base(value) + { + } +} + +/// +/// An open string-backed enumeration: pre-defined values are recognised, but any other non-empty +/// value is accepted as free text (never fails to parse). +/// +public abstract class OpenEnumeration where T : OpenEnumeration +{ + private static IEnumerable AllDefinedItems { get; } = typeof(T) + .GetProperties(BindingFlags.Public | BindingFlags.Static | BindingFlags.DeclaredOnly) + .Select(p => p.GetValue(null)) + .Cast() + .ToArray(); + + public static bool TryParse(string? value, [NotNullWhen(true)] out T? result) + { + result = null; + + if (string.IsNullOrWhiteSpace(value)) + { + return false; + } + + result = AllDefinedItems.FirstOrDefault(x => string.Equals(x.Value, value, StringComparison.OrdinalIgnoreCase)) + ?? (T) Activator.CreateInstance( + typeof(T), + BindingFlags.Instance | BindingFlags.NonPublic | BindingFlags.Public, + binder: null, + args: [value], + culture: null)!; + + return true; + } + + protected OpenEnumeration(string value) => Value = value; + + public string Value { get; } + + public override string ToString() => Value; +} + +public sealed class Status : OpenEnumeration +{ + public static Status Active { get; } = new("active"); + public static Status Archived { get; } = new("archived"); + + private Status(string value) : base(value) + { + } +} From 871e396eb2b23aed49bc2a0214cb76347ad5b418 Mon Sep 17 00:00:00 2001 From: Graham Mawji-Bull Date: Fri, 17 Jul 2026 12:20:32 +0100 Subject: [PATCH 2/3] docs: document collection and enum query/header validation Add a README section and an example endpoint covering repeated-key collection binding, collection-level validation attributes, and enum / parsable-type validation. --- README.md | 41 +++++++++++++++++++ .../EndpointWithQueryCollection.cs | 21 ++++++++++ 2 files changed, 62 insertions(+) create mode 100644 examples/Example/Endpoints/QueryCollection/EndpointWithQueryCollection.cs diff --git a/README.md b/README.md index 51e0950..b0ac9d8 100644 --- a/README.md +++ b/README.md @@ -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` binder with a FluentValidation `RuleForEach`. Note the minimal API framework only binds +> arrays / `StringValues` for a bare `[FromQuery]` parameter; use `FromQuery` for `List` / +> `IEnumerable` 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`). 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`. + ### Request Body You can validate any request body automatically by using the `[FromBody]` attribute and registering a `FluentValidation` validator. diff --git a/examples/Example/Endpoints/QueryCollection/EndpointWithQueryCollection.cs b/examples/Example/Endpoints/QueryCollection/EndpointWithQueryCollection.cs new file mode 100644 index 0000000..36c348e --- /dev/null +++ b/examples/Example/Endpoints/QueryCollection/EndpointWithQueryCollection.cs @@ -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, + })); + } +} From 4868792505ba21ab3cde9b887e3c9a7e92d8a2c9 Mon Sep 17 00:00:00 2001 From: Graham Mawji-Bull Date: Fri, 17 Jul 2026 12:23:12 +0100 Subject: [PATCH 3/3] docs: add CLAUDE.md project guide Scaffold a concise agent guide covering build/test commands, project layout, the two validation paths (middleware vs custom binders), and repo conventions. --- .claude/CLAUDE.md | 60 +++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 60 insertions(+) create mode 100644 .claude/CLAUDE.md diff --git a/.claude/CLAUDE.md b/.claude/CLAUDE.md new file mode 100644 index 0000000..481bc37 --- /dev/null +++ b/.claude/CLAUDE.md @@ -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~" # 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`. + +## 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`, `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`/`IEnumerable` collection properties. + +Supporting pieces: +- `Internal/Utils` — `TryCastValue` (scalar `TypeParsers` dict + a parse-convention fallback for + enums / `IParsable` / 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` + FluentValidation. A parameter type is validatable if the framework can bind it + (scalar, enum, `IParsable`, 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/`.