Location>code7788 >text

How to Elegantly Make Core Support Asynchronous Model Validation

Popularity:60 ℃/2024-12-17 20:54:20

preamble

There's an ongoing concern in the official Core repositoryPlease reconsider allowing async model validationFluentValidationThe authors of the book are also very concerned about this issue, asFluentValidationAsynchronous validation functionality is built in, but because MVC's built-in model validation pipeline is synchronized, both compatible features and integration are severely hampered. Every time MVC modifies the validation functionality there is the potential for integration problems.

Not only that.FluentValidationAlthough it is an excellent object validation library, the way it is implemented leads to some problems with Core integration. For example, the validation message localization functionality is managed through global objects, which leads to a very awkward way of trying to integrate message localization and dependency injection together.

I recently found a copy and modification of the asynchronous model validation service obtained from the original model binding system in the comments of a question. This modified version still looks intuitive, using a custom model binder to replace the built-in binder, and then replacing the built-in validation service with a custom asynchronous validation service. A companion asynchronous validation feature and asynchronous validator helper are also provided.

But the process of using it still revealed some unsatisfactory aspects:

  1. This library is directly oriented to the Core framework and will be forced to rely on irrelevant content if you only want to use the basic asynchronous validation functionality.
  2. The implementation of the asynchronous validation feature does not fully revert to the use of the synchronous feature, using an abstract method to force the implementation to return theValidationResultvalidation methods. The built-in features only need to be overridden to returnboolmaybeValidationResultmethod will suffice for most simple validation requirements.boolThe version is perfectly adequate. Hence the lack of ease of use.
  3. If an asynchronous validation feature is marked for a model and the controller-supplied method of manually validating the model is used, this causes an exception to occur for manual validation. This causes the ability to revalidate the model after adjusting it on the code to no longer be available. There is also no corresponding method provided for manually revalidating the model asynchronously.
  4. Model validation is an internal feature of MVC and cannot be used in a minimal API-like context. The built-in model validator again does not automatically recursively validate, which in turn leads to the possible need to switch to theFluentValidation. As I said before.FluentValidationIntegrating with MVC is flawed. If this is done, there is again the possibility that the use of multiple validation schemes at the same time drives up administrative costs.

In short, it just feels awkward no matter what. Therefore, I rewrote a new version based on the original remodeling in order to solve the above problems.

Book promotion

For more on the new book check outC# and .NET6 Development from Beginning to Practical is available, and the author himself is here to advertise it!
image

main body (of a book)

Asynchronous model validation

To solve the first problem, the first thing is to separate the basic validator and MVC integration classes to different projects, so the relevant content of the basic validator is put into theIn the MVC, the MVC-related content is placed in theCenter.

The solution to the second problem is simply to re-add asynchronous methods to the set based on the original validation characteristics.

AsyncValidationAttribute

public abstract class AsyncValidationAttribute : ValidationAttribute
{
    private volatile bool _hasBaseIsValidAsync;

    protected override sealed ValidationResult? IsValid(object? value, ValidationContext validationContext)
    {
        throw new InvalidOperationException("Async validation called synchronously.");
    }

    public override sealed bool IsValid(object? value)
    {
        throw new InvalidOperationException("Async validation called synchronously.");
    }

    public virtual async ValueTask<bool> IsValidAsync(object? value, CancellationToken cancellationToken = default)
    {
        if (!_hasBaseIsValidAsync)
        {
            // track that this method overload has not been overridden.
            _hasBaseIsValidAsync = true;
        }

        // call overridden method.
        // The IsValid method without a validationContext predates the one accepting the context.
        // This is theoretically unreachable through normal use cases.
        // Instead, the overload using validationContext should be called.
        return await IsValidAsync(value, null!, cancellationToken: cancellationToken) == ;
    }

    protected virtual async ValueTask<ValidationResult?> IsValidAsync(object? value, ValidationContext validationContext, CancellationToken cancellationToken = default)
    {
        if (_hasBaseIsValidAsync)
        {
            // this means neither of the IsValidAsync methods has been overridden, throw.
            throw new NotImplementedException("IsValidAsync(object value, CancellationToken cancellationToken) has not been implemented by this class.  The preferred entry point is GetValidationResultAsync() and classes should override IsValidAsync(object value, ValidationContext context, CancellationToken cancellationToken).");
        }

        // call overridden method.
        return await IsValidAsync(value, cancellationToken)
            ? 
            : CreateFailedValidationResult(validationContext);
    }

    public async ValueTask<ValidationResult?> GetValidationResultAsync(object? value, ValidationContext validationContext, CancellationToken cancellationToken = default)
    {
        if (validationContext == null)
        {
            throw new ArgumentNullException(nameof(validationContext));
        }

        ValidationResult? result = await IsValidAsync(value, validationContext, cancellationToken);

        // If validation fails, we want to ensure we have a ValidationResult that guarantees it has an ErrorMessage
        if (result != null)
        {
            if (())
            {
                var errorMessage = FormatErrorMessage();
                result = new ValidationResult(errorMessage, result?.MemberNames);
            }
        }

        return result;
    }

    public async ValueTask ValidateAsync(object? value, string name, CancellationToken cancellationToken = default)
    {
        if (!(await IsValidAsync(value, cancellationToken)))
        {
            throw new ValidationException(FormatErrorMessage(name), this, value);
        }
    }

