Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

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/polls project 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 fn signatures built on Tokio and Hyper/Tower.
  • Typed Signatures: Views take (Request, PathParams) and explicitly return Result<Response, DjangorsError>.
  • Explicit Router Chain: Routes are chained on Router::new() using HTTP verb methods (.get(), .post()), rather than Python lists of path() objects.
  • Middleware Stack: Middleware is composed using standard Rust tower::ServiceBuilder layers rather than string class paths in settings.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 the polls app.
  • 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 to Question with 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 base models.Model Python 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 Rust impl blocks on the struct.
  • Schema Migration Tooling: dj makemigrations and dj migrate execute 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.rs and routing from examples/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 in examples/polls. Views build URL strings explicitly via format!("/{}/results/", question_id).
  • Templates vs. String Formatting: The examples/polls application generates HTML dynamically using Rust format!() strings.
  • Explicit DB Access: Views retrieve the Database handle from request extensions via req.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? where T is a Rust struct deriving serde::Deserialize.
  • Explicit Auth Gating: Instead of Python @login_required decorators, handlers extract Auth::<User> directly via Auth::<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

  1. Start the dev server:
DATABASE_URL="postgres://postgres:postgres@localhost/djangors_dev" dj run --port 8000
  1. Submit a login POST request:
curl -X POST http://localhost:8000/accounts/login/ \
  -d "username=testuser&password=correct_password" -i
  1. 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.rs and examples/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

  1. security_headers_layer(): Injects HTTP security headers (X-Content-Type-Options: nosniff, X-Frame-Options: DENY, Referrer-Policy, etc.).
  2. SessionLayer: Manages signed, encrypted cookie sessions (SignedCookieStore).
  3. csrf_layer(): Enforces Cross-Site Request Forgery (CSRF) protection on mutating requests (POST, PUT, DELETE), requiring csrftoken cookies and X-CSRFToken headers.

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’s tower::ServiceBuilder.
  • Session Store: SignedCookieStore stores cryptographically signed session state directly in browser cookies without requiring a database session table by default.
  • Static Manifests: dj collectstatic produces a production manifest JSON file containing hashed asset paths for high-performance static file serving.

Running and Verifying

  1. Verify static file collection:
mkdir -p static
echo "body { background: #f0f0f0; }" > static/style.css
dj collectstatic --source static --output staticfiles
  1. 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.register Python decorators.
  • Type-Safe Model Admin Configuration: Customizations are passed via ModelAdminConfig structs to register_with::<T>().
  • Staff Authentication Gating: Access to /admin/ endpoints requires an authenticated user with is_staff = true or is_superuser = true. Unauthenticated requests receive HTTP 401 Unauthorized.

Running and Accessing Admin

  1. Start the server:
