-
Notifications
You must be signed in to change notification settings - Fork 10.3k
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
Comments
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. |
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.
It's also a logical need to be able to inject a context with the DI Service Provider into validations equivalent to 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? |
@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 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. |
API Review Notes:
To be continued! |
Should we make the |
@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 |
|
// 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 { } |
Couple notes from playing with this:
|
Good spot -- this should be a quick bug fix.
Right now, the default We could work around this by taking the |
Hmm it might be nice to avoid requiring
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 |
Agree, we should aim to make this as pay for play as possible. Is ValidationContext sealed 😢 |
Yes, the IServiceProvider only exists for IValidatableObject instances that need it.
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. |
Yep, and the MiniValidation calls 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 |
For anything like this.
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. |
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 There is no way for me to intercept the binding to call TryParse to return a validation failure. I must using 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. |
This issue also highlights related Minimal API binding and validation challenges #35501 |
@davidfowl Yes, I use 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 |
Yes, 35501 first please. |
The issue you referenced covers errors that surface from minimal API's binding layer itself. For example, 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
I think |
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. |
@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:
|
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 |
@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! |
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
Validator discovery and registration
Validate-specific Context Object
Minimal API-specific Extension Methods
Usage Examples
The following demonstrates how the API can be consumed to support model validation in minimal APIs via an endpoint filter implementation.
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.
Implementation Details
Default Validation Behavior of Validatable Type Info
The
ValidatableTypeInfo.Validate
method follows these steps when validating an object:Null check: If the value being validated is null, it immediately returns without validation unless the type is marked as required.
RequiredAttribute handling:
RequiredAttribute
s are validated before other attributes. If the requiredness check fails, remaining validation attributes are not applied.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.Property validation: Iterates through each property defined in
Members
collection:IValidatableObject support: If the type implements
IValidatableObject
, it calls theValidate
method after validating individual properties, collecting any additional validation results.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.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:Customer.HomeAddress.Street
)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:ValidatableTypeInfo
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:
[ValidatableType]
at build timeValidatableTypeInfo
andValidatablePropertyInfo
AddValidation
call in user code and add the generatedIValidatableInfoResolver
to the list of resolvers available in theValidationOptions
The source generator creates a specialized
IValidatableInfoResolver
implementation that can handle all your validatable types and parameters without runtime reflection overhead.The generator emits a
ValidationAttributeCache
to support compiling and cachingValidationAttributes
by their type and arguments.The generator also creates strongly-typed implementations of the abstract validation classes:
The generator emits an interceptor to the
AddValidation
method that injects the generatedITypeInfoResolver
into the options object.Validation Extensibility
Similar to existing validation options solutions, users can customize the behavior of the validation system by:
ValidationAttribute
implementationsIValidatableObject
implementations for complex validation logicIn addition to this, this implementation supports defining vustom validation behavior by defining custom
IValidatableInfoResolver
implementations and inserting them into theValidationOptions.Resolvers
property.Open Questions and Future Considerations
The text was updated successfully, but these errors were encountered: