Quantcast
Channel: Andrew Lock | .NET Escapades
Viewing all articles
Browse latest Browse all 743

Delaying strongly-typed options configuration using PostConfigure in ASP.NET Core

$
0
0
Delaying strongly-typed options configuration using PostConfigure in ASP.NET Core

In this post I describe a scenario in which a library author wants to use the Options pattern to configure their library, and enforce some default values / constraints. I describe the difficulties with trying to achieve this using the standard Configure<T>() method for strongly-typed options, and introduce the concept of "post" configuration actions such as PostConfigure<T>() and PostConfigureAll<T>().

tl;dr; If you need to ensure a configuration action for a strongly-typed settings instance runs after all other configuration actions, you can use the PostConfigure<T>() method. Actions registered using this method are executed in the order they are registered, but after all other Configure<T>() actions have been applied.

Using the Options pattern as a library author

The Options pattern is the standard way to add strongly-typed settings to ASP.NET Core applications, by binding POCO objects to a configuration object consisting of key-value pairs. If you're building a library designed for ASP.NET Core, using the Options pattern to configure your library is a standard approach to take.

ASP.NET Core strongly-typed settings are configured for your application in Startup.ConfigureServices(), typically by calling services.Configure<MySettings>() to configure a strongly-typed settings object MySettings. You can use multiple configuration "steps" to configure a single MySettings instance, where each step corresponds to a Configure<MySettings>() invocation. The order that the Configure<MySettings>() calls are made controls the order in which configuration is "applied" to MySettings, and hence its final properties.

public void ConfigureServices(IServiceCollection services)
{
    services.Configure<MySettings>(Configuration.GetSection("MySettings")); 
    services.Configure<MySettings>(opts => opts.SomeValue = "Overriden"); // Overrides SomeValue property (which may have been set in the previous Configure call)
} 

As a library author, this can be both useful and a challenge. On the positive side, if you use the Options pattern then users of your library will have a familiar and extensible mechanism for configuring your library. On the other hand, you lose some control over when and how your library is configured.

An example library using options

Lets explore this a little. Imagine you have a service that sends WhatsApp messages to users. You have a hosted API service that users can register with, which you've also open sourced. Users can send requests to the hosted API service to send a message to a user via WhatsApp. Alternatively, as it's open source, users could also host their own instance of the API to send messages, instead of using your hosted service.

You've also created a simple .NET Standard library that users can use to call the API. At the heart of the library is the IWhatsAppService:

public interface IWhatsAppService
{
    Task<bool> SendMessage(string fromNumber, string toNumber, string message)
}

which is implemented as WhatsAppService (not shown). There are a number of configuration settings required, for which you've created a strongly-typed settings object, and provided default values:

public class WhatsAppSettings
{
    public string ApiUrl { get; set; } = Constants.DefaultUrl;
    public string ApiKey { get; set; }
    public string Region { get; set; } = Constants.DefaultRegion;
}

public class Constants
{
    public const string DefaultHostedUrl = "https://example.com/api/whatsapp";
    public const string DefaultHostedRegion = "eu-west1";
}

The details of this aren't really important, what's more important is that there is a specific rule you need to enforce: when the ApiUrl is set to Constants.DefaultHostedUrl, the Region must be set to DefaultHostedRegion.

By default, the WhatsAppSettings instance will have the correct values for the hosted service, but the user is free to update the values to point to another API if they wish. What we don't want to happen (and which we'll come to shortly) is for the user to change the Region, while still using the default ApiUrl.

To help the user add your library to their application, you've created a couple of extension methods they can call in ConfigureServices():

public static class WhatsAppServiceCollectionExtensions
{
    public static IServiceCollection AddWhatsApp(this IServiceCollection services)
    {
        // Add ASP.NET Core Options libraries - needed so we can use IOptions<WhatsAppSettings>
        services.AddOptions();

        // Add our required services
        services.AddSingleton<IWhatsAppService, WhatsAppService>();
        return services;
    }
}

This extension method registers all the required services with the DI container. To add the library to an ASP.NET Core application in Startup.ConfigureServices(), and to keep the defaults, you would use

public void ConfigureServices(IServiceCollection services)
{
    services.AddWhatsApp();
}

So that's our library. The question is, how do we ensure that if the user uses the default hosted URL DefaultHostedUrl, the region is always set to DefaultHostedRegion.

Enforcing constraints on strongly-typed settings

I'm going to leave aside the whole question of whether strongly-typed options are the right place to enforce these sorts of constraints, as well as the fact API URLs definitely shouldn't be hard coded as constants! This whole scenario is just for me to introduce a feature, so just go with it! 😁

As the library author, we know that we need to add a configuration action for WhatsAppSettings to enforce the constraint on the hosted region. As an initial attempt we simply add the configuration action to the AddWhatsApp extension method:

public static class WhatsAppServiceCollectionExtensions
{
    public static IServiceCollection AddWhatsApp(this IServiceCollection services)
    {
        services.AddOptions();
        services.AddSingleton<IWhatsAppService, WhatsAppService>();

        // Add configuration action for WhatsAppSettings
        services.Configure<WhatsAppSettings>(options => 
        {
            if(options.ApiUrl == Constants.DefaultUrl)
            {
                // if we're using the hosted service URL, use the correct region
                options.Region = Constants.DefaultHostedRegion;
            }
        });
        return services;
    }
}

