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

VerbPathAuthz checkBodyReturns
GET/orgsRead on OrgVec<Org> + cursor
GET/orgs/:idRead on OrgOrg (404/403)
POST/orgsCreate on OrgCreateOrgDtoOrg
PATCH/orgs/:idUpdate on OrgUpdateOrgDtoOrg (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.

#[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 {}
  • service is the field name on the struct, not the type. Follow the framework convention: a single service is named svc, several are svc_<thing>.
  • 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. Omit them with readonly for a read-only resource.
  • 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(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 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(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.

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
paginate = pagenot wired yetcompile 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.

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/items/entity.rs
#[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):

src/items/service.rs
impl CrudService for ItemsService {
type Entity = Items;
type Create = CreateItemDto;
type Update = UpdateItemDto;
fn soft_delete_column() -> Option<entity::Column> {
Some(entity::Column::DeletedAt)
}
}
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 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).

  • 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.