Nowadays, GraphQL has become a more and more popular technology for building web servers. At the same time, the C# lacks a "native" client for it. Under "native" I mean an ability to build queries inside the C# without the need to write raw GraphQL and be sure that if your project could compile, it would work as expected.
The idea
I was looking for such a tool continuously, and the closes what I managed to find was Strawberry Shake. It requires you to write a raw GraphQL. At the same time, it will generate all the necessary wrappings for C#, and you will have a nice type-safe API to work with.
I have been using it a lot but wanted to have something even more native to simplify integration between different app parts.
My goal is to have a public interface that allows to execute queries like that:
var response = await client.Query(q => q.User(42, user => new { user.Id, user.FirstName, user.LastName });
It will be equivalent to following GraphQL query:
query { user(id: 42) { id firstName lastName } }
ZeroQL
After a few weeks of digging, I made a library that does exactly that.
Meet the ZeroQL! It is C# friendly GraphQL client with a Linq-like interface and excellent performance equivalent to a simple HTTP call.
Let's see it in action with an example. Let's suppose that we have a local HotChocolate server on localhost:10000, and it serves the following GraphQL schema:
schema {
query: Query
mutation: Mutation
}
type Query {
me: User!
user(id: Int!): User
}
type Mutation {
addUser(firstName: String!, lastName: String!): User!
}
type User {
id: Int!
firstName: String!
lastName: String!
role: Role!
}
type Role {
id: Int!
name: String!
}
Initial setup
Now, let's create a console app that can access it. We can do it with the next commands:
dotnet new console -o QLClient # create console app
cd QLClient # go to the project folder
curl http://localhost:10000/graphql?sdl > schema.graphql # fetch graphql schema from server
dotnet new tool-manifest # create manifest file to track NuGet tools
dotnet tool install ZeroQL.CLI # add ZeroQL.CLI NuGet tool
dotnet add package ZeroQL # add ZeroQL NuGet package
dotnet zeroql generate --schema .\schema.graphql --namespace TestServer.Client --client-name TestServerGraphQLClient --output Generated/GraphQL.g.cs # generate wrappers from the schema.graphql
The last step can be placed in a separate target inside the csproj file to ensure we have the latest changes from schama.graphql. It may look like that:
<Target Name="GenerateQLClient" BeforeTargets="BeforeCompile">
<Exec Command="dotnet zeroql generate --schema .\schema.graphql --namespace TestServer.Client --client-name TestServerGraphQLClient --output Generated/GraphQL.g.cs" />
</Target>
It may look a bit complicated at first glance, but don't be afraid we need to do it only once.
The initial setup is ready, and we can execute our first query. Let's modify the Program.cs to look like that:
var httpClient = new HttpClient();
httpClient.BaseAddress = new Uri("http://localhost:10000/graphql");
var client = new TestServerGraphQLClient(httpClient);
var response = await client.Query(static q => q.Me(o => new { o.Id, o.FirstName, o.LastName }));
Console.WriteLine($"GraphQL: {response.Query}"); // GraphQL: query { me { id firstName lastName } }
Console.WriteLine($"{response.Data.Id}: {response.Data.FirstName} {response.Data.LastName}"); // 1: Jon Smith
As you can see, the workflow is straightforward. Create the GraphQL client, write a query in C#, execute it and get results. Let's look at this sample in detail.
How it works
The class TestServerGraphQLClient
is generated via ZeroQL.CLI. It has a method, Query
, that accepts a "graphql" lambda*(not expression)*. This "graphql" lambda takes one argument of type Query
. It generated too. Then the source generator will look inside the lambda, analyzes, and transforms it into the corresponding GraphQL. After that, it is placed in a "special" dictionary. This dictionary contains the stringified lambda and the associated graphql. If you look at the Query
method itself, you will see that it has a hidden argument queryKey
:
public async Task<GraphQLResult<TResult>> Query<TResult>(
Func<TQuery, TResult> query,
[CallerArgumentExpression("query")] string queryKey = null!)
{
return await Execute<Unit, TQuery, TResult>(OperationKind.Query, null, null, (i, q) => query(q), queryKey);
}
The CallerArgumentExpression
is a new feature of C# 10. It allows us to get a stringified representation of the expression that was passed inside the argument. In our case are looking for the argument query
which is equal to static q => q.Me(o => new { o.Id, o.FirstName, o.LastName })
as a result the argument queryKey
will contain hardcoded string "static q => q.Me(o => new { o.Id, o.FirstName, o.LastName })"
— that exact representation that required to get the corresponding graphql from the "special" dictionary. As a result, we always know what graphql we need for each call. The crucial thing here is that the graphql is generated at compile-time. So, there is nothing to do at runtime except executing the HTTP call. As a result, we have zero overhead at runtime.
Another important thing is that the "graphql" lambda must be a static one. There are two reasons for that. First, analyzing it via the source generator is much easier because there are no outside-scope variables that can make things complicated. Second, if you plan to have graphql variables like that:
var variables = new { Id = 1 };
var response = await client.Query(variables, static (i, q) => q.User(i.Id, o => new { o.Id, o.FirstName, o.LastName }));
It is the simplest way to ensure that all inputs are analyzed. With such an approach, we have them as parameters and can serialize them to add to the request.
Supported features
Now let's see what features are supported at the moment.
For exmaple, we can get deeply nested fields:
var variables = new { Id = 1 };
var response = await client.Query(
variables,
static (i, q) => q
.User(i.Id,
o => new
{
o.Id,
o.FirstName,
o.LastName,
Role = o.Role(role => role.Name)
}));
Console.WriteLine($"GraphQL: {response.Query}"); // GraphQL: query GetUserWithRole($id: Int!) { user(id: $id) { id firstName lastName role { name } } }
Console.WriteLine($"{response.Data.Id}: {response.Data.FirstName} {response.Data.LastName}, Role: {response.Data.Role}"); // 1: Jon Smith, Role: Admin
Also, touch multiple fields at the same time:
var variables = new { Id = 1 };
var response = await client.Query(
variables,
static (i, q) => new
{
MyFirstName = q.Me(o => o.FirstName),
User = q.User(i.Id,
o => new
{
o.FirstName,
o.LastName,
Role = o.Role(role => role.Name)
})
});
Console.WriteLine($"GraphQL: {response.Query}"); // GraphQL: query GetUserWithRole($id: Int!) { me { firstName } user(id: $id) { firstName lastName role { name } } }
Console.WriteLine($"Me: {response.Data.MyFirstName}, User: {response.Data.User.FirstName} {response.Data.User.LastName}, Role: {response.Data.User.Role}"); // Me: Jon, User: Jon Smith, Role: Admin
Execute mutations:
var response = await client.Mutation(m => m.AddUser("Jon", "Doe", o => o.Id));
Console.WriteLine($"GraphQL: {response.Query}");
Console.WriteLine($"Id: {response.Data}");
Limitations
The biggest limitations will be the fact that you can run this library only with .Net 6. It is required because of the CallerArgumentExpression
attribute I mentioned earlier. Maybe there is a trick to fix it, but I have not spent much time on it.
Also, the library is in the early stages of development. Some features are not implemented yet.
For example:
- fragments
- @defer attribute
- @stream attribute
- subscriptions
Some of them, I would say, are really important, like fragments. At the same time, I thought it would be nice to share what I have done and get some feedback. It may definitely help to guide future developments.
Top comments (8)
Nice work:)
Nice, sounds like a great project to become a Source Generator package in future
devblogs.microsoft.com/dotnet/intr...
Not sure that I understood you correctly. This library already utilizes source generators under the hood.
Ah I may have misunderstood - it looked like I would have to install a tool and add a target to my csproj - I thought you could wrap that up in the package so that it works with just a PackageReference
Oh, you are speaking about source generation for schema.graphql. I decided to keep it as part of the CLI because source generation still is pretty laggy in cases when a developer needs to access source-generated classes. Sometimes it can get stuck for a few seconds, and you need to wait until compilation errors disappear. For me, it is annoying. So, a separate call to CLI in the target solves this issue.
That looks very promising!! I'll keep an eye on the project. Kudos 🎉
Looks like it will be not usefull for .netfraemwork?)
The ZeroQL relays on
CallerArgumentExpressionAttribute
andModuleInitializerAttribute
they are not available for the .netframework. There are ways to achive the same behaviour, but it would considerably slow down the source generation.