DEV Community

Cover image for ZeroQL V2 - C# GraphQL client
Stanislav Silin
Stanislav Silin

Posted on • Edited on

ZeroQL V2 - C# GraphQL client

In this article, I want to present the major update for ZeroQL. It brings new features and support for more complex workflows.
Such as file uploads, new request-like way to define queries and mutations, persisted queries, and more.
If you are interested, continue reading.

What is ZeroQL?

If you read my previous articles[1][2] you know that ZeroQL is a C# GraphQL client that allows you to write GraphQL queries with C# in a Linq-like way. I will give you a quick overview for those who don't know.

Let's suppose that we have a GraphQL schema like that:

schema {
  query: Query
  mutation: Mutation
}

type Query {
  me: User!
  user(id: Int!): User
}

type Mutation {
  addUser(firstName: String!, lastName: String!): User!
  updateUser(userId: Int!, firstName: String!, lastName: String!): User!
  addAvatar(userId: Int!, avatar: Upload!): Boolean!
}

type User {
  id: Int!
  firstName: String!
  lastName: String!
  role: Role!
}

type Role {
  id: Int!
  name: String!
}
Enter fullscreen mode Exit fullscreen mode

The ZeroQL allows to generate a fully-typed client:

# create console app
dotnet new console -o QLClient
# go to project folder 
cd QLClient
# fetch graphql schema from server(depends on your web server)
curl http://localhost:10000/graphql?sdl > schema.graphql 
# create manifest file to track nuget tools
dotnet new tool-manifest 
# add ZeroQL.CLI nuget tool
dotnet tool install ZeroQL.CLI
# add ZeroQL nuget package
dotnet add package ZeroQL 
# to bootstrap schema.graphql file from graphql schema
dotnet zeroql generate --schema .\schema.graphql --namespace TestServer.Client --client-name TestServerGraphQLClient --output Generated/GraphQL.g.cs
Enter fullscreen mode Exit fullscreen mode

Now we can write GraphQL queries 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 o => o
    .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
Enter fullscreen mode Exit fullscreen mode

The q.Me method is a query field from the schema, and the o => new { o.Id, o.FirstName, o.LastName } is a selection set.
When we call the Query method, the ZeroQL generates a GraphQL query from the C# code at compile time. Then at runtime, it sends the query to the server and gets the response.
As a result, the performance is the same as if we write a GraphQL query manually.

File uploading

The new version supports the official way to upload files. I mean the Upload scalar type.
Here is a sample GraphQL query:

mutation AddAvatar($id: Int!, $file: Upload!) {
  addAvatar(id: $id, file: $file)
}
Enter fullscreen mode Exit fullscreen mode

Now we can replicate it like that:

var variables = new
{
    Id = id,
    File = new Upload("avatar.png", imageStream)
};
var response = await client.Mutation(variables, static (i, m) => m.AddAvatar(i.Id, i.File));
Enter fullscreen mode Exit fullscreen mode

The GraphQL client will create the multipart request and send the request in parts. For more info about the protocol, you can look here.

Request Syntax

ZeroQL brings a new way to define GraphQL queries. The main goal of it is to provide a way to extract complex queries into a separate entity and then reuse them inside the business layer.
For example, here is a more complex query:

mutation UpdateUser($id: Int!, $avatar: Upload!, $firstName: String!, $lastName: String!) {
  updateUser(firstName: $firstName, lastName: $lastName) {
    id,
    firstName,
    lastName,
  },
  addAvatar(userId: $id, avatar: $avatar)
}
Enter fullscreen mode Exit fullscreen mode

The lambda syntax will look like that:

var variables = new 
{
    Id = id,
    FirstName = firstName,
    LastName = lastName,
    Upload = new Upload("avatar.png", imageStream)
};

var response = await client
    .Mutation(variables, static (i, m) => new
    {
        User = m.UpdateUser(i.Id, i.FirstName, i.LastName, o => new UserResponse(o.Id, o.FirstName, o.LastName)),
        AvatarUpdated = m.AddAvatar(i.Id, i.Upload)
    });
Enter fullscreen mode Exit fullscreen mode

Such implementation inside a service looks clunky and too verbose. The request syntax makes it much simple:

// somewhere in a proper place
public record UpdateUserResponse(UserResponse User, bool AvatarUpdated);

public record UpdateUser(int Id, string FirstName, string LastName, Upload File) 
  : GraphQL<Mutation, UpdateUserResponse>
{
    public override UpdateUserResponse Execute(Mutation mutation) => new UpdateUserResponse(
        mutation.UpdateUser(Id, FirstName, LastName, o => new UserResponse(o.Id, o.FirstName, o.LastName)),
        mutation.AddAvatar(Id, File));
}

// ...

// inside the service
var response = await client.Execute(new UpdateUser(id, firstName, lastName, new Upload("avatar.png", imageStream)));
Enter fullscreen mode Exit fullscreen mode

