Skip to content

Support unified model for DataAnnotations-based validation via source generator #46349

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

Closed
Tracked by #32557
captainsafia opened this issue Jan 31, 2023 · 32 comments
Closed
Tracked by #32557
Assignees
Labels
api-approved API was approved in API review, it can be implemented area-minimal Includes minimal APIs, endpoint filters, parameter binding, request delegate generator etc
Milestone

Comments

@captainsafia
Copy link
Member

captainsafia commented Jan 31, 2023

Summary

Disclaimer: this document is a joint effort by Safia and Copilot. 😄

This document outlines the design details of a framework-agnostic implementation of complex object validation built on top of the System.ComponentModel validation attributes and APIs.

Motivation and goals

Historically, whenever a framework wants to implement a data validation feature for its data models, it must implement the logic for discovering validatable types, walking the type graph, invoking the validation provider, gathering validation errors, and handling deeply nested or infinitely recursive type structures.

This exercise has been replicated in multiple implementations including MVC's model validation and Blazor's validation experiments. These implementations may have subtle differences in behavior and have to maintain their own implementations of model validation.

The goal of this proposal is to implement a generic layer for for the discovery of validatable types and the implementation of validation logic that can plug in to any consuming framework (minimal APIs, Blazor, etc.)

Proposed API

All APIs proposed below are net-new and reside in the Microsoft.AspNetCore.Http.Abstractions assembly.

Base interface for validation information

// Assembly: Microsoft.AspNetCore.Http.Abstractions

namespace Microsoft.AspNetCore.Http.Validation;

public interface IValidatableInfo
{
  ValueTask ValidateAsync(object? value, ValidateContext context, CancellationToken cancellationToken);
}

public abstract class ValidatablePropertyInfo : IValidatableInfo
{
  public ValidatablePropertyInfo(
        Type declaringType,
        Type propertyType,
        string name,
        string displayName)

  protected abstract ValidationAttribute[] GetValidationAttributes();

  public virtual ValueTask ValidateAsync(object? value, ValidateContext context, CancellationToken cancellationToken);
}

public abstract class ValidatableParameterInfo : IValidatableInfo
{
  public ValidatableParameterInfo(
        Type parameterType,
        string name,
        string displayName)

  protected abstract ValidationAttribute[] GetValidationAttributes();

  public virtual ValueTask ValidateAsync(object? value, ValidateContext context, CancellationToken cancellationToken);
}

public abstract class ValidatableTypeInfo : IValidatableInfo
{
  public ValidatableTypeInfo(
        Type type,
        IEnumerable<ValidatablePropertyInfo> members)

  protected abstract ValidationAttribute[] GetValidationAttributes();

  public virtual ValueTask ValidateAsync(object? value, ValidateContext context, CancellationToken cancellationToken);
}

Validator discovery and registration

// Assembly: Microsoft.AspNetCore.Http.Abstractions

namespace Microsoft.AspNetCore.Http.Validation;

public interface IValidatableInfoResolver
{
    bool TryGetValidatableTypeInfo(Type type, [NotNullWhen(true)] out IValidatableInfo? validatableTypeInfo);
    bool TryetValidatableParameterInfo(ParameterInfo parameterInfo, [NotNullWhen(true)] out IValidatableInfo? validatableParameterInfo);
}

public class ValidationOptions
{
    public List<IValidatableInfoResolver> Resolvers { get; } = [];
    public int MaxDepth { get; set; } = 32;

    public bool TryGetValidatableTypeInfo(Type type, [NotNullWhen(true)] out IValidatableInfo? validatableTypeInfo);
    public bool TryGetValidatableParameterInfo(ParameterInfo parameterInfo, [NotNullWhen(true)] out IValidatableInfo? validatableParameterInfo);
}

namespace Microsoft.Extensions.DependencyInjection;

public static class ValidationServiceCollectionExtensions
{
    public static IServiceCollection AddValidation(this IServiceCollection services,
        Action<ValidationOptions>? configureOptions);
}


[AttributeUsage(AttributeTargets.Class)]
public sealed class ValidatableTypeAttribute : Attribute { }

Validate-specific Context Object

// Assembly: Microsoft.AspNetCore.Http.Abstractions

namespace Microsoft.AspNetCore.Http.Validation;

public abstract class ValidateContext { }

public sealed class DefaultValidateContext
{
    public ValidationContext? ValidationContext { get; set; }
    public string Prefix { get; set; }
    public int CurrentDepth { get; set; }
    public required ValidationOptions ValidationOptions { get; set; }
    public Dictionary<string, string[]>? ValidationErrors { get; set; }
}

Minimal API-specific Extension Methods

// Assembly: Microsoft.AspNetCore.Routing

namespace Microsoft.AspNetCore.Builder;

public static class ValidationEndpointConventionBuilderExtensions
{
  public static TBuilder DisableValidation<TBuilder>(this TBuilder builder)
        where TBuilder : IEndpointConventionBuilder
} 
// Assembly: Microsoft.AspNetCore.Http.Abstractions

namespace Microsoft.AspNetCore.Http.Metadata;

public interface IDisableValidationMetadata { }

Usage Examples

The following demonstrates how the API can be consumed to support model validation in minimal APIs via an endpoint filter implementation.

