2

I have some data stored as Json. One property in the data is either an integer (legacy data) like so:

"Difficulty": 2,

Or a complete object (new versions):

"Difficulty": {
      "$id": "625",
      "CombatModifier": 2,
      "Name": "Normal",
      "StartingFunds": {
        "$id": "626",
        "Value": 2000.0
      },
      "Dwarves": [
        "Miner",
        "Miner",
        "Miner",
        "Crafter"
      ]
    },

I am trying to write a custom converter for the type that allows deserialization of both versions.

This is C#, using the latest version of newtonsoft.json.

I've written a converter, and deserializing the integer format is trivial - it's only the mix that is causing me trouble. The only way I can think to check is to try and fail; but this appears to leave the reader in an unrecoverable state. Also, calling deserialize in the catch block leads to an infinite loop.

public class DifficultyConverter : JsonConverter
    {
        public override void WriteJson(JsonWriter writer, object value, JsonSerializer serializer)
        {
            throw new NotImplementedException();
        }

        public override object ReadJson(JsonReader reader, Type objectType, object existingValue, JsonSerializer serializer)
        {
            try
            {
                var jObject = serializer.Deserialize<JValue>(reader);
                if (jObject.Value is Int32 intv)
                    return Library.EnumerateDifficulties().FirstOrDefault(d => d.CombatModifier == intv);
                else
                    return null;
            }
            catch (Exception e)
            {
                return serializer.Deserialize<Difficulty>(reader);
            }
        }                

        public override bool CanWrite
        {
            get { return false; }
        }

        public override bool CanConvert(Type objectType)
        {
            return objectType == typeof(Difficulty);
        }
    }

Ideally I would be able to serialize into the new format always, and still support reading both formats. A couple of other options include:

  • Creating another serializer object that does not include the custom converter and calling it from the catch block.
  • Detecting out of date files at load and modifying the text before attempting to deserialize.

Kind of want to avoid those tho.

  • `Also, calling deserialize in the catch block leads to an infinite loop.` Why would it cause that? – mjwills Sep 05 '19 at 23:57
  • Are you using `PreserveReferencesHandling`? – dbc Sep 06 '19 at 00:39
  • 1
    If you are using `PreserveReferencesHandling`, can you share a [mcve] showing a full JSON sample and your classes? – dbc Sep 06 '19 at 00:53

1 Answers1

0

You have a couple of problems here:

  1. You are getting an infinite recursion in calls to ReadJson() because your converter is registered with the serializer you are using to do the nested deserialization, either through settings or by directly applying [JsonConverter(typeof(DifficultyConverter))] to Difficulty.

    The standard solution to avoid this is to manually allocate your Difficulty and then use serializer.Populate() to deserialize its members (e.g. as shown in this answer to Json.NET custom serialization with JsonConverter - how to get the "default" behavior) -- but you are also using PreserveReferencesHandling.Objects, which does not work with this approach.

    What does work with reference preservation is to adopt the approach from this answer to JSON.Net throws StackOverflowException when using [JsonConvert()] and deserialize to some DTO that contains a property of type Difficulty which has a superseding converter applied directly to the property.

  2. serializer.Deserialize<JValue>(reader); may advance the reader past the current token. This will cause the later attempt to deserialize as an object to fail.

    Instead, just check the JsonReader.TokenType or preload into a JToken and check the Type.

Putting the above together, your converter should look like the following:

public class DifficultyConverter : JsonConverter
{
    public override void WriteJson(JsonWriter writer, object value, JsonSerializer serializer)
    {
        throw new NotImplementedException();
    }

    public override object ReadJson(JsonReader reader, Type objectType, object existingValue, JsonSerializer serializer)
    {
        var token = JToken.Load(reader);

        switch (token.Type)
        {
            case JTokenType.Null:
                return null;

            case JTokenType.Integer:
                {
                    var intv = (int)token;
                    return Library.EnumerateDifficulties().FirstOrDefault(d => d.CombatModifier == intv);
                }

            case JTokenType.Object:
                return token.DefaultToObject(objectType, serializer);

            default:
                throw new JsonSerializationException(string.Format("Unknown token {0}", token.Type));
        }
    }                

