# Documentation
Source: https://loco.rs/docs/
Loco is the **one-person framework for Rust** โ batteries-included and Rails-inspired. These docs take you from your first `cargo loco` command all the way to running in production: models, controllers, background jobs, mailers, auth, and the CLI that wires them together.
---
# Tutorials
Source: https://loco.rs/docs/tutorials/
---
# Your First App
Source: https://loco.rs/docs/tutorials/your-first-app/
This is the fastest path from "nothing installed" to "a working API you built yourself." You'll install the tooling, generate a new Loco app, start it, talk to it with `curl`, then add a database-backed resource with a single generator command. Every step below is meant to work exactly as written โ if something doesn't match what you see, that's worth reporting.
You need a working Rust toolchain (stable, via [rustup](https://rustup.rs)) and about 10 minutes. No prior Loco knowledge is assumed.
## 1. Install the tooling
Loco ships as two things: the `loco` app generator (a small standalone CLI), and `loco-rs`, the framework your generated app depends on. You also need `sea-orm-cli` because your app will use a database.
```sh
cargo install loco
cargo install sea-orm-cli
```
## 2. Generate a new app
Run `loco new` with the database, background-worker, and asset flags spelled out explicitly. Doing this up front skips every interactive prompt except one (the app name) โ a fully deterministic, scriptable way to create an app:
```sh
loco new --name hello_loco --db sqlite --bg async --assets none
```
```sh
๐ Loco app generated successfully in:
hello_loco/
```
If the directory you run this in is itself inside a git repository, loco new asks you to confirm before continuing. Answer y, or pass -a/--allow-in-git-repo to skip the prompt entirely.
You now have a `hello_loco/` folder with a runnable app inside it. Here's the part of the layout you'll touch in this lesson:
| Path | What's there |
|---|---|
| `src/app.rs` | Wires routes, workers, and tasks together โ the one file that ties everything to `Hooks`. |
| `src/controllers/` | Request handlers, one file per resource. |
| `src/models/` | Your database entities (`_entities/`, generated) and your own model logic. |
| `migration/src/` | One file per schema change, applied in order. |
| `config/development.yaml` | Settings for the `development` environment โ port, database URI, logging, etc. |
`--db sqlite` picked SQLite (a local file, zero setup) as the database, `--bg async` runs background jobs in-process, and `--assets none` skips generating server- or client-rendered view scaffolding โ you're building a pure JSON API.
## 3. Start the server
```sh
cd hello_loco
cargo loco start
```
You'll see Loco's boot banner and, at the bottom, `listening on port 5150`. `cargo loco` is not a real cargo subcommand โ it's a Cargo alias (`loco = "run --"`) baked into every generated app's `.cargo/config.toml`, so `cargo loco start` really runs your app's own binary with `start` as an argument.
Leave this running and, in another terminal, hit the built-in liveness check โ no code written yet, and it already answers:
```sh
$ curl localhost:5150/_ping
{"ok":true}
```
`/_ping` is one of three built-in monitoring endpoints (`/_ping`, `/_health`, `/_readiness`) mounted unconditionally by `AppRoutes::with_default_routes()` in `src/app.rs`.
Because you picked a database (--db sqlite), this app was also generated with a complete, ready-to-use authentication suite mounted at /api/auth/* (register, login, current user, and more) โ any Loco app with a database gets one, it isn't specific to a particular starter "template". This lesson doesn't use it; if you want to explore it, see Build a small authenticated app.
Stop the server with `Ctrl+C` before continuing โ you'll restart it after generating code.
## 4. Generate a CRUD resource
This is where Loco earns its keep. A **scaffold** generates a database migration, a Sea-ORM model/entity, a full CRUD controller, and request tests โ in one command:
```sh
cargo loco generate scaffold posts title:string content:text --api
```
`--api` tells the generator to produce a JSON API controller (there's no default scaffold kind โ you must pick `--api`, `--html`, or `--htmx`). The output ends with a few confirmation lines:
```sh
* Migration for `posts` added! You can now apply it with `$ cargo loco db migrate && cargo loco db entities`.
* A test for model `posts` was added. Run with `cargo test`.
* Controller `Posts` was added successfully.
* Tests for controller `Posts` was added successfully. Run `cargo test`.
```
Unlike a plain `migration` generator, `scaffold` (like `model`) already **applied** the migration and regenerated the Sea-ORM entities for you โ there's nothing left to run manually. You should now have:
```
src/
controllers/posts.rs <- CRUD handlers + routes
models/_entities/posts.rs <- generated Sea-ORM entity
models/posts.rs <- your extension point
migration/
src/mYYYYMMDD_HHMMSS_posts.rs
```
`title:string` and `content:text` are both nullable columns here (no `!`/`^` suffix) โ that's intentional to keep this first pass simple. The field-type suffixes (required, unique) and the full type list are covered in [Generators & field types](/docs/reference/generators).
## 5. Run it and hit your new endpoint
```sh
cargo loco start
```
In another terminal, create a post:
```sh
$ curl -X POST -H "Content-Type: application/json" -d '{
"title": "My first Loco post",
"content": "It works."
}' localhost:5150/api/posts
{"id":1,"created_at":"...","updated_at":"...","title":"My first Loco post","content":"It works."}
```
And list it back:
```sh
$ curl localhost:5150/api/posts
[{"id":1,"created_at":"...","updated_at":"...","title":"My first Loco post","content":"It works."}]
```
That's a full round trip: a generated migration created the `posts` table, a generated Sea-ORM entity modeled it, and a generated controller exposed it over HTTP โ with zero hand-written Rust.
## What you built
In a few minutes, without writing a line of Rust yourself, you:
- Installed the Loco CLI and `sea-orm-cli`.
- Generated a new app with an explicit, reproducible `loco new` command.
- Started it and hit two built-in endpoints (`/api`, `/_ping`).
- Generated a complete CRUD API for a `posts` resource and exercised it with `curl`.
## Next
- [The Tour](/docs/tutorials/the-tour) โ a faster walkthrough that also covers models with relations, hand-editing a controller, background workers, and tasks.
- [Add a model](/docs/how-to/add-model) โ the how-to version of what you just did, with the field-type mini-language spelled out.
- [Build a small authenticated app](/docs/tutorials/saas-with-auth) โ start from the SaaS starter path instead, with registration, login, and JWT-protected routes baked in.
- [CLI reference](/docs/reference/cli) and [Generators & field types](/docs/reference/generators) โ the exhaustive dictionaries behind everything you just ran.
---
# The Tour
Source: https://loco.rs/docs/tutorials/the-tour/
This tour moves faster than [Your First App](/docs/tutorials/your-first-app) and assumes you've already installed `loco` and `sea-orm-cli` and generated at least one app. In one pass, you'll touch the four pieces that make up almost every Loco feature: **models**, **controllers**, **workers**, and **tasks**. Each section links to the reference page that documents its full surface โ this page only shows you the working path.
## Set up
```sh
loco new --name tour_app --db sqlite --bg async --assets none
cd tour_app
```
Picking a database (--db sqlite) also gives this app a ready-made authentication suite at /api/auth/* โ that's covered separately in Build a small authenticated app. This tour ignores it and builds its own resources alongside it.
## Models and controllers: a scaffold, and a plain model with a relation
Generate a `posts` scaffold โ model, migration, entity, CRUD controller, and tests in one step:
```sh
$ cargo loco generate scaffold posts title:string! content:text --api
```
Now generate a `comments` **model only** (no controller yet โ you'll hand-write that one) that belongs to a post, using the `references` field type:
```sh
$ cargo loco generate model comments content:text post:references
```
Both commands write a migration, **apply it immediately**, and regenerate Sea-ORM entities โ there's no separate "now run the migration" step. Open the comments migration and you'll see the relation as a plain data description, not hand-rolled SQL builder calls:
```rust
// migration/src/mYYYYMMDD_HHMMSS_comments.rs
use loco_rs::schema::*;
use sea_orm_migration::prelude::*;
#[derive(DeriveMigrationName)]
pub struct Migration;
#[async_trait::async_trait]
impl MigrationTrait for Migration {
async fn up(&self, m: &SchemaManager) -> Result<(), DbErr> {
create_table(m, "comments",
&[
("id", ColType::PkAuto),
("content", ColType::TextNull),
],
&[
("post", ""),
]
).await
}
async fn down(&self, m: &SchemaManager) -> Result<(), DbErr> {
drop_table(m, "comments").await
}
}
```
`("post", "")` means "add a foreign key to `posts`, and figure out the column name for me" โ it becomes a required `post_id` column. That column is a 64-bit integer (`BigInteger`), matching the 64-bit auto-increment primary keys Loco 1.0 uses everywhere; see [Schema & ColType DSL](/docs/reference/schema-dsl) for the full column-type story.
## Controllers: a generated one, and one you wire by hand
`posts` already has a full CRUD controller from its scaffold. `comments` only has a model so far โ `scaffold` generates model *and* controller together, while `model` only builds the data layer. Reach for `controller` when you already have a model and just need a thin API on top:
```sh
$ cargo loco generate controller comments --api
```
With no action names given, this stubs a single `index` action returning an empty body โ it has no idea about your model's columns. Replace `src/controllers/comments.rs` entirely with a small, purpose-built API: comments can only be **added** through a shallow route, and **listed** through a nested route under their post โ never fetched singly, updated, or deleted:
```rust
#![allow(clippy::unused_async)]
use loco_rs::prelude::*;
use serde::{Deserialize, Serialize};
use crate::models::_entities::comments::{ActiveModel, Entity};
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct Params {
pub content: Option,
pub post_id: i64,
}
impl Params {
fn update(&self, item: &mut ActiveModel) {
item.content = Set(self.content.clone());
item.post_id = Set(self.post_id);
}
}
pub async fn add(State(ctx): State, Json(params): Json) -> Result {
let mut item = ActiveModel { ..Default::default() };
params.update(&mut item);
let item = item.insert(&ctx.db).await?;
format::json(item)
}
pub fn routes() -> Routes {
Routes::new()
.prefix("api/comments/")
.add("/", post(add))
}
```
Now add the nested read on the `posts` side. In `src/controllers/posts.rs` (generated by the scaffold), add a route and a handler that loads a post's comments through the relation Sea-ORM generated for you:
```rust
// add to the existing imports
use crate::models::_entities::comments;
pub async fn comments_for_post(
Path(id): Path,
State(ctx): State,
) -> Result {
let item = load_item(&ctx, id).await?;
let comments = item.find_related(comments::Entity).all(&ctx.db).await?;
format::json(comments)
}
pub fn routes() -> Routes {
Routes::new()
.prefix("api/posts/")
.add("/", get(list))
.add("/", post(add))
.add("{id}", get(get_one))
.add("{id}", delete(remove))
.add("{id}", put(update))
.add("{id}", patch(update))
.add("{id}/comments", get(comments_for_post)) // <- add this
}
```
Try it:
```sh
cargo loco start
```
```sh
$ curl -X POST -H "Content-Type: application/json" -d '{"title":"Tour post","content":"hi"}' localhost:5150/api/posts
{"id":1,...}
$ curl -X POST -H "Content-Type: application/json" -d '{"content":"nice post","post_id":1}' localhost:5150/api/comments
{"id":1,...}
$ curl localhost:5150/api/posts/1/comments
[{"id":1,"content":"nice post","post_id":1,...}]
```
## Workers: do something in the background
Generate a worker and see it register itself in `src/app.rs`:
```sh
$ cargo loco generate worker notifier
```
```
added: "src/workers/notifier.rs"
injected: "src/workers/mod.rs"
injected: "src/app.rs"
```
The generated struct is always plainly named `Worker`, namespaced by its own module (`workers::notifier::Worker`) โ that's how you'll refer to it. Edit `src/workers/notifier.rs`'s `WorkerArgs` and `perform`:
```rust
#[derive(Deserialize, Debug, Serialize)]
pub struct WorkerArgs {
pub post_id: i64,
}
#[async_trait]
impl BackgroundWorker for Worker {
fn build(ctx: &AppContext) -> Self {
Self { ctx: ctx.clone() }
}
async fn perform(&self, args: WorkerArgs) -> Result<()> {
println!("new comment/notification for post {}", args.post_id);
Ok(())
}
}
```
Now enqueue it from `posts::add` in `src/controllers/posts.rs`, right after the post is inserted:
```rust
pub async fn add(State(ctx): State, Json(params): Json) -> Result {
let mut item = ActiveModel { ..Default::default() };
params.update(&mut item);
let item = item.insert(&ctx.db).await?;
crate::workers::notifier::Worker::perform_later(
&ctx,
crate::workers::notifier::WorkerArgs { post_id: item.id },
)
.await?;
format::json(item)
}
```
Because you generated this app with `--bg async`, `workers.mode` in `config/development.yaml` is `BackgroundAsync` โ the job runs in-process, no extra `--worker` process needed. Restart with `cargo loco start`, `POST` a new post, and watch the worker's `println!` appear in the same terminal.
Async-in-process is a development convenience. For a real deployment you'd typically move to a Redis-, Postgres-, or SQLite-backed queue and run workers as their own process(es) with `cargo loco start --worker`; see [Add a background worker](/docs/how-to/add-worker) for the async-vs-queue tradeoff.
## Tasks: a one-off, run from the CLI
Generate a task:
```sh
$ cargo loco generate task posts_report
```
Edit `src/tasks/posts_report.rs`:
```rust
use loco_rs::prelude::*;
use crate::models::_entities::posts;
pub struct PostsReport;
#[async_trait]
impl Task for PostsReport {
fn task(&self) -> TaskInfo {
TaskInfo {
name: "posts_report".to_string(),
detail: "Print every post's title".to_string(),
}
}
async fn run(&self, app_context: &AppContext, _vars: &task::Vars) -> Result<()> {
let posts = posts::Entity::find().all(&app_context.db).await?;
for post in &posts {
println!("- {}", post.title);
}
println!("done: {} posts", posts.len());
Ok(())
}
}
```
List and run it:
```sh
$ cargo loco task
posts_report [Print every post's title]
$ cargo loco task posts_report
- Tour post
done: 1 posts
```
Tasks are compiled into your app binary and are environment-aware (`cargo loco task posts_report -e production` runs against production config) โ a safer alternative to ad-hoc SQL against a live database. Tasks can also be triggered on a schedule; see [Write a one-off task](/docs/how-to/write-task) and [Schedule recurring jobs](/docs/how-to/schedule-jobs).
## See everything you just wired
```sh
$ cargo loco routes
```
lists every route across every controller โ the scaffolded `posts` CRUD, the hand-written `comments` routes, the nested `posts/{id}/comments` route you added by hand, and the `/api/auth/*` suite that came bundled with the database.
## Next
- [Build a small authenticated app](/docs/tutorials/saas-with-auth) โ use that bundled auth suite for real: register, log in, and protect a route with a JWT.
- [Add a model](/docs/how-to/add-model) and [Generators & field types](/docs/reference/generators) โ the full field-type mini-language you saw a slice of here.
- [Schema & ColType DSL](/docs/reference/schema-dsl) โ every column type the migration DSL supports.
- [Add a background worker](/docs/how-to/add-worker), [Write a one-off task](/docs/how-to/write-task), [Schedule recurring jobs](/docs/how-to/schedule-jobs) โ the full picture behind this tour's background-job and task sections.
---
# Build a Small Authenticated App
Source: https://loco.rs/docs/tutorials/saas-with-auth/
Every Loco app generated with a database ships with a complete authentication suite: registration, login, email verification, password reset, magic links, and a JWT-protected "current user" endpoint โ no extra generator, no starter template to hunt for. This lesson generates one, exercises the built-in auth flow end to end, then protects a resource of your own with the same JWT extractor the built-in endpoints use.
You should already be comfortable with the basics from [Your First App](/docs/tutorials/your-first-app).
## 1. Generate the app
```sh
loco new --name saas_app --db sqlite --bg async --assets serverside
cd saas_app
```
This is the same combination of flags Loco's own `examples/demo` app in the loco-rs repository is generated with โ server-rendered assets, SQLite, and in-process async workers. Choosing a database is what turns on authentication: `auth` and `mailer` scaffolding are both included automatically whenever `--db` is `sqlite` or `postgres`, regardless of which starter you picked interactively โ there's no separate "SaaS" flag to remember.
Confirm the auth routes are already there, with nothing else generated yet:
```sh
$ cargo loco routes
...
[POST] /api/auth/register
[GET] /api/auth/verify/{token}
[POST] /api/auth/login
[POST] /api/auth/forgot
[POST] /api/auth/reset
[GET] /api/auth/current
[POST] /api/auth/magic-link
[GET] /api/auth/magic-link/{token}
[POST] /api/auth/resend-verification-mail
...
```
## 2. Skip the SMTP dependency
Registering a user sends a welcome email through the configured mailer. `config/development.yaml` enables SMTP against `localhost:1025` by default, which means registration will fail with a 500 unless you either run a local SMTP catcher there, or tell the mailer to stub outgoing mail instead of sending it. For this lesson, stub it โ open `config/development.yaml` and add `stub: true` under `mailer`:
```yaml
mailer:
stub: true
smtp:
enable: true
host: localhost
# ...
```
The generated config/test.yaml already sets mailer.stub: true โ that's why your app's tests never need a live mailbox. You're applying the same setting to development.yaml so cargo loco start behaves the same way.
## 3. Start the app
```sh
cargo loco start
```
## 4. Register a user
```sh
$ curl --location 'localhost:5150/api/auth/register' \
--header 'Content-Type: application/json' \
--data-raw '{
"name": "Loco user",
"email": "user@loco.rs",
"password": "12341234"
}'
{}
```
An empty `{}` on success is intentional: the endpoint always answers the same way whether or not the email was already registered, so no request can be used to probe your user list.
## 5. Log in
```sh
$ curl --location 'localhost:5150/api/auth/login' \
--header 'Content-Type: application/json' \
--data-raw '{
"email": "user@loco.rs",
"password": "12341234"
}'
```
```json
{
"token": "eyJhbGciOiJIUzUxMiJ9...",
"pid": "2b20f998-b11e-4aeb-96d7-beca7671abda",
"name": "Loco user",
"is_verified": false
}
```
`is_verified` is `false` because you haven't clicked the (stubbed, unsent) verification email โ that's fine, **login doesn't require a verified email**, only a matching password. Save the `token`; every authenticated request below uses it as a bearer token.
## 6. Call the built-in protected endpoint
```sh
$ curl --location 'localhost:5150/api/auth/current' \
--header 'Authorization: Bearer TOKEN'
```
```json
{
"pid": "2b20f998-b11e-4aeb-96d7-beca7671abda",
"name": "Loco user",
"email": "user@loco.rs"
}
```
Under the hood, `current` is nothing special โ it's a normal handler that takes `auth::JWT` as its first argument:
```rust
async fn current(auth: auth::JWT, State(ctx): State) -> Result {
let user = users::Model::find_by_pid(&ctx.db, &auth.claims.pid).await?;
format::json(CurrentResponse::new(&user))
}
```
If the `Authorization` header is missing, malformed, or carries an expired/invalid token, axum never reaches your handler body โ the `auth::JWT` extractor itself rejects the request with `401 Unauthorized`. Try it without the header to see that happen.
## 7. Protect a resource of your own
The pattern above works for any handler, not just the built-in ones. Generate a `notes` scaffold:
```sh
$ cargo loco generate scaffold notes title:string content:text --api
```
Open `src/controllers/notes.rs` and change the `add` handler's signature to also require `auth::JWT`:
```rust
pub async fn add(
auth: auth::JWT,
State(ctx): State,
Json(params): Json,
) -> Result {
// we only need to know the request carries a valid, known user
let _current_user = crate::models::users::Model::find_by_pid(&ctx.db, &auth.claims.pid).await?;
let mut item = ActiveModel { ..Default::default() };
params.update(&mut item);
let item = item.insert(&ctx.db).await?;
format::json(item)
}
```
`auth::JWT` is already in scope through `loco_rs::prelude::*`, which every generated controller imports. Restart the app and confirm the two behaviors:
```sh
# no token: rejected before your handler even runs
$ curl -X POST -H "Content-Type: application/json" \
-d '{"title":"secret","content":"shh"}' localhost:5150/api/notes
# 401 Unauthorized
# with token: goes through
$ curl -X POST -H "Content-Type: application/json" \
-H "Authorization: Bearer TOKEN" \
-d '{"title":"secret","content":"shh"}' localhost:5150/api/notes
{"id":1,"created_at":"...","updated_at":"...","title":"secret","content":"shh"}
```
`list`, `get_one`, `update`, and `remove` on `notes` are still open to anyone โ add `auth: auth::JWT` to their signatures the same way if you want the whole resource locked down.
## What's actually enforced, and what isn't
- The JWT secret and expiration live in `config/development.yaml` under `auth.jwt`. Every environment (`development`, `test`, `production`) gets its own generated secret โ never share one across environments. See the [Configuration reference](/docs/reference/configuration) for every key under `auth:`.
- Tokens are signed HS512 by default, and the configured secret must be valid base64 โ this is handled for you in the generated config, but matters if you ever hand-roll one.
- `auth::JWT` only checks that the token is valid and unexpired; it does not check `is_verified`. If your app needs "must have verified their email" as a business rule, check `user.email_verified_at.is_some()` yourself inside the handler, the same way you looked up the user by `pid` above.
## Next
- [Protect a Route with JWT](/docs/how-to/jwt-auth) โ the full endpoint-by-endpoint reference: forgot/reset password, email verification, magic links, and API-key auth as an alternative to JWTs.
- [Configuration reference](/docs/reference/configuration) โ every `auth:` and `mailer:` YAML key.
- [The Tour](/docs/tutorials/the-tour) โ if you haven't yet, see models, workers, and tasks covered end to end.
- [Add a model](/docs/how-to/add-model) โ keep building out `notes` (or your own resource) with relations and validation.
---
# Add a model
Source: https://loco.rs/docs/how-to/add-model/
**Goal:** add a new database-backed model to a Loco app โ a migration, a Sea-ORM entity, and your own model file to extend it โ using the model generator.
This assumes a working Loco app with the `with-db` feature enabled (the default). For the full field-type mini-language and every generator kind, see [Generators & field types](/docs/reference/generators). For the migration DSL used under the hood, see [Schema & ColType DSL](/docs/reference/schema-dsl).
## 1. Generate the model
Run the model generator with a name and a list of `field:type` pairs:
```sh
$ cargo loco generate model posts title:string! content:text user:references
```
This does three things in one step:
1. Writes a migration under `migration/src/` that creates a `posts` table.
2. Applies the migration against your development database.
3. Regenerates Sea-ORM entities into `src/models/_entities/`, and scaffolds `src/models/posts.rs` for your own model code.
You end up with:
```
src/
models/
_entities/
posts.rs <-- generated entity (Entity, Model, ActiveModel, Column, Relation)
posts.rs <-- your extension point
migration/
src/
m20240101_000002_posts.rs
```
Set the `SKIP_MIGRATION` environment variable if you want the generator to only write the migration file, without applying it or regenerating entities โ useful when scripting several `generate model` calls back to back before running `db migrate` once at the end.
## 2. Read the field syntax
Each `field:type` pair follows a small suffix convention:
- no suffix โ nullable column (`Option`)
- `!` โ required column (`NOT NULL`)
- `^` โ unique column (implies `NOT NULL`)
So `title:string!` is a required `String`, and `content:text` is a nullable `Option`.
`user:references` is special: it doesn't name a column type, it declares a belongs-to foreign key. It adds a required `user_id` column referencing the `users` table (`user:references?` makes it nullable; `user:references:author_id` picks a custom column name). See [Generators & field types ยง References](/docs/reference/generators#references-belongs-to-foreign-keys) for the full syntax.
1.0 change: the generator's int/int!/int^ field type now maps to i64 / BIGINT (big_integer), not i32 as in earlier Loco versions โ matching the framework's i64 auto-increment primary keys. Use small_int if you specifically need a 16-bit column.
## 3. Add more fields to an existing model
To add columns to a table you already created, generate a plain migration instead of a new model โ name it `AddTo
` so Loco infers an "add columns" migration:
```sh
$ cargo loco generate migration AddViewsToPosts views:int
```
Apply it and regenerate entities:
```sh
$ cargo loco db migrate
$ cargo loco db entities
```
Removing columns follows the mirror-image naming convention, `RemoveFrom
`:
```sh
$ cargo loco generate migration RemoveViewsFromPosts views:int
```
## 4. Generate without timestamps (optional)
By default every table generated through the DSL gets `created_at`/`updated_at` columns. To opt out, pass `--without-tz` to `model`, `migration`, or `scaffold`:
```sh
$ cargo loco generate model posts title:string! content:text --without-tz
```
The flag is --without-tz, not --without-timestamps โ an older spelling that no longer works.
## 5. Verify
Confirm the migration applied and entities exist:
```sh
$ cargo loco db status
$ ls src/models/_entities/
```
Then write against the model directly โ e.g. in a `cargo loco playground` script or a test:
```rust
use migration::Migrator;
use loco_rs::testing::prelude::*;
use myapp::models::_entities::posts;
let boot = boot_test::().await?;
let post = posts::ActiveModel {
title: sea_orm::ActiveValue::set("hello".to_string()),
user_id: sea_orm::ActiveValue::set(1),
..Default::default()
}
.insert(&boot.app_context.db)
.await?;
assert_eq!(post.title, "hello");
```
**Result:** a `posts` table exists in your database, `posts::Entity`/`Model`/`ActiveModel` compile, and `src/models/posts.rs` is where you add custom methods (e.g. `Model::find_by_title`) the same way `examples/demo/src/models/users.rs` extends the generated `users` entity.
## Next
- [Query data](/docs/how-to/query-data) with the `ConditionBuilder` DSL.
- [Foreign-key relationships](/docs/reference/schema-dsl#table-level-operations) and [request/model validation](/docs/how-to/validate-requests) beyond a single table.
---
# How-to Guides
Source: https://loco.rs/docs/how-to/
---
# Query data with the condition DSL
Source: https://loco.rs/docs/how-to/query-data/
**Goal:** filter rows from a model using Loco's `ConditionBuilder` fluent DSL instead of hand-assembling a Sea-ORM `Condition`.
This assumes a working model (see [Add a model](/docs/how-to/add-model)). For the exhaustive operator list, exact signatures, and the `date_range` boundary semantics, see [Query DSL & pagination](/docs/reference/query-pagination).
## 1. Import the DSL
`query` is the model-layer query module, reachable straight from the prelude:
```rust
use loco_rs::prelude::*;
use sea_orm::EntityTrait;
```
`query::condition()` starts a builder; `.build()` finalizes it into a `sea_orm::Condition` you pass to `.filter(..)`.
## 2. Build a simple filter
```rust
let cond = query::condition()
.eq(users::Column::Email, "user1@example.com")
.build();
let user = users::Entity::find().filter(cond).one(&db).await?;
```
This is the same pattern the demo app's `users` model uses for all of its lookups, e.g. `Model::find_by_email` in `examples/demo/src/models/users.rs`:
```rust
pub async fn find_by_email(db: &DatabaseConnection, email: &str) -> ModelResult {
let user = users::Entity::find()
.filter(
model::query::condition()
.eq(users::Column::Email, email)
.build(),
)
.one(db)
.await?;
user.ok_or_else(|| ModelError::EntityNotFound)
}
```
## 3. Chain multiple conditions
Every operator returns `Self`, so conditions chain and AND together by default:
```rust
let cond = query::condition()
.contains(posts::Column::Title, "loco")
.gt(posts::Column::Views, 10)
.is_not_null(posts::Column::PublishedAt)
.build();
let published = posts::Entity::find().filter(cond).all(&db).await?;
```
Available operators (see the [reference](/docs/reference/query-pagination#operators) for the full table): `eq`/`ne`, `gt`/`gte`/`lt`/`lte`, `between`/`not_between`, `like`/`not_like`, `starts_with`/`ends_with`/`contains`, `is_null`/`is_not_null`, `is_in`/`is_not_in`, and `date_range`. Each also exists as a free function (`query::eq(col, v)`) that starts a new builder โ useful when you only need one condition.
## 4. Filter a date range
`date_range` returns a `DateRangeBuilder` instead of `Self`, because a range can have zero, one, or two bounds:
```rust
use chrono::NaiveDateTime;
let from: NaiveDateTime = /* ... */;
let to: NaiveDateTime = /* ... */;
let cond = query::condition()
.date_range(posts::Column::CreatedAt)
.dates(Some(&from), Some(&to))
.build() // DateRangeBuilder -> ConditionBuilder
.build(); // ConditionBuilder -> Condition
```
Boundary behavior is asymmetric: a single-ended range (only from or only to) is strict (> / <), but a double-ended range (both from and to) is inclusive (BETWEEN). See the reference for the full table.
## 5. Sort results
`SortDirection` is a small serde-friendly enum that converts to Sea-ORM's `Order` โ it's orthogonal to `ConditionBuilder`, meant to pair with `.order_by()`:
```rust
use loco_rs::model::query::SortDirection;
let direction = SortDirection::Desc;
let recent = posts::Entity::find()
.filter(cond)
.order_by(posts::Column::CreatedAt, direction.order())
.all(&db)
.await?;
```
Because `SortDirection` derives `Deserialize`/`Serialize` with `"asc"`/`"desc"` renames, it deserializes directly from a query-string parameter (e.g. `?sort=desc` in a controller's `Query` extractor).
## Result
You have a `Condition` built from readable, chainable method calls instead of raw Sea-ORM `Condition::all().add(...)` boilerplate, and it composes with `.filter()`, `.order_by()`, and โ as the next guide shows โ `query::paginate`.
## Next
- [Paginate results](/docs/how-to/paginate) using the condition you just built.
- [Query DSL & pagination reference](/docs/reference/query-pagination) for every operator's exact SQL output.
---
# Paginate query results
Source: https://loco.rs/docs/how-to/paginate/
**Goal:** return a page of rows plus paging metadata (`total_pages`, `total_items`, ...) from a controller endpoint, instead of loading an entire table.
This builds on [Query data with the condition DSL](/docs/how-to/query-data). For exact signatures and the `PagerMeta` shape, see [Query DSL & pagination](/docs/reference/query-pagination#pagination).
## 1. Accept pagination params in your request
`PaginationQuery` is a small, `#[serde(flatten)]`-friendly struct with two fields, `page` (1-based, default `1`) and `page_size` (default `25`). Flatten it into your controller's own query-params struct so callers can pass `?page=2&page_size=10` alongside your own filters:
```rust
use loco_rs::prelude::*;
use serde::Deserialize;
#[derive(Debug, Deserialize)]
pub struct ListQueryParams {
pub title: Option,
#[serde(flatten)]
pub pagination: query::PaginationQuery,
}
```
## 2. Paginate an entity query with an optional condition
`query::paginate` takes a `Select` (an unresolved entity query), an optional pre-built `Condition`, and a `&PaginationQuery`. It applies the condition for you, so don't call `.filter()` yourself first:
```rust
use axum::extract::{Query, State};
pub async fn list(
State(ctx): State,
Query(params): Query,
) -> Result {
let condition = params
.title
.as_ref()
.map(|t| query::condition().contains(posts::Column::Title, t).build());
let res = query::paginate(
&ctx.db,
posts::Entity::find(),
condition,
¶ms.pagination,
)
.await?;
format::json(res)
}
```
## 3. Or paginate a pre-built selector with `fetch_page`
Use `fetch_page` when you've already composed a `Select` (with your own `.filter()`/`.order_by()`/joins) and just need to page over it โ it takes no separate condition argument:
```rust
let selector = posts::Entity::find()
.filter(query::condition().eq(posts::Column::UserId, user_id).build())
.order_by_desc(posts::Column::CreatedAt);
let res = query::fetch_page(&ctx.db, selector, &query::PaginationQuery::page(2)).await?;
```
## 4. What you get back
Both functions return `LocoResult>`:
```rust
pub struct PageResponse {
pub page: Vec,
pub meta: PagerMeta,
}
pub struct PagerMeta {
pub page: u64,
pub page_size: u64,
pub total_pages: u64,
pub total_items: u64,
}
```
Returning `res` from a controller (e.g. via `format::json(res)`) serializes to:
```json
{
"page": [ { "id": 1, "title": "..." }, ... ],
"meta": { "page": 2, "page_size": 25, "total_pages": 4, "total_items": 87 }
}
```
## Result
A request like `GET /posts?page=2&page_size=10&title=loco` returns exactly one page of matching rows plus enough metadata for a client to render "page 2 of 4" or build next/prev links โ without loading the whole table or hand-writing `OFFSET`/`LIMIT` math. Remember `page` is 1-based on the way in; both functions handle the conversion to Sea-ORM's 0-based paging internally.
## Next
- [Query DSL & pagination reference](/docs/reference/query-pagination) for `PaginationQuery`'s exact defaults and the `paginate`/`fetch_page` signatures.
- [Query data](/docs/how-to/query-data) for building the `Condition` you pass in.
---
# Seed data
Source: https://loco.rs/docs/how-to/seed-data/
**Goal:** populate a freshly-migrated database with known rows from YAML fixture files โ for local development, tests, and reproducing an environment's data elsewhere.
This assumes a working model (see [Add a model](/docs/how-to/add-model)).
## 1. Write a fixture file
Fixtures live under `src/fixtures/`, one YAML file per table, containing a plain list of records:
```
src/
fixtures/
users.yaml
```
```yaml
# examples/demo/src/fixtures/users.yaml
---
- id: 1
pid: 11111111-1111-1111-1111-111111111111
email: user1@example.com
password: "$argon2id$v=19$m=19456,t=2,p=1$ETQBx4rTgNAZhSaeYZKOZg$eYTdH26CRT6nUJtacLDEboP0li6xUwUF/q5nSlQ8uuc"
api_key: lo-95ec80d7-cb60-4b70-9b4b-9ef74cb88758
name: user1
created_at: "2023-11-12T12:34:56.789Z"
updated_at: "2023-11-12T12:34:56.789Z"
```
Include every `NOT NULL` column your migration defined; nullable columns can be omitted.
## 2. Wire the fixture into `Hooks::seed`
Add a call to `db::seed::` in your app's `Hooks::seed` implementation, one line per fixture file:
```rust
use std::path::Path;
use loco_rs::{app::{AppContext, Hooks}, db, Result};
impl Hooks for App {
// ...
async fn seed(ctx: &AppContext, base: &Path) -> Result<()> {
db::seed::(&ctx.db, &base.join("users.yaml").display().to_string())
.await?;
Ok(())
}
}
```
`db::seed` reads the YAML into `Vec`, converts each row through `A::from_json`, inserts them with `insert_many`, and resets the table's auto-increment sequence afterward so subsequently-created rows don't collide with the seeded IDs.
## 3. Run the seed command
```sh
$ cargo loco db seed
```
By default this reads from `src/fixtures` and inserts into whatever environment you're targeting (`-e`/`--environment`, default `development`). Common flags:
```sh
# clear all data before seeding โ useful for a repeatable dev/test reset
$ cargo loco db seed --reset
# seed from a different folder (e.g. environment-specific fixtures)
$ cargo loco db seed --from src/fixtures/staging
# target a specific environment
$ cargo loco db seed -e test
```
## 4. Dump existing data back to fixtures
The same command can go the other direction: export live table contents to YAML files, e.g. to capture a snapshot of production-like data for local fixtures.
```sh
# dump every table to --from's folder (default: src/fixtures)
$ cargo loco db seed --dump
# dump only specific tables
$ cargo loco db seed --dump-tables users,posts
```
`--dump`/`--dump-tables` and seeding are mutually exclusive for a single invocation โ passing either one dumps instead of seeding.
## 5. Use seeding in tests
With the `testing` feature enabled, `boot_test` + `seed::` gives each test a freshly-seeded database:
```rust
use loco_rs::testing::prelude::*;
#[tokio::test]
#[serial]
async fn can_find_seeded_user() {
let boot = boot_test::().await?;
seed::(&boot.app_context).await?;
let user = Model::find_by_email(&boot.app_context.db, "user1@example.com").await;
assert!(user.is_ok());
}
```
## Result
`cargo loco db seed` (optionally with `--reset`) leaves your database populated with the exact rows in `src/fixtures/*.yaml`, and `cargo loco db seed --dump` lets you regenerate those same fixture files from a live database whenever your schema or sample data changes.
---
# Connect a second database
Source: https://loco.rs/docs/how-to/multi-database/
**Goal:** query a second (or third+) database from a controller, alongside the app's primary `ctx.db` connection โ for example, a read replica, a legacy database, or per-tenant databases.
Loco ships a ready-made [initializer](/docs/how-to/add-middleware) for this: `MultiDbInitializer`, a named map of connections. It lives under `loco_rs::initializers::multi_db` and is gated behind the `with-db` feature. Each config entry accepts the same keys as the primary `database:` block โ see [Configuration ยง database](/docs/reference/configuration) for the full list (`uri`, `enable_logging`, `min_connections`, `max_connections`, `connect_timeout`, `idle_timeout`, `acquire_timeout`, `auto_migrate`, `dangerously_truncate`, `dangerously_recreate`, `run_on_start`).
## Configure each named database
Add one or more entries under a `multi_db` map, nested under the top-level `initializers` key in your environment config. A single extra connection is just a one-entry map:
```yaml
initializers:
multi_db:
secondary_db:
uri: postgres://loco:loco@localhost:5432/loco_app
enable_logging: false
connect_timeout: 500
idle_timeout: 500
min_connections: 1
max_connections: 1
auto_migrate: false
dangerously_truncate: false
dangerously_recreate: false
```
Add more entries to open more connections:
```yaml
initializers:
multi_db:
secondary_db:
uri: postgres://loco:loco@localhost:5432/loco_app
enable_logging: false
connect_timeout: 500
idle_timeout: 500
min_connections: 1
max_connections: 1
auto_migrate: false
dangerously_truncate: false
dangerously_recreate: false
third_db:
uri: postgres://loco:loco@localhost:5432/loco_app_reporting
enable_logging: false
connect_timeout: 500
idle_timeout: 500
min_connections: 1
max_connections: 1
auto_migrate: false
dangerously_truncate: false
dangerously_recreate: false
```
## Register the initializer
```rust
use loco_rs::app::{AppContext, Initializer};
async fn initializers(_ctx: &AppContext) -> Result>> {
let initializers: Vec> = vec![
Box::new(loco_rs::initializers::multi_db::MultiDbInitializer),
];
Ok(initializers)
}
```
## Look connections up by name
`MultiDbInitializer` layers a `loco_rs::db::MultiDb` (a thin `HashMap` wrapper) as an axum `Extension`:
```rust
use sea_orm::EntityTrait;
use axum::{response::IntoResponse, Extension};
use loco_rs::db::MultiDb;
pub async fn list(
State(ctx): State,
Extension(multi_db): Extension,
) -> Result {
let third_db = multi_db.get("third_db")?;
let res = Entity::find().all(third_db).await;
format::json(res)
}
```
`multi_db.get(name)` returns an error if that key isn't configured โ no silent `None`/panic.
## Result
`ctx.db` remains your app's primary connection (used for auto-migration, boot-time checks, etc.); the extra connection(s) arrive purely through the `MultiDb` axum `Extension` and only in handlers that ask for them.
## Migrating from `extra_db`
`ExtraDbInitializer` has been removed in favor of `MultiDbInitializer`. To migrate:
- Config: former `initializers.extra_db: { ... }` becomes `initializers.multi_db: { : { ... } }` โ pick a name for your connection and nest the same keys under it.
- Registration: swap `Box::new(loco_rs::initializers::extra_db::ExtraDbInitializer)` for `Box::new(loco_rs::initializers::multi_db::MultiDbInitializer)`.
- Handlers: change `Extension(db): Extension` to `Extension(multi_db): Extension`, then look up the connection with `let db = multi_db.get("")?;`.
## Next
- [Configuration reference](/docs/reference/configuration) for every key a `database:`-shaped block accepts.
- [Add middleware](/docs/how-to/add-middleware) for how the `initializers`/middleware hooks work.
---
# Add a controller
Source: https://loco.rs/docs/how-to/add-controller/
**Goal:** add a new HTTP endpoint group to your Loco app โ generated, or written by hand โ and get it showing up in `cargo loco routes`.
This guide assumes a working Loco app (`cargo loco start` runs). For the full `Routes`/`AppRoutes` API and the exhaustive `Hooks` surface, see the [Hooks reference](/docs/reference/hooks).
## 1. Generate a controller
```sh
cargo loco generate controller [ACTION ...] (--api|--html|--htmx)
```
One of `--api`, `--html`, or `--htmx` is **required** โ there is no default kind; omitting all three is a hard error. Additional positional arguments become extra actions (handler functions + routes) alongside the default `index`.
```sh
cargo loco generate controller notes list get --api
```
This:
- creates `src/controllers/notes.rs` with an `index` handler plus one handler per extra action (`list`, `get`), each returning `format::empty()` as a starting point
- adds `pub mod notes;` to `src/controllers/mod.rs`
- injects `.add_route(controllers::notes::routes())` into your `routes()` implementation in `src/app.rs` โ no manual wiring needed
- generates a matching test file under `tests/requests/`
The generated `routes()` function looks like this:
```rust
// src/controllers/notes.rs
pub fn routes() -> Routes {
Routes::new()
.prefix("api/notes/")
.add("/", get(index))
.add("list", get(list))
.add("get", get(get))
}
```
Edit the handler bodies and route methods (`get`/`post`/`put`/`delete`, etc.) to fit your endpoint. `--html`/`--htmx` generate the same shape plus `src/views/.rs` view stubs and templates under `assets/views/` โ see [Render server-side views](/docs/how-to/render-views) for what to do with those.
## 2. Confirm the routes are registered
```sh
cargo loco routes
```
```sh
[GET] /_ping
[GET] /_health
[GET] /_readiness
[GET] /api/notes/
[GET] /api/notes/list
[GET] /api/notes/get
```
If your new routes don't appear, check that `src/app.rs`'s `routes()` implementation calls `.add_route(controllers::notes::routes())` (the generator does this for you, but double-check after a manual edit or merge conflict).
## 3. Write a controller by hand (no generator)
Sometimes you want a controller without a generator scaffold โ e.g. a small internal endpoint.
1. Create `src/controllers/example.rs`:
```rust
use loco_rs::prelude::*;
async fn hello() -> Result {
format::text("hello")
}
async fn echo(Json(body): Json) -> Result {
format::json(body)
}
pub fn routes() -> Routes {
Routes::new()
.add("/", get(hello))
.add("/echo", post(echo))
}
```
2. Declare the module in `src/controllers/mod.rs`:
```rust
pub mod example;
```
3. Register its routes in `src/app.rs`'s `Hooks::routes`:
```rust
fn routes(_ctx: &AppContext) -> AppRoutes {
AppRoutes::with_default_routes()
.add_route(controllers::example::routes())
}
```
`AppRoutes::with_default_routes()` also mounts the built-in `/_ping`, `/_health`, `/_readiness` monitoring endpoints.
## 4. Prefix a whole controller
`Routes::prefix` scopes every route added to that `Routes` instance:
```rust
pub fn routes() -> Routes {
Routes::new()
.prefix("notes")
.add("/", get(list))
.add("/{id}", get(get_one))
}
```
## 5. Prefix a whole app (or a group of controllers)
`AppRoutes::prefix` applies to every controller added after it:
```rust
fn routes(_ctx: &AppContext) -> AppRoutes {
AppRoutes::with_default_routes()
.prefix("/api")
.add_route(controllers::notes::routes())
.add_route(controllers::users::routes())
}
```
## 6. Nest routes under an additional path segment
Use `nest_prefix` to append another path segment to the *current* prefix for routes added afterward, or `nest_route`/`nest_routes` to scope a prefix to just the routes passed in (without touching the running prefix):
```rust
fn routes(_ctx: &AppContext) -> AppRoutes {
let v1_notes = Routes::new().add("/", get(|| async { "notes v1" }));
AppRoutes::with_default_routes()
.prefix("api")
.add_route(controllers::auth::routes())
// only these routes get the extra `v1` segment: /api/v1/...
.nest_route("v1", v1_notes)
}
```
`Routes::nest` (on a `Routes` value, not `AppRoutes`) does the same job when you're composing route groups before returning them from a controller's `routes()` function โ handy for merging several sub-resources with `Routes::merge`/`merge_all` and then nesting the result once:
```rust
let user_routes = Routes::new()
.add("/users", get(list_users))
.add("/users", post(create_user));
let product_routes = Routes::new().add("/products", get(list_products));
let api_routes = Routes::new().merge(user_routes).merge(product_routes);
Routes::new()
.add("/health", get(|| async { "ok" }))
.nest("/api", api_routes);
// -> GET /health, GET /api/users, POST /api/users, GET /api/products
```
## 7. Apply a `tower::Layer` to just one controller or route
`Routes::layer` attaches a `tower::Layer` (rate limiting, custom auth, tracing, etc.) to every handler in that `Routes` value only โ for middleware that should run on *every* route, see [Add middleware](/docs/how-to/add-middleware) instead.
```rust
// src/controllers/notes.rs
pub fn routes() -> Routes {
Routes::new()
.prefix("notes")
.add("/", get(list).layer(my_tower_layer()))
}
```
## Verify
```sh
cargo loco routes
cargo test --test requests_notes # if the generator produced tests/requests/notes.rs
```
A `curl` against the new path should return your handler's response:
```sh
curl -s localhost:5150/api/notes/
```
## Next
- [Validate requests](/docs/how-to/validate-requests)
- [Respond with different formats](/docs/how-to/respond-formats)
- [Handle errors](/docs/how-to/handle-errors)
---
# Validate requests
Source: https://loco.rs/docs/how-to/validate-requests/
**Goal:** reject malformed input before your handler logic runs, and return either a simple `400 Bad Request` or a structured, field-by-field JSON error body.
This assumes a working controller โ see [Add a controller](/docs/how-to/add-controller) if you need one first. All six extractors live in `loco_rs::controller::extractor::validate` (`JsonValidate`/`JsonValidateWithMessage` are re-exported from `loco_rs::prelude`).
## 1. Pick an extractor
| Extractor | Content type | Structured JSON errors? |
|---|---|---|
| `JsonValidate` | `application/json` | No โ plain `400 Bad Request` |
| `JsonValidateWithMessage` | `application/json` | Yes |
| `FormValidate` | `application/x-www-form-urlencoded` | No โ plain `400 Bad Request` |
| `FormValidateWithMessage` | `application/x-www-form-urlencoded` | Yes |
| `QueryValidate` | any (reads the query string) | No โ plain `400 Bad Request` |
| `QueryValidateWithMessage` | any (reads the query string) | Yes |
Each is a `FromRequest` newtype: `JsonValidate(pub T)`, etc. โ pattern-match to get at the inner, already-validated `T`.
## 2. Define the validated type
Derive `validator::Validate`:
```rust
use serde::Deserialize;
use validator::Validate;
#[derive(Debug, Deserialize, Validate)]
pub struct CreateNote {
#[validate(length(min = 3, message = "title must be at least 3 characters"))]
pub title: String,
#[validate(email)]
pub email: String,
}
```
`validator::Validate` is automatically adapted to Loco's own `ValidatorTrait` โ you don't implement anything extra to use it with the extractors above.
## 3. Use the extractor in a handler
```rust
use loco_rs::prelude::*;
#[debug_handler]
pub async fn create(
State(_ctx): State,
JsonValidate(params): JsonValidate,
) -> Result {
// `params` is guaranteed valid here
format::json(params)
}
```
Swap the extractor type to change source/behavior โ the handler body doesn't otherwise change:
```rust
#[debug_handler]
pub async fn search(
QueryValidateWithMessage(params): QueryValidateWithMessage,
) -> Result {
format::json(params)
}
```
`QueryValidate`/`QueryValidateWithMessage` read the URL query string (e.g. `?title=abc&email=a@b.com`) regardless of the request's `Content-Type`.
## 4. What the client sees on failure
`JsonValidate`/`FormValidate`/`QueryValidate` (no `WithMessage`) return a bare `400`:
```json
{ "error": "Bad Request" }
```
The `*WithMessage` variants return the field-by-field detail, under an `errors` key, with **no** `error`/`description` set:
```json
{
"errors": {
"title": [
{ "code": "length", "message": "title must be at least 3 characters", "params": { "min": 3, "value": "ab" } }
],
"email": [
{ "code": "email", "message": null, "params": { "value": "not-an-email" } }
]
}
}
```
This is the same `Validation` branch of the [error โ HTTP status map](/docs/reference/errors) (always `400`) โ the `WithMessage` extractors populate `errors`, the plain ones map validation failures to a message-less `Error::BadRequest`.
Malformed input the extractor itself can't even deserialize (bad JSON, an unparsable query string) also returns `400`, before validation runs at all.
## 5. Validate without the `validator` crate
Implement `ValidatorTrait` directly for full control โ useful if a rule doesn't fit `validator`'s derive macros:
```rust
use loco_rs::prelude::*;
use std::collections::{BTreeMap, HashMap};
#[derive(Debug, serde::Deserialize)]
pub struct CustomParams {
pub name: String,
}
impl ValidatorTrait for CustomParams {
fn validate(&self) -> Result<(), ModelValidationErrors> {
if self.name.len() < 5 {
let mut errors: BTreeMap> = BTreeMap::new();
let mut params: HashMap = HashMap::new();
params.insert("min".to_string(), serde_json::json!(5));
errors.insert(
"name".to_string(),
vec![ValidationError { code: "length".to_string(), message: None, params }],
);
return Err(ModelValidationErrors { errors });
}
Ok(())
}
}
```
`ValidationError` has three fields: `code: String`, `message: Option`, `params: HashMap`. Empty `params` are omitted from the JSON automatically. Any type implementing `ValidatorTrait` works with all six extractors above โ the extractor doesn't care whether validation came from the `validator` crate or your own impl.
## Verify
```sh
curl -s -X POST localhost:5150/api/notes -H 'content-type: application/json' -d '{"title":"ab","email":"bad"}'
# {"errors":{"title":[...],"email":[...]}} (JsonValidateWithMessage)
# {"error":"Bad Request"} (JsonValidate)
```
## Next
- [Handle errors](/docs/how-to/handle-errors) โ the full error โ status map and `CustomError`
- [Respond with different formats](/docs/how-to/respond-formats)
---
# Render server-side views
Source: https://loco.rs/docs/how-to/render-views/
**Goal:** return server-rendered HTML from a controller using Loco's built-in [Tera](http://keats.github.io/tera/)-based view engine.
This assumes a working app with the view engine initializer configured (the SaaS/HTML starters have this out of the box). If you generated an API-only app and are adding HTML for the first time, see step 5.
## 1. Create a template
Templates live under `assets/views/` (the `assets/` folder sits next to `src/` and `config/` at your project root):
```html
{{ title }}
```
## 2. Wrap it in a typed view function
Encapsulate the template call so controllers never touch Tera or template paths directly โ this is what lets you swap view engines later without touching controllers.
```rust
// src/views/dashboard.rs
use loco_rs::prelude::*;
pub fn home(v: impl ViewRenderer) -> Result {
format::render().view(&v, "home/hello.html", data!({"title": "Loco"}))
}
```
Add it to `src/views/mod.rs`:
```rust
pub mod dashboard;
```
## 3. Extract the view engine in your controller
`ViewEngine` is a `FromRequestParts` extractor โ `TeraView` is the concrete engine Loco supplies. Both come from the prelude.
```rust
// src/controllers/dashboard.rs
use loco_rs::prelude::*;
use crate::views;
pub async fn render_home(ViewEngine(v): ViewEngine) -> Result {
views::dashboard::home(v)
}
pub fn routes() -> Routes {
Routes::new().prefix("home").add("/", get(render_home))
}
```
Register the controller's routes as usual โ see [Add a controller](/docs/how-to/add-controller).
`ViewEngine` requires a `TeraLayer` `Extension` to be installed on the router; it panics with `"TeraLayer missing. Is the TeraLayer installed?"` if it isn't. This is wired up by the `ViewEngineInitializer` in `src/initializers/view_engine.rs` (present by default in HTML/HTMX starters) โ see step 5 if you need to add it.
## 4. Two ways to render
**`format::view`** โ the simplest form, renders directly to an HTML response:
```rust
pub fn home(v: impl ViewRenderer) -> Result {
format::view(&v, "home/hello.html", data!({"title": "Loco"}))
}
```
**`format::render()`** โ a chainable builder when you need more than a bare 200 HTML body. It terminates with `.view(...)`, `.template(...)`, `.html(...)`, or `.json(...)`, and can add headers, an `ETag`, cookies, or a status code first:
```rust
pub fn home(v: impl ViewRenderer) -> Result {
format::render()
.etag("home-v1")?
.cookies(&[axum_extra::extract::cookie::Cookie::new("last_view", "home")])?
.view(&v, "home/hello.html", data!({"title": "Loco"}))
}
```
`format::render()` also has a `.response()` escape hatch that returns the underlying `axum::http::response::Builder` if you need something the chain doesn't cover, and `.redirect(to)` / `.redirect_with_header_key(key, to)` for redirects (see [Respond with different formats](/docs/how-to/respond-formats)).
For an inline template string with no file on disk, use `format::template(tmpl, data)` (or the builder's `.template(...)`) instead of `.view(...)`.
## 5. Enabling the view engine on an API-only app
If your app was generated `--api`-only, add the initializer:
```rust
// src/initializers/view_engine.rs
use async_trait::async_trait;
use axum::{Extension, Router as AxumRouter};
use loco_rs::{
app::{AppContext, Initializer},
controller::views::{engines, ViewEngine},
Result,
};
pub struct ViewEngineInitializer;
#[async_trait]
impl Initializer for ViewEngineInitializer {
fn name(&self) -> String {
"view-engine".to_string()
}
async fn after_routes(&self, router: AxumRouter, _ctx: &AppContext) -> Result {
let tera_engine = engines::TeraView::build()?;
Ok(router.layer(Extension(ViewEngine::from(tera_engine))))
}
}
```
Register it in `src/app.rs`:
```rust
async fn initializers(_ctx: &AppContext) -> Result>> {
Ok(vec![Box::new(initializers::view_engine::ViewEngineInitializer)])
}
```
`TeraView::build()` loads templates from `assets/views` (`DEFAULT_ASSET_FOLDER = "assets"`). Use `TeraView::build_with_post_process(|tera| { ... })` instead if you need to register custom Tera functions (e.g. an i18n `t(...)` function) โ see the demo app's `src/initializers/view_engine.rs` for a working example with `fluent-templates`.
## 6. Serving static assets referenced by your templates
Templates that reference `` need the `static` middleware โ see [Serve static & SPA assets](/docs/how-to/serve-assets).
## 7. Use a different template engine entirely
Because controllers only depend on `ViewRenderer` (a one-method trait: `render(&self, key: &str, data: S) -> Result`), you can substitute Tera for anything else. Implement `ViewRenderer` for your own type, register it via an `Initializer` the same way as `TeraView` above, and swap the extractor's generic parameter (`ViewEngine` โ `ViewEngine`) โ no controller logic changes.
## Verify
```sh
cargo loco routes # confirm GET /home is registered
curl -s localhost:5150/home
```
## Next
- [Respond with different formats](/docs/how-to/respond-formats) โ JSON/HTML/YAML and content negotiation
- [Serve static & SPA assets](/docs/how-to/serve-assets)
- [Handle errors](/docs/how-to/handle-errors)
---
# Respond with different formats
Source: https://loco.rs/docs/how-to/respond-formats/
**Goal:** return the right response shape (JSON, HTML, plain text, YAML, a redirect, an empty body) from a handler, and โ when a single endpoint must serve more than one format โ pick the shape based on the request's `Content-Type`/`Accept` header.
This assumes a working controller โ see [Add a controller](/docs/how-to/add-controller). All helpers live in the `format` module (`loco_rs::controller::format`, re-exported as `format` from the prelude). Keep handlers returning `Result` (or `Result`) so you can freely swap which `format::*` call you return.
## 1. Simple responses
```rust
use loco_rs::prelude::*;
async fn as_json() -> Result {
format::json(serde_json::json!({ "hello": "world" }))
}
async fn as_text() -> Result {
format::text("hello, world")
}
async fn as_html() -> Result {
format::html("
hello
")
}
async fn as_yaml() -> Result {
format::yaml("openapi: 3.1.0\ninfo:\n title: my api\n")
// sets Content-Type: application/yaml
}
async fn nothing() -> Result {
format::empty() // 200, empty body
}
async fn empty_object() -> Result {
format::empty_json() // 200, body: {}
}
async fn go_elsewhere() -> Result {
format::redirect("/dashboard") // axum::response::Redirect::to(..)
}
```
`format::view`/`format::template` render HTML from a Tera view or an inline template string โ see [Render server-side views](/docs/how-to/render-views).
## 2. When you need more than a one-liner: `format::render()`
`format::render()` returns a `RenderBuilder` you chain, terminating with one of `.json(..)`, `.html(..)`, `.text(..)`, `.empty()`, `.view(..)`, `.template(..)`, `.redirect(..)`, or `.redirect_with_header_key(..)`:
```rust
async fn get_one(State(ctx): State) -> Result {
format::render()
.etag("some-etag-value")?
.header("X-Custom", "1")
.json(load_item(&ctx).await?)
}
```
Available builder methods:
| Method | Purpose |
|---|---|
| `.status(code)` | set the response status (defaults to `200`) |
| `.header(key, value)` | add a single response header |
| `.etag(value)` | set the `ETag` header (errors on non-visible-ASCII input) |
| `.cookies(&[Cookie, ..])` | add one `Set-Cookie` header per cookie |
| `.response()` | escape hatch: hand back the raw `axum::http::response::Builder` |
| `.redirect(to)` | `303 See Other` with `Location: ` |
| `.redirect_with_header_key(key, to)` | same, but with a custom header instead of `Location` โ e.g. `HX-Redirect` for HTMX |
```rust
async fn htmx_redirect() -> Result {
format::render().redirect_with_header_key("HX-Redirect", "/notes")
}
```
## 3. Content negotiation: respond differently per client
Use the `RespondTo` extractor (or its wrapper `Format(pub RespondTo)`) to detect the request's format from `Content-Type` (checked first) or, failing that, `Accept`:
```rust
use loco_rs::prelude::*;
pub async fn get_one(
respond_to: RespondTo,
Path(id): Path,
State(ctx): State,
) -> Result {
let item = load_item(&ctx, id).await?;
match respond_to {
RespondTo::Html => format::html(&format!("{:?}", item.title)),
_ => format::json(item),
}
}
```
`RespondTo` variants: `None` (neither header present/parseable), `Html`, `Json`, `Xml`, `Other(String)` (any other MIME type, preserved verbatim). Both `RespondTo` and `Format` implement `FromRequestParts`, so you can extract either one directly as a handler parameter โ `Format(respond_to)` if you prefer the wrapped form.
## 4. Combine format negotiation with error handling
A common pattern: run your fallible logic first, then match on both the `Result` and the format in one place, so error rendering stays consistent per-format:
```rust
pub async fn get_one(
respond_to: RespondTo,
Path(id): Path,
State(ctx): State,
) -> Result {
let res = load_item(&ctx, id).await;
match res {
Ok(item) => match respond_to {
RespondTo::Html => format::html(&format!("{:?}", item.title)),
_ => format::json(item),
},
Err(Error::Model(ModelError::Validation(errors))) => match respond_to {
RespondTo::Html => format::html(&format!("errors: {errors:?}")),
_ => bad_request("opaque message: cannot respond!"),
},
// unhandled error kinds: let the framework's default error rendering take over
Err(err) => Err(err),
}
}
```
See [Handle errors](/docs/how-to/handle-errors) for what "the framework's default error rendering" produces for each `Error` variant.
## Verify
```sh
curl -s localhost:5150/notes/1 -H 'accept: application/json'
curl -s localhost:5150/notes/1 -H 'accept: text/html'
```
## Next
- [Render server-side views](/docs/how-to/render-views)
- [Handle errors](/docs/how-to/handle-errors)
- [Validate requests](/docs/how-to/validate-requests)
---
# Handle errors
Source: https://loco.rs/docs/how-to/handle-errors/
**Goal:** return the right HTTP status and JSON error body from a handler, without hand-writing `impl IntoResponse` yourself.
This assumes a working controller โ see [Add a controller](/docs/how-to/add-controller). Every handler that returns `loco_rs::Result` (i.e. `Result`) gets its error automatically converted to an HTTP response by `impl IntoResponse for Error` โ you never call `.into_response()` on an error yourself. For the exhaustive variant list and constructors, see the [Error model reference](/docs/reference/errors).
## 1. The three common-case helpers
`loco_rs::prelude` re-exports three free functions for the HTTP-facing error variants you'll reach for most:
```rust
use loco_rs::prelude::*;
async fn get_one(Path(id): Path, State(ctx): State) -> Result {
let Some(item) = find_item(&ctx, id).await? else {
return not_found();
};
format::json(item)
}
async fn login(State(ctx): State, Json(params): Json) -> Result {
let Ok(user) = find_user(&ctx, ¶ms.email).await else {
return unauthorized("invalid credentials");
};
// ...
format::json(user)
}
async fn create(Json(params): Json) -> Result {
if params.title.is_empty() {
return bad_request("title is required");
}
// ...
format::empty()
}
```
Each returns `Result` (always the `Err` arm), so `return unauthorized(msg)` type-checks against any handler's `Result` return type. All three are also just regular ways to construct an `Error` and propagate it with `?` from a helper function you call from the handler.
| Fn | HTTP status | Response body | Notes |
|---|---|---|---|
| `not_found()` | 404 | `{"error":"not_found","description":"Resource was not found"}` | Takes no message. |
| `unauthorized(msg)` | 401 | `{"error":"unauthorized","description":"You do not have permission to access this resource"}` | `msg` is logged (`tracing::warn!`) but **not** sent to the client. |
| `bad_request(msg)` | 400 | `{"error":"Bad Request","description":""}` | `msg` **is** sent to the client. |
## 2. Everything else falls through to 500
`Error` is `#[non_exhaustive]` with ~28 more variants (`DB`, `Model`, `IO`, `Tera`, `Message`, `InternalServerError`, ...). Only seven variants get a specific status; the framework's `IntoResponse` match ends in a wildcard arm โ **every other variant becomes `500 Internal Server Error`** with body `{"error":"internal_server_error","description":"Internal Server Error"}`.
In practice this means: if you `?`-propagate a `sea_orm::DbErr`, an `std::io::Error`, or anything else that converts into `Error` via `#[from]`, and you haven't matched it explicitly, the client gets a generic 500 โ which is usually what you want (don't leak internals), and every response is logged at `tracing::error!` first regardless of variant, so you still see the real cause server-side.
The full variant โ status table, including `Validation` (โ 400, from the `validator` crate) and `JsonRejection` (โ axum's own rejection status), is in the [Error model reference](/docs/reference/errors).
## 3. Return an arbitrary status: `Error::CustomError`
When none of the built-in helpers fit โ a `409 Conflict`, a `429 Too Many Requests`, a body shape the framework doesn't produce โ build one directly:
```rust
use loco_rs::prelude::*;
use loco_rs::controller::ErrorDetail;
use axum::http::StatusCode;
async fn create(Json(params): Json) -> Result {
if already_exists(¶ms).await? {
return Err(Error::CustomError(
StatusCode::CONFLICT,
ErrorDetail::new("conflict", "a resource with this name already exists"),
));
}
format::empty()
}
```
`Error::CustomError(StatusCode, ErrorDetail)` passes both the status and the body through **unchanged** โ it's the one variant the response mapping doesn't rewrite. `ErrorDetail::new(error, description)` sets both fields (an empty description collapses to `None`); `ErrorDetail::with_reason(error)` sets only `error`. The response body is always `{error, description, errors}` with `None` fields omitted from the JSON.
## 4. Convert a foreign error without a dedicated variant
Use `Error::wrap`/`Error::msg` at a `?`/`.map_err(..)` call site to fold any `std::error::Error` into `Error::Any`/`Error::Message` (both fall through to the 500 catch-all above):
```rust
let parsed: MyType = serde_json::from_str(&raw).map_err(Error::wrap)?;
```
Reach for `Error::string("...")` when you have a plain `&str`/message and no source error to wrap.
## 5. Content-type-aware error handling
If an endpoint needs to render errors differently for HTML vs. JSON clients, match on both the fallible call's `Result` and the negotiated format in one place โ see [Respond with different formats](/docs/how-to/respond-formats#4-combine-format-negotiation-with-error-handling).
## Verify
```sh
curl -i localhost:5150/notes/999999 # -> 404 {"error":"not_found",...}
curl -i -X POST localhost:5150/auth/login -d '{"email":"x","password":"y"}' -H 'content-type: application/json'
# -> 401 {"error":"unauthorized",...}
```
## Next
- [Validate requests](/docs/how-to/validate-requests) โ the `Validation` variant and structured field errors
- [Respond with different formats](/docs/how-to/respond-formats)
- [Error model reference](/docs/reference/errors) โ full variant list and constructors
---
# Add middleware
Source: https://loco.rs/docs/how-to/add-middleware/
**Goal:** turn on one of Loco's 13 built-in middlewares, or write your own when none of them fit, and confirm it's actually running.
This assumes a working app. For the full config-key/knob table for every built-in middleware, see the [Middleware catalog reference](/docs/reference/middleware).
## 1. Enable a built-in middleware via config
Every middleware lives under `server.middlewares.` in your environment YAML (`config/development.yaml`, `config/production.yaml`, ...). Most are disabled by default; a few (`catch_panic`, `etag`, `logger`, `request_id`, and `fallback` outside `Production`) are enabled unless you write the key at all.
Enable `remote_ip` (useful behind a proxy/load balancer) and `compression`:
```yaml
server:
middlewares:
remote_ip:
enable: true
compression:
enable: true
```
> **Watch out:** for middlewares that are enabled *by default* (e.g. `etag`, `catch_panic`), writing the key at all โ even as `{}` โ replaces the framework's own default with the struct's own `#[serde(default)]`, which resolves `enable` to `false` unless you set `enable: true` explicitly. Don't add a middleware's key to config unless you also intend to set `enable`.
## 2. Verify it's registered
```sh
cargo loco middleware --config
```
```sh
limit_payload {"body_limit":{"Limit":2000000}}
cors (disabled)
catch_panic {"enable":true}
etag {"enable":true}
remote_ip {"enable":true,"source":"RightmostXForwardedFor"}
compression {"enable":true}
timeout_request (disabled)
static (disabled)
secure_headers (disabled)
logger {"config":{"enable":true},"environment":"development"}
request_id {"enable":true}
fallback {"enable":true,"code":200,"file":null,"not_found":null}
powered_by {"ident":"loco.rs"}
```
`cargo loco middleware` (without `--config`) prints just the enabled/disabled state.
## 3. Common examples
Set a request body size limit:
```yaml
server:
middlewares:
limit_payload:
body_limit: 5mb # or "disable" to remove the limit entirely
```
Turn on CORS (disabled by default โ note the field is `expose_headers`, **plural**):
```yaml
server:
middlewares:
cors:
enable: true
allow_origins:
- https://example.com
allow_headers:
- Content-Type
allow_methods:
- GET
- POST
expose_headers:
- X-Custom-Header
max_age: 3600
```
Serve static assets or an SPA โ see [Serve static & SPA assets](/docs/how-to/serve-assets) for the full walkthrough:
```yaml
server:
middlewares:
static:
enable: true
folder:
uri: "/static"
path: "assets/static"
```
Then use the extractor for a middleware that exposes one, e.g. `RemoteIP`:
```rust
use loco_rs::prelude::*;
#[debug_handler]
pub async fn list(ip: RemoteIP, State(ctx): State) -> Result {
tracing::info!(%ip, "handling request");
format::json(Entity::find().all(&ctx.db).await?)
}
```
## 4. Apply a middleware to a single route instead of globally
Config-driven middleware always applies to every route in the app. To scope a `tower::Layer` to one controller or route, use `Routes::layer` โ see [Add a controller ยง 7](/docs/how-to/add-controller#7-apply-a-tower-layer-to-just-one-controller-or-route).
## 5. Write a custom middleware
Implement the `MiddlewareLayer` trait:
```rust
pub trait MiddlewareLayer {
fn name(&self) -> &'static str;
fn is_enabled(&self) -> bool { true } // default
fn config(&self) -> serde_json::Result;
fn apply(&self, app: AXRouter) -> Result>;
}
```
A minimal example that stamps every response with a custom header, config-toggleable like the built-ins:
```rust
// src/middlewares/hello.rs
use axum::{http::HeaderValue, response::Response, Router as AXRouter};
use loco_rs::{app::AppContext, controller::middleware::MiddlewareLayer, Result};
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct HelloHeader {
#[serde(default)]
pub enable: bool,
}
impl MiddlewareLayer for HelloHeader {
fn name(&self) -> &'static str {
"hello_header"
}
fn is_enabled(&self) -> bool {
self.enable
}
fn config(&self) -> serde_json::Result {
serde_json::to_value(self)
}
fn apply(&self, app: AXRouter) -> Result> {
Ok(app.layer(axum::middleware::map_response(add_header)))
}
}
async fn add_header(mut res: Response) -> Response {
res.headers_mut()
.insert("X-Hello", HeaderValue::from_static("loco"));
res
}
```
Register it alongside (or instead of) the default stack by overriding the `middlewares` hook on `App` in `src/app.rs`:
```rust
impl Hooks for App {
// ...
fn middlewares(ctx: &AppContext) -> Vec> {
let mut mids = middleware::default_middleware_stack(ctx);
mids.push(Box::new(middlewares::hello::HelloHeader { enable: true }));
mids
}
}
```
Remember the ordering rule: `AppRoutes::to_router` applies this `Vec` one `.layer(...)` call at a time, and each new layer wraps the router as the **outer** layer โ so the middleware **last** in the vec is the **first** to see an incoming request (LIFO). Push your custom middleware onto whichever end of the vec matches where it needs to sit relative to `logger`/`catch_panic`/etc. See the [Middleware catalog ยง stack ordering](/docs/reference/middleware#stack-ordering-build-order-vs-request-order-lifo) for the full explanation and the built-in coding order.
## Verify
```sh
cargo loco middleware --config # confirm your custom entry and its config appear
curl -i localhost:5150/ # confirm the custom header/behavior shows up
```
## Next
- [Middleware catalog reference](/docs/reference/middleware) โ every built-in middleware's config key and knobs
- [Serve static & SPA assets](/docs/how-to/serve-assets)
- [Handle errors](/docs/how-to/handle-errors)
---
# Serve static & SPA assets
Source: https://loco.rs/docs/how-to/serve-assets/
**Goal:** serve files (images, CSS, JS, a compiled SPA bundle) directly from Loco, either from disk or embedded into the compiled binary.
This assumes a working app. For the full knob table, see the `static` entry in the [Middleware catalog reference](/docs/reference/middleware).
## 1. Put files under `assets/static/`
```
assets/
โโโ static/
โ โโโ image.png
โ โโโ 404.html
โโโ views/
```
`assets/` sits at your project root, next to `src/` and `config/`.
## 2. Enable the `static` middleware
```yaml
# config/development.yaml
server:
middlewares:
static:
enable: true
must_exist: true
folder:
uri: "/static"
path: "assets/static"
fallback: "assets/static/404.html"
```
| Key | Default | Purpose |
|---|---|---|
| `must_exist` | `true` | if `true`, a missing configured folder is a boot-time error |
| `folder.uri` | `/static` | the URL prefix clients request under |
| `folder.path` | `assets/static` | the on-disk folder served |
| `fallback` | `assets/static/404.html` | file served when a requested path doesn't exist |
| `precompressed` | `false` | serve a `.gz` sibling file instead of compressing on the fly, if one exists |
| `cache_control` | `None` | e.g. `"max-age=31536000, public"`; set `null` to disable caching headers entirely |
Reference the served files from HTML/templates as normal:
```html
```
## 3. Disable the welcome-screen fallback if it's shadowing your assets
Outside `Production`, Loco enables a separate `fallback` middleware by default (the "Loco welcome screen" for unmatched routes), and it takes precedence over `static`. If your static assets aren't showing up as expected in development, disable it:
```yaml
server:
middlewares:
fallback:
enable: false
```
## 4. Serve a single-page app (SPA)
Point `fallback` (on `static_assets`, not the framework-wide `fallback` middleware above) at your SPA's `index.html` so client-side routes resolve correctly on a hard refresh:
```yaml
server:
middlewares:
static:
enable: true
must_exist: true
folder:
uri: "/"
path: "assets/static"
fallback: "assets/static/index.html"
```
Any request that doesn't match a real file under `assets/static/` falls back to `index.html`, letting your client-side router take over.
## 5. Serve precompressed assets
If your build pipeline already produces `.gz` files next to the originals (e.g. `app.js` and `app.js.gz`), turn on `precompressed` and Loco serves the `.gz` variant directly instead of compressing per-request:
```yaml
server:
middlewares:
static:
enable: true
precompressed: true
```
## 6. Embed assets into the binary with `embedded_assets`
For single-binary deployment (no separate asset directory to ship or mount), enable the `embedded_assets` Cargo feature:
```toml
[dependencies]
loco-rs = { version = "...", features = ["embedded_assets"] }
```
This is a **compile-time swap**, not a separate API โ the same `server.middlewares.static` config key and knobs still apply, and your controllers/views don't change at all:
- the `static` middleware's implementation swaps from reading `assets/static/` off disk to serving files baked into the binary at build time
- the Tera view engine (`TeraView`) likewise swaps to an embedded variant that serves compiled-in templates instead of reading `assets/views/` off disk
At build time you'll see log output confirming what got embedded:
```
warning: loco-rs@x.y.z: Discovered directories for assets:
warning: loco-rs@x.y.z: - /path/to/app/assets/static
warning: loco-rs@x.y.z: - /path/to/app/assets/views
warning: loco-rs@x.y.z: Found asset: /path/to/app/assets/static/image.png -> /static/image.png
warning: loco-rs@x.y.z: Found 6 asset files
warning: loco-rs@x.y.z: Generated code for 6 static assets and 7 templates
```
Trade-offs: the binary grows by roughly the size of `assets/`, and any asset change requires a full recompile โ there's no "edit and refresh" loop like serving from disk. Toggle the feature on/off per build profile (e.g. embedded for release, filesystem for local dev) if that recompile cost is a problem during active asset iteration.
## Verify
```sh
curl -I localhost:5150/static/image.png # 200, correct Content-Type
curl -I localhost:5150/static/does-not-exist.png # falls back per `fallback` config
```
## Next
- [Render server-side views](/docs/how-to/render-views)
- [Add middleware](/docs/how-to/add-middleware)
- [Middleware catalog reference](/docs/reference/middleware)
---
# Add websockets / realtime
Source: https://loco.rs/docs/how-to/websockets/
Goal: add realtime, bidirectional communication (chat, live updates, notifications) to a Loco app.
Loco doesn't ship a built-in websocket abstraction. Because a Loco app compiles down to a real `axum::Router` (see [Coming from Axum](/docs/explanation/coming-from-axum)), any Axum-compatible websocket layer mounts onto it the same way it would onto a hand-rolled Axum app โ there's no Loco-specific API to learn for this, and no Loco-specific limitation either.
## Chat room example
For a worked example using [socketioxide](https://github.com/Totodore/socketioxide), see the [`loco-rs/chat-rooms`](https://github.com/loco-rs/chat-rooms) reference app. It shows a full chat-room implementation wired into a Loco router.
If you need something other than socketioxide, look for any crate that integrates with `axum::Router` (raw `axum::extract::ws`, `socketioxide`, etc.) and mount it the same way you'd [add a controller](/docs/how-to/add-controller) โ as routes on the app's `Router`.
---
# Add a background worker
Source: https://loco.rs/docs/how-to/add-worker/
Goal: move slow or non-request-critical work (sending a report, calling a third-party API, resizing an image) out of the request path and into a background job.
## Prerequisites
- A queue backend configured (Redis, Postgres, or SQLite) if you want jobs to survive a restart. If you haven't decided yet, see [Choose a queue backend](/docs/how-to/choose-queue-backend). For local dev you can skip this โ the default `BackgroundQueue` mode with no `queue:` config still works, it just won't persist jobs (jobs are dropped with a logged error if no provider is populated). Many apps start with `workers.mode: BackgroundAsync`, which needs no queue backend at all.
## 1. Generate the worker
```sh
cargo loco generate worker report_worker
```
This creates `src/workers/report_worker.rs`, adds `pub mod report_worker;` to `src/workers/mod.rs`, and injects a registration call into `connect_workers` in `src/app.rs`. It also generates a test stub under `tests/workers/`.
The generated struct is always named `Worker` (scoped inside its own `workers::report_worker` module), with an empty `WorkerArgs` struct for you to fill in:
```rust
use serde::{Deserialize, Serialize};
use loco_rs::prelude::*;
pub struct Worker {
pub ctx: AppContext,
}
#[derive(Deserialize, Debug, Serialize)]
pub struct WorkerArgs {}
#[async_trait]
impl BackgroundWorker for Worker {
fn build(ctx: &AppContext) -> Self {
Self { ctx: ctx.clone() }
}
fn class_name() -> String {
"ReportWorker".to_string()
}
async fn perform(&self, _args: WorkerArgs) -> Result<()> {
// TODO: your job logic goes here
Ok(())
}
}
```
## 2. Add typed arguments and job logic
Fill in `WorkerArgs` with whatever data the job needs (it's serialized into the queue, so keep it small and `Serialize + Deserialize`), then implement `perform`:
```rust
use loco_rs::prelude::*;
use serde::{Deserialize, Serialize};
pub struct DownloadWorker {
pub ctx: AppContext,
}
#[derive(Deserialize, Debug, Serialize)]
pub struct DownloadWorkerArgs {
pub user_guid: String,
}
#[async_trait]
impl BackgroundWorker for DownloadWorker {
fn build(ctx: &AppContext) -> Self {
Self { ctx: ctx.clone() }
}
async fn perform(&self, args: DownloadWorkerArgs) -> Result<()> {
// .. do the actual work, use self.ctx for DB/cache/etc ..
println!("processing download for {}", args.user_guid);
Ok(())
}
}
```
(This example mirrors `examples/demo/src/workers/downloader.rs`.)
## 3. Confirm it's registered
The generator already injected this, but it's worth knowing what it did โ `Hooks::connect_workers` is where every worker is registered against the shared `Queue`:
```rust
// src/app.rs
#[async_trait]
impl Hooks for App {
// ..
async fn connect_workers(ctx: &AppContext, queue: &Queue) -> Result<()> {
queue.register(DownloadWorker::build(ctx)).await?;
Ok(())
}
// ..
}
```
If you wrote the worker manually instead of generating it, add the `queue.register(...)` line yourself.
## 4. Enqueue a job
Call the trait's `perform_later` from a controller, task, or another worker:
```rust
DownloadWorker::perform_later(
&ctx,
DownloadWorkerArgs {
user_guid: "foo".to_string(),
},
)
.await?;
```
`perform_later` returns `Result` โ the job id, not `Result<()>`. In `BackgroundQueue` mode the id is assigned by the queue provider; in `ForegroundBlocking`/`BackgroundAsync` mode (or when no provider is configured) Loco generates a fresh UUID so you always get a stable handle back:
```rust
let job_id: String = DownloadWorker::perform_later(&ctx, args).await?;
```
If you need higher/lower priority for this particular job, use `perform_later_with_priority` instead โ see [Choose a queue backend](/docs/how-to/choose-queue-backend#priority-queues) for priority semantics shared across all three backends:
```rust
DownloadWorker::perform_later_with_priority(&ctx, args, Some(50)).await?;
```
## 5. Run the worker process
How you run workers depends on `workers.mode` (see [Choose a queue backend](/docs/how-to/choose-queue-backend)):
```sh
# BackgroundQueue mode: run a dedicated worker process
cargo loco start --worker
# or run server + worker in the same process
cargo loco start --server-and-worker
```
`BackgroundAsync` and `ForegroundBlocking` modes don't need a separate worker process โ jobs run inside whichever process called `perform_later`.
### Filtering by tags
Give a worker tags, then start a worker process that only picks up matching jobs:
```rust
fn tags() -> Vec {
vec!["download".to_string(), "network".to_string()]
}
```
```sh
cargo loco start --worker download,network
```
A worker started with no tags (`cargo loco start --worker`) only processes untagged jobs; `--all` and `--server-and-worker` don't support tag filtering.
## 6. Verify
Test with `ForegroundBlocking` mode set in `config/test.yaml`, so `perform_later` runs synchronously and returns only once the job is done:
```rust
use loco_rs::testing::prelude::*;
#[tokio::test]
#[serial]
async fn test_run_download_worker() {
let boot = boot_test::().await.unwrap();
assert!(
DownloadWorker::perform_later(
&boot.app_context,
DownloadWorkerArgs { user_guid: "foo".to_string() }
)
.await
.is_ok()
);
// .. assert side effects here ..
}
```
Put worker tests under `tests/workers/` โ the generator does this for you automatically.
## Reference
- Every `queue:`/`workers:` YAML key: [Configuration reference](/docs/reference/configuration#queue)
- `cargo loco start`/`jobs` flags: [CLI reference](/docs/reference/cli)
- `worker`/`worker_redis` feature flags: [Feature flags reference](/docs/reference/feature-flags)
---
# Choose a queue backend
Source: https://loco.rs/docs/how-to/choose-queue-backend/
Goal: decide how background jobs (see [Add a background worker](/docs/how-to/add-worker)) are enqueued, stored, and processed, and configure it.
## 1. Pick a worker mode
`workers.mode` controls whether jobs go through a persistent queue at all:
```yaml
# config/development.yaml
workers:
mode: BackgroundQueue # default. Options: BackgroundQueue | ForegroundBlocking | BackgroundAsync
```
| Mode | Needs a `queue:` backend? | Behavior |
|---|---|---|
| `BackgroundQueue` (default) | yes | Enqueues to the configured provider; a separate worker process (or thread) dequeues and runs jobs. Survives restarts. |
| `ForegroundBlocking` | no | Runs the job inline, blocking the caller until it finishes. Used in tests. |
| `BackgroundAsync` | no | `tokio::spawn`s the job in the same process. No external store โ jobs are lost on crash/restart. |
If you only need `BackgroundAsync` or `ForegroundBlocking`, you can stop here โ skip the `queue:` config entirely.
The `loco new` wizard asks for this up front, offering `Async`, `Queue: Redis`, `Queue: Postgres`, `Queue: SQLite`, or `Blocking`; picking one of the three `Queue: *` options wires up the matching `workers.mode: BackgroundQueue` config, `queue.kind`, and Cargo feature (`worker` for Postgres/SQLite, `worker_redis` for Redis) for you.
## 2. Pick and configure a backend
All three backends share the same `perform_later` API and priority semantics; switching is a config change, not a code change. Set `queue.kind` in your environment YAML:
### Redis
```yaml
queue:
kind: Redis
uri: "{{ get_env(name='REDIS_URL', default='redis://127.0.0.1') }}"
dangerously_flush: false # clears the queue on boot โ dev/test only
queues: [high, low] # optional: named/priority queues, first = most important
num_workers: 2 # concurrent job handlers
```
Requires the `worker_redis` Cargo feature. Unlike Postgres/SQLite, this one is **not** in the default feature set โ enable it explicitly (`worker_redis` implies `worker`, so you don't need to list both):
```toml
loco-rs = { version = "...", features = ["worker_redis"] }
```
`setup()` is a no-op for Redis โ there's no schema to create.
### Postgres
```yaml
queue:
kind: Postgres
uri: "{{ get_env(name='PGQ_URL', default='postgres://localhost:5432/mydb') }}"
dangerously_flush: false
enable_logging: false
max_connections: 20
min_connections: 1
connect_timeout: 500 # ms
idle_timeout: 500 # ms
poll_interval_sec: 1
num_workers: 2
```
Requires the `worker` Cargo feature (on by default โ no extra `features = [...]` needed for a plain `loco-rs` dependency). Jobs live in a `pg_loco_queue` table; the table (and a `priority` column, for pre-1.0 tables) is created/migrated automatically on boot.
### SQLite
```yaml
queue:
kind: Sqlite
uri: "{{ get_env(name='SQLTQ_URL', default='sqlite://loco_development.sqlite?mode=rwc') }}"
dangerously_flush: false
poll_interval_sec: 1
num_workers: 2
# remaining keys identical to Postgres
```
Requires the `worker` Cargo feature (on by default) โ the same flag that gates the Postgres backend above; both share the `sqlx`-based provider and are picked between at runtime by `queue.kind`. Uses `sqlt_loco_queue` (+ a lock table, since SQLite has no `SELECT ... FOR UPDATE SKIP LOCKED`).
The upshot: `worker` covers Postgres and SQLite queues (already in the default feature set), while `worker_redis` adds the Redis queue on top. Which backend actually runs is a runtime choice โ `queue.kind: Postgres | Sqlite | Redis` โ not a per-database feature flag. For the exhaustive key list (defaults included), see [Configuration reference โ queue](/docs/reference/configuration#queue). For flag names and how to trim the default feature set, see [Feature flags reference](/docs/reference/feature-flags).
## 3. Run the worker process
```sh
cargo loco start --worker # dedicated worker process
cargo loco start --server-and-worker # server + worker, one process
```
See [Add a background worker](/docs/how-to/add-worker#5-run-the-worker-process) for tag filtering.
## Priority queues
All three backends support per-job priority: higher `priority` (a full `i32`) is dequeued first; ties break by earlier `run_at`, then by job id. Set it with `perform_later_with_priority` instead of `perform_later`:
```rust
DownloadWorker::perform_later_with_priority(&ctx, args, Some(100)).await?;
```
Redis additionally supports **named** queues via `queue.queues` โ `Worker::queue()` picks which named queue a job lands in, and the config list order sets each queue's priority (first = most important). The default named queues are `["default", "mailer"]`.
## Managing jobs from the CLI
Once `worker` (Postgres/SQLite) or `worker_redis` (Redis) is enabled, `cargo loco jobs` is available for all three backends โ including Redis, which now fully supports admin operations (cancel, clear, requeue, dump/import are no longer Postgres/SQLite-only):
```sh
cargo loco jobs cancel --name
cargo loco jobs tidy # delete completed/cancelled jobs
cargo loco jobs purge --max-age 90 # delete old failed/cancelled jobs
cargo loco jobs dump -f
cargo loco jobs import -f
cargo loco jobs requeue --from-age 0 # move stuck "processing" jobs back to "queued"
```
See the full flag list in the [CLI reference](/docs/reference/cli#2-3-jobs-subcommands).
### Automatic requeue (reaper)
Running `cargo loco jobs requeue` by hand recovers jobs stranded in `processing` after a worker crash, but nothing does this automatically by default. To have the running worker process do it periodically, opt in with a `reaper` block under `queue:` (all three backends support it):
```yaml
queue:
kind: Postgres
uri: "{{ get_env(name='PGQ_URL', default='postgres://localhost:5432/mydb') }}"
# ...
reaper:
age_minutes: 10 # requeue jobs stuck in "processing" for longer than this
interval_seconds: 60 # optional, default 60 โ how often to sweep
```
Leaving `reaper` unset (the default) keeps prior behavior unchanged โ no background sweep runs, and stranded jobs stay in `processing` until you run `cargo loco jobs requeue` yourself.
## Choosing between the three
- **Redis** โ lowest latency, named/priority queues, no extra schema. Good default if you already run Redis.
- **Postgres** โ no extra moving part if your app's database is already Postgres; `FOR UPDATE SKIP LOCKED` gives solid concurrency.
- **SQLite** โ zero extra infrastructure for small deployments or local dev; uses a lock table instead of `SKIP LOCKED`, so it's less suited to high worker concurrency.
---
# Schedule recurring jobs
Source: https://loco.rs/docs/how-to/schedule-jobs/
Goal: run a [task](/docs/how-to/write-task) or a shell command on a recurring schedule, without hand-rolling `crontab`.
## 1. Create a scheduler config
Generate a dedicated file:
```sh
cargo loco generate scheduler
```
This creates `config/scheduler.yaml`. Alternatively, add a `scheduler:` block directly to your environment YAML (`config/development.yaml`, etc.) โ both forms use the same schema.
## 2. Define jobs
```yaml
scheduler:
output: stdout # default output for all jobs: stdout | silent
jobs:
write_content:
shell: true # run `run` as a shell command (default: false = run a task)
run: "echo loco >> ./scheduler.txt"
schedule: run every 1 second # English syntax
output: silent # overrides the job-level default
tags: ["base", "infra"]
run_task:
run: "foo" # a registered task name
schedule: "at 10:00 am"
run_on_start: true # also run once when the scheduler starts
list_if_users:
run: "user_report"
shell: true
schedule: "* 2 * * * *" # cron syntax
tags: ["base", "users"]
```
Each job entry has:
| Key | Required? | Notes |
|---|---|---|
| `run` | yes | A shell command (if `shell: true`) or a registered task name plus optional `KEY:VALUE` args (if `shell: false`, the default) |
| `schedule` | yes | English phrase or cron expression โ see below |
| `shell` | no, default `false` | `false` runs `run` as a task; `true` runs it as a shell command |
| `run_on_start` | no, default `false` | Also fire once immediately when the scheduler starts |
| `tags` | no | Group jobs so you can run them together with `--tag` |
| `output` | no | Overrides `scheduler.output` for this job only |
### Schedule syntax
`schedule` accepts either form โ Loco auto-detects cron syntax by checking whether the string starts with a digit or `*`; anything else is parsed as English via `english_to_cron`:
- English: `every 15 seconds`, `run every minute`, `fire every day at 4:00 pm`, `at 10:00 am`, `run at midnight on the 1st and 15th of the month`, `On Sunday at 12:00`, `7pm every Thursday`, `midnight on Tuesdays`
- Cron (7 fields, **UTC**, includes seconds and year):
```
sec min hour day of month month day of week year
* * * * * * *
```
## 3. Verify the config
```sh
# dedicated file
cargo loco scheduler --config config/scheduler.yaml --list
# scheduler: block embedded in the environment file
LOCO_ENV=production cargo loco scheduler --list
```
## 4. Run it
As a standalone process:
```sh
cargo loco scheduler # uses scheduler: in config/.yaml
cargo loco scheduler --config config/scheduler.yaml # uses a dedicated file
```
Or bundled with the server and worker in one process:
```sh
cargo loco start --all
```
If your jobs live in a dedicated `scheduler.yaml` rather than embedded in the environment file, `start --all` needs to be told where to find it โ set `SCHEDULER_CONFIG`:
```sh
SCHEDULER_CONFIG=config/scheduler.yaml cargo loco start --all
```
Each firing spawns a **subprocess** (`/bin/sh -c` on Unix, `cmd.exe /C` on Windows); `LOCO_ENV` is propagated to it, so a task job resolves the same config/environment as the parent process. On shutdown (Ctrl+C), the scheduler waits for running jobs before exiting.
## 5. Run a subset by name or tag
```sh
LOCO_ENV=production cargo loco scheduler --name 'run_task'
LOCO_ENV=production cargo loco scheduler --tag 'base'
```
## Reference
- Writing the task a scheduler job invokes: [Write a task](/docs/how-to/write-task)
- `scheduler`/`SCHEDULER_CONFIG` config keys: [Configuration reference](/docs/reference/configuration)
- `cargo loco scheduler` flags: [CLI reference](/docs/reference/cli)
---
# Connect to Postgres and Redis over TLS
Source: https://loco.rs/docs/how-to/connect-over-tls/
Goal: connect your app to a **managed** database or cache that requires (or should use) encryption in transit. Most cloud providers โ AWS RDS/ElastiCache, Supabase, Neon, Azure, Upstash โ either require TLS or strongly recommend it.
Loco uses [rustls](https://github.com/rustls/rustls) with the pure-Rust `ring` provider for every TLS path, so none of this needs a system OpenSSL or a C toolchain.
## Postgres over TLS
Postgres TLS works out of the box whenever the `with-db` feature is on (the default for database apps) โ there is **no Cargo feature to enable and no code to write**. You turn it on entirely through the connection URL in `config/*.yaml`, using the same `sslmode` / `sslrootcert` parameters `libpq` and every Postgres client understand.
```yaml
# config/production.yaml
database:
# Require an encrypted connection; fail if the server won't do TLS.
uri: "postgres://user:pass@db.example.com:5432/myapp?sslmode=require"
```
`sslmode` accepts the standard values, from weakest to strongest:
| `sslmode` | Encrypted? | Verifies the server? | Use when |
|---|---|---|---|
| `disable` | no | no | local/dev only |
| `prefer` | if available | no | โ |
| `require` | yes | no | encryption without certificate checks |
| `verify-ca` | yes | CA chain | you trust the CA |
| `verify-full` | yes | CA chain **and** hostname | recommended for production |
For `verify-ca` / `verify-full` against a provider whose CA is not in the bundled root store, point at the CA bundle they give you, and add client-certificate paths for mutual TLS:
```yaml
database:
uri: "postgres://user:pass@db.example.com:5432/myapp?sslmode=verify-full&sslrootcert=/etc/ssl/rds-ca.pem"
# For mTLS, also: &sslcert=/path/client.crt&sslkey=/path/client.key
```
Provider quick reference (all support `sslmode=require`; use `verify-full` + their CA for the strongest setting):
- **AWS RDS/Aurora** โ download the [RDS CA bundle](https://docs.aws.amazon.com/AmazonRDS/latest/UserGuide/UsingWithRDS.SSL.html) and pass it as `sslrootcert`.
- **Supabase / Neon** โ TLS is required; `sslmode=require` works directly, `verify-full` with their published CA is stronger.
- **Azure Database for PostgreSQL** โ requires TLS; use `sslmode=require` or stricter.
> **Troubleshooting:** the error `server does not support TLS` means the **server** refused the TLS negotiation (wrong host/port, or TLS disabled server-side) โ it is not a Loco or client bug. Check that you are pointing at the provider's TLS endpoint.
### Postgres queue over TLS
If you use the Postgres **queue** backend (`worker` feature) pointed at a TLS-only managed Postgres, the worker pool carries its own rustls TLS backend, so the same `sslmode=...` URL in `queue.uri` works there too โ including in a worker-only build that does not enable `with-db`.
## Redis over TLS
Redis TLS is opt-in behind a Cargo feature, because the base Redis client does not compile a TLS stack by default.
1. Enable the `redis_tls` feature alongside your Redis feature:
```toml
# Cargo.toml
loco-rs = { version = "*", features = ["worker_redis", "redis_tls"] }
# or, for the Redis cache backend:
# loco-rs = { version = "*", features = ["cache_redis", "redis_tls"] }
```
`redis_tls` arms **both** the worker queue and the cache Redis paths at once (they share the same underlying client), using webpki-bundled roots so it works in slim/distroless container images with no system certificate store.
2. Use the `rediss://` scheme (note the double `s`) in your config โ that is the only change on the config side:
```yaml
# config/production.yaml
queue:
kind: Redis
uri: "rediss://:password@my-redis.example.com:6380"
# and/or the cache:
cache:
kind: Redis
uri: "rediss://:password@my-redis.example.com:6380"
```
Provider notes:
- **AWS ElastiCache** โ enable "encryption in-transit" on the cluster, then use `rediss://` with the auth token as the password.
- **Upstash / Redis Cloud** โ TLS endpoints are `rediss://` by default; copy the URL from the dashboard.
---
# Write a one-off task
Source: https://loco.rs/docs/how-to/write-task/
Goal: run an ad-hoc, CLI-invokable operation (data fix, report, one-time migration) with typed access to your app's `AppContext` โ without building a UI for it. Tasks can also be invoked on a schedule, see [Schedule recurring jobs](/docs/how-to/schedule-jobs).
## 1. Generate a task
```sh
cargo loco generate task user_report
```
This creates `src/tasks/user_report.rs`, adds `pub mod user_report;` to `src/tasks/mod.rs`, and registers it in `src/app.rs`:
```rust
use loco_rs::prelude::*;
pub struct UserReport;
#[async_trait]
impl Task for UserReport {
fn task(&self) -> TaskInfo {
TaskInfo {
name: "user_report".to_string(),
detail: "Task generator".to_string(),
}
}
async fn run(&self, _app_context: &AppContext, _vars: &task::Vars) -> Result<()> {
println!("Task UserReport generated");
Ok(())
}
}
```
## 2. Implement the logic
The `Task` trait has two parts: `task()` describes the task (its name and a help blurb shown when listing tasks), and `run()` does the work with access to `AppContext` and CLI arguments:
```rust
use loco_rs::prelude::*;
use crate::{mailers::auth::AuthMailer, models::_entities::users, models::users::RegisterParams};
pub struct UserCreate;
#[async_trait]
impl Task for UserCreate {
fn task(&self) -> TaskInfo {
TaskInfo {
name: "user:create".to_string(),
detail: "Create a new user with email, name, and password.\n\
Usage: cargo loco task user:create email:user@example.com name:\"John Doe\" password:\"secret\""
.to_string(),
}
}
async fn run(&self, app_context: &AppContext, vars: &task::Vars) -> Result<()> {
let email = vars.cli_arg("email").map_err(|_| Error::string("email is mandatory"))?;
let name = vars.cli_arg("name").map_err(|_| Error::string("name is mandatory"))?;
let password = vars.cli_arg("password").map_err(|_| Error::string("password is mandatory"))?;
let register_params = RegisterParams {
email: email.clone(),
password: password.clone(),
name: name.clone(),
};
let user = users::Model::create_with_password(&app_context.db, ®ister_params).await?;
AuthMailer::send_welcome(app_context, &user).await?;
println!("user created: {}", user.email);
Ok(())
}
}
```
(Adapted from `examples/demo/src/tasks/user_create.rs`.)
`vars.cli_arg("key")` reads a `key:value` pair passed on the command line; it returns a `Result`, so missing required arguments become a clear task error rather than a panic.
## 3. Confirm registration
The generator injects this automatically โ but if you write a task by hand, register it yourself in `register_tasks`:
```rust
// src/app.rs
impl Hooks for App {
// ..
fn register_tasks(tasks: &mut Tasks) {
tasks.register(tasks::user_create::UserCreate);
}
// ..
}
```
Registering a task under a name that's already taken replaces the previous one โ the registry is keyed by task name.
## 4. Run it
```sh
cargo loco task user:create email:user@example.com name:"John Doe" password:secret
```
General form:
```sh
cargo loco task [KEY:VALUE ...]
```
## 5. List all registered tasks
```sh
cargo loco task
```
Running `task` with no name lists every task currently registered via `register_tasks` (not a history of past runs) โ each with its `name` and `detail`.
## Reference
- Run tasks on a schedule instead of manually: [Schedule recurring jobs](/docs/how-to/schedule-jobs)
- `cargo loco task` / `cargo loco generate task` flags: [CLI reference](/docs/reference/cli)
---
# Send an email
Source: https://loco.rs/docs/how-to/send-email/
Goal: send a transactional email (welcome message, password reset, notification) from a controller or task, without blocking the request while SMTP does its thing.
A mailer delivers over SMTP in the background, using the same [background worker](/docs/how-to/add-worker) infrastructure โ calling a mailer enqueues a `MailerWorker` job (on the `"mailer"` queue) and returns immediately.
## 1. Generate a mailer
```sh
cargo loco generate mailer auth
```
This creates `src/mailers/auth.rs`, adds `pub mod auth;` to `src/mailers/mod.rs`, and scaffolds a `welcome/` template directory:
```
src/
mailers/
auth/
welcome/ <-- one directory per email, holding all its parts
subject.t
html.t
text.t
auth.rs <-- mailer definition
```
The generated mailer looks like this:
```rust
#![allow(non_upper_case_globals)]
use loco_rs::prelude::*;
use serde_json::json;
static welcome: Dir<'_> = include_dir!("src/mailers/auth/welcome");
#[allow(clippy::module_name_repetitions)]
pub struct AuthMailer {}
impl Mailer for AuthMailer {}
impl AuthMailer {
pub async fn send_welcome(ctx: &AppContext, to: &str, msg: &str) -> Result<()> {
Self::mail_template(
ctx,
&welcome,
mailer::Args {
to: to.to_string(),
locals: json!({
"message": msg,
"domain": ctx.config.server.full_url()
}),
..Default::default()
},
)
.await?;
Ok(())
}
}
```
A template directory must contain exactly three files โ `subject.t`, `html.t`, `text.t` (all Tera templates, rendered against `locals`). Missing any one of them is an error at send time.
## 2. Call it from a controller
```rust
use crate::mailers::auth::AuthMailer;
async fn register(
State(ctx): State,
Json(params): Json,
) -> Result {
// .. register the user ..
AuthMailer::send_welcome(&ctx, &user.email, "Welcome!").await?;
format::json(())
}
```
`mail`/`mail_template` return as soon as the job is enqueued โ actual SMTP delivery happens in a worker process.
## 3. Configure SMTP
```yaml
# config/development.yaml โ local mail catcher (e.g. mailtutan, MailHog)
mailer:
smtp:
enable: true
host: localhost
port: 1025
secure: false
```
```yaml
# config/production.yaml โ provider requiring implicit TLS on port 465
mailer:
smtp:
enable: true
host: smtp.example.com
port: 465
tls: implicit # overrides `secure` โ see below
auth:
user: postmaster@mg.example.com
password: "{{ get_env(name='SMTP_PASSWORD') }}"
hello_name: mail.example.com # optional EHLO client id
```
### Picking the right `tls` mode
`tls` is the authoritative setting; when present it **overrides** the legacy `secure` boolean:
| `tls` value | Port | Behavior |
|---|---|---|
| `starttls` | 587 (typical) | Connects in cleartext, then upgrades with `STARTTLS`. This is what `secure: true` used to (and still does) select. |
| `implicit` | 465 (typical) | Connection is encrypted from the first byte (SMTPS). **Required** for providers that only accept implicit TLS โ `STARTTLS` will not work against a 465 listener. |
| `none` | โ | Cleartext, no TLS. Local sinks (Mailpit, mailtutan) only. |
If `tls` is omitted, the legacy `secure` field still works: `secure: true` โ `starttls`, `secure: false` โ `none`. If you're setting up a provider that documents "port 465 / SMTPS," set `tls: implicit` explicitly โ `secure: true` alone cannot express that mode.
## 4. Set a default from-address or priority
Override `opts()` on your mailer:
```rust
impl Mailer for AuthMailer {
fn opts() -> MailerOpts {
MailerOpts {
from: "Acme ".to_string(),
reply_to: None,
priority: 100, // default background-queue priority for mailer jobs
}
}
}
```
Mailer jobs enqueue at priority `100` by default (`DEFAULT_MAILER_PRIORITY`) โ see [Choose a queue backend](/docs/how-to/choose-queue-backend#priority-queues) for what priority means across queue backends. Raise it if a particular mailer's messages (e.g. password resets) should jump ahead of lower-priority background work.
## 5. CC, BCC, and threading headers
`Args` (passed to `mail_template`) and `Email` (passed to `mail`) both support `cc`, `bcc`, and a `headers` field for threading:
```rust
Self::mail_template(
ctx,
&welcome,
mailer::Args {
to: user.email.clone(),
cc: Some("audit@acme.example".to_string()),
bcc: Some("archive@acme.example".to_string()),
headers: Some(mailer::EmailHeaders {
in_reply_to: Some(original_message_id.clone()),
references: Some(original_message_id.clone()),
message_id: None,
}),
locals: json!({ "name": user.name }),
..Default::default()
},
)
.await?;
```
`EmailHeaders` maps to `References`/`In-Reply-To`/`Message-ID`, useful for grouping notification emails into a single thread in the recipient's mail client.
## 6. Run the mailer worker
Mailer delivery goes through the background worker infrastructure, so a worker process must be running to actually send anything:
```sh
cargo loco start --worker # dedicated worker process
cargo loco start --server-and-worker # server + worker together
```
## 7. Test without sending real email
Set `stub: true` to capture emails instead of sending them:
```yaml
mailer:
stub: true
```
If mail is dispatched through a worker, set `workers.mode: ForegroundBlocking` in your test config so the send completes synchronously within the test.
```rust
use loco_rs::testing::prelude::*;
#[tokio::test]
#[serial]
async fn can_register() {
configure_insta!();
request::(|request, ctx| async move {
// .. call the endpoint that sends an email ..
with_settings!({ filters => cleanup_email() }, {
assert_debug_snapshot!(ctx.mailer.unwrap().deliveries());
});
})
.await;
}
```
`deliveries()` (available under the `testing` feature) reports how many emails were "sent" and their content, so you can assert on both.
## Reference
- All `mailer:` YAML keys: [Configuration reference](/docs/reference/configuration#mailer)
- Worker modes and running a worker process: [Add a background worker](/docs/how-to/add-worker)
---
# Configure file storage
Source: https://loco.rs/docs/how-to/configure-storage/
Goal: give your app a place to put uploaded files โ on disk, in memory (for tests), or in a cloud bucket โ through one consistent `Storage` API, without hand-rolling an OpenDAL client yourself.
Loco's storage layer is a thin abstraction over [Apache OpenDAL](https://opendal.apache.org/). Every driver ends up implementing the same `StoreDriver` trait, so your controller code doesn't change when you swap local disk for S3.
## Prerequisites
Local, in-memory, and null storage work with no extra Cargo features. Cloud drivers need one of:
```toml
loco-rs = { version = "...", features = ["storage_aws_s3"] } # or storage_azure, storage_gcp, all_storage
```
See the [feature flags reference](/docs/reference/feature-flags) for the full matrix.
## 1. Wire up a single driver
Storage isn't configured in YAML โ it's wired in code, in the `after_context` hook (`src/app.rs`), and lands on `ctx.storage: Arc`.
```rust
use loco_rs::storage::{self, drivers};
async fn after_context(ctx: AppContext) -> Result {
Ok(AppContext {
storage: storage::Storage::single(drivers::local::new()).into(),
..ctx
})
}
```
If you don't override `after_context` at all, Loco defaults to the **`Null` driver** โ every storage operation returns `StorageError::Any("Operation not supported by null storage")`. That's a deliberate fail-fast default, not a bug: it means "you haven't wired storage yet."
## 2. Pick a driver
Every driver is built by a plain constructor function under `loco_rs::storage::drivers::*` โ no trait object wrangling required.
| Driver | Feature | Constructor | Notes |
|---|---|---|---|
| Local filesystem | none | `drivers::local::new()` โ rooted at the current working directory `drivers::local::new_with_prefix(prefix) -> StorageResult>` | `new_with_prefix` errors if the prefix path doesn't exist |
| In-memory | none | `drivers::mem::new()` | Good for tests; data doesn't survive process exit |
| Null | none | `drivers::null::new()` | The framework default; every op errors |
| AWS S3 | `storage_aws_s3` | `drivers::aws::new(bucket, region) -> StorageResult<...>` `drivers::aws::with_credentials(bucket, region, cred) -> StorageResult<...>` `drivers::aws::with_credentials_and_endpoint(bucket, region, endpoint, cred) -> StorageResult<...>` | `Credential { key_id, secret_key, token: Option }` |
| Azure Blob | `storage_azure` | `drivers::azure::new(container, account_name, access_key, endpoint) -> StorageResult<...>` | |
| Google Cloud Storage | `storage_gcp` | `drivers::gcp::new(bucket, credential_path) -> StorageResult<...>` | `credential_path` points to a service-account JSON key file |
All the cloud constructors return `StorageResult>` (they can fail to build the underlying OpenDAL operator), so propagate the error with `?`:
```rust
use loco_rs::storage::{self, drivers};
async fn after_context(ctx: AppContext) -> Result {
let store = drivers::aws::new("my-app-uploads", "us-east-1")?;
Ok(AppContext {
storage: storage::Storage::single(store).into(),
..ctx
})
}
```
For credentials that aren't in the environment/instance profile, pass them explicitly:
```rust
use loco_rs::storage::drivers::aws::{self, Credential};
let credential = Credential {
key_id: std::env::var("AWS_ACCESS_KEY_ID")?,
secret_key: std::env::var("AWS_SECRET_ACCESS_KEY")?,
token: None,
};
let store = aws::with_credentials("my-app-uploads", "us-east-1", credential)?;
```
> The storage driver trait is `StoreDriver` (not `StorageDriver`) โ you'll see it in error messages and if you implement your own driver.
## 3. Use multiple drivers with a strategy (optional)
For redundancy across providers, set up several named stores and a `StorageStrategy` that decides how operations fan out across them.
**Mirror** โ replicates uploads/deletes/renames/copies to every store; download tries the primary, then falls through to secondaries on failure. This is `ReplicatedStrategy::mirror`.
```rust
use std::collections::BTreeMap;
use loco_rs::storage::{
drivers, Storage,
strategies::{replicated::{ReplicatedStrategy, FailurePolicy}, StorageStrategy},
};
let primary = drivers::aws::new("bucket-primary", "us-east-1")?;
let mirror = drivers::azure::new("container", "account", "access-key", "https://account.blob.core.windows.net")?;
let strategy: Box = Box::new(ReplicatedStrategy::mirror(
"primary",
Some(vec!["mirror".to_string()]),
FailurePolicy::FailIfAny, // or AllowAll
));
let storage = Storage::new(
BTreeMap::from([
("primary".to_string(), primary),
("mirror".to_string(), mirror),
]),
strategy,
);
```
`FailurePolicy::FailIfAny` requires every secondary to succeed (errors bubble up as `StorageError::Multi`); `AllowAll` swallows secondary failures.
**Backup** โ the primary must always succeed for writes; secondary failures are governed by a separate failure policy, and downloads *always* come from the primary only. This is `ReplicatedStrategy::backup`.
```rust
use loco_rs::storage::strategies::replicated::{ReplicatedStrategy, FailurePolicy};
let strategy: Box = Box::new(ReplicatedStrategy::backup(
"primary",
Some(vec!["backup_store".to_string()]),
FailurePolicy::AllowAll, // also: FailIfAny, AllowSingleFailure, FailAtFailures(n)
));
```
Mirror and backup are both `ReplicatedStrategy`, differing only in the constructor used (`mirror` vs `backup`) and the `FailurePolicy` you pick. It exposes a `_with_policy`/`_with_strategy` variant on every `Storage` method (`upload_with_strategy`, `download_with_policy`, ...) if you need to override the strategy for a single call.
## 4. Upload and download in a controller
```rust
use loco_rs::prelude::*;
use std::path::PathBuf;
async fn upload_file(
State(ctx): State,
mut multipart: Multipart,
) -> Result {
while let Some(field) = multipart.next_field().await.map_err(|_| {
Error::BadRequest("could not read multipart".into())
})? {
let file_name = field
.file_name()
.map(str::to_string)
.ok_or_else(|| Error::BadRequest("file name not found".into()))?;
let content = field
.bytes()
.await
.map_err(|_| Error::BadRequest("could not read bytes".into()))?;
let path = PathBuf::from("uploads").join(file_name);
ctx.storage.as_ref().upload(&path, &content).await?;
return format::json(serde_json::json!({ "path": path }));
}
not_found()
}
```
(Requires the `multipart` feature on the `axum` crate.)
## 5. Stream large files instead of buffering them
For files too large to comfortably hold in memory, use the streaming API โ `download_stream`/`upload_stream` return/accept a `BytesStream`, which converts directly to/from an axum `Body`. This is undocumented in earlier Loco releases but is a stable, full public feature.
Streaming a download straight into an HTTP response, with zero extra buffering:
```rust
use axum::response::IntoResponse;
use std::path::Path;
async fn download_video(State(ctx): State) -> Result {
let stream = ctx.storage.download_stream(Path::new("videos/demo.mp4")).await?;
Ok(stream.into_body())
}
```
Streaming an upload from an incoming request body (axum's `Body` stream yields `axum::Error`, so map it to `std::io::Error` first โ that's the error type `BytesStream` expects):
```rust
use loco_rs::storage::stream::BytesStream;
use futures_util::StreamExt;
use std::path::Path;
async fn upload_video(State(ctx): State, body: axum::body::Body) -> Result {
let mapped = body
.into_data_stream()
.map(|chunk| chunk.map_err(std::io::Error::other));
let stream = BytesStream::from_body_stream(mapped);
ctx.storage.upload_stream(Path::new("videos/demo.mp4"), stream).await?;
format::empty()
}
```
If you need the whole payload as one `Bytes` buffer anyway, `BytesStream::collect()` gives you that โ but at that point you've given up the memory benefit of streaming.
**Strategy caveat:** streaming isn't uniformly "true streaming" once a strategy other than `SingleStrategy` is involved. `ReplicatedStrategy` unifies the former mirror/backup behavior: reads (both the buffered `download` and `download_stream`) fall back to secondaries when `read_from_secondaries` is set โ i.e. constructed via `ReplicatedStrategy::mirror` โ and are served from the primary only when constructed via `ReplicatedStrategy::backup`. Either way, `upload_stream` buffers the whole payload once via `collect()` and then fans out concurrently to secondaries. If you need guaranteed zero-buffering streaming to a single store, stick to `SingleStrategy` (the default).
## 6. Verify
```rust
use loco_rs::testing::prelude::*;
#[tokio::test]
#[serial]
async fn can_upload_and_download() {
request::(|request, ctx| async move {
let file_content = "loco file upload";
let file_part = Part::bytes(file_content.as_bytes()).file_name("loco.txt");
let multipart_form = MultipartForm::new().add_part("file", file_part);
let response = request.post("/upload/file").multipart(multipart_form).await;
response.assert_status_ok();
let res: serde_json::Value = serde_json::from_str(&response.text()).unwrap();
let path = res["path"].as_str().unwrap();
let stored: String = ctx.storage.as_ref().download(&std::path::Path::new(path)).await.unwrap();
assert_eq!(stored, file_content);
})
.await;
}
```
## Reference
- `storage_aws_s3` / `storage_azure` / `storage_gcp` / `all_storage` feature flags: [Feature flags reference](/docs/reference/feature-flags)
- Storage has no YAML configuration surface โ everything above is the complete configuration story; there is no `storage:` key to look up in the [Configuration reference](/docs/reference/configuration)
---
# Use the cache
Source: https://loco.rs/docs/how-to/use-cache/
Goal: cache a value (a computed result, an external API response, a hot query) behind a key, with an optional TTL, using a driver you can swap per environment.
`ctx.cache` is available in every controller, task, and worker. Values are serialized as JSON strings under the hood, so anything `Serialize + DeserializeOwned` can go in.
## 1. Configure a driver
Cache drivers are configured entirely in YAML โ no code changes needed to switch drivers between environments.
```yaml
# config/development.yaml โ fast, disposable, no external dependency
cache:
kind: InMem
max_capacity: 33554432 # optional, bytes; default 32MiB (32 * 1024 * 1024)
```
```yaml
# config/production.yaml โ shared across processes
cache:
kind: Redis
uri: "{{ get_env(name='REDIS_CACHE_URL', default='redis://127.0.0.1:6379') }}"
max_size: 10 # required โ max pool connections
```
```yaml
# omit the `cache` key entirely, or set explicitly โ this is the default
cache:
kind: Null
```
`InMem` needs the `cache_inmem` feature (on by default); `Redis` needs `cache_redis` (off by default โ add it to your `Cargo.toml`). See the [feature flags reference](/docs/reference/feature-flags).
If you omit `cache` from the config file altogether, Loco silently falls back to the **`Null` driver**: `get()` always returns `None`, and every mutating operation (`insert`, `insert_with_expiry`, `remove`, `clear`, `ping`) returns an error. This is a fail-fast default for "you haven't configured a real cache" โ don't ship it to production by accident.
## 2. Insert and read values
```rust
use loco_rs::cache;
use serde::{Serialize, Deserialize};
#[derive(Serialize, Deserialize)]
struct User {
name: String,
age: u32,
}
async fn cache_basics(ctx: &AppContext) -> Result<()> {
ctx.cache.insert("greeting", &"hello".to_string()).await?;
let user = User { name: "Alice".to_string(), age: 30 };
ctx.cache.insert("user:1", &user).await?;
let greeting: Option = ctx.cache.get("greeting").await?;
let cached_user: Option = ctx.cache.get("user:1").await?;
let exists: bool = ctx.cache.contains_key("user:1").await?;
ctx.cache.remove("greeting").await?;
Ok(())
}
```
## 3. Set a TTL with `insert_with_expiry`
```rust
use std::time::Duration;
ctx.cache
.insert_with_expiry("session:abc", &token, Duration::from_secs(300))
.await?;
```
## 4. Cache the result of a computation with `get_or_insert`
`get_or_insert` returns the cached value if present, otherwise runs the given future, stores the result, and returns it. `get_or_insert_with_expiry` does the same but attaches a TTL to the freshly-computed value.
```rust
let expensive_report = ctx
.cache
.get_or_insert::("report:daily", async {
build_daily_report(ctx).await
})
.await?;
```
```rust
use std::time::Duration;
let expensive_report = ctx
.cache
.get_or_insert_with_expiry::(
"report:daily",
Duration::from_secs(3600),
async { build_daily_report(ctx).await },
)
.await?;
```
## 5. Health-check and clear
```rust
// Fails if the backing store (e.g. Redis) is unreachable.
ctx.cache.ping().await?;
// Wipe the cache.
ctx.cache.clear().await?;
```
> **Redis caveat:** `Cache::clear()` on the Redis driver issues **`FLUSHDB`** โ it flushes the *entire* Redis logical database, not just the keys your app put there. If other data (session store, queue, another app) shares that same Redis DB/instance, `clear()` will delete it too. Point cache at its own Redis DB (`redis://host:6379/1`, a separate `db` index) if you need to isolate it, and treat `clear()` as a blunt, whole-database operation.
## 6. Verify
```rust
#[tokio::test]
async fn can_get_or_insert() {
let app_ctx = get_app_context().await; // your test AppContext
let key = "loco";
assert_eq!(app_ctx.cache.get::(key).await.unwrap(), None);
let result = app_ctx
.cache
.get_or_insert::(key, async { Ok("loco-cache-value".to_string()) })
.await
.unwrap();
assert_eq!(result, "loco-cache-value");
assert_eq!(
app_ctx.cache.get::(key).await.unwrap(),
Some("loco-cache-value".to_string())
);
}
```
## Reference
- Every `cache:` YAML key (`kind`, `max_capacity`, `uri`, `max_size`): [Configuration reference ยง cache](/docs/reference/configuration#cache)
- `cache_inmem` / `cache_redis` feature flags: [Feature flags reference](/docs/reference/feature-flags)
---
# Deploy to production
Source: https://loco.rs/docs/how-to/deploy/
Goal: get a Loco app running on a production host. Loco compiles to a single self-contained binary โ the target server needs neither `cargo` nor a Rust toolchain, just the binary and a `config/` folder.
## 1. Build the release binary
```sh
cargo build --release
```
Your binary name matches the `[package] name` in `Cargo.toml` (with a `-cli` suffix, e.g. `myapp-cli`), and lands in `./target/release/`.
## 2. Generate a Dockerfile (optional)
```sh
cargo loco generate deployment docker
```
`kind` is a **positional** argument โ `docker` or `nginx`, not a `--kind` flag.
This writes two files to your project root:
- `Dockerfile` โ multi-stage build: compiles with `cargo build --release` in a `rust:slim` builder stage, then copies just the compiled binary and `config/` into a slim `debian:bookworm-slim` runtime image. If your app has a `frontend/package.json` (client-side rendering), it also installs Node and runs `npm install && npm run build` in the builder stage. If `server.middlewares.static_assets` is configured, the folders it points to are copied into the final image too.
- `.dockerignore` โ excludes `target/`, `.git`, and other build artifacts from the Docker build context.
Build and run it like any other image:
```sh
docker build -t myapp .
docker run -p 5150:5150 --env-file .env myapp
```
## 3. Generate an nginx config (optional)
```sh
cargo loco generate deployment nginx
```
This writes `nginx/default.conf`, a reverse-proxy config derived from your current `server.host` / `server.port` (`config/.yaml`) โ it proxies both the bare domain and wildcard subdomains to your app.
## 4. Review production config
There's no separate "production mode" โ Loco picks a config file by environment (`config/production.yaml` by default, or override with `LOCO_ENV`). Before deploying, walk through these sections:
**Logger** โ turn `pretty_backtrace` off (it's development-friendly, not performance-friendly) and prefer `json` for log aggregation:
```yaml
logger:
enable: true
pretty_backtrace: false
level: info
format: json
```
See [Configure logging](/docs/how-to/configure-logging) for the full picture.
**Server** โ bind to all interfaces and inject the port from the environment:
```yaml
server:
port: {{ get_env(name="NODE_PORT", default=5150) }}
host: {{ get_env(name="APP_HOST", default="http://localhost") }}
```
**Database** โ real connection limits, no destructive flags:
```yaml
database:
uri: "{{ get_env(name='DATABASE_URL', default='postgres://loco:loco@localhost:5432/loco_app') }}"
enable_logging: false
connect_timeout: 500
idle_timeout: 500
min_connections: 1
max_connections: 10
auto_migrate: true
dangerously_truncate: false
dangerously_recreate: false
```
**Auth secret** โ inject via environment, never hardcode:
```yaml
auth:
jwt:
secret: "{{ get_env(name='JWT_SECRET') }}"
expiration: 604800
```
**Queue / mailer** โ same pattern: point `uri`/`host` at env vars. See the [Configuration reference](/docs/reference/configuration) for every key across all of these sections.
## 5. Run `loco doctor` before going live
```sh
myapp-cli doctor --production
```
`doctor` validates DB/cache/queue connectivity against the config it would actually load. Add `-c`/`--config` to also print the fully-resolved config for inspection:
```sh
myapp-cli doctor --config --production
```
## 6. Ship it
Copy the binary and the `config/` folder to the server (no source, no `Cargo.lock`, no toolchain needed):
```sh
scp target/release/myapp-cli config/ user@server:/opt/myapp/
ssh user@server '/opt/myapp/myapp-cli start'
```
## Verify
- `myapp-cli doctor --production` exits 0 and reports all checks passing.
- `myapp-cli start` boots and the startup banner shows the environment, DB, and logger you expect.
- Hitting the app's health/root route through nginx (if you generated one) returns a response, confirming the reverse proxy is wired to the right host/port.
## Reference
- `generate deployment` CLI shape (`docker`/`nginx` as `kind`): [CLI reference](/docs/reference/cli)
- Every config key referenced above (`logger`, `server`, `database`, `auth`, `mailer`, `queue`): [Configuration reference](/docs/reference/configuration)
---
# Configure logging
Source: https://loco.rs/docs/how-to/configure-logging/
Goal: control what Loco logs, in what shape, and where โ stdout for development, structured JSON for production, and (optionally) a rotating log file โ without drowning in third-party crate noise.
Loco's logger is built on `tracing`. `logger.enable`, `logger.level`, and `logger.format` are required keys in every config file.
## 1. Set the minimum config
```yaml
# config/development.yaml
logger:
enable: true
pretty_backtrace: true
level: debug
format: compact
```
- `level`: `off` | `trace` | `debug` | `info` | `warn` | `error`.
- `format`: `compact` | `pretty` | `json`.
- `pretty_backtrace`: when `true`, forces `RUST_BACKTRACE=1` and nicely-formatted panic backtraces. It's a development convenience โ turn it off in performance-sensitive production deployments.
## 2. Know the filtering precedence
Loco doesn't just apply `level` globally to every crate โ by default it whitelists a small set of modules (`loco_rs`, `sea_orm_migration`, `tower_http`, `sqlx::query`, `playground`, `loco_gen`) plus your own app crate, and applies `level` only to those. Everything else stays quiet.
Three ways to control this, in strict precedence order:
1. **`RUST_LOG` environment variable** โ if set, it wins outright, ignoring both `level` and `override_filter`. Use this for one-off debugging on a running process without touching config:
```sh
RUST_LOG=debug cargo loco start
```
2. **`logger.override_filter`** โ a raw `tracing-subscriber` `EnvFilter` directive string. Use this to permanently see traces from libraries outside the built-in whitelist:
```yaml
logger:
enable: true
level: info
format: compact
override_filter: "trace" # or a directive like "myapp=debug,tower_http=debug"
```
3. **The built-in module whitelist + `level`** โ what you get if neither of the above is set. This is the common case: set `level` and trust Loco's whitelist to keep noise down.
## 3. Choose a format per environment
- `compact` โ human-readable single-line output; good default for local development.
- `pretty` โ multi-line, more spacious human-readable output.
- `json` โ structured, one JSON object per line; use this in production so a log aggregator (Loki, CloudWatch, Datadog, etc.) can parse fields directly.
```yaml
# config/production.yaml
logger:
enable: true
pretty_backtrace: false
level: info
format: json
```
## 4. Add a rotating file appender
`file_appender` writes logs to disk independently of (and with its own level/format, separate from) the stdout logger โ useful when you want stdout kept quiet but still capture a full trail on disk.
```yaml
logger:
enable: true
level: info
format: compact
file_appender:
enable: true
non_blocking: false # true offloads writes to a background thread
level: debug
format: json
rotation: daily # minutely | hourly | daily | never โ default is hourly
dir: ./logs # default "./logs" if omitted
filename_prefix: myapp
filename_suffix: log
max_log_files: 7 # required โ old files beyond this count are pruned
```
With `rotation: daily` and the settings above, you'll get files like `./logs/myapp..log`, rotated once a day, with only the newest 7 kept around.
## 5. Verify
Start the app and confirm the shape you expect shows up:
```sh
cargo loco start
# ... watch stdout for compact/pretty/json-formatted lines at the level you set
```
If you enabled a file appender, tail the log directory:
```sh
tail -f ./logs/*.log
```
To confirm the filtering precedence, try overriding at runtime without touching the config file:
```sh
RUST_LOG=loco_rs=trace cargo loco start
```
You should see much more verbose output than `logger.level` alone would produce โ confirming `RUST_LOG` took precedence.
## Reference
- Every `logger:` YAML key, including all `file_appender` sub-keys: [Configuration reference ยง logger](/docs/reference/configuration#logger)
---
# Load static data
Source: https://loco.rs/docs/how-to/load-data/
Goal: give your app access to read-only data that lives in a JSON file โ loaded once and kept in memory โ without standing up a database table or hand-rolling file I/O.
This is a good fit for data that's read far more often than it changes: machine learning hyperparameters, an IP banlist, calendar events, stock data, security policies, per-container configuration. If the data changes on every request, reach for the [cache](/docs/how-to/use-cache) or the database instead.
## 1. Generate a data loader
```
$ cargo loco g data stocks
added: "data/stocks/data.json"
added: "src/data/stocks.rs"
injected: "src/data/mod.rs"
* Data loader `Stocks` was added successfully.
```
This creates a `data/stocks/data.json` file (next to `src/`, the same way `config/` sits next to `src/`) and a `src/data/stocks.rs` module under the `crate::data::stocks` namespace.
## 2. Shape your data
`src/data/stocks.rs` starts with a placeholder struct:
```rust
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct Stocks {
pub is_loaded: bool,
}
```
Replace it with a struct matching the real shape of `data/stocks/data.json` (tools like [quicktype](https://quicktype.io/) can generate this from a sample file). Any `serde`-friendly type works.
## 3. Load the data
Under the hood, the generated module calls into `loco_rs::data`, which exposes two loader functions:
```rust
// asynchronous โ use from controllers, workers, tasks
pub async fn load_json_file(path: &str) -> Result;
// synchronous โ use during boot, or outside an async context
pub fn load_json_file_sync(path: &str) -> Result;
```
Both resolve `path` relative to a data folder โ `data/` by default. Call `data::stocks::get()` from anywhere to read the in-memory copy, loaded once for the life of the process; it's cheap to call as many times as you like. Call `data::stocks::read()` if you instead want to re-read straight from disk on every call (this pays an I/O cost each time).
## 4. Point at a different data folder (optional)
The data folder defaults to `data/`, resolved relative to wherever the app binary runs. Set the `LOCO_DATA` environment variable to override it:
```
LOCO_DATA=/etc/myapp/data cargo loco start
```
Whatever machine runs the binary needs a `data/` folder (or the `LOCO_DATA` path) present alongside it, the same way it needs `config/`.
## Updating the data
Because the in-memory copy is loaded once per process, picking up new data means restarting the process โ conceptually similar to a deploy, but without rebuilding or shipping new code, so it's fast. If you need to refresh without a restart, call the `read()` function directly and cache the result yourself using the [cache](/docs/how-to/use-cache) system.
---
# Protect a Route with JWT
Source: https://loco.rs/docs/how-to/jwt-auth/
Goal: require a valid JWT on a handler, and issue tokens your clients can send back.
Loco ships two axum extractors for JWT-protected routes:
- `auth::JWT` โ validates the token and gives you the claims. Works without a database.
- `auth::JWTWithUser` โ validates the token **and** loads the user record from the database via your model's [`Authenticable`](#the-authenticable-contract) implementation. Requires the `with-db` feature.
Both live in `loco_rs::controller::extractor::auth` and are re-exported through `loco_rs::prelude::*` whenever the `auth` feature is on.
If you generated your app with `loco new` and picked a database-backed starter, `with-db` and `auth` are already on by default โ see the [feature flags reference](/docs/reference/feature-flags) if you need to check or change that.
## 1. Enable the `auth` feature
`auth` is a **default** feature (`Cargo.toml`): it pulls in `jsonwebtoken` with its pure-Rust `rust_crypto` backend, so no C toolchain is required even in a minimal build. If you depend on `loco-rs` with `default-features = false`, add it back explicitly:
```toml
loco-rs = { version = "...", default-features = false, features = ["auth", "with-db"] }
```
## 2. Configure the secret and expiration
Add an `auth.jwt` block to `config/development.yaml` (and every other environment file):
```yaml
auth:
jwt:
secret: "{{ get_env(name='JWT_SECRET') }}" # required, must be valid base64
expiration: 604800 # required, seconds (7 days)
```
Two facts that will save you a confusing error message:
- **The secret must be valid base64.** Loco encodes/decodes tokens with `EncodingKey::from_base64_secret` / `DecodingKey::from_base64_secret`. A plain, non-base64 string doesn't fail at config-load time โ it fails later, when a token is generated or validated, with an error that doesn't obviously point at the config. Generate a base64 secret, e.g. `openssl rand -base64 64`, and inject it via `get_env` as above rather than hardcoding it.
- **The default signing algorithm is HS512**, not HS256. It's set in code (`Algorithm::HS512`) and isn't a YAML key โ override it only from Rust, via `JWT::algorithm(..)` when constructing the signer.
For the full `auth.jwt` key reference, including `location`, see the [configuration reference](/docs/reference/configuration#auth). Token location (`Bearer` / `Query` / `Cookie`) is covered in its own guide: [Configure where Loco looks for the JWT](/docs/how-to/jwt-locations).
## 3. Generate a token
Use `loco_rs::auth::jwt::JWT` (the signer/validator โ not to be confused with the extractor of the same name below) to mint a token, typically from a login handler, using the secret and expiration already loaded on `AppContext`:
```rust
use loco_rs::prelude::*;
async fn login(State(ctx): State, /* ... */) -> Result {
// `user` is whatever you loaded/verified during login (see hash-passwords.md).
let jwt_config = ctx.config.get_jwt_config()?;
let token = loco_rs::auth::jwt::JWT::new(&jwt_config.secret)
.generate_token(jwt_config.expiration, user.pid.to_string(), serde_json::Map::new())
.map_err(|e| Error::string(&e.to_string()))?;
format::json(serde_json::json!({ "token": token }))
}
```
`generate_token` takes the expiration (seconds), the `pid` that becomes `claims.pid`, and an optional `serde_json::Map` of custom claims that get flattened alongside `pid` in the token. It returns a `jsonwebtoken` result, not Loco's `Result`, so map the error explicitly as shown.
## 4. Protect a route: claims only
Add `auth::JWT` as a handler parameter. Axum runs it as an extractor before your handler body executes; if the token is missing, unparsable, in the wrong location, or expired, the request never reaches your code โ Loco returns `401 Unauthorized` for you.
```rust
use loco_rs::prelude::*;
use loco_rs::controller::extractor::auth;
async fn current(
auth: auth::JWT,
State(_ctx): State,
) -> Result {
format::json(serde_json::json!({ "pid": auth.claims.pid }))
}
```
`auth.claims` is `UserClaims { pid, claims, .. }` โ use `auth.claims.pid` for the subject, and `auth.claims.claims` for any custom claims you flattened in at generation time. This extractor needs no database, so it works even in DB-less apps.
## 5. Protect a route: claims + loaded user
When the handler needs the actual user row (not just the `pid`), use `auth::JWTWithUser` where `T` is your user model. This requires the `with-db` feature and requires `T` to implement `Authenticable`.
```rust
use loco_rs::prelude::*;
use loco_rs::controller::extractor::auth;
async fn current(
auth: auth::JWTWithUser,
State(_ctx): State,
) -> Result {
format::json(&auth.user)
}
```
Internally, `JWTWithUser` validates the token exactly like `auth::JWT`, then calls `T::find_by_claims_key(&ctx.db, &claims.pid)` to load the user. A database miss becomes `401 Unauthorized`; a database error becomes `500 Internal Server Error`.
## The `Authenticable` contract
Both `JWTWithUser` and `ApiToken` (see the [API-key guide](/docs/how-to/api-key-auth)) require your user model to implement `loco_rs::model::Authenticable`:
```rust
pub trait Authenticable: Clone {
async fn find_by_api_key(db: &DatabaseConnection, api_key: &str) -> ModelResult;
async fn find_by_claims_key(db: &DatabaseConnection, claims_key: &str) -> ModelResult;
}
```
A typical Sea-ORM implementation looks up the row by the relevant column and maps a miss to `ModelError::EntityNotFound`:
```rust
impl Authenticable for super::_entities::users::Model {
async fn find_by_api_key(db: &DatabaseConnection, api_key: &str) -> ModelResult {
let user = super::_entities::users::Entity::find()
.filter(super::_entities::users::Column::ApiKey.eq(api_key))
.one(db)
.await?;
user.ok_or(ModelError::EntityNotFound)
}
async fn find_by_claims_key(db: &DatabaseConnection, claims_key: &str) -> ModelResult {
let user = super::_entities::users::Entity::find()
.filter(super::_entities::users::Column::Pid.eq(claims_key))
.one(db)
.await?;
user.ok_or(ModelError::EntityNotFound)
}
}
```
**Note on scope:** the framework defines the `Authenticable` trait and the extractors above, but it does not generate a users model or `/api/auth/*` controllers for you โ `loco-gen` has no auth/user template. That register/login/verify/reset-password flow, including the `Authenticable` implementation shown above, ships in the **SaaS starter** template (`loco new` โ "SaaS app (with DB and user auth)"), which lives outside this repository. Use the pattern above as a starting point if you're wiring auth into a custom model.
## Verify it works
```sh
curl --location '127.0.0.1:5150/api/some/protected/route' \
--header 'Authorization: Bearer '
```
A missing, malformed, or expired token returns `401 Unauthorized`. A valid token reaches your handler with `auth.claims` (and, for `JWTWithUser`, `auth.user`) populated.
## Related
- [Configure where Loco looks for the JWT](/docs/how-to/jwt-locations) โ Bearer / Query / Cookie, single or multiple.
- [Protect a route with an API key](/docs/how-to/api-key-auth) โ `ApiToken`, a separate always-Bearer-header mechanism.
- [Hash and verify passwords](/docs/how-to/hash-passwords) โ for the login handler that issues the token above.
- [Configuration reference](/docs/reference/configuration#auth) โ every `auth.jwt` key.
- [Feature flags reference](/docs/reference/feature-flags) โ `auth` / `with-db` defaults and interactions.
- [AppContext & prelude reference](/docs/reference/app-context) โ what `loco_rs::prelude::*` brings in under `auth`.
---
# Protect a Route with an API Key
Source: https://loco.rs/docs/how-to/api-key-auth/
Goal: authenticate a request with a long-lived, per-user API key instead of a JWT โ useful for machine-to-machine or CLI clients that shouldn't have to re-authenticate for a fresh token.
Loco provides `auth::ApiToken`, an axum extractor that reads a key from the `Authorization` header and loads the matching user via your model's `Authenticable::find_by_api_key`.
## Prerequisites
- The `with-db` feature (on by default) โ `ApiToken` is compiled only under `#[cfg(feature = "with-db")]`.
- Your user model implements `loco_rs::model::Authenticable`, in particular `find_by_api_key`. See [the `Authenticable` contract](/docs/how-to/jwt-auth#the-authenticable-contract) for the trait shape and an example implementation.
- A column on your user model to store the key (e.g. `api_key`), and a way to populate it โ `loco_rs::hash::random_string` is a convenient generator; see [Hash and verify passwords](/docs/how-to/hash-passwords#generate-random-tokens).
Unlike JWT auth, `ApiToken` needs **no `auth.jwt` configuration at all** โ it doesn't call into `auth.jwt.secret`/`expiration`/`location`. It only needs a database and an `Authenticable` implementation.
## 1. Implement `find_by_api_key`
```rust
use loco_rs::model::{Authenticable, ModelError, ModelResult};
use sea_orm::{DatabaseConnection, EntityTrait, ColumnTrait, QueryFilter};
impl Authenticable for super::_entities::users::Model {
async fn find_by_api_key(db: &DatabaseConnection, api_key: &str) -> ModelResult {
let user = super::_entities::users::Entity::find()
.filter(super::_entities::users::Column::ApiKey.eq(api_key))
.one(db)
.await?;
user.ok_or(ModelError::EntityNotFound)
}
async fn find_by_claims_key(db: &DatabaseConnection, claims_key: &str) -> ModelResult {
// Required by the trait even if this app only uses ApiToken.
// See jwt-auth.md if you also support JWTWithUser.
unimplemented!()
}
}
```
## 2. Add `ApiToken` to a handler
```rust
use loco_rs::prelude::*;
use loco_rs::controller::extractor::auth;
async fn current_by_api_key(
auth: auth::ApiToken,
State(_ctx): State,
) -> Result {
format::json(&auth.user)
}
pub fn routes() -> Routes {
Routes::new()
.prefix("user")
.add("/current-api", get(current_by_api_key))
}
```
`auth.user` is the fully loaded `T` (your `Authenticable` model) โ there's no separate claims struct to unwrap, unlike the JWT extractors.
## 3. Send the key as a Bearer token
**`ApiToken` always reads the key from the `Authorization: Bearer ` header, and only from there.** This is a hard-coded read (`extract_token_from_header`), independent of any `auth.jwt.location` setting โ the `location` config (`Bearer`/`Query`/`Cookie`, described in [Configure where Loco looks for the JWT](/docs/how-to/jwt-locations)) applies to the `JWT`/`JWTWithUser` extractors only, never to `ApiToken`.
```sh
curl --location '127.0.0.1:5150/api/user/current-api' \
--header 'Authorization: Bearer '
```
## Verify it works
- A valid, known key returns the user (200, with your handler's JSON body).
- An unknown key returns `401 Unauthorized` (the model lookup misses, mapped from `ModelError::EntityNotFound`).
- A database error while looking up the key returns `500 Internal Server Error`, and is logged via `tracing::error!`.
## Related
- [Protect a route with JWT](/docs/how-to/jwt-auth) โ the `Authenticable` contract in full, plus the `JWT` / `JWTWithUser` extractors.
- [Configure where Loco looks for the JWT](/docs/how-to/jwt-locations) โ applies to JWT auth, not to `ApiToken`.
- [Hash and verify passwords](/docs/how-to/hash-passwords) โ generate the random key value to store per user.
- [Feature flags reference](/docs/reference/feature-flags) โ `with-db` default and what it gates.
---
# Configure Where Loco Looks for the JWT
Source: https://loco.rs/docs/how-to/jwt-locations/
Goal: control where the `JWT` and `JWTWithUser` extractors look for the token in an incoming request โ the `Authorization` header (default), a query string parameter, a cookie, or a fallback list of several.
This setting is `auth.jwt.location` in your config file. It applies only to the JWT extractors (`auth::JWT`, `auth::JWTWithUser`); it has **no effect** on `auth::ApiToken`, which always reads a `Bearer` header regardless of this config โ see [Protect a route with an API key](/docs/how-to/api-key-auth#3-send-the-key-as-a-bearer-token).
If you haven't set up JWT auth yet, start with [Protect a route with JWT](/docs/how-to/jwt-auth); this page only covers the `location` key.
## Default: no configuration needed
If you omit `location` entirely, Loco reads the token from the `Authorization: Bearer ` header:
```yaml
auth:
jwt:
secret: "{{ get_env(name='JWT_SECRET') }}"
expiration: 604800
# location omitted => Bearer header
```
## Single location
Set `location` to one map with a `from:` tag. The three variants:
**Bearer header** (equivalent to the default, spelled out explicitly):
```yaml
auth:
jwt:
location:
from: Bearer
secret: "{{ get_env(name='JWT_SECRET') }}"
expiration: 604800
```
**Query parameter** โ reads a named query-string value, e.g. for links that can't carry custom headers (email verification links, WebSocket handshakes):
```yaml
auth:
jwt:
location:
from: Query
name: token
secret: "{{ get_env(name='JWT_SECRET') }}"
expiration: 604800
```
```sh
curl 'http://127.0.0.1:5150/api/protected?token='
```
**Cookie** โ reads a named cookie, e.g. for server-rendered apps using session-style cookies:
```yaml
auth:
jwt:
location:
from: Cookie
name: auth_token
secret: "{{ get_env(name='JWT_SECRET') }}"
expiration: 604800
```
```sh
curl 'http://127.0.0.1:5150/api/protected' --cookie 'auth_token='
```
## Multiple locations (tried in order)
Set `location` to a YAML list instead of a single map. Loco tries each location in the order listed and uses the first one that yields a token โ useful when, say, browser clients send a cookie but API clients send a Bearer header:
```yaml
auth:
jwt:
location:
- from: Cookie
name: auth_token
- from: Query
name: token
- from: Bearer
secret: "{{ get_env(name='JWT_SECRET') }}"
expiration: 604800
```
With this config, a request is accepted if it carries a valid token in the `auth_token` cookie, **or** (if that's absent) a `token` query parameter, **or** (if both are absent) an `Authorization: Bearer` header โ checked in that order. If none of the configured locations yields a token, the request is rejected with `401 Unauthorized`.
## Verify it works
For a `Multiple` config like the one above, confirm each location independently:
```sh
# via cookie
curl 'http://127.0.0.1:5150/api/protected' --cookie 'auth_token='
# via query parameter
curl 'http://127.0.0.1:5150/api/protected?token='
# via Bearer header
curl 'http://127.0.0.1:5150/api/protected' --header 'Authorization: Bearer '
```
Each should succeed independently; a request with no token in any of the three should return `401 Unauthorized`.
## Related
- [Protect a route with JWT](/docs/how-to/jwt-auth) โ the extractors this setting controls, and how to generate tokens.
- [Protect a route with an API key](/docs/how-to/api-key-auth) โ a separate, always-Bearer-header mechanism this setting does not affect.
- [Configuration reference](/docs/reference/configuration#auth) โ the full `auth.jwt` key table, including `JWTLocation`/`JWTLocationConfig` shapes.
---
# Hash and Verify Passwords
Source: https://loco.rs/docs/how-to/hash-passwords/
Goal: store passwords safely at registration, verify them at login, and generate random strings for things like reset tokens or API keys.
`loco_rs::hash` wraps Argon2id (`argon2` crate) for password hashing and a random alphanumeric generator for tokens. Unlike the JWT auth machinery, this module is **not** feature-gated โ it's always available, with no Cargo feature to enable.
## Hash a password on registration
```rust
use loco_rs::hash;
let hashed = hash::hash_password("the-users-plaintext-password")?;
// store `hashed` in your user row, never the plaintext
```
`hash_password` uses Argon2id with a fresh random salt (`OsRng`) per call, so hashing the same password twice produces two different strings โ that's expected; `verify_password` (below) handles the comparison. It returns Loco's `Result`; a hashing failure surfaces as `Error::Message` via `Error::msg`.
## Verify a password on login
```rust
use loco_rs::hash;
if hash::verify_password(&submitted_password, &user.password_hash) {
// credentials are valid
} else {
// reject the login
}
```
`verify_password` returns a plain `bool`, not a `Result` โ it returns `false` for both "wrong password" and "malformed/foreign hash string", so a bad row in the database fails closed rather than raising an error you'd have to remember to handle. It's `#[must_use]`, so the compiler will warn if you ignore the result.
## Generate random tokens
```rust
use loco_rs::hash;
let reset_token = hash::random_string(32);
let api_key = hash::random_string(40);
```
`random_string(length)` returns an alphanumeric string of exactly `length` characters, suitable for password-reset tokens or per-user API keys โ see [Protect a route with an API key](/docs/how-to/api-key-auth) for wiring a generated key into `Authenticable::find_by_api_key`.
## Verify it works
A quick round-trip in a test or a scratch binary:
```rust
let pass = "correct horse battery staple";
let hashed = hash::hash_password(pass)?;
assert!(hash::verify_password(pass, &hashed));
assert!(!hash::verify_password("wrong password", &hashed));
```
## Related
- [Protect a route with an API key](/docs/how-to/api-key-auth) โ use `random_string` to mint the key, `find_by_api_key` to look it up.
- [Protect a route with JWT](/docs/how-to/jwt-auth) โ issue a token once `verify_password` confirms the login.
- [Error model reference](/docs/reference/errors) โ how `Error::msg`/`Error::Message` fit into the crate's error type.
---
# Write request (controller) tests
Source: https://loco.rs/docs/how-to/request-tests/
Goal: call your app's HTTP endpoints from a test โ without a real bound port โ and assert on the response, using Loco's `request`/`boot_test` helpers over [axum-test](https://crates.io/crates/axum-test).
## 1. Enable the `testing` feature
Request tests need the `testing` Cargo feature (pulls in `axum-test`, `scraper`, and `tree-fs`). Add it to your `dev-dependencies` โ it's already there in apps generated by `loco new`:
```toml
[dev-dependencies]
loco-rs = { version = "*", features = ["testing"] }
serial_test = "*"
insta = { version = "*", features = ["redactions"] }
```
## 2. Write a request test with `request::()`
`request` boots your app in `Environment::Test` and gives you an in-memory `TestServer` plus the app's `AppContext` โ no DB is created:
```rust
use demo::app::App;
use loco_rs::testing::prelude::*;
use serial_test::serial;
#[tokio::test]
#[serial]
async fn can_get_notes() {
request::(|request, _ctx| async move {
let res = request.get("/api/notes/").await;
assert_eq!(res.status_code(), 200);
})
.await;
}
```
The callback receives `(TestServer, AppContext)` โ `request` (an [axum-test](https://crates.io/crates/axum-test) `TestServer`) drives HTTP calls (`.get`, `.post`, `.json(...)`, etc.), and `ctx` gives you the same `AppContext` your controllers see (DB connection, config, mailer, ...) โ see the [AppContext reference](/docs/reference/app-context).
Mark tests `#[serial]` (from the `serial_test` crate) whenever they share app-level or DB-level state with other tests, so they don't run concurrently against the same fixtures.
If your test needs a real, freshly created database (registering a user, then reading it back), use `request_with_create_db` instead โ see [DB model tests](/docs/how-to/model-tests) for the full story on DB-backed tests and cleanup.
## 3. Assert on the response
`request`/`response` come straight from axum-test โ the common assertions:
```rust
let response = request.post("/api/auth/login").json(&payload).await;
assert_eq!(response.status_code(), 200);
response.assert_json(&serde_json::json!({ "token": "..." }));
let body: LoginResponse = serde_json::from_str(&response.text()).unwrap();
```
For HTML/HTMX responses, parse `response.text()` with the [HTML selector assertions](/docs/how-to/fixtures-snapshots#html-assertions-with-select) (`assert_css_exists`, `assert_css_eq`, `select`, ...) instead of string-matching raw markup.
## 4. Customize the request with `RequestConfigBuilder`
`request` uses a default `RequestConfig` (no saved cookies, `default_content_type: "application/json"`). To change that โ e.g. to keep cookies across calls in the same test (cookie/session auth flows) โ build a custom config and use `request_with_config`:
```rust
use loco_rs::testing::prelude::*;
let config = RequestConfigBuilder::new()
.save_cookies(true)
.default_content_type("application/json")
.build();
request_with_config::(config, |request, _ctx| async move {
// cookies set by one call are sent on subsequent calls in this closure
request.post("/session/login").json(&payload).await;
let res = request.get("/session/me").await;
assert_eq!(res.status_code(), 200);
})
.await;
```
`RequestConfigBuilder` methods: `.save_cookies(bool)`, `.default_content_type(impl Into)`, `.default_scheme(impl Into)`, `.build()`.
> **Gotcha:** `RequestConfig::default_scheme` is *not* forwarded to axum-test's underlying `TestServerConfig` โ only `default_content_type` and `save_cookies` are. Setting `.default_scheme(...)` has no observable effect today; don't rely on it to force `https`.
## 5. Reach for `boot_test` directly when you don't need HTTP
`request` is a thin wrapper: it calls `boot_test::()` to boot the app, then wraps the router in a `TestServer`. If you don't need to go through HTTP at all โ e.g. you're testing a model or service function directly โ call `boot_test` yourself and skip the server:
```rust
use demo::app::App;
use loco_rs::testing::prelude::*;
#[tokio::test]
#[serial]
async fn test_something() {
let boot = boot_test::().await.expect("failed to boot test app");
// use boot.app_context, e.g. boot.app_context.db, boot.app_context.config, ...
}
```
`boot_test()` is **single-generic** โ it takes only your `Hooks` implementation (typically `App`), not a second `Migrator` type parameter. Signatures live in `src/testing/request.rs`:
| Function | Use when |
|---|---|
| `boot_test::()` | You need `AppContext` without a DB, and without HTTP. |
| `boot_test_with_create_db::()` | You need `AppContext` backed by a **fresh, throwaway database** (with-db only). See [DB model tests](/docs/how-to/model-tests). |
| `boot_test_unique_port::(port)` | You need the server actually bound to a TCP port (rare โ most tests should use `request`/`TestServer` instead). |
`request`/`boot_test` both boot with `Environment::Test`, so they read `config/test.yaml` โ see [configuration precedence](/docs/reference/configuration#loading-precedence) for how the config folder and environment name are resolved.
## Verify it
```sh
cargo test
```
A passing test prints the usual `test result: ok` from `cargo test`; a failing status-code or JSON assertion panics with axum-test's standard diff/message.
---
# Write DB model tests
Source: https://loco.rs/docs/how-to/model-tests/
Goal: exercise your Sea-ORM models against a real database in a test, with fixture data loaded and the database cleaned up afterwards โ with no manual teardown code.
This page covers the `with-db` half of the `testing` feature (`db.rs`): `boot_test`, `seed`, and the two DB-lifecycle strategies Loco supports. For HTTP-level tests, see [request tests](/docs/how-to/request-tests); for insta snapshots/redactions, see [Fixtures & snapshots](/docs/how-to/fixtures-snapshots).
## 1. Enable the features
```toml
[dev-dependencies]
loco-rs = { version = "*", features = ["testing"] }
serial_test = "*"
```
`with-db` (on by default) must also be enabled on your `loco-rs` dependency for `db.rs`'s helpers (`seed`, `boot_test_with_create_db`, `TestSupport`, ...) to be compiled in.
## 2. Pick a DB-lifecycle strategy
Loco supports two ways to run DB tests, and both are legitimate โ pick based on whether your test suite shares one test database or spins up a throwaway one per test.
### A. Shared test DB + truncate-before-boot (what `loco new` generates)
Point `config/test.yaml`'s `database.uri` at one test database (SQLite file or Postgres DB) and set `dangerously_truncate: true`. Every `boot_test::()` call then truncates the configured tables before the test body runs, so each test starts from a clean slate โ as long as tests run one at a time:
```yaml
# config/test.yaml
database:
uri: "sqlite://demo_test.sqlite?mode=rwc"
dangerously_truncate: true
```
```rust
use demo::app::App;
use loco_rs::testing::prelude::*;
use serial_test::serial;
#[tokio::test]
#[serial] // required: tests share one DB, so they must not interleave
async fn can_find_by_pid() {
let boot = boot_test::().await.expect("failed to boot test app");
seed::(&boot.app_context).await.expect("failed to seed");
let existing = Model::find_by_pid(&boot.app_context.db, "11111111-1111-1111-1111-111111111111").await;
assert!(existing.is_ok());
}
```
> โ ๏ธ `dangerously_truncate` clears data on every `boot_test` call โ never point it at a database you care about (never production). Control exactly which tables get truncated via the `truncate` hook on your `Hooks` impl (`async fn truncate(ctx: &AppContext) -> Result<()> { truncate_table(&ctx.db, users::Entity).await }`).
### B. Fresh, unique database per test (no `#[serial]` needed for DB isolation)
`boot_test_with_create_db::()` creates a brand-new, uniquely-named database before booting, and returns a `BootResultWrapper` instead of a plain `BootResult`:
```rust
use demo::app::App;
use loco_rs::testing::prelude::*;
#[tokio::test]
async fn can_register_in_isolated_db() {
let boot = boot_test_with_create_db::()
.await
.expect("failed to boot test app with a fresh db");
// BootResultWrapper derefs to BootResult, so `boot.app_context` works as usual
seed::(&boot.app_context).await.expect("failed to seed");
// ... exercise boot.app_context.db ...
} // <- `boot` drops here: the throwaway database is cleaned up automatically
```
How the unique DB is provisioned depends on the scheme of `config.database.uri` (dispatched in `init_test_db_creation`):
| URI scheme | Backing strategy |
|---|---|
| `postgres://` | Creates a new database named `_loco_test_{10-char-random}_{unix-timestamp}` on the same Postgres server. |
| `sqlite://` | Backs the DB with a `tree-fs` temporary file (`test.sqlite` in a fresh temp dir). |
| anything else | No-op passthrough (`Any`) โ used as-is, no isolation. |
### `BootResultWrapper` auto-cleans on `Drop`
`BootResultWrapper` (only present with `with-db`) wraps a `BootResult` plus the `Box` that created the throwaway DB:
- It `Deref`s to `BootResult`, so `boot.app_context`, `boot.router`, etc. all work exactly like the plain `boot_test` return value.
- Its `Drop` implementation calls `test_db.cleanup_db()` โ for Postgres this `DROP DATABASE`s the throwaway DB on a spawned blocking task; for SQLite it removes the temp directory. This runs automatically at the end of the test function's scope, with no explicit teardown call needed.
> **Caveat:** if the test process is killed mid-run (e.g. `Ctrl+C`), `Drop` never fires and the throwaway database/schema is left behind โ you'll need to remove it manually (`DROP DATABASE _loco_test_...` for Postgres, or delete the leftover temp dir for SQLite).
## 3. Seed fixture data
`seed::(&ctx)` loads fixtures from the hardcoded `src/fixtures` folder by delegating to your `Hooks::seed` implementation:
```rust
seed::(&boot.app_context).await.expect("failed to seed");
```
This is the same fixture format used by `cargo loco db seed` โ see the [`db seed` CLI reference](/docs/reference/cli#2-2-db-subcommands) for the on-disk layout and the `--from ` flag if you keep fixtures elsewhere.
## 4. Generated model tests already do this for you
`cargo loco generate model ...` (and `scaffold`) scaffold a starter test in `tests/models/.rs` that already follows pattern A above โ `boot_test::()`, `seed::()`, a local `configure_insta!()` macro for snapshot naming, and a commented-out `assert_debug_snapshot!` call for you to fill in. See [Using generators](/docs/how-to/use-generators).
## Verify it
```sh
cargo test
```
For pattern A, run the whole suite (or at least the model tests) together so `#[serial]` can do its job โ running a single test in isolation (`cargo test can_find_by_pid`) also works, but mixing serial and non-serial DB tests in the same binary without `#[serial]` on all of them will produce flaky failures from concurrent truncation.
---
# Snapshot tests with fixtures and redactions
Source: https://loco.rs/docs/how-to/fixtures-snapshots/
Goal: snapshot a model, request response, or rendered HTML with [insta](https://crates.io/crates/insta), without the snapshot flapping every run because of a fresh UUID, timestamp, or password hash.
## 1. Enable insta
```toml
[dev-dependencies]
loco-rs = { version = "*", features = ["testing"] }
insta = { version = "*", features = ["redactions"] }
```
Loco does **not** re-export `insta` โ it's a regular dev-dependency of your app. What Loco provides is the *filter tables* (`src/testing/redaction.rs`) you feed into insta's `filters` setting.
## 2. Snapshot a value with `assert_debug_snapshot!`
```rust
use insta::assert_debug_snapshot;
use loco_rs::testing::prelude::*;
let boot = boot_test::().await.unwrap();
let user = users::Model::find_by_email(&boot.app_context.db, "user1@example.com").await;
assert_debug_snapshot!(user);
```
The first run writes a `.snap` file under a `snapshots/` folder next to your test; review and accept it with `cargo insta review` (or by hand), then future runs diff against it.
## 3. Redact dynamic fields before snapshotting
A freshly created user has a random UUID `pid`, an incrementing `id`, a bcrypt `password` hash, and `created_at`/`updated_at` timestamps โ all of which change every run and would break the snapshot. Wrap the assertion in `insta::with_settings!` with one of Loco's cleanup filter sets:
```rust
use insta::{assert_debug_snapshot, with_settings};
use loco_rs::testing::prelude::*;
let res = Model::create_with_password(&boot.app_context.db, ¶ms).await;
with_settings!({
filters => cleanup_user_model()
}, {
assert_debug_snapshot!(res);
});
```
| Filter fn | What it redacts | Placeholder |
|---|---|---|
| `cleanup_user_model()` | UUIDs/PIDs, bcrypt `password: "..."` hashes, JWT-shaped tokens, ISO-8601 timestamps (with and without timezone), `id: ` | `PID`, `"PASSWORD"`, `TOKEN`, `DATE`, `id: ID` |
| `cleanup_email()` | Mailer message identifiers, RFC-2822 dates, UUID-shaped random ids | `IDENTIFIER`, `DATE`, `RANDOM_ID` |
Both combine a base table with the shared date filter (`get_cleanup_date()`); `cleanup_user_model()` additionally folds in `get_cleanup_model()` (the `id: N` โ `id: ID` rule). If you need a custom combination, the individual tables (`get_cleanup_user_model()`, `get_cleanup_date()`, `get_cleanup_model()`, `get_cleanup_mail()`) are public too โ build your own `Vec<(&str, &str)>` filter list from them.
Use `cleanup_email()` the same way when snapshotting mailer deliveries (`ctx.mailer.unwrap().deliveries()`):
```rust
with_settings!({
filters => cleanup_email()
}, {
assert_debug_snapshot!(ctx.mailer.unwrap().deliveries());
});
```
## 4. Give each test file its own snapshot namespace
Snapshot filenames are derived from the test function name โ across test files that's usually enough, but if two files both have a test with the same name, or you simply want per-file namespacing, define a small local macro (this is *not* a Loco API โ it's plain insta, one line of glue code repeated per test file):
```rust
macro_rules! configure_insta {
($($expr:expr),*) => {
let mut settings = insta::Settings::clone_current();
settings.set_prepend_module_to_snapshot(false);
settings.set_snapshot_suffix("users"); // suffixes every snapshot in this file
let _guard = settings.bind_to_scope();
};
}
#[tokio::test]
#[serial]
async fn can_find_by_pid() {
configure_insta!();
// ...
}
```
`cargo loco generate model`/`scaffold` already scaffold this macro (without the suffix line) into the generated `tests/models/.rs` โ see [Using generators](/docs/how-to/use-generators).
## HTML assertions with `select()`
For server-rendered (HTML/HTMX) views, don't snapshot or string-match raw markup โ parse it with Loco's `scraper`-backed selector helpers (`src/testing/selector.rs`) instead. All of them panic with a descriptive message on failure, so they read like normal assertions:
```rust
use loco_rs::testing::prelude::*;
let html = response.text();
assert_css_exists(&html, ".flash-message");
assert_css_not_exists(&html, ".error");
assert_css_eq(&html, "h1.title", "Welcome to Loco");
assert_link(&html, "a.home", "/");
assert_attribute_exists(&html, "form", "action");
assert_attribute_eq(&html, "input[name=email]", "type", "email");
assert_count(&html, "ul#posts li", 3);
assert_css_eq_list(&html, "ul#posts li", &["Post 1", "Post 2", "Post 3"]);
```
`select(html, selector) -> Vec` returns the outer HTML of every match, for cases where you want to snapshot a fragment instead of asserting on it directly (combine it with `assert_debug_snapshot!` and the redaction filters above if the fragment contains dynamic data):
```rust
let items = select(&html, ".item");
assert_debug_snapshot!(items);
```
## Verify it
```sh
cargo test
```
To review/update snapshots interactively after intentional output changes, install and run [`cargo-insta`](https://crates.io/crates/cargo-insta):
```sh
cargo install cargo-insta
cargo insta review
```
---
# Generate code with cargo loco generate
Source: https://loco.rs/docs/how-to/use-generators/
Goal: scaffold application code (models, migrations, controllers, workers, mailers, deployment files, ...) from Loco's built-in templates instead of hand-writing boilerplate.
## 1. Know the constraint: debug builds only
`cargo loco generate` (alias `g`) is compiled only in debug builds โ `#[cfg(debug_assertions)]` gates the whole subcommand (`src/cli.rs`). It's available whenever you run your app the normal dev way (`cargo run`, `cargo loco start`, `cargo test`), but it is **not present in a `--release` binary**. `model`/`migration`/`scaffold` are additionally gated on the `with-db` feature (on by default).
## 2. Run a generator
```sh
# an empty model (entity + migration + test)
cargo loco generate model posts
# a model with typed fields
cargo loco generate model posts title:string! content:text
# a full CRUD resource: entity + migration + controller + routes + tests
cargo loco generate scaffold posts title:string! user:references --api
# controller only, no model/migration
cargo loco generate controller posts index show --api
# non-DB generators
cargo loco generate task cleanup_old_sessions
cargo loco generate worker send_digest
cargo loco generate mailer welcome
cargo loco generate scheduler
cargo loco generate data countries
cargo loco generate deployment docker
```
Every generator writes files relative to your project root and prints what it created (or, for `model`/`migration`/`scaffold`, injects a `mod` line into the relevant `mod.rs`).
## 3. Pick the right kind
| Kind | Needs `with-db` | What you get |
|---|---|---|
| `model` | yes | Sea-ORM entity + model file + migration + a starter test in `tests/models/` |
| `migration` | yes | Standalone migration file (add/remove columns, join tables, or an empty stub โ inferred from the name) |
| `scaffold` | yes | Full CRUD: entity, migration, controller, routes, views (`--html`/`--htmx`), tests |
| `controller` | no | Controller + routes + tests, no model |
| `task` | no | One-off/CLI task stub, registered automatically |
| `scheduler` | no | `config/scheduler.yaml` starter |
| `worker` | no | Background worker stub, registered automatically |
| `mailer` | no | Mailer struct + embedded `subject`/`html`/`text` templates |
| `data` | no | Data-loader struct + a static `data//data.json` |
| `deployment` | no | `docker` or `nginx` deployment files |
| `override` | no | Copies a built-in template locally so you can edit it โ see [Override built-in templates](/docs/how-to/override-templates) |
`scaffold` and `controller` both require **exactly one** of `--api`, `--html`, or `--htmx` โ there's no default; omitting all of them is a hard CLI error.
This is a summary for orientation only โ the exhaustive, verified dictionary of every kind, every flag, and migration-name inference rules is the [Generators & field types reference](/docs/reference/generators); the raw CLI flag shapes are also in the [CLI reference](/docs/reference/cli#2-4-generate-subcommands).
## 4. Use the field-type mini-language
`model`, `migration`, and `scaffold` all take `name:type` pairs after the resource name. The full table of ~50 base types (with their `!`/`^` suffix variants, arities, and Rust types) lives in the [field-type mini-language reference](/docs/reference/generators#field-type-mini-language) โ check it before guessing a type name. A few load-bearing facts to keep in mind while typing field lists:
- No suffix = nullable (`Option`); `!` = required; `^` = unique (implies required). Not every type has a `^` form (`bool`, `tstz`, `json` don't).
- **`int` is `i64`/`BIGINT`** in Loco 1.0 (it was `i32` before) โ `big_int` is just an alias. Use `small_int`/`small_unsigned` if you need a 16-bit column.
- `name:references` adds a required belongs-to foreign key (`name_id`); `name:references?` makes it nullable; `name:references:custom_id` (optionally with `?`) picks the FK column name explicitly.
- `array` types take the element type as a second colon segment: `tags:array:string`, `scores:array!:int`.
```sh
cargo loco generate model movies long_title:string director:references award:references:prize_id
```
## 5. Apply generated migrations
Generating a `migration` (standalone or via `scaffold`/`model`) only writes the file โ it doesn't touch the database. Apply it and regenerate entities:
```sh
cargo loco db migrate && cargo loco db entities
```
## Verify it
```sh
cargo build # generators need a debug build to even be available
cargo loco generate model posts title:string!
cargo loco db migrate
cargo test
```
A successful generator run prints the list of files it created/modified; `cargo build` (or `cargo check`) then confirms the generated code compiles, and `cargo test` runs the starter test the generator scaffolded for you.
---
# Override a built-in generator template
Source: https://loco.rs/docs/how-to/override-templates/
Goal: change what `cargo loco generate ` produces โ e.g. add a header to every generated controller, or tweak the HTMX scaffold views โ without forking Loco.
Every generator kind covered in [Using generators](/docs/how-to/use-generators) is driven by `.t` template files baked into the `loco-gen` crate. `cargo loco generate override` copies one of those templates (or a whole folder of them) into your app's own `.loco-templates/` directory; from then on, generation runs read your copy instead of the built-in one.
## 1. List what's overridable
Run `override` with no path to see every template, grouped by generator kind:
```sh
cargo loco generate override
```
This prints a tree of all available templates plus example invocations โ it ignores `--info` (a bare invocation always lists).
## 2. Preview a specific file or folder with `--info`
Before copying, check what's under a given path (without actually copying anything) by adding `--info`:
```sh
cargo loco generate override scaffold/htmx --info
```
## 3. Copy one file, a folder, or everything
```sh
# override a single template file
cargo loco generate override scaffold/api/controller.t
# override every template under a folder (e.g. the whole htmx scaffold)
cargo loco generate override scaffold/htmx
# override every template in the project
cargo loco generate override .
```
Copied files land under `.loco-templates/` (mirroring the built-in path, e.g. `.loco-templates/scaffold/api/controller.t`) and the command prints each file it copied. If nothing matched the given path, it tells you no templates were found instead of silently no-op'ing.
## 4. Edit your copy
Open the copied `.t` file under `.loco-templates/` and edit it like any other [Tera](https://keats.github.io/tera/) template โ it uses the same variables (`name`, `pkg_name`, casing filters like `snake_case`/`pascal_case`, etc.) as the built-in one you copied it from. The next time you run the matching `cargo loco generate ...`, your local copy is used instead of the built-in template.
## 5. Revert to the built-in template
Delete your local copy โ Loco always prefers `.loco-templates/` when it exists, and falls back to the built-in template the moment it doesn't:
```sh
rm .loco-templates/scaffold/api/controller.t
```
## Verify it
```sh
cargo loco generate override scaffold/api/controller.t
# edit .loco-templates/scaffold/api/controller.t
cargo loco generate controller widgets index --api
```
Confirm the generated `src/controllers/widgets.rs` reflects your edited template (e.g. the header/comment you added), not the stock output.
See the [Generators & field types reference](/docs/reference/generators#override) for the exact `override` CLI shape, and the [CLI reference](/docs/reference/cli#2-4-generate-subcommands) for how `override` fits among the other `generate` subcommands.
---
# Diagnose your app with cargo loco doctor
Source: https://loco.rs/docs/how-to/run-doctor/
Goal: quickly check whether your app's environment (database, queue, tooling, dependency versions) is set up correctly, both locally and in CI.
## 1. Run it
```sh
cargo loco doctor
```
Each check prints one line with a status icon, and a description on the line(s) below when there's something actionable to say:
```
โ DB connection: success
โ queue connection: success
โ SeaORM CLI was not found
To fix, run:
$ cargo install sea-orm-cli
```
- โ = `Ok`
- โ = `NotOk`
- โ ๏ธ = `NotConfigure` (the resource isn't configured โ not necessarily an error)
If **any** check comes back `NotOk`, the process exits with a non-zero status โ wire `cargo loco doctor` into CI to fail the build on a broken DB/queue connection or an outdated dependency.
## 2. What gets checked
`doctor` always runs:
| Check | Condition | What it does |
|---|---|---|
| Database | `with-db` enabled | Connects, pings, and verifies access using `config.database`. |
| Queue | `workers.mode` is `BackgroundQueue` | Creates the queue provider and pings it; reports `NotConfigure` if no queue is set up. |
| Initializer checks | any registered `Initializer` implements `check()` | Runs each one, prefixing its message with `Initializer {name}: `. |
...and, **only when not run with `--production`**, three more:
| Check | What it does |
|---|---|
| Deps | Reads `Cargo.lock` and flags any "blessed" dependency below its minimum version. |
| SeaOrmCLI | Runs `sea-orm-cli --version` and checks it against the minimum. |
| PublishedLocoVersion | Compares your `loco-rs` version against what's published on crates.io. |
Current blessed minimum versions: `tokio 1.33.0`, `sea-orm 2.0.0-rc`, `validator 0.20.0`, `axum 0.8.1`. (`sea-orm`/`sea-orm-cli` are still pinned to the `2.0.0-rc` line as of this writing โ expect that floor to move to `2.0.0` once Sea-ORM ships stable.)
## 3. Skip dev-only checks in production with `--production`
Deployed environments typically don't have `sea-orm-cli` installed, may not have network access to crates.io, and don't need a "you're behind on X" nag on every boot. `--production` (short `-p`) skips the three dev-only checks above and only runs Database/Queue/Initializer checks:
```sh
cargo loco doctor --production
```
## 4. Inspect resolved configuration with `--config`
`--config` (short `-c`) bypasses checks entirely and instead dumps the fully-resolved `Config` (as YAML) plus the active environment name โ useful when you're not sure which config file/environment actually got loaded:
```sh
cargo loco doctor --config
```
```
# ...your full resolved config, dumped as YAML...
Environment: development
```
This complements the [configuration reference](/docs/reference/configuration#loading-precedence) โ if a setting doesn't look like what you expect, `doctor --config` shows you the config *after* file precedence, environment resolution, and Tera templating have all been applied, not just what's on disk.
## 5. Add your own checks
If you ship a custom [`Initializer`](/docs/how-to/add-middleware), implement its `check` method and `doctor` will pick it up automatically โ no extra wiring needed:
```rust
use loco_rs::doctor::{Check, CheckStatus};
async fn check(&self, app_context: &AppContext) -> loco_rs::Result