File: Forms\ClientValidationCache.cs
Web Access
Project: src\aspnetcore\src\Components\Endpoints\src\Microsoft.AspNetCore.Components.Endpoints.csproj (Microsoft.AspNetCore.Components.Endpoints)
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.
 
using System.Collections.Concurrent;
using System.ComponentModel;
using System.ComponentModel.DataAnnotations;
using System.Diagnostics.CodeAnalysis;
using System.Linq;
using System.Reflection;
using Microsoft.AspNetCore.Components.Forms;
using Microsoft.AspNetCore.Components.HotReload;
using Microsoft.Extensions.Options;
using Microsoft.Extensions.Validation;
 
namespace Microsoft.AspNetCore.Components.Endpoints.Forms;
 
using FieldKey = (Type ModelType, string FieldName);
 
internal sealed class ClientValidationCache : IDisposable
{
    private readonly ConcurrentDictionary<FieldKey, ClientValidationFieldMetadata?> _metadataCache = new();
    private readonly ConcurrentDictionary<Type, bool> _typeHasValidatableInfo = new();
    private readonly ConcurrentDictionary<(Type FormType, string RenderedName), bool> _fieldReachabilityCache = new();
    private readonly ValidationOptions _validationOptions;
 
    [UnconditionalSuppressMessage("Trimming", "IL2066",
        Justification = "Preserves ValidationOptions's parameterless constructor used by Microsoft.Extensions.Options to materialize IOptions<ValidationOptions>.")]
    [DynamicDependency(DynamicallyAccessedMemberTypes.PublicParameterlessConstructor, typeof(ValidationOptions))]
    public ClientValidationCache(IOptions<ValidationOptions> validationOptions)
    {
        _validationOptions = validationOptions.Value;
 
        if (HotReloadManager.IsSupported)
        {
            HotReloadManager.Default.OnDeltaApplied += ClearCache;
        }
    }
 
    public IEnumerable<(string renderedName, ClientValidationFieldMetadata metadata)> GetValidatableFieldMetadata(
        IReadOnlyDictionary<FieldIdentifier, string> fields,
        object formModel)
    {
        var formType = formModel.GetType();
 
        // The form model type determines which validation pipeline (DataAnnotations.Validator vs MEV) the server uses.
        var formHasValidatableInfo = HasValidatableTypeInfo(formType);
 
        foreach (var (fieldIdentifier, renderedName) in fields)
        {
            // Don't enable client-side validation for fields that would not get server-side validation
            // to help developers avoid security mistakes.
            if (!IsServerValidatable(fieldIdentifier, renderedName, formType, formModel, formHasValidatableInfo))
            {
                continue;
            }
 
            var fieldKey = (fieldIdentifier.Model.GetType(), fieldIdentifier.FieldName);
            var cachedMetadata = _metadataCache.GetOrAdd(
                fieldKey,
                static key => BuildFieldMetadata(key.ModelType, key.FieldName));
 
            if (cachedMetadata is { } fieldMetadata)
            {
                yield return (renderedName, fieldMetadata);
            }
        }
    }
 
    private bool IsServerValidatable(
        in FieldIdentifier fieldIdentifier,
        string renderedName,
        Type formType,
        object formModel,
        bool formHasValidatableInfo)
    {
        if (formHasValidatableInfo)
        {
            // MEV submit path (ValidateAsync) validates a field only if it recurses to it from th form model.
            // The result depends only on the form type + rendered name, so it is cached across requests.
            return _fieldReachabilityCache.GetOrAdd(
                (formType, renderedName),
                static (key, self) => self.IsFieldValidatedByMev(key.FormType, key.RenderedName),
                this);
        }
        else
        {
            // DataAnnotations submit path (Validator.TryValidateObject) validates only the form
            // model's top-level properties and does not recurse.
            // A field is top-level iff its owner is the form model instance.
            return ReferenceEquals(fieldIdentifier.Model, formModel);
        }
    }
 
#pragma warning disable ASP0029 // Type is for evaluation purposes only and is subject to change or removal in future updates.
    private bool IsFieldValidatedByMev(Type formType, string renderedName)
    {
        var path = GetModelRelativePath(renderedName);
        if (path.Count == 0)
        {
            return false;
        }
 
        var currentType = formType;
        for (var i = 0; i < path.Count - 1; i++)
        {
            if (!_validationOptions.TryGetValidatableTypeInfo(currentType, out var typeInfo)
                || !typeInfo.TryFindProperty(path[i], _validationOptions, out _))
            {
                return false;
            }
 
            var memberType = GetRecursableMemberType(currentType, path[i]);
            if (memberType is null || !_validationOptions.TryGetValidatableTypeInfo(memberType, out _))
            {
                // MEV only recurses into a member whose type is itself validatable.
                return false;
            }
 
            currentType = memberType;
        }
 
        // The leaf must be a member MEV validates on the owner type.
        return _validationOptions.TryGetValidatableTypeInfo(currentType, out var ownerInfo)
            && ownerInfo.TryFindProperty(path[^1], _validationOptions, out _);
    }
 
