Injecting Action Method Values from Configuration in ASP.NET Core

Introduction

ASP.NET Core offers some mechanisms by which it can automatically "inject", or set, values to action method parameters. Normally these come from the query string, the form, the payload, headers, or the Dependency Injection (DI), but the mechanism is extensible, and here we will see a way to inject values from the configuration.

We will build:

  • A binding source
  • A model binder provider
  • A model binder
  • An extension method to ease the registration of the model binder provider
  • An "injection" attribute to trigger the injection

Model binding in ASP.NET Core is a complex topic and we won't cover it in depth, just what is needed to get this done with a custom model binder. What I'm going to present could probably be done with either a custom value provider or a custom model binder (as in this post), as they are related topics: the former is used to get values from sources and the latter to get them into .NET classes. I will go with model binding for now, and will probably write a similar post in the future using a value provider.

Throughout this post, I will be calling "injecting" and "binding" interchangeably to the same thing: populating some model class from the configuration.

So, without further ado, let's get down to business!

Binding Source

The first thing that we'll build is a binding source. A binding source is a class that inherits from BindingSource and contains some metadata to describe a source of data. It doesn't really perform any useful function.

public sealed class ConfigurationBindingSource : BindingSource 
{
    public const string Name = "Configuration";
    public static readonly BindingSource Configuration = new ConfigurationBindingSource();
    public ConfigurationBindingSource() : base(Name, Name, false, false) { }
}

You can see that I created a static field that holds an instance of this class (Configuration), this is so that it can be used, for example, in properties, such as in the FromConfigurationAttribute class we'll see in a moment.

Model Binding

Model binding requires a model binding provider (IModelBinderProvider) which in turn creates a model binder (IModelBinder). By default a number of model binders is included:

Binder Purpose
ArrayModelBinder<TElement> A specialisation of CollectionModelBinder<TElement> that binds arrays with some IModelBinder that must be provided
BinderTypeModelBinder Binds values using a supplied IModelBinder
BodyModelBinder Binds values from the HTTP payload using input formatters
ByteArrayModelBinder Binds values from byte arrays as Base64 strings
CancellationTokenModelBinder Binds CancellationToken parameters
CollectionModelBinder<TElement> Binds collections of values (POCOs)
ComplexObjectModelBinder Binds complex values (POCOs)
ComplexTypeModelBinder Deprecated, use ComplexObjectModelBinder instead
DateTimeModelBinder Binds DateTime values
DecimalModelBinder Binds Decimal values
DictionaryModelBinder<TKey,TValue> A specialisation of CollectionModelBinder<TElement> that binds dictionaries with some IModelBinder that must be provided
DoubleModelBinder Binds Double values
EnumTypeModelBinder Binds enumeration values
FloatModelBinder Binds Single values
FormCollectionModelBinder Binds values from a IFormCollection
FormFileModelBinder Binds values from a IFormFile
HeaderModelBinder Binds values from the HTTP headers
KeyValuePairModelBinder<TKey,TValue> Binds values from KeyValuePair<TKey,TValue>
ServicesModelBinder Binds values from the dependency injection container
SimpleTypeModelBinder Binds simple values using a type converter
HttpRequestMessageModelBinder Binds HttpRequestMessage objects

This next class is a custom IModelBinderProvider that must be registered, as we will see:

public sealed class FromConfigurationModelBinderProvider : IModelBinderProvider
{
    public IModelBinder GetBinder(ModelBinderProviderContext context)
    {
        ArgumentNullException.ThrowIfNull(context);
        if (context.Metadata?.BindingSource?.Id == ConfigurationBindingSource.Name)
        {
            return new FromConfigurationModelBinder();
        }
        return context.CreateBinder(context.Metadata);
    }
}

And it builds IModelBinder instances:

