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. (It's Rustango's equivalent of a Django REST Framework ModelViewSet or a Laravel API resource controller, if you've used those.)

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 in the familiar DRF shape — 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) — always compiled.

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 viewtemplate 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 + a field-keyed JSON error mapre-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
Django analogueDRF ModelViewSetgeneric class-based views

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 (Django's startapp):

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 is what makes this a DRF API — it 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::sqlx::PgPool;
use crate::blog::post_view_set::PostViewSet;

pub fn api(pool: PgPool) -> 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 DRF-shape 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::sqlx::PgPool;
use serde_json::json;

async fn app() -> axum::Router {
    let pool = PgPool::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());   // DRF field-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 DRF 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 builderViewSet::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. Only these six verbs are wired — no automatic HEAD/OPTIONS. Bulk create is free: POST a JSON array and every element is inserted in order, validated atomically (one bad element rejects the whole batch).


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 1000).
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 (when no serializer).
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 (≤ 1000).
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(*)).
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.
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 Django-style 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 is sortable unless you set .ordering_fields([...]) to restrict it. Without a param, the ordering default applies. 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. ?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 1000.


Validation

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

  • Declarative constraintsmax_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 (DRF ModelSerializer behaviour), 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.


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 connection IP (or X-Forwarded-For / X-Real-IP).


Custom actions beyond CRUD

There's no DRF @action decorator — the ViewSet is strictly the six CRUD routes. For extra endpoints, mount your own handlers alongside the ViewSet:

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 per-list extra WHERE logic, .filter_backend(…) contributes predicates to the built-in list query without a separate route.


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, PgPool) captures a PgPool (PostgreSQL).
  • 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.