2

I'm using System.Text.Json and it fails to deserialize BookLevel[]. BookLevel is something like List<List<object>>.

The JSON value could not be converted to Deribit.Models.BookLevel. Path: $.params.data.bids[0] | LineNumber: 0 | BytePositionInLine: 234.. Exception: JsonException

public record BookResponse
{
    [JsonPropertyName("type")]
    public string Type { get; init; } = null!;

    [JsonPropertyName("timestamp")]
    public long Timestamp { get; init; }

    [JsonPropertyName("prev_change_id")]
    public decimal PreviousChangeId { get; init; }

    [JsonPropertyName("instrument_name")]
    public string InstrumentName { get; init; } = null!;

    [JsonPropertyName("change_id")]
    public decimal ChangeId { get; init; }

    [JsonPropertyName("bids")]
    public BookLevel[] Bids { get; init; } = null!;

    [JsonPropertyName("asks")]
    public BookLevel[] Asks { get; init; } = null!;
}

public record BookLevel
{
    [JsonPropertyOrder(1)]
    public string Action { get; init; } = null!;

    [JsonPropertyOrder(2)]
    public decimal Amount { get; init; }

    [JsonPropertyOrder(3)]
    public decimal Price { get; init; }
}

{"jsonrpc":"2.0","method":"subscription","params":{"channel":"book.BTC-PERPETUAL.100ms","data":{"type":"change","timestamp":1648477437698,"prev_change_id":42599922395,"instrument_name":"BTC-PERPETUAL","change_id":42599922580,"bids":[["change",47452.0,55700.0],["change",47451.5,24170.0],["delete",47449.0,0.0],["new",47446.5,2130.0],["change",47440.5,56210.0],["new",47439.0,46520.0],["new",47438.0,660.0],["new",47437.0,47430.0],["change",47429.5,20000.0],["change",47429.0,2810.0],["change",47428.5,36460.0],["change",47428.0,3070.0],["new",47427.0,21110.0],["delete",47423.5,0.0],["new",47421.0,33400.0],["change",47420.5,33190.0],["new",47420.0,140.0],["change",47390.0,63980.0],["new",47382.0,85480.0],["delete",47381.0,0.0],["new",47379.5,32770.0]],"asks":[["change",47452.5,15950.0],["new",47467.0,101970.0],["delete",47467.5,0.0],["change",47469.0,1200.0],["change",47470.5,31470.0],["change",47471.5,2010.0],["change",47474.0,79380.0],["change",47474.5,47470.0],["new",47475.5,2970.0],["new",47476.0,21010.0],["change",47476.5,7630.0],["change",47477.0,42510.0],["change",47478.5,100.0],["delete",47480.0,0.0],["change",47482.5,5650.0],["delete",47485.5,0.0],["new",47494.0,150.0],["change",47494.5,43340.0],["new",47523.5,32590.0],["delete",47527.5,0.0]]}}}

Back in Newtonsoft.Json

I could've marked BookLevel as [JsonConverter(typeof(ObjectToArrayConverter<LevelEvent>))] and use the following. How do I do that with System.Text.Json?

/// <summary>
/// Adapted from https://stackoverflow.com/questions/39461518/c-sharp-json-net-deserialize-response-that-uses-an-unusual-data-structure
/// </summary>
/// <typeparam name="T"></typeparam>
public class ObjectToArrayConverter<T> : JsonConverter
{
    public override bool CanConvert(Type objectType)
    {
        return typeof(T) == objectType;
    }

    static bool ShouldSkip(JsonProperty property)
    {
        return property.Ignored || !property.Readable || !property.Writable;
    }

    public override void WriteJson(JsonWriter writer, object value, JsonSerializer serializer)
    {
        var type = value.GetType();

        if (!(serializer.ContractResolver.ResolveContract(type) is JsonObjectContract contract))
        {
            throw new JsonSerializationException("invalid type " + type.FullName);
        }
    
        var list = contract.Properties.Where(p => !ShouldSkip(p)).Select(p => p.ValueProvider.GetValue(value));
        serializer.Serialize(writer, list);
    }

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

        if (!(serializer.ContractResolver.ResolveContract(objectType) is JsonObjectContract contract))
        {
            throw new JsonSerializationException("invalid type " + objectType.FullName);
        }
    
        var value = existingValue ?? contract.DefaultCreator();
    
        foreach (var pair in contract.Properties.Where(p => !ShouldSkip(p)).Zip(token, (p, v) => new { Value = v, Property = p }))
        {
            var propertyValue = pair.Value.ToObject(pair.Property.PropertyType, serializer);
            pair.Property.ValueProvider.SetValue(value, propertyValue);
        }