DATABASE_URL="postgres://postgres:postgres@localhost/djangors_dev" dj run --port 8000
  1. Open http://localhost:8000/admin/ in your browser. Log in using your superuser credentials to manage Question and Choice records.

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 to false.
  • 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:

  1. Async HTTP Handling: Tokio-powered async handlers with typed request extractors.
  2. ORM Models: Strong-typed models with primary keys, foreign keys, filtering (q!), and atomic F-expressions (set!).
  3. Session Authentication: Password hashing (argon2), login/logout session handling, and staff-gated endpoints (Auth<User>).
  4. Automated Testing: Real-socket integration tests (#[tokio::test]).
  5. Security & Middleware: CSRF protection, signed cookie sessions, and security headers via Tower middleware.
  6. Admin Dashboard: Full CRUD admin interface with AdminSite and ModelAdminConfig.

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 --deploy prior 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: i64 with #[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 by rustc.

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 fn operations executing on the Tokio runtime.
  • Explicit Database State: Request state (like Database connection pools) is explicitly retrieved via req.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 CommandDjangors CommandDescription
python manage.py startprojectdj newScaffolds a new project
python manage.py startappdj new-appScaffolds a new app/module inside a project
python manage.py runserverdj runStarts dev server with live-reloading file watch loop
python manage.py migratedj migrateApplies pending database migrations
python manage.py makemigrationsdj makemigrationsIntrospects the project binary; v1 detects new models and new fields
python manage.py sqlmigratedj sqlmigrate <app> <migration>Renders a migration’s SQL without applying it
python manage.py showmigrationsdj showmigrationsLists migrations and their applied state
python manage.py createsuperuserdj createsuperuserPrompts for superuser credentials and creates User
python manage.py collectstaticdj collectstaticBundles static assets into a production output dir
python manage.py runworkerdj runworkerStarts the background-task worker loop
python manage.py testdj testRuns workspace unit and integration test suite (cargo test)
python manage.py shelldj shellLaunches interactive Rust REPL via evcxr
python manage.py dbshelldj dbshellConnects directly to configured database CLI
python manage.py checkdj check [--deploy]Runs static checks (with --deploy, production-safety checks)
python manage.py createpermissionsdj createpermissionsGenerates 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, and list_filter actually exist on the target model struct at registration.
  • Search Type Safety: search_fields enforces text-compatible fields, and list_filter enforces 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

  1. Cookie: CSRF token is stored in the csrftoken cookie.
  2. Header Check First: CsrfLayer checks for the token in the X-CSRFToken HTTP header on unsafe requests (POST, PUT, PATCH, DELETE).
  3. Form Body Fallback: If the HTTP header is absent, Djangors checks application/x-www-form-urlencoded POST request bodies for a csrfmiddlewaretoken field.

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 AspectDjango (Python)Djangors (Rust)
Execution ModelDynamic, interpreted (CPython WSGI/ASGI)Compiled native binary (Tokio async loop)
Type CheckingRuntime duck typing, optional type hintsStrict compile-time static type system
ConcurrencyThread pool (WSGI) or async event loop (ASGI)Native async task channels and multithreaded Tokio runtime
Deployment ModelPython interpreter + virtualenv + Gunicorn/Uvicorn + Nginx static servingCompiled application binary plus configured static/file storage and dependencies
Development ReloadingInstant Python module re-import in dev serverFile watcher trigger followed by cargo incremental binary re-compilation
Memory ManagementAutomatic 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 conventionDjangors production boundary
models.pymodels.rs
forms.py / serializer inputcontracts.rs, Form<T>, or REST deserializer
QuerySet calls in a viewrepositories.rs
model/service/business rulesservices.rs
Http404, PermissionDenied, API exceptionerrors.rs -> DjangorsError
views.pyviews.rs
urls.pyurls.rs
admin.pyadmin.rs
Celery task#[task] handler
TestCase / API clienttests/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 Model trait before calling Book::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::NotFound to 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:

  1. Resolve the authenticated principal.
  2. Resolve the tenant from trusted session/membership data.
  3. Check the role or object permission.
  4. Query by tenant and record ID together.
  5. 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 testDjangors equivalent
TestCaseunit/service tests plus TestDatabase
Clientdjangors_test::TestClient or a real Hyper socket
RequestFactoryconstruct Request directly
assertEqual(response.status_code, 404)response.assert_status(StatusCode::NOT_FOUND)
override_settingsconstruct typed settings/state for the test
TransactionTestCaseexplicit database/transaction integration test
Celery eager task testcall 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:

  1. Load framework and application settings.
  2. Initialize development or production logging.
  3. Connect the database and run migrations.
  4. Create task tables and install runtime handles.
  5. Build Redis, cache, mail, and provider clients.
  6. Register recurring jobs and start the worker.
  7. Attach typed state and an optional ErrorRenderer.
  8. Wrap RouterService with sessions, tenant resolution, security headers, CSRF, and logging layers.
  9. 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 call Router::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

PathTargetRequests/secp50p95p99
helloDjangors60,890.01650.4277 ms1.1753 ms2.1721 ms
helloaxum78,447.39420.3339 ms0.8985 ms1.5812 ms
helloDjango/Gunicorn (4 workers)830.934237.9564 ms52.0187 ms59.9514 ms
full-stackDjangors7,289.73474.0285 ms7.4487 ms10.2702 ms
full-stackaxum9,503.00873.1260 ms5.3474 ms7.4207 ms
full-stackDjango/Gunicorn (4 workers)25.99291.3821 s1.4702 s1.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 books examples.
  • 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.

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:

FailureHTTP statusStable code
Missing/invalid authentication401not_authenticated
Authenticated but not allowed403permission_denied
Invalid input400 or 422feature-specific code
Record not found404<resource>_not_found
Conflict with existing state409feature-specific code
Unexpected persistence/infrastructure failure500generic 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:

  1. Resolve the authenticated principal.
  2. Resolve the tenant from the principal/session, not from an unchecked body field.
  3. Check the role or permission.
  4. Query by both tenant and record ID.
  5. 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:

  1. Load framework settings and application settings.
  2. Initialize development or production logging.
  3. Connect the database and run migrations.
  4. Create task tables and install process-global handles required by task handlers.
  5. Build external clients such as Redis and mail backends.
  6. Register recurring jobs and start the worker.
  7. Build the root router and attach typed shared state with .with_state(...).
  8. Wrap the router in sessions, tenant resolution, security headers, CSRF, and other middleware.
  9. 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:

ModuleUse it forExamples
tasks.rsapp-owned durable jobsimports, notifications, cleanup
notifications.rsemail/SMS/push orchestrationtrial notices, status updates
emails.rs / email_templates.rsmail backend and message contentshared account mail
storage.rsobject/file storage adapteruploads, download keys
importers.rs / exporters.rsCSV or bulk data formatsstudent imports, reports
pdf.rsdocument-specific PDF layoutcertificates, receipts
pagination.rsapp-specific list response ruleslegacy endpoint parity
audit.rsdurable audit eventsactor, action, target, metadata
crypto.rs / verification.rscryptographic or verification workflowstokens, enrollment
moodle_client.rs or another client moduleexternal provider adapterLMS 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 return 200 with {"status":"ok"}.
  • /readyz: dependencies are usable; return 200 when all checks pass and 503 when 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 QuerySet construction 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

ConcernActix Web / AxumDjangors
Handlerasync fn with extractorsasync fn(Request, PathParams) -> Result<Response, DjangorsError>
RouterApp / Scope or Router / nestRouter::new(), .get(), .post(), .mount()
Application stateweb::Data<T> / State<T>Router::with_state(T) and Request::require_state::<T>()
Path valuesPath<T>PathParams::get_as::<T>("name")
Query valuesQuery<T>Request::query("name") or a query extractor/contract
JSON bodyJson<T>Json::<T>::from_request(&req).await
Form bodyForm<T>Form::<T>::from_request(&req).await
ResponseHttpResponse / IntoResponseResponse::json, html, text, bytes, redirect
MiddlewareTransform / LayerTower Layer with ServiceBuilder
Error typeResponseError / IntoResponseDjangorsError or app error impl From<AppError>
ORMDiesel, SeaORM, SQLx, custom#[derive(Model)], QuerySet, q!, Database
SerializationSerde structsSerde contracts or djangors-rest serializers
Authcustom extractor/middlewaredjangors-auth, sessions, token/JWT auth, permissions
CRUD APIhandwritten handlers or macrosREST ViewSet and ModelSerializer
HTML templatesTera, Askama, Mauddjangors-template and class-based views
JobsTokio task, external queue#[task], database queue, Worker, recurring jobs
Testsactix_web::test, tower::ServiceExtdjangors-test::TestClient, TestDatabase, real-socket tests
Adminbuild it yourselfdjangors-admin
CLIbinary/subcommands you createdj 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:

  1. tracing/request ID
  2. host validation and security headers
  3. session/authentication state
  4. tenant resolution
  5. CSRF for cookie-authenticated unsafe requests
  6. 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::TemplateEngine is the equivalent of Tera/Askama’s template engine.
  • djangors_template::render returns 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_views provides ListView, DetailView, CreateView, UpdateView, and DeleteView for 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:

  1. Validate authenticated ownership and intended purpose.
  2. Enforce size and content-type limits.
  3. Store using a generated storage key, never a client filename as a path.
  4. Persist metadata and scan untrusted content when required.
  5. Queue expensive processing as a task.
  6. 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:

  1. Load DjangorsSettings and typed application settings.
  2. Initialize dev or production tracing.
  3. Connect the database and run migrations.
  4. Create task tables and install runtime handles.
  5. Build Redis, mail, cache, and external provider clients.
  6. Register recurring jobs and start the worker.
  7. Build the root router and attach typed state.
  8. Wrap RouterService in sessions, tenant, security, CSRF, and logging layers.
  9. 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:

  1. Write down its routes, methods, path/query/body contracts, status codes, auth rules, tenant rules, and error envelope.
  2. Move persistent structs to models.rs and create a migration.
  3. Define public input/output structs in contracts.rs.
  4. Move SQL into repositories.rs.
  5. Move validation and workflows into services.rs.
  6. Define domain failures in errors.rs and map them to DjangorsError.
  7. Add serializers or ViewSet options for public field visibility.
  8. Implement thin views and app-local URLs.
  9. Add permission/scoping checks before enabling routes.
  10. Add service, permission, model, API, and task tests.
  11. Attach dependencies in startup and verify middleware order.
  12. 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 implies unique and db_index. Every model must have exactly one primary key field.
  • auto: Marks auto-incrementing / database-generated fields (such as BIGSERIAL). Ignored during save() insertions.
  • max_length = N: Sets maximum string length. Valid only on String fields (maps to FieldKind::Char). String without max_length maps to FieldKind::Text.
  • default = value: Sets default field value (Text, I64, F64, or Bool).
  • unique: Adds a UNIQUE constraint 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_delete options: "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::Char or FieldKind::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 (except ManyToMany)

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, and Decimal are defined in FieldKind for schema metadata, but currently trigger a compile error on Model derive for save/update operations.


QuerySet Methods

Access querysets using Model::objects() (or QuerySet::<T>::new()).

Important

objects(), meta(), and field_values() are methods on the Model trait. Every file that calls them needs use djangors_orm::Model; — the #[derive(Model)] attribute does not pre-import it. A bare Model::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 with AND. Accepts a whole Q-style tree, so OR and NOT compose (see Combining filters).
  • .exclude(q!(...)): The negation of .filter() — Django’s .exclude().
  • .filter_or_icontains(&["field1", "field2"], "term"): Generates case-insensitive ILIKE %term% queries across specified text fields combined with OR.
  • .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 - means DESC.
  • .limit(n) / .offset(n): Paginates results using standard SQL LIMIT and OFFSET.
  • .debug_sql() / .debug_params(): The SELECT this queryset would run and the parameters it would bind, for debugging and tests — Django’s str(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.

SuffixSQLNotes
(none) / __eq=
__ne<>
__lt __lte __gt __gte< <= > >=
__contains / __icontainsLIKE / ILIKE %v%
__startswith / __endswithLIKE 'v%' / LIKE '%v'
__iexactILIKE 'v'Case-insensitive, no wildcards
__inIN (...)Takes a Vec; an empty one compiles to FALSE, never invalid IN ()
__isnullIS 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 returns Vec<T>.
  • .get(db).await: Fetches a single object. Returns Ok(T), Err(OrmError::NotFound) if 0 rows returned, or Err(OrmError::MultipleObjectsReturned) if >1 row.
  • .first(db).await: Returns Result<Option<T>, OrmError> (LIMIT 1).
  • .exists(db).await: Returns Result<bool, OrmError> check (SELECT 1 ... LIMIT 1).
  • .count(db).await: Returns Result<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. Returns Result<u64, OrmError> (rows affected count).
  • .delete(db).await: Deletes all rows matching the queryset’s filters. Returns Result<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.

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_update must run inside a transaction — calling it against a bare pool conn returns OrmError::SelectForUpdateOutsideTransaction. Use the Postgres db.transaction(|conn| ...) form (or transaction_conn, which works on both backends). nowait, skip_locked, and lock_of are Postgres-only; SQLite rejects them with UnsupportedOnDialect.


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 Exists or NotExists subquery whose filters contain no OuterRef is 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, ignoring auto fields, 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 matching model.id. Returns Err(OrmError::NotFound) if no row matched.
  • model.delete(db).await: Deletes the row matching model.id. Returns Err(OrmError::NotFound) if no row was deleted.

Important

save is INSERT-only — it always creates a new row. Calling save() on a row you already fetched or previously saved inserts a duplicate; use update() 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

ViewSignature (generic bound)Renders / redirects
ListView<M>M: Model + FromRowRenders object_list
DetailView<M>M: Model + FromRowRenders object (looked up by pk)
CreateView<M>M: Model + FromRow + ModelFormGET renders a blank form; POST inserts + redirects
UpdateView<M>M: Model + FromRow + ModelFormGET pre-fills a form; POST updates by pk + redirects
DeleteView<M>M: Model + FromRowGET 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 via PathParams::get_as::<i64>("pk"). The route must expose a segment named pk, 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:

Viewobject_listobjectfieldsformerrors
ListViewall rows————
DetailView—one row———
CreateView (GET)——field names—{} empty
CreateView (POST error)——field namessubmitted dataper-field + __all__
UpdateView (GET)—one rowfield names—{} empty
UpdateView (POST error)—one row—submitted dataper-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 (from Model::field_names()), useful for rendering an empty form’s inputs.
  • form — the submitted HashMap<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 the Form extractor, then M::validate_form(&data) runs. On success the cleaned values build a new instance via from_cleaned_form(), the row is inserted, and the response redirects to success_url. On failure the form template is re-rendered with fields, form, and errors.
  • UpdateView (POST) — the row for pk is fetched, then M::validate_form(&data) runs. On success apply_cleaned_form() mutates the fetched instance, all non-primary-key fields are written back, and the response redirects to success_url. On failure the form template is re-rendered with object, form, and errors.

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 / DeleteView need M: Model + FromRow (both provided by #[derive(Model)]).
    • CreateView / UpdateView additionally need M: ModelForm — also generated by #[derive(Model)].
  • A djangors_orm::djangors_db::Database must be in the request state, because every view calls req.require_state::<Database>() to fetch rows. Attach it with Router::with_state(db) (or Request::with_state).
  • A TemplateEngine must be passed via config.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 (a static, app state, etc.) as long as it outlives the handlers.
  • The configured template_name must exist in one of the engine’s search directories, or rendering fails with an internal error.

Note

The Database comes from request state, but the TemplateEngine comes 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 Response directly and perform no login/permission checks. Wrap them in your own middleware, or check the current user inside a wrapper handler, using djangors-auth (see the Authentication guide).
  • No pagination. ListView fetches every row with QuerySet::all() and renders them all. There is no page-size or pagination control.
  • ListView / DetailView are render-only. They do not branch on the HTTP method — they always render. Only CreateView / UpdateView / DeleteView distinguish GET from POST (anything that is not GET is treated as POST).
  • URL-encoded forms only. CreateView / UpdateView parse the body with Form::<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 i64 pk (params.get_as::<i64>("pk")); a non-integer pk is a BadRequest.
  • The pk name is fixed. Detail/update/delete look up the pk path 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 .html or .htm automatically 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: &lt;script&gt;alert(1)&lt;&#x2f;script&gt; -->

<!-- In unsafe.txt -->
{{ value }} <!-- Output: <script>alert(1)</script> -->

Registered Template Filters

djangors-template includes built-in Django filters:

FilterExample UsageDescription / 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 {% ... %}:

FunctionExample UsageDescription / 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, &params_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"> (renders checked for boolean truthy values like "true", "on", "1")
  • Select: <select> with <option> choices
  • RadioSelect: Radio inputs with label containers
  • DateInput: <input type="date">
  • EmailInput: <input type="email">
  • PasswordInput: <input type="password"> (by default, render_value = false to 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:

MethodReturnsNotes
id_for_label()StringThe element id (attrs["id"], else "id_<name>")
label_tag()String<label for="...">…</label> (sentence-cased name unless overridden)
render_widget()StringThe 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:

  1. Management Form: Renders TOTAL_FORMS and INITIAL_FORMS as hidden inputs.
  2. Prefixing: Prefixing field names per form (form-0-title, form-1-title, …).
  3. Deletion: can_delete option renders a DELETE checkbox per form.
  4. Security Cap: TOTAL_FORMS is checked against max_num (default 1000) 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

MethodSignatureNotes
with_max_num(max)fn(self, usize) -> SelfRaises/lowers the TOTAL_FORMS security cap (default 1000)
add_prefix(index, field)fn(&self, usize, &str) -> StringBuilds "<prefix>-<index>-<field>" (e.g. author-0-title)
render_delete_checkbox(index, checked)fn(&self, usize, bool) -> StringRenders 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

  1. Form<T>::from_request(&req).await reads the full request body bytes via req.body_bytes().await.
  2. Body bytes are deserialized into type T (which must implement serde::de::DeserializeOwned) using serde_urlencoded::from_bytes.
  3. Error Handling:
    • If the request body contains missing required fields or invalid data types for T, extraction returns DjangorsError::BadRequest(msg) containing "failed to parse form body: ..." with HTTP status 400 Bad Request.

Combining Extractors in Handlers

djangors-core extractors implement FromRequest:

#![allow(unused)]
fn main() {
use djangors_core::extract::{Form, FromRequest, Json, Query};
}
ExtractorSourceContent Type / Format
Form<T>Request Bodyapplication/x-www-form-urlencoded
Json<T>Request Bodyapplication/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(&params, "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:

MethodCreate pathUpdate path
validate_form(&map)Validate the post bodySame, 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

ItemDescription
enum LevelSeverity, mirroring Django message levels: Debug, Info, Success, Warning, Error.
struct MessageA 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_errorShorthands 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 Message should 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.

ItemDescription
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.

ItemDescription
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 (table djangors_flatpage) and djangors_contrib_redirects (table djangors_redirect) — see Schema Migrations.

Note

Admin editing requires djangors-admin in your dependency tree and a staff superuser account, created with dj 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):

MethodSignatureNotes
newnew(method: Method, uri: Uri, headers: HeaderMap, body: Bytes) -> SelfParses + URL-decodes the query string at construction
methodfn(&self) -> &MethodHTTP verb
pathfn(&self) -> &strURI path, e.g. "/hello/world"
headerfn(&self, name: &str) -> Option<&HeaderValue>Single header lookup
headersfn(&self) -> &HeaderMapAll headers
queryfn(&self, name: &str) -> Option<&str>Decoded query parameter
raw_queryfn(&self) -> Option<&str>Undecoded query string, e.g. "q=rust&page=2"
body_bytesasync fn(&self) -> &[u8]Buffered body (trivially async)
statefn<T: Send + Sync + 'static>(&self) -> Option<&T>Top-level app state (see below)
require_statefn<T>(&self) -> Result<&T, DjangorsError>Like state, but DjangorsError::Internal when missing
extfn<T: Send + Sync + 'static>(&self) -> Option<&T>Per-request extension map
with_statefn(self, state: AppState) -> SelfAttach app state
with_extensionsfn(self, extensions: Extensions) -> SelfReplace per-request extensions
into_partsfn(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:

  • state is app-wide, configured once at startup via Router::with_state(...), and stored in an AppState on the request. Use it for long-lived singletons every handler needs — a Database connection pool, a Groups broadcast registry, a storage backend.
  • ext is a per-request map of values inserted by middleware into the incoming hyper request’s Extensions (which Router::dispatch propagates via with_extensions). Use it for request-scoped facts middleware computes — the resolved Session, 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.

ConstructorSignatureNotes
texttext(status: StatusCode, body: &str) -> Selftext/plain; charset=utf-8
htmlhtml(status: StatusCode, body: impl Into<String>) -> Selftext/html; charset=utf-8
bytesbytes(status: StatusCode, content_type: &str, body: Vec<u8>) -> SelfRaw bytes, explicit content type
jsonjson<T: Serialize>(status: StatusCode, value: &T) -> Result<Self, DjangorsError>application/json; charset=utf-8; DjangorsError::Internal on serialize failure
redirectredirect(location: &str) -> Self302 Found + Location
ssesse<S>(stream: S) -> StreamingResponse where S: Stream<Item = String>Builds an SSE stream (see How to Stream Server-Sent Events)
headerheader(name: &str, value: &str) -> SelfBuilder, overwrites existing value
into_hyperinto_hyper(self) -> hyper::Response<Full<Bytes>>Converts for the server/Service path
into_hyper_boxedinto_hyper_boxed(self) -> hyper::Response<BoxBody<Bytes, Infallible>>Boxed-body variant
status / headers / bodyaccessorsRead 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.

MethodSignatureNotes
newnew() -> SelfEmpty state
insertinsert<T>(self, value: T) -> SelfBuilder-style; returns a new AppState
getget<T>(&self) -> Option<&T>Lookup by type
containscontains<T>(&self) -> boolType check
lenfn(&self) -> usizeNumber of distinct types
is_emptyfn(&self) -> boolNo state attached
mergemerge(self, other: &AppState) -> SelfUnion; 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.

MethodSignatureNotes
routeroute(path: &str, method: Method, handler: impl Handler)Register with an explicit method
route_streamingroute_streaming(path: &str, method: Method, handler: impl StreamingHandler)Streaming handlers for non-GET too
get_sse / sseget_sse(path: &str, handler: impl StreamingHandler)SSE GET helper (alias: sse)
namename(name: &str) -> SelfName the most recently registered route (panics on duplicates)
reversereverse(name: &str, params: &[(&str, &str)]) -> Result<String, ReverseError>Path reconstruction; values are percent-encoded
mountmount(prefix: &str, sub_router: Router) -> SelfPrefixes 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_sse was used here to keep the snippet self-contained; a real SSE handler returns a StreamingResponse. 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.

LayerBuilderBehavior
logging_layer()fn() -> TraceLayertower_http::trace, logs method/path/status/latency via tracing
security_headers_layer()fn() -> SecurityHeadersLayerX-Content-Type-Options: nosniff, X-Frame-Options: DENY, Referrer-Policy: same-origin
hsts_layer(max_age)fn(max_age_secs: u64) -> HstsLayerStrict-Transport-Security; .with_include_subdomains(bool) (default off)
normalize_path_layer()fn() -> NormalizePathLayerTrims trailing slashes (Django CommonMiddleware-ish)
compression_layer()fn() -> CompressionLayergzip + brotli response compression
request_id_layer()fn() -> RequestIdLayerSets/propagates X-Request-ID (UUID)
csrf_layer()fn() -> CsrfLayerDouble-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 plain Full<Bytes> body (e.g. security_headers_layer) must sit inside it — put compression first in the ServiceBuilder chain as shown above. csrf_layer().with_secure(true) (and SignedCookieStore::with_secure) must be enabled off HTTP in production — see Security. Serve the stack with Djangors::serve_service(listener, service); the plain serve()/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:

VariantStatusDomain code
NotFound404not_found
BadRequest(String)400bad_request
Internal(String)500internal
Panicked(String)500panicked
Unauthorized(String)401unauthorized
Forbidden(String)403forbidden
TooManyRequests(String)429too_many_requests
Api(ApiError)api.statusapi.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 the Api variant.
  • .with_details(json!(...)) attaches a structured payload; when chained onto a built-in variant it promotes it to Api, 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’s Display text 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.

MethodSignatureNotes
newnew(total_items: i64, page_size: i64) -> SelfPanics if page_size <= 0
total_pagesfn(&self) -> i64Ceiling division; 0 items ⇒ 1 page
offsetfn(&self, page: i64) -> i640-indexed SQL OFFSET, clamped into [1, total_pages()]
has_previousfn(&self, page: i64) -> boolpage > 1
has_nextfn(&self, page: i64) -> boolpage * 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 of pk|order-value (order value may itself contain |).
  • decode_cursor(cursor: &str) -> Result<(i64, Option<String>), CursorError>.
  • CursorError variants: 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).

FunctionOutputNotes
init_dev_logging()compact colored console outputDefault level "info,djangors_core=debug"
init_production_logging()structured JSON linesDefault level "info,djangors_core=info", ideal for log aggregators
init_production_logging_with_sentry(dsn) (sentry feature)JSON + Sentry captureERROR 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 by DJANGORS_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, when DJANGORS_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("&lt;script&gt;"));
assert!(escaped.contains("&amp;"));
assert!(escaped.contains("&#x27;"));
}

Where to go next

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_FAILED signals.
  • 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 with codename ("{app_label}.{action}_{model}") and name.
  • 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) and verify_password(password, hash) use Argon2id with random salts.
  • Password Reset: generate_password_reset_token, verify_password_reset_token, request_password_reset, and confirm_password_reset provide 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:

VariantMeaning
AuthError::HashingArgon2id encode/verify failure
AuthError::DatabaseUnderlying ORM/db error
AuthError::RateLimitedRateLimitedBackend tripped its window
AuthError::AccountLocked { retry_after_secs }PersistentLockoutBackend rejected with a locked account
AuthError::InvalidTokenReset token missing, malformed, or expired
AuthError::Mailrequest_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:

MethodMissing 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.

MethodPathAction
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:

FunctionCustom permissionCustom configCustom serializer / pagination / throttle
viewset_routes::<M>(router, base)No (IsAuthenticated)NoNo
viewset_routes_with_permission::<M,P>(router, base, perm)YesNoNo
viewset_routes_with_config::<M>(router, base, config)NoYesNo
viewset_routes_with_config_and_permission::<M,P>(router, base, config, perm)YesYesNo
viewset_routes_with_options::<M,P>(router, base, options, perm)YesYes (via options)Yes
scoped_viewset_routes::<M>(router, base)No (IsAuthenticated)NoNo — mandates M: Scoped
scoped_viewset_routes_with_config::<M>(router, base, config)No (IsAuthenticated)YesNo — 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_config passes the config to the list handler only. _with_options is different: the options reach every handler, so a serializer’s read/write field split actually applies to create and update as well as to list and retrieve. 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).

