Skip to content

Latest commit

 

History

History
894 lines (646 loc) · 35.5 KB

File metadata and controls

894 lines (646 loc) · 35.5 KB

Getting Started: build a blog with Rustango

This walkthrough takes you from an empty directory to a deployed blog: posts, an admin UI, a JSON API, JWT authentication, and tests. End to end.

Time: ~45 minutes for the full tour, ~10 minutes if you just want to see it running.

Runnable version: every step below is mirrored in a tested, compilable example at crates/rustango/examples/getting_started_blog. If a step ever looks off, diff against it.

Build a blog with Rustango: generate the migration, apply it, boot the server, and hit the JSON API — all from one binary


What you need to know first

Two different questions, and the docs used to answer only the second one.

Rust is assumed. Not expert Rust, but you should be comfortable with structs, traits, Result and ?, and enough async/.await to read a function without looking things up. If that is not you yet, the Rust Book comes first; this guide will not teach the language underneath it.

Web backend experience is not assumed. If you have never built a web API, start with Web API basics in the glossary. It is a five-minute primer on requests, routes, handlers and migrations, and it is written for exactly this gap. Come back here afterwards.

No other framework is assumed. Every step explains itself, and where a term is doing real work, the glossary defines it in plain language.

What you need installed

Tool Why Install
Rust 1.88+ Compiler https://rustup.rs
A database This guide uses Postgres see Choosing a database below
psql (optional) Inspect DB brew install libpq / apt install postgresql-client
rustc --version    # should print 1.88+

Choosing a database

Docker is not required. This guide reaches for it because one command gives you a throwaway Postgres, but nothing in rustango depends on it. Pick whichever row fits your machine — everything after this step is identical:

You want Do this Notes
No database server at all Run with SQLite (below) Nothing to install. Best for learning the framework.
Postgres without Docker Install Postgres natively, then point DATABASE_URL at localhost See Native Postgres.
Postgres with Docker docker compose up -d in the generated project What the rest of this guide assumes.
MySQL or MariaDB Scaffold with --backend mysql See MySQL.

Whichever you pick, the only thing that changes is DATABASE_URL. Here is the shape of each:

DATABASE_URL=postgres://user:password@localhost:5432/myblog_dev
DATABASE_URL=mysql://user:password@localhost:3306/myblog_dev
DATABASE_URL=sqlite://myblog_dev.db?mode=rwc

SQLite — zero setup

Scaffold the project for SQLite and there is nothing to install, nothing to start, and nothing to edit afterwards:

cargo rustango new myblog --backend sqlite

The generated .env.example, docker-compose.yml and settings tiers are all written for SQLite, the database is a file created by the first cargo run -- migrate, and you can skip Step 4 entirely.

If you already generated a Postgres project and want to switch it, every template keeps all three backends wired up — so it is one flag plus a URL:

cargo run --no-default-features --features sqlite
DATABASE_URL=sqlite://myblog_dev.db?mode=rwc

mode=rwc tells SQLite to create the file if it does not exist. Everything in this guide — models, migrations, the admin, the ORM — works unchanged; only Postgres-specific features (JSONB operators, schema-mode multi-tenancy) do not apply.

Native Postgres (no Docker)