As I said, the main advantage is splitting the query declaration and query execution. Also, it can be useful if you execute a query via AOT runtime. The "lambda"-like syntax relay on anonymous types to pass the query variables. It is fine when you need just to serialize the instance, but it has issues when you want to get some value from the fields. A good example would be the Upload type. It requires special handling, and in the case of an anonymous type, it requires reflection and Reflection.Emit to handle it with acceptable performance. As a result, when the app is compiled in AOT mode, it will fail at runtime. The request-like syntax doesn't have such an issue because query variables would have a concrete type, and we can interact with it without the need for reflection.

Persisted queries

The new version brings support for graphql persisted queries. The ZeroQL supports "static persisted queries" and "automatic persisted queries" pipelines.

The persisted queries can generally reduce the request size and execution time. For example, if we have the next request:

{
  "variables" : { "id" : 1, "avatar": null, "firstName": "John", "lastName": "Smith" },
  "query": "mutation UpdateUser($id: Int!, $avatar: Upload!, $firstName: String!, $lastName: String!) {
  updateUser(firstName: $firstName, lastName: $lastName) {
    id,
    firstName,
    lastName,
  },
  addAvatar(userId: $id, avatar: $avatar)
}"
}
Enter fullscreen mode Exit fullscreen mode

With persistent queries it will be transformed into this:

{
  "variables" : { "id" : 1, "avatar": null, "firstName": "John", "lastName": "Smith" },
  "persistedQuery": {
    "version":1,
    "sha256Hash":"420548026ac8cec25f7c4c592c3adc9140c9a9d70a9cfbda8a21b92b01b548f2"
  }
}
Enter fullscreen mode Exit fullscreen mode

It brings a bunch of improvements. First, the request is smaller, and the network usage is lower. Secondly, the hash works like an id. The GraphQL server can cache and reuse the execution pipeline without the need to parse the GraphQL syntax on every request. As a result, the request execution time can be much lower. You can think about it like a "sql procedure." You pass the name, and the SQL server knows what to do next.

Automatic persisted queries

Basically, on the first attempt, the client will send the "hashed" request. Then if the server identifies the hash, it will execute the appropriate query. If such hash is missing on the server, the request is rejected.
Then the client will send another request like that:

{
  "variables" : { "id" : 1, "avatar": null, "firstName": "John", "lastName": "Smith" },
  "query": "mutation UpdateUser($id: Int!, $avatar: Upload!, $firstName: String!, $lastName: String!) {
  updateUser(firstName: $firstName, lastName: $lastName) {
    id,
    firstName,
    lastName,
  },
  addAvatar(userId: $id, avatar: $avatar)
}",
  "persistedQuery": {
    "version":1,
    "sha256Hash":"420548026ac8cec25f7c4c592c3adc9140c9a9d70a9cfbda8a21b92b01b548f2"
  }
}
Enter fullscreen mode Exit fullscreen mode

The server will remember the hash and the query. So, the next time when the client sends the hash, the request will be executed as expected.

You can enable it for the ZeroQL client by passing PersistedQueryPipeline pipeline into the client constructor like that:

var client = new TestServerGraphQLClient(httpClient, new PersistedQueryPipeline()); 
var response = await client.Execute(new GetUserQuery(1)); 

Console.WriteLine($"GraphQL: {response.Query}"); // GraphQL: 8cc1ee42eecdac2a8590486826856c041b04981a2c55d5cc560c338e1f6f0285:query GetUserQuery($id: Int!) { user(id: $id) { id firstName lastName } }
Console.WriteLine(response.Data); // UserModel { Id = 1, FirstName = Jon, LastName = Smith }
Enter fullscreen mode Exit fullscreen mode

From the ZeroQL client perspective, it is all you need to do. Additionally, you will need to configure your GraphQL server, but it is a totally different story. You can find more information about how to do it with the HotChocolate server here.

Static persisted queries

This workflow requires additional interaction between a server and a client at build time. The idea is the same, but the server expects to know about all possible queries and their hashes before the request happens. To make it possible, you need to export queries from the client.
For ZeroQL it can be done via ZeroQL.CLI like that:

 dotnet zeroql queries extract -a .\bin\Debug\net6.0\TestProject.dll -c TestServer.Client.TestServerGraphQLClient -o ./queries
Enter fullscreen mode Exit fullscreen mode

The queries folder will contain a bunch of "hashed" GraphQL files. Inside they will have a GraphQL query associated hash in file name:

8cc1ee42eecdac2a8590486826856c041b04981a2c55d5cc560c338e1f6f0285.graphql # query GetUserQuery($id: Int!) { user(id: $id) { id firstName lastName } }
21cc96eaf0c0db2b5f980c8ec8b5aba2e40eb24f370cfc0cd7e4825509742ae2.graphql # mutation AddAvatar($id: Int!, $file: Upload!) { addUserProfileImage(userId: $id, file: $file)}
Enter fullscreen mode Exit fullscreen mode

Then you need to configure your server to access them. How to do it for the HotChocolate server is described here.

Conclusion

That is all that I wanted to share right now. I would say it is a big release with improvements that extend the capabilities of ZeroQL. The support for file uploading, persisted queries, and request-like syntax opens possibilities for new workflows and optimizations.

The ZeroQL repository is Github.
Feel free to create issues and ask questions.

Top comments (0)