ZeroQL icon indicating copy to clipboard operation
ZeroQL copied to clipboard

Expose the "Query" and "Mutation" extension methods on the IGraphqlClient iterface rather than the concrete type

Open be1223 opened this issue 1 year ago • 2 comments

Right now you can only use the "lambda" syntax on the concrete graphQLClient, it would be nice if this could be used on the interface:

IGraphQLClient client = new MyGeneratedZeroQLClient(new HttpClient());

var fooIds = await client.Query(static x => x.GetFoos(f => f.Id));

var variables = new { name = "some-name" };
var newFooId = await client.Mutation(variables, static (v, m) => m.CreateFoo(v.name, f => f.Id));

At present the only callable method on IGraphqlClient is Execute forcing us down the "Request" syntax route.

I explored the lambda syntax as a workaround for bug #94 and was forced to pass in the generated concrete client into my class constructor, moving away from the interface reference that I currently used.

be1223 avatar Feb 14 '24 10:02 be1223

At the moment, we need the concrete implementation because it contains references to TQuery, TMutation. Then, the extension methods Query and Mutation expose them as part of the lambda.

public class GraphQLClient<TQuery, TMutation> : IGraphQLClient, IDisposable
{
  // ...
}

public static async Task<GraphQLResult<TResult>> Query<TVariables, TQuery, TMutation, TResult>(
        this GraphQLClient<TQuery, TMutation> client,
        string name,
        TVariables variables,
        Func<TVariables, TQuery, TResult> query,
        CancellationToken cancellationToken = default,
        [CallerArgumentExpression(nameof(query))] string queryKey = null!)
    {
      //..
    }

We can move it to the interface level, but then you will need to manually pass the correct TQuery and TMutation. From my perspective, you don't want to do it.

was forced to pass in the generated concrete client into my class constructor, moving away from the interface reference that I currently used.

What is the problem with the concrete client inside the class constructor?

byme8 avatar Feb 14 '24 12:02 byme8

I wonder if an intermediate interface could help here:

public interface IGraphQLClient { ... }

public interface IGraphQLClient<TQuery, TMutation> : IGraphQLClient { ... }

public class GraphQLClient<TQuery, TMutation> : IGraphQLClient<TQuery, TMutation> { ... }

That said, Query is still an extension method, so it can't be mocked with common tools (such as Moq). Does it need to be an extension method?

As for getting around this now, it might actually be easier to mock interactions with the HttpClient than to mock the IGraphQLClient. Consider this rough example:

public class WidgetService
{
    private readonly ConcreteZeroGraphQLClient _client;

    public QueryService(ConcreteZeroGraphQLClient client) { _client = client; }

    public async Task<int> GetLengthOfLargestWidgetAsync()
    {
        IEnumerable<Widget> widgets = _client.Query(...);
        return widgets.Max(w => w.Length);
    }
}
public class WidgetServiceTests
{
    [Test]
    public async Task GetLengthOfLargestWidget_WhenGraphQLClientReturnsLotsOfResults_ReturnsLargest()
    {
        // Arrange
        var mockHttpClient = CreateHttpClientMock(); // Out of scope for this demo
        mockHttpClient
            .Setup(x => x.SendAsync(It.IsAny<HttpRequestMessage>, It.IsAny<CancellationToken>())
            .ReturnsAsync(new HttpResponseMessage
            {
                Content = new StringContent("""
                    {
                        "widgets": [
                            { "name": "foo", "length": 12 },
                            { "name": "bar", "length": 6 },
                            { "name": "baz", "length": 8 }
                        ]
                    }
                """)
            });

        var graphQLClient = new ConcreteGraphQLClient(mockHttpClient.Object);
        var widgetService = new WidgetService(graphQLClient);

        // Act
        var largest = await widgetService.GetLengthOfLargestWidgetAsync();

        // Assert
        largest.Should().Be(12);
    }
}

tommcdo avatar Mar 06 '24 15:03 tommcdo

Checkout v7.0.0-preview.1

Now there is a way to create wrapper around the initial ZeroQL client and it works on interface level too. Here example how to create wrapper interface, fake it and use without server:

var httpClient = new HttpClient
{
    BaseAddress = new Uri("http://localhost:10000/graphql")
};

var zeroQlClient = new UserZeroQLClient(httpClient);
var wrapper = new UserGraphQlClient(zeroQlClient);

var fakeInterface = A.Fake<IUserGraphQLClient>();

A.CallTo(() => fakeInterface.QueryAsync(A<Func<Query, User>>.Ignored, A<string>.Ignored))
    .Returns(new User(new ID("FAKE_1"), "FAKE_FIRST_NAME", "FAKE_LAST_NAME"));

var serviceWithFake = new SomeService(fakeInterface);
var serviceWithReal = new SomeService(wrapper);

var fakeUser = await serviceWithFake.GetCurrentUser();
var realUser = await serviceWithReal.GetCurrentUser();

Console.WriteLine(JsonSerializer.Serialize(fakeUser));
// {"Id":{"Value":"FAKE_1"},"FirstName":"FAKE_FIRST_NAME","LastName":"FAKE_LAST_NAME"}
Console.WriteLine(JsonSerializer.Serialize(realUser));
// {"Id":{"Value":"1"},"FirstName":"John","LastName":"Smith"}

public record User(ID Id, string FirstName, string LastName);

public class SomeService(IUserGraphQLClient wrapper)
{
    public async Task<User?> GetCurrentUser()
    {
       // here we are doing query purely on top of interface
        var response = await wrapper.QueryAsync(q => q
            .Me(u => new User(u.Id, u.FirstName, u.LastName)));

        return response;
    }
}

public interface IUserGraphQLClient
{
    Task<TResult?> QueryAsync<TResult>(
        [GraphQLLambda] Func<Query, TResult> query,
        [CallerArgumentExpression(nameof(query))]
        string queryKey = "");
}

public class UserGraphQlClient(UserZeroQLClient client) : IUserGraphQLClient
{
    public async Task<TResult?> QueryAsync<TResult>(
        [GraphQLLambda] Func<Query, TResult> query,
        [CallerArgumentExpression(nameof(query))]
        string queryKey = "")
    {
        var result = await client.Query(query, queryKey: queryKey);

        return result.Data!;
    }
}

byme8 avatar May 26 '24 08:05 byme8