Chapter 9d — tenant_router for tenancy projects (v0.30, #80)
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 (regression guard for the v0.30.1CountQuery.searchfix). →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)
v0.30 unification: every builder knob that worked for router(...)
now works identically for tenant_router(...) — including
permissions (the has_perm check runs against the same per-request
connection, no second pool acquire). The earlier v0.27 v1 of
tenant_router was filter-less and perm-less; that limitation is
gone.
Sub-sections 9.114 (full pagination — count + next + prev),
9.116b (typed permissions), 9.117 (OpenAPI auto-derive),
9.118 (response shaping via .fields(&[...])) queued for Slice 9b.