Skip to content

Queries and mutations

Every top-level operation is an async method on a #[resolver] impl block, tagged with #[query] or #[mutation]. The method signature is the wire contract: parameters become GraphQL arguments, the return type becomes the response shape, and an error short-circuits with a GraphQL error envelope. There is no separate “schema definition” step — the registry composes one root Query and one root Mutation from every decorated method in the running app.

src/users/resolver.rs
use nest_rs_graphql::resolver;
#[resolver]
pub struct UsersResolver;
#[resolver]
impl UsersResolver {
#[query]
#[public]
async fn user_count(&self) -> i32 {
42
}
}

#[public] is the operation’s access posture: every #[query] / #[mutation] declares one of two, and a method with none does not compile:

  • #[public] — deliberately ungated.
  • #[authorize(Action, Entity)] — ability gate + automatic response masking.

The posture is always one of these visible attributes — a parameter type is never a posture. A bound mutation acts on a row named by id: it still carries #[authorize(Action, Entity)], then loads the Authorized<E, A> subject in its body. See Database / CRUD.

The toy examples on this page are #[public]; the service-backed ones at the bottom carry #[authorize].

On the wire this reads as:

Terminal window
$ curl -sX POST http://localhost:3000/graphql \
-H 'Content-Type: application/json' \
-d '{"query":"{ userCount }"}'
{"data":{"userCount":42}}

The method name is camelCased; primitive returns map to their GraphQL scalar (i32Int, StringString, boolBoolean).

Add typed parameters and they become required GraphQL arguments; Option<T> makes one nullable.

#[resolver]
impl UsersResolver {
#[query]
#[public]
async fn label(&self, id: String, locale: Option<String>) -> String {
let locale = locale.as_deref().unwrap_or("en");
format!("user {id} in {locale}")
}
}
Terminal window
$ curl -sX POST http://localhost:3000/graphql -d \
'{"query":"{ label(id: \"42\", locale: \"fr\") }"}'
{"data":{"label":"user 42 in fr"}}

Validation happens at deserialization: a missing required arg returns a parse error, a wrong scalar returns a coercion error. Both come back with no resolver call.

Beyond shape checks, an argument binds a pipe per parameter: Valid<T> runs validator rules over an input object, Piped<P, T> names a custom transform. The SDL still shows T; a rejection surfaces in the response’s errors — the resolver body never runs.

use async_graphql::InputObject;
use nest_rs_pipes::Valid;
use validator::Validate;
#[derive(InputObject, Validate)]
pub struct NameInput {
#[validate(length(min = 1))]
name: String,
}
#[query]
#[public]
async fn named(&self, input: Valid<NameInput>) -> async_graphql::Result<String> {
Ok(input.into_inner().name)
}

For anything beyond a scalar, define a SimpleObject (output) and optionally an InputObject (input):

use async_graphql::SimpleObject;
use nest_rs_graphql::resolver;
#[derive(SimpleObject)]
pub struct User {
id: String,
name: String,
email: String,
}
#[resolver]
pub struct UsersResolver;
#[resolver]
impl UsersResolver {
#[query]
#[public]
async fn user(&self, id: String) -> User {
User { id, name: "Ada".into(), email: "ada@example.com".into() }
}
}

Every field of User is queryable independently:

Terminal window
$ curl -sX POST http://localhost:3000/graphql -d \
'{"query":"{ user(id:\"1\") { name email } }"}'
{"data":{"user":{"name":"Ada","email":"ada@example.com"}}}

When you #[expose] an entity, the wire DTO is generated for you and plugs into this slot directly — no second SimpleObject to maintain (Relations).

#[mutation] is the same shape as #[query]; the marker swaps the operation root.