    public async ValueTask ValidateAsync(object? value, ValidationContext validationContext, CancellationToken cancellationToken = default)
    {
        if (validationContext == null)
        {
            throw new ArgumentNullException(nameof(validationContext));
        }

        ValidationResult? result = await GetValidationResultAsync(value, validationContext, cancellationToken: cancellationToken);

        if (result != null)
        {
            // Convenience -- if implementation did not fill in an error message,
            throw new ValidationException(result, this, value);
        }
    }
}

The basic idea is to inherit the original validation feature, seal the original validation method to throw an exception, and add the corresponding asynchronous validation method. At the same time, I also added the corresponding custom asynchronous validation featuresCustomAsyncValidationAttributeThe code is more extensive and the basic idea remains the same, so if you're interested you can check out the repository code.. Of course the interface used to implement validation functionality directly on typesIValidatableObjectA corresponding asynchronous version has also been addedIAsyncValidatableObjectI'm just trying to put on a good show.

Then is the implementation of the corresponding asynchronous validator, the method is also very simple, copy the code of the original validator, change all the required methods to asynchronous methods, add in the internal synchronous validation features and asynchronous validation features of the normal processing, and finally add a custom new interface support part of the finished. Detailed code can be viewed

According to the naming convention, if a type is explicitly asynchronous-specific, the internal methods may not use theAsyncSuffixes, such asTaskmethods in the class, so the author uses method names without suffixes, which also facilitates modifying the code that uses the validator by simply changing the type.

The key to solving the third problem isIModelValidatorinterface implementation, the code provided by the comments directly throws an exception, which is very brute force, so here it is directly modified to return a blank result set. To match the asynchronous validation, add a new interfaceIAsyncModelValidatorrealizationIModelValidatorAnd add the corresponding asynchronous methods, the reason for implementing the synchronous interface is to be able to smoothly register to the MVC framework. This set of down need to re-implement the ten or so services, the amount of code is large, and the difference between the original service is limited to synchronous and asynchronous, so no longer show, interested parties can view the warehouse code

One point of interest is that the replica versionDefaultComplexObjectValidationStrategyrequire the use ofModelMetadataNET 8.0 added a feature specifically designed to simplify static reflection, which can be used to simplify code and improve performance. The good thing is that .NET 8.0 added a feature specifically designed to simplify static reflection, which can be used to simplify code and improve performance.

#if NET8_0_OR_GREATER
    [UnsafeAccessor(, Name = nameof(ThrowIfRecordTypeHasValidationOnProperties))]
    internal extern static void ThrowIfRecordTypeHasValidationOnProperties(ModelMetadata modelMetadata);

    [UnsafeAccessor(, Name = "get_BoundProperties")]
    internal extern static IReadOnlyList<ModelMetadata> GetBoundProperties(ModelMetadata modelMetadata);

    [UnsafeAccessor(, Name = "get_BoundConstructorParameterMapping")]
    internal extern static IReadOnlyDictionary<ModelMetadata, ModelMetadata> GetBoundConstructorParameterMapping(ModelMetadata modelMetadata);
#endif

Finally, add the appropriate helper methods for service registration and manual revalidation, and you can use it normally. If you continue to use synchronous manual model validation at this point, no exception will occur, but the asynchronous validation feature will also be ignored.

MVC Asynchronous Model Validation Service Registration Extension

namespace ;

public static class AsyncValidationExtension
{
    public static IMvcBuilder AddAsyncDataAnnotations(this IMvcBuilder builder)
    {
        <IConfigureOptions<MvcOptions>, ConfigureMvcOptionsSetup>();
        <ParameterBinder, AsyncParamterBinder>();
        <IAsyncObjectModelValidator>(s =>
        {
            var options = <IOptions<MvcOptions>>().Value;
            var metadataProvider = <IModelMetadataProvider>();
            return new DefaultAsyncObjecValidator(metadataProvider, , options);
        });
        return builder;
    }

    public static IMvcCoreBuilder AddAsyncDataAnnotations(this IMvcCoreBuilder builder)
    {
        <IConfigureOptions<MvcOptions>, ConfigureMvcOptionsSetup>();
        <ParameterBinder, AsyncParamterBinder>();
        <IAsyncObjectModelValidator>(s =>
        {
            var options = <IOptions<MvcOptions>>().Value;
            var cache = <ValidatorCache>();
            var metadataProvider = <IModelMetadataProvider>();
            return new DefaultAsyncObjecValidator(metadataProvider, , options);
        });
        return builder;
    }

    internal sealed class ConfigureMvcOptionsSetup : IConfigureOptions<MvcOptions>
    {
        private readonly IStringLocalizerFactory? _stringLocalizerFactory;
        private readonly IValidationAttributeAdapterProvider _validationAttributeAdapterProvider;
        private readonly IOptions<MvcDataAnnotationsLocalizationOptions> _dataAnnotationLocalizationOptions;

        public ConfigureMvcOptionsSetup(
            IValidationAttributeAdapterProvider validationAttributeAdapterProvider,
            IOptions<MvcDataAnnotationsLocalizationOptions> dataAnnotationLocalizationOptions)
        {
            (validationAttributeAdapterProvider);
            (dataAnnotationLocalizationOptions);

            _validationAttributeAdapterProvider = validationAttributeAdapterProvider;
            _dataAnnotationLocalizationOptions = dataAnnotationLocalizationOptions;
        }

        public ConfigureMvcOptionsSetup(
            IValidationAttributeAdapterProvider validationAttributeAdapterProvider,
            IOptions<MvcDataAnnotationsLocalizationOptions> dataAnnotationLocalizationOptions,
            IStringLocalizerFactory stringLocalizerFactory)
            : this(validationAttributeAdapterProvider, dataAnnotationLocalizationOptions)
        {
            _stringLocalizerFactory = stringLocalizerFactory;
        }

