Skip to main content

Crate actus

Crate actus 

Source
Expand description

§Actus

The pragmatic web framework for Rust: auditable controllers, persistent services, real HTTP — out of the box. Built directly on hyper and tokio; there is no separate server to run it on.

Actus gives you a clear two-tier structure — a top-level routing blueprint and self-contained controllers — while letting you mix REST, RPC-style actions, and legacy URL migrations in the same codebase. A reviewer can answer what endpoints exist, what they require, and who can call them by reading two macros, without grepping for attribute decorators across files — and the third answer is a declaration the build checks: each controller states the least-privileged caller it accepts, and a families block in app_routes! refuses to compile a controller that states nothing or claims a floor its prefix does not accept.

§Philosophy

Most Rust web frameworks are either unopinionated (you invent the structure) or rigidly opinionated (you bend to their paradigm). Actus picks a middle:

  • A clear hierarchy. The whole URL layout is declared once, in app_routes! { ... } — the entire backend is visible at a glance.
  • A clear unit of code. Each controller owns a URL prefix and declares its routes, access points, and parameters in one routes! { ... } block.
  • Pragmatism inside that structure. REST verbs (GET/POST/PUT/ DELETE), RPC-style action names (/charge, /refund), path parameters ({id}), and legacy URLs (login.php) all coexist in the same block.

§Design principles

  • Two kinds of cross-cutting concern get two shapes. HTTP-protocol concerns (CORS, body limits, compression) are named Server::with_X(...) methods with their lifecycle position built in; application concerns (logging, auth gates, request IDs, rate-limit policy) are Middleware. You never have to position CORS in a stack.
  • Auditability over uniformity. “What does this server do?” and “what endpoints exist?” are answerable from Server::new(...) and the two macros — without walking a chain of layers.
  • Explicit over magic. No DI container, no extractors reaching into thin air: the app_routes! deps block is constructor injection, and routes are declared, not discovered.
  • HTTP correctness out of the box. You shouldn’t need to know that compression goes outermost, or that the body cap gates the body parse — that is framework knowledge, not application knowledge.
  • Policy-agnostic. No roles, no Access enum, no built-in RBAC. Authorization lives in your policy layer, called from a controller’s prepare hook or a handler.

This crate is the façade you depend on. It re-exports the public API of the implementation crates (actus-server, actus-controller, actus-reply) and the two macros that declare your application’s URL surface. Add it with:

[dependencies]
actus = "1.0"
tokio = { version = "1", features = ["full"] }
serde_json = "1"

Optional features: compression (gzip/brotli responses), websocket (ws::upgrade), and openapi (OpenAPI 3.x generation).

§Quick start

Two macros declare everything: routes! (one controller’s API surface) and app_routes! (the whole application’s URL blueprint). A reviewer can see every endpoint by reading just those two places.

use actus::prelude::*;
use serde_json::json;

// A controller owns a URL prefix and declares its routes in one block.
struct Greeter;

#[controller]
impl Greeter {
    routes! {
        GET ""       => index(),
        GET "{name}" => greet(name: String),
    }

    pub async fn index(&self) -> Reply {
        reply!(json!({ "hello": "world" }))
    }

    pub async fn greet(&self, name: String) -> Reply {
        reply!(json!({ "hello": name }))
    }
}

// The application's URL blueprint, declared in one place. (The `deps`
// block — for injected services — is optional and omitted here.)
app_routes! {
    routes {
        "greet" => Greeter,
    }
}

// `init()` is generated by `app_routes!`; it builds the router.
#[tokio::main]
async fn main() -> actus::InitResult<()> {
    let router = init().await?;
    Server::new(router).run(3000).await?;
    Ok(())
}