use async_graphql::{InputObject, SimpleObject};
use nest_rs_graphql::resolver;
#[derive(InputObject)]
pub struct NewUserInput {
name: String,
email: String,
}
#[derive(SimpleObject)]
pub struct User {
id: String,
name: String,
email: String,
}
#[resolver]
pub struct UsersResolver;
#[resolver]
impl UsersResolver {
#[mutation]
#[public]
async fn create_user(&self, input: NewUserInput) -> User {
User {
id: "u-001".into(),
name: input.name,
email: input.email,
}
}
}
Terminal window
$ curl -sX POST http://localhost:3000/graphql -d \
'{"query":"mutation { createUser(input: {name: \"Ada\", email: \"ada@example.com\"}) { id name } }"}'
{"data":{"createUser":{"id":"u-001","name":"Ada"}}}

InputObject makes a struct usable as an argument type; SimpleObject makes one usable as a return type. Both come from async-graphql (re-exported by nest-rs-graphql).

A resolver method takes an optional ctx: &Context<'_> first non-receiver argument. The context exposes per-request data the auth chain seeded (a principal, the assembled Ability) and is the entry point to typed batch loaders.

use async_graphql::{Context, Result};
use nest_rs_graphql::resolver;
#[resolver]
pub struct ProfileResolver;
#[resolver]
impl ProfileResolver {
#[query]
#[public]
async fn me(&self, ctx: &Context<'_>) -> Result<String> {
let claims = ctx.data::<crate::Claims>()?;
Ok(format!("subject: {:?}", claims.sub))
}
}

The principal type lands in ctx through forward_principal! — declared once per app, see Security. On a public query the call returns an error; bind a GraphqlAuthnGuard to refuse anonymous traffic before the method runs.

A resolver method may return T or async_graphql::Result<T> — both work, but a Result lets you short-circuit with a typed GraphQL error:

use async_graphql::{Error, Result};
#[query]
#[public]
async fn user(&self, id: String) -> Result<User> {
if id.is_empty() {
return Err(Error::new("id must not be empty"));
}
Ok(User { id, name: "Ada".into(), email: "ada@example.com".into() })
}

The full error story (extensions, codes, mapping ServiceError) is on its own page: Errors.

Reach the service through the resolver struct, then call into it — this is the same shape every adapter uses:

src/users/graphql/resolver.rs
use std::sync::Arc;
use async_graphql::{Context, Result};
use nest_rs_authz::{Create, Read};
use nest_rs_graphql::resolver;
use nest_rs_seaorm::graphql::bind;
use crate::Claims;
use crate::authn::AuthnGuard;
use crate::authz::AuthzGuard;
use crate::users::{CreateUser, Entity as UserEntity, User, UsersService};
#[resolver]
#[use_guards(AuthnGuard, AuthzGuard)]
pub struct UsersResolver {
#[inject]
svc: Arc<UsersService>,
}
#[resolver]
impl UsersResolver {
#[query]
#[authorize(Read, UserEntity)]
async fn user(&self, ctx: &Context<'_>, id: String) -> Result<Option<User>> {
Ok(bind::<UsersService, Read>(ctx, &id).await?.as_ref().map(User::from))
}
#[mutation]
#[authorize(Create, UserEntity)]
async fn create_user(&self, ctx: &Context<'_>, input: CreateUser) -> Result<User> {
let actor = ctx.data::<Claims>()?;
let user = self.svc.create_in_org(input, actor.org_id).await?;
Ok(User::from(&user))
}
}

The service stays the single audited choke point to the database. The resolver only translates between the wire shape and the service — #[authorize(Action, Entity)] is each operation’s access posture, gating the call against the ambient ability and masking the returned value automatically. The #[use_guards(AuthnGuard, AuthzGuard)] binding on the struct is what runs authn + the ability chain per operation; its AuthzGuard marker is provided by AuthzGraphqlModule, which the feature’s UsersGraphqlModule imports (omit it and boot fails naming the missing guard). See Database / CRUD for the service contract and Security for the guard wiring.

  • Field resolvers — add computed or composed fields to an output type.
  • Relations — let #[expose] write the entity-to-entity wire shape for you.
  • Errors — the full mapping from service errors to a GraphQL envelope.

Built by YV17labs