Is there any way how to read polymorphic objects from appsettings.json
in a strongly-typed way? Below is a very simplified example of what I need.
I have multiple app components, named Features
here. These components are created in runtime by a factory. My design intent is that each component is configured by its separate strongly-typed options. In this example FileSizeCheckerOptions
and PersonCheckerOption
are instances of these. Each feature can be included multiple times with different option.
But with the existing ASP.NET Core configuration system, I am not able to read polymorphic strongly typed options. If the settings were read by a JSON deserializer, I could use something like this. But this is not the case of appsettings.json
, where options are just key-value pairs.
appsettings.json
{
"DynamicConfig":
{
"Features": [
{
"Type": "FileSizeChecker",
"Options": { "MaxFileSize": 1000 }
},
{
"Type": "PersonChecker",
"Options": {
"MinAge": 10,
"MaxAge": 99
}
},
{
"Type": "PersonChecker",
"Options": {
"MinAge": 15,
"MaxAge": 20
}
}
]
}
}
Startup.cs
public void ConfigureServices(IServiceCollection services)
{
services.Configure<FeaturesOptions>(Configuration.GetSection("DynamicConfig"));
ServiceProvider serviceProvider = services.BuildServiceProvider();
// try to load settings in strongly typed way
var options = serviceProvider.GetRequiredService<IOptions<FeaturesOptions>>().Value;
}
Other definitions
public enum FeatureType
{
FileSizeChecker,
PersonChecker
}
public class FeaturesOptions
{
public FeatureConfig[] Features { get; set; }
}
public class FeatureConfig
{
public FeatureType Type { get; set; }
// cannot read polymorphic object
// public object Options { get; set; }
}
public class FileSizeCheckerOptions
{
public int MaxFileSize { get; set; }
}
public class PersonCheckerOption
{
public int MinAge { get; set; }
public int MaxAge { get; set; }
}
The key to answer this question is to know how the keys are generated. In your case, the key / value pairs will be:
DynamicConfig:Features:0:Type
DynamicConfig:Features:0:Options:MaxFileSize
DynamicConfig:Features:1:Type
DynamicConfig:Features:1:Options:MinAge
DynamicConfig:Features:1:Options:MaxAge
DynamicConfig:Features:2:Type
DynamicConfig:Features:2:Options:MinAge
DynamicConfig:Features:2:Options:MaxAge
Notice how each element of the array is represented by DynamicConfig:Features:{i}
.
The second thing to know is that you can map any section of a configuration to an object instance, with the ConfigurationBinder.Bind
method:
var conf = new PersonCheckerOption();
Configuration.GetSection($"DynamicConfig:Features:1:Options").Bind(conf);
When we put all this together, we can map your configuration to your data structure:
services.Configure<FeaturesOptions>(opts =>
{
var features = new List<FeatureConfig>();
for (var i = 0; ; i++)
{
// read the section of the nth item of the array
var root = $"DynamicConfig:Features:{i}";
// null value = the item doesn't exist in the array => exit loop
var typeName = Configuration.GetValue<string>($"{root}:Type");
if (typeName == null)
break;
// instantiate the appropriate FeatureConfig
FeatureConfig conf = typeName switch
{
"FileSizeChecker" => new FileSizeCheckerOptions(),
"PersonChecker" => new PersonCheckerOption(),
_ => throw new InvalidOperationException($"Unknown feature type {typeName}"),
};
// bind the config to the instance
Configuration.GetSection($"{root}:Options").Bind(conf);
features.Add(conf);
}
opts.Features = features.ToArray();
});
Note: all options must derive from FeatureConfig
for this to work (e.g. public class FileSizeCheckerOptions : FeatureConfig
). You could even use reflection to automatically detect all the options inheriting from FeatureConfig
, to avoid the switch over the type name.
Note 2: you can also map your configuration to a Dictionary
, or a dynamic
object if you prefer; see my answer to Bind netcore IConfigurationSection to a dynamic object.
Based on Metoule answer, I've created reusable extension method, that accepts delegate that accepts section and returns instance to bind to.
Please note that not all edge cases are handled (e.g. Features
must be list, not array).
public class FeaturesOptions
{
public List<FeatureConfigOptions> Features { get; set; }
}
public abstract class FeatureConfigOptions
{
public string Type { get; set; }
}
public class FileSizeCheckerOptions : FeatureConfigOptions
{
public int MaxFileSize { get; set; }
}
public class PersonCheckerOptions : FeatureConfigOptions
{
public int MinAge { get; set; }
public int MaxAge { get; set; }
}
FeaturesOptions options = new FeaturesOptions();
IConfiguration configuration = new ConfigurationBuilder()
.AddJsonFile("path-to-the-appsettings.json")
.Build();
configuration.Bind(options, (propertyType, section) =>
{
string type = section.GetValue<string>("Type");
switch (type)
{
case "FileSizeChecker": return new FileSizeCheckerOptions();
case "PersonChecker": return new PersonCheckerOptions();
default: throw new InvalidOperationException($"Unknown feature type {type}"); // or you can return null to skip the binding.
};
});
{
"Features":
[
{
"Type": "FileSizeChecker",
"MaxFileSize": 1000
},
{
"Type": "PersonChecker",
"MinAge": 10,
"MaxAge": 99
},
{
"Type": "PersonChecker",
"MinAge": 15,
"MaxAge": 20
}
]
}
using System.Collections;
namespace Microsoft.Extensions.Configuration
{
/// <summary>
/// </summary>
/// <param name="requestedType">Abstract type or interface that is about to be bound.</param>
/// <param name="configurationSection">Configuration section to be bound from.</param>
/// <returns>Instance of object to be used for binding, or <c>null</c> if section should not be bound.</returns>
public delegate object? ObjectFactory(Type requestedType, IConfigurationSection configurationSection);
public static class IConfigurationExtensions
{
public static void Bind(this IConfiguration configuration, object instance, ObjectFactory objectFactory)
{
if (configuration is null)
throw new ArgumentNullException(nameof(configuration));
if (instance is null)
throw new ArgumentNullException(nameof(instance));
if (objectFactory is null)
throw new ArgumentNullException(nameof(objectFactory));
// first, bind all bindable instance properties.
configuration.Bind(instance);
// then scan for all interfaces or abstract types
foreach (var property in instance.GetType().GetProperties())
{
var propertyType = property.PropertyType;
if (propertyType.IsPrimitive || propertyType.IsValueType || propertyType.IsEnum || propertyType == typeof(string))
continue;
var propertySection = configuration.GetSection(property.Name);
if (!propertySection.Exists())
continue;
object? propertyValue;
if (propertyType.IsAbstract || propertyType.IsInterface)
{
propertyValue = CreateAndBindValueForAbstractPropertyTypeOrInterface(propertyType, objectFactory, propertySection);
property.SetValue(instance, propertyValue);
}
else
{
propertyValue = property.GetValue(instance);
}
if (propertyValue is null)
continue;
var isGenericList = propertyType.IsAssignableTo(typeof(IList)) && propertyType.IsGenericType;
if (isGenericList)
{
var listItemType = propertyType.GenericTypeArguments[0];
if (listItemType.IsPrimitive || listItemType.IsValueType || listItemType.IsEnum || listItemType == typeof(string))
continue;
if (listItemType.IsAbstract || listItemType.IsInterface)
{
var newListPropertyValue = (IList)Activator.CreateInstance(propertyType)!;
for (int i = 0; ; i++)
{
var listItemSection = propertySection.GetSection(i.ToString());
if (!listItemSection.Exists())
break;
var listItem = CreateAndBindValueForAbstractPropertyTypeOrInterface(listItemType, objectFactory, listItemSection);
if (listItem is not null)
newListPropertyValue.Add(listItem);
}
property.SetValue(instance, newListPropertyValue);
}
else
{
var listPropertyValue = (IList)property.GetValue(instance, null)!;
for (int i = 0; i < listPropertyValue.Count; i++)
{
var listItem = listPropertyValue[i];
if (listItem is not null)
{
var listItemSection = propertySection.GetSection(i.ToString());
listItemSection.Bind(listItem, objectFactory);
}
}
}
}
else
{
propertySection.Bind(propertyValue, objectFactory);
}
}
}
private static object? CreateAndBindValueForAbstractPropertyTypeOrInterface(Type abstractPropertyType, ObjectFactory objectFactory, IConfigurationSection section)
{
if (abstractPropertyType is null)
throw new ArgumentNullException(nameof(abstractPropertyType));
if (objectFactory is null)
throw new ArgumentNullException(nameof(objectFactory));
if (section is null)
throw new ArgumentNullException(nameof(section));
var propertyValue = objectFactory(abstractPropertyType, section);
if (propertyValue is not null)
section.Bind(propertyValue, objectFactory);
return propertyValue;
}
}
}
If you love us? You can donate to us via Paypal or buy me a coffee so we can maintain and grow! Thank you!
Donate Us With