Install Postgres from your package manager (brew install postgresql@16, apt install postgresql, or the Windows installer at https://www.postgresql.org/download/windows/), then create the role and database the generated config expects:

createuser -s rustango          # or: CREATE ROLE rustango LOGIN SUPERUSER PASSWORD 'rustango';
createdb myblog_dev -O rustango

The generated .env.example points at the Docker service hostname. Change the host to localhost:

# .env  —  `postgres` is the docker-compose service name; use localhost natively
DATABASE_URL=postgres://rustango:rustango@localhost:5432/myblog_dev

On Windows? Docker Desktop's Hyper-V / WSL2 backend is a common source of start-up failures. If it is fighting you, use the SQLite path above to learn the framework and come back to Docker when you are packaging for deployment — that is what the container setup is really for.

Already running Postgres locally? Then port 5432 is taken, and the container quietly loses the race. Your app connects to the local server, which has none of your tables. The error that comes back is not readable, because a non-English server sends its message in its own encoding. Either stop the local service or move the container to another port.

MySQL

Scaffold with --backend mysql and the generated .env.example, docker-compose.yml and settings tiers are all written for MySQL:

cargo rustango new myblog --backend mysql

Running it natively instead of in the container takes a database and a user:

CREATE DATABASE myblog_dev CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
CREATE USER 'rustango'@'localhost' IDENTIFIED BY 'rustango';
GRANT ALL PRIVILEGES ON myblog_dev.* TO 'rustango'@'localhost';
DATABASE_URL=mysql://rustango:rustango@localhost:3306/myblog_dev

utf8mb4 is worth setting deliberately: MySQL's older utf8 is three bytes and cannot store an emoji, which surfaces much later as a write that fails on one row. MariaDB works through the same driver and the same URL scheme.


Step 1: Install the scaffolder

The scaffolder generates project and app skeletons for you, like rails new.

cargo install cargo-rustango

This adds the cargo rustango ... subcommand globally. Confirm it's there:

cargo rustango --help

The scaffolder's own version is the one your project pins, so installing the newest gives you the newest rustango. To generate a project on an older release, install that generator instead (cargo install cargo-rustango --version 0.58.0) — see Scaffolding.


Step 2: Create the project

This scaffolds a fresh project, the Rustango equivalent of rails new or composer create-project.

cd ~/projects                                 # wherever you keep code
cargo rustango new myblog                     # default = fullstack template
cd myblog

Run cargo rustango new with no arguments at all and it asks for the template, the backend and any extra features, then prints the equivalent command line before creating anything — see Scaffolding.

Here's what was generated:

myblog/
├── Cargo.toml                  # rustango + axum + sqlx + tokio
├── .env.example                # template for DATABASE_URL etc.
├── .gitignore
├── docker-compose.yml          # Postgres in a container
├── README.md                   # project-specific
├── config/                     # tiered settings (default + dev/staging/prod)
├── migrations/                 # empty — `cargo run -- makemigrations` populates
└── src/
    ├── main.rs                 # entry point: `Cli::new().api(urls::api()).run()`
    ├── models.rs               # every #[derive(Model)] lives here
    ├── views.rs                # axum request handlers
    └── urls.rs                 # `pub fn api()` route aggregator

There is a single binary: cargo run boots the HTTP server, and every management verb (migrate, makemigrations, startapp, check, …) flows through the same binary via cargo run -- <verb>. There's no separate manage binary.

Cargo.toml is the dependency manifest (like composer.json or a Gemfile). Open it and confirm rustango is listed under [dependencies].

Confirm the [features] block — pick a database backend. #[derive(Model)] cfg-gates its generated FromRow / LoadRelated impls on your crate's features (a cfg inside a derive macro resolves against the destination crate, not Rustango), so a backend feature must be enabled here or the first model won't compile. A current scaffold includes:

[features]
default  = ["postgres"]            # the backend `cargo run` uses
postgres = ["rustango/postgres"]
sqlite   = ["rustango/sqlite"]
mysql    = ["rustango/mysql"]

If your generated Cargo.toml has no [features] block (an older cargo-rustango), add the one above by hand — that always fixes it. Without it the build fails with "the trait bound …: MaybePgFromRow is not satisfied" plus a tell-tale warning: unexpected cfg condition value: postgres.


Step 3: Set up your environment

Configuration lives in a .env file. Copy the template:

cp .env.example .env

The generated .env is Docker-friendly out of the box. Because we'll run cargo on the host (not inside the dev container), change the database host from postgres to localhost:

DATABASE_URL=postgres://rustango:rustango@localhost:5432/myblog_dev
RUSTANGO_BIND=0.0.0.0:8080
RUSTANGO_APEX_DOMAIN=localhost

The credentials, port, and database name (myblog_dev) already match the docker-compose.yml Postgres service, so you don't need to touch those.

RUSTANGO_SESSION_SECRET signs sessions and tokens. It is left commented out in the generated .env.example, and for development you can leave it that way: the first boot generates a key into ./var/ and reuses it, so restarts don't sign you out.

For production set a real one — 32 bytes of base64, from your secret manager or:

openssl rand -base64 32     # paste output as RUSTANGO_SESSION_SECRET value

A value that is not 32 bytes of base64 cannot be used. The server says so on boot and falls back to the generated key rather than failing, so watch for that warning if you set one and sessions behave as though you had not.


Step 4: Start the database

Using SQLite? Skip this step — there is no server to start. Make sure .env has DATABASE_URL=sqlite://myblog_dev.db?mode=rwc and add --no-default-features --features sqlite to every cargo run below.

Using a native Postgres? It is already running as a service; just confirm psql "$DATABASE_URL" -c "SELECT version();" succeeds, then skip ahead.

The project ships with a docker-compose.yml that runs Postgres in a container, so you don't install a database by hand. We'll run the app itself with cargo on the host, so start just the postgres service in the background (the compose file also defines an optional rust dev-container that would otherwise bind port 8080):

docker compose up -d postgres

Confirm it's running:

docker compose ps
psql "$DATABASE_URL" -c "SELECT version();"   # should print Postgres version

Step 5: Run the built-in migrations

Migrations create your database tables, same idea as php artisan migrate or rails db:migrate. Run them once to set up the framework's own tables:

cargo run -- migrate

The first compile takes ~2 minutes (Rust builds everything from source). A fresh project ships no migration files of its own yet, but migrate is not a no-op: it first generates the framework's own migrations from the compiled models and applies them, so you'll see a few applied … lines rather than nothing to migrate. (That message appears only once everything — framework and project — is already up to date.) It also creates the audit-log table, so audited models work the moment you add them. You generate your first project migration in Step 9.

Check the migration state:

cargo run -- showmigrations

On a fresh project this prints (no migrations in ./migrations). Once you create a model and run makemigrations (Step 9), each applied migration shows an [X] here.


Step 6: First boot

Start the server to make sure everything is wired up.

cargo run

You'll see:

listening on http://0.0.0.0:8080

Open http://localhost:8080 in your browser. The scaffold ships a simple root handler (views::index) that greets you with Hello from Rustango! and a link to the admin — that confirms Rustango is running. (Projects that don't define their own / route get a built-in welcome page instead, via Cli::with_welcome().)

Press Ctrl-C to stop.


Step 7: Create an app

An "app" is a self-contained feature module. Your blog app will hold the Post model, its routes, and its templates.

cargo run -- startapp blog

This writes:

src/blog/
├── mod.rs
├── models.rs              # a starter model named after the app (you'll replace it)
├── views.rs               # axum handlers
├── urls.rs                # blog-specific routes (pub fn api())
└── tests.rs               # in-process router + inventory smoke tests

startapp wires the new module in for you: it declares mod blog; in src/main.rs and inserts a .merge(crate::blog::urls::api()) line into the api() aggregator in src/urls.rs, so the blog's routes compose into the app automatically. No manual module registration needed.


Step 8: Define a model

A model is a database table described as a Rust struct. Open src/blog/models.rs and define your Post. (For the full reference — every field type, custom primary keys, and all attributes — see the Models guide.)

use rustango::{Auto, Model};
use chrono::{DateTime, Utc};

#[derive(Model, Clone, Debug)]
#[rustango(
    table = "posts",
    display = "title",
    admin(
        list_display  = "id, title, status, published_at",
        search_fields = "title, body",
        list_filter   = "status, author_id",
        ordering      = "-published_at",
    ),
    audit(track = "title, body, status"),
    index("status, published_at"),
)]
pub struct Post {
    #[rustango(primary_key)]
    pub id: Auto<i64>,

