Search code examples
c#asp.net-web-api

Can validation results for attribute-based validations be merged with ones for custom validations?


When there are multiple invalid properties that are decorated with data annotations and validated with custom validation, I want to obtain validation results for all the invalid properties rather than just the first invalid one (as what is produced by the following code).

using MiniValidation;
using System.ComponentModel.DataAnnotations;

var widget = new Widget { Name = "A", Age = 0 };

if (!MiniValidator.TryValidate(widget, out var errors))
{
    foreach (var entry in errors)
    {
        Console.WriteLine($"  {entry.Key}:");
        foreach (var error in entry.Value)
        {
            Console.WriteLine($"  - {error}");
        }
    }
}

class Widget : IValidatableObject
{
    [Required, MinLength(3)] 
    public string Name { get; set; } = null!;
    public int Age { get; set; }
    public IEnumerable<ValidationResult> Validate(ValidationContext ctx)
    {
        if (Age < 10)
            yield return new ValidationResult("Age cannot be less than 10.", [nameof(Age)]);
    }
}

enter image description here

I don't want to replace the attribute-based validations with custom validation.

class Widget : IValidatableObject
{
    public string Name { get; set; } = null!;
    public int Age { get; set; }
    public IEnumerable<ValidationResult> Validate(ValidationContext ctx)
    {
        if (Name is null)
            yield return new ValidationResult("Name must be specified.", [nameof(Name)]);
        if (Name?.Length < 3)
            yield return new ValidationResult("Name must contain at least 3 characters.", [nameof(Name)]);


        if (Age < 10)
            yield return new ValidationResult("Age cannot be less than 10.", [nameof(Age)]);
    }
}

enter image description here


Solution

  • I was able to reproduce the observed behavior and I was actually mistaken about the behavior when inheriting from an annotated class.

    (talking about the comment "If it is this: github.com/DamianEdwards/MiniValidation then the readme suggests you can do this by inheritance. Have a base with annotations and derived with custom validation." )

    Outside providing a feature to that project, I see these remedies:

    1. Switch to FluentValidation or a similar more mature tooling.
    2. Roll your own along the lines of this poc:
    using System;
    using System.Linq;
    using System.Collections.Generic;
    using System.ComponentModel.DataAnnotations;
    
    public class Program
    {
        public static void Main()
        {
            var widget = new Widget
            {
                Name = "A",
                Age = 0
            };
            
            if (!MyValidator.TryValidate(widget, out var myErrors))
            {
                foreach( var (name, error) in myErrors )
                {
                    Console.WriteLine($"{name} :");
                    foreach( var msg in error )
                        Console.WriteLine($"    - {msg}");
                }
            }
        }
    }
    
    static class MyValidator
    {
        public record MyResult(string Name, IReadOnlyCollection<string> Errors);
        
        public static bool TryValidate<T>(T obj, out IEnumerable<MyResult> errors)
        {
            var ctx = new ValidationContext(obj);
            var results = new List<ValidationResult>();
            if (obj is IValidatableObject ivo)
            {
                results.AddRange(ivo.Validate(ctx));
            }
            
            var validatorResult = Validator.TryValidateObject(obj, ctx, results, true);
            
            if (!validatorResult || results.Any())
            {
                errors = results.SelectMany(r => r.MemberNames.Select(n => new {Name = n, Error = r.ErrorMessage}))
                    .GroupBy(o => o.Name)
                    .Select(grp => new MyResult(grp.Key, grp.Select(o => o.Error).ToList()))
                    .ToList();
                                
                return false;
            }
            else
            {
                errors = [];
                return true;
            }
        }
    }
    
    class Widget : IValidatableObject
    {
        
        [Required, MinLength(3)]
        public string Name { get; set; } = null !;
        [Range(10, 1000, 
            ErrorMessage = "Value for {0} must be between {1} and {2}.")]
        public int Age { get; set; }
    
        public IEnumerable<ValidationResult> Validate(ValidationContext ctx)
        {
            if (Age < 10)
                yield return new ValidationResult("Age cannot be less than 10.", [nameof(Age)]);
        }
    }
    

    Fiddle: https://dotnetfiddle.net/5FUcEo

    Output:

    Age :
        - Age cannot be less than 10.
        - Value for Age must be between 10 and 1000.
    Name :
        - The field Name must be a string or array type with a minimum length of '3'.
    

    Disclaimer: I do not consider above code "production ready". It is supposed to briefly showcase a possible first step towards a solid solution.