Your first app
Build a small task-list API with an entity, a migration, validation, JWT authentication, access rules and feature tests, from navree new to green tests.
On this page
This tutorial builds Planner, a small JSON API for to-do lists. People sign up, sign in, create tasks and tick them off, and nobody can see or change anyone else's tasks. Along the way you use the generators, write a migration and an entity, validate input, protect routes with access attributes, and finish with feature tests that sign up real users.
Every command and file below comes from a real run of the CLI, and the finished app passes its tests and cargo clippy -- -D warnings.
Before you start
You need Rust 1.95 or newer and the navree CLI. Installation covers both. The commands use a POSIX shell; on Windows, run them in Git Bash or WSL, or adapt the curl quoting for PowerShell.
Create the app
navree new planner
cd planner
navree key:generatenavree new writes the app; Directory structure explains each file. navree key:generate compiles the app (a few minutes the first time) and prints a key. Put it in .env:
APP_KEY=base64:G60q3lWREHaFECJUwNhN58ArvmrSl0sptSaOEWgeBLk=The app already has accounts: AuthModule serves /api/auth/register, /api/auth/login and the rest, and stores users in its own users table. You add the tasks.
Generate the tasks module
A feature lives in a module: a directory under src/modules/ with its routes, logic, table and tests. Generate one with the full set of create, read, update and delete routes:
navree make:module tasks --crud created src/modules/tasks/mod.rs
created src/modules/tasks/controller.rs
created src/modules/tasks/service.rs
created src/modules/tasks/entity.rs
created src/modules/tasks/dto.rs
created src/modules/tasks/migrations/mod.rs
created src/modules/tasks/migrations/m20261026_081038_create_tasks_table.rs
created src/modules/tasks/tests/mod.rs
updated src/modules/mod.rs
updated src/app.rsThe generator also declared the module in src/modules/mod.rs and added it to the app root in src/app.rs:
// Feature modules (`navree make:module` adds new ones above the marker).
.module(modules::users::UsersModule)
.module(modules::tasks::TasksModule)
// navree:modulesThe module's files:
| File | What it holds |
|---|---|
mod.rs | TasksModule: its providers, controller, the import that registers Task with the access module, and its migrations |
controller.rs | TaskController: five routes under /api/v1/tasks, each with an access check |
service.rs | TaskService: listing, creating, updating and deleting, in the request's unit of work |
entity.rs | The SeaORM entity for the tasks table, and Task, the access subject |
dto.rs | The request bodies and their validation rules |
migrations/ | The migration that creates the tasks table |
tests/mod.rs | Tests that boot the module on a fresh SQLite database |
A generated resource has a name column. A task needs a title and a done flag, so the next sections change the migration, the entity, the request bodies and the service.
Define the table
Open the migration and replace the name column with title and done:
Table::create()
.table(Tasks::Table)
.col(ColumnDef::new(Tasks::Id).uuid().not_null().primary_key())
.col(ColumnDef::new(Tasks::OwnerId).uuid().null())
.col(ColumnDef::new(Tasks::Name).string().not_null())
.col(ColumnDef::new(Tasks::Title).string().not_null())
.col(
ColumnDef::new(Tasks::Done)
.boolean()
.not_null()
.default(false),
)
// ... created_at and updated_atThe column names come from the Tasks enum at the bottom of the file:
#[derive(DeriveIden)]
enum Tasks {
Table,
Id,
OwnerId,
Name,
Title,
Done,
CreatedAt,
UpdatedAt,
}Migrations are SeaORM migrations. Each module owns a migration set (MigrationSet::new("tasks")), and navree migrate runs every module's set in dependency order. Migrations covers the rest.
Update the entity
The entity is the Rust shape of a row. It is also what the API returns, since the generated routes serialize it as it is:
#[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel, Serialize)]
#[sea_orm(table_name = "tasks")]
pub struct Model {
/// A UUID v7, so ids sort by creation time.
#[sea_orm(primary_key, auto_increment = false)]
pub id: Uuid,
/// The user who created it (abilities can test `owner_id`).
pub owner_id: Option<Uuid>,
pub name: String,
/// What needs doing.
pub title: String,
/// Whether it is done.
pub done: bool,
pub created_at: DateTimeUtc,
pub updated_at: DateTimeUtc,
}Below the model, the generator declared the access subject. Rules call it Task and permissions start with tasks.:
impl Subject for Entity {
const SUBJECT: &'static str = "Task";
const PERMISSION_PREFIX: &'static str = "tasks";
}
pub type Task = Entity;Validate the input
Request bodies are plain structs with garde rules. The Valid extractor checks them before the handler runs. Replace dto.rs:
//! Request bodies of the tasks API, with their validation rules
//! (checked by the `Valid` extractor before the handler runs).
use garde::Validate;
use serde::Deserialize;
/// The body of `POST /api/v1/tasks`.
#[derive(Debug, Deserialize, Validate)]
pub struct CreateTask {
/// 1 to 200 characters.
#[garde(length(min = 1, max = 200))]
pub title: String,
}
/// The body of `PATCH /api/v1/tasks/{id}`: only the fields present change.
#[derive(Debug, Deserialize, Validate)]
pub struct UpdateTask {
/// 1 to 200 characters.
#[garde(length(min = 1, max = 200))]
pub title: Option<String>,
/// Marks the task done or not done.
#[garde(skip)]
pub done: Option<bool>,
}
impl UpdateTask {
/// The fields this update changes, for field-level ability checks.
pub fn changed_fields(&self) -> Vec<&'static str> {
let mut fields = Vec::new();
if self.title.is_some() {
fields.push("title");
}
if self.done.is_some() {
fields.push("done");
}
fields
}
}garde requires a rule on every field, so done says #[garde(skip)]; serde already guarantees it is a boolean. changed_fields feeds field-level access rules, which you can add later without touching the controller. Validation lists the rules and the error format.
Write the service
The service does the work. Change the two methods that set columns:
/// Creates a task owned by the acting user.
pub async fn create(&self, uow: &Uow, input: CreateTask) -> Result<Model, DbErr> {
let now = Utc::now();
let task = ActiveModel {
id: Set(Uuid::now_v7()),
owner_id: Set(uow.actor()),
name: Set(input.name),
title: Set(input.title),
done: Set(false),
created_at: Set(now),
updated_at: Set(now),
};
Repo::<Entity>::insert(uow, task).await
}
/// Applies the fields present in `input`.
pub async fn update(&self, uow: &Uow, task: Model, input: UpdateTask) -> Result<Model, DbErr> {
let mut active: ActiveModel = task.into();
if let Some(name) = input.name {
active.name = Set(name);
}
if let Some(title) = input.title {
active.title = Set(title);
}
if let Some(done) = input.done {
active.done = Set(done);
}
active.updated_at = Set(Utc::now());
Repo::<Entity>::update(uow, active).await
}Uow is the request's unit of work: a transaction that opens on first use and commits when the response status is below 400, or rolls back otherwise. uow.actor() is the signed-in user's id, so every task records its owner. Repo writes through the unit of work and records a change event for each write, which the audit log and change observers receive at commit. See The unit of work.
Protect the routes
The controller needs no changes. Here is what the generator wrote:
#[controller("/tasks", version = "1")]
impl TaskController {
/// Lists the tasks the user may read, newest first
/// (`?page=2&per_page=50`).
#[get("/")]
#[can(Read, Task)]
async fn index(
&self,
uow: Uow,
scope: Accessible<Task, Read>,
page: PageQuery,
) -> Result<Page<Model>> {
Ok(self.tasks.list(&uow, scope, page).await?)
}
/// Creates a task owned by the caller.
#[post("/")]
#[can(Create, Task)]
async fn store(
&self,
uow: Uow,
Valid(Json(input)): Valid<Json<CreateTask>>,
) -> Result<Created<Model>> {
let task = self.tasks.create(&uow, input).await?;
Ok(Created(format!("/api/v1/tasks/{}", task.id), task))
}
/// Updates the fields present in the body.
#[patch("/{id}")]
#[can(Update, Task, from = "id")]
async fn update(
&self,
uow: Uow,
task: Authorized<Task, Update>,
Valid(Json(input)): Valid<Json<UpdateTask>>,
) -> Result<Json<Model>> {
task.check_fields(&input.changed_fields())?;
let task = self.tasks.update(&uow, task.into_inner(), input).await?;
Ok(Json(task))
}
// ... show and destroy
}#[controller("/tasks", version = "1")]mounts the routes at/api/v1/tasks: thehttp.api_prefix, the version, then the path.#[can(Create, Task)]lets the request through only if the caller's abilities allow creating aTask. A guest gets 401 and a signed-in user without the ability gets 403.#[can(Update, Task, from = "id")]loads the task named by the{id}path parameter, checks the ability against that record, and hands it to the handler asAuthorized<Task, Update>. A missing task is a 404.Accessible<Task, Read>turns the same rules into a SQL filter, so the list holds only the tasks the user may read.
Routes are deny-by-default: a route without #[public], #[authenticated], #[roles], #[permissions] or #[can] doesn't compile. Access control on routes lists every attribute.
Decide who may do what
The #[can] checks consult the user's abilities. Right now nobody has any on Task, so every task route answers 403. Generate a policy:
navree make:policy tasks task created src/modules/tasks/policies/task.rs
created src/modules/tasks/policies/mod.rs
updated src/modules/tasks/mod.rs
updated src/abilities.rsThe generator also calls the policy from the app's abilities function:
ctx.grant_permissions(rules);
// Module policies (`navree make:policy` adds them above the marker).
crate::modules::tasks::policies::task::define(ctx, rules);
// navree:policiesNow write the rules: any signed-in user may create tasks, and may read, update and delete their own.
//! Abilities on `Task`, added to every user's rules by the app's
//! abilities function (`src/abilities.rs`).
use navree::access::{AbilityCtx, CREATE, DELETE, READ, Rules, UPDATE, field};
/// The rules on `Task` for one user (or guest). Rules and conditions
/// name database columns; later rules win.
pub fn define(ctx: &AbilityCtx, rules: &mut Rules) {
let Some(user) = &ctx.user else {
return;
};
rules.can(CREATE, "Task");
for action in [READ, UPDATE, DELETE] {
rules
.can(action, "Task")
.when(field("owner_id").eq(user.id));
}
}The rules follow CASL's model. owner_id is a column, so the same condition checks a single task and filters the list in SQL. Users with the admin role pass every check (super_admin_role in src/app.rs). Authorization covers roles, permissions and field rules.
Try it
Start the app:
navree devSign up. Registration is on in new apps (auth.registration = true), and it returns tokens straight away:
curl -s -X POST http://127.0.0.1:8080/api/auth/register \
-H 'Content-Type: application/json' \
-d '{"name":"Ada","email":"[email protected]","password":"correct horse"}'{
"access_token": "eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9...",
"expires_in": 900,
"refresh_token": "01a17746-ba88-7403-9c7b-1745d2ce4cc6.XPfb8pg4HWAME1TAx4nwNQo2...",
"token_type": "Bearer",
"user": {
"__type": "User",
"created_at": "2026-10-26T08:14:46.406702500Z",
"email": "[email protected]",
"email_verified_at": null,
"id": "01a17746-ba87-7343-b9dd-cbe3dcd0add6",
"name": "Ada"
}
}The access token is a JWT that lasts 15 minutes; POST /api/auth/refresh with the refresh token gets a new pair, and POST /api/auth/login signs in again later. Copy the access token into a shell variable:
TOKEN=eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9... # the access_token from the responseCreate a task:
curl -s -X POST http://127.0.0.1:8080/api/v1/tasks \
-H "Authorization: Bearer $TOKEN" \
-H 'Content-Type: application/json' \
-d '{"title":"Write the docs"}'{
"id": "01a17746-cd83-7311-8275-b4a3a5ee89fd",
"owner_id": "01a17746-ba87-7343-b9dd-cbe3dcd0add6",
"title": "Write the docs",
"done": false,
"created_at": "2026-10-26T08:14:51.267783400Z",
"updated_at": "2026-10-26T08:14:51.267783400Z"
}The status is 201 Created, with Location: /api/v1/tasks/01a17746-.... List your tasks:
curl -s http://127.0.0.1:8080/api/v1/tasks -H "Authorization: Bearer $TOKEN"{
"data": [
{
"id": "01a17746-cd83-7311-8275-b4a3a5ee89fd",
"owner_id": "01a17746-ba87-7343-b9dd-cbe3dcd0add6",
"title": "Write the docs",
"done": false,
"created_at": "2026-10-26T08:14:51.267783400Z",
"updated_at": "2026-10-26T08:14:51.267783400Z"
}
],
"meta": { "current_page": 1, "per_page": 15, "total": 1, "last_page": 1 }
}Tick it off with PATCH:
curl -s -X PATCH http://127.0.0.1:8080/api/v1/tasks/01a17746-cd83-7311-8275-b4a3a5ee89fd \
-H "Authorization: Bearer $TOKEN" \
-H 'Content-Type: application/json' \
-d '{"done":true}'Invalid input never reaches the handler. An empty title is a 400 in Navree's error format, with the messages per field under errors:
{
"statusCode": 400,
"message": ["title must be longer than or equal to 1 characters"],
"error": "Bad Request",
"errors": { "title": ["title must be longer than or equal to 1 characters"] }
}Without a token, the API answers 401. With a token for another user, Grace, GET /api/v1/tasks shows none of Ada's tasks, and GET /api/v1/tasks/{id} of Ada's task is a 403:
{"statusCode":403,"message":"Forbidden","error":"Forbidden"}Two commands help you see what is going on. navree routes prints a table of every route: its method, path, name, access rule and module. These are the tasks rows, without the name column (the generated routes have no names):
GET /api/v1/tasks can:read Task TasksModule
POST /api/v1/tasks can:create Task TasksModule
DELETE /api/v1/tasks/{id} can:delete Task from id TasksModule
GET /api/v1/tasks/{id} can:read Task from id TasksModule
PATCH /api/v1/tasks/{id} can:update Task from id TasksModulenavree access:explain <user id> <action> <subject> shows which rule decides a check:
navree access:explain 01a17746-ba87-7343-b9dd-cbe3dcd0add6 read TaskUser 01a17746-ba87-7343-b9dd-cbe3dcd0add6 may `read` Task
Decided by rule #2: can read Task where {"owner_id":"01a17746-ba87-7343-b9dd-cbe3dcd0add6"}
Roles: (none)Update the module's tests
The generator's tests in src/modules/tasks/tests/mod.rs still send and check name. Replace every name in that file with title: the JSON bodies, the assert_json_path paths (data.0.name becomes data.0.title) and assert_validation_error("name").
These tests boot only the tasks module, with acting_as(principal(id)) standing in for a signed-in user and their own test abilities, so they stay fast and focused. Testing explains TestApp.
Write a feature test
Now test the whole app the way a client uses it: real sign-ups, real bearer tokens and the real abilities. Create tests/tasks.rs:
//! The tasks API end to end: real accounts, real access tokens.
use navree::config::ConfigSources;
use navree::crypto::generate_key;
use navree::testing::{TempDirGuard, TestApp, temp_sqlite_url};
use serde_json::{Value, json};
/// The whole app on a fresh database, with fast password hashing.
async fn app() -> (TestApp, TempDirGuard) {
let (url, guard) = temp_sqlite_url();
let sources = ConfigSources::new()
.set("app.key", generate_key())
.set("database.url", url)
.set("database.migrate_on_boot", true)
.set("http.per_ip_rate_limit", Value::Null)
.set("mail.transport", "array")
.set("observe.metrics", false)
.set("hashing.memory_kib", 1024)
.set("hashing.iterations", 1);
let app = TestApp::from_builder(planner::app(sources)).await;
(app, guard)
}
/// Registers an account and returns its access token.
async fn sign_up(app: &TestApp, email: &str) -> String {
let body = app
.client()
.post("/api/auth/register")
.json(&json!({ "name": "Ada", "email": email, "password": "correct horse" }))
.send()
.await
.assert_status(201)
.json_value();
body["access_token"].as_str().unwrap().to_owned()
}
#[tokio::test]
async fn guests_cannot_list_tasks() {
let (app, _guard) = app().await;
app.client()
.get("/api/v1/tasks")
.send()
.await
.assert_status(401);
}
#[tokio::test]
async fn users_manage_their_own_tasks() {
let (app, _guard) = app().await;
let token = sign_up(&app, "[email protected]").await;
let client = app.client();
let task = client
.post("/api/v1/tasks")
.bearer(&token)
.json(&json!({ "title": "Write the docs" }))
.send()
.await
.assert_status(201)
.assert_json_path("done", json!(false))
.json_value();
let path = format!("/api/v1/tasks/{}", task["id"].as_str().unwrap());
client
.patch(&path)
.bearer(&token)
.json(&json!({ "done": true }))
.send()
.await
.assert_status(200)
.assert_json_path("done", json!(true));
client
.get("/api/v1/tasks")
.bearer(&token)
.send()
.await
.assert_status(200)
.assert_json_path("meta.total", json!(1))
.assert_json_path("data.0.title", json!("Write the docs"));
}
#[tokio::test]
async fn other_users_cannot_see_or_change_them() {
let (app, _guard) = app().await;
let ada = sign_up(&app, "[email protected]").await;
let grace = sign_up(&app, "[email protected]").await;
let client = app.client();
let task = client
.post("/api/v1/tasks")
.bearer(&ada)
.json(&json!({ "title": "Private" }))
.send()
.await
.assert_status(201)
.json_value();
let path = format!("/api/v1/tasks/{}", task["id"].as_str().unwrap());
client
.get("/api/v1/tasks")
.bearer(&grace)
.send()
.await
.assert_json_path("meta.total", json!(0));
client
.get(&path)
.bearer(&grace)
.send()
.await
.assert_status(403);
client
.delete(&path)
.bearer(&grace)
.send()
.await
.assert_status(403);
}
#[tokio::test]
async fn titles_are_validated() {
let (app, _guard) = app().await;
let token = sign_up(&app, "[email protected]").await;
app.client()
.post("/api/v1/tasks")
.bearer(&token)
.json(&json!({ "title": "" }))
.send()
.await
.assert_validation_error("title");
}A few things make this test fast and hermetic:
ConfigSources::new()starts empty, so the test never reads.env,config/app.tomlor your environment. Each.setsupplies a setting the app needs.temp_sqlite_url()gives each test its own SQLite file, deleted when theTempDirGuarddrops.hashing.memory_kibandhashing.iterationsmake Argon2id cheap. Keep the defaults in production.http.per_ip_rate_limitset to null turns off the 600-requests-a-minute limit, which every in-process request would share.TestApp::from_builder(planner::app(sources))boots exactly whatmainboots, and the client calls it in-process, without a network socket.
Run everything:
cargo testtest result: ok. 8 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out
test result: ok. 3 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out
test result: ok. 4 passed; 0 failed; 0 ignored; 0 measured; 0 filtered outThe first line is the module tests in src/ (users and tasks), the second the app tests the template wrote in tests/app.rs, and the third your new feature tests. Cargo also prints empty results for the binary and the doc tests, left out here.
What you built
- mod.rsTasksModule
- controller.rsfive routes, each with #[can]
- service.rsTaskService
- entity.rsthe tasks table and the Task subject
- dto.rsCreateTask, UpdateTask and their rules
- migrations/
- mod.rs
- m20261026_081038_create_tasks_table.rs
- policies/
- mod.rs
- task.rswho may do what with tasks
- tests/
- mod.rsmodule tests
Planner has sign-up and sign-in with rotating refresh tokens, a validated CRUD API whose every route checks abilities, per-user data enforced in SQL, OpenAPI docs at /api/docs, health checks at /health/live and /health/ready, and tests at two levels.
Where to go next
How modules declare providers, controllers, imports and exports.
Roles, permissions, field rules and SQL filters.
SeaORM entities and Repo in depth.
Send work to the background with navree make:job.
Build a release binary and run it behind a proxy.
A larger app built the same way.