    public override bool CanWrite => false;

    public override bool CanConvert(Type objectType) => objectType == typeof(Difficulty);
}

Using the following extension methods:

public static partial class JsonExtensions
{
    public static object DefaultToObject(this JToken token, Type type, JsonSerializer serializer = null)
    {
        var oldParent = token.Parent;

        var dtoToken = new JObject(new JProperty(nameof(DefaultSerializationDTO<object>.Value), token));
        var dtoType = typeof(DefaultSerializationDTO<>).MakeGenericType(type);
        var dto = (IHasValue)(serializer ?? JsonSerializer.CreateDefault()).Deserialize(dtoToken.CreateReader(), dtoType);

        if (oldParent == null)
            token.RemoveFromLowestPossibleParent();

        return dto == null ? null : dto.GetValue();
    }

    public static JToken RemoveFromLowestPossibleParent(this JToken node)
    {
        if (node == null)
            return null;
        // If the parent is a JProperty, remove that instead of the token itself.
        var contained = node.Parent is JProperty ? node.Parent : node;
        contained.Remove();
        // Also detach the node from its immediate containing property -- Remove() does not do this even though it seems like it should
        if (contained is JProperty)
            ((JProperty)node.Parent).Value = null;
        return node;
    }

    interface IHasValue
    {
        object GetValue();
    }

    [JsonObject(NamingStrategyType = typeof(Newtonsoft.Json.Serialization.DefaultNamingStrategy), IsReference = false)]
    class DefaultSerializationDTO<T> : IHasValue
    {
        public DefaultSerializationDTO(T value) { this.Value = value; }

        public DefaultSerializationDTO() { }

        [JsonConverter(typeof(NoConverter)), JsonProperty(ReferenceLoopHandling = ReferenceLoopHandling.Serialize)]
        public T Value { get; set; }

        public object GetValue() => Value;
    }
}

public class NoConverter : JsonConverter
{
    // NoConverter taken from this answer https://stackoverflow.com/a/39739105/3744182
    // To https://stackoverflow.com/questions/39738714/selectively-use-default-json-converter
    // By https://stackoverflow.com/users/3744182/dbc
    public override bool CanConvert(Type objectType)  { throw new NotImplementedException(); /* This converter should only be applied via attributes */ }

    public override bool CanRead { get { return false; } }

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

    public override bool CanWrite { get { return false; } }

    public override void WriteJson(JsonWriter writer, object value, JsonSerializer serializer) { throw new NotImplementedException(); }
}

Demo fiddle here.

dbc
  • 104,963
  • 20
  • 228
  • 340
  • 1
    Thank you, your solution worked, though I haven't dissected all of it yet. – Pilwicket Shortstature Sep 08 '19 at 00:21
  • You mention that the converter is 'applied directly to...', however it actually is not. I'm adding it to the converters list on the serializer. In your code, I couldn't find the type DefaultNamingStrategy. I'm actually using a custom typename resolver so I took that off entirely and everything appears to work. I don't know if it would be an issue if the type being serialized contained anything that actually depended on my custom resolver. – Pilwicket Shortstature Sep 08 '19 at 00:27
  • [`DefaultNamingStrategy`](https://www.newtonsoft.com/json/help/html/T_Newtonsoft_Json_Serialization_DefaultNamingStrategy.htm) was added in Json.NET [9.0.1](https://github.com/JamesNK/Newtonsoft.Json/releases/tag/9.0.1). It protects against a potential problem that could arise when using a camel case contract resolver. The symptom of the potential problem would be that the converter failed to work at all, so apparently it isn't necessary. Incidentally, can you confirm you are using `PreserveReferencesHandling`? – dbc Sep 08 '19 at 00:50