Click here to Skip to main content
15,886,362 members
Articles / Programming Languages / C#

Nested Polymorphic Deserializer using JSON.Net and Generics

Rate me:
Please Sign up or sign in to vote.
5.00/5 (3 votes)
5 Oct 2020CPOL4 min read 12K   102   1  
How to deserialize a JSON string that contains nested polymorphic objects
How I went about deserializing UISchema JSON from the JSONForms.io Person example code using Generics.

Introduction

I wanted to work on a dynamic form builder for a project I had been investigating when I stumbled across https://jsonforms.io. While the concept worked the way I envisaged; I wanted to have something in C# that could be used not only for Web UI but also for WPF forms. My current concept is to make a C# port of JSONForms, this is still a work in progress...

I spent a fair amount of time trying to get the JSON to deserialize correctly. The UI Schema used in JSONForms is a variant of the JSON Schema (https://json-schema.org/) but does not use the $type property to identify the type, and the type names did not line up with the defined classes, which lead to some head-scratching. There were a lot of answers available through StackOverflow and the NewtonSoft websites but nothing seemed to fit.

The supplied code was put together on LinqPad5 (https://www.linqpad.net/).

The Source JSON

The Person example from JSONForms is contained in the file https://github.com/eclipsesource/jsonforms/blob/master/packages/examples/src/person.ts, I started with only the UISchema section which for convenience I placed in a static class.

JavaScript
public static class Person
{
    public static string UI_Schema => @"{
    type: 'VerticalLayout',
    elements: [
        {
            type: 'HorizontalLayout',
            elements: [
                {
                    type: 'Control',
                    scope: '#/properties/name'
                },
                {
                    type: 'Control',
                    scope: '#/properties/personalData/properties/age'
                },
                {
                    type: 'Control',
                    scope: '#/properties/birthDate'
                }
            ]
        },
        {
            type: 'Label',
            text: 'Additional Information'
        },
        {
            type: 'HorizontalLayout',
            elements: [
                {
                    type: 'Control',
                    scope: '#/properties/personalData/properties/height'
                },
                {
                    type: 'Control',
                    scope: '#/properties/nationality'
                },
                {
                    type: 'Control',
                    scope: '#/properties/occupation',
                    suggestion: [
                        'Accountant',
                        'Engineer',
                        'Freelancer',
                        'Journalism',
                        'Physician',
                        'Student',
                        'Teacher',
                        'Other'
                    ]
                }
            ]
        },
        {
            type: 'Group',
            elements: [
                {
                    type: 'Control',
                    label: 'Eats vegetables?',
                    scope: '#/properties/vegetables'
                },
                {
                    type: 'Control',
                    label: 'Kind of vegetables',
                    scope: '#/properties/kindOfVegetables',
                    rule: {
                        effect: 'HIDE',
                        condition: {
                            type: 'SCHEMA',
                            scope: '#/properties/vegetables',
                            schema: {
                                const: false
                            }
                        }
                    }
                }
            ]
        }
    ]
}";
}

The key item to note is the existence of the type property that exists on most of the objects. This is what is used by JSONForms to decode the JSON into their classes.

The Class Hierarchies

To be able to deserialize, I needed to port the class structures from JSONForms; this resulted in two related hierarchies: UISchema and Conditions.

UISchema

The UISchema classes are used to outline the form layout and contain classes for Layouts, Controls, Labels and Categories. All classes are identified by a type property hardcoded into the class constructor. Note that the value assigned to the class's type is not the same as the class name.

C#
public class UISchemaElement
{
    public string type { get; set; } = "VOID";
    public Dictionary<string, object> options { get; set; } = new Dictionary<string, object>();
    public Rule rule { get; set; }
}

Layouts

C#
public class Layout : UISchemaElement
{
    public List<UISchemaElement> elements { get; set; }

    public Layout()
    {
        elements = new List<UISchemaElement>();
    }
}

public class VerticalLayout : Layout
{
    public VerticalLayout()
    {
        type = "VerticalLayout";
    }
}

public class HorizontalLayout : Layout
{
    public HorizontalLayout()
    {
        type = "HorizontalLayout";
    }
}

public class GroupLayout : Layout
{
    public string label { get; set; }

    public GroupLayout()
    {
        type = "GroupLayout";
    }
}

Categories

C#
public class Category : Layout, ICategorize
{
    public string label { get; set; }

    public Category()
    {
        type = "Category";
    }
}

public class Categorization : UISchemaElement, ICategorize
{
    public string label { get; set; }

    public List<ICategorize> elements { get; set; }

    public Categorization()
    {
        {
            type = "Categorization";
        }
    }
}

Category while a Layout class is treaded as special, it is the only layout that is allowed in the Categorization list of elements; as such the ICategorize interface is applied to it along with the Categorization class. The ICategorize interface is an empty interface.

Labels and Controls

C#
public class LabelElement : UISchemaElement
{
    public string text { get; set; }

    public LabelElement()
    {
        type = "Label";
    }
}

public class ControlElement : UISchemaElement, IScopable
{
    public string label { get; set; }
    public string scope { get; set; }

    public ControlElement()
    {
        type = "Control";
    }
}

First Attempt

My first attempts to deserialize were complete failures; The results would come back as a single UISchema object with no elements and only the type value.

C#
var result = JsonConvert.DeserializeObject<UISchemaElement>(Person.UI_Schema); 

First Attempts result

My Solution

After scouring what resources I could find, and some aborted directions (I could deserialize the top-level object but none of the child elements), I uncovered what was to be the key piece of the puzzle.

JSON.Net contains an abstract class JsonConverter<T> that can be used to serialize and deserialize to complex classes. I had been using this to get the top-level, but the examples I had been following had not satisfied the nested classes issue. Until I discovered the JsonSerializer.Populate method. The populate method acts to deserialize a JObject into a new POCO bay calling the existing serializer with all of its attendant converters.

I also wanted to avoid having switch statements or nested if statements in my converters; to overcome this, I used a TypeMap to actively target the allowed conversions my Converter could use.

JavaScript
public class TypeMapConverter<T> : JsonConverter<T> where T : class
{
    public TypeMapConverter(string Selector)
    {
        selector = Selector;
    }
    protected string selector { get; private set; }
    protected Dictionary<string, Type> TypeMap;

    public new bool CanConvert(Type objectType)
    {
        return typeof(T).IsAssignableFrom(objectType);
    }

    public override bool CanRead => true;

    public override bool CanWrite => false;

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

    public override T ReadJson(JsonReader reader, Type objectType, 
           T existingValue, bool hasExistingValue, JsonSerializer serializer)
    {
        JObject jObject = JObject.Load(reader);
        string key = jObject[selector]?.Value<string>() ?? string.Empty;
        if (string.IsNullOrEmpty(key))
        {
            return (T)System.Activator.CreateInstance(typeof(T));
        }
        T item;
        if (TypeMap.TryGetValue(jObject[selector].Value<string>(), out Type target))
        {
            item = (T)System.Activator.CreateInstance(target);
            serializer.Populate(jObject.CreateReader(), item);
            return item;
        }
        return (T)System.Activator.CreateInstance(typeof(T));
    }
}

public class UISchemaConverter : TypeMapConverter<UISchemaElement>
{
    public UISchemaConverter(string Selector) : base(Selector)
    {
        TypeMap = new Dictionary<string, System.Type>()
        {
            { "Label", typeof(LabelElement) },
            { "Control", typeof(ControlElement) },
            { "Categorization", typeof(Categorization) },
            { "VerticalLayout", typeof(VerticalLayout) },
            { "HorizontalLayout", typeof(HorizontalLayout) },
            { "Group", typeof(GroupLayout) },
            { "Category", typeof(Category) }
        };
    }
}

The TypeMapConverter is a wrapper class for JsonConverter. It holds the main conversion processing functionality and the Type selection code. The ReadJson method extracts the JObject to get access to the selector field, the TypeMap is queried to get the correct type and a new instance of that type is created. Once the type is created, it is then fed back into the Serializer to populate the item. The process is recursive; it correctly accounts for the nested polymorphic nature of the UISchema JSON.

By using the Generic TypeMapConverter, I can now easily add new converters to the system by creating an inherited class with the TypeMap built in the constructor; for example, ConditionConverter.

JavaScript
public class ConditionConverter : TypeMapConverter<Condition>
{
    public ConditionConverter(string Selector) : base(Selector)
    {
        TypeMap = new Dictionary<string, System.Type>()
        {
            { "LEAF", typeof(LeafCondition)},
            { "OR", typeof(OrCondition)},
            { "AND", typeof(AndCondition)},
            { "SCHEMA", typeof(SchemaCondition)}
        };
    }
}

Using the Code

As with any JSON.NET deserialization; converters can be added to the Serialize or Deserialize method calls like:

JavaScript
result = JsonConvert.DeserializeObject<UISchemaElement>
(Person.UI_Schema, new UISchemaConverter("type"), new ConditionConverter("type"));

The important part is to remember to set the Selector property in the constructor call; if it is left as an empty string (or the selector is not found in the JSON), an empty root object will be returned.

The result of the deserialization call using the JSON from Person.UISchema is shown below:

Solution Results

History

  • 5th October, 2020: First published

License

This article, along with any associated source code and files, is licensed under The Code Project Open License (CPOL)


Written By
Product Manager
Australia Australia
I started working with Dot Net Framework 1.1 in 2002 and have used every version since that time.
Since 2009, I have been developing against the SharePoint stack as well as dabbling in WebAPI, Javascript frameworks and other things that spike my interest.

Comments and Discussions

 
-- There are no messages in this forum --