        return value;
    }
}
nop
  • 4,711
  • 6
  • 32
  • 93
  • What is the code you're using to do the deserialization with System.Text.Json? – gunr2171 Mar 28 '22 at 14:31
  • @gunr2171, none. I have Newtonsoft.Json, which looks better to me, but I'm forced to use System.Text.Json, which is incredibly bad in my eyes. – nop Mar 28 '22 at 15:01
  • None? How did you get this error? "I'm using System.Text.Json and it fails to deserialize BookLevel[]" – gunr2171 Mar 28 '22 at 15:02
  • @junr2171. I didn't have a converter for BookLevel – nop Mar 28 '22 at 15:08
  • JsonLogic might be worth looking at, if you have to do a lot of this sort of thing. – Tony Hopkinson Mar 28 '22 at 15:22
  • @TonyHopkinson, if I had to choose, I would've chosen Newtonsoft.Json, because it is the best. I don't know why System.Text.Json took over and it is now the preferred way. but it's years away from Newtonsoft.Json. – nop Mar 28 '22 at 15:24
  • JsonLogic is a nuget, that lets you convert one Json to another with a set of mappings. Its a bit opaque at first, but you can have one converter and pick out rules based on the type. Gives youa nifty abstraction. – Tony Hopkinson Mar 28 '22 at 15:32

1 Answers1

4

For your BookLevel, you can:

using System.Text.Json;
using System.Text.Json.Serialization;

[JsonConverter(typeof(BookLevelConverter))]
public record BookLevel
{
    public string Action { get; init; } = null!;

    public decimal Amount { get; init; }

    public decimal Price { get; init; }
}

public class BookLevelConverter : JsonConverter<BookLevel>
{
    public override BookLevel? Read(
        ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
    {
        var arr = JsonSerializer.Deserialize<JsonElement[]>(ref reader, options);
        return arr is null ? null : new BookLevel()
        {
            Action = arr[0].GetString()!,
            Amount = arr[1].GetInt32(),
            Price = arr[2].GetInt32()
        };
    }

    public override void Write(
        Utf8JsonWriter writer, BookLevel value, JsonSerializerOptions options)
    {
        var arr = new object[] { value.Action, value.Amount, value.Price };
        JsonSerializer.Serialize(writer, arr, options);
    }
}

But why are you doing this? It's a response from a third party service?


Well, there is the more generic way. Although I have tested it on your BookLevel, I can't guarantee that it could work on all types and in all edge cases. And I don't know whether there are simpler solutions or not.

All the public properties with JsonPlainArrayIndexAttribute will be serialized. Attributes like JsonIgnore won't take effect. You can ignore a property by not adding the JsonPlainArrayIndexAttribute. And to make sure it can be successfully deserilized, the options like JsonSerializerOptions.DefaultIgnoreCondition will be ignored as well.

using System.Diagnostics;
using System.Text.Json;
using System.Text.Json.Serialization;


[AttributeUsage(AttributeTargets.Property, Inherited = true, AllowMultiple = false)]
public sealed class JsonPlainArrayIndexAttribute : Attribute
{
    readonly int index;
    public JsonPlainArrayIndexAttribute(int index)
    {
        this.index = index;
    }
    public int Index
    {
        get { return index; }
    }
}

public sealed class JsonPlainArrayConverter<T> : JsonConverter<T> where T : new()
{
    public override T? Read(
        ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
    {
        Debug.Assert(typeof(T) == typeToConvert);

        var props = typeToConvert.GetProperties();
        var linq = from prop in props
                   let attr = prop.GetCustomAttributes(typeof(JsonPlainArrayIndexAttribute), true)
                   where prop.CanWrite && attr.Length is 1
                   orderby ((JsonPlainArrayIndexAttribute)attr[0]).Index
                   select prop;

        var arr = JsonSerializer.Deserialize<IEnumerable<JsonElement>>(ref reader, options);
        if (arr is null)
            return default;

        var result = new T();
        foreach (var (prop, value) in linq.Zip(arr))
            prop.SetValue(result, value.Deserialize(prop.PropertyType, options));

        return result;
    }

    public override void Write(Utf8JsonWriter writer, T value, JsonSerializerOptions options)
    {
        var type = typeof(T);
        var props = type.GetProperties();
        var linq = from prop in props
                   let attr = prop.GetCustomAttributes(typeof(JsonPlainArrayIndexAttribute), true)
                   where prop.CanRead && attr.Length is 1
                   orderby ((JsonPlainArrayIndexAttribute)attr[0]).Index
                   select prop.GetValue(value);
        JsonSerializer.Serialize<IEnumerable<object>>(writer, linq, options);
    }
}

And the BookLevel:

[JsonConverter(typeof(JsonPlainArrayConverter<BookLevel>))]
public record BookLevel
{
    [JsonPlainArrayIndex(0)]
    public string Action { get; init; } = null!;

    [JsonPlainArrayIndex(1)]
    public decimal Amount { get; init; }

    [JsonPlainArrayIndex(2)]
    public decimal Price { get; init; }
}
yueyinqiu
  • 369
  • 1
  • 7
  • Thanks! It's a response from a third party service. Isn't there a more generic way? – nop Mar 28 '22 at 15:00
  • @nop Actually the current code above is just a simple draft without consideration of some exceptions. I may try a more generic way and paste it later. – yueyinqiu Mar 28 '22 at 15:04
  • Thanks! I accepted it but I would like more generic way, so I can use it on other classes too. `ObjectToArrayConverter` in my question is working for Newtonsoft.Json, maybe something similar. – nop Mar 28 '22 at 15:25
  • @nop maybe you can try the new codes – yueyinqiu Mar 28 '22 at 15:51