Natural-key actions
Serve a record at a clean, natural-key URL (a slug or username) instead of the primary key — self-documenting in OpenAPI and the playground.
Natural-key actions
A plain .action(...) reads or writes at /api/<table>/<id>/<name>/ — always keyed by the primary key, always with a trailing action name. That shape doesn't fit the common "give me this record at a clean URL" endpoint: /api/communities/<slug>, /api/developers/<username>. Before action_by, the only way to serve that shape was a hand-mounted Plugin::routes() handler — which works, but is invisible in the OpenAPI spec and the /api/playground.
ResourceConfig::action_by(lookup_field, method, handler) closes that gap: a detail action keyed by a real column (typically #[umbral(unique)], like a slug) instead of the PK, mounted with no /<name>/ suffix.
Declaring one
use http::Method;use umbral_rest::ResourceConfig;use serde_json::json; let communities = ResourceConfig::new("community") // GET /api/community/{slug}/ — no `/<name>/` suffix. .action_by("slug", Method::GET, |ctx| async move { let row = ctx.resolved_row.expect("action_by always resolves a row"); Ok(json!({ "community": row })) });lookup_field must name a real column on the table, and it must be #[umbral(unique)] (or the primary key) — a non-unique column could match more than one row, and there's no well-defined "first match" for a public endpoint. RestPlugin::routes() panics at boot with a clear message if the column is missing or not unique, the same "caught at boot, not in prod" posture the framework uses for backend/field mismatches.
What the handler gets
The ActionContext the handler receives carries the row the dispatch already looked up:
ctx.resolved_row— the whole row as JSON, so the handler doesn't need to re-query for data it's already holding.ctx.pk— the row's primary key, for handlers that need it.ctx.name— the lookup column's name (there's no separate action name in the URL).
The PK route keeps working
Declaring action_by on a table replaces its plain /api/<table>/<id>/ mount with a combined one: GET tries the lookup column first, and falls back to the ordinary PK-based retrieve when the value doesn't match any row — so GET /api/community/42 still works even after adding a slug lookup, as long as 42 isn't itself a slug. PUT/PATCH/DELETE/OPTIONS at that URL are untouched.
Same gate, same OpenAPI registry
action_by reuses the .action() machinery end to end: it runs under the resource's Permission::check with Action::Custom(lookup_field) — the same gate a plain .action() gets — and it registers in umbral_rest::registered_action_schemas(), so umbral-openapi emits /api/<table>/{<lookup_field>}/ as its own path item with no extra wiring. .action_input_schema / .action_output_schema still key by the lookup field's name.
See also
- Action schemas: typed request/response shapes for any
@action, includingaction_by. plugins/umbral-rest/src/resource.rs(ResourceConfig::action_by) for the full contract and the route-collision rule.planning/gaps4.mdentry #80 for the design rationale.