Controllers
A handler is an ordinary async fn. A controller is a struct with an
impl block full of them, which is a convention rather than a requirement —
Route::get("/", index) takes a free function just as happily.
pub struct HomeController;
#[arcature::controller]
impl HomeController {
pub async fn index() -> String {
"hello".to_string()
}
pub async fn show(id: u64) -> String {
format!("show {id}")
}
}
The impl block is emitted unchanged. HomeController::index() is still a
plain async function a test can call directly, and still a genuine Axum
handler.
What the macro adds
#[controller] additionally emits impl ControllerMetadata, whose METHODS
const carries one entry per handler: the method name, its parameter names,
and the page it renders.
use arcature::ControllerMetadata;
let methods = <HomeController as ControllerMetadata>::METHODS;
assert_eq!(methods[0].name, "index");
assert_eq!(methods[1].params, ["id"]);
METHODS is a &'static const. Nothing registers itself at startup and
nothing is looked up by TypeId; see
Decisions.
The contract the macro enforces
Every method in the block must be pub, async, have a return type, and
take no self receiver — an Axum handler is a free function. Breaking any of
those produces error[ARC-M004] at the method, not a page of trait-bound
noise.
The page edge
A handler that returns Page<T> has its page identity read off the return
type:
#[arcature::page("Dashboard")]
pub struct DashboardPage {
pub title: String,
}
#[arcature::controller]
impl DashboardController {
pub async fn index() -> arcature::Page<DashboardPage> {
arcature::dx::page(DashboardPage {
title: "Dashboard".to_string(),
})
}
#[page("Reports")]
pub async fn reports() -> String {
"reports".to_string()
}
}
methods[0].page is Some("Dashboard"), derived from the signature and
never from the body. The derivation compiles to <T>::PAGE_CONTRACT.name(),
a const that exists only for #[page] types — so a handler that tries to
return a non-page type as a page fails to compile. That is the Client
Exposure Firewall applied to the return type; Inertia covers
the rest of it.
Any other return shape (Response, Json<T>, String, impl IntoResponse) yields page: None. A handler that renders a page without
returning Page<T> declares the identity with an explicit #[page("Name")]
helper attribute, as reports does above.
Extractors
Handler arguments are Axum extractors, unchanged, because the router is Axum’s:
use arcature::axum::extract::{Path, Query, State};
pub async fn show(
State(state): State<AppState>,
Path(id): Path<i64>,
) -> Result<Response> {
Ok(json(&id))
}
Arcature adds its own: Auth and Current for the signed-in user,
Session, Flash, CsrfToken, Validated<T> and its typed variants, and
RequestCache. Each is documented in the chapter that owns it.
Responses
Four builders cover the common shapes.
| Call | Produces |
|---|---|
text(StatusCode::OK, "hello") | a text/plain response |
json(&value) | an application/json response, status 200 |
no_content() | 204 |
redirect().to("/dashboard") | 303, or 308 after .permanent() |
json takes one argument. It does not take a status; build the response
directly if you need a different one.
redirect() takes no arguments — it returns a builder. .to(path),
.back(), .permanent(). Unlike the other three it returns a
RedirectResponse, not a Response, so a handler declared
-> Result<Response> finishes with .into_response():
Ok(redirect().to("/dashboard").into_response())
Redirect targets are validated against open redirects: an absolute URL to another host is rejected rather than followed.
redirect().route("links.show", 42) resolves the name against the
application’s route table, and redirect().with("status", "saved") writes
flash data through the session. Neither can be finished by into_response,
which sees no request and so has neither the table nor the session: the
builder rides along in the response extensions and RedirectMapper – stage
20 of the pipeline, installed by default – takes it out and completes it.
.back() works the same way, reading Referer through the same open-redirect
validation.
The one thing to know is what happens without that layer. An application that
assembles its own pipeline instead of using the builder, and does not install
RedirectMapper, gets the fallback response unchanged: a literal path still
redirects, a named route answers 400, and flash data is dropped.
Errors
Handlers return Result<Response> — Arcature’s Result, whose error type
converts into an HTTP response. bad_request, forbidden and not_found
build the common ones; Problem builds an RFC 9457 body. The pipeline’s
error-mapping stage gives a body to errors that were returned bodiless, and
in release builds it redacts 5xx detail rather than leaking it.
Grouping controllers into a module
module! names the controllers, services, jobs and listeners that belong
together and aggregates their metadata into one ModuleDescriptor:
arcature::module! {
pub Dashboard {
controllers: [DashboardController],
}
}
let descriptor = dashboard_module();
assert_eq!(descriptor.controllers, ["DashboardController"]);
The descriptor is built from the same &'static consts the macros emit, so
the module is a description of wiring rather than a container that resolves
things at runtime.
arc make:module dashboard writes this block, a controller, a service and a
routes table into one directory and registers the lot. See
Your first module.