4
\$\begingroup\$

I have currently two use-cases that require JSON transformations before it can be deserialized.

  • The first use-case requires changing the custom short type property name from $t to $type and resolving full type names from their aliases. This is the same as in my previous question.
  • The second use-case is about fixing property names that contain whitespaces and need to be trimmed like > Name < instead of >Name< (yeah, I've spent quite some time spotting this mistake in a large file recently).

My previous solution supported only resolving type names and was build with a custom JsonTextReader. Now that I wanted to add another trasnformation to it, I noticed that it cannot be chained because the reader supports forward-only reading and there is no way to add to it.

I've changed my strategy and used the JToken.Parse instead with a couple of visitors that work similar to the ExpressionVisitor.

The first one of them is the JsonVisitor that recursively rebuilds the tree and allows to intercept this process by overriding VisitProperty:

public delegate JToken VisitJsonCallback(JToken token);

public class JsonVisitor
{
    public static JToken Visit(JToken token, params JsonVisitor[] visitors)
    {
        return visitors.Aggregate(token, (current, visitor) => visitor.Visit(current));
    }

    public static VisitJsonCallback Create(params JsonVisitor[] visitors) => jToken => Visit(jToken, visitors);

    public JToken Visit(JToken token)
    {
        return
            token is JValue
                ? token
                : VisitInternal(token, CreateJContainer(token.Type));
    }

    private JToken VisitInternal(JToken token, JContainer result)
    {
        if (token is JValue)
        {
            return token;
        }

        foreach (var element in token)
        {
            switch (element)
            {
                case JProperty property:
                    var visitedProperty = VisitProperty(property);
                    result.Add(visitedProperty);
                    break;
                default:
                    result.Add(Visit(element));
                    break;
            }
        }
        return result;
    }

    protected virtual JProperty VisitProperty(JProperty property)
    {
        return new JProperty(property.Name, Visit(property.Value));
    }

    private static JContainer CreateJContainer(JTokenType tokenType)
    {
        switch (tokenType)
        {
            case JTokenType.Object: return new JObject();
            case JTokenType.Array: return new JArray();
            default: throw new ArgumentOutOfRangeException($"Invalid type: {tokenType}");
        }
    }
}

Since I didn't have any other use cases yet, it's the only overridable API right now but I think it should be easy to extend it in future.

Then I have two more of them that cover both use-cases (here in the reverse order)

public class PropertyNameTrimmer : JsonVisitor
{
    protected override JProperty VisitProperty(JProperty property)
    {
        return new JProperty(property.Name.Trim(), Visit(property.Value));
    }
}

public class TypeResolver : JsonVisitor
{
    private readonly string _typePropertyName;
    private readonly Func<string, string> _resolveType;

    public TypeResolver(string typePropertyName, Func<string, string> resolveType)
    {
        _typePropertyName = typePropertyName;
        _resolveType = resolveType;
    }

    protected override JProperty VisitProperty(JProperty property)
    {
        return
            property.Name == _typePropertyName
                ? new JProperty("$type", _resolveType(property.Value.Value<string>()))
                : base.VisitProperty(property);
    }
}

Example

I would use them by passing the result from one to the other:

void Main()
{
    var json = @"{ ""$t"": ""MyType"", ""User  "": ""John"", ""Nested"": { ""$t"": ""OtherType"", ""   ZIP"": 12345 }, ""Numbers"": [1, 2, 3 ] }";

    var jToken = JToken.Parse(json);
    jToken.ToString().Dump("Original"); 

    var propertyNameTrimmer = new PropertyNameTrimmer();
    var typeResolver = new TypeResolver("$t", shortName => shortName + "_resolved");

    var trimmed = propertyNameTrimmer.Visit(jToken);
    trimmed.ToString().Dump(nameof(PropertyNameTrimmer));
    var typeResolved = typeResolver.Visit(trimmed);
    typeResolved.ToString().Dump(nameof(TypeResolver));
}

So calling them in this order produces:

Original

{
  "$t": "MyType",
  "User  ": "John",
  "Nested": {
    "$t": "OtherType",
    "   ZIP": 12345
  },
  "Numbers": [
    1,
    2,
    3
  ]
} 

PropertyNameTrimmer - whitespaces removed from property names

{
  "$t": "MyType",
  "User": "John",
  "Nested": {
    "$t": "OtherType",
    "ZIP": 12345
  },
  "Numbers": [
    1,
    2,
    3
  ]
} 

TypeResolver - property names changed and types resolved

{
  "$type": "MyType_resolved",
  "User": "John",
  "Nested": {
    "$type": "OtherType_resolved",
    "ZIP": 12345
  },
  "Numbers": [
    1,
    2,
    3
  ]
} 

How do you like this solution? Have I missed anything important (but null-checks) and can I improve this in any way?

\$\endgroup\$
  • \$\begingroup\$ Your visitor looks fine for what it does, but why rewrite property names instead of throwing an exception: "Unknown property: 'User ' at position ..."? That also catches other problems like typos ("Usr") and case differences ("Zip"), which are difficult to auto-correct due to ambiguity. \$\endgroup\$ – Pieter Witvoet Nov 29 '18 at 9:56
  • \$\begingroup\$ @PieterWitvoet yeah, it's a tough decision and I actually wasn't sure (and I'm still not) which one would be better, fixing or throwing exceptions. I was also going to allow dashes in property names that I would rewrite to C# valid names. Typos are difficult to handle anyway because if a property is optional it's hard to say whether it's something else or a typo. I guess I cannot eliminate all mistakes but at least reduce the probability. Btw, json is case-insensitive. \$\endgroup\$ – t3chb0t Nov 29 '18 at 10:02
  • \$\begingroup\$ @PieterWitvoet for a complete validation I'll probably should use json-schema but I'm not sure about its license and whether I could use it at work... most likely not so I'm not even trying but even when using json-schema I still need to rewrite some parts of it ;-) \$\endgroup\$ – t3chb0t Nov 29 '18 at 10:05
  • 1
    \$\begingroup\$ A tiny improvement could be to insert a case JValue value: result.Add(value); break; in the loop in VisitInternal(...) \$\endgroup\$ – Henrik Hansen Dec 1 '18 at 8:21
  • 1
    \$\begingroup\$ @HenrikHansen sorry, for the confusion, I only abandoned the JsonTextReader for transforming, I moved it's logic to the JsonVisitor but both are using the PrettyTypeResolver as a dependency. This is the nice part when you have separated everything and can change one implementation and still reuse the others ;-) \$\endgroup\$ – t3chb0t Dec 1 '18 at 8:30

Your Answer

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

Browse other questions tagged or ask your own question.