runtime: Developers can get immediate feedback on validation problems

Updated by @maryamariyan:

Goal

When an application starts, we want to get immediate feedback on validation problems. e.g. we would like to get exceptions thrown on app startup rather than later.

Benefits of eager validation:

  • Enabling this feature forces the program to crash fast when an invalid configuration is passed, as opposed to the default lazy validation only when IOptions<T> is requested

API Proposal

ValidateOnStart feature is implemented as extension to OptionsBuilder<TOptions> To allow for eager validation, an API suggestion is to add an extension method on OptionsBuilder, (not IHost).

Usage:

services.AddOptions<MyOptions>()
    .ValidateDataAnnotations()
    .ValidateOnStart();                 // Support eager validation

APIs:

According to usage, we’d need the APIs below:

namespace Microsoft.Extensions.DependencyInjection
{
    public static class OptionsBuilderExtensions
    {
        public static OptionsBuilder<TOptions> ValidateOnStart<TOptions>(this OptionsBuilder<TOptions> optionsBuilder) where TOptions : class;
    }
}

Focus of this issue:

The focus here is on eager validation at application startup, and these APIs don’t trigger for IOptionsSnapshot and IOptionsMonitor, where values may get recomputed on every request or upon configuration reload after the startup has completed.

  • It would support named options too.
IOptions<TOptions>:
  • Reads configuration data only after the app has started.
  • Does not support named options
IOptionsSnapshot<TOptions>:

May be recomputed on every request, and supports named options

IOptionsMonitor<TOptions>:

Is registered as a singleton, supports named options, change notifications, configuration reloads, and can be injected to any service lifetime.

Original Description (click to view)

AB#1244419 From exp review with @ajcvickers @DamianEdwards @Eilon @davidfowl

We should support some mechanism for eager (fail fast on startup) validation of options.

Needs to also work for generic host as well as webhost, must be configurable on a per options instance, since this will never work for request based options.

About this issue

  • Original URL
  • State: closed
  • Created 6 years ago
  • Reactions: 46
  • Comments: 27 (18 by maintainers)

Most upvoted comments

Thank you for all the great ideas in this thread. I wanted to retain the Validate and ValidateDataAnnotations provided by OptionsBuilder but enable eager validation, the patterns above led me to:

Usage:

services.AddOptions<LoggingSettings>()
     .ValidateDataAnnotations()
     .ValidateEagerly();

Classes:

public class StartupOptionsValidation<T> : IStartupFilter
    {
        public Action<IApplicationBuilder> Configure(Action<IApplicationBuilder> next)
        {
            return builder =>
            {
                var options = builder.ApplicationServices.GetService(typeof(IOptions<>).MakeGenericType(typeof(T)));
                if (options != null)
                {
                    // Retrieve the value to trigger validation
                    var optionsValue = ((IOptions<object>)options).Value;
                }

                next(builder);
            };
        }
    }
public static class OptionsBuilderValidationExtensions
    {
        public static OptionsBuilder<TOptions> ValidateEagerly<TOptions>(this OptionsBuilder<TOptions> optionsBuilder) where TOptions : class
        {
            optionsBuilder.Services.AddTransient<IStartupFilter, StartupOptionsValidation<TOptions>>();
            return optionsBuilder;
        }
    }

Is Eagerly the right term here? I think we’d prefer OnStartup Any opinions @davidfowl?

I prefer ValidateOnStartup, though people may think it has something to do with the Startup class. What about ValidateOnStart?

The empty error message of OptionsValidationException is utterly unhelpful - at least an error message containing the number of errors and the first error would be much more helpful. As the current implementation stands unless the exception is specifically caught and the containing errors manually logged there is no way to know what the error is except that it is somewhere in the configuration. Even having the first error message will be extremely helpful as even if there are more than one errors they can be addressed one by one.

A possible implementation:

/// <summary>
/// Thrown when options validation fails.
/// </summary>
public class OptionsValidationException : Exception
{
    string _message;

    /// <inheritdoc/>
    public override string Message => _message ?? (_message = CreateExceptionMessage());

    /// <summary>
    /// Constructor.
    /// </summary>
    /// <param name="optionsName">The name of the options instance that failed.</param>
    /// <param name="optionsType">The options type that failed.</param>
    /// <param name="failureMessages">The validation failure messages.</param>
    public OptionsValidationException(string optionsName, Type optionsType, IReadOnlyCollection<string> failureMessages)
    {
        Failures = failureMessages ?? Array.Empty<string>();
        OptionsType = optionsType ?? throw new ArgumentNullException(nameof(optionsType));
        OptionsName = optionsName ?? throw new ArgumentNullException(nameof(optionsName));
    }

    /// <summary>
    /// The name of the options instance that failed.
    /// </summary>
    public string OptionsName { get; }

    /// <summary>
    /// The type of the options that failed.
    /// </summary>
    public Type OptionsType { get; }

    /// <summary>
    /// The validation failures.
    /// </summary>
    public IReadOnlyCollection<string> Failures { get; }

    /// <summary>
    /// Returns an error message.
    /// </summary>
    /// <returns></returns>
    string CreateExceptionMessage()
    {
        if (Failures.Count > 0)
        {
            return $"{Failures.Count} validation error(s) occurred while validating options of type '{OptionsType.FullName}'. The first error is: {Failures.First()}";
        }
        else
        {
            return $"One or more validation errors occurred while validating options of type '{OptionsType.FullName}'.";
        }
    }
}

