Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Support input validation for minimal APIs via generic resolver model #60724

Open
wants to merge 31 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
31 commits
Select commit Hold shift + click to select a range
96592d5
Add generic implementation for validations source generator
captainsafia Feb 27, 2025
1f4e615
Make validate methods async and fix AoT suppressions
captainsafia Feb 27, 2025
0b77a21
Update emitted code
captainsafia Feb 27, 2025
f928939
Remove deadcode
captainsafia Feb 28, 2025
75f3d1b
Clean up API shapes a bit
captainsafia Feb 28, 2025
3a7867a
Fix up RequiredAttribute handling
captainsafia Feb 28, 2025
5e25ec3
Add ValidatableContext and simplify API signature
captainsafia Feb 28, 2025
a4bf559
Support ValidationOptions and multiple resolvers
captainsafia Feb 28, 2025
c1970e5
Make ValidatableContext a setup entry
captainsafia Feb 28, 2025
c6f88cf
Move registration of filter to route handlers
captainsafia Mar 3, 2025
98cad73
Fix async for tests and IValidatableObject
captainsafia Mar 3, 2025
d69c466
Add doc comments
captainsafia Mar 3, 2025
ec8848f
Add more tests
captainsafia Mar 3, 2025
3196915
Enable PublicAPI analyzers and update public API
captainsafia Mar 3, 2025
18b4939
Add MaxDepth handling
captainsafia Mar 3, 2025
0d91665
Docs tweaks and package generator in shared framework
captainsafia Mar 3, 2025
bcc2529
Clean up tests
captainsafia Mar 3, 2025
c345a1b
Update for trimming
captainsafia Mar 3, 2025
889fd26
Harden parameter resolution check
captainsafia Mar 4, 2025
5bbd091
Switch to runtime-based resolution for ParameterInfo validations
captainsafia Mar 4, 2025
248e82a
Prune out uneeded types
captainsafia Mar 4, 2025
95b4d5d
Fix up ValidatableParameterInfo signature
captainsafia Mar 4, 2025
0df79fb
Make Validate methods virtual and support CustomValidationAttribute
captainsafia Mar 4, 2025
0195662
Fix up emitted code and use explicit namespaces
captainsafia Mar 5, 2025
0878c62
Fix up suppression for ValidationContext trimming
captainsafia Mar 5, 2025
8fc9d1b
Actually use attribute-based suppression
captainsafia Mar 5, 2025
03a3c06
Fix up suppression for trimming warnings
captainsafia Mar 5, 2025
fbe46cf
Benchmarks, more tests, some tweaks
captainsafia Mar 6, 2025
1ff9b04
More tests and add DisableValidationFilter
captainsafia Mar 6, 2025
669443e
Update API and add sample app
captainsafia Mar 9, 2025
4d776c0
Tweak more APIs
captainsafia Mar 9, 2025
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
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,11 @@
Private="false"
OutputItemType="AspNetCoreAnalyzer"
ReferenceOutputAssembly="false" />

<ProjectReference Include="$(RepoRoot)src\Http\Http.Extensions\gen\Microsoft.AspNetCore.Http.ValidationsGenerator\Microsoft.AspNetCore.Http.ValidationsGenerator.csproj"
Private="false"
OutputItemType="AspNetCoreAnalyzer"
ReferenceOutputAssembly="false" />
</ItemGroup>

<ItemGroup>
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.

namespace Microsoft.AspNetCore.Http.Metadata;