        public void Configure(MvcOptions options)
        {
            (options);

            (0, new AsyncDataAnnotationsModelValidatorProvider(
                _validationAttributeAdapterProvider,
                _dataAnnotationLocalizationOptions,
                _stringLocalizerFactory));

            (0, new DefaultAsyncModelValidatorProvider());
        }
    }
}

public static class AsyncValidatiorExtension
{
    public static Task<bool> TryValidateModelAsync(
        this ControllerBase controller,
        object model,
        CancellationToken cancellationToken = default)
    {
        return TryValidateModelAsync(controller, model, null, cancellationToken);
    }

    public static async Task<bool> TryValidateModelAsync(
        this ControllerBase controller,
        object model,
        string? prefix,
        CancellationToken cancellationToken = default)
    {
        await TryValidateModelAsync(
            ,
            model: model,
            prefix: prefix ?? ,
            cancellationToken);

        return ;
    }

    public static Task<bool> TryValidateModelAsync(
        this PageModel page,
        object model,
        CancellationToken cancellationToken = default)
    {
        return TryValidateModelAsync(page, model, null, cancellationToken);
    }

    public static async Task<bool> TryValidateModelAsync(
        this PageModel page,
        object model,
        string? prefix,
        CancellationToken cancellationToken = default)
    {
        await TryValidateModelAsync(
            ,
            model: model,
            prefix: prefix ?? ,
            cancellationToken);

        return ;
    }

    private static Task TryValidateModelAsync(
        ActionContext context,
        object model,
        string? prefix,
        CancellationToken cancellationToken = default)
    {
        (context);
        (model);

        var validator = <IAsyncObjectModelValidator>();

        return (
            context,
            validationState: null,
            prefix: prefix ?? ,
            model: model,
            cancellationToken);
    }
}

Object graph recursive validation

Finally came to the last problem, this is actually the most difficult.Nuget on some other people write recursive validator, but I checked the code and issues found that these validators have this and that problem. First of all, these validators do not support asynchronous validation, and I have my own asynchronous validation base class, even if these validators support asynchronous validation is not compatible with the type provided by the author; secondly, the API shape and internal operation mechanism of these validators are not fully aligned with the official version, which means that after manually unpacking the object with the official validator out of the results may not be correct; once again, there are these validators have no Again, all these validators have unresolved issues, and the authors have basically abandoned them. In the end, I had to write one myself.

There used to be an experimental recursive form model validator in Blazor. However, this validator can only be used in Blazor, and it requires a special feature to indicate that a property of the model is of a complex object type, which requires deeper validation of its internal properties, which in turn leads to chaining the entire property chain to mark the feature if a property itself does not need to be validated, but the other properties inside it need to be validated. This semi-automatic usage is still not very convenient. If the source code of the type is not under your control and cannot be modified, then it's a complete non-starter.

After referring to this form validator that no longer exists, I implemented the first version of the object graph validator, but debugging found an extremely troublesome problem, the automatic short-circuiting of circularly referenced objects behaves very strangely no matter what. Either some objects are not verified, or some objects are verified twice, or simply direct stack overflow, how to adjust the short-circuit conditions are not right. And at this point is only the implementation of synchronous validation, if you want to add asynchronous validation, it will definitely become a more troublesome problem.

After many fruitless attempts can only reorganize ideas and code. God is not responsible for those who have the heart, in nearly half a month of fumbling finally a flash of light, figured out the key to the problem. After a line of code was written, everything finally worked as expected, and even the order of validation results were exactly the same as expected. After completing this fully automated object graph validator, other scenarios such as minimal APIs can finally be used to validate the entire object model with validation features like MVC. And this validator is included in the base package, has no dependency on Core, and can be used in any .NET project.

The entire validation class code is more, more than two thousand lines, API shape and basic behavior and the official validator to maintain consistency (I have viewed the other validator code is basically within 500 lines, almost impossible to avoid the existence of defects), so only to show the key parts of the complete code, please check the repository!ObjectGraphValidation

private static bool TryValidateObjectRecursive(
    object instance,
    ValidationContext validationContext,
    ValidationResultStore? validationResults,
    AsyncValidationBehavior asyncValidationBehavior,
    bool validateAllProperties,
    Func<Type, bool>? predicate,
    bool throwOnFirstError)
{
    if (instance == null)
    {
        throw new ArgumentNullException(nameof(instance));
    }
    if (validationContext == null)
    {
        throw new ArgumentNullException(nameof(validationContext));
    }
    if (instance != )
    {
        throw new ArgumentException("The instance provided must match the ObjectInstance on the ValidationContext supplied.", nameof(instance));
    }

    // 这里就是关键,只要在这里记录访问历史,一切都会好起来的
    if (!((_validatedObjectsKey, out var item)
        && item is HashSet<object> visited
        && (instance)))
    {
        return true;
    }

    bool isValid = true;
    bool breakOnFirstError = (validationResults == null);

    foreach (ValidationError err in GetObjectValidationErrors(
        instance,
        validationContext,
        asyncValidationBehavior,
        validateAllProperties,
        breakOnFirstError))
    {
        if (throwOnFirstError) ();

        isValid = false;

        if (breakOnFirstError) break;

        TransferErrorToResult(validationResults!, err);
    }

    if (!isValid && breakOnFirstError) return isValid;

    var propertyObjectsAreValid = TryValidatePropertyObjects(
        instance,
        validationContext,
        validationResults,
        asyncValidationBehavior,
        validateAllProperties,
        predicate,
        throwOnFirstError);

    if (isValid && !propertyObjectsAreValid) isValid = false;

    return isValid;
}

