5
\$\begingroup\$

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.

\$\endgroup\$
1
\$\begingroup\$

This was a short-lived project. I had to abandon this approach because it is not possible to chain JsonTextReaders. They do not support the decorator pattern or cannot be connected in any other way so one reader cannot work with the result of the previous one.

I discovered this disadvanted when I wanted to add another module for validating or fixing property names (e.g. beginning or ending with a whitespace).

Instead, I now use JToken.Parse and my JsonVisitor to modify the tree and one of them still uses the PrettyTypeExpander under the hood.

\$\endgroup\$

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service, privacy policy and cookie policy

Not the answer you're looking for? Browse other questions tagged or ask your own question.