System.Text.Json: Deserialize JSON with automatic casting
Asked Answered
B

8

60

Using .Net Core 3's new System.Text.Json JsonSerializer, how do you automatically cast types (e.g. int to string and string to int)? For example, this throws an exception because id in JSON is numeric while C#'s Product.Id is expecting a string:

public class HomeController : Controller
{
    public IActionResult Index()
    {
        var json = @"{""id"":1,""name"":""Foo""}";
        var o = JsonSerializer.Deserialize<Product>(json, new JsonSerializerOptions
        {
            PropertyNameCaseInsensitive = true,
        });

        return View();
    }
}

public class Product
{
    public string Id { get; set; }
    public string Name { get; set; }
}

Newtonsoft's Json.Net handled this beautifully. It didn't matter if you were passing in a numeric value while C# was expecting a string (or vice versa), everything got deserialized as expected. How do you handle this using System.Text.Json if you have no control over the type format being passed in as JSON?

Backfire answered 29/11, 2019 at 1:31 Comment(0)
P
74
  1. The new System.Text.Json api exposes a JsonConverter api which allows us to convert the type as we like.

    For example, we can create a generic number to string converter:

    public class AutoNumberToStringConverter : JsonConverter<object>
    {
        public override bool CanConvert(Type typeToConvert)
        {
            return typeof(string) == typeToConvert;
        }
        public override object Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
        {
            if(reader.TokenType == JsonTokenType.Number) {
                return reader.TryGetInt64(out long l) ?
                    l.ToString():
                    reader.GetDouble().ToString();
            }
            if(reader.TokenType == JsonTokenType.String) {
                return reader.GetString();
            }
            using(JsonDocument document = JsonDocument.ParseValue(ref reader)){
                return document.RootElement.Clone().ToString();
            }
        }
    
        public override void Write(Utf8JsonWriter writer, object value, JsonSerializerOptions options)
        {
            writer.WriteStringValue( value.ToString());
        }
    }
    
  2. When working with MVC/Razor Page, we can register this converter in startup:

    services.AddControllersWithViews().AddJsonOptions(opts => {
        opts.JsonSerializerOptions.PropertyNameCaseInsensitive= true;
        opts.JsonSerializerOptions.Converters.Insert(0, new AutoNumberToStringConverter());
    });
    

    and then the MVC/Razor will handle the type conversion automatically.

  3. Or if you like to control the serialization/deserialization manually:

    var opts = new JsonSerializerOptions {
        PropertyNameCaseInsensitive = true,
    };
    opts.Converters.Add(new AutoNumberToStringConverter());
    var o = JsonSerializer.Deserialize<Product>(json,opts) ;
    
  4. In a similar way you can enable string to number type conversion as below :

    public class AutoStringToNumberConverter : JsonConverter<object>
    {
        public override bool CanConvert(Type typeToConvert)
        {
            // see https://mcmap.net/q/158643/-c-how-to-determine-whether-a-type-is-a-number
            switch (Type.GetTypeCode(typeToConvert))
            {
                case TypeCode.Byte:
                case TypeCode.SByte:
                case TypeCode.UInt16:
                case TypeCode.UInt32:
                case TypeCode.UInt64:
                case TypeCode.Int16:
                case TypeCode.Int32:
                case TypeCode.Int64:
                case TypeCode.Decimal:
                case TypeCode.Double:
                case TypeCode.Single:
                return true;
                default:
                return false;
            }
        }
        public override object Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
        {
            if(reader.TokenType == JsonTokenType.String) {
                var s = reader.GetString() ;
                return int.TryParse(s,out var i) ? 
                    i :
                    (double.TryParse(s, out var d) ?
                        d :
                        throw new Exception($"unable to parse {s} to number")
                    );
            }
            if(reader.TokenType == JsonTokenType.Number) {
                return reader.TryGetInt64(out long l) ?
                    l:
                    reader.GetDouble();
            }
            using(JsonDocument document = JsonDocument.ParseValue(ref reader)){
                throw new Exception($"unable to parse {document.RootElement.ToString()} to number");
            }
        }
    
    
        public override void Write(Utf8JsonWriter writer, object value, JsonSerializerOptions options)
        {
            var str = value.ToString();             // I don't want to write int/decimal/double/...  for each case, so I just convert it to string . You might want to replace it with strong type version.
            if(int.TryParse(str, out var i)){
                writer.WriteNumberValue(i);
            }
            else if(double.TryParse(str, out var d)){
                writer.WriteNumberValue(d);
            }
            else{
                throw new Exception($"unable to parse {str} to number");
            }
        }
    }
    
