How to make an IOptions section optional in .NET Core?

Viewed 1564

Consider an example service that optionally supports LDAP authentication, otherwise, it does something like local Identity authentication. When LDAP is completely configured, appsettings.json might look like this...

{
  "LdapOptions": {
    "Host": "ldap.example.com",
    "Port": 389
  }
}

With an options class.

public class LdapOptions
{
    public string Host { get; set; }
    public int Port { get; set; } = 389;
}

And Startup has the expected Configure call.

service.Configure<LdapOptions>(nameof(LdapOptions));

This work great when I have a complete valid "LdapOptions" section. But, it's not so great if I intentionally leave the section out of my appsettings.

An IOptions<TOptions> instance resolves even if I leave the section out of my appsettings entirely; it even resolves if I remove the Startup configure call entirely! I get an object that appears, based on property values, to be default(TOptions).

public AuthenticationService(IOptions<LdapOptions> ldapOptions)
{
    this.ldapOptions = ldapOptions.Value; // never null, sometimes default(LdapOptions)!
}

I don't want to depend on checking properties if a section is intentionally left out. I can imagine scenarios where all of the properties in an object have explicit defaults and this wouldn't work. I'd like something like a Maybe<TOptions> with a HasValue property, but I'll take a null.

Is there any way to make an options section optional?


Update: Be aware that I also intend to validate data annotations...

services.AddOptions<LdapOptions>()
    .Configure(conf.GetSection(nameof(LdapOptions)))
    .ValidateDataAnnotations();

So, what I really want is for optional options to be valid when the section is missing (conf.Exists() == false) and then normal validations to kick in when the section is partially or completely filled out.

I can't imagine any solution working with data annotation validations that depends on the behavior of creating a default instance (for example, there is no correct default for Host, so a default instance will always be invalid).

2 Answers

The whole idea of IOptions<T> is to have non-null default values, so that your settings file doesn't contain hundreds/thousands sections to configure the entire ASP pipeline

So, its not possible to make it optional in the sense that you will get null, but you can always defined some "magic" property to indicate whether this was configured or not:

public class LdapOptions
{
    public bool IsEnabled { get; set; } = false;
    public string Host { get; set; }
    public int Port { get; set; } = 389;
}

and your app settings file:

{
  "LdapOptions": {
    "IsEnabled: true,
    "Host": "ldap.example.com",
    "Port": 389
  }
}

Now, if you keep 'IsEnabled' consistently 'true' in your settings, if IsEnabled is false, that means the section is missing.

An alternative solution is to use a different design approach, e.g. put the auth type in the settings file:

public class LdapOptions
{
    public string AuthType { get; set; } = "Local";
    public string Host { get; set; }
    public int Port { get; set; } = 389;
}

And your app settings:

{
  "LdapOptions": {
    "AuthType : "LDAP",
    "Host": "ldap.example.com",
    "Port": 389
  }
}

This is IMO a cleaner & more consistent approach

If you must have a logic that is based on available/missing section, you can also configure it directly:

var section = conf.GetSection(nameof(LdapOptions));
var optionsBuilder = services.AddOptions<LdapOptions>();

if section.Value != null {
    optionsBuilder.Configure(section).ValidateDataAnnotations();
}
else {
    optionsBuilder.Configure(options => {
       // Set defaults here
       options.Host = "Deafult Host";
    }
}

I wanted to avoid lambdas in Startup that would need to be copy/pasted correctly for every "optional" section and I wanted to be very explicit about optionality (at the expense of some awkward naming).

Startup.cs

public void ConfigureServices(IServiceCollection services)
{
    services.AddOption<Optional<LdapOptions>>()
        .ConfigureOptional(conf.GetSection(nameof(LdapOptions)))
        .ValidateOptionalDataAnnotations();
}

The Optional type is pretty straightforward, but may need a better name (to avoid interfering with other implementations of the generic Option/Some/Maybe pattern). I thought about just using null, but that seemed contrary to Options insistence on returning something no matter what.

Optional.cs

    public class Optional<TOptions> where TOptions : class
    {
        public TOptions Value { get; set; }
        public bool HasValue { get => !(Value is null); }
    }

The configure extension method takes into account section existence.

OptionalExtensions.cs

    public static class OptionalExtensions
    {
        public static OptionsBuilder<Optional<TOptions>> ConfigureOptional<TOptions>(this OptionsBuilder<Optional<TOptions>> optionsBuilder, IConfigurationSection config) where TOptions : class
        {
            return optionsBuilder.Configure(options =>
            {
                if (config.Exists())
                {
                    options.Value = config.Get<TOptions>();
                }
            });
        }

        public static OptionsBuilder<Optional<TOptions>> ValidateOptionalDataAnnotations<TOptions>(this OptionsBuilder<Optional<TOptions>> optionsBuilder) where TOptions : class
        {
            optionsBuilder.Services.AddSingleton<IValidateOptions<Optional<TOptions>>>(new DataAnnotationValidateOptional<TOptions>(optionsBuilder.Name));
            return optionsBuilder;
        }
    }

The validate extension method works with a custom options validator that also takes into account how missing sections work (like the comment says, "missing optional options are always valid").

DataAnnotationValidateOptional.cs

    public class DataAnnotationValidateOptional<TOptions> : IValidateOptions<Optional<TOptions>> where TOptions : class
    {
        private readonly DataAnnotationValidateOptions<TOptions> innerValidator;

        public DataAnnotationValidateOptional(string name)
        {
            this.innerValidator = new DataAnnotationValidateOptions<TOptions>(name);
        }

        public ValidateOptionsResult Validate(string name, Optional<TOptions> options)
        {
            if (options.Value is null)
            {
                // Missing optional options are always valid.
                return ValidateOptionsResult.Success;
            }

            return this.innerValidator.Validate(name, options.Value);
        }
    }

Now, anywhere you need to use an optional option, like, say, a login controller, you can take the following actions...

LdapLoginController.cs

[ApiController]
[Route("/api/login/ldap")]
public class LdapLoginController : ControllerBase
{
    private readonly Optional<LdapOptions> ldapOptions;

    public LdapLoginController(IOptionsSnapshot<Optional<LdapOptions>> ldapOptions)
    {
        // data annotations should trigger here and possibly throw an OptionsValidationException
        this.ldapOptions = ldapOptions.Value;
    }

    [HttpPost]
    public void Post(...)
    {
        if (!ldapOptions.Value.HasValue)
        {
            // a missing section is valid, but indicates that this option was not configured; I figure that relates to a 501 Not Implemented
            return StatusCode((int)HttpStatusCode.NotImplemented);
        }

        // else we can proceed with valid options
    }
}
Related