I wrote a post about my approach here: https://andrewlock.net/adding-validation-to-strongly-typed-configuration-objects-in-asp-net-core/, and I have NuGet package to provide the funcitonality: https://github.com/andrewlock/NetEscapades.Configuration/blob/master/src/NetEscapades.Configuration.Validation

Essentially, I implement a simple interface in my options objects (IValidatable), and register it with the DI container. You can perform the validation any way you like (DataAnnotations, Fluent validation etc).

Then I have a StartupFilter that just calls Validate on all the settings:

public Action<IApplicationBuilder> Configure(Action<IApplicationBuilder> next)
{
    foreach (var validatableObject in _validatableObjects)
    {
        validatableObject.Validate();
    }

    //don't alter the configuration
    return next;
}

Any exceptions will happen on app startup. There’s a few caveats to this approach:

  • IStartupFilter is HTTP/middleware specific AFAIK, not generic host
  • My implementation assumes singleton settings objects that don’t change. That’s the way I almost invariably use them, but would require a different approach for the IOptionsMonitor approach.
  • Named options would require each named option being registered separately I guess (I don’t use them)

Just be aware that using IStartupFilter won’t work for non-host apps, consider command line application, Azure Function or AWS Lambda. (I would expect something more handy than manually resolving options from service provider)

Another thing to consider: how should be “reloadOnChange” handled? Last thing I want is to throw at runtime. With current instrumentation of IOptionsMonitor<T>.OnChange I get last valid options (good), in other hand, I’m not getting notification about changed invalid options (not so good).

I have created a small class (based on the idea by @andrewlock of using an IStartupFilter) that “extends” the OptionsConfigurationServiceCollectionExtensions.Configure method by adding validation and also supports eager validation at startup.

Usage:

public void ConfigureServices(IServiceCollection services)
{
    services.ConfigureWithDataAnnotationsValidation<MyOptions>(
        Configuration.GetSection("MyOptionsPath"), 
        validateAtStartup: true
        );
}

Class:

/// <summary>
/// Extension methods for adding configuration related options services to the DI container.
/// </summary>
public static class OptionsConfigurationServiceCollectionExtensions
{
    /// <summary>
    /// Registers a configuration instance which <typeparamref name="TOptions"/> will bind against and validates the instance
    /// on by using <see cref="DataAnnotationValidateOptions{TOptions}"/>.
    /// </summary>
    /// <typeparam name="TOptions">The type of options being configured.</typeparam>
    /// <param name="services">The <see cref="IServiceCollection"/> to add the services to.</param>
    /// <param name="config">The configuration being bound.</param>
    /// <param name="validateAtStartup">Indicates if the options should be validated during application startup.</param>
    /// <returns>The <see cref="IServiceCollection"/> so that additional calls can be chained.</returns>
    public static IServiceCollection ConfigureWithDataAnnotationsValidation<TOptions>(this IServiceCollection services, IConfiguration config, bool validateAtStartup = false) where TOptions : class
    {
        services.Configure<TOptions>(config);
        services.AddSingleton<IValidateOptions<TOptions>>(new DataAnnotationValidateOptions<TOptions>(Microsoft.Extensions.Options.Options.DefaultName));

        if (validateAtStartup)
        {
            ValidateAtStartup(services, typeof(TOptions));
        }

        return services;
    }

    /// <summary>
    /// Registers a type of options to be validated during application startup.
    /// </summary>
    /// <param name="services">The <see cref="IServiceCollection"/> to add the services to.</param>
    /// <param name="type">The type of options to validate.</param>
    static void ValidateAtStartup(IServiceCollection services, Type type)
    {
        var existingService = services.Select(x => x.ImplementationInstance).OfType<StartupOptionsValidation>().FirstOrDefault();
        if (existingService == null)
        {
            existingService = new StartupOptionsValidation();
            services.AddSingleton<IStartupFilter>(existingService);
        }

        existingService.OptionsTypes.Add(type);
    }

    /// <summary>
    /// A startup filter that validates option instances during application startup.
    /// </summary>
    class StartupOptionsValidation : IStartupFilter
    {
        IList<Type> _optionsTypes;

        /// <summary>
        /// The type of options to validate.
        /// </summary>
        public IList<Type> OptionsTypes => _optionsTypes ?? (_optionsTypes = new List<Type>());

        /// <inheritdoc/>
        public Action<IApplicationBuilder> Configure(Action<IApplicationBuilder> next)
        {
            return builder =>
            {
                if (_optionsTypes != null)
                {
                    foreach (var optionsType in _optionsTypes)
                    {
                        var options = builder.ApplicationServices.GetService(typeof(IOptions<>).MakeGenericType(optionsType));
                        if (options != null)
                        {
                            // Retrieve the value to trigger validation
                            var optionsValue = ((IOptions<object>)options).Value;
                        }
                    }
                }

                next(builder);
            };
        }
    }
}

@maryamariyan can you please ensure User stories have a title in the form “XXX can YY” indicating what user experience changes. Take a look at other titles: https://themesof.net/?q=is:open kinds:ub team:Libraries Same for #44517.

Thanks @andrewlock I’m guessing we will end up with something similar just in a more generic host friendly way, that probably uses options itself to configure what option instances to validate at startup.