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.
A simple query
Section titled “A simple query”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:
$ 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 (i32 → Int, String → String, bool → Boolean).
Arguments
Section titled “Arguments”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}") }}$ 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)}Returning a typed entity
Section titled “Returning a typed entity”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:
$ 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).
A mutation
Section titled “A mutation”#[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, } }}$ 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).
Reading the request context
Section titled “Reading the request context”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.
Returning a Result
Section titled “Returning a Result”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.
Injecting a service
Section titled “Injecting a service”Reach the service through the resolver struct, then call into it — this is the same shape every adapter uses:
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.
Going further
Section titled “Going further”- 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