rustango
A Django-shaped, batteries-included web framework for Rust.
ORM with auto-migrations, multi-tenancy, auto-admin, 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.
[]
= "0.22"
Table of contents
- Quick start
- Project layout
manageCLI reference- ORM cookbook
- Migrations
- Auto-admin
- APIs (ViewSet + Serializer + JWT)
- Forms
- Multi-tenancy
- Authentication & permissions
- Security middleware
- Caching
- Email + storage + scheduling
- Signals
- i18n
- Testing
- Feature flags
- 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 welcome page confirming rustango is wired up. Replace welcome_router() in src/main.rs once you mount your own / route.
4. Add an app + model
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
├── migrations/ # JSON files written by `manage makemigrations`
├── src/
│ ├── main.rs # HTTP entry point — Builder chain
│ ├── lib.rs # `pub mod` registry of every app module
│ ├── urls.rs # top-level Router composition
│ ├── models.rs # OR per-app: src/blog/models.rs
│ ├── views.rs
│ ├── urls.rs
│ └── bin/manage.rs # CLI dispatcher (re-exports user models)
└── tests/ # integration tests using test_client
Per-app structure (created by manage startapp <name>):
src/blog/
├── mod.rs
├── models.rs # #[derive(Model)] structs
├── views.rs # axum handlers
└── urls.rs # router for this app
manage CLI reference
Every command has --help. Exit code is non-zero on validation/IO/system-check errors.
Migrations
|
Data migrations
Scaffolders
System
|
Tenancy (only with tenancy feature)
ORM cookbook
Model declaration
use ;
use ForeignKey;
use ;
use Uuid;
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?;
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.23", = ["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 manage 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!;
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).
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 |
Generate a secret: openssl rand -base64 32.
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
Programmatic provisioning
use *;
let org = create_tenant_if_missing.await?;
create_operator_if_missing.await?;
create_user_if_missing.await?;
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 |
CORS presets
permissive // dev: any origin, common methods
new.allow_origins // prod: explicit allowlist
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.22"
# Multi-tenant
= { = "0.22", = ["tenancy"] }
# With Redis cache
= { = "0.22", = ["cache-redis"] }
# Bare ORM only (no admin, no forms, no email, no storage)
= { = "0.22", = 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 |
Production checklist
Run before deploy:
Audits:
- ✅ DEBUG-style env (
RUSTANGO_ENVisprodorproduction) - ✅
SECRET_KEYset and ≥ 32 bytes - ✅
DATABASE_URLset - ✅ Pending migrations applied
- ✅ Models registered in inventory
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