Logo Questions Linux Laravel Mysql Ubuntu Git Menu
 

Validation of ASP.NET Core options during startup

Core2 has a hook for validating options read from appsettings.json:

services.PostConfigure<MyConfig>(options => {
  // do some validation
  // maybe throw exception if appsettings.json has invalid data
});

This validation code triggers on first use of MyConfig, and every time after that. So I get multiple runtime errors.

However it is more sensible to run validation during startup - if config validation fails I want the app to fail immediately. The docs imply that is how it works, but that is not what happens.

So am I doing it right? If so and this is by design, then how can I change what I'm doing so it works the way I want?

(Also, what is the difference between PostConfigure and PostConfigureAll? There is no difference in this case, so when should I use either one?)

like image 733
lonix Avatar asked Aug 05 '18 08:08

lonix


People also ask

What is ModelState in ASP.NET Core?

Model state represents errors that come from two subsystems: model binding and model validation. Errors that originate from model binding are generally data conversion errors. For example, an "x" is entered in an integer field.


2 Answers

There is no real way to run a configuration validation during startup. As you already noticed, post configure actions run, just like normal configure actions, lazily when the options object is being requested. This completely by design, and allows for many important features, for example reloading configuration during run-time or also options cache invalidation.

What the post configuration action is usually being used for is not a validation in terms of “if there’s something wrong, then throw an exception”, but rather “if there’s something wrong, fall back to sane defaults and make it work”.

For example, there’s a post configuration step in the authentication stack, that makes sure that there’s always a SignInScheme set for remote authentication handlers:

options.SignInScheme = options.SignInScheme ?? _authOptions.DefaultSignInScheme ?? _authOptions.DefaultScheme;

As you can see, this will not fail but rather just provides multiple fallbacks.

In this sense, it’s also important to remember that options and configuration are actually two separate things. It’s just that the configuration is a commonly used source for configuring options. So one might argue that it is not actually the job of the options to validate that the configuration is correct.

As such it might make more sense to actually check the configuration in the Startup, before configuring the options. Something like this:

var myOptionsConfiguration = Configuration.GetSection("MyOptions");

if (string.IsNullOrEmpty(myOptionsConfiguration["Url"]))
    throw new Exception("MyOptions:Url is a required configuration");

services.Configure<MyOptions>(myOptionsConfiguration);

Of course this easily becomes very excessive, and will likely force you to bind/parse many properties manually. It will also ignore the configuration chaining that the options pattern supports (i.e. configuring a single options object with multiple sources/actions).

So what you could do here is keep your post configuration action for validation, and simply trigger the validation during startup by actually requesting the options object. For example, you could simply add IOptions<MyOptions> as a dependency to the Startup.Configure method:

public void Configure(IApplicationBuilder app, IOptions<MyOptions> myOptions)
{
    // all configuration and post configuration actions automatically run

    // …
}

If you have multiple of these options, you could even move this into a separate type:

public class OptionsValidator
{
    public OptionsValidator(IOptions<MyOptions> myOptions, IOptions<OtherOptions> otherOptions)
    { }
}

At that time, you could also move the logic from the post configuration action into that OptionsValidator. So you could trigger the validation explicitly as part of the application startup:

public void Configure(IApplicationBuilder app, OptionsValidator optionsValidator)
{
    optionsValidator.Validate();

    // …
}

As you can see, there’s no single answer for this. You should think about your requirements and see what makes the most sense for your case. And of course, this whole validation only makes sense for certain configurations. In particular, you will have difficulties when working configurations that will change during run-time (you could make this work with a custom options monitor, but it’s probably not worth the hassle). But as most own applications usually just use cached IOptions<T>, you likely don’t need that.


As for PostConfigure and PostConfigureAll, they both register an IPostConfigure<TOptions>. The difference is simply that the former will only match a single named option (by default the unnamed option—if you don’t care about option names), while PostConfigureAll will run for all names.

Named options are for example used for the authentication stack, where each authentication method is identified by its scheme name. So you could for example add multiple OAuth handlers and use PostConfigure("oauth-a", …) to configure one and PostConfigure("oauth-b", …) to configure the other, or use PostConfigureAll(…) to configure them both.

like image 155
poke Avatar answered Sep 19 '22 08:09

poke


On an ASP.NET Core 2.2 project I got this working doing eager validation by following these steps...

Given an Options class like this one:

public class CredCycleOptions
{
    [Range(1753, int.MaxValue, ErrorMessage = "Please enter a valid integer Number.")]
    public int VerifiedMinYear { get; set; }
    [Range(1753, int.MaxValue, ErrorMessage = "Please enter a valid integer Number.")]
    public int SignedMinYear { get; set; }
    [Range(1753, int.MaxValue, ErrorMessage = "Please enter a valid integer Number.")]
    public int SentMinYear { get; set; }
    [Range(1753, int.MaxValue, ErrorMessage = "Please enter a valid integer Number.")]
    public int ConfirmedMinYear { get; set; }
}

In Startup.cs add these lines to ConfigureServices method:

services.AddOptions();

// This will validate Eagerly...
services.ConfigureAndValidate<CredCycleOptions>("CredCycle", Configuration);

ConfigureAndValidate is an extension method from here.

public static class OptionsExtensions
{
    private static void ValidateByDataAnnotation(object instance, string sectionName)
    {
        var validationResults = new List<ValidationResult>();
        var context = new ValidationContext(instance);
        var valid = Validator.TryValidateObject(instance, context, validationResults);

        if (valid)
            return;

        var msg = string.Join("\n", validationResults.Select(r => r.ErrorMessage));

        throw new Exception($"Invalid configuration for section '{sectionName}':\n{msg}");
    }

    public static OptionsBuilder<TOptions> ValidateByDataAnnotation<TOptions>(
        this OptionsBuilder<TOptions> builder,
        string sectionName)
        where TOptions : class
    {
        return builder.PostConfigure(x => ValidateByDataAnnotation(x, sectionName));
    }

    public static IServiceCollection ConfigureAndValidate<TOptions>(
        this IServiceCollection services,
        string sectionName,
        IConfiguration configuration)
        where TOptions : class
    {
        var section = configuration.GetSection(sectionName);

        services
            .AddOptions<TOptions>()
            .Bind(section)
            .ValidateByDataAnnotation(sectionName)
            .ValidateEagerly();

        return services;
    }

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

        return optionsBuilder;
    }
}

I plumbed ValidateEargerly extension method right inside ConfigureAndValidate. It makes use of this other class from here:

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);
        };
    }
}

This allows us to add data annotations to the CredCycleOptions and get nice error feedback right at the moment the app starts making it an ideal solution.

enter image description here

If an option is missing or have a wrong value, we don't want users to catch these errors at runtime. That would be a bad experience.

like image 44
Leniel Maccaferri Avatar answered Sep 20 '22 08:09

Leniel Maccaferri