Custom JsonConverter WriteJson Does Not Alter Serialization of Sub-properties
Asked Answered
V

5

27

I always had the impression that the JSON serializer actually traverses your entire object's tree, and executes the custom JsonConverter's WriteJson function on each interface-typed object that it comes across - not so.

I have the following classes and interfaces:

public interface IAnimal
{
    string Name { get; set; }
    string Speak();
    List<IAnimal> Children { get; set; }
}

public class Cat : IAnimal
{
    public string Name { get; set; }
    public List<IAnimal> Children { get; set; }        

    public Cat()
    {
        Children = new List<IAnimal>();
    }

    public Cat(string name="") : this()
    {
        Name = name;
    }

    public string Speak()
    {
        return "Meow";
    }       
}

 public class Dog : IAnimal
 {
    public string Name { get; set; }
    public List<IAnimal> Children { get; set; }

    public Dog()
    {
        Children = new List<IAnimal>();   
    }

    public Dog(string name="") : this()
    {
        Name = name;
    }

    public string Speak()
    {
        return "Arf";
    }

}

To avoid the $type property in the JSON, I've written a custom JsonConverter class, whose WriteJson is

public override void WriteJson(JsonWriter writer, object value, JsonSerializer serializer)
{
    JToken t = JToken.FromObject(value);

    if (t.Type != JTokenType.Object)
    {
        t.WriteTo(writer);                
    }
    else
    {
        IAnimal animal = value as IAnimal;
        JObject o = (JObject)t;

        if (animal != null)
        {
            if (animal is Dog)
            {
                o.AddFirst(new JProperty("type", "Dog"));
                //o.Find
            }
            else if (animal is Cat)
            {
                o.AddFirst(new JProperty("type", "Cat"));
            }

            foreach(IAnimal childAnimal in animal.Children)
            {
                // ???
            }

            o.WriteTo(writer);
        }
    }
}

