To implement options validation types or options validators, we can create a class that implements one or more IValidateOptions<TOptions> interfaces. One type can validate multiple options, and multiple types can validate the same options, so the possible combinations should cover all use cases.Using a custom class is no harder than using data annotations. However, it allows us to remove the validation concerns from the options class and code more complex validation logic. You should pick the way that makes the most sense for your project.
On top of personal preferences, say you use a third-party library with options. You load that library into your application and expect the configuration to be a certain way. You could create a class to validate that the options class provided by the library is configured appropriately for your application and even validate this at startup time.
You can’t use data annotations for that because you don’t control the code. Moreover, it is not a general validation that should apply to all consumers but specific validation for that one app.
Let’s start with the skeleton of the test class:
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Options;
using Xunit;
namespace OptionsValidation;
public class ValidateOptionsWithTypes
{
[Fact]
public void Should_pass_validation() {}
[Fact]
public void Should_fail_validation() {}
private class Options
{
public string?
MyImportantProperty { get; set; }
}
private class OptionsValidator : IValidateOptions<Options>
{
public ValidateOptionsResult Validate(
string name, Options options)
{
if (string.IsNullOrEmpty(options.MyImportantProperty))
{
return ValidateOptionsResult.Fail(
“‘MyImportantProperty’ is required.”);
}
return ValidateOptionsResult.Success;
}
}
}
In the preceding code, we have the Options class that is similar to the previous example but without the data annotation. The difference is that instead of using the [Required] attribute, we created the OptionsValidator class (highlighted) containing the validation logic.OptionsValidator implements IValidateOptions<Options>, which only contains a Validate method. This method allows named and default options to be validated. The name argument represents the options’ names. In our case, we implemented the required logic for all options. The ValidateOptionsResult class exposes a few members to help us, such as the Success and Skip fields, and two Fail() methods.ValidateOptionsResult.Success indicates success. ValidateOptionsResult.Skip indicates that the validator did not validate the options, most likely because it only validates certain named options but not the given one.The ValidateOptionsResult.Fail(message) and ValidateOptionsResult.Fail(messages) methods take a single message or a collection of messages as an argument.To make this work, we must make the validator available to the IoC container, as we did with the options configuration. We explore the two test cases next, which are very similar to the data annotation example.Here’s the first test case that passes the validation:
[Fact]
public void Should_pass_validation()
{
// Arrange
var services = new ServiceCollection();
services.AddSingleton<IValidateOptions<Options>, OptionsValidator>();
services.AddOptions<Options>()
.Configure(o => o.MyImportantProperty = “A value”)
.ValidateOnStart()
;
var serviceProvider = services.BuildServiceProvider();
// Act & Assert
var options = serviceProvider
.GetRequiredService<IOptionsMonitor<Options>>();
Assert.Equal(
“A value”,
options.CurrentValue.MyImportantProperty
);
}
The test case simulates an application that configures the MyImportantProperty correctly, which passes validation. The highlighted line shows how to register the validator class. The rest is done by the framework when using the options class.Next, we explore a test that fails the validation:
[Fact]
public void Should_fail_validation()
{
// Arrange
var services = new ServiceCollection();
services.AddSingleton<IValidateOptions<Options>, OptionsValidator>();
services.AddOptions<Options>().ValidateOnStart();
var serviceProvider = services.BuildServiceProvider();
// Act & Assert
var options = serviceProvider
.GetRequiredService<IOptionsMonitor<Options>>();
var error = Assert.Throws<OptionsValidationException>(
() => options.CurrentValue);
Assert.Collection(error.Failures,
f => Assert.Equal(“‘MyImportantProperty’ is required.”, f)
);
}
The test simulates a program where the Options class is not configured appropriately. When accessing the options object, the framework builds the class and validates it, throwing an OptionsValidationException because of the validation rules (highlighted lines).Using types to validate options is handy when you don’t want to use data annotations, can’t use data annotations, or need to implement certain logic that is easier within a method than with attributes.Next, we glance at how to leverage options with FluentValidation.