InvalidCastException in custom JsonConverter

89 views Asked by At

I am attempting to create a null safe json converter that handles json string values like "null" and return a default instance of a type.

So far it's been working as expected, but I'm running into a casting issue with nullable integers in a nested object on the 'id' property

{
    "company_detail": null,
    "country_code": [
        {
            "id": 1,
            "code": "1",
            "country_name": "United States (+1)"
        },
        {
            "id": 2,
            "code": "1",
            "country_name": "Canada (+1)"
        },
        {
            "id": 3,
            "code": "44",
            "country_name": "United Kingdom (+44)"
        }
    ]
}

The exception is thrown when reading a session variable from HttpContext or when deserializing manually

return _accessor.HttpContext.Session.Get<UserInformation>(SessionKeyNames.UserInformation);

 var info = JsonConvert.DeserializeObject<UserInformation>(JsonConvert.SerializeObject(new UserInformation
            {
                CountryCode = new List<CountryCode> { new CountryCode { Id = 1, Code = "1", CountryName = "United States"} }
            }));
Newtonsoft.Json.JsonSerializationException: 'Error setting value to 'Id' on 'Models.CountryCode'.'

InvalidCastException: Unable to cast object of type 'System.Int64' to type 'System.Nullable`1[System.Int32]'.

public class NullSafeJsonConverter : JsonConverter
{
    public override bool CanConvert(Type objectType) => true;

    public override bool CanRead => true;

    public override bool CanWrite => false;

    public override object ReadJson(JsonReader reader, Type objectType, object existingValue, Newtonsoft.Json.JsonSerializer serializer)
    {
        var contract = serializer.ContractResolver.ResolveContract(objectType);
        bool asString = objectType == typeof(string);
        object obj = asString ? String.Empty : obj = contract.DefaultCreator();

        switch (reader.TokenType)
        {
            case JsonToken.Null:
            case JsonToken.None:
                break;
            case JsonToken.StartArray:
                var arr = JArray.Load(reader);
                serializer.Populate(arr.CreateReader(), obj);
                break;
            case JsonToken.String:
                var nullish = String.IsNullOrEmpty((string)reader.Value) || Regex.IsMatch((string)reader.Value, @"^['""]?null['""]?$");
                obj =  (nullish && !asString) ? contract.DefaultCreator() : reader.Value;
                break;
            case JsonToken.StartObject:
                var o = JObject.Load(reader);

                //obj = o.ToObject(objectType);
                serializer.Populate(o.CreateReader(), obj);
                break;
            default:
                obj = reader.Value;
                break;
        }
        return obj;

    }

    public override void WriteJson(JsonWriter writer, object value, Newtonsoft.Json.JsonSerializer serializer)
    {
        throw new NotImplementedException();
    }     
}

The type that is throwing the exception is the 'CountryCode' property

public class UserInformation
{
    /// <summary>
    /// Company detail.
    /// </summary>       
    public CompanyDetail CompanyDetail { get; set; }

    /// <summary>
    /// country code list
    /// </summary>
    public List<CountryCode> CountryCode { get; set; }
}
public class CountryCode
{
    [JsonProperty(PropertyName = "id")]
    public int? Id
    {
        get;
        set;
    }

    [JsonProperty(PropertyName = "code")]
    public string Code
    {
        get;
        set;
    }

    [JsonProperty(PropertyName = "country_name")]
    public string CountryName
    {
        get;
        set;
    }

    public CountryCode()
    {
    }

    public CountryCode(int? id = null, string code = null, string countryName = null)
    {
        Id = id;
        Code = code;
        CountryName = countryName;
    }
}

My startup file contains

public override void Configure(IApplicationBuilder app, IWebHostEnvironment env, IServiceProvider serviceProvider)
{
    JsonConvert.DefaultSettings().Converters.Add(new NullSafeJsonConverter());
}

Note that, for nullable value types, the converter is probably not necessary.

1

There are 1 answers

1
dbc On BEST ANSWER

Your immediate problem is here:

obj = reader.Value;

You have no guarantee that the value in the reader is of the same type as the required objectType. JsonTextReader parses integers as long and floating-point values as double, but objectType might be int, decimal, or even some enum type. So you will need to deserialize or convert reader.Value to the required type.

