This version is in beta. Some features may change before release.

The Plugin trait

umbral's only extension mechanism. Built-ins and third-party plugins are structurally identical.

Every umbral extension is a Plugin. Auth, sessions, admin, tasks, and REST are plugins. So is every third-party crate that ships models, routes, or commands. The trait is the architectural keystone: if a built-in needed special-casing to fit, the contract would be wrong.

The trait

Code
rust
use umbral::prelude::*;
 
pub struct BlogPlugin;
 
impl Plugin for BlogPlugin {
fn name(&self) -> &'static str { "blog" }
 
fn dependencies(&self) -> &'static [&'static str] { &["auth"] }
 
fn models(&self) -> Vec<umbral::migrate::ModelMeta> {
// One line for the whole crate: every #[derive(Model)] in THIS crate,
// via the link-time registry. Or list them explicitly —
// `vec![umbral::migrate::ModelMeta::for_::<Post>()]` — if a table
// must never depend on the linker keeping the model's object file.
umbral::discovered_models!()
}
 
fn routes(&self) -> Router {
Router::new().route("/posts", get(list_posts))
}
 
// system_checks and on_ready default to no-op.
}

Register it on the builder:

Code
rust
App::builder()
.settings(settings)
.database("default", pool)
.plugin(BlogPlugin)
.build()?;

App::build() topologically sorts the registered plugins, runs each plugin's system_checks() alongside the framework's, merges every plugin's routes() into the app router, and fires on_ready in dependency order.

The sort reads two edge sets: the dependencies() you declare, and the cross-plugin foreign keys it finds on your models. A ForeignKey<T> renders REFERENCES "<T::TABLE>" inside CREATE TABLE, so the plugin owning that table is migrated first whether or not you declared the edge.

Info
You still want to declare `dependencies()` — it documents intent, and it orders plugins that depend on each other for reasons the schema can't see (an `on_ready` hook that reads another plugin's registry, say). The FK derivation only guarantees that a missing declaration can't silently produce a `relation "auth_user" does not exist` on the first deploy to a fresh database.

A #[umbral(db_constraint = false)] column renders no REFERENCES clause, so it imposes no ordering. And if the foreign keys themselves form a cycle, the build fails with BuildError::ForeignKeyCycle, naming the columns that close the loop rather than a bare PluginCycle you never wrote.

What plugins contribute

Every method except name() has a default that returns the empty contribution, so a plugin opts in only to what it ships: a pure-route plugin overrides routes(), a pure-data plugin overrides models(), and the auth plugin overrides almost all of them.

name (required)

&'static str. Unique within the binary; the migration tracking key. The reserved name 'app' belongs to the implicit plugin that owns .model::<T>() registrations.

dependencies

&'static [&'static str]. Plugins that must load first. The topological sort unions this with the cross-plugin foreign keys read off your models; declared cycles surface as BuildError::PluginCycle, FK-implied ones as BuildError::ForeignKeyCycle.

models

Vec<ModelMeta>. Per-plugin model list, in declaration order. The migration engine diffs these for makemigrations.

routes

An axum Router. Plugins choose their own path prefixes; the App merges them all flat.

system_checks

Vec<SystemCheck>. Boot-time invariants run in phase 4. Severity::Error blocks boot; Severity::Warning logs and continues.

on_ready

Sync hook fired in dependency order once the app is actually up — never during migrate/makemigrations. Seed content, spawn background work, wire signals, seal late registrations.

The full method surface

Every method below is a real trait method with a default impl (only name() is required). Signatures match crates/umbral-core/src/plugin.rs exactly.

