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>();
I resolved this by taking a slightly different route. Essentially I made use of the ASP.NET Core MVC 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 ASP.NET Core 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())
});