File: ClientValidation\DefaultClientValidationService.cs
Web Access
Project: src\src\aspnetcore\src\Components\Forms\src\Microsoft.AspNetCore.Components.Forms.csproj (Microsoft.AspNetCore.Components.Forms)
// 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.Globalization;
using System.Linq;
using System.Reflection;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Options;
using Microsoft.Extensions.Validation;

namespace Microsoft.AspNetCore.Components.Forms.ClientValidation;

/// <summary>
/// Generates <c>data-val-*</c> HTML attributes from <see cref="ValidationAttribute"/>s on model properties.
/// </summary>
internal sealed class DefaultClientValidationService : IClientValidationService
{
    // Stores only culture-independent reflection results. Display name and error message text
    // are resolved per call so the output respects CultureInfo.CurrentUICulture.
    private readonly ConcurrentDictionary<(Type ModelType, string FieldName), FieldMetadata> _metadataCache = new();

    private readonly IValidationLocalizer? _validationLocalizer;

    [UnconditionalSuppressMessage("Trimming", "IL2066",
        Justification = "DynamicDependency preserves ValidationOptions's parameterless constructor used by Microsoft.Extensions.Options to materialize IOptions<ValidationOptions>.")]
    [DynamicDependency(DynamicallyAccessedMemberTypes.PublicParameterlessConstructor, typeof(ValidationOptions))]
    public DefaultClientValidationService(IServiceProvider serviceProvider)
    {
        _validationLocalizer = serviceProvider.GetService<IOptions<ValidationOptions>>()?.Value?.Localizer;
    }

    public IReadOnlyDictionary<string, object>? GetClientValidationAttributes(FieldIdentifier fieldIdentifier)
    {
        var modelType = fieldIdentifier.Model.GetType();
        var cacheKey = (modelType, fieldIdentifier.FieldName);
        var metadata = _metadataCache.GetOrAdd(cacheKey, static key => BuildMetadata(key.ModelType, key.FieldName));

        if (metadata.ValidationAttributes.Length == 0)
        {
            return null;
        }

        var displayName = ResolveDisplayName(in metadata, fieldIdentifier.FieldName);
        var htmlAttributes = new Dictionary<string, object>();

        foreach (var validationAttribute in metadata.ValidationAttributes)
        {
            var errorMessage = ResolveErrorMessage(validationAttribute, fieldIdentifier.FieldName, displayName, metadata.DeclaringType);
            AddAttributes(htmlAttributes, validationAttribute, errorMessage);
        }

        if (htmlAttributes.Count == 0)
        {
            return null;
        }

        htmlAttributes.TryAdd("data-val", "true");
        return htmlAttributes;
    }

    // Maps each ValidationAttribute to its data-val-* keys. Custom attributes can implement
    // IClientValidationAdapter for their own mappings. TryAdd is first-wins.
    private static void AddAttributes(
        Dictionary<string, object> htmlAttributes,
        ValidationAttribute validationAttribute,
        string errorMessage)
    {
        switch (validationAttribute)
        {
            case RequiredAttribute:
                htmlAttributes.TryAdd("data-val-required", errorMessage);
                break;

            case StringLengthAttribute sla:
                htmlAttributes.TryAdd("data-val-length", errorMessage);
                if (sla.MaximumLength != int.MaxValue)
                {
                    htmlAttributes.TryAdd("data-val-length-max", sla.MaximumLength.ToString(CultureInfo.InvariantCulture));
                }
                if (sla.MinimumLength != 0)
                {
                    htmlAttributes.TryAdd("data-val-length-min", sla.MinimumLength.ToString(CultureInfo.InvariantCulture));
                }
                break;

            case MaxLengthAttribute maxla:
                htmlAttributes.TryAdd("data-val-maxlength", errorMessage);
                htmlAttributes.TryAdd("data-val-maxlength-max", maxla.Length.ToString(CultureInfo.InvariantCulture));
                break;

            case MinLengthAttribute minla:
                htmlAttributes.TryAdd("data-val-minlength", errorMessage);
                htmlAttributes.TryAdd("data-val-minlength-min", minla.Length.ToString(CultureInfo.InvariantCulture));
                break;

            case RangeAttribute ra:
                // The JS range validator is numeric-only (uses Number()); skip non-numeric operands.
                if (!IsNumericRangeOperand(ra.OperandType))
                {
                    break;
                }
                // Triggers RangeAttribute.SetupConversion() to convert string Min/Max to OperandType.
                ra.IsValid(3);
                htmlAttributes.TryAdd("data-val-range", errorMessage);
                htmlAttributes.TryAdd("data-val-range-min", Convert.ToString(ra.Minimum, CultureInfo.InvariantCulture)!);
                htmlAttributes.TryAdd("data-val-range-max", Convert.ToString(ra.Maximum, CultureInfo.InvariantCulture)!);
                break;

            case RegularExpressionAttribute rea:
                htmlAttributes.TryAdd("data-val-regex", errorMessage);
                htmlAttributes.TryAdd("data-val-regex-pattern", rea.Pattern);
                break;

            case CompareAttribute ca:
                htmlAttributes.TryAdd("data-val-equalto", errorMessage);
                // "*." prefix tells the JS equalto validator to resolve the other field
                // relative to the current field's name prefix (e.g., "Model.Password").
                htmlAttributes.TryAdd("data-val-equalto-other", "*." + ca.OtherProperty);
                break;

            case EmailAddressAttribute:
                htmlAttributes.TryAdd("data-val-email", errorMessage);
                break;

            case UrlAttribute:
                htmlAttributes.TryAdd("data-val-url", errorMessage);
                break;

            case PhoneAttribute:
                htmlAttributes.TryAdd("data-val-phone", errorMessage);
                break;

            case CreditCardAttribute:
                htmlAttributes.TryAdd("data-val-creditcard", errorMessage);
                break;

            case FileExtensionsAttribute fea:
                htmlAttributes.TryAdd("data-val-fileextensions", errorMessage);
                var normalizedExtensions = fea.Extensions
                    .Replace(" ", string.Empty)
                    .Replace(".", string.Empty)
                    .ToLowerInvariant();
                var parsedExtensions = normalizedExtensions
                    .Split(',')
                    .Select(e => "." + e);
                htmlAttributes.TryAdd("data-val-fileextensions-extensions", string.Join(",", parsedExtensions));
                break;

            default:
                if (validationAttribute is IClientValidationAdapter adapter)
                {
                    foreach (var rule in adapter.GetClientValidationRules(errorMessage))
                    {
                        EmitRule(htmlAttributes, rule);
                    }
                }
                break;
        }
    }

