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

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

Code
txt
full-app/
Cargo.toml
src/
main.rs
blog.rs # plugin module: model + routes + REST customisation
templates/
base.html
home.html
404.html
500.html

Cargo.toml

Code
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

Code
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

Code
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

Code
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

Code
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

Code
html
{% extends "base.html" %}
{% block content %}<h1>Not found</h1><p>That URL doesn't exist.</p>{% endblock %}

templates/500.html

Code
html
{% extends "base.html" %}
{% block content %}<h1>Internal error</h1><p>Sorry, something broke.</p>{% endblock %}

Run it

Code
bash
cargo run
# listening on http://127.0.0.1:3000
 
# Public surface
curl 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/login

What this example shows

SurfaceWhere it comes from
HTML pages with templatestemplates/ + umbral::templates::render
ORM + managed migrations#[derive(Model)] + umbral::migrate::*
Custom 404 / 500 pagesnot_found_template / server_error_template
REST CRUDumbral_rest::RestPlugin::default() + ResourceConfig
REST @actionResourceConfig::action("publish", POST, Detail, ...)
REST pagination + auth + permissionspaginate(...).authenticate(...).resource(... .permission(...))
OpenAPI schema + Swagger UIumbral_openapi::OpenApiPlugin
Admin UI at /admin/umbral_admin::AdminPlugin
Users + password hashingumbral_auth::AuthPlugin::<AuthUser>::default() + authenticate(...)
Sessions + request.userumbral_sessions::SessionsPlugin::default() + umbral_auth::OptionalUser / User extractors
Login / logout helpersumbral_auth::login(...) / umbral_auth::logout(...)
Background tasksumbral_tasks::TasksPlugin::default() + #[umbral::task] (self-registering) + tasks-worker
Emailumbral_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