Unfortunately, this approach isn't very robust. As I've discussed in several previous posts, configuration actions are applied to a strongly-typed settings instance in the same order that they are added to the DI container. That means if the user calls Configure<WhatsAppSettings>() after they call AddWhatsApp(), they will overwrite any changes enforced by the extension method:

public void ConfigureServices(IServiceCollection services)
{
    // Add the necessary settings and also enforce the hosted service region constraint
    services.AddWhatsApp(); 

    // Add another configuration action, overwriting previous configuration
    services.Configure<WhatsAppSettings>(options => 
    {
        options.ApiUrl = Constants.DefaultUrl;
        options.ApiKey = "MY-KEY-123456"
        options.Region = "us-east3"; // "Oh noes, wrong one!"
    });
}

This highlights one of the fundamental difficulties of working with a DI container where the order things are added to the container matters, and you (the library author) are fundamentally not in control of that process. Luckily there's a solution to this by way of the PostConfigure<T>() family of extension methods.

Configuring strongly-typed options last with PostConfigure()

PostConfigure<T>() is an extension method that works very similarly to the Configure<T>() method, with one exception - PostConfigure<T>() configuration actions run after all Configure<T>() actions have executed. So when configuring a strongly typed settings object, the Options framework will run all "standard" configuration actions (in the order they were added to the DI container), followed by all "post" configuration actions (in the order they were added to the DI container).

This provides a nice, simple solution to our scenario. We can update the AddWhatsApp() extension method to use PostConfigure<T>(), and then we can be sure that it will run after any standard configuration actions:

public static class WhatsAppServiceCollectionExtensions
{
    public static IServiceCollection AddWhatsApp(this IServiceCollection services)
    {
        services.AddOptions();
        services.AddSingleton<IWhatsAppService, WhatsAppService>();

        // Use PostConfigure to ensure it runs after normal configuration
        services.PostConfigure<WhatsAppSettings>(options => 
        {
            if(options.ApiUrl == Constants.DefaultUrl)
            {
                options.Region = Constants.DefaultHostedRegion;
            }
        });
        return services;
    }
}

Now users can place their call to Configure<WhatsAppSettings>() anywhere in ConfigureServices() and it's fine. That's a lot easier than relying on users to read your documentation that says "You must call Configure<WhatsAppSettings> before calling AddWhatsApp(). It's more usable for the user, and hopefully fewer issues raised on GitHub for you!

One of the first thoughts I had when discovering this method was "what if the end user uses PostConfigure<T>() too?" Well, in that situation, there's not a lot you can do about it. But also, that's fine - the idea here was to try and enforce certain constraints in normal circumstances. Fundamentally, if an application author wants to misuse your library they'll always find a way…

Post configuration options for named options

The PostConfigure<T>() extension method doesn't just support the "default" options instance, it also works with named instances (which I've discussed in previous posts). If you're familiar with named options and they're use then the PostConfigure methods won't hold any surprises - there's a "post" configuration version for most of the "standard" configuration methods and interfaces:

  • PostConfigure<T>(options) - Configure the default options instance T
  • PostConfigure<T>(name, options) - Configure the named options instance T with name name
  • PostConfigureAll<T>(options) - Configure all the options instances T (both the default and named instances)
  • IPostConfigureOptions<T> - This is the "post" version of the IConfigureNamedOptions<T> instance, which allows you to use injected services when configuring your options. There is no "post" version of the IConfigureOptions<T> interface.

Only the last point is worth watching out for, so I'll reiterate: there is no "post" version of the IConfigureOptions<T> interface. The IPostConfigureOptions<T> interface (shown below) uses named instances:

public interface IPostConfigureOptions<in TOptions> where TOptions : class
{
    void PostConfigure(string name, TOptions options);
}

This means if you aren't using named options, and you just want to implement an interface that's equivalent to the IConfigureOptions<T> interface, you should look for the special Options.DefaultName value, which is string.Empty. So for example, imagine you have the following implementation of IConfigureOptions<T> which configures the default options instance:

public class ConfigureMySettingsOptions : IConfigureOptions<MySettings>
{
    private readonly CalculatorService _calculator;
    public ConfigureMySettingsOptions(CalculatorService calculator)
    {
        _calculator = calculator;
    }

    public void Configure(MySettings options)
    {
        options.MyValue = _someService.DoComplexCalcaulation();
    }
}

If you want to create a "post" configuration version that only configures the default options, you should use:

public class ConfigureMySettingsOptions : IPostConfigureOptions<MySettings>
{
    private readonly CalculatorService _calculator;
    public ConfigureMySettingsOptions(CalculatorService calculator)
    {
        _calculator = calculator;
    }

    public void PostConfigure(string name, MySettings options)
    {
        // Only run when name == string.Empty
        if(name == Options.Options.DefaultName)
        {
            options.MyValue = _someService.DoComplexCalcaulation();
        }
    }
}

You can add this post configuration class to your DI container using:

public void ConfigureServices(IServiceCollection services)
{
    services.AddSingleton<IPostConfigureOptions<MySettings>, ConfigureMySettingsOptions>();
}

Summary

In this post I showed how you could use PostConfigure<T>() to configure strongly-typed options after all the standard Configure<T>() actions have run. This is useful as a library author, as it allows you to do things like configure default values only if the user hasn't configured options, or to enforce constraints. As an application user you generally shouldn't need to use PostConfigure<T>() as you can already control the order in which configuration occurs, based on the order you call methods in ConfigureServices().


Viewing all articles
Browse latest Browse all 743

Trending Articles