internal static class ValidationEndpointFilterFactory
{
    public static EndpointFilterDelegate Create(EndpointFilterFactoryContext context, EndpointFilterDelegate next)
    {
        var parameters = context.MethodInfo.GetParameters();
        var options = context.ApplicationServices.GetService<IOptions<ValidationOptions>>()?.Value;
        if (options is null)
        {
            return next;
        }
        var validatableParameters = parameters
            .Select(p => options.TryGetValidatableParameterInfo(p, out var validatableParameter) ? validatableParameter : null);
        var ValidateContext = new DefaultValidateContext { ValidationOptions = options };
        return async (context) =>
        {
            ValidateContext.ValidationErrors?.Clear();

            for (var i = 0; i < context.Arguments.Count; i++)
            {
                var validatableParameter = validatableParameters.ElementAt(i);

                var argument = context.Arguments[i];
                if (argument is null || validatableParameter is null)
                {
                    continue;
                }
                var validationContext = new ValidationContext(argument, context.HttpContext.RequestServices, items: null);
                ValidateContext.ValidationContext = validationContext;
                await validatableParameter.Validate(argument, ValidateContext);
            }

            if (ValidateContext.ValidationErrors is { Count: > 0 })
            {
                context.HttpContext.Response.StatusCode = StatusCodes.Status400BadRequest;
                context.HttpContext.Response.ContentType = "application/problem+json";
                return await ValueTask.FromResult(new HttpValidationProblemDetails(ValidateContext.ValidationErrors));
            }

            return await next(context);
        };
    }
}

The following demonstrates how the API can be used to enable validation, alongside the validations source generator for a minimal API and highlights the types of validatable arguments that are supported by the generator.

// Example of using validation with source generator in a minimal API
var builder = WebApplication.CreateBuilder(args);

builder.Services.AddValidation();

var app = builder.Build();

// ValidationEndpointFilterFactory is implicitly enabled on all endpoints
app.MapGet("/customers/{id}", ([Range(1, int.MaxValue)] int id) =>
    $"Getting customer with ID: {id}");

app.MapPost("/customers", (Customer customer) =>
{
    // Validation happens automatically before this code runs
    return TypedResults.Created($"/customers/{customer.Name}", customer);
});

app.MapPost("/orders", (Order order) =>
{
    // Both attribute validation and IValidatableObject.Validate are called automatically
    return TypedResults.Created($"/orders/{order.OrderId}", order);
});

app.MapPost("/products", ([EvenNumberAttribute(ErrorMessage = "Product ID must be even")] int productId,
    [Required] string name) =>
{
    return TypedResults.Ok(new { productId, name });
})
.DisableValidation();

app.Run();

// Define validatable types with the ValidatableType attribute
[ValidatableType]
public class Customer
{
    [Required]
    public string Name { get; set; }

    [EmailAddress]
    public string Email { get; set; }

    [Range(18, 120)]
    [Display(Name = "Customer Age")]
    public int Age { get; set; }

    // Complex property with nested validation
    public Address HomeAddress { get; set; } = new Address();
}

public class Address
{
    [Required]
    public string Street { get; set; }

    [Required]
    public string City { get; set; }

    [StringLength(5)]
    public string ZipCode { get; set; }
}

// Define a type implementing IValidatableObject for custom validation
[ValidatableType]
public class Order : IValidatableObject
{
    [Range(1, int.MaxValue)]
    public int OrderId { get; set; }

    [Required]
    public string ProductName { get; set; }

    public int Quantity { get; set; }

    // Custom validation logic using IValidatableObject
    public IEnumerable<ValidationResult> Validate(ValidationContext validationContext)
    {
        if (Quantity <= 0)
        {
            yield return new ValidationResult(
                "Quantity must be greater than zero",
                [nameof(Quantity)]);
        }
    }
}


// Use a custom validation attribute
public class EvenNumberAttribute : ValidationAttribute
{
    public override bool IsValid(object? value)
    {
        if (value is int number)
        {
            return number % 2 == 0;
        }
        return false;
    }
}

Implementation Details

Default Validation Behavior of Validatable Type Info

The ValidatableTypeInfo.Validate method follows these steps when validating an object:

  1. Null check: If the value being validated is null, it immediately returns without validation unless the type is marked as required.

  2. RequiredAttribute handling: RequiredAttributes are validated before other attributes. If the requiredness check fails, remaining validation attributes are not applied.

  3. Depth limit check: Before processing nested objects, it checks if the current validation depth exceeds MaxDepth (default 32) to prevent stack overflows from circular references or extremely deep object graphs.

  4. Property validation: Iterates through each property defined in Members collection:

    • Gets the property value from the object
    • Applies validation attributes defined on that property
    • For nullable properties, skips validation if the value is null (unless marked required)
    • Handles collections by validating each item in the collection if the property is enumerable
  5. IValidatableObject support: If the type implements IValidatableObject, it calls the Validate method after validating individual properties, collecting any additional validation results.

  6. Error aggregation: Validation errors are added to the ValidationErrors dictionary in the context with property names as keys (prefixed if nested) and error messages as values.

  7. Recursive validation: For properties with complex types that have their own validation requirements, it recursively validates those objects with an updated context prefix to maintain the property path.

Validation Error Handling

Validation errors are collected in a Dictionary<string, string[]> where:

  • Keys are property names (including paths for nested properties like Customer.HomeAddress.Street)
  • Values are arrays of error messages for each property

This format is compatible with ASP.NET Core's ValidationProblemDetails for consistent error responses.

Parameter Validation

The ValidatableParameterInfo class provides similar validation for method parameters:

  1. Validates attributes applied directly to parameters
  2. For complex types, delegates to the appropriate ValidatableTypeInfo
  3. Supports special handling for common parameter types (primitives, strings, collections)

The validation endpoint filter demonstrates integration with minimal APIs, automatically validating all parameters before the endpoint handler executes.

Source Generation

