Search code examples
c#json.netjson-deserialization

How to invoke JsonConverter from inside other JsonConverter?


I'm trying to parse each step based on the command field. For that I decided to delegate the parsing to specific convertor inside CustomElementJsonConverter::ReadJson which is created using a factory. But, how do I invoke the convertor _converters[elementType])?

Does my approach even make sense or I should/could do it other way? There can be many element types. That's why I decided to do the way I'm doing so I won't have to modify CustomElementJsonConverter each type a new type is added.

TARGET TYPE

public class Message
{
    [JsonConverter(typeof(CustomElementJsonConverter))]
    [JsonProperty("data", Required = Required.Always)]
    public Sequence Sequence { get; set; }
}

public class Step
{
    public List<ElementData> Elements { get; set; }
}

public class Sequence
{
    public List<Step> Steps { get; set; }
}

TEST DATA

var msg = $$"""
{
    "data": [
        [
            {
                "type": "type1",
                "command": "Command1_Type1",
                "prop1": "test1",
                "prop2": "test2",
            },
            {
                "type": "type1",
                "command": "Command2_Type1",
                "prop1": "test3",
                "prop2": "test4",
            }
        ],
        [
            {
                "type": "type2",
                "command": "Command1_Type2",
                "prop3": 1,
            }
        ]         
    ]
}
""";

CODE

public class CustomElementJsonConverter : JsonConverter<Step>
{
    private static readonly Dictionary<string, JsonConverter> _converters;
    
    static CustomElementJsonConverter()
    {
       foreach (var elementType in Enum.GetValues(typeof(ElementType)).Cast<ElementType>())
       {
           var elemTypeJsonName = elementType.GetEnumMemberValue().ToLower();
           _converters[elemTypeJsonName] = ElementJsonConverterFactory.Create(elementType);
       }
    }

    public override Step ReadJson(JsonReader reader, Type objectType, Step existingValue, bool hasExistingValue, JsonSerializer serializer)
    {
       var jElements = JArray.Load(reader);

       var elements = new List<ElementData>();
       foreach (var jElement in jElements)
       {        
           var elementType = jElement["type"].Value<string>().ToLower();       
           
           var convertor = _converters[elementType];

           //HOW TO INVOKE THE CONVERTOR?           
       }

       return new Step { Elements = elements };
    }  
}

public static class ElementJsonConverterFactory
{
   public static JsonConverter Create(ElementType elementType) => elementType switch
   {
       ElementType.Type1 => new ElementType1JsonConverter()
       ElementType.Type2 => new ElementType2JsonConverter()
   };
}

public class ElementType1JsonConverter : JsonConverter<ElementData>
{
   public override ElementData ReadJson(JsonReader reader, Type objectType, ElementData existingValue, bool hasExistingValue, JsonSerializer serializer)
   {
       // if CommandType == Command1_Type1 should parse and return object ElementData_For_Command1Type1
       // if CommandType == Command2_Type1 should parse and return object ElementData_For_Command2Type1
   }

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

public class ElementType2JsonConverter : JsonConverter<ElementData>
{
   public override ElementData ReadJson(JsonReader reader, Type objectType, ElementData existingValue, bool hasExistingValue, JsonSerializer serializer)
   {
       // if CommandType == Command1_Type2 should parse and return object ElementData_For_Command1Type2
   }

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

public enum ElementType
{
    [EnumMember(Value = "type1")] Type1,
    [EnumMember(Value = "type2")] Type2,
}

public class ElementData
{
   [JsonProperty("type", Required = Required.Always)]
   public ElementType ElementType { get; set; }
}

public class CommandElementData1 : ElementData
{
   public enum CommandType
   {
      [EnumMember] Command1_Type1,
      [EnumMember] Command2_Type1,
   }

   [JsonProperty(Required = Required.Always)]
   public CommandType Command { get; set; }
}

public class ElementData_For_Command1Type1 : CommandElementData1
{
   [JsonProperty(Required = Required.Always)]
   public string Prop1 { get; set; }
   
   [JsonProperty(Required = Required.Always)]
   public string Prop2 { get; set; }
}

public class ElementData_For_Command2Type1 : CommandElementData1
{
   [JsonProperty(Required = Required.Always)]
   public string Prop1 { get; set; }
   
   [JsonProperty(Required = Required.Always)]
   public string Prop2 { get; set; }
}

public class CommandElementData2 : ElementData
{
   public enum CommandType
   {
      [EnumMember] Command1_Type2,
   }

   [JsonProperty(Required = Required.Always)]
   public CommandType Command { get; set; }
}

public class ElementData_For_Command1Type2 : CommandElementData2
{
   [JsonProperty(Required = Required.Always)]
   public int Prop3 { get; set; }      
}

Solution

  • You can just call the base class method JsonConverter.ReadJson(JsonReader reader, Type objectType, Object existingValue, JsonSerializer serializer) directly. It will do the necessary casting to invoke the derived class generic method JsonConverter<T>.ReadJson(). If the incoming reader is already correctly positioned you can pass it as the first argument. If you have already loaded the JSON into a JToken hierarchy you can use JToken.CreateReader() to create a reader for the specific token you wish to deserialize.

