Search code examples
c#.net.net-core.net-5fluentvalidation

How to write fluent validation rules inside IValidateOptions using FluentValidation?


For my .Net 5 workerservice app I want to validate options by implementing the IValidateOptions interface but don't want to write my own error messages. That's why I want to make use of the package FluentValidation.AspNetCore.

Given the model

namespace App.Models
{
    public class MyOptions
    {
        public string Text { get; set; }
    }
}

I added validation rules

namespace App.ModelValidators
{
    public class MyOptionsValidator : AbstractValidator<MyOptions>
    {
        public MyOptionsValidator()
        {
            RuleFor(model => model.Text).NotEmpty();
        }
    }
}

Next I'm using this validator inside the validation

namespace App.OptionsValidators
{
    public class MyOptionsValidator : IValidateOptions<MyOptions>
    {
        private readonly IValidator<MyOptions> _validator;
        
        public MyOptionsValidator(IValidator<MyOptions> validator)
        {
            _validator = validator;
        }
        
        public ValidateOptionsResult Validate(string name, MyOptions options)
        {
            var validationResult = _validator.Validate(options);

            if (validationResult.IsValid)
            {
                return ValidateOptionsResult.Success;
            }
            
            return ValidateOptionsResult.Fail(validationResult.Errors.Select(validationFailure => validationFailure.ErrorMessage));
        }
    }
}

Lastly I setup the DI container

services.AddTransient<IValidator<MyOptions>, ModelValidators.MyOptionsValidator>();
services.AddSingleton<IValidateOptions<MyOptions>, OptionsValidators.MyOptionsValidator>();
services.Configure<MyOptions>(configuration.GetSection("My"));

I would like to know if this can be simplified?

Maybe I can just implement the IValidateOptions interface, avoid the AbstractValidator and write fluent rules inside the .Validate() method?

Sample code what I want to achieve

namespace App.OptionsValidators
{
    public class MyOptionsValidator : IValidateOptions<MyOptions>
    {
        public ValidateOptionsResult Validate(string name, MyOptions options)
        {
            var validationResult = options.Text.Should.Not.Be.Empty();

            if (validationResult.IsValid)
            {
                return ValidateOptionsResult.Success;
            }
            
            return ValidateOptionsResult.Fail(validationResult.ErrorMessage);
        }
    }
}

so I don't need the AbstractValidator<MyOptions> anymore.


My first approach I'm not sure about

Instead of using FluentValidation I'm using DataAnnotations.

  • I added the [Required] attribute to the Text property
  • I completely removed the class MyOptionsValidator : AbstractValidator<MyOptions>
  • I only register this in the DI setup

.

services.AddSingleton<IValidateOptions<MyOptions>, OptionsValidators.MyOptionsValidator>();
services.Configure<MyOptions>(configuration.GetSection("My"));

Inside MyOptionsValidator I validate the options like so

    public ValidateOptionsResult Validate(string name, MyOptions options)
    {
        var validationResults = new List<ValidationResult>();
        
        if (!Validator.TryValidateObject(options, new ValidationContext(options), validationResults, true))
        {
            return ValidateOptionsResult.Fail(validationResults.Select(validationResult => validationResult.ErrorMessage));
        }

        return ValidateOptionsResult.Success;
    }

but maybe there are still better ways :)


Solution

  • I have a strong preference to utilise the same approach for validation across the stack, and in my case this is via FluentValidation. The following would be my approach.

    Create a new base validator for your options/settings validators:

    public abstract class AbstractOptionsValidator<T> : AbstractValidator<T>, IValidateOptions<T>
        where T : class
    {
        public virtual ValidateOptionsResult Validate(string name, T options)
        {
            var validateResult = this.Validate(options);
            return validateResult.IsValid ? ValidateOptionsResult.Success : ValidateOptionsResult.Fail(validateResult.Errors.Select(x => x.ErrorMessage));
        }
    }
    

    This extends the FluentValidation AbstractValidator<T> to support IValidateOptions<T>. You've now got a base that you can use for all of your options/settings validators. For the following settings:

    public class FooSettings
    {
        public string Bar { get; set; }
    }
    

    you end up with a typical validator:

    public class FooSettingsValidator : AbstractOptionsValidator<FooSettings>, IFooSettingsValidator
    {
        public FooSettingsValidator()
        {
            RuleFor(x => x.Bar).NotEmpty();
        }
    }
    

    Let the DI container know about it:

    serviceCollection.AddSingleton<IValidateOptions<FooSettings>, FooSettingsValidator>();
    

    If there is nothing built in to do the above, I'd look to Scrutor to turn this into an automatic process.

    All of the above affords me all of the benefits of using FluentValidation, whilst utilising the first class options validation support that Microsoft has provided for us.

    LINQPad working example:

    enter image description here

    enter image description here

    using static FluentAssertions.FluentActions;
    
    void Main()
    {
        var fixture = new Fixture();
        var validator = new FooSettingsValidator();
        validator.Validate(fixture.Build<FooSettings>().Without(x => x.Bar).Create()).Errors.Select(x => x.ErrorMessage).Should().BeEquivalentTo(new string[] { "'Bar' must not be empty." });
        validator.Validate(fixture.Build<FooSettings>().Create()).Errors.Select(x => x.ErrorMessage).Should().BeEquivalentTo(new string[] { });
    
        using (var scope = ServiceProvider.Create(bar: null).CreateScope())
        {
            Invoking(() => scope.ServiceProvider.GetRequiredService<IOptionsSnapshot<FooSettings>>().Value).Should().Throw<OptionsValidationException>();
        }
    
        using (var scope = ServiceProvider.Create(bar: "asdf").CreateScope())
        {
            scope.ServiceProvider.GetRequiredService<IOptionsSnapshot<FooSettings>>().Value.Bar.Should().Be("asdf");
        }
    }
    
    // You can define other methods, fields, classes and namespaces here
    public class FooSettings
    {
        public string Bar { get; set; }
    }
    
    public interface IFooSettingsValidator : IValidator { }
    
    public class FooSettingsValidator : AbstractOptionsValidator<FooSettings>, IFooSettingsValidator
    {
        public FooSettingsValidator()
        {
            RuleFor(x => x.Bar).NotEmpty();
        }
    }
    
    public abstract class AbstractOptionsValidator<T> : AbstractValidator<T>, IValidateOptions<T>
        where T : class
    {
        public virtual ValidateOptionsResult Validate(string name, T options)
        {
            var validateResult = this.Validate(options);
            return validateResult.IsValid ? ValidateOptionsResult.Success : ValidateOptionsResult.Fail(validateResult.Errors.Select(x => x.ErrorMessage));
        }
    }
    
    public class ServiceProvider
    {
        public static IServiceProvider Create(string bar)
        {
            var serviceCollection = new ServiceCollection();
    
            var config = new ConfigurationBuilder()
                        .AddInMemoryCollection(
                            new List<KeyValuePair<string, string>> { new KeyValuePair<string, string>("Foo:Bar", bar) })
                        .Build();
            serviceCollection.AddSingleton<IConfiguration>(config);
            serviceCollection.AddOptions();
            //serviceCollection.Configure<FooSettings>(config.GetSection("Foo"));
            serviceCollection.AddOptions<FooSettings>()
                                .Bind(config.GetSection("Foo"));
    
            serviceCollection.AddSingleton<IValidateOptions<FooSettings>, FooSettingsValidator>();
            serviceCollection.AddSingleton<IFooSettingsValidator, FooSettingsValidator>();
    
            return serviceCollection.BuildServiceProvider();
        }
    }