Mirror on GraphQL
A UsersResolver alongside the controller, queries and mutations on one impl, auto-resolved relations, SDL emitted as a side effect of the dev run.
The reference feature gains a GraphQL adapter — a single resolver
struct, two operations, zero changes to the service. By the end of this
page, POST /graphql answers { users { id name } } against the same
authn, the same ability, the same Postgres rows, and the User.org
field auto-resolves through a dataloader without an N+1.
The resolver
Section titled “The resolver”A resolver is a struct decorated with #[resolver], with operations
on the impl block. The reference feature wires both halves:
The crate driving this page is
nest-rs-graphql,
which builds on top of async-graphql.
Schema discovery, dataloader registration, and the authz bridge come
from the framework crate; the schema model itself is async-graphql’s.
use std::sync::Arc;
use async_graphql::{Context, Result};use nest_rs::authz::{Create, Read};use nest_rs::graphql::{crud, resolver};use nest_rs::seaorm::graphql::bind;
use crate::Claims;use crate::authz::AuthzGuard;use crate::users::{CreateUser, Entity as UserEntity, UpdateUser, User, UsersService};
#[resolver]#[use_guards(AuthzGuard)]pub struct UsersResolver { #[inject] svc: Arc<UsersService>,}
#[crud( service = svc, entity = UserEntity, output = User, create = CreateUser, update = UpdateUser,)]impl UsersResolver { #[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)) }
#[query] #[authorize(Read, UserEntity)] async fn user(&self, ctx: &Context<'_>, id: String) -> Result<Option<User>> { Ok(bind::<Read, UsersService>(ctx, &id) .await? .as_ref() .map(User::from)) }}#[authorize(Action, Entity)] is each operation’s access posture:
the macro emits the class-level gate before your body runs and masks
the returned value after it — you never call authorize or a masking
function by hand. A deliberately open operation declares #[public]
instead.
Symmetry with the HTTP controller is intentional — the service is the same, the inputs are the same, the policy is the same. The bridges are different:
| HTTP | GraphQL | Job |
|---|---|---|
Authorize<Create, users::Entity> extractor | #[authorize(Create, users::Entity)] attribute | Refuse the operation if the ability disallows it and mask the response fields |
Bind<Read, UsersService> extractor | bind::<Read, UsersService>(ctx, &id).await? call | Load the row through the service, refuse it on a denied ability |
Ctx<Claims> extractor | ctx.data::<Claims>()? call | Read the principal the authz bridge seeded |
The same #[use_guards(...)] declaration on the resolver struct — the
markers are the same, the access graph checks the same thing. It names
AuthzGuard alone here because apps/api already declares AuthnGuard
with use_guards_global, and a guard bound at two scopes still runs
exactly once: scope chooses the site, never the count.
The GraphQL module
Section titled “The GraphQL module”The adapter imports the port (UsersModule), the authz bridge
(AuthzGraphqlModule), and — because the entity exposes a
belongs_to org relation (next section) — the org port whose service
hosts the auto-emitted loader:
use nest_rs::core::module;
use super::resolver::UsersResolver;use crate::authz::graphql::AuthzGraphqlModule;use crate::orgs::OrgsModule;use crate::users::UsersModule;
#[module( imports = [UsersModule, OrgsModule, AuthzGraphqlModule], providers = [UsersResolver],)]pub struct UsersGraphqlModule;mod module;mod resolver;
pub use module::UsersGraphqlModule;pub use resolver::UsersResolver;And the feature root:
mod entity;mod module;mod service;
pub mod http;pub mod graphql;
pub use entity::*;pub use module::UsersModule;pub use service::UsersService;
pub use graphql::{UsersGraphqlModule, UsersResolver};pub use http::{UsersController, UsersHttpModule};Wire the transport once
Section titled “Wire the transport once”The app root imports GraphqlModule::for_root(None) to mount the HTTP
endpoint, and the feature’s GraphQL adapter for the operations.
UsersGraphqlModule, GraphqlModule::for_root(None),GraphqlModule::for_root(...) mounts POST /graphql on the HTTP transport.
GET /graphql serves the playground only when it is switched on — it is
off by default, so an unconfigured deployment exposes no interactive console
and GET /graphql answers 405. Turn it on for local work with
NESTRS_GRAPHQL__PLAYGROUND=true (see
GraphQL configuration).
The schema itself is composed from every #[resolver] the access graph
reaches — no central queries = [...] list to keep in sync.
Run a query
Section titled “Run a query”$ curl -s -H "Authorization: Bearer $TOKEN" \ -H 'Content-Type: application/json' \ -d '{"query":"{ users { id name email } }"}' \ http://localhost:3002/graphql{"data":{"users":[{"id":"018f…","name":"Ada","email":"ada@acme.test"}]}}The ambient ability runs on the query — a plain user gets the same
filter as the HTTP GET /users. The response side differs by one
GraphQL rule: email is String! in the schema, and a non-null field
cannot be nulled, so a caller whose grant excludes it is refused
(FORBIDDEN, naming the field) rather than served a row without it.
Asking for { users { id name } } works. Same policy, two transports,
each answering in its own shape.
Relations resolve themselves
Section titled “Relations resolve themselves”If the entity declared a belongs_to field — say org pointing at
OrgsEntity — adding it to the SeaORM model is enough. The macro
emits the Org PK loader on OrgsService and a #[ComplexObject]
field resolver on the wire DTO. No #[field_resolver] to write, no
#[dataloader] to call by hand.
#[expose(name = "User", service = super::service::UsersService, graphql)]#[sea_orm::model]#[derive(Clone, Debug, DeriveEntityModel)]pub struct Model { // ... id, org_id, name, email — each carrying #[expose] ...
#[sea_orm(belongs_to, from = "org_id", to = "id")] #[expose] pub org: HasOne<crate::orgs::Entity>,}A nested query batches through the loader rather than issuing one query per parent:
$ curl -s -H "Authorization: Bearer $TOKEN" \ -H 'Content-Type: application/json' \ -d '{"query":"{ users { id name org { id } } }"}' \ http://localhost:3002/graphql{"data":{"users":[ {"id":"018f…","name":"Ada","org":{"id":"018e…"}}]}}The User.org resolver runs through Repo::scoped(Action::Read), so
an out-of-scope org never surfaces — the row-level filter applies to
each batched query the loader issues.
Emit the SDL
Section titled “Emit the SDL”For tooling, commit a schema.graphql alongside the binary. The
framework writes one as a side effect of the dev run when
NESTRS_GRAPHQL__EMIT_SDL=1. Add the path to .gitignore for prod
builds, commit it for dev:
$ NESTRS_GRAPHQL__EMIT_SDL=1 nestrs run dev apiINFO nest_rs::graphql: wrote schema to apps/api/schema.graphql
$ head -5 apps/api/schema.graphqltype Query { user(id: String!): User users(first: Int, after: String): [User!]!}The dev run emits the SDL on demand — one env var, no extra binary to install. Commit the file and assert on it if you want a drift gate.
Now mirror your own blog
Section titled “Now mirror your own blog”posts already has the entity, the service and the policy — a resolver is one
more adapter over them:
$ nestrs g graphql postsThat writes crates/features/src/posts/graphql/{resolver.rs,module.rs,mod.rs}
and wires PostsGraphqlModule into blog. Add GraphqlModule::for_root(None)
to the app’s imports, restart, and POST /graphql answers
{ posts { id title } } through the same ability filter your HTTP routes go
through. No User.org equivalent — that field is the one thing on this page
that needs a relation blog does not have.
What the reference feature has
Section titled “What the reference feature has”- A
UsersResolvermirroring the HTTP controller’s operations, going through the same service and the same policy. - A
UsersGraphqlModulemounted in the app —POST /graphqlanswers{ users { id name } }with the ambient ability filter applied. - An auto-resolved
User.orgfield that batches through a dataloader and respects the row-level scope.
Going further
Section titled “Going further”- Tutorial overview — recap the full
postsbuild, start to finish. - GraphQL — the reference page for resolvers, dataloaders, and the authz bridge.
- Publish — the reference workspace where this multi-transport feature ships.