Batteries included
Everything wired. ORM, REST + OpenAPI, admin, auth, sessions, email, tasks. The kitchen-sink reference.
Every built-in plugin wired into one app. Use this as the reference for "what does a complete umbral app look like" and trim down from there. The point is to show every surface in one place; a real app uses some subset.
Project layout
txt
full-app/ Cargo.toml src/ main.rs blog.rs # plugin module: model + routes + REST customisation templates/ base.html home.html 404.html 500.htmlCargo.toml
toml
[package]name = "full-app"version = "0.1.0"edition = "2024" [dependencies]umbral = "0.0.1"umbral-auth = "0.0.1"umbral-sessions = "0.0.1"umbral-admin = "0.0.1"umbral-rest = "0.0.1"umbral-openapi = "0.0.1"umbral-tasks = "0.0.1"umbral-email = "0.0.1"tokio = { version = "1", features = ["macros", "rt-multi-thread"] }sqlx = { version = "0.8", features = ["sqlite", "runtime-tokio"] }chrono = { version = "0.4", features = ["serde"] }serde = { version = "1", features = ["derive"] }serde_json = "1"http = "1"tracing = "0.1"tracing-subscriber = { version = "0.3", features = ["env-filter"] }src/blog.rs, the app's domain plugin
rust
//! The blog plugin - owns the Post model, mounts /blog routes,//! bundles REST customisation (hide/transform/computed) and a//! custom @action endpoint for publishing. use http::Method;use serde::{Deserialize, Serialize};use serde_json::{json, Map, Value}; use umbral::plugin::{AppContext, Plugin, PluginError};use umbral::web::{Html, Json, Path, Router, StatusCode, get}; use umbral_rest::{ActionError, ActionScope, ResourceConfig}; #[derive(Debug, Clone, Serialize, Deserialize, sqlx::FromRow, umbral::orm::Model)]pub struct Post { pub id: i64, pub title: String, pub body: String, pub author_email: String, pub published: bool, pub created_at: chrono::DateTime<chrono::Utc>,} async fn list_posts() -> Result<Html<String>, (StatusCode, String)> { let posts = Post::objects() .filter(post::PUBLISHED.eq(true)) .order_by(post::CREATED_AT.desc()) .fetch() .await .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; let body = umbral::templates::render( "home.html", &umbral::templates::context!(posts => posts), ) .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; Ok(Html(body))} async fn post_detail(Path(id): Path<i64>) -> Result<Json<Post>, (StatusCode, String)> { match Post::objects().get(post::ID.eq(id)).await { Ok(p) => Ok(Json(p)), Err(umbral::orm::GetError::NotFound) => Err((StatusCode::NOT_FOUND, "not found".into())), Err(e) => Err((StatusCode::INTERNAL_SERVER_ERROR, e.to_string())), }} /// Per-table REST config - bundled with the plugin so main.rs doesn't/// thread customisation per-table.pub fn rest_resource() -> ResourceConfig { ResourceConfig::new("post") // Hide raw author email; expose masked form. .transform("author_email", |v| { let s = v.as_str().unwrap_or(""); match s.split_once('@') { Some((_, d)) => json!(format!("***@{d}")), None => v.clone(), } }) // Add a `summary` field derived from the body. .computed("summary", |row: &Map<String, Value>| { let body = row.get("body").and_then(|v| v.as_str()).unwrap_or(""); json!(body.chars().take(120).collect::<String>()) }) // Public read, staff write. .permission(umbral_rest::OrPermission::new(vec![ Box::new(umbral_rest::ReadOnly), Box::new(umbral_rest::IsStaff), ])) // @action: POST /api/post/{id}/publish/ .action( "publish", Method::POST, ActionScope::Detail, |ctx| async move { let id: i64 = ctx .pk .as_deref() .unwrap_or_default() .parse() .map_err(|_| ActionError::BadInput("bad id".into()))?; let mut patch = serde_json::Map::new(); patch.insert("published".into(), json!(true)); let affected = Post::objects() .filter(post::ID.eq(id)) .update_values(patch) .await .map_err(ActionError::internal)?; if affected == 0 { return Err(ActionError::NotFound(format!("no post {id}"))); } Ok(json!({ "id": id, "published": true })) }, )} pub struct BlogPlugin; impl Plugin for BlogPlugin { fn name(&self) -> &'static str { "blog" } fn models(&self) -> Vec<umbral::migrate::ModelMeta> { vec![umbral::migrate::ModelMeta::for_::<Post>()] } fn routes(&self) -> Router { Router::new() .route("/", get(list_posts)) .route("/posts/{id}", get(post_detail)) } fn on_ready(&self, _ctx: &AppContext) -> Result<(), PluginError> { tracing::info!(plugin = "blog", "ready"); Ok(()) }}src/main.rs
rust
mod blog; use umbral::prelude::*;use umbral::web::{Form, Html, IntoResponse, Redirect, StatusCode}; use umbral_auth::{AuthUser, OptionalUser};use umbral_rest::FnAuthentication; #[derive(serde::Deserialize)]struct LoginForm { username: String, password: String,} async fn login(Form(form): Form<LoginForm>) -> Result<umbral::web::Response, StatusCode> { let user: AuthUser = umbral_auth::authenticate(&form.username, &form.password) .await .map_err(|_| StatusCode::UNAUTHORIZED)?; let mut response = Redirect::to("/").into_response(); umbral_auth::login(response.headers_mut(), &user) .await .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; Ok(response)} async fn logout(headers: umbral::web::HeaderMap) -> umbral::web::Response { let mut response = Redirect::to("/").into_response(); umbral_auth::logout(&headers, response.headers_mut()).await.ok(); response} async fn me(OptionalUser(maybe): OptionalUser) -> Html<String> { match maybe { Some(u) => Html(format!("<p>hi, {}</p>", u.username)), None => Html(r#"<a href="/login">log in</a>"#.into()), }} /// Background task - would normally live in a `tasks.rs` module./// `#[umbral::task]` registers the handler by name at startup, so the/// worker (`cargo run -- tasks-worker`) can dispatch it. Enqueue work with/// `umbral_tasks::enqueue("send_welcome", payload, Default::default()).await?`.#[umbral::task]async fn send_welcome(email: String) -> Result<(), String> { let msg = umbral_email::EmailMessage::new("Welcome", vec![email]) .from("no-reply@example.com") .text_body("Glad you signed up."); umbral_email::send(&msg).await.map_err(|e| e.to_string())?; Ok(())} #[tokio::main]async fn main() -> Result<(), Box<dyn std::error::Error>> { tracing_subscriber::fmt() .with_env_filter( tracing_subscriber::EnvFilter::try_from_default_env() .unwrap_or_else(|_| tracing_subscriber::EnvFilter::new("info")), ) .init(); let settings = Settings::from_env()?; let pool = umbral::db::connect("sqlite://full-app.db?mode=rwc").await?; let app = App::builder() .settings(settings) .database("default", pool) .templates_dir(concat!(env!("CARGO_MANIFEST_DIR"), "/templates")) .not_found_template("404.html") .server_error_template("500.html") // Domain plugin: model + routes + REST customisation. .plugin(blog::BlogPlugin) // Auth: users + password hashing. Generic over the user model; // `AuthUser` is the built-in default. .plugin(umbral_auth::AuthPlugin::<AuthUser>::default()) // Sessions: anonymous on every visit, fresh token on login. .plugin(umbral_sessions::SessionsPlugin::default()) // REST: /api/post/ CRUD + /api/post/{id}/publish/ action. // The REST resource is teed into umbral-openapi automatically. .plugin( umbral_rest::RestPlugin::default() .resource(blog::rest_resource()) .paginate(umbral_rest::PageNumberPagination::new(20)) .authenticate(FnAuthentication::new(|headers| async move { let user = umbral_auth::current_user(&headers).await.ok().flatten()?; Some(umbral_rest::Identity::user(user.id).with_staff(user.is_staff)) })), ) // OpenAPI: Swagger UI at /openapi/ + schema at // /openapi/openapi.json (the default mount; override with // `.at("/docs")`). .plugin( umbral_openapi::OpenApiPlugin::new() .title("Full-app API") .version("0.1.0") .description("Auto-generated from registered models + REST resources."), ) // Admin: CRUD UI at /admin/, staff-only. .plugin(umbral_admin::AdminPlugin::default()) // Background tasks: DB-backed queue. Handlers register // themselves via `#[umbral::task]`, so the plugin itself takes // no handler list. Drain the queue with `cargo run -- tasks-worker`. .plugin(umbral_tasks::TasksPlugin::default()) // Email: console backend in dev (prints to stderr). Configure // the SMTP transport via settings for prod. .plugin(umbral_email::EmailPlugin) .routes( Routes::new() .get("/me", me) .post("/login", login) .post("/logout", logout), ) .build()?; umbral::migrate::make().await.ok(); umbral::migrate::run().await?; let addr = "127.0.0.1:3000".parse::<std::net::SocketAddr>()?; println!("listening on http://{addr}"); println!(" / - blog (HTML)"); println!(" /api/post/ - REST CRUD"); println!(" /api/post/{{id}}/publish/ - @action publish"); println!(" /admin/ - admin UI (staff only)"); println!(" /openapi/ - OpenAPI / Swagger UI"); println!(" /openapi/openapi.json - OpenAPI schema"); println!(" /me - request.user demo"); println!(" /login · /logout - auth"); app.serve(addr).await?; Ok(())}templates/base.html
html
<!doctype html><html lang="en"> <head> <meta charset="utf-8" /> <title>{% block title %}Full app{% endblock %}</title> </head> <body> <header> <a href="/">home</a> · <a href="/admin/">admin</a> · <a href="/openapi/">api</a> </header> <main>{% block content %}{% endblock %}</main> </body></html>templates/home.html
html
{% extends "base.html" %}{% block title %}Posts{% endblock %}{% block content %} <h1>Posts</h1> <ul> {% for p in posts %} <li><a href="/posts/{{ p.id }}">{{ p.title }}</a></li> {% endfor %} </ul>{% endblock %}templates/404.html
html
{% extends "base.html" %}{% block content %}<h1>Not found</h1><p>That URL doesn't exist.</p>{% endblock %}templates/500.html
html
{% extends "base.html" %}{% block content %}<h1>Internal error</h1><p>Sorry, something broke.</p>{% endblock %}Run it
bash
cargo run# listening on http://127.0.0.1:3000 # Public surfacecurl http://127.0.0.1:3000/api/post/curl http://127.0.0.1:3000/openapi/openapi.json # Auth flow (needs an AuthUser; create via `cargo run -- createsuperuser`)curl -X POST -d "username=alice&password=secret" http://127.0.0.1:3000/loginWhat this example shows
| Surface | Where it comes from |
|---|---|
| HTML pages with templates | templates/ + umbral::templates::render |
| ORM + managed migrations | #[derive(Model)] + umbral::migrate::* |
| Custom 404 / 500 pages | not_found_template / server_error_template |
| REST CRUD | umbral_rest::RestPlugin::default() + ResourceConfig |
REST @action | ResourceConfig::action("publish", POST, Detail, ...) |
| REST pagination + auth + permissions | paginate(...).authenticate(...).resource(... .permission(...)) |
| OpenAPI schema + Swagger UI | umbral_openapi::OpenApiPlugin |
Admin UI at /admin/ | umbral_admin::AdminPlugin |
| Users + password hashing | umbral_auth::AuthPlugin::<AuthUser>::default() + authenticate(...) |
Sessions + request.user | umbral_sessions::SessionsPlugin::default() + umbral_auth::OptionalUser / User extractors |
| Login / logout helpers | umbral_auth::login(...) / umbral_auth::logout(...) |
| Background tasks | umbral_tasks::TasksPlugin::default() + #[umbral::task] (self-registering) + tasks-worker |
umbral_email::EmailPlugin + umbral_email::send(&msg) |
Next
- For the smaller-but-runnable shape, see Basic.
- For an API-only variant (no HTML, no admin), see REST API service.
- For a deeper look at any one plugin, see its page under Plugins.
examplesfull-stack