/// <summary>
/// A marker interface which can be used to identify metadata that disables validation
/// on a given endpoint.
/// </summary>
public interface IDisableValidationMetadata
{
}
41 changes: 41 additions & 0 deletions src/Http/Http.Abstractions/src/PublicAPI.Unshipped.txt
Original file line number Diff line number Diff line change
@@ -1,4 +1,45 @@
#nullable enable
abstract Microsoft.AspNetCore.Http.Validation.ValidatableParameterInfo.GetValidationAttributes() -> System.ComponentModel.DataAnnotations.ValidationAttribute![]!
abstract Microsoft.AspNetCore.Http.Validation.ValidatablePropertyInfo.GetValidationAttributes() -> System.ComponentModel.DataAnnotations.ValidationAttribute![]!
Microsoft.AspNetCore.Http.Metadata.IDisableValidationMetadata
Microsoft.AspNetCore.Http.ProducesResponseTypeMetadata.Description.get -> string?
Microsoft.AspNetCore.Http.ProducesResponseTypeMetadata.Description.set -> void
Microsoft.AspNetCore.Http.Metadata.IProducesResponseTypeMetadata.Description.get -> string?
Microsoft.AspNetCore.Http.Validation.IValidatableInfo
Microsoft.AspNetCore.Http.Validation.IValidatableInfo.ValidateAsync(object? value, Microsoft.AspNetCore.Http.Validation.ValidatableContext! context, System.Threading.CancellationToken cancellationToken) -> System.Threading.Tasks.ValueTask
Microsoft.AspNetCore.Http.Validation.IValidatableInfoResolver
Microsoft.AspNetCore.Http.Validation.IValidatableInfoResolver.TryGetValidatableParameterInfo(System.Reflection.ParameterInfo! parameterInfo, out Microsoft.AspNetCore.Http.Validation.IValidatableInfo? validatableInfo) -> bool
Microsoft.AspNetCore.Http.Validation.IValidatableInfoResolver.TryGetValidatableTypeInfo(System.Type! type, out Microsoft.AspNetCore.Http.Validation.IValidatableInfo? validatableInfo) -> bool
Microsoft.AspNetCore.Http.Validation.ValidatableContext
Microsoft.AspNetCore.Http.Validation.ValidatableContext.CurrentDepth.get -> int
Microsoft.AspNetCore.Http.Validation.ValidatableContext.CurrentDepth.set -> void
Microsoft.AspNetCore.Http.Validation.ValidatableContext.Prefix.get -> string!
Microsoft.AspNetCore.Http.Validation.ValidatableContext.Prefix.set -> void
Microsoft.AspNetCore.Http.Validation.ValidatableContext.ValidatableContext() -> void
Microsoft.AspNetCore.Http.Validation.ValidatableContext.ValidationContext.get -> System.ComponentModel.DataAnnotations.ValidationContext?
Microsoft.AspNetCore.Http.Validation.ValidatableContext.ValidationContext.set -> void
Microsoft.AspNetCore.Http.Validation.ValidatableContext.ValidationErrors.get -> System.Collections.Generic.Dictionary<string!, string![]!>?
Microsoft.AspNetCore.Http.Validation.ValidatableContext.ValidationErrors.set -> void
Microsoft.AspNetCore.Http.Validation.ValidatableContext.ValidationOptions.get -> Microsoft.AspNetCore.Http.Validation.ValidationOptions!
Microsoft.AspNetCore.Http.Validation.ValidatableContext.ValidationOptions.set -> void
Microsoft.AspNetCore.Http.Validation.ValidatableParameterInfo
Microsoft.AspNetCore.Http.Validation.ValidatableParameterInfo.ValidatableParameterInfo(System.Type! parameterType, string! name, string! displayName) -> void
Microsoft.AspNetCore.Http.Validation.ValidatablePropertyInfo
Microsoft.AspNetCore.Http.Validation.ValidatablePropertyInfo.IsRequired.get -> bool
Microsoft.AspNetCore.Http.Validation.ValidatablePropertyInfo.ValidatablePropertyInfo(System.Type! declaringType, System.Type! propertyType, string! name, string! displayName) -> void
Microsoft.AspNetCore.Http.Validation.ValidatableTypeAttribute
Microsoft.AspNetCore.Http.Validation.ValidatableTypeAttribute.ValidatableTypeAttribute() -> void
Microsoft.AspNetCore.Http.Validation.ValidatableTypeInfo
Microsoft.AspNetCore.Http.Validation.ValidatableTypeInfo.ValidatableTypeInfo(System.Type! type, System.Collections.Generic.IReadOnlyList<Microsoft.AspNetCore.Http.Validation.ValidatablePropertyInfo!>! members) -> void
Microsoft.AspNetCore.Http.Validation.ValidationOptions
Microsoft.AspNetCore.Http.Validation.ValidationOptions.MaxDepth.get -> int
Microsoft.AspNetCore.Http.Validation.ValidationOptions.MaxDepth.set -> void
Microsoft.AspNetCore.Http.Validation.ValidationOptions.Resolvers.get -> System.Collections.Generic.IList<Microsoft.AspNetCore.Http.Validation.IValidatableInfoResolver!>!
Microsoft.AspNetCore.Http.Validation.ValidationOptions.TryGetValidatableParameterInfo(System.Reflection.ParameterInfo! parameterInfo, out Microsoft.AspNetCore.Http.Validation.IValidatableInfo? validatableInfo) -> bool
Microsoft.AspNetCore.Http.Validation.ValidationOptions.TryGetValidatableTypeInfo(System.Type! type, out Microsoft.AspNetCore.Http.Validation.IValidatableInfo? validatableTypeInfo) -> bool
Microsoft.AspNetCore.Http.Validation.ValidationOptions.ValidationOptions() -> void
Microsoft.Extensions.DependencyInjection.ValidationServiceCollectionExtensions
static Microsoft.Extensions.DependencyInjection.ValidationServiceCollectionExtensions.AddValidation(this Microsoft.Extensions.DependencyInjection.IServiceCollection! services, System.Action<Microsoft.AspNetCore.Http.Validation.ValidationOptions!>? configureOptions = null) -> Microsoft.Extensions.DependencyInjection.IServiceCollection!
virtual Microsoft.AspNetCore.Http.Validation.ValidatableParameterInfo.ValidateAsync(object? value, Microsoft.AspNetCore.Http.Validation.ValidatableContext! context, System.Threading.CancellationToken cancellationToken) -> System.Threading.Tasks.ValueTask
virtual Microsoft.AspNetCore.Http.Validation.ValidatablePropertyInfo.ValidateAsync(object? value, Microsoft.AspNetCore.Http.Validation.ValidatableContext! context, System.Threading.CancellationToken cancellationToken) -> System.Threading.Tasks.ValueTask
virtual Microsoft.AspNetCore.Http.Validation.ValidatableTypeInfo.ValidateAsync(object? value, Microsoft.AspNetCore.Http.Validation.ValidatableContext! context, System.Threading.CancellationToken cancellationToken) -> System.Threading.Tasks.ValueTask
18 changes: 18 additions & 0 deletions src/Http/Http.Abstractions/src/Validation/IValidatableInfo.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.

