713 words
4 minutes
ASP.NET Core - make sure IOptions<T> is initialized

1. Introduction#

As most people know IOptions<T> is a convenient way of handling configuration options in your application. Even though I’ve been using it for quite some time, last week I was unpleasantly surprised by a production bug caused by wrong usage of this mechanism.

2. Problem#

Let’s say we have a simple controller which depends on two instances of IOptions<T>

[Route("api/[controller]")]
public class ValuesController : Controller
{
private readonly IOptions<PaymentOptions> _paymentOptions;
private readonly IOptions<NotificationOptions> _notificationOptions;
public ValuesController(IOptions<PaymentOptions> paymentOptions, IOptions<NotificationOptions> notificationOptions)
{
_paymentOptions = paymentOptions ?? throw new ArgumentNullException(nameof(paymentOptions));
_notificationOptions = notificationOptions ?? throw new ArgumentNullException(nameof(notificationOptions));
}
// GET api/values
[HttpGet]
public IEnumerable<string> Get()
{
var notificationOptions = _notificationOptions.Value;
var paymentOptions = _paymentOptions.Value;
return new string[] {"value1", "value2"};
}
}

Then we add only IOptions in Startup class - IOptions<PaymentOptoins>

public class Startup
{
// Rest of the code omitted for brevity
// This method gets called by the runtime. Use this method to add services to the container.
public void ConfigureServices(IServiceCollection services)
{
services.AddOptions();
services.AddMvc();
services.Configure<PaymentOptions>(Configuration.GetSection("PaymentOptions"));
}
// Rest of the code omitted for brevity
}

Now, when we run the app and hit one of the endpoints in ValuesController I was expecting to get an exception, as I didn’t register IOptions<NotificationOptions>. Surprisingly for me, the application was running normally and IOptions<NotificationOptions> was created with the new instance of NotificationOptions class(with all the properties defaulted).

In order to find out why it behaves like that we have to take a look how services responsible for handling IOptions<T> are registered in the container.

public static IServiceCollection AddOptions(this IServiceCollection services)
{
if (services == null)
throw new ArgumentNullException(nameof (services));
services.TryAdd(ServiceDescriptor.Singleton(typeof (IOptions<>), typeof (OptionsManager<>)));
services.TryAdd(ServiceDescriptor.Scoped(typeof (IOptionsSnapshot<>), typeof (OptionsManager<>)));
services.TryAdd(ServiceDescriptor.Singleton(typeof (IOptionsMonitor<>), typeof (OptionsMonitor<>)));
services.TryAdd(ServiceDescriptor.Transient(typeof (IOptionsFactory<>), typeof (OptionsFactory<>)));
services.TryAdd(ServiceDescriptor.Singleton(typeof (IOptionsMonitorCache<>), typeof (OptionsCache<>)));
return services;
}

As you can see AddOptions method registers open generics for (among others) IOptions<> which means that you don’t have to register specific types of IOptions<T>, however, you are responsible for its configuration with

services.Configure<T>(Configuration.GetSection("Section"));

I was always convinced that services.Configure registers and configure IOptions<T> but apparently, I was wrong.

3. Ensuring initialization#

As these kind of errors are rather difficult to catch(at least in case of our application) I wanted to minimize the risk of doing the same bug again. The idea was to make sure that at least one IConfigureOptions<T> or IPostConfigureOptions<T> service is registered in the container (as these are the services which are responsible for setting the values of T class). My first approach was to replace default OptionsFactory<T> with a custom one

public class ValidatableOptionsFactory<TOptions> : OptionsFactory<TOptions> where TOptions : class, new()
{
private static readonly string NamespacePrefix = typeof(Program).Namespace.Split('.').First();
public ValidatableOptionsFactory(IEnumerable<IConfigureOptions<TOptions>> setups, IEnumerable<IPostConfigureOptions<TOptions>> postConfigures)
: base(setups, postConfigures)
{
if (typeof(TOptions).Namespace.StartsWith(NamespacePrefix) && setups.Any() == false && postConfigures.Any() == false)
{
throw new InvalidOperationException($"No configuration options or post configuration options was found to configure {typeof(TOptions)}");
}
}
}
public class Startup
{
// This method gets called by the runtime. Use this method to add services to the container.
public void ConfigureServices(IServiceCollection services)
{
services.AddTransient(typeof(IOptionsFactory<>), typeof(ValidatableOptionsFactory<>));
services.AddOptions();
}
}

This method works fine (although probably needs smarter TOptions filter)

however I got to conclusion that there is no point to do validation in a runtime, and the integration test might be a better choice. I came up with following code (I am assuming that all configuration options are stored in the same assembly, as this is convention in my project)

public class OptionsTests
{
private readonly Lazy<IWebHost> _webHost = new Lazy<IWebHost>(Program.BuildWebHost(null));
// this test will fail as not every option in assembly is configured
[Fact]
public void AllConfigurationOptions_HasConfigurationServicesDefined()
{
var optionsType = typeof(IOptions<>);
var constructorParameterTypes = typeof(Program).Assembly.GetExportedTypes()
.SelectMany(type => type.GetConstructors().SelectMany(ctor => ctor.GetParameters()))
.Select(parameter => parameter.ParameterType);
var optionValueTypes = constructorParameterTypes.Where(parameterType => IsAssignableToGenericType(parameterType, optionsType))
.Select(options => options.GetGenericArguments().Single())
.Distinct();
var postConfigureOptionsType = typeof(IPostConfigureOptions<>);
var configureOptionsType = typeof(IConfigureOptions<>);
var postConfiguration = optionValueTypes.Select(type => new
{
optionsType = type,
configureOptionsType = _webHost.Value.Services.GetServices(configureOptionsType.MakeGenericType(type)),
postConfigureServices = _webHost.Value.Services.GetServices(postConfigureOptionsType.MakeGenericType(type))
});
var emptyConfigurations =
postConfiguration.Where(item => item.configureOptionsType.Any() == false && item.postConfigureServices.Any() == false)
.Select(item => item.optionsType);
emptyConfigurations.Should().BeEmpty("All options should be configured");
}
// https://stackoverflow.com/questions/74616/how-to-detect-if-type-is-another-generic-type/1075059#1075059
public static bool IsAssignableToGenericType(Type givenType, Type genericType)
{
var interfaceTypes = givenType.GetInterfaces();
if (interfaceTypes.Any(it => it.IsGenericType && it.GetGenericTypeDefinition() == genericType))
{
return true;
}
if (givenType.IsGenericType && givenType.GetGenericTypeDefinition() == genericType)
return true;
var baseType = givenType.BaseType;
if (baseType == null) return false;
return IsAssignableToGenericType(baseType, genericType);
}
}

Running the test shows that NotificationOptions was not configured

Source code for this post can be found here