§What’s in the box

  • Hyper-based HTTP serverServer::run(port) binds 127.0.0.1; Server::run_on(addr) binds anywhere (e.g. 0.0.0.0:port). Graceful shutdown on SIGTERM / SIGINT with a configurable drain deadline.
  • Two-macro routingapp_routes! (the app’s URL blueprint, with a deps block for injected services) and #[controller] + routes! (per-controller verbs, path patterns, typed query/body extraction, a prepare hook, and per-controller max_body_bytes / rate_limit).
  • Longest-prefix routing at arbitrary depth, with a trailing {...rest} catch-all and distinct 404 vs 405 (carrying Allow).
  • Typed extraction & state — query as a multimap, form-urlencoded bodies, and typed path/query/body params; prepare hooks stash typed values via params.insert::<T>(...) that handlers read back.
  • Repliesreply! for JSON, chunked streams, and Server-Sent Events; WebError for structured RFC 7807 application/problem+json.
  • HTTP-protocol featuresServer::with_cors, with_compression (gzip/brotli, compression feature), a per-request timeout, and three DoS guards (max connections, in-flight body budget, header-read timeout).
  • WebSocket (websocket feature) — ws::upgrade(...) from a handler.
  • OpenAPI 3.x (openapi feature) — openapi::generate(...) walks the route tree and emits a spec.
  • Middlewarebefore / after hooks via Server::with_middleware; ships a RequestLogger.
  • Route families#[controller(expects = "…")] declares a controller’s caller floor; Router::mounts() inventories every mount (absences included) for a boot-time coverage check; Server::router() lets a middleware gate on the declaration; and a families { … } block in app_routes! makes a missing or unaccepted declaration a compile error — see below.

§Route families at compile time

Coverage, not authorization: the label is opaque to Actus. With a families block, every controller mounted under a listed prefix must carry #[controller(expects = "…")], and — when the entry names accepted floors — the declared floor must be one of them. This compiles:

use actus::prelude::*;

struct Things;
#[controller(expects = "credential")]
impl Things {
    routes! { GET "" => list() }
    async fn list(&self) -> Reply { reply!() }
}

struct Health;
#[controller]                 // declares nothing — fine outside every family
impl Health {
    routes! { GET "" => ok() }
    async fn ok(&self) -> Reply { reply!() }
}

app_routes! {
    families { "api" => ["credential", "anonymous"] }
    routes {
        "api/things" => Things,
        "health"     => Health,
    }
}

A controller that declares nothing under a covered prefix does not compile — the error names the controller and says what to add:

use actus::prelude::*;

struct Things;
#[controller]                 // forgot `expects = …`
impl Things {
    routes! { GET "" => list() }
    async fn list(&self) -> Reply { reply!() }
}

app_routes! {
    families { "api" }
    routes { "api/things" => Things }
}

A floor the family does not accept fails the const membership check when init is compiled — i.e. whenever it is reachable from something that runs, which in an application it always is. ⚠️ “Compiled” means codegen: cargo check (and IDE diagnostics built on it) does not evaluate it; cargo build, cargo test and CI do. The presence check above is a type error and shows under check.

use actus::prelude::*;

struct Hooks;
#[controller(expects = "signature")]
impl Hooks {
    routes! { POST "" => receive() }
    async fn receive(&self) -> Reply { reply!() }
}

app_routes! {
    families { "api" => ["credential", "anonymous"] }   // "signature" is not accepted here
    routes { "api/hooks" => Hooks }
}

#[tokio::main]
async fn main() { let _ = init().await; }

Families nest, longest prefix winning — the same rule as routing — so an exception can be confined to the one subtree that earns it, without moving a URL. Here api/auth — the controller that issues sessions, whose callers by definition have none yet — may declare "session-entry", and nothing else under api/ may; this doctest runs init, so the const check is evaluated:

use actus::prelude::*;

struct Things;
#[controller(expects = "credential")]
impl Things {
    routes! { GET "" => list() }
    async fn list(&self) -> Reply { reply!() }
}

struct Login;
#[controller(expects = "session-entry")]
impl Login {
    routes! { POST "" => login() }
    async fn login(&self) -> Reply { reply!() }
}

