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

Routing

A route is a method, a path, a handler, and optionally a name. Routes is a collection of them, and it is what Application::routes takes.

use arcature::prelude::*;

pub fn routes() -> Routes {
    Routes::new([
        Route::get("/", home).name("home"),
        Route::get("/links", index).name("links.index"),
        Route::post("/links", store).name("links.store"),
    ])
}

Constructors exist for get, post, put, patch, delete, head and options. Anything else falls through to any.

Paths and parameters

Paths are Axum paths, because the router is Axum’s. /links/{id} captures a segment; the handler takes it with Path:

use arcature::axum::extract::Path;

async fn show(Path(id): Path<i64>) -> Result<Response> {
    Ok(text(StatusCode::OK, format!("link {id}")))
}

Names and URL generation

A named route can be turned back into a URL. Parameters are filled in declaration order:

let url = routes.url_for("links.show", &["42"])?;   // "/links/42"

url_for returns Err(Error::NotFound(..)) for a name that was never declared, so a typo is a runtime error rather than a silently broken link. Routes::named() iterates every name and its path template.

Groups

RouteGroup shares a path prefix and, optionally, middleware:

Routes::new([
    RouteGroup::new("/admin", [
        Route::get("/panel", panel).name("admin.panel"),
        Route::get("/users", users).name("admin.users"),
    ])
    .middleware(RequireAuth),
])

The prefix is joined onto each route’s path when the group is flattened.

Where middleware can attach

Middleware attaches at three scopes, and the difference between them is not cosmetic:

ScopeCallReaches
One routeRoute::middlewarethat route only
One groupRouteGroup::middlewareeach route in the group, individually
A collectionRoutes::middlewareevery route present at the time of the call

Route owns a MethodRouter, not a folded Router. That is deliberate: a Router::layer fold applies to everything in the router, so per-route middleware written that way leaks onto sibling routes. Holding a MethodRouter per route makes the leak impossible to express.

Routes::middleware is the one that folds a whole router, and its scope rule is stated in the API: routes merged in afterwards are not covered. That is what lets a guarded collection and a public collection be merged without the guard spreading:

let guarded = Routes::new([...]).middleware(RequireAuth);
let public  = Routes::new([...]);
let all = guarded.merge(public);   // `public` is still public

Writing middleware

Middleware is a Clone type whose handle returns a boxed future. #[middleware] writes that plumbing for an ordinary async function:

use arcature::routing::{Request, Response};
use arcature::{Next, Result, middleware};

#[middleware]
pub async fn require_auth(request: Request, next: Next) -> Result<Response> {
    Ok(next.run(request).await)
}

The function is emitted unchanged, so it stays callable directly from a test. Alongside it the macro generates a unit struct named after the function in PascalCase — RequireAuth here — implementing Middleware. Override the name with #[middleware(RequireAdmin)].

Returning Err maps the framework error to a response instead of continuing. Not calling next.run short-circuits.

For middleware that is not a Middleware — a tower_http layer, say — Route::layer, RouteGroup::layer and Routes::layer take a raw tower::Layer.

The routes! macro

The builder API above is the whole runtime. routes! is a declarative front end over it that additionally emits &'static metadata and typed URL helpers. This block compiles today:

routes! {
    pub app {
        get "/" => home { name: home, page: "Home" }

        group "/auth" {
            get  "/login" => login { name: auth.login }
            post "/login" => store { name: auth.store }
        }

        group "/admin" {
            middleware: [RequireAuth];
            get "/panel" => panel { name: admin.panel }
        }
    }
}

It generates three things from pub app:

  • app_routes() -> Routes — the collection, built with the same builder API.
  • APP_ROUTES: &[RouteDescriptor] — a &'static const describing every route: method, path, handler name, declared page names. Nothing registers itself at startup; the array is the whole registry.
  • app_route — a module of URL functions mirroring the dotted names: app_route::home(), app_route::auth::login(), app_route::admin::panel(). A path parameter becomes a function argument, so app_route::links::show(42) is "/links/42" and a missing parameter is a compile error rather than a broken URL.

Declare state with a state: line:

routes! {
    pub api {
        state: AppState;
        get "/health" => health { name: api.health }
    }
}

And expand a controller into its conventional actions with resource:

routes! {
    pub web {
        resource "/links" => LinksController {
            name: links,
            only: [index, show, destroy]
        }
    }
}

That produces links.index (GET /links), links.show (GET /links/{id}) and links.destroy (DELETE /links/{id}), each with its helper.

Falling through to Axum

Routes::into_router hands back the axum::Router, and Routes::router borrows it. Route::layer takes a tower::Layer. The escape hatch is not hidden and using it is not a defeat; the framework’s opinions are meant to run out somewhere visible.

Where routing sits in the pipeline

The router is stage 19 of 20. Everything a request passes through before it — security headers, CORS, request id, panic catching, error mapping, body limit, timeout, sessions, CSRF, Inertia, page contracts — is fixed and documented in Deployment and in src/application/pipeline.rs. User .layer() calls go in at stage 18, just outside the router; static files are stage 20, reached only when the router does not match.