In this example, yes, a dog can have cats for children and vice-versa. In the converter, I want to insert the "type" property so that it saves that to the serialization. I have the following setup. (Zoo has only a name and a list of IAnimals. I didn't include it here for brevity and laziness ;))

Zoo hardcodedZoo = new Zoo()
            {   Name = "My Zoo",               
                Animals = new List<IAnimal> { new Dog("Ruff"), new Cat("Cleo"),
                    new Dog("Rover"){
                        Children = new List<IAnimal>{ new Dog("Fido"), new Dog("Fluffy")}
                    } }
            };

            JsonSerializerSettings settings = new JsonSerializerSettings(){
                ContractResolver = new CamelCasePropertyNamesContractResolver() ,                    
                Formatting = Formatting.Indented
            };
            settings.Converters.Add(new AnimalsConverter());            

            string serializedHardCodedZoo = JsonConvert.SerializeObject(hardcodedZoo, settings);

serializedHardCodedZoo has the following output after serialization:

{
  "name": "My Zoo",
  "animals": [
    {
      "type": "Dog",
      "Name": "Ruff",
      "Children": []
    },
    {
      "type": "Cat",
      "Name": "Cleo",
      "Children": []
    },
    {
      "type": "Dog",
      "Name": "Rover",
      "Children": [
        {
          "Name": "Fido",
          "Children": []
        },
        {
          "Name": "Fluffy",
          "Children": []
        }
      ]
    }
  ]
}

The type property shows up on Ruff, Cleo, and Rover, but not for Fido and Fluffy. I guess the WriteJson isn't called recursively. How do I get that type property there?

As an aside, why does it not camel-case IAnimals like I expect it to?

Villarreal answered 25/3, 2015 at 21:38 Comment(0)
R
43

The reason that your converter is not getting applied to your child objects is because JToken.FromObject() uses a new instance of the serializer internally, which does not know about your converter. There is an overload that allows you to pass in the serializer, but if you do so here you will have another problem: since you are inside a converter and you are using JToken.FromObject() to try to serialize the parent object, you will get into an infinite recursive loop. (JToken.FromObject() calls the serializer, which calls your converter, which calls JToken.FromObject(), etc.)

To get around this problem, you must handle the parent object manually. You can do this without much trouble using a bit of reflection to enumerate the parent properties:

public override void WriteJson(JsonWriter writer, object value, JsonSerializer serializer)
{
    JObject jo = new JObject();
    Type type = value.GetType();
    jo.Add("type", type.Name);

    foreach (PropertyInfo prop in type.GetProperties())
    {
        if (prop.CanRead)
        {
            object propVal = prop.GetValue(value, null);
            if (propVal != null)
            {
                jo.Add(prop.Name, JToken.FromObject(propVal, serializer));
            }
        }
    }
    jo.WriteTo(writer);
}

Fiddle: https://dotnetfiddle.net/sVWsE4

Rudderpost answered 26/3, 2015 at 14:30 Comment(4)
It's worth noting, though, that this can cause other serialization-based attributes to be ignored. For instance, if you put [JsonIgnore] on a property, it will still get added with the above code.Selfsatisfied
have similar problem here #43821565 can you please help?Riebling
Any update on a way to do this that honours the attributes?Assignation
@MrHinsh-MartinHinshelwood maybe the answer I have just posted may helpMonometallic
M
2

Here's an idea, instead of doing the reflection on every property, iterate through the normally serialized JObject and then changed the token of properties you're interested in.

That way you can still leverage all the ''JsonIgnore'' attributes and other attractive features built-in.

public override void WriteJson(JsonWriter writer, object value, JsonSerializer serializer)
{
    JToken jToken = JToken.FromObject(value);

    if (jToken.Type == JTokenType.Object)
    {
        JObject jObject = (JObject)jToken;
        ...
        AddRemoveSerializedProperties(jObject, val);
        ...
    }
    ...
}

And then

private void AddRemoveSerializedProperties(JObject jObject, MahMan baseContract)
   {
       jObject.AddFirst(....);

        foreach (KeyValuePair<string, JToken> propertyJToken in jObject)
        {
            if (propertyJToken.Value.Type != JTokenType.Object)
                continue;

            JToken nestedJObject = propertyJToken.Value;
            PropertyInfo clrProperty = baseContract.GetType().GetProperty(propertyJToken.Key);
            MahMan nestedObjectValue = clrProperty.GetValue(baseContract) as MahMan;
            if(nestedObj != null)
                AddRemoveSerializedProperties((JObject)nestedJObject, nestedObjectValue);
        }
    }
Maganmagana answered 4/10, 2017 at 19:34 Comment(4)
where is jObject coming from?Spence
Hi Lee, sorry about that, the jToken is castable to a jObject if the jToken type is 'Object' I added the missing line, if you have any other questions poke meMaganmagana
What is MahMan in this context?Batts
@Batts whatever custom type you're dealing with. Some BaseObject of your own model. In this case I always needed to serialize an additional property to all objects of type "MahMan", but could be anything you need to work with . (to be fair this was a while back)Maganmagana
M
2

I had this issue using two custom converters for a parent and child type. A simpler method I found is that since an overload of JToken.FromObject() takes a serializer as a parameter, you can pass along the serializer you were given in WriteJson(). However you need to remove your converter from the serializer to avoid a recursive call to it (but add it back in after):

public override void WriteJson(JsonWriter writer, object value, JsonSerializer serializer)
{
    serializer.Converters.Remove(this);
    JToken jToken = JToken.FromObject(value, serializer);
    serializer.Converters.Add(this);

    // Perform any necessary conversions on the object returned
}
Muleteer answered 13/12, 2019 at 20:56 Comment(1)
This is a bad practice: 1. It is not threat-safe 2. It will fail to serialize child nodes of the same type the same way as this parent (for missing the current serializer) 3. If the FormObject fails, the state of your seralizer has changed 4. If it not fails, the order of applying converters might have changed.Fish
E
0

Here is a hacky solution to your problem that gets the work done and looks tidy.

public class MyJsonConverter : JsonConverter
{
    public const string TypePropertyName = "type";
    private bool _dormant = false;

    /// <summary>
    /// A hack is involved:
    ///     " JToken.FromObject(value, serializer); " creates amn infinite loop in normal circumstances
    ///     for that reason before calling it "_dormant = true;" is called.
    ///     the result is that this JsonConverter will reply false to exactly one "CanConvert()" call.
    ///     this gap will allow to generate a a basic version without any extra properties, and then add them on the call with " JToken.FromObject(value, serializer); ".
    /// </summary>
    public override void WriteJson(JsonWriter writer, object value, JsonSerializer serializer)
    {
        _dormant = true;
        JToken t = JToken.FromObject(value, serializer);
        if (t.Type == JTokenType.Object && value is IContent)
        {
            JObject o = (JObject)t;
            o.AddFirst(new JProperty(TypePropertyName, value.GetType().Name));
            o.WriteTo(writer);
        }
        else
        {
            t.WriteTo(writer);
        }
    }

    public override object ReadJson(JsonReader reader, Type objectType, object existingValue, JsonSerializer serializer)
    {
        throw new NotImplementedException();
    }

    public override bool CanRead => false;

    public override bool CanConvert(Type objectType)
    {
        if (_dormant)
        {
            _dormant = false;
            return false;
        }
        return true;
    }
}
Elianore answered 23/6, 2021 at 9:53 Comment(0)
M
0

Given the design of Json.Net, when you implement the contract, you are responsible for implementing the serialization and deserialization of the whole graph derived by traversing the type(s) handled by the contract itself.

This is hard, since we have to mimic the behavior of json.net on our own.

The accepted answer gives you the idea, for serializing. I put my 2c with the solution we have in production that implements both writing and reading, trying to be as transparent to the developer as possible.

Disclaimer

The following code solves our purposes (and my constraints) and is far for perfect. The converter must:

  • handle polymorphic types (with deep hierarchies)
  • be driven by attributes
  • serialize cyclic graphs
  • preserve the references
  • preserve references across other contracts we have
  • avoid to reinvent the wheel

For this purpose, we needed to shamefully bend and ruin the design of the entities to make the whole machinery work. Therefore:

  • all the entities have a public or internal empty constructor used for this
  • collections and dictionaries are mutable (e.g. no IReadOnlyList ...)
  • read only properties have an internal setter for serialization purposes
  • nullable values are defaulted to an ugly default! in the internal constructor, therefore protecting the developer for making mistakes, but opening a huge whole when deserializing a malformed or manually edited json, since nulls slip through.

Serialization

The key point for serialization is being able to do it, without recursively being called. This could be achieved (as suggested by other answers) by using the contract already defined in the serializer provided in WriteJson.

private JObject ManuallySerializeParent(object value, JsonSerializer serializer)
{
    var o = new JObject();
    foreach (var item in _contract.Properties)
    {
        // (1)
        if (item.Ignored || item.ShouldSerialize?.Invoke(value) == false || item.ValueProvider == null || item.PropertyName == null)
            continue;

        var propertyValue = item.ValueProvider.GetValue(value);
        if (propertyValue != null)
            o.Add(item.PropertyName, JToken.FromObject(propertyValue, serializer));
    }
    return o;
}

The condition in (1) can be improved since it does not cover all the possible cases, but for us it is sufficient.

Deserialization

The deserialization process is nearly specular (with a catch, more below):

    var contract = serializer.ContractResolver.ResolveContract(type);
    var newReader = o.CreateReader();

    // here we cannot use ToObject since it would recurse
    var ret = ManuallyCreateObject(newReader, contract);

    serializer.Populate(newReader, ret);

The catch is that, apparently, there is no public way to ask Json.Net to initialize an instance following it's knowledge. The ManuallyCreateObject is an ugly helper method that access the private implementation detail JsonSerializerInternalReader and invoke the appropriate method. Since this is a bad thing, you need to keep your code aligned to the library, since it can change at any time.

private static readonly Lazy<PrivateSerializerAccessor> _privateSerializerAccessor = new(LazyThreadSafetyMode.PublicationOnly);

private sealed class PrivateSerializerAccessor
{
    public PrivateSerializerAccessor()
    {
        var type = typeof(JsonSerializer).Assembly.DefinedTypes.Single(x => x.Name == "JsonSerializerInternalReader") ?? throw new InvalidOperationException("Persistent bug: cannot access to the private serializer.");
        var method = type.GetMethod("CreateNewObject", BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance, new Type[] { typeof(JsonReader), typeof(JsonObjectContract), typeof(JsonProperty), typeof(JsonProperty), typeof(string), typeof(bool).MakeByRefType() }) ?? throw new InvalidOperationException("Persistent bug: cannot get the method.");

        StolenSerializedType = type;
        StolenCreateNewObjectMethod = method;
    }

    public MethodInfo StolenCreateNewObjectMethod { get; }
    public Type StolenSerializedType { get; }

    public object CreateStolenSerializer()
    {
        return Activator.CreateInstance(StolenSerializedType, new JsonSerializer()) ?? throw new InvalidOperationException("Cannot create the private serializer.");
    }
}

private static object ManuallyCreateObject(JsonReader reader, JsonContract contract)
{
    var accessor = _privateSerializerAccessor.Value;

    var o = accessor.CreateStolenSerializer();
    var newObject = accessor.StolenCreateNewObjectMethod.Invoke(o, new object?[] { reader, contract, null, null, null, null }) ?? throw new InvalidOperationException("Cannot create the object.");

    return newObject;
}

Full Code

Here is the full vetted and redacted code, including helper classes.

    [System.AttributeUsage(AttributeTargets.Class, Inherited = false, AllowMultiple = false)]
    public sealed class PolymorphicAttribute : Attribute
    {
        public PolymorphicAttribute(string contractTypeName)
        {
            if (string.IsNullOrWhiteSpace(contractTypeName))
                throw new ArgumentNullException(nameof(contractTypeName));

            ContractTypeName = contractTypeName;
        }

        public string ContractTypeName { get; }
    }
    
    public sealed class ModelJsonConverter : JsonConverter
    {
        private const string ContractName = "contractName";
        private const string IdentityMetadataName = "$id";
        private const string InvalidContractName = "@@invalid@@";
        private const string ReferenceMetadataName = "$ref";

        private static readonly Lazy<PrivateSerializerAccessor> _privateSerializerAccessor = new(LazyThreadSafetyMode.PublicationOnly);
        private readonly JsonObjectContract _contract;
        private readonly JsonObjectTracker _tracker;

        public ModelJsonConverter(JsonObjectTracker tracker, JsonObjectContract contract)
        {
            ArgumentNullException.ThrowIfNull(tracker);
            ArgumentNullException.ThrowIfNull(contract);

            _tracker = tracker;
            _contract = contract;
        }

        public override bool CanRead => true;
        public override bool CanWrite => true;
        internal static KnownConverterTypes KnownTypes { get; } = InitializeKnownTypes();

        public override bool CanConvert(Type objectType)
        {
            return KnownTypes.TypeToId.ContainsKey(objectType);
        }

        public override object? ReadJson(JsonReader reader, Type objectType, object? existingValue, JsonSerializer serializer)
        {
            ArgumentNullException.ThrowIfNull(reader);
            ArgumentNullException.ThrowIfNull(serializer);

            var o = JObject.Load(reader);

            if (o == null)
                return null;

            var objectIdReference = o.GetValue(ReferenceMetadataName, StringComparison.Ordinal)?.ToString();

            if (objectIdReference != null && _tracker.TryGetTrackedReference(objectIdReference, out var r))
                return r;

            var id = o.GetValue(ContractName, StringComparison.Ordinal)?.ToString();
            var objectId = o.GetValue(IdentityMetadataName, StringComparison.Ordinal)?.ToString();

            if (string.IsNullOrEmpty(id))
                return null;

            if (!KnownTypes.IdToType.TryGetValue(id, out var type))
                throw new InvalidOperationException($@"Invalid contract ""{id}"".");

            var contract = serializer.ContractResolver.ResolveContract(type);

            var newReader = o.CreateReader();
            var ret = ManuallyCreateObject(newReader, contract);

            serializer.Populate(newReader, ret);

            if (objectId != null && ret != null)
                _tracker.TrackReference(objectId, ret);

            return ret;
        }

        public override void WriteJson(JsonWriter writer, object? value, JsonSerializer serializer)
        {
            ArgumentNullException.ThrowIfNull(writer);
            ArgumentNullException.ThrowIfNull(serializer);

            if (value == null)
                return;

            if (_tracker.TryGetTrackedInstance(value, out var id))
            {
                var o = new JObject
                {
                    { ReferenceMetadataName, id }
                };

                o.WriteTo(writer);
            }
            else
            {
                // we generate the ids in such a way they can formally blend with json net but they do not overlap.
                id = "y" + (_tracker.GenerateNewId()).ToString(CultureInfo.InvariantCulture);

                _tracker.TrackInstance(value, id);
                var o = ManuallySerializeParent(value, serializer);

                // note one huge detail: we conform to the metadata syntax $id, but we write it purposefully at the end
                // this way, json.net would simply ignore it by default and would not try to locate the reference.
                o.Add(ContractName, new JValue(KnownTypes.TypeToId[value.GetType()]));
                o.Add(IdentityMetadataName, new JValue(id));

                o.WriteTo(writer);
            }
        }

        private static KnownConverterTypes InitializeKnownTypes()
        {
            var assembly = typeof(ModelJsonConverter).Assembly;

            var polimorphicTypes = assembly.GetTypes()
                .Where(x => x.IsPublic && x.IsClass && x.GetCustomAttribute<PolymorphicAttribute>(false) != null)
                .Select(x => (Type: x, x.GetCustomAttribute<PolymorphicAttribute>(false)!.ContractTypeName));

            var idToType = new Dictionary<string, Type>(StringComparer.Ordinal);
            var typeToId = new Dictionary<Type, string>();

            foreach (var item in polimorphicTypes)
            {
                idToType.Add(item.ContractTypeName, item.Type);
                typeToId.Add(item.Type, item.ContractTypeName);

                // we also need to add all the abstract ancestors, so that the serializer knowns we are responsible for those types too.
                // however, note that we do not allow deserialization of those types. Also, it is safe to use a placeholder for the type
                // identifier, since we recover the type information from the runtime object.

                var candidate = item.Type.BaseType;
                while (candidate != null && candidate != typeof(object))
                {
                    if (candidate.IsPublic && candidate.IsAbstract)
                        typeToId.TryAdd(candidate, InvalidContractName);

                    candidate = candidate.BaseType;
                }
            }

            return new(idToType, typeToId);
        }

        private static object ManuallyCreateObject(JsonReader reader, JsonContract contract)
        {
            var accessor = _privateSerializerAccessor.Value;

            var o = accessor.CreateStolenSerializer();
            var newObject = accessor.StolenCreateNewObjectMethod.Invoke(o, new object?[] { reader, contract, null, null, null, null }) ?? throw new InvalidOperationException("Cannot create the object.");

            return newObject;
        }

        private JObject ManuallySerializeParent(object value, JsonSerializer serializer)
        {
            var o = new JObject();

            foreach (var item in _contract.Properties)
            {
                if (item.Ignored || item.ShouldSerialize?.Invoke(value) == false || item.ValueProvider == null || item.PropertyName == null)
                    continue;

                var propertyValue = item.ValueProvider.GetValue(value);

                if (propertyValue != null)
                {
                    o.Add(item.PropertyName, JToken.FromObject(propertyValue, serializer));
                }
            }
            return o;
        }

        private sealed class PrivateSerializerAccessor
        {
            public PrivateSerializerAccessor()
            {
                var type = typeof(JsonSerializer).Assembly.DefinedTypes.Single(x => x.Name == "JsonSerializerInternalReader") ?? throw new InvalidOperationException("Persistent bug: cannot access to the private serializer.");
                var method = type.GetMethod("CreateNewObject", BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance, new Type[] { typeof(JsonReader), typeof(JsonObjectContract), typeof(JsonProperty), typeof(JsonProperty), typeof(string), typeof(bool).MakeByRefType() }) ?? throw new InvalidOperationException("Persistent bug: cannot get the method.");

                StolenSerializedType = type;
                StolenCreateNewObjectMethod = method;
            }

            public MethodInfo StolenCreateNewObjectMethod { get; }

            public Type StolenSerializedType { get; }

            public object CreateStolenSerializer()
            {
                return Activator.CreateInstance(StolenSerializedType, new JsonSerializer()) ?? throw new InvalidOperationException("Cannot create the private serializer.");
            }
        }
    }
}
    // this class is not thread safe.
    public sealed class JsonObjectTracker
    {
        private readonly Dictionary<object, string> _ids = new();
        private readonly Dictionary<string, object> _instances = new();
        private long _id;

        public long GenerateNewId() => Interlocked.Increment(ref _id);

        public void TrackInstance(object instance, string objectId)
        {
            ArgumentNullException.ThrowIfNull(instance);
            ArgumentNullException.ThrowIfNull(objectId);

            _ids.TryAdd(instance, objectId);
        }

        public void TrackReference(string objectId, object instance)
        {
            ArgumentNullException.ThrowIfNull(objectId);
            ArgumentNullException.ThrowIfNull(instance);

            _instances.TryAdd(objectId, instance);
        }

        public bool TryGetTrackedInstance(object instance, [MaybeNullWhen(false)] out string objectId)
        {
            ArgumentNullException.ThrowIfNull(instance);

            return _ids.TryGetValue(instance, out objectId);
        }

        public bool TryGetTrackedReference(string reference, [MaybeNullWhen(false)] out object instance)
        {
            return _instances.TryGetValue(reference, out instance);
        }
    }
Monometallic answered 9/9, 2023 at 9:35 Comment(0)

© 2022 - 2024 — McMap. All rights reserved.