navree

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

Shell

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

.env

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:

Shell
Text

The generator also declared the module in src/modules/mod.rs and added it to the app root in src/app.rs:

src/app.rs

The module's files:

FileWhat it holds
mod.rsTasksModule: its providers, controller, the import that registers Task with the access module, and its migrations
controller.rsTaskController: five routes under /api/v1/tasks, each with an access check
service.rsTaskService: listing, creating, updating and deleting, in the request's unit of work
entity.rsThe SeaORM entity for the tasks table, and Task, the access subject
dto.rsThe request bodies and their validation rules
migrations/The migration that creates the tasks table
tests/mod.rsTests 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:

src/modules/tasks/migrations/m20261026_081038_create_tasks_table.rs

The column names come from the Tasks enum at the bottom of the file:

src/modules/tasks/migrations/m20261026_081038_create_tasks_table.rs

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:

src/modules/tasks/entity.rs

Below the model, the generator declared the access subject. Rules call it Task and permissions start with tasks.:

src/modules/tasks/entity.rs

Validate the input

Request bodies are plain structs with garde rules. The Valid extractor checks them before the handler runs. Replace dto.rs:

src/modules/tasks/dto.rs

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:

src/modules/tasks/service.rs

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:

src/modules/tasks/controller.rs
  • #[controller("/tasks", version = "1")] mounts the routes at /api/v1/tasks: the http.api_prefix, the version, then the path.
  • #[can(Create, Task)] lets the request through only if the caller's abilities allow creating a Task. 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 as Authorized<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:

Shell
Text

The generator also calls the policy from the app's abilities function:

src/abilities.rs

Now write the rules: any signed-in user may create tasks, and may read, update and delete their own.

src/modules/tasks/policies/task.rs

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:

Shell

Sign up. Registration is on in new apps (auth.registration = true), and it returns tokens straight away:

Shell
JSON

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:

Shell

Create a task:

Shell
JSON

The status is 201 Created, with Location: /api/v1/tasks/01a17746-.... List your tasks:

Shell
JSON

Tick it off with PATCH:

Shell

Invalid input never reaches the handler. An empty title is a 400 in Navree's error format, with the messages per field under errors:

JSON

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:

JSON

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

Text

navree access:explain <user id> <action> <subject> shows which rule decides a check:

Shell
Text

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:

tests/tasks.rs

A few things make this test fast and hermetic:

  • ConfigSources::new() starts empty, so the test never reads .env, config/app.toml or your environment. Each .set supplies a setting the app needs.
  • temp_sqlite_url() gives each test its own SQLite file, deleted when the TempDirGuard drops.
  • hashing.memory_kib and hashing.iterations make Argon2id cheap. Keep the defaults in production.
  • http.per_ip_rate_limit set 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 what main boots, and the client calls it in-process, without a network socket.

Run everything:

Shell
Text

The 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

planner/src/modules/tasks
  • 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

Popular pages

  • Installation
  • Your first app
  • Routing and controllers
  • Validation
  • Authentication
  • Benchmarks