The validation system leverages a source generator to:

  1. Analyze types marked with [ValidatableType] at build time
  2. Analyze minimal API endpoints at build-time to automatically discover validatable types without an attribute
  3. Generate concrete implementations of ValidatableTypeInfo and ValidatablePropertyInfo
  4. Intercept the AddValidation call in user code and add the generated IValidatableInfoResolver to the list of resolvers available in the ValidationOptions
  5. Pre-compiles and caches instances of ValidationAttributes uniquely hashed by their type and initialization arguments

The source generator creates a specialized IValidatableInfoResolver implementation that can handle all your validatable types and parameters without runtime reflection overhead.

file class GeneratedValidatableInfoResolver : IValidatableInfoResolver
{
    public ValidatableTypeInfo? GetValidatableTypeInfo(Type type)
    {
        // Fast type lookups with no reflection
        if (type == typeof(Customer))
        {
            return CreateCustomerType();
        }
        if (type == typeof(Address))
        {
            return CreateAddressType();
        }
        // Other types...

        return null;
    }

    public ValidatableParameterInfo? GetValidatableParameterInfo(ParameterInfo parameterInfo)
    {
        // ParameterInfo-based validations are resolved at runtime
        return null;
    }

    // Pre-generated factory methods for each type
    private ValidatableTypeInfo CreateCustomerType()
    {
        return new GeneratedValidatableTypeInfo(
            type: typeof(Customer),
            members: [
                // Pre-compiled property validation info
                new GeneratedValidatablePropertyInfo(
                    containingType: typeof(Customer),
                    propertyType: typeof(string),
                    name: "Name",
                    displayName: "Name",
                    isEnumerable: false,
                    isNullable: false,
                    isRequired: true,
                    hasValidatableType: false,
                    validationAttributes: [
                        // Pre-created validation attributes
                        ValidationAttributeCache.GetOrCreateValidationAttribute(
                            typeof(RequiredAttribute),
                            Array.Empty<string>(),
                            new Dictionary<string, string>())
                    ]),
                // Other properties...
            ],
            implementsIValidatableObject: false);
    }

    // Other factory methods...
}

The generator emits a ValidationAttributeCache to support compiling and caching ValidationAttributes by their type and arguments.

// Generated ValidationAttribute storage and creation
[GeneratedCode("Microsoft.AspNetCore.Http.ValidationsGenerator", "42.42.42.42")]
file static class ValidationAttributeCache
{
    private static readonly ConcurrentDictionary<string, ValidationAttribute?> _cache = new();

    public static ValidationAttribute? GetOrCreateValidationAttribute(
        Type attributeType,
        string[] arguments,
        IReadOnlyDictionary<string, string> namedArguments)
    {
        // Creates validation attributes efficiently with arguments and properties
        return _cache.GetOrAdd($"{attributeType.FullName}|{string.Join(",", arguments)}|{string.Join(",", namedArguments.Select(x => $"{x.Key}={x.Value}"))}", _ =>
        {
            var type = attributeType;
            ValidationAttribute? attribute = null;

            // Special handling for common attributes with optimization
            if (arguments.Length == 0)
            {
                attribute = type switch
                {
                    Type t when t == typeof(RequiredAttribute) => new RequiredAttribute(),
                    Type t when t == typeof(EmailAddressAttribute) => new EmailAddressAttribute(),
                    Type t when t == typeof(PhoneAttribute) => new PhoneAttribute(),
                    // Other attribute types...
                    _ => null
                };
            }
            else if (type == typeof(StringLengthAttribute))
            {
                if (!int.TryParse(arguments[0], out var maxLength))
                    throw new ArgumentException($"Invalid maxLength value for StringLengthAttribute: {arguments[0]}");
                attribute = new StringLengthAttribute(maxLength);
            }
            else if (type == typeof(RangeAttribute) && arguments.Length == 2)
            {
                if (int.TryParse(arguments[0], out var min) && int.TryParse(arguments[1], out var max))
                    attribute = new RangeAttribute(min, max);
                else if (double.TryParse(arguments[0], out var dmin) && double.TryParse(arguments[1], out var dmax))
                    attribute = new RangeAttribute(dmin, dmax);
            }
            // Other attribute constructors...

            // Apply named arguments as properties after construction
            foreach (var namedArg in namedArguments)
            {
                var prop = type.GetProperty(namedArg.Key);
                if (prop != null && prop.CanWrite)
                {
                    prop.SetValue(attribute, Convert.ChangeType(namedArg.Value, prop.PropertyType));
                }
            }

            return attribute;
        });
    }
}

The generator also creates strongly-typed implementations of the abstract validation classes:

file sealed class GeneratedValidatablePropertyInfo : ValidatablePropertyInfo
{
    private readonly ValidationAttribute[] _validationAttributes;

    public GeneratedValidatablePropertyInfo(
        Type containingType,
        Type propertyType,
        string name,
        string displayName,
        bool isEnumerable,
        bool isNullable,
        bool isRequired,
        bool hasValidatableType,
        ValidationAttribute[] validationAttributes)
        : base(containingType, propertyType, name, displayName,
              isEnumerable, isNullable, isRequired, hasValidatableType)
    {
        _validationAttributes = validationAttributes;
    }

    protected override ValidationAttribute[] GetValidationAttributes() => _validationAttributes;
}

The generator emits an interceptor to the AddValidation method that injects the generated ITypeInfoResolver into the options object.

file static class GeneratedServiceCollectionExtensions
{
    public static IServiceCollection AddValidation(
        this IServiceCollection services,
        Action<ValidationOptions>? configureOptions)
    {
        return ValidationServiceCollectionExtensions.AddValidation(services, options =>
        {
            options.Resolvers.Insert(0, new GeneratedValidatableInfoResolver());
            if (configureOptions is not null)
            {
                configureOptions(options);
            }
        });
    }
}

