Description
Description
When using the Options pattern, the ValidateDataAnnotations() function can be used to validate various DataAnnotations that are set as attributes in an options class. One of the most common DataAnnotations is the [Required] attribute. If [Required]
is set for a given field, an exception should be thrown if that field is not present in the configuration.
This works fine for strings, and other reference types. But for value types, the [Required]
DataAnnotation does nothing. If the value is not present in the configuration, the default value is used, and the [Required]
DataAnnotation is ignored.
Ideally, the [Required]
annotation should apply to all fields, both reference and value types.
This has been confusing for our team because many assumed that [Required]
applied to all fields, and didn't realize that default values were being passed in for all bools, ints, doubles, TimeSpans, etc. instead of an exception being thrown.
Reproduction Steps
Here is a minimal class that should throw an OptionsValidationException
:
using System.ComponentModel.DataAnnotations;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Options;
var emptyConfiguration = new ConfigurationBuilder().Build();
var services = new ServiceCollection();
services
.AddOptionsWithValidateOnStart<SampleOptions>()
.Bind(emptyConfiguration)
.ValidateDataAnnotations();
var sampleOptions = services
.BuildServiceProvider()
.GetRequiredService<IOptions<SampleOptions>>();
Console.WriteLine($"IsEnabled: {sampleOptions.Value.IsEnabled}");
public class SampleOptions
{
[Required]
public required bool IsEnabled { get; init; }
}
When I run it, I get this output:
IsEnabled: False
But I expect this output:
Unhandled exception. Microsoft.Extensions.Options.OptionsValidationException: DataAnnotation validation failed for 'SampleOptions' members: 'IsEnabled' with the error: 'The IsEnabled field is required.'.
at Microsoft.Extensions.Options.OptionsFactory`1.Create(String name)
at Microsoft.Extensions.Options.UnnamedOptionsManager`1.get_Value()
at Program.<Main>$(String[] args) in Q:\code\Other\OptionsValidationSample\Program.cs:line 18
That exception is what gets thrown if I change the type of IsEnabled from bool to string.
For reference, this is what my .csproj
file looks like:
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net9.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.Extensions.Configuration" Version="9.0.5" />
<PackageReference Include="Microsoft.Extensions.DependencyInjection" Version="9.0.5" />
<PackageReference Include="Microsoft.Extensions.Options" Version="9.0.5" />
<PackageReference Include="Microsoft.Extensions.Options.ConfigurationExtensions" Version="9.0.5" />
<PackageReference Include="Microsoft.Extensions.Options.DataAnnotations" Version="9.0.5" />
</ItemGroup>
</Project>
Expected behavior
I expect an exception like this to be thrown if a boolean config value or other value type is missing from the configuration:
Unhandled exception. Microsoft.Extensions.Options.OptionsValidationException: DataAnnotation validation failed for 'SampleOptions' members: 'IsEnabled' with the error: 'The IsEnabled field is required.'.
at Microsoft.Extensions.Options.OptionsFactory`1.Create(String name)
at Microsoft.Extensions.Options.UnnamedOptionsManager`1.get_Value()
at Program.<Main>$(String[] args) in Q:\code\Other\OptionsValidationSample\Program.cs:line 18
Actual behavior
When a boolean or other value type is missing from the configuration, the default value is provided and no exception is thrown. For example, all bools are false, all ints are 0, etc.
Regression?
No
Known Workarounds
Right now I'm using a workaround where I directly check the configuration to see if the values exist. It allows me to make a call like this to do actual validation of the required value types:
configuration.ValidateRequiredProperties<SampleOptions>()
Here is the contents of the ValidateRequiredProperties()
function:
public static class ConfigurationExtensions
{
/// <summary>
/// Validates that all properties of optionsType with an <see cref="RequiredAttribute"/> are present in the
/// configuration. This function doesn't check for nested configuration values.
///
/// The <see cref="OptionsBuilderDataAnnotationsExtensions.ValidateDataAnnotations"/> function does the same thing
/// for object types, but never throws if a primitive type or struct is missing. Thus, if a property of type bool,
/// int, long, double, TimeSpan, etc. is missing from the configuration, it will not throw. This function validates
/// all of those property types.
/// </summary>
public static IConfiguration ValidateRequiredProperties<TOptions>(
this IConfiguration configuration) where TOptions : class
{
var optionsType = typeof(TOptions);
var missingProperties = optionsType
.GetProperties()
.Where(IsRequired)
.Select(GetConfigurationKeyNameOrPropertyName)
.Where(key => configuration[key] is null)
.ToList();
if (missingProperties.Count > 0)
{
throw new OptionsValidationException(
optionsName: optionsType.Name,
optionsType: optionsType,
failureMessages: missingProperties.Select(p =>
$"DataAnnotation validation failed for '{optionsType.Name}' members: '{p}' with the error: 'The {p} field is required.'."));
}
return configuration;
}
/// <summary>
/// Returns the Name from the <see cref="ConfigurationKeyNameAttribute"/> if one exists. Else, returns the property
/// name.
/// </summary>
public static string GetConfigurationKeyNameOrPropertyName(this PropertyInfo property)
=> property.GetCustomAttribute<ConfigurationKeyNameAttribute>()?.Name ?? property.Name;
/// <summary>
/// Returns true if the <see cref="RequiredAttribute"/> is present on the property, else false.
/// </summary>
private static bool IsRequired(this PropertyInfo property)
=> property.GetCustomAttribute<RequiredAttribute>() is not null;
}
Configuration
Version: .net 8
OS: Windows and Linux
Architecture: x64
Other information
No response