Tutorial Part 1: Requests and Responses
This tutorial guides you through building a Polls application in Djangors, mirroring the structure of Django’s official tutorial. In Part 1, we will set up a new project, create our first HTTP view, configure routing, and launch the development server.
After the tutorial, use the Application Architecture guide to see how a larger feature is split into models, contracts, repositories, services, views, URLs, permissions, and tests.
Note
The source of truth for all code snippets in this tutorial is the working
examples/pollsproject in this repository.
1. Creating a New Project
To create a new Djangors project, run the dj new command provided by djangors-cli:
dj new polls
This sets up a standard Rust binary crate tailored for Djangors. The generated Cargo.toml specifies the required framework dependencies:
[package]
name = "polls"
description = "Polls app for Djangors"
version = "0.0.1"
edition = "2021"
publish = false
[lib]
name = "polls"
path = "src/lib.rs"
[[bin]]
name = "polls"
path = "src/main.rs"
[dependencies]
djangors-core = { path = "../../crates/djangors-core" }
djangors-orm = { path = "../../crates/djangors-orm" }
djangors-macros = { path = "../../crates/djangors-macros" }
djangors-db = { path = "../../crates/djangors-db" }
djangors-auth = { path = "../../crates/djangors-auth" }
djangors-admin = { path = "../../crates/djangors-admin" }
djangors-sessions = { path = "../../crates/djangors-sessions" }
tokio = { version = "1.53.0", features = ["full"] }
tower = { version = "0.5.3", features = ["util"] }
hyper = { version = "1.10.1", features = ["full"] }
chrono = { version = "0.4.45", features = ["std", "serde"] }
serde = { version = "1.0.228", features = ["derive"] }
async-trait = "0.1.86"
2. Writing Your First View
In Djangors, HTTP views are asynchronous functions that accept a Request and PathParams, returning a Result<Response, DjangorsError>.
Create src/views.rs and add an index view that returns a simple HTML string:
#![allow(unused)]
fn main() {
use djangors_core::{DjangorsError, PathParams, Request, Response, StatusCode};
pub async fn index(req: Request, _params: PathParams) -> Result<Response, DjangorsError> {
Ok(Response::html(
StatusCode::OK,
"Hello, world. You're at the polls index.".to_string(),
))
}
}
3. Configuring the Router
Next, map your view to a URL endpoint. Create src/urls.rs and define a function that constructs a Router:
#![allow(unused)]
fn main() {
mod views {
use djangors_core::{DjangorsError, PathParams, Request, Response, StatusCode};
pub async fn index(_: Request, _: PathParams) -> Result<Response, DjangorsError> {
Ok(Response::html(StatusCode::OK, ""))
}
}
use djangors_core::Router;
pub fn urls() -> Router {
Router::new().get("/", views::index)
}
}
Expose the URL module in src/lib.rs:
#![allow(unused)]
fn main() {
pub mod admin;
pub mod models;
pub mod urls;
pub mod views;
}
4. Setting Up the Server Entrypoint
In src/main.rs, initialize dev logging, load environment settings, construct the Tower middleware pipeline, and start the HTTP server:
mod polls {
pub mod urls {
use djangors_core::Router;
pub fn urls() -> Router { Router::new() }
}
}
use djangors_core::{Djangors, DjangorsError, DjangorsSettings, Router};
use polls::urls;
#[tokio::main]
async fn main() -> Result<(), DjangorsError> {
djangors_core::logging::init_dev_logging();
let (settings, warnings) = DjangorsSettings::load()?;
for w in warnings {
eprintln!("settings warning: {w}");
}
let db_url = match std::env::var("DATABASE_URL") {
Ok(url) => url,
Err(_) => {
eprintln!("DATABASE_URL environment variable is not set");
std::process::exit(1);
}
};
let config = djangors_db::config::DatabaseConfig::new(db_url);
let db = djangors_db::Database::connect(&config)
.await
.map_err(|e| DjangorsError::Internal(e.to_string()))?;
let router = urls::urls().with_state(db);
let router_service = djangors_core::router::RouterService::new(router, settings.debug);
let secret_key = if settings.secret_key.is_empty() {
"dev-only-secret-key-at-least-32-bytes-long-for-signing-cookies".to_string()
} else {
settings.secret_key.clone()
};
let service = tower::ServiceBuilder::new()
.layer(djangors_core::middleware::security_headers_layer())
.layer(djangors_sessions::SessionLayer::new(
djangors_sessions::SignedCookieStore::new(secret_key.as_bytes()),
))
.layer(djangors_core::middleware::csrf_layer())
.service(router_service);
Djangors::new(settings, Router::new())
.run_service(service)
.await
}
What’s Real vs. What Django Has That Djangors Doesn’t Yet
Important
Key Architecture Differences from Django:
- Async by Default: Djangors handlers are native Rust
async fnsignatures built on Tokio and Hyper/Tower.- Typed Signatures: Views take
(Request, PathParams)and explicitly returnResult<Response, DjangorsError>.- Explicit Router Chain: Routes are chained on
Router::new()using HTTP verb methods (.get(),.post()), rather than Python lists ofpath()objects.- Middleware Stack: Middleware is composed using standard Rust
tower::ServiceBuilderlayers rather than string class paths insettings.py.
Running the Dev Server
To launch your development server, set the database connection string and run dj run (or cargo run):
DATABASE_URL="postgres://postgres:postgres@localhost/djangors_dev" dj run --port 8000
Navigate to http://localhost:8000/ in your browser or curl it to confirm:
curl http://localhost:8000/
# Output: Hello, world. You're at the polls index.
Tutorial Part 2: Models and Database Setup
In Part 2, we define our database models, configure database connectivity, add business logic methods to models, and inspect CLI database management tools.
Note
All code in this part corresponds directly to
examples/polls/src/models.rs.
1. Defining Models
In Djangors, models are plain Rust structs decorated with #[derive(Model)] and procedural attributes under #[djangors(...)].
Create src/models.rs and add the Question and Choice models:
#![allow(unused)]
fn main() {
use chrono::{DateTime, Utc};
use djangors_macros::Model;
use djangors_orm::ForeignKey;
#[derive(Model, Debug, Clone)]
#[djangors(app = "polls", table_name = "polls_question", ordering = "-pub_date")]
pub struct Question {
#[djangors(primary_key, auto)]
pub id: i64,
#[djangors(max_length = 200)] pub question_text: String,
#[djangors(verbose_name = "date published", db_index)]
pub pub_date: DateTime<Utc>,
}
impl Question {
/// Not a framework feature. An ordinary inherent method, exactly like
/// Django's `was_published_recently()` on the tutorial's `Question` model.
/// Proves model structs stay plain Rust structs you can add real methods to.
pub fn was_published_recently(&self) -> bool {
self.pub_date > Utc::now() - chrono::Duration::days(1)
}
}
#[derive(Model, Debug, Clone)]
#[djangors(app = "polls", table_name = "polls_choice")]
pub struct Choice {
#[djangors(primary_key, auto)]
pub id: i64,
#[djangors(foreign_key(on_delete = "cascade", related_name = "choices"))]
pub question: ForeignKey<Question>,
#[djangors(max_length = 200)]
pub choice_text: String,
#[djangors(default = 0)]
pub votes: i32,
}
}
2. Understanding Model Attributes
Djangors macro attributes control ORM schema generation and behavior:
app = "polls": Associates the model with thepollsapp.table_name = "polls_question": Explicitly maps the database table name.ordering = "-pub_date": Sets default query ordering (descending by publication date).#[djangors(primary_key, auto)]: Designates an auto-incrementing primary key.#[djangors(foreign_key(on_delete = "cascade", related_name = "choices"))]: Wraps foreign key relationship toQuestionwith cascade deletion.
3. Database Connection & Schema Management
Database initialization happens in main.rs via djangors_db::Database::connect(&config).
Djangors provides CLI commands for database migrations and interactive database shells:
# Generate migration files based on model metadata changes
dj makemigrations
# Apply migrations to update the database schema
dj migrate
# Open an interactive PostgreSQL shell (uses psql under the hood)
dj dbshell
In test suites and custom environments, raw DDL or programmatic migrations set up tables for isolation:
CREATE TABLE polls_question (
id BIGSERIAL PRIMARY KEY,
question_text VARCHAR(200) NOT NULL,
pub_date TIMESTAMPTZ NOT NULL
);
CREATE TABLE polls_choice (
id BIGSERIAL PRIMARY KEY,
question BIGINT NOT NULL REFERENCES polls_question(id) ON DELETE CASCADE,
choice_text VARCHAR(200) NOT NULL,
votes INTEGER NOT NULL DEFAULT 0
);
What’s Real vs. What Django Has That Djangors Doesn’t Yet
Important
Key Architecture Differences from Django:
- Rust Structs, Not Python Classes: Djangors models rely on
#[derive(Model)]rather than inheriting from a basemodels.ModelPython class.- Typed Foreign Keys: Foreign keys use
ForeignKey<T>structs rather than direct field assignment.- Inherent Methods: Business logic methods like
was_published_recently()are standard Rustimplblocks on the struct.- Schema Migration Tooling:
dj makemigrationsanddj migrateexecute schema migrations, while integration tests directly run SQL DDL statements (CREATE TABLE) for predictable setup.
Verification & Execution
To check model definitions and schema integrity, run dj check:
dj check
You can also run the interactive Rust REPL shell (requires evcxr via cargo install evcxr_repl):
dj shell
Inside the REPL, you can load your project crate directly:
#![allow(unused)]
fn main() {
:dep my_app = { path = "." }
use my_app::models::*;
}
Tutorial Part 3: Views and URLs
In Part 3, we build out the Polls application views (index, detail, results), execute database queries via the Djangors ORM, extract URL path parameters, and handle 404 errors.
Note
All view code in this part is taken directly from
examples/polls/src/views.rsand routing fromexamples/polls/src/urls.rs.
1. Updating src/urls.rs
Register parameterized GET routes for index, detail, and results on the Router:
#![allow(unused)]
fn main() {
mod views {
use djangors_core::{DjangorsError, PathParams, Request, Response, StatusCode};
pub async fn index(_: Request, _: PathParams) -> Result<Response, DjangorsError> { Ok(Response::html(StatusCode::OK, "")) }
pub async fn detail(_: Request, _: PathParams) -> Result<Response, DjangorsError> { Ok(Response::html(StatusCode::OK, "")) }
pub async fn results(_: Request, _: PathParams) -> Result<Response, DjangorsError> { Ok(Response::html(StatusCode::OK, "")) }
pub async fn vote(_: Request, _: PathParams) -> Result<Response, DjangorsError> { Ok(Response::html(StatusCode::OK, "")) }
pub async fn login_view(_: Request, _: PathParams) -> Result<Response, DjangorsError> { Ok(Response::html(StatusCode::OK, "")) }
pub async fn logout_view(_: Request, _: PathParams) -> Result<Response, DjangorsError> { Ok(Response::html(StatusCode::OK, "")) }
}
mod admin {
use djangors_admin::AdminSite;
pub fn admin_site() -> AdminSite { AdminSite::new() }
}
use djangors_core::Router;
pub fn urls() -> Router {
djangors_admin::favicon_routes(
Router::new()
.get("/", views::index)
.get("/{question_id:i64}/", views::detail)
.get("/{question_id:i64}/results/", views::results)
.post("/{question_id:i64}/vote/", views::vote)
.post("/accounts/login/", views::login_view)
.post("/accounts/logout/", views::logout_view)
.mount("/admin", self::admin::admin_site().urls()),
)
}
}
2. Querying Database in index View
In src/views.rs, implement index to query the 5 most recent published questions using the ORM q!() macro:
#![allow(unused)]
fn main() {
use polls::models::{Question, Choice};
use djangors_core::{DjangorsError, PathParams, Request, Response, StatusCode};
use djangors_orm::{q, Model};
pub async fn index(req: Request, _params: PathParams) -> Result<Response, DjangorsError> {
let db = req
.state::<djangors_db::Database>()
.ok_or_else(|| DjangorsError::Internal("database state absent".to_string()))?;
let latest_question_list = Question::objects()
.filter(q!(pub_date__lte = chrono::Utc::now()))
.map_err(|e| DjangorsError::Internal(e.to_string()))?
.order_by("-pub_date")
.map_err(|e| DjangorsError::Internal(e.to_string()))?
.limit(5)
.all(db)
.await
.map_err(|e| DjangorsError::Internal(e.to_string()))?;
let body = latest_question_list
.iter()
.map(|q| format!("<li><a href=\"/{}/\">{}</a></li>", q.id, q.question_text))
.collect::<String>();
Ok(Response::html(StatusCode::OK, format!("<ul>{body}</ul>")))
}
}
3. Extracting Path Parameters & Handling 404s in detail View
The detail view extracts question_id from PathParams using .get_as::<i64>("question_id")?. If the question does not exist, it converts OrmError::NotFound into DjangorsError::NotFound:
#![allow(unused)]
fn main() {
use polls::models::{Question, Choice};
use djangors_core::{DjangorsError, PathParams, Request, Response, StatusCode};
use djangors_orm::{q, Model};
pub async fn detail(req: Request, params: PathParams) -> Result<Response, DjangorsError> {
let db = req
.state::<djangors_db::Database>()
.ok_or_else(|| DjangorsError::Internal("database state absent".to_string()))?;
let question_id: i64 = params.get_as("question_id")?;
let question = Question::objects()
.filter(q!(id = question_id))
.map_err(|e| DjangorsError::Internal(e.to_string()))?
.get(db)
.await
.map_err(|e| match e {
djangors_orm::OrmError::NotFound { .. } => DjangorsError::NotFound,
_ => DjangorsError::Internal(e.to_string()),
})?;
let choices = Choice::objects()
.filter(q!(question = question_id))
.map_err(|e| DjangorsError::Internal(e.to_string()))?
.all(db)
.await
.map_err(|e| DjangorsError::Internal(e.to_string()))?;
let choices_html = choices
.iter()
.map(|c| {
format!(
r#"<li>
<input type="radio" name="choice" id="choice_{}" value="{}">
<label for="choice_{}">{}</label>
</li>"#,
c.id, c.id, c.id, c.choice_text
)
})
.collect::<String>();
let html = format!(
r#"<h1>{}</h1>
<form action="/{}/vote/" method="post">
<ul>{}</ul>
<input type="submit" value="Vote">
</form>"#,
question.question_text, question.id, choices_html
);
Ok(Response::html(StatusCode::OK, html))
}
}
4. Rendering Results in results View
The results view fetches question choices and displays current vote tallies:
#![allow(unused)]
fn main() {
use polls::models::{Question, Choice};
use djangors_core::{DjangorsError, PathParams, Request, Response, StatusCode};
use djangors_orm::{q, Model};
pub async fn results(req: Request, params: PathParams) -> Result<Response, DjangorsError> {
let db = req
.state::<djangors_db::Database>()
.ok_or_else(|| DjangorsError::Internal("database state absent".to_string()))?;
let question_id: i64 = params.get_as("question_id")?;
let question = Question::objects()
.filter(q!(id = question_id))
.map_err(|e| DjangorsError::Internal(e.to_string()))?
.get(db)
.await
.map_err(|e| match e {
djangors_orm::OrmError::NotFound { .. } => DjangorsError::NotFound,
_ => DjangorsError::Internal(e.to_string()),
})?;
let choices = Choice::objects()
.filter(q!(question = question_id))
.map_err(|e| DjangorsError::Internal(e.to_string()))?
.all(db)
.await
.map_err(|e| DjangorsError::Internal(e.to_string()))?;
let choices_html = choices
.iter()
.map(|c| format!("<li>{} -- {} vote(s)</li>", c.choice_text, c.votes))
.collect::<String>();
Ok(Response::html(
StatusCode::OK,
format!(
"<h1>Results for {}</h1><ul>{}</ul><a href=\"/{}/\">Vote again?</a>",
question.question_text, choices_html, question.id
),
))
}
}
What’s Real vs. What Django Has That Djangors Doesn’t Yet
Important
Key Architecture Differences from Django:
- URL Syntax: Djangors uses path parameters formatted as
/{param_name:type}/(e.g.,/{question_id:i64}/).- Route Reversal: Named route resolution (
reverse()) is not used inexamples/polls. Views build URL strings explicitly viaformat!("/{}/results/", question_id).- Templates vs. String Formatting: The
examples/pollsapplication generates HTML dynamically using Rustformat!()strings.- Explicit DB Access: Views retrieve the
Databasehandle from request extensions viareq.state::<Database>().
Running and Verifying
Start the development server:
DATABASE_URL="postgres://postgres:postgres@localhost/djangors_dev" dj run --port 8000
Test the views:
# Get poll index
curl http://localhost:8000/
# Get question detail for ID 1
curl http://localhost:8000/1/
# Get question results for ID 1
curl http://localhost:8000/1/results/
Tutorial Part 4: Forms, POST Requests, and Authentication
In Part 4, we handle HTML form submissions, execute atomic database updates using F-expressions, enforce login-gating, and implement session-based authentication (login_view and logout_view).
Note
All code snippets in this part are directly sourced from
examples/polls/src/views.rs.
1. Extracting Form Submissions with Form<T>
Djangors provides a Form<T> extractor powered by serde::Deserialize for extracting URL-encoded form data.
In src/views.rs, implement the vote view:
#![allow(unused)]
fn main() {
use polls::models::{Question, Choice};
use djangors_auth::{Auth, AuthBackend};
use djangors_core::extract::{Form, FromRequest};
use djangors_core::{DjangorsError, PathParams, Request, Response};
use djangors_orm::{q, Model};
pub async fn vote(req: Request, params: PathParams) -> Result<Response, DjangorsError> {
// 1. Enforce authentication (returns 401 Unauthorized if user is not logged in)
let _auth = Auth::<djangors_auth::User>::from_request(&req).await?;
let db = req
.state::<djangors_db::Database>()
.ok_or_else(|| DjangorsError::Internal("database state absent".to_string()))?;
let question_id: i64 = params.get_as("question_id")?;
#[derive(serde::Deserialize)]
struct VoteForm {
choice: i64,
}
let Form(vote) = Form::<VoteForm>::from_request(&req).await?;
let question = Question::objects()
.filter(q!(id = question_id))
.map_err(|e| DjangorsError::Internal(e.to_string()))?
.get(db)
.await
.map_err(|e| match e {
djangors_orm::OrmError::NotFound { .. } => DjangorsError::NotFound,
_ => DjangorsError::Internal(e.to_string()),
})?;
// 2. Perform atomic increment on choice votes using F() expressions
Choice::objects()
.filter(q!(question = question.id, id = vote.choice))
.map_err(|e| DjangorsError::Internal(e.to_string()))?
.update(
db,
djangors_orm::set!(votes = djangors_orm::F("votes") + 1i64),
)
.await
.map_err(|e| DjangorsError::Internal(e.to_string()))?;
// 3. Redirect to the results page
Ok(Response::redirect(&format!("/{}/results/", question_id)))
}
}
2. Implementing Login and Logout Views
Authentication in Djangors leverages ModelBackend for credential verification and djangors_sessions::Session for session persistence.
Add login_view and logout_view to src/views.rs:
#![allow(unused)]
fn main() {
use djangors_core::{DjangorsError, PathParams, Request, Response, StatusCode};
use djangors_core::extract::{Form, FromRequest};
use djangors_auth::AuthBackend;
#[derive(serde::Deserialize)]
pub struct LoginForm {
pub username: String,
pub password: String,
}
pub async fn login_view(req: Request, _params: PathParams) -> Result<Response, DjangorsError> {
let db = req
.state::<djangors_db::Database>()
.ok_or_else(|| DjangorsError::Internal("database state absent".to_string()))?;
let session = req.ext::<djangors_sessions::Session>().ok_or_else(|| {
DjangorsError::Internal("session extension absent (SessionLayer misconfigured)".to_string())
})?;
let Form(form) = Form::<LoginForm>::from_request(&req).await?;
let backend = djangors_auth::ModelBackend;
match backend
.authenticate(db, &form.username, &form.password)
.await
{
Ok(Some(user)) => {
djangors_auth::login(session, &user);
Ok(Response::redirect("/"))
}
Ok(None) => Ok(Response::redirect("/accounts/login/?error=1")),
Err(e) => Err(DjangorsError::Internal(e.to_string())),
}
}
pub async fn logout_view(req: Request, _params: PathParams) -> Result<Response, DjangorsError> {
let session = req.ext::<djangors_sessions::Session>().ok_or_else(|| {
DjangorsError::Internal("session extension absent (SessionLayer misconfigured)".to_string())
})?;
djangors_auth::logout(session).await;
Ok(Response::redirect("/"))
}
}
What’s Real vs. What Django Has That Djangors Doesn’t Yet
Important
Key Architecture Differences from Django:
- Strongly-Typed Form Extraction: Request body parsing uses
Form<T>::from_request(&req).await?whereTis a Rust struct derivingserde::Deserialize.- Explicit Auth Gating: Instead of Python
@login_requireddecorators, handlers extractAuth::<User>directly viaAuth::<djangors_auth::User>::from_request(&req).await?.- Atomic F-Expressions: Database field updates use
set!(field = F("field") + delta)macros for race-condition-free increments.- Redirect Response: Responses issue HTTP redirects using
Response::redirect(url).
Running and Verifying
- Start the dev server:
DATABASE_URL="postgres://postgres:postgres@localhost/djangors_dev" dj run --port 8000
- Submit a login POST request:
curl -X POST http://localhost:8000/accounts/login/ \
-d "username=testuser&password=correct_password" -i
- Post a vote for question 1 (authenticated with CSRF and session cookies):
curl -X POST http://localhost:8000/1/vote/ \
-H "Cookie: csrftoken=YOUR_CSRF_TOKEN; djangors_sessionid=YOUR_SESSION_ID" \
-H "X-CSRFToken: YOUR_CSRF_TOKEN" \
-d "choice=1" -i
Tutorial Part 5: Automated Testing
In Part 5, we write integration tests in Rust to test our endpoints, authentication, CSRF middleware, and database state transitions.
Note
All test code in this part is adapted directly from
examples/polls/tests/voting.rs.
1. Setting Up Integration Tests
Djangors applications leverage standard Rust integration test files in tests/. Create tests/voting.rs:
#![allow(unused)]
fn main() {
use djangors_auth::{hash_password, User};
use djangors_core::middleware::{csrf_layer, security_headers_layer};
use djangors_core::router::RouterService;
use djangors_core::{Djangors, DjangorsSettings, Router};
use djangors_db::Database;
use djangors_orm::Model as _;
use djangors_sessions::{SessionLayer, SignedCookieStore};
use polls::models::{Choice, Question};
use polls::urls;
use std::net::SocketAddr;
use tokio::io::{AsyncReadExt, AsyncWriteExt};
use tokio::net::TcpListener;
use tower::ServiceBuilder;
static DB_MUTEX: std::sync::Mutex<()> = std::sync::Mutex::new(());
async fn send_request(addr: SocketAddr, req: &str) -> String {
let mut stream = tokio::net::TcpStream::connect(addr).await.unwrap();
stream.write_all(req.as_bytes()).await.unwrap();
let mut buf = Vec::new();
stream.read_to_end(&mut buf).await.unwrap();
String::from_utf8_lossy(&buf).into_owned()
}
}
2. Launching Test Server & Executing Test Assertions
The test suite initializes test database tables, seeds model data, binds a Tokio TCP listener on an ephemeral port (127.0.0.1:0), and tests HTTP requests:
#![allow(unused)]
fn main() {
use djangors_auth::{hash_password, User};
use djangors_core::middleware::{csrf_layer, security_headers_layer};
use djangors_core::router::RouterService;
use djangors_core::{Djangors, DjangorsSettings, Router};
use djangors_db::Database;
use djangors_sessions::{SessionLayer, SignedCookieStore};
use polls::models::{Choice, Question};
use polls::urls;
use tokio::net::TcpListener;
use tower::ServiceBuilder;
static DB_MUTEX: std::sync::Mutex<()> = std::sync::Mutex::new(());
async fn send_request(addr: std::net::SocketAddr, req: &str) -> String { String::new() }
#[ignore]
#[tokio::test]
async fn test_polls_voting_integration() {
let _guard = DB_MUTEX.lock().unwrap();
let db_url = "postgres://postgres:postgres@localhost/djangors_test";
let config = djangors_db::config::DatabaseConfig::new(db_url);
let db = Database::connect(&config).await.unwrap();
// Clean up and recreate schema for isolated test execution
let _ = djangors_orm::sqlx::query("DROP TABLE IF EXISTS polls_choice").execute(db.pool()).await;
let _ = djangors_orm::sqlx::query("DROP TABLE IF EXISTS polls_question").execute(db.pool()).await;
let _ = djangors_orm::sqlx::query("DROP TABLE IF EXISTS auth_user").execute(db.pool()).await;
djangors_orm::sqlx::query(
"CREATE TABLE auth_user (
id BIGSERIAL PRIMARY KEY,
username VARCHAR(150) NOT NULL,
email VARCHAR(254) NOT NULL,
password TEXT NOT NULL,
is_active BOOLEAN NOT NULL,
is_staff BOOLEAN NOT NULL,
is_superuser BOOLEAN NOT NULL,
date_joined TIMESTAMPTZ NOT NULL,
last_login TIMESTAMPTZ
)",
).execute(db.pool()).await.unwrap();
djangors_orm::sqlx::query(
"CREATE TABLE polls_question (
id BIGSERIAL PRIMARY KEY,
question_text VARCHAR(200) NOT NULL,
pub_date TIMESTAMPTZ NOT NULL
)",
).execute(db.pool()).await.unwrap();
djangors_orm::sqlx::query(
"CREATE TABLE polls_choice (
id BIGSERIAL PRIMARY KEY,
question BIGINT NOT NULL REFERENCES polls_question(id) ON DELETE CASCADE,
choice_text VARCHAR(200) NOT NULL,
votes INTEGER NOT NULL DEFAULT 0
)",
).execute(db.pool()).await.unwrap();
// Seed test fixtures
let now = chrono::Utc::now();
let user = User {
id: 0,
username: "testuser".to_string(),
email: "test@example.com".to_string(),
password: hash_password("correct_password").unwrap(),
is_active: true,
is_staff: false,
is_superuser: false,
date_joined: now,
last_login: Some(now),
};
user.save(&db).await.unwrap();
let question = Question {
id: 0,
question_text: "What is your favorite color?".to_string(),
pub_date: now,
};
let q_saved = question.save(&db).await.unwrap();
let choice = Choice {
id: 0,
question: djangors_orm::ForeignKey::new(q_saved.id),
choice_text: "Blue".to_string(),
votes: 0,
};
let c_saved = choice.save(&db).await.unwrap();
// Bind listener & spawn server task
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
let addr = listener.local_addr().unwrap();
let settings = DjangorsSettings {
host: "127.0.0.1".to_string(),
port: addr.port(),
debug: true,
..Default::default()
};
let router = urls::urls().with_state(db.clone());
let router_service = RouterService::new(router, settings.debug);
let service = ServiceBuilder::new()
.layer(security_headers_layer())
.layer(SessionLayer::new(SignedCookieStore::new(
b"dev-only-secret-key-at-least-32-bytes-long-for-signing-cookies",
)))
.layer(csrf_layer())
.service(router_service);
let app = Djangors::new(settings, Router::new());
tokio::spawn(async move {
app.serve_service(listener, service).await.unwrap();
});
tokio::time::sleep(std::time::Duration::from_millis(100)).await;
// Test 1: GET / returns 200 OK
let res = send_request(addr, "GET / HTTP/1.1\r\nHost: 127.0.0.1\r\nConnection: close\r\n\r\n").await;
assert!(res.contains("200 OK"));
assert!(res.contains("What is your favorite color?"));
}
}
What’s Real vs. What Django Has That Djangors Doesn’t Yet
Important
Key Architecture Differences from Django:
- Async Test Runner: Tests use `
Tutorial Part 6: Static Files, Favicon, and Middleware Stack
In Part 6, we examine Djangors’ middleware architecture (security_headers_layer, SessionLayer, csrf_layer), static asset management (favicon_routes), and static file collection (collectstatic).
Note
All middleware configuration code matches
examples/polls/src/main.rsandexamples/polls/src/urls.rs.
1. Configuring the Tower Middleware Stack
In Djangors, middleware is built using standard Rust Tower layers in src/main.rs:
fn main() {
let settings = djangors_core::DjangorsSettings::default();
let router_service = djangors_core::router::RouterService::new(djangors_core::Router::new(), settings.debug);
let secret_key = if settings.secret_key.is_empty() {
"dev-only-secret-key-at-least-32-bytes-long-for-signing-cookies".to_string()
} else {
settings.secret_key.clone()
};
let service = tower::ServiceBuilder::new()
.layer(djangors_core::middleware::security_headers_layer())
.layer(djangors_sessions::SessionLayer::new(
djangors_sessions::SignedCookieStore::new(secret_key.as_bytes()),
))
.layer(djangors_core::middleware::csrf_layer())
.service(router_service);
}
Layer Breakdown
security_headers_layer(): Injects HTTP security headers (X-Content-Type-Options: nosniff,X-Frame-Options: DENY,Referrer-Policy, etc.).SessionLayer: Manages signed, encrypted cookie sessions (SignedCookieStore).csrf_layer(): Enforces Cross-Site Request Forgery (CSRF) protection on mutating requests (POST,PUT,DELETE), requiringcsrftokencookies andX-CSRFTokenheaders.
2. Favicon & Static File Serving
In src/urls.rs, djangors_admin::favicon_routes wraps the main application router to automatically handle favicon.ico requests:
#![allow(unused)]
fn main() {
mod views {
use djangors_core::{DjangorsError, PathParams, Request, Response, StatusCode};
pub async fn index(_: Request, _: PathParams) -> Result<Response, DjangorsError> { Ok(Response::html(StatusCode::OK, "")) }
pub async fn detail(_: Request, _: PathParams) -> Result<Response, DjangorsError> { Ok(Response::html(StatusCode::OK, "")) }
pub async fn results(_: Request, _: PathParams) -> Result<Response, DjangorsError> { Ok(Response::html(StatusCode::OK, "")) }
pub async fn vote(_: Request, _: PathParams) -> Result<Response, DjangorsError> { Ok(Response::html(StatusCode::OK, "")) }
pub async fn login_view(_: Request, _: PathParams) -> Result<Response, DjangorsError> { Ok(Response::html(StatusCode::OK, "")) }
pub async fn logout_view(_: Request, _: PathParams) -> Result<Response, DjangorsError> { Ok(Response::html(StatusCode::OK, "")) }
}
mod admin {
use djangors_admin::AdminSite;
pub fn admin_site() -> AdminSite { AdminSite::new() }
}
use djangors_core::Router;
pub fn urls() -> Router {
djangors_admin::favicon_routes(
Router::new()
.get("/", views::index)
.get("/{question_id:i64}/", views::detail)
.get("/{question_id:i64}/results/", views::results)
.post("/{question_id:i64}/vote/", views::vote)
.post("/accounts/login/", views::login_view)
.post("/accounts/logout/", views::logout_view)
.mount("/admin", self::admin::admin_site().urls()),
)
}
}
3. Collecting Static Assets for Production
Djangors provides dj collectstatic to discover static assets across source directories, generate cache-busting content hashes, and output them along with a manifest.json:
# Collect static files into the deployment output directory
dj collectstatic --source static --output staticfiles
What’s Real vs. What Django Has That Djangors Doesn’t Yet
Important
Key Architecture Differences from Django:
- Tower Middleware: Instead of string lists in
MIDDLEWARE = [...], middleware is constructed at compile-time using Rust’stower::ServiceBuilder.- Session Store:
SignedCookieStorestores cryptographically signed session state directly in browser cookies without requiring a database session table by default.- Static Manifests:
dj collectstaticproduces a production manifest JSON file containing hashed asset paths for high-performance static file serving.
Running and Verifying
- Verify static file collection:
mkdir -p static
echo "body { background: #f0f0f0; }" > static/style.css
dj collectstatic --source static --output staticfiles
- Inspect the generated manifest in
staticfiles/manifest.json.
Tutorial Part 7: The Admin Site and Customization
In Part 7, we set up the Djangors Admin site, register our application models (Question, Choice, Group, Permission), configure admin search and filtering using ModelAdminConfig, mount admin routes, create a superuser account, and grant permissions.
Note
All admin code in this part comes directly from
examples/polls/src/admin.rs.
1. Setting Up src/admin.rs
In Djangors, the admin site is configured in src/admin.rs using AdminSite.
Create src/admin.rs and register the models:
#![allow(unused)]
fn main() {
use polls::models::{Question, Choice};
use djangors_admin::{AdminSite, ModelAdminConfig};
pub fn admin_site() -> AdminSite {
let site = AdminSite::new();
// Register application models with default configuration
site.register::<Question>();
site.register::<Choice>();
// Register auth models
site.register::<djangors_auth::Permission>();
// Register Group with custom ModelAdminConfig (enabling search_fields)
site.register_with::<djangors_auth::Group>(ModelAdminConfig {
search_fields: Some(&["name"]),
..Default::default()
});
site.register::<djangors_auth::UserGroup>();
site.register::<djangors_auth::GroupPermission>();
site.register::<djangors_auth::UserPermission>();
site
}
}
2. Mounting Admin Routes
Mount the admin site onto your main router in src/urls.rs:
#![allow(unused)]
fn main() {
mod views {
use djangors_core::{DjangorsError, PathParams, Request, Response, StatusCode};
pub async fn index(_: Request, _: PathParams) -> Result<Response, DjangorsError> { Ok(Response::html(StatusCode::OK, "")) }
pub async fn detail(_: Request, _: PathParams) -> Result<Response, DjangorsError> { Ok(Response::html(StatusCode::OK, "")) }
pub async fn results(_: Request, _: PathParams) -> Result<Response, DjangorsError> { Ok(Response::html(StatusCode::OK, "")) }
pub async fn vote(_: Request, _: PathParams) -> Result<Response, DjangorsError> { Ok(Response::html(StatusCode::OK, "")) }
pub async fn login_view(_: Request, _: PathParams) -> Result<Response, DjangorsError> { Ok(Response::html(StatusCode::OK, "")) }
pub async fn logout_view(_: Request, _: PathParams) -> Result<Response, DjangorsError> { Ok(Response::html(StatusCode::OK, "")) }
}
mod admin {
use djangors_admin::AdminSite;
pub fn admin_site() -> AdminSite { AdminSite::new() }
}
use djangors_core::Router;
pub fn urls() -> Router {
djangors_admin::favicon_routes(
Router::new()
.get("/", views::index)
.get("/{question_id:i64}/", views::detail)
.get("/{question_id:i64}/results/", views::results)
.post("/{question_id:i64}/vote/", views::vote)
.post("/accounts/login/", views::login_view)
.post("/accounts/logout/", views::logout_view)
.mount("/admin", self::admin::admin_site().urls()),
)
}
}
3. Creating Superusers & Permissions
Use djangors-cli subcommands to seed system permissions and create an administrator account:
# Create standard view/add/change/delete permissions for registered models
dj createpermissions
# Create an administrative user with full superuser privileges
dj createsuperuser --username admin --email admin@example.com
4. Customizing ModelAdmin with ModelAdminConfig
The ModelAdminConfig struct allows customizing changelists and search functionality:
search_fields: Specifies text fields for?q=ILIKE queries (e.g.Some(&["name"])).list_display: Controls visible columns on model changelists.list_filter: Filters changelist rows by boolean or specified fields.date_hierarchy: Groups records by date/time hierarchies.
What’s Real vs. What Django Has That Djangors Doesn’t Yet
Important
Key Architecture Differences from Django:
- Code-Driven Admin: Admin registration uses Rust functions and method calls (
site.register::<Question>()) rather than@admin.registerPython decorators.- Type-Safe Model Admin Configuration: Customizations are passed via
ModelAdminConfigstructs toregister_with::<T>().- Staff Authentication Gating: Access to
/admin/endpoints requires an authenticated user withis_staff = trueoris_superuser = true. Unauthenticated requests receive HTTP 401 Unauthorized.
Running and Accessing Admin
- Start the server:
DATABASE_URL="postgres://postgres:postgres@localhost/djangors_dev" dj run --port 8000
- Open
http://localhost:8000/admin/in your browser. Log in using your superuser credentials to manageQuestionandChoicerecords.
Tutorial Part 8: Packaging and Pre-flight Deployment Checks
In Part 8, we review modular application architecture (src/lib.rs), execute pre-flight deployment security audits using dj check --deploy, compile release binaries, and summarize the complete Djangors application stack.
Note
All project layout examples match the structure in
examples/polls.
1. Modular Application Architecture
A well-structured Djangors project splits application logic into a library crate (src/lib.rs) and a server binary entrypoint (src/main.rs).
src/lib.rs
#![allow(unused)]
fn main() {
pub mod admin;
pub mod models;
pub mod urls;
pub mod views;
}
This structure makes all models, views, and routing logic reusable for unit tests, background worker binaries, or custom CLI tools.
2. Pre-flight Deployment Checks
Before deploying to production, run dj check --deploy to audit your security configuration and environment settings:
dj check --deploy
dj check --deploy verifies key production safety requirements:
debug: Ensures debug mode is set tofalse.secret_key: Verifies that a cryptographically secure, non-default secret key of at least 32 bytes is configured.allowed_hosts: Checks that allowed hosts are configured explicitly rather than defaulting to wildcard*.- Security Headers: Ensures security headers and CSRF protections are active in the middleware stack.
3. Building Production Release Binaries
To compile an optimized production binary with full link-time optimization (LTO):
cargo build --release --bin polls
The resulting binary will be located at target/release/polls. Run it in production by providing environment variables:
DATABASE_URL="postgres://prod_user:prod_pass@db.example.com/polls_prod" \
SECRET_KEY="a-secure-production-secret-key-that-is-at-least-32-bytes-long" \
PORT=8000 \
./target/release/polls
4. Complete App Summary
Congratulations! You have completed the Djangors Polls Tutorial. You have built a production-ready Web application featuring:
- Async HTTP Handling: Tokio-powered async handlers with typed request extractors.
- ORM Models: Strong-typed models with primary keys, foreign keys, filtering (
q!), and atomic F-expressions (set!). - Session Authentication: Password hashing (
argon2), login/logout session handling, and staff-gated endpoints (Auth<User>). - Automated Testing: Real-socket integration tests (
#[tokio::test]). - Security & Middleware: CSRF protection, signed cookie sessions, and security headers via Tower middleware.
- Admin Dashboard: Full CRUD admin interface with
AdminSiteandModelAdminConfig.
What’s Real vs. What Django Has That Djangors Doesn’t Yet
Important
Key Architecture Differences from Django:
- Binary Output: Djangors compiles down to a single native binary with zero runtime interpreter dependencies.
- Pre-Flight Inspection: Deployment audits are performed using
dj check --deployprior to compilation.- Cargo Workspace Integration: Djangors apps natively integrate into standard Rust Cargo workspaces.
Final Verification Command
Run the test suite across your completed project:
cargo test
Djangors for Django Developers
This guide provides a direct side-by-side translation reference for experienced Django developers learning Djangors. While Djangors preserves Django’s familiar mental model (models, views, routers, settings, admin, authentication, migrations), Rust’s type system and asynchronous runtime introduce distinct structural and safety guarantees.
Use this guide for vocabulary and API translation. For production file boundaries, read the Application Architecture guide. For Actix Web or Axum users, see Moving from Actix Web or Axum.
1. Models and Schema Definition
In Django, models inherit from models.Model and use Python field descriptors. In Djangors, models are plain Rust structs deriving #[derive(Model)] with #[djangors(...)] attributes.
Django (models.py)
from django.db import models
class Book(models.Model):
title = models.CharField(max_length=200)
author = models.ForeignKey('Author', on_delete=models.CASCADE)
is_published = models.BooleanField(default=True)
created_at = models.DateTimeField(auto_now_add=True)
class Meta:
db_table = "library_book"
ordering = ["-created_at"]
Djangors
#![allow(unused)]
fn main() {
use djangors_orm::Model;
#[derive(djangors_macros::Model, Debug, Clone)]
#[djangors(app = "library", table_name = "library_author")]
pub struct Author { #[djangors(primary_key, auto)] pub id: i64, #[djangors(max_length = 200)] pub name: String }
use djangors_macros::Model;
use djangors_orm::ForeignKey;
use chrono::{DateTime, Utc};
#[derive(Model, Debug, Clone)]
#[djangors(app = "library", table_name = "library_book", ordering = "-created_at")]
pub struct Book {
#[djangors(primary_key, auto)]
pub id: i64,
#[djangors(max_length = 200)]
pub title: String,
#[djangors(foreign_key(on_delete = "cascade", related_name = "books"))]
pub author: ForeignKey<Author>,
#[djangors(default = true)]
pub is_published: bool,
pub created_at: DateTime<Utc>,
}
}
Key Differences & Guarantees
- Explicit Primary Key Required: In Djangors, primary key fields (such as
id: i64with#[djangors(primary_key, auto)]) must be explicitly declared on every model. - Compile-Time Metadata:
#[derive(Model)]generates model metadata (Model::meta()) at compile time. Field types and relation targets are checked byrustc.
2. URL Routing
Django uses list-based urlpatterns with path() or re_path(). Djangors uses builder-pattern Router instances with method-chained route registrations (.get(), .post(), .route()).
Django (urls.py)
from django.urls import path
from . import views
urlpatterns = [
path('articles/', views.list_articles, name='article-list'),
path('articles/<int:id>/', views.article_detail, name='article-detail'),
]
Djangors
fn main() {
use djangors_core::{Request, PathParams, Response, DjangorsError, StatusCode};
async fn list_articles(_: Request, _: PathParams) -> Result<Response, DjangorsError> { Ok(Response::text(StatusCode::OK, "")) }
async fn article_detail(_: Request, _: PathParams) -> Result<Response, DjangorsError> { Ok(Response::text(StatusCode::OK, "")) }
use djangors_core::Router;
let router = Router::new()
.get("/articles/", list_articles)
.get("/articles/{id}", article_detail);
}
Key Differences & Guarantees
- Path Parameters: Djangors routes use
{id}syntax for path parameters rather than<int:id>. - Method Scope: Routes are bound directly to HTTP verbs (
.get(),.post(),.put(),.delete()).
3. View Handlers
Django views receive an HttpRequest object and return an HttpResponse. Djangors view handlers are async fn functions receiving Request and PathParams, returning Result<Response, DjangorsError>.
Django (views.py)
from django.http import HttpResponse, Http404
from django.shortcuts import render
from .models import Article
def article_detail(request, id):
try:
article = Article.objects.get(pk=id)
except Article.DoesNotExist:
raise Http404("Article not found")
return render(request, "articles/detail.html", {"article": article})
Djangors
#![allow(unused)]
fn main() {
#[derive(djangors_macros::Model, Debug, Clone, serde::Serialize)]
#[djangors(app = "library", table_name = "library_article")]
pub struct Article { #[djangors(primary_key, auto)] pub id: i64, pub title: String }
use djangors_orm::Model;
use djangors_core::{Request, Response, PathParams, DjangorsError, StatusCode};
// A process-wide template engine, mirroring djangors-admin's own `ADMIN_TEMPLATES` pattern.
static TEMPLATES: std::sync::LazyLock<djangors_template::TemplateEngine> =
std::sync::LazyLock::new(|| {
djangors_template::TemplateEngine::new(vec!["templates".into()]).unwrap()
});
pub async fn article_detail(req: Request, params: PathParams) -> Result<Response, DjangorsError> {
let article_id: i64 = params.get_as("id")?;
let db = req.state::<djangors_db::Database>()
.ok_or_else(|| DjangorsError::Internal("Database state missing".into()))?;
let article = Article::objects()
.filter(djangors_orm::q!(id = article_id))
.map_err(|e| DjangorsError::Internal(e.to_string()))?
.get(db)
.await
.map_err(|_| DjangorsError::NotFound)?;
djangors_template::render(&TEMPLATES, "articles/detail.html", minijinja::context! { article })
}
}
Key Differences & Guarantees
- Async by Default: All Djangors view handlers are non-blocking
async fnoperations executing on the Tokio runtime. - Explicit Database State: Request state (like
Databaseconnection pools) is explicitly retrieved viareq.state::<Database>().
Generic Class-Based Views
For the common list/detail/create/update/delete shape, djangors-views mirrors Django’s
django.views.generic. The hand-written article_detail above could instead be:
#![allow(unused)]
fn main() {
use djangors_views::{DetailView, ViewSetConfig};
let config = ViewSetConfig { engine: &TEMPLATES, template_name: "articles/detail.html", success_url: "/articles/" };
DetailView::<Article>::detail(req, params, &config).await
}
ListView/CreateView/UpdateView/DeleteView follow the same shape, generic over any
#[derive(Model)] type (CreateView/UpdateView additionally need the model’s own generated
ModelForm methods, see §8).
4. Command-Line Interface (manage.py vs dj)
Django uses python manage.py <command>. Djangors provides the dj command-line utility.
| Django Command | Djangors Command | Description |
|---|---|---|
python manage.py startproject | dj new | Scaffolds a new project |
python manage.py startapp | dj new-app | Scaffolds a new app/module inside a project |
python manage.py runserver | dj run | Starts dev server with live-reloading file watch loop |
python manage.py migrate | dj migrate | Applies pending database migrations |
python manage.py makemigrations | dj makemigrations | Introspects the project binary; v1 detects new models and new fields |
python manage.py sqlmigrate | dj sqlmigrate <app> <migration> | Renders a migration’s SQL without applying it |
python manage.py showmigrations | dj showmigrations | Lists migrations and their applied state |
python manage.py createsuperuser | dj createsuperuser | Prompts for superuser credentials and creates User |
python manage.py collectstatic | dj collectstatic | Bundles static assets into a production output dir |
python manage.py runworker | dj runworker | Starts the background-task worker loop |
python manage.py test | dj test | Runs workspace unit and integration test suite (cargo test) |
python manage.py shell | dj shell | Launches interactive Rust REPL via evcxr |
python manage.py dbshell | dj dbshell | Connects directly to configured database CLI |
python manage.py check | dj check [--deploy] | Runs static checks (with --deploy, production-safety checks) |
python manage.py createpermissions | dj createpermissions | Generates the standard view/add/change/delete permissions per model |
dj makemigrations scope
dj runs the project’s own binary in a hidden model-introspection mode, so registrations from application crates are visible. It stores the last model state in migrations/.schema_snapshot.json and generates numbered SQL migrations. v1 covers new models and new fields; field-type changes, removals, renames, indexes, and relation alterations are deferred.
Database migrations in Djangors are authored as raw SQL files or generated via programmatic schema utilities.
ℹ️ dj shell (evcxr Rust REPL)
dj shell launches an interactive Rust REPL via evcxr (installed via cargo install evcxr_repl).
Because dj is a separate binary process from your application, target project models cannot be auto-imported across process boundaries automatically. To import your project’s models into the REPL session, use :dep with a path dependency:
#![allow(unused)]
fn main() {
:dep my_app = { path = "." }
use my_app::models::*;
}
5. Admin Site Registration
Django registers models with admin.site.register(Model, ModelAdmin). Djangors uses AdminSite::register_with::<M>(ModelAdminConfig) for type-safe static configuration.
Django (admin.py)
from django.contrib import admin
from .models import Article
@admin.register(Article)
class ArticleAdmin(admin.ModelAdmin):
list_display = ["title", "author", "is_published"]
search_fields = ["title"]
list_filter = ["is_published"]
Djangors
#[derive(djangors_macros::Model, Debug, Clone)]
#[djangors(app = "library", table_name = "library_article")]
pub struct Article { #[djangors(primary_key, auto)] pub id: i64, pub title: String, pub author: String, pub is_published: bool }
fn main() {
use djangors_admin::{AdminSite, ModelAdminConfig};
let site = AdminSite::new().with_site_header("Admin Console");
site.register_with::<Article>(ModelAdminConfig {
list_display: Some(&["title", "author", "is_published"]),
search_fields: Some(&["title"]),
list_filter: Some(&["is_published"]),
..Default::default()
});
}
Key Differences & Guarantees
- Compile-Time Field Validation: Djangors validates that fields listed in
list_display,search_fields, andlist_filteractually exist on the target model struct at registration. - Search Type Safety:
search_fieldsenforces text-compatible fields, andlist_filterenforces boolean fields.
6. Settings and Configuration
Django uses a Python file (settings.py). Djangors uses DjangorsSettings, which loads from djangors.toml and environment variable overrides (DJANGORS_*).
Django (settings.py)
DEBUG = True
ALLOWED_HOSTS = ["localhost", "127.0.0.1"]
SECRET_KEY = "django-insecure-secret"
Djangors (djangors.toml & DjangorsSettings)
# djangors.toml
debug = true
allowed_hosts = ["localhost", "127.0.0.1"]
secret_key = "djangors-insecure-secret"
host = "127.0.0.1"
port = 8000
Environment variable overrides:
export DJANGORS_DEBUG=true
export DJANGORS_ALLOWED_HOSTS="localhost,127.0.0.1"
export DJANGORS_SECRET_KEY="supersecret"
export DJANGORS_PORT=8000
Loading in Rust:
fn main() -> Result<(), Box<dyn std::error::Error>> {
use djangors_core::DjangorsSettings;
let (settings, warnings) = DjangorsSettings::load()?;
Ok(())
}
7. CSRF Protection
Django uses {% csrf_token %} tag and CsrfViewMiddleware to check both request headers and POST form bodies. Djangors provides csrf_layer().
Header-First with Form-Body Fallback
- Cookie: CSRF token is stored in the
csrftokencookie. - Header Check First:
CsrfLayerchecks for the token in theX-CSRFTokenHTTP header on unsafe requests (POST, PUT, PATCH, DELETE). - Form Body Fallback: If the HTTP header is absent, Djangors checks
application/x-www-form-urlencodedPOST request bodies for acsrfmiddlewaretokenfield.
In HTML forms:
<form method="post">
<input type="hidden" name="csrfmiddlewaretoken" value="{{ csrf_token }}">
<button type="submit">Submit</button>
</form>
Adding the CSRF layer works like every Djangors middleware: it’s composed via
tower::ServiceBuilder around a RouterService, not a method on Router itself:
fn main() {
let router = djangors_core::Router::new();
let settings = djangors_core::DjangorsSettings::default();
use djangors_core::middleware::csrf_layer;
use djangors_core::router::RouterService;
use tower::ServiceBuilder;
let router_service = RouterService::new(router, settings.debug);
let service = ServiceBuilder::new()
.layer(csrf_layer())
.service(router_service);
}
8. Form Handling & Extraction
Django provides forms.Form and forms.ModelForm for rendering and parsing HTML forms. Djangors uses the Form<T> extractor to deserialize form bodies directly into Rust structs.
Django (forms.py / views.py)
class ContactForm(forms.Form):
name = forms.CharField(max_length=100)
email = forms.EmailField()
def contact_view(request):
form = ContactForm(request.POST)
if form.is_valid():
name = form.cleaned_data['name']
Djangors
#![allow(unused)]
fn main() {
use serde::Deserialize;
use djangors_core::extract::{Form, FromRequest};
use djangors_core::{Request, Response, PathParams, DjangorsError, StatusCode};
#[derive(Debug, Deserialize)]
pub struct ContactForm {
pub name: String,
pub email: String,
}
pub async fn contact_view(req: Request, _params: PathParams) -> Result<Response, DjangorsError> {
let Form(form) = Form::<ContactForm>::from_request(&req).await?;
println!("Submitted name: {}", form.name);
Ok(Response::text(StatusCode::OK, "OK"))
}
}
ModelForm Parity
#[derive(Model)] also generates a ModelForm equivalent directly on the model itself, so no
second, hand-written form struct is required:
#![allow(unused)]
fn main() {
use djangors_macros::Model;
#[derive(Model, Debug, Clone)]
#[djangors(app = "myapp", table_name = "contact")]
pub struct Contact {
#[djangors(primary_key, auto)]
pub id: i64,
#[djangors(max_length = 100)]
pub name: String,
pub email: String,
}
async fn handle_submission(data: &std::collections::HashMap<String, String>) {
match Contact::validate_form(data) {
Ok(cleaned) => {
let contact = Contact::from_cleaned_form(cleaned);
// contact.save(&db).await
}
Err(errors) => {
// errors.fields is a per-field HashMap<String, FieldError>, same shape
// `djangors-forms`' own Form<T> extractor already uses
}
}
}
}
Auto/primary-key fields and FileField-kind fields are excluded from the generated form (the
same way save()‘s own INSERT already skips auto fields). apply_cleaned_form applies a
validated submission onto an existing instance for the update path, leaving the primary key
untouched. HTML widget rendering is not part of this: pair it with djangors-views’ generic
CreateView/UpdateView (see below) or build your own template.
9. REST Framework APIs
Django relies on Django REST Framework (DRF) ModelSerializer and ViewSet. Djangors provides djangors-rest with serialize, deserialize, and ViewSet<M>.
Django REST Framework
from rest_framework import serializers, viewsets
from .models import Article
class ArticleSerializer(serializers.ModelSerializer):
class Meta:
model = Article
fields = '__all__'
class ArticleViewSet(viewsets.ModelViewSet):
queryset = Article.objects.all()
serializer_class = ArticleSerializer
Djangors (djangors-rest)
#[derive(djangors_macros::Model, Debug, Clone, serde::Serialize)]
#[djangors(app = "library", table_name = "library_article")]
pub struct Article { #[djangors(primary_key, auto)] pub id: i64 }
fn main() {
let article_instance = Article { id: 1 };
use djangors_rest::{serialize, viewset_routes};
use djangors_core::Router;
// Low-level model serialization
let json_val = serialize::<Article>(&article_instance);
// High-level ViewSet registration. `ViewSet<M>` has no instance to construct;
// its CRUD handlers are mounted directly as a free function, IsAuthenticated by default.
let router = viewset_routes::<Article>(Router::new(), "/articles");
}
10. Conceptual Architecture Comparison
| Architectural Aspect | Django (Python) | Djangors (Rust) |
|---|---|---|
| Execution Model | Dynamic, interpreted (CPython WSGI/ASGI) | Compiled native binary (Tokio async loop) |
| Type Checking | Runtime duck typing, optional type hints | Strict compile-time static type system |
| Concurrency | Thread pool (WSGI) or async event loop (ASGI) | Native async task channels and multithreaded Tokio runtime |
| Deployment Model | Python interpreter + virtualenv + Gunicorn/Uvicorn + Nginx static serving | Compiled application binary plus configured static/file storage and dependencies |
| Development Reloading | Instant Python module re-import in dev server | File watcher trigger followed by cargo incremental binary re-compilation |
| Memory Management | Automatic garbage collection (ref counting + GC) | Ownership, borrowing, and RAII without garbage collection overhead |
11. The Django app pattern in Djangors
Django makes it easy to put a model, form, serializer, view, and URL in one app. Djangors can do the same, but a real feature should make its boundaries explicit as it grows:
src/apps/library/
├── mod.rs # public module surface and app wiring
├── models.rs # #[derive(Model)] persistence records
├── contracts.rs # request/path/query/response DTOs
├── errors.rs # domain failures and HTTP translation
├── repositories.rs # QuerySet and database access only
├── services.rs # business rules, workflows, transactions
├── permissions.rs # role and object/tenant access rules
├── serializers.rs # REST representation and field visibility
├── views.rs # Request -> Response adapters
├── urls.rs # app-local Router
└── admin.rs # admin registration, when needed
tests/apps/library/
├── models.rs
├── services.rs
├── permissions.rs
└── api.rs
The dependency direction is:
urls -> views -> permissions/contracts -> services -> repositories -> ORM/DB
| Django convention | Djangors production boundary |
|---|---|
models.py | models.rs |
forms.py / serializer input | contracts.rs, Form<T>, or REST deserializer |
QuerySet calls in a view | repositories.rs |
| model/service/business rules | services.rs |
Http404, PermissionDenied, API exception | errors.rs -> DjangorsError |
views.py | views.rs |
urls.py | urls.rs |
admin.py | admin.rs |
| Celery task | #[task] handler |
TestCase / API client | tests/apps/<app>/ and djangors-test |
The important translation is not “Django view becomes a Rust function.” It is “Django’s implicit application conventions become explicit Rust modules.” For a tiny endpoint, combining files is fine. Split the layers before a view owns SQL, authorization, validation, error formatting, and task enqueueing together.
12. ORM translation: QuerySets, managers, and transactions
The Djangors ORM resembles Django’s QuerySet API, but its operations are typed and asynchronous.
Django
books = (
Book.objects
.filter(is_published=True)
.order_by("-created_at")[:10]
)
book = Book.objects.get(pk=book_id)
Djangors
#![allow(unused)]
fn main() {
use djangors_orm::{q, Model};
let books = Book::objects()
.filter(q!(is_published = true))?
.order_by("-created_at")?
.limit(10)
.all(db)
.await?;
let book = Book::objects()
.filter(q!(id = book_id))?
.get(db)
.await?;
}
The usual Rust differences are:
- import the
Modeltrait before callingBook::objects(); - handle query construction and database errors with
Result; - await database operations explicitly;
- use
q!()for field expressions instead of assembling SQL strings; - map
OrmError::NotFoundto a domain error at the service boundary; - keep QuerySet construction in a repository once a feature has business logic.
For multi-step writes, use the database transaction APIs. A transaction is a database consistency boundary; it is not a replacement for a service. The service decides the workflow and the repository performs its queries.
13. Contracts, serializers, and public API shape
In Django, a ModelSerializer or form often becomes the public contract. In
Rust, make the contract explicit rather than deserializing into a model that
contains server-owned fields:
#![allow(unused)]
fn main() {
#[derive(Debug, serde::Deserialize)]
pub struct CreateBook {
pub title: String,
}
#[derive(Debug, serde::Serialize)]
pub struct BookResponse {
pub id: i64,
pub title: String,
pub published: bool,
}
}
For CRUD endpoints, ModelSerializer and FieldSet provide DRF-like field
control:
#![allow(unused)]
fn main() {
use djangors_rest::{FieldSet, ModelSerializer};
let serializer = ModelSerializer::<Book>::new(
FieldSet::all()
.read_only(&["id", "created_at"])
.excluding(&["internal_notes"]),
);
}
Use read_only for primary keys, ownership, timestamps, computed fields, and
workflow status. Use write_only for passwords and tokens. Use a custom
Serializer<M> when the API shape differs from the database record. A
serializer validates representation-level input; a service still owns
cross-record validation and state transitions.
14. Errors and error rendering
Django’s Http404, PermissionDenied, DRF exception handler, and custom
exception classes become an app error enum plus a conversion to
DjangorsError:
#![allow(unused)]
fn main() {
#[derive(Debug)]
pub enum BookError {
Unauthenticated,
Forbidden,
NotFound,
InvalidTitle,
Persistence(String),
}
impl From<BookError> for djangors_core::DjangorsError {
fn from(error: BookError) -> Self {
use hyper::StatusCode;
match error {
BookError::Unauthenticated => djangors_core::DjangorsError::api(
StatusCode::UNAUTHORIZED,
"not_authenticated",
"Authentication credentials were not provided.",
),
BookError::Forbidden => djangors_core::DjangorsError::api(
StatusCode::FORBIDDEN,
"permission_denied",
"You do not have permission to perform this action.",
),
BookError::NotFound => djangors_core::DjangorsError::api(
StatusCode::NOT_FOUND,
"book_not_found",
"Book was not found.",
),
BookError::InvalidTitle => djangors_core::DjangorsError::api(
StatusCode::BAD_REQUEST,
"invalid_title",
"Title is required.",
),
BookError::Persistence(message) => {
tracing::error!(error = %message, "book persistence failure");
djangors_core::DjangorsError::Internal("Book operation failed.".into())
}
}
}
}
}
Views return Result<Response, DjangorsError> and let the router render the
error. API errors default to JSON; browser errors can render HTML in debug or
production mode according to the request. Register a custom
djangors_core::error::ErrorRenderer in router state when every endpoint must
use one envelope:
{
"error": {
"code": "book_not_found",
"message": "Book was not found.",
"details": null
}
}
Never expose SQL, connection strings, stack traces, provider responses, or
Rust Debug variant names in production responses. Log diagnostic details
with tracing; return a safe client message. Attach field-level details for
validation errors.
15. Authentication, permissions, and tenant scope
request.user in Django becomes an authenticated user resolved from a Djangors
session, token, or JWT. djangors_rest::current_user(&req) supports the
configured mechanisms; IsAuthenticated is the standard route guard.
Keep these three questions separate:
authentication: who is the caller?
permission: may the caller perform this action?
scope: which rows belong to the caller/tenant?
Use IsStaff, IsSuperuser, and composed permissions for common policies. For
tenant-owned models, implement Scoped and mount with
scoped_viewset_routes or scoped_viewset_routes_with_config. Scoping limits
rows; it does not automatically decide which role may edit them.
For a handwritten view, use this order:
- Resolve the authenticated principal.
- Resolve the tenant from trusted session/membership data.
- Check the role or object permission.
- Query by tenant and record ID together.
- Validate every foreign key against that same tenant before writing.
Do not trust a tenant ID from a request body, and do not load an unscoped row before checking its tenant.
16. Background work: Celery and management commands
Django’s Celery task becomes a serializable Djangors task:
#![allow(unused)]
fn main() {
#[derive(serde::Serialize, serde::Deserialize)]
pub struct SendReportArgs {
pub report_id: i64,
}
#[djangors_tasks::task]
pub async fn send_report(
args: SendReportArgs,
) -> Result<(), djangors_tasks::TaskError> {
let db = crate::runtime::db();
// Reload by ID, perform idempotent work, and persist the result.
let _ = (db, args);
Ok(())
}
}
Enqueue after the database state needed by the task is committed:
#![allow(unused)]
fn main() {
djangors_tasks::enqueue(
db,
"send_report",
&SendReportArgs { report_id },
).await?;
}
Task handlers do not receive Request, sessions, or a database reference.
Pass small IDs and immutable values; reload current state in the handler. The
database-backed worker records pending/running/completed/failed states, retries
failures up to max_attempts, and isolates panics. Make tasks idempotent and
treat an already-completed job as success.
Use dj runworker for the worker process and register_recurring for
five-field cron jobs. Create task tables and register handlers before the
worker starts. A short in-process tokio::spawn is fine for work that may be
lost on process restart; it is not a durable queue.
17. Testing translation
| Django test | Djangors equivalent |
|---|---|
TestCase | unit/service tests plus TestDatabase |
Client | djangors_test::TestClient or a real Hyper socket |
RequestFactory | construct Request directly |
assertEqual(response.status_code, 404) | response.assert_status(StatusCode::NOT_FOUND) |
override_settings | construct typed settings/state for the test |
TransactionTestCase | explicit database/transaction integration test |
| Celery eager task test | call the service directly, then test queue execution separately |
#![allow(unused)]
fn main() {
#[tokio::test]
async fn missing_book_is_not_found() {
let response = djangors_test::TestClient::new(crate::urls::urls())
.get("/api/v1/books/999")
.send()
.await;
response.assert_status(hyper::StatusCode::NOT_FOUND);
}
}
Organize production tests outside src/:
tests/apps/library/
├── models.rs # metadata and constraints
├── services.rs # business rules without HTTP
├── permissions.rs # roles and tenant isolation
├── api.rs # real status/body/header contracts
└── tasks.rs # serialization, retries, idempotency
Run SQLite for fast feedback, but also test PostgreSQL-specific behavior such as locking, extensions, and SQL expressions against PostgreSQL.
18. Startup, settings, and middleware
settings.py is split into framework settings, typed application settings,
and explicit startup wiring:
#![allow(unused)]
fn main() {
let (framework_settings, warnings) = djangors_core::DjangorsSettings::load()?;
for warning in warnings {
eprintln!("settings warning: {warning}");
}
let db = djangors_db::Database::connect(
&djangors_db::DatabaseConfig::new(database_url),
).await?;
let router = crate::urls::urls()
.with_state(db.clone())
.with_state(app_settings);
}
A production startup sequence is:
- Load framework and application settings.
- Initialize development or production logging.
- Connect the database and run migrations.
- Create task tables and install runtime handles.
- Build Redis, cache, mail, and provider clients.
- Register recurring jobs and start the worker.
- Attach typed state and an optional
ErrorRenderer. - Wrap
RouterServicewith sessions, tenant resolution, security headers, CSRF, and logging layers. - Serve with graceful shutdown.
Unlike Django middleware strings in MIDDLEWARE, Djangors uses Tower’s
ServiceBuilder:
#![allow(unused)]
fn main() {
let router_service = djangors_core::router::RouterService::new(router, debug);
let service = tower::ServiceBuilder::new()
.layer(djangors_sessions::SessionLayer::new(session_store))
.layer(djangors_core::middleware::security_headers_layer())
.layer(djangors_core::middleware::csrf_layer())
.service(router_service);
}
Use /healthz as a process liveness probe and /readyz as a dependency
readiness probe. A readiness failure should return 503 and a safe generic
message while logging the real database/cache error.
19. Migrations and schema ownership
makemigrations can inspect registered model metadata, but production schema
changes still need review. Keep migrations owned by the domain app, make
forward and rollback behavior explicit, and test foreign keys, unique indexes,
check constraints, and tenant constraints.
dj makemigrations
dj sqlmigrate library 0001
dj migrate --plan
dj migrate
dj showmigrations
Use dj migrate --rollback deliberately and never use --fake unless the
database already has exactly the schema represented by the migration. Do not
hide schema changes in a task or an application startup side effect.
20. What does not translate one-for-one
Some Django assumptions need a deliberate design choice:
- There is no automatic
reverse()behavior unless you name routes with.name("route-name")and callRouter::reverse. - Rust has no dynamic model field access equivalent to arbitrary Python attribute lookup; field names, types, and relationships are checked earlier.
- A model is a plain struct, not a place to put request context or global service dependencies.
- Djangors tasks are database-backed and durable; a raw Tokio task is not.
- HTML rendering and API serialization are separate choices; do not return
database rows directly just because they implement
Serialize. - Djangors provides SSE, not a drop-in WebSocket abstraction; confirm the capability before designing a WebSocket-dependent feature.
- SQLite is a useful test backend but does not prove PostgreSQL locking, extensions, or dialect-specific behavior.
The migration is complete when every endpoint has an explicit route, contract, permission rule, tenant rule where applicable, service workflow, repository query, error mapping, response shape, migration, and tests—not merely when the Rust code compiles.
Honest HTTP benchmarks
These are measurements, not performance claims inferred from language reputation. They were run on 2026-07-27 in this development environment: x86_64 Linux, 8 vCPUs, Intel Xeon Processor (Skylake, IBRS, no TSX), PostgreSQL 16.14 on the same machine, Rust 1.96.0, axum 0.8.9, Django 6.0.7, Gunicorn 26.0.0, and oha 1.15.0. The machine is a QEMU VM; results are single-machine development numbers and are not a production or TechEmpower submission.
Targets and fairness
The Djangors target is examples/polls, with a new /hello/ view returning Hello, world! and
its existing / view. The latter executes the real Question::objects() filter/order/limit query
and renders five links. The axum scratch target is benchmarks/, excluded from the main Cargo
workspace; its /hello/ and / handlers return equivalent bodies and execute equivalent SQL.
The Django target is a minimal WSGI application in benchmarks/django_app/, served by four
Gunicorn workers. Its / view executes the same SQL. All targets used the same Postgres database,
which contained five seeded polls_question rows.
The hello path is deliberately routing/response only. The full-stack path includes one database query, connection-pool/driver work, and response construction. Django’s full-stack result is much slower in this setup; that is a measured result, not a reason to claim a general Django ratio. The benchmark does not tune pools or concurrency, and does not compare admin functionality. The admin is the framework value that the lower-level axum comparison does not provide.
Reproduction
python3 -m venv benchmarks/.venv
benchmarks/.venv/bin/pip install Django gunicorn 'psycopg[binary]'
cargo build --release -p polls
cargo build --release --manifest-path benchmarks/Cargo.toml
# create djangors_bench and polls_question, then seed five rows; DATABASE_URL uses that database
DATABASE_URL='postgres://bench:bench@127.0.0.1/djangors_bench' target/release/polls
DATABASE_URL='postgres://bench:bench@127.0.0.1/djangors_bench' benchmarks/target/release/djangors-benchmark-axum
(cd benchmarks/django_app && ../.venv/bin/gunicorn --bind 127.0.0.1:9002 --workers 4 wsgi:application)
/tmp/oha-install/bin/oha -c 32 -z 10s --no-tui http://127.0.0.1:PORT/PATH
The exact load command for every result below was the final command, with PORT/PATH substituted:
/tmp/oha-install/bin/oha -c 32 -z 10s --no-tui URL (32 connections, 10 seconds).
Results
| Path | Target | Requests/sec | p50 | p95 | p99 |
|---|---|---|---|---|---|
| hello | Djangors | 60,890.0165 | 0.4277 ms | 1.1753 ms | 2.1721 ms |
| hello | axum | 78,447.3942 | 0.3339 ms | 0.8985 ms | 1.5812 ms |
| hello | Django/Gunicorn (4 workers) | 830.9342 | 37.9564 ms | 52.0187 ms | 59.9514 ms |
| full-stack | Djangors | 7,289.7347 | 4.0285 ms | 7.4487 ms | 10.2702 ms |
| full-stack | axum | 9,503.0087 | 3.1260 ms | 5.3474 ms | 7.4207 ms |
| full-stack | Django/Gunicorn (4 workers) | 25.9929 | 1.3821 s | 1.4702 s | 1.4922 s |
Raw load-tool output
The following is the terminal output produced by the load runs (the histogram is abbreviated only by omitting repeated tool headings; the percentile lines and request totals are verbatim).
djangors hello: 609353 responses, 60890.0165 req/s; p50 .4277 ms, p95 1.1753 ms, p99 2.1721 ms
axum hello: 784919 responses, 78447.3942 req/s; p50 .3339 ms, p95 .8985 ms, p99 1.5812 ms
django hello: 8283 responses, 830.9342 req/s; p50 37.9564 ms, p95 52.0187 ms, p99 59.9514 ms
djangors full: 72917 responses, 7289.7347 req/s; p50 4.0285 ms, p95 7.4487 ms, p99 10.2702 ms
axum full: 95081 responses, 9503.0087 req/s; p50 3.1260 ms, p95 5.3474 ms, p99 7.4207 ms
django full: 228 responses, 25.9929 req/s; p50 1.3821 s, p95 1.4702 s, p99 1.4922 s
These numbers do not support the hoped-for “within 15–25%” axum comparison on this particular run: Djangors achieved 76.7% of axum’s full-stack throughput (23.3% lower), while its p50 was 28.9% higher. That is close to the stated target only for throughput, and this page reports the latency gap plainly. No Django 10–50x headline is asserted because these are specific local measurements with different server architectures and no production tuning.
Application Architecture
This guide explains how to organize a real Djangors application after you finish the polls tutorial. The framework does not force one architecture, but the following shape is a good default for beginners and scales well as an app grows.
The short version is:
URL -> view -> contract / permission -> service -> repository -> ORM / database
Each layer has one job. Keeping those jobs separate makes code easier to find, test, and change.
Which path should I follow?
- New to web development: follow the 8-part tutorial, then return here and build one small feature using the
booksexamples. - Coming from Django: read the Django comparison, then use this guide for the production app boundaries that Django’s conventions often leave implicit.
- Coming from Actix Web or Axum: read Moving from Actix Web or Axum for handler, state, router, middleware, error, and testing translations.
Do not create every layer on the first five-line endpoint. Begin with a view and route, add a model when data is persistent, and split contracts, repositories, services, permissions, and errors as soon as the feature has more than one workflow or endpoint.
The recommended app layout
For a small project, start with src/models.rs, src/views.rs, and
src/urls.rs. Once a feature has more than one endpoint or meaningful business
rules, give it a domain app:
src/
├── main.rs # process startup and middleware
├── lib.rs # public modules
├── urls.rs # root router; mounts app routers
└── apps/
└── books/
├── mod.rs # app module and public API
├── models.rs # database records
├── contracts.rs # request and response DTOs
├── repositories.rs # database queries
├── services.rs # business rules and workflows
├── views.rs # HTTP handlers
├── urls.rs # app-local routes
├── serializers.rs # API representation, when using REST
├── permissions.rs # access rules, when needed
└── admin.rs # admin registration, when needed
tests/
└── apps/books/
├── models.rs
├── services.rs
├── permissions.rs
└── api.rs
The reference school-management backend uses this same domain-app pattern for each feature. Copy the boundaries, not the domain names.
What each file means
models.rs: what is stored
A model is a typed database record. It describes fields, relations, and schema
metadata. It may contain small, record-specific methods such as
is_archived(), but it should not know about HTTP requests or decide who is
allowed to call an endpoint.
#![allow(unused)]
fn main() {
use djangors_macros::Model;
#[derive(Model, Debug, Clone)]
#[djangors(app = "books", table_name = "books_book")]
pub struct Book {
#[djangors(primary_key, auto)]
pub id: i64,
#[djangors(max_length = 200)]
pub title: String,
#[djangors(default = false)]
pub published: bool,
}
impl Book {
pub fn can_be_checked_out(&self) -> bool {
self.published
}
}
}
Use ForeignKey<T> for native Djangors relationships and keep database
details here. Put input validation that spans multiple records in a service.
contracts.rs: what crosses the boundary
A contract is a request, path/query object, or response shape. It is also called a DTO (data-transfer object). Contracts prevent database columns from accidentally becoming your public API.
#![allow(unused)]
fn main() {
use serde::{Deserialize, Serialize};
#[derive(Debug, Deserialize)]
pub struct CreateBook {
pub title: String,
}
#[derive(Debug, Serialize)]
pub struct BookResponse {
pub id: i64,
pub title: String,
}
}
Use Option<T> for fields in a partial update. A response contract should
contain only data clients are allowed to see. If the API calls an identifier
public_id, do not expose an internal database key just because the model has
one.
repositories.rs: how data is read and written
A repository owns ORM and database access. It returns models or domain-level
data; it never returns Response, reads headers, or checks a user’s role.
#![allow(unused)]
fn main() {
use djangors_db::Database;
use djangors_orm::{q, Model};
use super::models::Book;
pub async fn find_by_id(db: &Database, id: i64)
-> Result<Option<Book>, djangors_orm::OrmError>
{
Book::objects().filter(q!(id = id))?.first(db).await
}
pub async fn published(db: &Database)
-> Result<Vec<Book>, djangors_orm::OrmError>
{
Book::objects().filter(q!(published = true))?.all(db).await
}
}
Keep query construction here so a change from one query to another has one obvious home. Always include tenant or owner filters in repository queries for tenant-scoped data; do not rely on every view remembering them.
services.rs: what the application is allowed to do
A service owns business rules and workflows: validation, state transitions, cross-record checks, transactions, and coordination between repositories. It should be callable from a view, a background task, a management command, or a test.
#![allow(unused)]
fn main() {
use super::{contracts::CreateBook, models::Book};
#[derive(Debug, PartialEq, Eq)]
pub enum CreateBookError {
EmptyTitle,
}
pub fn validate_new_book(input: &CreateBook) -> Result<(), CreateBookError> {
if input.title.trim().is_empty() {
return Err(CreateBookError::EmptyTitle);
}
Ok(())
}
pub fn new_book(input: CreateBook) -> Result<Book, CreateBookError> {
validate_new_book(&input)?;
Ok(Book { id: 0, title: input.title.trim().to_owned(), published: false })
}
}
The example separates validation from persistence so it is easy to test. A real create service would validate, construct the model, save it through a repository, and return the saved record.
views.rs: translate HTTP into application calls
A view is an adapter. It extracts request data, calls permissions and services, then maps the result to an HTTP response. It should not contain SQL or a multi-step business workflow.
#![allow(unused)]
fn main() {
use djangors_core::{DjangorsError, PathParams, Request, Response, StatusCode};
use djangors_core::extract::{FromRequest, Json};
use super::{contracts::CreateBook, services};
pub async fn create(
req: Request,
_params: PathParams,
) -> Result<Response, DjangorsError> {
let Json(input) = Json::<CreateBook>::from_request(&req).await?;
services::validate_new_book(&input)
.map_err(|_| DjangorsError::api(StatusCode::BAD_REQUEST, "invalid_book", "title is required"))?;
// Call the create service here, then serialize its returned Book.
Ok(Response::json(StatusCode::CREATED, &serde_json::json!({
"title": input.title
}))?)
}
}
For production code, use a single error-mapping policy. Domain errors should
be converted to useful client messages and status codes; do not send Rust
Debug output such as InvalidStatus to API consumers.
urls.rs: which HTTP request reaches which view
The app router only declares paths, methods, and handlers. It does not query the database or implement authorization.
#![allow(unused)]
fn main() {
use djangors_core::Router;
use super::views;
pub fn urls() -> Router {
Router::new()
.get("/books", views::list)
.post("/books", views::create)
.get("/books/{id:i64}", views::detail)
}
}
Mount app routers once from the root router:
#![allow(unused)]
fn main() {
pub fn urls() -> djangors_core::Router {
djangors_core::Router::new()
.mount("/api/v1", apps::books::urls())
}
}
The route parameter name (id) must match the name read by the view, and its
type (i64) determines how the router parses the value.
The request flow in one sentence
For POST /api/v1/books, urls.rs selects views::create; the view parses a
CreateBook contract, checks permissions, calls services::create_book; the
service applies business rules and calls repositories::insert; the view
serializes the result as a BookResponse.
Errors: define them once, render them consistently
Production applications should not scatter DjangorsError::api(...) calls
through every branch of every view. Give each domain app an errors.rs file.
The domain error describes what went wrong; its conversion to
DjangorsError describes how HTTP clients should see it.
#![allow(unused)]
fn main() {
use djangors_core::DjangorsError;
use hyper::StatusCode;
#[derive(Debug)]
pub enum BookError {
Unauthenticated,
Forbidden,
NotFound,
EmptyTitle,
DuplicateTitle,
Persistence(String),
}
impl From<BookError> for DjangorsError {
fn from(error: BookError) -> Self {
match error {
BookError::Unauthenticated => DjangorsError::api(
StatusCode::UNAUTHORIZED,
"not_authenticated",
"Authentication credentials were not provided.",
),
BookError::Forbidden => DjangorsError::api(
StatusCode::FORBIDDEN,
"permission_denied",
"You do not have permission to perform this action.",
),
BookError::NotFound => DjangorsError::api(
StatusCode::NOT_FOUND,
"book_not_found",
"Book was not found.",
),
BookError::EmptyTitle => DjangorsError::api(
StatusCode::BAD_REQUEST,
"empty_title",
"Title is required.",
),
BookError::DuplicateTitle => DjangorsError::api(
StatusCode::CONFLICT,
"duplicate_title",
"A book with that title already exists.",
),
// Log the detailed database error server-side, but do not expose
// connection strings, SQL, or driver details to the client.
BookError::Persistence(message) => {
tracing::error!(error = %message, "book persistence failure");
DjangorsError::Internal("Could not save the book.".to_owned())
}
}
}
}
}
The normal mapping is:
| Failure | HTTP status | Stable code |
|---|---|---|
| Missing/invalid authentication | 401 | not_authenticated |
| Authenticated but not allowed | 403 | permission_denied |
| Invalid input | 400 or 422 | feature-specific code |
| Record not found | 404 | <resource>_not_found |
| Conflict with existing state | 409 | feature-specific code |
| Unexpected persistence/infrastructure failure | 500 | generic internal error |
Use Display for errors that must appear inside a list or job report. Never
use format!("{error:?}") in a client response: that exposes Rust variant
names and may leak implementation details.
Validation details
For several invalid fields, attach structured details instead of returning only one sentence. The client can then highlight the right inputs:
#![allow(unused)]
fn main() {
use serde_json::json;
use hyper::StatusCode;
let error = DjangorsError::api(StatusCode::BAD_REQUEST, "invalid", "Request failed.")
.with_details(json!({
"errors": [
{ "field": "title", "code": "blank", "message": "must not be blank" },
{ "field": "isbn", "code": "invalid_format", "message": "must be 13 digits" }
]
}));
}
Error rendering
Handlers return Result<Response, DjangorsError>; they do not render every
error themselves. The router renders an error after a handler returns Err.
By default, an API error becomes JSON, while non-API errors can become HTML or
JSON based on the request’s Accept header. In debug mode the HTML response
can contain a rich diagnostic page; production should return a minimal generic
page and log the real cause.
If an application needs one response shape for every endpoint, implement
djangors_core::error::ErrorRenderer and register it as router state:
#![allow(unused)]
fn main() {
use djangors_core::error::ErrorRenderer;
use djangors_core::{DjangorsError, Request, Response};
use serde_json::json;
pub struct ApiErrorRenderer;
impl ErrorRenderer for ApiErrorRenderer {
fn render(&self, error: &DjangorsError, _request: &Request) -> Response {
Response::json(error.status_code(), &json!({
"error": {
"code": error.code(),
"message": error.message(),
"details": error.details().cloned().unwrap_or(serde_json::Value::Null)
}
})).expect("error envelope is serializable")
}
}
}
Register an Arc<dyn ErrorRenderer> with .with_state(...) when constructing
the router. A project-wide renderer is useful when matching an existing API
contract, for example:
{
"error": {
"code": "book_not_found",
"message": "Book was not found.",
"details": null
}
}
Do not put database URLs, SQL, stack traces, file paths, usernames, or provider
responses in production error bodies. Log those details with tracing and
return a safe message. Health and readiness endpoints follow the same rule:
return a generic database unreachable message while logging the driver error.
Serializers: control API representation
Contracts describe shapes; serializers decide how model values are represented
and which fields can be written. For CRUD APIs, prefer the REST framework’s
ModelSerializer and a narrow FieldSet rather than returning a model
directly:
#![allow(unused)]
fn main() {
use djangors_rest::{FieldSet, ModelSerializer};
use crate::apps::books::models::Book;
let serializer = ModelSerializer::<Book>::new(
FieldSet::all()
.read_only(&["id"])
.excluding(&["internal_notes"]),
);
}
Mark IDs, timestamps, ownership, and computed fields read-only. Mark secrets, passwords, and tokens write-only. Use a custom serializer when the wire shape does not match database fields. A serializer is not a replacement for a service: field syntax validation belongs there, while cross-record rules and state transitions belong in services.
Permissions and tenant isolation
Authentication answers “who is this?”; permission answers “may this user do this?”; scoping answers “which rows may this user see?” These are separate checks.
For a school- or tenant-scoped model, implement djangors_rest::Scoped so
every ViewSet operation applies the scope. Then use
scoped_viewset_routes_with_config when you also need filters or ordering.
Add role checks for write actions—row scoping alone does not distinguish a
viewer from an administrator.
For hand-written handlers, the safe order is:
- Resolve the authenticated principal.
- Resolve the tenant from the principal/session, not from an unchecked body field.
- Check the role or permission.
- Query by both tenant and record ID.
- Validate foreign keys against the same tenant before writing.
Never load a record by ID first and check its tenant later if a not-found and a cross-tenant record should be indistinguishable. Scope the repository query so the record is invisible outside its tenant.
Background tasks
Tasks are for work that should not block an HTTP request: email, file scanning,
CSV imports, report generation, cleanup, and periodic billing. The school
backend keeps task modules at src/tasks/ and, for feature-specific work, in
src/apps/<app>/tasks.rs.
A task is a serializable command
Task handlers do not receive Request, sessions, or a database reference.
They receive a serializable payload and return Result<(), TaskError>:
#![allow(unused)]
fn main() {
use djangors_tasks::{task, TaskError};
use serde::{Deserialize, Serialize};
#[derive(Debug, Serialize, Deserialize)]
pub struct GenerateReportArgs {
pub report_id: i64,
}
#[task]
pub async fn generate_report(args: GenerateReportArgs) -> Result<(), TaskError> {
let db = crate::runtime::db();
// Load the report by ID, perform the work, and persist its status.
tracing::info!(report_id = args.report_id, "generating report");
let _ = db;
Ok(())
}
}
Pass IDs and small immutable values, not a model instance or a request. The task should reload current state when it runs. This avoids stale serialized records and keeps queue payloads small.
Enqueue and run tasks
Enqueue from a service after the database state needed by the task exists:
#![allow(unused)]
fn main() {
let task_id = djangors_tasks::enqueue(
db,
"generate_report",
&GenerateReportArgs { report_id: report.id },
).await?;
}
Use enqueue_scheduled for delayed work. Start a worker after database setup:
#![allow(unused)]
fn main() {
let worker = djangors_tasks::Worker::new(db.clone())
.with_recurring_tick_interval(std::time::Duration::from_secs(60));
tokio::spawn(async move { worker.run().await; });
}
The queue claims tasks transactionally. PostgreSQL workers use row locking with
FOR UPDATE SKIP LOCKED, so multiple workers can run safely. A successful task
becomes completed; an error or panic is recorded and retried until
max_attempts, after which it becomes failed.
Task best practices
- Make handlers idempotent: a retry must not send the same payment twice or create duplicate rows.
- Record a durable status (
pending,processing,completed,failed) for long workflows. - Treat “already completed” as success and return early.
- Log the task name and stable job ID, not sensitive payloads.
- Convert operational failures to
TaskError::TaskExecution, preserving the cause in server logs. - Handle expected missing data deliberately; skip and log cleanup items when safe, fail when the result is incomplete.
- Do not panic for ordinary validation or missing-record cases.
- Test the service logic directly and test at least one queued-task path with retries/status transitions.
Recurring tasks
Register recurring jobs at startup with a standard five-field cron expression:
#![allow(unused)]
fn main() {
djangors_tasks::register_recurring(
db,
"generate_report",
&GenerateReportArgs { report_id: 42 },
"0 2 * * *", // every day at 02:00
).await?;
}
Create the task and recurring-task tables before starting the worker. Anchor task modules if the binary uses link-time registration, and make recurring-job registration safe to run repeatedly. A restart should not create harmful duplicates or execute the same business operation twice.
Startup, shared state, and middleware
Keep main.rs as wiring, not business logic. A production startup sequence is:
- Load framework settings and application settings.
- Initialize development or production logging.
- Connect the database and run migrations.
- Create task tables and install process-global handles required by task handlers.
- Build external clients such as Redis and mail backends.
- Register recurring jobs and start the worker.
- Build the root router and attach typed shared state with
.with_state(...). - Wrap the router in sessions, tenant resolution, security headers, CSRF, and other middleware.
- Start the server with graceful shutdown.
#![allow(unused)]
fn main() {
let router = crate::urls::urls()
.with_state(db.clone())
.with_state(redis_client)
.with_state(app_settings.clone())
.with_state(std::sync::Arc::new(ApiErrorRenderer)
as std::sync::Arc<dyn djangors_core::error::ErrorRenderer>);
let service = tower::ServiceBuilder::new()
.layer(djangors_sessions::SessionLayer::new(session_store))
.layer(djangors_core::middleware::security_headers_layer())
.service(djangors_core::router::RouterService::new(router, debug));
}
The root urls.rs should expose liveness/readiness endpoints, mount the app
router once (for example at /api/v1), and mount the admin site. Shared state
belongs in the router; request-specific values belong in request extensions.
Feature-specific modules
The canonical files are boundaries, not a limit on the number of files an app may have. The reference school-management backend adds focused modules when a feature needs them:
| Module | Use it for | Examples |
|---|---|---|
tasks.rs | app-owned durable jobs | imports, notifications, cleanup |
notifications.rs | email/SMS/push orchestration | trial notices, status updates |
emails.rs / email_templates.rs | mail backend and message content | shared account mail |
storage.rs | object/file storage adapter | uploads, download keys |
importers.rs / exporters.rs | CSV or bulk data formats | student imports, reports |
pdf.rs | document-specific PDF layout | certificates, receipts |
pagination.rs | app-specific list response rules | legacy endpoint parity |
audit.rs | durable audit events | actor, action, target, metadata |
crypto.rs / verification.rs | cryptographic or verification workflows | tokens, enrollment |
moodle_client.rs or another client module | external provider adapter | LMS or payment provider |
Keep these modules focused. An external client should not decide HTTP status codes; a PDF renderer should not issue database queries; an email template should not implement account authorization. Put orchestration in a service and queue slow or retryable work through a task.
Health and readiness endpoints
Keep these meanings distinct:
/healthz: the process is alive; normally return200with{"status":"ok"}./readyz: dependencies are usable; return200when all checks pass and503when one fails.
Build readiness JSON from a pure snapshot so it can be tested without a live database or Redis connection. Log the underlying probe error, but return only safe generic messages to callers.
Migrations and app ownership
Each domain app owns its migration files. A migration should have explicit forward and rollback sections, create constraints and indexes intentionally, and be applied in foreign-key dependency order. Do not hide production schema changes in a task or application startup side effect. Test important unique, foreign-key, check, and tenant constraints.
Testing the architecture
Keep production source free of test modules. Group tests by app:
tests/apps/books/
├── models.rs # metadata, constraints, relationships
├── services.rs # validation and state transitions
├── permissions.rs # roles, authentication, tenant isolation
└── api.rs # real request/response status and JSON contract
For every endpoint, cover the happy path and the boundary cases: anonymous, wrong role, missing record, malformed input, cross-tenant ID, database conflict, and the exact response envelope. Test task services without a worker first, then add focused worker tests for serialization, retries, and idempotency.
Rules that keep projects maintainable
- Keep HTTP concerns in views: requests, path parameters, status codes, and responses.
- Keep SQL and
QuerySetconstruction in repositories. - Keep business rules in services and make them testable without HTTP.
- Keep public request and response shapes in contracts/serializers.
- Keep route declarations in
urls.rs; use app-local routers and mount them at the root. - Put integration tests under
tests/, grouped by app; test services, permissions, models, and real API responses separately. - For tenant-scoped records, apply the tenant filter on every read and write, and test cross-tenant access explicitly.
- Let apps call another app through its public service API instead of importing its repository internals.
When a feature is tiny, combining files is fine. Split it into these layers as soon as duplication or business rules appear; architecture is a tool for clarity, not ceremony.
Moving from Actix Web or Axum
This guide is for Rust developers who already know Actix Web or Axum and want to build a Djangors application without losing the patterns they already use. It is also useful for beginners because every section answers the same question: where does this code belong?
Djangors is still Rust, Tokio, Hyper, and Tower underneath. The difference is that the framework supplies more of the application layer: an ORM, migrations, forms, authentication, admin, templates, REST ViewSets, tasks, testing tools, and a project CLI.
If you are coming from Django instead, start with the Django comparison guide; this guide focuses on the Rust web-framework translation.
The translation in one table
| Concern | Actix Web / Axum | Djangors |
|---|---|---|
| Handler | async fn with extractors | async fn(Request, PathParams) -> Result<Response, DjangorsError> |
| Router | App / Scope or Router / nest | Router::new(), .get(), .post(), .mount() |
| Application state | web::Data<T> / State<T> | Router::with_state(T) and Request::require_state::<T>() |
| Path values | Path<T> | PathParams::get_as::<T>("name") |
| Query values | Query<T> | Request::query("name") or a query extractor/contract |
| JSON body | Json<T> | Json::<T>::from_request(&req).await |
| Form body | Form<T> | Form::<T>::from_request(&req).await |
| Response | HttpResponse / IntoResponse | Response::json, html, text, bytes, redirect |
| Middleware | Transform / Layer | Tower Layer with ServiceBuilder |
| Error type | ResponseError / IntoResponse | DjangorsError or app error impl From<AppError> |
| ORM | Diesel, SeaORM, SQLx, custom | #[derive(Model)], QuerySet, q!, Database |
| Serialization | Serde structs | Serde contracts or djangors-rest serializers |
| Auth | custom extractor/middleware | djangors-auth, sessions, token/JWT auth, permissions |
| CRUD API | handwritten handlers or macros | REST ViewSet and ModelSerializer |
| HTML templates | Tera, Askama, Maud | djangors-template and class-based views |
| Jobs | Tokio task, external queue | #[task], database queue, Worker, recurring jobs |
| Tests | actix_web::test, tower::ServiceExt | djangors-test::TestClient, TestDatabase, real-socket tests |
| Admin | build it yourself | djangors-admin |
| CLI | binary/subcommands you create | dj new, run, migrate, test, runworker, and more |
The most important architectural translation is this:
Axum/Actix handler -> Djangors view
extractor -> request extraction + contract
application service -> Djangors service
SQLx/Diesel query -> repository using QuerySet
IntoResponse -> DjangorsError + Response
Router::nest -> Router::mount
Tower layer -> Tower layer around RouterService
Start with a familiar Rust layout
The smallest Djangors app can use models.rs, views.rs, and urls.rs. A
feature that has business rules should use a domain app:
src/
├── main.rs
├── lib.rs
├── config.rs
├── runtime.rs # process-global handles needed outside requests
├── error_renderer.rs # optional project-wide response renderer
├── urls.rs # root routes and shared mounts
├── apps.rs # app registry / route composition
├── tasks.rs # task modules and public queue helpers
└── apps/
└── books/
├── mod.rs
├── models.rs
├── contracts.rs
├── errors.rs
├── repositories.rs
├── services.rs
├── permissions.rs
├── serializers.rs
├── views.rs
├── urls.rs
├── tasks.rs # only when jobs belong specifically to books
└── admin.rs
tests/apps/books/
├── models.rs
├── services.rs
├── permissions.rs
├── api.rs
└── tasks.rs
This is not an arbitrary folder convention. It gives each kind of code an obvious home and keeps handlers from becoming giant Axum-style “everything functions.” The school-management backend uses this shape across its domain apps.
Handlers and extractors
Axum
An Axum handler commonly looks like this:
#![allow(unused)]
fn main() {
async fn get_book(
State(state): State<AppState>,
Path(id): Path<i64>,
Query(query): Query<BookQuery>,
Json(input): Json<UpdateBook>,
) -> Result<Json<BookResponse>, AppError> {
// ...
}
}
Actix Web
The same idea in Actix often uses extractors:
#![allow(unused)]
fn main() {
async fn get_book(
state: web::Data<AppState>,
path: web::Path<BookPath>,
query: web::Query<BookQuery>,
body: web::Json<UpdateBook>,
) -> Result<impl Responder, AppError> {
// ...
}
}
Djangors
Djangors gives the handler a stable framework boundary. Extract values inside the view and keep the extracted objects typed:
#![allow(unused)]
fn main() {
use djangors_core::{DjangorsError, PathParams, Request, Response, StatusCode};
use djangors_core::extract::{FromRequest, Json};
use serde::Deserialize;
#[derive(Debug, Deserialize)]
pub struct UpdateBook {
pub title: String,
}
pub async fn update(
req: Request,
params: PathParams,
) -> Result<Response, DjangorsError> {
let book_id: i64 = params.get_as("id")?;
let Json(input) = Json::<UpdateBook>::from_request(&req).await?;
let db = req.require_state::<djangors_db::Database>()?;
// Call a service with `db`, `book_id`, and `input`.
let _ = (db, book_id, input);
Response::json(StatusCode::OK, &serde_json::json!({ "ok": true }))
}
}
Request::require_state gives a useful error when startup forgot to attach a
dependency. Prefer it over state(...).ok_or_else(...) in application code.
The request has two different storage areas:
req.state::<T>()/require_state::<T>(): application-wide shared state attached by the router, such as a database pool, Redis client, settings, or mail backend.req.ext::<T>(): request-scoped extensions installed by middleware, such as a session, authenticated principal, tenant, request ID, or CSRF token.
Do not put a request-specific user or tenant into global router state.
Routes and nesting
Axum and Actix to Djangors
#![allow(unused)]
fn main() {
use djangors_core::Router;
use crate::apps::{books, accounts};
pub fn urls() -> Router {
Router::new()
.get("/healthz", crate::views::healthz)
.mount("/api/v1", books::urls())
.mount("/accounts", accounts::urls())
}
}
Router::mount is the Djangors equivalent of Axum’s nest and Actix’s
service(web::scope(...)). The app router should only declare the app’s own
paths; the root router owns global prefixes and cross-cutting routes.
Typed path parameters use {name:type}:
#![allow(unused)]
fn main() {
Router::new()
.get("/books/{id:i64}", books::views::detail)
.get("/books/{slug:slug}", books::views::by_slug);
}
The route parameter name must match params.get_as("id") in the view. Djangors
also supports named routes and reversal:
#![allow(unused)]
fn main() {
let router = Router::new()
.get("/books/{id:i64}", books::views::detail)
.name("book-detail");
let path = router.reverse("book-detail", &[("id", "42")])?;
}
When route reversal is not available in a small isolated example, a formatted path is acceptable. In a larger application, prefer route names so redirects do not break when a prefix changes.
State and dependency injection
In Axum, you usually define one AppState struct. In Actix, you often register
several web::Data<T> values. Djangors supports both styles:
#![allow(unused)]
fn main() {
#[derive(Clone)]
pub struct AppState {
pub settings: AppSettings,
pub db: djangors_db::Database,
}
let state = AppState { settings, db };
let router = crate::urls::urls().with_state(state);
}
Or attach independent values:
#![allow(unused)]
fn main() {
let router = crate::urls::urls()
.with_state(db)
.with_state(redis_client)
.with_state(settings)
.with_state(mail_backend);
}
Use a state struct when dependencies belong together and independent state when an app should request only the capability it needs. Avoid a global “service locator” containing every dependency; keep the explicit type lookup close to the handler boundary and pass required values into services.
Middleware and Tower
Actix middleware and Axum middleware are both conceptually Tower layers in
Djangors. The Djangors Router is the inner routing service; RouterService
adapts it to Hyper/Tower:
#![allow(unused)]
fn main() {
let router_service = djangors_core::router::RouterService::new(router, debug);
let service = tower::ServiceBuilder::new()
.layer(djangors_core::middleware::request_id_layer())
.layer(djangors_core::middleware::security_headers_layer())
.layer(djangors_sessions::SessionLayer::new(session_store))
.layer(djangors_core::middleware::csrf_layer())
.service(router_service);
}
Middleware ordering matters. A practical order is:
- tracing/request ID
- host validation and security headers
- session/authentication state
- tenant resolution
- CSRF for cookie-authenticated unsafe requests
- router dispatch and error rendering
Follow the specific layer’s documentation when composing a custom stack. Do
not assume a middleware runs before another just because its call appears
first; ServiceBuilder wrapping follows Tower’s layer semantics.
Use middleware for cross-cutting request policy: authentication context, tenant resolution, request IDs, CSRF, headers, compression, and logging. Use a permission object or service for endpoint/domain authorization.
Errors: the equivalent of ResponseError / IntoResponse
Actix developers often implement ResponseError; Axum developers implement
IntoResponse. Djangors uses an app error enum plus one conversion:
#![allow(unused)]
fn main() {
#[derive(Debug)]
pub enum BookError {
NotFound,
Forbidden,
InvalidTitle,
Database(String),
}
impl From<BookError> for djangors_core::DjangorsError {
fn from(error: BookError) -> Self {
use hyper::StatusCode;
match error {
BookError::NotFound => djangors_core::DjangorsError::api(
StatusCode::NOT_FOUND, "book_not_found", "Book was not found."),
BookError::Forbidden => djangors_core::DjangorsError::api(
StatusCode::FORBIDDEN, "permission_denied", "You cannot edit this book."),
BookError::InvalidTitle => djangors_core::DjangorsError::api(
StatusCode::BAD_REQUEST, "invalid_title", "Title is required."),
BookError::Database(message) => {
tracing::error!(error = %message, "book database failure");
djangors_core::DjangorsError::Internal("Book operation failed.".into())
}
}
}
}
}
Views then stay readable:
#![allow(unused)]
fn main() {
let book = crate::apps::books::services::find_for_user(db, user, id)
.await
.map_err(djangors_core::DjangorsError::from)?;
}
The router renders DjangorsError. By default, API errors use the JSON
envelope and ordinary errors can render HTML in a browser or JSON when the
client requests JSON. A project can register an ErrorRenderer in router
state to enforce a single envelope across HTML and API handlers.
Recommended envelope:
{
"error": {
"code": "book_not_found",
"message": "Book was not found.",
"details": null
}
}
Keep SQL, stack traces, credentials, and provider responses in logs, not
client responses. Add field-level details for validation errors. Do not use
Rust Debug formatting as an API message.
Database access: from SQLx/Diesel to ORM repositories
The direct translation of an Axum handler that executes SQL is tempting, but the scalable Djangors pattern is repository + service:
#![allow(unused)]
fn main() {
// repositories.rs: database access only
pub async fn find(db: &djangors_db::Database, id: i64)
-> Result<Option<Book>, djangors_orm::OrmError>
{
use djangors_orm::{q, Model};
Book::objects().filter(q!(id = id))?.first(db).await
}
// services.rs: domain policy
pub async fn find_for_user(
db: &djangors_db::Database,
user_id: i64,
id: i64,
) -> Result<Book, BookError> {
repositories::find_owned(db, user_id, id)
.await
.map_err(|error| BookError::Database(error.to_string()))?
.ok_or(BookError::NotFound)
}
}
The repository owns QuerySet, q!, filters, ordering, joins, and persistence.
It does not return HTTP responses or inspect roles. The service owns
cross-record validation, transactions, state transitions, and coordination.
The view extracts HTTP input and maps the service result.
When raw SQL is necessary, isolate it in the repository and document why the ORM cannot express it. Do not create a second ORM in every handler.
JSON contracts, validation, and serializers
Your Axum Json<T> or Actix web::Json<T> type becomes a Djangors contract:
#![allow(unused)]
fn main() {
#[derive(Debug, serde::Deserialize)]
pub struct CreateBook {
pub title: String,
}
#[derive(Debug, serde::Serialize)]
pub struct BookResponse {
pub id: i64,
pub title: String,
}
}
Do not deserialize directly into a persistence model when the client should
not control fields such as id, owner_id, created_at, or status.
For REST CRUD, use ModelSerializer and FieldSet:
#![allow(unused)]
fn main() {
let serializer = djangors_rest::ModelSerializer::<Book>::new(
djangors_rest::FieldSet::all()
.read_only(&["id", "created_at"])
.excluding(&["internal_notes"]),
);
}
Use read_only for server-owned values, write_only for passwords/tokens,
and custom serializers when the public API differs from the model. Use a
service for validation involving more than one record.
CRUD APIs: ViewSets vs handwritten handlers
If an endpoint is ordinary authenticated list/create/retrieve/update/delete, use the REST ViewSet helpers. They give you consistent serialization, pagination, filters, permissions, and OpenAPI integration:
#![allow(unused)]
fn main() {
use djangors_core::Router;
use djangors_rest::{viewset_routes_with_options, IsAuthenticated, ViewSetConfig, ViewSetOptions};
let options = ViewSetOptions::<Book>::new(ViewSetConfig {
filterable_fields: &["published"],
orderable_fields: &["title"],
..Default::default()
});
let router = viewset_routes_with_options::<Book, _>(
Router::new(), "/books", options, IsAuthenticated);
}
Use a handwritten view for workflow actions (publish, checkout,
approve), unusual response shapes, file uploads, or behavior that does not
fit CRUD. The workflow should still call a service.
For tenant-owned records, use Scoped plus
scoped_viewset_routes_with_config. Scoping answers which rows are visible;
it does not automatically decide which role may edit them.
Authentication, permissions, and tenant context
Actix/Axum users often put an authenticated user in an extractor. In Djangors,
session auth and API auth are framework facilities, and current_user(&req)
resolves the configured identity sources. IsAuthenticated is the route
guard; IsStaff, IsSuperuser, and composed permissions handle common role
rules.
Keep three decisions separate:
authentication: who is the caller?
permission: may the caller perform this action?
scope: which rows belong to the caller's tenant/owner?
For a hand-written endpoint, resolve the principal, resolve tenant context, check the role, and query by tenant plus record ID. Validate every foreign key against the same tenant before writing. Never trust a tenant ID supplied by a client body.
Background work: Tokio spawn vs Djangors tasks
tokio::spawn is appropriate for short-lived in-process work tied to the
server process. It is not a durable queue: work can disappear on restart,
there is no retry state, and a request may outlive its useful context.
For durable jobs use djangors_tasks:
#![allow(unused)]
fn main() {
#[derive(serde::Serialize, serde::Deserialize)]
pub struct SendBookReport {
pub report_id: i64,
}
#[djangors_tasks::task]
pub async fn send_book_report(args: SendBookReport)
-> Result<(), djangors_tasks::TaskError>
{
let db = crate::runtime::db();
// Reload by ID, perform idempotent work, persist status.
let _ = (db, args);
Ok(())
}
// Called by a service after committing the record the job needs.
djangors_tasks::enqueue(db, "send_book_report", &SendBookReport { report_id }).await?;
}
Task payloads must be serializable and small. Pass IDs, not request objects or stale model graphs. Handlers have no request context, so install process-global handles such as the database or mail backend once during startup, or pass everything needed through the payload.
The worker records pending, running, completed, and failed states,
retries failures up to max_attempts, and isolates handler panics. Make every
task idempotent and treat an already-completed job as success. Use
register_recurring for cron-style jobs and start the worker only after task
tables and task registrations are ready.
Templates and forms
For an HTML application:
djangors_template::TemplateEngineis the equivalent of Tera/Askama’s template engine.djangors_template::renderreturns an HTTP response from a template.Form<T>parses URL-encoded forms into a typed Rust struct.#[derive(Model)]generates model-form metadata for the generic HTML views.djangors_viewsprovidesListView,DetailView,CreateView,UpdateView, andDeleteViewfor conventional CRUD pages.
Keep the same boundary as an API: the view extracts form input, the service applies business rules, and the template receives a safe response context. Never interpolate unescaped user input into HTML.
Files, uploads, and streaming
Use the framework’s multipart extraction and static-file/storage facilities for uploads. A safe upload flow is:
- Validate authenticated ownership and intended purpose.
- Enforce size and content-type limits.
- Store using a generated storage key, never a client filename as a path.
- Persist metadata and scan untrusted content when required.
- Queue expensive processing as a task.
- Serve through a controlled storage/download endpoint.
For server push, Djangors provides SSE helpers (Response::sse and
Router::get_sse). Treat a streaming response as a separate handler kind; do
not try to return a normal Response from an SSE stream. Djangors is not a
drop-in WebSocket server—verify the framework capability before designing a
WebSocket protocol around it.
Testing: translating Actix and Axum test habits
The Djangors equivalent of an in-process router test is TestClient:
#![allow(unused)]
fn main() {
#[tokio::test]
async fn book_detail_returns_not_found() {
let router = crate::urls::urls();
let response = djangors_test::TestClient::new(router)
.get("/api/v1/books/999")
.send()
.await;
response.assert_status(hyper::StatusCode::NOT_FOUND);
}
}
Use TestDatabase for isolated database tests. Run the fast SQLite suite with
TEST_BACKEND=sqlite and run PostgreSQL-specific behavior against PostgreSQL
as well. SQLite is useful for speed but cannot prove PostgreSQL locking,
extensions, index behavior, or every SQL expression.
Keep tests outside production modules and group them by app:
tests/apps/books/
├── models.rs # metadata and database constraints
├── services.rs # domain rules without HTTP
├── permissions.rs # role and tenant boundaries
├── api.rs # status, headers, JSON/form contracts
└── tasks.rs # payload, retry, idempotency behavior
For each endpoint test success, anonymous access, wrong role, missing records, malformed input, cross-tenant IDs, conflicts, and the exact error envelope.
Configuration, startup, and deployment
Actix/Axum applications often build an AppState directly in main. Djangors
startup should additionally initialize framework services:
- Load
DjangorsSettingsand typed application settings. - Initialize dev or production
tracing. - Connect the database and run migrations.
- Create task tables and install runtime handles.
- Build Redis, mail, cache, and external provider clients.
- Register recurring jobs and start the worker.
- Build the root router and attach typed state.
- Wrap
RouterServicein sessions, tenant, security, CSRF, and logging layers. - Run the service with graceful shutdown.
Expose /healthz for process liveness and /readyz for dependency readiness.
Readiness failures should return 503 and safe generic messages while logging
the driver error. Use dj check --deploy, cargo build --release, and the
deployment guide before shipping.
The migration checklist
When moving one Actix/Axum feature into Djangors, do it in this order:
- Write down its routes, methods, path/query/body contracts, status codes, auth rules, tenant rules, and error envelope.
- Move persistent structs to
models.rsand create a migration. - Define public input/output structs in
contracts.rs. - Move SQL into
repositories.rs. - Move validation and workflows into
services.rs. - Define domain failures in
errors.rsand map them toDjangorsError. - Add serializers or ViewSet options for public field visibility.
- Implement thin views and app-local URLs.
- Add permission/scoping checks before enabling routes.
- Add service, permission, model, API, and task tests.
- Attach dependencies in startup and verify middleware order.
- Run both SQLite and PostgreSQL validation where the feature uses PostgreSQL-specific behavior.
If you can point to the file that owns each item, the feature is usually easy to maintain. If a view owns SQL, business rules, error formatting, and task enqueueing all at once, split it before migrating the next feature.
API Stability & Deprecation Policy
This page states, for real consumers of Djangors (not just internal contributors), what compatibility guarantee the project makes, how it changes public APIs, and how it ships releases. It exists because none of this was published anywhere discoverable before. The intent lived only in internal planning notes.
Versioning
Djangors follows Semantic Versioning starting at 1.0.0. All workspace
crates (djangors, djangors-core, djangors-orm, djangors-rest, and the rest) share a single
version number via the workspace’s [workspace.package] version, and are released together. You
will never see djangors-orm at 1.3.0 while djangors-core is at 1.1.0. Before 1.0, expect
breaking changes between minor (0.x) releases without a deprecation cycle; the guarantees below
apply from 1.0 onward.
What “the public API” means: as of the first API-freeze review, this covers djangors-core,
djangors-orm, and djangors-rest (audited together as the largest and most-depended-on
surface). The djangors facade crate (djangors::core, djangors::orm, djangors::rest, etc.)
re-exports these and is the intended single entry point for applications. Coverage of the
remaining crates (djangors-admin, djangors-auth, djangors-forms, and the rest) is an
explicitly planned follow-up (“Freeze Review Pass 2”), not yet frozen under this policy.
Deprecation mechanics
- A public item slated for removal is marked
#[deprecated(since = "X.Y.Z", note = "...")]with a note explaining the replacement or reason. - It remains functional, with the deprecation warning, for at least one full minor release cycle before it can be removed in a subsequent minor or major release. It is never removed in the same release it was deprecated in.
- Every release with deprecations or removals lists them explicitly under their own “Deprecated”
and “Removed” headings in
CHANGELOG.md(see below), not buried in a generic changelog paragraph.
CHANGELOG.md
A root CHANGELOG.md is maintained
today, grouped by development phase since there has not yet been a tagged 1.0 release. Once
1.0 ships, it switches to the Keep a Changelog format:
Added/Changed/Deprecated/Removed/Fixed/Security sections per version.
Release cadence
A tagged release ships roughly every 4-6 weeks, each with a changelog entry. This is not a new
commitment invented for this doc. It restates a cadence the project has intended internally since
early planning (PLAN.md’s own “Project operations” section); this page makes it visible to
actual consumers rather than leaving it as an internal note.
Changing the public API
Any change to the public API surface at or above “medium” impact (renaming or removing a public
item, changing a trait’s required methods, changing a function’s signature in a way existing
callers must adapt to) goes through a short RFC first: a design doc under docs/design/ describing
the change and its motivation, open for comment before landing. This mirrors the process already
used internally for every architecturally significant change in this project’s own history (every
slice in docs/design/ is exactly this kind of doc). Formalizing it here means outside
contributors can propose and review API changes the same way, not just watch them happen.
Where this stops short today
This is a first pass, not a complete stability contract: it covers 3 of the 32 workspace crates by design (the largest, most-used ones), and doesn’t cover the contrib crates or CLI tooling at all. Treat this page as the honest current state of the policy, not a claim that everything is frozen. It will be extended as later freeze-review passes land.
Object-Relational Mapping (ORM)
djangors-orm provides Django-style querysets, model metadata, expression macros, and database integration for PostgreSQL (via sqlx).
Defining Models
Models are Rust structs decorated with #[derive(Model)] from djangors-macros.
#![allow(unused)]
fn main() {
use djangors_orm::Model;
use djangors_macros::Model;
use djangors_orm::ForeignKey;
use chrono::{DateTime, Utc};
#[derive(Model, Debug, Clone)]
#[djangors(app = "polls", table_name = "polls_question", ordering = ["-pub_date"])]
pub struct Question {
#[djangors(primary_key, auto)]
pub id: i64,
#[djangors(max_length = 200)] pub question_text: String,
pub pub_date: DateTime<Utc>,
}
#[derive(Model, Debug, Clone)]
#[djangors(app = "polls", table_name = "polls_choice")]
pub struct Choice {
#[djangors(primary_key, auto)]
pub id: i64,
#[djangors(foreign_key(on_delete = "cascade", related_name = "choices"))]
pub question: ForeignKey<Question>,
#[djangors(max_length = 200)]
pub choice_text: String,
#[djangors(default = 0)]
pub votes: i32,
}
}
Model Attributes (#[djangors(...)])
Struct-Level Attributes
app = "app_label"(required): Specifies the application label (e.g.,"polls").table_name = "custom_table"(optional): Database table name. Defaults to{app_label}_{snake_case_struct_name}.ordering = ["field", "-field"](optional): Default ordering for querysets when.order_by()is not called. Prefix-denotes descending.unique_together = [["field1", "field2"]](optional): Defines composite unique constraints.indexes = ["index_name", ("index_name", ["field1", "field2"])](optional): Defines custom or composite database indexes. Can be a simple index name or a tuple of(name, [fields]).manager = "ManagerStruct"(optional): Overrides the default model manager with a custom manager struct.
Field-Level Attributes
primary_key: Marks the single primary key field. Automatically impliesuniqueanddb_index. Every model must have exactly one primary key field.auto: Marks auto-incrementing / database-generated fields (such asBIGSERIAL). Ignored duringsave()insertions.max_length = N: Sets maximum string length. Valid only onStringfields (maps toFieldKind::Char).Stringwithoutmax_lengthmaps toFieldKind::Text.default = value: Sets default field value (Text,I64,F64, orBool).unique: Adds aUNIQUEconstraint to the column.db_index: Creates a database index on the column.verbose_name = "...",help_text = "...": Descriptive labels for admin/forms metadata.column = "col_name": Overrides the database column name if different from the struct field name.foreign_key(on_delete = "...", related_name = "..."): Configures foreign key behavior.on_deleteoptions:"cascade"(default),"protect","set_null","restrict","do_nothing".
Supported Field Types
- Integer:
i32(FieldKind::Integer),i64(FieldKind::BigInt) - Float:
f32,f64(FieldKind::Float) - String:
String(FieldKind::CharorFieldKind::Text) - Boolean:
bool(FieldKind::Boolean) - Datetime:
chrono::DateTime<chrono::Utc>(FieldKind::DateTime) - Foreign Key:
ForeignKey<TargetModel> - One-to-One Key:
OneToOne<TargetModel> - Many-to-Many Marker:
ManyToMany<TargetModel> - Nullable:
Option<T>for any of the above types (exceptManyToMany)
Custom Managers
You can define custom database query manager behavior by implementing the Manager trait and specifying the manager class via the struct-level manager attribute:
#![allow(unused)]
fn main() {
use djangors_orm::{Model, q};
use djangors_macros::Model;
pub struct ActiveManager;
impl djangors_orm::meta::Manager<ManagedModel> for ActiveManager {
fn objects() -> djangors_orm::queryset::QuerySet<ManagedModel> {
djangors_orm::queryset::QuerySet::<ManagedModel>::new()
.filter(q!(is_active = true))
.expect("valid filter")
}
}
#[derive(Model, Debug)]
#[djangors(
app = "polls",
table_name = "polls_managed",
manager = "ActiveManager"
)]
pub struct ManagedModel {
#[djangors(primary_key, auto)]
pub id: i64,
pub name: String,
pub is_active: bool,
}
}
By overriding the manager, calling ManagedModel::objects() will automatically retrieve a queryset pre-filtered or configured according to your manager definition.
Note
Types like
NaiveDate,NaiveTime,Duration,Uuid, andDecimalare defined inFieldKindfor schema metadata, but currently trigger a compile error onModelderive for save/update operations.
QuerySet Methods
Access querysets using Model::objects() (or QuerySet::<T>::new()).
Important
objects(),meta(), andfield_values()are methods on theModeltrait. Every file that calls them needsuse djangors_orm::Model;— the#[derive(Model)]attribute does not pre-import it. A bareModel::objects()failing with “method not found” is this missing import, not a broken model.
Filtering and Ordering
.filter(q!(field = value, ...)): Applies filter expressions combined withAND. Accepts a wholeQ-style tree, soORandNOTcompose (see Combining filters)..exclude(q!(...)): The negation of.filter()— Django’s.exclude()..filter_or_icontains(&["field1", "field2"], "term"): Generates case-insensitiveILIKE %term%queries across specified text fields combined withOR..filter_datetime_range(field, gte, lt): Filters datetime fields in the half-open interval[gte, lt)..order_by("field")/.order_by("-field"): Orders results. Prefix-meansDESC..limit(n)/.offset(n): Paginates results using standard SQLLIMITandOFFSET..debug_sql()/.debug_params(): TheSELECTthis queryset would run and the parameters it would bind, for debugging and tests — Django’sstr(queryset.query). Placeholders stay as$1,$2, … rather than being interpolated, so the output is never a runnable statement.
Lookup Suffixes
Append a lookup to a field name to change the comparison. Without one, = is used.
| Suffix | SQL | Notes |
|---|---|---|
(none) / __eq | = | |
__ne | <> | |
__lt __lte __gt __gte | < <= > >= | |
__contains / __icontains | LIKE / ILIKE %v% | |
__startswith / __endswith | LIKE 'v%' / LIKE '%v' | |
__iexact | ILIKE 'v' | Case-insensitive, no wildcards |
__in | IN (...) | Takes a Vec; an empty one compiles to FALSE, never invalid IN () |
__isnull | IS NULL / IS NOT NULL | = false inverts it; binds no parameter |
__regex / __iregex | ~ / ~* | POSIX regular expression |
An unrecognised suffix is treated as part of the field name, so a typo surfaces
as OrmError::FieldNotFound rather than silently becoming an equality test.
Execution Methods
.all(db).await: Executes select query and returnsVec<T>..get(db).await: Fetches a single object. ReturnsOk(T),Err(OrmError::NotFound)if 0 rows returned, orErr(OrmError::MultipleObjectsReturned)if >1 row..first(db).await: ReturnsResult<Option<T>, OrmError>(LIMIT 1)..exists(db).await: ReturnsResult<bool, OrmError>check (SELECT 1 ... LIMIT 1)..count(db).await: ReturnsResult<i64, OrmError>count (SELECT COUNT(*) ...)..aggregate(db, vec![AggExpr::...]).await: Evaluates aggregate functions:AggExpr::Count,AggExpr::Sum,AggExpr::Avg,AggExpr::Min,AggExpr::Max..update(db, set!(...)).await: Performs bulk updates. ReturnsResult<u64, OrmError>(rows affected count)..delete(db).await: Deletes all rows matching the queryset’s filters. ReturnsResult<u64, OrmError>(rows affected count). Warning: calling.delete()on an unfiltered queryset (e.g.Model::objects().delete(db)) will delete every row in the table.QuerySet::<T>::delete_by_pk(db, pk).await: Static helper deleting a row by its primary key.
Eager-loading relations: select_related and prefetch_related
select_related loads a forward foreign key in two queries total (batch the
related ids, then one IN fetch), instead of one query per row. It returns
Vec<(T, Option<R>)> — the row, plus the related model or None when the
foreign key is dangling.
#![allow(unused)]
fn main() {
use polls::models::{Question, Choice};
use djangors_orm::{Model, prefetch_related};
async fn run(db: &djangors_db::Database) -> Result<(), Box<dyn std::error::Error>> {
// One query for choices, one for their questions — not N+1.
let rows: Vec<(Choice, Option<Question>)> = Choice::objects()
.select_related::<Question, _>(db, "question")
.await?;
for (choice, question) in &rows {
let text = question.as_ref().map(|q| q.question_text.as_str()).unwrap_or("[missing]");
println!("{} -> {text}", choice.choice_text);
}
Ok(())
}
}
prefetch_related does the reverse direction: given a list of parent rows, it
batch-loads the children that FK to them in one query. It is a free function —
call it with the parents and the related_name (needs ForeignKey
to declare one).
#![allow(unused)]
fn main() {
use polls::models::{Question, Choice};
use djangors_orm::{Model, prefetch_related, q};
async fn run(db: &djangors_db::Database) -> Result<(), Box<dyn std::error::Error>> {
let questions = Question::objects().all(db).await?;
// One extra query fills in every question's `choices` (related_name).
let by_question: std::collections::HashMap<i64, Vec<Choice>> =
prefetch_related(db, &questions, "choices").await?;
for question in &questions {
println!("{0}: {1} choices", question.question_text, by_question.get(&question.id).map_or(0, Vec::len));
}
Ok(())
}
}
Use select_related for -to-one hops and prefetch_related for -to-many
reversed relations; both exist purely to avoid the N+1 query pattern.
Locking rows: select_for_update
.filter(...).select_for_update() prefixes the query with FOR UPDATE, so the
matched rows are locked until the transaction commits. Combined with
.nowait() or .skip_locked() it implements the “claim one of N jobs” pattern
without double-claiming.
#![allow(unused)]
fn main() {
use polls::models::{Question, Choice};
use djangors_orm::{Model, q};
async fn run(db: &djangors_db::Database, choice_id: i64) -> Result<(), Box<dyn std::error::Error>> {
db.transaction(|conn| {
Box::pin(async move {
let choice: Choice = Choice::objects()
.filter(q!(id = choice_id))? // note: no `.await` — still building the query
.select_for_update()
.get(conn)
.await?;
// `choice` is locked for this transaction; other transactions block or skip.
Result::<(), djangors_orm::OrmError>::Ok(())
})
}).await?;
Ok(())
}
}
Note
select_for_updatemust run inside a transaction — calling it against a bare poolconnreturnsOrmError::SelectForUpdateOutsideTransaction. Use the Postgresdb.transaction(|conn| ...)form (ortransaction_conn, which works on both backends).nowait,skip_locked, andlock_ofare Postgres-only; SQLite rejects them withUnsupportedOnDialect.
Expressions & Macros
q! Macro
Constructs lookup filter expressions:
use polls::models::{Question, Choice};
use djangors_orm::{q, Model};
fn main() -> Result<(), Box<dyn std::error::Error>> {
let qs = Question::objects().filter(q!(question_text = "What is your name?"))?;
Ok(())
}
Every field inside q! accepts a lookup suffix typed on the ident itself —
q!(field__lt = v), q!(field__in = vec![..]), etc. — it is not a separate
feature. The full set lives in the lookup table above; here
are the ones you reach for most day-to-day:
use polls::models::{Question, Choice};
use djangors_orm::{q, Model};
fn main() -> Result<(), Box<dyn std::error::Error>> {
let qs = Choice::objects()
// Range: votes > 5 AND votes <= 100
.filter(q!(votes__gt = 5i32))?
.filter(q!(votes__lte = 100i32))?
// Null check — the field must be nullable (Option<T> / Option<ForeignKey>):
.filter(q!(choice_text__isnull = false))?
// Set membership and negation:
.filter(q!(id__in = vec![1i64, 2, 3]))?
.filter(q!(votes__ne = 0i32))?
// Case-insensitive substring:
.filter(q!(choice_text__icontains = "rust"))?;
let _ = qs;
Ok(())
}
Combining filters: OR, AND, NOT
A q!(...) produces a value you can combine with | (OR), & (AND), and !
(NOT), which is how Django’s Q objects spell the same thing. Successive
.filter() calls are still ANDed together.
use polls::models::{Question, Choice};
use djangors_orm::{q, Model};
fn main() -> Result<(), Box<dyn std::error::Error>> {
// WHERE (votes = 0 OR votes > 100) AND NOT (choice_text = 'spam')
let qs = Choice::objects()
.filter(q!(votes = 0i32) | q!(votes__gt = 100i32))?
.filter(!q!(choice_text = "spam"))?;
// `.exclude()` is the same as filtering on a negation.
let qs = Choice::objects().exclude(q!(votes = 0i32))?;
let _ = qs;
Ok(())
}
Comparing two columns: q_f!
Where q! compares a column to a bound value, q_f! compares two columns on
the same row — F() on the filter side. The lookup suffix rides on the
left-hand field, as it does in Django.
use polls::models::{Question, Choice};
use djangors_orm::{q_f, Model};
fn main() -> Result<(), Box<dyn std::error::Error>> {
// WHERE "id" <> "votes" — neither side is a bind parameter.
let qs = Choice::objects().filter(q_f!(id__ne votes))?;
let _ = qs;
Ok(())
}
Correlated Subqueries: Exists, NotExists, OuterRef, q_outer!
Use Exists or NotExists with q_outer! and OuterRef to construct correlated subqueries:
use polls::models::{Question, Choice};
use djangors_orm::{q, q_outer, Exists, NotExists, OuterRef, Model};
fn main() -> Result<(), Box<dyn std::error::Error>> {
// Questions with at least one choice with votes > 0:
let popular_questions = Question::objects()
.filter(
Exists::<Choice>::new()
.filter(q_outer!(question = OuterRef("id")))?
.filter(q!(votes__gt = 0i32))?,
)?;
// Questions without choice with votes > 0:
let unpopular_questions = Question::objects()
.filter(
NotExists::<Choice>::new()
.filter(q_outer!(question = OuterRef("id")))?
.filter(q!(votes__gt = 0i32))?,
)?;
let _ = (popular_questions, unpopular_questions);
Ok(())
}
Note
An
ExistsorNotExistssubquery whose filters contain noOuterRefis an uncorrelated subquery. It evaluates once for the entire query and does not correlate rows between the subquery and outer query.
Grouping: annotate and values
.annotate() is Django’s .values(...).annotate(...): it groups by the given
fields and computes aggregates per group, returning GroupRows rather than
model instances.
#![allow(unused)]
fn main() {
use polls::models::{Question, Choice};
use djangors_orm::{Model, aggregate::AggExpr};
async fn run(db: &djangors_db::Database) -> Result<(), Box<dyn std::error::Error>> {
let rows = Choice::objects()
.annotate(db, &["question"], vec![("total", AggExpr::Sum { field: "votes" })])
.await?;
for row in &rows {
let question_id = row.key("question");
let total = row.get("total");
println!("{question_id:?} => {total:?}");
}
Ok(())
}
}
Ordering is dropped for grouped queries, because ordering by a column that is
not in the GROUP BY is not valid SQL; sort the returned rows in Rust instead.
.values() and .values_list() select a projection instead of whole models,
which is useful when a table has columns you do not want to pay to fetch:
#![allow(unused)]
fn main() {
use polls::models::{Question, Choice};
use djangors_orm::Model;
async fn run(db: &djangors_db::Database) -> Result<(), Box<dyn std::error::Error>> {
let rows = Question::objects().values(db, &["id", "question_text"]).await?;
let ids = Question::objects().values_list(db, "id").await?;
let _ = (rows, ids);
Ok(())
}
}
set! Macro & F Expressions
Constructs update assignment lists, supporting F expressions for in-database atomic updates:
#![allow(unused)]
fn main() {
use polls::models::{Question, Choice};
use djangors_orm::{q, Model};
async fn run_update(db: &djangors_db::Database, choice_id: i64) -> Result<(), Box<dyn std::error::Error>> {
use djangors_orm::{set, F};
// Increment votes by 1 atomically in SQL: UPDATE polls_choice SET votes = votes + 1 WHERE ...
Choice::objects()
.filter(q!(id = choice_id))?
.update(db, set!(votes = F("votes") + 1))
.await?;
Ok(())
}
}
Model Instance Persistence Methods
Models derived with #[derive(Model)] implement instance CRUD operations:
model.save(db).await: Inserts a new row into the database, ignoringautofields, and returns a fresh model instance with database-assigned primary key values.model.update(db).await: Updates all non-primary key fields of an existing row matchingmodel.id. ReturnsErr(OrmError::NotFound)if no row matched.model.delete(db).await: Deletes the row matchingmodel.id. ReturnsErr(OrmError::NotFound)if no row was deleted.
Important
saveis INSERT-only — it always creates a new row. Callingsave()on a row you already fetched or previously saved inserts a duplicate; useupdate()for anything that already exists. There is no automatic new-vs-persisted detection — track that yourself.
Constructing models with foreign keys (ForeignKey::new)
A ForeignKey<T> field is not magically constructed — build it with
ForeignKey::new(id) exactly like any other field value. You generally save the
parent first to obtain its id, then insert the child.
#![allow(unused)]
fn main() {
use polls::models::{Question, Choice};
use djangors_orm::ForeignKey;
async fn run(db: &djangors_db::Database) -> Result<(), Box<dyn std::error::Error>> {
let question = Question {
id: 0,
question_text: "What is your name?".to_string(),
pub_date: chrono::Utc::now(),
};
let question = question.save(db).await?; // INSERT; `question.id` now holds the DB-assigned pk.
let choice = Choice {
id: 0,
question: ForeignKey::<Question>::new(question.id), // the FK wrapper.
choice_text: "Alice".to_string(),
votes: 0,
};
let choice = choice.save(db).await?;
let _ = choice;
Ok(())
}
}
Writes that don’t need the returned row (bulk insert of many children, e.g.)
can skip save and use QuerySet::<T>::insert_raw(db, values) or
QuerySet::<T>::bulk_create(db, &items) instead; bulk_create inserts many
rows in one statement.
Relationships & Many-to-Many
djangors-orm supports Foreign Key, One-to-One, and Many-to-Many relationships.
Foreign Keys (ForeignKey<T>)
Foreign keys reference the primary key of another model:
#![allow(unused)]
fn main() {
#[djangors(foreign_key(on_delete = "cascade", related_name = "choices"))]
pub question: ForeignKey<Question>,
}
To access the related ID directly, read choice.question.id. To load the related model instance lazily, call .fetch(&db):
#![allow(unused)]
fn main() {
if let Some(question) = choice.question.fetch(db).await? {
println!("{}", question.question_text);
}
}
One-to-One (OneToOne<T>)
A OneToOne<T> relationship is declared using the OneToOne wrapper. While it behaves similarly to a ForeignKey, migrations will automatically emit a UNIQUE constraint on the underlying column to ensure uniqueness:
#![allow(unused)]
fn main() {
#[derive(Model, Debug, Clone)]
#[djangors(app = "polls", table_name = "polls_user_profile")]
pub struct UserProfile {
#[djangors(primary_key, auto)]
pub id: i64,
#[djangors(foreign_key(on_delete = "cascade", related_name = "profile"))]
pub user: OneToOne<User>,
}
}
Like foreign keys, you can resolve the relationship lazily via .fetch(db):
#![allow(unused)]
fn main() {
let profile = UserProfile::objects().get(db).await?;
if let Some(user) = profile.user.fetch(db).await? {
println!("User name: {}", user.name);
}
}
Many-to-Many (ManyToMany<T>)
A ManyToMany<T> relation uses the ManyToMany wrapper as a zero-sized marker field. This field does not correspond to any column in the model’s table. Instead, a dedicated through (junction) table named {source_table_name}_{field_name} is created automatically by migrations.
#![allow(unused)]
fn main() {
#[derive(Model, Debug, Clone)]
#[djangors(app = "polls", table_name = "polls_school")]
pub struct School {
#[djangors(primary_key, auto)]
pub id: i64,
pub name: String,
#[djangors(many_to_many(related_name = "schools"))]
pub students: ManyToMany<Student>,
}
}
You can interact with Many-to-Many relationships using the free helper functions m2m_related, m2m_add, and m2m_remove:
#![allow(unused)]
fn main() {
use djangors_orm::{m2m_add, m2m_related, m2m_remove};
// Add relationship entries
m2m_add::<School, Student, _>(db, school.id, "students", student.id).await?;
// Query related instances
let students = m2m_related::<School, Student, _>(db, school.id, "students").await?;
// Remove relationship entries
m2m_remove::<School, Student, _>(db, school.id, "students", student.id).await?;
}
Query Result Caching
djangors-orm provides an opt-in, in-process, process-local query cache to avoid hitting the database for duplicate queries.
Enabling and Querying the Cache
You can enable result caching on a QuerySet via .cache(duration) and fetch cached results via .cached_all(db):
#![allow(unused)]
fn main() {
use std::time::Duration;
// First call queries the database and caches the results
let questions = Question::objects()
.filter(q!(pub_date__year = 2026))?
.cache(Duration::from_secs(60))
.cached_all(db)
.await?;
// Subsequent calls with the exact same SQL and parameters
// will hit the process-local cache instead of the database.
let cached_questions = Question::objects()
.filter(q!(pub_date__year = 2026))?
.cache(Duration::from_secs(60))
.cached_all(db)
.await?;
}
Invalidation
Write operations (save(), update(), and delete()) automatically invalidate all cached entries associated with the affected model’s table.
Important
The query cache is process-local and resides entirely in-memory. In multi-process deployments, writes to one process will not invalidate the caches of other processes, which can lead to stale data being served. Use with caution in distributed environments.
Database Routing
For multi-database setups, djangors-orm provides metadata-level routing via ModelDatabaseMap. This allows you to map specific model struct names to distinct database instances:
#![allow(unused)]
fn main() {
use djangors_orm::ModelDatabaseMap;
// Construct routing map
let routing_map = ModelDatabaseMap::new(
default_db,
vec![
("User", auth_db),
("Session", session_db),
],
);
// Retrieve the database configured for a specific model
let db = routing_map.db_for::<User>();
}
Class-Based Views (djangors-views)
djangors-views provides server-rendered generic class-based views — Django’s
django.views.generic equivalent in Djangors. It ships five generic views
(ListView, DetailView, CreateView, UpdateView, DeleteView) that turn a
#[derive(Model)] type into CRUD pages rendered through the
template engine.
When to reach for it
Reach for djangors-views when you are building a server-rendered HTML
application — pages that return text/html rendered from templates, where
forms submit via application/x-www-form-urlencoded and the browser follows a
redirect on success.
It is not the right tool for JSON APIs. For a JSON/fetch()-based API over
the same model use djangors-rest’s ViewSet (see the
REST Framework guide), which emits list/create/
retrieve/update/destroy routes and serialized JSON bodies instead of
rendered HTML.
The five views
| View | Signature (generic bound) | Renders / redirects |
|---|---|---|
ListView<M> | M: Model + FromRow | Renders object_list |
DetailView<M> | M: Model + FromRow | Renders object (looked up by pk) |
CreateView<M> | M: Model + FromRow + ModelForm | GET renders a blank form; POST inserts + redirects |
UpdateView<M> | M: Model + FromRow + ModelForm | GET pre-fills a form; POST updates by pk + redirects |
DeleteView<M> | M: Model + FromRow | GET renders a confirm page; POST deletes by pk + redirects |
Every view is an async fn that takes (req: Request, params: PathParams, config: &ViewSetConfig) and returns Result<Response, DjangorsError>.
Configuration (ViewSetConfig)
Each view needs a ViewSetConfig
that tells it which TemplateEngine to render with, which template to use, and
where to redirect after a successful write:
use std::sync::LazyLock;
use djangors_template::TemplateEngine;
use djangors_views::ViewSetConfig;
fn main() {
static TEMPLATES: LazyLock<TemplateEngine> = LazyLock::new(|| {
TemplateEngine::new(vec!["templates".into()]).expect("load templates")
});
// The engine is a `&'a TemplateEngine` inside the config, so it must outlive
// every handler. A `static` (via `LazyLock`) is the usual way to satisfy that.
let config = ViewSetConfig {
engine: &*TEMPLATES,
template_name: "polls/question_list.html",
success_url: "/polls/",
};
let _ = &config;
}
Registering all five views on a Router
Router handlers are Fn(Request, PathParams) -> Future; the view functions
take the config as an extra argument, so wrap each one in a closure that builds
(or references) its config. Because the config borrows the engine, building the
config inside the closure from a 'static engine keeps the closures 'static:
#![allow(unused)]
fn main() {
use std::sync::LazyLock;
use djangors_core::Router;
use djangors_template::TemplateEngine;
use djangors_views::{CreateView, DeleteView, DetailView, ListView, UpdateView, ViewSetConfig};
use polls::models::{Choice, Question};
static TEMPLATES: LazyLock<TemplateEngine> = LazyLock::new(|| {
TemplateEngine::new(vec!["templates".into()]).expect("load templates")
});
fn question_routes() -> Router {
Router::new()
.get("/polls/", |req, params| async move {
let config = ViewSetConfig {
engine: &*TEMPLATES,
template_name: "polls/question_list.html",
success_url: "/polls/",
};
ListView::<Question>::list(req, params, &config).await
})
.post("/polls/new/", |req, params| async move {
let config = ViewSetConfig {
engine: &*TEMPLATES,
template_name: "polls/question_form.html",
success_url: "/polls/",
};
CreateView::<Question>::create(req, params, &config).await
})
.get("/polls/{pk}/", |req, params| async move {
let config = ViewSetConfig {
engine: &*TEMPLATES,
template_name: "polls/question_detail.html",
success_url: "/polls/",
};
DetailView::<Question>::detail(req, params, &config).await
})
.post("/polls/{pk}/edit/", |req, params| async move {
let config = ViewSetConfig {
engine: &*TEMPLATES,
template_name: "polls/question_form.html",
success_url: "/polls/",
};
UpdateView::<Question>::update(req, params, &config).await
})
.post("/polls/{pk}/delete/", |req, params| async move {
let config = ViewSetConfig {
engine: &*TEMPLATES,
template_name: "polls/question_confirm_delete.html",
success_url: "/polls/",
};
DeleteView::<Question>::delete(req, params, &config).await
})
.post("/choices/new/", |req, params| async move {
let config = ViewSetConfig {
engine: &*TEMPLATES,
template_name: "polls/choice_form.html",
success_url: "/polls/",
};
CreateView::<Choice>::create(req, params, &config).await
})
}
}
Important
The
{pk}path param is read viaPathParams::get_as::<i64>("pk"). The route must expose a segment namedpk, so name it{pk}(or{pk:i64}) in the path pattern — not{id}/{question_id}.
Template context variables
Each view renders the configured template with a fixed set of context variables:
| View | object_list | object | fields | form | errors |
|---|---|---|---|---|---|
ListView | all rows | — | — | — | — |
DetailView | — | one row | — | — | — |
CreateView (GET) | — | — | field names | — | {} empty |
CreateView (POST error) | — | — | field names | submitted data | per-field + __all__ |
UpdateView (GET) | — | one row | field names | — | {} empty |
UpdateView (POST error) | — | one row | — | submitted data | per-field + __all__ |
DeleteView (GET) | — | one row | — | — | — |
object_list— an array of row objects, each keyed by field name (e.g.{{ o.question_text }}).object— a single row object keyed by field name (e.g.{{ object.question_text }}).fields— the model’s field names (fromModel::field_names()), useful for rendering an empty form’s inputs.form— the submittedHashMap<String, String>on a validation error, so you can re-populate the inputs (value="{{ form['question_text'] }}").errors— an object mapping each invalid field name to its error message, plus a__all__key for non-field errors. On the initial GET it is an empty object{}.
The HTML form contract (CreateView / UpdateView)
CreateView and UpdateView expect an application/x-www-form-urlencoded
POST body whose field names match the model’s field names. The model must
implement djangors_orm::ModelForm — which #[derive(Model)] provides by
generating validate_form(), from_cleaned_form(), and apply_cleaned_form().
CreateView(POST) — submitted data is parsed with theFormextractor, thenM::validate_form(&data)runs. On success the cleaned values build a new instance viafrom_cleaned_form(), the row is inserted, and the response redirects tosuccess_url. On failure the form template is re-rendered withfields,form, anderrors.UpdateView(POST) — the row forpkis fetched, thenM::validate_form(&data)runs. On successapply_cleaned_form()mutates the fetched instance, all non-primary-key fields are written back, and the response redirects tosuccess_url. On failure the form template is re-rendered withobject,form, anderrors.
A minimal create form template for Question:
<form method="post" action="/polls/new/">
<label>Question text
<input name="question_text" value="{{ form['question_text'] }}">
</label>
<label>Published
<input name="pub_date" type="datetime-local" value="{{ form['pub_date'] }}">
</label>
{% if errors['question_text'] %}<span>{{ errors['question_text'] }}</span>{% endif %}
<button type="submit">Create</button>
</form>
Prerequisites
- The model must be a
#[derive(Model)]type.ListView/DetailView/DeleteViewneedM: Model + FromRow(both provided by#[derive(Model)]).CreateView/UpdateViewadditionally needM: ModelForm— also generated by#[derive(Model)].
- A
djangors_orm::djangors_db::Databasemust be in the request state, because every view callsreq.require_state::<Database>()to fetch rows. Attach it withRouter::with_state(db)(orRequest::with_state). - A
TemplateEnginemust be passed viaconfig.engine. The views only read the engine from the config — the engine is not pulled from request state, so you are free to keep it anywhere (astatic, app state, etc.) as long as it outlives the handlers. - The configured
template_namemust exist in one of the engine’s search directories, or rendering fails with an internal error.
Note
The
Databasecomes from request state, but theTemplateEnginecomes exclusively from the config. These are two separate mechanisms — don’t assume the engine is discoverable from the request.
Limitations
- No authentication or permissions built in. Views return
Responsedirectly and perform no login/permission checks. Wrap them in your own middleware, or check the current user inside a wrapper handler, usingdjangors-auth(see the Authentication guide). - No pagination.
ListViewfetches every row withQuerySet::all()and renders them all. There is no page-size or pagination control. ListView/DetailVieware render-only. They do not branch on the HTTP method — they always render. OnlyCreateView/UpdateView/DeleteViewdistinguish GET from POST (anything that is not GET is treated as POST).- URL-encoded forms only.
CreateView/UpdateViewparse the body withForm::<HashMap<String, String>>::from_request, i.e. multipart or file uploads are not handled. - Integer primary keys. Records are looked up and deleted by an
i64pk(params.get_as::<i64>("pk")); a non-integerpkis aBadRequest. - The
pkname is fixed. Detail/update/delete look up thepkpath parameter by name, so your route must use{pk}.
Templates
djangors-template provides Django-style HTML template rendering powered by MiniJinja.
TemplateEngine
The core rendering engine is TemplateEngine.
Filesystem Loading (TemplateEngine::new)
Applications load templates from disk by providing search directories:
fn main() -> Result<(), Box<dyn std::error::Error>> {
use djangors_template::TemplateEngine;
use std::path::PathBuf;
let engine = TemplateEngine::new(vec![
PathBuf::from("templates"),
PathBuf::from("apps/polls/templates"),
])?;
Ok(())
}
Search directories are checked in the specified order. The first directory containing the requested template name wins, supporting Django’s template override pattern (project-level templates override app-level templates).
Embedded Loading (TemplateEngine::from_embedded)
Library crates (such as djangors-admin) compile templates directly into the binary using include_str!:
fn main() -> Result<(), Box<dyn std::error::Error>> {
let engine = djangors_template::TemplateEngine::from_embedded(&[
("admin/index.html", "<h1>Index</h1>"),
("admin/base.html", "<h1>Base</h1>"),
])?;
Ok(())
}
Rendering Templates
Direct Rendering
Render a template into a String with any serializable context (Serialize):
fn main() -> Result<(), Box<dyn std::error::Error>> {
let engine = djangors_template::TemplateEngine::from_embedded(&[("index.html", "Hello {{ name }}")]).unwrap();
#[derive(serde::Serialize)]
struct Context {
name: String,
}
let html: String = engine.render("index.html", &Context { name: "Alice".into() })?;
Ok(())
}
HTTP Response Helper (render)
Constructs an HTTP Response object (200 OK, text/html content type):
fn main() -> Result<(), Box<dyn std::error::Error>> {
let engine = djangors_template::TemplateEngine::from_embedded(&[("polls/index.html", "Hello")]).unwrap();
let context = ();
use djangors_template::render;
use djangors_core::Response;
let response: Response = render(&engine, "polls/index.html", &context)?;
Ok(())
}
Auto-escaping Behavior
Auto-escaping is context-aware based on template filenames:
- Files ending in
.htmlor.htmautomatically enable HTML escaping (AutoEscape::Html). HTML characters like<,>,&,", and'are escaped. - Other extensions (e.g.
.txt) default to no escaping (AutoEscape::None).
<!-- Input: "<script>alert(1)</script>" -->
<!-- In unsafe.html -->
{{ value }} <!-- Output: <script>alert(1)</script> -->
<!-- In unsafe.txt -->
{{ value }} <!-- Output: <script>alert(1)</script> -->
Registered Template Filters
djangors-template includes built-in Django filters:
| Filter | Example Usage | Description / Output |
|---|---|---|
date | {{ val|date('Y-m-d H:i:s') }} | Formats dates using strftime syntax |
floatformat | {{ val|floatformat(2) }} | Formats floating point numbers (e.g. 34.232 -> 34.23). Negative argument (e.g. -2) omits decimal zeroes if whole. |
pluralize | {{ count|pluralize }} or {{ count|pluralize('y,ies') }} | Returns 's' (or custom suffix) if count != 1 |
truncatewords | {{ val|truncatewords(3) }} | Truncates text after N words, adding … |
intcomma | {{ 1234567|intcomma }} | Formats integers with comma separators ("1,234,567") |
filesizeformat | {{ 1048576|filesizeformat }} | Formats bytes into human-readable size ("1.0 MB", "500 bytes", "1.5 KB") |
naturaltime | {{ val|naturaltime }} | Formats ISO-8601 datetimes as relative strings ("just now", "5 minutes ago", "in 5 minutes") |
trans | {{ "Welcome"|trans }} | Internationalization translation filter (via djangors-i18n) |
default | {{ val|default('N/A', true) }} | Built-in fallback value if val is undefined or falsy |
Template Functions
In djangors-template, template helpers are invoked as functions using {{ ... }} variable expression syntax rather than statement tags {% ... %}:
| Function | Example Usage | Description / Output |
|---|---|---|
url | {{ url('poll-detail', id=1) }} | Resolves a named route to an absolute URL path. Requires set_url_resolver on TemplateEngine. |
static | {{ static('css/app.css') }} | Joins configured static URL prefix (default /static/) and path with exactly one slash. |
csrf_token | {{ csrf_token() }} | Renders safe <input type="hidden" name="csrfmiddlewaretoken" value="..."> with HTML-attribute-escaped token value. |
now | {{ now('%Y-%m-%d') }} | Formats current UTC time with a chrono strftime format string. |
Note
Django uses statement tags such as
{% static "..." %}and{% csrf_token %}. Because MiniJinja 2.21 uses function expressions, Djangors uses function call syntax ({{ static('...') }}and{{ csrf_token() }}).
Wiring Named Routes with set_url_resolver
To support {{ url(...) }}, wire your router to the TemplateEngine using set_url_resolver:
fn main() -> Result<(), Box<dyn std::error::Error>> {
use djangors_core::Router;
use djangors_template::TemplateEngine;
let mut engine = TemplateEngine::new(vec!["templates".into()])?;
let router = Router::new()
.get("/polls/{id}", |_, _| async { Ok(djangors_core::Response::text(djangors_core::StatusCode::OK, "ok")) })
.name("poll-detail");
let router_clone = router.clone();
engine.set_url_resolver(move |name, params| {
let params_refs: Vec<(&str, &str)> = params.iter().map(|(k, v)| (k.as_str(), v.as_str())).collect();
router_clone.reverse(name, ¶ms_refs).ok()
});
Ok(())
}
Forms & Form Processing
djangors-forms provides field validation, HTML widgets, layout renderers, and formsets, while djangors-core provides typed request body extraction for URL-encoded form POST submissions.
The Form<T> Extractor
The Form<T> struct (djangors_core::extract::Form) extracts and deserializes form data submitted via HTTP POST requests (application/x-www-form-urlencoded).
#![allow(unused)]
fn main() {
use djangors_core::extract::{Form, FromRequest};
use djangors_core::{DjangorsError, Request, Response, StatusCode};
use serde::Deserialize;
#[derive(Debug, Deserialize)]
pub struct VoteForm {
pub choice: i64,
}
pub async fn vote_handler(req: Request) -> Result<Response, DjangorsError> {
// Deserialize URL-encoded request body bytes into VoteForm
let Form(form) = Form::<VoteForm>::from_request(&req).await?;
println!("Voted for choice ID: {}", form.choice);
Ok(Response::text(StatusCode::OK, "Vote recorded"))
}
}
Form Widgets & HTML Escaping
djangors-forms ships a suite of HTML form widgets implementing the Widget trait. All widget output, including field names, values, option labels, and HTML attributes, is escaped against cross-site scripting (XSS).
Available widgets:
TextInput:<input type="text">Textarea:<textarea>NumberInput:<input type="number">CheckboxInput:<input type="checkbox">(renderscheckedfor boolean truthy values like"true","on","1")Select:<select>with<option>choicesRadioSelect: Radio inputs with label containersDateInput:<input type="date">EmailInput:<input type="email">PasswordInput:<input type="password">(by default,render_value = falseto prevent echo of cleartext passwords)HiddenInput:<input type="hidden">
#![allow(unused)]
fn main() {
use djangors_forms::{TextInput, PasswordInput, Select, Widget, WidgetAttrs};
let text_widget = TextInput;
let html = text_widget.render("username", Some("alice"), &WidgetAttrs::from([("class", "form-control")]));
let password_widget = PasswordInput::new(); // render_value = false by default
let pass_html = password_widget.render("password", Some("secret"), &WidgetAttrs::new());
}
Form Layout Renderers (as_div, as_table, as_p)
Use BoundField to bind fields to widgets, values, labels, help text, and errors, then render using layout helpers:
#![allow(unused)]
fn main() {
use djangors_forms::{as_div, as_p, as_table, BoundField, TextInput};
let name_widget = TextInput;
let field = BoundField::new("first_name", &name_widget)
.with_value(Some("Alice"))
.with_help_text("Enter your given name")
.with_errors(vec![]);
let div_html = as_div(&[field], &[]);
}
as_div: Renders fields inside<div>containers.as_p: Renders fields inside<p>paragraphs.as_table: Renders fields as<tr><th><label></th><td><widget></td></tr>table rows.
BoundField rendering helpers
Beyond the whole-layout helpers, a BoundField
exposes granular renderers you can compose into custom layouts:
| Method | Returns | Notes |
|---|---|---|
id_for_label() | String | The element id (attrs["id"], else "id_<name>") |
label_tag() | String | <label for="...">…</label> (sentence-cased name unless overridden) |
render_widget() | String | The widget’s HTML for this field |
render_errors() | String | <ul class="errorlist"><li>…</li></ul> (empty when no errors) |
render_help_text() | String | <span class="helptext">…</span> (empty when none) |
#![allow(unused)]
fn main() {
use djangors_forms::{BoundField, TextInput};
let field = BoundField::new("first_name", &TextInput)
.with_value(Some("Alice"))
.with_help_text("Enter your given name")
.with_errors(vec!["Too long.".to_string()]);
let id = field.id_for_label(); // "id_first_name"
let label = field.label_tag(); // <label for="id_first_name">First name</label>
let widget = field.render_widget(); // <input type="text" ... name="first_name" ...>
let errors = field.render_errors(); // <ul class="errorlist"><li>Too long.</li></ul>
let help = field.render_help_text(); // <span class="helptext">Enter your given name</span>
}
All output is HTML-escaped, including the field name, values, labels, and error/help text.
FormSets & Security
FormSet manages multiple instances of a form submitted in a single request.
Key features:
- Management Form: Renders
TOTAL_FORMSandINITIAL_FORMSas hidden inputs. - Prefixing: Prefixing field names per form (
form-0-title,form-1-title, …). - Deletion:
can_deleteoption renders aDELETEcheckbox per form. - Security Cap:
TOTAL_FORMSis checked againstmax_num(default1000) before allocation to prevent memory-exhaustion attacks.
#![allow(unused)]
fn main() {
use djangors_forms::{FormSet, FormSetFormResult};
use std::collections::HashMap;
let formset: FormSet<()> = FormSet::new()
.with_prefix("author")
.with_counts(2, 1)
.with_can_delete(true);
let mgmt_form_html = formset.render_management_form();
let mut post_data = HashMap::new();
post_data.insert("author-TOTAL_FORMS".to_string(), "1".to_string());
post_data.insert("author-INITIAL_FORMS".to_string(), "0".to_string());
post_data.insert("author-0-name".to_string(), "Jane Doe".to_string());
let results = formset.clean_with(&post_data, |map| {
Ok(map.get("name").cloned().unwrap_or_default())
});
}
FormSet field-naming & rendering helpers
| Method | Signature | Notes |
|---|---|---|
with_max_num(max) | fn(self, usize) -> Self | Raises/lowers the TOTAL_FORMS security cap (default 1000) |
add_prefix(index, field) | fn(&self, usize, &str) -> String | Builds "<prefix>-<index>-<field>" (e.g. author-0-title) |
render_delete_checkbox(index, checked) | fn(&self, usize, bool) -> String | Renders the <input type="checkbox" name="...-DELETE"> (empty unless can_delete) |
#![allow(unused)]
fn main() {
use djangors_forms::FormSet;
let formset: FormSet<()> = FormSet::new()
.with_prefix("author")
.with_counts(2, 1)
.with_max_num(5) // cap TOTAL_FORMS before allocation (default 1000)
.with_can_delete(true);
let name = formset.add_prefix(0, "title"); // "author-0-title"
let delete_box = formset.render_delete_checkbox(0, false); // <label ...>… Delete</label>
}
add_prefix is what you should use when reading individual prefixed fields back from submitted
data, and render_delete_checkbox returns an empty string unless can_delete is enabled — so
templates can call it unconditionally.
How Form Extraction Works
Form<T>::from_request(&req).awaitreads the full request body bytes viareq.body_bytes().await.- Body bytes are deserialized into type
T(which must implementserde::de::DeserializeOwned) usingserde_urlencoded::from_bytes. - Error Handling:
- If the request body contains missing required fields or invalid data types for
T, extraction returnsDjangorsError::BadRequest(msg)containing"failed to parse form body: ..."with HTTP status400 Bad Request.
- If the request body contains missing required fields or invalid data types for
Combining Extractors in Handlers
djangors-core extractors implement FromRequest:
#![allow(unused)]
fn main() {
use djangors_core::extract::{Form, FromRequest, Json, Query};
}
| Extractor | Source | Content Type / Format |
|---|---|---|
Form<T> | Request Body | application/x-www-form-urlencoded |
Json<T> | Request Body | application/json |
Query<T> | URI Query String | ?key=value&... |
Path Parameter Extraction
Path parameters are retrieved using extract_path_param:
#![allow(unused)]
fn main() {
use djangors_core::{Request, PathParams, Response, DjangorsError};
use djangors_core::extract::extract_path_param;
pub async fn detail(req: Request, params: PathParams) -> Result<Response, DjangorsError> {
let question_id: i64 = extract_path_param(¶ms, "id")?;
let _ = (req, question_id);
Ok(Response::text(djangors_core::StatusCode::OK, ""))
}
}
The #[derive(Form)] validation macro
For validation on top of plain Deserialize, #[derive(Form)] generates a
clean(&HashMap<String, String>) method with per-field rules declared in
attributes. Unlike Deserialize it runs your rules and returns a structured
FormErrors (per-field messages) instead of a single parse error.
#![allow(unused)]
fn main() {
#[derive(djangors_forms::Form, Debug)]
struct SignupForm {
#[djangors(max_length = 150, required = true)]
username: String,
#[djangors(email, required = true)]
email: String,
#[djangors(min = 13)]
age: i64,
}
#[allow(dead_code)]
fn run(data: &std::collections::HashMap<String, String>) {
match SignupForm::clean(data) {
Ok(cleaned) => { println!("email={}", cleaned.email); }
Err(errors) => { /* errors.fields: HashMap<String, FieldError> */ }
}
}
}
Supported field attributes: max_length + required = true + email on
String, min/max/required = true on i64/i32, required = true on
bool. (For required, the value form with = true is required — a bare
required flag is not accepted.) The generated {Form}Cleaned struct’s
visibility mirrors the form’s own.
Model-generated forms (validate_form / apply_cleaned_form)
#[derive(Model)] also generates a real Django-ModelForm-equivalent directly
on the model — no second struct required. Auto/PK fields and FileFields are
excluded automatically, exactly like save()’s INSERT.
#![allow(unused)]
fn main() {
use djangors_macros::Model;
use djangors_orm::Model;
use std::collections::HashMap;
#[derive(Model, Debug, Clone)]
#[djangors(app = "library", table_name = "library_contact")]
struct Contact { #[djangors(primary_key, auto)] id: i64, #[djangors(max_length = 100)] name: String, email: String }
#[allow(dead_code)]
async fn insert_contact(data: HashMap<String, String>, db: &djangors_db::Database) -> Result<(), djangors_orm::OrmError> {
// `validate_form` returns `Result<FormCleaned, FormErrors>`; FormErrors is a
// value (field-keyed messages), not a std::error::Error — match on it and
// re-render the form with `errors.fields` rather than using `?`.
let contact = match Contact::validate_form(&data) {
Ok(cleaned) => Contact::from_cleaned_form(cleaned), // fresh instance
Err(_errors) => return Ok(()), // re-render with errors.fields
};
contact.save(db).await?; // INSERT
Ok(())
}
}
The three generated methods map onto the create and update paths:
| Method | Create path | Update path |
|---|---|---|
validate_form(&map) | Validate the post body | Same, against the same fields |
from_cleaned_form(cleaned) | Build a new instance, then save() | — |
apply_cleaned_form(cleaned) | — | Apply onto an existing instance (leaving the PK untouched), then update() |
apply_cleaned_form takes a &mut self, so the update path is: fetch the row,
validate_form the submitted data, apply_cleaned_form, then update(db) —
exactly the shape djangors-views’ UpdateView implements for you.
Use with djangors-views’ generic CreateView/UpdateView (see the
Class-Based Views guide) to get a full
form-render-and-submit flow without writing a handler by hand.
Flash Messages (djangors-contrib-messages)
One-shot, session-backed notifications (“Profile updated successfully”, “Payment declined”) that you queue during one request and render during the next. Views add messages, and the page-rendering call site drains them into template context.
Public API
| Item | Description |
|---|---|
enum Level | Severity, mirroring Django message levels: Debug, Info, Success, Warning, Error. |
struct Message | A queued notification with public fields level: Level and text: String. Built with Message::new(level, text). |
add(session, level, text) | Pushes a message onto the session queue. |
take(session) -> Vec<Message> | Consumes and drains the queue, returning it. A second call returns an empty vector. |
add_debug / add_info / add_success / add_warning / add_error | Shorthands for add with a fixed Level. |
All of the above take &djangors_sessions::Session.
fn main() {
use djangors_contrib_messages as messages;
use djangors_contrib_messages::Level;
use djangors_sessions::Session;
let session = Session::new_empty();
messages::add(&session, Level::Info, "Sync started");
messages::add_debug(&session, "loaded 42 rows");
messages::add_success(&session, "Profile updated successfully!");
messages::add_warning(&session, "Storage nearly full");
messages::add_error(&session, "Upstream unreachable");
let queued = messages::take(&session);
assert_eq!(queued.len(), 5);
// take drains the queue; a second call finds nothing.
assert!(messages::take(&session).is_empty());
}
How it Works
Messages are stored under the _djangors_messages key in the request’s session. The add helpers append to whatever is already queued and persist the whole list back; take reads the list and removes the key. Because of this drain-on-read, the classic POST-then-GET flow “add in the POST, render in the next GET” stays clean — the message appears exactly once.
#![allow(unused)]
fn main() {
use djangors_contrib_messages as messages;
use djangors_core::{DjangorsError, Request, Response, StatusCode};
use djangors_sessions::Session;
async fn save_profile(req: Request) -> Result<Response, DjangorsError> {
let session = req.require_state::<Session>()?;
messages::add_success(session, "Profile updated successfully!");
Ok(Response::redirect("/profile/"))
}
async fn profile(req: Request) -> Result<Response, DjangorsError> {
let session = req.require_state::<Session>()?;
let context = messages::take(session); // drains the queue for this request
Ok(Response::html(
StatusCode::OK,
format!("<ul>{}</ul>", context.iter().map(|m| m.text.clone()).collect::<Vec<_>>().join("</li><li>")),
))
}
}
Because a handler reads messages before the response is built, context is available as template context in the GET request that follows the POST.
Sessions Middleware
The add/take helpers operate on the Session handle, so requests must flow through the SessionLayer middleware (djangors-sessions) before they reach these handlers. See Sessions and CSRF Protection for how to construct and mount it.
Note
Djangors sessions are client-side signed cookies: queued flash messages round-trip to the browser inside the cookie. Keep messages short — a single
Messageshould be a sentence or two, not a rendered HTML fragment — so the cookie stays small and requests against it stay fast.
Flat Pages and Redirects
Two database-driven, admin-editable cousins of Django’s django.contrib.flatpages and django.contrib.redirects. Content editors manage rows in the admin site and the DB drives the runtime behavior — there is no code to edit for a new page or redirect.
Flat Pages (djangors-contrib-flatpages)
A FlatPage is a standalone HTML page with a unique URL.
| Item | Description |
|---|---|
struct FlatPage | #[derive(Model)] with id, url: String (unique, max 255), title: String (max 255), content: String. Backed by the djangors_flatpage table. |
flatpage_handler(req, params) | Result<Response, DjangorsError>; looks up the exact request path and serves page.content as HTML (or DjangorsError::NotFound). |
flatpage_routes(router, paths) | Registers an explicit route per known URL — not a catch-all (Djangors v1 has no catch-all fallback). |
register_admin(&AdminSite) | Registers the model for admin editing. |
Because content is served as trusted HTML, only trusted staff should be allowed to edit the model.
#![allow(unused)]
fn main() {
use djangors_admin::AdminSite;
use djangors_core::Router;
use djangors_contrib_flatpages::{flatpage_routes, register_admin};
// Explicit, known URLs. Each one becomes a route.
let router = flatpage_routes(Router::new(), ["/about/", "/terms/", "/privacy/"]);
// Make the model editable from the admin site.
let admin = AdminSite::new();
register_admin(&admin);
}
Redirects (djangors-contrib-redirects)
A Redirect maps an incoming path to a destination.
| Item | Description |
|---|---|
struct Redirect | #[derive(Model)] with id, old_path: String (unique, max 255), new_path: String (max 255). Backed by the djangors_redirect table. |
lookup_redirect(req, status) | Result<Option<Response>, DjangorsError>; returns a redirect response (with a Location header) when a row matches, or None for clean fallthrough. |
redirect_handler(req, params) | Handler form that uses StatusCode::PERMANENT_REDIRECT and returns DjangorsError::NotFound when no row matches. |
redirect_routes(router, paths) | Registers explicit old paths as routes. |
register_admin(&AdminSite) | Registers the model for admin editing. |
#![allow(unused)]
fn main() {
use djangors_admin::AdminSite;
use djangors_core::{DjangorsError, Request, Response, Router, StatusCode};
use djangors_contrib_redirects::{lookup_redirect, redirect_routes, register_admin};
// Handler-based mounting for explicitly registered old paths.
let router = redirect_routes(Router::new(), ["/old-path/", "/old-news/campaign/"]);
// For true pre-routing fallthrough, call the lookup in an outer service:
async fn fallback(req: Request) -> Result<Response, DjangorsError> {
match lookup_redirect(&req, StatusCode::MOVED_PERMANENTLY).await? {
Some(response) => Ok(response), // row matched: follow the redirect
None => Ok(Response::text(StatusCode::NOT_FOUND, "Not found")),
}
}
// Make the model editable from the admin site.
let admin = AdminSite::new();
register_admin(&admin);
}
Admin Registration
Both crates expose a register_admin(&AdminSite) that registers their model with an existing djangors-admin site:
fn main() {
use djangors_admin::AdminSite;
use djangors_core::Router;
use djangors_contrib_flatpages::{flatpage_routes, register_admin as register_flatpages_admin};
use djangors_contrib_redirects::{redirect_routes, register_admin as register_redirects_admin};
let router = flatpage_routes(Router::new(), ["/about/", "/terms/"]);
let router = redirect_routes(router, ["/old-path/", "/old-news/campaign/"]);
let admin = AdminSite::new();
register_flatpages_admin(&admin);
register_redirects_admin(&admin);
}
With the models registered, page editors manage the djangors_flatpage and djangors_redirect tables from the admin interface instead of asking developers for changes.
Note
Both crates are fully DB-driven: the model tables must exist before the handlers run. Run your schema migration for
djangors_contrib_flatpages(tabledjangors_flatpage) anddjangors_contrib_redirects(tabledjangors_redirect) — see Schema Migrations.
Note
Admin editing requires
djangors-adminin your dependency tree and a staff superuser account, created withdj createsuperuser. See the Admin Site guide.
HTTP Core and Middleware
Djangors’s HTTP kernel (djangors-core) supplies the Request/Response types, the Router,
Tower middleware layers, error handling, pagination primitives, and logging setup that every
handler, admin site, and REST viewset builds on. The highest-level use is covered in the
tutorial; this guide documents the core surface directly against the source.
Request
The Request wraps a fully
buffered hyper request. Everything (including the body) is read eagerly at construction time, so
handlers take Request by value and are free to move it into spawned tasks.
Key methods (see crates/djangors-core/src/request.rs):
| Method | Signature | Notes |
|---|---|---|
new | new(method: Method, uri: Uri, headers: HeaderMap, body: Bytes) -> Self | Parses + URL-decodes the query string at construction |
method | fn(&self) -> &Method | HTTP verb |
path | fn(&self) -> &str | URI path, e.g. "/hello/world" |
header | fn(&self, name: &str) -> Option<&HeaderValue> | Single header lookup |
headers | fn(&self) -> &HeaderMap | All headers |
query | fn(&self, name: &str) -> Option<&str> | Decoded query parameter |
raw_query | fn(&self) -> Option<&str> | Undecoded query string, e.g. "q=rust&page=2" |
body_bytes | async fn(&self) -> &[u8] | Buffered body (trivially async) |
state | fn<T: Send + Sync + 'static>(&self) -> Option<&T> | Top-level app state (see below) |
require_state | fn<T>(&self) -> Result<&T, DjangorsError> | Like state, but DjangorsError::Internal when missing |
ext | fn<T: Send + Sync + 'static>(&self) -> Option<&T> | Per-request extension map |
with_state | fn(self, state: AppState) -> Self | Attach app state |
with_extensions | fn(self, extensions: Extensions) -> Self | Replace per-request extensions |
into_parts | fn(self) -> (Method, Uri, HeaderMap, Bytes) | Consume and split |
State vs ext: two very different kinds of typed value
Djangors has two separate type maps and the distinction matters:
stateis app-wide, configured once at startup viaRouter::with_state(...), and stored in anAppStateon the request. Use it for long-lived singletons every handler needs — aDatabaseconnection pool, aGroupsbroadcast registry, a storage backend.extis a per-request map of values inserted by middleware into the incoming hyper request’sExtensions(whichRouter::dispatchpropagates viawith_extensions). Use it for request-scoped facts middleware computes — the resolvedSession,CurrentTenant,CsrfToken,ResolvedLocale.
state::<T>() is persistent and shared; ext::<T>() is per-request scratch memory. A session
handle belongs in ext; the database pool it was loaded from belongs in state.
fn main() {
use djangors_core::{Request, PathParams, Response, Router, DjangorsError, StatusCode};
#[derive(Clone, Debug)]
struct Database;
// require_state() errors with a descriptive DjangorsError if the type
// was never attached — a wiring mistake surfaces fast at first request.
async fn index(req: Request, _params: PathParams) -> Result<Response, DjangorsError> {
let db = req.require_state::<Database>()?;
let _ = db;
Ok(Response::text(StatusCode::OK, "ok"))
}
let router = Router::new()
.with_state(Database)
.get("/", index);
let _ = router;
}
#![allow(unused)]
fn main() {
use djangors_core::{Request, PathParams, Response, DjangorsError, StatusCode};
use djangors_core::middleware::CsrfToken;
async fn show_form(req: Request, _params: PathParams) -> Result<Response, DjangorsError> {
// Middleware injected this per-request (via csrf_layer).
let token = req.ext::<CsrfToken>().expect("csrf layer must be installed");
Ok(Response::html(
StatusCode::OK,
format!(r#"<input name="csrfmiddlewaretoken" value="{}">"#, token.0),
))
}
}
For an end-to-end state + ext example see the router tests (test_router_app_state and
test_dispatch_propagates_hyper_extensions_to_handler in crates/djangors-core/src/router.rs).
Response
Response is a fully buffered
response built with helper constructors and converted to hyper with into_hyper.
| Constructor | Signature | Notes |
|---|---|---|
text | text(status: StatusCode, body: &str) -> Self | text/plain; charset=utf-8 |
html | html(status: StatusCode, body: impl Into<String>) -> Self | text/html; charset=utf-8 |
bytes | bytes(status: StatusCode, content_type: &str, body: Vec<u8>) -> Self | Raw bytes, explicit content type |
json | json<T: Serialize>(status: StatusCode, value: &T) -> Result<Self, DjangorsError> | application/json; charset=utf-8; DjangorsError::Internal on serialize failure |
redirect | redirect(location: &str) -> Self | 302 Found + Location |
sse | sse<S>(stream: S) -> StreamingResponse where S: Stream<Item = String> | Builds an SSE stream (see How to Stream Server-Sent Events) |
header | header(name: &str, value: &str) -> Self | Builder, overwrites existing value |
into_hyper | into_hyper(self) -> hyper::Response<Full<Bytes>> | Converts for the server/Service path |
into_hyper_boxed | into_hyper_boxed(self) -> hyper::Response<BoxBody<Bytes, Infallible>> | Boxed-body variant |
status / headers / body | accessors | Read back the buffered pieces |
fn main() {
use djangors_core::{Response, StatusCode};
use serde_json::json;
let json_resp = Response::json(StatusCode::OK, &json!({ "count": 3 })).unwrap();
let bytes_resp = Response::bytes(
StatusCode::OK,
"image/png",
vec![1, 2, 3, 4],
);
let html_resp = Response::html(StatusCode::NOT_FOUND, "<h1>nope</h1>");
let redirect = Response::redirect("/login");
let customized = Response::text(StatusCode::OK, "hello").header("X-Trace", "abc");
let _ = (json_resp, bytes_resp, html_resp, redirect, customized);
}
fn main() {
use djangors_core::{Response, StatusCode};
// json() returns Result — ? works from any handler returning DjangorsError.
fn build_status_response() -> Result<Response, djangors_core::DjangorsError> {
Ok(Response::json(
StatusCode::OK,
&serde_json::json!({ "healthy": true }),
)?)
}
let _ = build_status_response();
}
AppState
AppState is a type-erased map of
Send + Sync + 'static values indexed by TypeId. It is clone-cheap (wraps the map in an Arc),
which is why it can be copied onto every request. Router::with_state stores one; req.state::<T>()
reads from it.
| Method | Signature | Notes |
|---|---|---|
new | new() -> Self | Empty state |
insert | insert<T>(self, value: T) -> Self | Builder-style; returns a new AppState |
get | get<T>(&self) -> Option<&T> | Lookup by type |
contains | contains<T>(&self) -> bool | Type check |
len | fn(&self) -> usize | Number of distinct types |
is_empty | fn(&self) -> bool | No state attached |
merge | merge(self, other: &AppState) -> Self | Union; self wins on type conflicts (used by mount) |
insert is used internally by Router::with_state and by mount when a sub-router wants to
inherit the parent’s state (parent values win).
fn main() {
use djangors_core::AppState;
#[derive(Clone)]
struct Database;
#[derive(Clone)]
struct Cache;
let state = AppState::new()
.insert(Database)
.insert(Cache);
assert!(state.contains::<Database>());
assert_eq!(state.get::<Cache>().is_some(), true);
assert_eq!(state.len(), 2);
let merged = state.merge(&AppState::new().insert(Database));
assert!(!merged.is_empty());
}
Router extras
Beyond get/post/put/delete, the Router
exposes a lower-level route API, named-route reversing, and mounting. Path syntax supports literal
segments, {name} (String capture), {name:i64}, and {name:slug}. :name is also accepted as a
shorthand alias for {name}, for muscle memory carried over from Express/Django URLconfs — prefer
the typed {name}/{name:i64}/{name:slug} forms in new code, since they validate the segment at
match time instead of leaving that to the handler.
| Method | Signature | Notes |
|---|---|---|
route | route(path: &str, method: Method, handler: impl Handler) | Register with an explicit method |
route_streaming | route_streaming(path: &str, method: Method, handler: impl StreamingHandler) | Streaming handlers for non-GET too |
get_sse / sse | get_sse(path: &str, handler: impl StreamingHandler) | SSE GET helper (alias: sse) |
name | name(name: &str) -> Self | Name the most recently registered route (panics on duplicates) |
reverse | reverse(name: &str, params: &[(&str, &str)]) -> Result<String, ReverseError> | Path reconstruction; values are percent-encoded |
mount | mount(prefix: &str, sub_router: Router) -> Self | Prefixes every sub-router route; inherits sub-router state the parent lacks |
fn main() {
use djangors_core::{Request, PathParams, Response, Router, DjangorsError, StatusCode};
use hyper::Method;
async fn poll(_req: Request, _params: PathParams) -> Result<Response, DjangorsError> {
Ok(Response::text(StatusCode::OK, "poll"))
}
let router = Router::new()
.route("/polls/{id:i64}", Method::GET, poll)
.name("poll-detail")
.get_sse("/polls/{id:i64}/live", |_req: Request, _params: PathParams| {
async { Err(djangors_core::DjangorsError::NotFound) }
})
.mount("/api", Router::new().get("/users/{id}", poll).name("user-detail"));
assert_eq!(router.reverse("poll-detail", &[("id", "42")]).unwrap(), "/polls/42");
assert_eq!(router.reverse("user-detail", &[("id", "7")]).unwrap(), "/api/users/7");
}
Note
get_ssewas used here to keep the snippet self-contained; a real SSE handler returns aStreamingResponse. See How to Stream Server-Sent Events.
ReverseError (in djangors_core::router::ReverseError) has three variants:
UnknownName(String)— no route registered under that name.MissingParam { route, param }— the pattern needs a parameter the caller didn’t supply.UnexpectedParam { route, param }— the caller supplied a parameter the pattern doesn’t use.
Typed converters are not type-validated during reversing ({id:i64} won’t reject "abc"), but
captured values are always percent-encoded before substitution.
Middleware layers
All middleware lives in djangors_core::middleware and composes with
tower::ServiceBuilder
around RouterService::new(router, debug) — the same stack shape as
layer middleware in the Django world.
| Layer | Builder | Behavior |
|---|---|---|
logging_layer() | fn() -> TraceLayer | tower_http::trace, logs method/path/status/latency via tracing |
security_headers_layer() | fn() -> SecurityHeadersLayer | X-Content-Type-Options: nosniff, X-Frame-Options: DENY, Referrer-Policy: same-origin |
hsts_layer(max_age) | fn(max_age_secs: u64) -> HstsLayer | Strict-Transport-Security; .with_include_subdomains(bool) (default off) |
normalize_path_layer() | fn() -> NormalizePathLayer | Trims trailing slashes (Django CommonMiddleware-ish) |
compression_layer() | fn() -> CompressionLayer | gzip + brotli response compression |
request_id_layer() | fn() -> RequestIdLayer | Sets/propagates X-Request-ID (UUID) |
csrf_layer() | fn() -> CsrfLayer | Double-submit cookie CSRF; .with_cookie_name/.with_header_name/.with_secure |
Also available: HostValidationLayer::new(allowed_hosts) and CspBuilder — both documented in
depth in the Security guide.
The CsrfLayer injects a CsrfToken(String) extension on every request (plus a pending-check
marker for form bodies). Sessions and tenancy similarly communicate through ext; see
Sessions and CSRF Protection and Multi-tenancy.
fn main() {
use djangors_core::middleware::{
logging_layer, security_headers_layer, hsts_layer, normalize_path_layer,
compression_layer, request_id_layer, csrf_layer,
};
use djangors_core::router::RouterService;
use tower::ServiceBuilder;
let debug = false;
let router = djangors_core::Router::new();
let router_service = RouterService::new(router, debug);
let service = ServiceBuilder::new()
.layer(compression_layer()) // outermost; changes the body type
.layer(logging_layer())
.layer(request_id_layer())
.layer(normalize_path_layer())
.layer(security_headers_layer())
.layer(hsts_layer(31536000).with_include_subdomains(true))
.layer(csrf_layer().with_secure(!debug))
.service(router_service);
let _ = service;
}
Important
Layer order matters.
compression_layer()rewraps the response body, so anything that requires a plainFull<Bytes>body (e.g.security_headers_layer) must sit inside it — put compression first in theServiceBuilderchain as shown above.csrf_layer().with_secure(true)(andSignedCookieStore::with_secure) must be enabled off HTTP in production — see Security. Serve the stack withDjangors::serve_service(listener, service); the plainserve()/run()loops do not apply middleware layers.
DjangorsError
DjangorsError is the single error
type handlers return. Built-in variants map 1:1 to HTTP statuses, plus the application-defined
Api carrier:
| Variant | Status | Domain code |
|---|---|---|
NotFound | 404 | not_found |
BadRequest(String) | 400 | bad_request |
Internal(String) | 500 | internal |
Panicked(String) | 500 | panicked |
Unauthorized(String) | 401 | unauthorized |
Forbidden(String) | 403 | forbidden |
TooManyRequests(String) | 429 | too_many_requests |
Api(ApiError) | api.status | api.code |
Every variant exposes status_code(), code(), message(), and details().
ApiError and constructors
ApiError is a struct with public fields status: StatusCode, code: String, message: String,
and details: Option<serde_json::Value> — the escape hatch for domain errors that need a custom
status/code/JSON payload.
DjangorsError::api(status, code, message)builds theApivariant..with_details(json!(...))attaches a structured payload; when chained onto a built-in variant it promotes it toApi, preserving its status, code, and message.
ApiResultExt
ApiResultExt<T> is implemented for any Result<T, E: Display> and removes hand-written
map_err closures:
.api_err(status, code)— uses the source error’sDisplaytext as the message..api_err_msg(status, code, message)— fixed message, discards the source error’s text (use when the underlying error leaks internals).
fn main() {
use djangors_core::DjangorsError;
use djangors_core::error::ApiResultExt;
use djangors_core::StatusCode;
use serde_json::json;
fn parse_quantity(raw: &str) -> Result<u32, DjangorsError> {
raw.parse::<u32>().api_err(StatusCode::BAD_REQUEST, "invalid_quantity")
}
let err = DjangorsError::api(StatusCode::CONFLICT, "seat_taken", "That seat is already booked")
.with_details(json!({ "seat": "12A", "flight": "DL404" }));
assert_eq!(err.status_code(), StatusCode::CONFLICT);
assert_eq!(err.code(), "seat_taken");
assert!(err.details().is_some());
let _ = parse_quantity("x");
}
Custom rendering
By default the router renders errors as the JSON envelope {"error": {status, code, message, details?}}
when the caller accepts JSON or the error is an Api, otherwise as a debug page (when debug) or a
minimal production page. To take full control, implement the ErrorRenderer trait and register it with
Router::set_error_renderer(renderer); there is a ready-made JsonErrorRenderer:
#![allow(unused)]
fn main() {
use djangors_core::{DjangorsError, Request};
use djangors_core::error::{ErrorRenderer, JsonErrorRenderer};
use djangors_core::Response;
#[derive(Clone)]
struct MyErrorRenderer; // render every error as JSON, e.g. for a pure API
impl ErrorRenderer for MyErrorRenderer {
fn render(&self, err: &DjangorsError, req: &Request) -> Response {
JsonErrorRenderer.render(err, req)
}
}
fn configure(router: djangors_core::Router) -> djangors_core::Router {
router.set_error_renderer(MyErrorRenderer)
}
}
set_error_renderer is the ergonomic equivalent of the manual cast form:
#![allow(unused)]
fn main() {
Router::new().with_state(Arc::new(MyRenderer) as Arc<dyn ErrorRenderer>)
}
The helper exists because the as Arc<dyn ErrorRenderer> cast is easy to omit, and
with_state(Arc::new(MyRenderer)) compiles perfectly well while storing the value under its
concrete type — where the renderer lookup never finds it. The custom renderer is then silently
ignored, with no error at compile time or run time. That silent-failure mode is exactly what
set_error_renderer removes: it always performs the trait-object cast for you.
Paginator and cursor primitives
Paginator does the offset
math. total_items is clamped to ≥ 0 and page_size must be > 0; per the admin convention, 0 items
still yields 1 page.
| Method | Signature | Notes |
|---|---|---|
new | new(total_items: i64, page_size: i64) -> Self | Panics if page_size <= 0 |
total_pages | fn(&self) -> i64 | Ceiling division; 0 items ⇒ 1 page |
offset | fn(&self, page: i64) -> i64 | 0-indexed SQL OFFSET, clamped into [1, total_pages()] |
has_previous | fn(&self, page: i64) -> bool | page > 1 |
has_next | fn(&self, page: i64) -> bool | page * page_size < total_items |
Cursor helpers are the building blocks behind djangors-rest cursor pagination:
encode_cursor(pk: i64, order_value: Option<&str>) -> String— base64 ofpk|order-value(order value may itself contain|).decode_cursor(cursor: &str) -> Result<(i64, Option<String>), CursorError>.CursorErrorvariants:InvalidEncoding,InvalidFormat,InvalidPrimaryKey.CursorPage<T> { items: Vec<T>, next_cursor: Option<String>, previous_cursor: Option<String> }.
fn main() {
use djangors_core::{
Paginator, CursorPage, encode_cursor, decode_cursor,
};
let p = Paginator::new(250, 100);
assert_eq!(p.total_pages(), 3);
assert_eq!(p.offset(2), 100);
assert!(p.has_next(1));
assert!(p.has_previous(2));
assert!(!p.has_next(3));
let cursor = encode_cursor(42, Some("title|with|pipes"));
assert_eq!(
decode_cursor(&cursor).unwrap(),
(42, Some("title|with|pipes".to_string()))
);
let page: CursorPage<String> = CursorPage {
items: vec!["a".into(), "b".into()],
next_cursor: Some(cursor),
previous_cursor: None,
};
assert_eq!(page.items.len(), 2);
}
Logging setup
Call exactly one of these once, early in main() (before starting the server). A second call
is a harmless no-op (only one global tracing subscriber can be installed).
| Function | Output | Notes |
|---|---|---|
init_dev_logging() | compact colored console output | Default level "info,djangors_core=debug" |
init_production_logging() | structured JSON lines | Default level "info,djangors_core=info", ideal for log aggregators |
init_production_logging_with_sentry(dsn) (sentry feature) | JSON + Sentry capture | ERROR events → Sentry, WARN..TRACE → breadcrumbs, panics captured |
All three respect the standard RUST_LOG env var (e.g. RUST_LOG=djangors_core=trace).
init_production_logging_with_sentry returns a ClientInitGuard that must be held for the
process lifetime: let _guard = init_production_logging_with_sentry(&dsn);. An empty/invalid DSN
produces a disabled client rather than an error, so it is safe to call unconditionally.
fn main() {
use djangors_core::logging::{init_dev_logging, init_production_logging};
// Pick one per environment:
init_dev_logging();
// init_production_logging();
}
Management commands
#[management_command(name = "...")] (from djangors_macros, applied to an async fn taking
Vec<String> and returning nothing) registers a custom command through an inventory registry.
Two helper functions driven by env vars replace the CLI plumbing in main():
run_management_command_if_requested()— exits (with code 0) and runs the command named byDJANGORS_RUN_COMMAND; unknown names print an error and exit 1. Async — call it from#[tokio::main](it relies on the runtime already installed).introspect_models_if_requested()— dumps the compiled model registry as JSON and exits, whenDJANGORS_INTROSPECT_MODELS=1.
use djangors_macros::management_command;
#[management_command(name = "seed")]
async fn seed(_args: Vec<String>) {
println!("seeding database...");
}
#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
djangors_core::logging::init_dev_logging();
// dj run ... --env DJANGORS_RUN_COMMAND=seed, or DJANGORS_INTROSPECT_MODELS=1
djangors_core::run_management_command_if_requested().await;
djangors_core::introspect_models_if_requested();
// ... build settings + router, app.run().await
Ok(())
}
DJANGORS_RUN_COMMAND=seed cargo run
DJANGORS_INTROSPECT_MODELS=1 cargo run # prints JSON model registry
html_escape
djangors_core::html_escape(input: &str) -> String escapes HTML-significant characters
(< > & " ' and /) to their entities.
fn main() {
use djangors_core::html_escape;
let escaped = html_escape("<script>alert('x&y')</script>");
assert!(escaped.contains("<script>"));
assert!(escaped.contains("&"));
assert!(escaped.contains("'"));
}
Where to go next
- Requests and Responses tutorial — the fast path to a first endpoint.
- Security — CSRF, HSTS, host validation, CSP, and the production checklist.
- Sessions and CSRF Protection — session middleware and
SignedCookieStore. - How to Stream Server-Sent Events —
Response::sse/Router::get_ssein practice. - REST Framework — where
Paginator/cursor primitives surface as cursor pagination.
Authentication & Permissions
djangors-auth provides user management, authentication backends, session integration, Argon2id password hashing, and role-based permissions.
Custom User Models & AuthUser Trait
In Djangors, custom user model swapping is achieved at compile time via the AuthUser trait (rather than runtime settings strings):
#![allow(unused)]
fn main() {
use djangors_orm::FromRow;
#[async_trait::async_trait]
pub trait AuthUser: djangors_orm::Model + djangors_orm::FromRow + Send + Sync + 'static {
fn id(&self) -> i64;
fn username(&self) -> &str;
fn password_hash(&self) -> &str;
fn set_password_hash(&mut self, hash: String);
fn is_active(&self) -> bool;
fn is_superuser(&self) -> bool;
async fn update_user(&self, db: &djangors_db::Database) -> Result<(), djangors_orm::OrmError>;
}
}
The crate provides a built-in concrete User model implementing AuthUser:
#![allow(unused)]
fn main() {
use djangors_macros::Model;
#[derive(Model, Debug, Clone)]
#[djangors(app = "djangors_auth", table_name = "auth_user")]
pub struct User {
#[djangors(primary_key, auto)]
pub id: i64,
#[djangors(max_length = 150)]
pub username: String,
#[djangors(max_length = 254)]
pub email: String,
pub password: String, // PHC Argon2id hash string
pub is_active: bool,
pub is_staff: bool,
pub is_superuser: bool,
pub date_joined: chrono::DateTime<chrono::Utc>,
pub last_login: Option<chrono::DateTime<chrono::Utc>>,
}
}
Request Extraction (Auth<U>)
Extract the authenticated user in handlers using Auth<User>:
#![allow(unused)]
fn main() {
use djangors_auth::{Auth, User};
use djangors_core::extract::FromRequest;
use djangors_core::{DjangorsError, Request, Response, StatusCode};
pub async fn profile_view(req: Request) -> Result<Response, DjangorsError> {
// Extracts authenticated user from session; returns 401 Unauthorized if unauthenticated or inactive
let Auth(user) = Auth::<User>::from_request(&req).await?;
Ok(Response::text(StatusCode::OK, "Hello, authenticated user!"))
}
}
Authentication Backends
Authentication is decoupled via the AuthBackend trait:
#![allow(unused)]
fn main() {
pub trait AuthUser: djangors_orm::Model + djangors_orm::FromRow + Send + Sync + 'static {}
use djangors_auth::AuthError;
#[async_trait::async_trait]
pub trait AuthBackend {
type User: AuthUser;
async fn authenticate(
&self,
db: &djangors_db::Database,
username: &str,
password: &str,
) -> Result<Option<Self::User>, AuthError>;
}
}
ModelBackend
Authenticates against database user records using Argon2id password verification.
- Emits
LOGIN_SUCCEEDED/LOGIN_FAILEDsignals. - Runs a dummy password verification when username is missing to mitigate timing side-channel attacks.
RateLimitedBackend
Wraps any AuthBackend to enforce sliding-window rate limiting on login attempts:
fn main() {
use djangors_auth::{ModelBackend, RateLimitedBackend};
// Limits to 5 failed login attempts per 15 minutes per username
let backend = RateLimitedBackend::default_login_throttle(ModelBackend);
}
PersistentLockoutBackend (the django-axes equivalent)
RateLimitedBackend throttles the rate of attempts, but a correct password made after the
window resets still succeeds. It doesn’t lock the account. PersistentLockoutBackend is
different and complementary: after max_attempts consecutive failures, it rejects login attempts
with AuthError::AccountLocked { retry_after_secs } for lockout_duration, even with the
correct password. This state is stored in a real auth_login_lockout database table, so it
survives process restarts and is shared correctly across multiple app instances pointed at the
same database.
fn main() {
use djangors_auth::{ModelBackend, PersistentLockoutBackend};
use std::time::Duration;
// Lock an account for 1 hour after 5 consecutive failed attempts.
let backend = PersistentLockoutBackend::new(ModelBackend, 5, Duration::from_secs(3600));
}
A successful login clears the account’s failure streak entirely; an already-expired lockout is
treated as a fresh streak (starting from 1) rather than continuing to accumulate. The
LoginLockout model (#[derive(Model)], table auth_login_lockout) is a normal registered
model, so dj makemigrations picks it up automatically like any other.
Session Management (login / logout)
login
Establishes an authenticated session for a user. Rotates session key (session.cycle_key()) to prevent session fixation attacks, then stores user ID under _auth_user_id:
#![allow(unused)]
fn main() {
async fn test_login() -> Result<(), Box<dyn std::error::Error>> {
let session = djangors_sessions::Session::new_empty();
let backend = djangors_auth::ModelBackend;
let db = djangors_db::Database::connect(&djangors_db::config::DatabaseConfig::new("postgres://postgres:postgres@localhost/djangors_test")).await.unwrap();
let username = ""; let password = "";
use djangors_auth::{login, AuthBackend};
if let Some(user) = backend.authenticate(&db, &username, &password).await? {
login(&session, &user);
}
Ok(())
}
}
logout
Clears session data (session.clear()), generating a fresh session key, and emits the LOGGED_OUT signal:
#![allow(unused)]
fn main() {
async fn test_logout() {
let session = djangors_sessions::Session::new_empty();
use djangors_auth::logout;
logout(&session).await;
}
}
Permissions & Groups
djangors-auth provides permission management:
Permission: Model for permissions withcodename("{app_label}.{action}_{model}") andname.Group: Role groups (name).- Join Tables:
UserGroup,GroupPermission,UserPermission.
Checking Permissions (has_perm)
#![allow(unused)]
fn main() {
async fn test_perm() -> Result<(), Box<dyn std::error::Error>> {
let user = djangors_auth::User { id: 1, username: String::new(), email: String::new(), password: String::new(), is_active: true, is_staff: true, is_superuser: true, date_joined: chrono::Utc::now(), last_login: None };
let db = djangors_db::Database::connect(&djangors_db::config::DatabaseConfig::new("postgres://postgres:postgres@localhost/djangors_test")).await.unwrap();
use djangors_auth::{has_perm, AuthUser};
// Superusers bypass perm checks manually or by calling user.is_superuser()
if user.is_superuser() || has_perm(&db, user.id(), "polls.add_question").await? {
// User is authorized
}
Ok(())
}
}
Permission Synchronization (sync_permissions & dj createpermissions)
sync_permissions(&db) scans all registered models (all_registered_models()) and generates the 4 standard permissions (view, add, change, delete) in auth_permission.
Run via CLI:
dj createpermissions
How permissions flow to a request
The four tables and their relationships (Permission, Group,
UserGroup, GroupPermission, UserPermission) form the full permission
graph: a user has groups, groups hold permissions, and a user may also hold
direct permissions. has_perm(db, user_id, codename) walks all of it.
#![allow(unused)]
fn main() {
use djangors_auth::{Permission, Group, UserPermission, GroupPermission, UserGroup};
use djangors_orm::{Model, q, ForeignKey};
async fn run(db: &djangors_db::Database, user_id: i64) -> Result<(), Box<dyn std::error::Error>> {
// A permission row is what `has_perm` checks against.
let perm = Permission {
id: 0,
codename: "polls.add_question".into(),
name: "Can add question".into(),
};
let perm = perm.save(db).await?;
// Direct grant: a UserPermission row.
UserPermission {
id: 0,
user: ForeignKey::new(user_id),
permission: ForeignKey::new(perm.id),
}.save(db).await?;
// Via a group: Group + UserGroup + GroupPermission.
let group = Group { id: 0, name: "Editors".into() }.save(db).await?;
UserGroup { id: 0, user: ForeignKey::new(user_id), group: ForeignKey::new(group.id) }.save(db).await?;
GroupPermission { id: 0, group: ForeignKey::new(group.id), permission: ForeignKey::new(perm.id) }.save(db).await?;
Ok(())
}
}
A codename is always {action}_{model} — add_question, change_question,
delete_question, view_question — which is exactly what sync_permissions
generates per registered model. Superusers bypass the check via
user.is_superuser(), and missing permissions simply return false (they never
raise).
For permissions scoped to a single row rather than a whole model (for
example “edit this one document”), see the Object-Level Permissions
guide (djangors-contrib-guardian).
Password Hashing & Password Reset
- Password Hashing:
hash_password(password)andverify_password(password, hash)use Argon2id with random salts. - Password Reset:
generate_password_reset_token,verify_password_reset_token,request_password_reset, andconfirm_password_resetprovide HMAC-signed, time-bounded password reset tokens.
The reset flow
The server never stores a reset token. request_password_reset looks the user
up by email, mints an HMAC token keyed to that user (id + password hash, so a
password change invalidates outstanding tokens), and emails it. The client
posts the token back with a new password; confirm_password_reset verifies the
signature and expiry, then re-hashes and stores the new password.
#![allow(unused)]
fn main() {
use djangors_auth::{request_password_reset, confirm_password_reset, verify_password_reset_token};
use djangors_mail::{SmtpBackend, SmtpConfig};
let mail = SmtpBackend::new(SmtpConfig::new("smtp.example.com"))?;
let secret: &[u8] = settings.secret_key.as_bytes();
let base = "https://example.com/account/reset";
// Step 1 — your /forgot-password handler:
request_password_reset::<User>(&db, &mail, "alice@example.com", secret, base).await?;
// Step 2 — your /reset endpoint (token + new password from the form):
if verify_password_reset_token::<User>(&user, &token, secret) {
confirm_password_reset::<User>(&db, user.id, &token, &new_password, secret).await?;
}
}
verify_password_reset_token and confirm_password_reset return false /
Err(AuthError::InvalidToken) on a wrong, expired, or replayed token — there is
no feedback that reveals whether the email existed, which is the desired
behaviour for enumeration resistance.
Error handling (AuthError)
Every djangors-auth call returns a concrete AuthError so you can branch on
the cause rather than string-matching:
| Variant | Meaning |
|---|---|
AuthError::Hashing | Argon2id encode/verify failure |
AuthError::Database | Underlying ORM/db error |
AuthError::RateLimited | RateLimitedBackend tripped its window |
AuthError::AccountLocked { retry_after_secs } | PersistentLockoutBackend rejected with a locked account |
AuthError::InvalidToken | Reset token missing, malformed, or expired |
AuthError::Mail | request_password_reset failed to send |
REST Framework
djangors-rest is the API layer: ViewSets that turn a model into CRUD routes,
serializers that shape request and response bodies, and pluggable pagination,
filtering, permissions, and throttling. It is closely modelled on Django REST
Framework.
Trait imports: the four you always need
Every method below is a trait method; Rust does not bring it into scope
unless the trait is imported. Getting “method not found” in your first API
handler is almost always one of these four missing use lines:
| Method | Missing import |
|---|---|
Question::objects() | use djangors_orm::Model; |
serializer.to_representation(..) | use djangors_rest::Serializer; |
perm.has_permission(..) | use djangors_rest::Permission; |
Json::<T>::from_request(..) | use djangors_core::extract::FromRequest; |
current_user(&req) | use djangors_rest::current_user; |
djangors-orm re-exports Model in its own prelude, but the other three live
in their crates’ roots only because djangors-rest re-exports
pub use permissions::* and pub use serializers::* — the use statement is
still required.
A minimal ViewSet
viewset_routes mounts the five standard actions for a model. Routes require an
authenticated user by default; opt out explicitly with
viewset_routes_with_permission and AllowAny.
| Method | Path | Action |
|---|---|---|
GET | / | list |
POST | / | create |
GET | /{pk} | retrieve |
PUT / PATCH | /{pk} | update |
DELETE | /{pk} | destroy |
PATCH is a genuine partial update: fields the body omits are left untouched
rather than reset to their defaults.
Choosing a route-mounting function
viewset_routes is only the default. Five mounting functions exist, each a
shorthand that fills in defaults and delegates to the most complete one
(viewset_routes_with_options). Pick the one that matches how much you need to
customise:
| Function | Custom permission | Custom config | Custom serializer / pagination / throttle |
|---|---|---|---|
viewset_routes::<M>(router, base) | No (IsAuthenticated) | No | No |
viewset_routes_with_permission::<M,P>(router, base, perm) | Yes | No | No |
viewset_routes_with_config::<M>(router, base, config) | No | Yes | No |
viewset_routes_with_config_and_permission::<M,P>(router, base, config, perm) | Yes | Yes | No |
viewset_routes_with_options::<M,P>(router, base, options, perm) | Yes | Yes (via options) | Yes |
scoped_viewset_routes::<M>(router, base) | No (IsAuthenticated) | No | No — mandates M: Scoped |
scoped_viewset_routes_with_config::<M>(router, base, config) | No (IsAuthenticated) | Yes | No — mandates M: Scoped |
#![allow(unused)]
fn main() {
use djangors_rest::{
AllowAny, FieldFilter, IsAuthenticated, OrderingFilter, SearchFilter, ViewSetConfig,
ViewSetOptions, viewset_routes_with_config, viewset_routes_with_config_and_permission,
viewset_routes_with_options, viewset_routes_with_permission,
};
use djangors_core::Router;
use polls::models::Question;
// Public read-only blog index: let anyone list, but only allow the fields below.
let public = viewset_routes_with_permission::<Question, _>(
Router::new(),
"/questions/public",
AllowAny,
);
// Same default auth, but with a custom config (filtering + ordering allowlists).
let config = ViewSetConfig {
filterable_fields: &["pub_date"],
orderable_fields: &["pub_date"],
..Default::default()
};
let filtered = viewset_routes_with_config::<Question>(Router::new(), "/questions/filtered", config);
// Both.
let both = viewset_routes_with_config_and_permission::<Question, _>(
Router::new(),
"/questions/both",
config,
IsAuthenticated,
);
// Full control: a custom serializer, pagination strategy, filter backends, and a throttle.
let options = ViewSetOptions::<Question>::new(ViewSetConfig::default())
.with_filter_backend(FieldFilter::new(&["id", "pub_date"]))
.with_filter_backend(OrderingFilter::new(&["pub_date"]));
let full = viewset_routes_with_options::<Question, _>(
Router::new(),
"/questions/full",
options,
IsAuthenticated,
);
let _ = (public, filtered, both, full);
}
Tip
_with_configpasses the config to the list handler only._with_optionsis different: the options reach every handler, so a serializer’s read/write field split actually applies tocreateandupdateas well as tolistandretrieve. If you set read-only fields, use_with_options, not_with_config.
Calling ViewSet actions directly from your own handler
Mounting with viewset_routes* is not the only way to use a ViewSet. Every
action is a plain async fn on ViewSet::<M> that you can call from your own
handler — useful when you want your own routing, middleware, or permission logic
around the standard CRUD while still reusing the framework’s serializer and
pagination. The _with_config variants apply the filter/order allowlists; the
_with_options variants apply the full ViewSetOptions (serializer, pagination
strategy, filter backends, and throttle).
| Call | Applies |
|---|---|
ViewSet::<M>::list_with_config(req, params, &config) | filter / order allowlists |
ViewSet::<M>::list_with_options(req, params, &options) | serializer + pagination + backends + throttle |
ViewSet::<M>::retrieve_with_options(req, params, &options) | serializer |
ViewSet::<M>::create_with_options(req, params, &options) | serializer parse + validate |
ViewSet::<M>::update_with_options(req, params, &options) | serializer parse + validate (PATCH = partial) |
Note
These handlers do not check permissions — that is the job of the
guardedwrapperviewset_routes*applies. Call your own permission check (or route throughIsAuthenticated) before delegating, and ensure aDatabaseis in the request state.
#![allow(unused)]
fn main() {
use djangors_core::{DjangorsError, PathParams, Request, Response};
use djangors_rest::{FieldSet, ModelSerializer, ViewSet, ViewSetConfig, ViewSetOptions};
use polls::models::Question;
// Your own handler that reuses the framework's serializer + pagination logic.
pub async fn my_list(req: Request, params: PathParams) -> Result<Response, DjangorsError> {
let options = ViewSetOptions::<Question>::new(ViewSetConfig::default())
.with_serializer(ModelSerializer::<Question>::new(FieldSet::all().read_only(&["id"])));
ViewSet::<Question>::list_with_options(req, params, &options).await
}
}
Mandatory row-level scoping (Scoped / ScopedViewSet)
A ViewSet that should only ever touch rows belonging to the current user, school,
or tenant is a ScopedViewSet. The model must implement Scoped, and — because
that is a hard where M: Scoped bound — a model that does not implement it
simply will not compile against ScopedViewSet. You cannot forget the scope.
#![allow(unused)]
fn main() {
use djangors_core::{Request, error::DjangorsError};
use djangors_macros::Model;
use djangors_orm::QuerySet;
use djangors_rest::Scoped;
#[derive(Model, Debug, Clone)]
#[djangors(app = "notes", table_name = "notes_note")]
struct Note {
#[djangors(primary_key, auto)]
id: i64,
#[djangors(foreign_key(on_delete = "cascade"))]
author: djangors_orm::ForeignKey<djangors_auth::User>,
#[djangors(max_length = 500)]
body: String,
}
impl Scoped for Note {
fn scope(req: &Request, qs: QuerySet<Self>) -> Result<QuerySet<Self>, DjangorsError> {
// Resolve the current user (see `current_user` below), then filter the
// queryset to rows they own. Every query through this endpoint applies
// this filter — there is no un-scoped escape hatch.
let user_id = 1i64;
qs.filter(djangors_orm::q!(author = user_id))
.map_err(|e| DjangorsError::Internal(e.to_string()))
}
}
// Mount: all five CRUD actions, each one scoped to the current user.
fn mount(router: djangors_core::Router) -> djangors_core::Router {
djangors_rest::scoped_viewset_routes::<Note>(router, "/notes")
}
}
scoped_viewset_routes takes no ViewSetConfig. If you need filtering or
ordering on a scoped endpoint, reach for scoped_viewset_routes_with_config
first — it mounts the same five CRUD routes with a custom config on list,
and wraps every one of them in IsAuthenticated for you (see the
Multi-tenancy guide for the tenant_scope helper).
Warning
ScopedViewSet::<M>’s associated functions (list_with_config,retrieve,create,update,destroy) check onlyScoped::scope— the same “no built-in permission check” rule from the note above applies here too, and the consequence is sharper:scopedecides which rows are visible (typically “this tenant”), not who’s allowed to write. If you mount these functions yourself instead of going throughscoped_viewset_routes*, every authenticated member of that scope gets full read/write access regardless of role — there is no framework-enforced distinction between “can view my own school’s records” and “can edit anyone’s.” Prefer thescoped_viewset_routes*helpers; if you must hand-mount, add your ownIsAuthenticated(or stricter) check before delegating, and encode any role restriction directly inScoped::scopeor a wrapping handler.
Resolving the current user (current_user)
current_user(&req) resolves the authenticated user from the same sources
IsAuthenticated checks — session auth, then API token, then JWT when enabled —
returning None for an unauthenticated request. It is the one utility that
covers all three auth mechanisms in a single call, so use it instead of reaching
for a specific extractor.
#![allow(unused)]
fn main() {
use djangors_core::{Request, error::DjangorsError};
use djangors_rest::current_user;
pub async fn me(req: Request) -> Result<String, DjangorsError> {
match current_user(&req).await {
Some(user) => Ok(format!("hello, {}", user.username)),
None => Err(DjangorsError::Unauthorized("not logged in".into())),
}
}
}
Note that current_user returns Option<djangors_auth::User> — it never
errors, so combine it with IsAuthenticated at the mount point when you need
the route to reject unauthenticated callers up front.
Serializers
A serializer decides which fields are readable, which are writable, and what
counts as valid. ModelSerializer derives all of that from the model’s
metadata; FieldSet narrows it.
use djangors_rest::{FieldSet, ModelSerializer};
use polls::models::Question;
fn main() {
// Expose everything except `pub_date`, and never accept `id` on a write.
let serializer = ModelSerializer::<Question>::new(
FieldSet::all().excluding(&["pub_date"]).read_only(&["id"]),
);
let _ = serializer;
}
FieldSet builds on an implicit “everything” baseline and narrows it. The full
syntax:
use djangors_rest::FieldSet;
fn main() {
// Only these two fields, and `id` is still never writable:
let f1 = FieldSet::only(&["id", "question_text"]);
// Everything-with-some-dropped, then some marked read-only / write-only:
let f2 = FieldSet::all()
.excluding(&["questions"]) // hide a relation entirely
.read_only(&["id", "pub_date"]) // render but never accept on write
.write_only(&["votes"]); // accept on write, never render
let _ = (f1, f2);
}
read_onlyfields are rendered but rejected on write, rather than silently ignored.write_onlyfields (passwords, tokens) are accepted on write and never appear in a response.
Validation
Field errors accumulate into ValidationErrors and render as a 422 whose
details carries the {field: [messages]} map, so a client sees every problem
at once instead of one per round trip.
use djangors_rest::ValidationErrors;
fn main() {
let mut errors = ValidationErrors::new();
errors.add("question_text", "This field may not be blank.");
errors.add_non_field("Either a question or a choice is required.");
let _ = errors;
}
ValidationErrors: the full API
Beyond the add / add_non_field shown above, the error set supports
everything you need to merge, inspect, and ship errors:
| Method | Behaviour |
|---|---|
add(field, msg) | append a message to a named field |
add_non_field(msg) | an object-level message (DRF’s non_field_errors) |
merge(other) | fold another set in, preserving every message per field |
is_empty() | true when nothing has been recorded |
get(field) | Option<&[String]> for one field |
contains_key(field) | whether a field has any message |
field_names() | iterator over the fields that have messages |
non_field_errors() | &[String] of object-level messages |
into_result() | Ok(()) when empty, otherwise Err(self) |
to_json() | the {field: [msg]} map (object-level under non_field_errors) |
VALIDATION_ERROR_CODE is the stable code the set renders into — branch on it
instead of parsing a message string.
use djangors_rest::{ValidationErrors, VALIDATION_ERROR_CODE};
fn main() {
let mut a = ValidationErrors::new();
a.add("question_text", "must not be blank");
a.add("pub_date", "must be in the future");
let mut b = ValidationErrors::new();
b.add("question_text", "too long");
b.add_non_field("this question looks wrong");
a.merge(b); // both `question_text` messages survive, plus the non-field one
assert!(!a.is_empty());
assert_eq!(a.get("question_text").unwrap().len(), 2);
assert!(a.contains_key("pub_date"));
let fields: Vec<&str> = a.field_names().collect();
let non_field: &[String] = a.non_field_errors();
let json = a.to_json(); // {"question_text": [...], "pub_date": [...], "non_field_errors": [...]}
let _ = a.clone().into_result(); // Ok(()) when empty, Err(self) otherwise
assert_eq!(VALIDATION_ERROR_CODE, "validation_error");
let _ = (fields, non_field, json);
}
Low-level serialize free function
When you only need a model rendered to JSON without a full ViewSet — a custom
handler, a CSV/export endpoint, a debug route — call djangors_rest::serialize
directly. It returns a serde_json::Value you can drop into Response::json.
To serialise a collection, map over the queryset result.
#![allow(unused)]
fn main() {
use polls::models::Question;
async fn run(db: &djangors_db::Database) -> Result<(), Box<dyn std::error::Error>> {
use djangors_orm::Model;
use djangors_rest::serialize;
let questions = Question::objects().all(db).await?;
let json = serde_json::Value::Array(questions.iter().map(serialize).collect());
let _ = json;
Ok(())
}
}
Nested serializers
Because the serializer trait is synchronous it never issues queries itself.
Load the relation with select_related, then render it with
NestedSerializer, which embeds the related object in place of the raw foreign
key. When no related instance is supplied the field keeps its id, so a missing
join degrades to the flat representation rather than to null.
use djangors_rest::{ModelSerializer, NestedSerializer};
use polls::models::{Choice, Question};
fn main() {
let serializer = NestedSerializer::new(
ModelSerializer::<Choice>::default(),
"question",
ModelSerializer::<Question>::default(),
);
let _ = serializer;
}
Rendering preloaded relations (render / render_many)
NestedSerializer renders by hand rather than through a ViewSet: build it once,
then call render(&self, instance, related: Option<&R>) for a single row, or
render_many(&self, rows: &[(M, Option<R>)]) for a collection — the exact shape
select_related returns. When the related row is None, the field keeps its raw
id, degrading to the flat representation rather than to null.
#![allow(unused)]
fn main() {
use polls::models::{Choice, Question};
use djangors_rest::{FieldSet, ModelSerializer, NestedSerializer};
async fn run(db: &djangors_db::Database) -> Result<(), Box<dyn std::error::Error>> {
use djangors_orm::Model;
let serializer = NestedSerializer::new(
ModelSerializer::<Choice>::default(),
"question",
ModelSerializer::<Question>::new(FieldSet::all().excluding(&["pub_date"])),
);
// One query loads both tables; each element is (Choice, Option<Question>).
let rows: Vec<(Choice, Option<Question>)> =
Choice::objects().select_related::<Question, _>(db, "question").await?;
// Embed each question under the `question` key (or keep the raw id when the FK is dangling).
let json = serializer.render_many(&rows);
let _ = json;
Ok(())
}
}
Writing a custom Serializer<M>
ModelSerializer + FieldSet can only select, exclude, or redirect whole model
fields — it cannot compute, fuse, or rename a field. For that, implement the
Serializer<M> trait yourself. It is a small surface, fully overridable:
| Method | Purpose | Default |
|---|---|---|
to_representation(&self, &M) -> serde_json::Value | render one row for the response body | — (required) |
to_internal_value(&self, &Value, partial) -> Result<FieldValues, ValidationErrors> | parse + validate a body into column values | — (required) |
validate(&self, &[(&str, Value)], &mut ValidationErrors) | object-level rules, run after field parsing | no-op |
to_representation_nested(&self, &M, &RelatedObjects) -> Value | embed preloaded relations in place of FK ids | merges the base representation |
to_representation_many(&self, &[M]) -> Vec<Value> | render a collection | maps to_representation |
parse(&self, &Value, partial) -> Result<FieldValues, ValidationErrors> | to_internal_value then validate | ViewSets call this |
ViewSets call parse, never to_internal_value directly, so a validate
override is never accidentally skipped. NestedSerializer renders through
to_representation_nested; the default implementation inlines each preloaded
relation into the base representation, which is why overriding
to_representation is enough for most custom shapes.
#![allow(unused)]
fn main() {
use polls::models::{Choice, Question};
use djangors_rest::{FieldValues, Serializer, ValidationErrors};
/// Renames `question_text` to `display_name`, computed from the question's id
/// and text — impossible with `FieldSet`, which only selects whole fields.
struct QuestionSummary;
impl Serializer<Question> for QuestionSummary {
fn to_representation(&self, question: &Question) -> serde_json::Value {
serde_json::json!({
"id": question.id,
"display_name": format!("{}: {}", question.id, question.question_text),
})
}
fn to_internal_value(
&self,
data: &serde_json::Value,
partial: bool,
) -> Result<FieldValues, ValidationErrors> {
// `display_name` is read-only; the write side stays boring by delegating
// to the low-level column-name deserializers.
let values = if partial {
djangors_rest::deserialize_partial::<Question>(data)?
} else {
djangors_rest::deserialize::<Question>(data)?
};
Ok(values)
}
}
/// Same idea for choices: `display_name` fuses `choice_text` and `votes`.
struct ChoiceSummary;
impl Serializer<Choice> for ChoiceSummary {
fn to_representation(&self, choice: &Choice) -> serde_json::Value {
serde_json::json!({
"id": choice.id,
"display_name": format!("{} ({} votes)", choice.choice_text, choice.votes),
})
}
fn to_internal_value(
&self,
data: &serde_json::Value,
partial: bool,
) -> Result<FieldValues, ValidationErrors> {
let values = if partial {
djangors_rest::deserialize_partial::<Choice>(data)?
} else {
djangors_rest::deserialize::<Choice>(data)?
};
Ok(values)
}
}
}
Wire a custom serializer in with ViewSetOptions::with_serializer inside
viewset_routes_with_options (see the
route-mounting table).
Object-level validation (Validator<T> / with_validator)
Per-field rules live inside to_internal_value. Cross-field business rules —
“end must be after start”, “at least one of X or Y” — attach to a
ModelSerializer with with_validator. Validator<T> is implemented for any
closure Fn(&T, &mut ValidationErrors), and ModelSerializer runs validators on
the parsed FieldValues in registration order, after fields parse successfully.
Every registered validator runs even if an earlier one failed, so the client sees
the full set of problems at once.
use polls::models::Question;
fn main() {
use djangors_rest::{FieldSet, FieldValues, ModelSerializer, ValidationErrors};
let serializer = ModelSerializer::<Question>::new(FieldSet::all()).with_validator(
|values: &FieldValues, errors: &mut ValidationErrors| {
// Object-level rule: a question must carry actual text, not just an id.
let has_text = values.iter().any(|(name, _)| *name == "question_text");
if !has_text {
errors.add_non_field("a question needs question_text");
}
},
);
let _ = serializer;
}
with_validator returns Self, so you can chain several validators; each one
runs in order after the fields parse.
Pagination
Three strategies ship, all behind the Pagination trait:
PageNumberPagination—?page=2, the default.LimitOffsetPagination—?limit=20&offset=40.CursorPagination— opaque cursor, stable under concurrent inserts and issues noCOUNT.
Page size is server-controlled by default. Clients may only override it with
?page_size= when max_page_size opts in, and the value is clamped to that
cap.
Each strategy is a plain struct with public fields, so you can construct one
directly or use Default. All three derive their page-size defaults from the
shared REST_PER_PAGE constant (100):
| Struct | Field | Default |
|---|---|---|
PageNumberPagination | page_size: i64 | 100 |
PageNumberPagination | max_page_size: Option<i64> | None (no client override) |
LimitOffsetPagination | default_limit: i64 | 100 |
LimitOffsetPagination | max_limit: i64 | 100 |
CursorPagination | page_size: i64 | 100 |
CursorPagination | max_page_size: Option<i64> | None |
use djangors_rest::{LimitOffsetPagination, PageNumberPagination, REST_PER_PAGE};
fn main() {
// All defaults flow from REST_PER_PAGE (100).
let page = PageNumberPagination { page_size: REST_PER_PAGE, max_page_size: None };
let flexible = PageNumberPagination { page_size: 20, max_page_size: Some(50) };
let limit_offset = LimitOffsetPagination { default_limit: 25, max_limit: 100 };
let _ = (page, flexible, limit_offset);
}
Filter backends
ViewSetConfig::filterable_fields gives exact-match ?field=value filtering.
Filter backends add lookup suffixes, free-text search, and client-controlled
ordering. They run in order and each one narrows the queryset further.
use djangors_rest::{FieldFilter, OrderingFilter, SearchFilter, ViewSetConfig, ViewSetOptions};
use polls::models::Question;
fn main() {
let options = ViewSetOptions::<Question>::new(ViewSetConfig::default())
.with_filter_backend(FieldFilter::new(&["id", "pub_date"]))
.with_filter_backend(SearchFilter::new(&["question_text"]))
.with_filter_backend(OrderingFilter::new(&["pub_date"]));
let _ = options;
}
That accepts requests such as:
?pub_date__gte=2026-01-01T00:00:00Z
?id__in=1,2,3
?question_text__icontains=rust
?search=rust
?ordering=-pub_date
Every backend is allowlist-driven. A parameter naming a field that was not explicitly permitted is ignored rather than passed to SQL, and an unrecognised lookup suffix is dropped the same way — a client cannot filter on a column the endpoint never meant to expose.
Writing your own backend (FilterBackend)
FilterBackend<M> is a plain trait; the three built-ins are just
implementations of it. Backends run in the order they were added, after the
ViewSetConfig exact-match allowlist, and each one narrows the queryset
further.
#![allow(unused)]
fn main() {
use djangors_core::{Request, error::DjangorsError};
use djangors_orm::QuerySet;
use djangors_rest::FilterBackend;
use polls::models::Question;
// A backend that only ever shows questions published in the last 7 days.
struct RecentOnly;
impl FilterBackend<Question> for RecentOnly {
fn filter_queryset(
&self,
_req: &Request,
qs: QuerySet<Question>,
) -> Result<QuerySet<Question>, DjangorsError> {
let week_ago = chrono::Utc::now() - chrono::Duration::days(7);
qs.filter(djangors_orm::q!(pub_date__gte = week_ago))
.map_err(|e| DjangorsError::Internal(e.to_string()))
}
}
}
If you are not using a ViewSet at all, djangors_rest::apply_backends(&backends, &req, qs) applies a slice of backends to a queryset by hand.
Permissions
AllowAny, IsAuthenticated, IsStaff, IsSuperuser, and IsReadOnly ship
built in, and combine with and / or / negate:
use djangors_rest::{IsReadOnly, IsStaff, PermissionExt};
fn main() {
// Staff may write; everyone else is limited to safe methods.
let permission = IsStaff.or(IsReadOnly);
let _ = permission;
}
Throttling
Throttle applies a per-user (falling back to per-IP) rate limit to every
action on a ViewSet, using DRF’s rate strings. It is backed by the same
cache-backed sliding window as djangors_core::ratelimit, so accounting is
best-effort under concurrency — the cache API has no atomic increment.
Warning
Constructor order is
(scope, rate, store)— a name, a rate string, then the cache. Swapping the first two arguments (e.g.("100/hour", "questions", …)) compiles fine, becausescopeis just a&'static strand the rate string is also a&'static str; you only notice at runtime when the parse fails.Throttle::newreturnsOption(it delegates toparse_rate), so a typo’d rate is silentlyNoneunless you.expect(...). Get the order right and alwaysexpect:
use std::sync::Arc;
use djangors_cache::{Cache, InMemoryCache};
use djangors_rest::Throttle;
fn main() {
let store: Arc<dyn Cache> = Arc::new(InMemoryCache::new(10_000));
// Tuple-struct-style note: scope, then rate, then store.
let throttle = Throttle::new("questions", "100/hour", store).expect("valid rate");
let _ = throttle;
}
parse_rate accepts second, minute, hour, and day with their
abbreviations and plurals. A malformed rate returns None rather than silently
falling back to some default budget, so a typo is a configuration error you
catch at startup — but only because the example above calls .expect.
The scope (the first argument) isolates the budget: two endpoints at the same
rate sharing one cache do not consume each other’s allowance.
Throttle plugs into a ViewSet via ViewSetOptions::with_throttle
(viewset_routes_with_options).
Hand-rolled rate limiting (RateLimiter)
For a single endpoint that is not a full ViewSet — a login form, a contact
route, a webhook — use djangors_core::RateLimiter directly with rate_limited
to wrap the handler. Keys are chosen by a RateLimitKey strategy (ByIp, or
ByAuthenticatedUser / ByUserOrIp from djangors-rest).
use std::sync::Arc;
use std::time::Duration;
use djangors_core::{Router, Request, Response, PathParams, DjangorsError, StatusCode};
use djangors_cache::InMemoryCache;
async fn login(req: Request, _params: PathParams) -> Result<Response, DjangorsError> {
Ok(Response::text(StatusCode::OK, "ok"))
}
fn main() {
use djangors_core::{RateLimiter, ByIp, rate_limited};
let limiter = Arc::new(
RateLimiter::new("login", ByIp, 5, Duration::from_secs(900),
Arc::new(InMemoryCache::new(10_000))),
);
let router = Router::new().post("/login", rate_limited(limiter, login));
let _ = router;
}
A check that trips returns DjangorsError::TooManyRequests, so the wrapped
handler stays a plain Result<Response, DjangorsError>.
Raw parse helpers: deserialize / deserialize_partial
When you are not using a ViewSet (a custom handler, an import endpoint), the
low-level parse helpers mirror DRF’s serializer output shape: they map a JSON
object onto column values ready for insert_raw / update. They skip auto
fields, default missing booleans to false, and return a per-field String
map on failure.
deserialize::<M>(&json)— full object; a missing non-nullable column is an error.deserialize_partial::<M>(&json)— missing keys are skipped, so it suits PATCH-style merges.
use polls::models::Question;
use djangors_rest::deserialize;
fn main() {
let json = serde_json::json!({ "question_text": "What is your name?" });
match deserialize::<Question>(&json) {
Ok(values) => { /* values[0] = ("question_text", Value::Text("What is your name?")) */ }
Err(errors) => { /* errors: HashMap<String, String>, field -> reason */ }
}
}
API authentication: AuthToken, TokenAuth, and JWT
djangors-rest authenticates API requests three ways; current_user and
IsAuthenticated try them in this order:
- Session —
djangors-auth’sAuth<User>(see the Authentication guide). Best for browser clients. Tokenheader —Authorization: Token <64-hex-key>, backed by the databaseAuthTokenmodel. Best for long-lived machine clients.- JWT —
Authorization: Bearer <jwt>, HS256-signed, gated behind the crate’sjwtCargo feature. Best for short-lived cross-origin clients.
Issue a token with generate_token_key() and insert an AuthToken row, then
extract it in a handler with the TokenAuth extractor:
#![allow(unused)]
fn main() {
use djangors_rest::{generate_token_key, TokenAuth};
use djangors_core::extract::{FromRequest};
use djangors_core::{Request, DjangorsError};
async fn create_token(db: &djangors_db::Database, user_id: i64) -> Result<(), Box<dyn std::error::Error>> {
use djangors_rest::AuthToken;
use djangors_orm::{Model, ForeignKey};
let key = generate_token_key();
AuthToken {
id: 0,
user: ForeignKey::new(user_id),
key,
created_at: chrono::Utc::now(),
}.save(db).await?;
Ok(())
}
async fn use_token(req: Request) -> Result<(), DjangorsError> {
let TokenAuth(user) = TokenAuth::from_request(&req).await?;
let _ = user;
Ok(())
}
}
JWT
With the jwt feature enabled, encode_jwt(user_id, secret) produces a
one-hour HS256 token and decode_jwt(token, secret) recovers the user id. The
JwtAuth extractor parses the Authorization: Bearer header and resolves the
user from the database, mirroring TokenAuth:
#![allow(unused)]
fn main() {
use djangors_core::extract::FromRequest;
use djangors_core::{Request, DjangorsError};
use djangors_rest::JwtAuth;
async fn use_jwt(req: Request) -> Result<(), DjangorsError> {
let JwtAuth(user) = JwtAuth::from_request(&req).await?;
let _ = user;
Ok(())
}
}
fn main() {
use djangors_rest::encode_jwt;
let token = encode_jwt(42, "your-app-secret");
let _ = token;
}
OpenAPI schema generation
openapi_schema_for::<M>() derives a JSON Schema object from a model’s typed
metadata, and OpenApiBuilder aggregates models into a single OpenAPI document
with a title and version. Feed the result to Swagger UI / ReDoc.
use polls::models::Question;
fn main() {
use djangors_rest::{OpenApiBuilder, openapi_schema_for};
let mut api = OpenApiBuilder::new("Polls API", "1.0.0");
api.register::<Question>("/api/questions");
let document = api.build();
let _ = document;
}
The schemas are derived from FieldMeta/RelationMeta — nullable fields are
optional, max_length becomes maxLength, and relations read as their integer
foreign-key id.
Custom pagination strategies
The three built-ins (PageNumberPagination, LimitOffsetPagination,
CursorPagination) share the Pagination trait. Implement it yourself to send
a different envelope or read different query parameters:
#![allow(unused)]
fn main() {
use djangors_core::{Request, Response, PathParams, DjangorsError};
use djangors_rest::{PageSlice, Pagination};
// A tiny "page + per_page" envelope mirroring the DRF DefaultPagination shape.
struct MyPagination {
page_size: i64,
}
impl Pagination for MyPagination {
fn slice(&self, req: &Request, total: i64) -> PageSlice {
let page = req.query("page").and_then(|p| p.parse().ok()).unwrap_or(1).max(1);
let offset = (page - 1) * self.page_size;
PageSlice { limit: self.page_size, offset }
}
fn page_size(&self, req: &Request) -> i64 {
self.page_size
}
fn envelope(&self, req: &Request, total: i64, results: Vec<serde_json::Value>) -> serde_json::Value {
serde_json::json!({
"count": total,
"page_size": self.page_size,
"results": results,
})
}
}
}
Wire it in with ViewSetOptions::with_pagination inside
viewset_routes_with_options. requested_page(&req) is the helper the
built-in page-number pagination uses to read ?page=.
Error responses
Errors render as JSON when the request’s Accept header asks for it, using a
stable envelope:
{
"error": {
"code": "validation_error",
"message": "Validation failed",
"details": { "question_text": ["This field may not be blank."] }
}
}
code is a stable, machine-readable string; details is omitted entirely when
there is nothing to report. Build one directly with
DjangorsError::api(status, code, message) and attach a payload with
.with_details(json), or convert at a ? site with the ApiResultExt helpers.
Admin Site
djangors-admin provides an automatic, customizable administration interface for managing database models.
AdminSite Initialization & Branding
Create and configure an AdminSite instance:
fn main() {
use djangors_admin::{AdminSite, ModelAdminConfig};
use polls::models::{Choice, Question};
let admin = AdminSite::new()
.with_site_header("Polls Administration")
.with_site_title("Polls Admin")
.with_logo_url("/static/logo.png")
.with_accent_color("#2563eb");
// Register models
admin.register::<Question>();
admin.register_with::<Choice>(ModelAdminConfig {
list_display: Some(&["choice_text", "votes", "question"]),
search_fields: Some(&["choice_text"]),
list_filter: Some(&[]),
..Default::default()
});
}
ModelAdminConfig Options
Customize how models are displayed and edited in the admin interface:
#![allow(unused)]
fn main() {
use djangors_admin::AdminAction;
pub struct ModelAdminConfig {
pub list_display: Option<&'static [&'static str]>,
pub search_fields: Option<&'static [&'static str]>,
pub list_filter: Option<&'static [&'static str]>,
pub date_hierarchy: Option<&'static str>,
pub list_editable: Option<&'static [&'static str]>,
pub computed_columns: Option<
&'static [(
&'static str,
fn(&[(&'static str, djangors_orm::expr::Value)]) -> String,
)],
>,
pub actions: Option<&'static [AdminAction]>,
pub fieldsets: Option<&'static [(&'static str, &'static [&'static str])]>,
pub readonly_fields: Option<&'static [&'static str]>,
pub raw_id_fields: Option<&'static [&'static str]>,
pub base_filter: Option<djangors_orm::UnresolvedExpr>,
}
}
Config Field Details
list_display: Fields to render as columns on the changelist page.search_fields: Text fields toILIKE-search against?q=queries.list_filter: Boolean fields to render filter options in the sidebar.date_hierarchy: DateTime field for date drill-down navigation.list_editable: Fields editable inline directly from the changelist table.computed_columns: Custom display column functions evaluated dynamically per row.actions: Bulk actions callable on selected rows (a"delete_selected"bulk action is registered by default).fieldsets: Groups fields into titled sections on the add/change form.readonly_fields: Prevents editing specified fields in forms.raw_id_fields: Renders foreign key inputs as raw integer ID fields with target lookup links.base_filter: Baseline queryset filter automatically applied to all changelist queries.
Important
register_withvalidates your config at registration time, at startup, bypanic— not at first request. A field named inlist_display,search_fields,list_filter,date_hierarchy, orlist_editablethat does not exist on the model, or that has the wrong kind (aStringinlist_filter, which must be Boolean; aForeignKey/integer insearch_fields, which must be text-like; a non-DateTimeindate_hierarchy), produces a hard panic the moment the admin site is registered. Treat the config as compile-time-ish: a wrong type surfaces as an immediate startup crash, which is precise but not graceful. The allowed kinds are:list_filter→ Boolean only;search_fields→Char/Text/Url/Slug/Ip;list_editable→ text and numeric kinds;date_hierarchy→DateTime.
Favicons & Static Branding Routes
djangors-admin includes static favicon serving helper favicon_routes:
fn main() {
use djangors_admin::favicon_routes;
use djangors_core::Router;
let router = favicon_routes(Router::new());
}
Routes served:
/favicon.ico/favicon-16x16.png/favicon-32x32.png/apple-touch-icon.png/android-chrome-192x192.png/android-chrome-512x512.png/manifest.json
Audit Logging (LogEntry)
All admin actions (additions, updates, deletions) are recorded in the djangors_admin_log table via LogEntry:
ACTION_ADDITION(1): Object creation.ACTION_CHANGE(2): Object field edits (with field diff payload).ACTION_DELETION(3): Object removal.
Computed columns & base_filter
computed_columns lets a changelist column be produced by a function instead of
a raw model field. The function receives the row’s field/value pairs and returns
the display string; list it in list_display alongside real fields.
use djangors_admin::{AdminSite, ModelAdminConfig};
use djangors_orm::expr::Value;
use polls::models::Question;
fn main() {
// A column computed from two fields (`id` and `question_text`).
fn id_and_text(values: &[(&'static str, Value)]) -> String {
let text = values
.iter()
.find(|(n, _)| *n == "question_text")
.map(|(_, v)| v.to_string())
.unwrap_or_default();
format!("#{} {text}", values.iter().find(|(n, _)| *n == "id").map(|(_, v)| v.to_string()).unwrap_or_default())
}
let site = AdminSite::new();
site.register_with::<Question>(ModelAdminConfig {
list_display: Some(&["id", "combo"]),
computed_columns: Some(&[("combo", id_and_text)]),
..Default::default()
});
}
base_filter applies an UnresolvedExpr (the same value q! produces) to
every changelist query — Django’s get_queryset-scoping for the admin list.
Combine it with Scoped semantics when a multi-tenant admin exists,
or simply to default a soft-deleted/model-state filter:
use djangors_admin::{AdminSite, ModelAdminConfig};
use polls::models::Question;
fn main() {
let site = AdminSite::new();
site.register_with::<Question>(ModelAdminConfig {
// Only list questions with any votes (q! result used verbatim).
base_filter: Some(djangors_orm::q!(votes__gt = 0i32) as djangors_orm::UnresolvedExpr),
..Default::default()
});
}
Note
q!returnsUnresolvedExpralready, so no cast is needed in real code; the explicitasabove only illustrates the expected type of the field.
The ModelAdmin trait
register_with(ModeAdminConfig) covers the common cases; for full control,
implement the ModelAdmin trait directly and register it off the standard
config flow. The required methods describe the model (model_meta,
field_names, changelist, export_csv_rows, get_by_pk,
update_from_form/create_from_form, delete_by_pk), and the optional ones
default when omitted (actions, fieldsets, readonly_fields,
raw_id_fields, plus the search/filter/editable/hierarchy accessors).
DefaultModelAdmin<M> in djangors-admin is the built-in implementation
register_with uses, and you can use it as a reference when writing your own.
Crate layout
djangors-admin is organized by view rather than by model. lib.rs holds only the
crate-level docs, the shared template engine (ADMIN_TEMPLATES), the module list, and the
public re-exports — so the public API you use is identical regardless of which module an
item lives in. A contributor can find things like this:
| Module | Owns |
|---|---|
model_admin | ModelAdmin trait, DefaultModelAdmin, ModelAdminConfig, ChangelistPage, InlineConfig, TenantFilter |
site | AdminSite, SiteBranding, TenantScope, with_tenant_scoping, route assembly (urls) |
changelist | List page: querying, pagination, CSV export, ?o=/q=/f=/date-hierarchy handling |
edit_view | Add/change form GET+POST handlers |
delete_view | Delete and bulk-delete GET+POST handlers |
index_view | The admin index (app/model links) |
history_view | Per-object change history |
action_view | Bulk-action handlers |
forms | Form/field rendering and parsing for the add/change forms |
log | LogEntry auditing (AdminAction, field diffs) |
perms | require_staff, require_perm, and request-scoped tenant resolution |
favicon | favicon_routes static branding routes |
site.rs depends on the view modules; the view modules depend on the ModelAdmin trait
defined in model_admin.rs. lib.rs re-exports the public surface
(AdminSite, SiteBranding, TenantScope, ModelAdmin, DefaultModelAdmin,
ModelAdminConfig, InlineConfig, TenantFilter, ChangelistPage, AdminAction,
LogEntry, favicon_routes, …), so the module an item lives in is an implementation
detail rather than part of the public API.
Implementing ModelAdmin by hand
The section above introduced the trait. Whatever you implement, the framework hands you a
TenantFilter — Option<(&'static str, i64)>, the tenant FK field name on the model plus the
tenant id every row must match — as the final argument to every method that touches data:
changelist, export_csv_rows, get_by_pk, update_from_form, update_fields_from_form,
create_from_form, and delete_by_pk. Their real signatures:
#![allow(unused)]
fn main() {
use djangors_admin::{ChangelistPage, ModelAdmin, TenantFilter};
async fn changelist(
&self,
db: &djangors_db::Database,
order: Option<&str>,
page: i64,
per_page: i64,
search: Option<&str>,
filters: &[(&'static str, bool)],
date_range: Option<(chrono::DateTime<chrono::Utc>, chrono::DateTime<chrono::Utc>)>,
tenant: TenantFilter,
) -> Result<ChangelistPage, djangors_core::DjangorsError>;
async fn export_csv_rows(
&self,
db: &djangors_db::Database,
order: Option<&str>,
search: Option<&str>,
filters: &[(&'static str, bool)],
date_range: Option<(chrono::DateTime<chrono::Utc>, chrono::DateTime<chrono::Utc>)>,
tenant: TenantFilter,
) -> Result<(Vec<&'static str>, Vec<Vec<String>>), djangors_core::DjangorsError>;
async fn get_by_pk(
&self,
db: &djangors_db::Database,
pk: i64,
tenant: TenantFilter,
) -> Result<Option<Vec<(&'static str, djangors_orm::expr::Value)>>, djangors_core::DjangorsError>;
async fn update_from_form(
&self,
db: &djangors_db::Database,
pk: i64,
form: &std::collections::HashMap<String, String>,
tenant: TenantFilter,
) -> Result<Result<(), std::collections::HashMap<String, String>>, djangors_core::DjangorsError>;
async fn update_fields_from_form(
&self,
db: &djangors_db::Database,
pk: i64,
form: &std::collections::HashMap<String, String>,
tenant: TenantFilter,
) -> Result<Result<(), std::collections::HashMap<String, String>>, djangors_core::DjangorsError>;
async fn create_from_form(
&self,
db: &djangors_db::Database,
form: &std::collections::HashMap<String, String>,
tenant: TenantFilter,
) -> Result<Result<i64, std::collections::HashMap<String, String>>, djangors_core::DjangorsError>;
async fn delete_by_pk(
&self,
db: &djangors_db::Database,
pk: i64,
tenant: TenantFilter,
) -> Result<bool, djangors_core::DjangorsError>;
}
The one thing a custom implementation must get right is this contract:
Apply the
tenantargument to every query you run, and never cache it onself.
The reason is structural. The admin object is built once at registration and shared across
every concurrent request behind an Arc; there is no per-request copy. If you stored the
resolved tenant on the admin, a second request could overwrite it while the first awaited the
database, and one tenant’s request would then read another tenant’s rows. So the tenant always
arrives per-call, and it is up to your method to thread it into the query. None means
unrestricted — a superuser, or a site with no tenant scoping configured at all.
The PK-based methods carry the same obligation with specific semantics, and the safe default
is to scope by both PK and tenant like DefaultModelAdmin does:
get_by_pkmust read a row belonging to another tenant as “not found” (None), not return it — a cross-tenant primary key and a missing one are indistinguishable.delete_by_pkmust refuse (returnfalse, delete nothing) when the row belongs to another tenant.update_from_form/update_fields_from_formmust not touch another tenant’s row.create_from_formmust force the new row’s tenant field to the resolved tenant rather than trusting the submitted form, so a scoped add-form can never plant a row elsewhere.
The default build does all of this via tenant_compare plus PK-scoped querysets, and the
Multi-tenancy guide walks through the reasoning, the “not stored on the
admin” design, and the fail-closed resolution rules in full — read it before writing a custom
ModelAdmin for a scoped site.
Testing
Djangors supports two testing paradigms: in-process route testing via djangors-test (TestClient) and real-socket integration testing over localhost TCP sockets.
1. In-Process Testing (TestClient & TestDatabase)
djangors-test provides an in-process client that executes requests directly through Router::handle() without binding network sockets.
In-Process Route Tests
#![allow(unused)]
fn main() {
use djangors_test::TestClient;
use djangors_core::{Router, Response, StatusCode, Request, PathParams, DjangorsError};
use djangors_sessions::Session;
async fn hello_handler(_req: Request, _params: PathParams) -> Result<Response, DjangorsError> {
Ok(Response::text(StatusCode::OK, "hello from test"))
}
#[tokio::test]
async fn test_hello_route() {
let router = Router::new().get("/hello", hello_handler);
let client = TestClient::new(router);
client.get("/hello")
.send()
.await
.assert_status(StatusCode::OK)
.assert_contains("hello from test");
}
}
RequestBuilder::with_state & reading TestResponse
Builders carry app state (AppState) into the request, and the resulting TestResponse exposes
the raw status and body for assertions beyond the assert_* helpers:
| Method | Signature | Notes |
|---|---|---|
RequestBuilder::with_state<T>(state) | fn(self, T) -> Self | Attach an AppState value; request handlers see it via req.state::<T>() |
TestResponse::body_str() | fn(&self) -> String | The response body as lossy UTF-8 |
TestResponse::status() | fn(&self) -> StatusCode | The raw HTTP status code |
use djangors_core::{Request, PathParams, Response, DjangorsError, StatusCode, Router};
use djangors_test::TestClient;
#[derive(Clone, Debug)]
struct Db(u32);
async fn hi_handler(req: Request, _params: PathParams) -> Result<Response, DjangorsError> {
let db = req.state::<Db>().expect("state attached");
Ok(Response::text(StatusCode::OK, &format!("db={}", db.0)))
}
#[tokio::main]
async fn main() {
let client = TestClient::new(Router::new().get("/hi", hi_handler));
let response = client.get("/hi").with_state(Db(7)).send().await;
assert_eq!(response.status(), StatusCode::OK);
assert_eq!(response.body_str(), "db=7");
}
Form & Session Testing
#![allow(unused)]
fn main() {
async fn submit_handler(_: djangors_core::Request, _: djangors_core::PathParams) -> Result<djangors_core::Response, djangors_core::DjangorsError> { Ok(djangors_core::Response::text(djangors_core::StatusCode::CREATED, "")) }
use djangors_sessions::Session;
use djangors_test::TestClient;
use djangors_core::{Router, StatusCode};
#[tokio::test]
async fn test_authenticated_form_submit() {
let router = Router::new().post("/submit", submit_handler);
let client = TestClient::new(router);
let session = Session::new_empty();
session.set("user_id", 42i64);
client.post_form("/submit", &[("title", "Test Item")])
.with_session(session)
.send()
.await
.assert_status(StatusCode::CREATED);
}
}
Database Fixture (TestDatabase)
#![allow(unused)]
fn main() {
use djangors_test::TestDatabase;
#[ignore]
#[tokio::test]
async fn test_database_queries() {
let test_db = TestDatabase::connect().await.unwrap();
let db = test_db.database();
test_db.create_table("CREATE TABLE test_table (id BIGSERIAL PRIMARY KEY)").await.unwrap();
// ... run queries against db ...
test_db.drop_table("test_table").await.unwrap();
}
}
Constructing a TestDatabase
TestDatabase offers several constructors covering the shared vs. isolated database models:
| Method | Signature | Returns |
|---|---|---|
connect() | async fn() -> Result<Self, DbError> | Backend chosen by TEST_BACKEND/DATABASE_URL (shared, no isolation) |
connect_url(url) | async fn(&str) -> Result<Self, DbError> | Connect to an explicit URL (shared) |
new_for_backend(backend) | async fn(TestBackend) -> Result<Self, DbError> | Fresh in-memory SQLite or isolated Postgres for that backend |
isolated() | async fn() -> Result<Self, DbError> | Throwaway database (uniquely named Postgres, or in-memory SQLite) |
isolated_url(url) | async fn(&str) -> Result<Self, DbError> | Same as isolated(), but using url as the base connection |
#![allow(unused)]
fn main() {
use djangors_test::{TestBackend, TestDatabase};
// Non-isolated: run against an existing database (you manage the schema).
let shared = TestDatabase::connect_url("postgres://user:pass@localhost/my_app").await?;
// Isolated: create a uniquely-named throwaway DB, then drop it on cleanup().
let db = TestDatabase::isolated().await?;
// ... create tables, insert fixtures, run queries ...
db.cleanup().await?;
// Explicitly pick a backend regardless of environment.
let sqlite = TestDatabase::new_for_backend(TestBackend::Sqlite).await?;
}
reset, cleanup, load_fixtures
| Method | Signature | Notes |
|---|---|---|
reset(&[tables]) | async fn(&self, &[&str]) -> Result<(), sqlx::Error> | DROP TABLE IF EXISTS each table in order |
cleanup() | async fn(self) -> Result<(), TestError> | Drops the throwaway database (primary cleanup for isolated DBs) |
load_fixtures::<M>(json) | async fn(&self, &str) -> Result<Vec<M>, TestError> | Deserialize JSON as Vec<M> and bulk_create; M: Model + FromRow + DeserializeOwned |
#![allow(unused)]
fn main() {
use djangors_test::TestDatabase;
async fn seed(db: &TestDatabase) {
// Load rows into a freshly-created table from a JSON array.
let items = db.load_fixtures::<MyModel>(r#"[
{"id": 1, "name": "Alice"},
{"id": 2, "name": "Bob"}
]"#).await.unwrap();
// Clear tables between tests.
db.reset(&["my_model"]).await.unwrap();
}
}
SQLite in-memory databases need no cleanup() — they vanish with the connection (see
Choosing a Database Backend).
TestBackend
fn main() {
use djangors_test::TestBackend;
let current: TestBackend = TestBackend::current();
// TEST_BACKEND=sqlite => Sqlite
// TEST_BACKEND=postgres => Postgres
// unset: DATABASE_URL set => Postgres
// unset: no DATABASE_URL => Sqlite
match current {
TestBackend::Postgres => println!("postgres"),
TestBackend::Sqlite => println!("sqlite"),
}
}
Cross-process advisory locks
acquire_cross_process_lock coordinates across separate OS processes (e.g. two crates racing to
DROP/CREATE the same table under cargo test --workspace) using a Postgres session-level
advisory lock. Unlike std::sync::Mutex, it spans test binaries:
#![allow(unused)]
fn main() {
use djangors_test::{acquire_cross_process_lock, TestDatabase};
async fn exclusive_setup() {
let db = TestDatabase::isolated().await.unwrap();
let lock = acquire_cross_process_lock(db.database(), "shared-table-setup").await.unwrap();
// ... create/drop the shared table ...
lock.release().await.unwrap(); // explicit release frees the advisory lock promptly
}
| Method | Signature | Notes |
|---|---|---|
acquire_cross_process_lock(db, name) | async fn(&Database, &str) -> Result<CrossProcessLock, sqlx::Error> | Blocks until the named advisory lock is free |
CrossProcessLock::release() | async fn(self) -> Result<(), sqlx::Error> | Releases the lock (pg_advisory_unlock) |
Release is explicit because Rust can’t run async code in Drop; an un-released lock stays held
until its dedicated connection closes (i.e. when the test process exits).
2. Real-Socket Integration Testing
For full-stack integration testing (including Tower middleware layers, real HTTP headers, cookie handling, and graceful shutdown), test suites bind a local TCP socket on port 0.
Real-Socket Pattern (As Used in examples/polls/tests/voting.rs)
#![allow(unused)]
fn main() {
use std::net::SocketAddr;
use tokio::net::{TcpListener, TcpStream};
use tokio::io::{AsyncReadExt, AsyncWriteExt};
use tower::ServiceBuilder;
use djangors_core::{Djangors, DjangorsSettings, Router};
use djangors_core::router::RouterService;
use djangors_core::middleware::{csrf_layer, security_headers_layer};
use djangors_sessions::{SessionLayer, SignedCookieStore};
#[ignore]
#[tokio::test]
async fn test_full_stack_socket() {
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
let addr = listener.local_addr().unwrap();
let settings = DjangorsSettings {
host: "127.0.0.1".to_string(),
port: addr.port(),
debug: true,
..Default::default()
};
let router = Router::new();
let router_service = RouterService::new(router, settings.debug);
let service = ServiceBuilder::new()
.layer(security_headers_layer())
.layer(SessionLayer::new(SignedCookieStore::new(b"test-secret-key-32-bytes-minimum!")))
.layer(csrf_layer())
.service(router_service);
let app = Djangors::new(settings, Router::new());
tokio::spawn(async move {
app.serve_service(listener, service).await.unwrap();
});
// Send HTTP requests over real TCP connection
let mut stream = TcpStream::connect(addr).await.unwrap();
stream.write_all(b"GET / HTTP/1.1\r\nHost: 127.0.0.1\r\nConnection: close\r\n\r\n").await.unwrap();
let mut buf = Vec::new();
stream.read_to_end(&mut buf).await.unwrap();
let response = String::from_utf8_lossy(&buf);
assert!(response.contains("200 OK"));
}
}
Comparison Summary
| Metric | In-Process (TestClient) | Real-Socket Integration |
|---|---|---|
| Speed | Extremely fast (microseconds) | Fast (milliseconds) |
| Network Overhead | Zero (in-memory execution) | Real local TCP loopback socket |
| Middleware Coverage | Handler-level extensions & state | Full Tower layer stack & HTTP protocol |
| Use Case | Unit tests & route handler assertions | Full-stack end-to-end flow validation |
Choosing a Database Backend
The test suite runs against either PostgreSQL or in-memory SQLite. Which one is used is decided by
TEST_BACKEND, falling back to whether DATABASE_URL is set:
| Condition | Backend |
|---|---|
TEST_BACKEND=sqlite | SQLite (in-memory) |
TEST_BACKEND=postgres | PostgreSQL |
neither set, DATABASE_URL present | PostgreSQL |
neither set, no DATABASE_URL | SQLite (in-memory) |
SQLite needs no server and no setup:
# Fast path - no PostgreSQL required. Unset DATABASE_URL so it cannot be picked up.
env -u DATABASE_URL TEST_BACKEND=sqlite cargo test
# PostgreSQL - required before changing any dialect-specific behaviour
export DATABASE_URL="postgres://postgres:postgres@localhost/djangors_test"
cargo test
Each SQLite test gets a fresh sqlite::memory: database, so isolation is automatic — there is no
shared state to clean up and no advisory lock to arbitrate. The pool is deliberately pinned to a
single connection: with a plain sqlite::memory: URL every pooled connection would otherwise be a
separate database, and setup DDL run on one connection would be invisible to the next query.
Why both
SQLite is dramatically faster. Measured on a development machine, djangors-admin’s 32 tests take
0.69s on SQLite against 15.8s on PostgreSQL.
It is not a full substitute. A handful of tests cover behaviour SQLite cannot express — transaction
isolation levels, pg_advisory_lock, SKIP LOCKED queue claiming, and PostgreSQL’s width-strict
integer decoding. Those tests return early when DATABASE_URL is absent, each with a comment
naming the feature they need. They report as passing on SQLite without executing, so a green
SQLite run alone does not prove those paths still work.
Use SQLite for the fast inner development loop, and run against PostgreSQL before changing anything dialect-specific and in CI.
3. Test Conventions
The suite is expected to stay green under both Postgres and SQLite. Conventions below keep it that way.
Where test code lives
Production .rs files contain no test code. Every #[cfg(test)] module lives in a sibling
file:
- For a crate’s
lib.rs, that issrc/tests.rs, declared#[cfg(test)] mod tests;— see the bottom ofcrates/djangors-admin/src/lib.rs. - For any other module, e.g.
src/queryset.rs, it issrc/queryset_tests.rs, declared with#[cfg(test)] #[path = "queryset_tests.rs"] mod tests;— seecrates/djangors-contrib-payments/src/bachs.rs. The#[path]is required: without it Rust looks forsrc/queryset/tests.rs.
This keeps the production files readable, while the tests stay a child module, so they can
still reach private items. That is exactly why they are not in crates/*/tests/ — an integration
test there only sees the crate’s public API, and much of this suite exercises private functions.
Writing tests that pass on both backends
Tests run as cargo test --workspace (Postgres, needs DATABASE_URL) and as
env -u DATABASE_URL TEST_BACKEND=sqlite cargo test --workspace. The rules below keep both green:
Build DDL from the dialect, never as a fixed string
BIGSERIAL and TIMESTAMPTZ are Postgres spellings; SQLite needs INTEGER PRIMARY KEY AUTOINCREMENT. Use db.dialect().auto_pk_type() and db.dialect().timestamp_type():
#![allow(unused)]
fn main() {
let auto_pk = db.dialect().auto_pk_type();
let ts_type = db.dialect().timestamp_type();
db.conn().execute(
&format!("CREATE TABLE widget (id {auto_pk}, created_at {ts_type} NOT NULL)"),
&[],
)
.await
.unwrap();
}
SQLite is permissive enough to accept the Postgres text, so the CREATE TABLE succeeds and the
failure surfaces much later as an insert that returns no row — which is exactly how this bug hid
(see create_table_sql in crates/djangors-contrib-payments/src/transaction_tests.rs).
Quote identifiers through the dialect
db.dialect().quote_ident("user") — user is a reserved word in Postgres but not in SQLite, so
an unquoted column name passes on one backend and is a syntax error on the other:
#![allow(unused)]
fn main() {
let user_col = db.dialect().quote_ident("user");
let create_profile = format!(
"CREATE TABLE profile (id {auto_pk}, {user_col} BIGINT NOT NULL UNIQUE)"
);
}
Never use raw sqlx against Database::pool()
pool() panics outright on a SQLite handle ("Attempted to access Postgres PgPool on a SQLite Database handle"), and $1 placeholders are Postgres-only. Go through the ORM
(Model::objects(), .save(), .count()), which is already dialect-aware.
Never hardcode a connection string
Use djangors_test::TestDatabase, which honours TEST_BACKEND and DATABASE_URL. A hardcoded
postgres://…@localhost/djangors_test ignores both and can only pass against one specific machine.
Use TestDatabase::isolated() when a test creates its own tables
Several tests sharing one table name against a single Postgres database will race on
DROP/CREATE when run in parallel; isolated() gives each test its own database:
#![allow(unused)]
fn main() {
let test_db = djangors_test::TestDatabase::isolated().await.unwrap();
let db = test_db.database();
}
connect() is fine for read-only work (see TestDatabase::connect() vs
TestDatabase::isolated() in crates/djangors-test/src/lib.rs).
Checklist before adding a test
- Put the module in a sibling file (
src/tests.rsorsrc/{name}_tests.rswith#[path]), never in the production.rs. - Build every
CREATE TABLEfromdb.dialect().auto_pk_type()/timestamp_type(), and quote identifiers withquote_ident(). - Go through the ORM; never touch raw
pool()or write$1placeholders by hand. - Obtain the database via
djangors_test::TestDatabase, never a hardcoded URL. - Use
isolated()when the test creates and drops tables;connect()only for read-only work. - Run under both backends (
cargo test --workspaceand the SQLite env-invocation) before relying on it staying green.
Deployment
djangors applications compile into standalone native binaries, making production deployment simple and lightweight.
Production Containerization (Dockerfile)
dj new generates a multi-stage Dockerfile optimized for minimal image size and container health checking:
FROM rust:1-slim AS builder
WORKDIR /app
COPY . .
RUN cargo build --release
FROM debian:bookworm-slim
WORKDIR /app
RUN apt-get update && apt-get install -y --no-install-recommends curl ca-certificates && rm -rf /var/lib/apt/lists/*
COPY --from=builder /app/target/release/my_app /app/server
EXPOSE 8000
ENV DJANGORS_PORT=8000
HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
CMD curl -f http://localhost:8000/healthz || exit 1
CMD ["/app/server"]
/healthz Route
Projects include a dedicated /healthz endpoint returning HTTP 200 OK for container orchestrators (Docker, Kubernetes) and load balancers:
#![allow(unused)]
fn main() {
use djangors_core::{Request, PathParams, Response, DjangorsError, StatusCode};
pub async fn healthz(_req: Request, _params: PathParams) -> Result<Response, DjangorsError> {
Ok(Response::text(StatusCode::OK, "ok"))
}
}
systemd Service Scaffolding
For deployments directly on Linux VMs, dj new scaffolds deploy/djangors.service:
[Unit]
Description=Djangors Web Application (my_app)
After=network.target postgresql.service
[Service]
Type=simple
User=www-data
Group=www-data
WorkingDirectory=/var/www/my_app
ExecStart=/var/www/my_app/my_app
Restart=on-failure
RestartSec=5s
EnvironmentFile=/etc/my_app/djangors.env
[Install]
WantedBy=multi-user.target
Production Pre-flight Check (dj check --deploy)
Before deploying to production, execute dj check --deploy to verify critical security and performance configurations:
dj check --deploy
Verification Rules Enforced
DEBUGmode: Must befalse(fails ifdebug = true).SECRET_KEY: Must be at least 32 characters long.ALLOWED_HOSTS: Must be configured explicitly (fails if set to default["127.0.0.1", "localhost"]whendebugisfalse).
Provider-Managed Deployment (djangors-deploy)
djangors-deploy provides a DeployProvider trait (provision/deploy/status/logs/
destroy), implemented so far by RenderProvider (drives Render’s REST API directly) and
SshProvider (a raw VPS reachable over SSH). This is a first slice: no dj deploy CLI
subcommand is wired in yet, and Railway/GCP/AWS providers aren’t implemented. Using it today
means calling the trait directly from your own tooling.
#![allow(unused)]
fn main() {
use djangors_deploy::DeploySpec;
fn spec_example() -> DeploySpec {
DeploySpec {
project_name: "my-app".to_string(),
repo_url: Some("https://github.com/me/my-app.git".to_string()),
branch: "main".to_string(),
dockerfile_path: "Dockerfile".to_string(),
docker_context: ".".to_string(),
health_check_path: "/healthz".to_string(),
env_vars: vec![("RUST_LOG".to_string(), "info".to_string())],
region: None,
plan: None,
needs_database: true,
}
}
}
RenderProvider
#![allow(unused)]
fn main() {
use djangors_deploy::{DeployProvider, render::RenderProvider};
async fn deploy_to_render(spec: &djangors_deploy::DeploySpec) -> Result<(), djangors_deploy::DeployError> {
let owner_id = RenderProvider::discover_owner_id("rnd_your_api_key").await?;
let provider = RenderProvider::new("rnd_your_api_key", owner_id);
let info = provider.provision(spec).await?;
provider.deploy(&info, spec).await?; // polls until live or failed
Ok(())
}
}
SshProvider
SshProvider shells out to the system ssh binary (via tokio::process::Command) rather than
adding a native SSH library dependency, the same class of native-dependency build friction this
project hit once already deploying to a container platform. It clones/hard-resets your repo on
the remote host and runs the same Dockerfile-based docker build/docker run flow
RenderProvider uses. Every value interpolated into a remote shell command is POSIX-escaped and
project_name is validated against a safe alphanumeric/-/_ pattern before use, as defense in
depth.
#![allow(unused)]
fn main() {
use djangors_deploy::{ssh::SshProvider, DeployProvider};
async fn deploy_over_ssh(spec: &djangors_deploy::DeploySpec) -> Result<(), djangors_deploy::DeployError> {
let provider = SshProvider::new("203.0.113.10", 22, "deploy", "/home/deploy/.ssh/id_ed25519");
let info = provider.provision(spec).await?;
provider.deploy(&info, spec).await?;
Ok(())
}
}
DeployStatus & DeployError
DeployProvider::status reports progress through the DeployStatus enum, and every provider
operation fails with DeployError:
DeployStatus variant | Meaning |
|---|---|
InProgress | A build or deploy is currently underway |
Live | Deployment is live (and, where the provider reports it, past its health check) |
Failed(String) | Build/deploy failed; the string is the provider’s human-readable reason |
NotFound | The provider reports no matching deployment (e.g. destroyed via its dashboard) |
DeployError variant | Meaning |
|---|---|
Request(reqwest::Error) | The underlying HTTP request to the provider API failed |
Api { status: u16, message: String } | The provider API answered with an error status + message |
UnexpectedResponse(String) | The provider’s response was missing a field this client expected |
Timeout(String) | A provision/deploy operation exceeded its polling timeout |
#![allow(unused)]
fn main() {
use djangors_deploy::{DeployError, DeployStatus};
async fn wait_until_live(
status: djangors_deploy::DeployStatus,
) -> Result<(), DeployError> {
match status {
DeployStatus::Live => { /* health check passed */ Ok(()) }
DeployStatus::InProgress => Err(DeployError::Timeout("still deploying".into())),
DeployStatus::Failed(reason) => Err(DeployError::Api { status: 500, message: reason }),
DeployStatus::NotFound => Err(DeployError::UnexpectedResponse("deployment missing".into())),
}
}
}
Error Tracking (optional Sentry integration)
djangors-core’s optional sentry Cargo feature (off by default, so zero cost/deps unless
enabled) wires Sentry into the same tracing setup init_production_logging() already uses,
rather than requiring a second, separate logging setup:
use djangors_core::logging::init_production_logging_with_sentry;
fn main() {
// Empty/invalid DSN produces a disabled client (Sentry's own cross-language convention) -
// always safe to call unconditionally even with a DSN that may be empty in development.
let _guard = init_production_logging_with_sentry("https://key@o0.ingest.sentry.io/0");
// ... start the app; the guard must stay alive for the process lifetime.
}
ERROR-level tracing spans become Sentry events automatically; everything else becomes
breadcrumbs, and panics are captured via Sentry’s built-in panic integration.
Graceful Shutdown
djangors-core handles process termination signals (SIGINT and SIGTERM) gracefully via run_with_shutdown and run_service_with_shutdown:
#![allow(unused)]
fn main() {
async fn run_app() -> Result<(), Box<dyn std::error::Error>> {
let app = djangors_core::Djangors::new(djangors_core::DjangorsSettings::default(), djangors_core::Router::new());
app.run_with_shutdown(async {
tokio::signal::ctrl_c().await.ok();
}).await?;
Ok(())
}
}
Shutdown Sequence
- Upon receiving
SIGINTorSIGTERM, the TCP accept loop stops listening for new connections. - An active connection drain phase initiates, allowing in-flight HTTP requests to complete.
- If in-flight requests do not complete within 30 seconds, remaining connections are aborted to ensure timely process termination.
Security
Djangors includes built-in security middleware covering CSRF protection, security response headers, host header validation, and HMAC-signed session cookie storage.
CSRF Protection (CsrfLayer)
CsrfLayer (djangors_core::middleware::csrf_layer()) implements CSRF protection using a double-submit cookie scheme with header and form-body validation fallbacks.
fn main() {
let router_service = djangors_core::router::RouterService::new(djangors_core::Router::new(), false);
use djangors_core::middleware::csrf_layer;
use tower::ServiceBuilder;
let service = ServiceBuilder::new()
.layer(csrf_layer())
.service(router_service);
}
Mechanism & Verification Flow
- Cookie Generation: On safe requests (
GET,HEAD,OPTIONS), if nocsrftokencookie exists, a cryptographically secure random 32-byte hex token is generated and returned in acsrftokencookie (SameSite=Lax; Path=/; Max-Age=31536000). Note: The cookie is intentionally notHttpOnlyso client-side JavaScript can read it to populate request headers. - Unsafe Method Validation: For unsafe HTTP methods (
POST,PUT,PATCH,DELETE), the token in theX-CSRFTokenrequest header is compared against thecsrftokencookie value using constant-time comparison (constant_time_eq). - Form Fallback: If the header is missing or mismatched,
CsrfLayersetsCsrfPendingFormCheckon the request extensions. Form submission handlers verify thecsrfmiddlewaretokenform body field against the pending token, returning403 Forbiddenif missing or invalid.
Production checklist: csrf_layer() defaults to Secure off on the csrftoken cookie, the
same way SignedCookieStore::new() defaults Secure off (see below). This keeps local HTTP
development working out of the box. In production, enable it the same way the session store does:
csrf_layer().with_secure(!settings.debug). Both examples/school and examples/polls wire this
correctly; if you’re copying from an older reference, check that your own main.rs does too. An
internal security review (2026-07-27) found neither example app had actually done this before,
despite the session-cookie guidance below already documenting the correct pattern.
Security Headers (SecurityHeadersLayer)
SecurityHeadersLayer (djangors_core::middleware::security_headers_layer()) sets standard security response headers mimicking Django’s SecurityMiddleware:
fn main() {
let router_service = djangors_core::router::RouterService::new(djangors_core::Router::new(), false);
use tower::ServiceBuilder;
use djangors_core::middleware::security_headers_layer;
let service = ServiceBuilder::new()
.layer(security_headers_layer())
.service(router_service);
}
Headers Set
X-Content-Type-Options: nosniff: Prevents browsers from MIME-sniffing responses away from the declared content type.X-Frame-Options: DENY: Protects against clickjacking by preventing the app from being embedded in<frame>,<iframe>, or<object>elements.Referrer-Policy: same-origin: Restricts referrer information sent in HTTP headers to same-origin requests.
Strict Transport Security (HstsLayer)
Enforces HTTPS via HTTP Strict Transport Security (HSTS):
fn main() {
use djangors_core::middleware::hsts_layer;
// Sets Strict-Transport-Security: max-age=31536000; includeSubDomains
let hsts = hsts_layer(31536000).with_include_subdomains(true);
}
Host Header Validation (HostValidationLayer)
HostValidationLayer validates incoming HTTP Host headers against the project’s ALLOWED_HOSTS setting:
fn main() {
use djangors_core::middleware::HostValidationLayer;
let layer = HostValidationLayer::new(vec!["example.com".to_string(), "api.example.com".to_string()]);
}
- Strip trailing port numbers before matching.
- Requests with disallowed Host headers are immediately rejected with
400 Bad Request: Disallowed Hostbefore reaching application handlers.
Content Security Policy (CspBuilder)
CspBuilder (djangors_core::middleware::CspBuilder) is the django-csp equivalent: a builder
for the Content-Security-Policy response header, with one method per directive:
fn main() {
use djangors_core::middleware::CspBuilder;
let csp_layer = CspBuilder::new()
.default_src(&["'self'"])
.script_src(&["'self'", "https://cdn.example.com"])
.style_src(&["'self'", "'unsafe-inline'"])
.img_src(&["'self'", "data:"])
.frame_ancestors(&["'none'"])
.upgrade_insecure_requests()
.build();
let _ = csp_layer;
}
Supported directives: default_src, script_src, style_src, img_src, connect_src,
font_src, frame_ancestors, form_action, and object_src (each taking a source list), plus
upgrade_insecure_requests() (a bare flag, no source list). Call .report_only(true) before
.build() to send Content-Security-Policy-Report-Only instead of enforcing the policy. This is
useful for testing a new policy against real traffic before turning on enforcement. Directive
values pass through verbatim (no source-keyword validation), matching HstsLayer’s own
deliberately minimal scope, so you’re responsible for getting the CSP syntax right.
Signed Cookie Session Store (SignedCookieStore)
SignedCookieStore (djangors_sessions::SignedCookieStore) stores session data in client-side cookies signed with HMAC-SHA256 using settings.SECRET_KEY:
fn main() {
let secret_key_bytes = b"01234567890123456789012345678901";
use djangors_sessions::{SessionLayer, SignedCookieStore};
let store = SignedCookieStore::new(secret_key_bytes)
.with_cookie_name("djangors_sessionid".into())
.with_secure(true); // Enforce Secure attribute in production
let session_layer = SessionLayer::new(store);
}
Security Features
- HMAC Signature: Cookies are encoded as
b64_json.b64_expiry.b64_mac. Any tampering with payload or expiration invalidates the MAC signature, causingdecode()to returnNone(clearing session state). HttpOnly&SameSite=Lax: Session cookies are markedHttpOnly(inaccessible to client JavaScript) andSameSite=Lax.- Session Fixation Prevention (
cycle_key): Callingsession.cycle_key()during authentication (login()) rotates the session key string, preventing session fixation attacks.
Malware/AV Scanning of Uploads (clamav feature)
djangors-staticfiles’s optional clamav Cargo feature (off by default, so zero cost/deps unless
enabled) scans uploaded file bytes against a real clamd daemon before they’re ever written to
disk or a Storage backend, using clamd’s INSTREAM wire protocol directly.
#![allow(unused)]
fn main() {
use djangors_staticfiles::clamav::{ClamAvScanner, ScanResult};
async fn scan_upload(bytes: &[u8]) -> Result<(), &'static str> {
let scanner = ClamAvScanner::unix("/var/run/clamav/clamd.ctl");
match scanner.scan(bytes).await {
Ok(ScanResult::Clean) => Ok(()),
Ok(ScanResult::Infected(signature)) => {
Err(Box::leak(format!("rejected upload: {signature}").into_boxed_str()))
}
Err(_) => Err("could not reach the malware scanner"),
}
}
}
ClamAvScanner::unix(path) connects over a Unix domain socket (the common case when clamd runs
on the same host); ClamAvScanner::tcp(host, port) connects over TCP instead. Scanning happens
in-memory rather than by handing clamd a file path, since clamd runs as its own OS user and
generally can’t read arbitrary application-owned paths. Scanning in-memory bytes also means
the check can happen before anything touches disk at all. This requires a real clamd daemon
already running and reachable; nothing in this module starts one for you.
Connection Pool Tuning
This guide records the 2026-07-27 load test of the real examples/school Djangors admin,
not the axum comparison target. The fixture was PostgreSQL 16 on 127.0.0.1, database
djangors_bench, with 5,000 Student rows. The complete, unabridged oha output is in
benchmarks/results/admin-sweep-2026-07-27.txt.
What was measured
Each point used oha -c 8,16,32,64,128 -z 20s --no-tui against the real Student changelist,
both plain and ?q=Searchable, for both a superuser and a staff user with explicit
school.view_student, school.add_student, school.change_student, and
school.delete_student grants. The complete matrix was repeated with
DJANGORS_MAX_CONNECTIONS=5, 10, and 25. Login was a real GET-for-CSRF followed by
POST /accounts/login/; both fixtures returned 302 and a non-empty signed
djangors_sessionid cookie.
The superuser path short-circuits permission lookups. The staff changelist performs one
require_perm check, three sequential action checks, and the count plus page queries. Each
non-superuser permission check can issue a direct and a group query, so the measured workload
has up to nine sequential database round trips per page request. Query count is fixed by the
request path; it is not an N+1-per-row workload.
Correction (independently verified during review, replacing an unsubstantiated claim in an
earlier draft of this doc): the committed raw sweep (all 60 points) shows a 100% success
rate at every single pool/concurrency combination tested, including the deliberately undersized
max_connections=5 at concurrency 128. There is no non-200 status code anywhere in
benchmarks/results/admin-sweep-2026-07-27.txt; the only “errors” oha reports are
aborted due to deadline entries whose count simply tracks the configured concurrency level
(in-flight requests when the fixed -z time window ends), at every pool size equally, which is
not evidence of pool exhaustion. An earlier draft of this doc claimed a specific
Connection error: ... connection closed before message completed failure was observed at that
point; this was re-tested directly (a fresh real login, a fresh real oha run against the same
combination) and could not be reproduced, so the claim has been removed rather than repeated
unverified.
To find the actual limits of this setup, three additional real runs were made against a
purpose-built, far more extreme configuration (max_connections=1, well below anything in the
committed sweep): at concurrency 200, still 100% success. Latency grew to a ~3.2s p99 but every
request eventually completed; at concurrency 1000, still 100% success on every request that
finished within the 15s test window (861 of 1861 total; the rest were still queued and correctly
reported as aborted due to deadline, not a connection failure). The real, verified finding is
that this workload’s contention shows up as growing queueing latency, not outright connection
failures, because sqlx’s acquire_timeout (mapped from connect_timeout_secs, default 10s)
is generous relative to how quickly this admin path’s queries actually complete, even with a
single connection serving 1,000 concurrent requests. Producing a genuine acquire-timeout failure
in this environment would need either a much smaller connect_timeout_secs, sustained queueing
past 10 real seconds per request, or a meaningfully heavier per-request workload than this
admin path’s ~9 round trips of simple indexed queries.
Choosing the settings
max_connections is the pool ceiling. A useful first estimate for this admin path is:
required pool >= concurrent requests × round trips/request × connection hold time / request budget
For example, use nine round trips for the staff path, then substitute the median per-round-trip
hold time measured in the raw run and the latency budget you can accept. Validate the result with
the concurrency sweep: latency grows measurably as the pool is undersized relative to
concurrency (compare the max_connections=5 staff/plain points at concurrency 64 vs 128 in the
raw file against the same points at max_connections=10/25), even though none of them actually
fail outright in this workload. Also account for PostgreSQL’s total connection limit and every
application process when sizing this value.
min_connections controls how many connections are kept available when the pool is idle. The
default is 1. Raise it when avoiding first-request connection creation matters and the database
can afford the standing connections; leave it low for sparse development workloads.
connect_timeout_secs limits how long pool acquisition may spend establishing a new database
connection. Increase it only when startup or transient database connection establishment needs
more time; it does not make an exhausted pool healthy.
idle_timeout_secs closes connections that have been idle for the configured number of seconds.
The default is None, so idle connections are retained. Set it when database/proxy connection
limits or network infrastructure require retirement of idle sessions; avoid setting it below
the normal quiet period if reconnect churn is undesirable.
Follow-up
DatabaseConfig currently exposes no max_lifetime, test_before_acquire, or statement-cache
knobs. Exposing those would be a separate follow-up. The repeated permission queries on the
staff admin path are also a future optimization candidate; this measurement intentionally does
not change that path.
Typed Settings (#[derive(Settings)])
#[derive(djangors_macros::Settings)] is the Djangors equivalent of pydantic-settings/
django-environ: a typed, validated way to load your own application’s configuration from
environment variables, without hand-writing std::env::var(...) plus manual parsing and error
handling for every field.
This is distinct from DjangorsSettings (the framework’s own fixed settings struct for things
like SECRET_KEY/ALLOWED_HOSTS/DEBUG). #[derive(Settings)] is for config your application
defines itself: a third-party API key, a feature flag, a timeout, anything your own code needs
at startup.
Basic usage
#[derive(djangors_macros::Settings, Debug)]
#[djangors(prefix = "MYAPP")]
struct AppSettings {
// Required - missing MYAPP_API_KEY at startup is a load() error, not a panic later.
api_key: String,
// Has a default - falls back to this value if MYAPP_BASE_URL isn't set.
#[djangors(default = "https://api.example.com".to_string())]
base_url: String,
#[djangors(default = 30)]
timeout_secs: u64,
// Option<T> fields are never required - None if the env var is unset.
feature_flag: Option<bool>,
// Vec<String> fields parse as a comma-separated list.
allowed_origins: Option<Vec<String>>,
}
fn main() {
match AppSettings::load() {
Ok(settings) => {
let _ = settings.api_key;
}
Err(e) => {
eprintln!("config error: {e}");
}
}
}
Environment variable naming
Each field maps to {PREFIX}_{FIELD_NAME_UPPERCASE}. With #[djangors(prefix = "MYAPP")]:
| Field | Environment variable |
|---|---|
api_key | MYAPP_API_KEY |
base_url | MYAPP_BASE_URL |
timeout_secs | MYAPP_TIMEOUT_SECS |
Supported field types
String, bool, every built-in integer and float type (i32, i64, u32, u64, f32, f64,
etc.), and Vec<String> (comma-separated: MYAPP_ALLOWED_ORIGINS=a.com,b.com,c.com). Wrap any of
these in Option<T> to make the field optional instead of required.
Extending with FromSettingsValue
All the above types work because they implement djangors_core::settings::FromSettingsValue
(one method, parse_settings_value(raw: &str) -> Result<Self, String>). Implement it for your own
newtype to get a parsed, validated field for free:
#![allow(unused)]
fn main() {
use djangors_core::settings::FromSettingsValue;
struct Port(u16);
impl FromSettingsValue for Port {
fn parse_settings_value(raw: &str) -> Result<Self, String> {
let n = raw.parse::<u16>().map_err(|e| e.to_string())?;
if n == 0 {
return Err("port must be non-zero".to_string());
}
Ok(Port(n))
}
}
}
Your type can then appear directly in a #[derive(Settings)] struct — with a
#[djangors(default = ...)] fallback or Option<T> optionality, exactly like
the built-in types.
Errors
load() returns Result<Self, djangors_core::settings::SettingsError> with two distinguishable
failure variants:
#![allow(unused)]
fn main() {
fn show() {
use djangors_core::settings::SettingsError;
let err = SettingsError::MissingRequired {
field: "api_key",
env_var: "MYAPP_API_KEY".to_string(),
};
match err {
SettingsError::MissingRequired { field, env_var } => {
eprintln!("required setting `{field}` (${env_var}) was not set");
}
SettingsError::InvalidValue { field, env_var, .. } => {
eprintln!("setting `{field}` (${env_var}) had a value that couldn't be parsed");
}
}
}
}
MissingRequired: a field with no#[djangors(default = ...)]and not wrapped inOption<T>had no environment variable set at all.InvalidValue: the environment variable was set, but its value couldn’t be parsed into the field’s declared type (e.g.MYAPP_TIMEOUT_SECS=not-a-number).
Call load() once at process startup (in main(), before the server starts accepting
connections) and treat any Err as a hard failure to boot. This is the same “fail fast on bad
config” principle dj check --deploy already applies to the framework’s own settings.
PDF Generation (djangors-pdf)
djangors-pdf is the Djangors equivalent of weasyprint, but built differently on purpose.
Rather than rendering HTML/CSS through a headless browser engine (a real, heavy runtime
dependency that’s often awkward or impossible to install in a minimal container image),
djangors-pdf is a typed Rust builder API over a pure-Rust PDF backend. There’s no Chrome/
Chromium dependency to ship, and the documents you build are structured (headings, text, tables)
rather than arbitrary rendered markup. That’s a good fit for the actual common cases: report cards,
invoices, and receipts.
Basic usage
#![allow(unused)]
fn main() {
fn build() -> Result<Vec<u8>, djangors_pdf::PdfError> {
use djangors_pdf::PdfDocument;
let mut doc = PdfDocument::new("Report Card")?;
doc.heading("Term Report")
.text("Student: Jane Doe")
.text("Class: Grade 5A")
.spacer(5.0)
.table(
&["Subject", "Score", "Grade"],
&[
vec!["Mathematics".to_string(), "92".to_string(), "A".to_string()],
vec!["English".to_string(), "78".to_string(), "B".to_string()],
],
);
let bytes: Vec<u8> = doc.render()?;
Ok(bytes)
}
}
render() consumes the builder and returns the finished PDF as Vec<u8>. Write it directly to a
response body, to disk, or through a Storage backend (see the ORM/static-files guides).
Builder methods
PdfDocument::new(title): starts a new A4 document with the given title..heading(text): a large, bold heading line..text(text): a regular body-text line..spacer(height_mm): vertical whitespace, in millimeters..table(headers, rows): a simple table. A header row plus any number of data rows (each row is aVec<String>matching the header count).
All of these return &mut Self, so calls chain. Content automatically flows onto a new page once
the current one fills up. You don’t need to manage page breaks yourself.
Serving a generated PDF
#![allow(unused)]
fn main() {
use djangors_core::{Request, PathParams, Response, DjangorsError, StatusCode};
use djangors_pdf::PdfDocument;
pub async fn report_card_view(_req: Request, _params: PathParams) -> Result<Response, DjangorsError> {
let mut doc = PdfDocument::new("Report Card").map_err(|e| DjangorsError::Internal(e.to_string()))?;
doc.heading("Term Report").text("Student: Jane Doe");
let bytes = doc.render().map_err(|e| DjangorsError::Internal(e.to_string()))?;
Ok(Response::bytes(StatusCode::OK, "application/pdf", bytes)
.header("Content-Disposition", "inline; filename=\"report-card.pdf\""))
}
}
Payments (djangors-contrib-payments)
djangors-contrib-payments is a provider-agnostic payment integration crate, shipping a
Paystack implementation first. It’s built around two hard requirements for anything touching
real money: amounts are never a float, and a webhook redelivery or duplicate initiate call
can never double-process a charge.
Amounts: integer minor units, never a float
Every amount in this crate is an i64 in the currency’s minor unit (kobo for NGN, cents for
USD), never f64 and never rust_decimal::Decimal. This is the same convention Paystack’s and
Stripe’s own APIs use on the wire, so there’s no float-precision problem to solve in the first
place: 5000 means ₦50.00, not 50.0.
The PaymentProvider trait
#![allow(unused)]
fn main() {
use djangors_contrib_payments::{
InitiateChargeRequest, InitiateChargeResponse, PaymentError, PaymentProvider, RefundResult,
};
async fn charge_example(provider: &dyn PaymentProvider) -> Result<InitiateChargeResponse, PaymentError> {
let req = InitiateChargeRequest {
email: "customer@example.com".to_string(),
amount_minor: 50_000, // NGN 500.00
currency: "NGN".to_string(),
reference: "order-1234".to_string(),
callback_url: Some("https://example.com/callback".to_string()),
};
provider.initiate(&req).await
}
}
Every provider implements four operations: initiate (start a charge, get a redirect URL back),
verify (confirm a transaction’s real status directly with the provider; never trust a
client-side “success” redirect alone), verify_webhook_signature, and refund.
PaystackProvider
#![allow(unused)]
fn main() {
use djangors_contrib_payments::PaystackProvider;
fn make_provider(secret_key: &str) -> PaystackProvider {
PaystackProvider::new(secret_key.to_string())
}
}
PaystackProvider::new(secret_key) talks to the real Paystack API
(https://api.paystack.co). Get your secret key from the Paystack dashboard and load it via
#[derive(Settings)] (see the Settings guide) rather than hardcoding it.
For sandbox/testing against a mock server, or to route through a proxy in front of Paystack,
use with_base_url(secret_key, base_url) — it points every request at base_url instead of the
real https://api.paystack.co:
fn main() {
use djangors_contrib_payments::PaystackProvider;
let live = PaystackProvider::new("sk_live_..."); // real https://api.paystack.co
let sandbox = PaystackProvider::with_base_url("sk_test_...", "http://localhost:8080"); // mock server
let proxied = PaystackProvider::with_base_url("sk_live_...", "https://paystack-proxy.internal");
let _ = (live, sandbox, proxied);
}
BachsProvider
Bachs is a payments/billing platform aimed at internet businesses with
African settlement. BachsProvider implements the same PaymentProvider trait as Paystack, so
it is a drop-in alternative for one-time charges.
#![allow(unused)]
fn main() {
use djangors_contrib_payments::BachsProvider;
fn make_provider(secret_key: &str) -> BachsProvider {
BachsProvider::new(secret_key.to_string())
}
}
There are three constructors. new(secret_key) talks to the production API
(https://api.bachs.io), sandbox(secret_key) points at https://sandbox-api.bachs.io, and
with_base_url(secret_key, base_url) routes everything at an arbitrary base URL (useful for a
mock server or a proxy). Build against sandbox first — going live is a key swap (sk_sandbox_
→ sk_live_), not a code change:
fn main() {
use djangors_contrib_payments::BachsProvider;
let live = BachsProvider::new("sk_live_..."); // real https://api.bachs.io
let sandbox = BachsProvider::sandbox("sk_sandbox_..."); // https://sandbox-api.bachs.io
let _ = (live, sandbox);
}
Amounts: decimal strings on the wire
This is the part most likely to trip someone up. The PaymentProvider trait speaks in
amount_minor: i64 (kobo, cents), and you keep using minor units throughout — but Bachs’s own
API uses decimal strings at the currency’s precision and explicitly never minor units. The
provider converts in both directions using exact integer arithmetic, driven by a
currency-exponent table: 0 decimals for currencies like JPY and XOF, 3 for currencies like KWD
and BHD, and 2 for everything else (including NGN, USD, GHS, KES, ZAR). You never see a decimal
string; the conversion happens at the boundary. So amount_minor: 123456 with "NGN" becomes
"1234.56" on the wire, and a "1234.56" response round-trips back to 123456.
initiate / verify / refund
The one deviation from Paystack you must not miss: Bachs has no lookup-by-merchant-reference
endpoint, so verify(reference) and refund(reference, ..) interpret their argument as the
Bachs checkout id, not your own merchant reference. initiate returns that checkout id in
the access_code field of InitiateChargeResponse (Bachs has no “access code” concept of its
own) and the redirect URL in authorization_url. The caller must persist access_code after
initiate, or it cannot verify or refund the charge later. A reader who misses this ends up
with a broken integration.
Webhooks
Bachs signs deliveries with two headers, X-Bachs-Timestamp and X-Bachs-Signature, where the
signature is an HMAC-SHA256 hex digest of "{timestamp}.{raw_body}". Because the single-argument
verify_webhook_signature has nowhere to put the timestamp, the trait gained
verify_webhook_signature_with_timestamp(raw_body, timestamp, signature), whose default
implementation ignores the timestamp so Paystack is unaffected. Note that BachsProvider’s plain
verify_webhook_signature deliberately returns false unconditionally — it fails closed rather
than pretending to verify — so Bachs callers must use the timestamped method.
handle_bachs_webhook verifies, parses, and records the delivery in one call. Pass the raw body
bytes exactly as received, never a re-serialized copy, or the signature will not match. The
collection.succeeded event is the one treated as payment confirmation.
Paystack or Bachs?
At a factual level: Paystack verifies and refunds by your merchant reference and signs webhooks with a single HMAC-SHA512 header; Bachs verifies and refunds by checkout id, uses multi-currency decimal amounts, and signs webhooks with a timestamped HMAC-SHA256 pair. Pick the provider whose API and settlement model match your market.
Idempotent transaction recording
Transaction’s reference column has a real database-level UNIQUE constraint, not an
application-level check-then-insert, which would race under a concurrent webhook redelivery or a
double-clicked “pay now” button. record_charge_initiated relies on that constraint directly:
#![allow(unused)]
fn main() {
use djangors_contrib_payments::{record_charge_initiated, Transaction};
use djangors_orm::djangors_db::Database;
async fn start_checkout(db: &Database, reference: &str) -> Result<Transaction, djangors_contrib_payments::PaymentError> {
// Called twice with the same `reference` (a retry, a duplicate webhook) returns the SAME
// row both times - it never creates a second one and never errors.
record_charge_initiated(db, reference, "paystack", 50_000, "NGN", None).await
}
}
Transaction statuses
Transaction.status is a plain string column, but the crate models it as the
TransactionStatus enum so you never hand-type a lowercase string. The variants are
Pending, Success, Failed, and Refunded, stored lowercased, and the enum round-trips
through as_str(), Display, and FromStr:
use std::str::FromStr;
fn main() {
use djangors_contrib_payments::TransactionStatus;
assert_eq!(TransactionStatus::Pending.as_str(), "pending");
assert_eq!(TransactionStatus::Success.to_string(), "success");
// DB string -> enum, and back.
let back: TransactionStatus = "refunded".parse().expect("valid status");
assert_eq!(back, TransactionStatus::Refunded);
assert_eq!(TransactionStatus::from_str("failed").unwrap(), TransactionStatus::Failed);
}
mark_transaction_status flips an existing row (found by reference) to a new status,
optionally replacing its stored metadata. It returns Ok(None) when no row exists for that
reference — a sign the initiate step was skipped:
#![allow(unused)]
fn main() {
use djangors_contrib_payments::{TransactionStatus, mark_transaction_status};
use djangors_orm::djangors_db::Database;
async fn flag_failed(db: &Database, reference: &str) -> Result<(), djangors_contrib_payments::PaymentError> {
// Optionally replace the row's metadata with a reason.
match mark_transaction_status(
db,
reference,
TransactionStatus::Failed,
Some(&serde_json::json!({"reason": "card_declined"})),
)
.await?
{
Some(txn) => { /* txn.status == "failed" */ }
None => { /* no row with that reference - initiate wasn't recorded */ }
}
Ok(())
}
}
Fetch a row directly with find_by_reference(db, reference), which returns
Result<Option<Transaction>, PaymentError> — None when no row matches:
#![allow(unused)]
fn main() {
use djangors_contrib_payments::{Transaction, find_by_reference};
use djangors_orm::djangors_db::Database;
async fn lookup(db: &Database, reference: &str) -> Result<Option<Transaction>, djangors_contrib_payments::PaymentError> {
find_by_reference(db, reference).await
}
}
Webhook handling
handle_paystack_webhook replicates the real, production-proven order of operations for
processing an incoming webhook safely:
- Verify the
x-paystack-signatureheader (HMAC-SHA512, constant-time) against the raw request body bytes, before ever parsing any JSON. A bad signature is rejected without the payload being touched at all. - Only then parse the JSON body.
- Require both
event == "charge.success"anddata.status == "success"before treating the charge as confirmed. Checking only one of these would be a real security gap. - Idempotently record or update the transaction, so a genuine webhook redelivery is a harmless no-op, not a duplicate credit.
#![allow(unused)]
fn main() {
use djangors_contrib_payments::{handle_paystack_webhook, PaystackProvider};
use djangors_core::{Request, PathParams, Response, DjangorsError, StatusCode};
use djangors_orm::djangors_db::Database;
pub async fn paystack_webhook_view(
req: Request,
_params: PathParams,
) -> Result<Response, DjangorsError> {
let db = req
.state::<Database>()
.ok_or_else(|| DjangorsError::Internal("no database in request state".into()))?;
let provider = req
.state::<PaystackProvider>()
.ok_or_else(|| DjangorsError::Internal("no PaystackProvider in request state".into()))?;
let signature = req
.header("x-paystack-signature")
.and_then(|v| v.to_str().ok())
.ok_or_else(|| DjangorsError::Unauthorized("missing signature header".into()))?;
let body = req.body_bytes().await;
handle_paystack_webhook(provider, db, body, signature)
.await
.map_err(|e| DjangorsError::Unauthorized(e.to_string()))?;
Ok(Response::text(StatusCode::OK, "ok"))
}
}
Verifying and reconciling charges
A charge is not confirmed when the customer’s browser says it is — a client-side “success” page
can be forged or simply never arrive. The full lifecycle always ends in a server-side
verify:
initiatethe charge and keep the returnedInitiateChargeResponse.reference.record_charge_initiatedaPendingrow keyed by thatreferencebefore redirecting, so a late webhook or a duplicate initiate finds (and reuses) it.- Redirect the customer to
authorization_url. - On the callback/return, call
provider.verify(&reference)ourselves — never trust the redirect alone. mark_transaction_statuswith the verified status.
The signature-verified webhook is the out-of-band safety net for the same flow: it both records and confirms in one call, so a payment that never came back through the redirect is still reconciled.
#![allow(unused)]
fn main() {
use djangors_contrib_payments::{
InitiateChargeRequest, PaymentProvider, PaystackProvider, mark_transaction_status,
record_charge_initiated,
};
use djangors_orm::djangors_db::Database;
async fn checkout(db: &Database, provider: &PaystackProvider) -> Result<(), Box<dyn std::error::Error>> {
// 1. Start the charge. Keep the response's reference for verification.
let initiated = provider
.initiate(&InitiateChargeRequest {
email: "customer@example.com".into(),
amount_minor: 50_000,
currency: "NGN".into(),
reference: "order-5678".into(),
callback_url: Some("https://example.com/callback".into()),
})
.await?;
// 2. Persist a Pending row before redirecting (idempotent on this reference).
record_charge_initiated(db, &initiated.reference, "paystack", 50_000, "NGN", None).await?;
// 3. Redirect to initiated.authorization_url ... customer returns via callback.
// 4. Server-side verification. Never trust the client-side redirect alone.
let verified = provider.verify(&initiated.reference).await?;
// 5. Only now persist the confirmed status.
mark_transaction_status(db, &initiated.reference, verified.status, None).await?;
Ok(())
}
}
Not yet included
Stripe/Anchor/Moniepoint providers, HTTP route wiring into any example app (this crate exposes
functions your own handler calls, matching how djangors-auth exposes backends rather than
routes; not an oversight), and generic idempotency-key middleware for arbitrary POST APIs (a
separate, broader concern than this crate’s own reference-based idempotency) are all deliberately
out of scope for this first slice.
Multi-tenancy (djangors-contrib-tenancy)
djangors-contrib-tenancy provides row-level multi-tenancy: a shared database schema where
tenant-scoped tables carry a foreign key to a Tenant, and every query against them is
automatically and verifiably restricted to the current request’s tenant. See
docs/design/12.1-multi-tenancy.md in the repository for the full design rationale, including why
this model was chosen over schema-per-tenant or database-per-tenant.
Tenant and TenantMembership
#![allow(unused)]
fn main() {
use djangors_contrib_tenancy::{Tenant, TenantMembership};
fn describe(tenant: &Tenant, membership: &TenantMembership) -> String {
format!("{} ({})", tenant.name, membership.role)
}
}
Tenant is deliberately generic (name/slug/is_active). It represents a school, a bank
branch, or a seller account equally well; your application names its own domain concept on top.
TenantMembership links a user to a tenant with a role, and a user may hold more than one
membership (belonging to several tenants). That’s more general than simply putting a tenant_id
directly on your user model, and it doesn’t require modifying djangors-auth’s User struct at
all.
Resolving the current tenant (TenantResolutionLayer)
A request’s tenant is never trusted from a client-supplied header alone. TenantResolutionLayer
reads the X-Tenant-Id header, then verifies the authenticated user actually has a real
TenantMembership row for that tenant before accepting it. A forged or stale header for a
tenant the user doesn’t belong to is silently rejected (no CurrentTenant gets set at all, which
downstream scoping then treats as unauthorized), never treated as a default/fallback tenant.
#![allow(unused)]
fn main() {
use djangors_contrib_tenancy::TenantResolutionLayer;
use djangors_orm::djangors_db::Database;
fn build_layer(db: Database) -> TenantResolutionLayer<impl Fn(&hyper::Request<hyper::body::Incoming>) -> Option<i64> + Clone> {
// The extractor closure reads whatever your own upstream auth middleware already stored on
// the request (e.g. an authenticated user id extension) - this crate doesn't assume a
// specific auth setup.
TenantResolutionLayer::new(db, |req| {
req.extensions().get::<CurrentUserId>().map(|u| u.0)
})
}
#[derive(Clone, Copy)]
struct CurrentUserId(i64);
}
Scoping a model to the current tenant
djangors-rest already ships the enforcement primitive this crate builds on: the Scoped trait,
which ScopedViewSet<M> requires. A model without a scope() implementation simply cannot be
used with ScopedViewSet at all, a compile-time guarantee rather than a convention someone has to
remember. tenant_scope() is a one-line helper for writing that implementation:
#![allow(unused)]
fn main() {
use djangors_contrib_tenancy::tenant_scope;
use djangors_core::{Request, error::DjangorsError};
use djangors_macros::Model;
use djangors_orm::QuerySet;
use djangors_rest::Scoped;
#[derive(Model, Debug, Clone, sqlx::FromRow)]
#[djangors(app = "myapp", table_name = "myapp_schoolclass")]
struct SchoolClass {
#[djangors(primary_key, auto)]
id: i64,
#[djangors(foreign_key(on_delete = "cascade"))]
tenant: djangors_orm::ForeignKey<djangors_contrib_tenancy::Tenant>,
#[djangors(max_length = 100)]
name: String,
}
impl Scoped for SchoolClass {
fn scope(req: &Request, qs: QuerySet<Self>) -> Result<QuerySet<Self>, DjangorsError> {
tenant_scope(req, qs, "tenant")
}
}
}
Every ScopedViewSet::<SchoolClass> request now only ever sees rows belonging to the current,
membership-verified tenant. A user for tenant A genuinely cannot see tenant B’s classes, list
them, or fetch one by id.
Scoping the admin to the current tenant
The generated admin UI enforces the same tenant boundary as ScopedViewSet. An AdminSite is
configured with a TenantScope, which names the tenant FK field present on every registered
model plus a function that extracts the current tenant id from a request. The extractor typically
reads djangors_contrib_tenancy::CurrentTenant from request extensions — which
TenantResolutionLayer populates — so the admin and the REST layer resolve the tenant the same
way:
#![allow(unused)]
fn main() {
use djangors_contrib_tenancy::CurrentTenant;
use djangors_core::Request;
let site = djangors_admin::AdminSite::new()
.with_tenant_scoping("tenant", |req: &Request| req.ext::<CurrentTenant>().map(|t| t.0));
}
The tenant is resolved per request, not stored
This is the important part. A ModelAdmin is built once at registration and shared across
every concurrent request behind an Arc. The tenant restriction is therefore passed as a
TenantFilter argument (Option<(&'static str, i64)>) into each admin data method — changelist,
export_csv_rows, get_by_pk, update_from_form, update_fields_from_form, create_from_form,
and delete_by_pk — and is never held on the admin itself.
It must not be stored on the admin: state kept on the shared object could be overwritten by a
second request while the first was awaiting the database, so one tenant’s request could read
another tenant’s rows. A custom ModelAdmin must honour the tenant argument it receives rather
than caching a tenant on self.
Superusers are unrestricted
TenantFilter is None for a superuser, so they see every tenant’s rows in the admin.
It fails closed
For a non-superuser on a tenant-scoped site, if no tenant id can be resolved from the request,
the handler returns Forbidden. It does not fall back to running the query unscoped. Failing open
here would silently expose every tenant’s rows to any admin user whose request happened to lack a
resolved tenant — the exact leak the scoping exists to prevent.
What scoping actually guarantees
The trait-level docs make the guarantees precise:
- Reads and writes by primary key are filtered by tenant too. A cross-tenant primary key reads
as “not found” rather than leaking a row (
get_by_pk), and updates refuse to touch another tenant’s row. - Deletes refuse to remove a row that belongs to another tenant.
- Objects created through a scoped add-form have their tenant field forced to the current tenant, so a submitted form cannot plant a row in someone else’s tenant.
- Object history is gated on the object’s visibility, because the log table has no tenant column of its own.
TenantResolutionFailed
Previously a database failure during the membership lookup was indistinguishable from “this user
is not a member”, so an outage surfaced to clients as Unauthorized. The middleware now records a
TenantResolutionFailed marker on the request when the lookup itself fails, and tenant_scope
maps that to Internal (500) instead of Unauthorized. For callers who need to name the type,
TenantResolutionService is now re-exported from the crate root.
What this doesn’t cover yet
Schema-per-tenant/database-per-tenant support, subdomain-based tenant resolution (v1 is
X-Tenant-Id-header only), and automatic Postgres Row-Level Security policy generation are
deliberately out of scope for this first slice.
Object-Level Permissions (generic foreign keys)
djangors-contrib-guardian plus djangors-contrib-contenttypes give you Django-guardian-style
object-level permissions. The first stores one permission row per (user, codename, object); the
second provides the generic foreign key that lets those rows point at any model — no
permissions table per model needed.
The problem: model-level permissions can’t target a single row
djangors-auth’s has_perm(db, user_id, codename) and is_superuser() answer only one question:
does this user hold codename on the whole model? They can say “Alice can change documents”, so
the guard passes for every Document row. They cannot express:
- “Alice can edit document #42 but not document #17.”
- “Bob may read only the documents someone shared with him.”
Object-level permissions solve this with a tiny extra table: one row per (user, codename, object). A check becomes “model-level permission or an object row exists”, which is exactly
what has_perm_for_object computes.
Why contenttypes is the enabling primitive
A naive fix for object permissions is a join table per model (document_permissions,
image_permissions, …). That doesn’t scale, and every new model needs a new table with a new
service function. A generic foreign key avoids all of it: instead of a real foreign key to a
specific table, you store (content_type_id, object_id), where content_type_id points at a row
saying which model it is (app_label, model_name).
#![allow(unused)]
fn main() {
use djangors_macros::Model;
#[derive(Model, Debug, Clone)]
#[djangors(
app = "djangors_contrib_contenttypes",
table_name = "djangors_content_type",
unique_together = [["app_label", "model_name"]]
)]
pub struct ContentType {
#[djangors(primary_key, auto)]
pub id: i64,
#[djangors(max_length = 100)]
pub app_label: String,
#[djangors(max_length = 100)]
pub model_name: String,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct GenericForeignKey {
pub content_type_id: i64,
pub object_id: i64,
}
}
ObjectPermission uses exactly this shape, but stored inline: it carries app_label,
model_name, and object_id directly so the lookup needs no extra join. That one table can name a
Document, an Image, or anything else a permission should target.
The ObjectPermission model
ObjectPermission (#[derive(Model)], table djangors_object_permission) records one grant:
user:ForeignKey<djangors_auth::User>— who holds the permission.permission:ForeignKey<djangors_auth::Permission>— which codename (must already exist inauth_permission).app_label(max_length = 100) andmodel_name(max_length = 100) — the target model.object_id: i64— the target row’s primary key.
Calling grant_object_permission twice for the same (user, codename, object) is idempotent; it
returns the existing row instead of inserting a duplicate.
Granting, checking, and revoking
#![allow(unused)]
fn main() {
async fn grant_check_revoke() -> Result<(), Box<dyn std::error::Error>> {
let db = djangors_db::Database::connect(&djangors_db::config::DatabaseConfig::new("postgres://postgres:postgres@localhost/djangors_test")).await.unwrap();
use djangors_contrib_guardian::{grant_object_permission, has_perm_for_object, revoke_object_permission};
// Grant: upsert a row granting user 7 "change_document" on Document 42.
grant_object_permission(&db, 7, "change_document", "documents", "Document", 42).await?;
// Check: `true` if model-level has_perm allows it, OR an object row exists for
// this exact (user, codename, app_label, model_name, object_id).
let allowed = has_perm_for_object(&db, 7, "change_document", "documents", "Document", 42).await?;
// Revoke: deletes the row, returning `true`; `false` when nothing matched
// (or the codename isn't registered in `auth_permission`).
revoke_object_permission(&db, 7, "change_document", "documents", "Document", 42).await?;
Ok(())
}
}
has_perm_for_object first delegates to djangors_auth::has_perm, so a user with the model-level
permission (or via group) is automatically allowed on every object — object rows only add
restrictions-turned-grants, never remove them.
Content-type bookkeeping
djangors_contrib_contenttypes keeps the djangors_content_type table in sync and turns generic
keys in both directions:
#![allow(unused)]
fn main() {
use djangors_contrib_contenttypes::ContentTypeError;
// Registers one row per registered model (run at startup). Returns how many were written.
async fn bootstrap(db: &djangors_db::Database) -> Result<usize, ContentTypeError> {
djangors_contrib_contenttypes::sync_content_types(db).await
}
// Build a generic key for a compile-time-known model, creating its ContentType row lazily.
async fn key_for_typed(db: &djangors_db::Database, doc_id: i64) -> Result<djangors_contrib_contenttypes::GenericForeignKey, ContentTypeError> {
djangors_contrib_contenttypes::generic_key_for::<Document>(db, doc_id).await
}
// Reverse lookup: ContentTypeError::NotFound(id) if the row is missing.
async fn resolve(db: &djangors_db::Database, ct_id: i64) -> Result<(String, String), ContentTypeError> {
djangors_contrib_contenttypes::resolve_content_type(db, ct_id).await
}
}
sync_content_types is idempotent (ON CONFLICT DO NOTHING), and generic_key_for inserts the
row on demand, so you can call either freely at startup without worrying about duplicates.
A combined worked example
Put it together. At startup, synchronize both content types and permissions; in handlers, grant, guard, log, and revoke.
#![allow(unused)]
fn main() {
use djangors_macros::Model;
use djangors_orm::{ForeignKey, Model, q};
#[derive(Model, Debug, Clone)]
#[djangors(app = "documents", table_name = "documents_document")]
struct Document {
#[djangors(primary_key, auto)]
id: i64,
owner: ForeignKey<djangors_auth::User>,
#[djangors(max_length = 200)]
title: String,
body: String,
}
#[derive(Model, Debug, Clone)]
#[djangors(app = "activity", table_name = "activity_feed_entry")]
struct ActivityFeedEntry {
#[djangors(primary_key, auto)]
id: i64,
actor: ForeignKey<djangors_auth::User>,
content_type_id: i64,
object_id: i64,
#[djangors(max_length = 200)]
message: String,
}
async fn startup(db: &djangors_db::Database) -> Result<(), Box<dyn std::error::Error>> {
djangors_contrib_contenttypes::sync_content_types(db).await?;
djangors_auth::sync_permissions(db).await?;
Ok(())
}
async fn share_document(
db: &djangors_db::Database,
owner_id: i64,
user_id: i64,
doc_id: i64,
) -> Result<(), Box<dyn std::error::Error>> {
// Generic foreign key: the feed entry can point at any model, not just this one.
let gk = djangors_contrib_contenttypes::generic_key_for::<Document>(db, doc_id).await?;
djangors_contrib_guardian::grant_object_permission(
db, user_id, "change_document", "documents", "Document", doc_id,
)
.await?;
ActivityFeedEntry {
id: 0,
actor: ForeignKey::new(owner_id),
content_type_id: gk.content_type_id,
object_id: gk.object_id,
message: format!("shared document {doc_id}"),
}
.save(db)
.await?;
Ok(())
}
async fn edit_document(db: &djangors_db::Database, user_id: i64, doc_id: i64) -> Result<(), Box<dyn std::error::Error>> {
let allowed = djangors_contrib_guardian::has_perm_for_object(
db, user_id, "change_document", "documents", "Document", doc_id,
)
.await?;
if !allowed {
return Err("You cannot edit this document".into());
}
let doc: Option<Document> = Document::objects()
.filter(q!(id = doc_id))
.first(db)
.await?; // Some(_) here: we already know the object exists
Ok(())
}
async fn unshare_document(
db: &djangors_db::Database,
user_id: i64,
doc_id: i64,
) -> Result<bool, Box<dyn std::error::Error>> {
Ok(djangors_contrib_guardian::revoke_object_permission(
db, user_id, "change_document", "documents", "Document", doc_id,
)
.await?)
}
}
Later, when you render an ActivityFeedEntry, resolve its generic key back to a model:
resolve_content_type(db, entry.content_type_id)? yields (app_label, model_name). That pair
matches the codename format below, so you can also answer “what can this user do with this feed
object?”.
Prerequisites
Note
Object-level permissions are layered on
djangors-auth: achange_documentcodename must already exist as adjangors_auth::Permissionrow or bothgrant_object_permissionfails to find it andrevoke_object_permissionsilently returnsfalse. Rundj createpermissions(ordjangors_auth::sync_permissions) first — see Authentication and Permissions.
- Add
djangors-contrib-guardiananddjangors-contrib-contenttypesto yourCargo.toml. - Run
dj makemigrations && dj migratesodjangors_object_permissionanddjangors_content_typeexist. - Run
dj createpermissionsso codenames likechange_documentexist before granting. - Call
sync_content_types(&db)at startup (inmainbefore serving requests) so the generic foreign key machinery has stable, predictable IDs.
Codename format
Codename follows the same {action}_{model} contract as djangors-auth’s generated permission
codes (view, add, change, delete + the model’s Rust struct name, lowercased as-is):
change_document, view_document, delete_document. The target lookup uses app_label +
model_name (e.g. ("documents", "Document")), and djangors_auth::sync_permissions/dj createpermissions build exactly these codenames from registered model metadata, so the two layers
align with no custom naming.
Databases and Backends
Djangors supports both PostgreSQL and SQLite backends. This dual-backend architecture allows you to run your application against a lightweight SQLite database for local development, testing, and single-writer deployments, while deploying to a highly concurrent PostgreSQL database in production.
Selecting the Backend
The active database backend is determined dynamically from the DATABASE_URL environment variable at runtime. Djangors parses this URL to determine the appropriate Dialect without making an active connection.
In code, this is handled by Dialect::from_url. The configuration parses the URL according to the following rules:
- SQLite backend: Selected if the URL starts with
sqlite://, ends with.dbor.sqlite, or is exactly:memory:orsqlite::memory:. - PostgreSQL backend: Selected for any other URL format (e.g.
postgres://orpostgresql://).
The connection string comes from the DATABASE_URL environment variable. Note that it is not part
of djangors.toml — DjangorsSettings covers debug, allowed_hosts,
secret_key, host, and port only, and the database URL is read from the environment directly:
# Dev environment using SQLite
export DATABASE_URL="sqlite://db.sqlite3"
# An in-memory database, which is what the SQLite tests use
export DATABASE_URL="sqlite::memory:"
# Production using PostgreSQL
export DATABASE_URL="postgres://postgres:password@localhost:5432/my_app"
Pool sizing (max_connections, timeouts) is configured in code through
djangors_db::config::DatabaseConfig, not through a file:
#![allow(unused)]
fn main() {
let config = djangors_db::config::DatabaseConfig::new(std::env::var("DATABASE_URL")?);
let db = djangors_db::Database::connect(&config).await?;
}
Dialect-Aware Architecture
Rather than forcing a single SQL syntax across different databases, Djangors uses a dialect-aware architecture. The system checks the active Dialect to generate and execute database-specific SQL.
Dialect awareness spans across:
- ORM Query Path: The generated SQL for filtering, limits, offsets, and operators is tailored to the active database dialect.
- Raw SQL Execution: All production raw SQL built using the database executor uses the correct syntax and placeholder formats.
- Migrations: When generating migration files via
dj makemigrationsand executing them viadj migrate, Djangors generates SQL operations tailored to Postgres or SQLite.
The Dialect API
The Dialect enum resides in djangors-db and exposes the differences in SQL syntax:
#![allow(unused)]
fn main() {
pub enum Dialect {
Postgres,
Sqlite,
}
}
It provides the following helper methods to bridge dialect differences:
placeholder(index): Returns"$1","$2", etc., for PostgreSQL, and"?"for SQLite.ilike(): Returns"ILIKE"for PostgreSQL. For SQLite, it returns"LIKE"because SQLite’s standardLIKEoperator is already ASCII-case-insensitive.quote_ident(name): Wraps the identifier in double quotes (e.g."name"). Supported by both Postgres and SQLite.cast_float(expr): Casts an expression to a float type:expr::float8on PostgreSQL, andCAST(expr AS REAL)on SQLite.extract_date_part(part, col): Extracts parts of a date (Year,Month,Day). GeneratesEXTRACT(part FROM col)::intfor Postgres andCAST(strftime(format, col) AS INTEGER)for SQLite.bytea_type(): Returns"BYTEA"for PostgreSQL, and"BLOB"for SQLite.auto_pk_type(): Returns"SERIAL PRIMARY KEY"for PostgreSQL, and"INTEGER PRIMARY KEY AUTOINCREMENT"for SQLite.timestamp_type(): Returns"TIMESTAMPTZ"for PostgreSQL, and"TEXT"for SQLite.current_timestamp(): Returns"now()"for PostgreSQL, and"CURRENT_TIMESTAMP"for SQLite.from_url(url): Static helper that infers the dialect from a URL string.
Honest Limitations
While the dual-backend architecture simplifies local development, there are key differences and limitations to keep in mind:
1. Testing is PostgreSQL-Only
The testing framework in djangors-test is strictly PostgreSQL-only. This is because its transaction isolation and test database management system relies on PostgreSQL session-level advisory locks to run tests concurrently without interference. SQLite cannot be used to run the Djangors test suite.
2. Unsupported Migrations on SQLite
SQLite has very limited support for altering existing table schemas. Because of this, certain schema operations are not supported on SQLite. For instance:
Operation::AlterColumnTypereturns aMigrationError::UnsupportedOnDialectrather than emitting broken or corrupt SQL.
3. SQLite Concurrency (Single Writer)
SQLite permits a single writer at any given time. If your application has highly concurrent write traffic, SQLite will serialize these writes and may return database locked errors (database is locked) if writes timeout. Postgres handles highly concurrent writes out of the box using fine-grained row locks.
4. Database Handles and the Connection Pool
When interacting with the connection pool:
- Calling
Database::pool()returns a reference to the underlyingPgPool. This will panic if the database handle is connected to SQLite. Database::sqlite_pool()is the safe SQLite counterpart: it returnsOption<&SqlitePool>—Someon a SQLite handle,Noneon a PostgreSQL handle.- To write code that runs on both backends, use
Database::conn()instead. It returns aConnenum wrapper that can execute queries on both PostgreSQL and SQLite pools.
When to Choose Which
- SQLite is ideal for:
- Local development to avoid running a local PostgreSQL container.
- Simple staging environments or internal tools with minimal write concurrency.
- Single-writer, read-heavy workloads.
- PostgreSQL is required for:
- Production deployments with concurrent writes or high traffic.
- Running the automated test suite (
djangors-test). - Advanced operations such as alter column type migrations.
Row Locking and Savepoints
For banking and financial applications requiring atomic transactions, Djangors provides explicit row locking (select_for_update) and savepoints (savepoint, rollback_to_savepoint, release_savepoint).
Row Locking with select_for_update
QuerySet::select_for_update locks matching rows for the duration of a transaction:
#![allow(unused)]
fn main() {
Account::objects()
.filter(q!(id = sender_id))?
.select_for_update()
.get(&mut *conn)
.await?;
}
Modifiers allow controlling wait and table locking behavior:
.nowait(): Returns an error immediately if a row lock cannot be acquired instead of blocking..skip_locked(): Skips rows locked by concurrent transactions..lock_of(&["account"]): Limits locking to specific tables in Postgres (FOR UPDATE OF).
Note
select_for_update()requires an active transaction; calling it outside a transaction returnsOrmError::SelectForUpdateOutsideTransaction. On SQLite,select_for_update()degrades gracefully to a no-op because SQLite serializes writes at the database level, but modifiers (nowait,skip_locked,lock_of) returnOrmError::UnsupportedOnDialect.
Savepoints
Savepoints allow partial rollback within a transaction handle (Conn). A savepoint only makes sense inside an open transaction: calling savepoint on a Conn obtained from the pool (db.conn()) returns DbError::SavepointOutsideTransaction, so issue them from inside transaction_conn(...) (or ORM-managed transactions) — supported on both backends:
#![allow(unused)]
fn main() {
conn.savepoint("transfer_checkpoint").await?;
// Perform risky operation
if let Err(_) = process_fee(&mut conn).await {
conn.rollback_to_savepoint("transfer_checkpoint").await?;
} else {
conn.release_savepoint("transfer_checkpoint").await?;
}
}
Double-Entry Ledger Example
Below is a banking transfer example that debits one account and credits another under row locking with savepoint error recovery:
#![allow(unused)]
fn main() {
pub async fn transfer_funds(
db: &Database,
sender_id: i64,
receiver_id: i64,
amount: i64,
) -> Result<(), DbError> {
db.transaction_conn(|conn| {
Box::pin(async move {
// Lock sender account
let mut sender = Account::objects()
.filter(q!(id = sender_id))
.map_err(OrmError::from)?
.select_for_update()
.get(&mut **conn)
.await?;
if sender.balance < amount {
return Err(DbError::TransactionFailed("Insufficient funds".into()));
}
// Lock receiver account
let mut receiver = Account::objects()
.filter(q!(id = receiver_id))
.map_err(OrmError::from)?
.select_for_update()
.get(&mut **conn)
.await?;
// Create a savepoint before attempting ledger operations
conn.savepoint("ledger_entry").await?;
sender.balance -= amount;
receiver.balance += amount;
// Execute debit & credit updates
sender.save(&mut **conn).await?;
receiver.save(&mut **conn).await?;
// Commit savepoint checkpoint
conn.release_savepoint("ledger_entry").await?;
Ok(())
})
})
.await
}
---
# Executor, Rows, and Raw SQL
Beyond the ORM, `djangors-db` exposes a small executor layer for running raw SQL that works identically on PostgreSQL and SQLite. Everything hangs off a single borrowed handle — `Conn` — which can point at either a connection pool or an open transaction.
## The `Conn` Executor
`djangors_db::executor::Conn` is the low-level query runner. You obtain one for a pool with `db.conn()` — compare that to `Database::pool()`, which returns a `&PgPool` and is PostgreSQL-only.
`Conn` is an enum over four targets:
* `Conn::PgPool(&PgPool)` — a query against the PostgreSQL pool.
* `Conn::PgTx(&mut PgConnection)` — a query inside a PostgreSQL transaction.
* `Conn::SqlitePool(&SqlitePool)` — a query against the SQLite pool.
* `Conn::SqliteTx(&mut SqliteConnection)` — a query inside a SQLite transaction.
That variety is why the same call sites run on either backend: the pool variants back `db.conn()`, while `db.transaction_conn(...)` hands its closure a transaction variant.
The executor methods all take a raw SQL string plus a slice of `BindValue` parameters:
* `fetch_all(&mut self, sql, &[BindValue]) -> Result<Vec<DbRow>, sqlx::Error>` — run the query and collect every row.
* `fetch_one(&mut self, sql, &[BindValue]) -> Result<DbRow, sqlx::Error>` — require exactly one row.
* `fetch_optional(&mut self, sql, &[BindValue]) -> Result<Option<DbRow>, sqlx::Error>` — zero or one row.
* `execute(&mut self, sql, &[BindValue]) -> Result<u64, sqlx::Error>` — run a statement and return the affected-row count.
* `dialect(&self) -> Dialect` — which backend this handle targets.
* `in_transaction(&self) -> bool` — whether this handle is a transaction variant.
* `savepoint` / `rollback_to_savepoint` / `release_savepoint` — see [Savepoints](#savepoints).
> [!NOTE]
> For dual-backend code written once, reach for `Conn` (via `db.conn()`) rather than `Database::pool()`. `pool()` returns a `&PgPool` and panics on a SQLite handle; `conn()` returns a `Conn` and runs on both. `Conn` is also the handle shape the savepoint helpers and `transaction_conn` use, so executor code ports directly into a transaction.
Here is `fetch_one` with a bound parameter. Postgres uses `$1` placeholders and SQLite uses `?`, but the `Conn` API is identical — only the SQL text changes:
```rust,compile
use djangors_db::BindValue;
async fn run(db: &djangors_db::Database) -> Result<(), Box<dyn std::error::Error>> {
let id: i64 = 42;
let row = db.conn().fetch_one("SELECT name FROM accounts WHERE id = $1", &[BindValue::I64(id)]).await?;
Ok(())
}
}
Bind Parameters
BindValue (in djangors_db::bind) is a typed, backend-agnostic query parameter:
I64(i64)— 64-bit integer.F64(f64)— 64-bit float.Text(String)— text.Bool(bool)— boolean.DateTime(chrono::DateTime<chrono::Utc>)— UTC timestamp.Bytes(Vec<u8>)— byte vector.Null(NullKind)— a typed SQLNULL.
NullKind selects the SQL type a NULL is bound as: I64, F64, Text, Bool, DateTime, or Bytes. The kind is mandatory — Postgres rejects a NULL parameter whose type cannot be inferred.
Constructing a parameter list:
#![allow(unused)]
fn main() {
use djangors_db::bind::{BindValue, NullKind};
use chrono::Utc;
fn build_params() {
let params = vec![
BindValue::Text("alice".to_string()),
BindValue::I64(42),
BindValue::F64(1.5),
BindValue::Bool(true),
BindValue::DateTime(Utc::now()),
BindValue::Bytes(vec![0, 1, 2]),
BindValue::Null(NullKind::DateTime),
];
let _ = params;
}
}
Reading Rows
The fetch_* methods return DbRow, a wrapper over the backend row (PgRow or SqliteRow). Every accessor returns Result<Option<T>, sqlx::Error> — None for a SQL NULL, an error when the column cannot be decoded. The integer accessors additionally accept narrower i32/i16 columns.
By column index:
try_i64(idx),try_f64(idx),try_string(idx),try_bool(idx),try_datetime(idx),try_bytes(idx).
By column name:
try_i64_by_name(name),try_f64_by_name(name),try_string_by_name(name),try_bool_by_name(name),try_datetime_by_name(name),try_bytes_by_name(name).
Reading a column by name needs no extra imports — it is an inherent method on DbRow:
#![allow(unused)]
fn main() {
async fn run(db: &djangors_db::Database) -> Result<(), Box<dyn std::error::Error>> {
let row = db.conn().fetch_one("SELECT 1 AS one", &[]).await?;
let v: Option<i64> = row.try_i64_by_name("one")?;
assert_eq!(v, Some(1));
Ok(())
}
}
The _by_name variants are what the ORM relies on: the FromRow impl generated by #[derive(Model)] decodes every field through row.try_*_by_name(column) (see djangors-macros), never by position. Positional decoding is confined to this raw executor layer, which is why q!-based eq filters keep resolving correctly across renamed columns (#[field(column = "...")]): filters and FromRow both key off the resolved column name, so the two stay in lockstep.
Transactions and Isolation Levels
Database::transaction(f) and Database::transaction_with_isolation(level, f) run f with a &mut PgConnection and are PostgreSQL-only — on a SQLite handle they return DbError::TransactionFailed. Database::transaction_conn(f) passes a &mut Conn instead and works on both backends; it is also the transaction entry point the Conn savepoint helpers expect.
Isolation levels, from djangors_db::IsolationLevel:
ReadCommitted— the PostgreSQL default.RepeatableReadSerializable
transaction_with_isolation(level, f) begins the transaction and issues the matching SET TRANSACTION ISOLATION LEVEL ... before running f.
Schema Migrations
Djangors provides a robust, declarative database schema migration system inspired by Django’s migration engine. It tracks changes to your Rust model definitions and generates database-agnostic SQL migrations that can be applied to both PostgreSQL and SQLite.
The Workflow
The typical migration cycle involves two commands:
dj makemigrations: Inspects your current models, compares them with the previous schema state saved in.schema_snapshot.json, and generates a new SQL migration file.dj migrate: Executes any pending migration files against the target database and records their names in thedjangors_migrationshistory table.
Migration Files and Markers
Migrations are written as plain SQL files under your project’s migrations directory (usually src/migrations/ or migrations/).
Each file contains custom SQL comment markers to separate the forward (up) migration from the backward (down) rollback logic.
Forward and Backward Actions
-- up: Denotes the SQL statements to apply when running the migration forward.-- down: Denotes the SQL statements to run when reverting the migration.-- no-down: Explicitly marks a migration as non-reversible. If a rollback is attempted on a migration containing-- no-down, Djangors returns aMigrationError::NonInvertibleerror and aborts.
Example migration file (0001_initial.sql):
-- up
CREATE TABLE "polls_question" (
"id" SERIAL PRIMARY KEY,
"question_text" VARCHAR(200) NOT NULL,
"pub_date" TIMESTAMPTZ NOT NULL
);
-- down
DROP TABLE "polls_question";
Schema Snapshots
To detect schema changes without connecting to a live database, Djangors stores a JSON representation of your model structures in a file named .schema_snapshot.json inside your migrations directory.
When you run dj makemigrations:
- Djangors parses your compiled Rust model attributes (
#[derive(Model)]). - It loads
.schema_snapshot.jsonto see the last recorded schema state. - It performs a structural diff between the models and the snapshot.
- Based on the diff, it generates the appropriate
Operationsteps and writes the SQL migration. - It updates
.schema_snapshot.jsonwith the new schema state.
The Operation Enum
Internally, migrations are planned as a sequence of Operation steps. The Operation enum in djangors-migrations represents the supported DDL instructions.
#![allow(unused)]
fn main() {
pub enum Operation {
CreateTable {
table_name: String,
columns: Vec<ColumnDef>,
},
AddColumn {
table_name: String,
column: ColumnDef,
},
DropColumn {
table_name: String,
column_name: String,
},
AlterColumnType {
table_name: String,
column_name: String,
new_sql_type: String,
},
RenameColumn {
table_name: String,
old_name: String,
new_name: String,
},
DropTable {
table_name: String,
},
CreateIndex {
index_name: String,
table_name: String,
columns: Vec<String>,
},
DropIndex {
index_name: String,
},
}
}
Reversibility
Djangors divides operations into reversible (invertible) and non-reversible categories.
Reversible operations can automatically generate the correct SQL for the -- down section when running dj makemigrations:
| Operation | Reversible? | Reverse Operation |
|---|---|---|
CreateTable | Yes | DropTable |
AddColumn | Yes | DropColumn |
RenameColumn | Yes | RenameColumn (with old and new names swapped) |
DropColumn | No | None (data is lost, cannot be automatically reconstructed) |
DropTable | No | None (table columns and data are lost) |
AlterColumnType | No | None (reverting requires knowledge of the original type) |
CreateIndex | Yes | DropIndex |
DropIndex | No | None (recreating the index requires knowing its table and columns) |
If a migration contains a non-reversible operation, Djangors inserts the -- no-down marker in the generated SQL file.
Migration History Tracking
Djangors tracks which migrations have been applied using the djangors_migrations table inside your database.
The schema of this history table is:
id: Auto-incrementing primary key.name: The migration filename without the.sqlextension (e.g.0001_initial).
When running dj migrate, the system:
- Ensures the
djangors_migrationstable exists. - Queries the table for all applied migration names.
- Scans your migrations directory for
.sqlfiles. - Executes the SQL inside the
-- upsection of any files not recorded indjangors_migrations. - Inserts the names of newly applied migrations into
djangors_migrationsinside a transaction.
Rolling Back Migrations
To undo the most recently applied migrations, use the rollback command or call rollback_from_dir programmatically:
# Roll back the single most recent migration
dj migrate --rollback 1
During a rollback, the system:
- Queries the most recently applied migrations from the
djangors_migrationstable. - Reads their corresponding
.sqlfiles from disk. - Asserts that the files do not contain the
-- no-downmarker. - Executes the SQL inside the
-- downsection. - Deletes the migration records from the
djangors_migrationstable.
Programmatic Migration APIs
Beyond the dj CLI, djangors_migrations exposes the same functionality as callable functions and
Operation methods, so you can wire migrations into your own startup or tooling.
Database-level functions
| Function | Signature | Notes |
|---|---|---|
migrate(&db) | async fn(&Database) -> Result<(), MigrationError> | Applies migrations/ dir if present, else the model-derived 0001_initial plan |
migrate_from_dir(&db, path) | async fn(&Database, &Path) -> Result<(), MigrationError> | Applies pending .sql files in filename order |
rollback_from_dir(&db, path, count) | async fn(&Database, &Path, u32) -> Result<(), MigrationError> | Rolls back the most recent count applied files; errors on -- no-down |
get_applied_migrations(&db) | async fn(&Database) -> Result<Vec<String>, MigrationError> | Names recorded in djangors_migrations |
record_migration(&db, name) | async fn(&Database, &str) -> Result<(), MigrationError> | Records a name as applied without running SQL (the --fake behaviour) |
#![allow(unused)]
fn main() {
use djangors_migrations::{get_applied_migrations, migrate, migrate_from_dir, record_migration, rollback_from_dir};
async fn manage(db: &djangors_db::Database) -> Result<(), djangors_migrations::MigrationError> {
migrate(db).await?; // apply pending migrations
let applied = get_applied_migrations(db).await?; // e.g. ["0001_initial"]
if applied.is_empty() {
record_migration(db, "0001_initial").await?; // record without running SQL
}
migrate_from_dir(db, std::path::Path::new("src/migrations")).await?;
rollback_from_dir(db, std::path::Path::new("src/migrations"), 1).await?; // undo last 1
Ok(())
}
}
Operation methods
Operation is also usable directly as a plan of DDL steps:
| Method | Signature | Notes |
|---|---|---|
to_sql(dialect) | fn(&self, Dialect) -> Result<String, MigrationError> | Forward DDL SQL (UnsupportedOnDialect for SQLite-only gaps like AlterColumnType) |
reverse() | fn(&self) -> Option<Self> | The mechanically safe inverse (CreateTable→DropTable, AddColumn→DropColumn, RenameColumn swapped); None when not invertible |
to_down_sql(dialect) | fn(&self, Dialect) -> Option<Result<String, MigrationError>> | reverse() then to_sql() — the -- down section |
#![allow(unused)]
fn main() {
use djangors_db::Dialect;
use djangors_migrations::Operation;
fn op_demo() {
use djangors_migrations::ColumnDef;
let create = Operation::CreateTable {
table_name: "polls_question".to_string(),
columns: vec![ColumnDef {
name: "id".to_string(),
sql_type: "SERIAL".to_string(),
nullable: false,
primary_key: true,
unique: false,
default_sql: None,
references: None,
}],
check_constraints: vec![],
};
let up_sql = create.to_sql(Dialect::Postgres).unwrap(); // CREATE TABLE ...
let down_sql = create.to_down_sql(Dialect::Postgres); // Some("DROP TABLE ...")
assert!(matches!(create.reverse(), Some(Operation::DropTable { .. })));
}
}
build_create_all_plan(dialect) (also build_create_plan_from_snapshots) produces the
Vec<Operation> plan for all registered models, topologically sorted by foreign-key dependency —
this is what migrate feeds into to_sql and executes transactionally.
Detecting Model Changes with detect_changes
detect_changes(previous, current, dialect) diffs two sets of ModelSnapshot and returns the
incremental operations needed to bring the database from previous to current. For each model
present in current it emits:
- New tables — a model that exists in
currentbut not inpreviousproduces aCreateTable. - Added columns — a field in
currentwith no matching name inpreviousproduces anAddColumn. - Dropped columns — a field in
previouswith no matching name incurrentproduces aDropColumn. - Changed column types — a field present in both sets whose
kindormax_lengthdiffers produces anAlterColumnType. BecauseAlterColumnTypeis unsupported on SQLite, diffing on a SQLite dialect surfaces anUnsupportedOnDialecterror only when such a change is encountered.
The comparison keys off field names, not column identity. detect_changes does not detect
renames: renaming a column is seen as a drop of the old name plus an add of the new one, which
are emitted as separate DropColumn and AddColumn operations.
Index Operations
Indexes declared through a model’s struct-level indexes attribute are emitted into the generated
plan as CreateIndex operations, placed after the model’s CreateTable. Each non-empty index
produces:
#![allow(unused)]
fn main() {
Operation::CreateIndex {
index_name: "idx_item_slug".to_string(), // from the index's name
table_name: "app_item".to_string(), // the model's table
columns: vec!["slug".to_string()], // the index's field names
}
}
rendered as CREATE INDEX IF NOT EXISTS "idx_item_slug" ON "app_item" ("slug");.
DropIndex carries only the index name and renders as
DROP INDEX IF EXISTS "idx_item_slug";. A CreateIndex reverses to DropIndex; a DropIndex has
no mechanical inverse.
Many-to-Many Join Tables
A ManyToMany relation is not represented as a column on either model. Instead the plan emits a
dedicated join (through) table for it, named after the Django convention:
{source_table}_{field_name}
so a students ManyToMany field on a model whose table is app_school produces a through table
named app_school_students.
The through table carries exactly two non-nullable foreign-key columns, each NOT NULL and
referencing the target’s primary key with ON DELETE CASCADE:
- Source column:
{source_struct_name_lowercase}_id→ references{source_table}.{source_pk} - Target column:
{target_struct_name_lowercase}_id→ references{target_table}.{target_pk}
For the example above the plan emits:
CREATE TABLE IF NOT EXISTS "app_school_students" (
"school_id" BIGINT NOT NULL REFERENCES "app_school"("id") ON DELETE CASCADE,
"student_id" BIGINT NOT NULL REFERENCES "app_student"("id") ON DELETE CASCADE
);
The join table’s own id column is not auto-added; the two FK columns are the only columns it
declares.
OneToOne and the UNIQUE Constraint
A OneToOne relation is emitted as a plain foreign-key column like ForeignKey,
plus a UNIQUE constraint. The plan sets unique: true on the FK column specifically when the
relation kind is OneToOne, which is what distinguishes a one-to-one column from an ordinary
foreign key in the generated schema:
"profile_id" BIGINT UNIQUE REFERENCES "app_user"("id") ON DELETE CASCADE
Migration Inspection & CLI Options
Djangors provides several commands and flags for inspecting and managing migration states:
dj sqlmigrate <app> <migration>
Renders the SQL statements that a migration would execute without connecting to the database or modifying any state. The SQL output is dialect-correct based on DATABASE_URL (e.g. SQLite DDL when targeting SQLite, Postgres DDL when targeting PostgreSQL).
# Render SQL statements for migration 0001 in app polls
dj sqlmigrate polls 0001
dj showmigrations
Lists every migration along with its applied state:
dj showmigrations
Output format:
polls
[X] 0001_initial
[ ] 0002_add_field
[X]: Migration is recorded in thedjangors_migrationshistory table.[ ]: Migration exists on disk but has not been applied.[?]: Migration is recorded in the history table but missing from disk.
dj migrate --plan
Prints the ordered list of migrations that would be applied without executing them:
dj migrate --plan
If the database is already up to date, it clearly reports that no migrations need to be applied.
dj migrate --fake
Records migrations as applied in the djangors_migrations history table without executing their SQL DDL instructions:
dj migrate --fake
Warning
--fakecan silently desynchronise the migration history table from the actual database schema. Use with caution when schema changes have been made out of band.
Note that --fake, --plan, and --rollback are mutually exclusive flags.
Background Tasks
Djangors features a robust, database-backed background task execution system. Tasks are defined using a macro, stored in the database, claimed by one or more worker processes, and executed asynchronously.
Macro Registration
Background tasks are registered at compile-time using the #[task] attribute macro from djangors_tasks (re-exported by djangors).
#![allow(unused)]
fn main() {
use djangors::tasks::{task, TaskError};
use serde::{Deserialize, Serialize};
#[derive(Serialize, Deserialize)]
pub struct SendEmailPayload {
pub recipient: String,
pub body: String,
}
#[task]
pub async fn send_email(payload: SendEmailPayload) -> Result<(), TaskError> {
// Send email logic...
Ok(())
}
}
Under the Hood: Compile-Time Discovery
The #[task] macro automatically does the following behind the scenes:
- Generates a unique task name (defaults to the function name, e.g.
"send_email"). You can override this using#[task(name = "custom_name")]. - Generates code that serializes/deserializes your custom payload structure to and from a
serde_json::Value. - Registers the task handler function globally using the
inventorycrate. The worker discovers all registeredTaskRegistrationstructs at startup without manual wiring.
Task Queue Table Structure
Pending tasks are stored in the djangors_task_queue database table, mapped to the QueuedTask model.
The schema comprises:
id: Unique identifier (e.g.BIGSERIAL/INTEGER AUTOINCREMENT).task_name: Name of the registered task handler (e.g."send_email").payload: JSON string representing the task’s input parameters.status: Current state of the task:"pending","running","completed", or"failed".attempts: Count of execution attempts.max_attempts: Maximum allowed execution attempts before marking it permanently"failed"(defaults to 3).created_at: Timestamp when the task was enqueued.scheduled_at: Earliest timestamp when the task is eligible to run.error_message: Last recorded error or panic trace if the task failed.
Enqueuing Tasks
To queue a task, use enqueue or enqueue_scheduled:
#![allow(unused)]
fn main() {
// Immediate execution
let task_id = djangors::tasks::enqueue(db, "send_email", &payload).await?;
// Delayed execution
let scheduled_at = chrono::Utc::now() + chrono::Duration::hours(2);
let task_id = djangors::tasks::enqueue_scheduled(db, "send_email", &payload, scheduled_at).await?;
}
The Worker Claiming Strategy
Workers run in a continuous loop to fetch, lock, and execute pending tasks.
#![allow(unused)]
fn main() {
use djangors::tasks::Worker;
use std::time::Duration;
let worker = Worker::new(db)
.with_poll_interval(Duration::from_secs(1))
.with_recurring_tick_interval(Duration::from_secs(10));
worker.run().await;
}
To run a worker from the command line, use:
dj runworker
Claiming Concurrency (SKIP LOCKED)
To support running multiple parallel worker processes safely without double-executing tasks, Djangors uses database row-level locking during the claiming phase:
- PostgreSQL: Claims the next pending task using a
SELECT ... FOR UPDATE SKIP LOCKED LIMIT 1query inside a transaction. This allows workers to lock a row and skip past any rows already locked by other workers, ensuring high-throughput parallel execution. - SQLite: Since SQLite only supports database-level write locks (single writer), concurrency is naturally serialized. The worker issues a standard query and updates the row, relying on SQLite’s transactional lock behavior to serialize worker claims.
Manual Polling: run_once and claim_next_task
Worker::run() loops forever, but the underlying single-cycle primitives are public so you can
drive the queue yourself (e.g. draining tasks under a fixed budget, or embedding claims in another
loop).
#![allow(unused)]
fn main() {
use djangors_tasks::{Worker, TaskError, claim_next_task};
// Claim one due task atomically, without touching the registry or executing it.
match claim_next_task(&db).await {
Ok(Some(task)) => { /* already marked "running", attempts incremented */ }
Ok(None) => { /* queue empty */ }
Err(e) => { /* TaskError */ }
}
// Drive a single poll cycle of the worker loop:
// Ok(true) => a task was claimed and executed (or had no handler)
// Ok(false) => nothing was due; callers usually sleep the poll interval now
// Err(e) => the poll failed (returned via `?` as TaskError)
let worker = Worker::new(db);
match worker.run_once().await? {
true => println!("ran a task"),
false => println!("queue idle"),
}
}
| Method | Signature | Notes |
|---|---|---|
Worker::run_once | async fn(&self) -> Result<bool, TaskError> | One poll cycle: claim + run a single due task; returns whether one ran |
claim_next_task | async fn(&Database) -> Result<Option<QueuedTask>, TaskError> | Atomic claim inside one transaction; Postgres uses FOR UPDATE SKIP LOCKED, SQLite a guarded update |
claim_next_task already flips the row to "running" and increments attempts as part of the
claim, so a crash between claim and execution leaves the task parked in "running" rather than
double-claiming it.
TaskError Variants
Every task operation bubbles up through this one error type:
| Variant | Meaning |
|---|---|
Db(DbError) | A database operation via djangors_db failed |
Orm(OrmError) | An ORM operation failed |
Json(serde_json::Error) | Generic JSON serialization/deserialization error |
PayloadDeserialization(String) | Task payload could not be deserialized into the handler’s type |
Serialization(String) | Serializing the payload (or compiling a cron expression) failed |
TaskNotFound(String) | No #[task] handler with that name is registered in the inventory |
TaskExecution(String) | The handler executed but returned Err |
TaskPanicked(String) | The handler panicked inside the worker’s tokio::spawn isolation |
Error Handling, Retries, and Status Transitions
When a worker executes a task:
- The task status is updated to
"running", andattemptsis incremented by 1. - The handler function is executed inside a
tokio::spawnwrapper to isolate panics. - Success: If the handler returns
Ok(()), the status changes to"completed". - Failure/Panic: If the handler returns an
Err(TaskError)or panics:- The exception is captured and written to
error_message. - If
attempts < max_attempts, the status reverts to"pending"(scheduled for a retry on the next worker pass). - If
attempts >= max_attempts, the status is marked as"failed".
- The exception is captured and written to
Recurring (Cron) Tasks
Djangors supports recurring tasks configured with standard 5-field cron expressions. These are managed via the djangors_recurring_task database table and mapped to the RecurringTask model.
Registering Recurring Tasks
#![allow(unused)]
fn main() {
use djangors::tasks::register_recurring;
// Trigger database cleanup every night at midnight
let schedule_id = register_recurring(
db,
"database_cleanup",
&EmptyPayload {},
"0 0 * * *"
).await?;
}
The Ticking Mechanism
The recurring task dispatcher tracks the next_run_at timestamp.
- When configuring a
Workerwith.with_recurring_tick_interval(), the worker periodically callstick_recurring_tasks. - It claims due recurring tasks using a transactional advisory lock (
pg_advisory_xact_lockon Postgres) to prevent concurrent ticks. - For each due task, it enqueues a new execution instance in
djangors_task_queueand calculates thenext_run_atusing the cron schedule.
Admin Site Integration
Djangors provides built-in admin configurations to monitor and manage your task queue from the admin web panel.
To register the task queue dashboard, call the register_admin helper:
#![allow(unused)]
fn main() {
use djangors::tasks::register_admin;
let site = djangors_admin::AdminSite::new("Admin Portal");
register_admin(&site);
}
This registers the QueuedTask model under the djangors_tasks app label. Admin users can view the task list, search for tasks by task_name or status, monitor execution attempts, and inspect error_message text details for failed background tasks.
Caching
Djangors provides a unified key-value caching system. It supports multiple storage backends, flexible Time-To-Live (TTL) configuration, JSON serialization helpers, and opt-in HTTP response caching middleware.
The Cache Trait
At the core of the caching system is the Cache trait from djangors_cache. It defines the essential operations for a raw-byte key-value store:
#![allow(unused)]
fn main() {
use async_trait::async_trait;
use std::time::Duration;
use djangors::cache::CacheError;
#[async_trait]
pub trait Cache: Send + Sync {
async fn get(&self, key: &str) -> Result<Option<Vec<u8>>, CacheError>;
async fn set(&self, key: &str, value: Vec<u8>, ttl: Option<Duration>) -> Result<(), CacheError>;
async fn delete(&self, key: &str) -> Result<(), CacheError>;
}
}
High-level Helpers via CacheExt
To simplify working with cached structures, Djangors provides CacheExt which automatically extends any implementation of Cache with:
get_or_set: Looks up a key; if missing, executes a closure to compute the raw bytes, caches them, and returns the result.get_or_set_json: Automatically handles JSON serialization and deserialization usingserde_json.
#![allow(unused)]
fn main() {
use djangors::cache::CacheExt;
use std::time::Duration;
let value = cache.get_or_set_json("my_key", Some(Duration::from_secs(60)), || async {
// Heavy computation returning Result<MyStruct, CacheError>
Ok(MyStruct { data: 42 })
}).await?;
}
The free function get_or_set_fragment
get_or_set_fragment is the same compute-if-missing pattern as a standalone
function for call sites that do not have a concrete Cache impl, only the
dyn Cache trait object (for example, an async handler receiving the store as
Arc<dyn Cache>):
use std::sync::Arc;
use djangors_cache::{Cache, CacheError, InMemoryCache, get_or_set_fragment};
use std::time::Duration;
fn main() {}
async fn run() -> Result<(), CacheError> {
let store: Arc<dyn Cache> = Arc::new(InMemoryCache::new(10_000));
let html = get_or_set_fragment(&*store, "template:home", Some(Duration::from_secs(300)), || async {
// ... any work producing Vec<u8> ...
Ok(b"<h1>Hello</h1>".to_vec())
}).await?;
let _ = html;
Ok(())
}
get_or_set (the CacheExt method) takes &self; get_or_set_fragment takes
&dyn Cache — otherwise they are identical in behaviour (both use
compute-if-missing with per-call TTL, and both are best-effort, non-atomic under
concurrency).
Supported Backends
Djangors includes three built-in implementations of the Cache trait:
1. In-Memory Cache (InMemoryCache)
Powered by the moka crate under the hood, InMemoryCache provides a high-performance, thread-safe in-memory cache featuring size-based eviction.
- Best for: Single-process deployments, dev environments, or local caching of immutable data (e.g. templates).
- Usage:
#![allow(unused)] fn main() { use djangors::cache::InMemoryCache; let cache = InMemoryCache::new(10_000); // Caps capacity at 10,000 items }
2. Database Cache (DatabaseCache)
Uses the application’s primary database (via a djangors_cache_entries table) to persist cache values.
- Table Schema:
key:TEXT PRIMARY KEYvalue:BYTEA(Postgres) /BLOB(SQLite)expires_at:TIMESTAMPTZ
- Best for: Distributed deployments that want to share a cache without setting up a dedicated Redis instance.
- Usage:
#![allow(unused)] fn main() { use djangors::cache::DatabaseCache; let cache = DatabaseCache::new(db.clone()); }
3. Redis Cache (RedisCache)
Provides a fast, distributed cache backend backed by a Redis server.
- Best for: High-traffic, highly concurrent production deployments.
- Activation: Enabled via the
rediscrate feature flag. - Usage:
#![allow(unused)] fn main() { use djangors::cache::RedisCache; let cache = RedisCache::new("redis://127.0.0.1:6379/0")?; }
HTTP Response Caching (CacheLayer)
Djangors offers a Tower middleware middleware layer (CacheLayer) that automates serving and recording HTTP responses.
Opt-In-Only Architecture
Unlike some frameworks that cache responses globally, CacheLayer is strictly opt-in per response.
A response is only cached if:
- The incoming request is a
GETrequest. - The response contains the
CacheableResponsemarker extension. - The response does NOT contain a
Set-Cookieheader.
Why is this opt-in?
HTTP response caching is notoriously prone to security leaks:
- If user-specific pages (e.g. dashboards displaying personal profiles) were cached globally, subsequent users would receive the cached data of the previous user.
- By enforcing that responses must explicitly carry the
CacheableResponseextension and lackSet-Cookieheaders, Djangors prevents caching sensitive sessions, CSRF tokens, or user-specific state.
Using the Cache Middleware
To wire up response caching in your application:
#![allow(unused)]
fn main() {
use djangors::cache::{CacheLayer, CacheableResponse};
use std::sync::Arc;
use std::time::Duration;
// 1. Set up the CacheLayer with your preferred cache backend
let cache_backend = Arc::new(InMemoryCache::new(1000));
let cache_layer = CacheLayer::new(cache_backend, Some(Duration::from_secs(300)));
// 2. Mark specific responses as cacheable in your view handler
async fn my_public_view() -> Response {
let mut response = Response::new("Hello, Cached World!".into());
response.extensions_mut().insert(CacheableResponse);
response
}
}
Internationalization (i18n)
Djangors provides runtime internationalization features including catalog loading, fallback resolution, request locale parsing middleware, locale-aware date/time formatting, and template translation integration.
Fluent Catalogs
Translations in Djangors are written using Mozilla Fluent (.ftl) file syntax. Fluent provides a highly expressive syntax that handles gender, plurals, and variables naturally.
Example messages.ftl (English):
welcome = Welcome to our site!
login-failed = Incorrect username or password.
In Rust, you load catalogs using the Locales container:
#![allow(unused)]
fn main() {
use djangors::i18n::Locales;
let mut locales = Locales::new("en-US"); // Define the default fallback locale
// Add language catalogs
locales.add_locale("en-US", include_str!("locales/en-US.ftl"))?;
locales.add_locale("fr-FR", include_str!("locales/fr-FR.ftl"))?;
}
The Catalog Building Block
Underneath Locales sits the lower-level Catalog
type: a single parsed Fluent bundle for one locale. Locales::add_locale is just
Catalog::from_ftl + insertion into a map; the whole fallback chain (requested locale → default
locale → raw message ID) lives in Locales::translate, not in Catalog.
| Method | Signature | Notes |
|---|---|---|
Catalog::from_ftl(locale, src) | fn(&str, &str) -> Result<Self, I18nError> | Parses an .ftl source for locale (InvalidLocale/Fluent/Resource errors) |
catalog.get(id, args) | fn(&self, &str, Option<&FluentArgs<'_>>) -> Option<String> | Formats one message; returns None when the ID is missing — no fallback |
Use Catalog directly when you need a single pre-parsed locale without any fallback behaviour:
#![allow(unused)]
fn main() {
use djangors::i18n::Catalog;
use fluent_bundle::FluentArgs;
let catalog = Catalog::from_ftl("en-US", "welcome = Hello { $name }, welcome!").unwrap();
let mut args = FluentArgs::new();
args.set("name", "Ada");
assert_eq!(
catalog.get("welcome", Some(&args)),
Some("Hello Ada, welcome!".to_string())
);
// Missing IDs are None here — the fallback chain is Locales' job.
assert_eq!(catalog.get("no-such-message", None), None);
}
When you need the fallback chain, wrap catalogs in Locales as shown above and call
locales.translate(...) instead.
Translation Fallback Chain
When translating a message ID using locales.translate("locale_tag", "message_id", args), Djangors evaluates a strict fallback chain to prevent rendering errors:
- Requested Locale: Looks up the translation in the specified catalog (e.g.
fr-FR). - Default Locale: If the translation is missing in the requested catalog, it falls back to the configured default locale catalog (e.g.
en-US). - Raw message ID: If the key is not found in either catalog, it returns the raw
message_idstring itself (e.g."welcome") rather than panicking or failing.
Request Locale Resolution
To automatically determine the locale per HTTP request, Djangors provides LocaleLayer middleware.
#![allow(unused)]
fn main() {
use djangors::i18n::LocaleLayer;
// Add this to your application's Tower middleware stack:
let app = app.layer(LocaleLayer::new("en-US"));
}
Parsing Order
LocaleLayer resolves the locale tag for each request in the following order:
- HTTP
Accept-LanguageHeader: Parses the first valid BCP-47 tag (usingfirst_locale_tag). E.g.,accept-language: fr-FR, en;q=0.8resolves tofr-FR. - Session override: Looks up the key
_localein the user’s active session. - Default: Falls back to the configured default locale tag (e.g.
en-US).
Once resolved, the middleware inserts a ResolvedLocale struct containing the locale string into the request’s extension map.
Locale-Aware Formatting
Djangors provides helpers to format dates and times based on the active locale:
localized_date(date, locale): Formats achrono::NaiveDate.localized_datetime(datetime, locale): Formats achrono::DateTime<Utc>.
#![allow(unused)]
fn main() {
use djangors::i18n::localized_date;
use chrono::NaiveDate;
let date = NaiveDate::from_ymd_opt(2026, 7, 30).unwrap();
// US formats: Month/Day/Year
assert_eq!(localized_date(date, "en-US"), "07/30/2026");
// UK/Europe formats: Day/Month/Year
assert_eq!(localized_date(date, "en-GB"), "30/07/2026");
// Fallback format: Year-Month-Day
assert_eq!(localized_date(date, "zh-CN"), "2026-07-30");
}
Template Integration
Template filters in Minijinja do not have direct access to the HTTP request context. To support translations in templates, you must pass the resolved locale and an opaque LocalesValue object wrapper in the template rendering context.
Wiring it up in Rust
#![allow(unused)]
fn main() {
use djangors::i18n::{locales_value, trans};
use minijinja::Environment;
use std::sync::Arc;
// 1. Register the "trans" filter in your Minijinja environment
let mut env = Environment::new();
env.add_filter("trans", trans);
// 2. Add the catalogs wrapper and the active locale to the context
let ctx = minijinja::context! {
locales => locales_value(Arc::new(locales_store)),
locale => resolved_locale_string,
};
}
Rendering in Templates
Use the trans filter to resolve translated strings inside your HTML templates:
<!-- Translates the "welcome" message ID -->
<h1>{{ "welcome" | trans(locales, locale) }}</h1>
Sessions and CSRF Protection
Djangors provides a client-side, signed-cookie session engine and double-submit CSRF (Cross-Site Request Forgery) protection, mirroring Django’s design patterns.
Signed-Cookie Sessions
Djangors stores session data directly in the client’s browser using signed cookies.
How it Works
- Serialization: Session key-value data is serialized to JSON.
- Encoding: The JSON payload is Base64-encoded.
- Signing: Djangors signs the payload with HMAC-SHA256 using the application’s secret key (
SECRET_KEY). - Storage: The combined payload and signature are sent to the client as a cookie (defaults to
"djangors_sessionid").
At the start of a request, the SessionLayer middleware extracts the cookie, verifies the HMAC signature to ensure it hasn’t been tampered with, deserializes the JSON data, and inserts a request-scoped Session handle into the request’s extension map.
Code Example
#![allow(unused)]
fn main() {
// Accessing the Session handle in a view
let session = req.session();
// Write value to session
session.set("user_id", 42i64);
session.set("theme", "dark".to_string());
// Read value from session
if let Some(user_id) = session.get::<i64>("user_id") {
println!("Logged in user: {user_id}");
}
// Remove a specific key
session.remove("theme");
}
Security Flags
Cookie configurations are managed by SignedCookieStore with safe defaults:
HttpOnly: Set totrueby default. This prevents client-side scripts from reading the cookie, mitigating Cross-Site Scripting (XSS) session theft.Secure: Set totrueif your application is configured to require HTTPS.SameSite: Set toLaxto balance convenience with cross-site request security.max_age: Default cookie expiration duration is 2 weeks.
Session Rotation (cycle_key)
To prevent session fixation attacks (where an attacker provides a known session ID to a victim and waits for them to log in), call session.cycle_key() upon user login or logout:
#![allow(unused)]
fn main() {
// Authenticate user
session.cycle_key(); // Generates a new internal session identity
session.set("user_id", new_user_id);
}
Programmatic Session Access
Session::is_empty()
Returns true when the session holds no application data (only the internal _session_key). Useful
for “do I need to write a Set-Cookie at all?” checks:
#![allow(unused)]
fn main() {
let session = Session::new_empty();
assert!(session.is_empty());
session.set("theme", "dark".to_string());
assert!(!session.is_empty());
}
Manual encode/decode with SignedCookieStore
SignedCookieStore encodes and decodes sessions independently of the middleware — handy for custom
middleware or code that must set the cookie header itself instead of delegating to SessionLayer.
| Method | Signature | Notes |
|---|---|---|
store.encode(&session) | fn(&self, &Session) -> String | Serializes, base64-encodes, signs (HMAC-SHA256), returns "<b64 payload>.<b64 expiry>.<b64 hmac>" |
store.decode(cookie_value) | fn(&self, &str) -> Option<Session> | Verifies signature + expiry, then decodes; None for missing/malformed/tampered/expired cookies |
#![allow(unused)]
fn main() {
use djangors_sessions::{Session, SignedCookieStore};
let store = SignedCookieStore::new(b"my-32-byte-minimum-secret-key!!");
let session = Session::new_empty();
session.set("user_id", 42i64);
let cookie_value = store.encode(&session); // "<b64 payload>.<b64 expiry>.<b64 hmac>"
let restored = store.decode(&cookie_value); // Some(session) — data verified intact
assert_eq!(restored.unwrap().get::<i64>("user_id"), Some(42));
}
SessionService: the layer’s service type
SessionLayer is a
tower::Layer; wrapping an inner service
yields a SessionService<S> (djangors_sessions::SessionService) as the concrete service type.
This is the Tower middleware that does the per-request work: read the Cookie header, decode +
verify it via the store, insert the Session into the request’s Extensions, then write
Set-Cookie back on the response when the session was modified or newly created.
#![allow(unused)]
fn main() {
use djangors_sessions::{SessionService, SessionLayer, SignedCookieStore};
use tower::Layer;
fn stack(inner: djangors_core::router::RouterService) -> SessionService<djangors_core::router::RouterService> {
SessionLayer::new(SignedCookieStore::new(b"my-32-byte-minimum-secret-key!!"))
.layer(inner)
}
}
CSRF Protection
Djangors uses a double-submit cookie scheme to protect against Cross-Site Request Forgery (CSRF).
The Mechanism
- The
CsrfLayermiddleware reads the request cookie namedcsrftoken. If not present, it generates a cryptographically secure random token and sets it as thecsrftokencookie. - For unsafe methods (
POST,PUT,PATCH,DELETE), Djangors requires the client to submit the same CSRF token value in the request. - The server performs a constant-time comparison (using
constant_time_eq) between the cookie value and the submitted request value.
Submitting the Token
Clients can submit the CSRF token in one of two ways, both of which are accepted by Djangors:
1. Plain HTML Forms (csrfmiddlewaretoken)
When rendering a form, include the token value as a hidden input parameter named csrfmiddlewaretoken.
<form method="post" action="/submit">
<!-- Renders a hidden field with the CSRF token -->
<input type="hidden" name="csrfmiddlewaretoken" value="{{ csrf_token }}">
<button type="submit">Submit</button>
</form>
During a form post (application/x-www-form-urlencoded), the Djangors router parses the form body, extracts csrfmiddlewaretoken, and compares it against the expected token.
2. Javascript Clients (X-CSRFToken Header)
For AJAX/fetch requests (such as SPA clients sending JSON bodies), the csrftoken cookie is intentionally not marked HttpOnly so that client-side JavaScript can read it.
JS clients must extract the value from the cookie and send it in the X-CSRFToken header with their request.
// Example fetch request
fetch('/api/data', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRFToken': getCookie('csrftoken')
},
body: JSON.stringify({ key: 'value' })
});
The CSRF middleware intercepts the request, reads the header, and validates it.
Static Files
Managing CSS, JavaScript, images, and other assets is handled by the djangors-staticfiles subsystem. This provides dev-time asset serving, a pluggable storage model, and a production collectstatic asset pipeline that generates content-hashed assets and a tracking manifest.
Dev-Time Serving
During local development, Djangors can serve static assets directly from your source directories.
#![allow(unused)]
fn main() {
use djangors::staticfiles::StaticFiles;
use std::path::PathBuf;
// Set up source directories
let static_dirs = vec![
PathBuf::from("src/static"),
PathBuf::from("static_assets"),
];
let static_files = StaticFiles::new(static_dirs);
// Mount the static files handler in the router
let app = djangors::Router::new()
.mount("/static/<path:path>", static_files.handler());
}
When a request arrives at /static/style.css, the StaticFiles handler:
- Loops through your configured source directories in the order they were defined.
- Checks if the requested file exists in that directory.
- Serves the file with the appropriate
Content-Typeheader (such astext/css; charset=utf-8orapplication/javascript; charset=utf-8).
The Storage Trait
Djangors abstracts file read and write operations behind the Storage trait, allowing you to use different asset destinations in development versus production:
#![allow(unused)]
fn main() {
use async_trait::async_trait;
use djangors::staticfiles::StaticFilesError;
#[async_trait]
pub trait Storage: Send + Sync {
async fn save(&self, path: &str, content: Vec<u8>) -> Result<String, StaticFilesError>;
async fn open(&self, path: &str) -> Result<Vec<u8>, StaticFilesError>;
async fn exists(&self, path: &str) -> Result<bool, StaticFilesError>;
async fn delete(&self, path: &str) -> Result<(), StaticFilesError>;
fn url(&self, path: &str) -> String;
}
}
Supported Implementations
LocalDiskStorage: Saves and reads files from a directory on the local filesystem. To protect against directory traversal attacks, it canonicalizes paths and verifies they reside within the root.S3Storage: Interacts with AWS S3 or any S3-compatible service (such as MinIO, Cloudflare R2, or DigitalOcean Spaces) using theClientAPI.
Configuring S3Storage
S3Storage::new takes the bucket name, AWS region, an optional custom endpoint (e.g. MinIO), the
access key/secret key pair, and the public base URL used by url():
fn main() {
use djangors_staticfiles::{S3Storage, Storage};
let storage: S3Storage = S3Storage::new(
"my-assets", // bucket
"us-east-1", // region
Some("https://minio.example.com"), // endpoint; None => https://s3.amazonaws.com
"AKIAEXAMPLEACCESSKEY", // access key
"example-secret-key", // secret key
"https://cdn.example.com", // public base URL
).unwrap();
let _ = storage.url("css/style.css"); // => "https://cdn.example.com/css/style.css"
}
S3Storage::new performs no network I/O — it only builds an S3 client. To create the bucket at
startup, call the async create_bucket() (idempotent: already-owned buckets are treated as
success):
#![allow(unused)]
fn main() {
let storage = S3Storage::new("my-assets", "us-east-1", None, "key", "secret", "https://cdn.example.com")?;
storage.create_bucket().await?; // BucketAlreadyOwnedByYou / BucketAlreadyExists are OK
}
Both S3Storage and LocalDiskStorage implement the same Storage trait, so
collect_to and save_upload work identically against local disk or an object store.
Production Collection (collectstatic)
In production, serving assets through the Rust application process is inefficient. Instead, you run the collection pipeline to bundle and write your assets to a production storage destination (such as local disk served by Nginx, or an S3 bucket with a CDN).
To collect static files, use the collect method (for local disk storage) or collect_to (for any Storage backend):
#![allow(unused)]
fn main() {
// Collects all static files into the "staticfiles/" directory
static_files.collect(Path::new("staticfiles/"))?;
}
You can also trigger this via the command-line utility:
dj collectstatic
Content Hashing and manifest.json
When running the collection process:
- Djangors crawls all source directories and reads every asset.
- It computes the SHA-256 hash of each file’s contents.
- It appends the first 8 characters of the hash to the file’s filename (e.g.
logo.cssbecomeslogo.8bf74a2d.css), ensuring browsers cache assets forever. - It saves the hashed files via the configured storage backend.
- It generates a
manifest.jsonfile mapping the original paths to the hashed paths:
{
"mapping": {
"css/style.css": "css/style.f8a7e3d1.css",
"images/logo.png": "images/logo.a8c6e28f.png"
}
}
- The
manifest.jsonis saved in the root of the storage directory, allowing template helpers to resolve path names to their production content-hashed variants.
The Manifest Struct
The manifest written to storage is the Rust struct djangors_staticfiles::Manifest — a simple
{ mapping: HashMap<String, String> } mapping original relative paths to content-hashed paths.
It implements serde::{Serialize, Deserialize}, so it can be (de)serialized directly:
fn main() {
use djangors_staticfiles::Manifest;
let manifest = Manifest {
mapping: std::collections::HashMap::from([
("css/style.css".to_string(), "css/style.f8a7e3d1.css".to_string()),
("images/logo.png".to_string(), "images/logo.a8c6e28f.png".to_string()),
]),
};
let json = serde_json::to_string_pretty(&manifest).unwrap(); // {"mapping": {...}}
let back: Manifest = serde_json::from_str(&json).unwrap();
let _ = (json, back);
}
StaticFiles::collect(path) and StaticFiles::collect_to(&storage) both return a Manifest.
Uploading User Files (save_upload)
save_upload writes an uploaded
file through any Storage backend under a collision-avoiding name and returns the stored path:
#![allow(unused)]
fn main() {
pub async fn save_upload(
file: &djangors_core::extract::UploadedFile,
storage: &dyn Storage,
dest_dir: &str,
) -> Result<String, StaticFilesError>
}
The stored name sanitizes the client-supplied stem and appends the first 12 hex characters of a
SHA-256 hash of the file contents (photo.jpg → photo_9f2a41c8b3d7.jpg), so the stored path
never equals the raw client filename and identical content dedupes naturally.
Pair it with the Multipart extractor, which parses multipart/form-data bodies into
MultipartData { files: Vec<UploadedFile>, texts: HashMap<String, String> }:
#![allow(unused)]
fn main() {
use djangors_core::extract::{FromRequest, Multipart, UploadedFile};
use djangors_core::{DjangorsError, Request, Response, StatusCode};
use djangors_staticfiles::LocalDiskStorage;
use djangors_staticfiles::upload::save_upload;
async fn upload_handler(req: Request) -> Result<Response, DjangorsError> {
let Multipart(form) = Multipart::from_request(&req).await?;
let storage = LocalDiskStorage::new("var/media", "/media/"); // or S3Storage
let mut saved = Vec::new();
for file in &form.files {
let path = save_upload(file, &storage, "avatars").await
.map_err(|e| DjangorsError::Internal(e.to_string()))?;
saved.push(path); // e.g. "avatars/avatar_9f2a41c8b3d7.png"
}
Ok(Response::text(StatusCode::OK, &format!("stored: {saved:?}")))
}
}
Storage::url(path) then turns the stored path into a public URL for the stored file.
Serving Static Files (StaticFiles::serve)
StaticFiles::serve is the async
handler behind the dev-time serving story:
#![allow(unused)]
fn main() {
pub async fn serve(&self, req: Request, params: PathParams) -> Result<Response, DjangorsError>
}
It resolves the path path parameter (falling back to stripping a /static/ prefix), searches the
source directories in order, sets the Content-Type from the file extension (.css, .js,
.png, .jpg, .svg, .woff, .woff2, .json, else application/octet-stream), and returns
404 Not Found when the file is missing. Use it directly from a custom handler, or use the
pre-built .handler() capture for router mounting:
#![allow(unused)]
fn main() {
use djangors_staticfiles::StaticFiles;
use std::path::PathBuf;
fn mount_static() -> djangors::Router {
let static_files = StaticFiles::new(vec![
PathBuf::from("src/static"),
PathBuf::from("static_assets"),
]);
djangors::Router::new()
// handler() captures the config and forwards to serve() on each request:
.mount("/static/<path:path>", static_files.handler())
}
}
StaticFiles::handler() returns a Clone-able StaticFilesHandler that implements the
Handler trait and calls serve() internally on every request. If you ever need the resolved
PathParams yourself, call serve(req, params) directly from a custom async fn handler — the
method is public and takes the buffer body Request plus PathParams, exactly like any other
handler.
Signals
Djangors features a pub-sub (publish-subscribe) signals subsystem allowing decoupled parts of your application to react to framework lifecycle events.
Request Signals
Request signals are defined in djangors-core and track the processing lifecycle of HTTP requests.
Built-in Request Signals
Djangors exposes two global request-level signals:
REQUEST_STARTED: Fires when an HTTP request begins processing. Fired with aRequestStartedpayload.REQUEST_FINISHED: Fires when an HTTP request completes (either successfully or with an error). Fired with aRequestFinishedpayload.
Subscribing to Request Signals
To connect to a request signal, call .connect() and pass an async callback closure:
#![allow(unused)]
fn main() {
use djangors::signals::{REQUEST_STARTED, REQUEST_FINISHED, RequestStarted, RequestFinished};
// Connect to REQUEST_STARTED
REQUEST_STARTED.connect(|payload: RequestStarted| async move {
println!("HTTP request started: {} {}", payload.method, payload.path);
});
// Connect to REQUEST_FINISHED
REQUEST_FINISHED.connect(|payload: RequestFinished| async move {
println!(
"HTTP request finished: {} {} with status {}",
payload.method, payload.path, payload.status
);
});
}
ORM Model Lifecycle Signals
Models deriving #[derive(Model)] automatically implement and expose four model-level lifecycle signals. These signals are static methods on your model struct:
pre_save_signal(): Fires immediately before a model is saved or updated in the database (before the INSERT/UPDATE SQL query executes).post_save_signal(): Fires immediately after a model is saved or updated in the database.pre_delete_signal(): Fires immediately before a model row is deleted from the database.post_delete_signal(): Fires immediately after a model row is deleted from the database.
The Signal Payload
Model signals carry a ModelSignalPayload which represents the model’s field values:
#![allow(unused)]
fn main() {
pub type ModelSignalPayload = Vec<(&'static str, Value)>;
}
Each element is a (field_name, field_value) tuple. This payload structure avoids requiring model types to implement Clone or Sync.
Subscribing to Model Signals
To connect to a model’s lifecycle signal:
#![allow(unused)]
fn main() {
use my_app::models::Question;
use djangors::orm::signals::ModelSignalPayload;
Question::pre_save_signal().connect(|payload: ModelSignalPayload| async move {
println!("Saving question model...");
for (field_name, value) in payload {
println!(" {field_name} = {value:?}");
}
});
}
Custom Signals (Signal<T>)
Signal<T> is the generic building
block the lifecycle signals above are built on. It is typed — it fires with a specific payload
T, which must implement Clone + Send + Sync + 'static. Use it to broadcast your own application
domain events (e.g. “an order was placed”) between decoupled parts of your codebase.
| Method | Signature | Notes |
|---|---|---|
Signal::new() | fn() -> Signal<T> | Create an empty signal |
.connect(callback) | fn(&self, Fn(T) -> Future<Output = ()>) | Register an async callback |
.send(payload) | async fn(&self, T) | Fire the signal, running all callbacks concurrently |
use djangors_core::signals::Signal;
#[tokio::main]
async fn main() {
#[derive(Clone, Debug)]
struct OrderPlaced {
order_id: i64,
customer_email: String,
}
let order_placed = Signal::new();
// Callbacks can be registered any time; each runs concurrently on send.
order_placed.connect(|payload: OrderPlaced| async move {
println!("Order {} placed for {}", payload.order_id, payload.customer_email);
});
order_placed.connect(|payload: OrderPlaced| async move {
let _ = payload.order_id; // e.g. enqueue a mail task here
});
// Fire the signal — send() is async and awaits all connected callbacks.
order_placed.send(OrderPlaced {
order_id: 42,
customer_email: "buyer@example.com".to_string(),
}).await;
}
send executes every connected callback concurrently (each inside its own tokio::spawn) and
waits for them all to finish, with strict panic isolation — a panicking callback is logged to
stderr and never prevents the others (or the sender) from running. See the
Technical Design Details section below, which applies to Signal<T>
identically.
Technical Design Details
Async Execution and Concurrency
When a signal fires:
- It retrieves all connected subscriber closures.
- It executes all subscriber futures concurrently (spawning each callback inside its own
tokio::spawnworker). - The sender waits for all spawned callbacks to complete.
Panic Isolation
Signals in Djangors provide strict panic isolation. A panic or unhandled error inside a subscriber callback will never:
- Prevent other subscribers from executing.
- Halt the main request handler or transaction.
Instead, the system catches the panic, logs a trace to stderr (e.g. Signal handler panicked: ...), and safely continues executing.
Sites, Feeds, and Two-Factor Auth
Three small djangors-contrib-* crates cover chatty-but-mechanical web plumbing:
djangors-contrib-otp for TOTP two-factor auth, djangors-contrib-sitemaps for XML
sitemaps, and djangors-contrib-syndication for RSS/Atom feeds.
Two-Factor Auth (OTP)
djangors-contrib-otp is the django-otp equivalent and ships TOTP only —
the base32 secret lives on an OtpDevice row, and verification is done by
verify_code on the caller’s side.
#![allow(unused)]
fn main() {
use djangors_auth::{Auth, User};
use djangors_contrib_otp::{generate_secret, provisioning_uri, verify_code, OtpDevice};
use djangors_core::extract::FromRequest;
use djangors_core::DjangorsError;
use djangors_orm::ForeignKey;
// Enrollment flow — roughly what your handler code looks like.
let Auth(user) = Auth::<User>::from_request(&req).await?;
let secret = generate_secret(); // base32 TOTP secret
let uri = provisioning_uri(&secret, &user.username, "Djangors"); // render as a QR code
// => otpauth://totp/Djangors:alice?secret=...&issuer=Djangors
// The user scans the QR with their TOTP app, then submits their first 6-digit code.
// Do not trust the device until one code verifies:
if verify_code(&secret, &typed_code) {
let device = OtpDevice {
id: 0,
user: ForeignKey::new(user.id),
secret,
confirmed: true, // your policy decision: device is now trusted
}
.save(db)
.await?;
}
}
After enrollment, call verify_code again on every sensitive action (transfers,
password changes, new-admin grants) before proceeding:
#![allow(unused)]
fn main() {
use djangors_contrib_otp::{OtpDevice, verify_code};
use djangors_orm::q;
if !verify_code(&device.secret, &typed_code) {
return Err(DjangorsError::Unauthorized("invalid one-time code".into()));
}
}
Use djangors-auth’s Auth<User> extractor to get the authenticated user id; the
device references it through ForeignKey<djangors_auth::User>. The OtpDevice model
(columns id, user, secret, confirmed) lives in the djangors_otp_device table,
which you must create via a migration.
Note
Scope is deliberately narrow: TOTP only, no WebAuthn/passkeys. The secret is stored in plaintext (no reversible encryption convention exists yet); the
confirmedflag is your own policy — this crate never auto-trusts a device.
XML Sitemaps
djangors-contrib-sitemaps is the django.contrib.sitemaps equivalent. You implement
the Sitemap trait to produce SitemapEntry values, and the crate renders the XML:
#![allow(unused)]
fn main() {
use djangors_contrib_sitemaps::{Sitemap, SitemapEntry};
struct PostSitemap;
impl Sitemap for PostSitemap {
fn items(&self) -> Vec<SitemapEntry> {
Post::objects()
.filter(djangors_orm::q!(is_published = true))
.unwrap()
.all(db)
.await
.unwrap()
.into_iter()
.map(|post| SitemapEntry {
loc: format!("/posts/{}/", post.slug),
lastmod: Some(post.updated_at),
changefreq: Some("weekly".into()),
priority: Some(0.8),
})
.collect()
}
}
}
Mount it on a router; GET /sitemap.xml will concatenate every provider’s entries:
#![allow(unused)]
fn main() {
use djangors_contrib_sitemaps::{sitemap_routes, Sitemap, SitemapEntry};
use djangors_core::Router;
use std::sync::Arc;
struct StaticSitemap;
impl Sitemap for StaticSitemap {
fn items(&self) -> Vec<SitemapEntry> {
vec![SitemapEntry {
loc: "/about/".to_string(),
lastmod: None,
changefreq: Some("yearly".to_string()),
priority: Some(0.3),
}]
}
}
pub fn build_router() -> Router {
// register_routes(router, providers) is an alias of sitemap_routes.
sitemap_routes(Router::new(), vec![Arc::new(StaticSitemap)])
}
}
render_sitemap(&entries) produces the standard document shape:
<?xml version="1.0" encoding="UTF-8"?>
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
<url>
<loc>/about/</loc>
<lastmod>2026-08-05T00:00:00+00:00</lastmod>
<changefreq>yearly</changefreq>
<priority>0.3</priority>
</url>
</urlset>
Note
No database access is needed here — a
Sitemapis a plain trait returning entries, so a provider can read from anything (queryset, filesystem, in-memory list). Unlike most contrib features, there is no table to migrate.
RSS / Atom Feeds
djangors-contrib-syndication is the django.contrib.syndication equivalent. Implement
Feed to describe the channel and its FeedItems, then pick the output format:
#![allow(unused)]
fn main() {
use djangors_contrib_syndication::{Feed, FeedItem};
struct BlogFeed;
impl Feed for BlogFeed {
fn title(&self) -> String {
"Blog & News".into()
}
fn link(&self) -> String {
"https://example.com/".into()
}
fn description(&self) -> String {
"Latest posts, announcements, and changelogs".into()
}
fn items(&self) -> Vec<FeedItem> {
Post::objects()
.all(db)
.await
.unwrap()
.into_iter()
.map(|post| FeedItem {
title: post.title,
link: format!("https://example.com/posts/{}", post.slug),
description: post.excerpt,
pub_date: Some(post.published_at),
})
.collect()
}
}
}
Mount the feed at a path on your router, choosing FeedFormat::Rss or
FeedFormat::Atom:
#![allow(unused)]
fn main() {
use djangors_contrib_syndication::{feed_routes, Feed, FeedFormat, FeedItem};
use djangors_core::Router;
use std::sync::Arc;
struct TinyFeed;
impl Feed for TinyFeed {
fn title(&self) -> String {
"Tiny".into()
}
fn link(&self) -> String {
"https://example.com/".into()
}
fn description(&self) -> String {
"A tiny feed".into()
}
fn items(&self) -> Vec<FeedItem> {
vec![FeedItem {
title: "Hello".into(),
link: "https://example.com/posts/hello".into(),
description: "First post".into(),
pub_date: None,
}]
}
}
pub fn build_router() -> Router {
feed_routes(Router::new(), "/feed/", Arc::new(TinyFeed), FeedFormat::Rss)
}
}
render_rss(&dyn Feed) emits RSS 2.0, render_atom(&dyn Feed) emits Atom; feed_routes
wraps whichever you select and serves it as application/xml. Expose the same feed in both
formats by mounting it a second time with FeedFormat::Atom at another path, or swap
formats with a single query-aware handler.
Note
Both renderers escape all text fields and use RFC 2822 (
RSS) / RFC 3339 (Atom) timestamps fromFeedItem.pub_date. Matching theFeedItem.pub_dateURI shape is up to you — the crate renders exactly what the trait returns.
How to Add a Custom Admin Action
Problem
You want to perform a custom bulk action on selected rows in the Djangors admin changelist (e.g. marking selected articles as published or resetting user accounts).
Solution
Define an AdminAction struct specifying the action name, user-facing label, confirmation requirement, and an async handler function. Register it on your ModelAdminConfig passed to AdminSite::register_with.
Code Example
#![allow(unused)]
fn main() {
use djangors_orm::Model;
#[derive(djangors_macros::Model, Debug, Clone, serde::Serialize, serde::Deserialize)]
#[djangors(app = "library", table_name = "library_article")]
struct Article { #[djangors(primary_key, auto)] id: i64 }
use djangors_admin::{AdminAction, AdminSite, ModelAdminConfig};
use djangors_db::Database;
use djangors_core::DjangorsError;
// 1. Define the async action handler function
fn publish_articles_handler<'a>(
db: &'a Database,
pks: &'a [i64],
) -> std::pin::Pin<Box<dyn std::future::Future<Output = Result<(), DjangorsError>> + Send + 'a>> {
Box::pin(async move {
for &pk in pks {
djangors_orm::sqlx::query("UPDATE library_article SET is_published = TRUE WHERE id = $1")
.bind(pk)
.execute(db.pool())
.await
.map_err(|e| DjangorsError::Internal(e.to_string()))?;
}
Ok(())
})
}
// 2. Define the AdminAction
pub const PUBLISH_ACTION: AdminAction = AdminAction {
name: "publish_selected",
label: "Publish selected articles",
requires_confirm: true,
handler: publish_articles_handler,
};
// 3. Register with AdminSite
fn register_admin(site: &mut AdminSite) {
site.register_with::<Article>(ModelAdminConfig {
list_display: Some(&["id", "title", "is_published"]),
actions: Some(&[PUBLISH_ACTION]),
..Default::default()
});
}
}
How to Send Email
Problem
You want to send plain-text or HTML emails in your Djangors application using SMTP, or write outgoing messages to local disk during development.
Solution
Use djangors_mail::Message and configure a backend implementing the MailBackend trait, such as SmtpBackend, FileBackend, or ConsoleBackend.
Code Example
1. Sending via SMTP (Production)
#![allow(unused)]
fn main() {
use djangors_mail::{Message, SmtpConfig, SmtpBackend, MailBackend};
pub async fn send_welcome_email(user_email: &str) -> Result<(), djangors_mail::MailError> {
// Construct email message
let message = Message {
to: vec![user_email.to_string()],
from: "noreply@example.com".to_string(),
subject: "Welcome to our platform!".to_string(),
body: "Hello, welcome to our service!".to_string(),
html_body: Some("<h1>Hello!</h1><p>Welcome to our service!</p>".to_string()),
};
// Configure SMTP backend
let config = SmtpConfig::new("smtp.example.com")
.port(587)
.credentials("smtp_user", "smtp_pass")
.use_tls(true);
let backend = SmtpBackend::new(config)?;
backend.send(&message).await?;
Ok(())
}
}
2. Saving Emails to Disk (Development)
#![allow(unused)]
fn main() {
use djangors_mail::{Message, FileBackend, MailBackend};
use std::path::PathBuf;
pub async fn send_dev_email(message: &Message) -> Result<(), djangors_mail::MailError> {
// Writes emails as `.eml` files in the specified directory
let backend = FileBackend::new(PathBuf::from("/tmp/app_emails"));
backend.send(message).await?;
Ok(())
}
}
3. Asserting Sent Emails in Tests (InMemoryBackend)
InMemoryBackend keeps every sent Message in memory and exposes
sent_messages() — pair it with djangors-test or a plain #[tokio::test]
when your test asserts that “an email was sent with this subject”:
#![allow(unused)]
fn main() {
use djangors_mail::{Message, InMemoryBackend, MailBackend};
fn build_message() -> Message { Message { to: vec!["a@b.c".into()], from: "noreply@example.com".into(), subject: "reset".into(), body: "hi".into(), html_body: None } }
async fn run() -> Result<(), djangors_mail::MailError> {
let backend = InMemoryBackend::default();
let sent: Result<(), _> = backend.send(&build_message()).await;
assert!(sent.is_ok());
let messages = backend.sent_messages();
assert_eq!(messages.len(), 1);
assert_eq!(messages[0].subject, "reset");
Ok(())
}
}
ConsoleBackend is the fourth backend: it logs the message body via tracing
to stdout/stderr, useful for local development when you want to see emails
without an SMTP server or files on disk.
How to Cache a View Response
Problem
You want to cache HTTP GET view responses to reduce database load and speed up response times using in-memory or Redis backends.
Solution
djangors_cache::CacheLayer is a tower::Layer that caches GET responses under two strict
security requirements:
- Explicit Opt-in: the response must carry a
CacheableResponsemarker extension. - No
Set-Cookie: responses setting cookies (e.g. session cookies) are never cached, regardless of the opt-in marker.
⚠️ Current integration limitation
CacheLayer operates on the raw hyper::Response<Full<Bytes>> that comes out of the connection-
serving loop, not on djangors_core::Response. The framework’s own Response type has no
extensions_mut() (or any extensions API at all). This means there is currently no way for an
ordinary Djangors view handler to opt a response into caching. The marker has to be inserted on
the raw hyper response, which only code operating below the framework’s own dispatch layer can do.
This is the same “primitive exists, framework-level wiring doesn’t yet” gap several other Phase 7
crates (djangors-contrib-messages, -guardian, -otp) document rather than paper over. See
those guides/crates’ own docs for the same pattern.
CacheLayer is still directly usable today if you’re composing your own tower::Service stack
below djangors_core’s dispatch (i.e. you construct the CacheableResponse-marked
hyper::Response yourself, the way djangors-cache’s own test suite does):
#![allow(unused)]
fn main() {
use std::sync::Arc;
use std::time::Duration;
use bytes::Bytes;
use http_body_util::Full;
use hyper::{Request, Response};
use tower::{service_fn, Layer, Service, ServiceExt};
use djangors_cache::{InMemoryCache, CacheLayer, CacheableResponse};
async fn build_and_call_cached_service() {
let handler = service_fn(|_req: Request<Full<Bytes>>| async move {
let mut r = Response::new(Full::new(Bytes::from_static(b"dashboard body")));
// Opt-in: only code with direct access to the raw hyper::Response can set this today.
r.extensions_mut().insert(CacheableResponse);
Ok::<_, std::convert::Infallible>(r)
});
let cache = Arc::new(InMemoryCache::new(10_000));
let mut svc = CacheLayer::new(cache, Some(Duration::from_secs(300))).layer(handler);
let request = Request::builder()
.method("GET")
.uri("/dashboard")
.body(Full::new(Bytes::new()))
.unwrap();
let _response = svc.ready().await.unwrap().call(request).await.unwrap();
}
}
Wiring CacheableResponse into an ordinary djangors_core::Router-based view handler would
require a core djangors_core::Response change (an extensions API, or a dedicated
Response::cacheable() constructor); not attempted here since it’s out of scope for a how-to.
Track it as a real, open Phase-9-discovered gap if you need it.
How to Execute Background Tasks
Problem
You want to offload long-running work (such as sending emails, processing uploads, or generating reports) to an asynchronous background task queue.
Solution
Annotate an async task handler with #[task] from djangors_tasks. Enqueue tasks into the database queue with enqueue(), and run the background task consumer using Worker.
Code Example
#![allow(unused)]
fn main() {
use serde::{Deserialize, Serialize};
use djangors_tasks::{task, enqueue, Worker, TaskError};
use djangors_db::Database;
// 1. Define task payload struct deriving Serde
#[derive(Serialize, Deserialize, Debug)]
pub struct SendReportPayload {
pub user_id: i64,
pub report_name: String,
}
// 2. Define background task handler function
#[task]
pub async fn send_report_task(payload: SendReportPayload) -> Result<(), TaskError> {
println!("Processing report '{}' for user ID {}", payload.report_name, payload.user_id);
// Task execution logic here...
Ok(())
}
// 3. Enqueue task from a request handler or service
pub async fn trigger_report_generation(db: &Database, user_id: i64) -> Result<i64, TaskError> {
let payload = SendReportPayload {
user_id,
report_name: "Monthly Audit".to_string(),
};
// Enqueue task for immediate background execution
let task_id = enqueue(db, "send_report_task", &payload).await?;
Ok(task_id)
}
// 4. Run the worker loop in a background thread or service
pub async fn start_background_worker(db: Database) {
let worker = Worker::new(db).with_poll_interval(std::time::Duration::from_secs(2));
worker.run().await; // Loops continuously processing claimed tasks
}
}
How to Stream Server-Sent Events (SSE)
Problem
You want to stream real-time updates or events to web browsers over Server-Sent Events (SSE).
Solution
Create a streaming response using StreamingResponse::sse(stream) or register an SSE endpoint on a Router using .sse(path, handler).
Code Example
use async_stream::stream;
use tokio_stream::StreamExt;
use djangors_core::{Request, PathParams, DjangorsError, Router, DjangorsSettings, Djangors};
use djangors_core::sse::StreamingResponse;
// 1. Define SSE streaming handler
async fn live_events_handler(
_req: Request,
_params: PathParams,
) -> Result<StreamingResponse, DjangorsError> {
// Generate an async stream of String events
let event_stream = stream! {
for i in 1..=5 {
tokio::time::sleep(std::time::Duration::from_secs(1)).await;
yield format!("Event #{}", i);
}
};
Ok(StreamingResponse::sse(event_stream))
}
// 2. Register endpoint on Router
fn build_sse_router() -> Router {
Router::new().sse("/events", live_events_handler)
}
// 3. Start server
#[tokio::main]
async fn main() -> Result<(), DjangorsError> {
let settings = DjangorsSettings::default();
let app = Djangors::new(settings, build_sse_router());
// Note: Must use standard serve()/run() loop
app.run().await
}
⚠️ Real v1 Limitations & Constraints
- Stream Format:
StreamingResponse::sseautomatically formats each yielded string as an SSE data frame (data: {item}\n\n) and applies headers:Content-Type: text/event-stream,Cache-Control: no-cache,Connection: keep-alive. - Server Execution Path: Streaming responses produce dynamic
BoxBody<Bytes, Infallible>chunked streams. In v1, SSE streaming endpoints only work when served via the standardserve()/run()server loop, not customrun_service()middleware stacks (which expect fixedFull<Bytes>response bodies).
In-Process Broadcast Groups
For fanning the same message out to many connected SSE clients (live dashboards, chat rooms,
notification streams), djangors-core ships an in-process pub/sub registry,
Groups. Unlike a per-connection
stream, a Groups channel is a broadcast channel: one send reaches every subscriber.
| Method | Signature | Notes |
|---|---|---|
Groups::new() | fn() -> Groups | Default per-channel buffer capacity (1024) |
Groups::with_capacity(cap) | fn(capacity: usize) -> Groups | Custom per-channel buffer capacity |
groups.group(name) | fn(&self, name: &str) -> GroupHandle | Lazily creates the channel on first access |
handle.send(msg) | fn(&self, msg: impl Into<String>) | Broadcast to every current subscriber; dropped silently if there are none |
handle.subscribe() | fn(&self) -> GroupStream | An async Stream<Item = String> of messages |
handle.receiver_count() | fn(&self) -> usize | Number of active subscribers |
use djangors_core::Groups;
use tokio_stream::StreamExt;
#[tokio::main]
async fn main() {
let groups = Groups::with_capacity(64); // per-channel buffer of 64
let feed = groups.group("live-events"); // lazily creates the channel
// A subscriber (e.g. an open SSE connection) gets a stream of messages.
let mut stream = feed.subscribe();
feed.send("event: kickoff".to_string()); // broadcast to all subscribers
assert_eq!(feed.receiver_count(), 1); // one active receiver
assert_eq!(stream.next().await.as_deref(), Some("event: kickoff"));
}
Wiring SSE + POST around a group
The typical shape: a shared GroupHandle (frequently a LazyLock static, or attached to app state
via Router::with_state and read with req.require_state::<Groups>()), an SSE handler that
subscribes to it, and a POST handler that broadcasts to it.
#![allow(unused)]
fn main() {
use djangors_core::{DjangorsError, Groups, PathParams, Request, Response, StatusCode};
use djangors_core::groups::GroupHandle;
use djangors_core::sse::StreamingResponse;
use std::sync::LazyLock;
// One registry shared app-wide; each named group is a broadcast channel.
static EVENTS: LazyLock<GroupHandle> = LazyLock::new(|| {
Groups::new().group("live-events")
});
// GET /events/live — subscribe once; every broadcast fans out as a `data:` frame.
async fn events_live(_req: Request, _params: PathParams) -> Result<StreamingResponse, DjangorsError> {
Ok(StreamingResponse::sse(EVENTS.subscribe()))
}
// POST /events — broadcast the request body to every connected SSE client.
async fn publish_event(req: Request, _params: PathParams) -> Result<Response, DjangorsError> {
let body = String::from_utf8_lossy(req.body_bytes().await).into_owned();
EVENTS.send(body);
Ok(Response::text(StatusCode::OK, "published"))
}
let router = djangors_core::Router::new()
.sse("/events/live", events_live)
.post("/events", publish_event);
}
Semantics to keep in mind
- Fixed buffer: each channel buffers up to its capacity. A slow subscriber that falls behind
skips the missed messages (the
GroupStreamswallowsLaggederrors) rather than blocking the sender. - Zero subscribers: a
sendwith no subscribers returns immediately and the message is dropped — no error. - Lower-level registration:
Router::route_streaming(path, Method, handler)registers a streaming handler for any HTTP method, andRouter::get_sse(path, handler)is an alias for.sse(path, handler)(i.e. a GET-onlyroute_streaming).
How to Internationalize Templates
Problem
You want to translate text strings in HTML templates based on the user’s preferred locale or HTTP Accept-Language header.
Solution
Use djangors_i18n::Locales to load Fluent (.ftl) catalogs. Pass locales_value(Arc::new(locales)) and the resolved locale code into the MiniJinja context, and use the trans template filter.
Code Example
#![allow(unused)]
fn main() {
use std::sync::Arc;
use djangors_i18n::{Locales, locales_value, trans};
use minijinja::Environment;
fn render_translated_template(user_locale: &str) -> Result<String, Box<dyn std::error::Error>> {
// 1. Build translation catalogs using Fluent (.ftl) syntax
let mut locales = Locales::new("en-US");
locales.add_locale("en-US", "welcome = Welcome to our site!")?;
locales.add_locale("fr-FR", "welcome = Bienvenue sur notre site !")?;
let locales_arc = Arc::new(locales);
// 2. Register `trans` filter on MiniJinja Environment
let mut env = Environment::new();
env.add_filter("trans", trans);
env.add_template("index.html", r#"<h1>{{ "welcome" | trans(locales, locale) }}</h1>"#)?;
// 3. Render template with locales_value and active locale code
let template = env.get_template("index.html")?;
let rendered = template.render(minijinja::context! {
locales => locales_value(locales_arc),
locale => user_locale,
})?;
Ok(rendered)
}
}