    #[rustango(max_length = 200)]
    pub title: String,

    pub body: String,

    #[rustango(max_length = 20, default = "'draft'")]
    pub status: String,                  // draft | published

    pub author_id: i64,

    #[rustango(auto_now_add)]
    pub published_at: Auto<DateTime<Utc>>,

    #[rustango(soft_delete)]
    pub deleted_at: Option<DateTime<Utc>>,
}

A few Rust things to note:

  • #[derive(Model, ...)] is a derive macro: it auto-generates code for the struct, the way a class decorator or base class would in other frameworks. Deriving Model is what gives the struct its query methods.
  • Auto<i64> marks a field the database fills in for you (an auto-incrementing i64 integer), like an auto primary key.
  • Option<...> means "this value may be absent." Option<DateTime<Utc>> is a timestamp that can be null, so deleted_at is empty until the row is soft-deleted.
  • The #[rustango(...)] attributes configure each field (max length, defaults, indexes) and the admin(...) block sets up the admin UI columns and filters.

Step 9: Create and apply the migration

Now turn that model into a real table. First, generate the migration from your model:

cargo run -- makemigrations

You'll see something like:

wrote ./migrations/0001_create_item_and_posts_and_rustango_admin_users_etc.json
    + CreateTable("item")
    + CreateTable("posts")
    + CreateTable("rustango_admin_users")
    + CreateTable("rustango_content_types")
    + CreateIndex { table: "posts", columns: ["status", "published_at"], ... }