Validation Extensibility

Similar to existing validation options solutions, users can customize the behavior of the validation system by:

  • Custom ValidationAttribute implementations
  • IValidatableObject implementations for complex validation logic

In addition to this, this implementation supports defining vustom validation behavior by defining custom IValidatableInfoResolver implementations and inserting them into the ValidationOptions.Resolvers property.

var builder = WebApplication.CreateBuilder(args);

builder.Services.AddValidation(options =>
{
    // Add custom resolver before the generated one to give it higher priority
    options.Resolvers.Insert(0, new CustomValidatableInfoResolver());
});


var app = builder.Build();

app.MapPost("/payments", (PaymentInfo payment, [FromQuery] decimal amount) =>
{
    // Both payment and amount will be validated using the custom validators
    return TypedResults.Ok(new { PaymentAccepted = true });
});

app.Run();

public class PaymentInfo
{
    public string CreditCardNumber { get; set; } = string.Empty;
    public string CardholderName { get; set; } = string.Empty;
    public DateTime ExpirationDate { get; set; }
    public string CVV { get; set; } = string.Empty;
}

public class CustomValidatableInfoResolver : IValidatableInfoResolver
{
    // Provide validation info for specific types
    public ValidatableTypeInfo? GetValidatableTypeInfo(Type type)
    {
        // Example: Special handling for a specific type
        if (type == typeof(PaymentInfo))
        {
            // Create custom validation rules for PaymentInfo type
            return new CustomPaymentInfoTypeInfo();
        }

        return null; // Return null to let other resolvers handle other types
    }

    // Provide validation info for parameters
    public ValidatableParameterInfo? GetValidatableParameterInfo(ParameterInfo parameterInfo)
    {
        // Example: Special validation for payment amount parameters
        if (parameterInfo.Name == "amount" && parameterInfo.ParameterType == typeof(decimal))
        {
            return new CustomAmountParameterInfo();
        }

        return null; // Return null to let other resolvers handle other parameters
    }

    // Example of custom ValidatableTypeInfo implementation
    private class CustomPaymentInfoTypeInfo : ValidatableTypeInfo
    {
        public CustomPaymentInfoTypeInfo()
            : base(typeof(PaymentInfo), CreateValidatableProperties(), implementsIValidatableObject: false)
        {
        }

        private static IEnumerable<ValidatablePropertyInfo> CreateValidatableProperties()
        {
            // Define custom validation logic for properties
            yield return new CustomPropertyInfo(
                typeof(PaymentInfo),
                typeof(string),
                "CreditCardNumber",
                "Credit Card Number",
                isEnumerable: false,
                isNullable: false,
                isRequired: true,
                hasValidatableType: false);

            // Add more properties as needed
        }
    }

    // Example of custom ValidatableParameterInfo implementation
    private class CustomAmountParameterInfo : ValidatableParameterInfo
    {
        private static readonly ValidationAttribute[] _attributes = new ValidationAttribute[]
        {
            new RangeAttribute(0.01, 10000.00) { ErrorMessage = "Amount must be between $0.01 and $10,000.00" }
        };

        public CustomAmountParameterInfo()
            : base("amount", "Payment Amount", isNullable: false, isRequired: true,
                  hasValidatableType: false, isEnumerable: false)
        {
        }

        protected override ValidationAttribute[] GetValidationAttributes() => _attributes;
    }

    // Example of custom property info implementation
    private class CustomPropertyInfo : ValidatablePropertyInfo
    {
        private static readonly ValidationAttribute[] _ccAttributes = new ValidationAttribute[]
        {
            new CreditCardAttribute(),
            new RequiredAttribute(),
            new StringLengthAttribute(19) { MinimumLength = 13, ErrorMessage = "Credit card number must be between 13 and 19 digits" }
        };

        public CustomPropertyInfo(
            Type containingType, Type propertyType, string name, string displayName,
            bool isEnumerable, bool isNullable, bool isRequired, bool hasValidatableType)
            : base(containingType, propertyType, name, displayName,
                  isEnumerable, isNullable, isRequired, hasValidatableType)
        {
        }

        protected override ValidationAttribute[] GetValidationAttributes() => _ccAttributes;
    }
}

Open Questions and Future Considerations

  • How should this validation system plugin to other validation systems like Blazor? This implementation has been applied to non-minimal APIs scenarios in practice.
  • Does the implementation account for all trimming and native AoT compat scenarios? The existing Options validation generator applies custom implementations of built-in ValidationAttributes to support native AoT compt that haven't been accounted for here.
  • Should a more robust validation result type be considered? The implementation currently relies on a lazily-initialized Dictionary for this but we can consider something more robust.
@captainsafia captainsafia added the old-area-web-frameworks-do-not-use *DEPRECATED* This label is deprecated in favor of the area-mvc and area-minimal labels label Jan 31, 2023
@captainsafia captainsafia added this to the .NET 8 Planning milestone Jan 31, 2023
@mitchdenny mitchdenny added area-minimal Includes minimal APIs, endpoint filters, parameter binding, request delegate generator etc and removed old-area-web-frameworks-do-not-use *DEPRECATED* This label is deprecated in favor of the area-mvc and area-minimal labels labels May 24, 2023
@dotnet-policy-service dotnet-policy-service bot added the pending-ci-rerun When assigned to a PR indicates that the CI checks should be rerun label Feb 6, 2024
@wtgodbe wtgodbe removed the pending-ci-rerun When assigned to a PR indicates that the CI checks should be rerun label Feb 6, 2024
@dotnet-policy-service dotnet-policy-service bot added the pending-ci-rerun When assigned to a PR indicates that the CI checks should be rerun label Feb 6, 2024
@wtgodbe wtgodbe removed the pending-ci-rerun When assigned to a PR indicates that the CI checks should be rerun label Feb 13, 2024
@dotnet dotnet deleted a comment from dotnet-policy-service bot Feb 13, 2024
@dotnet dotnet deleted a comment from dotnet-policy-service bot Feb 13, 2024
@captainsafia
Copy link
Member Author

