File: Forms\DataAnnotationsLocalizer.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.ComponentModel.DataAnnotations;
using System.Globalization;
using Microsoft.Extensions.Localization;
using Microsoft.Extensions.Validation;
 
namespace Microsoft.AspNetCore.Components.Endpoints.Forms;
 
internal class DataAnnotationsLocalizer(ValidationOptions options, IStringLocalizerFactory? localizerFactory)
{
    // Mirrors the decision tree used by the server-side validation.
    // Resource-attribute display names bypass the localizer (resource lookup is the canonical
    // localized source). Literal display names act as both lookup key and fallback for the localizer.
    public string ResolveDisplayName(in ClientValidationFieldMetadata metadata, bool useStringLocalizer)
    {
        if (metadata.ResourceDisplayAttribute is { } resourceAttribute)
        {
            return resourceAttribute.GetName() ?? metadata.PropertyName;
        }
 
        if (metadata.LiteralDisplayName is not { } literal)
        {
            return metadata.PropertyName;
        }
 
        if (!useStringLocalizer || localizerFactory is null)
        {
            return literal;
        }
 
        var localizer = GetStringLocalizer(metadata.DeclaringType, localizerFactory);
        var localizedTemplate = localizer[literal];
 
        return localizedTemplate.ResourceNotFound ? literal : localizedTemplate.Value;
    }
 
    // Mirrors the decision tree used by the server-side validation. Falls back to
    // FormatErrorMessage when no localizer is configured or the attribute already supplies
    // resource-based localization.
    //
    // Keep in sync with the generated ResolveAttributeErrorMessage/FormatErrorMessage in
    // src/Validation/gen/Templates/ValidatableInfo.cs, which this mirrors for the SSR client payload.
    public string? ResolveAttributeErrorMessage(
       string? memberName,
       string displayName,
       Type type,
       ValidationAttribute attribute,
       bool useStringLocalizer)
    {
        if (!useStringLocalizer || localizerFactory is null || attribute.ErrorMessageResourceType is not null)
        {
            return attribute.FormatErrorMessage(displayName);
        }
 
        var localizer = GetStringLocalizer(type, localizerFactory);
        var localizedTemplate = FindLocalizedTemplate(localizer, attribute, memberName, type);
 
        if (localizedTemplate is null)
        {
            return attribute.FormatErrorMessage(displayName);
        }
 
        // Format the localized template with attribute-specific arguments
        return FormatMessage(attribute, CultureInfo.CurrentCulture, localizedTemplate, displayName);
    }
 
    // Resolves the localized message template for a validation attribute.
    //
    // An explicit ErrorMessage is used verbatim as the lookup key. Otherwise the built-in key
    // convention is applied, walking from the most specific key to the least specific one:
    //
    //   {DeclaringType}_{MemberName}_{AttributeType}_Error
    //   {DeclaringType}_{AttributeType}_Error
    //   {AttributeType}_Error
    //
    // Returns null when no key resolves, in which case the caller falls back to the
    // non-localized message produced by the attribute itself.
    //
    // Keep in sync with the generated LocalizationHelpers.FindLocalizedTemplate in
    // src/Validation/gen/Templates/LocalizationHelpers.cs.
    private static string? FindLocalizedTemplate(
        IStringLocalizer localizer,
        ValidationAttribute attribute,
        string? memberName,
        Type declaringType)
    {
        if (!string.IsNullOrEmpty(attribute.ErrorMessage))
        {
            var explicitMatch = localizer[attribute.ErrorMessage];
            if (!explicitMatch.ResourceNotFound)
            {
                return explicitMatch.Value;
            }
        }
 
        var attributeName = attribute.GetType().Name;
        var typeName = GetKeySegment(declaringType);
 
        // The member-specific tier is skipped when there is no member to key on, which is the case
        // for a type-level attribute that reports no member names.
        if (memberName is not null)
        {
            var memberMatch = localizer[$"{typeName}_{memberName}_{attributeName}_Error"];
            if (!memberMatch.ResourceNotFound)
            {
                return memberMatch.Value;
            }
        }
 
        var typeMatch = localizer[$"{typeName}_{attributeName}_Error"];
        if (!typeMatch.ResourceNotFound)
        {
            return typeMatch.Value;
        }
 
        var globalMatch = localizer[$"{attributeName}_Error"];
 
        return globalMatch.ResourceNotFound ? null : globalMatch.Value;
    }
 
    private static string GetKeySegment(Type type)
    {
        var name = (Nullable.GetUnderlyingType(type) ?? type).Name;
        var arityIndex = name.IndexOf('`');
 
        return arityIndex < 0 ? name : name[..arityIndex];
    }
 
    private IStringLocalizer GetStringLocalizer(Type type, IStringLocalizerFactory localizerFactory)
        => options.LocalizerProvider(type, localizerFactory)
            ?? throw new InvalidOperationException(
                $"The {nameof(ValidationOptions)}.{nameof(ValidationOptions.LocalizerProvider)} " +
                $"delegate returned null for type '{type.FullName}'. " +
                $"The delegate must return a non-null {nameof(IStringLocalizer)} instance.");
 
    private static string FormatMessage(ValidationAttribute attribute, CultureInfo culture, string messageTemplate, string displayName)
        => attribute switch
        {
            IValidationMessageFormatter selfFormatter => selfFormatter.FormatMessage(culture, messageTemplate, displayName),
            CompareAttribute a => string.Format(culture, messageTemplate, displayName, a.OtherPropertyDisplayName ?? a.OtherProperty),
            FileExtensionsAttribute a => string.Format(culture, messageTemplate, displayName, a.Extensions),
            LengthAttribute a => string.Format(culture, messageTemplate, displayName, a.MinimumLength, a.MaximumLength),
            MaxLengthAttribute a => string.Format(culture, messageTemplate, displayName, a.Length),
            MinLengthAttribute a => string.Format(culture, messageTemplate, displayName, a.Length),
            RangeAttribute a => string.Format(culture, messageTemplate, displayName, a.Minimum, a.Maximum),
            RegularExpressionAttribute a => string.Format(culture, messageTemplate, displayName, a.Pattern),
            StringLengthAttribute a => string.Format(culture, messageTemplate, displayName, a.MaximumLength, a.MinimumLength),
            _ => string.Format(culture, messageTemplate, displayName),
        };
}