Rustango docs
← Erste Schritte

Erste Schritte: einen Blog mit Rustango bauen

Diese Anleitung führt dich von einem leeren Verzeichnis bis zu einem deployten Blog: Beiträge, eine Admin-Oberfläche, eine JSON-API, JWT-Authentifizierung und Tests. Von Anfang bis Ende.

Dauer: ~45 Minuten für die komplette Tour, ~10 Minuten, wenn du es nur laufen sehen willst.

Lauffähige Version: Jeder Schritt unten ist in einem getesteten, kompilierbaren Beispiel unter crates/rustango/examples/getting_started_blog nachgebildet. Falls ein Schritt jemals seltsam aussieht, vergleiche ihn damit.

Einen Blog mit Rustango bauen: die Migration generieren, sie anwenden, den Server starten und die JSON-API abfragen — alles aus einem einzigen Binary


Was du vorher wissen solltest

Zwei verschiedene Fragen — und die Doku hat bisher nur die zweite beantwortet.

Rust wird vorausgesetzt. Kein Experten-Rust, aber du solltest mit Structs, Traits, Result und ? vertraut sein und genug async/.await beherrschen, um eine Funktion zu lesen, ohne etwas nachschlagen zu müssen. Wenn das noch nicht auf dich zutrifft, kommt zuerst das Rust Book; diese Anleitung bringt dir die Sprache darunter nicht bei.

Erfahrung mit Web-Backends wird nicht vorausgesetzt. Wenn du noch nie eine Web-API gebaut hast, fang mit Grundlagen der Web-APIs im Glossar an. Das ist eine Fünf-Minuten-Einführung zu Requests, Routen, Handlern und Migrationen, und sie ist genau für diese Lücke geschrieben. Komm danach hierher zurück.

Vorkenntnisse aus einem anderen Web-Framework werden nicht vorausgesetzt. Wo diese Doku gelegentlich einen Vergleich zieht, ist das eine Nebenbemerkung, nie die Erklärung: Sagt dir die Parallele nichts, überspring sie — der Schritt steht auch für sich. Wo ein Begriff echte Arbeit leistet, erklärt ihn das Glossar in einfachen Worten.

Was du installiert haben musst

WerkzeugWofürInstallation
Rust 1.88+Compilerhttps://rustup.rs
Eine DatenbankDiese Anleitung nutzt Postgressiehe Datenbank wählen unten
psql (optional)DB inspizierenbrew install libpq / apt install postgresql-client
rustc --version    # should print 1.88+

Datenbank wählen

Docker ist nicht erforderlich. Diese Anleitung greift darauf zurück, weil ein einziger Befehl ein wegwerfbares Postgres liefert — aber nichts in rustango hängt davon ab. Wähle die Zeile, die zu deinem Rechner passt; alles Weitere ist identisch:

Du willstMach dasHinweise
Gar keinen DatenbankserverMit SQLite laufen lassen (siehe unten)Nichts zu installieren. Ideal zum Lernen.
Postgres ohne DockerPostgres nativ installieren, DATABASE_URL auf localhost zeigen lassenSiehe Natives Postgres.
Postgres mit Dockerdocker compose up -d im generierten ProjektWovon der Rest dieser Anleitung ausgeht.
MySQL oder MariaDBMit --backend mysql generierenSiehe MySQL.

Was du auch wählst: Das Einzige, was sich ändert, ist DATABASE_URL. So sieht sie jeweils aus:

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 — ohne jede Einrichtung

Generiere das Projekt gleich für SQLite: dann gibt es nichts zu installieren, nichts zu starten und hinterher nichts zu bearbeiten.

cargo rustango new myblog --backend sqlite

Die generierten .env.example, docker-compose.yml und Settings-Stufen sind alle für SQLite geschrieben, die Datenbank ist eine Datei, die das erste cargo run -- migrate anlegt, und du kannst Schritt 4 komplett überspringen.

Hast du bereits ein Postgres-Projekt generiert und willst umsteigen: jede Vorlage lässt alle drei Backends verdrahtet — es ist also ein Flag plus eine URL:

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

mode=rwc weist SQLite an, die Datei anzulegen, falls sie fehlt. Alles in dieser Anleitung — Modelle, Migrationen, das Admin, das ORM — funktioniert unverändert; nur Postgres-spezifische Funktionen (JSONB-Operatoren, Schema-Mode-Mandanten) entfallen.