This first migration creates your models — posts, plus the starter item model the scaffold shipped in src/models.rs — alongside the framework's own admin and content-type tables. Open the JSON if you like: it carries the operations plus a full schema snapshot.

Apply it to the database:

cargo run -- migrate

Confirm the table exists:

psql "$DATABASE_URL" -c "\d posts"

Step 10: Try the ORM

Let's read and write rows from code. The ORM lets you work with database rows as Rust structs instead of raw SQL.

Temporarily edit src/main.rs to run a quick create-and-read test before booting the server. Replace the Cli body with an ad-hoc ORM smoke test (keep the scaffolder's #[rustango::main] and the mod declarations at the top of the file):

mod blog;
mod models;
mod urls;
mod views;

use crate::blog::models::Post;
use rustango::sql::{FetcherPool, Pool};
use rustango::{Auto, Model};

#[rustango::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
    let _ = dotenvy::dotenv();
    let pool = Pool::connect(&std::env::var("DATABASE_URL")?).await?;

    // CREATE
    let mut p = Post {
        id: Auto::default(),
        title: "First post".into(),
        body: "Hello, world.".into(),
        status: "draft".into(),
        author_id: 1,
        published_at: Auto::default(),
        deleted_at: None,
    };
    p.save_pool(&pool).await?;
    println!("created post id = {}", p.id.get().copied().unwrap());

    // READ
    let posts = Post::objects().fetch(&pool).await?;
    for post in &posts {
        println!("- {}", post.title);
    }

    Ok(())
}

What's happening here, in plain terms:

  • pool is the shared database connection pool. You pass a reference to it (&pool) into query calls instead of opening a new connection each time.
  • Database calls are asynchronous, so each one ends in .await — that pauses until the result comes back, then continues. The ? after an .await says "if this errored, stop and return the error."
  • main returns a Result, Rust's success-or-error type, which is why ? and the closing Ok(()) work.
  • To save a row, call .save_pool(&pool) on it. To read rows, build a query with Post::objects() and run it with .fetch(&pool) — with no filters, that reads every row.
  • .fetch(…) comes from the FetcherPool trait, which is why the imports bring it in. Without that line the method does not exist and the compiler says so without explaining why.
  • These are the multi-backend calls, and everything above compiles unchanged on all three databases. There are also .save(&pool) and .fetch_on(&pool), which take a driver-specific sqlx::PgPool and exist only when the postgres feature is on. Prefer the multi-backend pair unless you deliberately want one database. See the ORM guide.

