Per this Using System.Text.Json to Serialize an IConfiguration back to Json it seems that the limited type information that you can put into JSON is discarded.
You seem to be under the impression that IConfiguration objects are storing ints, bools, etc. (for example) corresponding to the JSON Element type. This is incorrect. All data within an IConfiguration is stored in stringified form. The base Configuration Provider classes all expect an IDictionary<string, string> filled with data. Even the JSON Configuration Providers perform an explicit ToString on the values.
I noticed this when I addressed the problem posed in that question with this extension method.
using System.Collections.Generic;
using System.Dynamic;
using Microsoft.Extensions.Configuration;
public static class ExtendConfig
{
public static dynamic AsDynamic(this IConfigurationRoot cr)
{
var result = new ExpandoObject();
var resultAsDict = result as IDictionary<string, object>;
foreach (var item in cr.AsEnumerable())
{
resultAsDict.Add(item.Key, item.Value);
}
return result;
}
}
This method reconstructs the graph but everything is now a string.
I could write my own parser and apply it to the original JSON string but that's a bit dire. Is there any way I can get this metadata so I can improve the fidelity of merged configs? I'm passing it through for consumption by JS which does notice the difference.
Merging is why I'm using the config extensions builder.
As IConfiguration
doesn't provide the information about types, but System.Text.Json
used by JsonConfigurationProvider
does, the working solution (or a workaround) would be using the System.Text.Json
deserializer directly to read the configuration file and match the types to the configuration keys.
But we have some minor issues to solve first. Like - where is the configuration file? We don't want to duplicate that information in code, we have to extract it from IConfiguration
instance.
Then - match the concrete existing configuration key to the JSON document tree node. That will require either DFS or BFS tree traversal algorithm. I'll go for DFS (Depth First Search). In a nutshell - if you have expandable nodes, you put them in the stack in reverse order. Then you have a while loop that takes a node from the stack, if it has children, you put them on the same stack, if not - you just yield the node. As simple as that, and BFS is pretty similar, but nevermind.
There's one thing more: Newtonsoft.Json
- a popular Nuget
package that was at a time used even by Microsoft. That JSON serializer is a little slower than System.Text.Json
, but it's more advanced, allowing the user to build a JSON document tree node by node.
Having this powerful tool makes creating a writeable JSON IConfiguration
relatively easy, especially using some helpers like mine below.
Look at the SaveChanges()
method. It walks through the IConfiguration
nodes, matches appropriate JObject
nodes by their path and copies the changes from IConfiguration
instance to the JObject
instance. Then you can just write the JSON file.
There's an ugly hack used to get the file. I get the private field containing the IConfigurationRoot
instance, but that could be skipped if you already have the configuration root. Having the root you can get JsonConfigurationProvider
from it, then it's just Source.Path
property.
So here's the code. It's a part of the Woof.Toolkit
and Woof.Config Nuget
package, that provides writeable JSON configurations, some helper methods to them, and also Azure Key Vault client that uses a JSON configuration, with some helper methods to encrypt and decrypt sensitive data with keys stored on AKV.
This is the first release of ConfigurationExtensions
class, so it might be sub-optimal in performance terms, but it works and illustrates how you can match the IConfiguration
instance nodes with JObject
nodes to get the types of the configuration properties.
using System;
using System.Collections.Generic;
using System.Globalization;
using System.IO;
using System.Linq;
using System.Reflection;
using System.Text.RegularExpressions;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.Configuration.Json;
using Newtonsoft.Json.Linq;
namespace Woof.Config;
/// <summary>
/// Extensions for <see cref="IConfiguration"/> making JSON type <see cref="IConfiguration"/> writeable.
/// </summary>
public static class ConfigurationExtensions {
/// <summary>
/// Gets the configuration root element.
/// </summary>
/// <param name="configuration">Any <see cref="IConfiguration"/> part.</param>
/// <returns>Root element.</returns>
public static IConfigurationRoot? GetRoot(this IConfiguration configuration) {
if (configuration is IConfigurationRoot root) return root;
var rootField = configuration.GetType().GetField("_root", BindingFlags.Instance | BindingFlags.NonPublic);
return rootField?.GetValue(configuration) as IConfigurationRoot;
}
/// <summary>
/// Gets the first <see cref="JsonConfigurationProvider"/> if exists, null otherwise.
/// </summary>
/// <param name="root">Configuration root element.</param>
/// <returns><see cref="JsonConfigurationProvider"/> or null.</returns>
public static JsonConfigurationProvider? GetJsonConfigurationProvider(this IConfigurationRoot root)
=> root.Providers.OfType<JsonConfigurationProvider>().FirstOrDefault();
/// <summary>
/// Gets the first <see cref="JsonConfigurationProvider"/> if exists, null otherwise.
/// </summary>
/// <param name="config">Any <see cref="IConfiguration"/> part.</param>
/// <returns><see cref="JsonConfigurationProvider"/> or null.</returns>
public static JsonConfigurationProvider? GetJsonConfigurationProvider(this IConfiguration config)
=> config.GetRoot()?.GetJsonConfigurationProvider();
/// <summary>
/// Saves changes made to <see cref="IConfiguration"/> to the JSON file if exists.
/// </summary>
/// <param name="config">Any <see cref="IConfiguration"/> part.</param>
/// <exception cref="InvalidOperationException">Configuration does not have <see cref="JsonConfigurationProvider"/>.</exception>
public static void SaveChanges(this IConfiguration config) {
var provider = config.GetJsonConfigurationProvider();
if (provider is null) throw new InvalidOperationException("Can't get JsonConfigurationProvider");
var sourceJson = File.ReadAllText(provider.Source.Path);
var target = JObject.Parse(sourceJson);
var stack = new Stack<IConfigurationSection>();
foreach (IConfigurationSection section in config.GetChildren().Reverse()) stack.Push(section);
while (stack.TryPop(out var node)) {
var children = node.GetChildren();
if (children.Any()) foreach (var child in children.Reverse()) stack.Push(child);
else {
var jPath = GetJPath(node.Path);
var element = target.SelectToken(jPath);
var valueString =
element!.Type == JTokenType.Null
? "null" :
element!.Type == JTokenType.String ? $"\"{node.Value}\"" : node.Value;
element!.Replace(JToken.Parse(valueString));
}
}
File.WriteAllText(provider.Source.Path, target.ToString());
}
/// <summary>
/// Sets <paramref name="configuration"/>'s <paramref name="key"/> with specified <paramref name="value"/>.
/// </summary>
/// <param name="configuration">The configuration.</param>
/// <param name="key">The key of the configuration section.</param>
/// <param name="value">Value to set.</param>
/// <exception cref="InvalidOperationException">Not supported type as value.</exception>
public static void SetValue(this IConfiguration configuration, string key, object? value) {
var c = CultureInfo.InvariantCulture;
var valueString = value switch {
null => null,
string v => v,
Uri v => v.ToString(),
byte[] v => Convert.ToBase64String(v),
bool v => v.ToString(c),
int v => v.ToString(c),
decimal v => v.ToString(c),
double v => v.ToString(c),
uint v => v.ToString(c),
long v => v.ToString(c),
ulong v => v.ToString(c),
short v => v.ToString(c),
ushort v => v.ToString(c),
byte v => v.ToString(c),
sbyte v => v.ToString(c),
float v => v.ToString(c),
_ => throw new InvalidOperationException($"Cannot set value of type {value.GetType()}")
};
configuration[key] = valueString;
}
/// <summary>
/// Gets the path for JObject.SelectToken method.
/// </summary>
/// <param name="path"><see cref="IConfiguration"/> path.</param>
/// <returns><see cref="JObject"/> path.</returns>
private static string GetJPath(string path) => RxIConfigurationIndex.Replace(path, "[$1]").Replace(':', '.');
/// <summary>
/// Matches the <see cref="IConfiguration"/> indices.
/// </summary>
private static readonly Regex RxIConfigurationIndex = new(@":(\d+)", RegexOptions.Compiled);
}
Why JObject
? Can JSON file represent only an object? No - it can represent any value, including null. But the JSON configuration MUST be an object. That's why I use JObject
as my secondary configuration root.