Expose over HTTP
A PostsService that goes through Repo, a PostsController that mounts GET /posts/:id and POST /posts, the first curl that returns a post.
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 {}Every read is filtered by its caller, starting here
Section titled “Every read is filtered by its caller, starting here”From this line on, no row crosses the data layer without someone to answer for
it. Repo ANDs the caller’s ambient Ability into
every read and every by-id write; nothing but AuthzGuard installs one, so a
request with no ability reads nothing rather than everything. That is the framework’s central claim doing its work: row-level filtering
you never write a WHERE for. The price is that it starts now rather than
later — there is no “skip auth for now” switch, because 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 modules to
the app, appends a development NESTRS_AUTHN__SECRET to .env, and gives you
POST /auth/dev-token — the route the next page gets its bearer token from.
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 AuthzAbility { 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 AuthzModule, 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::AuthzModule;use crate::posts::PostsModule;
#[module( imports = [PostsModule, AuthzModule], 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::AuthzModule;use features::blog::BlogHttpModule;use features::posts::PostsHttpModule;
#[module(imports = [ HttpModule::for_root(HttpConfig { port: 3005, ..Default::default() }), AuthnModule, AuthzModule, 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 AuthzModule 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.workspace = truefeatures.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="/posts" handler="list"INFO nest_rs::routes: mounted route controller="PostsController" method="POST" path="/posts" handler="create"INFO nest_rs::routes: mounted route controller="PostsController" method="GET" path="/posts/:id" handler="get"INFO nest_rs::routes: mounted route controller="PostsController" method="PATCH" path="/posts/:id" handler="update"INFO nest_rs::routes: mounted route controller="PostsController" method="DELETE" path="/posts/:id" handler="delete"Five 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 strategy="…JwtStrategy<…Claims>" reason="missing_credentials" trace_id=01a0156ebd7d77e0b78201a531dd9536 span_id=e0312d68a4872409WARN nest_rs::layers: guard denied the request guard="…AuthnGuard<…>" status=401 trace_id=01a0156ebd7d77e0b78201a531dd9536 span_id=e0312d68a4872409Both lines carry the request’s trace_id, and no actor_id — nobody was
authenticated, which is the whole story of a 401.
Drop #[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 SeaOrmDatabaseModule 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
AuthzAbilitygrantingManageonPost. - A
PostsHttpModulemounted in the app — the boot log shows the routes.
Going further
Section titled “Going further”- Persist through Postgres — the next step: wire
SeaOrmDatabaseModuleand see the first curl return a post. - Controllers — the reference page for
#[controller]and#[crud].