Natives Postgres (ohne Docker)

Installiere Postgres über deinen Paketmanager (brew install postgresql@16, apt install postgresql oder das Windows-Installationsprogramm unter https://www.postgresql.org/download/windows/) und lege dann Rolle und Datenbank an, die die generierte Konfiguration erwartet:

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

Die generierte .env.example zeigt auf den Docker-Servicenamen. Ersetze den Host durch localhost:

# .env  —  `postgres` ist der docker-compose-Servicename; nativ ist es localhost
DATABASE_URL=postgres://rustango:rustango@localhost:5432/myblog_dev

Unter Windows? Das Hyper-V-/WSL2-Backend von Docker Desktop ist eine häufige Ursache für Startprobleme. Wenn es sich querstellt, nimm den SQLite-Weg oben, um das Framework zu lernen, und komm zu Docker zurück, wenn es ans Deployment geht — dafür ist das Container-Setup eigentlich da.

Läuft bei dir schon lokal ein Postgres? Dann ist Port 5432 belegt, und der Container verliert das Rennen stillschweigend. Deine App verbindet sich mit dem lokalen Server, der keine deiner Tabellen hat. Der Fehler, der zurückkommt, ist nicht lesbar, weil ein nicht englischsprachiger Server seine Meldung in seiner eigenen Kodierung schickt. Stoppe entweder den lokalen Dienst oder leg den Container auf einen anderen Port.

MySQL

Generiere mit --backend mysql, dann sind die erzeugten .env.example, die docker-compose.yml und die Settings-Stufen alle für MySQL geschrieben:

cargo rustango new myblog --backend mysql

Willst du es nativ statt im Container betreiben, brauchst du eine Datenbank und einen Benutzer:

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 lohnt sich bewusst zu setzen: Das ältere utf8 von MySQL ist drei Bytes breit und kann kein Emoji speichern, was viel später als Schreibvorgang auftaucht, der an einer einzigen Zeile scheitert. MariaDB läuft über denselben Treiber und dasselbe URL-Schema.


Schritt 1: Den Scaffolder installieren

Der Scaffolder generiert für dich Projekt- und App-Gerüste, ähnlich wie rails new.

cargo install cargo-rustango

Das ergänzt den cargo rustango ...-Unterbefehl global. Bestätige, dass er vorhanden ist:

cargo rustango --help

Die Version des Scaffolders ist die, die dein Projekt pinnt — installierst du den neuesten, bekommst du das neueste rustango. Um ein Projekt auf einem älteren Release zu generieren, installiere stattdessen jenen Generator (cargo install cargo-rustango --version 0.57.11) — siehe Scaffolding.


Schritt 2: Das Projekt erstellen

Das erzeugt ein frisches Projekt, das Rustango-Äquivalent zu rails new oder composer create-project.

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

Rufst du cargo rustango new ganz ohne Argumente auf, fragt es nach Vorlage, Backend und zusätzlichen Features und gibt die entsprechende Kommandozeile aus, bevor es etwas anlegt — siehe Scaffolding.

Das wurde generiert:

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

Es gibt ein einziges Binary: cargo run startet den HTTP-Server, und jedes Verwaltungsverb (migrate, makemigrations, startapp, check, …) läuft über dasselbe Binary via cargo run -- <verb>. Es gibt kein separates manage-Binary.

Cargo.toml ist das Abhängigkeits-Manifest (wie composer.json oder ein Gemfile). Öffne es und bestätige, dass rustango unter [dependencies] aufgeführt ist.

Bestätige den [features]-Block — wähle ein Datenbank-Backend. #[derive(Model)] cfg-gated seine generierten FromRow- / LoadRelated-Impls anhand der Features deiner Crate (ein cfg innerhalb eines Derive-Makros wird gegen die Ziel-Crate aufgelöst, nicht gegen Rustango), also muss hier ein Backend-Feature aktiviert sein, sonst kompiliert das erste Modell nicht. Ein aktuelles Gerüst enthält:

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

Wenn deine generierte Cargo.toml keinen [features]-Block hat (ein älteres cargo-rustango), füge den obigen von Hand hinzu — das behebt es immer. Ohne ihn schlägt der Build fehl mit "the trait bound …: MaybePgFromRow is not satisfied" plus einem verräterischen warning: unexpected cfg condition value: postgres.


Schritt 3: Deine Umgebung einrichten

Die Konfiguration liegt in einer .env-Datei. Kopiere die Vorlage:

cp .env.example .env

Die generierte .env ist von Haus aus Docker-freundlich. Da wir cargo auf dem Host ausführen (nicht im Dev-Container), ändere den Datenbank-Host von postgres auf localhost:

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

Die Zugangsdaten, der Port und der Datenbankname (myblog_dev) passen bereits zum Postgres-Dienst aus der docker-compose.yml, du musst sie also nicht anfassen.

RUSTANGO_SESSION_SECRET signiert Sessions und Tokens. In der generierten .env.example ist es auskommentiert, und für die Entwicklung kannst du das so lassen: der erste Start erzeugt einen Schlüssel unter ./var/ und verwendet ihn weiter, sodass Neustarts dich nicht ausloggen.

Für die Produktion setze einen echten — 32 Bytes Base64, aus deinem Secret-Manager oder:

openssl rand -base64 32     # paste output as RUSTANGO_SESSION_SECRET value

Ein Wert, der nicht 32 Bytes Base64 ist, kann nicht verwendet werden. Der Server sagt das beim Start und fällt auf den generierten Schlüssel zurück, statt abzubrechen — achte also auf diese Warnung, wenn du einen gesetzt hast und Sessions sich verhalten, als hättest du es nicht.


Schritt 4: Die Datenbank starten

Du nutzt SQLite? Überspring diesen Schritt — es gibt keinen Server zu starten. Stell sicher, dass in .env DATABASE_URL=sqlite://myblog_dev.db?mode=rwc steht, und häng an jedes cargo run unten --no-default-features --features sqlite an.

Du nutzt ein natives Postgres? Es läuft bereits als Dienst; prüf nur, dass psql "$DATABASE_URL" -c "SELECT version();" durchgeht, und spring weiter.

Das Projekt bringt eine docker-compose.yml mit, die Postgres in einem Container ausführt, sodass du keine Datenbank von Hand installieren musst. Die App selbst führen wir mit cargo auf dem Host aus, also starte nur den postgres-Dienst im Hintergrund (die Compose-Datei definiert außerdem einen optionalen rust-Dev-Container, der andernfalls Port 8080 belegen würde):

docker compose up -d postgres

Bestätige, dass er läuft:

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

Schritt 5: Die integrierten Migrationen ausführen

Migrationen erstellen deine Datenbanktabellen, dieselbe Idee wie php artisan migrate oder rails db:migrate. Führe sie einmal aus, um die eigenen Tabellen des Frameworks einzurichten:

cargo run -- migrate

Der erste Kompiliervorgang dauert ~2 Minuten (Rust baut alles aus dem Quellcode). Ein frisches Projekt bringt noch keine Migrationsdateien mit, du siehst also nothing to migrate (already up to date) — migrate richtet dennoch die Audit-Log-Tabelle des Frameworks ein, damit auditierte Modelle sofort funktionieren, sobald du sie hinzufügst. Deine erste echte Migration generierst du in Schritt 9.

Prüfe den Migrationsstatus:

cargo run -- showmigrations

Bei einem frischen Projekt gibt das (no migrations in ./migrations) aus. Sobald du ein Modell erstellst und makemigrations ausführst (Schritt 9), erscheint hier für jede angewendete Migration ein [X].


Schritt 6: Erster Start

Starte den Server, um sicherzustellen, dass alles verdrahtet ist.

cargo run

Du siehst:

listening on http://0.0.0.0:8080

Öffne http://localhost:8080 in deinem Browser. Das Gerüst bringt einen einfachen Root-Handler (views::index) mit, der dich mit Hello from Rustango! und einem Link zum Admin begrüßt — das bestätigt, dass Rustango läuft. (Projekte, die keine eigene /-Route definieren, bekommen stattdessen eine integrierte Willkommensseite über Cli::with_welcome().)

Drücke Strg-C zum Stoppen.


Schritt 7: Eine App erstellen

Eine „App" ist ein in sich geschlossenes Feature-Modul. Deine Blog-App wird das Post-Modell, seine Routen und seine Templates enthalten.

cargo run -- startapp blog

Das schreibt:

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 verdrahtet das neue Modul für dich: Es deklariert mod blog; in src/main.rs und fügt eine .merge(crate::blog::urls::api())-Zeile in den api()-Aggregator in src/urls.rs ein, sodass sich die Routen des Blogs automatisch in die App einfügen. Keine manuelle Modulregistrierung nötig.


Schritt 8: Ein Modell definieren

Ein Modell ist eine Datenbanktabelle, beschrieben als Rust-Struct — eine Active-Record-Klasse in Rust. Öffne src/blog/models.rs und definiere deinen Post. (Für die vollständige Referenz — jeden Feldtyp, benutzerdefinierte Primärschlüssel und alle Attribute — siehe den Modelle-Leitfaden.)

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>>,
}

