Skip to content

Keep LineNumber, BytePositionInLine and Path when calling JsonSerializer.Deserialize<TValue>(ref reader) #77345

@ivarne

Description

@ivarne

Description

A common pattern when implementing JsonConverter<MyType> is to read a few properties and forward the Utf8JsonReader to JsonSerializer.Deserialize<TValue>(ref reader) to read some common type that don't need special handling. If a JsonException is thrown in the forwarded call, LineNumber, BytePositionInLine and Path is wrong with regards to the original json that was tried to deserialize.

I'd like to keep the references LineNumber, BytePositionInLine and Path relative to the full json.

Reproduction Steps

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

var json = @"{
    ""name"": ""test"",
    ""props"": [
        ""prop1"",
        ""prop2"",
        [""this"", ""is"", ""a"", ""error""]
    ]
}";
try
{
    var comp = JsonSerializer.Deserialize<MyComponent>(json, new JsonSerializerOptions(JsonSerializerDefaults.Web));
}
catch (JsonException e)
{
    Console.WriteLine(e.Message);
    Console.WriteLine($"Path: {e.Path}");
    Console.WriteLine($"LineNumber: {e.LineNumber}");
    Console.WriteLine($"BytePositionInLine: {e.BytePositionInLine}");
}

[JsonConverter(typeof(MyComponentConverter))]
public class MyComponent
{
    public string? Name { get; set; }
    public List<string>? Props { get; set; }
}

public class MyComponentConverter : JsonConverter<MyComponent>
{
    public override MyComponent? Read(ref Utf8JsonReader reader, Type type, JsonSerializerOptions options)
    {
        var component = new MyComponent();
        while (reader.Read() && reader.TokenType != JsonTokenType.EndObject)
        {
            var propName = reader.GetString();
            reader.Read();
            switch (propName)
            {
                case "name":
                    component.Name = reader.GetString();
                    break;
                case "props":
                    // use default behaviour for this list
                    component.Props = JsonSerializer.Deserialize<List<string>>(ref reader, options);
                    break;
                default:
                    reader.Skip();
                    break;
            }
        }

        return component;
    }

    public override void Write(Utf8JsonWriter writer, MyComponent value, JsonSerializerOptions options) => throw new NotImplementedException();
}

Expected behavior

I expect the JsonException to tell me where the parser was, when the error occurred. Just like it does when I don't use the custom converter.

Message: The JSON value could not be converted to System.String. Path: $.props[2] | LineNumber: 5 | BytePositionInLine: 9.
Path: $.props[2]
LineNumber: 5
BytePositionInLine: 9

Actual behavior

The actual result resets the e.Path, e.LineNumber and e.BytePositionInLine when the Utf8JsonReader is passed to JsonSerializer.Deserialize

Message: The JSON value could not be converted to System.String. Path: $[2] | LineNumber: 3 | BytePositionInLine: 9.
Path: $[2]
LineNumber: 3
BytePositionInLine: 9

Regression?

I don't know, but assume this has always been this way.

Known Workarounds

I can read everything inside the custom converter from the Utf8JsonReader without forwarding the simple components to the builtin tools.

Configuration

dotnet 6 and 7

Other information

I have a draft solution that does not forward path information in main...ivarne:runtime:custom-recursive-json-parsing. Not sure if it is correct. I don't understand all the concepts here.

Metadata

Metadata

Assignees

No one assigned

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions