Chapter 9d — tenant_router for tenancy projects
5 live tests against ViewSet::for_model(...).tenant_router(...)
mounted under a real TenantContext extension with header-based
tenant resolution. Run with
DATABASE_URL=... cargo test --test cookbook_chapter09d_viewset_tenant_router -- --test-threads=1.
- §9.116 — paginated list against the per-request tenant connection.
→
tenant_router_lists_paginated_payload - §9.116 —
?search=…ILIKE narrowing matchescountto results (the paginatedcountreflects the search filter, not the whole table). →tenant_router_search_param_narrows_count_and_results - §9.116 —
?{field}=…exact filter viafilter_fields. →tenant_router_filter_param_exact_match - §9.116 — full CRUD round-trip (POST → GET → PUT → DELETE → GET 404).
→
tenant_router_full_crud_round_trip - §9.116 — missing
x-orgheader → 404 from theTenantextractor before any SQL runs. →tenant_router_missing_header_yields_404_not_500
Why a separate router builder
router(prefix, pool) bakes a single pool at mount time — fine for
single-tenant projects, broken for multi-tenant ones. Schema-mode
tenants share the registry pool but rely on a per-checkout SET search_path, and database-mode tenants live in entirely separate
Postgres databases. Mounting a normal ViewSet against &pool from
inside a tenant project hits the wrong schema/database on every
request.
tenant_router(prefix) solves this by resolving the connection per
request via the Tenant extractor:
let posts_router = ViewSet::for_model(Post::SCHEMA)
.filter_fields(&["author_id"])
.search_fields(&["title", "body"])
.ordering(&[("published_at", true)])
.page_size(20)
.permissions_for_model::<Post>()
.tenant_router("/api/posts"); // no pool!
axum::Router::new().merge(posts_router)
Every builder knob that works for router(...) works identically for
tenant_router(...) — including permissions (the has_perm check
runs against the same per-request connection, with no second pool
acquire).