Queries and mutations
There are three ways to describe an operation. Pick per test; they all end in the same ExecuteAsync().
| Style | Best for |
|---|---|
Shape-driven: Query("root", args).Select(shape) | one root field, where the selection is also what you assert |
Fluent: Query("Name", q => q.Field(...)) | several root fields, aliases, connections with filters and paging |
Raw: Request("query { … }") | fragments, directives, anything the builders don't cover |
One operation in all three styles:
using var response = await Proto.Context.GraphQL().Query("me").Select(new { id = Gql.Field, email = Gql.Field }).ExecuteAsync();
Shape-driven operations
GraphQLRequestBuilder Query(string rootField, object? arguments = null, string? operationName = null);
GraphQLRequestBuilder Mutation(string rootField, object? arguments = null, string? operationName = null);
GraphQLRequestBuilder Subscription(string rootField, object? arguments = null, string? operationName = null);
GraphQLRequestBuilder Select<TShape>(TShape selectionShape);
GraphQLRequestBuilder Select<TShape>();
Task<GraphQLResponse> ExecuteAsync(CancellationToken cancellationToken = default);
Task<GraphQLResponse> ExpectAsync<TShape>(TShape expectedShape, CancellationToken cancellationToken = default);
The operation name defaults to the root field in PascalCase (createOrder → CreateOrder).
Select, then execute
using var response = await Proto.Context.GraphQL()
.Query("me")
.Select(new { id = Gql.Field, email = Gql.Field, role = Gql.Field })
.ExecuteAsync();
Gql.Field is a placeholder meaning "select this scalar, I don't care about its value".
Select and assert in one step
ExpectAsync(shape) is Select(shape) + ExecuteAsync() + Should.MatchShape(shape); on a shape mismatch it disposes the response and rethrows:
using var controlPlane = await Proto.Context.GraphQL()
.Query("controlPlane")
.ExpectAsync(new
{
userCount = 1,
workspaceCount = 1,
releaseCount = 0,
monthlyRecurringRevenue = 199m
});
controlPlane.Should.HaveNoErrors();
Arrays work too. The element shape becomes the selection, and the whole array is asserted:
using var workspaces = await Proto.Context.GraphQL()
.Query("workspaces")
.ExpectAsync(new[]
{
new { name = "analytics", region = "eu-central", plan = "growth" }
});
Select a type
private sealed record ViewerSelection(string Id, string Email, string Role);
using var response = await Proto.Context.GraphQL()
.Query("me")
.Select<ViewerSelection>()
.ExecuteAsync();
var viewer = response.ReadDataAs<ViewerSelection>();
How a shape becomes a selection set
new { id = Gql.Field, // → id
owner = new { email = Gql.Field } } // → owner { email }
new { total = JsonValue.GreaterThan(0) } // → total (a matcher is a leaf: selected, then asserted)
- Every public property becomes a field, named by
[JsonPropertyName]or else camelCase.[JsonIgnore]properties are skipped. - Recursion stops at a leaf:
Gql.Field, anyJsonValuematcher, or a value of a primitive, enum,string,decimal,DateTime,DateTimeOffset,DateOnly,TimeOnly,GuidorUritype. - An array or enumerable is unwrapped to its first element, so
new[] { new { id = Gql.Field } }selects{ id }. IDictionary<string, …>shapes are supported; the keys are the field names and the values describe their selections.- A nested object with no public properties throws
ArgumentException(GraphQL selection type '…' has no selectable public properties.), because GraphQL doesn't allow an empty selection. A fluent operation with no fields at all throwsInvalidOperationException.
Don't put Gql.Enum(...) inside a selection shape; it isn't treated as a leaf. It belongs in arguments.
Arguments and variables
Anything in the arguments object is inlined as a literal, except Gql.Variable(...), which declares an operation variable and sends the value separately:
.Mutation("createOrder", new
{
input = Gql.Variable("CreateOrderInput!", new CreateOrderRequest("notebook", 2, 12.50m))
})
produces mutation CreateOrder($input: CreateOrderInput!) { createOrder(input: $input) { … } }.
The type string is parsed when you create the variable, so a typo fails immediately. You can also build types with GqlType:
Gql.Variable(GqlType.Named("CreateOrderInput").NonNull(), order)
Gql.Variable(GqlType.Id.NonNull().List(), ids) // [ID!]
GqlType has Id, String, Int, Float, Boolean, Upload and Named(name). GraphQLOperationBuilder.Variable(name, type) takes the same syntax as a string or a GraphQLTypeReference.
For enum literals in arguments, use Gql.Enum("DESC").
Shape variables merge with .Variables. On a name clash the shape value applies. A shape that contributes no variables leaves .Variables(...) untouched.
Fluent operations
GraphQLRequestBuilder Query(string? name, Action<GraphQLOperationBuilder> configure);
GraphQLRequestBuilder Mutation(string? name, Action<GraphQLOperationBuilder> configure);
GraphQLRequestBuilder Subscription(string? name, Action<GraphQLOperationBuilder> configure);
The second parameter decides the overload: a lambda gives you the fluent builder, an object gives you shape-driven.
using var response = await Proto.Context.GraphQL()
.Query("Dashboard", query => query
.Variable("tenant", "String!")
.Field("me", me => me.Fields("id", "email"))
.Field("workspaces", workspaces => workspaces
.Alias("active")
.Argument("tenant", Gql.Var("tenant"))
.Select(workspace => workspace
.Fields("name", "region")
.Field("owner", owner => owner.Fields("email")))))
.Variables(new { tenant = "acme" })
.ExecuteAsync();
| Builder | Members |
|---|---|
| operation | Variable(name, type), Field(name, configure?), Connection(name, configure) |
| field | Alias(alias), Argument(name, value), Fields(params names), Select(configure) |
| selection | Field(name, configure?), Fields(params names) |
Gql.Var("tenant") references a declared variable ($tenant); supply its value with .Variables(...). The builder above emits one Dashboard query with the me fields and the aliased workspaces selection, variables included.
Connections
Connection understands the common cursor-connection shape: filtering, ordering and paging.
using var response = await Proto.Context.GraphQL()
.Query("FindNotebooks", query => query
.Connection("orders", orders => orders
.Where(filter => filter.Contains("product", "notebook"))
.OrderBy(order => order.Descending("total"))
.First(1)
.Nodes("product", "total", "status")
.PageInfo("hasNextPage", "hasPreviousPage")
.TotalCount()))
.ExecuteAsync();
response.Should.HaveNoErrors().Should.MatchShape(new
{
orders = new
{
nodes = new[] { new { product = "notebook-pro", total = 40m, status = "pending" } },
pageInfo = new { hasNextPage = true, hasPreviousPage = false },
totalCount = 2
}
});
| Connection member | Produces |
|---|---|
First(n), Last(n), After(cursor), Before(cursor) | paging arguments |
Where(filter => …) | a where: argument |
OrderBy(order => order.Ascending(f).Descending(g)) | an order: argument with ASC/DESC enums |
Nodes(params fields) / Nodes(field => …) | nodes { … } |
PageInfo(params fields) | pageInfo { … }: hasNextPage, hasPreviousPage, startCursor, endCursor when none are given |
TotalCount() | totalCount |
The filter builder emits the { field: { op: value } } convention used by Hot Chocolate: Equal, NotEqual, Contains, StartsWith, EndsWith, GreaterThan, GreaterThanOrEqual, LessThan, LessThanOrEqual, In, plus Nested(field, …), Some(field, …) for lists, and Or(...).
With shape-driven operations, Should.MatchShape compares against the root field's value. With fluent and raw operations there's no single root, so it compares against the whole data object. That is why the example above wraps its shape in orders = ….
Raw documents
using var response = await Proto.Context.GraphQL()
.Request(
"""
query ViewerCard {
viewer: me { ...ViewerFields }
}
fragment ViewerFields on UserResponse { id email role }
""",
operationName: "ViewerCard")
.ExecuteAsync();
response.Should.HaveNoErrors().Should.MatchShape(new
{
viewer = new
{
id = JsonValue.NotNull(),
email = JsonValue.StringContaining("@example.test"),
role = "member"
}
});
The document is parsed when you call Request; if the named operation isn't in it, the call throws ArgumentException.
Headers and variables
GraphQLRequestBuilder Header(string name, string value);
GraphQLRequestBuilder Variables(object variables);
GraphQLRequestBuilder ConnectionPayload(object payload);
The trace records header names and the count, not header values. ConnectionPayload sets the optional connection_init payload subscriptions send. See Subscriptions.
File uploads
ProtoTest implements the GraphQL multipart request spec. Put Gql.Upload(...) in shape-driven arguments or anywhere inside .Variables(...), and it's declared as Upload! automatically:
var expected = new
{
fileName = "example.txt",
contentType = "text/plain",
length = JsonValue.GreaterThan(0)
};
using var uploaded = await Proto.Context.GraphQL()
.Mutation("uploadDocument", new
{
file = Gql.Upload("ProtoTest GraphQL"u8.ToArray(), "example.txt", "text/plain")
})
.Select(expected)
.ExecuteAsync();
uploaded.Should.HaveNoErrors().Should.MatchShape(expected);
static GraphQLUpload Upload(ReadOnlyMemory<byte> content, string fileName, string contentType = "application/octet-stream");
static GraphQLUpload Upload(Func<Stream> openRead, string fileName, string contentType = "application/octet-stream");
The request is sent as multipart/form-data with operations, map and numbered file parts, plus the GraphQL-preflight: 1 header that CSRF-protected servers expect. The Func<Stream> overload opens a fresh stream per send.
Gql.Upload is only intercepted in shape-driven arguments and in .Variables(...). A fluent .Argument("file", Gql.Upload(...)) renders as a literal and is not routed through the multipart normalizer.
Transport details
Queries and mutations are sent as POST with Accept: application/graphql-response+json, application/json;q=0.9. The endpoint must be an absolute HTTP(S) URI; the resolve step is traced as graphql.endpoint.resolve with the server address. Calling ExecuteAsync() on a subscription throws; use SubscribeAsync().