dotnet / extensions

This repository contains a suite of libraries that provide facilities commonly needed when creating production-ready applications.
MIT License
2.59k stars 740 forks source link

[API Proposal]: Introduce contextual options #5049

Closed geeknoid closed 5 months ago

geeknoid commented 6 months ago

Background and motivation

This proposal (and matching implementation) aims to extend the existing .NET options model to fully support experimentation. The feature makes it possible to query the option system while supply some contextual information which tailors the returned configuration state. For example, an app can query to get the options for a particular region, or a particular cluster, or whatever the app wants.

You can find the code for this design in https://github.com/dotnet/extensions/tree/main/src/Libraries/Microsoft.Extensions.Options.Contextual.

API Proposal

namespace Microsoft.Extensions.Options.Contextual;

/// <summary>
/// Used to retrieve configured <typeparamref name="TOptions"/> instances.
/// </summary>
/// <typeparam name="TOptions">The type of options being requested.</typeparam>
/// <typeparam name="TContext">A type defining the context for this request.</typeparam>
public interface IContextualOptions<TOptions, TContext>
    where TOptions : class
    where TContext : IOptionsContext
{
    /// <summary>
    /// Gets the configured <typeparamref name="TOptions"/> instance.
    /// </summary>
    /// <param name="context">The context that will be used to create the options.</param>
    /// <param name="cancellationToken">The token to monitor for cancellation requests.</param>
    /// <returns>A configured instance of <typeparamref name="TOptions"/>.</returns>
    ValueTask<TOptions> GetAsync(in TContext context, CancellationToken cancellationToken);
}

/// <summary>
/// Used to retrieve named configured <typeparamref name="TOptions"/> instances.
/// </summary>
/// <typeparam name="TOptions">The type of options being requested.</typeparam>
/// <typeparam name="TContext">A type defining the context for this request.</typeparam>
public interface INamedContextualOptions<TOptions, TContext> : IContextualOptions<TOptions, TContext>
    where TOptions : class
    where TContext : IOptionsContext
{
    /// <summary>
    /// Gets the named configured <typeparamref name="TOptions"/> instance.
    /// </summary>
    /// <param name="name">The name of the options to get.</param>
    /// <param name="context">The context that will be used to create the options.</param>
    /// <param name="cancellationToken">The token to monitor for cancellation requests.</param>
    /// <returns>A configured instance of <typeparamref name="TOptions"/>.</returns>
    ValueTask<TOptions> GetAsync(string name, in TContext context, CancellationToken cancellationToken);
}

/// <summary>
/// The context used to configure contextual options.
/// </summary>
public interface IOptionsContext
{
    /// <summary>
    /// Passes context data to a contextual options provider.
    /// </summary>
    /// <typeparam name="T">The type that the contextual options provider uses to collect context.</typeparam>
    /// <param name="receiver">The object that the contextual options provider uses to collect the context.</param>
    void PopulateReceiver<T>(T receiver)
        where T : IOptionsContextReceiver;
}

/// <summary>
/// Generates an implementation of <see cref="IOptionsContext"/> for the annotated type.
/// </summary>
[AttributeUsage(AttributeTargets.Class | AttributeTargets.Struct, Inherited = false, AllowMultiple = false)]
public sealed class OptionsContextAttribute : Attribute
{
}

The provider API is for a component that couples the contextual option machinery with an actual infrastructure that delivers contextual option state.

namespace Microsoft.Extensions.Options.Contextual.Provider;

/// <summary>
/// Represents something that configures the <typeparamref name="TOptions"/> type.
/// </summary>
/// <typeparam name="TOptions">The type of options configured.</typeparam>
public interface IConfigureContextualOptions<in TOptions> : IDisposable
    where TOptions : class
{
    /// <summary>
    /// Invoked to configure a <typeparamref name="TOptions"/> instance.
    /// </summary>
    /// <param name="options">The options instance to configure.</param>
    void Configure(TOptions options);
}

/// <summary>
/// Used to retrieve named configuration data from a contextual options provider implementation.
/// </summary>
/// <typeparam name="TOptions">The type of options configured.</typeparam>
public interface ILoadContextualOptions<TOptions>
    where TOptions : class
{
    /// <summary>
    /// Gets the data to configure an instance of <typeparamref name="TOptions"/>.
    /// </summary>
    /// <typeparam name="TContext">A type defining the context for this request.</typeparam>
    /// <param name="name">The name of the options to configure.</param>
    /// <param name="context">The context that will be used to configure the options.</param>
    /// <param name="cancellationToken">The token to monitor for cancellation requests.</param>
    /// <returns>An object to configure an instance of <typeparamref name="TOptions"/>.</returns>
    ValueTask<IConfigureContextualOptions<TOptions>> LoadAsync<TContext>(string name, in TContext context, CancellationToken cancellationToken)
                where TContext : IOptionsContext;
}