Pyrene answered 29/11, 2019 at 6:13 Comment(5)
Shouldn't the converter in (1) be JsonConverter<string> instead of JsonConverter<object>? The current implementation throws System.InvalidCastException: Unable to cast object of type 'System.Collections.Generic.List1[System.String]' to type 'System.Collections.Generic.IList1[System.Object]' when deserializing an array like [12345] to a string[] field. Also you won't need to override the CanConvert() method.Xenophanes
You should serialize and parse numbers in the invariant locale not the current culture locale, e.g. int.TryParse(s,NumberStyles.Integer, NumberFormatInfo.InvariantInfo, out var i) and double.TryParse(s, NumberStyles.Float, NumberFormatInfo.InvariantInfo, out var d)Fledge
StringToNumber and NumberToString worked for me, but adding both gives me error: Unable to cast object of type 'System.Double' to type 'System.Int64'.Fleck
The edited answer is incorrect. The property field must be numeric in order to use AllowReadingFromString, which is the opposite of what the question was asking.Riancho
Looking at revision history, two last changes are invalid/wrong/incorrect, so instead of downvoting this answer I am going to revert those invalid edits.Perth
H
12

You can use JsonNumberHandlingAttribute in your model class in order to specify how to treat number deserialization. The allowed options are specified in JsonNumberHandling enum.

Example of usage:

public class Product
{
    [JsonNumberHandling(JsonNumberHandling.WriteAsString)]
    public string Id { get; set; }
    
    public string Name { get; set; }
}

If serialization from string to int is required, you can use JsonNumberHandling.AllowReadingFromString

Hexad answered 4/6, 2021 at 12:54 Comment(4)
It should be noted that this only applies to use of System.Text.Json with the .NET 5.0 or .NET 6.0 Preview 7 (as of August 2021) framework versions. See the link referenced to JsonNumberHandlingAttribute in the answer, specifically the "Applies to" section.Demmy
Hmm when I place it like this I am getting the When 'JsonNumberHandlingAttribute' is placed on a property or field, the property or field must be a number or a collection errorNickola
Yeah, I think this only works when you want to deserialize a string as a number, not a number as a string. If you look at the migration guide, it sounds like System.Text.Json doesn't support this natively unfortunately, even on .NET 6: learn.microsoft.com/en-us/dotnet/standard/serialization/…Arcane
In other words, if you're receiving data from a bad external source where it will encode IDs that just happen to be all numbers as integers, and others as strings, then you're out of luck. Start writing your own parsers.Riancho
Q
12

To automatically deserialize strings as numbers, you could simply set the NumberHandling property to AllowReadingFromString in the options:

var o = JsonSerializer.Deserialize<Product>(json, new JsonSerializerOptions
{
    // [...]
    NumberHandling = JsonNumberHandling.AllowReadingFromString
});

Note: This will automatically deserialize strings as numbers, but not numbers as string. If you need more advanced logic, you will need a custom converter (see other answers).

Quadrature answered 6/7, 2021 at 17:3 Comment(6)
Json data was like { "No": 2 } and the type was record Data(string No) it wasnt able to deserialize it. Now i understand AllowReadingFromString was expecting data like { "No": "2" } i mean number as string ... So it is a different case.Calamitous
@Calamitous The option is reading from string (deserialize strings as numbers), not deserialize as string. Not sure how to do that.Quadrature
@Calamitous It should be noted that this only applies to use of System.Text.Json with the .NET 5.0 or .NET 6.0 Preview 7 (as of August 2021) framework versions. See the link referenced to NumberHandling in the answer, specifically the "Applies to" section.Demmy
Incomplete Answer. Going from Number to String, not String to Number. This is only half an answer.Concertante
@PatrickKnott As mentioned in my answer, for advanced logic you need a custom converter - see all the other answers. But for people who only need string-to-number deserialization (which is 99%) they should know there's a built-in option for that.Quadrature
I understand, however the question was specific when I viewed question; e.g. int to string and string to int. I don't think I downvoted, I just made it clear this was half an answer because a lot of people don't read beyond the code example. Unfortunately for my organization, someone did exactly what you did, but it was half an answer and did not fully do what the developer intended for it to do. I then wound up fixing it. I doubt it was this post that they used, although it was an excellent part of an answer... but for the sake of any future users, I figured I'd point it out.Concertante
B
7

Unfortunately for me the example of itminus did not work, here is my variant.