    private bool HasValidatableTypeInfo(Type type) =>
        _validationOptions.Resolvers.Count > 0
            && _typeHasValidatableInfo.GetOrAdd(type,
                key => _validationOptions.TryGetValidatableTypeInfo(key, out _));
#pragma warning restore ASP0029
 
    // Derives the field's property-name path relative to the form model from the rendered HTML name.
    // The rendered name is the binder key "{modelPrefix}.{path}" (e.g. "Person.Address.Street"):
    // the single leading prefix segment maps to the form model, and collection indices ("Items[0]")
    // do not affect type reachability, so both are dropped.
    private static List<string> GetModelRelativePath(string renderedName)
    {
        var segments = new List<string>();
 
        var start = renderedName.IndexOf('.') + 1; // skip the leading model-prefix segment
        if (start <= 0)
        {
            return segments; // no '.', so there is nothing below the model root
        }
 
        foreach (var rawSegment in renderedName.Substring(start).Split('.'))
        {
            var bracketIndex = rawSegment.IndexOf('[');
            var name = bracketIndex >= 0 ? rawSegment[..bracketIndex] : rawSegment;
            if (name.Length > 0)
            {
                segments.Add(name);
            }
        }
 
        return segments;
    }
 
    [UnconditionalSuppressMessage("Trimming", "IL2070",
        Justification = "Model types are application code and are preserved by default.")]
    private static Type? GetRecursableMemberType(Type ownerType, string propertyName)
    {
        var propertyType = ownerType.GetProperty(propertyName, BindingFlags.Public | BindingFlags.Instance)?.PropertyType;
        if (propertyType is null)
        {
            return null;
        }
 
        propertyType = Nullable.GetUnderlyingType(propertyType) ?? propertyType;
 
        if (propertyType != typeof(string) && GetEnumerableElementType(propertyType) is { } elementType)
        {
            return elementType;
        }
 
        return propertyType;
    }
 
    [UnconditionalSuppressMessage("Trimming", "IL2070",
        Justification = "Model types are application code and are preserved by default.")]
    private static Type? GetEnumerableElementType(Type type)
    {
        if (type.IsArray)
        {
            return type.GetElementType();
        }
 
        foreach (var interfaceType in type.GetInterfaces())
        {
            if (interfaceType.IsGenericType && interfaceType.GetGenericTypeDefinition() == typeof(IEnumerable<>))
            {
                return interfaceType.GetGenericArguments()[0];
            }
        }
 
        return null;
    }
 
    private void ClearCache()
    {
        _metadataCache.Clear();
        _typeHasValidatableInfo.Clear();
        _fieldReachabilityCache.Clear();
    }
 
    public void Dispose()
    {
        if (HotReloadManager.IsSupported)
        {
            HotReloadManager.Default.OnDeltaApplied -= ClearCache;
        }
    }
 
    [UnconditionalSuppressMessage("Trimming", "IL2070",
        Justification = "Model types are application code and are preserved by default.")]
    private static ClientValidationFieldMetadata? BuildFieldMetadata(Type modelType, string propertyName)
    {
        var property = modelType.GetProperty(propertyName, BindingFlags.Public | BindingFlags.Instance);
 
        if (property is null)
        {
            return null;
        }
 
        var validationAttributes = property.GetCustomAttributes<ValidationAttribute>(inherit: true).ToArray();
 
        if (validationAttributes.Length == 0)
        {
            return null;
        }
 
        var displayAttribute = property.GetCustomAttribute<DisplayAttribute>(inherit: true);
        DisplayAttribute? resourceDisplayAttribute = null;
        string? literalDisplayName = null;
 
        if (displayAttribute is { ResourceType: not null, Name: not null })
        {
            resourceDisplayAttribute = displayAttribute;
        }
        else
        {
            literalDisplayName = displayAttribute?.Name
                ?? property.GetCustomAttribute<DisplayNameAttribute>(inherit: true)?.DisplayName;
        }
 
        return new ClientValidationFieldMetadata(
            propertyName: property.Name,
            validationAttributes,
            declaringType: property.DeclaringType,
            resourceDisplayAttribute,
            literalDisplayName);
    }
}