Ein paar Rust-Dinge, die zu beachten sind:

  • #[derive(Model, ...)] ist ein Derive-Makro: Es generiert automatisch Code für das Struct, so wie es ein Klassendekorator oder eine Basisklasse in anderen Frameworks täte. Das Ableiten von Model verleiht dem Struct seine Abfragemethoden.
  • Auto<i64> markiert ein Feld, das die Datenbank für dich befüllt (ein automatisch hochzählender i64-Integer), wie ein Auto-Primärschlüssel.
  • Option<...> bedeutet „dieser Wert kann fehlen". Option<DateTime<Utc>> ist ein Zeitstempel, der null sein kann, sodass deleted_at leer ist, bis die Zeile per Soft-Delete gelöscht wird.
  • Die #[rustango(...)]-Attribute konfigurieren jedes Feld (maximale Länge, Defaults, Indizes), und der admin(...)-Block richtet die Spalten und Filter der Admin-Oberfläche ein.

Schritt 9: Die Migration erstellen und anwenden

Verwandle dieses Modell nun in eine echte Tabelle. Generiere zunächst die Migration aus deinem Modell:

cargo run -- makemigrations

Du siehst etwa Folgendes:

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"], ... }

Diese erste Migration erstellt deine Modelle — posts, plus das Starter-Modell item, das das Gerüst in src/models.rs mitgeliefert hat — zusammen mit den Admin- und Content-Type-Tabellen des Frameworks. Öffne die JSON, wenn du willst: Sie enthält die Operationen plus einen vollständigen Schema-Snapshot.

