CRUD
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 #[resolver] (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.
A full REST CRUD, 22 lines
Section titled “A full REST CRUD, 22 lines”use std::sync::Arc;
use nest_rs_http::{controller, crud};
use crate::authn::AuthGuard;use crate::authz::AuthzGuard;use crate::orgs::{CreateOrgDto, Entity as OrgEntity, Org, OrgsService, UpdateOrgDto};
#[controller(path = "/orgs")]#[use_guards(AuthGuard, AuthzGuard)]pub struct OrgsController { #[inject] svc: Arc<OrgsService>,}
#[crud( service = svc, entity = OrgEntity, output = Org, create = CreateOrgDto, update = UpdateOrgDto,)]impl OrgsController {}The empty impl block is intentional. From this declaration the framework mounts:
| Verb | Path | Authz check | Body | Returns |
|---|---|---|---|---|
GET | /orgs | Read on Org | — | Vec<Org> + cursor |
GET | /orgs/:id | Read on Org | — | Org (404/403) |
POST | /orgs | Create on Org | CreateOrgDto | Org |
PATCH | /orgs/:id | Update on Org | UpdateOrgDto | Org (404/403) |
DELETE | /orgs/:id | Delete 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.
What #[crud] needs
Section titled “What #[crud] needs”#[crud( service = svc, // the field on the struct holding Arc<…Service> entity = OrgEntity, // the SeaORM entity (used in authz Authorize<Action, Entity>) output = Org, // the #[expose]-generated wire type returned by handlers create = CreateOrgDto, update = UpdateOrgDto, paginate = cursor, // optional — cursor is already the default; `none` opts out readonly, // optional — only list + get, no mutations)]impl OrgsController {}serviceis the field name on the struct, not the type. Follow the framework convention: a single service is namedsvc, several aresvc_<thing>.outputis the type the handler returns — typically the#[expose]output (Org), not the SeaORMModel. The shaper runs response masking on it.createandupdatemap to the#[expose(input(create), input(update))]generated input types. Omit them withreadonlyfor a read-only resource.paginatedefaults tocursor— every generated list is keyset-paginated (next cursor inx-next-cursoron REST,first/afterarguments on GraphQL).paginate = noneopts out into the full collection (backstopped byCrudService::list’s hard cap).
Override one handler, keep the rest
Section titled “Override one handler, keep the rest”#[crud] only generates the operations you did not write. Hand-write
the ones that need custom logic — the rest are filled in:
#[controller(path = "/users")]#[use_guards(AuthGuard, AuthzGuard)]pub struct UsersController { #[inject] svc: Arc<UsersService>,}
#[crud( service = svc, entity = UserEntity, output = User, create = CreateUserDto, update = UpdateUserDto,)]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<CreateUserDto>>, ) -> 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<UsersService, Read>) -> 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 GraphQL twin
Section titled “The GraphQL twin”The same attribute, with query/mutation names derived from the output
type. User ⇒ users, user, create_user, update_user,
delete_user:
#[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)) }}Every operation — generated or hand-written — declares its posture with
#[authorize(Action, Entity)] (gate + automatic response masking) or
#[public]; an operation with neither does not compile.
Same override rule — the GraphQL create_user and user you wrote take
over; users, update_user, delete_user are generated.
Pagination
Section titled “Pagination”| Form | Surface | Wire shape |
|---|---|---|
paginate = cursor (default) | HTTP + GraphQL | Vec<T> body + x-next-cursor header (REST); [T] + first/after arguments (GraphQL) |
paginate = none | HTTP + GraphQL | full ability-scoped collection, hard-capped by CrudService::list |
paginate = page | not wired yet | compile error pointing here — hand-write against <T>Page |
Keyset pagination (cursor) is the default — stable under inserts, and the
body stays a plain array so response masking works unchanged. For an
offset envelope (page numbers + total), hand-write the operation against
the <T>Page type the entity’s #[expose] attribute emits when it
declares paginate:
#[expose(name = "Item", complex, paginate)]See Pagination for both shapes end to end.
By-id binding goes through the service
Section titled “By-id binding goes through the service”Every generated get / update / delete calls
CrudService::access(action, id) — not a raw find_by_id. access loads
the row through Repo (so the ability’s Condition filters it), then
checks the field-level rules of Ability::can against the loaded
model. The result tells the handler which HTTP status to return:
Access outcome | Meaning | HTTP |
|---|---|---|
Access::Found(m) | Row visible, action allowed | proceed |
Access::Missing | Row absent (or invisible at the row level) | 404 |
Access::Denied | Row visible, action denied by a field-level rule | 403 |
#[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.
Wire it in
Section titled “Wire it in”#[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:
#[module(providers = [OrgsService])]pub struct OrgsModule;#[module(imports = [OrgsModule, AuthzHttpModule], providers = [OrgsController])]pub struct OrgsHttpModule;The HTTP transport + the data context interceptor activate at the app
root with HttpModule::for_root(...) and DatabaseModule::for_root(...).
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.
Soft delete and timestamps
Section titled “Soft delete and timestamps”Opt in on the entity — never imposed on every table (join tables, lookups):
#[expose( name = "Item", service = super::service::ItemsService, 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 (one line — the framework cannot infer opt-in per service without specialization):
impl CrudService for ItemsService { type Entity = Items; type Create = CreateItemDto; type Update = UpdateItemDto;
fn soft_delete_column() -> Option<entity::Column> { Some(entity::Column::DeletedAt) }}| Concern | Behaviour |
|---|---|
list / page / access | AND deleted_at IS NULL with the ability scope |
delete (via CrudService) | UPDATE … SET deleted_at = now() — idempotent |
| Hard purge | Repo::delete directly (admin escape hatch) |
timestamps flag | Emits ActiveModelBehavior::before_save — remove any manual empty impl ActiveModelBehavior on the entity |
Migration snippet (Postgres):
ALTER TABLE item 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).
Entities without soft_delete_column keep today’s hard-delete semantics
(orgs/ in Publish stays hard delete).
Reference
Section titled “Reference”crates/features/src/orgs/— the empty-impl exemplar (cursor pagination).crates/features/src/users/— the override exemplar (create+getcustom, 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.
Going further
Section titled “Going further”- Database —
CrudService,Repo, the ambient executor; why every CRUD call goes through the service. - Security —
Ability,Authorize, the masking shaper; what makesRead/Create/Update/Deletemean what they mean here. - OpenAPI — every generated route ships with an
#[api]summary and the righttags, so the document atGET /api-jsoncomposes automatically. - GraphQL — the
#[crud]twin on a resolver; relations and field resolvers backed by dataloaders.