public class AutoNumberToStringConverter : JsonConverter<string>
{
    public override bool CanConvert(Type typeToConvert)
    {
        return typeof(string) == typeToConvert;
    }

    public override string Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
    {
        if (reader.TokenType == JsonTokenType.Number)
        {
            if (reader.TryGetInt64(out long number))
            {
                return number.ToString(CultureInfo.InvariantCulture);
            }

            if (reader.TryGetDouble(out var doubleNumber))
            {
                return doubleNumber.ToString(CultureInfo.InvariantCulture);
            }
        }

        if (reader.TokenType == JsonTokenType.String)
        {
            return reader.GetString();
        }

        using var document = JsonDocument.ParseValue(ref reader);
        return document.RootElement.Clone().ToString();
    }

    public override void Write(Utf8JsonWriter writer, string value, JsonSerializerOptions options)
    {
        writer.WriteStringValue(value);
    }
}
Belding answered 25/8, 2020 at 11:10 Comment(2)
This was a very nice implementation. To improve, I think the document.RootElement does not have to be cloned, as we only need it to perform .ToString() before we return and the document goes out of scope.Vander
The best half of the answer. Num -> String.Concertante
A
2

To the date of writing, the NumberHandling property is available only in .NET 5.0 and .NET 6.0 RC, which I can't use. Unfortunately, the string to number converter by itminus didn't work for me either.

So I made another solution that handles different number types and also their nullable variants. I tried to make the code as DRY as possible.

Number and nullable number types

First, the main generic classes for string-to-number and string-to-nullable-number conversion:

public delegate T FromStringFunc<T>(string str);
public delegate T ReadingFunc<T>(ref Utf8JsonReader reader);
public delegate void WritingAction<T>(Utf8JsonWriter writer, T value);

public class StringToNumberConverter<T> : JsonConverter<T> where T : struct
{
    protected ISet<TypeCode> AllowedTypeCodes { get; }
    protected FromStringFunc<T> FromString { get; }
    protected ReadingFunc<T> ReadValue { get; }
    protected WritingAction<T> WriteValue { get; }

    public StringToNumberConverter(ISet<TypeCode> allowedTypeCodes, FromStringFunc<T> fromString, ReadingFunc<T> read, WritingAction<T> write)
    : base()
    {
        AllowedTypeCodes = allowedTypeCodes;
        FromString = fromString;
        ReadValue = read;
        WriteValue = write;
    }

    public override bool CanConvert(Type typeToConvert)
    {
        return AllowedTypeCodes.Contains(Type.GetTypeCode(typeToConvert));
    }

    public override T Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
    {
        if (reader.TokenType == JsonTokenType.String)
        {
            var s = reader.GetString();
            return FromString(s);
        }

        if (reader.TokenType == JsonTokenType.Number)
            return ReadValue(ref reader);

        using JsonDocument document = JsonDocument.ParseValue(ref reader);
        throw new Exception($"unable to parse {document.RootElement} to number");
    }

    public override void Write(Utf8JsonWriter writer, T value, JsonSerializerOptions options)
    {
        WriteValue(writer, value);
    }
}

public class StringToNullableNumberConverter<T> : JsonConverter<T?> where T : struct
{
    private readonly StringToNumberConverter<T> stringToNumber;
    protected WritingAction<T> WriteValue { get; }

    public StringToNullableNumberConverter(ISet<TypeCode> allowedTypeCodes, FromStringFunc<T> fromString, ReadingFunc<T> read, WritingAction<T> write)
    : base()
    {
        stringToNumber = new StringToNumberConverter<T>(allowedTypeCodes, fromString, read, write);
        WriteValue = write;
    }

    public override bool CanConvert(Type typeToConvert)
    {
        return stringToNumber.CanConvert(Nullable.GetUnderlyingType(typeToConvert) ?? typeToConvert);
    }

    public override T? Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
    {
        if (reader.TokenType == JsonTokenType.Null)
            return null;

        return stringToNumber.Read(ref reader, typeToConvert, options);
    }

    public override void Write(Utf8JsonWriter writer, T? value, JsonSerializerOptions options)
    {
        if (!value.HasValue)
            writer.WriteNullValue();
        else
            stringToNumber.Write(writer, value.Value, options);
    }
}

Then a util class that simplifies their usage. It holds non-generalizable, type-exact conversion methods and settings:

