Saving .NET configuration to a JSON file.

I was developing a piece of software that, as usual, required configuration information.

I decided to use the JSON configuration, both because of the ease-of-use format and for native support in .NET 7.0 with the JsonConfigurationProvider.

However, to my surprise, this provider could read the configuration, no problem, but it could not save any changes done at run-time to the configuration file.

Searching online I stumbled upon this answer on StackOverflow.

Implementing Paweł Górszczak solution yielded a provider that could save any alteration done at run-time to the JSON file.

As he alluded in his comment:

//... It requires modification if you need to support change multi level json structure.

The saved JSON file is a flat file in the dictionary form, that is, key-value pairs. Which is how the configuration is stored internally by the ConfigurationProvider.

That being said, though, I decided that it was too unseemly to have a JSON format and use it simply to store key-value pairs of a more complex format. So, I decide to write a way for the ConfigurationProvider to write proper JSON files.

And this was the result:

using Microsoft.Extensions.Configuration.Json;
using System.Dynamic;

namespace ManifestPrinter.Configuration
{
    /// <summary>
    /// Represents a property provider that uses a JSON file.
    /// </summary>
    public class WritableJsonConfigurationProvider : JsonConfigurationProvider
    {
        /// <summary>
        /// Creates a new instance with the <see cref="WritableJsonConfigurationProvider"/>.
        /// </summary>
        /// <param name="source">The source settings.</param>
        public WritableJsonConfigurationProvider(JsonConfigurationSource source) : base(source) 
        {
            SaveTimer = new()
            {
                Interval = 500,
                Enabled = false,
            };

            SaveTimer.Elapsed += (s, e) =>
            {
                SaveInternal();
                SaveTimer.Stop();
            };
        }

        /// <summary>
        /// Allows access to the underlying data dictionary.
        /// </summary>
        /// <returns>An <see cref="IDictionary{TKey, TValue}"/> that contains the data.</returns>
        internal IDictionary<string, string?> GetData() => Data;

        /// <inheritdoc/>
        public override void Set(string key, string? value)
        {
            if (Data.TryGetValue(key, out string? currValue) && currValue == value) return;
            base.Set(key, value);
            Save();
        }

        /// <summary>
        /// A lock object.
        /// </summary>
        private readonly static object lockObj = new();

        /// <summary>
        /// Saves the configuration to the file.
        /// </summary>
        /// <exception cref="FileNotFoundException">Thrown when the configuration file was not found.</exception>"
        private void SaveInternal()
        {
            /*
             * It was done this way because for some reason
             * the program was cutting some keys from the configuration
             * when saving the configuration to the JSON file.
             * 
             * This way we gurantee that all the keys are saved.
             */

            try
            {
                SaveTimer.Stop();

                /* 
                 * Read the old condiguration.
                 */
                var fileFullPath = Source.FileProvider.GetFileInfo(Source.Path).PhysicalPath;
                if (!File.Exists(fileFullPath))
                    throw new FileNotFoundException("The configuration file was not found.", fileFullPath);
                using var stream = File.OpenRead(fileFullPath);
                var data = JsonConfigurationFileParser.Parse(stream);

                /* 
                 * Update the old configuration with the new one.
                 */
                foreach (var key in Data.Keys.Where(x => !data.ContainsKey(x) || Data[x] != data[x]))
                    data[key] = Data[key];

                /* 
                 * Generate the ExpandoObject, convert it to a JSON string and save it to the configuration file.
                 */
                var output = Utils.ConvertToJson(GetExpandoObject(data));
                lock (lockObj)
                    while (true)
                        try
                        {
                            File.WriteAllText(fileFullPath, output);
                            break;
                        }
                        catch (IOException ex)
                        {
                            /*
                             * If the file is being used by another process,
                             * we wait a little bit and try again.
                             */
                            if (ex.Message.Contains("is being used by another process"))
                                Thread.Sleep(100);
                            else
                                throw;
                        }
            }
            catch (InvalidDataException ex)
            {
                /*
                 * Something went wrong, so we log it, but ignore it.
                 * And we will try again later.
                 */
                Log.Write(ex);
                Log.Write("Attempting to save configuration file again in a few moments.");
                Save();
            }
        }