Wende sie auf die Datenbank an:

cargo run -- migrate

Bestätige, dass die Tabelle existiert:

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

Schritt 10: Das ORM ausprobieren

Lass uns Zeilen aus dem Code lesen und schreiben. Das ORM lässt dich mit Datenbankzeilen als Rust-Structs arbeiten statt mit rohem SQL.

Bearbeite src/main.rs vorübergehend, um vor dem Serverstart einen schnellen Erstellen-und-Lesen-Test auszuführen. Ersetze den Cli-Rumpf durch einen Ad-hoc-ORM-Smoke-Test (behalte das #[rustango::main] des Scaffolders und die mod-Deklarationen am Anfang der Datei):

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(())
}

Was hier geschieht, in einfachen Worten:

  • pool ist der gemeinsam genutzte Datenbank-Verbindungspool. Du übergibst eine Referenz darauf (&pool) an Abfrageaufrufe, statt jedes Mal eine neue Verbindung zu öffnen.
  • Datenbankaufrufe sind asynchron, daher endet jeder mit .await — das pausiert, bis das Ergebnis zurückkommt, und macht dann weiter. Das ? nach einem .await sagt „falls das einen Fehler ergab, halte an und gib den Fehler zurück".
  • main gibt ein Result zurück, Rusts Erfolg-oder-Fehler-Typ, weshalb ? und das abschließende Ok(()) funktionieren.
  • Um eine Zeile zu speichern, rufe .save_pool(&pool) darauf auf. Um Zeilen zu lesen, baue eine Abfrage mit Post::objects() und führe sie mit .fetch(&pool) aus — ohne Filter liefert das jede Zeile der Tabelle.
  • .fetch(…) stammt aus dem FetcherPool-Trait, weshalb die Imports es hereinholen. Ohne diese Zeile existiert die Methode nicht, und der Compiler sagt dir das, ohne zu erklären, warum.
  • Das sind die Multi-Backend-Aufrufe, und alles oben kompiliert unverändert auf allen drei Datenbanken. Es gibt außerdem .save(&pool) und .fetch_on(&pool), die einen treiberspezifischen sqlx::PgPool nehmen und nur existieren, wenn das postgres-Feature aktiviert ist. Bevorzuge das Multi-Backend-Paar, sofern du dich nicht bewusst auf eine einzelne Datenbank festlegen willst. Siehe den ORM-Leitfaden.