Hi all --

We originally intended to ship this feature in .NET 9. However, due to shifting priorities and some additional requirements associated with this feature, this work is being punted to a future release.

For the time being, we recommend using packages, like MiniValidation, to add validation support to your minimal APIs. While we work through all the technical details to ship a high-quality feature in a future release, we'd still appreciate your feedback to scope out the shape of the API as needed.

@captainsafia captainsafia modified the milestones: .NET 9 Planning, Backlog Jun 18, 2024
@Simonl9l
Copy link

Simonl9l commented Sep 14, 2024

I'm exploring this subject and by no mean an expert

I'd suggest from a scope perspective, there are use cases as simple as a console app not using a Host, and perhaps a simple DI Service Container in support of say a CLI of some kind.

System.ComponentModel.Annotations seems to have a lot of dependencies on Aps.Net (Core), and the ValidationContext with [RequiresUnreferencedCode] attributes all over it is not AoT-able.

It's also a logical need to be able to inject a context with the DI Service Provider into validations equivalent to [ValidationAttribute].

I also ran into this related discussion - dotnet/csharplang#6373 so perhaps the C# and Asp Net Core team, and others, can get together on this?

@captainsafia
Copy link
Member Author

@Simonl9l Thanks for sharing these thoughts!

There have been some conversations about the possibility of generating a "common type validation source generator" based on the System.ComponentModel.Annotations library. The primary motivations was the goal of standardizing the way validation is done across implementations in the ecosystem (the Options validation generator that shipped in .NET 8, the minimal APIs validation generator once we ship it, and support for Blazor Web) and providing an avenue for code sharing amongst the stacks.

We've only had a few conversations about what this might look like and haven't committed to a full design yet, but the possibility of some sort of common layer for source generator-based validation engines has definitely been discussed.

@mikekistler mikekistler modified the milestones: Backlog, .NET 10 Planning Oct 28, 2024
@captainsafia captainsafia changed the title Support generating parameter validations via source generation Support unified model for DataAnnotations-based validation via source generator Feb 26, 2025
@captainsafia captainsafia removed this from the .NET 10 Planning milestone Feb 26, 2025
@halter73
Copy link
Member

halter73 commented Mar 7, 2025

API Review Notes:

  • ValidatableSubTypes is confusing
    • Should we name it ValidatableChildTypes?
    • Can we just remove this property/parameter?
      • Yes!
  • Can we make the Info classes more purely abstract with empty protected ctors?
    • Yes
  • Let's make sure we have consistent ordering of constructor parameters
  • What's HasValidatableType?
    • It's more like IsValidatableType. It could be determined from the PropertyType,
      but it's costly to reevaluate.
  • Can we simplify the "Info" classes into a single IValidatableInfo interface on the public API.
    • Maybe?
  • Can we rename Validate to ValidateAsync?
    • Yes.
  • And add a CancellationToken?
    • Yes
    • As a separate parameter or on the ValidatableContext?
      • Separate parameter

To be continued!

@DamianEdwards
Copy link
Member

Should we make the Task returning methods return ValueTask instead? Or are we assuming that a specific validation implementation will always either be sync or async, rather than potentially sync or async on each invocation (which IIUC is when you'd want to consider using ValueTask)?

@captainsafia
Copy link
Member Author

Should we make the Task returning methods return ValueTask instead? Or are we assuming that a specific validation implementation will always either be sync or async, rather than potentially sync or async on each invocation (which IIUC is when you'd want to consider using ValueTask)?

@BrennanConroy brought this up at the tail-end of the API review yesterday. We didn't actually have the discussion so I suspect it'll be one of the first topics we bring up during the next round.

FWIW, I'm pro using ValueTask here since particularly in the current iteration of our validation APIs, the actual validate calls are completed synchronously.

@BrennanConroy
Copy link
Member

https://devblogs.microsoft.com/dotnet/understanding-the-whys-whats-and-whens-of-valuetask/#should-every-new-asynchronous-api-return-valuetask-/-valuetask%3Ctresult%3E

ValueTask / ValueTask are great choices when a) you expect consumers of your API to only await them directly, b) allocation-related overhead is important to avoid for your API, and c) either you expect synchronous completion to be a very common case, or you’re able to effectively pool objects for use with asynchronous completion.

@bartonjs
Copy link
Member

  • The methods returning ValueTask probably want to return Task instead, as they are unlikely to use the completion source implementation.
  • The constructors on the abstract types should be declared protected for consistency.
  • "ValidateContext" should be "ValidationContext".
    • The incorrect name was chosen to avoid a conflict with a ValidationContext type in System.ComponentModel, which is involved in this flow.
    • This new context type is proposed to move into System.ComponentModel, so further bikeshedding on the name isn't valuable in this meeting (it'll be debated in the .NET BCL API Review meeting)
  • The design of ValidateContext (abstract with no members) leads to very low usability. DefaultValidateContext is just being merged down.
  • ValidateContext.Prefix needs a better name. e.g. "Path", "FullName", "Scope", ...
  • ValidationOptions.Resolvers probably wants to be IList with a non-public type (to permit future customization)
  • TODO: Find a better namespace and library
