How to load polymorphic objects in appsettings.json
Asked Answered
P

2

11

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

}
Prisca answered 2/5, 2019 at 14:41 Comment(4)
Would there be a downside to just doing "FileSizeCheckerOptions": { "MaxFileSize": 1000 }?Elmerelmina
@ScottHannen It would not. I need to read a type of feature (FileSizeChecker) - this is a key for the factory to create a new feature. And I need to load options for this feature.Prisca
Did you find a solution for this?Dinothere
@HariPachuveetil I think that there is no simple solution. This is caused by a .Core loading mechanism where all values are treated as key-value pairs, so there no space for polymorphism.Prisca
C
10

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.

Chretien answered 15/6, 2021 at 13:19 Comment(0)
C
2

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

appsettings.json

{
    "Features":
    [
        {
            "Type": "FileSizeChecker",
            "MaxFileSize": 1000
        },
        {
            "Type": "PersonChecker",
            "MinAge": 10,
            "MaxAge": 99
        },
        {
            "Type": "PersonChecker",
            "MinAge": 15,
            "MaxAge": 20
        }
    ]
}

IConfigurationExtensions.cs

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

Commensurate answered 5/4, 2022 at 11:46 Comment(1)
Inner Bind call throws InvalidOperationException because FeatureConfigOptions is abstract. Is there a way to affect this behavior in this case?Taskmaster

© 2022 - 2024 — McMap. All rights reserved.