Führe es aus:

cargo run

Du solltest die ID deines neuen Beitrags und die zurückgelesenen Zeilen sehen. Stelle src/main.rs wieder in seine gescaffoldete Server-Form zurück, sobald du bestätigt hast, dass es funktioniert — der nächste Schritt baut darauf auf.


Schritt 11: Den Auto-Admin einschalten

Rustango bringt eine generierte Admin-Oberfläche für deine Modelle mit — ein fertiges Backoffice zum Durchsuchen und Bearbeiten deiner Daten. Der Aufbau besteht aus zwei kleinen Schritten: einem Helfer, der einen Pool in einen Admin-Router verwandelt, und einem .nest(...)-Aufruf, um ihn einzuhängen.

Füge den Helfer selbst zu src/urls.rs hinzu — der Scaffolder generiert ihn nicht, weil nichts, was er generiert, ihn aufrufen würde. Das admin_prefix muss zu dem Pfad passen, unter dem du ihn im nächsten Schritt einhängst (/admin), damit die eigenen Links und Formularaktionen des Admins aufgelöst werden:

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 nimmt den Pool jedes Backends, sodass dieser Helfer keinen Treiber nennt und auf allen dreien funktioniert.

Verbinde dann einen Pool in src/main.rs und hänge den Admin in den API-Router ein, bevor du ihn an das Cli übergibst. Behalte die mod blog;-Zeile aus Schritt 7 — die registriert dein Post-Modell beim 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() ist derselbe vereinheitlichte Dispatcher, den der Scaffolder generiert hat — er bedient weiterhin jedes cargo run -- <verb>; du hast nur den Router angereichert, den er zur Runserver-Zeit bedient.

Führe es aus:

cargo run

Öffne http://localhost:8080/admin (kein abschließender Schrägstrich). Du siehst die Admin-Startseite mit einem posts-Link. Klicke ihn an, um deinen Entwurfsbeitrag in der Liste zu sehen, klicke den Beitrag an, um sein Bearbeitungsformular zu öffnen, und speichere. Der Audit-Trail-Tab zeichnet jeden Schreibvorgang auf.


Schritt 12: Die JSON-API bauen

Ein ViewSet stellt ein Modell als REST-API mit List-, Create-, Retrieve-, Update- und Delete-Endpunkten bereit — aus einer einzigen Deklaration, ohne dass du die Routen von Hand schreibst.

12a. Das ViewSet generieren

Erstelle das Gerüst der Datei, fülle dann aus, welche Felder und Verhaltensweisen exponiert werden sollen:

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

Bearbeite 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;

Registriere das neue Modul, indem du mod post_view_set; zu den anderen mod-Deklarationen am Anfang von src/main.rs hinzufügst.

12b. Die Routen einhängen

Hänge die Routen des ViewSet an den Router der App an (die Rustango-Version einer urls.py- oder routes/api.php-Datei). Der ViewSet-Router braucht den Datenbankpool, also baue ihn in src/main.rs, wo der Pool lebt, und merge ihn in den 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() ist der Aggregator, den der Scaffolder generiert hat; manage startapp merged die Routen jeder Unter-App auf dieselbe Weise hinein.)

12c. Die Endpunkte ausprobieren

Starte den Server:

cargo run

In einem anderen Terminal rufe die API mit curl auf:

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

Schritt 13: Die Ausgabe mit einem Serializer formen

Standardmäßig gibt das ViewSet jedes Modellfeld zurück. Ein Serializer lässt dich die Form der Antwort steuern: interne Felder verbergen, sie umbenennen oder einige als read-only markieren. Er ist der Vertrag zwischen deinen Modellen und dem JSON, das deine API ausliefert.

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

Bearbeite 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>>,
}

Sobald ein Serializer angehängt ist, bilden seine Felder die gesamte Schreiboberfläche: Alles, was ein Client postet und hier nicht aufgeführt ist, wird vor dem INSERT verworfen. Deshalb taucht author_id hier auf. Im Modell ist es NOT NULL ohne Default — lässt du es weg, scheitert jedes Create an der Not-Null-Beschränkung. status darf draußen bleiben, weil das Modell ihm default = "'draft'" mitgibt.

