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
.
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.
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
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
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
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.
var result = JsonConvert.DeserializeObject<UISchemaElement>(Person.UI_Schema);
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.
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
.
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:
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:
History
- 5th October, 2020: First published