这个方法是一切递归的开始,因此对象的访问记录也应该从这里开始,只是当时被 Blazor 的代码干扰了一下,老想着在别处处理这个问题被坑了半个月。The delegate in the parameter is a customized decision condition.,Used to decide whether to validate an object of this type,If it's clear to you that there will be no more validation features inside a type,Useless recursion can be blocked here。The author has internally excluded most of the known built-in types that do not require in-depth verification,for exampleintList<T>Basic types such as these and complex types that will not have any other attributes inside them that are directly or indirectly present to validate the characteristic markers. HereList<T>The attributes that will not be validated any further are the attributes of the type itself, such asCountIfTTypes that are likely to have validation flags are going to validate properly, if you inherit directly from theList<T>Adding your own new attribute also validates it properly.

Validation results for object graphs can come from deep objects, so a way to preserve this structural information to provide more valuable validation results is needed. Here the author refers to Blazor's validator to make a version that better meets the needs here. Caution is needed if you want to rewrite the equality judgment for value types, otherwise problems may arise.

public sealed class FieldIdentifier : IEquatable<FieldIdentifier>
{
    private static readonly object TopLevelObjectFaker = new();

    public static FieldIdentifier GetFakeTopLevelObjectIdentifier(string fieldName)
    {
        return new(TopLevelObjectFaker, fieldName, null);
    }

    public FieldIdentifier(object model, string fieldName, FieldIdentifier? modelOwner)
    {
        Model = model ?? throw new ArgumentNullException(nameof(model));

        CheckTopLevelObjectFaker(model, modelOwner);

        // Note that we do allow an empty string. This is used by some validation systems
        // as a place to store object-level (not per-property) messages.
        FieldName = fieldName ?? throw new ArgumentNullException(nameof(fieldName));

        ModelOwner = modelOwner;
    }

    public FieldIdentifier(object model, int enumerableElementIndex, FieldIdentifier? modelOwner)
    {
        Model = model ?? throw new ArgumentNullException(nameof(model));

        CheckTopLevelObjectFaker(model, modelOwner);

        if (enumerableElementIndex < 0)
        {
            throw new ArgumentOutOfRangeException(nameof(enumerableElementIndex), "The index must be great than or equals 0.");
        }

        EnumerableElementIndex = enumerableElementIndex;

        ModelOwner = modelOwner;
    }

    private static void CheckTopLevelObjectFaker(object model, FieldIdentifier? modelOwner)
    {
        if (model == TopLevelObjectFaker && modelOwner is not null)
        {
            throw new ArgumentException($"{nameof(modelOwner)} must be null when {nameof(model)} is {nameof(TopLevelObjectFaker)}", nameof(modelOwner));
        }
    }

    public object Model { get; }

    public bool ModelIsCopiedInstanceOfValueType => ().IsValueType;

    public bool ModelIsTopLevelFakeObject => Model == TopLevelObjectFaker;

    public string? FieldName { get; }

    public int? EnumerableElementIndex { get; }

    public FieldIdentifier? ModelOwner { get; }

    /// <inheritdoc />
    public override int GetHashCode()
    {
        // We want to compare Model instances by reference.  returns identical hashes for equal object references (ignoring any `Equals`/`GetHashCode` overrides) which is what we want.
        var modelHash = (Model);
        var fieldHash = FieldName is null ? 0 : (FieldName);
        var indexHash = EnumerableElementIndex ?? 0;
        var ownerHash = (ModelOwner);
        return (modelHash, fieldHash, indexHash, ownerHash).GetHashCode();
    }

    /// <inheritdoc />
    public override bool Equals(object? obj)
        => obj is FieldIdentifier otherIdentifier
        && Equals(otherIdentifier);

    /// <inheritdoc />
    public bool Equals(FieldIdentifier? otherIdentifier)
    {
        return (ReferenceEquals(otherIdentifier?.Model, Model) || Equals(otherIdentifier?.Model, Model))
            && (otherIdentifier?.FieldName, FieldName, )
            && (otherIdentifier?.EnumerableElementIndex, EnumerableElementIndex)
            && ReferenceEquals(otherIdentifier?.ModelOwner, ModelOwner);
    }

    /// <inheritdoc/>
    public static bool operator ==(FieldIdentifier? left, FieldIdentifier? right)
    {
        if (left is not null) return (right);
        if (right is not null) return (left);
        return Equals(left, right);
    }

    /// <inheritdoc/>
    public static bool operator !=(FieldIdentifier? left, FieldIdentifier? right) => !(left == right);

    /// <inheritdoc/>
    public override string? ToString()
    {
        if (ModelIsTopLevelFakeObject) return FieldName;

        var sb = new StringBuilder();
        var fieldIdentifier = this;
        var chainHasTopLevelFaker = false;
        do
        {
            (0,  is not null ? $".{}" : $"[{}]");

            if (chainHasTopLevelFaker is false && ) chainHasTopLevelFaker = true;

            fieldIdentifier = ;

        } while (fieldIdentifier != null && !);

        if (fieldIdentifier is null && !chainHasTopLevelFaker) (0, "$");
        else if (fieldIdentifier is { ModelIsTopLevelFakeObject: true }) (0, );

        return ();
    }
}

