13

I accidentally introduced a breaking change in my API by switching to System.Text.Json in my ASP.NET Core app. I had a client that was sending a JSON document and was using numbers 1 or 0 for boolean fields instead of true or false:

// What they're sending.
{ "Active": 1 }

// What they should be sending.
{ "Active": true }

The Newtonsoft.Json library automatically handled this by casting numbers to bools (0 = false, everything else = true), but System.Text.Json doesn't do this; it throws an exception instead. This means my API endpoints suddenly stopped working for my silly client who's sending 1's and 0's!

I can't seem to find any mention of this in the migration guide. I would like to restore the behavior back to how Newtonsoft handles it, but I'm not sure if there's a flag to enable it somewhere I'm not seeing, or whether I'll have to write a custom converter.

Could someone help me restore the behavior to be like Newtonsoft's?


Here's some code to demonstrate the issue:

using System;

string data = "{ \"Active\": 1 }";

try
{
    OutputState s1 = System.Text.Json.JsonSerializer.Deserialize<OutputState>(data);
    Console.WriteLine($"OutputState 1: {s1.Active}");
}
catch (Exception ex)
{
    Console.WriteLine($"System.Text.Json failed: {ex.Message}");
}

try
{
    OutputState s2 = Newtonsoft.Json.JsonConvert.DeserializeObject<OutputState>(data);
    Console.WriteLine($"OutputState 2: {s2.Active}");
}
catch (Exception ex)
{
    Console.WriteLine($"Newtonsoft.Json failed: {ex.Message}");
}

public record OutputState(bool Active);

Also a .NET fiddle for an interactive playground: https://dotnetfiddle.net/xgm2u7

dbc
  • 104,963
  • 20
  • 228
  • 340
Phil K
  • 4,939
  • 6
  • 31
  • 56
  • In your example, it seems that using a record is not allowed by `System.Text.Json`: `System.Text.Json failed: Deserialization of reference types without parameterless constructor is not supported. Type 'OutputState'` (though it works with a class). – Métoule Aug 06 '21 at 13:38
  • 2
    Custom converters can be implemented. Have you tried this approach? https://learn.microsoft.com/en-us/dotnet/standard/serialization/system-text-json-converters-how-to?pivots=dotnet-5-0 – Noah Stahl Aug 06 '21 at 13:38
  • 2
    @Métoule It's possible in .NET 5. You are probably on 3.1 or lower: https://learn.microsoft.com/en-us/dotnet/standard/serialization/system-text-json-immutability?pivots=dotnet-5-0#immutable-types-and-records – Phil K Aug 06 '21 at 13:43

1 Answers1

21

You can create a custom JsonConverter<bool> that emulates the logic of Json.NET's JsonReader.ReadAsBoolean():

public class BoolConverter : JsonConverter<bool>
{
    public override void Write(Utf8JsonWriter writer, bool value, JsonSerializerOptions options) =>
        writer.WriteBooleanValue(value);
    
    public override bool Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) =>
        reader.TokenType switch
        {
            JsonTokenType.True => true,
            JsonTokenType.False => false,
            JsonTokenType.String => bool.TryParse(reader.GetString(), out var b) ? b : throw new JsonException(),
            JsonTokenType.Number => reader.TryGetInt64(out long l) ? Convert.ToBoolean(l) : reader.TryGetDouble(out double d) ? Convert.ToBoolean(d) : false,
            _ => throw new JsonException(),
        };
}

Use by adding it to JsonSerializerOptions.Converters:

var options = new JsonSerializerOptions
{
    Converters = { new BoolConverter() },
};
var s1 = System.Text.Json.JsonSerializer.Deserialize<OutputState>(data, options);

Demo fiddle #1 here.

Notes:

  • Json.NET automatically tries to deserialize strings to bool using bool.TryParse() which parses using a comparison that is ordinal and case-insensitive. Simply checking e.g. Utf8JsonReader.ValueTextEquals("true") will not emulate Newtonsoft's case invariance.

  • Json.NET supports deserializing both integers and floating point numbers to bool by attempting to parse the value token to a long or double (or decimal when FloatParseHandling.Decimal is set) and then calling Convert.ToBoolean() on the result. The converter emulates this logic.

  • There may be some boundary conditions where my BoolConverter<bool> and Json.NET do not behave identically, particularly for overflow or rounding-near-zero situations.

  • If you also need to support deserializing numbers to bool?, grab NullableConverterFactory from this answer to How to deserialize an empty string to a null value for all `Nullable<T>` value types using System.Text.Json? and add it to converters like so:

     var options = new JsonSerializerOptions
     {
         Converters = { new BoolConverter(), new NullableConverterFactory() },
     };
    

    This converter factory also fixes another obscure breaking change from Newtonsoft to System.Text.Json, namely that the former will deserialize an empty JSON string "" to any Nullable<T> but the latter will not.

    Demo fiddle #2 here.

dbc
  • 104,963
  • 20
  • 228
  • 340
  • 3
    Alternatively, you can specify a converter to deserialize specific property using an attribute: `[JsonConverter(typeof(BoolConverter))]bool Active { get;set; }`. Then you'd just call `Deserialize` as usual. – n0rd Aug 06 '21 at 19:08