Der Typ jedes Serializer-Felds spiegelt das passende Modellfeld wider, sodass id und published_at ihren Auto<…>-Wrapper vom Modell behalten (ein Auto<i64> serialisiert weiterhin zu einem schlichten JSON-Integer). Registriere dann das Modul, indem du mod post_serializer; zu den anderen mod-Deklarationen in src/main.rs hinzufügst.

Verdrahte den Serializer mit dem ViewSet über das serializer-Attribut — List-, Retrieve- und Create-Antworten werden dann durch ihn gerendert (die feldbasierte fields-Projektion wird zugunsten der Form des Serializers umgangen):

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

Das funktioniert identisch auf PostgreSQL, MySQL und SQLite. method- / read_only- / source- / write_only-Overrides gelten alle für die Antwort, und Request-Bodies werden ebenfalls durch den Serializer validiert: create / update führen sein validate() aus (pro Feld und feldübergreifend) und geben bei Fehlschlag einen 400 mit feldbasierter Fehlerkarte zurück ({field: [messages]}), und read-only- / berechnete Felder, die ein Client postet, werden ignoriert. (Hinweis: nested- / many-Serializer-Felder brauchen die zugehörigen Zeilen, geladen via select_related; andernfalls werden sie als ihr Default gerendert.) Siehe den ViewSets-Leitfaden für das vollständige Eingabe- und Ausgabeverhalten.


Schritt 14: JWT-Authentifizierung hinzufügen

JWTs sind signierte Tokens, die du einem Client nach dem Login übergibst und bei jeder Anfrage prüfst, ein gängiges Muster für API-Auth. Das rustango::jwt-Modul von Rustango stellt sie aus und verifiziert sie (HS256) und ist standardmäßig aktiv — kein zusätzliches Feature-Flag.

14a. Beim Login ein Token ausstellen

Backe die Benutzer-ID (das „Subject" des Tokens) und beliebige benutzerdefinierte Claims, wie Rollen, in ein signiertes Token und übergib es dann dem 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. Das Token bei jeder Anfrage verifizieren

Dekodiere das Token — das prüft die Signatur und die Ablaufzeit — und lies dann die Claims zurück. Falls es fehlt oder ungültig ist, weise die Anfrage als nicht autorisiert ab:

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-Lebenszyklus

rustango::jwt stellt zustandslose Einzeltokens aus. Für das vollständige Muster — kurzlebige Access-Tokens, ein langlebiges Refresh-Token in einem HttpOnly-Cookie, Rotation und eine JTI-Blacklist zum Widerruf — aktiviere das tenancy-Feature und verwende rustango::tenancy::jwt_lifecycle::JwtLifecycle, dessen Methoden issue_pair_with / verify_access / refresh das Paar für dich verwalten.


Schritt 15: Security-Middleware hinzufügen

Middleware umschließt jede Anfrage, um querschnittliches Verhalten zu ergänzen. Hier stapelst du Request-IDs, Access-Logging, Rate-Limiting, CORS und Security-Header in einer Kette. Jedes .method(...) fügt eine Schicht hinzu; die Reihenfolge der Aufrufe bestimmt die Reihenfolge im Stack. Siehe den Middleware-Leitfaden für den vollständigen Schichtenkatalog und die Reihenfolgeregeln.

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()),
    );

Übergib die fertige app genau wie zuvor an das Cli — rustango::manage::Cli::new().api(app).with_welcome().run().await — und jede Anfrage fließt nun durch den vollständigen Middleware-Stack.


Schritt 16: Tests schreiben

Rustango enthält einen Testclient, der deinen Router in-process ansteuert, sodass du reale HTTP-Antworten prüfen kannst, ohne einen Server zu starten und ohne das Netzwerk zu berühren. Erstelle das Gerüst einer Testdatei:

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

Die make:*-Generatoren nehmen einen PascalCase-Namen; PostSmoke wird zur snake_case-Datei tests/post_smoke.rs.

Bearbeite tests/post_smoke.rs. Integrationstests leben in einer separaten Crate, daher bauen sie den zu testenden Router direkt aus dem ViewSet (derselbe router(...)-Aufruf, den du in Schritt 12b eingehängt hast):

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

