Inside a custom JsonConverter
you can use the IReferenceResolver
returned by JsonSerializer.ReferenceResolver
to manually read and write Json.NET's "$id"
and "$ref"
properties.
The following converter provides a template for this:
public abstract class ReferenceHandlingCustomCreationConverter<T> : JsonConverter where T : class
{
const string refProperty = "$ref";
const string idProperty = "$id";
public override bool CanConvert(Type objectType)
{
return typeof(T).IsAssignableFrom(objectType);
}
protected virtual T Create(Type objectType, T existingValue, JsonSerializer serializer, JObject obj)
{
return existingValue ?? (T)serializer.ContractResolver.ResolveContract(objectType).DefaultCreator();
}
protected abstract void Populate(JObject obj, T value, JsonSerializer serializer);
protected abstract void WriteProperties(JsonWriter writer, T value, JsonSerializer serializer, JsonObjectContract contract);
public override sealed object ReadJson(JsonReader reader, Type objectType, object existingValue, JsonSerializer serializer)
{
var contract = serializer.ContractResolver.ResolveContract(objectType);
if (!(contract is JsonObjectContract))
{
throw new JsonSerializationException(string.Format("Invalid non-object contract type {0}", contract));
}
if (!(existingValue == null || existingValue is T))
{
throw new JsonSerializationException(string.Format("Converter cannot read JSON with the specified existing value. {0} is required.", typeof(T)));
}
if (reader.MoveToContent().TokenType == JsonToken.Null)
return null;
var obj = JObject.Load(reader);
var refId = (string)obj[refProperty].RemoveFromLowestPossibleParent();
var objId = (string)obj[idProperty].RemoveFromLowestPossibleParent();
if (refId != null)
{
var reference = serializer.ReferenceResolver.ResolveReference(serializer, refId);
if (reference != null)
return reference;
}
var value = Create(objectType, (T)existingValue, serializer, obj);
if (objId != null)
{
// Add the empty array into the reference table BEFORE poppulating it, to handle recursive references.
serializer.ReferenceResolver.AddReference(serializer, objId, value);
}
Populate(obj, value, serializer);
return value;
}
public override sealed void WriteJson(JsonWriter writer, object value, JsonSerializer serializer)
{
var contract = serializer.ContractResolver.ResolveContract(value.GetType());
if (!(contract is JsonObjectContract))
{
throw new JsonSerializationException(string.Format("Invalid non-object contract type {0}", contract));
}
if (!(value is T))
{
throw new JsonSerializationException(string.Format("Converter cannot read JSON with the specified existing value. {0} is required.", typeof(T)));
}
writer.WriteStartObject();
if (serializer.ReferenceResolver.IsReferenced(serializer, value))
{
writer.WritePropertyName(refProperty);
writer.WriteValue(serializer.ReferenceResolver.GetReference(serializer, value));
}
else
{
writer.WritePropertyName(idProperty);
writer.WriteValue(serializer.ReferenceResolver.GetReference(serializer, value));
WriteProperties(writer, (T)value, serializer, (JsonObjectContract)contract);
}
writer.WriteEndObject();
}
}
public static partial class JsonExtensions
{
public static JsonReader MoveToContent(this JsonReader reader)
{
if (reader.TokenType == JsonToken.None)
reader.Read();
while (reader.TokenType == JsonToken.Comment && reader.Read())
;
return reader;
}
public static JToken RemoveFromLowestPossibleParent(this JToken node)
{
if (node == null)
return null;
var contained = node.AncestorsAndSelf().Where(t => t.Parent is JContainer && t.Parent.Type != JTokenType.Property).FirstOrDefault();
if (contained != null)
contained.Remove();
// Also detach the node from its immediate containing property -- Remove() does not do this even though it seems like it should
if (node.Parent is JProperty)
((JProperty)node.Parent).Value = null;
return node;
}
}
Implementing the converter requires implementing the two abstract methods:
protected abstract void Populate(JObject obj, T value, JsonSerializer serializer);
protected abstract void WriteProperties(JsonWriter writer, T value, JsonSerializer serializer, JsonObjectContract contract);
One default generic implementation might look like:
public class DefaultReferenceHandlingCustomCreationConverter<T> : ReferenceHandlingCustomCreationConverter<T> where T : class
{
protected override void Populate(JObject obj, T value, JsonSerializer serializer)
{
using (var reader = obj.CreateReader())
serializer.Populate(reader, value);
}
protected override void WriteProperties(JsonWriter writer, T value, JsonSerializer serializer, JsonObjectContract contract)
{
foreach (var property in contract.Properties.Where(p => p.Writable && !p.Ignored))
{
// TODO: handle JsonProperty attributes including
// property.Converter, property.IsReference, property.ItemConverter, property.ItemReferenceLoopHandling,
// property.ItemReferenceLoopHandling, property.ObjectCreationHandling, property.ReferenceLoopHandling, property.Required
var itemValue = property.ValueProvider.GetValue(value);
writer.WritePropertyName(property.PropertyName);
serializer.Serialize(writer, itemValue);
}
}
}
Using it, then you would serialize as follows:
var settings = new JsonSerializerSettings
{
Converters = { new DefaultReferenceHandlingCustomCreationConverter<RootObject>() },
ReferenceLoopHandling = ReferenceLoopHandling.Serialize,
};
var json = JsonConvert.SerializeObject(parent, Formatting.Indented, settings);
Notes:
The converter is designed to work with c# classes that are serialized as JSON objects. It would also be possible to create a converter that manually writes and reads "$ref"
, "$id"
and "$values"
properties for collections, e.g. as shown in this answer to Cannot preserve reference to array or readonly list, or list created from a non-default constructor.
The converter splits up the tasks of creating and populating the object during deserialization, and thus will not work with objects with only parameterized constructors. This is required to make recursive self-references resolve correctly.
Serializing with ReferenceLoopHandling.Serialize
is required.
Sample fiddle here.