I have to create a C# class for a dynamic JSON I get from an external API. The json looks like this:
{
"id": "dataset0",
"properties": {
"createtime": "datetime format"
},
"dynamic0": {
"id": "123-456",
"metadata": {
"metadata0": "value0",
"metadata1": "value1"
}
},
"dynamic1": {
"id": "456-789",
"metadata": {
"metadata0": "value0"
}
}
}
-> id & properties are fixed followed by 0..n dynamic keys with a fixed structure.
My idea to achieve this is: inherit from a dictionary and enhance this by the two fixed properties. So I've created a class like this:
public class Dataset<TKey, TValue> : IDictionary<TKey, TValue> where TValue : DynamicKey where TKey : notnull
{
public string Id { get; set; } = string.Empty;
public Properties { get; set; } = new Properties ();
private readonly Dictionary<TKey, TValue> data = new();
public TValue this[TKey key]
{
get => data[key];
set => data[key] = value;
}
public ICollection<TKey> Keys => data.Keys;
public ICollection<TValue> Values => data.Values;
public int Count => data.Count;
public bool IsReadOnly => throw new NotImplementedException();
public void Add(TKey key, TValue value)
{
data.Add(key, value);
}
public void Add(KeyValuePair<TKey, TValue> item)
{
data.Add(item.Key, item.Value);
}
public void Clear()
{
data.Clear();
}
public bool Contains(KeyValuePair<TKey, TValue> item)
{
return data.Contains(item);
}
public bool ContainsKey(TKey key)
{
return data.ContainsKey(key);
}
public void CopyTo(KeyValuePair<TKey, TValue>[] array, int arrayIndex)
{
throw new NotImplementedException();
}
public IEnumerator<KeyValuePair<TKey, TValue>> GetEnumerator()
{
return data.GetEnumerator();
}
public bool Remove(TKey key)
{
return data.Remove(key);
}
public bool Remove(KeyValuePair<TKey, TValue> item)
{
return data.Remove(item.Key);
}
public bool TryGetValue(TKey key, [MaybeNullWhen(false)] out TValue value)
{
return data.TryGetValue(key, out value);
}
IEnumerator IEnumerable.GetEnumerator()
{
return GetEnumerator();
}
}
with this approach it works directly in C# totally fine, so I could use it like I want:
var data = new Dataset<string, DynamicKey>();
data.Id = "test";
data.Properties = new Properties
{
CreateTime = DateTime.Now
};
data.Add("test", new DynamicKey { Id = "123456", Metadata = new Dictionary<string, object> { { "some", "metadata" } } });
But it can't be (correctly) (de-)serialized from/to json via
JsonConvert.SerializeObject(data); -> returns only the dict elements, but the fixed properties are missing
JsonConvert.DeserializeObject<Dataset<string, DynamicKey>>(json) -> try to parsed fixed properties into dict elements and failes them
Any ideas to solve this?
I have also tried:
Your situation is basically identical to the one from How to deserialize a child object with dynamic (numeric) key names?. Your root JSON object has a set of fixed properties, then 0..n properties with dynamic names with a fixed structure. Thus, you can use the converter from this answer to read your JSON into a model that includes a Dictionary<TKey, TValue> DynamicValues { get; init; }
property to capture the dynamically named properties. The converter from that answer will need to be adjusted slightly to account for the fact that your root model has generic parameters.
First define the following data model:
[JsonConverter(typeof(TypedExtensionDataConverter))]
public class Root<TKey, TValue> where TValue : DynamicKey where TKey : notnull
{
// The fixed properties from the JSON:
public string id { get; set; }
public Properties properties { get; set; }
// A dictionary to hold the properties with dynamic names but a fixed structure in the JSON:
[JsonTypedExtensionData]
public Dictionary<TKey, TValue> DynamicValues { get; init; } = new();
}
public class Properties // Properties was not shown in your question
{
public string createtime { get; set; }
}
public abstract class DynamicKey; // DynamicKey was not shown in your question
public class IdAndMetadata : DynamicKey
{
public string id { get; set; }
public Metadata metadata { get; set; }
}
public class Metadata
{
public string metadata0 { get; set; }
public string metadata1 { get; set; }
}
and introduce the following converter:
[AttributeUsage(AttributeTargets.Field | AttributeTargets.Property, AllowMultiple = false)]
public sealed class JsonTypedExtensionDataAttribute : Attribute;
public class TypedExtensionDataConverter : JsonConverter
{
// TypedExtensionDataConverter taken from this answer https://stackoverflow.com/a/40094403/3744182
// to https://stackoverflow.com/questions/40088941/how-to-deserialize-a-child-object-with-dynamic-numeric-key-names
// and adjusted so it can be applied to a class with a generic parameter.
public override bool CanConvert(Type objectType) => throw new NotImplementedException(); // CanConvert() is not called when the converter is applied via attributes
static JsonProperty GetExtensionJsonProperty(JsonObjectContract contract)
{
try
{
return contract.Properties.Where(p => p.PropertyName != null && p.AttributeProvider?.GetAttributes(typeof(JsonTypedExtensionDataAttribute), false).Any() == true).Single();
}
catch (InvalidOperationException ex)
{
throw new JsonSerializationException(string.Format("Exactly one property with JsonTypedExtensionDataAttribute is required for type {0}", contract.UnderlyingType), ex);
}
}
public override object? ReadJson(JsonReader reader, Type objectType, object? existingValue, JsonSerializer serializer)
{
if (!(serializer.ContractResolver.ResolveContract(objectType) is JsonObjectContract contract))
throw new JsonSerializationException($"Contract for {objectType} is not a JsonObjectContract");
if (!(contract.DefaultCreator is {} creator))
throw new JsonSerializationException($"No default creator found for {objectType}");
var extensionJsonProperty = GetExtensionJsonProperty(contract);
if (reader.MoveToContentAndAssert().TokenType == JsonToken.Null)
return null;
var jObj = JObject.Load(reader);
var extensionJProperty = (JProperty?)null;
for (int i = jObj.Count - 1; i >= 0; i--)
{
var property = (JProperty)jObj.AsList()[i];
if (contract.Properties.GetClosestMatchProperty(property.Name) == null)
{
if (extensionJProperty == null)
{
extensionJProperty = new JProperty(extensionJsonProperty.PropertyName!, new JObject());
jObj.Add(extensionJProperty);
}
((JObject)extensionJProperty.Value).Add(property.RemoveFromLowestPossibleParent());
}
}
var value = existingValue ?? creator();
using (var subReader = jObj.CreateReader())
serializer.Populate(subReader, value);
return value;
}
public override void WriteJson(JsonWriter writer, object? value, JsonSerializer serializer)
{
if (value == null)
{
writer.WriteNull();
return;
}
if (!(serializer.ContractResolver.ResolveContract(value.GetType()) is JsonObjectContract contract))
throw new JsonSerializationException($"Contract for {value.GetType()} is not a JsonObjectContract");
var extensionJsonProperty = GetExtensionJsonProperty(contract);
JObject jObj;
using (new PushValue<bool>(true, () => Disabled, (canWrite) => Disabled = canWrite))
{
jObj = JObject.FromObject(value!, serializer);
}
var extensionValue = (jObj[extensionJsonProperty.PropertyName!] as JObject)?.RemoveFromLowestPossibleParent();
if (extensionValue != null)
for (int i = extensionValue.Count - 1; i >= 0; i--)
{
var property = (JProperty)extensionValue.AsList()[i];
jObj.Add(property.RemoveFromLowestPossibleParent());
}
jObj.WriteTo(writer);
}
[ThreadStatic]
static bool disabled;
// Disables the converter in a thread-safe manner.
bool Disabled { get { return disabled; } set { disabled = value; } }
public override bool CanWrite => !Disabled;
public override bool CanRead => !Disabled;
}
public static partial class JsonExtensions
{
public static JsonReader MoveToContentAndAssert(this JsonReader reader)
{
ArgumentNullException.ThrowIfNull(reader);
if (reader.TokenType == JsonToken.None) // Skip past beginning of stream.
reader.ReadAndAssert();
while (reader.TokenType == JsonToken.Comment) // Skip past comments.
reader.ReadAndAssert();
return reader;
}
public static JsonReader ReadAndAssert(this JsonReader reader)
{
ArgumentNullException.ThrowIfNull(reader);
if (!reader.Read())
throw new JsonReaderException("Unexpected end of JSON stream.");
return reader;
}
public static TJToken? RemoveFromLowestPossibleParent<TJToken>(this TJToken? node) where TJToken : JToken
{
if (node == null)
return null;
JToken toRemove;
var property = node.Parent as JProperty;
if (property != null)
{
// Also detach the node from its immediate containing property -- Remove() does not do this even though it seems like it should
toRemove = property;
property.Value = null!;
}
else
{
toRemove = node;
}
if (toRemove.Parent != null)
toRemove.Remove();
return node;
}
public static IList<JToken> AsList(this IList<JToken> container) => container;
}
internal struct PushValue<T> : IDisposable
{
Action<T> setValue;
T oldValue;
public PushValue(T value, Func<T> getValue, Action<T> setValue)
{
if (getValue == null || setValue == null)
throw new ArgumentNullException();
this.setValue = setValue;
this.oldValue = getValue();
setValue(value);
}
// By using a disposable struct we avoid the overhead of allocating and freeing an instance of a finalizable class.
public void Dispose() => setValue?.Invoke(oldValue);
}
And now you will be able to deserialize and re-serialize the JSON from your question successfully:
var json =
"""
{
"id": "dataset0",
"properties": {
"createtime": "datetime format"
},
"dynamic0": {
"id": "123-456",
"metadata": {
"metadata0": "value0",
"metadata1": "value1"
}
},
"dynamic1": {
"id": "456-789",
"metadata": {
"metadata0": "value0"
}
}
}
""";
var root = JsonConvert.DeserializeObject<Root<string, IdAndMetadata>>(json);
var settings = new JsonSerializerSettings
{
NullValueHandling = NullValueHandling.Ignore,
};
var json2 = JsonConvert.SerializeObject(root, Formatting.Indented, settings);
Notes:
TKey
for the dictionary key, but Json.NET has limited support for using complex classes as dictionary keys. You might want to eliminate that generic parameter and use string
as your key.Demo fiddle .NET 8 here.