async fn app() -> axum::Router {
    // Ein Test in `tests/` ist eine separate Crate und führt niemals `main`
    // aus, also hat nichts `.env` für ihn geladen. Ohne diese Zeile ist
    // `DATABASE_URL` nicht gesetzt und beide Tests panicken, bevor sie die
    // Datenbank erreichen.
    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")
        // Poste die Felder des Serializers. `status` wird weggelassen, weil der
        // Serializer es nicht aufführt und es ohnehin verworfen würde —
        // das `default = "'draft'"` des Modells trägt es ein.
        .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");
}

Drei Dinge in diesem Ausschnitt macht man leicht falsch, und jedes erzeugt einen anderen Fehlschlag:

  • dotenvy::dotenv() — lässt du es weg, scheitern beide Tests, noch bevor eine einzige Anfrage gestellt wird.
  • content, nicht body — der Serializer akzeptiert hier beides, da source = "body" den Modellnamen bei der Eingabe weiterhin funktionieren lässt, aber content ist der Name, den deine API tatsächlich veröffentlicht.
  • author_id — lässt du es weg, scheitert allein der Create-Test, mit einer Not-Null-Verletzung aus der Datenbank. Der List-Test geht weiterhin durch, weil eine leere Tabelle eine gültige leere Seite ist.

Pool ist der Multi-Backend-Pool, und router nimmt den Pool jedes Backends, sodass diese Datei auf PostgreSQL, MySQL und SQLite unverändert kompiliert.

Achtung: Integrationstests in tests/ können nur dann use myblog::…, wenn die Crate ein Library-Target bereitstellt. Ein frisches Gerüst ist reines Binary (src/main.rs, kein src/lib.rs), also füge eine einzeilige src/lib.rs hinzu, die die Module re-exportiert, die du testen willst — pub mod models; pub mod post_view_set; pub mod urls; — und behalte die passenden mod …;-Zeilen in src/main.rs. (Wenn du lieber kein Library-Target hinzufügen möchtest, baue den Router stattdessen vollständig inline im Test, so wie make:test sein app() scaffoldet.)

Führe die Tests aus:

cargo test --test post_smoke

Schritt 17: Den Systemcheck ausführen

Bevor du deployst, führe den integrierten Prüfer aus. Er meldet gängige Fehlkonfigurationen (wie ein schwaches RUSTANGO_SESSION_SECRET oder eine nicht erreichbare Datenbank), bevor sie in Produktion auffallen.

cargo run -- check --deploy

In deiner lokalen Dev-Umgebung siehst du etwa Folgendes:

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

(Die genauen Modell-/Migrationszahlen hängen von deinem Projekt ab.) Diese drei Warnungen sind die erwarteten Dev-Umgebungs-Warnungen. In einem Produktions-Setup — RUSTANGO_ENV=prod, eine managed-Database-DATABASE_URL, eine gesetzte Apex-Domain — verschwinden sie und du siehst all checks passed. Behebe verbleibende Warnungen oder Fehler, bevor du in die Produktion pushst.


Schritt 18: In die Produktion deployen

Wie du deployst, hängt von deiner Plattform ab (Fly, Railway, Kubernetes, blankes ECS usw.). Die framework-seitigen Schritte sind überall gleich; das --release-Flag baut ein optimiertes 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

Stelle sicher, dass dein Reverse-Proxy:

  • HTTPS terminiert
  • X-Forwarded-For weiterleitet für akkurate IPs im AccessLogLayer
  • X-Forwarded-Host, X-Forwarded-Proto weiterleitet
  • axum::serve(listener, app.into_make_service_with_connect_info::<SocketAddr>()) verwendet, damit ConnectInfo für Rate-Limiting + IP-Filterung befüllt ist

Wie es weitergeht

ThemaDoku
Lauffähige Version dieses Leitfadensexamples/getting_started_blog
Jeder manage-Unterbefehldocs/manage.md
ORM-Kochbuch (fortgeschrittene Filter, Aggregationen, M2M, Soft-Delete)docs/orm.md
Middleware (der vollständige Schichtenkatalog + Reihenfolge)docs/middleware.md
Performance-Benchmarks (vs. Go)docs/benchmarks.md
API-Konventionen (Benennung, Builder-Muster, Feature-Gates)docs/api-conventions.md
Security-Features im Detaildocs/security.md
Multi-TenancyREADME — Abschnitt Multi-tenancy
API-Dokuhttps://docs.rs/rustango

Wenn du auf etwas stößt, das nicht funktioniert oder unklar ist, öffne ein Issue.