ASP.net MVC - Custom attribute error message with

2020-04-12 13:19发布

问题:

I am having a property in my View Model which can accept integer and nullable values:

    [Display(Name = "Code Postal")]
    public int? CodePostal { get; set; }

When I type in string values, how can display another message than the default one:

The field Code Postal must be a number.

Thanks

回答1:

You could write a metadata aware attribute:

[AttributeUsage(AttributeTargets.Property, AllowMultiple = false, Inherited = true)]
public class MustBeAValidIntegerAttribute : Attribute, IMetadataAware
{
    public MustBeAValidIntegerAttribute(string errorMessage)
    {
        ErrorMessage = errorMessage;
    }

    public string ErrorMessage { get; private set; }

    public void OnMetadataCreated(ModelMetadata metadata)
    {
        metadata.AdditionalValues["mustbeavalidinteger"] = ErrorMessage;
    }
}

and a custom model binder that uses this attribute because it is the default model binder that adds the hardcoded error message you are seeing when it binds those integral types from the request:

public class NullableIntegerModelBinder: DefaultModelBinder
{
    public override object BindModel(ControllerContext controllerContext, ModelBindingContext bindingContext)
    {
        if (!bindingContext.ModelMetadata.AdditionalValues.ContainsKey("mustbeavalidinteger"))
        {
            return base.BindModel(controllerContext, bindingContext);
        }

        var mustBeAValidIntegerMessage = bindingContext.ModelMetadata.AdditionalValues["mustbeavalidinteger"] as string;
        if (string.IsNullOrEmpty(mustBeAValidIntegerMessage))
        {
            return base.BindModel(controllerContext, bindingContext);
        }

        var value = bindingContext.ValueProvider.GetValue(bindingContext.ModelName);
        if (value == null)
        {
            return null;
        }

        try
        {
            return value.ConvertTo(typeof(int?));
        }
        catch (Exception)
        {
            bindingContext.ModelState.AddModelError(bindingContext.ModelName, mustBeAValidIntegerMessage);
            bindingContext.ModelState.SetModelValue(bindingContext.ModelName, value);
        }

        return null;
    }
}

which will be registered in Application_Start:

ModelBinders.Binders.Add(typeof(int?), new NullableIntegerModelBinder());

From this moment on things get pretty standard.

View model:

public class MyViewModel
{
    [Display(Name = "Code Postal")]
    [MustBeAValidInteger("CodePostal must be a valid integer")]
    public int? CodePostal { get; set; }
}

Controller:

public class HomeController : Controller
{
    public ActionResult Index()
    {
        return View(new MyViewModel());
    }

    [HttpPost]
    public ActionResult Index(MyViewModel model)
    {
        return View(model);
    }
}

View:

@model MyViewModel

@using (Html.BeginForm())
{
    @Html.EditorFor(x => x.CodePostal)
    @Html.ValidationMessageFor(x => x.CodePostal)
    <button type="submit">OK</button>
}


回答2:

Alternative - rewrite resource strings

The easiest way is to replace default validation resource strings. This other SO answer will help you with that.

But you have to remember that these strings will them be used on all of your models not just particular property of some class.


Note: According to Darin (and me not testing the code) I'm striking part of my answer. The simplified approach by changing resource strings still stands. I've done that myself and I know it works.

Regular expression attribute

Add an additional attribute to your property:

[Display(Name = "Code Postal")]
[RegularExpression("\d+", ErrorMessage = "I'm now all yours...")]
public int? CodePostal { get; set; }

Even though you set regular expression on a non-string property this should still work. If we look at validation code it's like this:

public override bool IsValid(object value)
{
    this.SetupRegex();
    string text = Convert.ToString(value, CultureInfo.CurrentCulture);
    if (string.IsNullOrEmpty(text))
    {
        return true;
    }

    Match match = this.Regex.Match(text);
    return match.Success && match.Index == 0 && match.Length == text.Length;
}

As we can see, this validator automatically converts the value to string. So if your value is a number it doesn't really matter because it will be converted to a string and validated by your regular expression.



回答3:

It's little disappointing to see about the amount of work we have to do when all we want is a custom error message for the implicit validations done by the default model binder. The reason is the DefaultModelBinder hides some important methods as private especially the GetValueInvalidResource and GetValueRequiredResource. I hope they will take care of this in future.

I was trying to give a generic solution for the problem avoiding to create model binders for every type.

Honestly I haven't tested the below implementation in all the cases(ex. when binding collections) but did in basic levels.

So here is the approach.

We have two custom attributes that helps to pass custom error message for our custom model binder. We could have a base class but that's fine.

public class PropertyValueInvalidAttribute: Attribute
{
    public string ErrorMessage { get; set; }

    public PropertyValueInvalid(string errorMessage)
    {
        ErrorMessage = errorMessage;
    }
}

public class PropertyValueRequiredAttribute: Attribute
{
    public string ErrorMessage { get; set; }

    public PropertyValueRequired(string errorMessage)
    {
        ErrorMessage = errorMessage;
    }
}

Here is the model binder this is generic and independent of types and takes care of customizing error messages for both required and invalid validations.

public class ExtendedModelBinder : DefaultModelBinder
{
    protected override void SetProperty(ControllerContext controllerContext, ModelBindingContext bindingContext, PropertyDescriptor propertyDescriptor, object value)
    {
        base.SetProperty(controllerContext, bindingContext, propertyDescriptor, value);

        if (propertyDescriptor.Attributes.OfType<PropertyValueInvalidAttribute>().Any())
        {
            var attr = propertyDescriptor.Attributes.OfType<PropertyValueInvalidAttribute>().First();

            foreach (ModelError error in bindingContext.ModelState[propertyDescriptor.Name]
            .Errors.Where(err => String.IsNullOrEmpty(err.ErrorMessage) && err.Exception != null)
            .ToList())
            {
                for (Exception exception = error.Exception; exception != null; exception = exception.InnerException)
                {
                    if (exception is FormatException)
                    {
                        bindingContext.ModelState[propertyDescriptor.Name].Errors.Remove(error);
                        bindingContext.ModelState[propertyDescriptor.Name].Errors.Add(attr.ErrorMessage);
                        break;
                    }
                }
            }
        }
    }

    protected override void OnPropertyValidated(ControllerContext controllerContext, ModelBindingContext bindingContext, PropertyDescriptor propertyDescriptor, object value)
    {
        if (propertyDescriptor.Attributes.OfType<PropertyValueRequiredAttribute>().Any())
        {
            var attr = propertyDescriptor.Attributes.OfType<PropertyValueRequiredAttribute>().First();

            var isTypeAllowsNullValue = (!propertyDescriptor.PropertyType.IsValueType || Nullable.GetUnderlyingType(propertyDescriptor.PropertyType) != null);

            if (value == null && !isTypeAllowsNullValue)
            {
                bindingContext.ModelState[propertyDescriptor.Name].Errors.Clear();
                bindingContext.ModelState.AddModelError(propertyDescriptor.Name, attr.ErrorMessage);
            }
        }

        base.OnPropertyValidated(controllerContext, bindingContext, propertyDescriptor, value);
    }
}

We are overriding the OnPropertyValidated method just to override the implicit required error message thrown by the default model binder, and we are overriding the SetProperty just to use our own message when the type is not valid.

Set our custom binder as the default in Global.asax.cs

ModelBinders.Binders.DefaultBinder = new ExtendedModelBinder();

And you can decorate your properties like this..

[PropertyValueRequired("this field is required")]
[PropertyValueInvalid("type is invalid")]
[Display(Name = "Code Postal")]
public int? CodePostal { get; set; }