CallApplies
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 guarded wrapper viewset_routes* applies. Call your own permission check (or route through IsAuthenticated) before delegating, and ensure a Database is 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 only Scoped::scope — the same “no built-in permission check” rule from the note above applies here too, and the consequence is sharper: scope decides which rows are visible (typically “this tenant”), not who’s allowed to write. If you mount these functions yourself instead of going through scoped_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 the scoped_viewset_routes* helpers; if you must hand-mount, add your own IsAuthenticated (or stricter) check before delegating, and encode any role restriction directly in Scoped::scope or 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_only fields are rendered but rejected on write, rather than silently ignored.
  • write_only fields (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:

MethodBehaviour
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:

MethodPurposeDefault
to_representation(&self, &M) -> serde_json::Valuerender 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 parsingno-op
to_representation_nested(&self, &M, &RelatedObjects) -> Valueembed preloaded relations in place of FK idsmerges the base representation
to_representation_many(&self, &[M]) -> Vec<Value>render a collectionmaps to_representation
parse(&self, &Value, partial) -> Result<FieldValues, ValidationErrors>to_internal_value then validateViewSets 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 no COUNT.

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):

StructFieldDefault
PageNumberPaginationpage_size: i64100
PageNumberPaginationmax_page_size: Option<i64>None (no client override)
LimitOffsetPaginationdefault_limit: i64100
LimitOffsetPaginationmax_limit: i64100
CursorPaginationpage_size: i64100
CursorPaginationmax_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, because scope is just a &'static str and the rate string is also a &'static str; you only notice at runtime when the parse fails. Throttle::new returns Option (it delegates to parse_rate), so a typo’d rate is silently None unless you .expect(...). Get the order right and always expect:

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:

  1. Session — djangors-auth’s Auth<User> (see the Authentication guide). Best for browser clients.
  2. Token header — Authorization: Token <64-hex-key>, backed by the database AuthToken model. Best for long-lived machine clients.
  3. JWT — Authorization: Bearer <jwt>, HS256-signed, gated behind the crate’s jwt Cargo 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 to ILIKE-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_with validates your config at registration time, at startup, by panic — not at first request. A field named in list_display, search_fields, list_filter, date_hierarchy, or list_editable that does not exist on the model, or that has the wrong kind (a String in list_filter, which must be Boolean; a ForeignKey/integer in search_fields, which must be text-like; a non-DateTime in date_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/Email/ 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! returns UnresolvedExpr already, so no cast is needed in real code; the explicit as above 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:

ModuleOwns
model_adminModelAdmin trait, DefaultModelAdmin, ModelAdminConfig, ChangelistPage, InlineConfig, TenantFilter
siteAdminSite, SiteBranding, TenantScope, with_tenant_scoping, route assembly (urls)
changelistList page: querying, pagination, CSV export, ?o=/q=/f=/date-hierarchy handling
edit_viewAdd/change form GET+POST handlers
delete_viewDelete and bulk-delete GET+POST handlers
index_viewThe admin index (app/model links)
history_viewPer-object change history
action_viewBulk-action handlers
formsForm/field rendering and parsing for the add/change forms
logLogEntry auditing (AdminAction, field diffs)
permsrequire_staff, require_perm, and request-scoped tenant resolution
faviconfavicon_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 tenant argument to every query you run, and never cache it on self.

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_pk must 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_pk must refuse (return false, delete nothing) when the row belongs to another tenant.
  • update_from_form / update_fields_from_form must not touch another tenant’s row.
  • create_from_form must 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:

MethodSignatureNotes
RequestBuilder::with_state<T>(state)fn(self, T) -> SelfAttach an AppState value; request handlers see it via req.state::<T>()
TestResponse::body_str()fn(&self) -> StringThe response body as lossy UTF-8
TestResponse::status()fn(&self) -> StatusCodeThe 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:

MethodSignatureReturns
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
MethodSignatureNotes
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
}
MethodSignatureNotes
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

