apricarax is under active development — APIs may change before a 1.0 release. Get notified when it's ready →

Docs

apricarax is a Cargo workspace: each concept below lives in its own crate, imported together through apricarax_core::prelude::*.

Routing & controllers Validation ORM (Active Record) Event sourcing Views (htmx + Askama) Authentication Background jobs Authorization CLI reference

Routing & controllers

Tag an impl block with #[controller] and its methods with a verb attribute; routes() is generated for you.

pub struct UserController;

#[controller]
impl UserController {
    #[get("/users")]
    pub async fn index(State(db): State<Db>) -> Result<Json<Vec<User>>, AppError> {
        Ok(Json(User::all(&db).await?))
    }

    #[post("/users")]
    pub async fn store(State(db): State<Db>, Json(payload): Json<NewUser>)
        -> Result<(StatusCode, Json<User>), AppError>
    {
        Ok((StatusCode::CREATED, Json(User::create(&db, payload).await?)))
    }
}

Merge controllers into your app's router:

pub fn api_routes() -> Router<AppState> {
    Router::new().merge(UserController::routes())
}

Handler return types aren't constrained by the macros at all — anything implementing Axum's IntoResponse works, including AskamaHtml (see Views).

Validation

Implement Validate on a payload and wrap its extractor in Valid. It works with both Json and Form, and runs before the handler body does:

#[derive(Deserialize)]
pub struct PostPayload { pub title: String, pub body: String }

impl Validate for PostPayload {
    fn validate(&self, v: &mut Validator) {
        v.required("title", &self.title).max_len("title", &self.title, 200);
        v.required("body", &self.body);
    }
}

#[post("/posts")]
pub async fn store(Valid(Json(payload)): Valid<Json<PostPayload>>) -> /* ... */

Rules are plain methods: required (non-blank), min_len/max_len (in characters), email, and check(field, ok, message) for anything else. Only a field's first failure is reported. A failing payload gets a 422:

{ "message": "validation failed",
  "errors": [{ "field": "title", "message": "is required" }] }

Checks that need the database, like "email already taken", stay in the handler: return AppError::Validation(vec![FieldError { .. }]) for the same response shape. For htmx forms, mirror the rules as HTML attributes (required, maxlength) so the browser catches mistakes before submitting; htmx doesn't swap 4xx responses by default.

ORM (Active Record)

#[derive(Model)] generates find, all, create, save, and delete from a struct's shape.

#[derive(Model, sqlx::FromRow, Serialize, Deserialize, Debug, Clone)]
#[model(table = "users", timestamps)]
pub struct User {
    #[model(primary_key, skip_on_insert)]
    pub id: i64,
    pub name: String,
    pub email: String,
    #[model(skip_on_insert)]
    pub created_at: chrono::NaiveDateTime,
    #[model(skip_on_insert)]
    pub updated_at: chrono::NaiveDateTime,
}

#[model(primary_key)] marks the key column; #[model(skip_on_insert)] excludes DB-defaulted columns from the generated New{Struct} insertable type. The key can be any column type: find takes the key field's own type (User::find(&db, 1), Tag::find(&db, "rust")), so passing the wrong type fails to compile. Leave skip_on_insert off a non-autoincrement key (a slug, a UUID) and create takes it from New{Struct}. Migrations are plain SQL, two files per migration ({timestamp}_{name}.up.sql / .down.sql), run via apricarax migrate.

Model::query() returns a fluent query builder. There's one method per operator (where_eq, where_ne, where_gt/_gte/_lt/_lte, where_like, where_in, where_null/where_not_null), so a mistyped operator fails to compile. Every value is bound as a parameter.

let page = Post::query()
    .where_gte("votes", 10)
    .where_in("status", ["draft", "live"])
    .order_by_desc("created_at")
    .paginate(&db, page_number, 20)   // Page { items, page, per_page, total }
    .await?;

let n = Post::query().where_null("deleted_at").count(&db).await?;

Page serializes to JSON as-is and offers last_page() and has_more() for templates (e.g. an htmx "load more" button). Also available: order_by, limit, offset, first, get.

Relations. Put belongs_to on a foreign-key field and has_many on the parent struct:

#[model(table = "posts", has_many(comments = Comment, foreign_key = "post_id"))]
pub struct Post { /* ... */ }