public sealed class FromConfigurationModelBinder : IModelBinder
{
    public Task BindModelAsync(ModelBindingContext bindingContext)
    {
        ArgumentNullException.ThrowIfNull(bindingContext);
        var param = bindingContext
            .ActionContext
            .ActionDescriptor
            .Parameters.OfType<IParameterInfoParameterDescriptor>()
            .SingleOrDefault(x => x.ParameterInfo.Name == bindingContext.ModelMetadata.Name);
        var attribute = param
            ?.ParameterInfo
            ?.CustomAttributes
            .SingleOrDefault(x => x.AttributeType == typeof(FromConfigurationAttribute));
        var key = attribute?.ConstructorArguments?.First().Value as string; 
        if (!string.IsNullOrWhiteSpace(key))
        {
            var config = bindingContext
                .HttpContext
                .RequestServices
                .GetRequiredService<IConfiguration>();
            var value = config.GetValue(bindingContext.ModelMetadata.ModelType, key);
            bindingContext.Result = ModelBindingResult.Success(value);
        }
        else
        {
            bindingContext.Result = ModelBindingResult.Failed();
        }
        return Task.CompletedTask;
    }
}

Very simply, what this does is, looks at the metadata for the current parameter, see if it has a FromConfigurationAttribute (see next) applied to it, and gets the value for its constructor parameter, which will be the configuration key. It then gets the IConfiguration object from DI, and gets the configuration from the key, binding it to the parameter's type. It then triggers success (ModelBindingResult.Success) or failure (ModelBindingResult.Failed).

Registration Method

The new model binder provider for configuration needs to be registered, here is a simple registration method:

public static class MvcOptionsExtensions
{
    public static MvcOptions RegisterConfigurationProvider(this MvcOptions options)
    {
        ArgumentNullException.ThrowIfNull(options);
        options.ModelBinderProviders.Insert(0, new FromConfigurationModelBinderProvider());
        return options;
    }
}

It's nothing more than an extension method over MvcOptions which inserts at the beginning of the ModelBinderProviders collection. To use:

builder.Services.AddControllers(static options =>
{
    options.RegisterConfigurationProvider();
});

When model binding kicks in, all the model binding providers in ModelBinderProviders will be called to create a model binder, and the first that returns success is used.

Injection Attribute

There are many attributes that serve to tell ASP.NET Core where to get the values for the parameters that they are applied to:

AttributeBinding SourcePurpose
[FromBody]BindingSource.BodyInjects a value from the HTTP POST payload (HttpRequest.Body)
[FromForm]BindingSource.FormInjects a value from the HTTP POST form (HttpRequest.Form)
[FromHeader]BindingSource.HeaderInjects a value from the HTTP headers (HttpRequest.Headers)
[FromQuery]BindingSource.QueryInjects a value from the HTTP query string (HttpRequest.Query)
[FromRoute]BindingSource.PathInjects a value from the HTTP request path (HttpRequest.Path)
[FromServices]BindingSource.ServicesInjects a value from the dependency injection container (HttpContext.RequestServices)

We need one for the new binding source:

[AttributeUsage(AttributeTargets.Parameter, AllowMultiple = false)]
public sealed class FromConfigurationAttribute(string key) : Attribute, IBindingSourceMetadata, IModelNameProvider
{
    public BindingSource? BindingSource => ConfigurationBindingSource.Configuration;
    public string? Name => key;
}

This attribute can only be applied to parameters (AttributeTargets.Parameter), and only once (AllowMultiple). It implements IBindingSourceMetadata, but this interface really does nothing useful, it's more of a convention.

Putting it All Together

So, after we have this code, instead of having:

public IActionResult Index([FromServices] IConfiguration config)
{
    var defaultLogLevel = config.GetValue<string>("Logging:LogLevel:Default");
    ...
}

we can just have:

public IActionResult Index([FromConfiguration("Logging:LogLevel:Default")] string defaultLogLevel) { ... }

Which I think results in more concise and clear code, which just shows our intent.

Conclusion

Once again, we can see that ASP.NET Core offers almost limitless extension mechanisms, which can be leveraged to build simpler, neater, and concise code. Let me hear your thoughts on this!

Comments

Popular posts from this blog

ASP.NET Core Middleware

.NET Cancellation Tokens

Audit Trails in EF Core