0

I have a bunch of Json files that I want to deserialize to immutable classes.

E.g. a Json file like this :

{
  "Prop1": 5,
  "Prop3": {
    "NestedProp1": "something",
    "NestedProp3": 42
  }
}

Should be deserialized to classes like that:

public class Outer 
{
  public int Prop1 { get; }
  public int Prop2 { get; }
  public Inner Prop3 { get; }
  public string Prop4 { get; }
  public Outer(int prop1, int? prop2, Inner prop3, string? prop4)
  {
    Prop1 = prop1;
    Prop2 = prop2 ?? GetSensibleRuntimeProp2Default();
    Prop3 = prop3;  
    Prop4 = prop4 ?? GetSensibleRuntimeProp4Default();
  }
...
}

public class Inner
{
  public string NestedProp1 { get; }
  public int NestedProp2 { get; }
  public int NestedProp3 { get; }
  public Inner(string nestedProp1, int? nestedProp2, int nestedProp3)
  {
    NestedProp1 = nestedProp1;
    NestedProp2 = nestedProp2 ?? GetSensibleRuntimeNestedProp2Default();
    NestedProp3 = nestedProp3;
  }
...
}

As one can see, some constructor parameters are nullable (ref or value types), so that some default value can be injected in case the value is not specified in the Json file. However the matching properties in the classes are not nullable.

The problem is System.Text.Json deserialization requires that constructor parameters and properties have the exact same type so if I try this I get an exception stating this requirement which is indeed documented.

How would I be able to work around this limitation? Could I somehow inject code in the deserilization process to insert my own policy for deserializing objects (while letting the default process handle values and arrays)?

I am dealing with existing classes and Json and I am not allowed to make changes like adding a nullable property to the classes in order to match the constructor parameter. The thing actually worked using Newtonsoft.Json and I am asked to convert it to using System.Text.Json.

I wrote come code that uses reflection to deserialize the first level object in the Json file using the target class constructor that best matches the Json object properties. It looks like that:

public static object CreateInstance(Type targetType, JsonObject prototype, JsonSerializerOptions serializerOptions)
{
    var constructors = GetEligibleConstructors(targetType);
    var prototypePropertySet = prototype.Select(kvp => kvp.Key)
          .ToImmutableHashSet(StringComparer.CurrentCultureIgnoreCase);
    var bestMatch = FindBestConstructorMatch(prototypePropertySet, constructors);
    if (bestMatch is null)
        throw new NoSuitableConstructorFoundException($"COuld not find a suitable constructor to instanciate {targetType.FullName} from \"{prototype.ToJsonString()}\"");
    var valuedParams = GetParameterValues(bestMatch, prototype, serializerOptions);
    return bestMatch.Constructor.Invoke(bestMatch.Parameters.Select(p => valuedParams[p.Name]).ToArray());
}

