Rustango docs
← Guides

ViewSets — CRUD REST APIs

A ViewSet turns a model into a full REST resource — endpoints to list, create, read, update and delete records — from one declaration.

New to REST APIs? This guide assumes you know what an endpoint, an HTTP verb (GET / POST / …) and a JSON request and response are. If any of those are fuzzy, the glossary is a five-minute primer — read it first, then come back here.

Pair a ViewSet with a serializer — the piece that shapes your JSON — and it guards both directions at once: the serializer formats every response (rename, hide, compute or nest fields) and governs every request (it validates incoming data and silently ignores fields a client shouldn't be allowed to set). Rejected input comes back as a JSON object keyed by field name. It all works the same on PostgreSQL, MySQL and SQLite.

This guide is tutorial-first: we build a complete REST blog API end to end — scaffolding, models, a serializer, the ViewSet, all six CRUD endpoints, input validation, filtering/search/pagination, and tests — then the rest of the page is a reference for every knob.

A Rustango ViewSet wired to a serializer: one #[viewset(serializer = …)] block gives typed JSON output and validated input across the six CRUD routes

Source: rustango::viewset (ViewSet, #[derive(ViewSet)], the #[viewset(...)] options + the for_model builder) — gated on admin or tenancy. Within it, .serializer::<S>() needs serializer, the builder's router() needs postgres, tenant_router() / OwnedBy need tenancy, and the QUERY action needs admin.

Runnable version: the blog built here mirrors the tested, compilable getting_started_blog example (its Post / PostSerializer / PostViewSet), and every behavior is pinned by the framework's own live tests — crates/rustango/tests/viewset_*.rs (notably viewset_serializer_render_sqlite_live and viewset_serializer_input_sqlite_live).


Table of contents


API views vs HTML views

Before the tutorial, one fork in the road. Rustango has two ways to turn a model into endpoints, and a ViewSet is one of them:

  • A ViewSet (this guide) is an API view — it speaks JSON, for frontend frameworks, mobile apps, and other services.
  • A template view (HTML views) is an HTML view — it renders server-side pages through Tera, for browsers and server-rendered sites.

Same model underneath; what differs is what comes out and who's calling.

API view — ViewSet (here)HTML view — template views
Modulerustango::viewsetrustango::template_views
Sends backJSON dataa server-rendered HTML page
Built forSPAs, mobile, other servicesbrowsers, server-rendered sites, admin-style CRUD
A "create"POST JSON → 201 + the objectPOST a form → 303 redirect (Post/Redirect/Get)
On bad input400 ApiError; serializer errors 422, fields in detailsre-render the form with the errors shown
A "list" isa paginated JSON envelopea loop over rows in your template
Usually authed bytokens / JWT / API keyssession cookies

Pick per resource — and you can mount both on the same model (a public JSON API and internal CRUD pages). The rest of this guide is the JSON/API side; for the HTML side see HTML views — server-rendered pages.


Build a REST blog API

We'll build a blog with two models — Author and Post — and expose Post as a REST resource at /api/posts whose JSON shape and validation are driven by a serializer. By the end you can curl every CRUD verb and watch the serializer shape output and reject bad input.

This walkthrough assumes a project created with cargo rustango new myblog (see Getting Started for project setup and the database). Every step is a real command or file.

Step 1 — Create the blog app

Apps are self-contained feature modules:

cargo run -- startapp blog

That writes src/blog/{mod,models,views,urls,tests}.rs and wires the module into main.rs + the urls::api() aggregator.

Step 2 — Define the models

src/blog/models.rs — an Author and a Post (a foreign key links them):

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

#[derive(Model, Clone, Debug)]
#[rustango(table = "authors", display = "name")]
pub struct Author {
    #[rustango(primary_key)]
    pub id: Auto<i64>,
    #[rustango(max_length = 120)]
    pub name: String,
    #[rustango(max_length = 200)]
    pub email: String,
}

#[derive(Model, Clone, Debug)]
#[rustango(table = "posts", display = "title", 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 | archived

    #[rustango(fk = "authors", on = "id")]
    pub author_id: i64,

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

Step 3 — Migrate

Generate and apply the migration (same as makemigrations + migrate):

cargo run -- makemigrations
cargo run -- migrate

Step 4 — Scaffold the serializer

The serializer defines the request/response contract. Generate the skeleton:

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

Then fill it in. This one exercises the whole input+output surface — a rename, a computed read-only field, a read-only server field, and a field validator:

// src/blog/post_serializer.rs
use rustango::{Auto, Serializer};
use chrono::{DateTime, Utc};
use crate::blog::models::Post;

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

    #[serializer(validate = "title_min_3")]   // input: reject titles < 3 chars
    pub title: String,

    #[serializer(source = "body")]            // JSON key `content`, column `body`
    pub content: String,

    pub status: String,
    pub author_id: i64,

    #[serializer(method = "summary")]         // output: computed, never written
    pub summary: String,

    #[serializer(read_only)]                  // output: shown, ignored on write
    pub published_at: Auto<DateTime<Utc>>,
}

impl PostSerializer {
    fn title_min_3(t: &String) -> Result<(), String> {
        if t.chars().count() < 3 {
            Err("title must be at least 3 characters".into())
        } else {
            Ok(())
        }
    }
    fn summary(p: &Post) -> String {
        p.body.chars().take(80).collect::<String>()
    }
}

Register the module — add pub mod post_serializer; to src/blog/mod.rs.

Note we only wrote one validator (title_min_3); the fields also inherit the model's constraints automatically — title is length-checked against the model's max_length = 200, and a choices/min/max column would be checked too, all returning friendly 400s on write. Add max_length / min_length / min / max serializer attributes to override a field's bound. (See the serializers guide for the full validation story.)

Step 5 — Scaffold the ViewSet and wire the serializer

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

Edit it to declare the resource and wire the serializer with the serializer attribute — that one line turns on serializer-driven output and input:

// src/blog/post_view_set.rs
use rustango::ViewSet;

#[derive(ViewSet)]
#[viewset(
    model         = Post,
    serializer    = crate::blog::post_serializer::PostSerializer,
    filter_fields = "author_id, status",
    search_fields = "title, body",
    ordering      = "-published_at",
    page_size     = 20,
)]
pub struct PostViewSet;

