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:
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:
Attribute | Binding Source | Purpose |
[FromBody] | BindingSource.Body | Injects a value from the HTTP POST payload (HttpRequest.Body) |
[FromForm] | BindingSource.Form | Injects a value from the HTTP POST form (HttpRequest.Form) |
[FromHeader] | BindingSource.Header | Injects a value from the HTTP headers (HttpRequest.Headers) |
[FromQuery] | BindingSource.Query | Injects a value from the HTTP query string (HttpRequest.Query) |
[FromRoute] | BindingSource.Path | Injects a value from the HTTP request path (HttpRequest.Path) |
[FromServices] | BindingSource.Services | Injects 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
Post a Comment