app_routes! {
    families {
        "api"      => ["credential"],
        "api/auth" => ["session-entry"],   // the deeper entry wins for its subtree
    }
    routes {
        "api/things" => Things,
        "api/auth"   => Login,
    }
}

#[tokio::main]
async fn main() {
    let router = init().await.expect("init");
    assert_eq!(router.mounts().len(), 2);
    // A boot-time check must decide "which family?" by the same rule the
    // macro just applied; `routing::covering_family` IS that rule.
    let families = ["api", "api/auth"];
    assert_eq!(actus::routing::covering_family("api/things", families), Some("api"));
    assert_eq!(actus::routing::covering_family("api/auth", families), Some("api/auth"));
}

That nesting is the intended answer when a family needs an exception: name the reason as its own floor, confine it to the subtree that earns it, and leave the URLs alone — the top-level segment is what every client and intermediary keys on, and moving routes to make a family uniform spends shipped URLs on a property a declaration already provides. (The README’s “Route families” section spells this out.)

And a family that covers no mount is a compile error at its literal — a typo there would otherwise constrain nothing:

use actus::prelude::*;

struct Things;
#[controller(expects = "credential")]
impl Things {
    routes! { GET "" => list() }
    async fn list(&self) -> Reply { reply!() }
}

app_routes! {
    families { "apo" }        // typo: nothing is mounted under `apo/`
    routes { "api/things" => Things }
}

See the prelude for the common imports, and the repository for the full guide — philosophy, framework comparisons, and the examples/ directory with auth, typed bodies, CORS, compression, WebSockets, SSE, and middleware in working code.

Modules§

openapi
OpenAPI 3.x doc generation — openapi::generate, openapi::Options. Available with the openapi feature. OpenAPI 3.1 doc generation. Behind the openapi feature.
prelude
Common imports for Actus applications.
routing
Route-resolution helpers, exposed for tools and boot-time checks — notably routing::covering_family, the rule the families block of app_routes! uses to decide which family a mount belongs to, so a startup coverage check written against it cannot disagree with the compile-time one. Route resolution helpers — matching a request path + verb against a controller’s &[RouteDef] and extracting the path/query/body parameters. Used by the #[controller] macro’s generated dispatch; exposed for tools and tests that need to resolve routes directly.
ws
WebSocket support — ws::upgrade, ws::WebSocket, ws::Message. Available with the websocket feature. WebSocket support (RFC 6455). Behind the websocket feature.

Structs§

Finalizer
Converts a ReplyData into a concrete hyper HTTP response — setting status, headers, and body, and driving buffered, streaming, SSE, and connection-upgrade replies.
Mount
One mounted controller’s declarations — a row of Router::mounts, the per-mount inventory.
RateLimitClass
One controller’s declared rate-limit class, as returned by Router::rate_limit_classes: the controller’s mount path and the class label it declared via #[controller(rate_limit = "…")]. Used by a startup coverage check that asserts every declared class has a policy.
Router
The Actus router. Dispatches requests to controllers by longest-prefix match over the route tree at arbitrary depth.
RouterBuilder
A builder for constructing the router.
Server
The main Actus server.

Constants§

GIB
One gibibyte (1024 × 1024 × 1024 bytes).
KIB
One kibibyte (1024 bytes). For readable byte-size limits, e.g. #[controller(max_body_bytes = 4 * KIB)].
MIB
One mebibyte (1024 × 1024 bytes), e.g. Server::with_max_body_bytes(2 * MIB).

Type Aliases§

InitError
Error type used by app_routes!’s generated init() for startup-time failures (DB connection refused, env var missing, migrations failing, etc.). Aliased to anyhow::Error so any error implementing std::error::Error + Send + Sync + 'static converts via ?, and the generated init() slots cleanly into an anyhow::Result<()> main.
InitResult
Result<T, actus::InitError>. The macro-generated init() returns this.