MethodSignatureDefaultWhat it contributes
namefn name(&self) -> &'static str- (required)Stable identifier; migration-tracking key.
dependenciesfn dependencies(&self) -> &'static [&'static str]&[]Plugins that must load first.
modelsfn models(&self) -> Vec<ModelMeta>emptyThe plugin's models, the migration diff target.
routesfn routes(&self) -> RouterRouter::new()HTTP routes, merged flat into the app router.
route_pathsfn route_paths(&self) -> Vec<RouteSpec>emptyDeclared paths for the dev-mode 404 page (informational only).
openapi_pathsfn openapi_paths(&self) -> Vec<(String, serde_json::Value)>emptyOpenAPI path items umbral-openapi merges into the spec.
system_checksfn system_checks(&self) -> Vec<SystemCheck>emptyBoot-time invariants (phase 4).
provides_storagefn provides_storage(&self) -> boolfalsetrue if on_ready registers a Storage backend (e.g. StoragePlugin).
databasefn database(&self) -> Option<&'static str>NoneDB alias every model this plugin owns reads/writes, for multi-database routing.
templates_dirsfn templates_dirs(&self) -> Vec<PathBuf>emptyTemplate search directories.
template_registrarsfn template_registrars(&self) -> Vec<TemplateRegistrar>emptyCustom minijinja filters / functions / globals the plugin makes available in templates.
wrap_routerfn wrap_router(&self, router: Router) -> Routerrouter unchangedThe tower/axum middleware seam (see below).
middlewarefn middleware(&self) -> Vec<Arc<dyn Middleware>>emptyErgonomic before_request / after_response hooks, stacked in onion order.
static_filesfn static_files(&self) -> Vec<StaticFile>emptyAssets baked into the binary (&'static [u8] bodies).
static_dirsfn static_dirs(&self) -> Vec<StaticDir>emptyOn-disk source dirs served live (dev) under a namespace via the unified static pipeline.
static_root_dirsfn static_root_dirs(&self) -> Vec<PathBuf>emptyOn-disk dirs served at the bare root of static_url (no namespace).
commandsfn commands(&self) -> Vec<Box<dyn PluginCommand>>emptyCLI subcommands.
api_endpointsfn api_endpoints(&self) -> Vec<ApiEndpoint>emptyEndpoints advertised for service discovery (a REST API root, a SPA).
on_readyfn on_ready(&self, ctx: &AppContext) -> Result<(), PluginError>Ok(())Late-init hook.

Notable hooks in depth

  • wrap_router(Router) -> Router - the middleware seam. Each plugin wraps the merged router with its own tower / axum layers; App::build calls it in topological order, so a plugin's layers wrap everything declared before it. umbral-security (CSRF + hardening headers) and umbral-sessions (the session layer) both ride this hook. Returning the router shape - rather than a Vec<Layer> - sidesteps the trait-object lifetime problem Layer's generics produce, so plugins keep the full axum/tower API at the call site.
  • middleware() -> Vec<Arc<dyn Middleware>> - the ergonomic alternative to wrap_router. Each Middleware gets a before_request / after_response hook and nothing else to wire; all plugins' (plus the app's) are collected into one stack installed at App::build in onion order. Reach for wrap_router when you need a real tower Layer (timeouts, tracing spans, body-limit); reach for middleware when you just want to look at the request or response.
  • commands() -> Vec<Box<dyn PluginCommand>> - CLI subcommands. umbral-auth ships createsuperuser and umbral-tasks ships tasks-worker through this hook. See Management commands.
  • templates_dirs() / template_registrars() - plugins contribute template directories and custom tags. See Rendering HTML.
  • static_files() - embedded assets baked into the binary; each entry becomes one GET <url_path> route serving a &'static [u8] body (typically include_bytes!). umbral-admin ships its precompiled CSS this way. static_dirs() / static_root_dirs() instead serve assets off disk through the unified static pipeline (live in dev); umbral-playground ships its Vite bundle via static_dirs().

on_ready receives an AppContext carrying the default DbPool and a Settings clone. The hook is sync (the trait must be object-safe for Vec<Box<dyn Plugin>>); bridge to async work with umbral::plugin::block_on_ready(fut), which is safe under multi-thread, current-thread (#[tokio::test]), and no-runtime callers.

When on_ready fires

on_ready means the application is up, and a hook that seeds content or backfills rows needs a migrated schema. So a CLI binary builds with build_deferred() and lets dispatch decide:

Code
rust
let app = App::builder()
.settings(settings)
.database("default", pool)
.plugin(AuthPlugin::default())
.build_deferred()?; // wire everything, fire nothing
 
umbral_cli::dispatch(app).await

dispatch fires the hooks for serve — after auto_migrate_on_serve() has applied migrations — and for every command that runs against live data (createsuperuser, worker, your own management commands). It skips them entirely for the schema commands (migrate, makemigrations, showmigrations, inspectdb, typegen, …).

Warning
`App::build()` still fires `on_ready` itself, which is what you want in a test or when embedding umbral under your own axum tree. Handing *that* app to `dispatch` means the hooks ran before argv was even parsed — so `cargo run -- migrate` against a fresh database seeds tables it hasn't created yet. `dispatch` prints a warning when it sees this; the fix is one word, `build()` → `build_deferred()`.

Driving the app yourself? App::serve() calls App::ready() for you. Call ready() by hand only if you hold an App and neither serve nor dispatch it.

What's deferred

  • A declarative middleware() whose entries are ordered tower Layers. The ergonomic before_request / after_response form is shipped - including declarative ordering via Middleware::order() -> i32 (lower = outer; the stack is stable-sorted before install, so a middleware places itself regardless of registration timing). What's still deferred is a declarative form over raw tower Layers - cross-plugin Layer ordering runs into the trait-object lifetime problem Layer's generics produce; use wrap_router when you need a raw Layer.
  • inventory / linkme auto-registration is intentionally not pursued - implicit compile-time collection contradicts umbral's explicit App::builder().plugin(...) / .middleware(...) wiring (the framework keeps exactly one intentional global). Middleware is always contributed explicitly.

The on_ready context

on_ready receives an &AppContext:

Code
rust
pub struct AppContext {
pub pool: DbPool, // the default connection pool, typed by backend
pub settings: Settings, // a clone of the active settings
}

pool is the same value umbral::db::pool_dispatched().clone() returns. Plugin code that needs the database normally goes through the ORM (Model::objects()…); this field is the escape hatch for schema-DDL bootstrap and backend-specific features like Postgres RLS.

The proof

plugins/umbral-auth/ is the first built-in plugin and uses exactly the contract above with zero special-casing inside umbral-core. See Users and passwords for the shape.

The spec lives in docs/specs/02-plugin-contract.md.