Handling abstract types in json.net
can be sometimes challenging because it requires a very long and complex syntax like:
"$type": "Namespace.Type, Assembly"
It's not that difficult if you have simple classes but as soon as there is some generics involved it becomes tricky pretty quickly:
"$type": "Namespace.Type`2[[System.Int32, mscorlib,..][System.String, mscorlib,..]], Assembly"
So I'd rather write
"$t": "Type<int, string>"
and be done.
Solving this required to implement your own JsonTextReader
and override the Read
method. Here I'm looking for my new special property $t
that stands for a shortcut and a pretty type string. If found, it rewirtes this property by using the original property name $type
and the PrettyTypeExpander
to convert the friendly name into a fully qualified json.net
type description.
[UsedImplicitly]
public class PrettyTypeReader : JsonTextReader
{
private readonly string _typePropertyName;
private readonly PrettyTypeExpander _prettyTypeExpander;
private bool _isPrettyType;
public PrettyTypeReader(TextReader reader, [NotNull] string typePropertyName, [NotNull] Func<string, Type> typeResolver)
: base(reader)
{
_typePropertyName = typePropertyName ?? throw new ArgumentNullException(nameof(typePropertyName));
_prettyTypeExpander = new PrettyTypeExpander(typeResolver ?? throw new ArgumentNullException(nameof(typeResolver)));
}
public PrettyTypeReader(TextReader reader, params Type[] assemblyProviders)
: this(reader, "$t", PrettyTypeResolver.Create(assemblyProviders))
{
}
private const string DefaultTypePropertyName = "$type";
public override bool Read()
{
if (base.Read() is var hasToken)
{
switch (TokenType)
{
// Replace custom type-property-name with the default one, e.g. "-t" -> "$type"
case JsonToken.PropertyName when IsCustomTypePropertyName(Value):
SetToken(JsonToken.PropertyName, DefaultTypePropertyName);
_isPrettyType = true;
break;
// Expand type name definition, e.g. "MyType" -> "Namespace.MyType, Assembly"
case JsonToken.String when _isPrettyType && Value is string typeName:
SetToken(JsonToken.String, _prettyTypeExpander.Expand(typeName));
break;
default:
_isPrettyType = false;
break;
}
}
return hasToken;
}
private bool IsCustomTypePropertyName(object value)
{
return value is string propertyName && propertyName.Equals(_typePropertyName);
}
}
The PrettyTypeExpander
parses and analyzes the pretty-string and tries to resolve the actual type. Then it builds the full-name.
internal class PrettyTypeExpander
{
private static readonly IImmutableDictionary<string, Type> Types =
ImmutableDictionary
.Create<string, Type>(StringComparer.OrdinalIgnoreCase)
.Add("bool", typeof(Boolean))
.Add("byte", typeof(Byte))
.Add("sbyte", typeof(SByte))
.Add("char", typeof(Char))
.Add("decimal", typeof(Decimal))
.Add("double", typeof(Double))
.Add("float", typeof(Single))
.Add("int", typeof(Int32))
.Add("uint", typeof(UInt32))
.Add("long", typeof(Int64))
.Add("ulong", typeof(UInt64))
.Add("object", typeof(Object))
.Add("short", typeof(Int16))
.Add("ushort", typeof(UInt16))
.Add("string", typeof(String));
// Used to specify user-friendly type names like: "List<int>" instead of "List`1[System.Int32...]" etc.
// https://regex101.com/r/QZ5T5I/1/
// language=regexp
private const string PrettyTypePattern = @"(?<type>(?i)[a-z0-9_.]+)(?:\<(?<genericArguments>(?i)[a-z0-9_., ]+)\>)?";
public PrettyTypeExpander([NotNull] Func<string, Type> typeResolver)
{
TypeResolver = typeResolver;
}
private Func<string, Type> TypeResolver { get; }
public string Expand(string prettyType)
{
var match =
Regex
.Match(prettyType, PrettyTypePattern, RegexOptions.ExplicitCapture)
.OnFailure(_ => new ArgumentException($"Invalid type alias: '{prettyType}'."));
var genericArguments = CreateGenericArguments(match.Groups["genericArguments"]);
var type = ResolveType($"{match.Groups["type"].Value}{genericArguments.Signature}");
return $"{type.FullName}{genericArguments.FullName}, {type.Assembly.GetName().Name}";
}
// Signature: "<, >"
// FullName: "[[T1],[T2]]" - the "`2" prefix is provided by type-full-name later
private (string Signature, string FullName) CreateGenericArguments(Group genericArguments)
{
if (genericArguments.Success)
{
// "<, >"
var commas = string.Join(string.Empty, genericArguments.Value.Where(c => c == ',').Select(c => $"{c} "));
var signature = $"<{commas}>";
var genericArgumentNames = genericArguments.Value.Split(',').Select(x => x.Trim()).ToList();
var genericArgumentFullNames =
(
from name in genericArgumentNames
let genericType = GetTypeByAlias(name) ?? ResolveType(name)
select $"[{genericType.FullName}, {genericType.Assembly.GetName().Name}]"
);
// Creates: "[[Namespace.T1, ...],[Namespace.T2, ...]]"
var fullName = $"[{string.Join(",", genericArgumentFullNames)}]";
return (signature, fullName);
}
else
{
return (default, default);
}
}
private static Type GetTypeByAlias(string name) => Types.TryGetValue(name, out var t) ? t : default;
private Type ResolveType(string typeName)
{
return
TypeResolver(typeName) ??
Type.GetType(typeName, ignoreCase: true, throwOnError: false) ??
throw DynamicException.Create("TypeNotFound", $"Could not resolve '{typeName}'.");
}
}
There's one more utility that scans assemblies for type definitions any tries to find a type by it's pretty-string. To reduce some noise I exclude anonymous and closure types.
public static class PrettyTypeResolver
{
public static Func<string, Type> Create(params Type[] assemblyProviders)
{
var types =
(
from assemblyProvider in assemblyProviders.Distinct()
from type in assemblyProvider.Assembly.GetTypes()
let prettyName = type.ToPrettyString()
where !prettyName.StartsWith("<>f__AnonymousType") && !prettyName.StartsWith("<>c__DisplayClass")
select (type, prettyName)
).ToList();
return prettyName => types.SingleOrDefault(t => SoftString.Comparer.Equals(t.prettyName, prettyName)).type;
}
}
As my tests show this works quite good. I'm not really concerned about type conflics as this is intended to be used for very specific polymorphic custom types so they should not exist anywhere else. Alghough specifying concrete types would be very easy. You would just need to create a different type-resolver and inject this to the reader.
Here is how I tested it and how I intend to use it:
[TestClass]
public class PrettyTypeReaderTest
{
[TestMethod]
public void Deserialize_CanResolveTypeByAlias()
{
var json =
@"
{
""Test0"": {
""$t"": ""JsonTestClass0""
},
""Test1"": {
""$t"": ""JsonTestClass1<int>""
},
""Test2"": {
""$t"": ""JsonTestClass2<int, string>""
}
}
";
using (var streamReader = json.ToStreamReader())
using (var jsonTextReader = new PrettyTypeReader(streamReader, typeof(PrettyTypeReaderTest)))
{
var jsonSerializer = new JsonSerializer { TypeNameHandling = TypeNameHandling.Auto };
var testClass0 = jsonSerializer.Deserialize<JsonTestClass0>(jsonTextReader);
Assert.IsNotNull(testClass0);
Assert.IsNotNull(testClass0.Test0);
Assert.IsNotNull(testClass0.Test1);
Assert.IsNotNull(testClass0.Test2);
}
}
}
internal class JsonTestClass0
{
public JsonTestClass0 Test0 { get; set; }
public TestInterface Test1 { get; set; }
public TestInterface Test2 { get; set; }
}
internal interface TestInterface
{
}
internal class JsonTestClass1<T1> : TestInterface
{
}
internal class JsonTestClass2<T1, T2> : TestInterface
{
}
I'd be happy to read your feedback and improvement suggestions.