Skip to content

Microsoft.Extensions.Options.DataAnnotations: ValidateDataAnnotations() doesn't validate [Required] annotation for value types #116160

Closed
@chappleg

Description

@chappleg

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

Metadata

Metadata

Assignees

No one assigned

    Type

    No type

    Projects

    No projects

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions