Mirror on GraphQL
You add a GraphQL adapter to the same feature — 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::authn::AuthGuard;use crate::authz::AuthzGuard;use crate::users::{CreateUserDto, Entity as UserEntity, UpdateUserDto, User, UsersService};
#[resolver]#[use_guards(AuthGuard, AuthzGuard)]pub struct UsersResolver { #[inject] svc: Arc<UsersService>,}
#[crud( service = svc, entity = UserEntity, output = User, create = CreateUserDto, update = UpdateUserDto,)]impl UsersResolver { #[mutation] #[authorize(Create, UserEntity)] async fn create_user(&self, ctx: &Context<'_>, input: CreateUserDto) -> 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::<UsersService, Read>(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, E> extractor | #[authorize(Create, E)] attribute | Refuse the operation if the ability disallows it and mask the response fields |
Bind<UsersService, Read> extractor | bind::<UsersService, Read>(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(AuthGuard, AuthzGuard)] declaration on the
resolver struct — the markers are the same, the access graph checks
the same thing.
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 (the endpoint)
and GET /graphql (the playground) on the HTTP transport. 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, and a missing email field on the
masker drops it from the response. Same policy, two transports.
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)]#[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!]!}No standalone generator binary, no CI drift check — the dev loop emits the SDL when you ask for it.
What you have now
Section titled “What you have now”- 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.