        /// <summary>
        /// Saves the current configuration to the file.
        /// </summary>
        public void Save()
        {
            SaveTimer.Stop();
            SaveTimer.Start();
        }

        /// <summary>
        /// A timer that is used to save the configuration to the file.
        /// </summary>
        private readonly System.Timers.Timer SaveTimer;

        /// <summary>
        /// Returns an <see cref="ExpandoObject"/> that represents the configuration.
        /// </summary>
        /// <param name="data">An <see cref="IDictionary{TKey, TValue}"/> that contains the configuration.</param>
        /// <returns>An <see cref="ExpandoObject"/> that represents the configuration.</returns>
        private static ExpandoObject GetExpandoObject(IDictionary<string, string?> data)
        {
            var exp = new ExpandoObject();

            /*
             * We use a stack to keep track of the hierarchy of ExpandoObjects.
             */
            var stk = new Stack<object>();

            /*
             * The keys of the data property are separated by a colon (:),
             * we split the keys by the colon and create a hierarchy of ExpandoObjects.
             * Finally the ExpandoObject at the top of the hierarchy is the one that will be returned.
             */
            foreach (var key in data.Keys)
            {
                /*
                 * If the key contains a colon (:), it means that it is a hierarchical key.
                 */
                var hier = key.Split(':');
                if (hier.Length == 1)
                {
                    ((IDictionary<string, object?>)exp)[key] = data[key];
                    continue;
                }

                /*
                 * Create the hierarchy of ExpandoObjects.
                 */
                var myKey = "";
                object obj = exp;
                IDictionary<string, object?> curr;
                stk.Clear();
                for (int i = 0; i < hier.Length; i++)
                {
                    var root = (i == 0);
                    var leaf = (i == hier.Length - 1);
                    myKey += (i == 0 ? "" : ":") + hier[i];
                    if (!(root || leaf) && data.ContainsKey(myKey))
                    {
                        /*
                         * This handles a very special case:
                         * When someone physically change the 
                         * JSON so that one of the keys is an
                         * empty object.
                         * 
                         * We remove that entry from the data
                         * and add a new ExpandoObject in its
                         * place.
                         */
                        data.Remove(myKey);
                        curr = stk.Pop() as IDictionary<string, object?>;
                        curr ??= stk.Pop() as IDictionary<string, object?>;
                        curr[hier[i]] = new ExpandoObject();
                    }

                    if (int.TryParse(hier[i], out int ndx))
                    {
                        /*
                         * A special case, if the key is something like "key:0", it means that it's an array.
                         * So, we create a list of ExpandoObjects that represents the array.
                         */
                        var list = obj as List<object>;

                        if (list.Count == ndx)
                            list.Add(leaf ? data[key] : obj = new ExpandoObject());
                        else
                            obj = list[ndx];
                    }
                    else
                    {
                        /*
                         * Insert the value or another ExpandoObject in the current ExpandoObject.
                         */
                        curr = obj as IDictionary<string, object?>;
                        if (!curr.ContainsKey(hier[i]))
                            curr.Add(hier[i], leaf ? data[key] : hier[i + 1].IsNumeric() ? new List<object>() : new ExpandoObject());

                        obj = curr[hier[i]];
                    }
                    stk.Push(obj);
                }
            }

            return exp;
        }

        /// <inheritdoc/>
        protected override void Dispose(bool disposing)
        {
            if (disposed) return;
            base.Dispose(disposing);
            SaveTimer?.Dispose();
            disposed = true;
        }
        private bool disposed;
    }
}

I tried to use override the Get method to retrieve default information from attributes in the underlying configuration classes, but it was too much of a hassle.

If anyone wants to tackle this, let me know.

Comments