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) areMiddleware. 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!depsblock 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
Accessenum, no built-in RBAC. Authorization lives in your policy layer, called from a controller’spreparehook 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 server —
Server::run(port)binds127.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 routing —
app_routes!(the app’s URL blueprint, with adepsblock for injected services) and#[controller]+routes!(per-controller verbs, path patterns, typed query/body extraction, apreparehook, and per-controllermax_body_bytes/rate_limit). - Longest-prefix routing at arbitrary depth, with a trailing
{...rest}catch-all and distinct404vs405(carryingAllow). - Typed extraction & state — query as a multimap, form-urlencoded
bodies, and typed path/query/body params;
preparehooks stash typed values viaparams.insert::<T>(...)that handlers read back. - Replies —
reply!for JSON, chunked streams, and Server-Sent Events;WebErrorfor structured RFC 7807application/problem+json. - HTTP-protocol features —
Server::with_cors,with_compression(gzip/brotli,compressionfeature), a per-request timeout, and three DoS guards (max connections, in-flight body budget, header-read timeout). - WebSocket (
websocketfeature) —ws::upgrade(...)from a handler. - OpenAPI 3.x (
openapifeature) —openapi::generate(...)walks the route tree and emits a spec. - Middleware —
before/afterhooks viaServer::with_middleware; ships aRequestLogger. - 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 afamilies { … }block inapp_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 theopenapifeature. OpenAPI 3.1 doc generation. Behind theopenapifeature. - prelude
- Common imports for Actus applications.
- routing
- Route-resolution helpers, exposed for tools and boot-time checks —
notably
routing::covering_family, the rule thefamiliesblock ofapp_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 thewebsocketfeature. WebSocket support (RFC 6455). Behind thewebsocketfeature.
Structs§
- Finalizer
- Converts a
ReplyDatainto a concretehyperHTTP 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. - Rate
Limit Class - One controller’s declared rate-limit class, as returned by
Router::rate_limit_classes: the controller’smountpath and theclasslabel 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.
- Router
Builder - 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§
- Init
Error - Error type used by
app_routes!’s generatedinit()for startup-time failures (DB connection refused, env var missing, migrations failing, etc.). Aliased toanyhow::Errorso any error implementingstd::error::Error + Send + Sync + 'staticconverts via?, and the generatedinit()slots cleanly into ananyhow::Result<()>main. - Init
Result Result<T, actus::InitError>. The macro-generatedinit()returns this.