Add pub mod post_view_set; to src/blog/mod.rs.

With a serializer wired you don't need fields = "..." — the serializer is the projection. Use fields only when you want the default (non-serializer) field projection instead.

Step 6 — Mount the routes

In a single-tenant project, nest the ViewSet's router under a path, passing the pool:

// src/blog/urls.rs (or your urls::api aggregator)
use axum::Router;
use rustango::sql::Pool;
use crate::blog::post_view_set::PostViewSet;

pub fn api(pool: Pool) -> Router {
    Router::new()
        .merge(PostViewSet::router("/api/posts", pool))
}

make:api_routes blog scaffolds exactly this aggregator if you'd rather generate it. Wire blog::urls::api(pool) into your top-level urls.rs.

Step 7 — Run it and exercise every endpoint

cargo run            # listening on http://0.0.0.0:8080

Create (POST). The serializer validates first, then writes only the fields it accepts:

# happy path — note `content` (the renamed `body`) on the way in
curl -X POST localhost:8080/api/posts \
  -H 'content-type: application/json' \
  -d '{"title":"Hello Rustango","content":"First post body.","status":"published","author_id":1}'
{
  "id": 1,
  "title": "Hello Rustango",
  "content": "First post body.",
  "status": "published",
  "author_id": 1,
  "summary": "First post body.",
  "published_at": "2026-01-02T12:00:00Z"
}

The response is the serializer's shape: body came back as content, the computed summary appeared, and published_at (read-only, server-set) is present.

Validation rejects bad input with a 400 — field-keyed arrays of messages:

curl -i -X POST localhost:8080/api/posts \
  -H 'content-type: application/json' \
  -d '{"title":"hi","content":"x","author_id":1}'
# HTTP/1.1 400 Bad Request
# {"title":["title must be at least 3 characters"]}

Read-only / computed fields a client posts are ignored — they can't inject published_at or summary:

curl -X POST localhost:8080/api/posts \
  -H 'content-type: application/json' \
  -d '{"title":"Sneaky","content":"x","author_id":1,"published_at":"1999-01-01T00:00:00Z","summary":"hax"}'
# → published_at is the server value, not 1999; summary is recomputed from body.

List (GET) — paginated, each row in the serializer's shape:

curl localhost:8080/api/posts
{ "count": 1, "page": 1, "page_size": 20, "last_page": 1, "results": [ { "id": 1, "title": "Hello Rustango", … } ] }

Retrieve / update / partial-update / delete:

curl localhost:8080/api/posts/1                       # retrieve  → 200
curl -X PUT   localhost:8080/api/posts/1 -H 'content-type: application/json' \
     -d '{"title":"Edited","content":"new body","status":"published","author_id":1}'   # full update → 200
curl -X PATCH localhost:8080/api/posts/1 -H 'content-type: application/json' \
     -d '{"title":"Just the title"}'                   # partial update → 200 (other fields untouched)
curl -X DELETE localhost:8080/api/posts/1              # destroy → 204

PATCH validation runs on what you send; read-only fields stay at their server value even if posted.

Step 8 — Filter, search, order, paginate

All on the list endpoint, no extra code (you declared the fields in Step 5):

curl 'localhost:8080/api/posts?status=published&author_id=1'      # filter
curl 'localhost:8080/api/posts?status__in=published,archived'     # lookup
curl 'localhost:8080/api/posts?search=rustango'                   # search title+body
curl 'localhost:8080/api/posts?ordering=title'                    # sort (asc)
curl 'localhost:8080/api/posts?page=2&page_size=10'               # paginate

Step 9 — Test it

The framework ships an in-process test client — assert on real HTTP responses without booting a server:

// tests/post_api.rs
use rustango::test_client::TestClient;
use myblog::blog::post_view_set::PostViewSet;
use rustango::sql::Pool;
use serde_json::json;

async fn app() -> axum::Router {
    // `Pool::connect` picks the backend from the URL scheme, so the
    // same test runs against sqlite, Postgres or MySQL.
    let pool = Pool::connect(&std::env::var("DATABASE_URL").unwrap()).await.unwrap();
    PostViewSet::router("/api/posts", pool)
}

#[tokio::test]
async fn rejects_short_title() {
    let client = TestClient::new(app().await);
    let res = client.post("/api/posts")
        .json(&json!({"title":"hi","content":"x","author_id":1}))
        .send().await;
    assert_eq!(res.status, 400);
    assert!(res.json_value()["title"].is_array());   // field-keyed error shape
}

#[tokio::test]
async fn create_then_list() {
    let client = TestClient::new(app().await);
    let created = client.post("/api/posts")
        .json(&json!({"title":"Hello","content":"b","status":"published","author_id":1}))
        .send().await;
    assert_eq!(created.status, 201);
    let list = client.get("/api/posts").send().await;
    assert!(list.json_value()["results"].is_array());
}
cargo test --test post_api

That's a complete, validated REST resource. The rest of this page is the reference behind each step.


The serializer marriage: input + output

Wiring a serializer (via serializer = … on the derive, or .serializer::<S>() on the builder) changes both directions. It works on PostgreSQL, MySQL and SQLite alike.

Output — responses render through the serializer

list, retrieve, create and update responses are produced by S::from_model(&row), so the serializer's overrides shape the JSON:

Serializer fieldEffect on the response
#[serializer(source = "body")]column body is emitted under the field's name (e.g. content)
#[serializer(method = "fn")]a computed field appears (from Self::fn(&model))
#[serializer(read_only)]included in output
#[serializer(write_only)]omitted from output

nested / many caveat. Nested and collection serializer fields render only when the related rows were loaded (via select_related / an eager fetch); otherwise they fall back to their default. The auto ViewSet list query loads the base row — wire relations explicitly if a nested field must be populated.

Input — requests are validated and filtered

On create and update, when a serializer is registered:

  1. Validation runs. The serializer's validate() — every per-field #[serializer(validate = "fn")] plus the container-level cross-field validate — runs against the JSON body. On failure the request is rejected 400 Bad Request with the field-keyed error shape: a JSON object keyed by field name with arrays of messages, e.g. {"title":["title must be at least 3 characters"]}.
  2. Writable-field filtering. Only the serializer's writable fields are persisted; read_only and method/computed fields a client posts are ignored (not written), and source renames are resolved to the model column. So a client can't set a server-controlled field by including it in the body.

Form-urlencoded bodies (vs JSON) skip validate() — there's no typed value to validate — but still get writable-field filtering.

Under the hood this is the ModelSerializer trait's validate(), writable_source_fields() and from_writable_json() methods, all generated by #[derive(Serializer)]. See the serializers guide for how to write the validators.


The two ways to define a ViewSet

Both produce an axum::Router of the same CRUD routes.