// Assembly: Microsoft.AspNetCore.Http.Abstractions

namespace Microsoft.AspNetCore.Http.Validation;

public interface IValidatableInfo
{
  ValueTask ValidateAsync(object? value, ValidateContext context, CancellationToken cancellationToken);
}

public abstract class ValidatablePropertyInfo : IValidateInfo
{
  protected ValidatablePropertyInfo(
        Type declaringType,
        Type propertyType,
        string name,
        string displayName);

  protected abstract ValidationAttribute[] GetValidationAttributes();

  public virtual ValueTask ValidateAsync(object? value, ValidateContext context, CancellationToken cancellationToken);
}

public abstract class ValidatableParameterInfo : IValidateInfo
{
  protected ValidatablePropertyInfo(
        Type parameterType,
        string name,
        string displayName);

  protected abstract ValidationAttribute[] GetValidationAttributes();

  public virtual ValueTask ValidateAsync(object? value, ValidateContext context, CancellationToken cancellationToken);
}

public abstract class ValidatableTypeInfo : IValidateInfo
{
  protected ValidatableTypeInfo(
        Type type,
        IEnumerable<ValidatablePropertyInfo> members);

  protected abstract ValidationAttribute[] GetValidationAttributes();

  public virtual ValueTask ValidateAsync(object? value, ValidateContext context, CancellationToken cancellationToken);
}

public sealed class ValidateContext
{
    public ValidationContext? ValidationContext { get; set; }
    public string Prefix { get; set; }
    public int CurrentDepth { get; set; }
    public required ValidationOptions ValidationOptions { get; set; }
    public Dictionary<string, string[]>? ValidationErrors { get; set; }
}

public interface IValidatableInfoResolver
{
    bool TryGetValidatableTypeInfo(Type type, [NotNullWhen(true)] out IValidatableInfo? validatableTypeInfo);
    bool TryGetValidatableParameterInfo(ParameterInfo parameterInfo, [NotNullWhen(true)] out IValidatableInfo? validatableParameterInfo);
}

public class ValidationOptions
{
    public IList<IValidatableInfoResolver> Resolvers { get; } = [];
    public int MaxDepth { get; set; } = 32;

    public bool TryGetValidatableTypeInfo(Type type, [NotNullWhen(true)] out IValidatableInfo? validatableTypeInfo);
    public bool TryGetValidatableParameterInfo(ParameterInfo parameterInfo, [NotNullWhen(true)] out IValidatableInfo? validatableParameterInfo);
}

[AttributeUsage(AttributeTargets.Class)]
public sealed class ValidatableTypeAttribute : Attribute { }

namespace Microsoft.Extensions.DependencyInjection;

public static class ValidationServiceCollectionExtensions
{
    public static IServiceCollection AddValidation(this IServiceCollection services,
        Action<ValidationOptions>? configureOptions);
}

// Assembly: Microsoft.AspNetCore.Routing

namespace Microsoft.AspNetCore.Builder;

public static class ValidationEndpointConventionBuilderExtensions
{
  public static TBuilder DisableValidation<TBuilder>(this TBuilder builder)
        where TBuilder : IEndpointConventionBuilder
} 

// Assembly: Microsoft.AspNetCore.Http.Abstractions

namespace Microsoft.AspNetCore.Http.Metadata;

public interface IDisableValidationMetadata { }

@bartonjs bartonjs added api-approved API was approved in API review, it can be implemented and removed api-ready-for-review API is ready for formal API review - https://github.com/dotnet/apireviews labels Mar 10, 2025
@BrennanConroy
Copy link
Member

BrennanConroy commented Mar 14, 2025

Couple notes from playing with this:

  • RuntimeValidatableParameterInfoResolver always returns true and a ValidatableParameterInfo regardless of if there are any ValidationAttributes. We should just return false in this case.
  • The filter created by ValidationEndpointFilterFactory always runs (because of previous point).
  • ValidationContext is yucky, you get a NullReferenceException if you fail to provide it on the ValidateContext which isn't obvious. You're also supposed to provide a new one per argument you're validating 😢

@captainsafia
Copy link
Member Author

RuntimeValidatableParameterInfoResolver always returns true and a ValidatableParameterInfo regardless of it there are any ValidationAttributes. We should just return false in this case.

Good spot -- this should be a quick bug fix.

ValidationContext is yucky, you get a NullReferenceException if you fail to provide it on the ValidateContext which isn't obvious. You're also supposed to provide a new one per argument you're validating 😢

Right now, the default Validate methods assume that the invoker has set the ValidationContext up with references to the ServiceProvider that the validation context can use. We can change the code so that we fallback initialize the ValidationContext with just the value we have access to to avoid the null-ref. The one place where this would get spooky is validating IValidatableObject implementors that interact with the DI container.

We could work around this by taking the IServiceProvider as a required property on the ValidateContext.

@DamianEdwards
Copy link
Member

DamianEdwards commented Mar 15, 2025

We could work around this by taking the IServiceProvider as a required property on the ValidateContext.

Hmm it might be nice to avoid requiring IServiceProvider as that means forcing initialization of request services to use validation (which adds an allocation today). Are we requiring the IServiceProvider for anything other than IValidatableObject instances that use one? Given that ValidationContext itself doesn't require an IServiceProvider it seems strange that we'd require one unless we need it for something else.

You're also supposed to provide a new one per argument you're validating

Yeah, I don't do that in MiniValidation as the properties I need to change per member are settable. I don't think it causes any issues?

