REST plugin
Auto-generated JSON CRUD from your models, plus authentication, permissions, filtering, pagination.
umbral-rest walks the same model registry the migration engine uses and mounts a JSON CRUD surface for every registered model. Add the plugin, get /api/<table>/ on List + Retrieve + Create + Update + Delete, plus filtering, free-text search, FK expansion (?include=), sparse fieldsets (?fields=), pagination, and an OpenAPI spec everything else (the umbral-playground UI, code generators, third-party clients) reads from.
use umbral::prelude::*; App::builder() .model::<Article>() .plugin(umbral_rest::RestPlugin::default()) .build()?;// → GET/POST/PATCH/PUT/DELETE under /api/article/The two access controls
A request goes through two gates before the handler runs:
- Authentication answers who are you?. It reads the headers, maybe hits the database, returns an
Identity(orNonefor anonymous). - Permission answers are you allowed?. It takes the
Identityand the action (List,Retrieve,Create,Update,Delete, orCustom) and either passes or returns a 401 / 403.
Authentication defaults to NoAuthentication (every request anonymous). Permission defaults to ReadOnly: anonymous reads pass, writes get 403. So a fresh RestPlugin::default() serves a public read-only API; POST / PATCH / DELETE return 403 until you opt in with a per-resource .permission(...) or the plugin-wide .default_permission(...).
| Page | What it covers |
|---|---|
| Authentication | SessionAuthentication, BearerAuthentication, ChainAuthentication, writing your own |
| Permissions | AllowAny, IsAuthenticated, IsStaff, ReadOnly, Or / And combinators, writing your own |
Other surfaces
- Model exposure. Every registered model is auto-served at
/api/<table>/(opt-out, not opt-in); a set of framework-internal tables (auth_user,session,umbral_migrations, thepermissions_*tables,task_row,admin_audit_log) is blocked by default, andpassword_hashis stripped from every response un-overridably. Tighten withinclude_only/exclude, or deliberately opt in withexpose. See Model exposure. - Filtering, search, pagination. Every list endpoint exposes
?field__op=value(lookups:eq,ne,gt,gte,lt,lte,in,contains,icontains,startswith,isnull),?search=term, and?page=2&page_size=50(page-number pagination) or?limit=&offset=once you wire aPaginationclass viaRestPlugin::paginate(...). Default is no pagination.?search=is a case-insensitive substring match (icontains) across the resource's text columns, ORingUPPER(col) LIKE UPPER('%term%')over each, and matching integers/bools by equality when the term parses. It is not full-text search: it doesn't stem or rank, and it skipstsvectorcolumns. For word-aware, index-backed search use aTsVectorcolumn and.matches_websearch(...). Restrict the searched columns withResourceConfig::search_fields(["title", "body"]), or turn it off withdisable_search(). - FK expansion and sparse fieldsets.
?include=author,categoryinlines related rows (select_related under the hood, up to 3 hops);?fields=id,titletrims the response to named columns. Sparse fieldsets project into included relations:?include=created_by&fields=created_by__name(or the dottedcreated_by.name) prunes the nested object down to justname, and a multi-hopauthor__profile__bioprunes each level.__and.are interchangeable separators, depth is capped at 3 hops, and a nested path against a relation you didn't?include=silently leaves the raw FK integer in place. - List cap. A list response returns at most 1000 rows. An unbounded query is clamped to that ceiling; a paginator passes its own concrete limit.
OPTIONS/ method discovery. Every resource answersOPTIONSwith204 No Contentand anAllowheader -OPTIONS, GET, POSTon a collection (plusPATCH, DELETEwhen.bulk()is on),OPTIONS, GET, PUT, PATCH, DELETEon a detail URL - instead of a bare405. CORS-preflightOPTIONSis handled separately by the cors layer.- Custom actions.
ResourceConfig::action("publish", Method::POST, ActionScope::Detail, |ctx| async move { ... })adds a non-CRUD endpoint that runs under the same auth + permission gate. - Field scoping.
ResourceConfig::hide("password_hash")strips a column from responses, and a hidden field can't be written back either (the write body is scrubbed of it).hideaccepts a single field or many:.hide(["password_hash", "ssn"]). Key a config off the model instead of a literal table withResourceConfig::for_::<AuthUser>().hide(["password_hash", "email"]), or hide on the plugin builder withRestPlugin::hide_model::<AuthUser>(["password_hash"])(usesAuthUser::TABLE, so a typo is a compile error).noform/noeditfield attributes control write-side behaviour.
The plugin contract itself is documented in arch.md; this folder covers the everyday consumer surface.