1. The derive macro — declarative, single-tenant; wire a serializer with serializer = …:

#[derive(ViewSet)]
#[viewset(
    model         = Post,
    serializer    = crate::blog::post_serializer::PostSerializer,
    filter_fields = "author_id, status",
    search_fields = "title, body",
    ordering      = "-published_at",
    page_size     = 20,
)]
pub struct PostViewSet;

let router = PostViewSet::router("/api/posts", pool);

2. The builder — ViewSet::for_model(...), programmatic, tri-dialect (PostgreSQL / SQLite / MySQL) and tenancy-aware; wire a serializer with .serializer::<S>():

use rustango::viewset::ViewSet;
use rustango::core::Model as _;

let router = ViewSet::for_model(Post::SCHEMA)
    .serializer::<PostSerializer>()
    .filter_fields(&["author_id", "status"])
    .search_fields(&["title", "body"])
    .ordering(&[("published_at", true)])    // true = DESC
    .page_size(20)
    .router_pool("/api/posts", pool);       // tri-dialect Pool

Reach for the builder when you need SQLite/MySQL, multi-tenancy, a runtime-built config, or the extras (throttling, custom filter backends, cursor pagination).


The CRUD endpoints

Mounting at /api/posts wires all six REST operations:

VerbPathActionSuccessBody
GET/api/postslist200paginated envelope (see Pagination)
POST/api/postscreate201the created object — or an array, for bulk create
GET/api/posts/{pk}retrieve200the object
PUT/api/posts/{pk}update (full)200the updated object
PATCH/api/posts/{pk}partial update200the updated object (only supplied fields change)
DELETE/api/posts/{pk}destroy204empty

A trailing slash on the mount prefix is optional. These six verbs are wired, plus an RFC 10008 QUERY collection action whenever the admin feature is on. The routes are built with axum::routing::get, so axum answers HEAD from the GET handler automatically; OPTIONS is not wired. Bulk create is free: POST a JSON array and every element is inserted in order, inside one transaction. One bad element rejects the whole batch and leaves nothing behind — whether it is caught by validation or by the database.

That second half was not true until #1403. Validation was atomic; the writes were one INSERT each with no transaction, so a unique or foreign-key violation on element 5 committed elements 0–4, returned 400 bulk entry 5, and named none of the rows it had created. Constraint violations are exactly the class validation cannot decide up front.

A batch holds at most 1000 rows (max_bulk_create(n); more is a 413), and each row spends one create throttle unit.

On a #[rustango(soft_delete)] model, DELETE stamps the column instead of deleting, and every action treats a soft-deleted row as gone.


Choosing which operations to expose

For a read-only resource (list + retrieve only), add read_only:

#[viewset(model = Post, read_only)]            // macro
ViewSet::for_model(Post::SCHEMA).read_only()   // builder

There's no per-verb toggle beyond read-only. For "everything except delete", mount the ViewSet and override the one route with your own handler (see Custom actions).


#[viewset(...)] attribute reference

KeyExampleDefaultWhat it does
modelmodel = PostrequiredThe model the resource is built over.
serializerserializer = path::To::SnoneWire a serializer for typed output + input (see above).
fields"id, title, body"all scalar fieldsWhitelist for the default (non-serializer) projection + writable fields.
filter_fields"author_id, status"noneFields filterable via ?field=value (+ lookups).
search_fields"title, body"noneFields the ?search= box matches (case-insensitive OR).
ordering"-published_at, id"noneDefault sort (- = DESC).
page_size2020Rows per page (client ?page_size= capped at 100).
read_only(flag)offExpose GET (list + retrieve) only.
permissions(...)permissions(create = "post.add")nonePer-action permission codenames.

Builder reference

Every method on ViewSet::for_model(SCHEMA) (each returns Self):