Here there is a special object dedicated to represent the function parameter name or local variable name, this special object can only be the root object, if there is no this special object as the root, the root object will use the$Symbolic representation. For value type objects, you can know by the properties that the object saved here is a copy, and modifying the object saved here may not give back the original data.Blazor is simple and brute-force and throws an exception when it finds out that the incoming object is a structure.

Minimum API Authentication

For .NET 6.0, validating yourself with an object graph validator at the API endpoint was fine, and that's all you could do. NET7.0 adds filter functionality to the minimal API as well, which makes it possible to simulate MVC's automatic validation mechanism. Originally, this article should be released nearly half a month ago, is because by chance I saw the .NET7.0 added a minimum API filter, I temporarily decided not to do anything, the pipeline to automatically verify the filter is also done and then sent.

is an example of a filter application referenced in the official documentation, and is therefore one of the more widely downloaded extensions, and there is also an example of a filter based on theFluentValidationthe. But the author's validation function has been completely self-implemented, want to access into the impossible, and these libraries are not restored to the MVC experience. In that case, why not just completely rewrite a closer to the MVC experience.

Here, the author plans to implement the following MVC functions:

  • Automatic validation of binding parameters: including complex objects, simple values and multiple parameters.
  • Optional automatic validation error response: whereas a normal controller will continue to perform controller actions when validation fails, API controllers will automatically return validation errors.
  • Manually get the validation results by code.
  • Manually revalidate the parameters.
  • Localization validation error message.

Maybe this ambition is too big, originally thought that two or three days can be done, but in the result of being tortured by endless bugs nearly half a month has passed. The good thing is that the time was not spent in vain, and all the intended goals were realized.

Similar to MVC, this validation filter is also based on model metadata, but it is an independent version written by the author himself. Because MVC's metadata system is too complex, and the minimal API was originally prepared for people who do not want to use MVC such a complex framework for a streamlined version of the function, now back to the past to rely on MVC's functionality is not good. The core of this metadata system is to save the names of binding parameters, additional validation features and localization features and other information.

Endpoint parameter validation metadata

internal sealed class EndpointBindingParametersValidationMetadata : IReadOnlyDictionary<string, ParameterValidationMetadata>
{
    private readonly MethodInfo _endpointMethod;
    private readonly IReadOnlyDictionary<string, ParameterValidationMetadata> _metadatas;

    public MethodInfo EndpointMethod => _endpointMethod;

    public EndpointBindingParametersValidationMetadata(MethodInfo endpointMethod, params IEnumerable<ParameterValidationMetadata> metadatas)
    {
        (endpointMethod);

        Dictionary<string, ParameterValidationMetadata> tempMetadatas = [];
        HashSet<string> names = [];
        foreach (var metadata in metadatas)
        {
            if (!()) throw new ArgumentException("metadata's parameter name must be unique.", nameof(metadatas));

            (, metadata);
        }

        _metadatas = ();
        _endpointMethod = endpointMethod;
    }

    public async ValueTask<Dictionary<string, ValidationResultStore>?> ValidateAsync(IDictionary<string, object?> arguments, CancellationToken cancellationToken = default)
    {
        Dictionary<string, ValidationResultStore> result = [];
        foreach (var argument in arguments)
        {
            if (!_metadatas.TryGetValue(, out var metadata))
            {
                throw new InvalidOperationException($"Parameter named {} does not exist.");
            }

            var argumentResults = await (, cancellationToken);
            if (argumentResults is not null) (, argumentResults);
        }
        return  > 0 ? result : null;
    }

    public IEnumerable<string> Keys => _metadatas.Keys;

    public IEnumerable<ParameterValidationMetadata> Values => _metadatas.Values;

    public int Count => _metadatas.Count;

    public ParameterValidationMetadata this[string key] => _metadatas[key];

    public bool ContainsKey(string key) => _metadatas.ContainsKey(key);

    public bool TryGetValue(
        string key,
        [MaybeNullWhen(false)] out ParameterValidationMetadata value)
        => _metadatas.TryGetValue(key, out value);

    public IEnumerator<KeyValuePair<string, ParameterValidationMetadata>> GetEnumerator() => _metadatas.GetEnumerator();

    IEnumerator () => GetEnumerator();

    internal sealed class ParameterValidationMetadata
    {
        private ParameterInfo _parameterInfo;
        private string? _displayName;
        private RequiredAttribute? _requiredAttribute;
        private ImmutableList<ValidationAttribute> _otherValidationAttributes;

        public ParameterValidationMetadata(ParameterInfo parameterInfo)
        {
            _parameterInfo = parameterInfo ?? throw new ArgumentNullException(nameof(parameterInfo));

            if (()) throw new ArgumentException("Parameter must be have name.", nameof(parameterInfo));

            _displayName = <DisplayAttribute>()?.Name
                ?? <DisplayNameAttribute>()?.DisplayName;

            _requiredAttribute = <RequiredAttribute>();
            _otherValidationAttributes = parameterInfo
                .GetCustomAttributes<ValidationAttribute>()
                .Where(attr => attr is not RequiredAttribute)
                .ToImmutableList();
        }

        public string ParameterName => _parameterInfo.Name!;

        public string? DisplayName => _displayName;

        public ParameterInfo Parameter => _parameterInfo;

