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> { vec![umbral::migrate::ModelMeta::for_::<Post>()] } 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 by their
dependencies(), 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.
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 uses this; cycles surface as BuildError::PluginCycle.
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 after system checks pass, in dependency order. 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.
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.