Run it:

cargo run

You should see your new post id and the rows read back. Restore src/main.rs to its scaffolded server shape once you've confirmed it works — the next step builds on that.


Step 11: Turn on the auto-admin

Rustango ships a generated admin UI for your models — browse, search and edit rows with no code of your own. Building it is two small steps: a helper that turns a pool into an admin router, and one .nest(...) call to mount it.

Add the helper to src/urls.rs yourself — the scaffolder does not generate it, because nothing it generates would call it. The admin_prefix must match the path you'll nest it under in the next step (/admin) so the admin's own links and form actions resolve:

use rustango::admin;
use rustango::sql::Pool;

pub fn admin_router(pool: Pool) -> Router {
    admin::Builder::new(pool)
        .title("Myblog Admin")
        .admin_prefix("/admin") // must match the `.nest("/admin", …)` below
        .build()
}

Builder::new takes any backend's pool, so this helper names no driver and works on all three.

Then connect a pool in src/main.rs and nest the admin into the API router before handing it to the Cli. Keep the mod blog; line from Step 7 — that's what registers your Post model with the admin:

mod blog;
mod models;
mod urls;
mod views;

#[rustango::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
    let _ = dotenvy::dotenv();
    let pool = rustango::sql::Pool::connect(&std::env::var("DATABASE_URL")?).await?;

    let api = urls::api().nest("/admin", urls::admin_router(pool));

    rustango::manage::Cli::new()
        .api(api)
        .with_health() // /health + /ready endpoints
        .run()
        .await
}

Cli::new()...run() is the same unified dispatcher the scaffolder generated — it still serves every cargo run -- <verb>; you've only enriched the router it serves at runserver time.

Run it:

cargo run

Open http://localhost:8080/admin (no trailing slash). You'll see the admin home with a posts link. Click it to see your draft post in the list, click the post to open its edit form, and save. The audit-trail tab records every write.


Step 12: Build the JSON API

A ViewSet exposes a model as a REST API with list, create, retrieve, update, and delete endpoints, all from one declaration.

12a. Generate the ViewSet

Scaffold the file, then fill in which fields and behaviors to expose:

cargo run -- make:viewset PostViewSet --model Post

Edit src/post_view_set.rs:

use rustango::ViewSet;
use crate::blog::models::Post;

#[derive(ViewSet)]
#[viewset(
    model         = Post,
    fields        = "id, title, body, status, author_id, published_at",
    filter_fields = "author_id, status",
    search_fields = "title, body",
    ordering      = "-published_at",
    page_size     = 20,
)]
pub struct PostViewSet;

Register the new module by adding mod post_view_set; alongside the other mod declarations at the top of src/main.rs.

12b. Mount the routes

Attach the ViewSet's routes to the app's router (the Rustango version of a urls.py or a routes/api.php file). The ViewSet router needs the database pool, so build it in src/main.rs where the pool lives and merge it into the urls::api() aggregator:

let api = urls::api()
    .nest("/admin", urls::admin_router(pool.clone()))
    .merge(crate::post_view_set::PostViewSet::router("/api/posts", pool));

rustango::manage::Cli::new()
    .api(api)
    .with_health()
    .run()
    .await