MetricIn-Process (TestClient)Real-Socket Integration
SpeedExtremely fast (microseconds)Fast (milliseconds)
Network OverheadZero (in-memory execution)Real local TCP loopback socket
Middleware CoverageHandler-level extensions & stateFull Tower layer stack & HTTP protocol
Use CaseUnit tests & route handler assertionsFull-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:

ConditionBackend
TEST_BACKEND=sqliteSQLite (in-memory)
TEST_BACKEND=postgresPostgreSQL
neither set, DATABASE_URL presentPostgreSQL
neither set, no DATABASE_URLSQLite (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 is src/tests.rs, declared #[cfg(test)] mod tests; — see the bottom of crates/djangors-admin/src/lib.rs.
  • For any other module, e.g. src/queryset.rs, it is src/queryset_tests.rs, declared with #[cfg(test)] #[path = "queryset_tests.rs"] mod tests; — see crates/djangors-contrib-payments/src/bachs.rs. The #[path] is required: without it Rust looks for src/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.rs or src/{name}_tests.rs with #[path]), never in the production .rs.
  • Build every CREATE TABLE from db.dialect().auto_pk_type() / timestamp_type(), and quote identifiers with quote_ident().
  • Go through the ORM; never touch raw pool() or write $1 placeholders 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 --workspace and 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

  1. DEBUG mode: Must be false (fails if debug = true).
  2. SECRET_KEY: Must be at least 32 characters long.
  3. ALLOWED_HOSTS: Must be configured explicitly (fails if set to default ["127.0.0.1", "localhost"] when debug is false).

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 variantMeaning
InProgressA build or deploy is currently underway
LiveDeployment 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
NotFoundThe provider reports no matching deployment (e.g. destroyed via its dashboard)
DeployError variantMeaning
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

  1. Upon receiving SIGINT or SIGTERM, the TCP accept loop stops listening for new connections.
  2. An active connection drain phase initiates, allowing in-flight HTTP requests to complete.
  3. 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

  1. Cookie Generation: On safe requests (GET, HEAD, OPTIONS), if no csrftoken cookie exists, a cryptographically secure random 32-byte hex token is generated and returned in a csrftoken cookie (SameSite=Lax; Path=/; Max-Age=31536000). Note: The cookie is intentionally not HttpOnly so client-side JavaScript can read it to populate request headers.
  2. Unsafe Method Validation: For unsafe HTTP methods (POST, PUT, PATCH, DELETE), the token in the X-CSRFToken request header is compared against the csrftoken cookie value using constant-time comparison (constant_time_eq).
  3. Form Fallback: If the header is missing or mismatched, CsrfLayer sets CsrfPendingFormCheck on the request extensions. Form submission handlers verify the csrfmiddlewaretoken form body field against the pending token, returning 403 Forbidden if 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 Host before 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.


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, causing decode() to return None (clearing session state).
  • HttpOnly & SameSite=Lax: Session cookies are marked HttpOnly (inaccessible to client JavaScript) and SameSite=Lax.
  • Session Fixation Prevention (cycle_key): Calling session.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")]:

FieldEnvironment variable
api_keyMYAPP_API_KEY
base_urlMYAPP_BASE_URL
timeout_secsMYAPP_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 in Option<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 a Vec<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:

  1. Verify the x-paystack-signature header (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.
  2. Only then parse the JSON body.
  3. Require both event == "charge.success" and data.status == "success" before treating the charge as confirmed. Checking only one of these would be a real security gap.
  4. 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:

  1. initiate the charge and keep the returned InitiateChargeResponse.reference.
  2. record_charge_initiated a Pending row keyed by that reference before redirecting, so a late webhook or a duplicate initiate finds (and reuses) it.
  3. Redirect the customer to authorization_url.
  4. On the callback/return, call provider.verify(&reference) ourselves — never trust the redirect alone.
  5. mark_transaction_status with 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 in auth_permission).
  • app_label (max_length = 100) and model_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: a change_document codename must already exist as a djangors_auth::Permission row or both grant_object_permission fails to find it and revoke_object_permission silently returns false. Run dj createpermissions (or djangors_auth::sync_permissions) first — see Authentication and Permissions.

  • Add djangors-contrib-guardian and djangors-contrib-contenttypes to your Cargo.toml.
  • Run dj makemigrations && dj migrate so djangors_object_permission and djangors_content_type exist.
  • Run dj createpermissions so codenames like change_document exist before granting.
  • Call sync_content_types(&db) at startup (in main before 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 .db or .sqlite, or is exactly :memory: or sqlite::memory:.
  • PostgreSQL backend: Selected for any other URL format (e.g. postgres:// or postgresql://).

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:

  1. ORM Query Path: The generated SQL for filtering, limits, offsets, and operators is tailored to the active database dialect.
  2. Raw SQL Execution: All production raw SQL built using the database executor uses the correct syntax and placeholder formats.
  3. Migrations: When generating migration files via dj makemigrations and executing them via dj 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 standard LIKE operator 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::float8 on PostgreSQL, and CAST(expr AS REAL) on SQLite.
  • extract_date_part(part, col): Extracts parts of a date (Year, Month, Day). Generates EXTRACT(part FROM col)::int for Postgres and CAST(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::AlterColumnType returns a MigrationError::UnsupportedOnDialect rather 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 underlying PgPool. This will panic if the database handle is connected to SQLite.
  • Database::sqlite_pool() is the safe SQLite counterpart: it returns Option<&SqlitePool> — Some on a SQLite handle, None on a PostgreSQL handle.
  • To write code that runs on both backends, use Database::conn() instead. It returns a Conn enum 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 returns OrmError::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) return OrmError::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 SQL NULL.

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.
  • RepeatableRead
  • Serializable

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:

  1. 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.
  2. dj migrate: Executes any pending migration files against the target database and records their names in the djangors_migrations history 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 a MigrationError::NonInvertible error 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:

  1. Djangors parses your compiled Rust model attributes (#[derive(Model)]).
  2. It loads .schema_snapshot.json to see the last recorded schema state.
  3. It performs a structural diff between the models and the snapshot.
  4. Based on the diff, it generates the appropriate Operation steps and writes the SQL migration.
  5. It updates .schema_snapshot.json with 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:

OperationReversible?Reverse Operation
CreateTableYesDropTable
AddColumnYesDropColumn
RenameColumnYesRenameColumn (with old and new names swapped)
DropColumnNoNone (data is lost, cannot be automatically reconstructed)
DropTableNoNone (table columns and data are lost)
AlterColumnTypeNoNone (reverting requires knowledge of the original type)
CreateIndexYesDropIndex
DropIndexNoNone (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 .sql extension (e.g. 0001_initial).

When running dj migrate, the system:

  1. Ensures the djangors_migrations table exists.
  2. Queries the table for all applied migration names.
  3. Scans your migrations directory for .sql files.
  4. Executes the SQL inside the -- up section of any files not recorded in djangors_migrations.
  5. Inserts the names of newly applied migrations into djangors_migrations inside 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:

  1. Queries the most recently applied migrations from the djangors_migrations table.
  2. Reads their corresponding .sql files from disk.
  3. Asserts that the files do not contain the -- no-down marker.
  4. Executes the SQL inside the -- down section.
  5. Deletes the migration records from the djangors_migrations table.

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

FunctionSignatureNotes
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:

MethodSignatureNotes
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 current but not in previous produces a CreateTable.
  • Added columns — a field in current with no matching name in previous produces an AddColumn.
  • Dropped columns — a field in previous with no matching name in current produces a DropColumn.
  • Changed column types — a field present in both sets whose kind or max_length differs produces an AlterColumnType. Because AlterColumnType is unsupported on SQLite, diffing on a SQLite dialect surfaces an UnsupportedOnDialect error 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 the djangors_migrations history 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

--fake can 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:

  1. Generates a unique task name (defaults to the function name, e.g. "send_email"). You can override this using #[task(name = "custom_name")].
  2. Generates code that serializes/deserializes your custom payload structure to and from a serde_json::Value.
  3. Registers the task handler function globally using the inventory crate. The worker discovers all registered TaskRegistration structs 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 1 query 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"),
}
}
MethodSignatureNotes
Worker::run_onceasync fn(&self) -> Result<bool, TaskError>One poll cycle: claim + run a single due task; returns whether one ran
claim_next_taskasync 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:

VariantMeaning
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:

  1. The task status is updated to "running", and attempts is incremented by 1.
  2. The handler function is executed inside a tokio::spawn wrapper to isolate panics.
  3. Success: If the handler returns Ok(()), the status changes to "completed".
  4. 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".

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.

  1. When configuring a Worker with .with_recurring_tick_interval(), the worker periodically calls tick_recurring_tasks.
  2. It claims due recurring tasks using a transactional advisory lock (pg_advisory_xact_lock on Postgres) to prevent concurrent ticks.
  3. For each due task, it enqueues a new execution instance in djangors_task_queue and calculates the next_run_at using 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 using serde_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 KEY
    • value: 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 redis crate 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:

  1. The incoming request is a GET request.
  2. The response contains the CacheableResponse marker extension.
  3. The response does NOT contain a Set-Cookie header.

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 CacheableResponse extension and lack Set-Cookie headers, 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.

MethodSignatureNotes
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:

  1. Requested Locale: Looks up the translation in the specified catalog (e.g. fr-FR).
  2. Default Locale: If the translation is missing in the requested catalog, it falls back to the configured default locale catalog (e.g. en-US).
  3. Raw message ID: If the key is not found in either catalog, it returns the raw message_id string 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:

  1. HTTP Accept-Language Header: Parses the first valid BCP-47 tag (using first_locale_tag). E.g., accept-language: fr-FR, en;q=0.8 resolves to fr-FR.
  2. Session override: Looks up the key _locale in the user’s active session.
  3. 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 a chrono::NaiveDate.
  • localized_datetime(datetime, locale): Formats a chrono::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.


Djangors stores session data directly in the client’s browser using signed cookies.

How it Works

  1. Serialization: Session key-value data is serialized to JSON.
  2. Encoding: The JSON payload is Base64-encoded.
  3. Signing: Djangors signs the payload with HMAC-SHA256 using the application’s secret key (SECRET_KEY).
  4. 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 to true by default. This prevents client-side scripts from reading the cookie, mitigating Cross-Site Scripting (XSS) session theft.
  • Secure: Set to true if your application is configured to require HTTPS.
  • SameSite: Set to Lax to 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.

MethodSignatureNotes
store.encode(&session)fn(&self, &Session) -> StringSerializes, 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

  1. The CsrfLayer middleware reads the request cookie named csrftoken. If not present, it generates a cryptographically secure random token and sets it as the csrftoken cookie.
  2. For unsafe methods (POST, PUT, PATCH, DELETE), Djangors requires the client to submit the same CSRF token value in the request.
  3. 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:

  1. Loops through your configured source directories in the order they were defined.
  2. Checks if the requested file exists in that directory.
  3. Serves the file with the appropriate Content-Type header (such as text/css; charset=utf-8 or application/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 the Client API.

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:

  1. Djangors crawls all source directories and reads every asset.
  2. It computes the SHA-256 hash of each file’s contents.
  3. It appends the first 8 characters of the hash to the file’s filename (e.g. logo.css becomes logo.8bf74a2d.css), ensuring browsers cache assets forever.
  4. It saves the hashed files via the configured storage backend.
  5. It generates a manifest.json file mapping the original paths to the hashed paths:
{
  "mapping": {
    "css/style.css": "css/style.f8a7e3d1.css",
    "images/logo.png": "images/logo.a8c6e28f.png"
  }
}
  1. The manifest.json is 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 a RequestStarted payload.
  • REQUEST_FINISHED: Fires when an HTTP request completes (either successfully or with an error). Fired with a RequestFinished payload.

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.

MethodSignatureNotes
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:

  1. It retrieves all connected subscriber closures.
  2. It executes all subscriber futures concurrently (spawning each callback inside its own tokio::spawn worker).
  3. 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 confirmed flag 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 Sitemap is 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 from FeedItem.pub_date. Matching the FeedItem.pub_date URI 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:

  1. Explicit Opt-in: the response must carry a CacheableResponse marker extension.
  2. 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::sse automatically 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 standard serve() / run() server loop, not custom run_service() middleware stacks (which expect fixed Full<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.

MethodSignatureNotes
Groups::new()fn() -> GroupsDefault per-channel buffer capacity (1024)
Groups::with_capacity(cap)fn(capacity: usize) -> GroupsCustom per-channel buffer capacity
groups.group(name)fn(&self, name: &str) -> GroupHandleLazily 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) -> GroupStreamAn async Stream<Item = String> of messages
handle.receiver_count()fn(&self) -> usizeNumber 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 GroupStream swallows Lagged errors) rather than blocking the sender.
  • Zero subscribers: a send with 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, and Router::get_sse(path, handler) is an alias for .sse(path, handler) (i.e. a GET-only route_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)
}
}