static class StringToNumberUtil
{
    public static readonly ISet<TypeCode> intCodes = new HashSet<TypeCode> { TypeCode.Byte, TypeCode.Int16, TypeCode.Int32 };
    public static readonly ISet<TypeCode> longCodes = new HashSet<TypeCode> { TypeCode.Int64 };
    public static readonly ISet<TypeCode> decimalCodes = new HashSet<TypeCode> { TypeCode.Decimal };
    public static readonly ISet<TypeCode> doubleCodes = new HashSet<TypeCode> { TypeCode.Double };

    public static int ParseInt(string s) => int.Parse(s, CultureInfo.InvariantCulture);
    public static long ParseLong(string s) => long.Parse(s, CultureInfo.InvariantCulture);
    public static decimal ParseDecimal(string s) => decimal.Parse(s, CultureInfo.InvariantCulture);
    public static double ParseDouble(string s) => double.Parse(s, CultureInfo.InvariantCulture);

    public static int ReadInt(ref Utf8JsonReader reader) => reader.GetInt32();
    public static long ReadLong(ref Utf8JsonReader reader) => reader.GetInt64();
    public static decimal ReadDecimal(ref Utf8JsonReader reader) => reader.GetDecimal();
    public static double ReadDouble(ref Utf8JsonReader reader) => reader.GetDouble();

    public static void WriteInt(Utf8JsonWriter writer, int value) => writer.WriteStringValue(value.ToString(CultureInfo.InvariantCulture));
    public static void WriteLong(Utf8JsonWriter writer, long value) => writer.WriteStringValue(value.ToString(CultureInfo.InvariantCulture));
    public static void WriteDecimal(Utf8JsonWriter writer, decimal value) => writer.WriteStringValue(value.ToString(CultureInfo.InvariantCulture));
    public static void WriteDouble(Utf8JsonWriter writer, double value) => writer.WriteStringValue(value.ToString(CultureInfo.InvariantCulture));
}

Finally, you can define convenience classes for individual number types...

public class StringToIntConverter : StringToNumberConverter<int>
{
    public StringToIntConverter()
        : base(StringToNumberUtil.intCodes, StringToNumberUtil.ParseInt, StringToNumberUtil.ReadInt, StringToNumberUtil.WriteInt)
    {
    }
}

public class StringToNullableIntConverter : StringToNullableNumberConverter<int>
{
    public StringToNullableIntConverter()
        : base(StringToNumberUtil.intCodes, StringToNumberUtil.ParseInt, StringToNumberUtil.ReadInt, StringToNumberUtil.WriteInt)
    {
    }
}

... and registered them in the JsonSerializerOptions like this:

var options = new JsonSerializerOptions {
    ...
};
options.Converters.Add(new StringToIntConverter());
options.Converters.Add(new StringToNullableIntConverter());
...

(Or register the converters straight away, if you like.)

options.Converters.Add(new StringToNumberConverter<int>(StringToNumberUtil.intCodes, StringToNumberUtil.ParseInt, StringToNumberUtil.ReadInt, StringToNumberUtil.WriteInt));
options.Converters.Add(new StringToNullableNumberConverter<int>(StringToNumberUtil.intCodes, StringToNumberUtil.ParseInt, StringToNumberUtil.ReadInt, StringToNumberUtil.WriteInt));

Numbers that should deserialize as enums

You can add this if your JSON contains string-encoded numeric attributes, whose values have predefined meaning representable as an enum.

public class StringToIntEnumConverter<T> : JsonConverter<T> where T : struct, System.Enum
{
    private StringToIntConverter stringToInt = new StringToIntConverter();

    public override bool CanConvert(Type typeToConvert)
    {
        return typeToConvert == typeof(T);
    }

    public override T Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
    {
        int val = stringToInt.Read(ref reader, typeToConvert, options);
        string underlyingValue = val.ToString(CultureInfo.InvariantCulture);

        return (T)Enum.Parse(typeof(T), underlyingValue);
    }

    public override void Write(Utf8JsonWriter writer, T value, JsonSerializerOptions options)
    {
        var number = Convert.ChangeType(value, Enum.GetUnderlyingType(typeof(T)), CultureInfo.InvariantCulture);

        writer.WriteStringValue(number.ToString());
    }
}

public class StringToNullableIntEnumConverter<T> : JsonConverter<T?> where T : struct, System.Enum
{
    private StringToIntEnumConverter<T> stringToIntEnum = new StringToIntEnumConverter<T>();

    public override bool CanConvert(Type typeToConvert)
    {
        return stringToIntEnum.CanConvert(Nullable.GetUnderlyingType(typeToConvert) ?? typeToConvert);
    }

