Search code examples
c#jsonasp.net-corepolymorphismjson-deserialization

How to load polymorphic objects in appsettings.json


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; }

}

Solution

  • 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.