# 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.

New to Loco?

Browse the docs

--- # 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> { // return None to opt out, or Some(Check { .. }) to report a result Ok(Some(Check { status: CheckStatus::Ok, message: "connected".to_string(), description: None, })) } ``` ## Verify it ```sh cargo loco doctor echo $? # 0 if every check passed, non-zero if any check is NotOk cargo loco doctor --production cargo loco doctor --config ``` See the [CLI reference](/docs/reference/cli#2-1-top-level-subcommands) for the exact flag list alongside every other `cargo loco` subcommand. --- # Configuration Source: https://loco.rs/docs/reference/configuration/ This page is a dictionary of every key Loco's configuration loader understands. It documents `struct Config` (`src/config/mod.rs`) and its sub-structs (`src/config/{auth,server,database,logger,mailer,queue,cache}.rs`). For a narrative walkthrough of settings and environments, see the-app/your-project. ## Loading & precedence - Default config folder: `config/` (`Config::new`, `src/config/mod.rs:128-131`). Override with the `LOCO_CONFIG_FOLDER` env var (read by `Environment::load`, `src/environment.rs:59-64`). - `Config::from_folder(env, path)` (`src/config/mod.rs:153-174`) resolves the file to load with this precedence โ€” **first file that exists wins**: 1. `{path}/{env}.local.yaml` 2. `{path}/{env}.yaml` If neither exists, loading fails with `Error::Message("no configuration file found in folder: ...")`. - Before parsing, the entire YAML file is rendered as a **Tera template** (`src/config/mod.rs:170`, `src/tera.rs:5-8`, `Tera::one_off(.., autoescape=false)`). `get_env(name=.., default=..)` used throughout the shipped config files is Tera's own built-in function โ€” it is **not** a Loco-registered function. - Parse failures raise `Error::YAMLFile(err, path)` (`src/config/mod.rs:172-173`). - `Config` implements `Display` by dumping itself back to YAML (`src/config/mod.rs:191-196`). - `Config::get_jwt_config(&self) -> Result<&JWT>` (`src/config/mod.rs:180-188`) returns an error if `auth` or `auth.jwt` is absent. ### Environment resolution `environment::resolve_from_env()` (`src/environment.rs:32-38`) picks the active environment name with this precedence: 1. `LOCO_ENV` 2. `RAILS_ENV` 3. `NODE_ENV` 4. fallback: `"development"` (`DEFAULT_ENVIRONMENT`, `src/environment.rs:21`) ## Top-level `Config` `struct Config` โ€” `src/config/mod.rs:62-92`. Every field is a top-level YAML key. | Key | Type | Required? | Notes | |---|---|---|---| | `logger` | [`Logger`](#logger) | required | `mod.rs:64` | | `server` | [`Server`](#server) | required | `mod.rs:65` | | `database` | [`Database`](#database) | required, only when the `with-db` feature is enabled | `#[cfg(feature = "with-db")]`, `mod.rs:66-67` | | `cache` | [`CacheConfig`](#cache) | optional โ€” `#[serde(default)]`, defaults to `Null` | `mod.rs:68-69` | | `queue` | `Option<`[`QueueConfig`](#queue)`>` | optional | `mod.rs:70` | | `auth` | `Option<`[`Auth`](#auth)`>` | optional | `mod.rs:71` | | `workers` | [`Workers`](#workers) | optional โ€” `#[serde(default)]` | `mod.rs:72-73` | | `mailer` | `Option<`[`Mailer`](#mailer)`>` | optional | `mod.rs:74` | | `initializers` | `Option` (= `Option>`) | optional | `mod.rs:75`, type alias at `mod.rs:106` | | `settings` | `Option` | optional โ€” `#[serde(default)]` | `mod.rs:88-89`; free-form app settings, surfaced at `ctx.config.settings` | | `scheduler` | `Option` | optional | `mod.rs:91`; struct owned by the scheduler area, not detailed here | ## `auth` `struct Auth` โ€” `src/config/auth.rs:13-17`. ```yaml auth: jwt: location: # optional, default: Bearer from: Bearer # or: {from: Query, name: } / {from: Cookie, name: } secret: # required โ€” must be valid base64 expiration: 604800 # required, u64 seconds (e.g. 7 days) ``` | Key | Type | Required? | Notes | |---|---|---|---| | `auth.jwt` | `Option` | optional | `auth.rs:16` | | `auth.jwt.location` | `Option` | optional, default: `Bearer` (resolved by `get_jwt_locations`, `src/controller/extractor/auth.rs:181-189`) | `auth.rs:24` | | `auth.jwt.secret` | `String` | required | `auth.rs:26`. **Must be valid base64** โ€” the JWT signer/verifier call `EncodingKey`/`DecodingKey::from_base64_secret` (`src/auth/jwt.rs:83,108`); a non-base64 string fails at token generation/validation time, not at config load time | | `auth.jwt.expiration` | `u64` (seconds) | required | `auth.rs:28` | `JWTLocationConfig` (`#[serde(untagged)]`, `auth.rs:47-54`) accepts either form: - `Single(JWTLocation)` โ€” a single location map - `Multiple(Vec)` โ€” a YAML list of location maps, tried in order until one yields a token `JWTLocation` (`#[serde(tag = "from")]`, `auth.rs:35-44`): | Variant | YAML shape | Notes | |---|---|---| | `Bearer` | `from: Bearer` | reads the `Authorization: Bearer ` header | | `Query { name }` | `from: Query`
`name: ` | reads a query-string parameter | | `Cookie { name }` | `from: Cookie`
`name: ` | reads a cookie | Related, not YAML-configurable: default signing algorithm is **HS512** (`JWT_ALGORITHM`, `src/auth/jwt.rs:13`), overridable in code via `JWT::algorithm(..)` (`jwt.rs:51`), not via config. ## `server` `struct Server` โ€” `src/config/server.rs:29-45`. ```yaml server: binding: localhost # optional, default "localhost" port: 5150 # required host: http://localhost # required ident: # optional โ€” overrides the `Server` response header middlewares: {} # optional, default {} โ€” see the middleware catalog reference ``` | Key | Type | Required? | Notes | |---|---|---|---| | `server.binding` | `String` | optional โ€” `#[serde(default = "default_binding")]` โ†’ `"localhost"` | `server.rs:33-34,47-49` | | `server.port` | `i32` | required | `server.rs:36` | | `server.host` | `String` | required | `server.rs:38` | | `server.ident` | `Option` | optional | `server.rs:40`. When set, replaces the `Server` header value | | `server.middlewares` | `middleware::Config` | optional โ€” `#[serde(default)]` | `server.rs:44`. Struct is owned by the middleware area; see the middleware catalog reference for every middleware's keys | `Server::full_url() -> String` returns `"{host}:{port}"` (`server.rs:52-55`). ## `workers` `struct Workers` โ€” `src/config/server.rs:64-68`. ```yaml workers: mode: BackgroundQueue # optional, default BackgroundQueue ``` | Key | Type | Required? | Notes | |---|---|---|---| | `workers.mode` | `WorkerMode` | optional โ€” `Workers` derives `Default` | `server.rs:67` | `WorkerMode` (`server.rs:71-83`): | Variant | Default? | Notes | |---|---|---| | `BackgroundQueue` | yes | Workers run asynchronously via a queue backend. **Requires a configured `queue`** | | `ForegroundBlocking` | no | Workers run in-process and block the caller until the task completes | | `BackgroundAsync` | no | Workers run asynchronously in-process (async task, no external queue) | ## `database` `struct Database` โ€” `src/config/database.rs:22-84`. Only present/required when the `with-db` feature is enabled. ```yaml database: uri: postgres://root:12341234@localhost:5432/myapp_development # required enable_logging: true # required โ€” SQLx statement logging min_connections: 1 # required max_connections: 1 # required connect_timeout: 500 # required, milliseconds idle_timeout: 500 # required, milliseconds acquire_timeout: 500 # optional, milliseconds auto_migrate: true # optional, default false dangerously_truncate: false# optional, default false dangerously_recreate: false# optional, default false run_on_start: # optional ``` | Key | Type | Required? | Notes | |---|---|---|---| | `database.uri` | `String` | required | `database.rs:28`. E.g. `postgres://...` or `sqlite://db.sqlite?mode=rwc` | | `database.enable_logging` | `bool` | required | `database.rs:31` โ€” enables SQLx statement logging | | `database.min_connections` | `u32` | required | `database.rs:34` | | `database.max_connections` | `u32` | required | `database.rs:37` | | `database.connect_timeout` | `u64` (ms) | required | `database.rs:40` | | `database.idle_timeout` | `u64` (ms) | required | `database.rs:43` | | `database.acquire_timeout` | `Option` (ms) | optional | `database.rs:46` | | `database.auto_migrate` | `bool` | optional โ€” `#[serde(default)]` | `database.rs:51-52`. Runs pending migrations on boot; recommended for development, discouraged in production | | `database.dangerously_truncate` | `bool` | optional โ€” `#[serde(default)]` | `database.rs:56-57`. Deletes row data on boot; typically used in `test` | | `database.dangerously_recreate` | `bool` | optional โ€” `#[serde(default)]` | `database.rs:63-64`. Drops and recreates schema on boot | | `database.run_on_start` | `Option` | optional | `database.rs:83`. Arbitrary SQL/PRAGMA statements executed once the connection is established. For SQLite, if unset, Loco applies its own PRAGMA defaults (`foreign_keys=ON`, `journal_mode=WAL`, `synchronous=NORMAL`, `mmap_size=134217728`, `journal_size_limit=67108864`, `cache_size=2000`, `busy_timeout=5000`) | Note: the `db_min_conn()=1` / `db_max_conn()=20` / `db_connect_timeout()=500` / `db_idle_timeout()=500` helper functions in `database.rs:86-100` are **not** defaults for `Database` itself (whose numeric fields have no `#[serde(default)]` and are required) โ€” they are reused as the `#[serde(default = ...)]` values for the Postgres/Sqlite [`queue`](#queue) configs below. ## `logger` `struct Logger` โ€” `src/config/logger.rs:21-49`. ```yaml logger: enable: true # required pretty_backtrace: false # optional, default false level: debug # required โ€” off|trace|debug|info|warn|error format: compact # required โ€” compact|pretty|json override_filter: # optional โ€” EnvFilter directive string file_appender: # optional enable: true # required within block non_blocking: false # optional, default false, within block level: info # required within block format: json # required within block rotation: daily # required within block โ€” minutely|hourly|daily|never dir: ./logs # optional, default "./logs" filename_prefix: # optional filename_suffix: # optional max_log_files: 7 # required within block ``` | Key | Type | Required? | Notes | |---|---|---|---| | `logger.enable` | `bool` | required | `logger.rs:24` | | `logger.pretty_backtrace` | `bool` | optional โ€” `#[serde(default)]` | `logger.rs:28-29`. When `true`, forces nicely-formatted backtraces (development-friendly); turn off in performance-sensitive production deployments | | `logger.level` | `logger::LogLevel` | required | `logger.rs:34`. Variants: `off`, `trace`, `debug`, `info` (enum's own `#[default]`, but the field itself has no `#[serde(default)]` so it must be present in YAML), `warn`, `error` (`src/logger.rs:15-36`) | | `logger.format` | `logger::Format` | required | `logger.rs:39`. Variants: `compact` (`#[default]`), `pretty`, `json` (`src/logger.rs:39-48`) | | `logger.override_filter` | `Option` | optional | `logger.rs:45`. A `tracing-subscriber` `EnvFilter` directive string | | `logger.file_appender` | `Option` | optional | `logger.rs:48` | | `logger.file_appender.enable` | `bool` | required within block | `logger.rs:54` | | `logger.file_appender.non_blocking` | `bool` | optional โ€” `#[serde(default)]` | `logger.rs:57-58` | | `logger.file_appender.level` | `logger::LogLevel` | required within block | `logger.rs:63` | | `logger.file_appender.format` | `logger::Format` | required within block | `logger.rs:68` | | `logger.file_appender.rotation` | `logger::Rotation` | required within block | `logger.rs:71`. Variants: `minutely`, `hourly` (`#[default]`), `daily`, `never` (`src/logger.rs:51-62`) | | `logger.file_appender.dir` | `Option` | optional, defaults to `"./logs"` when unset (applied at file-appender init, `src/logger.rs:113`) | `logger.rs:76` | | `logger.file_appender.filename_prefix` | `Option` | optional | `logger.rs:79` | | `logger.file_appender.filename_suffix` | `Option` | optional | `logger.rs:82` | | `logger.file_appender.max_log_files` | `usize` | required within block | `logger.rs:85` | ## `mailer` `struct Mailer` โ€” `src/config/mailer.rs:29-35`. ```yaml # development: capture instead of sending mailer: stub: false # optional, default false smtp: enable: true # required host: localhost # required port: 1025 # required secure: false # required โ€” legacy shorthand, see below # production: implicit TLS on port 465 (SMTPS) 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: # optional โ€” EHLO client id ``` | Key | Type | Required? | Notes | |---|---|---|---| | `mailer.stub` | `bool` | optional โ€” `#[serde(default)]` | `mailer.rs:33-34`. When `true`, mail is captured rather than sent | | `mailer.smtp` | `Option` | optional | `mailer.rs:31` | | `mailer.smtp.enable` | `bool` | required | `mailer.rs:55` | | `mailer.smtp.host` | `String` | required | `mailer.rs:57` | | `mailer.smtp.port` | `u16` | required | `mailer.rs:59` | | `mailer.smtp.secure` | `bool` | required | `mailer.rs:65`. Legacy shorthand: `true` selects `STARTTLS` (port 587), `false` selects cleartext | | `mailer.smtp.tls` | `Option` | optional โ€” `#[serde(default)]` | `mailer.rs:69`. **When set, overrides `secure`.** Variants (`#[serde(rename_all = "lowercase")]`, `mailer.rs:38-50`): `starttls` (opportunistic TLS, port 587 โ€” what `secure: true` selects), `implicit` (TLS from the first byte, SMTPS, port 465 โ€” required by providers that only accept implicit TLS), `none` (cleartext) | | `mailer.smtp.auth` | `Option` | optional | `mailer.rs:71` | | `mailer.smtp.auth.user` | `String` | required within block | `mailer.rs:93` | | `mailer.smtp.auth.password` | `String` | required within block | `mailer.rs:95` | | `mailer.smtp.hello_name` | `Option` | optional | `mailer.rs:73`. `EHLO` client identifier, sent instead of the hostname | Effective TLS mode is resolved by `SmtpMailer::tls_mode()` (`mailer.rs:76-87`): if `tls` is set, it wins outright; otherwise `secure: true` โ†’ `Starttls`, `secure: false` โ†’ `None`. ## `queue` `enum QueueConfig` โ€” `src/config/queue.rs:5-14`, `#[serde(tag = "kind")]` with variants `Redis`, `Postgres`, `Sqlite`. ```yaml # kind: Redis queue: kind: Redis uri: redis://127.0.0.1 # required dangerously_flush: false # optional, default false queues: [high, low] # optional โ€” priority order, first = most important num_workers: 2 # optional, default 2 # reaper: # optional, disabled by default (opt-in) # age_minutes: 10 # requeue jobs stuck in `processing` for longer than this # interval_seconds: 60 # optional, default 60 โ€” how often to sweep # kind: Postgres queue: kind: Postgres uri: postgres://... # required dangerously_flush: false # optional, default false enable_logging: false # optional, default false max_connections: 20 # optional, default 20 min_connections: 1 # optional, default 1 connect_timeout: 500 # optional, default 500 (ms) idle_timeout: 500 # optional, default 500 (ms) poll_interval_sec: 1 # optional, default 1 num_workers: 2 # optional, default 2 # reaper: # optional, disabled by default (opt-in) # age_minutes: 10 # requeue jobs stuck in `processing` for longer than this # interval_seconds: 60 # optional, default 60 โ€” how often to sweep # kind: Sqlite (same shape as Postgres) queue: kind: Sqlite uri: sqlite://... poll_interval_sec: 1 # optional, default 1 (own default fn) # ...remaining keys identical to Postgres, including the optional `reaper` ``` | Key | Type | Required? | Notes | |---|---|---|---| | `queue.kind` | tag: `Redis` \| `Postgres` \| `Sqlite` | required | `queue.rs:6-14` | | **Redis** (`RedisQueueConfig`, `queue.rs:16-28`) | | | | | `queue.uri` | `String` | required | `queue.rs:18` | | `queue.dangerously_flush` | `bool` | optional โ€” `#[serde(default)]` | `queue.rs:20` | | `queue.queues` | `Option>` | optional | `queue.rs:24`. Declares named priority queues; first entry is most important | | `queue.num_workers` | `u32` | optional, default `2` (`num_workers()`) | `queue.rs:26-27` | | `queue.reaper` | `Option` | optional, default `None` (disabled) | `queue.rs:29-31`. See below | | **Postgres** (`PostgresQueueConfig`, `queue.rs:30-57`) | | | | | `queue.uri` | `String` | required | `queue.rs:32` | | `queue.dangerously_flush` | `bool` | optional, default `false` | `queue.rs:34-35` | | `queue.enable_logging` | `bool` | optional, default `false` | `queue.rs:37-38` | | `queue.max_connections` | `u32` | optional, default `20` (`db_max_conn()`) | `queue.rs:40-41` | | `queue.min_connections` | `u32` | optional, default `1` (`db_min_conn()`) | `queue.rs:43-44` | | `queue.connect_timeout` | `u64` (ms) | optional, default `500` (`db_connect_timeout()`) | `queue.rs:46-47` | | `queue.idle_timeout` | `u64` (ms) | optional, default `500` (`db_idle_timeout()`) | `queue.rs:49-50` | | `queue.poll_interval_sec` | `u32` | optional, default `1` (`pgq_poll_interval()`) | `queue.rs:52-53` | | `queue.num_workers` | `u32` | optional, default `2` | `queue.rs:55-56` | | `queue.reaper` | `Option` | optional, default `None` (disabled) | `queue.rs:57-59`. See below | | **Sqlite** (`SqliteQueueConfig`, `queue.rs:59-86`) | | | | | โ€” | identical fields to Postgres, including `queue.reaper` | | `poll_interval_sec` defaults via its own `sqlt_poll_interval()=1` (`queue.rs:81-82,92-94`); all other defaults are shared with Postgres via the same `db_*` helper functions | | **`ReaperConfig`** (`queue.rs`, all three backends) | | | Opt-in visibility-timeout reaper: when set, the queue provider spawns a background task that periodically requeues jobs stuck in `processing` (e.g. after a worker crash), reusing the same logic as `cargo loco jobs requeue`. Leaving it unset keeps the previous behavior โ€” no automatic requeue. | | `queue.reaper.age_minutes` | `i64` | required (only if `reaper` is set) | Requeue jobs that have been `processing` for longer than this many minutes | | `queue.reaper.interval_seconds` | `u64` | optional, default `60` (`default_reaper_interval_seconds()`) | How often the reaper sweeps for stale jobs | ## `cache` `enum CacheConfig` โ€” `src/config/cache.rs:4-16`, `#[serde(tag = "kind")]`. **Default variant: `Null`** (`#[default]`, `cache.rs:14-15`) โ€” this is what `Config.cache`'s `#[serde(default)]` produces when the `cache` key is omitted entirely. ```yaml cache: kind: InMem # requires the `cache_inmem` feature max_capacity: 33554432 # optional, default 33554432 bytes (32 MiB) # --- or --- cache: kind: Redis # requires the `cache_redis` feature uri: redis://... # required max_size: 100 # required โ€” max pool connections # --- or (default) --- cache: kind: Null # no-op cache; used when `cache` key is omitted ``` | Key | Type | Required? | Notes | |---|---|---|---| | `cache.kind` | tag: `InMem` \| `Redis` \| `Null` | required if `cache` present | `cache.rs:6-16` | | **InMem** (`InMemCacheConfig`, `cache.rs:18-22`) โ€” feature-gated on `cache_inmem` | | | | | `cache.max_capacity` | `u64` | optional, default `33554432` (`32 * 1024 * 1024`, `cache_in_mem_max_capacity()`) | `cache.rs:20-21,24-26` | | **Redis** (`RedisCacheConfig`, `cache.rs:28-33`) โ€” feature-gated on `cache_redis` | | | | | `cache.uri` | `String` | required | `cache.rs:30` | | `cache.max_size` | `u32` | required โ€” max pool connections | `cache.rs:32` | | **Null** | (no fields) | โ€” | default no-op cache | If the corresponding feature (`cache_inmem` / `cache_redis`) is not compiled in, that `kind` value will fail to deserialize. ## `initializers`, `settings`, `scheduler` - `initializers: Option>` (`mod.rs:75,106`) โ€” a free-form map consumed by app initializers (e.g. an `oauth2` initializer reading `initializers.oauth2`). Keys and shapes are defined by whichever initializer reads them, not by `Config` itself. - `settings: Option` (`mod.rs:88-89`) โ€” arbitrary app-defined settings, deserialize your own type from `ctx.config.settings`. - `scheduler: Option` (`mod.rs:91`) โ€” struct and keys owned by the scheduler area; not detailed on this page. ## Environment variables | Variable | Purpose | Source | |---|---|---| | `LOCO_ENV` | Selects the active environment; highest precedence | `src/environment.rs:22,34` | | `RAILS_ENV` | Falls back to this if `LOCO_ENV` is unset | `src/environment.rs:23,35` | | `NODE_ENV` | Falls back to this if both above are unset | `src/environment.rs:24,36` | | `LOCO_CONFIG_FOLDER` | Overrides the `config/` folder Loco loads from | `src/env_vars.rs:16`, read in `Environment::load` (`src/environment.rs:59-64`) | | `LOCO_DATA` | Data folder path | `src/env_vars.rs:20` | | `LOCO_POSTGRES_DB_OPTIONS` | Extra Postgres connection options (only meaningful with `with-db`) | `src/env_vars.rs:8` | | `SCHEDULER_CONFIG` | Path to the scheduler config file | `src/env_vars.rs:18` | | `RUST_BACKTRACE` | Effectively forced to `1` when `logger.pretty_backtrace: true` | logger init | | any name passed to `get_env(name=.., default=..)` in a config YAML file | Injected into the rendered YAML by Tera's built-in `get_env` function at load time | `src/tera.rs:5-8` (Tera, not Loco code) | Secrets (JWT `secret`, SMTP `password`, database `uri` credentials) are plain `String` fields with no dedicated vault type; the convention is to inject them via `{{ get_env(name="...") }}` at config-load time rather than hardcoding them in the YAML file. --- # CLI reference Source: https://loco.rs/docs/reference/cli/ Loco ships **two** command-line surfaces: | Binary | Crate | Installed via | Purpose | |---|---|---|---| | `loco` | `loco-new` (binary crate, not `loco-rs`) | `cargo install loco` | Scaffolds a new app on disk. One subcommand: `new`. | | `cargo loco` | generated into every app by `loco new`, backed by `loco_rs::cli` | built with your app | Runtime operations against *your* app: start the server, run migrations, generate code, run tasks, etc. | `cargo loco` has **two `main()` implementations**, selected by the `with-db` Cargo feature (`src/cli.rs:712` when `with-db` is on, `src/cli.rs:872` when it is off). The non-`with-db` build has no `Db` subcommand and no DB-backed generators (`model`/`migration`/`scaffold`). Both variants share the same top-level `-e, --environment ` global flag (default `development`). --- ## 1. `loco new` โ€” the app generator Source: `loco-new/src/bin/main.rs:30-62`. ### 1.1 Global flag | Flag | Type | Default | Purpose | |---|---|---|---| | `-l, --log ` | `LevelFilter` | `ERROR` | Verbosity of the generator's own logging (`main.rs:22-24`) | ### 1.2 `new` flags | Flag | Type | Default | Purpose | |---|---|---|---| | `-p, --path ` | `PathBuf` | `.` | Local directory to generate into (`main.rs:34-36`) | | `-n, --name ` | `Option` | none โ€” prompts | App name (`main.rs:38-40`) | | `--db ` | `wizard::DBOption` | none โ€” prompts | DB provider: `sqlite` \| `postgres` \| `none` (`main.rs:42-44`) | | `--bg ` | `wizard::BackgroundOption` | none โ€” prompts | Background-worker mode: `async` \| `queue-redis` \| `queue-postgres` \| `queue-sqlite` \| `blocking` (`main.rs:46-48`) | | `--assets ` | `wizard::AssetsOption` | none โ€” prompts | Asset serving: `serverside` \| `clientside` \| `none` (`main.rs:50-52`) | | `--embedded-assets` | `bool` | `false` โ€” prompts (interactive, serverside only) | Embed static assets into the binary (`embedded_assets` feature). Serverside only; combined with `--assets clientside` it is a hard error. Supplying it (or running fully flag-driven) skips the interactive prompt | | `-a, --allow-in-git-repo` | `bool` | `false` | Skip the "you're inside a git repo, continue?" abort prompt (`main.rs:54-56`) | | `--os ` | `wizard::OS` | `linux` on Unix, `windows` otherwise | Generate a Unix- or Windows-optimized starter: `windows` \| `linux` \| `macos` (`main.rs:58-60`, `DEFAULT_OS` `main.rs:64-67`) | There is **no `-t/--template`** and **no `-v/--verbose`** flag. The `Template` enum exists internally and derives `ValueEnum`, but it is not wired to any CLI argument โ€” template choice is interactive-only (ยง1.4). If `--db`, `--bg`, **and** `--assets` are all supplied, the wizard skips every prompt except app name (`wizard.rs:287-297`); app name still prompts unless `--name` is also given. ### 1.3 Interactive prompts (when flags are omitted) Source: `loco-new/src/wizard.rs`. 1. **App name?** (default `myapp`) โ€” non-empty, no leading digit, Unicode XID + `-`/`_` (`wizard.rs:201-267`). 2. **"You are inside a git repository. Do you wish to continue?"** โ€” only if `cwd` is a git repo and `--allow-in-git-repo` was not passed; default No, aborts on decline (`wizard.rs:227-240`; `main.rs:91-94`). 3. **"What would you like to build?"** โ€” template select (`wizard.rs:299-302`). 4. Conditional DB / background / assets follow-ups, depending on the chosen template. ### 1.4 Templates Source: `wizard.rs:12-27`, branch logic `wizard.rs:304-333`. | Template (enum) | Menu label | DB prompt? | BG prompt? | Assets | |---|---|---|---|---| | `SaasServerSideRendering` (default) | "Saas App with server side rendering" | yes | yes | forced `Serverside` | | `SaasClientSideRendering` | "Saas App with client side rendering" | yes | yes | forced `Clientside` | | `RestApi` | "Rest API (with DB and user auth)" | yes | yes | forced `None` | | `Lightweight` | "lightweight-service (minimal, only controllers and views)" | no โ€” forced `None` | no โ€” forced `Async` | forced `None` | | `Advanced` | "Advanced" | yes | yes | **asks** (only template that prompts for asset config) | ### 1.5 Option enums (clap values + menu labels) **`DBOption`** โ€” `wizard.rs:39-79` โ€” clap `--db` values: `sqlite` (default), `postgres`, `none`. | Value | Endpoint template | Notes | |---|---|---| | `sqlite` | `sqlite://NAME_ENV.sqlite?mode=rwc` | default | | `postgres` | `postgres://loco:loco@localhost:5432/NAME_ENV` | warns a running Postgres instance is required | | `none` | โ€” | `enable()` is `false`; disables DB, auth, and mailer generation | **`BackgroundOption`** โ€” `wizard.rs:81-125` โ€” clap `--bg` values: `async` (default), `queue-redis`, `queue-postgres`, `queue-sqlite`, `blocking`. | Value | Menu label | Notes | |---|---|---| | `async` | "Async (in-process tokio async tasks)" | default | | `queue-redis` | "Queue: Redis (standalone workers)" | warns the selected queue backend must be reachable; generates with the `worker_redis` feature | | `queue-postgres` | "Queue: Postgres (standalone workers)" | warns the selected queue backend must be reachable; generates with the `worker` feature | | `queue-sqlite` | "Queue: SQLite (standalone workers)" | warns the selected queue backend must be reachable; generates with the `worker` feature | | `blocking` | "Blocking (run tasks in foreground)" | warns it **blocks requests** until the task completes | **`AssetsOption`** โ€” `wizard.rs:127-162` โ€” clap `--assets` values: `serverside` (default), `clientside`, `none`. | Value | Menu label | Notes | |---|---|---| | `serverside` | "Server (configures server-rendered views)" | default | | `clientside` | "Client (configures assets for frontend serving)" | prints follow-up: `cd frontend/ && npm install && npm run build` | | `none` | "None" | โ€” | **`OS`** โ€” `loco-new/src/lib.rs:41-51` โ€” clap `--os` values: `windows`, `linux`, `macos`. `windows` adds a second `tool` bin target to the generated `Cargo.toml` (`Cargo.toml.t:65-70`). ### 1.6 Derived generation settings Source: `loco-new/src/settings.rs:58-89`. - DB enabled โ†’ `Features::default()` (loco-rs default features apply to the generated app). - DB **disabled** (Lightweight template, or `--db none`) โ†’ `default-features = false`, feature names = `["cli"]`; if background is `queue-redis`, `"worker_redis"` is appended; if background is `queue-postgres` or `queue-sqlite`, `"worker"` is appended. - `auth` and `mailer` scaffolding are enabled iff DB is enabled. - Serverside assets โ†’ generated `Initializers { view_engine: true }`. - `loco_version_text`: normally `version = "0.17"`; when env var `LOCO_DEV_MODE_PATH` is set, becomes `version = "*", path = ""` โ€” this is how the local framework checkout is dogfooded. - Generated app's own edition is pinned in `loco-new/base_template/Cargo.toml.t` independent of the `loco-rs` framework edition. --- ## 2. `cargo loco` โ€” the runtime CLI Source: `src/cli.rs:64-171` (`enum Commands`). ### 2.1 Top-level subcommands | Subcommand | Alias | Gated on | Flags | Purpose | |---|---|---|---|---| | `start` | `s` | โ€” | `-w/--worker[=tags]`, `-s/--server-and-worker`, `-a/--all`, `--scheduler`, `-b/--binding `, `-p/--port `, `-n/--no-banner` (`worker`/`server_and_worker`/`all` are mutually exclusive) | Boot the app in a start mode (`cli.rs:66-91`) | | `db` | โ€” | `#[cfg(feature = "with-db")]` | see ยง2.2 | Database operations (`cli.rs:92-97`) | | `routes` | โ€” | โ€” | none | Print all application endpoints as a tree (`cli.rs:98-99`) | | `middleware` | โ€” | โ€” | `-c/--config` | List middlewares (enabled first, then disabled); `--config` also prints each one's resolved config (`cli.rs:101-105`) | | `task` | `t` | โ€” | `[name]`, `key:val...` params | Run a custom task by name, with `key:value` params (`cli.rs:107-114`) | | `jobs` | โ€” | `#[cfg(feature = "worker")]` | see ยง2.3 | Manage the background jobs queue (`cli.rs:115-120`) | | `scheduler` | โ€” | โ€” | `-n/--name `, `-t/--tag `, `-c/--config `, `-l/--list` | Run or inspect the scheduler (`cli.rs:121-137`) | | `generate` | `g` | `#[cfg(debug_assertions)]` | see ยง2.4 | Code generation (`cli.rs:138-146`) | | `doctor` | โ€” | โ€” | `-c/--config`, `-p/--production` | Validate/diagnose the app; `--config` instead dumps the resolved config + environment and skips checks (`cli.rs:147-154`, `814-832`) | | `version` | โ€” | โ€” | none | Print the app version (`cli.rs:155-156`) | | `watch` | `w` | โ€” | `-w/--worker[=tags]`, `-s/--server-and-worker`, `--scheduler` | Wraps `cargo-watch -s 'cargo loco start ...'` (`cli.rs:158-170`, `838-867`); requires `cargo-watch` installed | **Start-mode resolution** (`cli.rs:736-750`): `--all` or (`--server-and-worker` **and** `--scheduler`) โ†’ `All`; `--server-and-worker` alone โ†’ `ServerAndWorker`; `--worker[=tags]` (+ `--scheduler`) โ†’ `WorkerAndScheduler` / `WorkerOnly`; `--scheduler` alone โ†’ `ServerAndScheduler`; none of the above โ†’ `ServerOnly`. ### 2.2 `db` subcommands `enum DbCommands`, `src/cli.rs:483-523` โ€” only present when `with-db` is enabled. | Command | Flags | Purpose | |---|---|---| | `create` | โ€” | Create the schema/database | | `migrate` | โ€” | Apply all pending up-migrations | | `down` | `[steps]` (default `1`) | Roll back the given number of migrations | | `reset` | โ€” | Drop all tables, then reapply every migration | | `status` | โ€” | Show migration status | | `entities` | โ€” (`#[cfg(debug_assertions)]`) | Generate entity `.rs` files from the current DB schema | | `truncate` | โ€” | Truncate table data without dropping tables | | `seed` | `-r/--reset`, `-d/--dump`, `--dump-tables `, `--from ` (default `src/fixtures`) | Seed the DB from files, or dump tables to files (`cli.rs:505-520`) | | `schema` | โ€” | Dump the database schema | ### 2.3 `jobs` subcommands `enum JobsCommands`, `src/cli.rs:598-647` โ€” only present when the `worker` feature is enabled (`worker_redis` implies `worker`). | Command | Flags | Purpose | |---|---|---| | `cancel` | `--name ` (required) | Set jobs matching `` to `cancelled` | | `tidy` | โ€” | Delete jobs that are `completed` or `cancelled` | | `purge` | `--max-age ` (default `90`), `--status `, `--dump ` | Delete old failed/cancelled jobs, optionally dumping them first | | `dump` | `--status `, `-f/--folder ` (default `.`) | Save job details to files | | `import` | `-f/--file ` | Import jobs from a file | | `requeue` | `--from-age ` (default `0`) | Move `processing` jobs older than the given age back to `queued` | ### 2.4 `generate` subcommands `enum ComponentArg`, `src/cli.rs:173-382` โ€” only present in debug builds (`#[cfg(debug_assertions)]` on `Commands::Generate`). `model`/`migration`/`scaffold` are additionally gated on `#[cfg(feature = "with-db")]`. Full field-type syntax is covered in the [Generators & field types](/docs/reference/generators) reference; this table lists CLI shape only. | Command | Gated | Args / flags | Notes | |---|---|---|---| | `model` | `with-db` | `name`, `--without-tz`, `field:type ...` | Fields support `references` (e.g. `director:references`) | | `migration` | `with-db` | `name`, `--without-tz`, `field:type ...` | Add/remove columns, join tables, empty migrations, references | | `scaffold` | `with-db` | `name`, `--without-tz`, `field:type ...`, `-k/--kind `, `--htmx`, `--html`, `--api` | Exactly one of `--kind`/`--htmx`/`--html`/`--api` is required (group `scaffold_kind_group`); errors otherwise (`cli.rs:426-429`) | | `controller` | โ€” | `name`, `actions...`, `-k/--kind `, `--htmx`, `--html`, `--api` | Same kind-selection rule as `scaffold` (`cli.rs:455-458`) | | `task` | โ€” | `name` | | | `scheduler` | โ€” | none | Scaffolds a scheduler config template | | `worker` | โ€” | `name` | | | `mailer` | โ€” | `name` | | | `data` | โ€” | `name` | Data loader | | `deployment` | โ€” | `kind` = `docker` \| `nginx` (`DeploymentKind`, `cli.rs:554-558`) | `docker` inspects static-assets config + `frontend/package.json`; `nginx` uses configured host/port | | `override` | โ€” | `[template_path]`, `--info` | Copies a built-in generator template locally for customization; no path lists all available templates | `-k/--kind ` accepts `ScaffoldKind`: `api` \| `html` \| `htmx` (`loco-gen/src/lib.rs:218-222`). --- ## 3. `cargo loco --help` (verified output) The top-level help, matching `enum Commands` exactly: ```sh The one-person framework for Rust Usage: demo_app-cli [OPTIONS] Commands: start Start an app db Perform DB operations routes Describe all application endpoints middleware Describe all application middlewares task Run a custom task jobs Managing jobs queue scheduler Run the scheduler generate code generation creates a set of files and code templates based on a predefined set of rules doctor Validate and diagnose configurations version Display the app version watch Watch and restart the app help Print this message or the help of the given subcommand(s) Options: -e, --environment Specify the environment [default: development] -h, --help Print help -V, --version Print version ``` --- # Reference Source: https://loco.rs/docs/reference/ --- # Generators & field types Source: https://loco.rs/docs/reference/generators/ `cargo loco generate ` (alias `cargo loco g `) scaffolds application code from templates baked into the `loco-gen` crate. The `generate` subcommand itself is compiled only under `#[cfg(debug_assertions)]` (`src/cli.rs:140`) โ€” it is available in ordinary (dev/debug) builds but is compiled out of `--release` binaries. The kinds that touch the database (`model`, `migration`, `scaffold`) are additionally gated behind the `with-db` Cargo feature (on by default) โ€” see [feature flags](/docs/reference/feature-flags). This page is the exhaustive dictionary of generator kinds and the field-type mini-language (`name:type`) they all share. It transcribes `loco-gen/src/lib.rs` (the `Component` and `ScaffoldKind` enums), `loco-gen/src/mappings.json` (the field-type table), `loco-gen/src/infer.rs` (naming/inflection conventions), and `src/cli.rs` (the CLI surface), re-verified against `HEAD`. ## Generator kinds `Component` enum: `loco-gen/src/lib.rs:237`. CLI subcommand enum `ComponentArg`: `src/cli.rs:175` (also gated `#[cfg(debug_assertions)]`). | Kind | CLI syntax | Feature gate | Notes | |---|---|---|---| | **model** | `cargo loco generate model [field:type ...] [--without-tz]` | `with-db` | Creates a Sea-ORM entity + model file + migration + tests. `lib.rs:239`, `cli.rs:197` | | **migration** | `cargo loco generate migration [field:type ...] [--without-tz]` | `with-db` | Standalone migration file; no model/entity. Name-based operation inference (create/add/remove/join) โ€” see [Migration-name inference](#migration-name-inference). `lib.rs:250`, `cli.rs:250` | | **scaffold** | `cargo loco generate scaffold [field:type ...] (--api\|--html\|--htmx) [--without-tz]` | `with-db` | Full CRUD: entity, migration, controller, routes, views (for `--html`/`--htmx`), tests. `lib.rs:261`, `cli.rs:268` | | **controller** | `cargo loco generate controller [action ...] (--api\|--html\|--htmx)` | none | Controller + routes + tests only โ€” no model/migration. `lib.rs:274`, `cli.rs:307` | | **task** | `cargo loco generate task ` | none | One-off/CLI task stub, registered in `src/tasks/mod.rs`. `lib.rs:284` | | **scheduler** | `cargo loco generate scheduler` | none | Writes `config/scheduler.yaml`. `lib.rs:288` | | **worker** | `cargo loco generate worker ` | none | Background worker stub in `src/workers/`, registered in `src/workers/mod.rs`. `lib.rs:289` | | **mailer** | `cargo loco generate mailer ` | none | Mailer struct in `src/mailers/.rs` + embedded `welcome/{subject,html,text}.t` templates. `lib.rs:293` | | **data** | `cargo loco generate data ` | none | Data-loader struct + a `data//data.json` static file. `lib.rs:297` | | **deployment** | `cargo loco generate deployment ` | none | `kind` is a **positional** value, not a `--kind` flag (see [Deployment](#deployment)). `lib.rs:301` | | **override** | `cargo loco generate override [template_path] [--info]` | none | Copies built-in templates into the app's `.loco-templates/` so you can customize them. `cli.rs:373` | ### Model, migration, scaffold All three take `name` and a list of `field:type` pairs (the [field-type mini-language](#field-type-mini-language) below), and accept `--without-tz` to omit the `created_at`/`updated_at` timestamp columns. `created_at`, `updated_at`, `create_at`, `update_at` field names are silently skipped if you pass them explicitly (`IGNORE_FIELDS`, `loco-gen/src/model.rs:16`) โ€” they're generated automatically. ```bash # empty model cargo loco generate model posts # model with fields cargo loco generate model posts title:string! content:text # model with a belongs-to reference (adds a `director_id` FK column on `movies`) cargo loco generate model movies long_title:string director:references award:references:prize_id # migration adding columns to an existing table cargo loco generate migration AddNameAndAgeToUsers name:string age:int # scaffold (model + controller + views + tests), API-only cargo loco generate scaffold posts title:string! user:references --api ``` After generating a `migration`, apply it and regenerate entities: `cargo loco db migrate && cargo loco db entities`. ### Scaffold / controller kind (`--api` / `--html` / `--htmx`) `ScaffoldKind` (`loco-gen/src/lib.rs:218`): ```rust pub enum ScaffoldKind { Api, Html, Htmx, } ``` `scaffold` and `controller` both require **exactly one** of `--kind `, `--api`, `--html`, or `--htmx` (they're a clap argument group). **There is no default** โ€” omitting all of them is a hard error: `Error: generating this component requires one of --kind, --htmx, --html, or --api to be specified` (scaffold: `src/cli.rs:428`; controller: `src/cli.rs:457`). ### Deployment `DeploymentKind` in `loco-gen` carries generator data (`loco-gen/src/lib.rs:224`): ```rust pub enum DeploymentKind { Docker { copy_paths: Vec, is_client_side_rendering: bool }, Nginx { host: String, port: i32 }, } ``` but the **CLI-facing** enum (`src/cli.rs:555`) is a plain `clap::ValueEnum { Docker, Nginx }` taken as a positional argument โ€” `copy_paths`/`is_client_side_rendering`/`host`/`port` are derived from the app's own `config/*.yaml` and filesystem at generation time (`src/cli.rs:562-596`), not passed on the command line: ```bash cargo loco generate deployment docker # writes Dockerfile, .dockerignore cargo loco generate deployment nginx # writes nginx/default.conf ``` ### Override Copies a built-in `.t` template (or a whole folder) into the local `.loco-templates/` directory (`DEFAULT_LOCAL_TEMPLATE`, `loco-gen/src/template.rs:8`) so subsequent generation runs use your copy instead of the built-in one. Delete the local copy to revert to the built-in template. ```bash # list all overridable templates cargo loco generate override # override one file cargo loco generate override scaffold/api/controller.t # override every template under a folder cargo loco generate override scaffold/htmx # preview what --info would show for a folder, without copying cargo loco generate override scaffold/htmx --info # override everything cargo loco generate override . ``` ## Field-type mini-language Every `field:type` argument to `model`/`migration`/`scaffold` is resolved through the type table baked into `loco-gen/src/mappings.json`. Transcribed in full below (re-verified against `HEAD`). **Suffix convention:** no suffix = nullable `Option`; **`!`** = required (non-null); **`^`** = unique (implies non-null). Not every base type has all three variants โ€” `bool`, `tstz`, and `json` have no `^` (unique) form. **1.0 change:** `int` now maps to **`i64` / `BIGINT`** (`big_integer`), matching the framework's i64 primary keys. Pre-1.0, `int` was `i32`. `unsigned` is an alias of `big_unsigned` (also i64). Use `small_int`/`small_unsigned` if you specifically need 16-bit columns. | `type` (suffix variants) | Rust type | `ColType` variant | Arity | |---|---|---|---| | `uuid` / `uuid!` / `uuid^` | `Option` / `Uuid` / `Uuid` | `UuidNull` / `Uuid` / `UuidUniq` | โ€” | | `string` / `string!` / `string^` | `Option` / `String` / `String` | `StringNull` / `String` / `StringUniq` | โ€” | | `text` / `text!` / `text^` | `Option` / `String` / `String` | `TextNull` / `Text` / `TextUniq` | โ€” | | `small_int` / `!` / `^` | `Option` / `i16` / `i16` | `SmallIntegerNull` / `SmallInteger` / `SmallIntegerUniq` | โ€” | | `small_unsigned` / `!` / `^` | `Option` / `i16` / `i16` | `SmallUnsignedNull` / `SmallUnsigned` / `SmallUnsignedUniq` | โ€” | | `int` / `!` / `^` **(โš  i64, was i32 pre-1.0)** | `Option` / `i64` / `i64` | `BigIntegerNull` / `BigInteger` / `BigIntegerUniq` | โ€” | | `big_int` / `!` / `^` (alias of `int`) | `Option` / `i64` / `i64` | `BigIntegerNull` / `BigInteger` / `BigIntegerUniq` | โ€” | | `unsigned` / `!` / `^` (alias of `big_unsigned`) | `Option` / `i64` / `i64` | `BigUnsignedNull` / `BigUnsigned` / `BigUnsignedUniq` | โ€” | | `big_unsigned` / `!` / `^` | `Option` / `i64` / `i64` | `BigUnsignedNull` / `BigUnsigned` / `BigUnsignedUniq` | โ€” | | `float` / `!` / `^` | `Option` / `f32` / `f32` | `FloatNull` / `Float` / `FloatUniq` | โ€” | | `double` / `!` / `^` | `Option` / `f64` / `f64` | `DoubleNull` / `Double` / `DoubleUniq` | โ€” | | `decimal` / `!` / `^` | `Option` / `Decimal` / `Decimal` | `DecimalNull` / `Decimal` / `DecimalUniq` | โ€” | | `decimal_len` / `!` / `^` | `Option` / `Decimal` / `Decimal` | `DecimalLenNull` / `DecimalLen` / `DecimalLenUniq` | **2** (precision, scale) | | `bool` / `!` (no `^`) | `Option` / `bool` | `BooleanNull` / `Boolean` | โ€” | | `tstz` / `!` (no `^`) | `Option` / `DateTimeWithTimeZone` | `TimestampWithTimeZoneNull` / `TimestampWithTimeZone` | โ€” | | `date` / `!` / `^` | `Option` / `Date` / `Date` | `DateNull` / `Date` / `DateUniq` | โ€” | | `date_time` / `!` / `^` | `Option` / `DateTime` / `DateTime` | `DateTimeNull` / `DateTime` / `DateTimeUniq` | โ€” | | `json` / `!` (no `^`) | `Option` / `serde_json::Value` | `JsonNull` / `Json` | โ€” | | `jsonb` / `!` / `^` | `Option` / `serde_json::Value` / `serde_json::Value` | `JsonBinaryNull` / `JsonBinary` / `JsonBinaryUniq` | โ€” | | `blob` / `!` / `^` | `Option>` / `Vec` / `Vec` | `BlobNull` / `Blob` / `BlobUniq` | โ€” | | `money` / `!` / `^` | `Option` / `Decimal` / `Decimal` | `MoneyNull` / `Money` / `MoneyUniq` | โ€” | | `binary_len` / `!` / `^` | `Option>` / `Vec` / `Vec` | `BinaryLenNull` / `BinaryLen` / `BinaryLenUniq` | **1** (length) | | `var_binary` / `!` / `^` | `Option>` / `Vec` / `Vec` | `VarBinaryNull` / `VarBinary` / `VarBinaryUniq` | **1** (length) | | `array` / `!` / `^` | `Option>` (see below) | `array_null` / `array` / `array_uniq` (generator emits `ColType::array(ArrayColType::โ€ฆ)` etc.) | **1** (element type) | `decimal`, `money`, and `decimal_len` all resolve to the same Rust type (`rust_decimal::Decimal`); the `ColType` distinguishes the SQL representation. ### Arrays `array`/`array!`/`array^` take one parameter โ€” the element type โ€” written as a second colon segment: `tags:array:string`, `scores:array!:int`. Valid element types (per `mappings.json`'s `array` entry) are `string`, `int`, `big_int`, `float`, `double`, `bool`, generating `Option>` where `T` is: | element | Rust `T` | |---|---| | `string` | `String` | | `int` | **`i32`** | | `big_int` | `i64` | | `float` | `f32` | | `double` | `f64` | | `bool` | `bool` | โš  **Inconsistency worth knowing:** the scalar `int` type is `i64` in 1.0, but `array:int`'s element type is still `i32` (`loco-gen/src/mappings.json`, the `array` entry's `rust.int` key) โ€” the i64 migration did not touch array element types. Confirmed by `loco-gen/src/model.rs:171-182` (`test_get_columns_with_array_types`), which asserts `array:string` โ†’ `array_null(ArrayColType::String)`. ### References (belongs-to foreign keys) A field typed `references` (not in the table above โ€” handled separately in `loco-gen/src/infer.rs:29-54`) generates a belongs-to foreign-key column instead of a regular column: | Syntax | Meaning | |---|---| | `name:references` | Required FK to the `names` table, column `name_id` | | `name:references:custom_id` | Required FK, explicit FK column name `custom_id` | | `name:references?` | Nullable FK to the `names` table | | `name:references?:custom_id` | Nullable FK, explicit FK column name | Example: `director:references award:references:prize_id` on a `movies` model adds a required `director_id` FK to `directors` and a required `prize_id` FK to `awards`. ## Migration-name inference For `cargo loco generate migration ...`, `guess_migration_type` (`loco-gen/src/infer.rs:56`) pattern-matches the **snake_cased** migration name to decide what to scaffold: | Name pattern | Inferred operation | |---|---| | `Create
` | `CreateTable` | | `AddRefTo
` | `AddReference` | | `AddTo
` | `AddColumns` | | `RemoveFrom
` | `RemoveColumns` | | `CreateJoinTableAnd` | `CreateJoinTable` (join table `a_b`, both sides singularized) | | anything else | `Empty` (blank migration stub) | ## Inflection conventions (`cruet` vs `heck`) Documented at `loco-gen/src/infer.rs:1-14`: **`cruet`** is used *only* for pluralization/singularization (`to_plural`/`to_singular` โ€” table names); **`heck`** is used for *all* case conversion (snake_case columns, PascalCase entity/struct names). The two crates disagree on acronym/digit casing (e.g. `i32`โ†’`i_32` under `cruet` vs `i32` under `heck`; `HTTPServer`โ†’`Httpserver` under `cruet` vs `HttpServer` under `heck`), so mixing them corrupts generated identifiers. The one deliberate exception: `guess_migration_type` normalizes the raw migration command name with `cruet`'s snake-casing before splitting it into keyword parts, because the parser is tuned to that specific behavior. ## Related reference pages - [Feature flags](/docs/reference/feature-flags) โ€” `with-db` and the other Cargo features gating generators. - Schema/`ColType` migration DSL and query pagination reference pages cover the migration-writer side (`add_column`, `add_reference`, `ColType`) in depth. --- # Feature flags Source: https://loco.rs/docs/reference/feature-flags/ `loco-rs` gates most of its optional functionality behind Cargo features, declared in root `Cargo.toml:27-64`. This page is the exhaustive matrix โ€” every flag, its default state, what it turns on, and how flags interact with each other and with `cargo loco`. ## Defaults ```toml default = ["auth", "cli", "with-db", "cache_inmem", "worker"] ``` A plain `loco-rs = "..."` dependency (no `default-features = false`) pulls in JWT auth, the `cargo loco` CLI, Sea-ORM database support, the in-memory cache, and the Postgres/SQLite-backed queue workers. The Redis-backed queue worker (`worker_redis`) is *not* in the default set โ€” opt in explicitly if your app uses a Redis queue. ## The matrix | Flag | Default | Enables (deps / sub-features) | Purpose | |---|---|---|---| | `auth` | **ON** | `dep:jsonwebtoken`, `jsonwebtoken/rust_crypto` | JWT authentication. Selects `jsonwebtoken`'s pure-Rust `rust_crypto` backend (jsonwebtoken 10 no longer bundles a crypto backend by default), so the flag stays self-contained and needs no C toolchain, even when enabled alone with `default-features = false`. | | `cli` | **ON** | `dep:clap` | Enables the `cargo loco` runtime CLI (`src/cli.rs`). | | `with-db` | **ON** | `dep:sea-orm`, `dep:sea-orm-migration`, `dep:sqlx`, `loco-gen/with-db` | Sea-ORM 2.0.0-rc database support. Gates the `db` CLI subcommand and the DB-dependent generators (`model`, `migration`, `scaffold`). | | `testing` | off | `dep:axum-test`, `dep:scraper`, `dep:tree-fs` | Test harness utilities. Also the feature set built for docs.rs (`[package.metadata.docs.rs] features = ["testing"]`, `Cargo.toml:211-212`) and used by the crate's own `dev-dependencies`. | | `cache_inmem` | **ON** | `dep:moka` | In-memory cache backend. | | `cache_redis` | off | `dep:bb8-redis`, `dep:bb8` | Redis-backed cache pool. | | `worker` | **ON** | `dep:sqlx`, `dep:ulid` | Background job queue/workers, Postgres and SQLite backends. Which one runs is chosen at runtime by `queue.kind` in config (`Postgres` or `Sqlite`), not by a separate feature per database. | | `worker_redis` | off | `worker`, `dep:redis` | Adds the Redis-backed queue backend on top of `worker` (implies it). Enable this if your app's `queue.kind` is `Redis`. | | `all_storage` | off | `storage_aws_s3` + `storage_azure` + `storage_gcp` | Umbrella flag โ€” turns on every cloud storage backend at once. | | `storage_aws_s3` | off | `opendal/services-s3` | AWS S3 storage backend. | | `storage_azure` | off | `opendal/services-azblob` | Azure Blob storage backend. | | `storage_gcp` | off | `opendal/services-gcs` | Google Cloud Storage backend. | | `embedded_assets` | off | (empty โ€” build-time flag) | Embeds the app's `assets/` directory into the compiled binary and swaps the view-engine's asset-loading path accordingly, instead of reading assets from disk at runtime. | Source: root `Cargo.toml:27-64`. ## Interactions - **`worker` unlocks the `jobs` subcommand.** `cargo loco jobs` (and its `cancel`/`tidy`/`purge`/`dump`/`import`/`requeue` subcommands) is compiled whenever the `worker` feature is enabled (`#[cfg(feature = "worker")]`, `src/cli.rs:27`). The same cfg gates the `JobStatus` import used by the jobs machinery. Since `worker_redis` implies `worker`, the `jobs` CLI is available for any queue backend โ€” Redis, Postgres, or SQLite. - **`debug_assertions` (not a Cargo feature) gates `generate` and `db entities`.** The `cargo loco generate` subcommand and the `db entities` subcommand are compiled only in debug builds (`#[cfg(debug_assertions)]`, `src/cli.rs:29, 140, 173`). They are unavailable in `--release` builds regardless of which Cargo features are on. - **`all_storage` is a pure umbrella.** It has no dependency of its own; it just turns on `storage_aws_s3`, `storage_azure`, and `storage_gcp` together. - **`auth` selects `jsonwebtoken/rust_crypto`.** Because jsonwebtoken 10 unbundled its crypto backend, `auth` explicitly enables the `rust_crypto` sub-feature so JWT support keeps working without requiring a system C toolchain (e.g. OpenSSL). - **`with-db` is a prerequisite, not an implication.** Enabling `worker` does not itself pull in `with-db`; the two are independent flags that happen to share the `sqlx` dependency. - **The queue backend is chosen at runtime, not by feature flag.** `worker` builds in the Postgres and SQLite queue providers; which one actually runs is decided by `queue.kind` (`Postgres` or `Sqlite`) in your app config. `worker_redis` adds the Redis provider, selected the same way with `queue.kind: Redis`. See [Choose a queue backend](/docs/how-to/choose-queue-backend). ## Disabling defaults To opt out of the default set (e.g. a DB-less app), depend with `default-features = false` and re-list only the flags you want: ```toml loco-rs = { version = "...", default-features = false, features = ["cli"] } ``` This is the pattern the `loco new` generator itself uses when the app is created without a database (see the CLI reference's app-creation flow): it emits `default-features = false` with `features = ["cli"]`, plus `worker_redis` if a Redis-backed queue was selected, or `worker` if a Postgres- or SQLite-backed queue was selected. --- # Middleware catalog Source: https://loco.rs/docs/reference/middleware/ Loco ships 13 built-in middlewares, all implementing the `MiddlewareLayer` trait (`src/controller/middleware/mod.rs:46-72`). Each is configured under `server.middlewares.` in your environment YAML (`src/config/server.rs:44`) and is optional (`Option`) โ€” omit the key entirely to get the framework's own default; supply the key (even as `{}`) to take over its `serde` defaults instead (see the callout below). ## 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>; } ``` `src/controller/middleware/mod.rs:46-72`. > **Config-key-present flips the default.** For every middleware below whose > "default enabled" is `true` (`catch_panic`, `etag`, `logger`, `request_id`, > and โ€” outside Production โ€” `fallback`), that default comes from > `default_middleware_stack`'s own fallback value, used only when the key is > **absent** from config (`Option` is `None`, > `src/controller/middleware/mod.rs:76-171`). If you write the key at all โ€” > even as an empty mapping (`etag: {}`) โ€” the struct's own `#[serde(default)]` > takes over, which resolves `enable` to `false` unless you set > `enable: true` explicitly. In short: don't write a middleware's key in > config unless you intend to also set `enable`. ## Stack ordering: build order vs. request order (LIFO) `default_middleware_stack(ctx)` (`mod.rs:76-171`) returns middlewares as a `Vec` in the coding order below (limit_payload โ†’ โ€ฆ โ†’ powered_by). `AppRoutes::to_router` applies them in that same order, one `app.layer(...)` call at a time (`src/controller/app_routes.rs:305-309`). Axum's `Router::layer` wraps the *existing* router with each new layer as the **outer** layer, so: > "the LAST middleware is the FIRST to meet the outside world (a user request > starting), or 'LIFO' order" โ€” `src/controller/app_routes.rs:283-286`. So an inbound request actually passes through the stack in the **reverse** of the table order โ€” `powered_by` first, `limit_payload` last (right before the route handler) โ€” and the response flows back out the opposite way. ## Full middleware set Table order = coding/config order (`default_middleware_stack`, `mod.rs:80-169`). ### 1. `limit_payload` - **Config key:** `limit_payload` ยท **Struct:** `limit_payload::LimitPayload` (`limit_payload.rs:26`) - **Default:** effectively always enabled โ€” `is_enabled()` is hard-coded `true` (`limit_payload.rs:70-72`); there is no `enable` field. To turn it off, set `body_limit: disable`. - **Purpose:** caps the request body size via Axum's `DefaultBodyLimit`. - **Knobs:** | Name | Type | Default | |---|---|---| | `body_limit` | `DefaultBodyLimitKind` (`""` e.g. `"5mb"`, or `"disable"`) | `2mb` (2,000,000 bytes) โ€” `limit_payload.rs:43-45` | ### 2. `cors` - **Config key:** `cors` ยท **Struct:** `cors::Cors` (`cors.rs:19-42`) - **Default:** **disabled**. - **Purpose:** Cross-Origin Resource Sharing headers. - **Knobs:** | Name | Type | Default | |---|---|---| | `enable` | `bool` | `false` | | `allow_origins` | `Vec` | `["*"]` | | `allow_headers` | `Vec` | `["*"]` | | `allow_methods` | `Vec` | `["*"]` | | `expose_headers` | `Vec` | `[]` (empty) | | `allow_credentials` | `bool` | `false` | | `max_age` | `Option` (seconds) | `None` | | `vary` | `Vec` | `["origin", "access-control-request-method", "access-control-request-headers"]` | > The field is `expose_headers` (plural) โ€” `cors.rs:32`. ### 3. `catch_panic` - **Config key:** `catch_panic` ยท **Struct:** `catch_panic::CatchPanic { enable }` (`catch_panic.rs:18-22`) - **Default:** **enabled**. - **Purpose:** catches panics in request handlers, logs them, and returns `500 Internal Server Error` instead of dropping the connection. - **Knobs:** | Name | Type | Default | |---|---|---| | `enable` | `bool` | `true` (framework default when key absent) | ### 4. `etag` - **Config key:** `etag` ยท **Struct:** `etag::Etag { enable }` (`etag.rs:27-31`) - **Default:** **enabled**. - **Purpose:** compares `If-None-Match` against the response `ETag` and returns `304 Not Modified` on a match. - **Knobs:** | Name | Type | Default | |---|---|---| | `enable` | `bool` | `true` (framework default when key absent) | ### 5. `remote_ip` - **Config key:** `remote_ip` ยท **Struct:** `remote_ip::RemoteIpMiddleware { enable, source }` (`remote_ip.rs`) - **Default:** **disabled**. - **Purpose:** resolves the client IP from a single, trusted source (a proxy header, or the raw socket address). Implemented as a thin wrapper over the [`axum-client-ip`](https://docs.rs/axum-client-ip) crate. - **Knobs:** | Name | Type | Default | |---|---|---| | `enable` | `bool` | `false` | | `source` | `axum_client_ip::ClientIpSource` | `RightmostXForwardedFor` | `source` selects exactly one trusted source โ€” there is no proxy-chain walking and no CIDR trust list. Valid values (serialized as the bare variant name, e.g. `source: XRealIp`): `RightmostXForwardedFor` (last value of the last `X-Forwarded-For` header, taken verbatim), `RightmostForwarded` (RFC 7239 `Forwarded` header), `CfConnectingIp` (Cloudflare), `CloudFrontViewerAddress` (AWS CloudFront), `FlyClientIp` (Fly.io), `TrueClientIp` (Akamai/Cloudflare), `XEnvoyExternalAddress` (Envoy/Istio), `XRealIp` (nginx), or `ConnectInfo` (the raw socket peer address, no header involved). > **BREAKING (was `trusted_proxies: Option>`):** the old middleware hand-rolled `X-Forwarded-For` parsing, walking the header right-to-left and skipping any IP in a configurable trusted-proxy CIDR list (or a built-in RFC-1918 + loopback list) โ€” i.e. it could see through a chain of one or more trusted proxies. The new `source` field trusts exactly **one** hop and applies no CIDR filtering at all. If you run multiple hops (CDN โ†’ load balancer โ†’ ingress), configure your innermost hop to compute and set the correct client IP itself, and point `source` at whatever header it writes (or pick a provider-specific source like `CfConnectingIp`). ### 6. `compression` - **Config key:** `compression` ยท **Struct:** `compression::Compression { enable }` (`compression.rs:14-18`) - **Default:** **disabled**. - **Purpose:** compresses response bodies (`tower_http::compression::CompressionLayer`). - **Knobs:** | Name | Type | Default | |---|---|---| | `enable` | `bool` | `false` | ### 7. `timeout_request` - **Config key:** `timeout_request` ยท **Struct:** `timeout::TimeOut { enable, timeout }` (`timeout.rs:23-30`) - **Default:** **disabled**. - **Purpose:** aborts a request and returns `408 Request Timeout` if it runs longer than `timeout`. - **Knobs:** | Name | Type | Default | |---|---|---| | `enable` | `bool` | `false` | | `timeout` | `u64` (milliseconds) | `5000` (`timeout.rs:38-40`) | ### 8. `static` - **Config key:** `static` (Rust field `static_assets`, `#[serde(rename = "static")]`, `mod.rs:197-199`) ยท **Struct:** `static_assets::StaticAssets` (`static_assets.rs:24-43`) - **Default:** **disabled**. - **Purpose:** serves a static-file folder, with an optional fallback file for SPA routing. - **Knobs:** | Name | Type | Default | |---|---|---| | `enable` | `bool` | `false` | | `must_exist` | `bool` | `true` | | `folder.uri` | `String` | `"/static"` | | `folder.path` | `PathBuf` | `"assets/static"` | | `fallback` | `PathBuf` | `"assets/static/404.html"` | | `precompressed` | `bool` | `false` (serves `.gz` variants when `true`) | | `cache_control` | `Option` | `None` (e.g. `"max-age=31536000"`) | > Under the `embedded_assets` feature, this swaps at compile time for `static_assets_embedded::StaticAssets` (`mod.rs:21-27`) โ€” same config key (`"static"`) and knob surface, assets baked into the binary instead of read from disk. ### 9. `secure_headers` - **Config key:** `secure_headers` ยท **Struct:** `secure_headers::SecureHeader { enable, preset, overrides }` (`secure_headers.rs:78-86`) - **Default:** **disabled**. - **Purpose:** injects a preset bundle of security headers (CSP, X-Frame-Options, etc.), individually overridable. - **Knobs:** | Name | Type | Default | |---|---|---| | `enable` | `bool` | `false` | | `preset` | `String` | `"github"` (`secure_headers.rs:94-96`) โ€” other presets: `owasp`, `empty` (`secure_headers.json`) | | `overrides` | `Option>` | `None` | ### 10. `logger` - **Config key:** `logger` ยท **Struct:** `logger::Config { enable }` โ†’ `logger::Middleware` via `logger::new(config, &env)` (`logger.rs:21-25, 36-42`) - **Default:** **enabled**. - **Purpose:** `TraceLayer`-based request logging (method, URI, version, user agent, request ID, environment). - **Knobs:** | Name | Type | Default | |---|---|---| | `enable` | `bool` | `true` (framework default when key absent) | ### 11. `request_id` - **Config key:** `request_id` ยท **Struct:** `request_id::RequestId { enable }` (`request_id.rs:28-32`) - **Default:** **enabled**. - **Purpose:** ensures every request has an `x-request-id` header (sanitizes an incoming one or generates a UUID v4), and exposes it to handlers as `LocoRequestId(String)` via `.get()` (`request_id.rs:63-72`). - **Knobs:** | Name | Type | Default | |---|---|---| | `enable` | `bool` | `true` (framework default when key absent) | ### 12. `fallback` - **Config key:** `fallback` ยท **Struct:** `fallback::Fallback { enable, code, file, not_found }` (`fallback.rs:17-37`); `StatusCodeWrapper(pub StatusCode)` (`fallback.rs:15`) - **Default:** enabled **only when `environment != Production`** (`mod.rs:158-167`). - **Purpose:** serves a response for unmatched routes โ€” a file, a plain message, or the bundled `fallback.html` โ€” instead of Axum's bare 404. - **Knobs:** | Name | Type | Default | |---|---|---| | `enable` | `bool` | `true` outside Production, `false` in Production (framework default when key absent) | | `code` | `StatusCode` (as `u16`) | `200` (`OK`) โ€” `fallback.rs:39-41`; set to `404` explicitly if that's what you want | | `file` | `Option` | `None` โ€” path to a file served as the fallback body | | `not_found` | `Option` | `None` โ€” a plain-text message served as the fallback body | If neither `file` nor `not_found` is set, the bundled `fallback.html` is served. ### 13. `powered_by` - **Config key:** none โ€” **not** part of `middleware::Config`; controlled by `server.ident: Option` (`src/config/server.rs:40`). Struct: `powered_by::Middleware` via `powered_by::new(ctx.config.server.ident.as_deref())` (`powered_by.rs:27-58`) - **Default:** **enabled**, sets `Server`-identifying header `X-Powered-By: loco.rs`. - **Purpose:** sets an `X-Powered-By` response header. - **Knobs (via `server.ident`, not `enable`):** | `server.ident` value | Effect | |---|---| | absent / `None` | `X-Powered-By: loco.rs` (default) | | `""` (empty string) | middleware disabled โ€” no header | | any other string | `X-Powered-By: ` | ## Introspecting the stack ``` cargo loco middleware # list every middleware and its enabled state cargo loco middleware --config # also print each middleware's JSON config ``` `src/cli.rs:100-104`, backed by `list_middlewares` (`src/boot.rs:588-597`), which calls each middleware's `name()`, `is_enabled()`, and `config()`. --- # AppContext & prelude Source: https://loco.rs/docs/reference/app-context/ `AppContext` is the cloneable, `axum`-`State`-compatible struct that carries every shared resource of a Loco app (DB connection, cache, queue, mailer, storage, config, and an open-ended DI slot). `loco_rs::prelude` is the single-import surface app code pulls in instead of naming individual `loco_rs` and `axum` paths. Both are declared in `src/app.rs` and `src/prelude.rs`. ## `AppContext` Defined at `src/app.rs:253-273`: ```rust #[derive(Clone, FromRef)] pub struct AppContext { pub environment: Environment, #[cfg(feature = "with-db")] pub db: DatabaseConnection, pub queue_provider: Option>, pub config: Config, pub mailer: Option, pub storage: Arc, pub cache: Arc, pub shared_store: Arc, } ``` ### Derives `#[derive(Clone, FromRef)]` (`src/app.rs:253`). `FromRef` (from `axum`) auto-generates `impl FromRef for ` for each field, so a handler can extract a single field directly โ€” e.g. `State` or `State>` โ€” instead of always taking the whole `State`. ### Fields | Field | Type | Feature gate | Purpose | |---|---|---|---| | `environment` | `Environment` | none | Which profile the app booted under (`Production` / `Development` / `Test` / `Any(String)`); drives config-file selection. | | `db` | `DatabaseConnection` (Sea-ORM 2.0) | `#[cfg(feature = "with-db")]` | The pooled Sea-ORM database connection used by every entity query and by `db::converge` migrations. Absent entirely from the struct when `with-db` is off. | | `queue_provider` | `Option>` | none | The background-job queue (Redis / Postgres / SQLite / in-process), if the app was booted with one wired up. `None` for queue-less apps. | | `config` | `Config` | none | The fully loaded, deserialized `config/.yaml` (+ `.local.yaml` overlay). | | `mailer` | `Option` | none | The configured email-sending backend (SMTP or stub), if the app enabled one. | | `storage` | `Arc` | none | The file/object storage abstraction (local disk or a cloud backend selected by the `storage_*` feature flags). | | `cache` | `Arc` | none | The cache handle (in-memory, Redis, or null backend per `cache_*` flags / `CacheConfig`). | | `shared_store` | `Arc` | none | A `TypeId`-keyed, concurrent DI container (backed by `DashMap`) for stashing arbitrary app-defined services โ€” see below. | `db` is the only field that is compiled out (not just `None`-able) when its feature (`with-db`) is disabled โ€” every other field is unconditionally present, with `Option`/empty-default standing in for "not configured." ### `SharedStore` โ€” the generic DI slot `shared_store: Arc` (`src/app.rs:33-245, 272`) is a small heterogeneous store for services that don't have a dedicated `AppContext` field. Its API: - `insert(&self, val: T)` (`:62`) - `remove(&self) -> Option` (`:103`) - `get_ref(&self) -> Option>` (`:151`) โ€” borrowed access via a `Deref` guard - `get(&self) -> Option` (`:195`) โ€” cloning access - `contains(&self) -> bool` (`:221`) To read a stashed value from inside a handler, use the extractor of the same name: `controller::extractor::shared_store::SharedStore(pub T)`, which implements `FromRequestParts` and returns `Error::InternalServerError` if `T` was never inserted (`src/controller/extractor/shared_store.rs:6-29`). **Naming note:** `app::SharedStore` (the container held on `AppContext`) and the extractor `controller::extractor::shared_store::SharedStore` (the axum extractor) are two distinct types that share a name. Both are reachable through the prelude โ€” see below โ€” so disambiguate by context: the field type is the store, the tuple-struct-with-generic is the extractor. ## `loco_rs::prelude` `use loco_rs::prelude::*;` (`src/prelude.rs`) is the standard single import for app code (controllers, models, workers, tasks). It re-exports, unconditionally unless noted: **Async / axum plumbing** - `async_trait::async_trait` - `axum::debug_handler` - `axum::extract::{Form, Multipart, Path, Query, State}` - `axum::response::{IntoResponse, Response}` - `axum::routing::{delete, get, head, options, patch, post, put, trace}` - `axum_extra::extract::cookie` **Third-party helpers** - `chrono::NaiveDateTime as DateTime` - `include_dir::{include_dir, Dir}` - `serde_json::json as data` โ€” sugar so controller/view code can write `data!({"item": ..})` instead of `json!` - `validator::Validate` **Core Loco types** - `app::{AppContext, Initializer}` - `bgworker::{BackgroundWorker, Queue}` - `errors::Error` and `Result` (the crate's `Result` alias) - `mailer` (the module itself) and `mailer::Mailer` - `task::{self, Task, TaskInfo}` - `validation::{self, Validatable, ValidatorTrait}` **Controller layer** - `controller::{bad_request, not_found, unauthorized}` โ€” error-response constructor fns - `controller::format` โ€” the response-builder module (`format::json`, `format::render()`, ...) - `controller::middleware::format::{Format, RespondTo}` โ€” content-negotiation extractor/enum - `controller::middleware::remote_ip::RemoteIP` โ€” computed-client-IP extractor - `controller::extractor::shared_store::SharedStore` โ€” the DI extractor (see above) - `controller::extractor::validate::{JsonValidate, JsonValidateWithMessage}` โ€” validating-body extractors - `controller::views::{engines::TeraView, ViewEngine, ViewRenderer}` - `controller::{Json, Routes}` **Feature-gated** - `#[cfg(feature = "auth")] controller::extractor::auth` (the whole `auth` extractor module: `JWT`, `JWTWithUser`, `ApiToken`, `UserClaims`, token-extraction helpers) - `#[cfg(feature = "with-db")]`: - Sea-ORM traits and types: `ActiveModelBehavior, ActiveModelTrait, ActiveValue, ColumnTrait, ConnectionTrait, DatabaseConnection, DbErr, EntityTrait, IntoActiveModel, ModelTrait, QueryFilter, Set, TransactionTrait` - Sea-ORM scalar re-exports: `sea_orm::prelude::{Date, DateTimeUtc, DateTimeWithTimeZone, Decimal, Uuid}` - `model::{query, Authenticable, ModelError, ModelResult}` โ€” plus a nested `pub mod model { pub use crate::model::query; }`, so `query` is reachable both as `loco_rs::prelude::query` and as `loco_rs::prelude::model::query` - `#[cfg(feature = "testing")] crate::testing::prelude::*` โ€” pulled in only for apps/tests built with the `testing` feature Source: `src/prelude.rs` (verified against `HEAD` at authoring time). --- # Hooks trait Source: https://loco.rs/docs/reference/hooks/ `Hooks` (`#[async_trait]`, `Send`, `src/app.rs:281-443`) is the single trait every Loco application implements โ€” typically on a `struct App` in `src/app.rs` โ€” to wire routing, workers, tasks, database seed/truncate, and lifecycle callbacks. `cargo loco generate` scaffolds an `impl Hooks for App` for you; this page is the exhaustive reference for what that `impl` can and must contain. ## Required methods No default implementation. The trait will not compile without these. | Method | Signature | Purpose | |---|---|---| | `app_name` | `fn app_name() -> &'static str` (`:296`) | Returns the app's crate name (conventionally `env!("CARGO_CRATE_NAME")`). | | `boot` | `async fn boot(mode: StartMode, environment: &Environment, config: Config) -> Result` (`:323`) | Initializes and boots the application for the given `StartMode` and `Environment`. Typically delegates to `create_app::(mode, environment, config)` (with DB) or `create_app::(mode, environment, config)` (without DB). | | `routes` | `fn routes(_ctx: &AppContext) -> AppRoutes` (`:413`) | Defines the application's routing configuration. | | `connect_workers` | `async fn connect_workers(ctx: &AppContext, queue: &Queue) -> Result<()>` (`:422`) | Registers background-job workers against the provided `Queue`. | | `register_tasks` | `fn register_tasks(tasks: &mut Tasks)` (`:425`) | Registers custom `cargo loco task` entries with the `Tasks` registry. | | `truncate` | `#[cfg(feature = "with-db")] async fn truncate(_ctx: &AppContext) -> Result<()>` (`:433`) | Truncates application tables. Invoked when `config.database.dangerously_truncate` is `true`; useful before tests. | | `seed` | `#[cfg(feature = "with-db")] async fn seed(_ctx: &AppContext, path: &Path) -> Result<()>` (`:437`) | Seeds the database with initial data from `path`. | `truncate` and `seed` only exist on the trait when the `with-db` Cargo feature is enabled. ## Provided methods Have a default implementation; override to change behavior. | Method | Signature | Default behavior | |---|---|---| | `app_version` | `fn app_version() -> String` (`:285`) | Returns `"dev".to_string()`. | | `serve` | `async fn serve(app: AxumRouter, ctx: &AppContext, serve_params: &ServeParams) -> Result<()>` (`:331-351`) | Binds a `tokio::net::TcpListener` on `serve_params.binding:serve_params.port` and runs `axum::serve(listener, app.into_make_service_with_connect_info::())` with graceful shutdown; on shutdown, calls `Self::on_shutdown(&ctx)`. | | `init_logger` | `fn init_logger(_ctx: &AppContext) -> Result` (`:360-362`) | Returns `Ok(false)`, meaning Loco initializes its own tracing/logging stack. | | `load_config` | `async fn load_config(env: &Environment) -> Result` (`:368-370`) | Returns `env.load()` โ€” the standard `config/{env}.yaml` (+ `.local.yaml` overlay) loading path. | | `before_routes` | `async fn before_routes(_ctx: &AppContext) -> Result>` (`:378`) | Returns `Ok(AxumRouter::new())` โ€” an empty router. | | `after_routes` | `async fn after_routes(router: AxumRouter, _ctx: &AppContext) -> Result` (`:388`) | Returns `Ok(router)` unchanged. | | `initializers` | `async fn initializers(_ctx: &AppContext) -> Result>>` (`:395`) | Returns `Ok(vec![])` โ€” no initializers. | | `middlewares` | `fn middlewares(ctx: &AppContext) -> Vec>` (`:401-403`) | Returns `middleware::default_middleware_stack(ctx)`. | | `before_run` | `async fn before_run(_app_context: &AppContext) -> Result<()>` (`:408`) | Returns `Ok(())` โ€” no-op. | | `after_context` | `async fn after_context(ctx: AppContext) -> Result` (`:416`) | Returns `Ok(ctx)` unchanged. | | `on_shutdown` | `async fn on_shutdown(_ctx: &AppContext)` (`:442`) | No-op. | ## Override points The methods below are the least-documented parts of `Hooks`. Each entry states exactly what overriding changes. ### `init_logger` โ€” own your tracing stack ```rust fn init_logger(_ctx: &AppContext) -> Result ``` Runs once during boot, before the rest of the app context is wired up. Returning `Ok(true)` tells Loco **not** to initialize its own logger โ€” the app is then responsible for setting up a complete tracing/logging stack itself. Returning `Ok(false)` (the default) leaves Loco's built-in logger in place. ### `load_config` โ€” replace the config loader ```rust async fn load_config(env: &Environment) -> Result ``` Runs during boot to produce the `Config` passed into `boot`. The default is `env.load()` (the standard `config/{env}.yaml` file resolution). Override to load configuration from a different source (e.g. a remote config service) while still returning a `Config`. ### `after_context` โ€” post-process `AppContext` ```rust async fn after_context(ctx: AppContext) -> Result ``` Runs after `AppContext` has been fully constructed (db, cache, storage, mailer, queue provider all present) but before routes are built. Takes `ctx` by value and must return a (possibly modified) `AppContext` โ€” the only hook that lets you replace fields on the context itself. ### `before_run` โ€” pre-run resource loading ```rust async fn before_run(_app_context: &AppContext) -> Result<()> ``` Runs before the app starts serving/running (applies to the server and to other run modes such as tasks/jobs, not only HTTP serve). Use it to load or warm resources that don't belong on `AppContext` itself. ### `serve` โ€” the HTTP serve loop ```rust async fn serve(app: AxumRouter, ctx: &AppContext, serve_params: &ServeParams) -> Result<()> ``` Runs when the app is started in server mode. The default binds a `TcpListener` and calls `axum::serve` with `app.into_make_service_with_connect_info::()` โ€” the `connect_info` layer is required for `remote_ip`/client-address extraction in controllers โ€” wrapped in graceful shutdown that calls `on_shutdown`. Override only to change the transport/serve mechanics (e.g. custom TLS termination); overriding without preserving `into_make_service_with_connect_info` will break connect-info extraction. ### `app_version` โ€” composite version string ```rust fn app_version() -> String ``` Called wherever Loco reports its version (e.g. `cargo loco version`, `/_ping`/`/_health` style diagnostics). Default is the literal `"dev"`; override to compose a real version string, e.g. from `CARGO_PKG_VERSION` plus a git SHA. ## `boot` signature note `boot`'s second parameter is `environment: &Environment` โ€” a reference to the `Environment` enum, **not** `&str`: ```rust async fn boot(mode: StartMode, environment: &Environment, config: Config) -> Result ``` Some existing docs and snippets show `environment: &str`; that signature is stale (the rustdoc example inside `src/app.rs:308` and `:315` itself still shows `&str` and should not be copied). `src/controller/mod.rs:47` is a correct reference example using `&Environment`. --- # Error model Source: https://loco.rs/docs/reference/errors/ `loco-rs` uses a single crate-wide error type. This page documents its shape, how it becomes an HTTP response, and the helpers for constructing it. ## `Result` and `Error` - `pub type Result = std::result::Result` (`src/lib.rs:52`). - `pub use self::errors::Error` (`src/lib.rs:5`) โ€” re-exported at the crate root and in `loco_rs::prelude`. - `Error` is declared `#[non_exhaustive]` (`src/errors.rs:31`). Any `match Error { .. }` outside the crate **must** include a wildcard `_ =>` arm โ€” the compiler enforces this, and the crate itself relies on it (see the status map below). ## Variant โ†’ HTTP status map `impl IntoResponse for Error` (`src/controller/mod.rs:180-253`) matches on the error and produces `(StatusCode, ErrorDetail)`, then serializes `ErrorDetail` as the JSON response body. Only seven variants are matched explicitly; **every other variant** falls through to the trailing `_ =>` arm. | Variant | Produced when | HTTP status | Response `ErrorDetail` | |---|---|---|---| | `NotFound` | Returned by the `not_found()` helper, or directly. | 404 | `{"error":"not_found","description":"Resource was not found"}` | | `Unauthorized(String)` | Returned by the `unauthorized(msg)` helper, or directly. Also logs `tracing::warn!(err)` with the original message (the message itself is **not** sent to the client). | 401 | `{"error":"unauthorized","description":"You do not have permission to access this resource"}` | | `CustomError(StatusCode, ErrorDetail)` | Constructed directly when the caller wants an arbitrary status and body. | passthrough โ€” whatever `StatusCode` was supplied | passthrough โ€” whatever `ErrorDetail` was supplied | | `WithBacktrace { inner, backtrace }` | Wraps another error variant; produced by calling `.bt()` on an `Error` (backtrace is only captured when `RUST_BACKTRACE` is set โ€” see Constructors below). Also prints the inner error (red, underlined) and the filtered backtrace to stdout via `backtrace::print_backtrace`. | 400 | `{"error":"Bad Request"}` (only the `error` reason is set, no `description`) | | `BadRequest(String)` | Returned by the `bad_request(msg)` helper, or directly. | 400 | `{"error":"Bad Request","description":""}` | | `JsonRejection(JsonRejection)` | Axum's `Json` extractor rejects a malformed/missing request body (surfaced via the `Json` wrapper's `#[from_request(rejection(Error))]`). Logs `tracing::debug!(err = err.body_text(), ...)`. | `err.status()` โ€” axum's own rejection status (commonly 400/415/422) | `{"error":"Bad Request"}` | | `Validation(ModelValidationErrors)` | A `validator`-crate validation failure converted `#[from] ModelValidationErrors`. | 400 | `{"errors": }` โ€” note `error`/`description` are `None` here; only `errors` is populated | | **everything else** (all other variants, current and future โ€” this is what the `#[non_exhaustive]` catch-all covers) | Any of the ~28 remaining variants (`DB`, `Model`, `IO`, `Redis`, `Sqlx`, `Tera`, `YAML`, `Message`, `Any`, `InternalServerError`, etc. โ€” see the full list below) | **500** | `{"error":"internal_server_error","description":"Internal Server Error"}` | Every response, regardless of variant, is first logged at `tracing::error!` with `error.msg` / `error.details` fields before the match runs (`src/controller/mod.rs:184-202`). ## `ErrorDetail` โ€” the response body shape ```rust #[derive(Debug, Serialize)] pub struct ErrorDetail { #[serde(skip_serializing_if = "Option::is_none")] pub error: Option, #[serde(skip_serializing_if = "Option::is_none")] pub description: Option, #[serde(skip_serializing_if = "Option::is_none")] pub errors: Option, } ``` (`src/controller/mod.rs:133-142`) Constructors: | Fn | Signature | Behavior | |---|---|---| | `ErrorDetail::new` | `new(error: T1, description: T2) -> Self` (`:147`) | Sets `error`; sets `description` to `None` if the passed description is an empty string, `Some(..)` otherwise. `errors` is always `None`. | | `ErrorDetail::with_reason` | `with_reason(error: T) -> Self` (`:161`) | Sets only `error`; `description`/`errors` are `None`. | The body is always wrapped in the crate's own `Json` type (`src/controller/mod.rs:170-178`, a thin `axum::Json` newtype), not raw `axum::Json`. ## Constructor helpers on `Error` `src/errors.rs:153-177`: | Fn | Signature | Notes | |---|---|---| | `Error::wrap` | `wrap(err: impl std::error::Error + Send + Sync + 'static) -> Self` (`:154`) | Boxes any error into `Error::Any(Box::new(err))`. Does **not** call `.bt()` (backtrace capture is commented out). | | `Error::msg` | `msg(err: impl std::error::Error + Send + Sync + 'static) -> Self` (`:158`) | Stringifies the error's `Display` into `Error::Message(err.to_string())`. Also does not capture a backtrace. | | `Error::string` | `string(s: &str) -> Self` (`:162`) | Builds `Error::Message(s.to_string())` directly from a string slice. `#[must_use]`. | | `Error::bt` | `bt(self) -> Self` (`:166`) | Captures `std::backtrace::Backtrace::capture()`. If the backtrace status is `Disabled` or `Unsupported` (i.e. `RUST_BACKTRACE` is unset), returns `self` unchanged โ€” no allocation, no wrapping. Otherwise wraps `self` in `Error::WithBacktrace`. `#[must_use]`. | Both `wrap`/`msg` are cheap-conversion helpers for turning a foreign `std::error::Error` into the crate's `Error` at a call site (e.g. inside a handler using `.map_err(Error::wrap)`); `bt` is the opt-in backtrace wrapper used internally (e.g. the hand-written `From` impl at `src/errors.rs:24-28` does `Self::JSON(val).bt()`). ## Controller helper functions Free functions in `src/controller/mod.rs` for the common HTTP-facing variants, each returning `Result` (i.e. always `Err(..)`): | Fn | Signature | file:line | |---|---|---| | `unauthorized` | `unauthorized, U>(msg: T) -> Result` | `:112` | | `bad_request` | `bad_request, U>(msg: T) -> Result` | `:121` | | `not_found` | `not_found() -> Result` | `:130` | All three are re-exported from `loco_rs::prelude`. ## Full variant list The complete `#[non_exhaustive] enum Error` (`src/errors.rs:32-151`), with feature gates where present: | Variant | Feature gate | |---|---| | `WithBacktrace { inner: Box, backtrace: Box }` | โ€” | | `Message(String)` | โ€” | | `QueueProviderMissing` | โ€” | | `TaskNotFound(String)` | โ€” | | `Scheduler(#[from] crate::scheduler::Error)` | โ€” | | `Axum(#[from] axum::http::Error)` | โ€” | | `Tera(#[from] tera::Error)` | โ€” | | `JSON(serde_json::Error)` | โ€” (hand-rolled `From`, not `#[from]`, so it can call `.bt()`) | | `JsonRejection(#[from] JsonRejection)` | โ€” | | `YAMLFile(#[source] serde_yaml::Error, String)` | โ€” | | `YAML(#[from] serde_yaml::Error)` | โ€” | | `EmailSender(#[from] lettre::error::Error)` | โ€” | | `Smtp(#[from] smtp::Error)` | โ€” | | `Worker(String)` | โ€” | | `IO(#[from] std::io::Error)` | โ€” | | `DB(#[from] sea_orm::DbErr)` | `with-db` | | `ParseAddress(#[from] AddressError)` | โ€” | | `Unauthorized(String)` | โ€” | | `NotFound` | โ€” | | `BadRequest(String)` | โ€” | | `CustomError(StatusCode, ErrorDetail)` | โ€” | | `InternalServerError` | โ€” | | `InvalidHeaderValue(#[from] InvalidHeaderValue)` | โ€” | | `InvalidHeaderName(#[from] InvalidHeaderName)` | โ€” | | `InvalidMethod(#[from] InvalidMethod)` | โ€” | | `Model(#[from] crate::model::ModelError)` | `with-db` | | `Redis(#[from] redis::RedisError)` | `worker_redis` | | `Sqlx(#[from] sqlx::Error)` | `worker` | | `Storage(#[from] crate::storage::StorageError)` | โ€” | | `Cache(#[from] crate::cache::CacheError)` | โ€” | | `Generators(#[from] loco_gen::Error)` | `debug_assertions` | | `VersionCheck(#[from] depcheck::VersionCheckError)` | โ€” | | `Any(#[from] Box)` | โ€” | | `Validation(#[from] ModelValidationErrors)` | โ€” | | `AxumFormRejection(#[from] axum::extract::rejection::FormRejection)` | โ€” | ## Removed variants (breaking as of the 1.0 error-enum narrowing) Commit `4a4a84ee` ("narrow the Error enum โ€” drop 4 low-value/leaky variants") removed four variants that are **confirmed absent** from current source (`src/errors.rs`): - `EnvVar(#[from] std::env::VarError)` - `Hash(String)` - `SemVer(#[from] semver::Error)` - `TaskJoinError(#[from] tokio::task::JoinError)` Any code that constructs or `match`es these four no longer compiles. Combined with `#[non_exhaustive]`, every downstream `match Error { .. }` must carry a `_ =>` arm โ€” this was already required before the removal, but the removal is a reminder that new/removed variants must not break exhaustive matches, and user code must not attempt to rely on exhaustiveness. --- # Schema & ColType DSL Source: https://loco.rs/docs/reference/schema-dsl/ `loco_rs::schema` is the Rails-like migration DSL migrations author against โ€” a thin, ergonomic layer over Sea-ORM's `sea_query`/`SchemaManager`. It is reached as `loco_rs::schema::*` (the generated migration template does `use loco_rs::schema::*;`) and re-exports all of `sea_orm_migration::schema::*` alongside its own additions, so lower-level column-def primitives (`string`, `integer`, `pk_auto`, โ€ฆ) are available directly if `ColType` doesn't cover a case. The module is gated behind the `with-db` feature (`src/lib.rs:20-21`). ## The i64 auto primary key (1.0 default) `ColType::PkAuto` builds an auto-increment **64-bit** (`BIGINT`) primary key, not a 32-bit one: ```rust // src/schema.rs:330-332 Self::PkAuto => big_pk_auto(name), ``` `big_pk_auto` (re-exported from `sea_orm_migration::schema`) is `big_integer(name).auto_increment().primary_key()` โ€” a `BigInteger` column, i.e. Rust-side `i64`. This is a deliberate 1.0 change from the previous 32-bit default (comment at `schema.rs:330-331`: Sea-ORM 2.0 maps SQLite integers to `i64`, and a `BIGINT` PK is the modern default, matching Rails 5.1+). The consequence propagates to foreign keys: every FK column generated by `create_table`/`create_join_table`/`add_reference` is typed `ColType::BigInteger` (or `BigIntegerNull` when nullable) so it matches the `id` column it points to (`schema.rs:677-683`, `schema.rs:781-782`). `ColType::PkUuid` is the alternative โ€” a `Uuid` primary key with no auto-increment (`pk_uuid`, wraps `uuid(name).primary_key()`). | Variant | Builds | Rust type | Anchor | |---|---|---|---| | `ColType::PkAuto` | `big_pk_auto(name)` โ€” auto-increment `BIGINT` PK | `i64` | `schema.rs:332` | | `ColType::PkUuid` | `pk_uuid(name)` โ€” `UUID` PK, no auto-increment | `Uuid` | `schema.rs:333` | ## `ColType` โ€” column type enum `enum ColType` (`schema.rs:161-284`) is the value half of every `(name, ColType)` tuple passed to `create_table`/`add_column`. Each family below generally follows a modifier convention โ€” but coverage is not uniform per family (e.g. `Boolean` has no `*Uniq`, `TimestampWithTimeZone` has no `*Uniq`, `Text` has no `*Len`): - **(bare)** โ€” `NOT NULL`, no default, no unique constraint. - **`*Null`** โ€” nullable. - **`*Uniq`** โ€” `NOT NULL` + unique index. - **`*WithDefault(v)`** โ€” `NOT NULL` + a default value. - **`*Len(n)`** โ€” fixed/max length, for `Char`/`String`/`Decimal`(precision, scale)/binary/varbit families. `ColType::to_def(&self, name) -> ColumnDef` (`schema.rs:328-470`) matches every variant to a Sea-ORM `ColumnDef` builder call; the tables below are transcribed from that match plus the enum declaration. ### Primary keys โ€” see [above](#the-i64-auto-primary-key-1-0-default) ### Char / String / Text | Variant | Notes | |---|---| | `Char`, `CharNull`, `CharUniq`, `CharWithDefault(char)` | fixed single-char-typed column | | `CharLen(u32)`, `CharLenNull(u32)`, `CharLenUniq(u32)`, `CharLenWithDefault(u32, char)` | fixed length `n` | | `String`, `StringNull`, `StringUniq`, `StringWithDefault(String)` | variable-length string, no length cap | | `StringLen(u32)`, `StringLenNull(u32)`, `StringLenUniq(u32)`, `StringLenWithDefault(u32, String)` | variable-length string with max length `n` | | `Text`, `TextNull`, `TextUniq`, `TextWithDefault(String)` | unbounded text; no `Len` variant | ### Numeric โ€” integers & unsigned | Variant | Rust type | Notes | |---|---|---| | `Integer`, `IntegerNull`, `IntegerUniq`, `IntegerWithDefault(i32)` | `i32` | 32-bit signed | | `SmallInteger`, `SmallIntegerNull`, `SmallIntegerUniq`, `SmallIntegerWithDefault(i16)` | `i16` | | | `BigInteger`, `BigIntegerNull`, `BigIntegerUniq`, `BigIntegerWithDefault(i64)` | `i64` | also the type auto-generated for FK columns | | `Unsigned`, `UnsignedNull`, `UnsignedUniq`, `UnsignedWithDefault(u32)` | `u32` | | | `SmallUnsigned`, `SmallUnsignedNull`, `SmallUnsignedUniq`, `SmallUnsignedWithDefault(u16)` | `u16` | | | `BigUnsigned`, `BigUnsignedNull`, `BigUnsignedUniq`, `BigUnsignedWithDefault(u64)` | `u64` | | ### Numeric โ€” decimal / float / money | Variant | Notes | |---|---| | `Decimal`, `DecimalNull`, `DecimalUniq`, `DecimalWithDefault(f64)` | unconstrained precision | | `DecimalLen(u32, u32)`, `DecimalLenNull(u32, u32)`, `DecimalLenUniq(u32, u32)`, `DecimalLenWithDefault(u32, u32, f64)` | `(precision, scale)` | | `Float`, `FloatNull`, `FloatUniq`, `FloatWithDefault(f32)` | `f32` | | `Double`, `DoubleNull`, `DoubleUniq`, `DoubleWithDefault(f64)` | `f64` | | `Money`, `MoneyNull`, `MoneyUniq`, `MoneyWithDefault(f64)` | currency-typed column | ### Boolean | Variant | Notes | |---|---| | `Boolean`, `BooleanNull`, `BooleanWithDefault(bool)` | no `*Uniq` variant | ### Date / time | Variant | Notes | |---|---| | `Date`, `DateNull`, `DateUniq`, `DateWithDefault(String)` | | | `Time`, `TimeNull`, `TimeUniq`, `TimeWithDefault(String)` | | | `DateTime`, `DateTimeNull`, `DateTimeUniq`, `DateTimeWithDefault(String)` | naive datetime, no timezone | | `TimestampWithTimeZone`, `TimestampWithTimeZoneNull`, `TimestampWithTimeZoneWithDefault(String)` | timezone-aware; no `*Uniq` variant | | `Interval(Option, Option)`, `IntervalNull(..)`, `IntervalUniq(..)` | Postgres interval; args are an optional `PgInterval` field-qualifier and optional precision | ### Binary | Variant | Notes | |---|---| | `Binary`, `BinaryNull`, `BinaryUniq` | unbounded, no default variant | | `BinaryLen(u32)`, `BinaryLenNull(u32)`, `BinaryLenUniq(u32)` | fixed length `n` | | `VarBinary(u32)`, `VarBinaryNull(u32)`, `VarBinaryUniq(u32)` | variable, max length `n` | | `Blob`, `BlobNull`, `BlobUniq` | | ### JSON | Variant | Notes | |---|---| | `Json`, `JsonNull`, `JsonUniq` | text-stored JSON | | `JsonBinary`, `JsonBinaryNull`, `JsonBinaryUniq` | binary JSON (`jsonb` on Postgres) | ### UUID | Variant | Notes | |---|---| | `Uuid`, `UuidNull`, `UuidUniq` | | | `UuidWithDefault(String)`, `UuidUniqWithDefault(String)` | default is a raw SQL expression string, e.g. `"gen_random_uuid()"` (via `Expr::cust`) | ### Bit strings | Variant | Notes | |---|---| | `VarBitLen(u32)`, `VarBitLenNull(u32)`, `VarBitLenUniq(u32)` | Postgres `VARBIT(n)` | ### Array | Item | Signature | Anchor | |---|---|---| | `ColType::Array(ColumnType)` / `ArrayNull(ColumnType)` / `ArrayUniq(ColumnType)` | wrap a Sea-ORM `ColumnType` for the element type | `schema.rs:276-278` | | `ColType::array(kind: ArrayColType) -> Self` | builds `Array(..)` | `schema.rs:298-300` | | `ColType::array_uniq(kind: ArrayColType) -> Self` | builds `ArrayUniq(..)` | `schema.rs:304-306` | | `ColType::array_null(kind: ArrayColType) -> Self` | builds `ArrayNull(..)` | `schema.rs:310-312` | | `enum ArrayColType { String, Int, BigInt, Float, Double, Bool }` | element-type selector for the `array*` constructors | `schema.rs:286-293` | `array_col_type` maps each `ArrayColType` to a `sea_orm::ColumnType`: `String` โ†’ `ColumnType::string(None)`, `Int` โ†’ `Integer`, `BigInt` โ†’ `BigInteger`, `Float` โ†’ `Float`, `Double` โ†’ `Double`, `Bool` โ†’ `Boolean` (`schema.rs:314-323`). ### Enum | Variant | Notes | |---|---| | `Enum(enum_name: String, variants: Vec)` | `NOT NULL` | | `EnumNull(enum_name, variants)` | nullable | | `EnumWithDefault(enum_name, variants, default_value: String)` | `NOT NULL` + default | | `EnumNullWithDefault(enum_name, variants, default_value: String)` | nullable + default | (`schema.rs:280-283`) Enum creation is backend-dependent and handled automatically by `create_table`/`create_join_table` (see [Enum type semantics](#enum-type-semantics-per-backend) below) โ€” you don't call `CREATE TYPE` yourself. ## Column-def helper functions (schema.rs's own additions) Beyond `ColType`, `schema.rs` defines a handful of standalone helpers used to build raw `ColumnDef`/`TableCreateStatement`/`TableAlterStatement` values, on top of everything re-exported from `sea_orm_migration::schema`: | Fn | Signature | Behavior | Anchor | |---|---|---|---| | `alter` | `fn alter(name: T) -> TableAlterStatement` | `Table::alter().table(name)` | `schema.rs:19-21` | | `table_auto_tz` | `fn table_auto_tz(name: T) -> TableCreateStatement` | `Table::create().table(name).if_not_exists()` **with** `created_at`/`updated_at` timestamptz columns already added (via `timestamps_tz`) | `schema.rs:24-29` | | `timestamps_tz` | `fn timestamps_tz(t: TableCreateStatement) -> TableCreateStatement` | adds `created_at`/`updated_at` as `timestamp_with_time_zone` columns defaulting to `Expr::current_timestamp()` | `schema.rs:34-39` | | `timestamptz` | `fn timestamptz(name: T) -> ColumnDef` | non-nullable timestamptz column | `schema.rs:53-61` | | `timestamptz_null` | `fn timestamptz_null(name: T) -> ColumnDef` | nullable timestamptz column | `schema.rs:42-50` | | `enum_type` | `fn enum_type(name: T, enum_name: &str) -> ColumnDef` | non-nullable enum column | `schema.rs:64-72` | | `enum_type_null` | `fn enum_type_null(name: T, enum_name: &str) -> ColumnDef` | nullable enum column | `schema.rs:75-83` | | `enum_type_with_default` | `fn enum_type_with_default(name: T, enum_name: &str, default_value: &str) -> ColumnDef` | non-nullable enum column + default | `schema.rs:93-102` | | `enum_type_null_with_default` | `fn enum_type_null_with_default(name: T, enum_name: &str, default_value: &str) -> ColumnDef` | nullable enum column + default | `schema.rs:112-121` | `table_auto_tz` is the timezone-aware counterpart to `sea_orm_migration::schema::table_auto` (which uses naive, non-tz timestamps) โ€” `create_table`/`create_join_table` use `table_auto_tz` internally, so tables built through the DSL always get timezone-aware `created_at`/`updated_at`. ## Table-level operations All are `async fn(m: &SchemaManager<'_>, ...) -> Result<(), DbErr>`, called from a migration's `up`/`down`. | Fn | Signature | Anchor | |---|---|---| | `create_table` | `create_table(m, table: &str, cols: &[(&str, ColType)], refs: &[(&str, &str)])` | `schema.rs:490-497` | | `create_join_table` | `create_join_table(m, table, cols, refs)` โ€” composite primary key over the reference columns | `schema.rs:512-519` | | `create_table_without_timestamps` | `create_table_without_timestamps(m, table, cols, refs)` โ€” no auto `created_at`/`updated_at` | `schema.rs:537-544` | | `create_join_table_without_timestamps` | `create_join_table_without_timestamps(m, table, cols, refs)` โ€” join table, no timestamps | `schema.rs:559-566` | | `add_column` | `add_column(m, table: &str, name: &str, atype: ColType)` | `schema.rs:721-735` | | `remove_column` | `remove_column(m, table: &str, name: &str)` | `schema.rs:745-754` | | `add_reference` | `add_reference(m, fromtbl: &str, totbl: &str, refname: &str)` | `schema.rs:764-839` | | `remove_reference` | `remove_reference(m, fromtbl: &str, totbl: &str, refname: &str)` | `schema.rs:849-892` | | `drop_table` | `drop_table(m, table: &str)` | `schema.rs:902-906` | | `add_enum_values` | `add_enum_values(m, enum_name: &str, new_values: Vec)` | `schema.rs:916-952` | | `drop_enum_type` | `drop_enum_type(m, enum_name: &str)` | `schema.rs:962-987` | All four `create_*` functions share one implementation (`create_table_impl`, `schema.rs:568-701`), parameterized by `is_join: bool` and `add_timestamps: bool`. ```rust // schema.rs:474-497 (doc example) create_table(m, "movies", vec![ ("title", ColType::String) ], vec![]).await; ``` ```sh loco g migration CreateMovies title:string user:references loco g migration CreateMovies title:string user:references:admin_id ``` ### `cols` and `refs` parameters - `cols: &[(&str, ColType)]` โ€” ordinary columns, in order, each turned into a `ColumnDef` via `ColType::to_def`. - `refs: &[(&str, &str)]` โ€” one entry per foreign-key reference the new/altered table should carry. The **first** element names the *referenced* table (it is pluralized/snake-cased the same way as any table name); the **second** element is an optional custom FK column name โ€” pass `""` to use the default `_id` (computed by `reference_id`, `schema.rs:708-711`). - Suffix the referenced-table name with `?` to make the FK column **nullable**: `refs: &[("user?", "")]` โ€” `create_table_impl` strips the `?` before normalizing the table name (`schema.rs:664-669`). - Nullable references get `ON DELETE SET NULL` / `ON UPDATE NO ACTION`; non-nullable references get `ON DELETE CASCADE` / `ON UPDATE CASCADE` (`schema.rs:685-696`). - The generated FK column is always `ColType::BigInteger`/`BigIntegerNull` (matching the i64 `PkAuto` default), unless a column of that name already exists in `cols` (`schema.rs:676-683`). - FK constraint name is deterministic: `fk-{referenced_table}-{ref_column}-to-{table}` (`schema.rs:687`) โ€” note this is **not** the same naming order `add_reference`/`remove_reference` use (see next section): a FK added through `create_table`'s `refs` parameter is named `fk-users-user_id-to-movies`, whereas `add_reference(m, "movies", "users", "")` names it `fk-movies-user_id-to-users`. Calling `remove_reference` against a FK that was created via `create_table`'s `refs` (rather than via `add_reference` itself) will look for the wrong constraint name and not find it. ### `add_reference` / `remove_reference` Unlike the `refs` tuples above, `add_reference`/`remove_reference` take table names in natural "reads as" order โ€” `add_reference(m, "movies", "users", "")` reads *"movies belongs-to users"*: `fromtbl` is the table being altered (`movies`), `totbl` is the table referenced (`users`). ```rust // schema.rs:756-764 (doc example) add_reference(m, "movies", "users", "").await; // ... remove_reference(m, "movies", "users", "").await; ``` - `add_reference` always builds a `ColType::BigInteger` FK column, adds it via `ALTER TABLE ... ADD COLUMN`, and โ€” on MySQL/Postgres only โ€” also `ADD FOREIGN KEY` in the same statement. On **SQLite it adds the column but skips the FK constraint** (SQLite doesn't allow adding FKs to an existing table; per Rails 5.2 convention, this is a documented no-op โ€” `schema.rs:817-830`). Any other backend returns `DbErr::BackendNotSupported { ctx: "add_reference" }`. - `remove_reference` drops the named FK constraint on MySQL/Postgres; on **SQLite it is a no-op** for the same reason (`schema.rs:879-883`). Any other backend returns `DbErr::BackendNotSupported { ctx: "remove_reference" }`. ## Enum type semantics per backend `create_table_impl` scans `cols` for any `ColType::Enum*` variant and, for each distinct `enum_name` not yet seen, checks whether the type already exists (`check_enum_exists`, `schema.rs:124-159`, Postgres-only `pg_type` lookup) before creating it: | Backend | Behavior | |---|---| | Postgres | Creates a native `CREATE TYPE ... AS ENUM (...)` if it doesn't already exist. | | SQLite | No native enum type; the column is created as `TEXT` with the enum behavior enforced via the column definition (no `CREATE TYPE` step). | | MySQL | Not created as a separate type; MySQL enums are inline in the column definition. | | other | No-op. | `add_enum_values(m, enum_name, new_values)` extends an existing enum: on Postgres it runs `ALTER TYPE {enum_name} ADD VALUE '{value}'` per new value; on SQLite/MySQL it's a logged no-op (`schema.rs:916-952`). `drop_enum_type(m, enum_name)` runs `DROP TYPE IF EXISTS {enum_name} CASCADE` on Postgres (guarded by the same existence check) and is a no-op elsewhere (`schema.rs:962-987`). ## Table naming `normalize_table(table: &str) -> String` (`schema.rs:704-706`) pluralizes and snake-cases every table name passed to the DSL: `cruet::to_plural(table).to_snake_case()` โ€” e.g. `"person"` โ†’ `"people"`, `"Movie"` โ†’ `"movies"`. This runs on every table-name argument across `create_table`, `add_column`, `add_reference`, etc., so callers pass singular or plural, either case, and get the same normalized table. ## Timestamps: default vs `_without_timestamps` `create_table`/`create_join_table` add `created_at`/`updated_at` (via `table_auto_tz`) unless you use the `_without_timestamps` variant (`create_table_without_timestamps`/`create_join_table_without_timestamps`), which builds a bare `Table::create().if_not_exists()` with no timestamp columns โ€” full control over the schema. The generator CLI flag that maps to the `_without_timestamps` functions is **`--without-tz`** (not `--without-timestamps`): ```sh loco g migration CreatePosts title:string --without-tz loco g migration CreateJoinTableUsersAndGroups count:int --without-tz loco g scaffold posts title:string! user:references --api --without-tz ``` (`src/cli.rs:193`, `:237`, `:241`, `:267`) > An internal doc-comment inside `schema.rs` (`schema.rs:533`, on `create_table_without_timestamps`) still shows the old flag spelling `--without-timestamps` in its example โ€” that comment is stale; the real CLI flag, wired in `src/cli.rs`, is `--without-tz`. ## Related: generator field-type mapping The `loco g model|migration|scaffold` field-type shorthand (e.g. `title:string!`, `count:int^`, `user:references`) maps onto this same `ColType` surface via `loco-gen/src/mappings.json` (`col_type` column). Notably, the generator's `int`/`unsigned` shorthand also produces 64-bit columns (`int` โ†’ `ColType::BigIntegerNull` / `Option`, `int!` โ†’ `ColType::BigInteger` / `i64`, `unsigned` family โ†’ `BigUnsigned*` / `i64`) โ€” consistent with the `PkAuto` 64-bit default on this page. The full field-type table (all ~50 shorthand entries) belongs on the generators reference page, not yet published as of this writing. --- # Query DSL & pagination Source: https://loco.rs/docs/reference/query-pagination/ `loco_rs::model::query` (reachable as `loco_rs::prelude::query` or `loco_rs::prelude::model::query`) is a small fluent DSL over Sea-ORM's `Condition`, plus pagination helpers that wrap Sea-ORM's `PaginatorTrait`. This page documents `ConditionBuilder`'s full operator surface, `DateRangeBuilder`, `SortDirection`, the pagination types/functions, and the model-layer `ModelError`/`ModelResult`/`Authenticable` types the query and model code return. All of this module is gated behind the `with-db` feature (`Cargo.toml:44-49` pulls `sea-orm`/`sea-orm-migration`/`sqlx`); `prelude.rs:29-30` and `prelude.rs:52-55` gate `query`, `ModelError`, `ModelResult`, `Authenticable` on the same feature. ## `ConditionBuilder` โ€” fluent filter DSL Module: `src/model/query/dsl/mod.rs`, re-exported at `src/model/query/mod.rs:4`. ```rust pub struct ConditionBuilder { condition: Condition, // sea_orm::Condition } ``` Two entry points build a `ConditionBuilder`: | Fn | Signature | Behavior | Anchor | |---|---|---|---| | `condition()` | `condition() -> ConditionBuilder` | Starts from `Condition::all()` (AND-combined). | `dsl/mod.rs:38` | | `with(condition)` | `const fn with(condition: Condition) -> ConditionBuilder` | Wraps an existing Sea-ORM `Condition` (used internally to chain builder calls). | `dsl/mod.rs:45` | `ConditionBuilder` implements `From for Condition` (`dsl/mod.rs:167`), and every builder method below returns `Self` (consuming `self`) so calls chain; `.build()` finalizes to a `sea_orm::Condition`: ```rust pub fn build(&self) -> Condition // dsl/mod.rs:701 ``` ### Operators Every operator exists twice: as a **free function** `query::(col, ..)` that starts a new builder (shorthand for `condition().(..)`), and as a **method** on `ConditionBuilder` for chaining. Both forms accept a Sea-ORM `ColumnTrait` (an entity's generated `Column` enum) as the column argument. | Operator | Free fn (anchor) | Builder method (anchor) | Args | SQL produced | |---|---|---|---|---| | Equals | `eq` `dsl/mod.rs:51` | `eq` `:235` | `col: T, value: V: Into` | `col = value` | | Not equals | `not_equal` `:57` | `ne` `:260` | `col, value` | `col <> value` | | Greater than | `gt` `:63` | `gt` `:285` | `col, value` | `col > value` | | Greater than or equal | `gt_equal` `:69` | `gte` `:311` | `col, value` | `col >= value` | | Less than | `lt` `:75` | `lt` `:337` | `col, value` | `col < value` | | Less than or equal | `lt_equal` `:81` | `lte` `:363` | `col, value` | `col <= value` | | Between | `between` `:87` | `between` `:389` | `col, a: V, b: V` | `col BETWEEN a AND b` | | Not between | `not_between` `:93` | `not_between` `:415` | `col, a, b` | `col NOT BETWEEN a AND b` | | Like | `like` `:99` | `like` `:441` | `col, pattern: V: Into` | `col LIKE pattern` (caller supplies wildcards) | | Not like | `not_like` `:105` | `not_like` `:467` | `col, pattern` | `col NOT LIKE pattern` | | Starts with | `starts_with` `:111` | `starts_with` `:493` | `col, s: V: Into` | `col LIKE 's%'` | | Ends with | `ends_with` `:117` | `ends_with` `:519` | `col, s` | `col LIKE '%s'` | | Contains | `contains` `:123` | `contains` `:545` | `col, s` | `col LIKE '%s%'` | | Is null | `is_null` `:130` | `is_null` `:572` | `col` | `col IS NULL` | | Is not null | `is_not_null` `:137` | `is_not_null` `:599` | `col` | `col IS NOT NULL` | | Is in | `is_in` `:144` | `is_in` `:626` | `col, values: I: IntoIterator` | `col IN (values...)` | | Is not in | `is_not_in` `:154` | `is_not_in` `:657` | `col, values` | `col NOT IN (values...)` | | Date range | `date_range` `:163` | `date_range` `:696` | `col` โ€” returns a `DateRangeBuilder`, not `Self` | see [Date range](#daterangebuilder-date-range-filtering) below | That is 17 comparison/pattern/membership operators plus `date_range`, matching the ~18-operator surface of the module. Example (from the module's own doctest, `dsl/mod.rs:194-233`): ```rust use loco_rs::prelude::*; use sea_orm::{EntityTrait, QueryFilter}; let cond = query::condition().eq(test_db::Column::Id, 1).build(); test_db::Entity::find().filter(cond); // WHERE "loco"."id" = 1 ``` `like`/`not_like` pass the pattern through verbatim (the caller writes `%` wildcards); `starts_with`/`ends_with`/`contains` add the wildcard(s) for you and always compile down to `LIKE`. ### `DateRangeBuilder` โ€” date-range filtering `date_range(col)` (either the free fn or the `ConditionBuilder` method) returns a `DateRangeBuilder` instead of `Self`, because a date range needs 0, 1, or 2 bounds before it can become a condition. Struct and impl: `src/model/query/dsl/date_range.rs:7-66`. | Method | Signature | Anchor | |---|---|---| | `new` | `const fn new(condition_builder: ConditionBuilder, col: T) -> Self` | `:15` | | `dates` | `fn dates(self, from: Option<&NaiveDateTime>, to: Option<&NaiveDateTime>) -> Self` | `:25` | | `from` | `fn from(self, from: &NaiveDateTime) -> Self` | `:35` | | `to` | `fn to(self, to: &NaiveDateTime) -> Self` | `:45` | | `build` | `fn build(self) -> ConditionBuilder` | `:54` | **Boundary behavior is asymmetric** (`date_range.rs:55-63`) โ€” this is the one non-obvious semantic in the DSL, worth knowing before using it: | Bounds set | SQL | |---|---| | neither `from` nor `to` | no condition added (passthrough) | | `to` only | `col < to` (strict) | | `from` only | `col > from` (strict) | | both `from` and `to` | `col BETWEEN from AND to` (inclusive) | So a single-ended range is exclusive at the bound, but a double-ended range is inclusive at both bounds โ€” `date_range(col).from(&d).build()` will *not* include rows exactly at `d`, but `date_range(col).dates(Some(&d), Some(&d2)).build()` *will* include rows exactly at `d` or `d2`. ### `SortDirection` `src/model/query/dsl/mod.rs:17-35`: ```rust pub enum SortDirection { Desc, // serde "desc" Asc, // serde "asc" } ``` - Derives `Deserialize, Serialize` with `#[serde(rename = "desc"/"asc")]` on each variant โ€” meant to deserialize directly from a query-string sort param. - `order(&self) -> Order` (`:29`, `#[must_use] const fn`) converts to `sea_orm::sea_query::Order::Desc`/`Order::Asc` for use with `.order_by(col, direction.order())`. This enum is not itself a `ConditionBuilder` operator โ€” it pairs with Sea-ORM's own `QueryOrder::order_by`, orthogonal to filtering. ## Pagination Module: `src/model/query/paginate/mod.rs`, re-exported at `src/model/query/mod.rs:5`. Reached as `query::paginate`, `query::fetch_page`, `query::PaginationQuery`. ### `PaginationQuery` ```rust pub struct PaginationQuery { pub page_size: u64, // default 25 pub page: u64, // default 1, 1-based } ``` (`paginate/mod.rs:31-45`) | Field | Type | Default | Notes | |---|---|---|---| | `page_size` | `u64` | `25` (`default_page_size`, `:5-7`) | Rows per page. | | `page` | `u64` | `1` (`default_page`, `:9-11`) | **1-based.** `paginate`/`fetch_page` internally `saturating_sub(1)` to reach Sea-ORM's 0-based `fetch_page`. | - Both fields use a custom `deserialize_pagination_filter` (`:69-75`) that parses a **string** into `u64` โ€” a workaround for a `serde_urlencoded` bug where numeric query-string params don't deserialize directly to integers. This makes `PaginationQuery` safe to use as a `#[serde(flatten)]` field inside an axum `Query` extractor struct, e.g.: ```rust #[derive(Debug, Deserialize)] pub struct ListQueryParams { pub title: Option, pub content: Option, #[serde(flatten)] pub pagination: query::PaginationQuery, } ``` (doctest at `paginate/mod.rs:19-30`) - `PaginationQuery::page(page: u64) -> Self` (`:49`) โ€” constructs with the given page and `page_size` defaulted via `..Default::default()`. - `impl Default for PaginationQuery` (`:58-65`) โ€” `page_size = 25`, `page = 1`. ### `PageResponse` and `PagerMeta` ```rust pub struct PageResponse { pub page: Vec, pub meta: PagerMeta, } ``` (`paginate/mod.rs:80-83`; `PagerMeta` is `crate::controller::views::pagination::PagerMeta`, imported at `:77`) `PagerMeta` (`src/controller/views/pagination.rs:13-22`) โ€” not re-derived by the inventory, verified directly from source for this page: ```rust pub struct PagerMeta { pub page: u64, // serializes as "page" pub page_size: u64, // serializes as "page_size" pub total_pages: u64,// serializes as "total_pages" pub total_items: u64,// serializes as "total_items" } ``` ### `paginate` and `fetch_page` | Fn | Signature | Anchor | |---|---|---| | `paginate` | `async fn paginate(db: &DatabaseConnection, entity: Select, condition: Option, pagination_query: &PaginationQuery) -> LocoResult> where E: EntityTrait, E::Model: Sync` | `paginate/mod.rs:146` | | `fetch_page` | `async fn fetch_page<'db, C, S>(db: &'db C, selector: S, pagination_query: &PaginationQuery) -> LocoResult> where C: ConnectionTrait + Sync, S: PaginatorTrait<'db, C> + Send` | `paginate/mod.rs:204` | Both: - Take the caller's 1-based `pagination_query.page` and internally do `.saturating_sub(1)` (`:156`, `:213`) before calling Sea-ORM's `Paginator::fetch_page` (which is 0-based). - Call `query.num_items_and_pages().await?` to populate `PagerMeta.total_pages`/`total_items`, then `query.fetch_page(page).await?` for the row data. - Return `Ok(PageResponse { page, meta })` โ€” the crate's `LocoResult` (i.e. `crate::Result`). `paginate` takes a `Select` (an entity query builder) plus an optional pre-built `Condition` โ€” it applies `.filter(condition)` itself if one is given, so you don't chain `.filter()` before calling it. `fetch_page` is the more generic form: it accepts anything implementing Sea-ORM's `PaginatorTrait` directly (so you can pre-build arbitrary selects, including `.order_by(..)`, and just page over the result), but does not take a separate `Condition` argument โ€” filter and ordering must already be applied to the selector you pass in. ```rust // paginate: entity + optional condition + pagination query let condition = query::condition().contains(Column::Name, "loco").build(); let res = query::paginate(&db, Entity::find(), Some(condition), &pagination_query).await; // fetch_page: pre-built selector (any PaginatorTrait), no separate condition arg let res = query::fetch_page(&db, Entity::find(), &query::PaginationQuery::page(2)).await; ``` (adapted from doctests at `paginate/mod.rs:92-140` and `:185-197`) ## Model-layer error types `src/model/mod.rs` โ€” the error type returned by model/authn code (distinct from the crate-wide `loco_rs::errors::Error`; see the [error model reference](/docs/reference/errors)). ### `ModelError` / `ModelResult` ```rust pub enum ModelError { EntityAlreadyExists, EntityNotFound, Validation(ModelValidationErrors), // #[from] #[cfg(feature = "auth")] Jwt(jsonwebtoken::errors::Error), // #[from] DbErr(sea_orm::DbErr), // #[from] Any(Box), // #[from] Message(String), } pub type ModelResult = std::result::Result; ``` (`src/model/mod.rs:13-35` for the enum, `:38` for the alias) Note: `ModelError` is **not** `#[non_exhaustive]` (unlike the crate-wide `Error`) โ€” this is the model layer's own, smaller error enum, not the one documented on the [error model reference](/docs/reference/errors) page. `Jwt` only exists when the `auth` feature is enabled (`mod.rs:23-25`). Constructors: | Fn | Signature | Anchor | |---|---|---| | `ModelError::wrap` | `#[must_use] fn wrap(err: impl std::error::Error + Send + Sync + 'static) -> Self` โ€” builds `Any(Box::new(err))` | `:42-44` | | `ModelError::to_msg` | `#[must_use] fn to_msg(err: impl std::error::Error + Send + Sync + 'static) -> Self` โ€” builds `Message(err.to_string())` | `:47-49` | | `ModelError::msg` | `#[must_use] fn msg(s: &str) -> Self` โ€” builds `Message(s.to_string())` | `:52-54` | `loco_rs::errors::Error` itself has a `Model(#[from] crate::model::ModelError)` variant (`with-db`-gated), so a `ModelError` returned from a model method converts automatically into the crate-wide `Error` at a controller boundary via `?`. ### `Authenticable` ```rust #[async_trait] 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; } ``` (`src/model/mod.rs:56-60`) A user model (typically the `users` entity) implements `Authenticable` so the auth extractors (`JWT`, `JWTWithUser`, `ApiToken` under `prelude::auth`, feature `auth`) can look the caller up: `find_by_claims_key` resolves a JWT's claims subject to a model instance; `find_by_api_key` resolves a bearer/API-key header the same way. Both are `async` (the trait itself is `#[async_trait]`) and return `ModelResult`, so a lookup failure surfaces as a `ModelError` (typically `EntityNotFound` or `DbErr`). All four items (`query`, `ModelError`, `ModelResult`, `Authenticable`) are re-exported from `loco_rs::prelude` (`prelude.rs:29-30`, `with-db`-gated). --- # Why "batteries included"? Source: https://loco.rs/docs/explanation/why-batteries-included/ Loco's tagline is "Axum with batteries included," and it is meant literally: everything under the hood is standard [Axum](https://crates.io/crates/axum) 0.8 and [Tower](https://crates.io/crates/tower), but almost none of the code you'd normally write to assemble a production web service in Rust โ€” wiring a DB pool into state, picking a logging stack, hand-rolling a queue, choosing a config format โ€” is code you have to write yourself. This page explains the design principle behind that choice, not the mechanics (those live in [Architecture](/docs/explanation/architecture) and the reference pages). ## The prime directive When a Loco app needs a capability, the framework's bias is: 1. **Reach for a built-in first.** Database access, caching, background jobs, mailing, file storage, view rendering, JWT auth, health checks, a CLI โ€” these already exist, wired into `AppContext` and toggled from YAML. 2. **Reach for a generator second.** `cargo loco generate` scaffolds the idiomatic shape of a model, controller, worker, mailer, task, or full CRUD scaffold. The generated code is not a black box โ€” it's a starting point you own and edit. 3. **Hand-wire only as a last resort**, and when you do, Loco gives you a small number of well-defined seams to do it safely โ€” `Hooks`, `Initializer`, `SharedStore`, `before_routes`/`after_routes` โ€” rather than forcing you to fork the framework or reassemble `main()` from scratch. This ordering is the single idea that explains most of what looks, from a plain-Axum perspective, like "magic": it isn't magic, it's a library of pre-wired decisions with an escape hatch at every layer. ## What "hand-wiring" looks like without it A typical Axum service starts every project by re-deciding things that have already been decided a thousand times: which connection-pool settings, which logging crate, how to get the DB handle into every handler, how config and secrets flow in, how a background job survives a restart. None of these decisions are hard, but making them **again**, per project, is where hours disappear and where inconsistency creeps in between a team's services. Loco's [`coming-from-axum`](/docs/explanation/coming-from-axum) page walks through this delta concretely (pool setup, `AddExtensionLayer` state wiring, `env_logger` vs `tracing`, `main.rs` assembly) for a real reference app. The short version: every one of those steps becomes either a YAML key or a generator invocation in Loco, and the underlying Axum `Router`/`State`/extractor model is unchanged โ€” so nothing about Axum's own learning curve is hidden from you. ## What Loco integrates Batteries, concretely, means the following are already implemented, tested, and reachable from `AppContext` or a `Hooks` default, rather than left as an exercise: - **Routing & the request pipeline** โ€” `AppRoutes`/`Routes` compile down to a real `axum::Router`; a documented, ordered stack of middleware (payload limits, CORS, compression, timeouts, security headers, request IDs, a static file server, a dev-mode fallback page, and more) is available with a config flip rather than a `tower::Layer` you write by hand. See [Architecture](/docs/explanation/architecture) and the [middleware catalog](/docs/reference/middleware). - **Data & persistence** โ€” Sea-ORM 2.0 entities, migrations, and a query/pagination layer are generated from a compact field-type DSL (`cargo loco generate model ...`), not written by hand column-by-column. - **Background processing** โ€” one `BackgroundWorker` trait and one `perform_later` call site work unmodified against three interchangeable queue backends (Redis, Postgres, SQLite); see [The background-processing model](/docs/explanation/background-processing-model). - **Scheduling & tasks** โ€” a cron-like scheduler (English or cron syntax) and ad-hoc CLI-invokable tasks, both driven from the same `Tasks`/`Hooks` registration points, no separate process supervisor to build. - **Caching, storage, mail** โ€” a `Cache` with in-memory/Redis/null backends, a multi-driver `Storage` abstraction (local/memory/S3/Azure/GCS) with single and replicated (mirror/backup) strategies, and a `Mailer` with SMTP/STARTTLS/implicit-TLS and a stub-for-tests mode โ€” each a field on `AppContext`, each swappable by config or feature flag rather than by rewriting call sites. - **Views** โ€” server-rendered Tera templates, JSON views, or a single-binary `embedded_assets` build, chosen without touching controller code. See [Views and assets](/docs/explanation/views-and-assets). - **Auth & security** โ€” JWT (HS512 by default, multi-location token extraction) and API-key extractors implementing the same `FromRequestParts` pattern as everything else in Axum. - **Operability** โ€” structured `tracing` logging with sane third-party filtering out of the box, `/_ping`/`/_health`/`/_readiness` endpoints, a `cargo loco doctor` diagnostic command, and a `cargo loco routes`/`middleware` introspection CLI. - **Configuration** โ€” one typed `Config` struct, one environment-resolution rule, one file-precedence rule, and Tera's own `get_env` for secrets โ€” see [The configuration model](/docs/explanation/configuration-model). None of this requires a plugin marketplace or a runtime registry: it's all compiled into `loco-rs` behind Cargo feature flags (see the [feature-flags reference](/docs/reference/feature-flags)), so an app only pays for what it turns on. ## The corollary: escape hatches, not walls "Batteries included" only works as a philosophy if it doesn't become "batteries mandatory." Every built-in in Loco has a documented seam for replacing or bypassing it: - Don't like the default middleware stack? Override `Hooks::middlewares` and return your own `Vec>`. - Want a raw Axum router mounted verbatim? `Hooks::before_routes`/`after_routes` hand you a real `axum::Router` to mutate directly โ€” the [Coming from Axum](/docs/explanation/coming-from-axum) page shows this as the literal drop-in path for existing Axum code. - Need a service that isn't a first-class `AppContext` field (a third-party API client, a feature-flag SDK)? `AppContext.shared_store` is a type-keyed DI container built for exactly that โ€” see [AppContext and dependency injection](/docs/explanation/appcontext-and-di). - Want to own the tracing/logging stack yourself? Return `Ok(true)` from `Hooks::init_logger` and Loco steps aside. - Want a different view engine than Tera? Implement `ViewRenderer` and swap it in via an `Initializer`. This is the same shape as Rails' "convention over configuration," reframed for a language where a global mutable app instance isn't an option: Loco supplies the convention as a compiled-in default, and the configuration/override points are explicit, typed, and ordered โ€” not implicit and discoverable only by reading source. The rest of this Explanation cluster works through each of those seams โ€” boot lifecycle, DI, config loading, background jobs, views, and the Axum relationship โ€” in more depth. --- # Architecture: the request lifecycle Source: https://loco.rs/docs/explanation/architecture/ A Loco app has two distinct timelines that are worth keeping separate in your head: **boot** (runs once, assembles everything the app needs) and **request handling** (runs per HTTP request, through a fixed pipeline). This page walks both, and explains the one piece of ordering that surprises almost everyone the first time: middleware runs in the *reverse* of the order you list it in. ## Boot: from `StartMode` to a running app Everything starts from `src/boot.rs`, driven by the `Hooks` trait your `App` implements (the exhaustive method-by-method reference is [Hooks trait](/docs/reference/hooks)). At a high level: ```text cargo loco start โ”‚ โ–ผ Hooks::load_config(env) โ†’ Config (default: env.load()) โ”‚ โ–ผ create_context::(env, config) โ†’ AppContext (db, mailer, queue, cache, storage wired up) โ”‚ Hooks::after_context(ctx) can rewrite ctx here โ–ผ db::converge + bgworker::converge (migrations / queue setup, if applicable) โ”‚ โ–ผ run_app::(mode, ctx) โ†’ BootResult โ”‚ โ”œโ”€ Hooks::before_run(&ctx) โ”‚ โ”œโ”€ Hooks::initializers(&ctx) โ†’ before_run() on each Initializer โ”‚ โ”œโ”€ Hooks::routes(&ctx) โ†’ AppRoutes โ”‚ โ”œโ”€ Hooks::before_routes / after_routes(router, &ctx) โ”‚ โ”œโ”€ Hooks::middlewares(&ctx) โ†’ Vec> โ”‚ โ””โ”€ after_routes() on each Initializer โ–ผ start::(boot, server_config) โ†’ binds the socket, spawns the scheduler if requested, calls Hooks::serve(...), prints the banner ``` Each `Hooks` method in that chain has a sensible default (see the reference for the exact signatures and defaults) โ€” a minimal `App` only needs to implement `app_name`, `boot`, `routes`, `connect_workers`, `register_tasks`, and (with a database) `truncate`/`seed`. Everything else โ€” logging setup, config loading, the middleware stack, initializer wiring โ€” is a provided method you override only when you need to change it. ### `StartMode`: what actually runs in this process `boot()` receives a `StartMode` that determines which of the app's subsystems are live in *this* process: | Mode | Server | Worker | Scheduler | |---|---|---|---| | `ServerOnly` | yes | no | no | | `ServerAndWorker` | yes | yes (same process) | no | | `ServerAndScheduler` | yes | no | yes | | `WorkerOnly { tags }` | no | yes, filtered by tag | no | | `WorkerAndScheduler { tags }` | no | yes, filtered by tag | yes | | `All` | yes | yes | yes | `StartMode` exists because "the web server" and "the thing that drains the job queue" don't have to be the same OS process โ€” in fact for anything beyond a single-dyno deployment you usually *want* them separate, so you can scale workers and the HTTP tier independently. `cargo loco start --worker`, `--server-and-worker`, `--scheduler`, and `--all` map directly onto these variants (`cargo loco start` alone is `ServerOnly`). The worker only actually runs if `workers.mode` in config is `BackgroundQueue` โ€” see [The background-processing model](/docs/explanation/background-processing-model) for why. ### `AppContext` is assembled once, here `create_context` is the one place `AppContext`'s eight fields (`environment`, `db`, `queue_provider`, `config`, `mailer`, `storage`, `cache`, `shared_store`) get their real values, before `Hooks::after_context` gets a final chance to post-process the struct (e.g. to stash a custom service into `shared_store`). Everything downstream โ€” routing, middleware, handlers, background workers, tasks, the scheduler โ€” receives the *same* `AppContext` value (it's cheaply `Clone`), which is why it's the natural place to reach for shared state. See [AppContext and dependency injection](/docs/explanation/appcontext-and-di) for the full story on that struct and its `shared_store` extensibility slot. ## Request handling: the onion Once boot finishes, `AppRoutes::to_router` has compiled your routes plus the middleware stack into one real `axum::Router`. A request's journey through it looks like this: ```text inbound request โ”‚ โ–ผ โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ” โ”‚ powered_by (outermost โ€” first) โ”‚ โ”‚ โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ” โ”‚ โ”‚ โ”‚ fallback (non-prod) โ”‚ โ”‚ โ”‚ โ”‚ โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ” โ”‚ โ”‚ โ”‚ โ”‚ โ”‚ request_id โ”‚ โ”‚ โ”‚ โ”‚ โ”‚ โ”‚ โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ” โ”‚ โ”‚ โ”‚ โ”‚ โ”‚ โ”‚ โ”‚ logger โ”‚ โ”‚ โ”‚ โ”‚ โ”‚ โ”‚ โ”‚ โ”‚ โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ” โ”‚ โ”‚ โ”‚ โ”‚ โ”‚ โ”‚ โ”‚ โ”‚ โ”‚ โ€ฆ cors, etag, โ”‚ โ”‚ โ”‚ โ”‚ โ”‚ โ”‚ โ”‚ โ”‚ โ”‚ โ”‚ compression โ€ฆ โ”‚ โ”‚ โ”‚ โ”‚ โ”‚ โ”‚ โ”‚ โ”‚ โ”‚ โ”‚ โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ” โ”‚ โ”‚ โ”‚ โ”‚ โ”‚ โ”‚ โ”‚ โ”‚ โ”‚ โ”‚ โ”‚limit_payloadโ”‚ โ”‚ โ”‚ โ”‚ โ”‚ โ”‚ โ”‚ โ”‚ โ”‚ โ”‚ โ”‚ โ”‚ (innermost)โ”‚ โ”‚ โ”‚ โ”‚ โ”‚ โ”‚ โ”‚ โ”‚ โ”‚ โ”‚ โ”‚ โ”‚ โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ” โ”‚ โ”‚ โ”‚ โ”‚ โ”‚ โ”‚ โ”‚ โ”‚ โ”‚ โ”‚ โ”‚ โ”‚ โ”‚handlerโ”‚ โ”‚ โ”‚ โ”‚ โ”‚ โ”‚ โ”‚ โ”‚ โ”‚ โ”‚ โ”‚ โ”‚ โ”‚ โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”˜ โ”‚ โ”‚ โ”‚ โ”‚ โ”‚ โ”‚ โ”‚ โ”‚ โ”‚ โ”‚ โ”‚ โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜ โ”‚ โ”‚ โ”‚ โ”‚ โ”‚ โ”‚ โ”‚ โ”‚ โ”‚ โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜ โ”‚ โ”‚ โ”‚ โ”‚ โ”‚ โ”‚ โ”‚ โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜ โ”‚ โ”‚ โ”‚ โ”‚ โ”‚ โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜ โ”‚ โ”‚ โ”‚ โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜ โ”‚ โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜ โ”‚ โ–ผ response, unwinding back out the same layers ``` ### Why the order is LIFO `AppRoutes::to_router` builds this onion by calling `app.layer(...)` once per middleware, in the order `default_middleware_stack` lists them (`limit_payload` first, `powered_by` last). Axum's `Router::layer` wraps the *existing* router with each new layer as the new **outermost** layer. The consequence, stated directly in the framework's own source comment: > "the LAST middleware is the FIRST to meet the outside world (a user request starting), or 'LIFO' order" โ€” `src/controller/app_routes.rs` So the coding/config order and the runtime order are opposites: routes are added first (they become the innermost core of the onion โ€” the thing every layer eventually wraps), and the *last* middleware added (`powered_by`, at the bottom of the default list) is the *first* thing an inbound request actually passes through. `request_id` is deliberately near the end of the list (so it's near the *outside* at runtime) precisely because every request needs its ID assigned as early in its life as possible. This matters practically whenever you reach for `Routes::layer(...)` to attach a `tower::Layer` to one controller, or override `Hooks::middlewares` to reorder the default stack โ€” get the direction backwards and a middleware that's supposed to run before authentication ends up running after it. The full ordered list, with each middleware's config key and default-enabled state, is in the [middleware catalog reference](/docs/reference/middleware); [Add middleware ยง 5](/docs/how-to/add-middleware#5-write-a-custom-middleware) shows how to hand-write a `tower::Layer` middleware of your own that participates in the same onion. ## Where this leaves the handler By the time your handler runs, it's just a normal Axum handler function taking normal Axum extractors (`State`, `Json`, `Path`, and so on โ€” nothing Loco-specific is required). The response side of the onion is symmetric: your `impl IntoResponse` (or Loco's `format::` helpers) produces a `Response`, which then unwinds back out through the same middleware stack in reverse, each layer getting a chance to post-process it (compression, headers, logging the outcome) before it leaves the process. For the mechanics of how routes get their axum `Router` shape (`AppRoutes`, `Routes`, prefixing, nesting) see [Add a controller](/docs/how-to/add-controller); for how this whole model maps onto plain Axum concepts you already know, see [Coming from Axum](/docs/explanation/coming-from-axum). --- # AppContext and dependency injection Source: https://loco.rs/docs/explanation/appcontext-and-di/ Rust doesn't let you reach for a mutable global app instance the way Rails or Django can โ€” there's no ambient `current_app` to mutate from anywhere. Loco's answer to "how does a handler get at the database, the cache, the mailer, my own service client?" is a single, cheaply-cloneable struct threaded through the whole app: `AppContext`. This page explains why that struct has the shape it does, and how `SharedStore` extends it to things Loco itself doesn't know about. ## `AppContext` as the one shared-state object Every handler, background worker, task, and scheduled job in a Loco app receives the same `AppContext` value โ€” assembled once at boot (see [Architecture](/docs/explanation/architecture)) and cloned cheaply wherever it's needed, because most of its fields are already `Arc<...>`-wrapped or otherwise cheap to clone. It carries eight fields: ```rust #[derive(Clone, FromRef)] pub struct AppContext { pub environment: Environment, #[cfg(feature = "with-db")] pub db: DatabaseConnection, pub queue_provider: Option>, pub config: Config, pub mailer: Option, pub storage: Arc, pub cache: Arc, pub shared_store: Arc, } ``` The full field-by-field reference โ€” types, feature gates, purpose โ€” lives in [AppContext & prelude](/docs/reference/app-context). What's worth explaining here is the *design*, not the field list: - **One struct, not seven services.** Rather than injecting the DB pool, the cache, the mailer, storage, and the queue as five separate pieces of Axum state, Loco bundles them into one `AppContext` and derives `FromRef` on it. That derive is what lets a handler ask for exactly the piece it needs โ€” `State` or `State>` โ€” while a background worker or the boot sequence can still ask for the whole thing. You get the ergonomics of narrow, single-purpose extraction without the boilerplate of hand-writing `FromRef` impls for every field. - **`db` is the only field that's compiled away, not just empty.** Every other field degrades gracefully when unconfigured (`None` for `mailer`/`queue_provider`, a `Null` driver for `cache`/`storage`) โ€” an app with no mailer configured still has a `mailer: Option` field, just set to `None`. `db` is different: with the `with-db` feature off, the field doesn't exist on the struct at all, which is a compile-time way of saying "this deployment shape genuinely has no database," rather than a runtime `Option` a caller could forget to check. - **It's the same value everywhere.** Because `create_context` builds `AppContext` exactly once at boot and every subsystem downstream โ€” routing, middleware, handlers, `connect_workers`, tasks, the scheduler โ€” receives that same value, there's no risk of a handler and a background job disagreeing about which DB pool or cache instance is "the real one." This is also why `Hooks::after_context(ctx: AppContext) -> Result` (which runs immediately after assembly, before routes are built) is the one hook that can rewrite the context itself โ€” it's your last and only chance to add something to it before it's handed out everywhere. ## The gap `AppContext`'s fixed fields can't fill `AppContext`'s eight fields cover what *every* Loco app needs. They obviously can't cover what *your* app needs โ€” a third-party API client, a feature-flag SDK handle, an app-specific cache of precomputed data. Two options exist, and they aren't in tension, they're the same design carried into two different lifecycles: - **`Initializer`** (see [Add middleware](/docs/how-to/add-middleware)) is the *install-time* extension point: a trait with `before_run`, `after_routes`, and `check` hooks, used to wire a whole piece of infrastructure into the app (register an Axum `Extension`, mount a session layer, install a doctor health check). - **`SharedStore`** is the *storage* extension point: a place to actually hold a value of a type Loco has never heard of, so it can be read back out in a handler, a worker, or anywhere else `AppContext` reaches. In practice they compose: you typically construct the value you want to share and call `ctx.shared_store.insert(..)` from inside `Hooks::after_context` (the same hook that runs once, right after the context is built), then read it back with the extractor below. ## `SharedStore`: a type-keyed DI container `AppContext.shared_store: Arc` is a small, concurrent, heterogeneous store โ€” internally a `DashMap` keyed by `TypeId`, so it can hold one value of any number of distinct `'static + Send + Sync` types at once. Its API is deliberately minimal: | Method | What it does | |---|---| | `insert(&self, val: T)` | Store (or overwrite) the value for type `T`. | | `get(&self) -> Option` | Fetch a *cloned* copy of `T`, if present. | | `get_ref(&self) -> Option>` | Fetch a borrowed `Deref` guard โ€” for types that aren't `Clone`, or when a clone would be wasteful. | | `remove(&self) -> Option` | Take the value back out. | | `contains(&self) -> bool` | Check presence without touching the value. | ### Reading it back: two paths, and a naming hazard to watch for There are two distinct `SharedStore` types reachable from `loco_rs::prelude`, and confusing them is the single easiest mistake to make with this feature: - **`loco_rs::app::SharedStore`** โ€” the container type above, held as `ctx.shared_store`. - **`loco_rs::controller::extractor::shared_store::SharedStore(pub T)`** โ€” an Axum `FromRequestParts` *extractor*, also re-exported as `SharedStore` from the prelude, that reaches into `ctx.shared_store`, clones out a `T`, and hands it to your handler as a plain argument: ```rust #[debug_handler] pub async fn index( SharedStore(service): SharedStore, ) -> impl IntoResponse { tracing::info!("api key: {}", service.api_key); format::empty() } ``` If `T` was never inserted, the extractor rejects the request with `Error::InternalServerError` โ€” which is the right failure mode for "the app forgot to wire something up," as opposed to a client-facing 4xx. For a type that isn't `Clone`, skip the extractor and reach for `ctx.shared_store.get_ref::()` directly off an ordinary `State` extraction instead โ€” you get a reference-counted guard rather than a copy. ## Why this shape, instead of a global registry The alternative designs are familiar from other ecosystems โ€” a service locator singleton, or a compile-time DI container that resolves a dependency graph. Loco deliberately avoids both: - A **global mutable singleton** isn't something safe Rust gives you for free, and reaching for `unsafe`/`OnceCell`-style globals to fake one would undermine the exact guarantee (no data races, no invisible mutation from anywhere) that makes Rust worth using for a server in the first place. - A **compile-time DI framework** (resolving constructor graphs, macro-generated wiring) adds a second configuration language on top of Rust itself, for a problem `AppContext` plus `SharedStore` already solves at the cost of one `insert`/`get` pair. `SharedStore` is intentionally closer to "a typed, thread-safe `HashMap>` you're handed for free" than a general DI framework โ€” it doesn't manage lifecycles, doesn't resolve dependencies between the things you store, and doesn't enforce a registration order. That's the trade: less power, but no new mental model to learn, and it composes with ordinary Rust ownership rather than working around it. For most apps, "stash a client in `after_context`, extract it with `SharedStore`" is the entire pattern. See the [AppContext & prelude reference](/docs/reference/app-context) for the exhaustive field/method signatures, and [Add middleware](/docs/how-to/add-middleware) for a related worked example (a custom `MiddlewareLayer`/`Initializer`-style extension wired into the app). --- # Explanation Source: https://loco.rs/docs/explanation/ --- # The configuration model Source: https://loco.rs/docs/explanation/configuration-model/ Every subsystem described elsewhere in this cluster โ€” the DB pool, the queue backend, the cache, the mailer, the middleware stack โ€” is switched on and tuned from one place: a per-environment YAML file, deserialized into one typed `Config` struct. This page explains the small number of rules that govern how that file is found, rendered, and trusted with secrets. For the exhaustive key-by-key listing, see the [Configuration reference](/docs/reference/configuration). ## Why a typed config struct, not ad-hoc env vars The alternative most hand-rolled Axum services fall into is reading a scatter of environment variables (`DATABASE_URL`, `PORT`, `RUST_LOG`, ...) directly in `main()`, each parsed and defaulted slightly differently, with no single place that shows what the app's full configuration surface even is. Loco instead deserializes the whole environment file into one `Config` struct (`src/config/mod.rs`), with every sub-area โ€” `server`, `database`, `logger`, `queue`, `cache`, `mailer`, `auth`, `workers` โ€” as a typed field with `serde` defaults where a sane one exists and a hard requirement (a missing-field deserialize error at boot) where there isn't one. This is the same "prefer a built-in over hand-wiring" bias covered in [Why batteries included](/docs/explanation/why-batteries-included), applied specifically to app configuration: you get one document that *is* the app's configuration surface, checked at boot rather than discovered at the call site that happens to read an env var. ## Which environment, and which file Two independent questions get resolved before any YAML is even opened: **Which environment name?** `environment::resolve_from_env()` checks, in order: 1. `LOCO_ENV` 2. `RAILS_ENV` 3. `NODE_ENV` 4. falls back to `"development"` The `RAILS_ENV`/`NODE_ENV` fallbacks exist so that a Loco app dropped into infrastructure already standardized on Rails- or Node-style environment naming doesn't need a separate variable just for Loco. **Which file, for that environment name?** `Config::from_folder` picks the *first* file that exists, in this order: 1. `{env}.local.yaml` 2. `{env}.yaml` If neither exists, boot fails outright with "no configuration file found." The `.local.yaml` tier is the mechanism for machine-local overrides โ€” a developer's own DB credentials, a locally-running service's port โ€” that should never be checked into version control alongside the shared `{env}.yaml`. It's a convention, not a special format: `development.local.yaml` is parsed with exactly the same rules as `development.yaml`, it's just consulted first and expected to be gitignored. Both tiers are read from a `config/` folder by default; `LOCO_CONFIG_FOLDER` overrides that location, which matters for deployments that mount configuration from somewhere other than the app's own source tree. ## The YAML is a Tera template first, a config file second Before `serde_yaml` ever sees the file, its entire contents are rendered as a [Tera](https://keats.github.io/tera/) template (`Tera::one_off(.., autoescape = false)`). This is a small design choice with a real consequence: it's what makes patterns like this legal inside a Loco config file at all โ€” ```yaml server: port: {{ get_env(name="NODE_PORT", default=5150) }} ``` `get_env(name=.., default=..)` here is **Tera's own built-in function**, not something Loco registers. Loco doesn't have a custom templating layer bolted onto YAML โ€” it reuses a general-purpose template engine's existing capability (reading env vars, with a default) so that config files can be static-looking YAML *and* environment-aware at the same time, without inventing a second interpolation syntax. Anything else Tera can do in a one-off render (conditionals, other built-in functions) is available in a config file too, though `get_env` covers the overwhelming majority of real use. The practical implication: a config value that looks hardcoded may not be โ€” always check for `{{ }}` before assuming a YAML value is literal โ€” and a value that needs to differ between "what's checked into git" and "what's true on this machine/host" belongs behind `get_env`, not behind a second config file. ## Secrets: a convention, not a vault type There is no dedicated `Secret` type or vault integration built into `Config`. A JWT secret, an SMTP password, a database URI โ€” these are all just `String` fields on ordinary config structs (`auth.jwt.secret`, `mailer.smtp.auth.password`, `database.uri`). The secrets *model* is the composition of two things already covered above: - Secret values are injected via `get_env(name=..)` at render time, so the checked-in YAML never contains the literal secret โ€” only the name of the environment variable to read it from. - Machine-local secrets that shouldn't even have an env-var name in shared code can go in `{env}.local.yaml` instead, which is expected to be gitignored entirely. This is deliberately unopinionated about *where* the environment variable itself comes from โ€” a `.env` file, a process manager, a secrets manager injecting env vars at container start โ€” because that's an operational concern outside the framework's scope, and Loco's contract stops at "a `String` field, populated from `get_env` or a local override file." One consequence worth knowing in advance: `auth.jwt.secret` specifically is expected to be valid **base64** (it's fed to `jsonwebtoken`'s `from_base64_secret` constructors) โ€” a plain passphrase string will fail at the point the JWT extractor tries to decode it, not at config-load time. ## What this buys you day to day Put together, the model gives you: one typed document per environment describing the whole app, a predictable override tier for anything machine-specific, and a templating escape hatch for anything environment-dependent โ€” all without a second configuration DSL or a runtime service to stand up just to manage config. Changing a pool size, flipping a middleware on, or pointing at a different queue backend (see [The background-processing model](/docs/explanation/background-processing-model)) is a YAML edit and a restart, not a recompile โ€” the same "config, not code" theme that runs through the rest of Loco's built-ins. See the [Configuration reference](/docs/reference/configuration) for every key, type, and default across every sub-config struct. --- # The background-processing model Source: https://loco.rs/docs/explanation/background-processing-model/ Loco lets you write one `BackgroundWorker` implementation and one `perform_later` call site, then choose โ€” by config, not by code change โ€” whether jobs are durably queued in Redis, Postgres, or SQLite, or not durably queued at all. This page explains the design that makes that swap safe, not the step-by-step of adding a worker (that's [Add a background worker](/docs/how-to/add-worker)) or the exhaustive config keys (that's the [Configuration reference](/docs/reference/configuration#queue) and [Choose a queue backend](/docs/how-to/choose-queue-backend)). ## One trait, one call site, three backends ```rust #[async_trait] pub trait BackgroundWorker { fn build(ctx: &AppContext) -> Self; async fn perform(&self, args: A) -> Result<()>; // + queue(), tags(), class_name(), perform_later(), perform_later_with_priority() } ``` You implement `perform`, register the worker in `connect_workers`, and enqueue work with `MyWorker::perform_later(&ctx, args).await?`. Nothing in that call references which backend is active โ€” that's decided entirely by `queue.kind` in config (`Redis` | `Postgres` | `Sqlite`), read at boot by `create_queue_provider`. This is the same "config over code" bias covered in [Why batteries included](/docs/explanation/why-batteries-included), applied to durability and delivery semantics: swapping backends is an operational decision (what's already running in your infrastructure, what latency/throughput profile you need), not a rewrite. `perform_later` (and its sibling `perform_later_with_priority`) returns `Result` โ€” the job's id โ€” rather than `Result<()>`. That return value matters because it's what you'd hand to `cargo loco jobs cancel`/`requeue` or log for later correlation; treat any `perform_later` call site that discards its return value as intentionally choosing not to track the job, not as the only option. ## The two SQL backends share one implementation Postgres and SQLite queueing used to be two independent, parallel implementations that had to be kept in sync by hand. As of the 1.0 line they're de-duplicated behind one internal `Driver` trait: ```rust pub(crate) trait Driver { type Pool; fn idle_count(&self) -> ...; async fn dequeue(pool: &Self::Pool, tags: &[String]) -> ...; async fn complete_job(pool: &Self::Pool, id: ..., interval: ...) -> ...; async fn fail_job(pool: &Self::Pool, id: ..., error: ...) -> ...; } ``` The `Job` model, the polling/registration loop (`JobRegistry`), panic-catching around `perform`, and the run-loop machinery all live once, generic over `Driver`. `PgDriver` and `SqliteDriver` only need to supply the three DB operations above plus a pool type โ€” everything else (worker registration, tag filtering, graceful cancellation) is shared code, not two copies that can drift. This is why Postgres and SQLite have identical *behavior* (same admin operations, same priority semantics, same job lifecycle) even though the underlying SQL is necessarily different โ€” one uses `FOR UPDATE SKIP LOCKED` for concurrent dequeue, the other simulates it with a lock table since SQLite has no equivalent. Redis, being architecturally different (no SQL, no row locking), keeps its own independent run loop rather than implementing `Driver` โ€” but is still held to the same external contract (the same `Queue` API, the same job lifecycle, the same admin operations) from the outside. That shared contract is what lets `cargo loco jobs cancel|tidy|purge|dump|import|requeue` work identically regardless of which backend is configured โ€” including Redis, which historically lagged the SQL backends on admin-operation support but is now at parity. ## Priority: one semantic, three storage strategies All three backends dequeue by priority first, then by age: a higher `i32` priority value is more urgent, ties break by earlier `run_at`, then by a stable job id. How each backend *stores* that ordering differs with its storage model, which is worth understanding since it explains the backends' relative strengths: - **Postgres / SQLite** add a `priority` column and an `ORDER BY priority DESC, run_at, id` on dequeue (existing pre-1.0 tables are auto-migrated to add the column). This is a natural fit for a row store with a query planner. - **Redis** has no query planner to lean on, so priority is encoded structurally: jobs live in a sorted set (ZSET) scored by *negative* priority, so the highest-priority job sorts first under `ZRANGE`'s ascending order โ€” with `run_at`/id used as an explicit tie-break in the dequeue logic, since the score alone can't carry three levels of ordering. Redis additionally supports **named queues** (`queue.queues: [high, low, ...]`, first = most important) with two independent workers backed by the default `["default", "mailer"]` queues, and a `Worker::queue()` override to route a specific worker's jobs into one. This is a coarser-grained tool than per-job priority โ€” named queues partition *which pool of workers* picks up a job, while `priority` decides ordering *within* that pool โ€” and the two compose (a named queue can still be priority-ordered internally). ## Worker modes: trading durability for simplicity `workers.mode` is a separate axis from the queue backend โ€” it decides whether a persistent queue is even in the picture: | Mode | Durable across restarts? | Where jobs run | Typical use | |---|---|---|---| | `BackgroundQueue` (default) | yes | a separate worker process/thread, dequeuing from the configured `queue:` backend | production | | `ForegroundBlocking` | n/a โ€” runs inline | the calling request/task, synchronously | tests, where you want deterministic execution before asserting on side effects | | `BackgroundAsync` | no โ€” lost on crash | `tokio::spawn` in the same process | low-stakes, best-effort work where standing up a queue backend isn't worth it | The reason this is a mode switch rather than a code difference is the same reason the backend is a config switch: `perform_later` and `perform` don't change, so a worker written and tested under `ForegroundBlocking` behaves identically once the app is switched to `BackgroundQueue` in production โ€” the only thing that changes is *when* and *where* `perform` actually executes, not its logic. ## Choosing a backend There's no universally correct choice โ€” the three backends trade off along real infrastructure axes: - **Redis** โ€” lowest latency, named/priority queues, no schema to manage; the right default if Redis is already part of your stack. - **Postgres** โ€” no new infrastructure if your app's primary database is already Postgres, and `FOR UPDATE SKIP LOCKED` gives solid concurrent-worker throughput. - **SQLite** โ€” zero extra infrastructure at all, good for small deployments or local development; the lock-table fallback for concurrency makes it less suited to a large number of concurrent workers than the other two. See [Choose a queue backend](/docs/how-to/choose-queue-backend) for the concrete config for each, and the [Configuration reference](/docs/reference/configuration#queue) for every field and its default. --- # Views and assets Source: https://loco.rs/docs/explanation/views-and-assets/ "How does this response get to the browser" has three different shapes in a Loco app โ€” server-rendered HTML, a JSON API behind a separately-built SPA, or a fully embedded single binary โ€” and Loco lets you pick without changing how controllers work. This page explains the model behind that choice. For the how-to of writing a specific view or wiring the static middleware, see [Render server-side views](/docs/how-to/render-views); for the exhaustive middleware/config keys, see the [middleware catalog](/docs/reference/middleware) and [feature flags reference](/docs/reference/feature-flags). ## The separation controllers don't have to care about Loco keeps the traditional split of responsibilities โ€” a controller parses the request and calls into models; a *view* is responsible only for shaping the final response โ€” and makes that split concrete with one trait: ```rust pub trait ViewRenderer { fn render(&self, key: &str, data: S) -> Result; } ``` A controller never talks to Tera, or to any specific templating engine, directly โ€” it takes a `v: impl ViewRenderer` (typically via the `ViewEngine` extractor) and calls `format::render().view(&v, "home/hello.html", data!({..}))`. The engine behind that trait is decided once, at the `Initializer` level โ€” swap `ViewEngine` for `ViewEngine` and every existing call site keeps compiling, because it was only ever coupled to the trait, not to Tera specifically. This is the same escape-hatch pattern described in [Why batteries included](/docs/explanation/why-batteries-included): Tera is the built-in, `ViewRenderer` is the seam you use if you need something else. For pure JSON APIs, "the view" can be as simple as a `#[derive(Serialize)]` struct shaped by hand and returned via `format::json(..)` โ€” no template engine in the loop at all. Most real apps mix both: JSON views for an API surface, Tera views for a handful of server-rendered pages (an admin panel, a marketing page, an email-verification landing page). ## Three deployment shapes, one set of controller code ### Server-side rendering (Tera) The default shape: `TeraView` reads templates from `assets/views/**/*.html` on disk at request time, alongside static files served from `assets/static/` through the `static` middleware. This is the natural choice when the app itself renders the HTML the browser gets โ€” templates can be edited without a rebuild (`cargo loco start` picks up a changed `.html` file on the next request), which matters during active UI development. ### Client-side rendering (SPA) Here Loco's job shrinks to two things: serve a JSON API, and serve the SPA's *built* static assets (the `dist/`-style output of a separate frontend build) through the same `static` middleware, usually with a fallback to `index.html` for client-side routing. There's no `TeraView` in the picture for the API surface at all โ€” the split between "backend serves data" and "frontend owns rendering" is total, and Loco's role is just: API controllers, plus a static file server pointed at wherever the frontend build lands. ### `embedded_assets`: one flag, two subsystems swapped together `embedded_assets` is a build-time Cargo feature that changes *where the bytes come from* without changing a single controller or view call: ```toml loco-rs = { version = "...", features = ["embedded_assets"] } ``` With it enabled, the entire `assets/` directory โ€” templates *and* static files โ€” is scanned at compile time and embedded directly into the binary. What makes this worth calling out as a distinct architectural idea, rather than just "a smaller deployment," is that it isn't one subsystem being swapped: **both** the Tera view engine and the static-assets middleware are simultaneously replaced with embedded-reading variants (`views::engine_embedded` in place of `views::engine`, `static_assets_embedded` in place of `static_assets`) behind the same `#[cfg(embedded_assets)]` gate. One flag flips two independently-registered subsystems in lockstep, so a template lookup and a static-file request both resolve against the same in-binary asset table rather than one reading disk and the other reading memory. From application code, nothing changes โ€” the `ViewEngine`/`ViewRenderer` and static-middleware config keys are identical either way. The trade-off is the mirror image of SSR's live-editing convenience: a single-binary deploy with atomic code/asset updates and no filesystem asset directory to manage in production, at the cost of a full recompile for any asset change and a larger binary. Projects often use plain filesystem assets in development (fast iteration) and flip `embedded_assets` on for release builds (simpler deployment) โ€” the same controller and view code runs unmodified in both. ## Why this is one flag and not per-subsystem toggles It would be possible to let the view engine and the static middleware be embedded independently โ€” but that would create deployment shapes where templates are baked into the binary while static files are read from disk (or vice versa), with no clear operational benefit and a real risk of the two drifting (a rebuild updates embedded templates but not the separately-deployed static folder, or the reverse). Coupling both under one feature flag makes "embedded" a single, coherent deployment mode rather than a matrix of partial states to reason about โ€” consistent with the broader theme in [Why batteries included](/docs/explanation/why-batteries-included) of collapsing a class of decisions into one well-tested default, with a clearly labeled way to opt out (here, just don't enable the feature). See [Render server-side views](/docs/how-to/render-views) for the concrete `assets/` directory layout, writing a Tera view and its controller wiring, and swapping in a custom `ViewRenderer`; see the [feature flags reference](/docs/reference/feature-flags) for `embedded_assets`'s place in the full flag matrix, and the [middleware catalog](/docs/reference/middleware#8-static) for the `static` middleware's config keys. --- # Coming from Axum Source: https://loco.rs/docs/explanation/coming-from-axum/ If you already know [Axum](https://crates.io/crates/axum), you already know most of Loco โ€” the framework compiles down to a real `axum::Router`, uses the same `FromRequestParts`/`FromRequest` extractor model, and pins Axum 0.8. This page is about the delta: what Loco pre-wires on top, and how the concepts you already have a mental model for (extractors, `State`, the `Router`) map onto Loco's names for the same things, plus the mechanics of moving a real Axum codebase over; for the request lifecycle these concepts sit inside, see [Architecture](/docs/explanation/architecture). ## The core claim: nothing is hidden, a lot is pre-decided Loco is not a new web framework with an Axum-shaped API โ€” it *is* Axum, with a layer of default decisions and a `Hooks` trait that assembles them consistently across every app that uses it. Every extractor you already know still works unmodified; the state type is just a specific struct (`AppContext`) instead of whatever ad-hoc struct you'd have hand-rolled; and the middleware you'd have written as `tower::Layer`/`Service` impls yourself either already exists as a config-toggleable built-in, or you write it exactly the way you would in plain Axum and attach it the same way. [Why batteries included](/docs/explanation/why-batteries-included) covers the philosophy; this page covers the mechanical mapping. ## Concept mapping | Axum concept | Loco equivalent | What changed | |---|---|---| | Your own `main()` assembling the router, state, and `axum::serve(...)` | `Hooks::boot` โ†’ `create_app`/`create_context`, `Hooks::serve` (default calls `axum::serve` for you) | You describe *what* to wire (routes, workers, tasks) via `Hooks`; Loco's boot sequence (see [Architecture](/docs/explanation/architecture)) does the assembling. `cargo loco start` replaces a hand-written `main.rs` entirely โ€” a generated app doesn't need one. | | A hand-rolled `ApiContext` struct + `AddExtensionLayer`/`State` | `AppContext` (8 fields: `environment`, `db`, `queue_provider`, `config`, `mailer`, `storage`, `cache`, `shared_store`), `#[derive(FromRef)]` | Same idea (one struct, threaded as Axum `State`) but pre-built with the pieces almost every service needs, and `FromRef`-derived so you can extract a single field (`State`) instead of always the whole context. See [AppContext and dependency injection](/docs/explanation/appcontext-and-di). | | `Router::new().route("/x", get(handler))` | `Routes::new().add("/x", get(handler))`, collected into `AppRoutes` | `Routes`/`AppRoutes` are a thin builder over the same `MethodRouter`/`Router` types โ€” `add`, `prefix`, `nest_route`, `merge`, and `layer` all compile down to the Axum calls you'd write by hand, plus route metadata (`cargo loco routes` listing) that a plain Axum `Router` can't give you back. | | `.layer(SomeTowerLayer::new())` on a router or route | `Routes::layer(..)` (per-route) or `Hooks::middlewares` (app-wide) | Identical `tower::Layer`/`Service` code โ€” Loco doesn't wrap or reinterpret Tower's traits. The app-wide default stack (CORS, compression, timeouts, etc.) is config-toggled rather than hand-attached; see the [middleware catalog](/docs/reference/middleware) and its LIFO ordering note in [Architecture](/docs/explanation/architecture#why-the-order-is-lifo). | | `Extension` / custom `FromRequestParts` impls for app-specific data | `State` field extraction, or `SharedStore` for anything not already a field | See [AppContext and dependency injection](/docs/explanation/appcontext-and-di) for when to reach for which. | | `dotenv` + manual env var parsing in `main` | Typed `Config` loaded from `config/{env}.yaml`, Tera's `get_env` for env-var interpolation | See [The configuration model](/docs/explanation/configuration-model). | | `env_logger`/`tracing_subscriber` set up by hand | `logger::init` (default), or return `Ok(true)` from `Hooks::init_logger` to opt out entirely and own it yourself | Loco's default filters out third-party log noise you didn't ask for while still using plain `tracing` underneath โ€” any crate emitting `tracing` events shows up the same way it would under a hand-rolled subscriber. | | A router you already have, that you don't want to restructure | Return it untouched from `Hooks::before_routes`/`after_routes` | This is the literal drop-in path: mount an existing `axum::Router` as-is and keep every extractor and handler signature you already wrote. | ## What "drop-in compatible" actually buys you Because routing metadata is optional rather than mandatory, you have a genuine choice at the boundary between "paste in existing Axum code" and "get Loco's introspection for free": - Return your existing router verbatim from `after_routes(router, _ctx)` โ€” zero changes to handler signatures, zero changes to how routes are declared, full compatibility, but `cargo loco routes` won't know about those routes (Axum doesn't expose method/path metadata off a live `Router`, which is precisely the gap `Routes`/`AppRoutes` exist to close). - Rewrite route declarations from `Router::new().route(path, method(handler))` to `Routes::new().add(path, method(handler))` โ€” this is a mechanical, same-shape edit (the handler itself, its extractors, and its return type are untouched) โ€” and get route listing, prefixing/nesting helpers, and per-route `tower::Layer` attachment (`Routes::layer`) back. Most apps end up doing the second for their own controllers and the first only for vendored or generated routers they don't want to touch. ## What Loco adds that plain Axum genuinely doesn't have Everything in the mapping table above is a *reframing* of something Axum already gives you. The following are not reframings โ€” they're capabilities with no direct Axum equivalent, because they're above the HTTP layer: - A background-job system (`BackgroundWorker`, three interchangeable queue backends) โ€” see [The background-processing model](/docs/explanation/background-processing-model). - A cron-like scheduler and a CLI task runner, both driven from the same app. - Database access via Sea-ORM with a generator that scaffolds models/migrations from a field-type DSL. - A `cargo loco` CLI: `routes`, `middleware --config`, `doctor`, `jobs`, `db`, `generate`. - Structured JWT/API-key auth extractors, a cache abstraction, and a multi-driver storage abstraction, each swappable by config rather than by code change. These are the parts of "batteries included" that live outside the request/response cycle Axum itself models โ€” which is also why they're covered by their own pages in this cluster rather than in this one's extractor/router mapping. ## A note on versions Loco 1.0 tracks Axum 0.8 and targets Rust edition 2024 (the `loco-rs` crate itself; apps generated by `loco new` currently still default to edition 2021 in their own `Cargo.toml` โ€” bump it yourself if you want 2024-edition semantics, such as the `unsafe`-required `std::env::set_var`, in your own app code). If you're moving code from an Axum service built against an older Axum release, the usual Axum 0.7โ†’0.8 migration notes apply on top of everything above โ€” nothing here changes because of Loco. --- # Upgrades Source: https://loco.rs/docs/extras/upgrades/ ## What to do when a new Loco version is out? - Create a clean branch in your code repo. - Update the Loco version in your main `Cargo.toml` - Consult with the [CHANGELOG](https://github.com/loco-rs/loco/blob/master/CHANGELOG.md) to find breaking changes and refactorings you should do (if any). - Run `cargo loco doctor` inside your project to verify that your app and environment is compatible with the new version As always, if anything turns wrong, [open an issue](https://github.com/loco-rs/loco/issues) and ask for help. ## Major Loco dependencies Loco is built on top of great libraries. It's wise to be mindful of their versions in new releases of Loco, and their individual changelogs. These are the major ones: - [SeaORM](https://www.sea-ql.org/SeaORM), [CHANGELOG](https://github.com/SeaQL/sea-orm/blob/master/CHANGELOG.md) - [Axum](https://github.com/tokio-rs/axum), [CHANGELOG](https://github.com/tokio-rs/axum/blob/main/axum/CHANGELOG.md) ## Upgrade from 0.16.x to 1.0 1.0 is a large, intentionally-breaking release โ€” the first stable Loco. Its headline change is the move to **Sea-ORM 2.0**. This section is assembled per area; start with the Sea-ORM steps, which affect every app that uses a database. Cross-check the [1.0.0 CHANGELOG](https://github.com/loco-rs/loco/blob/master/CHANGELOG.md) for anything specific to APIs you use directly. ### Toolchain: Rust 1.94+ Loco 1.0 uses Sea-ORM 2.0, whose MSRV is **Rust 1.94**. Update your toolchain: ```sh rustup update ``` ### Sea-ORM 2.0 Loco upgraded from Sea-ORM 1.1 to Sea-ORM 2.0. For most apps the migration is mechanical โ€” bump the pins and the CLI โ€” because Loco's `schema` helpers and the generated model/migration shapes absorb the API changes for you. **1. Bump the dependency pins.** In your app `Cargo.toml`: ```toml # before sea-orm = { version = "1.1", features = ["sqlx-sqlite", "sqlx-postgres", "runtime-tokio-rustls", "macros"] } # after sea-orm = { version = "2.0", features = ["sqlx-sqlite", "sqlx-postgres", "runtime-tokio-rustls", "macros"] } ``` And in your `migration/Cargo.toml`: ```toml # before sea-orm-migration = { version = "1.1.0", features = [...] } # after sea-orm-migration = { version = "2.0", features = [...] } ``` If you depend on `sqlx` directly, bump it to `0.9`. **2. Update the Sea-ORM CLI** (used by `cargo loco db entities`) to 2.0: ```sh cargo install sea-orm-cli --version '^2.0' ``` `cargo loco doctor` will now flag a Sea-ORM or Sea-ORM CLI older than 2.0. **3. Regenerate entities (recommended).** Run `cargo loco db entities` so your `src/models/_entities/` are produced by the 2.0 codegen. **4. Hand-written queries / migrations.** If you wrote custom raw SQL or custom migrations, apply these Sea-ORM 2.0 changes (the same ones Loco itself made): - Raw-`Statement` calls gain a `_raw` suffix. `db.execute(stmt)` โ†’ `db.execute_raw(stmt)`; `db.query_one(stmt)` / `db.query_all(stmt)` โ†’ `query_one_raw` / `query_all_raw`. SeaQuery statements (e.g. from `Entity::find().into_query()`) are now passed **by reference** and need no manual `.build(...)`: `db.query_all(&select)`. - `sqlx` 0.9 requires runtime-built SQL strings to be wrapped in `AssertSqlSafe(...)`: `sqlx::query(AssertSqlSafe(format!(...)))`. - Bring `ExprTrait` into scope for expression methods: `use sea_orm::ExprTrait;`. Replace `Alias::new("col")` with the bare string `"col"`. - Unsupported-backend branches should return `DbErr::BackendNotSupported { .. }` rather than panic; Sea-ORM 2.0 removed the internal panics (a new `DbErr` variant carries the case). If you `match` on `DbErr` exhaustively, add the arm. - `insert_many` no longer needs `.on_empty_do_nothing()`, and `exec_with_returning_many` is now `exec_with_returning`. **5. Note on Postgres auto-increment.** Sea-ORM 2.0 emits `GENERATED BY DEFAULT AS IDENTITY` instead of `SERIAL` for new `auto_increment()` columns. Existing tables are unaffected; only newly generated migrations differ. See the Sea-ORM 2.0 migration guide for the `option-postgres-use-serial` escape hatch if you need the old behavior. For the full upstream detail see the [Sea-ORM 2.0 migration guide](https://www.sea-ql.org/blog/2026-01-12-sea-orm-2.0/). ### Generated code now uses 64-bit primary keys Newly **generated** models and scaffolds now use `i64` (BIGINT) primary keys and foreign keys, and the `int`/`unsigned` field types generate 64-bit columns. This is required by Sea-ORM 2.0 (its codegen maps SQLite integers to `i64`) and matches the modern bigint-by-default convention. This only affects code you generate *after* upgrading โ€” your existing tables, migrations, and entities are untouched. If you scaffold new resources and want them to relate to older `i32`-keyed tables, make the key types match (either widen the old ones with a migration, or hand-edit the new `id`/foreign-key fields back to `i32`). ### Multi-database: `ExtraDbInitializer` โ†’ `MultiDbInitializer` The single-extra-connection initializer (`initializers.extra_db`, which layered a bare `Extension`) was removed. Use `MultiDbInitializer` with a one-entry `initializers.multi_db` map instead, and extract the connection with `Extension`: ```rust // before: Extension // after: let conn = multi_db.get("")?; ``` Move whatever you configured under `extra_db` into a one-entry `multi_db` map. ### `AppContext` is now `#[non_exhaustive]` โ€” construct with the builder Field access (`ctx.db`, `ctx.config`, `State`/`FromRef` extraction) is unchanged, so most apps need no change. But direct struct-literal construction and exhaustive pattern matches on `AppContext` from outside the framework no longer compile (this makes future context fields non-breaking to add). If you built an `AppContext` by hand โ€” e.g. in a custom boot or test harness โ€” use the builder: ```rust let ctx = AppContext::builder(environment, db, config) // builder(environment, config) without `with-db` .queue_provider(queue) .mailer(mailer) .storage(storage) .build(); ``` ### `loco_rs::Error` is now `#[non_exhaustive]` The framework's `Error` enum is marked `#[non_exhaustive]` so new variants can be added in the future without a breaking change. If you `match` on `loco_rs::Error` (or `loco_rs::prelude::Error`) exhaustively, add a wildcard arm: ```rust match err { Error::NotFound => { /* ... */ } // ...handle the variants you care about... _ => { /* fallback */ } } ``` Most apps use `Result` / `?` and never match on `Error` directly, so no change is needed. ### More accurate HTTP status codes for errors `IntoResponse for Error` previously collapsed most variants to `500`. Now `Model(EntityNotFound)` โ†’ `404`, `Model(EntityAlreadyExists)` โ†’ `409`, and model validation / form-body rejections โ†’ `4xx` (matching JSON rejections); genuinely internal errors still return `500`. This is behavior-only โ€” no API changed โ€” but if your tests asserted the old `500`s, update them to the corrected codes. ### Background job priorities (Redis backend is breaking) Background jobs now support a **priority** (higher numbers run first). You can enqueue with an explicit priority: ```rust DownloadWorker::perform_later_with_priority(&ctx, args, Some(42)).await?; ``` - **Postgres / SQLite: no action needed.** A `priority` column is added to the queue table automatically on startup; existing jobs default to priority `0`. - **Redis: breaking.** To order by priority, the Redis backend now stores the queue as a **Sorted Set (ZSET)** instead of a List. Jobs already sitting in the old List-based queue keys will not be picked up after upgrading. **Drain your Redis queues before deploying 1.0** (let workers finish in-flight jobs on the old version, or clear the queue if you can re-enqueue). Newly enqueued jobs use the ZSET format automatically. Mailer jobs enqueue at priority `100` by default; override per mailer via `MailerOpts { priority, .. }`. ### `perform_later` returns the job id `Worker::perform_later` now returns the enqueued job's id (`Result` instead of `Result<()>`), and `Queue::enqueue` returns `Result>`. Existing call sites keep working โ€” `perform_later(..) .await?;` simply ignores the returned id. Capture it when you want to track status: ```rust let job_id = DownloadWorker::perform_later(&ctx, args).await?; ``` ### Background queue is now a `QueueProvider` adapter `bgworker::Queue` is now a newtype over `Arc`, so backends are pluggable. All queue methods keep the same signatures and behavior. Only two source-level changes affect callers: - Construct a no-op queue with `Queue::empty()` instead of `Queue::None`. - Code that pattern-matched the enum variants (e.g. `Queue::Postgres(pool, ..)` to reach the raw pool) no longer compiles โ€” use the provider methods instead. ### `PageResponse` carries a `meta: PagerMeta` Pagination results moved the flat `total_pages` / `total_items` fields into a `meta: PagerMeta` (which also carries `page` and `page_size`): ```rust // before let total = page.total_pages; // after let total = page.meta.total_pages; // also: page.meta.page, page.meta.page_size, page.meta.total_items ``` ### Storage: `MirrorStrategy` / `BackupStrategy` โ†’ `ReplicatedStrategy` The two strategies were the same primary-plus-secondaries replication engine and are now one `storage::strategies::replicated::ReplicatedStrategy` with a single `FailurePolicy` enum: ```rust // MirrorStrategy::new(p, s, MirrorAll) -> ReplicatedStrategy::mirror(p, s, FailurePolicy::FailIfAny); // BackupStrategy::new(p, s, BackupAll) -> ReplicatedStrategy::backup(p, s, FailurePolicy::FailIfAny); ``` Old `FailureMode` maps: `AllowMirrorFailure` / `AllowBackupFailure` โ†’ `AllowAll`, `AtLeastOneFailure` โ†’ `AllowSingleFailure`, `CountFailure(n)` โ†’ `FailAtFailures(n)`. Former-backup secondary writes now run concurrently (were sequential); the collected errors and failure decision are unchanged. ### Storage: local driver no longer roots at `/` (security) `storage::drivers::local::new()` previously rooted the store at `/`, so a key derived from user input could escape to the whole disk (key `etc/passwd` read `/etc/passwd`). It now roots at the current working directory. If you relied on absolute-path keys, opt back in explicitly: ```rust local::new_with_prefix("/your/root") ``` ### Config: `{env}.local.yaml` now deep-merges over `{env}.yaml` Previously the first existing file won and the other was ignored, so a `.local.yaml` had to restate the whole config. Both files now layer with local precedence: mappings merge recursively; scalars and sequences in local replace the base value (sequences are **not** concatenated). If you kept a full-config `.local.yaml`, trim it to just the keys you override โ€” base keys now persist unless explicitly overridden. ### Fallback middleware defaults to `404` When the built-in fallback is enabled without an explicit `code`, it now returns `404 Not Found` (matching its docs and the bundled not-found page) instead of `200 OK`. If you relied on the enabled fallback returning `200`, set `code: 200` explicitly. The file-based fallback (`ServeFile`) is unaffected. ### `remote_ip` rebuilt on `axum-client-ip`; `trusted_proxies` removed (security) **This is a silent, security-relevant change.** An old config's `trusted_proxies:` key is now an unknown field and is **ignored without error**, so review your `remote_ip` config before upgrading โ€” it will not fail to load. Previously the middleware walked `X-Forwarded-For` right-to-left, skipping any address in a `trusted_proxies` CIDR list (or a built-in RFC-1918 + loopback list). It now trusts exactly **one** configured source (`source: ClientIpSource`, default `RightmostXForwardedFor`) and does **no** CIDR filtering. - **Single reverse-proxy deployments:** unaffected. - **Multi-hop topologies (CDN โ†’ LB โ†’ ingress):** configure your innermost hop to set the client IP (e.g. nginx `set_real_ip_from` / `real_ip_recursive`), or point `source` at a provider header (`CfConnectingIp`, `CloudFrontViewerAddress`, `XRealIp`, `ConnectInfo`, โ€ฆ). The `RemoteIP` extractor and its `Display` output are unchanged. ### JWT: `algorithm()` restricted to the HMAC family `JWT::algorithm()` now takes `loco_rs::auth::jwt::JWTAlgorithm` (`HS256` / `HS384` / `HS512`) instead of `jsonwebtoken::Algorithm`. Asymmetric algorithms โ€” which could never work with Loco's shared base64 secret and silently produced broken tokens โ€” are no longer representable. If you passed a `jsonwebtoken::Algorithm`, switch to the matching `JWTAlgorithm` variant. ### View engine: use `TeraView::build_with_post_process` In `after_routes`, replace `TeraView::build()?.post_process(...)` with the combined constructor: ```rust // before engines::TeraView::build()?.post_process(move |tera| { tera.register_function("t", FluentLoader::new(arc.clone())); Ok(()) })? // after engines::TeraView::build_with_post_process(move |tera| { tera.register_function("t", FluentLoader::new(arc.clone())); Ok(()) })? ``` ### Mailer: `Template::new(dir)` now returns `Result` Email templates render through a full Tera instance (so they support inheritance and shared templates). Standard usage via `Mailer::mail_template` is unchanged; if you called `Template::new(dir)` directly, add `?`: ```rust let tpl = Template::new(dir)?; ``` ### Tasks: `Vars::cli_arg` returns `Result<&str>` `Vars::cli_arg` now returns `Result<&str>` (was `Result<&String>`). Callers that relied on `&String` (e.g. `.clone()` into a `String`) should use `.to_owned()`. ### Dependency majors 1.0 bumps several dependency majors. These are transitive for most apps โ€” you only need to act if you use one of these crates **directly** through Loco's public API: `thiserror` 1โ†’2, `tower` 0.4โ†’0.5, `heck`โ†’0.5, `byte-unit` 4โ†’5, `ipnetwork` 0.20โ†’0.21, `strum`โ†’0.27, `redis` 0.31โ†’1, `bb8-redis`โ†’0.26, `opendal` 0.54โ†’0.57. `serde_yaml` (archived) was replaced by the maintained `serde_yaml_ng` fork. ### Feature-flag changes (1.0) - `auth_jwt` โ†’ `auth`. - `bg_redis` โ†’ `worker_redis`; `bg_pg`/`bg_sqlt` โ†’ `worker`. `default` now includes `worker` (Postgres+SQLite queues); add `worker_redis` for a Redis queue. - `integration_test` removed (was dead). - `loco new` now offers Redis/Postgres/SQLite queue backends and (serverside) embedded assets. ## Upgrade from 0.15.x to 0.16.x ### Use `AppContext` instead of `Config` in `init_logger` in the `Hooks` trait PR: [#1418](https://github.com/loco-rs/loco/pull/1418) If you are supplying an implementation of `init_logger` in your `impl` of the `Hooks` trait in order to set up your own logging, you will need to make the following change: ```diff - fn init_logger(config: &config::Config, env: &Environment) -> Result { + fn init_logger(ctx: &AppContext) -> Result { ``` Any code in your `init_logger` implementation that makes use of the `config` can access it through `ctx.config`. In addition, you will also be able to access anything else in the `AppContext`, such as the new `shared_store`. The `env` parameter is also removed, as that is accessible from the `AppContext` as `ctx.environment`. ### Swap to validators builtin email validation PR: [#1359](https://github.com/loco-rs/loco/pull/1359) Swap from using the loco custom email validator, to the builtin email validator from `validator`. ```diff - #[validate(custom (function = "validation::is_valid_email"))] + #[validate(email(message = "invalid email"))] pub email: String, ``` ### Job system PR: [#1384](https://github.com/loco-rs/loco/pull/1384) PR: [#1396](https://github.com/loco-rs/loco/pull/1396) Two major changes have been made to the background job system: 1. The Redis provider is no longer Sidekiq-compatible and uses a custom implementation 2. All providers (Redis, PostgreSQL, SQLite) now support tag-based job filtering #### What Changed ##### Removing Sidekiq Compatibility The Redis background job system has been completely refactored, replacing the Sidekiq-compatible implementation with a new custom implementation. This provides greater flexibility and improved performance, but means: - Jobs pushed from older Loco versions (pre-0.16) will not be recognized or processed - The Redis data structures have changed entirely - There is no automatic migration path for existing queued jobs ##### Adding Job Filtering A new tag-based job filtering system has been added to all background worker providers: - Workers can now specify which tags they're interested in processing - Jobs can be tagged when enqueued - Workers with no tags only process untagged jobs, while tagged workers process jobs with matching tags - The same API is used across all providers #### How to Upgrade To upgrade to the new job system: 1. **Process existing jobs**: - Make sure all jobs in your queue are processed/completed before upgrading 2. **Clean up old data**: - For Redis: Flush the Redis database used for jobs (`FLUSHDB` command) - For PostgreSQL: Drop the job queue tables - For SQLite: Delete the job queue tables 3. **Update Loco**: - Update to Loco 0.16+ - Loco will automatically create new job tables with the correct schema on first run ### Generic Cache PR: [#1385](https://github.com/loco-rs/loco/pull/1385) The cache API has been refactored to support storing and retrieving any serializable type, not just strings. This is a breaking change that requires updates to your code: #### Breaking Changes: 1. **Type Parameters Required**: All cache methods now require explicit type parameters 2. **Method Signatures**: Some method signatures have changed to support generics 3. **Object Serialization**: Any type you store must implement `Serialize` and `Deserialize` from serde #### Migration Guide: **Before:** ```rust // Get a string value from cache let value = cache.get("key").await?; // Insert or get with callback let value = app_ctx.cache.get_or_insert("key", async { Ok("value".to_string()) }).await.unwrap(); // Insert or get with expiry let value = app_ctx.cache.get_or_insert_with_expiry("key", Duration::from_secs(300), async { Ok("value".to_string()) }).await.unwrap(); ``` **After:** ```rust // Get a string value from cache - specify the type let value = cache.get::("key").await?; // Direct insert with any serializable type cache.insert("key", &"value".to_string()).await?; // Insert or get with callback - specify return type let value = app_ctx.cache.get_or_insert::("key", async { Ok("value".to_string()) }).await.unwrap(); // Store complex types #[derive(Serialize, Deserialize)] struct User { name: String, age: u32, } let user = app_ctx.cache.get_or_insert_with_expiry::( "user:1", Duration::from_secs(300), async { Ok(User { name: "Alice".to_string(), age: 30 }) } ).await.unwrap(); ``` #### Implementing for Custom Types: For your custom types to work with the cache, ensure they implement `Serialize` and `Deserialize`: ```rust use serde::{Serialize, Deserialize}; #[derive(Serialize, Deserialize)] struct MyType { // fields... } ``` ### Authentication Error Handling Authentication error handling has been improved to better distinguish between actual authorization failures and system errors: 1. **System errors now return 500**: Database errors during authentication now return Internal Server Error (500) instead of Unauthorized (401) 2. **Improved error logging**: Authentication errors are now logged with detailed messages using `tracing::error` 3. **Message changes**: Generic error messages have been updated from "other error: '{e}'" to "could not authorize" #### Migration Guide: If you have code that relies on database errors during authentication returning 401 status codes, you'll need to update your error handling. Any code expecting a 401 for database connectivity issues should now handle 500 responses as well. Client applications should be prepared to handle both 401 and 500 status codes during authentication failures, with 401 indicating authorization problems and 500 indicating system errors. ### Server side rendering We had some changes in Tera template. go to `src/initializers/view_engine.rs` and replace the `after_routes` function with: ```rust async fn after_routes(&self, router: AxumRouter, _ctx: &AppContext) -> Result { let tera_engine = if std::path::Path::new(I18N_DIR).exists() { let arc = std::sync::Arc::new( ArcLoader::builder(&I18N_DIR, unic_langid::langid!("en-US")) .shared_resources(Some(&[I18N_SHARED.into()])) .customize(|bundle| bundle.set_use_isolating(false)) .build() .map_err(|e| Error::string(&e.to_string()))?, ); info!("locales loaded"); engines::TeraView::build()?.post_process(move |tera| { tera.register_function("t", FluentLoader::new(arc.clone())); Ok(()) })? } else { engines::TeraView::build()? }; Ok(router.layer(Extension(ViewEngine::from(tera_engine)))) } ``` ## Upgrade from 0.14.x to 0.15.x ### Upgrade validator crate PR: [#1199](https://github.com/loco-rs/loco/pull/1199) Update the `validator` crate version in your `Cargo.toml`: From ``` validator = { version = "0.19" } ``` To ``` validator = { version = "0.20" } ``` ### User claims PR: [#1159](https://github.com/loco-rs/loco/pull/1159) - Flattened (De)Serialization of Custom User Claims: The `claims` field in `UserClaims` has changed from `Option` to `Map`. - Mandatory Map Value in `generate_token` function: When calling `generate_token`, the `Map` argument is now required. If you are not using custom claims, pass an empty map (`serde_json::Map::new()`). - Updated generate_token Signature: The `generate_token` function now takes `expiration` as a value instead of a reference. ### Pagination Response PR: [#1197](https://github.com/loco-rs/loco/pull/1197) The pagination response now includes the `total_items` field, providing the total number of items available. ```JSON {"results":[],"pagination":{"page":0,"page_size":0,"total_pages":0,"total_items":0}} ``` ### Explicit id in migrations PR: [#1268](https://github.com/loco-rs/loco/pull/1268) Migrations using `create_table` now require `("id", ColType::PkAuto)`, new migrations will have this field automatically added. ```diff async fn up(&self, m: &SchemaManager) -> Result<(), DbErr> { create_table(m, "movies", &[ + ("id", ColType::PkAuto), ("title", ColType::StringNull), ], &[ ("user", ""), ] ).await } ``` ## Upgrade from 0.13.x to 0.14.x ### Upgrading from Axum 0.7 to 0.8 PR: [#1130](https://github.com/loco-rs/loco/pull/1130) The upgrade to Axum 0.8 introduces a breaking change. For more details, refer to the [announcement](https://tokio.rs/blog/2025-01-01-announcing-axum-0-8-0). #### Steps to Upgrade - In your `Cargo.toml`, update the Axum version from `0.7.5` to `0.8.1`. - Replace use `axum::async_trait`; with use `async_trait::async_trait;`. For more information, see [here](https://tokio.rs/blog/2025-01-01-announcing-axum-0-8-0#async_trait-removal). - The URL parameter syntax has changed. Refer to [this section](https://tokio.rs/blog/2025-01-01-announcing-axum-0-8-0#path-parameter-syntax-changes) for the updated syntax. The new path parameter format is: The path parameter syntax has changed from `/:single` and `/*many` to `/{single}` and `/{*many}`. ### Extending the `boot` Function Hook PR: [#1143](https://github.com/loco-rs/loco/pull/1143) The `boot` hook function now accepts an additional Config parameter. The function signature has changed from: From ```rust async fn boot(mode: StartMode, environment: &Environment) -> Result { create_app::(mode, environment).await } ``` To: ```rust async fn boot(mode: StartMode, environment: &Environment, config: Config) -> Result { create_app::(mode, environment, config).await } ``` Make sure to import the `Config` type as needed. ### Upgrade validator crate PR: [#993](https://github.com/loco-rs/loco/pull/993) Update the `validator` crate version in your `Cargo.toml`: From ``` validator = { version = "0.18" } ``` To ``` validator = { version = "0.19" } ``` ### Extend truncate and seed hooks PR: [#1158](https://github.com/loco-rs/loco/pull/1158) The `truncate` and `seed` functions now receive `AppContext` instead of `DatabaseConnection` as their argument. From ```rust async fn truncate(db: &DatabaseConnection) -> Result<()> {} async fn seed(db: &DatabaseConnection, base: &Path) -> Result<()> {} ``` To ```rust async fn truncate(ctx: &AppContext) -> Result<()> {} async fn seed(_ctx: &AppContext, base: &Path) -> Result<()> {} ``` Impact on Testing: Testing code involving the seed function must also be updated accordingly. from: ```rust async fn load_page() { request::(|request, ctx| async move { seed::(&ctx.db).await.unwrap(); ... }) .await; } ``` to ```rust async fn load_page() { request::(|request, ctx| async move { seed::(&ctx).await.unwrap(); ... }) .await; } ``` --- # Extras Source: https://loco.rs/docs/extras/ --- # Around the Web Source: https://loco.rs/docs/resources/around-the-web/ Check out Loco resources and links from around the Web. ## Blogs - [Introducing Loco: the "Rust on Rails"](https://blog.rng0.io/introducing-loco/) - by [@jondot](https://x.com/jondot) - [Loco is a New Framework for Rust Inspired by Rails](https://www.infoq.com/news/2024/02/loco-new-framework-rust-rails/) - by [infoq.com](https://infoq.com) - [Going in cold inside the locomotive](https://vanhalt.com/post/loco-rs/) by [@vanhalt](https://twitter.com/vanhalt) - [Getting Started with Loco & SeaORM](https://www.sea-ql.org/blog/2024-05-28-getting-started-with-loco-seaorm/) by [Billy Chan (SeaORM)](https://github.com/billy1624) ## Videos - [A Legendary Web Framework is Reborn... in Rust](https://www.youtube.com/watch?v=7utPutDORb4) - by [Code to the Moon](https://www.youtube.com/@codetothemoon) ## Ecosystem - [rhai-loco](https://docs.rs/rhai-loco/latest/rhai_loco/) - This crate adds [Rhai](https://rhai.rs) script support to [Loco](https://loco.rs) - [loco-oauth2](https://github.com/yinho999/loco-oauth2) - Loco OAuth2 is a simple OAuth2 initializer for the Loco API. It is designed to be a tiny and easy-to-use library for implementing OAuth2 in your application. ## App Examples - [Chat rooms](https://github.com/loco-rs/chat-rooms) - With opening a web socket - [Todo list](https://github.com/loco-rs/todo-list) - working with rest API --- # FAQ Source: https://loco.rs/docs/resources/faq/
How can I automatically reload code? Try [cargo watchexec](https://crates.io/crates/watchexec): ``` $ watchexec --notify -r -- cargo loco start ``` Or [bacon](https://github.com/Canop/bacon) ``` $ bacon run ```

Do I have to have `cargo` to run tasks or other things? You don't have to run things through `cargo` but in development it's highly recommended. If you build `--release`, your binary contains everything including your code and `cargo` or Rust is not needed.

Is this production ready? Loco is still in its beginning, but its roots are not. It's almost a rewrite of `Hyperstackjs.io`, and Hyperstack is based on an internal Rails-like framework which is production ready. Most of Loco is glue code around Axum, SeaORM, and other stable frameworks, so you can consider that. At this stage, at version 0.1.x, we would recommend to _adopt and report issues_ if they arise.

Adding Custom Middleware in Loco Loco is compatible with Axum middlewares. Simply implement `FromRequestParts` in your custom struct and integrate it within your controller.

Injecting Custom State or Layers in Loco? Yes, you can achieve this by implementing `Hooks::after_routes`. This hook receive Axum routers that Loco has already built, allowing you to seamlessly add any available Axum functions that suit your needs. If you need your routes or (404) fallback handler to be affected by loco's middleware, you can add them in `Hooks::before_routes` which is called before the middleware is installed.

--- # Resources Source: https://loco.rs/docs/resources/