        public async ValueTask<ValidationResultStore?> ValidateAsync(object? argument, CancellationToken cancellationToken = default)
        {
            if (argument is not null && !().IsAssignableTo(_parameterInfo.ParameterType))
            {
                throw new InvalidCastException($"Object cannot assign to {ParameterName} of type {_parameterInfo.ParameterType}.");
            }

            var topName = ParameterName ?? $"<argumentSelf({argument?.GetType()?.Name})>";
            ValidationResultStore resultStore = new();
            List<ValidationResult> results = [];

            var validationContext = new ValidationContext(argument ?? new())
            {
                MemberName = ParameterName
            };

            if (DisplayName is not null)  = DisplayName;

            // Verify the characteristics located on the parameter
            if (argument is null && _requiredAttribute is not null)
            {
                var result = _requiredAttribute.GetValidationResult(argument, validationContext)!;
                result = new LocalizableValidationResult(, , _requiredAttribute, validationContext);
                (result);
            }

            if (argument is not null)
            {
                foreach (var validation in _otherValidationAttributes)
                {
                    if (validation is AsyncValidationAttribute asyncValidation)
                    {
                        var result = await (argument, validationContext, cancellationToken);
                        if (result != )
                        {
                            result = new LocalizableValidationResult(result!.ErrorMessage, , validation, validationContext);
                            (result);
                        }
                    }
                    else
                    {
                        var result = (argument, validationContext);
                        if (result != )
                        {
                            result = new LocalizableValidationResult(result!.ErrorMessage, , validation, validationContext);
                            (result);
                        }
                    }
                }

                // Validating the internal properties of an object
                await (
                    argument,
                    new ValidationContext(argument),
                    resultStore,
                    true,
                    static type => !IsRequestDelegateFactorySpecialBoundType(type),
                    topName,
                    cancellationToken);
            }

            if ( > 0)
            {
                var id = (topName);
                (id, results);
            }

            return () ? resultStore : null;
        }
    }
}

internal static bool IsRequestDelegateFactorySpecialBoundType(Type type) =>
    (typeof(HttpContext))
    || (typeof(HttpRequest))
    || (typeof(HttpResponse))
    || (typeof(ClaimsPrincipal))
    || (typeof(CancellationToken))
    || (typeof(IFormFile))
    || (typeof(IEnumerable<IFormFile>))
    || (typeof(Stream))
    || (typeof(PipeReader));

End Point Filter Factory

You need to use endpoint information to generate parameter validation metadata in the endpoint construction phase and save it to the endpoint metadata backup, and then decide whether you need to add validation filters through the metadata generation results, and the Endpoint Filter Factory is just right for this purpose.

public static EndpointParameterDataAnnotationsRouteHandlerBuilder<TBuilder> AddEndpointParameterDataAnnotations<TBuilder>(
    this TBuilder endpointConvention)
    where TBuilder : IEndpointConventionBuilder
{
    (static endpointBuilder =>
    {
        var loggerFactory = <ILoggerFactory>();
        var logger = (_filterLoggerName);

        // rule outMVCstarting point or ending point (in stories etc)
        if ((static md => md is ActionDescriptor))
        {
            ("Cannot add parameter data annotations validation filter to MVC controller or Razor pages endpoint {actionName}.", );
            return;
        }

        // Check for duplicate registrations for automatic validation filters
        if ((static md => md is EndpointBindingParametersValidationMetadata))
        {
            ("Already has a parameter data annotations validation filter on endpoint {actionName}.", );
            return;
        }

        if ((static md => md is EndpointBindingParametersValidationMetadataMark))
        {
            ("Already called method AddEndpointParameterDataAnnotations before on endpoint {actionName}.", );
            return;
        }

        // Mark Auto Verify Filter Registered
        (new EndpointBindingParametersValidationMetadataMark());

        ((filterFactoryContext, next) =>
        {
            var loggerFactory = <ILoggerFactory>();
            var logger = (_filterLoggerName);

            var parameters = ();

            // Finding Binding Parameters,Record indexing backup
            var isServicePredicate = <IServiceProviderIsService>();
            List<int> bindingParameterIndexs = new();
            for (int i = 0; i < ; i++)
            {
                ParameterInfo? parameter = parameters[i];
                if (IsRequestDelegateFactorySpecialBoundType()) continue;
                if (<FromServicesAttribute>() is not null) continue;
#if NET8_0_OR_GREATER
                if (<FromKeyedServicesAttribute>() is not null) continue;
#endif
                if (isServicePredicate?.IsService() is true) continue;

                (i);
            }

            if ( is 0)
            {
                ("Route handler method '{methodName}' does not contain any validatable parameters, skipping adding validation filter.", );
            }

            // 构建参数模型验证元数据添加到starting point or ending point (in stories etc)元数据集合
            EndpointBindingParametersValidationMetadata? validationMetadata;
            try
            {
                List<ParameterValidationMetadata> bindingParameters = new();
                foreach (var argumentIndex in bindingParameterIndexs)
                {
                    (new(parameters[argumentIndex]));
                }
                validationMetadata = new(, bindingParameters);
            }
            catch (Exception e)
            {
                validationMetadata = null;
                (e, "Build parameter validation metadate failed for route handler method '{methodName}', skipping adding validation filter.", );
            }

            if (validationMetadata?.Any() is not true) return invocationContext => next(invocationContext);

            (validationMetadata);

            // everything is fine,Registration Validation Filter
            return async invocationContext =>
            {
                var endpoint = ();
                var metadata = endpoint?.Metadata
                    .FirstOrDefault(static md => md is EndpointBindingParametersValidationMetadata) as EndpointBindingParametersValidationMetadata;

                if (metadata is null) return await next(invocationContext);

                Dictionary<string, object?> arguments = new();
                foreach (var argumentIndex in bindingParameterIndexs)
                {
                    (parameters[argumentIndex].Name!, [argumentIndex]);
                }

                try
                {
                    var results = await (arguments);
                    if (results != null) (_validationResultItemName, results);
                }
                catch (Exception e)
                {
                    (e, "Validate parameter failed for route handler method '{methodName}'.", );
                }

                return await next(invocationContext);
            };
        });
    });

    return new(endpointConvention);
}

