Search code examples
c#jsondynamicsystem.text.json

C# - Deserializing nested json to nested Dictionary<string, object>


I'm using .net core 3.1 and library System.Text.Json

How can I deserialize nested json object to Dictionary<string, object>, but the expectation is that based on json property type I'll get proper C# type:

String -> string
Number -> int/double
Object -> Dictionary<string, object>

By default - if I try to deserialize to Dictionary<string, object> - basically every object is a JsonElement. I'd like it to be of type as mentioned above.

Any idea how it can be achieved?


Solution

  • In order to deserialize free-form JSON into .Net primitive types instead of JsonElement objects, you will need to write a custom JsonConverter, as no such functionality is provided by System.Text.Json out of the box.

    One such converter is the following:

    public class ObjectAsPrimitiveConverter : JsonConverter<object>
    {
        FloatFormat FloatFormat { get; init; }
        UnknownNumberFormat UnknownNumberFormat { get; init; }
        ObjectFormat ObjectFormat { get; init; }
    
        public ObjectAsPrimitiveConverter() : this(FloatFormat.Double, UnknownNumberFormat.Error, ObjectFormat.Expando) { }
        public ObjectAsPrimitiveConverter(FloatFormat floatFormat, UnknownNumberFormat unknownNumberFormat, ObjectFormat objectFormat)
        {
            this.FloatFormat = floatFormat;
            this.UnknownNumberFormat = unknownNumberFormat;
            this.ObjectFormat = objectFormat;
        }
        
        public override void Write(Utf8JsonWriter writer, object value, JsonSerializerOptions options)
        {
            if (value.GetType() == typeof(object))
            {
                writer.WriteStartObject();
                writer.WriteEndObject();
            }
            else
            {
                JsonSerializer.Serialize(writer, value, value.GetType(), options);
            }
        }
        
        public override object Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
        {
            switch (reader.TokenType)
            {
                case JsonTokenType.Null:
                    return null;
                case JsonTokenType.False:
                    return false;
                case JsonTokenType.True:
                    return true;
                case JsonTokenType.String:
                    return reader.GetString();
                case JsonTokenType.Number:
                {
                    if (reader.TryGetInt32(out var i))
                        return i;
                    if (reader.TryGetInt64(out var l))
                        return l;
                    // BigInteger could be added here.
                    if (FloatFormat == FloatFormat.Decimal && reader.TryGetDecimal(out var m))
                        return m;
                    else if (FloatFormat == FloatFormat.Double && reader.TryGetDouble(out var d))
                        return d;
                    using var doc = JsonDocument.ParseValue(ref reader);
                    if (UnknownNumberFormat == UnknownNumberFormat.JsonElement)
                        return doc.RootElement.Clone();
                    throw new JsonException(string.Format("Cannot parse number {0}", doc.RootElement.ToString()));
                }
                case JsonTokenType.StartArray:
                {
                    var list = new List<object>();
                    while (reader.Read())
                    {
                        switch (reader.TokenType)
                        {
                            default:
                                list.Add(Read(ref reader, typeof(object), options));
                                break;
                            case JsonTokenType.EndArray:
                                return list;
                        }
                    }
                    throw new JsonException();
                }
                case JsonTokenType.StartObject:
                    var dict = CreateDictionary();
                    while (reader.Read())
                    {
                        switch (reader.TokenType)
                        {
                            case JsonTokenType.EndObject:
                                return dict;
                            case JsonTokenType.PropertyName:
                                var key = reader.GetString();
                                reader.Read();
                                dict.Add(key, Read(ref reader, typeof(object), options));
                                break;
                            default:
                                throw new JsonException();
                        }
                    }
                    throw new JsonException();
                default:
                    throw new JsonException(string.Format("Unknown token {0}", reader.TokenType));
            }
        }
        
        protected virtual IDictionary<string, object> CreateDictionary() => 
            ObjectFormat == ObjectFormat.Expando ? new ExpandoObject() : new Dictionary<string, object>();
    }
    
    public enum FloatFormat
    {
        Double,
        Decimal,
    }
    
    public enum UnknownNumberFormat
    {
        Error,
        JsonElement,
    }
    
    public enum ObjectFormat
    {
        Expando,
        Dictionary,
    }
    

    And to use it, deserialize to object (or dynamic if configured to use ExpandoObject) as follows:

    var options = new JsonSerializerOptions
    {
        Converters = { new ObjectAsPrimitiveConverter(floatFormat : FloatFormat.Double, unknownNumberFormat : UnknownNumberFormat.Error, objectFormat : ObjectFormat.Expando) },
        WriteIndented = true,
    };
    dynamic d = JsonSerializer.Deserialize<dynamic>(json, options);
    

    Notes:

    • JSON allows for numbers of arbitrary precision and magnitude, while the .Net primitive numeric types do not. In situations where some JSON number cannot be parsed into a .Net primitive type, the converter provides the option to either return a JsonElement for the number, or throw an exception.

      The converter could be extended to attempt to deserialize unsupported numbers to BigInteger.

    • You can configure the converter to use double or decimal for floating point numbers, and Dictionary<string, object> or ExpandoObject for JSON objects.

    Demo fiddle here.