Expose over HTTP
You write the service that owns the entity, the controller that mounts
the CRUD routes, and the HTTP module that ties them together. By the end
of this page, the boot log shows five CRUD routes mounted from one empty
#[crud] impl. The first successful curl lands on the next page,
Persist through Postgres, once Postgres is wired.
The service
Section titled “The service”A service is the entity’s single DB gateway. Every read and write goes
through Repo<Posts>; controllers, resolvers, gateways never touch
SeaORM directly. The HTTP layer in this section drives a service that
implements CrudService — that trait is what the #[crud(...)] macro
on the controller expands against.
The service crate here is
nest-rs-seaorm —
it provides CrudService, Repo, ServiceError, and the SeaORM
integration. SeaORM stays the source of truth for the column types and
the active-model semantics.
use nest_rs_core::injectable;use nest_rs_seaorm::{Creatable, CrudService, Deletable, Updatable};
use super::entity::{CreatePost, Entity as Posts, UpdatePost};
#[injectable]#[derive(Default)]pub struct PostsService;
impl CrudService for PostsService { type Entity = Posts;}
impl Creatable for PostsService { type Create = CreatePost;}
impl Updatable for PostsService { type Update = UpdatePost;}
impl Deletable for PostsService {}#[injectable] marks the struct as a provider — the container builds
it once and shares it as Arc<PostsService>. CrudService carries the
read half — it names the entity, and every resource implements it.
Writes are opt-in traits: Creatable (type Create), Updatable
(type Update), and Deletable — a resource implements each only when
it genuinely offers that operation. A read-only projection implements
CrudService alone and declares no write trait. No custom methods yet:
the read half plus these three write impls give a bare CRUD feature its
full surface.
Repo::<Posts>::conn() reaches the ambient executor — the pool on safe
routes, the transaction on mutating routes. You don’t pass it in; the
data layer installs it before the handler runs
(Persist through Postgres makes that concrete).
Register the service in the port module:
use nest_rs_core::module;
use super::service::PostsService;
#[module(providers = [PostsService])]pub struct PostsModule;The controller
Section titled “The controller”A controller is a struct with #[controller(path = ...)] and a
#[crud(...)] impl block that declares the entity. The macro generates
every CRUD verb — list, page, create, get, update, delete —
from the service’s trait impls: the read verbs from CrudService, and
each mutating verb only if the matching write trait is implemented.
Naming create = ... without a Creatable impl is a build break, not
a silent no-op — a forgotten write path fails to compile. You don’t
write handler bodies unless you need domain logic the trait doesn’t
cover.
use std::sync::Arc;
use nest_rs_http::{controller, crud};
use crate::authn::AuthnGuard;use crate::authz::AuthzGuard;use crate::posts::{CreatePost, Entity as PostEntity, Post, PostsService, UpdatePost};
#[controller(path = "/posts")]#[use_guards(AuthnGuard, AuthzGuard)]pub struct PostsController { #[inject] svc: Arc<PostsService>,}
#[crud( service = svc, entity = PostEntity, output = Post, create = CreatePost, update = UpdatePost,)]impl PostsController {}The guards are not optional
Section titled “The guards are not optional”A DB-backed controller needs the two guards, and that is a property of the
data layer rather than a policy choice. Repo filters every read by the
caller’s ambient Ability; nothing but AuthzGuard
installs one, and without it every row is filtered out and every generated op
rejects the request before it reaches your service. There is no “skip auth for
now” configuration — a public read of a table is not something the 1.0 data
layer expresses.
AuthnGuard and AuthzGuard are your types, not framework ones: the
framework is generic over the principal and the policy, so each workspace writes
them once. Generate them:
nestrs g authThat writes crates/features/src/{identity,authn,authz}/, adds the two modules
to the app, and appends a development NESTRS_AUTHN__SECRET to .env. Then
grant the ability — the policy starts empty, so Post needs a rule:
use nest_rs_authz::{AbilityBuilder, AbilityFactory, Action};use crate::posts as post;
impl AbilityFactory for AppAbility { type Actor = Claims;
fn define(&self, _actor: &Claims, ab: &mut AbilityBuilder) { ab.can(Action::Manage, post::Entity); }}Manage is every action on Post, unconditionally — the smallest rule that
lets the tutorial move. Authenticate and authorize
narrows it down to what a given caller may actually touch.
The HTTP module
Section titled “The HTTP module”The feature’s HTTP adapter is a separate module. It imports the port
(PostsModule) and AuthzHttpModule, which is what makes the ability guard
reachable — omit it and the access graph fails the boot by name.
use nest_rs_core::module;
use super::controller::PostsController;use crate::authz::AuthzHttpModule;use crate::posts::PostsModule;
#[module( imports = [PostsModule, AuthzHttpModule], providers = [PostsController],)]pub struct PostsHttpModule;mod controller;mod module;
pub use controller::PostsController;pub use module::PostsHttpModule;Add the adapter to the feature root:
mod entity;mod module;mod service;
pub mod http;
pub use entity::*;pub use module::PostsModule;pub use service::PostsService;
pub use http::{PostsController, PostsHttpModule};Wire it in
Section titled “Wire it in”The app root imports the feature’s HTTP adapter beside the greeting one the scaffold wired on page 1.
use nest_rs_core::module;use nest_rs_http::{HttpConfig, HttpModule};
use features::authn::AuthnModule;use features::authz::AuthzHttpModule;use features::blog::BlogHttpModule;use features::posts::PostsHttpModule;
#[module(imports = [ HttpModule::for_root(HttpConfig { port: 3005, ..Default::default() }), AuthnModule, AuthzHttpModule, BlogHttpModule, PostsHttpModule,])]pub struct BlogModule;BlogHttpModule is the scaffold’s greeting on /. Drop the import and delete
crates/features/src/blog/ whenever you want — it exists so page 1 had
something to prove.
PostsHttpModule already imports AuthzHttpModule transitively, so listing
both here changes nothing at boot. It changes what the file tells you: an
app’s root module is the inventory of the concerns it serves, and auth is one of
them.
Add features to apps/blog/Cargo.toml:
[dependencies]nest-rs-core.workspace = truefeatures.workspace = truenest-rs-http.workspace = trueanyhow.workspace = truetokio.workspace = trueThe port 3005 is what the reference workspace pins — yours may differ
if other apps already occupy it when you ran nestrs new blog.
Run it
Section titled “Run it”Boot the binary and read the route table from the boot log — the
nest_rs::routes target prints every mount on startup.
$ nestrs run dev blogDEBUG nest_rs::http: transport listening addr=0.0.0.0:3005 tls=falseINFO nest_rs::routes: mounted route controller=PostsController method=GET path=/postsINFO nest_rs::routes: mounted route controller=PostsController method=POST path=/postsINFO nest_rs::routes: mounted route controller=PostsController method=GET path=/posts/:idINFO nest_rs::routes: mounted route controller=PostsController method=PATCH path=/posts/:idINFO nest_rs::routes: mounted route controller=PostsController method=DELETE path=/posts/:idFive routes mounted from one #[crud] block — the full CRUD surface,
before you wrote a single handler body. The shape is right; the live
call lands on the next page.
What fails if you get it wrong
Section titled “What fails if you get it wrong”The guards run first, so an anonymous call stops at the door — the route table is mounted, the data layer is not, and neither matters yet:
$ curl -s -o /dev/null -w '%{http_code}\n' http://localhost:3005/posts401WARN nest_rs::authn: authentication failed reason="missing_credentials"WARN nest_rs::layers: guard denied the request status=401Drop #[use_guards(...)] from the controller and the answer becomes a 500
instead, because #[crud] still emits the class gate and nothing installed an
ability for it to read:
ERROR nest_rs::authz: missing request Ability — route is authorized but no ability guard ran action=Read subject="features::posts::entity::Entity" hint="bind the ability guard (#[use_guards(AuthnGuard, AuthzGuard)]) …"That log line is the difference between a five-minute fix and an afternoon:
grep nest_rs::authz whenever a guarded route answers 500.
Persist through Postgres plugs DatabaseModule in, and
the same curl — with a token — returns a post.
What you have now
Section titled “What you have now”- A
PostsServicethat owns the entity throughRepo<Posts>. - A
PostsControllerwith the full CRUD surface#[crud]generated, behindAuthnGuard+AuthzGuard. - An
AppAbilitygrantingManageonPost. - A
PostsHttpModulemounted in the app — the boot log shows the routes.
Going further
Section titled “Going further”- Persist through Postgres — the next step: wire
DatabaseModuleand see the first curl return a post. - Controllers — the reference page for
#[controller]and#[crud].
Built by YV17labs