public sealed class EndpointBindingParametersValidationMetadataMark;

A constructor of special type is returned here and used as a parameter to the error result filter, ensuring that the error result filter can only be registered after the parameter validation filter.

Automatic validation of error return filters

public static TBuilder AddValidationProblemResult<TBuilder>(
    this EndpointParameterDataAnnotationsRouteHandlerBuilder<TBuilder> validationEndpointBuilder,
    int statusCode = StatusCodes.Status400BadRequest)
    where TBuilder : IEndpointConventionBuilder
{
    (endpointBuilder =>
    {
        var loggerFactory = <ILoggerFactory>();
        var logger = (_filterLoggerName);

        // probe OpenAPI Existence of metadata
        if (!(static md =>
            md is IProducesResponseTypeMetadata pr
            && (?.IsAssignableTo(typeof(HttpValidationProblemDetails))) is true)
        )
        {
            // increase OpenAPI metadata
            (
                new ProducesResponseTypeMetadata(
                    statusCode,
                    typeof(HttpValidationProblemDetails),
                    ["application/problem+json", "application/json"]
                )
            );
        }

        // probe重复注册自动验证错误返回过滤器
        if ((static md => md is EndpointParameterDataAnnotationsValidationProblemResultMark))
        {
            ("Already has a parameter data annotations validation problem result filter on endpoint {actionName}.", );
            return;
        }

        // Flag auto validation error return filter is registered
        (new EndpointParameterDataAnnotationsValidationProblemResultMark());

        (static (filterFactoryContext, next) =>
        {
            return async invocationContext =>
            {
                var errors = ();

                if (errors is { Count: > 0 }) return (errors);
                else return await next(invocationContext);
            };
        });
    });

    return ;
}

public static TBuilder RestoreToOriginalBuilder<TBuilder>(this EndpointParameterDataAnnotationsRouteHandlerBuilder<TBuilder> validationEndpointBuilder)
    where TBuilder : IEndpointConventionBuilder
{
    return ;
}

public sealed class EndpointParameterDataAnnotationsValidationProblemResultMark;

Registering the error result filter returns the original constructor, which can be used to continue registering something else. Or use a specialized helper method to restore it directly, skipping the registration error result filter.

Manual revalidation of parameters

public static async Task<bool> TryValidateEndpointParametersAsync(
    this HttpContext httpContext,
    params IEnumerable<KeyValuePair<string, object?>> arguments)
{
     (httpContext);

    var metadata = ();
    if (metadata is null) return false;

    if (!()) throw new ArgumentException("There are no elements in the sequence.", nameof(arguments));

    HashSet<string> names = [];
    foreach (var name in (arg => ))
    {
        if ((name)) throw new ArgumentException("Argument's name cannot be null or empty.", nameof(arguments));
        if (!(name)) throw new ArgumentException("Argument's name must be unique.", nameof(arguments));
    }

    var currentResults = ();
    var newResults = await ((arg => , arg => ));

    if (newResults is null) // This validation resulted in no errors
    {
        if (currentResults != null)
        {
            // Remove parameter entries that do not have validation error data in this validation result
            foreach (var argument in arguments) ();
            // If the set becomes empty after removal,Clear the result set directly
            if ( is 0) (_validationResultItemName);
        }
    }
    else
    {
        if (currentResults != null)
        {
            // If the last validation result has data with the same name parameter,However, the results of this validation did not,Remove obsolete old result data for this parameter
            foreach (var argument in arguments)
            {
                if (!(key => key == )) ();
            }
        }
        else
        {
            // The last validation showed no errors,New error result set
            (_validationResultItemName);

            currentResults = [];
            (_validationResultItemName, currentResults);
        }
        // Add a parameter item with no error data from the last validation,Or update the validation error data for a parameter entry with the same name
        foreach (var newResult in newResults) currentResults[] = ;
    }

    return true;
}

It could be argued that the fundamental purpose of building metadata is to support manual revalidation, and MVC's manual revalidation likewise relies on a metadata system. Indeed it is a good idea to borrow it and use it.

Get validation results

internal static Dictionary<string, ValidationResultStore>? GetEndpointParameterDataAnnotationsValidationResultsCore(this HttpContext httpContext)
{
    (httpContext);

    (_validationResultItemName, out var result);
    return result as Dictionary<string, ValidationResultStore>;
}

public static EndpointArgumentsValidationResults? GetEndpointParameterDataAnnotationsValidationResults(this HttpContext httpContext)
{
    var results = ();
    if (results is null) return null;

    return new(results
        .ToDictionary(
            static r => ,
            static r => new ArgumentPropertiesValidationResults((
                static fr => ()!,
                static fr => ())
            )
        )
    );
}

Get localized validation error messages

We know that MVC's localization features are very flexible and support message templates, which can make localization resources common to the greatest extent possible. But different validation features of the message template placeholders are not the same , in addition to the 0 placeholder unified representation of the attribute name , the number of other placeholders is not determined . Therefore, the MVC framework uses a set of adapters to allow developers to develop their own targeted message generation , at the same time for the built-in validation features to prepare a set of adapters. I also drew on this adapter system to develop a simple version , and built the official existing validation features of the adapter . The main difference is that this set of adapters do not have client-side validation-related features.

