Skip to content

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.

crates/features/src/orgs/http/controller.rs
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 OrgVec<Org> + cursor
GET/orgs/:idRead on OrgOrg (404/403)
POST/orgsCreate on OrgCreateOrgOrg
PATCH/orgs/:idUpdate on OrgUpdateOrgOrg (404/403)
DELETE/orgs/:idDelete on Org204 (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 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.

#[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
#[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 same attribute, with query/mutation names derived from the output type. Userusers, user, create_user, update_user, delete_user:

crates/features/src/users/graphql/resolver.rs
#[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.

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:

crates/features/src/posts/graphql/resolver.rs
#[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 for Update (404 on a missing row, FORBIDDEN on a denied one), and returns the loaded Authorized<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.

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. 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 outcomeMeaningHTTP
Access::Found(m)Row visible, action allowedproceed
Access::MissingRow absent (or invisible at the row level)404
Access::DeniedRow visible, action denied by a 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:

src/orgs/module.rs
#[module(providers = [OrgsService])]
pub struct OrgsModule;
src/orgs/http/module.rs
#[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.

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

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):

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_saveremove 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).

  • 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.
  • DatabaseCrudService, Repo, the ambient executor; why every CRUD call goes through the service.
  • SecurityAbility, Authorize, the masking shaper; what makes Read / Create / Update / Delete mean what they mean here.
  • OpenAPI — every generated route ships with an #[api] summary and the right tags, so the document at GET /api-json composes automatically.
  • GraphQL — the #[crud] twin on a resolver; relations and field resolvers backed by dataloaders.

Built by YV17labs