Skip to content

CRUD

One #[crud] attribute on a controller or resolver — list, get, create, update, delete are generated, scoped, and masked, all going through the service.

A REST CRUD or a GraphQL CRUD is one attribute on the controller or resolver impl block. #[crud(...)] reads the entity’s CrudService, synthesises every operation the developer did not hand-write, and re-emits the block under #[routes] (HTTP) or #[operations] (GraphQL). Auth, row-level filtering, by-id binding and response masking come from the same data context described in Database and Security — #[crud] just wires the endpoints.

Terminal window
cargo add nest-rs --features seaorm

#[expose] lives in nest-rs-resource, and seaorm supplies the model it decorates, so one feature activates both crates. Neither half stands alone: #[expose] expands to nest_rs::seaorm paths and #[crud] expands to nest_rs::resource ones, so a feature per crate would have to imply the other — and Cargo rejects that as a cycle. #[wire_enum] rides the same feature for the same reason.

crates/features/src/orgs/http/controller.rs (from the demo)
use std::sync::Arc;
use nest_rs::http::{controller, crud};
use crate::authn::AuthnGuard;
use crate::authz::AuthzGuard;
use crate::orgs::{CreateOrg, Entity as OrgEntity, Org, OrgsService, UpdateOrg};
#[controller(path = "/orgs")]
#[use_guards(AuthnGuard, AuthzGuard)]
pub struct OrgsController {
#[inject]
svc: Arc<OrgsService>,
}
#[crud(
service = svc,
entity = OrgEntity,
output = Org,
create = CreateOrg,
update = UpdateOrg,
)]
impl OrgsController {}

The empty impl block is intentional. From this declaration the framework mounts:

VerbPathAuthz checkBodyReturns
GET/orgsRead on Org—200 Vec<Org> + cursor
GET/orgs/:idRead on Org—200 Org (404/403)
POST/orgsCreate on OrgCreateOrg201 Org + Location
PATCH/orgs/:idUpdate on OrgUpdateOrg200 Org (404/403)
DELETE/orgs/:idDelete on Org—204 (404/403)

Every handler delegates to the same OrgsService instance, which goes through Repo against the ambient executor — so reads are pool, mutations sit inside the request’s transaction, and Ability filters every row.

The 201 carries Location: /orgs/<id> — the row it just minted, as RFC 9110 §15.3.2 asks. The path is the one the caller posted to, so a global prefix or a /v1 segment is already part of it, and the value is an absolute-path reference: naming a host would mean trusting the Host header. The OpenAPI document declares the header, so a generated client reads it rather than discarding it. An entity keyed on something other than a Uuid gets the 201 and the body without the header.

The verb is PATCH, but the body is not a partial one. Update<Name> mirrors the column types the entity exposed with input(update), so a non-Option column is required:

Terminal window
$ curl -X PATCH …/posts/$ID -d '{"title":"Updated"}'
{"…":"…status.400","status":400,"detail":"parse error: missing field `body`"}

Make a column Option<T> on the entity when callers must be able to omit it — that is the one knob, and it makes the field nullable on the wire too.

crates/features/src/orgs/http/controller.rs
#[crud(
service = svc, // the field on the struct holding Arc<…Service>
entity = OrgEntity, // the SeaORM entity (used in authz Authorize<A, S>)
output = Org, // the #[expose]-generated wire type returned by handlers
create = CreateOrg, // the bare entity-derived input (no `Dto` suffix)
update = UpdateOrg,
paginate = cursor, // optional — cursor is already the default; `none` opts out
ops = [list, get, create, update, delete], // optional — omit for all five
)]
impl OrgsController {}
  • service is the field name on the struct, not the type. Follow the framework convention: a single service is named svc, several are <thing>_svc.
  • output is the type the handler returns — typically the #[expose] output (Org), not the SeaORM Model. The shaper runs response masking on it.
  • create and update map to the #[expose(input(create), input(update))] generated input types (bare CreateOrg / UpdateOrg, not …Dto). List only the operations a resource has with ops = [list, get]; omit ops for all five. A create/update op requires its input type and the service’s Creatable/Updatable impl, or the build fails — never a silent no-op.
  • paginate defaults to cursor — every generated list is keyset-paginated (next cursor in x-next-cursor on REST, first/after arguments on GraphQL). paginate = none opts out into the full collection (backstopped by CrudService::list’s hard cap).

#[crud] only generates the operations you did not write. Hand-write the ones that need custom logic — the rest are filled in:

crates/features/src/users/http/controller.rs (from the demo)
#[controller(path = "/users")]
#[use_guards(AuthnGuard, AuthzGuard)]
pub struct UsersController {
#[inject]
svc: Arc<UsersService>,
}
#[crud(
service = svc,
entity = UserEntity,
output = User,
create = CreateUser,
update = UpdateUser,
)]
impl UsersController {
#[post("/")]
#[api(summary = "Create a user in the caller's org", tags("User"))]
async fn create(
&self,
_authz: Authorize<Create, UserEntity>,
auth: Ctx<Claims>,
body: Valid<Json<CreateUser>>,
) -> Result<Json<User>> {
Ok(Json(
self.svc.create_in_org(body.into_inner(), auth.org_id).await?,
))
}
#[get("/:id")]
async fn get(&self, user: Bind<Read, UsersService>) -> Json<User> {
Json(User::from(&*user))
}
}

The create and get methods take over their generated counterparts; the default list, update and delete are still emitted. Names match by ident — a hand-written delete cancels the generated one.

The same attribute, with query/mutation names derived from the output type. User ⇒ users, user, create_user, update_user, delete_user:

crates/features/src/users/graphql/resolver.rs (from the demo)
#[resolver]
#[use_guards(AuthnGuard, 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))
}
}

Every operation — generated or hand-written — declares its posture with #[authorize(Action, Entity)] (gate + automatic response masking) or #[public]; an operation with none does not compile. Posture is always a visible attribute — a parameter type never stands in for it.

Same override rule — the GraphQL create_user and user you wrote take over; users, update_user, delete_user are generated.

A bound mutation acts on one existing row the caller already named by id — publish, archive, confirm. It declares its posture like any other operation — a visible #[authorize(Action, Entity)] — then loads the row in the body with bind_required, which returns the Authorized<A, E> proof to hand straight to the service:

crates/features/src/posts/graphql/resolver.rs (from the demo)
#[crud(service = svc, entity = PostEntity, output = Post, /* … */)]
impl PostsResolver {
#[mutation]
#[authorize(Update, PostEntity)]
async fn publish_post(&self, ctx: &Context<'_>, id: String) -> Result<Post> {
let post = bind_required::<Update, PostsService>(ctx, &id).await?;
Ok(Post::from(&self.svc.publish(post).await?))
}
}

Two pieces, each with one job:

  • #[authorize(Update, PostEntity)] is the posture — the class gate before the body and the response mask after it. This is the only thing that decides authorization, and it is greppable.
  • bind_required::<Update, PostsService>(ctx, &id) is the binding — it parses the id, runs the row-level access check for Update (404 on a missing row, FORBIDDEN on a denied one), and returns the loaded Authorized<Update, PostEntity>.

The action lives in the type, so the proof is action-true: a service method that takes Authorized<Update, E> cannot be handed an Authorized<Read, E> — a compile error, not a runtime surprise. The Authorized<A, E> value is also proof of authorization: its constructor is sealed, mintable only by the binding seams, so the mutation body can neither reach a row the caller could not load nor act under an action it was not granted.

The #[authorize(Update, bind = PostsService)] form is a shorthand: it keeps the posture explicit and binds the Authorized<A, E> subject for you from the container-resolved service, so the body never calls bind_required itself. It also synthesises the id: String! SDL argument — so the method must not declare an id parameter of its own:

crates/features/src/posts/graphql/resolver.rs (from the demo)
use nest_rs::seaorm::Authorized;
#[mutation]
#[authorize(Update, bind = PostsService)]
async fn publish_post(
&self,
_ctx: &Context<'_>,
subject: Authorized<Update, PostEntity>, // the proof, already loaded
) -> Result<Post> {
Ok(Post::from(&self.svc.publish(subject).await?))
}

Writing id: String alongside it is E0415: identifier 'id' is bound more than once in this parameter list, reported against the #[crud(…)] attribute on the impl block rather than the method — drop the parameter, not the attribute.

FormSurfaceWire shape
paginate = cursor (default)HTTP + GraphQLVec<T> body + x-next-cursor header (REST); [T] + first/after arguments (GraphQL)
paginate = noneHTTP + GraphQLfull ability-scoped collection, hard-capped by CrudService::list

Keyset pagination (cursor) is the default — stable under inserts, and the body stays a plain array so response masking works unchanged. Those two values are the whole knob: there is no offset mode, and a consumer that needs page numbers plus a total hand-writes that operation on the service.

See Pagination for the shape end to end.

Every generated get / update / delete calls CrudService::access(action, id) — not a raw find_by_id. The authorization is decided in SQL, by the same condition_for the list path joins, so the row a list returns and the row access grants cannot diverge. Denied vs missing is the reference for how; here is what a handler sees:

Access outcomeMeaningHTTP
Access::Found(m)Row exists, action allowedproceed
Access::MissingRow does not exist404
Access::DeniedRow exists, action denied by a row-level or field-level rule403

#[crud] also rejects non-UUID-v7 ids before any load (route-model binding’s validation half) — a malformed id is 400 Bad Request, never a DB round-trip.

#[crud] adds nothing to the module declaration — the orchestrator on the impl block is what #[controller] / #[resolver] already discover. List the controller (and the service it injects) like any other provider:

crates/features/src/orgs/module.rs
#[module(providers = [OrgsService])]
pub struct OrgsModule;
crates/features/src/orgs/http/module.rs
#[module(imports = [OrgsModule, AuthzModule], providers = [OrgsController])]
pub struct OrgsHttpModule;

The HTTP transport + the data context interceptor activate at the app root with HttpModule::for_root(...), SeaOrmModule::for_root(...) and SeaOrmDatabaseModule. Importing only OrgsModule (no HTTP module) gives a worker the same OrgsService without mounting the endpoints — that is the port + adapter split this framework is built around.

Opt in on the entity — never imposed on every table (join tables, lookups):

crates/features/src/posts/entity.rs
#[expose(
name = "Post",
service = super::service::PostsService,
soft_delete,
timestamps,
)]
pub struct Model {
// …
#[expose]
pub created_at: DateTimeWithTimeZone,
#[expose]
pub updated_at: DateTimeWithTimeZone,
// No #[expose] => hidden on every transport.
pub deleted_at: Option<DateTimeWithTimeZone>,
}

Activate on the service. CrudService carries the read half plus soft_delete_column; the write half is opt-in — implement Creatable, Updatable, Deletable only for the operations the resource offers (the framework cannot infer opt-in per service without specialization):

crates/features/src/posts/service.rs
impl CrudService for PostsService {
type Entity = Posts;
fn soft_delete_column() -> Option<entity::Column> {
Some(entity::Column::DeletedAt)
}
}
impl Creatable for PostsService { type Create = CreatePost; }
impl Updatable for PostsService { type Update = UpdatePost; }
impl Deletable for PostsService {}
ConcernBehaviour
list / page / accessAND deleted_at IS NULL with the ability scope
delete (via CrudService)UPDATE … SET deleted_at = now() — idempotent
Hard purgeRepo::delete directly (admin escape hatch)
timestamps flagEmits ActiveModelBehavior::before_save — remove any manual empty impl ActiveModelBehavior on the entity

Migration snippet (Postgres):

the migration, in SQL
ALTER TABLE post
ADD COLUMN created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
ADD COLUMN updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
ADD COLUMN deleted_at TIMESTAMPTZ NULL;

Custom queries that use Repo::scoped must AND live_condition::<E>() when E: SoftDeletable (e.g. login-by-email paths).

An entity that does not declare soft_delete_column hard-deletes — the right shape for join tables and lookups (orgs/ in Publish stays hard delete). Drop both halves together when you mean it: the entity flag makes the column addressable, the service override is what tombstones.

Keeping only one half is a boot failure, not a caveat. #[expose(..., soft_delete)] on an entity whose service never overrides the column would answer DELETE with the same 204 a tombstone answers, having destroyed the row — so SeaOrmDatabaseModule refuses to start and names both sides:

Terminal window
Error: soft delete is declared on the entity but not on the service:
- `post` is `#[expose(..., soft_delete)]` but `features::posts::PostService`
does not override `CrudService::soft_delete_column`: DELETE would erase the
row for good and reads would never filter `deleted_at`.

nestrs g resource scaffolds both halves plus the three columns, matching the migration it tells you to generate next.

  • crates/features/src/orgs/ — the empty-impl exemplar (cursor pagination).
  • crates/features/src/users/ — the override exemplar (create + get custom, the rest generated).
  • crates/nest-rs-http-macros/src/crud.rs — the REST expansion.
  • crates/nest-rs-graphql-macros/src/crud.rs — the GraphQL expansion.
  • crates/nest-rs-seaorm/ — CrudService, Access, Repo.
  • crates/nest-rs-resource/ — #[expose] for the entity, pagination envelopes.
  • Database — CrudService, Repo, the ambient executor; why every CRUD call goes through the service.
  • Security — Ability, Authorize, the masking shaper; what makes Read / Create / Update / Delete mean what they mean here.
  • OpenAPI — every generated route ships with an #[api] summary, the right tags, and the response schema of the #[expose]d entity, so the document at GET /api-json composes automatically.
  • GraphQL — the #[crud] twin on a resolver; relations and field resolvers backed by dataloaders.