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::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:
| 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 | CreateOrg | Org |
PATCH | /orgs/:id | Update on Org | UpdateOrg | 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.
PATCH takes a whole Update<Name>
Section titled “PATCH takes a whole Update<Name>”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:
$ 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.
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<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 {}serviceis the field name on the struct, not the type. Follow the framework convention: a single service is namedsvc, several are<thing>_svc.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 (bareCreateOrg/UpdateOrg, not…Dto). List only the operations a resource has withops = [list, get]; omitopsfor all five. A create/update op requires its input type and the service’sCreatable/Updatableimpl, or the build fails — never a silent no-op.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(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<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(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::<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 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.
Bound mutations
Section titled “Bound mutations”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<E, A> proof to hand straight to the service:
#[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::<PostsService, Update>(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::<PostsService, Update>(ctx, &id)is the binding — it parses the id, runs the row-level access check forUpdate(404 on a missing row,FORBIDDENon a denied one), and returns the loadedAuthorized<PostEntity, Update>.
The action lives in the type, so the proof is action-true: a service
method that takes Authorized<E, Update> cannot be handed an
Authorized<E, Read> — a compile error, not a runtime surprise. The
Authorized<E, A> 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.
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 |
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.
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 = "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):
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 {}| 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 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
default, and the right shape for join tables and lookups (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.
Built by YV17labs