(I'm deserializing from a JsonObject and not from text but I don't think it's relevant to the issue)

So basically:

  • get all public constructors of the target type,
  • find the one that has the most parameters in common with the Json object properties
  • get the parameters values from the Json using standard System.Text.Json deserialization
  • Invoke the constructor

Obviously this method limits me to the first level object since all the child properties will be handled by standard deserialization.

I would like to be able to do that recursively by using a similar code in a JsonConverter that would be called on deserializing Json objects while the deserialization of Json arrays or primitive values would be left to the standard converters.

Manuel Ornato
  • 19
  • 1
  • 3
  • Just forget System.Text.Json exists, the sooner the better. You are just waisting your and time of another people trying to do something. – Serge Feb 10 '22 at 16:32
  • If you can't change your classes `Inner` and `Outer`, your option is to create a [custom `JsonConverter`](https://learn.microsoft.com/en-us/dotnet/standard/serialization/system-text-json-converters-how-to) for each type. Or you could try writing a generic converter using reflection. System.Text.Json does not make its contract information public, as explained in [System.Text.Json API is there something like IContractResolver](https://stackoverflow.com/q/58926112/3744182), so you can't inject custom construction logic in runtime. – dbc Feb 10 '22 at 16:46
  • There are some 3rd party packages that enhance System.Text.Json, maybe they will work for you here. See e.g. [Json.Net JsonConstructor attribute alternative for System.Text.Json](https://stackoverflow.com/q/58453049/3744182) – dbc Feb 10 '22 at 16:49
  • @dbc, thanks I will check that but the reason for getting rid of Json.Net in the first place was to reduce dependency on 3rd party packages... ;-) – Manuel Ornato Feb 10 '22 at 18:17
  • @dbc, the code is in a library that will be consumed to deserialize to classes I can't know at compile time, so specific converters are not possible (unless emitting code at runtime but I'd rather not). I have actually written reflection code that can deal with nullable parameters on the first level (outer) classes but I'm stuck on how to do this recursively for nested objects. – Manuel Ornato Feb 10 '22 at 18:25
  • I think that System.Text.Json may not be the best serializer for you at this point. Even if you get it to work using custom converters, the performance hit in writing your own reflection code is likely to make the overall performance worse than Newtonsoft. That being said, if you can share reflection code that works for a single class, perhaps we can suggest how you could make that work for multiple classes using the [factory pattern](https://learn.microsoft.com/en-us/dotnet/standard/serialization/system-text-json-converters-how-to#sample-factory-pattern-converter). – dbc Feb 10 '22 at 23:24
  • @dbc, I realize that, maybe it's too soon but it works well for us apart from this specific issue. Performance hit is not a problem here, this deserialization process is a one time thing that will occur at the beginning of applications for configuration purposes. Using reflection is totally OK by our requirements in this case. – Manuel Ornato Feb 11 '22 at 23:36

2 Answers2

0

Following @dbc advice, I used the JsonConverterFactory pattern to address my issue.

Here are the two classes I wrote. The converter itself:

public class CustomObjectConverter<T> : JsonConverter<T> where T : class
{
    private record struct UnmatchedParameterInfo(string Name, Type Type, bool AcceptsNullValue);
    private record struct MatchedParameterInfo(string Name, Type Type, object Value);

    // ConstructorMatcher is a helper class that will keep track of 
    // Json properties that can be matched with the parameters of 
    // a specific constructor as they are read from the reader
    private class ConstructorMatcher
    {
        public ConstructorInfo ConstructorInfo { get; }
        private Dictionary<string, UnmatchedParameterInfo> UnmatchedParameters { get; }
        private Dictionary<string, MatchedParameterInfo> MatchedParameters { get; } = new();

        private ImmutableList<string> ParameterList { get; }

        public ConstructorMatcher(ConstructorInfo constructorInfo)
        {
            ConstructorInfo = constructorInfo ?? throw new ArgumentNullException(nameof(constructorInfo));
            var parameters = constructorInfo.GetParameters();
            if (parameters.Any(p => p.Name is null))
                throw new Exception("<useful exception message>");
            ParameterList = parameters.Select(p => p.Name!).ToImmutableList();
            UnmatchedParameters = 
                 parameters.ToDictionary(
                              p => p.Name!, 
                              p => new UnmatchedParameterInfo(
                                           p.Name!, 
                                           p.ParameterType,
                                           AcceptsNullValue(p.ParameterType)), 
                              StringComparer.CurrentCultureIgnoreCase);
        }

        // Checks is null can be assigned to a certain type
        private bool AcceptsNullValue(Type type)
        {
            // This is improvable as it doesn't use reference types 
            // nullability information
            return type.IsClass || Nullable.GetUnderlyingType(type) is not null;
        }

        public int MatchCount => MatchedParameters.Count;

        public int UnmatchedCount => UnmatchedParameters.Count;

        // Checks if the constructor still has a yet unmatched parameter
        // named "name"
        public bool HasParameter(string name) => UnmatchedParameters.ContainsKey(name);

        // Returns the type that the constructor expects for parameter "name".
        // The converter needs this because it has to know beforehand the type
        // of the property it is about to deserialize.
        public Type GetTypeForParameter(string name)
        {
            return UnmatchedParameters.TryGetValue(name, out var info) 
                       ? info.Type 
                       : throw new Exception("<useful exception message>");
        }

        // Binds a value to a constructor parameter
        public void AddParameterValue(string name, Type type, object? value)
        {
            if (!UnmatchedParameters.Remove(name, out var unmatchedParameterInfo))
                throw new Exception("<useful exception message>");
            if (unmatchedParameterInfo.Type != type)
                throw new Exception("<useful exception message>");
            MatchedParameters.Add(unmatchedParameterInfo.Name, 
                                  new MatchedParameterInfo(
                                         unmatchedParameterInfo.Name,
                                         unmatchedParameterInfo.Type, 
                                         value));
        }

        // Checks if the constructor has some unbound parameters
        // that won't accept null values
        public bool HasNonNullableUnmatchedParameters => UnmatchedParameters.Values.Any(upi => !upi.AcceptsNullValue);

        // Gets parameter values in the righ order for constructor invocation
        public object?[] GetInvocationParameters()
        {
            return ParameterList.Select(parameterName => MatchedParameters.TryGetValue(parameterName, out var mpi)
                                                             ? mpi.Value
                                                             : null)
                                .ToArray();
        }
    }

    // For each Json property:
    // - read the Json property name, 
    // - eliminate constructors that don't have a parameter with 
    //   a compatible name
    // - get the expected type for this property (end throw if all 
    //   constructors don't expect the same type for the same property)
    // - deserialize the property value to the expected type
    // - bind the deserialized value to the matching parameter of each 
    //   candidate constructor
    // When all properties are read, invoke the constructor that has the 
    // most parameters bound to values, with the less unbound parameters
    // (with the condition that all unbound parameters can be bound to null)
    public override T? Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
    {
        if (reader.TokenType != JsonTokenType.StartObject)
        {
            throw new JsonException();
        }

        var candidates = typeToConvert.GetConstructors().Select(ci => new ConstructorMatcher(ci)).ToImmutableList();

        while (reader.Read())
        {
            if (reader.TokenType == JsonTokenType.EndObject)
            {
                var bestCandidate = candidates
                                    .Where(c => !c.HasNonNullableUnmatchedParameters)
                                    .OrderByDescending(c => c.MatchCount)
                                              .ThenBy(c => c.UnmatchedCount)
                                              .FirstOrDefault() ??
                                    throw new NoSuitableConstructorFoundException("<useful exception message>");
                return (T?)bestCandidate.ConstructorInfo.Invoke(bestCandidate.GetInvocationParameters());
            }

            if (reader.TokenType != JsonTokenType.PropertyName)
            {
                throw new JsonException();
            }

            var propertyName = reader.GetString() ?? throw new Exception("<useful exception message>");

            candidates = candidates.Where(c => c.HasParameter(propertyName)).ToImmutableList();
            var possibleTypes = candidates.Select(c => c.GetTypeForParameter(propertyName))
                                          .Distinct()
                                          .ToArray();
            if (possibleTypes.Length > 1)
                throw new AmbiguousConfigurationException("<useful exception message>");

            var propertyType = possibleTypes[0];

            var value = JsonSerializer.Deserialize(ref reader, propertyType, options);

            foreach (var constructorMatcher in candidates)
            {
                constructorMatcher.AddParameterValue(propertyName, propertyType, value);
            }
        }

        throw new JsonException();
    }

    public override void Write(Utf8JsonWriter writer, T value, JsonSerializerOptions options)
    {
        writer.WriteStartObject();
        foreach (var property in typeof(T).GetProperties())
        {
            writer.WritePropertyName(options.PropertyNamingPolicy?.ConvertName(property.Name) ?? property.Name);
            JsonSerializer.Serialize(value, options);
        }
        writer.WriteEndObject();
    }
}

Now for the converter factory the problem we have to address is "to which target types exactly our converter should be applied?"

We'd want to handle all Json objects but there is nothing that clearly ties Json objects with a certain class of CLR types specifically. Json objects can be deserialized to POCO classes but also dictionaries and structs. Conversely collection classes will typically be deserialized from Json arrays. Some Json objects can also be handled by custom converters from the serialization options. I'm quite sure our strategy can be improved as we limited ourselves to what was sufficient in our specific situation.

We chose to apply our converter to all classes that don't implement IEnumerable (the main limitation being we dont handle structs, I guess).

Furthermore, if we find a more specific converter in the serialization options that can convert the target type, we will apply it instead of our converter.

public class CustomObjectConverterFactory: JsonConverterFactory
{
    public override bool CanConvert(Type typeToConvert)
    {
        return typeToConvert.IsClass && !typeof(IEnumerable).IsAssignableFrom(typeToConvert);
    }

    public override JsonConverter? CreateConverter(Type typeToConvert, JsonSerializerOptions options)
    {
        var moreSpecificConverter = options.Converters.FirstOrDefault(c => c != this && c.CanConvert(typeToConvert));
        if (moreSpecificConverter is not null)
            return moreSpecificConverter is JsonConverterFactory moreSpecificFactory
                       ? moreSpecificFactory.CreateConverter(typeToConvert, options)
                       : moreSpecificConverter;
        return (JsonConverter?)Activator.CreateInstance(typeof(CustomObjectConverter<>).MakeGenericType(typeToConvert));
    }
}
Manuel Ornato
  • 19
  • 1
  • 3
  • I don't think I would not accept a pull request with this solution. It's very complex and you never know if it works until runtime. I hope your test coverage is good for this. – tymtam Feb 22 '22 at 00:47
  • @tymtam I quite agree, it involves re-implementing some deserialization logic (eg. finding the best constructor) which is bad. Unfortunately, I don't think System.Text.Json would allow a better solution at its present state (.net 6). So I guess the alternative answer could be "wait for a future version allowing more customisation". – Manuel Ornato Feb 23 '22 at 08:27
0

I think that the simplest solution is to use classes with nullable values for deserialisation and then transform these into your desired classes that have non-nullable properties.

I think this solution is:

  1. very simple
  2. easy to understand
  3. not taking away compile time checks
  4. easy to maintain.

Option 2 below allows arbitrarily complex logic for when the value for a give property is null (throw, log, run some code to get default, get value from configuration).

public class OuterExternal // <-- this is given to the deserialise method
{
  public int? Prop1 { get; }
  ...
  public string? Prop4 { get; }
  ...
}

public class Outer
{
  public int Prop1 { get; }
  ...
  public string Prop4 { get; }
}

You can create Outer objects using a constructor or a factory method.

public class Outer
{
  ...
  // Option 1: Use constructor
  public Outer(OuterExternal external)
    => this(
      prop1: external.prop1 ?? GetSensibleRuntimeProp1Default(),
      ...
      prop4: ...)
  }
}

public class SomeService
{
  // Option 2 Construction done somewhere else:
  private Outer FromExternal(OuterExternal external)
  {
    return new Outer(
      prop1: external.prop1 ?? GetValueForProp1BecauseInputWasNull(someState),
      ...,
      prop4: ...);
  }
} 
tymtam
  • 31,798
  • 8
  • 86
  • 126
  • Simple indeed, however this is not an answer to the question stated in the OP title. The code is to be used in a library and the classes that are to be deserialized are provided at runtime by the consumer of the library, they are not known at compile time. – Manuel Ornato Feb 23 '22 at 08:18