@BrennanConroy
Copy link
Member

You're also supposed to provide a new one per argument you're validating

Yeah, I don't do that in MiniValidation as the properties I need to change per member are settable. I don't think it causes any issues?

Really? But ObjectInstance is get only, and settable only in the ctor.

@davidfowl
Copy link
Member

Hmm it might be nice to avoid requiring IServiceProvider as that means forcing initialization of request services to use validation (which adds an allocation today). Are we requiring the IServiceProvider for anything other than IValidatableObject instances that use one? Given that ValidationContext itself doesn't require an IServiceProvider it seems strange that we'd require one unless we need it for something else.

Agree, we should aim to make this as pay for play as possible. Is ValidationContext sealed 😢

@captainsafia
Copy link
Member Author

Are we requiring the IServiceProvider for anything other than IValidatableObject instances that use one?

Yes, the IServiceProvider only exists for IValidatableObject instances that need it.

Agree, we should aim to make this as pay for play as possible. Is ValidationContext sealed 😢

Yes, it's sealed. Although we can make additive changes to it. For example, dotnet/runtime#113426 adds a new trim-safe constructor.

It's also perfectly valid to say that if we initialize a ValidationContext for you, you don't get the service provider configured correctly. Since it's optional on the ValidationContext anyway, a responsible IValidatableObject-implementation should be resilient to not having a ServiceProvider configured.

@DamianEdwards
Copy link
Member

You're also supposed to provide a new one per argument you're validating

Yeah, I don't do that in MiniValidation as the properties I need to change per member are settable. I don't think it causes any issues?

Really? But ObjectInstance is get only, and settable only in the ctor.

Yep, and the ObjectInstance property is documented as being unreliable and that it should be accessed with caution. See docs here and source here.

MiniValidation calls Validator.TryValidateValue for each member being validated, and the ValidationContext instance I pass has ObjectInstance set to the overall entrypoint complex type being validated. AFAICT this is the intended use given the API design as TryValidateObject accepts a full object to be validated (not recursively but still all members) and a single instance of ValidationContext. During the validation of all members for that call, the ValidationContext instance will be the same and hence the ObjectInstance member will be the original object being validated. Given that ValidationContext.MemberName is settable so that it can be updated to indicate which member of the ObjectInstance is currently being validated, this was intended.

All that said, you are right that it requires a new instance for each argument to a minimal API delegate, as each is a separate logical validation operation. As @captainsafia says, we could investigate API changes to allow ValidationContext instance re-use in this scenario (not across requests or anything like that, just across arguments of the same endpoint delegate), but I'm also wondering if we might want to look at doing parallel validation of arguments which of course would require separate instances.

@Kumima
Copy link

Kumima commented Mar 20, 2025

Will #35501 also be improved within this issue? This issue is indeed less urgent than #35501. We can easily write custom code to handle the validation of the request object. But it's hard for us to handle the internal bind failure. Please prioritize #35501.

@mrpmorris
Copy link

For anything like this.

public static EndpointFilterDelegate Create(EndpointFilterFactoryContext context, EndpointFilterDelegate next)

Would it be possible to use an interface that requires static methods? Otherwise, without the strong typing of the interface, the signature acts like a magic string that could easily be broken by a coder in future so that it doesn't match the required signature and the app will then behave differently without warning.

Having static methods on an interface will also make it easier to use code completion to create the method stub.

@jscarle
Copy link

jscarle commented Mar 23, 2025

This issue touches on a long standing frustration I have with Minimal API custom binding and I'm optimistically hoping that this validation initiative could help improve on that.

To help understand the issue, I'd like to first provide some more context. One of the long standing challenges in software development of any application is ensuring data within your application is validated. This has led to things that we now know as industry standard knowledge, like Domain Drive Design which is at the root of certain architectures like Clean Architecture. These patterns tend to create multiple layers of abstractions that are, in part, a result of limitations at outer layers forcing the use of primitives instead of domain objects. This happens just as much on the external facing layers (such as API requests and responses) as it does on internal layers (such as into and out of databases).

In the .NET ecosystem, this has led to complex DTO mappings and created entire communities around projects such as AutoMapper and Mapperly. Validation has also sprouted the same sort of situation and resulted in such projects as FluentValidation. At the end of the day, there is no good way to strongly type data on Minimal APIs without a large amount of complexity and custom implementation work to support objects that represent strongly typed primitives.

I've put together a very basic working example (the logic makes absolutely no sense, nor is it meant to) of the challenge when trying to use a type to strongly represent a primitive value.

using System.Diagnostics.CodeAnalysis;
using System.Text.RegularExpressions;
using Microsoft.AspNetCore.Mvc;

var builder = WebApplication.CreateBuilder(args);

builder.Services.AddOpenApi(options =>
{
    options.AddSchemaTransformer((schema, context, _) =>
    {
        if (context.JsonTypeInfo.Type == typeof(EmailAddress))
        {
            schema.Type = "string";
            schema.Format = "email";
        }
        
        return Task.CompletedTask;
    });
});

var app = builder.Build();

app.MapOpenApi();

app.MapGet("/validate-email", ([FromQuery] EmailAddress? emailAddress) => emailAddress is not null);

app.UseSwaggerUI(options => options.SwaggerEndpoint("/openapi/v1.json", "Default"));

app.Run();

internal partial class EmailAddress : IParsable<EmailAddress>
{
    private readonly string _emailAddress;

    private EmailAddress(string emailAddress)
    {
        _emailAddress = emailAddress;
    }

