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
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:
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.
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.
| Method | Signature | Default | What it contributes |
|---|---|---|---|
name | fn name(&self) -> &'static str | - (required) | Stable identifier; migration-tracking key. |
dependencies | fn dependencies(&self) -> &'static [&'static str] | &[] | Plugins that must load first. |
models | fn models(&self) -> Vec<ModelMeta> | empty | The plugin's models, the migration diff target. |
routes | fn routes(&self) -> Router | Router::new() | HTTP routes, merged flat into the app router. |
route_paths | fn route_paths(&self) -> Vec<RouteSpec> | empty | Declared paths for the dev-mode 404 page (informational only). |
openapi_paths | fn openapi_paths(&self) -> Vec<(String, serde_json::Value)> | empty | OpenAPI path items umbral-openapi merges into the spec. |
system_checks | fn system_checks(&self) -> Vec<SystemCheck> | empty | Boot-time invariants (phase 4). |
provides_storage | fn provides_storage(&self) -> bool | false | true if on_ready registers a Storage backend (e.g. StoragePlugin). |
database | fn database(&self) -> Option<&'static str> | None | DB alias every model this plugin owns reads/writes, for multi-database routing. |
templates_dirs | fn templates_dirs(&self) -> Vec<PathBuf> | empty | Template search directories. |
template_registrars | fn template_registrars(&self) -> Vec<TemplateRegistrar> | empty | Custom minijinja filters / functions / globals the plugin makes available in templates. |
wrap_router | fn wrap_router(&self, router: Router) -> Router | router unchanged | The tower/axum middleware seam (see below). |
middleware | fn middleware(&self) -> Vec<Arc<dyn Middleware>> | empty | Ergonomic before_request / after_response hooks, stacked in onion order. |
static_files | fn static_files(&self) -> Vec<StaticFile> | empty | Assets baked into the binary (&'static [u8] bodies). |
static_dirs | fn static_dirs(&self) -> Vec<StaticDir> | empty | On-disk source dirs served live (dev) under a namespace via the unified static pipeline. |
static_root_dirs | fn static_root_dirs(&self) -> Vec<PathBuf> | empty | On-disk dirs served at the bare root of static_url (no namespace). |
commands | fn commands(&self) -> Vec<Box<dyn PluginCommand>> | empty | CLI subcommands. |
api_endpoints | fn api_endpoints(&self) -> Vec<ApiEndpoint> | empty | Endpoints advertised for service discovery (a REST API root, a SPA). |
on_ready | fn 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::buildcalls it in topological order, so a plugin's layers wrap everything declared before it.umbral-security(CSRF + hardening headers) andumbral-sessions(the session layer) both ride this hook. Returning the router shape - rather than aVec<Layer>- sidesteps the trait-object lifetime problemLayer's generics produce, so plugins keep the full axum/tower API at the call site.middleware() -> Vec<Arc<dyn Middleware>>- the ergonomic alternative towrap_router. EachMiddlewaregets abefore_request/after_responsehook and nothing else to wire; all plugins' (plus the app's) are collected into one stack installed atApp::buildin onion order. Reach forwrap_routerwhen you need a real towerLayer(timeouts, tracing spans, body-limit); reach formiddlewarewhen you just want to look at the request or response.commands() -> Vec<Box<dyn PluginCommand>>- CLI subcommands.umbral-authshipscreatesuperuserandumbral-tasksshipstasks-workerthrough 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 oneGET <url_path>route serving a&'static [u8]body (typicallyinclude_bytes!).umbral-adminships its precompiled CSS this way.static_dirs()/static_root_dirs()instead serve assets off disk through the unified static pipeline (live in dev);umbral-playgroundships its Vite bundle viastatic_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:
let app = App::builder() .settings(settings) .database("default", pool) .plugin(AuthPlugin::default()) .build_deferred()?; // wire everything, fire nothing umbral_cli::dispatch(app).awaitdispatch 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, …).
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 towerLayers. The ergonomicbefore_request/after_responseform is shipped - including declarative ordering viaMiddleware::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 towerLayers - cross-pluginLayerordering runs into the trait-object lifetime problemLayer's generics produce; usewrap_routerwhen you need a rawLayer. - 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:
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.