rustango
A Django-shaped, batteries-included web framework for Rust.
Bi-dialect ORM (Postgres / MySQL / SQLite) with auto-migrations, multi-tenancy, auto-admin (token-driven theme system + dark mode + per-tenant branding via pluggable Storage trait — S3/R2/B2/MinIO/Local), sessions + JWT + OAuth2/OIDC + HMAC auth, signals, caching, first-class media (Postgres rows + S3/R2/B2/MinIO + presigned uploads + collections + tags), email pipeline (renderer + jobs + Mailable), background jobs (in-mem + Postgres), webhook delivery, OpenAPI 3.1 auto-derive from serializers + viewsets, JSON:API + RFC 7807 Problem Details, scheduled tasks, RFC 6238 TOTP, signed URLs, Prometheus metrics, OTel-shape tracing, distributed locks + rate limits + feature flags, every standard middleware (CSRF, CSP nonce, gzip/deflate, body limit, real-IP, idempotency, maintenance, trailing slash, static files, method override, server-timing, …) — all shipped, all opt-out via cargo features.
[]
# Postgres (default)
= "0.29"
# SQLite — file-backed or in-memory, full bi-dialect ORM
= { = "0.29", = ["sqlite"] }
# Multiple backends in one binary
= { = "0.29", = ["postgres", "sqlite"] }
Spin up an app on SQLite in 30 lines
use Arc;
use ;
use Model as _;
use AppBuilder;
use ;
use Model;
async
async
DATABASE_URL='sqlite:./var/app.db?mode=rwc' \
Same code unchanged with DATABASE_URL=postgres://… boots on Postgres.
Multi-tenant on SQLite? The
tenancymodule'sTenantPools/Builderis still PG-only — pending refactor in v0.28. For SQLite tenants today, see Cookbook chapter 13 for the per-tenantPoolregistry shape.
Table of contents
- Quick start
- Project layout
manageCLI reference- Configuration
- ORM cookbook
- Migrations
- Auto-admin
- APIs (ViewSet + Serializer + JWT)
- HTML views (Django-shape CBVs)
- Forms
- Multi-tenancy
- Authentication & permissions
- Security middleware
- Caching
- Email + storage + scheduling
- Signals
- i18n
- Testing
- Feature flags
- Contributing — git hooks
- Production checklist
Quick start
1. Install the scaffolder
2. Create a project
3. First-time setup
Autoreload during development — recompiles + restarts on every file save:
# Or with bacon (faster, nicer UI):
You should see the scaffolded landing page (src/views.rs::index) at / confirming rustango is wired up. Replace it with your own handler — or mount a real router under / — when you're ready.
4. Add an app + model
Since v0.28.3 the scaffolder ships a singularized starter model
(startapp posts → pub struct Post on table "post"), an
admin(...) config block, a created_at timestamp, and a smoke
test that asserts the model registered itself in inventory.
Rename the struct or table literal freely once it doesn't fit.
Edit src/blog/models.rs:
use ;
use ;
5. Generate a viewset + serializer
Edit the generated files to fill in field lists, then mount in src/urls.rs.
Project layout
A scaffolded project looks like:
myblog/
├── Cargo.toml # rustango + axum + tokio + sqlx
├── .env / .env.example # DATABASE_URL, SECRET_KEY, RUSTANGO_ENV
├── docker-compose.yml # Postgres in a container for local dev
├── README.md
├── config/ # tiered settings (#87, v0.29)
│ ├── default.toml # shared knobs, every section commented
│ ├── dev_settings.toml # local Postgres URL, relaxed headers, `(dev)` tagline
│ ├── staging_settings.toml # 0.0.0.0 bind, strict headers, 30d retention
│ └── prod_settings.toml # pool 5-50, strict headers, 365d retention
├── migrations/ # JSON files written by `cargo run -- makemigrations`
└── src/
├── main.rs # one binary — HTTP server + manage CLI; declares `mod`s here
├── urls.rs # top-level Router composition
├── models.rs # OR per-app: src/blog/models.rs
└── views.rs
The runtime picks a tier from RUSTANGO_ENV (defaults to dev); see
Configuration below.
Since v0.16 the manage CLI is dispatched from main.rs via
rustango::manage::Cli — running cargo run with no args starts
the HTTP server, and cargo run -- <verb> dispatches a CLI command.
There is no longer a separate src/bin/manage.rs, and the scaffold
is binary-only (no src/lib.rs) — apps are declared as mod blog;
inside src/main.rs. The scaffolded main.rs is just:
async
The tenant template additionally calls .tenancy() and
.migrations_dir(...) on the Cli builder.
Per-app structure (created by cargo run -- startapp <name>):
src/blog/
├── mod.rs
├── models.rs # #[derive(Model)] structs
├── views.rs # axum handlers
└── urls.rs # router for this app
manage CLI reference
Since v0.16 there is no separate manage binary — the verbs are
dispatched by the project's own binary via rustango::manage::Cli,
so the canonical invocation is cargo run -- <verb>. Every command
has --help. Exit code is non-zero on validation / IO / system-check
errors.
Migrations
# one fresh diff (dev-iteration escape
# hatch — refuses to touch applied rows)
|
Data migrations
Scaffolders
System
# DATABASE_URL, RUSTANGO_APEX_DOMAIN, RUSTANGO_BIND
# PLUS Settings audit (#87): flags dev-defaults left
# in prod tier — headers_preset=dev, hsts_max_age=0,
# argon2 below OWASP floor, JWT TTL > 1h, loopback bind
|
Tenancy (only with tenancy feature)
# Re-seed the rustango_permissions catalog after adding
# `#[rustango(permissions)]` to a model — without --slug walks every
# active tenant; idempotent (UNIQUE on (content_type_id, codename)).
# Move a populated tenant between schema and database storage modes
# (pg_dump → psql pipe, Org row update, pool eviction, smoke check):
Configuration
Tiered TOML settings (since v0.29, #87)
— a fresh project ships four config files; the runtime picks the
right tier from RUSTANGO_ENV.
config/
├── default.toml # shared defaults; every section commented + documented
├── dev_settings.toml # local Postgres URL, headers_preset = "dev",
│ # hsts_max_age_secs = 0, tagline "(dev)"
├── staging_settings.toml # bind 0.0.0.0, strict headers, retention 30d
└── prod_settings.toml # pool 5-50, strict headers, retention 365d
Loader pipeline
config/default.toml— required, shared defaults.config/<RUSTANGO_ENV>_settings.toml— tier overlay (the legacy<env>.tomlshape still loads when no_settingsvariant exists).RUSTANGO__SECTION__KEY=valueenv vars — final override.
// Reads RUSTANGO_ENV (defaults to "dev"), runs the layered load.
let cfg = load_from_env?;
// Or pick the tier explicitly:
let cfg = load?;
// Resolved tier (useful for telemetry / version pages):
let tier = current_env_tier;
// One-liner wiring — at runserver time this picks up bind address,
// RouteConfig, AND auto-applies the security_headers + CORS +
// access_log + body_limit layers built from [security] / [audit] /
// [server] settings. Falls back to Cli defaults silently if config
// files are missing (with a tracing::warn).
new
.api
.with_settings_from_env
.with_health // /health + /ready endpoints
.with_static // CSS, JS, images
.with_csrf // form-driven app? mount CSRF
.with_welcome // friendly "/" on first run
.run.await
Apps using only TOML-side routes config no longer need to call
.routes(RouteConfig::legacy()) from code — set
[routes] legacy_preset = true in prod_settings.toml and
with_settings_from_env() picks it up.
Layer order at runserver time (innermost → outermost):
body_limit → access_log → CORS → security_headers → handler.
This matches the canonical recommendation in the production
checklist below — and you don't have to wire any of it manually.
Sections
# config/default.toml
[] # url, pool_min_size, pool_max_size
[] # allowed_tables, read_only_tables
[] # bind, request_timeout_secs, max_body_bytes
[] # argon2 cost, lockout threshold/duration
[] # access_ttl_secs, refresh_ttl_secs, issuer, audience
[] # name, tagline, logo_url, primary_color, theme_mode
[] # headers_preset, csp, hsts_max_age_secs, cors_allowed_origins
[] # legacy_preset + per-field URL prefix overrides
[] # retention_days, redact_query_params
[] # apex_domain
[] # backend, redis_url
[] # backend, concurrency
[] # backend, smtp_host, from_address
Every field is Option<T> with sensible defaults documented in
config::sections — missing
keys fall through to Default::default(), so your TOML stays
forward-compatible.
Compile-time feature reflection
let feats = detected_features;
// → ["postgres", "tenancy", "admin", "manage", "config", ...]
Useful on /about pages, in deployment audits, or as a sanity check
that the prod binary was built with every feature its TOML
references.
Deploy audit
cargo run -- check --deploy flags dev-defaults that survived a
promotion to the prod tier:
[security] headers_preset = "dev"or"none"→ warning[security] hsts_max_age_secs = 0→ warning[auth] argon2_memory_kib < 19456→ warning (OWASP 2024 floor)[auth.jwt] access_ttl_secs > 3600→ warning (use refresh flow)[server] bind = 127.0.0.1:*/localhost:*→ warning[audit] retention_daysunset → info ("log grows forever")[routes] legacy_preset = true→ info (deliberate v0.28 shape)
The audit is a no-op on dev/staging tiers — operators only want verbose feedback when something promoted to prod was incorrectly relaxed.
ORM cookbook
Model declaration
use ;
use ForeignKey;
use ;
use Uuid;
Field types
| Rust type | Postgres | MySQL | Notes |
|---|---|---|---|
i16 |
SMALLINT |
SMALLINT |
2-byte signed, -32768..=32767. Smallest portable integer. |
i32 |
INTEGER |
INT |
4-byte signed. |
i64 |
BIGINT |
BIGINT |
8-byte signed. Default PK width. |
f32 |
REAL |
FLOAT |
|
f64 |
DOUBLE PRECISION |
DOUBLE |
|
bool |
BOOLEAN |
TINYINT(1) |
|
String |
TEXT / VARCHAR(N) |
TEXT / VARCHAR(N) |
TEXT unless max_length = N is set. |
chrono::DateTime<Utc> |
TIMESTAMPTZ |
DATETIME(6) |
|
chrono::NaiveDate |
DATE |
DATE |
|
uuid::Uuid |
UUID |
CHAR(36) |
|
serde_json::Value |
JSONB |
JSON |
i8, u8/u16/u32/u64 are intentionally not supported — Postgres has no native 1-byte signed integer and no unsigned integers, so cross-dialect storage would diverge. Use i16 and bounded min/max attributes instead. Auto<i16> is also rejected (SMALLSERIAL exhausts at 32k); use Auto<i32> or Auto<i64> for auto-incrementing PKs.
Nullable fields
Wrap any scalar in Option<T> to make the column NULL-able. None round-trips through fetch + insert + update; parse_form_value and the admin's edit form both treat empty input as NULL.
Auto<Option<T>> and Option<Auto<T>> are both rejected — Auto<T> columns are server-assigned and cannot be NULL.
Primary keys
Any scalar field marked #[rustango(primary_key)] becomes the PK. Three common shapes:
// Auto-incrementing i64 PK — the default for most tables.
pub id: ,
// Auto-incrementing i32 PK — saves 4 bytes per row when 2B is enough.
pub id: ,
// Server-generated UUID PK — Postgres `gen_random_uuid()`.
pub id: ,
// Caller-supplied String PK — common for slug-shaped natural keys.
pub slug: String,
Auto<T> is supported on i32, i64, Uuid, and DateTime<Utc> (the last for auto_now_add defaults). Other PK types (i16, plain integers, String) require the caller to supply the value on insert.
Foreign keys
ForeignKey<T, K = i64> is the typed, lazy-loadable wrapper. T is the parent model; K is the parent's primary-key type (defaults to i64).
use ForeignKey;
// Default — parent PK is i64 (or Auto<i64>). Stored as BIGINT.
pub author: ,
// Parent PK is uuid::Uuid. Stored as UUID.
pub region: ,
// Parent PK is String. Stored as VARCHAR(N) — provide max_length on the field.
pub user: ,
// Nullable FK — column allows NULL, the field stores Option<ForeignKey<…>>.
pub editor: ,
pub region: ,
// Self-referential FK — for trees / hierarchies. The macro substitutes the
// containing table name, sidestepping the type-name self-reference cycle.
pub parent_id: ,
Reading the parent on demand:
let mut book = objects.where_.fetch_one.await?;
let author: &Author = book.author.get.await?; // fires one SELECT, caches the result
println!;
let cached = book.author.get.await?; // no SQL — returns the cached parent
For one-shot eager loading:
let books = objects
.select_related // single LEFT JOIN
.fetch.await?; // every book.author is already Loaded
prefetch_related (the bulk N+1 killer for <parent>.<child>_set) currently requires i64 parent PKs; non-i64 FK PKs work for everything else but skip the prefetch grouper — tracked as P10 in the ORM improvement plan.
Raw FK attribute (bypass ForeignKey<T>)
When you want a foreign-key constraint on a plain typed field — no lazy-load, no wrapper — use #[rustango(fk = "<table>")]:
This is the v0.1 form — emits the same SQL constraint, no Rust-side resolver. Use it for hot-path columns where you don't want to opt into ForeignKey's lazy-load machinery, or when migrating from a legacy schema.
Composite (multi-column) FK
Container attribute, declared once per relation:
Field attributes
Attribute on the field carries column-level options:
| Attribute | Effect |
|---|---|
#[rustango(primary_key)] |
Marks the PK. Exactly one per model. |
#[rustango(column = "name")] |
Override the SQL column name. Default = field name. |
#[rustango(max_length = N)] |
VARCHAR(N) (String) / range check (integer). |
#[rustango(min = N, max = N)] |
Integer range check enforced at parse + insert/update. |
#[rustango(default = "expr")] |
Raw SQL fragment for the column's DEFAULT. Quote literals yourself. |
#[rustango(unique)] |
Adds UNIQUE to the column DDL inline. |
#[rustango(index)] |
Single-column CREATE INDEX. |
#[rustango(auto_now_add)] |
DB sets now() on INSERT; field becomes immutable on subsequent saves. Pair with Auto<DateTime<Utc>>. |
#[rustango(auto_now)] |
Same as above + bound to now() on every UPDATE too. |
#[rustango(auto_uuid)] |
DB sets gen_random_uuid() on INSERT. Pair with Auto<Uuid>. |
#[rustango(soft_delete)] |
Routes admin DELETE through UPDATE … = NOW(). Requires Option<DateTime<Utc>>. |
#[rustango(generated_as = "EXPR")] |
DB-computed column — emits GENERATED ALWAYS AS (EXPR) STORED. The macro skips the column from every INSERT and UPDATE; Postgres recomputes the value from EXPR. Read-back via FromRow works as for any other column. |
#[rustango(fk = "table")] |
Raw foreign-key constraint on a plain field. |
#[rustango(on = "column")] |
Override the FK target column (default "id"). |
Container attributes
Attributes on the struct itself, all wrapped in #[rustango(...)]:
| Attribute | Effect |
|---|---|
table = "name" |
Override the SQL table name. Default = snake-case of struct name. |
display = "field" |
Field rendered when this model is the target of an FK in admin / OpenAPI. |
app = "label" |
Django-shape app label. Default = inferred from module path. |
admin(...) |
Auto-admin config: list_display, search_fields, list_filter, ordering, list_per_page, readonly_fields. |
audit(track = "f1, f2") |
Per-write before/after diff captured for these fields. Empty list = all scalar fields. |
permissions |
Auto-seed the four CRUD codenames (add, change, delete, view). |
index("a, b") / unique_together = "a, b" |
Composite (multi-column) index / unique constraint. The first declared unique_together doubles as the ON CONFLICT target for the macro-generated Model::upsert() — useful for surrogate-PK + composite-UNIQUE shapes (junction rows, membership tables) where conflicting on a BIGSERIAL PK would never fire. |
unique_together(columns = "a, b", name = "my_idx") |
Same with an explicit constraint name override. |
check(name = "n", expr = "raw SQL") |
Table-level CHECK constraint. |
m2m(name = "...", to = "...", through = "...", src = "...", dst = "...") |
Many-to-many relation through a junction table. |
fk_composite(name = "...", to = "...", from = (...), on = (...)) |
Multi-column FK (see above). |
Querying
use Column as _;
use Fetcher as _;
// Filter + order
let recent = objects
.where_
.where_
.order_by // true = DESC
.limit
.fetch.await?;
// Pagination
let page = objects.page.fetch.await?;
// Aggregation
let count = objects.where_.count.await?;
let avg = objects.avg.await?;
// IN / NOT IN
let some = objects
.where_
.fetch.await?;
// Pattern lookups
let drafts = objects
.where_ // ILIKE %draft%
.fetch.await?;
// Pre-load FKs (no N+1)
let with_authors = objects
.select_related
.fetch.await?;
Mutations
// Insert
let mut p = Post ;
p.save_on.await?;
println!;
// Update — same `save_on()`, dispatched by Auto<T> PK state
p.title = "Hello world".into;
p.save_on.await?;
// Bulk insert
bulk_insert_on.await?;
// Bulk update
objects
.where_
.update
.set
.execute_on.await?;
// Upsert (ON CONFLICT)
post.upsert_on.await?;
// Delete (soft if model has #[rustango(soft_delete)])
p.soft_delete_on.await?;
p.restore_on.await?;
Transactions
transaction.await?;
EXPLAIN — query planner output for any queryset
use ;
// Plain EXPLAIN — safe (no execution).
let plan = objects
.where_
.explain.await?;
for line in plan
// EXPLAIN (ANALYZE, BUFFERS) — actually runs the query.
let plan = objects
.where_
.explain_on
.await?;
// EXPLAIN (FORMAT JSON) — parseable payload.
let plan = objects
.explain_on
.await?;
let parsed: Value = from_str?;
Bi-dialect (Postgres + MySQL) via &Pool
The classic API takes &PgPool (Postgres-only). The v0.23.0 series
adds a parallel &Pool API that targets either backend; pick MySQL
8.0+ or Postgres at runtime via the connection URL.
# Cargo.toml — opt in to MySQL alongside the default postgres feature
= { = "0.29", = ["mysql"] }
use ;
// Connect from URL (postgres:// or mysql://) or from split env vars
// (DB_DRIVER + DB_HOST + DB_PORT + DB_USER + DB_PASSWORD + DB_NAME +
// DB_PARAMS — passwords are auto percent-encoded).
let pool = connect_from_env.await?;
// Schema bootstrap (CREATE TABLE per registered model — dialect-aware
// type names, BIGINT AUTO_INCREMENT vs BIGSERIAL, JSON vs JSONB, etc.).
apply_all_pool.await?;
// Or run the file-based ledger runner — full Django-shape lifecycle.
migrate_pool.await?;
migrate_to_pool.await?;
downgrade_pool.await?;
unapply_pool.await?;
// Macro-emitted CRUD against either backend (every #[derive(Model)] type).
let mut user = User ;
user.insert_pool.await?; // Auto<i64> populated via
// RETURNING (PG) / LAST_INSERT_ID() (MySQL)
user.name = "Alice".into;
user.save_pool.await?; // INSERT-or-UPDATE; audited models
// emit a transactional diff audit row
user.delete_pool.await?; // DELETE (transactional with audit
// emit when the model is audited)
// QuerySet read path — single-table, select_related joins, prefetch,
// pagination, and aggregates all bi-dialect.
let posts: = objects
.filter
.select_related // joins decoded automatically
.order_by
.limit
.fetch_pool.await?;
let n: i64 = objects.count_pool.await?;
let page = fetch_paginated_pool.await?;
let with_kids: =
.await?;
// Cross-table atomicity — open a backend-tagged transaction.
let mut tx = transaction_pool.await?;
match &mut tx
tx.commit.await?;
Operator translations: ILIKE → LOWER(col) LIKE LOWER(?),
IS DISTINCT FROM → NOT (col <=> ?), JSONB @> → JSON_CONTAINS,
JSONB ?/?|/?& → JSON_CONTAINS_PATH(col, 'one'|'all', CONCAT('$.', ?)),
UPDATE … FROM (VALUES …) → UPDATE … INNER JOIN (VALUES ROW(?, ?), …).
ON CONFLICT DO UPDATE SET col = EXCLUDED.col → ON DUPLICATE KEY UPDATE col = VALUES(col) (with target: vec![]; MySQL's upsert can't take a
target column list).
MySQL caveats:
- requires MySQL 8.0+ (window functions for
fetch_paginated_pool,JSONcolumn type,VALUES ROW(…)syntax forbulk_update_pool) fetch_paginated_poolusesCOUNT(*) OVER ()— needs 8.0LAST_INSERT_ID()reports one auto-assigned column per connection, so models with multipleAuto<T>PKs error at runtime on MySQL (PostgresRETURNINGis unaffected)
Migration story: the &PgPool API stays exactly as it was — every
existing app keeps working unchanged on upgrade. Adopt &Pool at
your own pace (or never, if you only target Postgres).
Many-to-many
// All declared via #[rustango(m2m(...))] — junction table auto-created
let tag_ids = post.tags_m2m.all.await?;
post.tags_m2m.add.await?;
post.tags_m2m.remove.await?;
post.tags_m2m.set.await?; // replace all
post.tags_m2m.clear.await?;
let has = post.tags_m2m.contains.await?;
ContentTypes — generic relations + composite-key FKs + soft-FK prefetch
Django-shape framework for "any registered model" pointers. Lets one
table point at any other model via (content_type_id, object_pk)
(comments-on-anything, audit log targets, activity streams, tag
generic relations) and lets a single FK constraint span multiple
columns. Sub-slices F.1 / F.2 / F.3 of v0.15.0.
Bootstrap
// Registers one row per #[derive(Model)] type in `rustango_content_types`.
// Idempotent — re-runs on a populated DB return Ok(0). Run once at startup
// after `migrate(&pool, dir).await?`.
let inserted = ensure_seeded.await?;
Lookups
use ContentType;
// By Rust type
let ct = .await?; // Option<ContentType>
// By natural key (parsed permission codenames, admin URLs, etc.)
let ct = by_natural_key.await?;
// By id (FK joins from audit log / permissions / generic FK rows)
let ct = by_id.await?;
// Full listing for admin sidebars / API
let all_cts = all.await?; // ordered (app, model)
Composite-key foreign keys (F.2)
Multi-column FKs declared on the model, not the field. Each participating column keeps its plain Rust type — the FK metadata records which columns participate and where they reference.
// Emits on migrate / apply_all:
// ALTER TABLE "audit_log" ADD CONSTRAINT "audit_log_target_fkey"
// FOREIGN KEY ("entity_table", "entity_pk")
// REFERENCES "ct_live_pair" ("table_name", "row_pk");
Both Postgres and MySQL emit the standard composite FK syntax —
only identifier quoting differs. Single-column FKs continue to use
the existing per-field Relation::Fk machinery; composite FKs sit
on ModelSchema.composite_relations so the existing single-FK
machinery (admin display, snapshot diff) stays untouched.
GenericForeignKey + soft-FK prefetch (F.3)
use ;
// `GenericForeignKey` — a runtime pointer at any registered model's row.
let gfk = .await?;
// gfk.content_type_id ← Post's ContentType row id
// gfk.object_pk ← post_id
Soft-FK prefetch — for integer columns that conceptually point at
another model's PK without a declared Relation::Fk (audit log
entity_pk, denormalized snapshots, optional cross-app refs):
let parent_pks: = posts.iter.map.collect;
// One batched SELECT + group-by-extractor → HashMap<i64, Vec<C>>
let by_post = .await?;
for post in &posts
Generic-FK prefetch — for (content_type_id, object_pk) pointers
that vary their target type per row. Single-target-type variant —
caller picks the concrete T: Model to hydrate; pairs whose
content_type_id doesn't match are filtered out:
let pairs: = audit_rows.iter
.map
.collect;
let posts: =
.await?;
for row in &audit_rows
Both prefetch helpers short-circuit on empty input (no DB round trip) and use a single batched SELECT for the actual fetch.
What this unblocks:
- Permissions (Option G, v0.16.0) —
permission.content_type_idis a real FK torustango_content_types.idinstead of a hard-codedapp.action_modelstring that breaks when two apps register the same model name. - Audit history admin panels —
User.history.all()-style queries become composite-FK joins instead of raw SQL. - Comments / tags / generic FK — one
Commentmodel points at anyPost/Photo/Articlevia(content_type_id, object_pk), queried + admin-rendered in one shape. - Activity stream / "recently changed" feeds — the target of
each entry hydrates in one batched
prefetch_genericcall per target type, no N+1.
Deferred (follow-up slice):
- Boxed-trait dynamic decoder registry →
prefetch_generic_dynfor mixed-target hydration in one query. - Admin renderer for
GenericForeignKeycolumns (clickable target links in list/detail). composite_relationssnapshot/diff support inmake_migrations.
Migrations
Migration files are JSON in migrations/, lex-sorted by name. They:
- Embed the full schema snapshot — any one file is a self-contained starting state
- Include both schema ops AND data ops in the
forwardlist, in any order - Are invertible when each op carries
reverse_sql(or has a natural inverse) - Run atomically per file by default — partial progress recoverable
Auto-detected schema changes
| Change | Op generated | Notes |
|---|---|---|
| New struct | CreateTable |
+ deferred FK constraints |
| Removed struct | DropTable |
CASCADE |
| New field | AddColumn |
rejects NOT NULL without default |
| Removed field | DropColumn |
|
| Type changed | AlterColumnType |
with USING ::pg_type cast |
| Nullable flipped | AlterColumnNullable |
|
| Default changed | AlterColumnDefault |
|
max_length changed |
AlterColumnMaxLength |
VARCHAR(N) ↔ TEXT |
unique toggled |
AlterColumnUnique |
|
New #[rustango(index)] / composite index |
CreateIndex |
unique flag respected |
New #[rustango(check(...))] |
AddCheckConstraint |
|
| New M2M relation | CreateM2MTable |
composite PK + 2 FKs ON DELETE CASCADE |
| Renames | NOT auto-detected | use cargo run -- makemigrations --empty and edit JSON |
Hand-authored data migrations
# Quick path — one-liner
# Or append to an existing migration
# Or scaffold an empty file and edit manually
Auto-admin
Mount once and every #[derive(Model)] is fully editable:
let app = new
.title
.show_only
.read_only
.build;
Lives at /__admin/. Per-model customization via #[rustango(admin(...))]:
| Knob | Effect |
|---|---|
list_display = "f1, f2, f3" |
Columns on list view (FKs render display-name) |
search_fields = "f1, f2" |
?q=... search box |
list_filter = "fk_field, status" |
Right-rail facet filters with counts |
ordering = "field, -other" |
Default sort (- prefix = DESC) |
list_per_page = 50 |
Pagination size |
readonly_fields = "created_at" |
Shown but not editable |
fieldsets = "Group A: f1, f2 | Group B: f3" |
Form layout |
actions = "delete_selected, my_action" |
Bulk actions |
Bulk actions
use ;
use Arc;
let registry = new
.register
.register;
// In a handler:
let result = registry.run.await?;
println!;
Theming + per-tenant branding (v0.26)
Both admin surfaces — the per-tenant Django-shape admin and the
operator console — share a CSS-variable token vocabulary
(--color-bg, --color-fg, --color-accent, --space-*,
--font-*, --radius-*, --shadow-*). Total customization without
rewriting HTML, just CSS-variable overrides. Light by default,
[data-theme="dark"] override, prefers-color-scheme auto-switch.
A fixed-position theme toggle button (auto → light → dark) persists
to localStorage with a no-flash inline <head> script.
Per-tenant branding rides the framework's existing Storage
trait — wire any backend (LocalStorage, S3, R2, B2, MinIO, custom)
through TenantAdminBuilder::brand_storage(...) /
operator_console::router_with_brand_storage(...). When the backend
exposes URLs (Storage::url(key)), rendered <img src> tags point
straight at the origin or CDN — the /__brand__/{slug}/{filename}
fallback handler is only mounted for backends that return None.
Org carries six brand columns: brand_name, brand_tagline,
logo_path, favicon_path, primary_color, theme_mode. Edit live
through the existing operator-console org-edit form; logo / favicon
upload via the same form's multipart sub-form. primary_color
drives a derived --color-accent / --color-accent-hover /
--color-accent-bg-soft triple via branding::build_brand_css(&org),
safelisted to hex-only values (no raw CSS from operator input).
Operator-console branding is env-driven for the global UI:
RUSTANGO_OPERATOR_BRAND_NAME="Acme Operations"
RUSTANGO_OPERATOR_TAGLINE="Internal admin"
RUSTANGO_OPERATOR_LOGO_URL="https://cdn.example.com/acme-ops.png"
RUSTANGO_OPERATOR_PRIMARY_COLOR="#2c5fb0"
RUSTANGO_OPERATOR_THEME_MODE="auto"
Session invalidation on password rotation (v0.28.4)
Sessions minted before a password change are now invalidated on
the next request instead of remaining valid until their TTL
expires. Both consoles (tenant admin + operator console) check the
cookie's iat (issued-at, stamped at mint time) against the
account's password_changed_at column on every authenticated
request — sessions older than the rotation get bounced to login.
The lookup is folded into the existing per-request is_superuser
/ active query, so there's no extra round-trip. Existing
deployments pick up the new column on the next cargo run -- migrate (idempotent ALTER TABLE … ADD COLUMN IF NOT EXISTS).
Cookies minted by pre-0.28.4 servers stay parseable
(#[serde(default)] on the new field) — their iat decodes as
0 so they're invalidated by any future password change.
Self-serve change-password page + CLI ergonomics (v0.28.2)
Tenant users can change their own password without an operator
in the loop. The admin sidebar shows a Change password link
when the tenant admin is wired with a session secret; clicking
it lands on /__change-password (URL configurable via
RouteConfig::change_password_url, defaults to
/change-password under friendly()). The form takes the
current password (verified server-side), a new password, and a
confirmation; on success it stores the new Argon2id hash and
redirects with a "Password updated" banner.
Two new CLI verbs cover the symmetric flow:
# Self-serve symmetric: requires the current password.
# Operator-driven recovery (no current pw needed):
Every password verb (create-operator, create-user,
reset-password, reset-operator-password, change-password,
change-operator-password) accepts a --generate flag that
emits a 20-character secure random password from a 58-char
unambiguous alphabet (no 0/O, 1/l/I):
# created user `alice` in tenant `acme` (id 1, superuser=true)
# generated password: kT3nx9pZQRgwYjvFmCdh
# store this safely — it won't be shown again
--password and --generate are mutually exclusive. Sessions
issued before a password change currently remain valid until
they expire — password_changed_at cookie invalidation is on
the v0.29 roadmap.
Users / roles / permissions admin (v0.28.1)
Every framework auth + RBAC table is admin-visible in a tenant
admin: rustango_users, rustango_roles, rustango_role_permissions,
rustango_user_roles, rustango_user_permissions. The four junction
models carry admin(...) config so list views render
role_id, codename, user_id, role_id, and
user_id, codename, granted with sensible ordering.
Visiting a user's detail page (/{admin_url}/rustango_users/{id})
renders a Roles & permissions panel showing the user's assigned
roles (linked through to each role's detail page) and their
effective codenames — the union of role grants + direct grants
minus explicit denials, computed by the same SQL the runtime
has_perm check uses. Quick links beneath the panel jump to the
four manage-able junction tables for inline editing. The panel is
read-only at the user level: assign / revoke flows go through the
junction-table admin pages.
When the permission tables haven't been seeded
(tenancy::ensure_permission_tables(&pool) not called), the panel
hides itself silently — same posture as the audit-trail panel.
APIs (ViewSet + Serializer + JWT)
#[derive(ViewSet)] — full CRUD in 5 lines
use ViewSet;
;
// Mount:
let app = new
.merge;
Endpoints
| Method | Path | Action |
|---|---|---|
GET |
/api/posts |
List (page-number or cursor pagination) |
POST |
/api/posts |
Create |
GET |
/api/posts/{pk} |
Retrieve |
PUT |
/api/posts/{pk} |
Update |
PATCH |
/api/posts/{pk} |
Partial update |
DELETE |
/api/posts/{pk} |
Delete (soft when model carries #[rustango(soft_delete)]) |
Query params (list endpoint)
?page=2&page_size=50 # page-number pagination
?cursor=eyJpZCI6MTAwfQ&page_size=50 # cursor pagination (opt-in)
?search=rust&ordering=-published_at # search + sort
?author_id=42 # exact filter
?published_at__gte=2026-01-01 # Django-style lookup operators
?title__icontains=draft # gt, gte, lt, lte, ne, in, not_in,
# contains, icontains, startswith,
# istartswith, endswith, iendswith, isnull
Serializers
use Serializer;
use ModelSerializer;
// Use:
let s = from_model;
let json = s.to_value;
let array = many_to_value;
JWT lifecycle
use ;
use json;
let jwt = new.with_access_ttl.with_refresh_ttl;
// Login: embed roles + scope in the token
let pair = jwt.issue_pair_with?;
// Authenticated request — no DB lookup needed:
let claims = jwt.verify_access.ok_or?;
let roles: = claims.get_custom.unwrap;
// Refresh — preserves custom claims:
let new_pair = jwt.refresh.ok_or?;
// Refresh with re-evaluated permissions:
let downgraded = jwt.refresh_with?;
// Logout:
jwt.revoke;
jwt.revoke;
Reserved-claim defense: sub, exp, jti, typ cannot appear in custom (returns JwtIssueError::ReservedClaim).
For tenancy projects there's a one-liner that mounts the four-route
JWT auth surface (/api/auth/login / /refresh / /logout / /me):
use auth_routes;
// Pick up `[auth.jwt] access_ttl_secs / refresh_ttl_secs` from the
// loaded Settings; falls through to defaults (15min / 7d) when unset.
let cfg = load_from_env?;
let auth = default.with_jwt_settings;
let api = api.merge;
The endpoints are tenant-aware via the Tenant extractor, so the
JWT's tenant claim is matched against the resolved subdomain — a
token minted on acme.example.com is rejected on globex.example.com.
HTML views (Django-shape CBVs)
rustango::template_views is the HTML-side sibling of viewset —
generic class-based views that build a Tera-rendered axum Router
over any #[derive(Model)] schema. The full Django-shape CRUD
surface ships: ListView, DetailView, CreateView, UpdateView,
DeleteView.
use ;
use Arc;
use Tera;
let tera = new;
let app = new
.merge
.merge
.merge
.merge
.merge;
| view | URL | template default | context |
|---|---|---|---|
ListView |
GET <prefix> |
<table>_list.html |
object_list, page, page_size, total, total_pages, has_next, has_prev |
DetailView |
GET <prefix>/{pk} |
<table>_detail.html |
object |
CreateView |
GET/POST <prefix>/new |
<table>_form.html |
form: { fields, errors }, is_create=true |
UpdateView |
GET/POST <prefix>/{pk}/edit |
<table>_form.html |
form: { fields, errors }, object, pk, is_update=true |
DeleteView |
GET/POST <prefix>/{pk}/delete |
<table>_confirm_delete.html |
object (GET only) |
CreateView/UpdateView/DeleteView are all two-step: GET renders a
form/confirmation page, POST mutates and 303s to success_url.
Form views auto-skip the PK and Auto<T> columns from the rendered
field set, parse application/x-www-form-urlencoded, coerce values
to the field's declared SQL type, and re-render with errors + a 422
status on validation failure (preserving what the user typed).
CSRF protection is the project's responsibility — mount under a CSRF-protected scope when the POSTs are reachable from a browser.
Tenancy projects swap .router(prefix, tera, pool) for
.tenant_router(prefix, tera) — each request resolves its own
connection via the Tenant extractor instead of capturing a single
pool at mount time. Same Tera context shape, same builder knobs.
The companion JSON-CRUD viewset::ViewSet::tenant_router (v0.30,
#80) carries the full builder chain — filter / search / ordering /
pagination / permissions all work in tenant mode now, no v1 caveats.
Behind the template_views Cargo feature (default-on).
Forms
use ;
use Form as DeriveForm;
// Typed form via derive
// In a handler:
match parse
// Or schema-driven for any model
let form = new;
match form.save.await
Multi-tenancy
use Builder;
async
| Env var | Default | Purpose |
|---|---|---|
DATABASE_URL |
— | Registry Postgres (orgs, operators, users) |
RUSTANGO_APEX_DOMAIN |
localhost |
Subdomain root → <slug>.<apex> |
RUSTANGO_BIND |
0.0.0.0:8080 |
Bind address |
RUSTANGO_SESSION_SECRET |
random (warns) | Base64-encoded 32-byte HMAC key |
RUSTANGO_OPERATOR_IMPERSONATION_TTL_SECS |
3600 |
"Open admin as superuser →" cookie lifetime (#78) |
Generate a secret: openssl rand -base64 32.
Tenant pool tuning (v0.27.7+)
Database-mode tenants get one cached PgPool each. Defaults
preserve pre-0.27.7 behavior; override via TenantPoolsConfig:
use ;
let pools = new.config;
CLI: cargo run -- prewarm-pools for an explicit ops trigger
after credential rotation.
Configurable URL prefixes (v0.28.0)
Default tenant admin paths use __ prefixes (/__login,
/__admin, /__audit, …). Apps that have reserved their root
namespace cleanly can flip to friendly URLs:
use RouteConfig;
from_env.await?
.routes // /login, /admin, /audit, …
// or build custom: RouteConfig { login_url: "/sign-in".into(), .. Default::default() }
.api
.serve
.await
Defaults preserve pre-0.28 paths so upgrading is a no-op until
apps explicitly call .routes(...).
Operator-as-superuser tenant impersonation (v0.27.8+)
Operators logged into the apex console (/orgs/<slug>/edit)
get an "Open admin as superuser →" button. Click → the
operator console mints a tenant-bound, slug-pinned, signed
cookie (1h TTL by default) and redirects to
<slug>.<apex>/__admin/. The tenant admin recognizes the
cookie as superuser; an unmissable banner reminds the operator
they're impersonating; every audited write tags
source = operator:<id>:impersonating so post-hoc forensics
can pinpoint operator-driven changes. End impersonation
button clears the cookie + redirects back to the operator
console.
Recovery CLI verbs
First user of a tenant is auto-promoted to superuser even
without --superuser so an onboarding script that forgets the
flag still produces a tenant with at least one functional admin.
Tenant resolver chain
Auto-resolves Tenant from request via, in order:
- Subdomain (
acme.myapp.com) - URL path prefix (
/t/acme/...) - Custom header (
X-Tenant-Slug: acme) - Port (rare; for testing)
Storage modes
- Schema mode — one Postgres schema per tenant, single database
- Database mode — full database per tenant;
TenantPoolslazily opens connections
Flip a populated tenant between modes with cargo run -- migrate-tenant-storage <slug> --to schema|database (since v0.26).
The verb runs pg_dump → psql, updates the Org row in a single
transaction, evicts the cached pool, and smoke-checks the new
location with SELECT 1 FROM rustango_users LIMIT 1. --dry-run
short-circuits before any pg_dump call to preview the move.
Programmatic provisioning
use *;
let org = create_tenant_if_missing.await?;
create_operator_if_missing.await?;
create_user_if_missing.await?;
Extra fields on tenant users
The framework's tenant User is fixed at seven columns: id,
username, password_hash, is_superuser, active, created_at,
plus data: serde_json::Value (JSONB) for ad-hoc per-user metadata.
Three escalating options when you want more:
1. Stuff it in data (zero-cost). No schema change, no override —
read/write user.data["display_name"]. Right answer for sparse,
non-indexed attributes (preferences, onboarding flags, app-specific
settings).
2. Sibling profile model with FK (works on any project). When you
want typed, indexable extras without touching rustango_users:
Run cargo run -- makemigrations && cargo run -- migrate. One JOIN
per access; no risk of conflicting with framework auth.
3. Custom user model (greenfield only). Extras go inline on
rustango_users itself via the TenantUserModel trait. The override
is read by manage init-tenancy and Builder::migrate to write the
bootstrap migration's CREATE TABLE with your extra columns:
use Auto;
async
Then:
# If you used `cargo rustango new --template tenant`, delete the
# scaffolder-written bootstrap JSONs — `init-tenancy` is idempotent
# and won't replace them otherwise:
Constraints:
- Your model must declare every framework column verbatim (
id,username,password_hash,is_superuser,active,created_at,data);validate_tenant_user_schemapanics with a clear message atinit-tenancytime otherwise. Extras must beNULL-able or carrydefault = "…". - Both the framework's
Userand yourAppUserregister in the model inventory (sametable). Subsequentmakemigrationsruns may emit redundant ops touchingrustango_users— review the JSON before applying. This is why option 3 is greenfield-only; option 2 sidesteps it. - The framework's auth and admin paths still read the seven core
columns by name — extras are accessible via
AppUser::objects().fetch(...).
Builder::user_model::<AppUser>() is the equivalent setter for code
that constructs the server Builder directly. Full reference in
docs/manage.md.
Runnable demo:
crates/rustango/examples/tenant_user_extension/.
Authentication & permissions
Auth backends (pluggable)
use ;
use ;
use Arc;
let backends = vec!;
let app = new
.route
.require_auth // 401 if no backend authenticates
.route
.require_perm // gate by codename
.require_auth;
async
Typed permission helpers (v0.16.0)
Permissions today use {table}.{action} string codenames
("post.change", "comment.delete", …). The
rustango::permissions facade adds typed convenience over the
existing engine so callers reach for permissions by their
T: Model type instead of hand-typing the codename:
use permissions;
// Old (still supported):
has_perm.await?;
// New, typed:
.await?;
// Bulk helpers exist for grant/revoke/set_user_perm/clear_user_perm too:
.await?;
.await?;
// Build the four standard codenames for a model:
let = ;
The full underlying engine (Role, RolePermission, UserRole,
UserPermission, has_perm / has_any_perm / has_all_perms,
assign_role / grant_role_perm / auto_create_permissions)
lives at [rustango::tenancy::permissions] — re-exported from
the top-level rustango::permissions for the conceptually-cleaner
path. Requires the tenancy feature (the underlying tables live
in the tenancy bootstrap migration).
TOTP / 2FA
use ;
// Enrollment:
let secret = generate;
user.totp_secret = secret.to_base32;
let qr_url = otpauth_url; // encode as QR
// Verification on login:
if !verify
API keys
use ;
// Issue:
let = generate_key?;
// Show full_token to user once. Store prefix + hash in your DB.
// Verify on request:
let = split_token.ok_or?;
let row = db_lookup_by_prefix.await?;
if verify_key?
Signed URLs (magic links / file downloads)
use ;
use Duration;
// Issue:
let url = sign;
// Verify on callback:
match verify
Security middleware
All optional, all chainable on any axum Router.
use ;
use ;
use ;
use ;
use ;
use ;
use ;
use Duration;
let app = new
.route
.security_headers
.cors
.rate_limit // 60 req/min/IP
.ip_filter
.request_id // X-Request-Id for log correlation
.access_log // tracing::info per request
.etag; // 304 Not Modified
Security headers presets
| Preset | When to use |
|---|---|
SecurityHeadersLayer::strict() |
Production: HSTS preload + XFO=DENY + nosniff + Referrer-Policy=no-referrer |
SecurityHeadersLayer::relaxed() |
Embeddable apps: SAMEORIGIN + 1y HSTS |
SecurityHeadersLayer::dev() |
Local: nosniff only (no HSTS lockout) |
SecurityHeadersLayer::empty() |
Build up from scratch |
SecurityHeadersLayer::from_settings(&Settings.security) builds the
layer from TOML — picks the preset by name (headers_preset = "strict" | "relaxed" | "dev" | "none"), then layers per-field
overrides on top (csp, hsts_max_age_secs):
let cfg = load_from_env?;
app.layer
Unknown preset names fail-safe to strict() — a typo in the TOML
shouldn't silently strip protection. (manage check --deploy
warns separately when the resolved preset is dev / none in the
prod tier.)
CORS presets
permissive // dev: any origin, common methods
new.allow_origins // prod: explicit allowlist
CorsLayer::from_settings(&Settings.security) builds the layer
from [security] cors_allowed_origins (#87 wiring). Returns
None when the list is empty so callers skip mounting altogether
(different from "allow zero origins" which would 403 every preflight).
["*"] maps to permissive(); specific origins build an allowlist
with sensible default methods + headers + 1h preflight cache:
let cfg = load_from_env?;
if let Some = from_settings
Caching
use ;
use Arc;
use Duration;
// Build a shared cache
let cache: BoxedCache = new;
// Raw strings
cache.set.await?;
let val: = cache.get.await?;
// Typed JSON helpers
set_json.await?;
let user: = get_json.await?;
// Fetch-or-compute pattern
let posts: = get_or_set.await?;
Redis backend (cache-redis feature)
use RedisCache;
let cache: BoxedCache = new;
Email + storage + scheduling
use ;
let mailer: = new; // dev: prints to stdout
let email = new
.to
.from
.reply_to
.subject
.body
.html_body;
mailer.send.await?;
File storage
The framework ships three layers, each useful on its own:
Storagetrait + backends — write/read/delete/url + presign.StorageRegistry— named "disks" (Laravel-style) with optional CDN prefixes. Pick the right backend per call site by name.Mediamodel +MediaManager— first-class Postgres-backed file references with direct browser uploads, soft delete, and orphan sweeps.
Storage backends
use ;
use ;
use Arc;
// Local disk:
let local: BoxedStorage = new;
local.save.await?;
// AWS S3 (or Cloudflare R2 / Backblaze B2 / MinIO — same struct):
let s3: BoxedStorage = new;
// Trait methods are identical across backends — swap in config.
s3.save.await?;
let bytes = s3.load.await?;
let public_url = s3.url;
The S3 backend is hand-rolled SigV4 over reqwest — no aws-sdk-s3 dependency. Behind the storage-s3 feature flag (default-on).
Presigned URLs (private files + direct browser uploads)
use Duration;
// Time-limited GET: paste into <img src=...> or <a href=...>
let download_url = s3
.presigned_get_url
.await
.expect;
// Time-limited PUT: browser uploads directly, server never proxies the body.
// Content-Type binding — browser MUST send a matching header (S3 enforces).
let upload_url = s3
.presigned_put_url
.await
.unwrap;
LocalStorage and InMemoryStorage return None from these methods (they can't sign). S3Storage (and any S3-compatible API) does. AWS caps presign expiry at 7 days; we clamp.
StorageRegistry — named disks
use StorageRegistry;
let registry = new
.set
.cdn
.set
.set
.with_default;
let s = registry.disk.unwrap;
let url = registry.cdn_url;
// → "https://cdn.example.com/avatars/alice.png"
let internal = registry.origin_url;
// → bypasses CDN — for internal admin tools
First-class Media model
Media is a Postgres-backed row. User models reference it via a normal integer FK (Option<ForeignKey<Media>>) — all metadata (disk, key, MIME, size, filename, free-form JSONB) lives on the Media row, so deletes are atomic and one file can be referenced by N parents without duplication.
use ;
// Once at startup:
ensure_table.await?;
let manager = new;
// Server-side save: writes to S3 + inserts a Media row in one call.
let m = manager.save_bytes.await?;
// CDN-aware URL (falls back to backend URL when no CDN configured):
let url = manager.url.expect;
// Time-limited download link for private files:
let dl = manager.presigned_get.await;
Direct browser uploads (no proxying through your server)
Two-step flow: server issues a presigned PUT URL, browser uploads to S3 directly, server confirms. Big files don't tie up handler bandwidth; you keep server-side gating on size/MIME via the pre-creation step.
// 1. Server: issue a presigned upload ticket.
let ticket = manager.begin_upload.await?;
// ticket.media_id -> the Pending Media row id
// ticket.upload_url -> hand to the browser
// ticket.expires_at -> show client a deadline
// 2. Browser:
// fetch(ticket.upload_url, {
// method: 'PUT',
// headers: { 'Content-Type': 'image/png' },
// body: file
// })
// 3. Server: confirm the object landed; flips Pending → Ready
// (or → Failed if the browser abandoned).
let m = manager.finalize_upload.await?;
assert!;
Lifecycle + cleanup
// Soft delete: marks deleted_at = NOW() but preserves the storage object.
manager.delete.await?;
// Hard purge: removes both the row AND the storage object. Typical
// "clean up after soft delete grace period" pattern.
manager.purge.await?;
// Background sweeps — wire to rustango::scheduler:
manager.purge_orphans.await?; // 7-day grace
manager.purge_pending.await?; // abandoned uploads
The MediaStatus enum (Pending / Ready / Failed) is stored as TEXT so admins can filter / order without bespoke type handling. Soft-deleted rows are excluded from manager.get(...) by default; manager.get_including_deleted(...) brings them back for restore flows.
Collections (folders) + tags
MediaCollection is a hierarchical "where the file lives" folder — one Media row belongs to at most one collection; collections nest via parent_id. MediaTag is a flat M2M label — one Media has any number of tags. Both are first-class Postgres tables, so the auto-admin lists/filters/searches them with no extra wiring.
use ensure_all_tables;
// Bootstrap rustango_media + rustango_media_collections +
// rustango_media_tags + rustango_media_tag_links in one call.
ensure_all_tables.await?;
// Folders (hierarchical).
let products = manager.create_collection.await?;
let cid = match products.id ;
let launch = manager.create_collection.await?;
// Drop a file into a folder at save-time.
let m = manager.save_bytes.await?;
// Move it later.
let mid = match m.id ;
manager.move_to_collection.await?;
// Walk the folder path.
let path = manager.collection_path.await?; // "products"
// List media in a folder, optionally recursive.
let in_folder = manager.list_in_collection.await?;
// Tags (M2M, free-form labels).
manager.tag.await?;
manager.untag.await?;
// Replace the entire tag set:
manager.set_tags.await?;
// Find media by tag, paginated.
let featured = manager.list_with_tag.await?;
// Top tags by usage:
let popular = manager.popular_tags.await?; // Vec<(MediaTag, i64)>
Deleting a collection orphans its Media (sets collection_id = NULL) — the rows + storage objects survive, just lose their folder. Deleting a tag cascades the junction rows away (tags are cheap to recreate).
REST router
The media_router exposes the manager surface as JSON endpoints — drop it under any prefix you like:
use media_router;
let app = new
.nest;
| Method | Path | Purpose |
|---|---|---|
POST |
/uploads/begin |
Issue a presigned PUT ticket for browser upload |
POST |
/uploads/{id}/finalize |
Confirm storage object landed → flips pending → ready |
GET |
/media/{id} |
Single Media row with url, presigned_url, tags |
DELETE |
/media/{id} |
Soft-delete (storage preserved) |
POST |
/media/{id}/move |
Move to another collection: {collection_id?} |
POST |
/media/{id}/tags |
Replace tag set: {slugs: [...]} |
DELETE |
/media/{id}/tags/{slug} |
Remove a single tag |
POST |
/collections |
Create folder: {name, slug, parent_id?, description?} |
GET |
/collections |
List all (non-deleted) folders |
GET |
/collections/{id} |
Single folder |
DELETE |
/collections/{id} |
Soft-delete (Media inside orphaned, NOT deleted) |
GET |
/collections/{id}/contents |
Media in folder. ?recursive=1 includes sub-folders |
POST |
/tags |
Create / upsert tag: {slug} |
GET |
/tags |
All tags |
GET |
/tags/popular |
Top tags by use count. ?limit=N |
GET |
/tags/{slug}/media |
Media carrying the tag. ?limit=N&offset=N |
MediaError implements IntoResponse so unknown disks return 400, storage transport errors 502, and "not found" errors 404.
Behind the media feature flag (default-on, implies storage + postgres); media_router additionally needs the admin feature for axum.
Scheduled tasks
use Scheduler;
use Duration;
let s = new;
s.every;
s.every;
let handle = s.start; // each task runs in its own tokio task with panic isolation
// ... app runs ...
handle.shutdown.await;
Signals
use ;
// Register at startup:
;
// Fire after save (until macro auto-fires it for you):
post.save_on.await?;
send_post_save.await;
Available: connect_pre_save, connect_post_save, connect_pre_delete, connect_post_delete. Disconnect via the returned ReceiverId.
i18n
use ;
// Load catalogs from disk
let t = from_directory?;
// Or build manually
let t = new
.add_locale
.add_locale;
// Translate
let s = t.translate;
// → "Bienvenue, Alice !" (fr-FR falls back to fr)
// Pick from Accept-Language
let lang = negotiate_language;
Testing
Test client
use TestClient;
async
Fixtures
use ;
let users = new.from_file?;
let posts = new.from_file?;
load_all.await?;
fixtures/users.json:
Feature flags
The default features cover everything most apps need. Trim them when shipping a slim binary:
# Default — everything except tenancy + cache-redis
= "0.29"
# Multi-tenant
= { = "0.29", = ["tenancy"] }
# With Redis cache
= { = "0.29", = ["cache-redis"] }
# Bare ORM only (no admin, no forms, no email, no storage)
= { = "0.29", = false, = ["postgres"] }
| Feature | What it adds | On by default? |
|---|---|---|
postgres |
sqlx + Postgres driver | yes |
admin |
rustango::admin HTTP layer (axum, Tera) |
yes |
config |
layered TOML config + env overrides | yes |
forms |
rustango::forms parsers + ModelForm |
yes |
serializer |
#[derive(Serializer)] |
yes |
cache |
Cache trait + InMemoryCache + NullCache |
yes |
cache-redis |
+ RedisCache | no |
signals |
pre/post save/delete dispatcher | yes |
email |
Mailer trait + console/in-memory/null |
yes |
storage |
Storage trait + Local/InMemory |
yes |
scheduler |
in-process cron-shape scheduler | yes |
secrets |
Secrets trait + Env/InMemory |
yes |
totp |
RFC 6238 2FA | yes |
webhook |
inbound HMAC signature verification | yes |
api_keys |
{prefix}.{secret} argon2 keys |
yes |
passwords |
argon2 hash + strength check | yes |
signed_url |
HMAC-SHA256 signed URLs | yes |
tenancy |
multi-tenancy + operator console + permissions | no |
csrf |
CSRF middleware (depends on forms) |
implied by admin |
template_views |
Django-shape CBVs (ListView, DetailView, …) |
yes |
Contributing — git hooks
Since v0.26 the rustango repo ships in-repo git hooks that catch formatting + obvious regressions before they hit CI. One-line setup per clone:
This sets git config core.hooksPath .githooks, after which:
- pre-commit (fast, blocking) —
cargo fmt --checkon staged Rust files, secret-shape scan (.env/*.pemfiles, AWS / GitHub / Stripe / Slack token prefixes anywhere in the diff), debris check (dbg!,todo!(),unimplemented!()insrc//tests/). - pre-push (slower, blocking) —
cargo check --workspace --all-features, scoped clippy on the rustango lib, lib tests.
Per-step env-var overrides documented at the top of each script.
Optional tools the hooks pick up when installed:
cargo install typos-cli (then PRECOMMIT_TYPOS=1),
cargo install cargo-deny (then PREPUSH_DENY=1).
git commit --no-verify / git push --no-verify bypass everything
when needed.
Production checklist
Run before deploy:
Audits the env + the loaded Settings:
- ✅ DEBUG-style env (
RUSTANGO_ENVisprodorproduction) - ✅
RUSTANGO_SESSION_SECRETset and ≥ 32 bytes (nochange-meplaceholder) - ✅
DATABASE_URLset, not pointing at localhost in prod - ✅
RUSTANGO_APEX_DOMAINset to a non-localhostvalue (tenancy projects) - ✅
RUSTANGO_BINDnot loopback-only - ✅ Pending migrations applied
- ✅ Models registered in inventory
- ✅
[security] headers_presetis not"dev"/"none"in prod tier - ✅
[security] hsts_max_age_secsis not0in prod tier - ✅
[auth] argon2_memory_kib≥ 19456 (OWASP 2024 floor) - ✅
[auth.jwt] access_ttl_secs≤ 3600 (use the refresh flow for longer sessions) - ✅
[server] bindis not loopback in prod tier
Then verify your stack has:
| Layer | Required | Tool in rustango |
|---|---|---|
| HTTPS termination | yes | (reverse proxy — nginx / cloudflare / aws ALB) |
| Security headers | yes | SecurityHeadersLayer::strict() |
| Rate limiting | yes | RateLimitLayer::per_ip(...) |
| Access logging | yes | AccessLogLayer::default() (PII-redacted by default) |
| Health endpoints | yes | health::health_router(pool) → /health, /ready |
| Request IDs | recommended | RequestIdLayer::default() |
| CORS allowlist | if you have a JS frontend | CorsLayer::new().allow_origins(...) |
| ETag caching | optional | EtagLayer::default() |
| Backups | yes | external — pg_dump |
Comparison
| rustango | Django | Laravel | Rocket | Cot | |
|---|---|---|---|---|---|
| ORM | ✅ | ✅ | ✅ | ❌ | ✅ |
| Auto-migrations | ✅ | ✅ | ✅ | ❌ | ✅ |
| Auto-admin | ✅ | ✅ | ⚠️ Filament | ❌ | ✅ |
| Multi-tenancy | ✅ | ⚠️ ext | ⚠️ ext | ❌ | ❌ |
| JWT lifecycle (refresh + blacklist + custom claims) | ✅ | ⚠️ ext | ⚠️ Sanctum/Passport | ❌ | ❌ |
| TOTP / 2FA | ✅ | ⚠️ ext | ✅ Fortify | ❌ | ❌ |
| Signals | ✅ | ✅ | ✅ Events | ❌ | ❌ |
| Cache backends | ✅ | ✅ | ✅ | ❌ | ⚠️ optional |
| Email backends | ✅ | ✅ | ✅ | ❌ | ❌ |
| File storage | ✅ | ⚠️ ext | ✅ Flysystem | ❌ | ❌ |
| Scheduled tasks | ✅ | ⚠️ Celery beat | ✅ | ❌ | ❌ |
| Security headers | ✅ | ✅ | ⚠️ middleware | ✅ Shield | ❌ |
| Test client | ✅ | ✅ | ✅ | ✅ Client | ✅ |
| Project scaffolder | ✅ cargo rustango new |
✅ startproject |
✅ Laravel installer | ❌ | ✅ cot new |
| File generators | ✅ make:* |
⚠️ ext | ✅ artisan | ❌ | ❌ |
✅ shipped · ⚠️ partial / via extension · ❌ not shipped
Documentation
- API docs: https://docs.rs/rustango
- Tutorial: see
docs/getting-started.md - CHANGELOG: see
CHANGELOG.md - Source: https://github.com/cot-rs/rustango
License
MIT OR Apache-2.0