graphql-dotnet / graphql-client

A GraphQL Client for .NET Standard
MIT License
614 stars 130 forks source link

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

Open AlexeyRaga opened 4 months ago

AlexeyRaga commented 4 months ago

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.

AlexeyRaga commented 4 months ago

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) {}
AlexeyRaga commented 4 months ago

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

rose-a commented 4 months ago

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.

AlexeyRaga commented 4 months ago

@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.

rose-a commented 4 months ago

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 :wink:

AlexeyRaga commented 4 months ago

@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?

rose-a commented 4 months ago

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?

AlexeyRaga commented 4 months ago

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?

rose-a commented 4 months ago

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

AlexeyRaga commented 4 months ago

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

rose-a commented 1 month ago

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