    Thus your CustomElementJsonConverter.ReadJson() should look something like:

    public override Step ReadJson(JsonReader reader, Type objectType, Step existingValue, bool hasExistingValue, JsonSerializer serializer) 
    {
        if (reader.MoveToContentAndAssert().TokenType == JsonToken.Null)
            return null; // TODO : decide whether to return null or throw an exception.
        var jElements = JArray.Load(reader);
    
        var elements = jElements.Select(jElement =>
        {        
            // TODO: check for null or missing missing `"type"` 
            var elementType = jElement["type"].Value<string>().ToLowerInvariant(); // FIXED: replaced ToLower with ToLowerInvariant
            var convertor = _converters[elementType];
            using var subReader = jElement.CreateReader();
            return (ElementData)convertor.ReadJson(subReader.MoveToContentAndAssert(), typeof(ElementData), null, serializer);
        }).ToList();
    
        return new Step { Elements = elements };
    }  
    

    If your array is very large, to reduce memory overhead you may want to iterate through the JSON array and process each entry individually rather than loading the entire array into a JArray. This version does that:

    public override Step ReadJson(JsonReader reader, Type objectType, Step existingValue, bool hasExistingValue, JsonSerializer serializer) 
    {
        if (reader.MoveToContentAndAssert().TokenType == JsonToken.Null)
            return null; // TODO : decide whether to return null or throw an exception.
            
        var elements = reader.EnumerateArray().Select(jElement =>
        {
            // TODO: check for null or missing missing `"type"` 
            var elementType = jElement["type"].Value<string>().ToLowerInvariant(); // FIXED: replaced ToLower with ToLowerInvariant
            var convertor = _converters[elementType];
            using var subReader = jElement.CreateReader();
            return (ElementData)convertor.ReadJson(subReader.MoveToContentAndAssert(), typeof(ElementData), null, serializer);
        }).ToList();
    
        return new Step { Elements = elements };
    }  
    

    Both versions use extension methods from the following class:

    public static partial class JsonExtensions
    {
        public static JsonReader AssertTokenType(this JsonReader reader, JsonToken tokenType) => 
            reader.TokenType == tokenType ? reader : throw new JsonSerializationException(string.Format("Unexpected token {0}, expected {1}", reader.TokenType, tokenType));
        
        public static JsonReader ReadToContentAndAssert(this JsonReader reader) =>
            reader.ReadAndAssert().MoveToContentAndAssert();
    
        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 IEnumerable<JToken> EnumerateArray(this JsonReader reader)
        {
            reader.MoveToContentAndAssert().AssertTokenType(JsonToken.StartArray);
            while (reader.ReadAndAssert().TokenType != JsonToken.EndArray)
                yield return JToken.Load(reader);
        }
    }
    

    Notes:

    • When you first create a JsonReader, it is positioned before the first JSON token. You must call Read() at least once to advance it to the first token. Also, be sure always to check the return from JsonReader.Read(). It can return false unexpectedly in the event of a truncated file.

      The extension method MoveToContentAndAssert() above advances to the first token if necessary and also skips comments and throws an exception for a truncated file.

    • Your converter uses a "type" property to indicate the concrete type to be deserialized. When serializing or deserializing the value, be sure to convert the value to lowercase using the invariant culture. If you use a localized culture your JSON may not be deserializable across locales. (See e.g. Why does "i" get replaced with "ı".)

    • A ReadJson() method is expected to check for invalid and/or unexpected JSON content and handle it gracefully enough to either throw an exception or leave the reader correctly positioned on exit. For instance, you need to decide how it should behave if the array value is null, or an array item is null.

    • For the Type objectType argument of ReadJson() pass the declared type of the reference, here ElementData. In polymorphic situations the converter may return a more derived type.

    • Your converter does not support populating an existing Step. If you need to do that, modify ReadJson() as follows:

      public override Step ReadJson(JsonReader reader, Type objectType, Step existingValue, bool hasExistingValue, JsonSerializer serializer) 
      {
          if (reader.MoveToContentAndAssert().TokenType == JsonToken.Null)
              return null; // TODO : decide whether to return null or throw an exception.
      
          // Reuse the existing values if present.
          var step = hasExistingValue ? existingValue : new Step();
          step.Elements ??= new ();
      
          step.Elements.AddRange(reader.EnumerateArray().Select(jElement =>
          {        
              // TODO: check for null or missing missing `"type"` 
              var elementType = jElement["type"].Value<string>().ToLowerInvariant(); // FIXED: replaced ToLower with ToLowerInvariant
              var convertor = _converters[elementType];
              using var subReader = jElement.CreateReader();
              return (ElementData)convertor.ReadJson(subReader.MoveToContentAndAssert(), typeof(ElementData), null, serializer);
          }));
      
          return step;
      }
      
    • The above code could not be fully tested since the question lacked a compilable mcve.

    Demo using simplified types here.