#[model(table = "comments")]
pub struct Comment {
    #[model(belongs_to = Post)]
    pub post_id: i64,
    /* ... */
}

let post = comment.post(&db).await?;                          // Option<Post>
let latest = post.comments().order_by_desc("created_at").get(&db).await?;

belongs_to generates an async method named after the field without its _id suffix. has_many returns a QueryBuilder rather than a loaded list, so you can still filter, order, count, or paginate; foreign_key defaults to {parent_snake_case}_id. There's no eager loading yet: loading comments for N posts is N queries.

Event sourcing

Opt in per model with #[derive(Aggregate)] when you want a full, replayable history instead of overwritten rows.

#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(tag = "type")]
pub enum PostEvent {
    Created { title: String, body: String },
    Edited { title: String, body: String },
}

#[derive(Debug, Default, Aggregate)]
#[aggregate(stream = "post", event = "PostEvent")]
pub struct Post {
    pub title: String,
    pub body: String,
}

impl Post {
    fn apply_event(&mut self, event: &PostEvent) {
        match event {
            PostEvent::Created { title, body } | PostEvent::Edited { title, body } => {
                self.title = title.clone();
                self.body = body.clone();
            }
        }
    }
}

Events append to a shared events table with a UNIQUE(aggregate_type, aggregate_id, version) constraint — the optimistic-concurrency check. A racing writer gets OrmError::Concurrency (a 409), not a silent overwrite.

// Append + read
event_store::append::<Post, _>(&db, id, expected_version, &event).await?;
let post: Post = event_store::load_aggregate(&db, id).await?;

// Append + update a read-model table in the same transaction
projection::append_and_project::<Post, _>(&db, id, expected_version, &event, &projector).await?;

Projections implement Projector<A> and run synchronously, inside the same transaction as the append — no async worker, no eventual-consistency window.

Snapshots

Replaying a stream from event #1 gets slower as it grows, so load_aggregate transparently uses a cached snapshot when one exists and only replays the tail since it. Snapshots are on by default — every 100 events — and live in their own snapshots table, auto-generated (once, idempotently) by make:aggregate. They're never rows in events: a snapshot isn't a real domain event, and mixing the two would collide with the UNIQUE(aggregate_type, aggregate_id, version) concurrency check and break the invariant that every row in events is foldable via apply. A snapshot is a disposable read-side cache, not a second source of truth — wiping the table just means the next read replays from scratch.

#[derive(Debug, Default, Serialize, Deserialize, Aggregate)]
#[aggregate(stream = "post", event = "PostEvent", snapshot_every = 50)]
pub struct Post { /* ... */ }

// snapshot_every = 0 opts an aggregate out of snapshotting entirely.

Snapshot writes happen just after the triggering append's transaction commits, in a transaction of their own — an accepted tradeoff, since a snapshot is disposable: if the process dies in between, the next read just replays a few extra events, never a correctness issue.

Views (htmx + Askama)

Wrap any Askama template in AskamaHtml and return it from a handler:

#[derive(Template)]
#[template(path = "posts/show.html")]
struct PostShowTemplate { title: String, body: String }

#[get("/posts/{id}")]
pub async fn show(/* ... */) -> Result<AskamaHtml<PostShowTemplate>, AppError> {
    /* ... */
}

Templates live in resources/views/. For htmx partial-swap responses, just return a smaller template from a different handler — the response body is the swap target's new contents:

<form hx-post="/posts/{{ id }}/comments" hx-target="#comments" hx-swap="beforeend">
    <textarea name="body"></textarea>
    <button type="submit">Add comment</button>
</form>

No SPA, no API client, no build step — the handler that creates the comment just renders the one-comment partial and returns it.

Authentication

apricarax-auth adds session-based auth: Argon2 password hashing, a DB-backed sessions table, and an AuthUser extractor that 401s automatically when a handler requires it.

impl apricarax_auth::Authenticatable for User {
    fn user_id(&self) -> i64 { self.id }
}

#[get("/me")]
pub async fn me(auth: AuthUser, State(db): State<Db>) -> Result<Json<User>, AppError> {
    Ok(Json(User::find(&db, auth.user_id).await?.ok_or(AppError::Unauthorized)?))
}

Set the cookie with session::cookie(session.token): it's HttpOnly, SameSite=Lax, and Secure unless APP_ENV=local (the default in a new app's .env.example). Expired sessions are pruned on each login.