(urls::api() is the aggregator the scaffolder generated; manage startapp merges any sub-app's routes into it the same way.)

12c. Try the endpoints

Start the server:

cargo run

In another terminal, hit the API with curl:

curl http://localhost:8080/api/posts                                    # list
curl -X POST http://localhost:8080/api/posts \
     -H "content-type: application/json" \
     -d '{"title":"From API","body":"Yo","status":"published","author_id":1}'
curl http://localhost:8080/api/posts/1                                   # retrieve
curl "http://localhost:8080/api/posts?search=API&ordering=-id"            # search + sort
curl "http://localhost:8080/api/posts?status__ne=draft"                   # lookup operator

Step 13: Shape the output with a Serializer

By default the ViewSet returns every model field. A Serializer lets you control the response shape: hide internal fields, rename them, or mark some read-only.

cargo run -- make:serializer PostSerializer --model Post

Edit src/post_serializer.rs:

use rustango::{Auto, Serializer};
use crate::blog::models::Post;

#[derive(Serializer, serde::Deserialize, Default)]
#[serializer(model = Post)]
pub struct PostSerializer {
    pub id: Auto<i64>,
    pub title: String,

    #[serializer(source = "body")]                      // rename in API
    pub content: String,

    pub author_id: i64,                                 // writable: NOT NULL with no default

    #[serializer(read_only)]                            // include in GET, ignore in POST/PUT
    pub published_at: Auto<chrono::DateTime<chrono::Utc>>,
}

Once a serializer is attached, its fields are the whole write surface: anything a client posts that is not listed here is dropped before the INSERT. That is why author_id appears. It is NOT NULL with no default on the model, so leaving it out makes every create fail on the not-null constraint. status can stay out because the model gives it default = "'draft'".

Each serializer field's type mirrors the matching model field, so id and published_at keep their Auto<…> wrapper from the model (an Auto<i64> still serializes to a plain JSON integer). Then register the module by adding mod post_serializer; alongside the other mod declarations in src/main.rs.

Wire the serializer into the ViewSet with the serializer attribute — list, retrieve, and create responses are then rendered through it (the field-level fields projection is bypassed in favor of the serializer's shape):

#[derive(ViewSet)]
#[viewset(
    model = Post,
    serializer = crate::post_serializer::PostSerializer,
    ordering = "-published_at",
)]
pub struct PostViewSet;

This works identically on PostgreSQL, MySQL, and SQLite. method / read_only / source / write_only overrides all apply to the response, and request bodies are validated through the serializer too: create / update run its validate() (per-field and cross-field), returning a 400 with one list of messages per field ({field: [messages]}) on failure, and read-only / computed fields a client posts are ignored. (Note: nested / many serializer fields need the related rows loaded via select_related; otherwise they render as their default.) See the ViewSets guide for the full input + output behavior.


Step 14: Add JWT authentication

JWTs are signed tokens you hand a client after login and check on each request, a common pattern for API auth. Rustango's rustango::jwt module issues and verifies them (HS256) and is on by default — no extra feature flag.

14a. Issue a token at login

These are fragments, not standalone files: this one goes inside your login handler in src/views.rs, alongside the handlers from Step 6.