public interface IAttributeAdapter
{
    Type CanProcessAttributeType { get; }

    object[]? GetLocalizationArguments(ValidationAttribute attribute);
}

public abstract class AttributeAdapterBase<TAttribute> : IAttributeAdapter
    where TAttribute : ValidationAttribute
{
    public Type CanProcessAttributeType => typeof(TAttribute);

    public object[]? GetLocalizationArguments(ValidationAttribute attribute)
    {
        return GetLocalizationArgumentsInternal((TAttribute)attribute);
    }

    protected abstract object[]? GetLocalizationArgumentsInternal(TAttribute attribute);
}

public sealed class RangeAttributeAdapter : AttributeAdapterBase<RangeAttribute>
{
    protected override object[]? GetLocalizationArgumentsInternal(RangeAttribute attribute)
    {
        return [, ];
    }
}

With the message template placeholder parameter in place, the rest is easy.

public static Dictionary<string, string[]>? GetEndpointParameterDataAnnotationsProblemDetails(this HttpContext httpContext)
{
    Dictionary<string, string[]>? result = null;

    var validationResult = ();
    if (validationResult?.Any(vrp => ()) is true)
    {
        var localizerFactory = <IStringLocalizerFactory>();

        EndpointParameterValidationLocalizationOptions? localizationOptions = null;
        AttributeLocalizationAdapters? adapters = null;
        if (localizerFactory != null)
        {
            localizationOptions = 
                .GetService<IOptions<EndpointParameterValidationLocalizationOptions>>()
                ?.Value;

            adapters = localizationOptions?.Adapters;
        }

        var metadatas = ();
        (metadatas != null);
        var endpointHandlerType = ;
        (endpointHandlerType != null);

        var errors = (vrp => );
        result = localizerFactory is null || !(adapters?.Count > 0)
            ? errors
                .ToDictionary(
                    static fvr => ()!,
                    static fvr => (ToErrorMessage).ToArray()
                )
            : errors
                .ToDictionary(
                    static fvr => ()!,
                    fvr => 
                        .Select(vr => 
                            ToLocalizedErrorMessage(
                                vr,
                                
                                    ? new KeyValuePair<Type, ParameterValidationMetadata>(
                                        endpointHandlerType,
                                        (metadatas?.TryGetValue(!, out var metadata)) is true
                                            ? metadata
                                            : null! /* never null */)
                                    : null,
                                adapters,
                                localizerFactory
                            )
                        )
                        .ToArray()
                );
    }

    return result;

    static string ToErrorMessage(ValidationResult result)
    {
        return !;
    }

    string ToLocalizedErrorMessage(
        ValidationResult result,
        KeyValuePair<Type, ParameterValidationMetadata>? parameterMetadata,
        AttributeLocalizationAdapters adapters,
        IStringLocalizerFactory localizerFactory)
    {
        if (result is LocalizableValidationResult localizable)
        {
            var localizer = ();

            string displayName;
            if (!(parameterMetadata?.))
            {
                var parameterLocalizer = ();
                displayName = parameterLocalizer[];
            }
            else displayName = GetDisplayName(localizable, localizer);

            var adapter = (ap => ().IsAssignableTo());
            if (adapter != null
                && !()
                && ()
                &&  == null)
            {
                return localizer
                [
                    ,
                    [displayName, .. () ?? []]
                ];
            }

            return (displayName);
        }

        return !;

        static string GetDisplayName(LocalizableValidationResult localizable, IStringLocalizer localizer)
        {
            string? displayName = null;
            ValidationAttributeStore store = ;
            DisplayAttribute? displayAttribute = null;
            DisplayNameAttribute? displayNameAttribute = null;

            if (())
            {
                displayAttribute = ();
                displayNameAttribute = ();
            }
            else if (())
            {
                displayAttribute = ();
                displayNameAttribute = ();
            }

            if (displayAttribute != null)
            {
                displayName = ();
            }
            else if (displayNameAttribute != null)
            {
                displayName = ;
            }

            return (displayName)
                ? 
                : localizer[displayName];
        }
    }
}

Localizing the error messages was arguably the most troublesome part of the whole project, and even revamped the object graph validator a bit for this purpose. Because MVC doesn't use a validator at all, but instead uses the raw validation feature, this means that MVC has access to the raw information for any customization. In order to do the same, the object graph validator must also return validation results containing the raw information. The key to this is theLocalizableValidationResultThis new type holds all the raw information needed for localization. The object graph validator repackages the original results once after the validation results are obtained, so this information is also available in non-MVC environments.

There is also for the special handling of the parameters themselves, parameter names and parameters on the characteristics and the characteristics of the object can not be a common set of logic, must be dealt with separately. For this reason, things to get some trouble, but also once again feel the power of MVC's metadata system.

基本使用方法

Nuget包中有一个比较完整的示例说明,也比较长,就不赘述了,可以到这里查看Instructions for use

concluding remarks

It's been a long development journey, underestimating the difficulty of the development at the beginning and getting myself into a hole for over a week, and then halfway through a temporary addition of new functionality and getting pitched into a hole for another week or so. Luckily, the pre-developed features were more rigorous, and the transformation for the temporary features went quite smoothly. It is also a good experience of the power of rigorous design, follow Microsoft's code is really able to avoid a lot of potholes in advance.

QQ group

Readers exchange QQ group:540719365
image

Welcome readers and friends to communicate together, such as the discovery of the book errors are also welcome to inform the author through the blog garden, QQ group and other ways.

The address for this article:How to Elegantly Make Core Support Asynchronous Model Validation