MethodPurpose
serializer::<S>()Wire a serializer for typed output + input (tri-dialect).
fields(&["…"])Default projection + writable field whitelist.
filter_fields(&["…"])Enable ?field=value filtering.
search_fields(&["…"])Enable ?search=.
ordering(&[("field", desc)])Default sort order.
ordering_fields(&["…"])Whitelist which fields ?ordering= may use.
page_size(n)Default page size (≤ 100).
max_page_size(n)Raise or lower the client cap itself (default 100).
max_bulk_create(n)Most rows one bulk create may carry (default 1000).
pk_param(name)Rename the path parameter used for detail routes.
read_only()GET-only.
permissions(ViewSetPerms{…}) / permissions_for_model::<T>()Per-action codename gates (the latter on tenancy).
cursor_pagination("id") / cursor_pagination_desc("id")Keyset pagination (skips COUNT(*)). Any totally-ordered column: integer, timestamp, date, uuid or string.
limit_offset_pagination()?limit=&offset= windowing.
pagination(PaginationStyle::…)Set the style explicitly.
filter_backend(closure)Add custom WHERE predicates beyond filter_fields.
throttle(…) / throttle_all(max, secs)Per-action fixed-window rate limits; QUERY spends the list budget.
router(prefix, pgpool)Mount (Postgres, static pool).
router_pool(prefix, pool)Mount tri-dialect (PG / SQLite / MySQL).
tenant_router(prefix)(tenancy) mount with per-request tenant resolution.

Filtering, search and ordering

All driven by query params on the list endpoint.

Filtering — each filter_fields entry accepts ?field=value (exact) plus richer lookups via a __suffix:

?status=published
?author_id__in=1,2,3
?published_at__gte=2026-01-01
?title__icontains=rust
?body__isnull=false

Supported lookups: ne, gt, gte, lt, lte, in, not_in, contains, icontains, startswith, istartswith, endswith, iendswith, isnull (no suffix = exact). Fields not in filter_fields are ignored.

Search — ?search=term matches search_fields with a case-insensitive OR.

Ordering — ?ordering=field,-other (- = DESC). Any field the response shows (with a serializer, the fields it renders) is sortable unless you set .ordering_fields([...]) to restrict it. Without a param, the ordering default applies, else the model's default_order; the primary key always breaks ties. They all compose.


Pagination

Pitfall — paginate on a deterministic order. Page-number and limit/offset pagination assume a stable sort; ordering on a non-unique column (or none) lets rows shift between pages — duplicated or skipped. Always add a unique tiebreaker, e.g. ordering = "-published_at, id". (Both also run COUNT(*) per call; cursor pagination skips it for large tables.)

Three styles; page-number is the default. The list envelope differs per style:

Page-number (default) — ?page=2&page_size=20:

{ "count": 137, "page": 2, "page_size": 20, "last_page": 7, "results": [ … ] }

Cursor — .cursor_pagination("id") (or _desc); skips COUNT(*), ideal for very large tables. The field must be totally ordered: an integer, timestamp, date, uuid or string. "id" is the usual choice; a created_at timestamp is the other, and is what you want on an append-only table. A float, bool, json or blob column panics at build time rather than failing per request. ?cursor=<token>&page_size=20:

{ "page_size": 20, "next": "<opaque-cursor-or-null>", "results": [ … ] }

Limit/offset — .limit_offset_pagination(). ?limit=20&offset=40:

{ "count": 137, "limit": 20, "offset": 40, "results": [ … ] }

page_size / limit are clamped to 100.


Validation

With a serializer wired, the create/update path runs the serializer's validators and returns field-keyed 400s — the recommended way to validate (see the marriage and the serializers guide). Three layers run:

  • Declarative constraints — max_length / min_length / min / max, and by default the field inherits the model's max_length / min / max / choices. So a #[rustango(max_length = 200)] column is length-checked on the API with no extra config, turning would-be DB-constraint 500s into friendly 400s like {"title":["Ensure this value has at most 200 characters."]}.
  • Per-field validate = "fn" and a cross-field validate hook — your custom rules (formats, cross-field, business logic).

Independently of a serializer, the write path always enforces the schema:

  • Types are coerced and checked — a bad i64 / DateTime / Uuid / bool value is a 400 naming the field.
  • Required / NOT NULL — a missing non-nullable field (or empty string for a non-nullable String) is a 400; nullable fields accept empty → NULL.
  • Database constraints — unique, foreign keys and check constraints surface as a 400 on INSERT/UPDATE.

So even without a serializer you get type + required + DB-constraint validation; wire a serializer to get declarative length/range/choice checks (auto-inherited) plus your own per-field and cross-field rules.

Error response shapes