CSRF protection is on by default. Every app's middleware stack rejects cross-origin POST/PUT/PATCH/DELETE requests with a 403, using the Sec-Fetch-Site header browsers already send (falling back to Origin vs. Host). There are no tokens to thread through forms; htmx requests are same-origin and just work. Non-browser clients (curl, server-to-server) send neither header and pass, since they carry no ambient cookie to abuse.

Background jobs

apricarax-queue adds a Laravel-style queue: dispatch() inserts a row into a jobs table, and apricarax queue:work runs a worker that polls, executes, and retries with backoff — a single SQLite-polling process, no Redis or external broker.

#[derive(Debug, Serialize, Deserialize, Job)]
#[job(queue = "default", retries = 3)]
pub struct SendCommentNotification {
    pub post_id: String,
    pub comment_id: i64,
}

impl SendCommentNotification {
    async fn handle_job(&self, db: &Db) -> Result<(), QueueError> {
        sqlx::query("INSERT INTO notifications (post_id, comment_id) VALUES (?, ?)")
            .bind(&self.post_id)
            .bind(self.comment_id)
            .execute(db)
            .await?;
        Ok(())
    }
}

#[derive(Job)] generates NAME (the struct's own name), QUEUE, and MAX_ATTEMPTS; the real work goes in a hand-written handle_job, the same split as #[derive(Aggregate)]'s apply_event.

apricarax_queue::dispatch(&db, &SendCommentNotification { post_id, comment_id }).await?;

Claiming a job is a single atomic UPDATE ... WHERE id = (SELECT ...) RETURNING, so two workers racing on the same row can't both claim it. A job that returns Err is retried with exponential backoff until it hits its MAX_ATTEMPTS, at which point it moves to failed_jobs instead of retrying forever. Since Rust has no runtime reflection to go from a job's stored name back to its type, an app registers every job it dispatches with a JobRegistry before running the worker:

let mut registry = JobRegistry::new();
registry.register::<SendCommentNotification>();
apricarax_queue::worker::run(&db, &registry, WorkOptions::default()).await?;

make:job lays down the jobs/failed_jobs migration once, idempotently — the same pattern make:aggregate uses for the snapshots table.

Authorization

apricarax-authz adds type-based authorization: a Policy<Actor, Resource> trait plus one authorize() call, no Gate registry and no string-keyed abilities. Laravel's Gate::allows('update', $post) resolves a policy class and an ability name at runtime by reflection — typo either one and PHP just returns false. Rust already knows the concrete types at every call site, so the "ability" can just be a zero-sized marker type, checked by the compiler instead of at request time:

pub struct UpdatePost;

impl Policy<User, Post> for UpdatePost {
    fn check(actor: &User, post: &Post) -> bool {
        post.author_id == actor.id
    }
}

// In a handler:
let user = User::find(&db, auth.user_id).await?.ok_or(AppError::Unauthorized)?;
authorize::<UpdatePost, _, _>(&user, &post)?;   // AppError::Forbidden (403) on false

Policy is generic over both the actor and the resource, so apricarax-authz never needs to know either type's shape — no framework-level role/permission system, no registry to keep in sync. Role-based rules are just more fields on your own Actor type:

impl Policy<User, Post> for UpdatePost {
    fn check(actor: &User, post: &Post) -> bool {
        actor.role == Role::Admin || post.author_id == actor.id
    }
}

Get the resource or action type wrong at a call site and it's a compile error, not a silently denied — or silently allowed — request.

CLI reference

apricarax new <name> [--local]Scaffold a new app
apricarax make:controller <Name>Generate a controller
apricarax make:model <Name> [--migration]Generate a model
apricarax make:aggregate <Name>Generate an event-sourced aggregate (also lays down the snapshots migration, once)
apricarax make:projection <Name> --aggregate <Agg>Generate a read-model projector
apricarax make:job <Name>Generate a queueable job (also lays down the jobs/failed_jobs migration, once)
apricarax make:policy <Name> --for <Resource> [--actor <Type>]Generate an authorization policy (actor defaults to User)
apricarax make:view <path>Generate an Askama view
apricarax make:migration <name> [--create <table>]Generate a migration pair
apricarax migrate / migrate:rollback / migrate:freshRun / undo migrations
apricarax serveRun the dev server
apricarax queue:work [--queue <name>] [--once]Run the queue worker

Ready to build something? Head to the quickstart.