    // Flattens a ClientValidationRule into the data-val-* dictionary. Null parameter values
    // are skipped; non-string values are formatted with invariant culture.
    private static void EmitRule(Dictionary<string, object> htmlAttributes, ClientValidationRule rule)
    {
        htmlAttributes.TryAdd($"data-val-{rule.Name}", rule.ErrorMessage);
        foreach (var (paramName, paramValue) in rule.Parameters)
        {
            if (paramValue is null)
            {
                continue;
            }

            var formatted = paramValue switch
            {
                string s => s,
                bool b => b ? "true" : "false",
                IFormattable f => f.ToString(format: null, CultureInfo.InvariantCulture),
                _ => paramValue.ToString() ?? string.Empty,
            };

            htmlAttributes.TryAdd($"data-val-{rule.Name}-{paramName}", formatted);
        }
    }

    // RangeAttribute supports non-numeric operand types (e.g., DateTime) that the JS validator
    // can't compare; only emit data-val-range for numeric ones.
    private static bool IsNumericRangeOperand(Type operandType)
        => operandType == typeof(int)
        || operandType == typeof(long)
        || operandType == typeof(short)
        || operandType == typeof(byte)
        || operandType == typeof(uint)
        || operandType == typeof(ulong)
        || operandType == typeof(ushort)
        || operandType == typeof(sbyte)
        || operandType == typeof(double)
        || operandType == typeof(float)
        || operandType == typeof(decimal);

    // Mirrors the decision tree used the server-side validation.
    // Resource attribute bypasses the localizer (resource lookup is the canonical localized source).
    // Literal acts as both lookup key and fallback for the localizer.
    private string ResolveDisplayName(in FieldMetadata metadata, string fieldName)
    {
        if (metadata.ResourceDisplayAttribute is { } resourceAttribute)
        {
            return resourceAttribute.GetName() ?? fieldName;
        }

        if (metadata.LiteralDisplayName is not { } literal)
        {
            return fieldName;
        }

        if (_validationLocalizer is null)
        {
            return literal;
        }

        return _validationLocalizer.ResolveDisplayName(new DisplayNameLocalizationContext
        {
            DeclaringType = metadata.DeclaringType,
            DisplayName = literal,
            MemberName = fieldName,
        }) ?? literal;
    }

    // Mirrors the decision tree used the server-side validation (see ResolveDisplayName).
    private string ResolveErrorMessage(
        ValidationAttribute attribute,
        string fieldName,
        string displayName,
        Type? declaringType)
    {
        if (_validationLocalizer is null || attribute.ErrorMessageResourceType is not null)
        {
            return attribute.FormatErrorMessage(displayName);
        }

        return _validationLocalizer.ResolveErrorMessage(new ErrorMessageLocalizationContext
        {
            MemberName = fieldName,
            DisplayName = displayName,
            DeclaringType = declaringType,
            Attribute = attribute,
        }) ?? attribute.FormatErrorMessage(displayName);
    }

    // All fields are culture-independent; localized text is resolved per call. At most one of
    // ResourceDisplayAttribute and LiteralDisplayName is non-null; both null means the property
    // has no display attribute.
    private readonly struct FieldMetadata(
        ValidationAttribute[] validationAttributes,
        Type? declaringType,
        DisplayAttribute? resourceDisplayAttribute,
        string? literalDisplayName)
    {
        // FieldMetadata.Empty is the sentinel for "no validatable property by this name".
        public static readonly FieldMetadata Empty = new(
            validationAttributes: [],
            declaringType: null,
            resourceDisplayAttribute: null,
            literalDisplayName: null);

        public ValidationAttribute[] ValidationAttributes { get; } = validationAttributes;

        public Type? DeclaringType { get; } = declaringType;

        // [Display(Name=..., ResourceType=...)]
        public DisplayAttribute? ResourceDisplayAttribute { get; } = resourceDisplayAttribute;

        // [Display(Name="X")] (no ResourceType) or [DisplayName("X")].
        public string? LiteralDisplayName { get; } = literalDisplayName;
    }

    [UnconditionalSuppressMessage("Trimming", "IL2070", Justification = "Model types are application code and are preserved by default.")]
    private static FieldMetadata BuildMetadata(Type modelType, string fieldName)
    {
        var property = modelType.GetProperty(fieldName, BindingFlags.Public | BindingFlags.Instance);
        if (property is null)
        {
            return FieldMetadata.Empty;
        }

        var validationAttributes = property.GetCustomAttributes<ValidationAttribute>(inherit: true).ToArray();

        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 FieldMetadata(validationAttributes, property.DeclaringType, resourceDisplayAttribute, literalDisplayName);
    }
}