25
loading...
This website collects cookies to deliver better user experience
.NET
, we use the IConfigurationBuilder
to manage our configuration. An IOptions<>
is used to make a configuration available as a strongly typed type in our applications.configuration
concept in .NET
is the combination of different configuration sources, called configuration providers, resulting in a single combined configuration. In contrast, the options
concept provides access to configuration
from our application code. I've attempted to illustrate it with the image below.IConfigurationBuilder
where different providers are provided, and the configuration block in the middle is the merged build-result of the configuration builder. In fact, you get a preconfigured configuration builder every time you use the ASP.NET
Web templates. You get a default HostBuilder that setups an IHost. This default builder also takes care of the default configuration.command line
will always win from a setting in the appsettings.json
file. Fun fact, there are two configurations in ASP.NET
. You have the AppConfiguration
we just discussed, and you have the HostConfiguration
. The HostConfiguration
is used to set variables like the DOTNET_ENVIRONMENT
, which is used to load the proper appsettings.json
and user secrets. Via means of ChainedConfiguration
the entire HostConfiguration
is also available as part of AppConfiguration
.{
"MySample": {
"MyText": "Hello World!",
"MyCollection": [
{
"MyOtherText": "Goodbye Cruel World!"
}
]
}
}
MySample:MyText
MySample:MyCollection:0:MyOtherText
As pointed out by the "The Twelve-Factor App" article linked previously, adding configuration files per environment does not scale. I typically end up with one appsettings.json for the defaults and an appsettings.Production.json that gets transformed in my CICD pipeline.
.NET6
in a post from Andrew Lock. It also contains a different visual representation of configuration, which neatly displays the merging of the different levels.IOptions<>
, IOptionsSnapshot<>
and IOptionsMonitor<>
. Probably the most used one is the default IOptions
one, with the drawback that you cannot read configuration after your app starts. Others have taken the task upon themself to explain the differences between the interfaces, for example Andrew Lock and Khalid Abuhakmeh. For this post, I will keep it simple with the regular IOptions
.public static partial class ServiceCollectionExtensions
{
public static IServiceCollection AddDemo(this IServiceCollection services, IConfiguration configuration)
{
services.Configure<DemoOptions>(configuration.GetSection(DemoOptions.DefaultConfigurationSectionName));
return services;
}
}
This snippet requires the Microsoft.Extensions.Options.ConfigurationExtensions
package to work
IOptions
. We have a total of seven registrations at this point.ServiceType = 'Microsoft.Extensions.Options.IOptions`1[TOptions]' ImplementationType = 'Microsoft.Extensions.Options.UnnamedOptionsManager`1[TOptions]'
ServiceType = 'Microsoft.Extensions.Options.IOptionsSnapshot`1[TOptions]' ImplementationType = 'Microsoft.Extensions.Options.OptionsManager`1[TOptions]'
ServiceType = 'Microsoft.Extensions.Options.IOptionsMonitor`1[TOptions]' ImplementationType = 'Microsoft.Extensions.Options.OptionsMonitor`1[TOptions]'
ServiceType = 'Microsoft.Extensions.Options.IOptionsFactory`1[TOptions]' ImplementationType = 'Microsoft.Extensions.Options.OptionsFactory`1[TOptions]'
ServiceType = 'Microsoft.Extensions.Options.IOptionsMonitorCache`1[TOptions]' ImplementationType = 'Microsoft.Extensions.Options.OptionsCache`1[TOptions]'
ServiceType = 'Microsoft.Extensions.Options.IOptionsChangeTokenSource`1[Test.Unit.DemoOptions]' ImplementationType = ''
ServiceType = 'Microsoft.Extensions.Options.IConfigureOptions`1[Test.Unit.DemoOptions]' ImplementationType = ''
IOptions
is the use of an Action<>
.public static partial class ServiceCollectionExtensions
{
public static IServiceCollection AddExample(this IServiceCollection services, Action<ExampleOptions> configureDelegate)
{
services.Configure(configureDelegate);
return services;
}
}
ServiceType = 'Microsoft.Extensions.Options.IOptions`1[TOptions]' ImplementationType = 'Microsoft.Extensions.Options.UnnamedOptionsManager`1[TOptions]'
ServiceType = 'Microsoft.Extensions.Options.IOptionsSnapshot`1[TOptions]' ImplementationType = 'Microsoft.Extensions.Options.OptionsManager`1[TOptions]'
ServiceType = 'Microsoft.Extensions.Options.IOptionsMonitor`1[TOptions]' ImplementationType = 'Microsoft.Extensions.Options.OptionsMonitor`1[TOptions]'
ServiceType = 'Microsoft.Extensions.Options.IOptionsFactory`1[TOptions]' ImplementationType = 'Microsoft.Extensions.Options.OptionsFactory`1[TOptions]'
ServiceType = 'Microsoft.Extensions.Options.IOptionsMonitorCache`1[TOptions]' ImplementationType = 'Microsoft.Extensions.Options.OptionsCache`1[TOptions]'
ServiceType = 'Microsoft.Extensions.Options.IConfigureOptions`1[Test.Unit.ExampleOptions]' ImplementationType = ''
IOptionsChangeTokenSource
. To be most flexible, you can combine both techniques like this.public static partial class ServiceCollectionExtensions
{
public static IServiceCollection AddExample(this IServiceCollection services, IConfiguration config)
{
services.AddExample(options => config.GetSection(ExampleOptions.DefaultConfigurationSectionName).Bind(options));
return services;
}
public static IServiceCollection AddExample(this IServiceCollection services, Action<ExampleOptions> configureDelegate)
{
services.Configure(configureDelegate);
return services;
}
}
configuration.GetSection
does not throw but returns null for a section that does not exist. Oddly enough, when configuration fails to bind, you still get an IOptions<TOptions>
but with null values.public static IConfigurationSection GetExistingSectionOrThrow(this IConfiguration configuration, string key)
{
var configurationSection = configuration.GetSection(key);
if (!configurationSection.Exists())
{
throw configuration switch
{
IConfigurationRoot configurationIsRoot => new ArgumentException($"Section with key '{key}' does not exist. Existing values are: {configurationIsRoot.GetDebugView()}", nameof(key)),
IConfigurationSection configurationIsSection => new ArgumentException($"Section with key '{key}' does not exist at '{configurationIsSection.Path}'. Expected configuration path is '{configurationSection.Path}'", nameof(key)),
_ => new ArgumentException($"Failed to find configuration at '{configurationSection.Path}'", nameof(key))
};
}
return configurationSection;
}
caution : configurationIsRoot.GetDebugView() prints all configuration settings and their value, if you have secrets you should add log masking to prevent them from being logged.
IValidateOptions
. I also rediscovered ValidateDataAnnotations
on the IOptionsBuilder
, which I previously dismissed since it was a different API (AddOptions<>
) than the Configure<>
APIs. With Resharper by my side, I checked the implementation and discovered that it uses DataAnnotationValidateOptions
a class that is a IValidateOptions
.IConfigureOptions
, IPostConfigureOptions
and IValidateOptions
. If you head back up to where I printed the dependency injection container, you see that every time you use Configure<>
, you get an IConfigureOptions
. I illustrated this process below, IOptions makes use of an OptionsFactory. This factory goes through all registered "option services".IPostConfigureOptions
or IValidateOptions
before the normal IConfigureOptions
, it won't run before it. The factory runs through 0 or more IConfigureOptions
, 0 or more IPostConfigureOptions
and finally 0 or more IValidateOptions
and always in that order.public class ConfigureLibraryExampleServiceOptions : IConfigureOptions<LibraryExampleServiceOptions>, IPostConfigureOptions<LibraryExampleServiceOptions>, IValidateOptions<LibraryExampleServiceOptions>
{
private readonly ILogger _logger;
public ConfigureLibraryExampleServiceOptions(ILogger<ConfigureLibraryExampleServiceOptions> logger)
{
_logger = logger;
}
public void Configure(LibraryExampleServiceOptions options)
{
_logger.LogInformation("ConfigureExampleServiceOptions Configure");
}
public void PostConfigure(string name, LibraryExampleServiceOptions options)
{
_logger.LogInformation("ConfigureExampleServiceOptions PostConfigure");
}
public ValidateOptionsResult Validate(string name, LibraryExampleServiceOptions options)
{
_logger.LogInformation("ConfigureExampleServiceOptions ValidateOptionsResult");
return ValidateOptionsResult.Skip;
}
}
.Value
property.var configuration = new ConfigurationBuilder()
.AddInMemoryCollection(new Dictionary<string, string>() {
[string.Join(":", LibraryExampleServiceOptions.DefaultConfigurationSectionName, nameof(LibraryExampleServiceOptions.BaseUrl))] = "http://example.com"
})
.Build();
var serviceProvider = new ServiceCollection()
.AddLogging(builder => builder.AddConsole())
.AddExampleLibrary(configuration)
.BuildServiceProvider();
var logger = serviceProvider.GetRequiredService<ILogger<Program>>();
logger.LogInformation("Before retrieving IOptions");
var options = serviceProvider.GetRequiredService<IOptions<LibraryExampleServiceOptions>>();
logger.LogInformation("After retrieving IOptions; before IOptions.Value");
var optionsValue = options.Value;
logger.LogInformation("After IOptions.Value");
Console.ReadLine();
info: Program[0]
Before retrieving IOptions
info: Program[0]
After retrieving IOptions; before IOptions.Value
info: Kaylumah.ValidatedStronglyTypedIOptions.Library.ConfigureLibraryExampleServiceOptions[0]
ConfigureExampleServiceOptions Configure
info: Kaylumah.ValidatedStronglyTypedIOptions.Library.ConfigureLibraryExampleServiceOptions[0]
ConfigureExampleServiceOptions PostConfigure
info: Kaylumah.ValidatedStronglyTypedIOptions.Library.ConfigureLibraryExampleServiceOptions[0]
ConfigureExampleServiceOptions ValidateOptionsResult
info: Program[0]
After IOptions.Value
DataAnnotationValidateOptions
for us. One thing to note is that IValidateOptions
is a named option, whereas the normal IOptions
is an unnamed option. Microsoft solved this by providing a "DefaultName" for an options object which is an empty string.public static partial class ServiceCollectionExtensions
{
public static IServiceCollection ConfigureWithValidation<TOptions>(this IServiceCollection services, IConfiguration config) where TOptions : class
=> services.ConfigureWithValidation<TOptions>(Options.Options.DefaultName, config);
public static IServiceCollection ConfigureWithValidation<TOptions>(this IServiceCollection services, string name, IConfiguration config) where TOptions : class
{
_ = config ?? throw new ArgumentNullException(nameof(config));
services.Configure<TOptions>(name, config);
services.AddDataAnnotationValidatedOptions<TOptions>(name);
return services;
}
public static IServiceCollection ConfigureWithValidation<TOptions>(this IServiceCollection services, Action<TOptions> configureOptions) where TOptions : class
=> services.ConfigureWithValidation<TOptions>(Options.Options.DefaultName, configureOptions);
public static IServiceCollection ConfigureWithValidation<TOptions>(this IServiceCollection services, string name, Action<TOptions> configureOptions) where TOptions : class
{
services.Configure(name, configureOptions);
services.AddDataAnnotationValidatedOptions<TOptions>(name);
return services;
}
private static IServiceCollection AddDataAnnotationValidatedOptions<TOptions>(this IServiceCollection services, string name) where TOptions : class
{
services.TryAddEnumerable(ServiceDescriptor.Singleton<IValidateOptions<TOptions>>(new DataAnnotationValidateOptions<TOptions>(name)));
return services;
}
}
Required
and Url
attributes. You can use any of the attributes provided by default, or create your custom attributes.public class LibraryExampleServiceOptions
{
public const string DefaultConfigurationSectionName = nameof(LibraryExampleServiceOptions);
[Required, Url]
public string? BaseUrl { get;set; }
}
Consider nullability and default values of properties when defining them. In the spirit of the example, you might have a retry-count if it has the value 0; is that because you specified it or forgot to define it? That's why I always define properties as [Required]
and Nullable
.
info: Program[0]
Before retrieving IOptions
info: Program[0]
After retrieving IOptions; before IOptions.Value
info: Kaylumah.ValidatedStronglyTypedIOptions.Library.ConfigureLibraryExampleServiceOptions[0]
ConfigureExampleServiceOptions Configure
info: Kaylumah.ValidatedStronglyTypedIOptions.Library.ConfigureLibraryExampleServiceOptions[0]
ConfigureExampleServiceOptions PostConfigure
info: Kaylumah.ValidatedStronglyTypedIOptions.Library.ConfigureLibraryExampleServiceOptions[0]
ConfigureExampleServiceOptions ValidateOptionsResult
Unhandled exception. Microsoft.Extensions.Options.OptionsValidationException: DataAnnotation validation failed for 'LibraryExampleServiceOptions' members: 'BaseUrl' with the error: 'The BaseUrl field is not a valid fully-qualified http, https, or ftp URL.'.
at Microsoft.Extensions.Options.OptionsFactory`1.Create(String name)
at Microsoft.Extensions.Options.UnnamedOptionsManager`1.get_Value()
at Program.<Main>$(String[] args)
Web API
template, which resulted in a nicely formatted error. I had to dig in the ASPNET code, and it's the ModelStateInvalidFilter that transforms ModelStateDictionary.cs into a ValidationProblemDetails. I've added an example of this to the source repo, with the output shown below.{
"type": "https://tools.ietf.org/html/rfc7231#section-6.5.1",
"title": "One or more validation errors occurred.",
"status": 400,
"traceId": "00-50f5816f844377e66f37688f297dfd29-ab771434a82ee290-00",
"errors": {
"Name": ["The Name field is required."],
"EmailAddresses[0].Label": ["The Label field is required."],
"EmailAddresses[0].Address": ["The Address field is required."]
}
}
ValidationAttributes
. We begin with defining a special ValidationResult
that is a composite of multiple ValidationResults.public class CompositeValidationResult : System.ComponentModel.DataAnnotations.ValidationResult
{
private readonly List<System.ComponentModel.DataAnnotations.ValidationResult> results = new();
public IEnumerable<System.ComponentModel.DataAnnotations.ValidationResult> Results => results;
public CompositeValidationResult(string? errorMessage) : base(errorMessage)
{
}
public CompositeValidationResult(string errorMessage, IEnumerable<string>? memberNames) : base(errorMessage, memberNames)
{
}
protected CompositeValidationResult(System.ComponentModel.DataAnnotations.ValidationResult validationResult) : base(validationResult)
{
}
public void AddResult(System.ComponentModel.DataAnnotations.ValidationResult validationResult)
{
results.Add(validationResult);
}
}
ValidationAttribute
for objects.[AttributeUsage(AttributeTargets.Property | AttributeTargets.Parameter)]
public sealed class ValidateObjectAttribute : ValidationAttribute
{
protected override System.ComponentModel.DataAnnotations.ValidationResult IsValid(object? value, ValidationContext validationContext)
{
if (value != null && validationContext != null)
{
var results = new List<System.ComponentModel.DataAnnotations.ValidationResult>();
var context = new ValidationContext(value, null, null);
System.ComponentModel.DataAnnotations.Validator.TryValidateObject(value, context, results, true);
if (results.Count != 0)
{
var compositeValidationResult = new CompositeValidationResult($"Validation for {validationContext.DisplayName} failed.", new[] { validationContext.MemberName });
results.ForEach(compositeValidationResult.AddResult);
return compositeValidationResult;
}
}
return System.ComponentModel.DataAnnotations.ValidationResult.Success;
}
}
ValidationAttribute
for collections.[AttributeUsage(AttributeTargets.Property | AttributeTargets.Parameter)]
public sealed class ValidateCollectionAttribute : ValidationAttribute
{
protected override System.ComponentModel.DataAnnotations.ValidationResult IsValid(object? value, ValidationContext validationContext)
{
CompositeValidationResult? collectionCompositeValidationResult = null;
if (value is IEnumerable collection && validationContext != null)
{
var index = 0;
foreach (var obj in collection)
{
var results = new List<System.ComponentModel.DataAnnotations.ValidationResult>();
var context = new ValidationContext(obj, null, null);
System.ComponentModel.DataAnnotations.Validator.TryValidateObject(obj, context, results, true);
if (results.Count != 0)
{
var compositeValidationResult = new CompositeValidationResult($"Validation for {validationContext.MemberName}[{index}] failed.", new[] { $"{validationContext.MemberName}[{index}]" });
results.ForEach(compositeValidationResult.AddResult);
if (collectionCompositeValidationResult == null)
{
collectionCompositeValidationResult = new CompositeValidationResult($"Validation for {validationContext.MemberName} failed.", new[] { validationContext.MemberName });
}
collectionCompositeValidationResult.AddResult(compositeValidationResult);
}
index++;
}
if (collectionCompositeValidationResult != null)
{
return collectionCompositeValidationResult;
}
}
return System.ComponentModel.DataAnnotations.ValidationResult.Success;
}
}
public static class Validator
{
public static ValidationResult[] ValidateReturnValue(object objectToValidate)
{
var validationResults = new List<System.ComponentModel.DataAnnotations.ValidationResult>();
if (objectToValidate == null)
{
validationResults.Add(new System.ComponentModel.DataAnnotations.ValidationResult("Return value is required."));
}
else
{
var validationContext = new ValidationContext(objectToValidate);
System.ComponentModel.DataAnnotations.Validator.TryValidateObject(objectToValidate, validationContext, validationResults, true);
if (validationResults.Count != 0)
{
var compositeValidationResult = new CompositeValidationResult($"Validation for {validationContext.DisplayName} failed.", new[] { validationContext.MemberName });
validationResults.ForEach(compositeValidationResult.AddResult);
}
}
var structuredValidationResults = StructureValidationResults(validationResults);
return structuredValidationResults;
}
private static ValidationResult[] StructureValidationResults(IEnumerable<System.ComponentModel.DataAnnotations.ValidationResult> validationResults)
{
var structuredValidationResults = new List<ValidationResult>();
foreach (var validationResult in validationResults)
{
var structuredValidationResult = new ValidationResult
{
ErrorMessage = validationResult.ErrorMessage,
MemberNames = validationResult.MemberNames.ToArray()
};
if (validationResult is CompositeValidationResult compositeValidationResult)
{
structuredValidationResult.ValidationResults = StructureValidationResults(compositeValidationResult.Results);
}
structuredValidationResults.Add(structuredValidationResult);
}
return structuredValidationResults.ToArray();
}
}
IValidateOptions
like thisinternal class CustomValidate : IValidateOptions<NestedParent>
{
public ValidateOptionsResult Validate(string name, NestedParent options)
{
var validationResults = Kaylumah.ValidatedStronglyTypedIOptions.Utilities.Validation.Validator.ValidateReturnValue(options);
if (validationResults.Any())
{
var builder = new StringBuilder();
foreach (var result in validationResults)
{
var pretty = PrettyPrint(result, string.Empty, true);
builder.Append(pretty);
}
return ValidateOptionsResult.Fail(builder.ToString());
}
return ValidateOptionsResult.Success;
}
private string PrettyPrint(Kaylumah.ValidatedStronglyTypedIOptions.Utilities.Validation.ValidationResult root, string indent, bool last)
{
// Based on https://stackoverflow.com/a/1649223
var sb = new StringBuilder();
sb.Append(indent);
if (last)
{
sb.Append("|-");
indent += " ";
}
else
{
sb.Append("|-");
indent += "| ";
}
sb.AppendLine(root.ToString());
if (root.ValidationResults != null)
{
for (var i = 0; i < root.ValidationResults.Length; i++)
{
var child = root.ValidationResults[i];
var pretty = PrettyPrint(child, indent, i == root.ValidationResults.Length - 1);
sb.Append(pretty);
}
}
return sb.ToString();
}
}
Microsoft.Extensions.Options.OptionsValidationException : |-Children => Validation for Children failed.
|-Children[0] => Validation for Children[0] failed.
|-Name => The Name field is required.
Stack Trace:
at Microsoft.Extensions.Options.OptionsFactory`1.Create(String name)
at Microsoft.Extensions.Options.UnnamedOptionsManager`1.get_Value()
IConfiguration
, we could get the error at startup; we don't have the same luxury with IOptions
since, as demonstrated, Value
triggers at runtime. It is, however, a step in the right direction.Note : since IOptions<> is an unbound generic you cannot retrieve all instances of it from the DI container to trigger this behaviour at startup
IOptions<>
all over the place. I've found it especially bothersome in unit tests. I would either need Options.Create
or create an IOptions
Moq. If you don't rely on reloading configuration (remember IOptions
is a Singleton), you can register a typed instance, which I find pretty neat.var serviceProvider = new ServiceCollection()
.Configure<StronglyTypedOptions>(builder => {
builder.Name = "TestStronglyTypedOptions";
})
.AddSingleton(sp => sp.GetRequiredService<IOptions<StronglyTypedOptions>>().Value)
.BuildServiceProvider();
var options = serviceProvider.GetRequiredService<IOptions<StronglyTypedOptions>>().Value;
var typedOptions = serviceProvider.GetRequiredService<StronglyTypedOptions>();
typedOptions.Name.Should().Be(options.Name);