    public static EmailAddress Parse(string? str, IFormatProvider? provider)
    {
        if (!IsValid(str))
            throw new ArgumentException($"'{nameof(str)}' is not a valid email address.");
        
        return new EmailAddress(str);
    }

    public static bool TryParse(string? str, IFormatProvider? provider, [NotNullWhen(true)] out EmailAddress? emailAddress)
    {
        if (!IsValid(str))
        {
            emailAddress = null;
            return false;
        }

        emailAddress = new EmailAddress(str);
        return true;
    }

    public override string ToString()
    {
        return _emailAddress;
    }

    private static bool IsValid([NotNullWhen(true)] string? str)
    {
        return !string.IsNullOrWhiteSpace(str) && EmailRegex.IsMatch(str);
    }
    
    [GeneratedRegex(@"^[^@]+@[^@]+\.[^@]+$")]
    private static partial Regex EmailRegex { get; }
}

As it stands, if I supply a valid email address, this will return true. However, and invalid email address will throw Microsoft.AspNetCore.Http.BadHttpRequestException: Failed to bind parameter "EmailAddress emailAddress" from "john.doe"..

There is no way for me to intercept the binding to call TryParse to return a validation failure. I must using string as a request parameter, which does not allow me to describe it automatically as per the type, expose validation outside of the strongly typed object, and do the validation post binding.

Although this proposal would eventually allow me to do validation during the binding process of Minimal API, it still would not allow me to use my strong type in the Minimal API parameters themselves.

@jscarle
Copy link

jscarle commented Mar 23, 2025

This issue also highlights related Minimal API binding and validation challenges #35501

@jscarle
Copy link

jscarle commented Mar 23, 2025

@jscarle are you aware of https://learn.microsoft.com/en-us/dotnet/api/microsoft.aspnetcore.http.asparametersattribute?view=aspnetcore-9.0?

@davidfowl Yes, I use [AsParameters] extensively. In my projects, I will typically have a request object and describe it like this:

internal sealed record GetUserRequest
{
    [FromQuery]
    public required EmailAddress EmailAddress { get; init; }
}

app.MapGet("/user", ([AsParameters] GetUserRequest request) => { ... });

But this simply transfers the problem from the delegate parameters to the properties of the request as the processing is the same on properties as it is on the parameters. My read of the Minimal APIs code base (from what I was able to follow along with) is that the AsParameters attribute simply loops through the properties as if they were the delegate parameters but the binding in itself is identical.

@Kumima
Copy link

Kumima commented Mar 24, 2025

This issue also highlights related Minimal API binding and validation challenges #35501

Yes, 35501 first please.

@captainsafia
Copy link
Member Author

Will #35501 also be improved within this issue? This issue is indeed less urgent than #35501. We can easily write custom code to handle the validation of the request object. But it's hard for us to handle the internal bind failure. Please prioritize #35501.

The issue you referenced covers errors that surface from minimal API's binding layer itself. For example, TryParse on a Guid failing to parse a invalid query. The validation APIs introduced here execute after binding happens since they operate on the bound values. If TryParse fails, validation never runs and we instead exit early with the binding error.

So, unfortunately, the validation work here doesn't solve the problem of customizing binding error messages in minimal APIs. Middleware was mentioned as a solution to this. BindAsync can also help here.

For requiredness, we can consider taking a breaking behavior change that favors using the validation logic for verifying Required attributes instead of the existing binding-layer logic. This would let you customize the error message for require values via the ErrorMessage on the RequiredAttribute.

There is no way for me to intercept the binding to call TryParse to return a validation failure. I must using string as a request parameter, which does not allow me to describe it automatically as per the type, expose validation outside of the strongly typed object, and do the validation post binding.

I think BindAsync might be the right tool for the job here. You can continue to implement an IParsable for EmailAddress but the minimal API handler would take a parameter that implements BindAsync. Inside BindAsync you can call the parsing logic and handle custom validation there.

@jscarle
Copy link

jscarle commented Mar 25, 2025

Nor middleware, nor BindAsync, in their current iteration would allow us to take advantage of this validation API for values against strongly typed primitives.

The middleware would have to react to an exception which is problematic from a performance perspective and would allow the validation API to react to the binding failure.

The signatures for BindAsync would, at most allow to return a null, but then the context as to why the T is null is lost and would be difficult to surface to the validation API.

@captainsafia
Copy link
Member Author

@jscarle What types of validations are you talking about? Are you referring specifically to requiredness/null-checks or other types of validations (ValidationAttribute, IValidatableObject implementors, etc). If it's the later, you should be able to take advantage of this API.

For the former, see this note:

For requiredness, we can consider taking a breaking behavior change that favors using the validation logic for verifying Required attributes instead of the existing binding-layer logic. This would let you customize the error message for require values via the ErrorMessage on the RequiredAttribute.

@captainsafia
Copy link
Member Author

Marking this issue as closed for .NET 10 Preview 3.

Sample app of current functionality is available at https://github.com/captainsafia/minapi-validation-support.

Docs issue: dotnet/AspNetCore.Docs#35090

I've created a new feature-validation label to track follow-ups and am working on adding all of them there: https://github.com/dotnet/aspnetcore/issues?q=is%3Aissue%20state%3Aopen%20label%3Aarea-minimal%20label%3Afeature-validation

@jscarle
Copy link

jscarle commented Mar 28, 2025

@captainsafia I haven't been able to follow up to your question, been a whirlwind of a week here. I'll look for that tag and see if I can expand on my comments. Thanks!

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
api-approved API was approved in API review, it can be implemented area-minimal Includes minimal APIs, endpoint filters, parameter binding, request delegate generator etc
Projects
No open projects
Status: No status
Development

No branches or pull requests