Copying FluentValidation Errors to the ModelState for a complex property?

1.2k views Asked by At

When I follow the FluentValidation docs and copy the FluentValidationm error to the ModelState dictionary, only simple properties will cause asp-validation-for attributes to work. When I use a complex property it will not work unless I prepend the class name to the ModelState key.

.NET 7, FluentValidation 11.4.0, RazorPages.

HTML

<form method="post">
    <div asp-validation-summary="All"></div>

    <input type="text" asp-for="Sample.TestValue" />
    <!-- Wont work unless prepend "Sample" to ModelState dictionary error key -->
    <span asp-validation-for="Sample.TestValue"></span>
    <button type="submit">Do it</button>
</form>

CodeBehind

namespace ValForTest.Pages;

using FluentValidation;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.RazorPages;

public class SampleValidator : AbstractValidator<Sample>
{
    public SampleValidator()
    {
        RuleFor(x => x.TestValue)
            .MaximumLength(1);
    }
}

public class Sample
{
    public string? TestValue { get; set; }
}

public class IndexModel : PageModel
{
    [BindProperty]
    public Sample Sample  { get; set; }  

    public void OnPost()
    {
        var validator = new SampleValidator();
        var result = validator.Validate(this.Sample);

        foreach (var error in result.Errors) 
        {
            this.ModelState.AddModelError(error.PropertyName, error.ErrorMessage);

            // This works!!! Code smell though. Better way to do this??
            // this.ModelState.AddModelError($"{nameof(Sample)}.{error.PropertyName}", error.ErrorMessage);
        }
    }    

    public void OnGet() { }
}

Result:

enter image description here

asp-validation-summary works, asp-validation-for does not.

However, if I uncomment my // this works line where I add the "fully qualified" property name which includes the complex class name, then it will show the asp-validation-for span:

enter image description here

How can I tell FluentValidation to add the class name to the properties?

2

There are 2 answers

0
keipala On BEST ANSWER

If you can, I suggest using the FluentValidation.AspNetCore package since it has an AddToModelState extension method that takes a prefix which will solve your issue.

Your OnPost() page handler then becomes:

    public void OnPost()
    {
        var validator = new SampleValidator();
        var result = validator.Validate(this.Sample);
        result.AddToModelState(ModelState, nameof(this.Sample);
    }    

Although to replicate this functionality yourself would be simple as well. For reference, here is how it is done in the library:

    /// <summary>
    /// Stores the errors in a ValidationResult object to the specified modelstate dictionary.
    /// </summary>
    /// <param name="result">The validation result to store</param>
    /// <param name="modelState">The ModelStateDictionary to store the errors in.</param>
    /// <param name="prefix">An optional prefix. If omitted, the property names will be the keys. If specified, the prefix will be concatenated to the property name with a period. Eg "user.Name"</param>
    public static void AddToModelState(this ValidationResult result, ModelStateDictionary modelState, string prefix) {
        if (!result.IsValid) {
            foreach (var error in result.Errors) {
                string key = string.IsNullOrEmpty(prefix)
                    ? error.PropertyName
                    : string.IsNullOrEmpty(error.PropertyName)
                        ? prefix
                        : prefix + "." + error.PropertyName;
                modelState.AddModelError(key, error.ErrorMessage);
            }
        }
    }
2
Bill Wheelock On

I ran into a similar issue until I wrapped my head around what's going on. The reason you aren't seeing the validation messages next to the fields is because FluentValidation has no idea what the property prefix is. Meaning, it knows TestValue, but has no way of knowing the bound property name is Sample. So specifying the name actually makes sense in your commented-out code. I added some PageModel extensions to handle this in our framework.

Extension Method:


    public class PageValidationResult
    {
        public PageValidationResult(bool isValid)
        {
            IsValid = isValid;
        }

        public bool IsValid { get; }

        public bool IsNotValid => !IsValid;
    }

    public static class PageModelExtentions
    {

        public static async Task<PageValidationResult> GetValidationAsync<TValidator, TInstance>(this PageModel pageModel, TInstance instance, string? name = null)
            where TValidator : AbstractValidator<TInstance>, new()
            where TInstance : notnull
        {
            var validator = new TValidator();

            var result = await validator.ValidateAsync(instance);

            if (!result.IsValid)
            {
                name ??= instance.GetType().Name;

                foreach (var error in result.Errors)
                {
                    var property = $"{name}.{error.PropertyName}";

                    pageModel.ModelState.ClearValidationState(property);

                    pageModel.ModelState.AddModelError(property, error.ErrorMessage);
                }
            }

            return new(result.IsValid);
        }

        public static async Task<bool> ValidateAsync<TValidator, TPageModel>(this TPageModel pageModel)
            where TValidator : AbstractValidator<TPageModel>, new()
            where TPageModel : notnull, PageModel =>
                (await GetValidationAsync<TValidator, TPageModel>(pageModel)).IsValid;

    }

Usage:


    public record LoginRequest(string Username, string Password);

    [BindProperty]
    public LoginRequest Login { get; set; } = default!;

    public async Task<IActionResult> OnPostAsync()
    {
        if (await this.ValidateAsync<Validator, LoginRequest>(Login, "Login") is false)
        {
            return Page();
        }
             
        [...]
    }

The PageValidationResult isn't strictly necessary here. You can just return a bool or whatever.

You can see the complete implementation here: https://github.com/fanzoo-kernel-team/kernel/blob/main/src/Fanzoo.Kernel/Web/Mvc/Abstractions/PageModelExtensions.cs