/// <summary>
/// Used by contextual options providers to collect context data.
/// </summary>
public interface IOptionsContextReceiver
{
    /// <summary>
    /// Add a key-value pair to the context.
    /// </summary>
    /// <typeparam name="T">The type of the data.</typeparam>
    /// <param name="key">The name of the data.</param>
    /// <param name="value">The data used to determine how to populate contextual options.</param>
    void Receive<T>(string key, T value);
}

/// <summary>
/// Helper class.
/// </summary>
public static class NullConfigureContextualOptions
{
    /// <summary>
    /// Gets a singleton instance of an empty configuration context.
    /// </summary>
    /// <typeparam name="TOptions">The options type to configure.</typeparam>
    /// <returns>A do-nothing instance of <see cref="IConfigureContextualOptions{TOptions}"/>.</returns>
    public static IConfigureContextualOptions<TOptions> GetInstance<TOptions>()
        where TOptions : class;
}

API Usage

Start with an option type.

internal class WeatherForecastOptions
{
    public string TemperatureScale { get; set; } = "Celsius"; // Celsius or Fahrenheit
    public int ForecastDays { get; set; }
}

Define a context and a receiver that will be used as inputs to dynamically configure the options.

[OptionsContext]
internal partial class WeatherForecastContext // Note class must be partial
{
    public Guid UserId { get; set; }
    public string? Country { get; set; }
}

internal class CountryContextReceiver : IOptionsContextReceiver
{
    public string? Country { get; private set; }

    public void Receive<T>(string key, T value)
    {
        if (key == nameof(Country))
        {
            Country = value?.ToString();
        }
    }
}

Create a service that consumes the options for a given context.

internal class WeatherForecast
{
    public DateTime Date { get; set; }
    public int Temperature { get; set; }
    public string TemperatureScale { get; set; } = string.Empty;
}

internal class WeatherForecastService
{
    private readonly IContextualOptions<WeatherForecastOptions, WeatherForecastContext> _contextualOptions;
    private readonly Random _rng = new(0);

    public WeatherForecastService(IContextualOptions<WeatherForecastOptions, WeatherForecastContext> contextualOptions)
    {
        _contextualOptions = contextualOptions;
    }

    public async Task<IEnumerable<WeatherForecast>> GetForecast(WeatherForecastContext context, CancellationToken cancellationToken)
    {
        WeatherForecastOptions options = await _contextualOptions.GetAsync(context, cancellationToken).ConfigureAwait(false);
        return Enumerable.Range(1, options.ForecastDays).Select(index => new WeatherForecast
        {
            Date = new DateTime(2000, 1, 1).AddDays(index),
            Temperature = _rng.Next(-20, 55),
            TemperatureScale = options.TemperatureScale,
        });
    }
}

The options can be configured with both global options (ForecastDays), and options that vary depending on the current context (TemperatureScale).

using var host = FakeHost.CreateBuilder()
    .ConfigureServices(services => services
        .Configure<WeatherForecastOptions>(options => options.ForecastDays = 7)
        .Configure<WeatherForecastOptions>(ConfigureTemperatureScaleBasedOnCountry)
        .AddSingleton<WeatherForecastService>())
        .Build();

static void ConfigureTemperatureScaleBasedOnCountry(IOptionsContext context, WeatherForecastOptions options)
{
    CountryContextReceiver receiver = new();
    context.PopulateReceiver(receiver);
    if (receiver.Country == "US")
    {
        options.TemperatureScale = "Fahrenheit";
    }
}

And lastly, the service is called with some context.

var forecastService = host.Services.GetRequiredService<WeatherForecastService>();

var usForcast = await forecastService.GetForecast(new WeatherForecastContext { Country = "US" }, CancellationToken.None);
var caForcast = await forecastService.GetForecast(new WeatherForecastContext { Country = "CA" }, CancellationToken.None);

Alternative Designs

No response

Risks

No response

geeknoid commented 6 months ago

@halter73 Can we please schedule this for review ASAP, we've got stuff blocked behind this.

Thanks.

mitchdenny commented 6 months ago

Some thought should be put into how this might interact with underlying configuration stores that extend the configuration system. For example Azure AppConfig has the concept of tags which can be used to produce a different set of configuration values.

This could work nicely with that.

halter73 commented 6 months ago

API Review Notes:

The lack of a non-internal implementation is a sticking point. We don't want to approve an abstraction that has a single implementation because it's unclear if it will work for any other implementations.

RussKie commented 5 months ago

closed this as completed

@geeknoid could you please help me understand the "completed" part? Judging by the API review comments there was feedback to work through before the API could be implemented. If we are deciding to not currently pursue this API then "not planned" should be more appropriate resolution. What am I missing?