byme8 / ZeroQL

C# GraphQL client with Linq-like syntax
MIT License
278 stars 13 forks source link

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

Closed be1223 closed 5 months ago

be1223 commented 8 months ago

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.

byme8 commented 8 months ago

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?

tommcdo commented 8 months ago

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);
    }
}
byme8 commented 5 months ago

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!;
    }
}