    public override T? Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
    {
        if (reader.TokenType == JsonTokenType.Null)
            return null;

        return stringToIntEnum.Read(ref reader, typeToConvert, options);
    }

    public override void Write(Utf8JsonWriter writer, T? value, JsonSerializerOptions options)
    {
        if (!value.HasValue)
        {
            writer.WriteNullValue();
            return;
        }

        stringToIntEnum.Write(writer, value.Value, options);
    }
}

Usage in JsonSerializerOptions:

var options = new JsonSerializerOptions {
    ...
};
options.Converters.Add(new StringToIntEnumConverter<OrderFlags>());
options.Converters.Add(new StringToNullableIntEnumConverter<OrderFlags>());
...
Aenneea answered 26/9, 2021 at 11:3 Comment(1)
Great solutions. And timely.Lave
P
1

For me below solves the issue.

  • Install System.Text.Json Nuget

  • Update startup.cs file with below

    services.AddControllers().AddJsonOptions(options => { options.JsonSerializerOptions.NumberHandling = System.Text.Json.Serialization.JsonNumberHandling.AllowReadingFromString; });

Peipeiffer answered 6/7, 2022 at 14:28 Comment(2)
This was not asked, this is the opposite conversion. -1Perth
Half an answer. This handles String -> Number, not Number -> String.Concertante
C
0

See above answers. This answer is the complete/easy why and how. It expounds upon Live2's answer as well as others above.

We specify the Type of T in the JsonConverter because we don't want T to be an int or a datetime. We want it to only apply when we are dealing with a property that we expect to be a string; if we don't do that, we will go through every scenario for every type every time. When we go to assign the property for the class, we will get a reader.TokenType while reading the incoming Json which we will make a switch on. (It probably actually reads the json name and then matches to the class property; I don't think it reads the class and then matches to the Json.) We also want to be careful about overrides, because they override normal behavior; and that behavior may have updates later.

In this case, we think we know better (and at this point in time, we do; STJ is barebones.)

First we need a converter to convert the incoming NUM to a string. Newtonsoft does this automagically, STJ needs a converter. (I haven't found a use for overriding CanConvert(), but read and write are required to be overwritten).

    public class NumToStringConverter : JsonConverter<string>
{
    public override string Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
    {
        if (reader.TokenType == JsonTokenType.Number)
        {
            if (reader.TryGetInt64(out long number))
            {
                return number.ToString();
            }

            if (reader.TryGetDouble(out var doubleNumber))
            {
                return doubleNumber.ToString(CultureInfo.InvariantCulture);
            }
        }

        if (reader.TokenType == JsonTokenType.String)
        {
            var str = reader.GetString();

            if (!string.IsNullOrWhiteSpace(str))
            {
                return str;
            }
        }

        using (var document = JsonDocument.ParseValue(ref reader))
        {
            return document.RootElement.Clone().ToString();
        }
    }

    public override void Write(Utf8JsonWriter writer, string value, JsonSerializerOptions options)
    {
/*don't change this too much or you'll be writing ints for strings for everything or worse.*/
        writer.WriteStringValue(value);
    }
}

Fortunately for us, STJ has already written an implementation for String to Int:

JsonNumberHandling.AllowReadingFromString;

Now, we make them work: In Startup, while adding services:

//.AddControllers() or .AddMvc()
services.AddMvc().AddJsonOptions(o => {
  o.JsonSerializerOptions.Converters.Add(new NumToStringConverter());
  o.JsonSerializerOptions.NumberHandling = JsonNumberHandling.AllowReadingFromString;
});
//beware, affects the entire API, not just one call
Concertante answered 30/4 at 17:5 Comment(0)
P
-1

Don't worry about it. Just add a property to the class that will return the item you want in the type that you want it.

public class Product
{
    public int Id { get; set; }

    public string IdString 
    {
        get
        {
            return Id.ToString();
        }
    }

    public string Name { get; set; }
}
Payload answered 29/11, 2019 at 2:10 Comment(3)
If the client passes the id in the JSON as string, then JsonSerializer will throw an exception using your example.Backfire
So your question was not clear. According to your question, the client is sending in a numeric Id. Now you are saying the client reserves the right to send in either a numeric or a string value in the Id position.Payload
While the client does not reserve the right to decide what to pass in, the OP wanted the ability for the client to decide what type to pass in using specific named fields; not additional fields. This answer sidesteps the question and is ultimately a workaround.Concertante

© 2022 - 2024 — McMap. All rights reserved.