The following version of the converter corrects this problem by calling Convert.ChangeType() for primitive types, and fixes a few other issues as well:

public class NullSafeJsonConverter : JsonConverter
{
    public override bool CanConvert(Type objectType) => 
        Nullable.GetUnderlyingType(objectType) == null; // For nullable value types, no it's probably not necessary

    public override bool CanRead => true;
    public override bool CanWrite => false;

    public override object ReadJson(JsonReader reader, Type objectType, object existingValue, Newtonsoft.Json.JsonSerializer serializer)
    {
        //TODO: Handle enums serialized as strings.
        //TODO: Handle types with their own converters.
        //TODO: What happens of objectType has a parameterized creator?
        Debug.Assert(CanConvert(objectType)); // Assert no unsupported types.
        var contract = serializer.ContractResolver.ResolveContract(objectType);
        bool asString = objectType == typeof(string);
        object obj = asString ? String.Empty : obj = contract.DefaultCreator();// What happens if DefaultCreator() is null?

        //Skip comments via MoveToContentAndAssert():
        switch (reader.MoveToContentAndAssert().TokenType)
        {
            // TODO
            case JsonToken.Null:
            case JsonToken.None:
                break;
            case JsonToken.StartArray:
                //Loading into a temp JArray is not necessary.
                //var arr = JArray.Load(reader);
                //TODO: This won't work for arrays or read-only collections.
                serializer.Populate(reader, obj);
                break;
            case JsonToken.StartObject:
                //Loading into a temp JObject is not necessary.
                //var o = JObject.Load(reader);
                //obj = o.ToObject(objectType);
                serializer.Populate(reader, obj);
                break;
            case JsonToken.String when asString:
                obj = reader.Value; // No need to do Regex.IsMatch() for strings.
                break;
            case JsonToken.String:
                var nullish = String.IsNullOrEmpty((string)reader.Value) || Regex.IsMatch((string)reader.Value, @"^['""]?null['""]?$");
                obj = nullish ? contract.DefaultCreator() : reader.Value;
                break;
            default:
                obj = reader.Value;
                break;
        }
        var underlyingType = Nullable.GetUnderlyingType(objectType) ?? objectType;
        if (obj != null && obj.GetType() != underlyingType && (underlyingType.IsPrimitive || underlyingType.IsEnum))
            obj = Convert.ChangeType(obj, underlyingType, CultureInfo.InvariantCulture);
        return obj;
    }

    public override void WriteJson(JsonWriter writer, object value, Newtonsoft.Json.JsonSerializer serializer)
    {
        throw new NotImplementedException();
    }     
}

public static partial class JsonExtensions
{
    public static JsonReader AssertTokenType(this JsonReader reader, JsonToken tokenType) => 
        reader.TokenType == tokenType ? reader : throw new JsonSerializationException(string.Format("Unexpected token {0}, expected {1}", reader.TokenType, tokenType));
    
    public static JsonReader ReadToContentAndAssert(this JsonReader reader) =>
        reader.ReadAndAssert().MoveToContentAndAssert();

    public static JsonReader MoveToContentAndAssert(this JsonReader reader)
    {
        ArgumentNullException.ThrowIfNull(reader);
        if (reader.TokenType == JsonToken.None)       // Skip past beginning of stream.
            reader.ReadAndAssert();
        while (reader.TokenType == JsonToken.Comment) // Skip past comments.
            reader.ReadAndAssert();
        return reader;
    }

    public static JsonReader ReadAndAssert(this JsonReader reader)
    {
        ArgumentNullException.ThrowIfNull(reader);
        if (!reader.Read())
            throw new JsonReaderException("Unexpected end of JSON stream.");
        return reader;
    }
}

That being said, I see a few problems with this approach:

  • Arrays and read-only collections cannot be deserialized, because they cannot be populated.
  • Objects with no default creator cannot be deserialized, because they cannot be created.
  • It is unclear how the converter should interact with types that their own converters, e.g. enums types or DateTime.
  • The converter will supersede Newtonsoft's built-in fallback converters for types like DataTable and KeyValuePair<TKey, TValue>.
  • The converter maps null (and "null") values to non-null defaults, but if a value is missing completely the corresponding property value may still be null.

However the specific problem in the question is resolved.

Demo fiddle here.