Build a writing API with Axum
Maya writes a draft, shares it with Ken, and asks him to edit it. Another customer, Northwind, must never see that conversation. This capstone brings the data model, permissions, transactions, and ordinary CRUD into one small Axum application.
Read the important pieces here first. The complete schema, application, and tests are at the end, ready to run in a separate directory.
Start with the request's identity
The application verifies a bearer token before it reaches a handler. In this local example, tokens map to identities in server configuration. An identity contains the tenant and user. Neither comes from a submitted post.
After verification, middleware binds that identity once:
let database = state.root.with_viewer(
Viewer::of(User::subject(UserId {
tenant_id: identity.tenant.clone(),
id: identity.user.clone(),
}))
.tenant(identity.tenant.as_str()),
);
request.extensions_mut().insert(database);A production application can replace token lookup with its identity provider. The rest of the application still receives the same scoped client. Verify both the identity and its membership in the selected tenant before constructing it.
Let handlers use ordinary CRUD
The list handler receives only ScopedClient. It has no system access to reach for:
async fn list_posts(
Extension(db): Extension<ScopedClient>,
) -> Result<Json<Vec<PostView>>, ApiError> {
let posts = db.post().order_by(Post::id).limit(25).all().await?;
Ok(Json(posts.into_iter().map(PostView::from).collect()))
}The schema makes new posts private to their author. Ken's list gains a post only when Maya shares it. Northwind's list remains separate, even if its user is also named Maya.
Request structs contain editable content, not ownership fields. @rent.creator
sets authorship from the verified viewer, and tenancy supplies the tenant.
Application code still validates inputs such as an empty title.
Save related changes together
Creating a post also opens its discussion. Both rows belong to one transaction:
let post = db.transaction(async |tx| {
let post = tx.create_post()
.id(rent::uuid::Uuid::now_v7().to_string())
.title(input.title)
.body(input.body)
.save().await?;
tx.create_comment()
.id(rent::uuid::Uuid::now_v7().to_string())
.post_id(&post.id)
.body("Discussion opened")
.save().await?;
Ok(post)
}).await?;If the comment fails, the post rolls back too. Every call inside the transaction
uses tx. No helper quietly opens a second connection.
Share through the same rules
Maya's sharing handler grants Ken editor access on one post. It uses the tenant already established by middleware:
let key = PostId {
tenant_id: identity.tenant.clone(),
id,
};
db.post_permissions(&key)
.grant_editor(User::subject(UserId {
tenant_id: identity.tenant,
id: input.user,
}))
.save().await?;The schema's manage_grants rule decides whether the caller can do this. An editor
can change content without automatically gaining the right to share it onward.
Use the API
| Route | Purpose |
|---|---|
GET /posts | List up to 25 readable posts |
POST /posts | Create a private post and its first comment |
GET /posts/{id} | Read one visible post |
PATCH /posts/{id} | Edit a post when permitted |
DELETE /posts/{id} | Delete a post as its author |
POST /posts/{id}/editors | Give another user editor access |
Requests use Authorization: Bearer <token>. Creation and editing accept
{"title":"Launch notes","body":"An early draft"}. Sharing accepts
{"user":"ken"}. Missing credentials return 401. Missing and unreadable posts
return 404. The application returns safe error messages rather than raw database
errors.
The included tests send real requests through the router. They cover missing credentials, private reads, sharing, an editor's update, a foreign tenant, and rejected ownership fields in submitted JSON. They use a temporary database and the same migrations as the application.
Complete example
Download this project, or copy the files below into an empty directory.
Replace RENT_REPOSITORY_URL in Cargo.toml with the URL supplied with your preview access. Use the matching Rent CLI release.
Use local example credentials and a dedicated database:
export DATABASE_URL='sqlite://writing-app.db?mode=rwc'
export EXAMPLE_TOKENS='{ "maya-token": { "tenant": "acme", "user": "maya" }, "ken-token": { "tenant": "acme", "user": "ken" }, "northwind-token": { "tenant": "northwind", "user": "maya" } }'
export BIND_ADDR='127.0.0.1:4011'
rent generate
rent migrate dev --name initial
cargo nextest run
cargo runThe opaque tokens are configured on the server for this local example. In a deployed application, replace that lookup with your identity provider's verification and membership checks. Keep the same scoped-client boundary in the handlers. Never accept a tenant or principal from an unverified header.
Cargo.toml
[package]
name = "rent-lesson-43-axum-api"
version = "0.1.0"
edition = "2024"
[workspace]
[[bin]]
name = "rent-lesson-43-axum-api"
path = "main.rs"
[dependencies]
rent = { git = "RENT_REPOSITORY_URL", tag = "v0.7.2", default-features = false, features = ["sqlite", "migrate", "extensions"] }
anyhow = "1.0.104"
tokio = { version = "1.53.1", features = ["full"] }
serde = { version = "1.0.229", features = ["derive", "rc"] }
serde_json = "1.0.151"
tempfile = "3.27.0"
axum = { version = "0.8.9" }
tower = { version = "0.5", features = ["util"] }rent.toml
schema = "schema.rsl"
generated = "generated"
migrations = "migrations"
[database]
dialect = "sqlite"
backends = ["sqlite"]schema.rsl
datasource db {
provider = "sqlite"
}
tenancy {
scope = "required"
field = "tenant_id"
}
access {
subjects = [User]
}
model User {
tenant_id String
id String
name String
posts Post[]
comments Comment[]
@@id([tenant_id, id])
}
model Post {
tenant_id String
id String
title String
body String
author_id String
author User @relation(fields: [tenant_id, author_id], references: [tenant_id, id]) @rent.creator
comments Comment[]
@@id([tenant_id, id])
}
model Comment {
tenant_id String
id String
body String
post_id String
author_id String
post Post @relation(fields: [tenant_id, post_id], references: [tenant_id, id], onDelete: Cascade)
author User @relation(fields: [tenant_id, author_id], references: [tenant_id, id]) @rent.creator
@@id([tenant_id, id])
}
permissions User {
read = anyone
create, update, delete = nobody
}
permissions Post {
editor = author or [User]
viewer = editor or [User]
read = viewer
create = anyone
update = editor
delete, manage_grants = author
}
permissions Comment {
read, create = post.viewer
update = author and post.viewer
delete = author and post.viewer or post.editor
}main.rs
use anyhow::Result;
use axum::{
Extension, Json, Router,
extract::{Path, Request, State},
http::{StatusCode, header},
middleware::{self, Next},
response::{IntoResponse, Response},
routing::{get, post},
};
use rent::{
policy::viewer::Viewer,
runtime::{EntityError, permission::PermissionError},
};
use serde::{Deserialize, Serialize};
use std::{collections::HashMap, sync::Arc};
#[allow(dead_code, unused_imports)]
#[rustfmt::skip]
#[path = "generated/mod.rs"]
mod generated;
use generated::{
client::{RootClient, ScopedClient},
post::{Post, PostId},
user::{User, UserId},
};
#[derive(Clone, Deserialize)]
struct Identity {
tenant: String,
user: String,
}
#[derive(Clone)]
struct AppState {
root: RootClient,
tokens: Arc<HashMap<String, Identity>>,
}
struct ApiError(StatusCode);
impl IntoResponse for ApiError {
fn into_response(self) -> Response {
let message = self.0.canonical_reason().unwrap_or("Request failed");
(self.0, Json(serde_json::json!({"error": message}))).into_response()
}
}
impl From<EntityError> for ApiError {
fn from(error: EntityError) -> Self {
let status = match error {
EntityError::NotFound {
..
} => StatusCode::NOT_FOUND,
EntityError::UniqueViolation {
..
}
| EntityError::OptimisticLock {
..
} => StatusCode::CONFLICT,
EntityError::Permission(
PermissionError::Denied {
..
}
| PermissionError::CreatorDenied {
..
},
) => StatusCode::FORBIDDEN,
_ => StatusCode::INTERNAL_SERVER_ERROR,
};
Self(status)
}
}
async fn authenticate(
State(state): State<AppState>,
mut request: Request,
next: Next,
) -> std::result::Result<Response, ApiError> {
let token = request
.headers()
.get(header::AUTHORIZATION)
.and_then(|value| value.to_str().ok())
.and_then(|value| value.strip_prefix("Bearer "))
.ok_or(ApiError(StatusCode::UNAUTHORIZED))?;
let identity = state.tokens.get(token).cloned().ok_or(ApiError(StatusCode::UNAUTHORIZED))?;
let database = state.root.with_viewer(
Viewer::of(User::subject(UserId {
tenant_id: identity.tenant.clone(),
id: identity.user.clone(),
}))
.tenant(identity.tenant.as_str()),
);
request.extensions_mut().insert(database);
request.extensions_mut().insert(identity);
Ok(next.run(request).await)
}
#[derive(Deserialize)]
#[serde(deny_unknown_fields)]
struct PostInput {
title: String,
body: String,
}
#[derive(Serialize)]
struct PostView {
id: String,
title: String,
body: String,
}
impl From<Post> for PostView {
fn from(post: Post) -> Self {
Self {
id: post.id,
title: post.title,
body: post.body,
}
}
}
async fn list_posts(Extension(db): Extension<ScopedClient>) -> std::result::Result<Json<Vec<PostView>>, ApiError> {
let posts = db.post().order_by(Post::id).limit(25).all().await?;
Ok(Json(posts.into_iter().map(PostView::from).collect()))
}
async fn read_post(
Extension(db): Extension<ScopedClient>,
Path(id): Path<String>,
) -> std::result::Result<Json<PostView>, ApiError> {
Ok(Json(db.post().id_eq(id).only().await?.into()))
}
async fn create_post(
Extension(db): Extension<ScopedClient>,
Json(input): Json<PostInput>,
) -> std::result::Result<(StatusCode, Json<PostView>), ApiError> {
if input.title.trim().is_empty() {
return Err(ApiError(StatusCode::BAD_REQUEST));
}
let post = db
.transaction(async |tx| {
let post = tx
.create_post()
.id(rent::uuid::Uuid::now_v7().to_string())
.title(input.title)
.body(input.body)
.save()
.await?;
tx.create_comment()
.id(rent::uuid::Uuid::now_v7().to_string())
.post_id(&post.id)
.body("Discussion opened")
.save()
.await?;
Ok(post)
})
.await?;
Ok((StatusCode::CREATED, Json(post.into())))
}
async fn update_post(
Extension(db): Extension<ScopedClient>,
Extension(identity): Extension<Identity>,
Path(id): Path<String>,
Json(input): Json<PostInput>,
) -> std::result::Result<Json<PostView>, ApiError> {
if input.title.trim().is_empty() {
return Err(ApiError(StatusCode::BAD_REQUEST));
}
let post = db
.update_post()
.one(PostId {
tenant_id: identity.tenant,
id,
})
.title(input.title)
.body(input.body)
.save_one()
.await?;
Ok(Json(post.into()))
}
async fn delete_post(
Extension(db): Extension<ScopedClient>,
Path(id): Path<String>,
) -> std::result::Result<StatusCode, ApiError> {
if db.delete_post().id_eq(id).exec().await? == 0 {
return Err(ApiError(StatusCode::NOT_FOUND));
}
Ok(StatusCode::NO_CONTENT)
}
#[derive(Deserialize)]
#[serde(deny_unknown_fields)]
struct ShareInput {
user: String,
}
async fn share_post(
Extension(db): Extension<ScopedClient>,
Extension(identity): Extension<Identity>,
Path(id): Path<String>,
Json(input): Json<ShareInput>,
) -> std::result::Result<StatusCode, ApiError> {
let key = PostId {
tenant_id: identity.tenant.clone(),
id,
};
db.post_permissions(&key)
.grant_editor(User::subject(UserId {
tenant_id: identity.tenant,
id: input.user,
}))
.save()
.await?;
Ok(StatusCode::NO_CONTENT)
}
fn app(
root: RootClient,
tokens: HashMap<String, Identity>,
) -> Router {
let state = AppState {
root,
tokens: Arc::new(tokens),
};
Router::new()
.route("/posts", get(list_posts).post(create_post))
.route("/posts/{id}", get(read_post).patch(update_post).delete(delete_post))
.route("/posts/{id}/editors", post(share_post))
.layer(middleware::from_fn_with_state(state, authenticate))
}
async fn provision(
root: &RootClient,
tokens: &HashMap<String, Identity>,
) -> Result<()> {
// This example provisions its local accounts at startup. Request handlers have no system client.
for identity in tokens.values() {
root.system()
.upsert_user(generated::user::unique::tenant_id_id)
.tenant_id(&identity.tenant)
.id(&identity.user)
.name(&identity.user)
.save()
.await?;
}
Ok(())
}
#[tokio::main]
async fn main() -> Result<()> {
let root = RootClient::connect(&std::env::var("DATABASE_URL")?).await?;
// These are server-configured opaque tokens for the local example, never claims from a request body.
let tokens: HashMap<String, Identity> = serde_json::from_str(&std::env::var("EXAMPLE_TOKENS")?)?;
provision(&root, &tokens).await?;
let address = std::env::var("BIND_ADDR").unwrap_or_else(|_| "127.0.0.1:4011".into());
let listener = tokio::net::TcpListener::bind(&address).await?;
println!("Listening on {address}");
axum::serve(listener, app(root, tokens)).await?;
Ok(())
}
#[cfg(test)]
mod test_support;
#[cfg(test)]
mod tests;test_support.rs
use anyhow::{Context, Result, ensure};
use std::process::Command;
pub fn database() -> Result<(tempfile::TempDir, String)> {
let directory = tempfile::tempdir()?;
std::fs::write(directory.path().join("schema.rsl"), include_str!("schema.rsl"))?;
std::fs::write(directory.path().join("rent.toml"), r#"schema = "schema.rsl""#)?;
let url = format!("sqlite://{}?mode=rwc", directory.path().join("app.db").display());
let executable = std::env::var("RENT_CLI").unwrap_or_else(|_| "rent".into());
let output = Command::new(executable)
.arg("migrate")
.arg("dev")
.arg("--root")
.arg(directory.path())
.arg("--to")
.arg("schema.rsl")
.arg("--database-url")
.arg(&url)
.output()
.context("run the Rent CLI")?;
ensure!(
output.status.success(),
"migration failed: {}",
String::from_utf8_lossy(&output.stderr)
);
Ok((directory, url))
}tests.rs
use super::*;
use axum::body::{Body, to_bytes};
use axum::http::Request;
use tower::ServiceExt;
fn request(
method: &str,
path: &str,
token: Option<&str>,
body: serde_json::Value,
) -> Request<Body> {
let mut builder = Request::builder()
.method(method)
.uri(path)
.header("content-type", "application/json");
if let Some(token) = token {
builder = builder.header("authorization", format!("Bearer {token}"));
}
builder.body(Body::from(body.to_string())).expect("valid test request")
}
#[tokio::test]
async fn routes_keep_identity_and_tenant_boundaries() -> Result<()> {
let (_directory, url) = test_support::database()?;
let root = RootClient::connect(&url).await?;
let tokens = HashMap::from([
(
"maya-token".into(),
Identity {
tenant: "acme".into(),
user: "maya".into(),
},
),
(
"ken-token".into(),
Identity {
tenant: "acme".into(),
user: "ken".into(),
},
),
(
"northwind-token".into(),
Identity {
tenant: "northwind".into(),
user: "maya".into(),
},
),
]);
provision(&root, &tokens).await?;
let router = app(root.clone(), tokens);
let response = router
.clone()
.oneshot(request("GET", "/posts", None, serde_json::Value::Null))
.await?;
assert_eq!(response.status(), StatusCode::UNAUTHORIZED);
let response = router
.clone()
.oneshot(request(
"POST",
"/posts",
Some("maya-token"),
serde_json::json!({"title":"Draft","body":"Hello"}),
))
.await?;
assert_eq!(response.status(), StatusCode::CREATED);
let body: serde_json::Value = serde_json::from_slice(&to_bytes(response.into_body(), 1024 * 1024).await?)?;
let id = body["id"].as_str().expect("post id");
for token in ["ken-token", "northwind-token"] {
let response = router
.clone()
.oneshot(request("GET", &format!("/posts/{id}"), Some(token), serde_json::Value::Null))
.await?;
assert_eq!(response.status(), StatusCode::NOT_FOUND);
}
let response = router
.clone()
.oneshot(request(
"POST",
&format!("/posts/{id}/editors"),
Some("maya-token"),
serde_json::json!({"user":"ken"}),
))
.await?;
assert_eq!(response.status(), StatusCode::NO_CONTENT);
let response = router
.clone()
.oneshot(request(
"PATCH",
&format!("/posts/{id}"),
Some("ken-token"),
serde_json::json!({"title":"Reviewed","body":"Hello"}),
))
.await?;
assert_eq!(response.status(), StatusCode::OK);
let response = router
.clone()
.oneshot(request(
"GET",
&format!("/posts/{id}"),
Some("northwind-token"),
serde_json::Value::Null,
))
.await?;
assert_eq!(response.status(), StatusCode::NOT_FOUND);
let response = router
.clone()
.oneshot(request(
"POST",
"/posts",
Some("ken-token"),
serde_json::json!({"title":"Spoof","body":"No","author_id":"maya"}),
))
.await?;
assert_eq!(response.status(), StatusCode::UNPROCESSABLE_ENTITY);
let maya = root.with_viewer(
Viewer::of(User::subject(UserId {
tenant_id: "acme".into(),
id: "maya".into(),
}))
.tenant("acme"),
);
assert_eq!(maya.comment().post_id_eq(id).count().await?, 1);
let response = router
.clone()
.oneshot(request(
"GET",
&format!("/posts/{id}"),
Some("ken-token"),
serde_json::Value::Null,
))
.await?;
assert_eq!(response.status(), StatusCode::OK);
let response = router
.clone()
.oneshot(request(
"DELETE",
&format!("/posts/{id}"),
Some("ken-token"),
serde_json::Value::Null,
))
.await?;
assert_eq!(response.status(), StatusCode::NOT_FOUND);
let response = router
.clone()
.oneshot(request(
"DELETE",
&format!("/posts/{id}"),
Some("maya-token"),
serde_json::Value::Null,
))
.await?;
assert_eq!(response.status(), StatusCode::NO_CONTENT);
assert_eq!(maya.comment().post_id_eq(id).count().await?, 0);
let response = router
.oneshot(request("GET", "/posts", Some("ken-token"), serde_json::Value::Null))
.await?;
assert_eq!(response.status(), StatusCode::OK);
let posts: serde_json::Value = serde_json::from_slice(&to_bytes(response.into_body(), 1024 * 1024).await?)?;
assert_eq!(posts, serde_json::json!([]));
Ok(())
}