Skip to content

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.

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.

crates/features/src/posts/service.rs (from the demo)
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:

crates/features/src/posts/module.rs (from the demo)
use nest_rs::core::module;
use super::service::PostsService;
#[module(providers = [PostsService])]
pub struct PostsModule;

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.

crates/features/src/posts/http/controller.rs (from the demo)
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:

Terminal window
nestrs g auth

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

crates/features/src/authz/ability.rs (from the demo)
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 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.

crates/features/src/posts/http/module.rs (from the demo)
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;
crates/features/src/posts/http/mod.rs (from the demo)
mod controller;
mod module;
pub use controller::PostsController;
pub use module::PostsHttpModule;

Add the adapter to the feature root:

crates/features/src/posts/mod.rs (from the demo)
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};

The app root imports the feature’s HTTP adapter beside the greeting one the scaffold wired on page 1.

apps/blog/src/module.rs
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:

apps/blog/Cargo.toml (from the demo)
[dependencies]
nest-rs.workspace = true
features.workspace = true
anyhow.workspace = true
tokio.workspace = true

The port 3005 is what the reference workspace pins — yours may differ if other apps already occupy it when you ran nestrs new blog.

Boot the binary and read the route table from the boot log — the nest_rs::routes target prints every mount on startup.

Terminal window
$ nestrs run dev blog
DEBUG nest_rs::http: transport listening addr=0.0.0.0:3005 tls=false
Terminal window
INFO 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.

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:

Terminal window
$ curl -s -o /dev/null -w '%{http_code}\n' http://localhost:3005/posts
401
Terminal window
WARN nest_rs::authn: authentication failed strategy="…JwtStrategy<…Claims>" reason="missing_credentials" trace_id=01a0156ebd7d77e0b78201a531dd9536 span_id=e0312d68a4872409
WARN nest_rs::layers: guard denied the request guard="…AuthnGuard<…>" status=401 trace_id=01a0156ebd7d77e0b78201a531dd9536 span_id=e0312d68a4872409

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

Terminal window
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.

  • A PostsService that owns the entity through Repo<Posts>.
  • A PostsController with the full CRUD surface #[crud] generated, behind AuthnGuard + AuthzGuard.
  • An AuthzAbility granting Manage on Post.
  • A PostsHttpModule mounted in the app — the boot log shows the routes.
  • Persist through Postgres — the next step: wire SeaOrmDatabaseModule and see the first curl return a post.
  • Controllers — the reference page for #[controller] and #[crud].