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.
Source:
rustango::viewset(ViewSet,#[derive(ViewSet)], the#[viewset(...)]options + thefor_modelbuilder) — gated onadminortenancy. Within it,.serializer::<S>()needsserializer, the builder'srouter()needspostgres,tenant_router()/OwnedByneedtenancy, and the QUERY action needsadmin.Runnable version: the blog built here mirrors the tested, compilable
getting_started_blogexample (itsPost/PostSerializer/PostViewSet), and every behavior is pinned by the framework's own live tests —crates/rustango/tests/viewset_*.rs(notablyviewset_serializer_render_sqlite_liveandviewset_serializer_input_sqlite_live).
Table of contents
- API views vs HTML views — JSON for clients, or HTML pages?
- Build a REST blog API — the full walkthrough
- The serializer marriage: input + output
- The two ways to define a ViewSet
- The CRUD endpoints · Choosing which to expose
#[viewset(...)]reference · Builder reference- Filtering, search & ordering · Pagination
- Validation · Permissions & throttling · Custom actions
- Mounting · Backends
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 | |
|---|---|---|
| Module | rustango::viewset | rustango::template_views |
| Sends back | JSON data | a server-rendered HTML page |
| Built for | SPAs, mobile, other services | browsers, server-rendered sites, admin-style CRUD |
| A "create" | POST JSON → 201 + the object | POST a form → 303 redirect (Post/Redirect/Get) |
| On bad input | 400 ApiError; serializer errors 422, fields in details | re-render the form with the errors shown |
| A "list" is | a paginated JSON envelope | a loop over rows in your template |
| Usually authed by | tokens / JWT / API keys | session 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. Usefieldsonly 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 field | Effect 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/manycaveat. Nested and collection serializer fields render only when the related rows were loaded (viaselect_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:
- Validation runs. The serializer's
validate()— every per-field#[serializer(validate = "fn")]plus the container-level cross-fieldvalidate— runs against the JSON body. On failure the request is rejected400 Bad Requestwith 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"]}. - Writable-field filtering. Only the serializer's writable fields are
persisted;
read_onlyandmethod/computed fields a client posts are ignored (not written), andsourcerenames 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:
| Verb | Path | Action | Success | Body |
|---|---|---|---|---|
GET | /api/posts | list | 200 | paginated envelope (see Pagination) |
POST | /api/posts | create | 201 | the created object — or an array, for bulk create |
GET | /api/posts/{pk} | retrieve | 200 | the object |
PUT | /api/posts/{pk} | update (full) | 200 | the updated object |
PATCH | /api/posts/{pk} | partial update | 200 | the updated object (only supplied fields change) |
DELETE | /api/posts/{pk} | destroy | 204 | empty |
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
INSERTeach with no transaction, so a unique or foreign-key violation on element 5 committed elements 0–4, returned400 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
| Key | Example | Default | What it does |
|---|---|---|---|
model | model = Post | required | The model the resource is built over. |
serializer | serializer = path::To::S | none | Wire a serializer for typed output + input (see above). |
fields | "id, title, body" | all scalar fields | Whitelist for the default (non-serializer) projection + writable fields. |
filter_fields | "author_id, status" | none | Fields filterable via ?field=value (+ lookups). |
search_fields | "title, body" | none | Fields the ?search= box matches (case-insensitive OR). |
ordering | "-published_at, id" | none | Default sort (- = DESC). |
page_size | 20 | 20 | Rows per page (client ?page_size= capped at 100). |
read_only | (flag) | off | Expose GET (list + retrieve) only. |
permissions(...) | permissions(create = "post.add") | none | Per-action permission codenames. |
Builder reference
Every method on ViewSet::for_model(SCHEMA) (each returns Self):
| Method | Purpose |
|---|---|
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 runCOUNT(*)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'smax_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-constraint500s into friendly400s like{"title":["Ensure this value has at most 200 characters."]}. - Per-field
validate = "fn"and a cross-fieldvalidatehook — 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/boolvalue is a400naming the field. - Required / NOT NULL — a missing non-nullable field (or empty string for a
non-nullable
String) is a400; nullable fields accept empty →NULL. - Database constraints — unique, foreign keys and check constraints surface
as a
400on 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}
erroris a stable code (bad_request,unauthorized,not_found,validation_failed,rate_limited,internal_error, …). Branch on it.- Serializer validation is a
422validation_failed, with the field map indetails:{"title": ["Ensure this value has at most 200 characters."], "non_field_errors": [ … ]}. The type-coercion and required400s above arebad_requestwith the reason inmessage; a database-constraint400withholds the driver text. - A
5xxnever carries the cause unlessRUSTANGO_DISCLOSE_ERRORSis set. It is logged;messageis 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_routeris tri-dialect — PostgreSQL, SQLite and MySQL — and is the recommended path. - The derive macro's
router(prefix, pool)takesimpl Into<rustango::sql::Pool>— aPgPool,MySqlPool,SqlitePoolor thePoolenum. 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.
![A Rustango ViewSet wired to a serializer: one #[viewset(serializer = …)] block gives typed JSON output and validated input across the six CRUD routes](/static/img/viewsets.png)