namespace Microsoft.AspNetCore.Http.Validation;

/// <summary>
/// Represents an interface for validating a value.
/// </summary>
public interface IValidatableInfo
{
/// <summary>
/// Validates the specified value.
/// </summary>
/// <param name="value">The value to validate.</param>
/// <param name="context">The validation context.</param>
/// <param name="cancellationToken"></param>
ValueTask ValidateAsync(object? value, ValidatableContext context, CancellationToken cancellationToken);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.

using System.Diagnostics.CodeAnalysis;
using System.Reflection;

namespace Microsoft.AspNetCore.Http.Validation;

/// <summary>
/// Provides an interface for resolving the validation information associated
/// with a given <seealso cref="Type"/> or <seealso cref="ParameterInfo"/>.
/// </summary>
public interface IValidatableInfoResolver
{
/// <summary>
/// Gets validation information for the specified type.
/// </summary>
/// <param name="type">The type to get validation information for.</param>
/// <param name="validatableInfo">
/// The output parameter that will contain the validatable information if found.
/// </param>
/// <returns><see langword="true" /> if the validatable type information was found; otherwise, false.</returns>
bool TryGetValidatableTypeInfo(Type type, [NotNullWhen(true)] out IValidatableInfo? validatableInfo);

/// <summary>
/// Gets validation information for the specified parameter.
/// </summary>
/// <param name="parameterInfo">The parameter to get validation information for.</param>
/// <param name="validatableInfo">The output parameter that will contain the validatable information if found.</param>
/// <returns><see langword="true" /> if the validatable parameter information was found; otherwise, false.</returns>
bool TryGetValidatableParameterInfo(ParameterInfo parameterInfo, [NotNullWhen(true)] out IValidatableInfo? validatableInfo);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.

using System.ComponentModel.DataAnnotations;
using System.Diagnostics;
using System.Diagnostics.CodeAnalysis;
using System.Linq;
using System.Reflection;

namespace Microsoft.AspNetCore.Http.Validation;

internal class RuntimeValidatableParameterInfoResolver : IValidatableInfoResolver
{
public bool TryGetValidatableTypeInfo(Type type, [NotNullWhen(true)] out IValidatableInfo? validatableInfo)
{
validatableInfo = null;
return false;
}

public bool TryGetValidatableParameterInfo(ParameterInfo parameterInfo, [NotNullWhen(true)] out IValidatableInfo? validatableInfo)
{
Debug.Assert(parameterInfo.Name != null, "Parameter must have name");
var validationAttributes = parameterInfo
.GetCustomAttributes<ValidationAttribute>()
.ToArray();
validatableInfo = new RuntimeValidatableParameterInfo(
parameterType: parameterInfo.ParameterType,
name: parameterInfo.Name,
displayName: GetDisplayName(parameterInfo),
validationAttributes: validationAttributes
);
return true;
}

private static string GetDisplayName(ParameterInfo parameterInfo)
{
var displayAttribute = parameterInfo.GetCustomAttribute<DisplayAttribute>();
if (displayAttribute != null)
{
return displayAttribute.Name ?? parameterInfo.Name!;
}

return parameterInfo.Name!;
}

private class RuntimeValidatableParameterInfo(
Type parameterType,
string name,
string displayName,
ValidationAttribute[] validationAttributes) :
ValidatableParameterInfo(parameterType, name, displayName)
{
protected override ValidationAttribute[] GetValidationAttributes() => _validationAttributes;

private readonly ValidationAttribute[] _validationAttributes = validationAttributes;
}
}
113 changes: 113 additions & 0 deletions src/Http/Http.Abstractions/src/Validation/TypeExtensions.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,113 @@
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.

using System.Collections;
using System.ComponentModel.DataAnnotations;
using System.Diagnostics.CodeAnalysis;

namespace Microsoft.AspNetCore.Http.Validation;

internal static class TypeExtensions
{
public static bool IsEnumerable(this Type type)
{
// Check if type itself is an IEnumerable
if (type.IsGenericType &&
(type.GetGenericTypeDefinition() == typeof(IEnumerable<>) ||
type.GetGenericTypeDefinition() == typeof(ICollection<>) ||
type.GetGenericTypeDefinition() == typeof(List<>)))
{
return true;
}

// Or an array
if (type.IsArray)
{
return true;
}

// Then evaluate if it implements IEnumerable and is not a string
if (typeof(IEnumerable).IsAssignableFrom(type) &&
type != typeof(string))
{
return true;
}

return false;
}

public static bool IsNullable(this Type type)
{
if (type.IsValueType)
{
return false;
}

if (type.IsGenericType &&
type.GetGenericTypeDefinition() == typeof(Nullable<>))
{
return true;
}

return false;
}

public static bool TryGetRequiredAttribute(this ValidationAttribute[] attributes, [NotNullWhen(true)] out RequiredAttribute? requiredAttribute)
{
foreach (var attribute in attributes)
{
if (attribute is RequiredAttribute requiredAttr)
{
requiredAttribute = requiredAttr;
return true;
}
}

requiredAttribute = null;
return false;
}

/// <summary>
/// Gets all types that the specified type implements or inherits from, including itself.
/// </summary>
/// <param name="type">The type to analyze.</param>
/// <returns>A collection containing the type itself, all implemented interfaces, and all base types.</returns>
public static IEnumerable<Type> GetAllImplementedTypes([DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.Interfaces)] this Type type)
{
ArgumentNullException.ThrowIfNull(type);

// Yield all interfaces directly and indirectly implemented by this type
foreach (var interfaceType in type.GetInterfaces())
{
yield return interfaceType;
}

// Finally, walk up the inheritance chain
var baseType = type.BaseType;
while (baseType != null && baseType != typeof(object))
{
yield return baseType;
baseType = baseType.BaseType;
}
}

/// <summary>
/// Determines whether the specified type implements the given interface.
/// </summary>
/// <param name="type">The type to check.</param>
/// <param name="interfaceType">The interface type to check for.</param>
/// <returns>True if the type implements the specified interface; otherwise, false.</returns>
public static bool ImplementsInterface(this Type type, Type interfaceType)
{
ArgumentNullException.ThrowIfNull(type);
ArgumentNullException.ThrowIfNull(interfaceType);

// Check if interfaceType is actually an interface
if (!interfaceType.IsInterface)
{
throw new ArgumentException($"Type {interfaceType.FullName} is not an interface.", nameof(interfaceType));
}

return interfaceType.IsAssignableFrom(type);
}
}
80 changes: 80 additions & 0 deletions src/Http/Http.Abstractions/src/Validation/ValidatableContext.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.

using System.ComponentModel.DataAnnotations;

namespace Microsoft.AspNetCore.Http.Validation;

/// <summary>
/// Represents the context for validating a validatable object.
/// </summary>
public sealed class ValidatableContext
{
/// <summary>
/// Gets or sets the validation context used for validating objects that implement <see cref="IValidatableObject"/> or have <see cref="ValidationAttribute"/>.
/// This context provides access to service provider and other validation metadata.
/// </summary>
public ValidationContext? ValidationContext { get; set; }

/// <summary>
/// Gets or sets the prefix used to identify the current object being validated in a complex object graph.
/// This is used to build property paths in validation error messages (e.g., "Customer.Address.Street").
/// </summary>
public string Prefix { get; set; } = string.Empty;

/// <summary>
/// Gets or sets the validation options that control validation behavior,
/// including validation depth limits and resolver registration.
/// </summary>
public required ValidationOptions ValidationOptions { get; set; }

/// <summary>
/// Gets or sets the dictionary of validation errors collected during validation.
/// Keys are property names or paths, and values are arrays of error messages.
/// This dictionary is lazily initialized when the first validation error is added.
/// </summary>
public Dictionary<string, string[]>? ValidationErrors { get; set; }

/// <summary>
/// Gets or sets the current depth in the validation hierarchy.
/// This is used to prevent stack overflows from circular references.
/// </summary>
public int CurrentDepth { get; set; }

internal void AddValidationError(string key, string[] error)
{
ValidationErrors ??= [];

ValidationErrors[key] = error;
}

internal void AddOrExtendValidationErrors(string key, string[] errors)
{
ValidationErrors ??= [];

if (ValidationErrors.TryGetValue(key, out var existingErrors))
{
ValidationErrors[key] = new string[existingErrors.Length + errors.Length];
existingErrors.CopyTo(ValidationErrors[key], 0);
errors.CopyTo(ValidationErrors[key], existingErrors.Length);
}
else
{
ValidationErrors[key] = errors;
}
}

internal void AddOrExtendValidationError(string key, string error)
{
ValidationErrors ??= [];

if (ValidationErrors.TryGetValue(key, out var existingErrors) && !existingErrors.Contains(error))
{
ValidationErrors[key] = [.. existingErrors, error];
}
else
{
ValidationErrors[key] = [error];
}
}
}
Loading
Loading