Bake the user id (the token's "subject") and any custom claims, like roles, into a signed token, then hand it to the client:

use rustango::jwt::{encode, Claims};
use std::time::Duration;

// Derive the signing key from your session secret.
let secret = std::env::var("RUSTANGO_SESSION_SECRET")?.into_bytes();

let mut claims = Claims::new(user_id.to_string());   // subject = user id
claims.set("roles", vec!["editor"]);
let token = encode(&claims.ttl(Duration::from_secs(900)), &secret)?;

// Send `token` to the client (e.g. in the login response body).

14b. Verify the token on each request

This belongs wherever you guard a route — inside a protected handler in src/views.rs, or in an axum extractor / middleware::from_fn layer if you want it applied to a whole subtree.

Decode the token — this checks the signature and expiry — then read the claims back. If it's missing or invalid, reject the request as unauthorized:

use rustango::jwt::decode;

let claims = decode(&access_token, &secret)
    .map_err(|_| StatusCode::UNAUTHORIZED)?;

let user_id = claims.subject().ok_or(StatusCode::UNAUTHORIZED)?;
let roles: Vec<String> = claims.get("roles").unwrap_or_default();

14c. Access + refresh lifecycle

rustango::jwt issues stateless single tokens. For the full pattern — short-lived access tokens, a long-lived refresh token in an HttpOnly cookie, rotation, and a JTI blacklist for revocation — enable the tenancy feature and use rustango::tenancy::jwt_lifecycle::JwtLifecycle, whose issue_pair_with / verify_access / refresh methods manage the pair for you.


Step 15: Add security middleware

Middleware wraps every request to add cross-cutting behavior. This one goes in src/main.rs: it replaces the let api = ... line from Step 11, so the router is fully assembled before it reaches the Cli. Here you stack request IDs, access logging, rate limiting, CORS, and security headers in one chain. Each .method(...) adds one layer. See the Middleware guide for the full layer catalog and ordering rules.

use rustango::security_headers::{SecurityHeadersLayer, SecurityHeadersRouterExt, CspBuilder};
use rustango::cors::{CorsLayer, CorsRouterExt};
use rustango::rate_limit::{RateLimitLayer, RateLimitRouterExt};
use rustango::access_log::{AccessLogLayer, AccessLogRouterExt};
use rustango::request_id::{RequestIdLayer, RequestIdRouterExt};
use rustango::health::health_router;
use std::time::Duration;

let app = urls::api()
    .nest("/admin", urls::admin_router(pool.clone()))
    .merge(crate::post_view_set::PostViewSet::router("/api/posts", pool.clone()))
    .merge(health_router(pool.clone()))                        // /health, /ready
    .request_id(RequestIdLayer::default())
    .access_log(AccessLogLayer::default())                      // PII-redacted
    .rate_limit(RateLimitLayer::per_ip(60, Duration::from_secs(60)))
    .cors(CorsLayer::new()
        .allow_origins(vec!["https://app.example.com"])
        .allow_methods(vec!["GET", "POST", "PUT", "PATCH", "DELETE"]))
    .security_headers(
        SecurityHeadersLayer::strict()
            .csp(CspBuilder::strict_starter().build()),
    );

Hand the finished app to the Cli exactly as before — rustango::manage::Cli::new().api(app).with_welcome().run().await — and every request now flows through the full middleware stack.


Step 16: Write tests

Rustango includes a test client that drives your router in-process, so you can assert on real HTTP responses without starting a server. Scaffold a test file:

cargo run -- make:test PostSmoke      # generates tests/post_smoke.rs

The make:* generators take a PascalCase name; PostSmoke becomes the snake_case file tests/post_smoke.rs.

Edit tests/post_smoke.rs. Integration tests live in a separate crate, so they build the router under test directly from the ViewSet (the same router(...) call you mounted in Step 12b):

use rustango::test_client::TestClient;
use myblog::post_view_set::PostViewSet;
use rustango::sql::Pool;
use serde_json::json;

async fn app() -> axum::Router {
    // A test in `tests/` is a separate crate and never runs `main`, so
    // nothing has loaded `.env` for it. Without this line `DATABASE_URL`
    // is unset and both tests panic before reaching the database.
    let _ = dotenvy::dotenv();

    let pool = Pool::connect(&std::env::var("DATABASE_URL").unwrap()).await.unwrap();
    PostViewSet::router("/api/posts", pool)
}

#[tokio::test]
async fn list_posts_returns_200() {
    let client = TestClient::new(app().await);
    let response = client.get("/api/posts").send().await;
    assert_eq!(response.status, 200);
    let v = response.json_value();
    assert!(v["results"].is_array());
}

#[tokio::test]
async fn create_post_returns_the_new_object() {
    let client = TestClient::new(app().await);
    let response = client.post("/api/posts")
        // Post the serializer's fields. `status` is omitted because the
        // serializer does not list it, so it would be dropped anyway —
        // the model's `default = "'draft'"` fills it in.
        .json(&json!({
            "title": "Test",
            "content": "x",
            "author_id": 1,
        }))
        .send().await;
    assert_eq!(response.status, 201);
    let v: serde_json::Value = response.json();
    assert_eq!(v["title"], "Test");
}

Three things in that snippet are easy to get wrong, and each produces a different failure:

  • dotenvy::dotenv() — omit it and both tests fail, before any request is made.
  • content, not body — the serializer accepts either here, since source = "body" keeps the model's name working on input, but content is the name your API actually publishes.
  • author_id — omit it and the create test alone fails, with a not-null violation from the database. The list test still passes, because an empty table is a valid empty page.

Pool is the multi-backend pool, and router takes any backend's pool, so this file compiles unchanged on PostgreSQL, MySQL and SQLite.

Heads-up: integration tests in tests/ can only use myblog::… if the crate exposes a library target. A fresh scaffold is binary-only (src/main.rs, no src/lib.rs), so add a one-line src/lib.rs that re-exports the modules you want to test — pub mod models; pub mod post_view_set; pub mod urls; — and keep the matching mod …; lines in src/main.rs. (If you'd rather not add a lib target, build the router fully inline in the test instead, the way make:test scaffolds its app().)

Run the tests:

cargo test --test post_smoke

Step 17: Run the system check

Before you deploy, run the built-in checker. It flags common misconfigurations, like a weak RUSTANGO_SESSION_SECRET or an unreachable database.

cargo run -- check --deploy

In your local dev environment you'll see something like:

running rustango system check (deploy mode)...
  [info]    6 models registered via inventory
  [info]    database reachable
  [info]    1 migration(s) on disk
  [info]    RUSTANGO_SESSION_SECRET length OK
  [info]    config tier resolved to `dev`
  [warning] RUSTANGO_ENV is unset — set to `prod` so config loaders pick the right tier
  [warning] DATABASE_URL points at localhost / 127.0.0.1 — verify this is intended in production
  [warning] RUSTANGO_APEX_DOMAIN is unset / `localhost` — set it for tenancy projects

(The exact model/migration counts depend on your project.) Those three warnings are the expected dev-environment ones. In a production setup — RUSTANGO_ENV=prod, a managed-database DATABASE_URL, an apex domain set — they clear and you'll see all checks passed. Fix any remaining warnings or errors before pushing to production.


Step 18: Deploy to production

How you deploy depends on your platform (Fly, Railway, Kubernetes, bare ECS, and so on). The framework-side steps are the same everywhere; the --release flag builds an optimized binary:

# 1. Set production env
export RUSTANGO_ENV=prod
export DATABASE_URL=postgres://prod-host/myblog
export RUSTANGO_SESSION_SECRET=$(openssl rand -base64 32)

# 2. Run migrations
cargo run --release -- migrate

# 3. Audit
cargo run --release -- check --deploy

# 4. Build binary
cargo build --release

# 5. Run with a process supervisor (systemd / docker / k8s)
./target/release/myblog

Make sure your reverse proxy:

  • Terminates HTTPS
  • Forwards X-Forwarded-For for accurate IPs in AccessLogLayer
  • Forwards X-Forwarded-Host, X-Forwarded-Proto
  • Uses axum::serve(listener, app.into_make_service_with_connect_info::<SocketAddr>()) so ConnectInfo is populated for rate limiting + IP filtering

Where to go next

Topic Doc
Runnable version of this guide examples/getting_started_blog
Every manage subcommand docs/manage.md
ORM cookbook (advanced filters, aggregations, M2M, soft delete) docs/orm.md
Middleware (the full layer catalog + ordering) docs/middleware.md
Performance benchmarks (vs Go) docs/benchmarks.md
API conventions (naming, builder patterns, feature gates) docs/api-conventions.md
Security features in depth docs/security.md
Multi-tenancy README — Multi-tenancy section
API docs https://docs.rs/rustango

If you hit something that doesn't work or is unclear, open an issue.