Every ViewSet error is an ApiError body, the same shape your own handlers send (#1193):

{"error": "<machine code>", "message": "<sentence>", "status": 400}
  • error is a stable code (bad_request, unauthorized, not_found, validation_failed, rate_limited, internal_error, …). Branch on it.
  • Serializer validation is a 422 validation_failed, with the field map in details: {"title": ["Ensure this value has at most 200 characters."], "non_field_errors": [ … ]}. The type-coercion and required 400s above are bad_request with the reason in message; a database-constraint 400 withholds the driver text.
  • A 5xx never carries the cause unless RUSTANGO_DISCLOSE_ERRORS is set. It is logged; message is generic.

Permissions and throttling

A ViewSet is public by default. Mounting one exposes all six CRUD verbs to anyone — there is no built-in authentication. Gate it with permissions(...) (below), put it behind the auth middleware (require_auth), or both, before exposing writes.

Permissions gate each action on codenames (OR within an action):

use rustango::viewset::{ViewSet, ViewSetPerms};

ViewSet::for_model(Post::SCHEMA)
    .permissions(ViewSetPerms {
        list:     vec!["post.view".into()],
        retrieve: vec!["post.view".into()],
        create:   vec!["post.add".into()],
        update:   vec!["post.change".into()],
        destroy:  vec!["post.delete".into()],
    })
    .router_pool("/api/posts", pool);

An empty action list = no check. Enforcement reads an authenticated user from the request (the tenancy auth integration); superusers bypass, a missing user is denied. .permissions_for_model::<Post>() auto-fills the standard post.view/add/change/delete codenames.

Throttling applies fixed-window per-client limits, per action:

ViewSet::for_model(Post::SCHEMA)
    .throttle_all(60, 60)              // 60 requests / 60s per client, every action
    .router_pool("/api/posts", pool);

Over-limit → 429 Too Many Requests + Retry-After. Counters are per-process; the client key is the trusted client IP (TrustedRealIp, else the socket; see security.md).


Custom actions beyond CRUD

You can't add an extra action to the ViewSet itself — it is strictly the six CRUD routes. For extra endpoints, mount your own handlers alongside it:

use axum::{Router, routing::{get, post}};

let api = Router::new()
    .merge(ViewSet::for_model(Post::SCHEMA).router_pool("/api/posts", pool.clone()))
    .route("/api/posts/stats", get(post_stats))
    .route("/api/posts/bulk_archive", post(bulk_archive));

For extra WHERE logic, .filter_backend(…) contributes predicates without a separate route.

Scoping rows to the authenticated principal

A backend runs on every action — list, retrieve, update, destroy — so it narrows the rows the whole resource can reach. A row the backend excludes is a 404 on the item routes, not a 403: a 403 would confirm the id exists.

Identity must come from the credential, never from the query string. A ?owner_id= filter is not a scope — it is a parameter the caller chooses.

A scope narrows reads only. A backend that owns a column also implements write_pins, so create stores the owner and update cannot change it; returning WritePin::Deny refuses the write with a 403.

A model's static global scopes also limit what is read, not what is written: a create or update may leave the scope (201 with no body, or 204). For a security boundary, use a filter backend with write_pins.

OwnedBy — the shipped backend

Most owned resources need exactly one rule: rows whose ownership column is the caller. Name the column and mount it.

use rustango::tenancy::auth_routes::{require_bearer, Config, JwtAuth};
use rustango::viewset::{OwnedBy, ViewSet};

let auth = JwtAuth::new(Config::default()); // one, also serving `auth.router()`
ViewSet::for_model(Note::SCHEMA)
    .filter_backend(OwnedBy::column("member_id"))
    .tenant_router("/api/notes")
    .layer(axum::middleware::from_fn_with_state(auth.clone(), require_bearer))

Any column works — owner_id, member_id, author_id — because the backend takes the name rather than assuming a convention. It fails closed on the two ways it can be wrong: an unauthenticated request and a column the model does not have both match nothing, so a typo at mount time cannot turn into "no predicates, return the table".

OwnedBy pins its column on writes: a create stores the caller whatever the body says, an update never moves the row, and a write with no principal is a 403.

Superusers are not special by default; .superuser_sees_all() opts in, because "admins see everything" is a product decision, not a framework one.

Where the identity comes from

[Principal] is the one identity type, resolved from whatever verified the request — an explicit Principal, an AuthenticatedUser left by a session or Bearer middleware, or an MCP agent token (which acts as the user who minted it). It authenticates nothing itself; it only reads what a verifying middleware already proved, so nothing may insert one without checking a credential first.

require_bearer is that middleware for a JSON API: it verifies the access token against the resolved tenant, re-reads the user row (a deactivated account stops working on the next request, not when the token expires), and inserts both AuthenticatedUser and Principal. Use it as an extractor anywhere:

use rustango::tenancy::{OptionalPrincipal, Principal};

async fn mine(principal: Principal) -> String {          // 401 when absent
    format!("user {}", principal.user_id)
}

async fn home(OptionalPrincipal(who): OptionalPrincipal) -> String {
    who.map_or("anonymous".into(), |p| format!("user {}", p.user_id))
}

Writing your own backend

When ownership is not a single column — a shared team, a soft-deleted row, a window of dates — implement the trait and override filter_with, which receives the request Parts:

use std::collections::HashMap;

use axum::http::request::Parts;
use rustango::core::{Filter, ModelSchema, Op, SqlValue, WhereExpr};
use rustango::tenancy::Principal;
use rustango::viewset::{match_nothing, ViewSetFilter};

struct OwnerFilter;

impl ViewSetFilter for OwnerFilter {
    // No principal in hand — fail closed. `vec![]` here would be *no filter*,
    // not a filter matching nothing, and would widen the query to every row.
    fn filter(&self, _p: &HashMap<String, String>, schema: &'static ModelSchema) -> Vec<WhereExpr> {
        vec![match_nothing(schema)]
    }

    fn filter_with(
        &self,
        parts: &Parts,
        _p: &HashMap<String, String>,
        schema: &'static ModelSchema,
    ) -> Vec<WhereExpr> {
        let Some(principal) = Principal::from_parts(parts) else {
            return vec![match_nothing(schema)];
        };
        vec![WhereExpr::Predicate(Filter::new(
            schema.field("owner_id").expect("owner_id").column,
            Op::Eq,
            SqlValue::from(principal.user_id),
        ))]
    }
}

ViewSet::for_model(Note::SCHEMA)
    .filter_backend(OwnerFilter)
    .tenant_router("/api/notes")

filter_with defaults to filter, so a backend that does not need the request — including the plain closure form — implements only filter as before.

match_nothing is the fail-closed branch, and it is worth using rather than hand-rolling: it returns col IS NULL AND col IS NOT NULL, a contradiction that binds no parameters and reads the same on every backend. The reason it is exported at all is that the obvious substitute is an empty Vec, and in a filter API vec![] means no filter — the opposite of what the branch is for.


Mounting

Compose the ViewSet's router into your app. Single-tenant, static pool:

let api = urls::api()
    .merge(PostViewSet::router("/api/posts", pool.clone()))                          // macro
    .merge(ViewSet::for_model(Author::SCHEMA).router_pool("/api/authors", pool.clone())); // builder

Multi-tenant (no pool captured — each request resolves its tenant connection):

let api = urls::api()
    .merge(ViewSet::for_model(Post::SCHEMA).tenant_router("/api/posts"));

make:api_routes <app> generates a per-app api() that gathers these .merge(...) lines; wire it into your top-level urls.rs.


Backend support

  • Builder + router_pool / tenant_router is tri-dialect — PostgreSQL, SQLite and MySQL — and is the recommended path.
  • The derive macro's router(prefix, pool) takes impl Into<rustango::sql::Pool> — a PgPool, MySqlPool, SqlitePool or the Pool enum. It is not Postgres-only (#1273).
  • Serializer input + output now works on all three backends (the per-row render is tri-dialect; the old PG-only gate is gone).
  • Filtering, search, ordering, the three pagination modes, permissions, throttling and bulk-create all work across the supported backends on the builder path.

Try it

The end-to-end flow above mirrors the compilable getting_started_blog example (Steps 12–13 of the getting-started guide). The framework's own live tests under crates/rustango/tests/viewset_*.rs are the most complete runnable reference — including the serializer input/output tests. They run on in-memory SQLite but need the matching feature flags, e.g.:

cd crates/rustango
cargo test --features sqlite,tenancy --test viewset_serializer_render_sqlite_live
cargo test --features sqlite,tenancy --test viewset_serializer_input_sqlite_live
cargo test --features sqlite,tenancy --test viewset_sqlite_live

See also

  • Serializers — shape the JSON a ViewSet sends and validates.
  • HTML views — the server-rendered counterpart to this JSON API.
  • OpenAPI — generate a spec + Swagger UI from your ViewSets.
  • URLs & routing — compose ViewSet routers into your app.