graphql-dotnet / graphql-client

A GraphQL Client for .NET Standard

Geek Repo:Geek Repo

Github PK Tool:Github PK Tool

Json Serialiser doesn't handle structs well and ignores type converters

AlexeyRaga opened this issue · comments

Hello,

I have some wrapper types that I want to use with GraphQL, which look like this:

public struct ProductId(Guid value) {
    public Guid Value => value;

    public override string ToString() => value.ToString();
    public override bool Equals(object? obj) => obj is ProductId other && other.Value == value;
    public override int GetHashCode() => value.GetHashCode();
    public static bool operator ==(ProductId left, ProductId right) => left.Value == right.Value;
    public static bool operator !=(ProductId left, ProductId right) => !(left == right);
}

I also provide JsonConverters for these types, which look similar to:

class ProductIdSystemTextJsonConverter : JsonConverter<ProductId>
{
    public override ProductId Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) =>
        new Foo(Guid.Parse(reader.GetString()!));

    public override void Write(Utf8JsonWriter writer, ProductId value, JsonSerializerOptions options) =>
        writer.WriteStringValue(value.Value);
}

I register the converters in the client:

var serialiser = new SystemTextJsonSerializer
        {
            Options =
            {
                PropertyNameCaseInsensitive = true
            }
        };
serialiser.Options.Converter.Add(new ProductIdTypeConverter());

But when deserialising the GraphQL response I get this error:

System.InvalidOperationException
Sequence contains no matching element
   at System.Linq.ThrowHelper.ThrowNoMatchException()
   at System.Linq.Enumerable.First[TSource](IEnumerable`1 source, Func`2 predicate)
   at GraphQL.Client.Serializer.SystemTextJson.ImmutableConverter.Read(Utf8JsonReader& reader, Type typeToConvert, JsonSerializerOptions options) in /_/src/GraphQL.Client.Serializer.SystemTextJson/ImmutableConverter.cs:line 80

I do not quite understand why ImmutableConverter was chosen and not the specific one that I registered...

I validated the approach, and it works if I create my wrapper types as records somehow:

public readonly record struct ProductId(Guid Value)
{
    public override string ToString() => Value.ToString();
}

But the goal is to make it work with plain structs and have libraries like https://github.com/lucasteles/Strongly to generate wrapper types, Json converters, and all the required boilerplate around them.

I believe that fixing the "plain structs" misbehavior would allow it to work rather smoothly.

Even for the records, this doesn't work:

public readonly partial record struct ProductId
{
    public System.Guid Value { get; }

    public ProductId(System.Guid value)
    {
        Value = value;
    }
}

But this does work:

public readonly partial record struct ProductId(Guid Value) {}

Interestingly, it works if I use Newtonsoft.Json but not System.Text.Json

Hi, sorry for the late reply...

I do not quite understand why ImmutableConverter was chosen and not the specific one that I registered...

You could try to insert your custom converter at the beginning of the converters list (like here), this had to be done for the ErrorPathConverter and MapConverter, too.

@rose-a Thanks for the suggestion!
Indeed, it works with the insert method!

But I still think that it is a bug because annotating the type with

[System.Text.Json.Serialization.JsonConverter(typeof(ProductIdSystemTextJsonConverter))]

doesn't work.

But it does work for the Newtonsoft serialiser. So, something is still odd here.

Yeah, I'd expect that to work, too.

But I don't know how to fine-tune the System.Text.Json serializer for that... PRs welcome 😉

@rose-a I may try to find some time to look into it...
As a super quick and easy fix, would you accept this logic?

If the type is marked with JsonConverterAttribute (there is a converter for this type), ImmutableConverter should return false from CanConvert so that the registered converter can have its turn.

Also, could you please elaborate on the ImmutableConverter, why is it needed?

Also, could you please elaborate on the ImmutableConverter, why is it needed?

If I remember correctly it is needed for deserialization of anonymous types... If you're playing around, you could check what happens if you remove it (some tests should fail)

If the type is marked with JsonConverterAttribute (there is a converter for this type), ImmutableConverter should return false from CanConvert so that the registered converter can have its turn.

Sounds reasonable, although I have no idea on how this will impact performance... @sungam3r @Shane32 do you have an opinion on this?

If you're playing around, you could check what happens if you remove it (some tests should fail)

Yeah, that's the thing.
I already checked it out, all the tests still pass if we remove ImmutableConverter.

I tried commenting out this line

https://github.com/graphql-dotnet/graphql-client/blob/master/src/GraphQL.Client.Serializer.SystemTextJson/JsonSerializerOptionsExtensions.cs#L9

and even removing the whole ImmutableConverter.cs file, all the tests are happy.

Hence the question: why do we need it?

Hm... trying to read up on this, it seems like this might have become obsolete wit .NET 5.0...

If so, then it may fix the issue in a nicest way: fixing bugs by removing code :)

@AlexeyRaga Could you please post a complete combination of class/struct and JsonConverter which shows the described behavior?