Search code examples
c#json.netjson-deserialization

JSON Deserialization: handling missing properties with a custom JsonConverter


I made a JSON Web Token class for a project I'm building:

[JsonObject]
public class JwtPayload
{
    [JsonProperty("iss", NullValueHandling = NullValueHandling.Ignore)]
    public string Issuer { get; set; } = "https://mycompany.com/myIdentityServices";

    [JsonProperty("sub", NullValueHandling = NullValueHandling.Ignore, DefaultValueHandling = DefaultValueHandling.IgnoreAndPopulate)]
    public string Subject { get; set; }

. . .

    [JsonProperty("nbf", NullValueHandling = NullValueHandling.Ignore, DefaultValueHandling = DefaultValueHandling.IgnoreAndPopulate)]
    [JsonConverter(typeof(JsonUnixTimestampConverter))]
    public DateTime? NotBefore { get; set; } = null;

    [JsonProperty("iat", NullValueHandling = NullValueHandling.Ignore, DefaultValueHandling = DefaultValueHandling.IgnoreAndPopulate)]
    [JsonConverter(typeof (JsonUnixTimestampConverter))]
    public DateTime? IssuedAt { get; set; }

. . .

}

I have a JsonConverter for the Unix Timestamp conversion that looks like this:

public class JsonUnixTimestampConverter : JsonConverter
{
    public override bool CanRead => true;

    public override void WriteJson(JsonWriter writer, object value, JsonSerializer serializer)
    {
        var dt = value as DateTime?;
        if (dt.HasValue)
        {
            writer.WriteRawValue(dt.Value.ToUnixTimestamp().ToString());
        }
        else
        {
            writer.WriteNull();
        }
    }

    public override object ReadJson(JsonReader reader, Type objectType, object existingValue,
        JsonSerializer serializer)
    {
        if (reader.TokenType == JsonToken.Null)
        {
            return GetDefault(objectType);
        }

        var text = reader.ReadAsString();
        return text.FromUnixTimestamp();
    }

    public override bool CanConvert(Type objectType)
    {
        return objectType == typeof (DateTime)  || objectType == typeof(DateTime?);
    }

    private object GetDefault(Type type)
    {
        if (type.IsValueType)
        {
            return Activator.CreateInstance(type);
        }
        return null;
    }
}

Serializing the token works swimmingly... I get exactly the JSON I'm expecting. The problem is, if I take a JSON token and try to do a JsonConvert.Deserialize<JwtPayload>("my JSON") where the "nbf" value is missing, my deserialization blows up as it tries to parse "iat" as a Unix timestamp, which doesn't go over so well.

Clearly I have a problem in my JsonConverter class, but almost all the examples I see online talk about converting a class to a string, not a string to a class, so it's difficult to understand what I'm doing wrong. Can someone help me?

I have tried different combinations of NullValueHandling and DefaultValueHandling, so I'm thinking the problem is in the JsonConverter class, but I could be wrong.


Solution

  • The problem is that JsonReader.ReadAsString() Reads the next JSON token from the stream as a String. You want to parse the current JSON token as a string. Thus:

        public override object ReadJson(JsonReader reader, Type objectType, object existingValue,
            JsonSerializer serializer)
        {
            if (reader.TokenType == JsonToken.Null)
            {
                return GetDefault(objectType);
            }
    
            var text = (string)JToken.Load(reader);
            return text.FromUnixTimestamp();
        }
    

    Using (string)JToken.Load(reader) instead of JsonReader.Value ensures that the reader will be positioned at the end of the current value in case it's a more complex object that you expect.

    Incidentally, if you are manually parsing and formatting UNIX time stamps as strings, you should be sure to do so in the invariant culture not the current culture. Or better yet let Json.NET do it for you by reading and writing your time stamps as long rather than string:

        public override void WriteJson(JsonWriter writer, object value, JsonSerializer serializer)
        {
            var dt = value as DateTime?;
            if (dt.HasValue)
            {
                writer.WriteValue(dt.Value.ToUnixTimestamp());
            }
            else
            {
                writer.WriteNull();
            }
        }
    
        public override object ReadJson(JsonReader reader, Type objectType, object existingValue,
            JsonSerializer serializer)
        {
            if (reader.TokenType == JsonToken.Null)
            {
                return GetDefault(objectType);
            }
    
            var stamp = (long)JToken.Load(reader);
            return stamp.FromUnixTimestamp();
        }
    

    Using

    public static class UnixTimeExtensions
    {
        const long SecondsToMilliseconds = 1000L;
        const long MillisecondsToTicks = 10000L;
    
        static readonly DateTime utcEpochStart = new DateTime(1970, 1, 1, 0, 0, 0, 0, DateTimeKind.Utc);
    
        public static DateTime FromUnixTimestamp(this long stamp)
        {
            return utcEpochStart.AddSeconds(stamp);
        }
    
        public static long ToUnixTimestamp(this DateTime dateTime)
        {
            var span = dateTime.ToUniversalTime() - utcEpochStart;
            return span.Ticks / (SecondsToMilliseconds * MillisecondsToTicks);
        }
    }