How to force MVC to Validate IValidatableObject

2020-01-24 11:43发布

It seems that when MVC validates a Model that it runs through the DataAnnotation attributes (like required, or range) first and if any of those fail it skips running the Validate method on my IValidatableObject model.

Is there a way to have MVC go ahead and run that method even if the other validation fails?

2条回答
Ridiculous、
2楼-- · 2020-01-24 11:55

There's a way to do it without requiring boilerplate code at the top of each controller action.

You'll need to replace the default model binder with one of your own:

protected void Application_Start()
{
    // ...
    ModelBinderProviders.BinderProviders.Clear();
    ModelBinderProviders.BinderProviders.Add(new CustomModelBinderProvider());
    // ...
}

Your model binder provider looks like this:

public class CustomModelBinderProvider : IModelBinderProvider
{
    public IModelBinder GetBinder(Type modelType)
    {
        return new CustomModelBinder();
    }
}

Now create a custom model binder that actually forces the validation. This is where the heavy lifting's done:

public class CustomModelBinder : DefaultModelBinder
{
    protected override void OnModelUpdated(ControllerContext controllerContext, ModelBindingContext bindingContext)
    {
        base.OnModelUpdated(controllerContext, bindingContext);

        ForceModelValidation(bindingContext);
    }

    private static void ForceModelValidation(ModelBindingContext bindingContext)
    {
        var model = bindingContext.Model as IValidatableObject;
        if (model == null) return;

        var modelState = bindingContext.ModelState;

        var errors = model.Validate(new ValidationContext(model, null, null));
        foreach (var error in errors)
        {
            foreach (var memberName in error.MemberNames)
            {
                // Only add errors that haven't already been added.
                // (This can happen if the model's Validate(...) method is called more than once, which will happen when
                // there are no property-level validation failures.)
                var memberNameClone = memberName;
                var idx = modelState.Keys.IndexOf(k => k == memberNameClone);
                if (idx < 0) continue;
                if (modelState.Values.ToArray()[idx].Errors.Any()) continue;

                modelState.AddModelError(memberName, error.ErrorMessage);
            }
        }
    }
}

You'll need an IndexOf extension method, too. This is a cheap implementation but it'll work:

public static int IndexOf<T>(this IEnumerable<T> source, Func<T, bool> predicate)
{
    if (source == null) throw new ArgumentNullException("source");
    if (predicate == null) throw new ArgumentNullException("predicate");

    var i = 0;
    foreach (var item in source)
    {
        if (predicate(item)) return i;
        i++;
    }

    return -1;
}
查看更多
家丑人穷心不美
3楼-- · 2020-01-24 12:12

You can manually call Validate() by passing in a new instance of ValidationContext, like so:

[HttpPost]
public ActionResult Create(Model model) {
    if (!ModelState.IsValid) {
        var errors = model.Validate(new ValidationContext(model, null, null));
        foreach (var error in errors)                                 
            foreach (var memberName in error.MemberNames)
                ModelState.AddModelError(memberName, error.ErrorMessage);

        return View(post);
    }
}

A caveat of this approach is that in instances where there are no property-level (DataAnnotation) errors, the validation will be run twice. To avoid that, you could add a property to your model, say a boolean Validated, which you set to true in your Validate() method once it runs and then check before manually calling the method in your controller.

So in your controller:

if (!ModelState.IsValid) {
    if (!model.Validated) {
        var validationResults = model.Validate(new ValidationContext(model, null, null));
        foreach (var error in validationResults)
            foreach (var memberName in error.MemberNames)
                ModelState.AddModelError(memberName, error.ErrorMessage);
    }

    return View(post);
}

And in your model:

public bool Validated { get; set; }

public IEnumerable<ValidationResult> Validate(ValidationContext validationContext) {
    // perform validation

    Validated = true;
}
查看更多
登录 后发表回答