Search code examples
c#asp.net-coreasp.net-core-mvc

ASP.NET Core MVC : extend how the RequiredAttribute works


I want to extend how the RequiredAttribute works with strongly typed view models. For example I have a reference data view model that looks like this:

public class ReferenceDataViewModel
{
    public int? Id { get; set; }
    public string? Name { get; set; }
}

This can be used on other view models like so:

public class MyEditViewModel
{
    // Optional: this property can be null or this property's Id value can be null
    public ReferenceViewModel? OptionalRef { get; set; }
    
    // Required: this property should not be null nor should this property's Id value
    [Required]
    public ReferenceViewModel? RequiredRef { get; set; }
}

With the RequiredRef The property itself should not be null (which is handled by the RequiredAttribute), but also the Id should not be null too.

I know I can achieve this by creating my own ValidationAttribute (see below), but I'd like to use the built in RequiredAttribute for consistency.

public class ExtendedRequiredAttribute : RequiredAttribute
{
    public ExtendedRequiredAttribute() { }

    public ExtendedRequiredAttribute(RequiredAttribute attributeToCopy)
    {
        AllowEmptyStrings = attributeToCopy.AllowEmptyStrings;

        if (attributeToCopy.ErrorMessage != null)
        {
            ErrorMessage = attributeToCopy.ErrorMessage;
        }
   
        if (attributeToCopy.ErrorMessageResourceType != null)
        {
            ErrorMessageResourceName = attributeToCopy.ErrorMessageResourceName;
            ErrorMessageResourceType = attributeToCopy.ErrorMessageResourceType;
        }
    }

    public override bool IsValid(object? value)
    {
        if (value is ReferenceDataViewModel entityReference)
        {
            return entityReference?.Id != null;
        }

        return base.IsValid(value);
    }
}

I've tried using an AttributeAdapter (which seems to work in ASP.NET MVC), but this doesn't help. I feel like I'm missing something:

public class ExtendedRequiredAttributeAdapter : AttributeAdapterBase<RequiredAttribute>
{
    public ExtendedRequiredAttributeAdapter(ExtendedRequiredAttribute attribute, IStringLocalizer? stringLocalizer)
        : base(attribute, stringLocalizer)
    { }

    public override void AddValidation(ClientModelValidationContext context)
    {
        MergeAttribute(context.Attributes, "data-val",          "true");
        MergeAttribute(context.Attributes, "data-val-required", GetErrorMessage(context));
    }

    public override string GetErrorMessage(ModelValidationContextBase validationContext)
    {
        ArgumentNullException.ThrowIfNull(validationContext);
        return GetErrorMessage(validationContext.ModelMetadata, validationContext.ModelMetadata.GetDisplayName());
    }
}

public class CustomValidationAttributeAdapterProvider : IValidationAttributeAdapterProvider
{
    private readonly IValidationAttributeAdapterProvider _innerProvider = new ValidationAttributeAdapterProvider();

    public IAttributeAdapter? GetAttributeAdapter(ValidationAttribute attribute, IStringLocalizer? stringLocalizer)
    {
        var type = attribute.GetType();

        if (type == typeof(RequiredAttribute))
        {
            var requiredAttribute = (RequiredAttribute)attribute;
            var extendedRequiredAttrib = new ExtendedRequiredAttribute(requiredAttribute);
            return new ExtendedRequiredAttributeAdapter(extendedRequiredAttrib, stringLocalizer);
        }

        return _innerProvider.GetAttributeAdapter(attribute, stringLocalizer);
    }
}

This is registered at startup:

services.AddSingleton<IValidationAttributeAdapterProvider, CustomValidationAttributeAdapterProvider>();

Solution

  • I resolved this by taking a slightly different route. Essentially I made use of the MVC core validation infrastructure by creating an IModelValidator and then bringing that into play via an IModelValidatorProvider, then registering that provider in the MvcOptions. Here's the code:

    public class ReferenceDataViewModelRequiredValidator : IModelValidator
    {
        public IEnumerable<ModelValidationResult> Validate(ModelValidationContext context)
        {
            var model = context.Model as ReferenceDataViewModel;
            if (model == null || model.Id.HasValue == false)
            {
                var message = GetErrorMessage(context.ModelMetadata);
                yield return new ModelValidationResult("", message);
            }
        }
    
        private string GetErrorMessage(ModelMetadata metadata)
        {
            string? message = null;
    
            var parent    = metadata.ContainerType;
            var modelName = metadata.PropertyName;
            if (parent != null && !string.IsNullOrWhiteSpace(modelName))
            {
                var modelProperty  = parent.GetProperty(modelName);
                var requiredAttrib = modelProperty?.GetCustomAttribute<RequiredAttribute>();
                if (requiredAttrib != null)
                {
                    message = requiredAttrib.ErrorMessage;
                    if (string.IsNullOrWhiteSpace(message))
                    {
                        message = requiredAttrib.FormatErrorMessage(modelName);
                    }
                }
            }
    
            if (string.IsNullOrWhiteSpace(message))
            {
                message = $"{metadata.GetDisplayName()} is required.";
            }
            return message;
        }
    
    }
    

    This can be integrated into MVC's validation via a ModelValidatorProvider:

    public class MyModelValidatorProvider : IModelValidatorProvider
    {
        public void CreateValidators(ModelValidatorProviderContext context)
        {
            if (HasRequiredAttribute(context.ModelMetadata))
            {
                if (context.ModelMetadata.ModelType == typeof(ReferenceDataViewModel))
                {
                    var validatorItem = new ValidatorItem()
                    {
                        IsReusable = true,
                        Validator  = new ReferenceDataViewModelRequiredValidator()
                    };
                    // put the validator in early
                    context.Results.Insert(0, validatorItem);
                }
    
        }
        /// <summary>
        /// Determines if the property being validated has a [Required] attribute
        /// </summary>
        /// <remarks>
        /// There is an IsRequired property on the <see cref="ModelMetadata"/>, but always seems to be "true".
        /// Not sure why.  
        /// </remarks>
        private static bool HasRequiredAttribute(ModelMetadata metadata)
        {
            if (metadata.ContainerType != null && !string.IsNullOrWhiteSpace(metadata.PropertyName))
            {
                var property = metadata.ContainerType.GetProperty(metadata.PropertyName);
                return property?.HasAttribute<RequiredAttribute>() ?? false;
            }
            return false;
        }
    }
    

    The ModelValidatorProvider needs to be registered at startup:

    builder
        .AddControllersWithViews(opts =>
        {
            opt.ModelValidatorProviders.Add(new MyModelValidatorProvider())
        });