proute discovers Rust page modules and generates route helper modules.
It follows the Elm Land and Gleam proute idea that the file path is the route,
then adds a small server-side convention for HTTP mutation endpoints.
The public Rust API in src/lib.rs owns mount configuration, route discovery,
typed path extraction, friendly IDs, route-parameter conversion, and generated
module output. Applications own their page modules, handler workflows, route
authorization, and Axum state.
The route-source decision
records the boundary and conventions. The implementation is kept in one
src/lib.rs module because discovery, validation, and emission form one
workflow.
A mount can declare a small allowlist of socket operations next to its route configuration. Proute generates the socket route, a stable path constant and typed dispatch. It checks duplicate identities and that each declared handler exists in the stated source file. Rust compilation checks the request and response types against the handler. A declaration does not grant access to a page handler by name or URL.
Mount::new("public", "src/pages/public", "/", "crate::pages::public")
.with_router_state_type("crate::app::AppState")
.with_operation_context_type("crate::pages::public::shared::operation_socket::OperationContext")
.with_operation_socket("/operations/live", "crate::pages::public::shared::operation_socket::index")
.with_operation(proute::Operation::new(
"publicRouteLoad",
"crate::islands::contracts::public_app::PublicRouteLoadRequest",
"crate::islands::contracts::public_app::PublicDocument",
"crate::pages::public::shared::operation_socket::route_load",
"shared/operation_socket.rs",
));The application owns the socket handler, authentication, per-message authority checks, request and response structs, and operation code. Pass only data that the handler is authorized to use. Generate the browser contract from the same mount declaration and verify both directions with serialized fixtures. The optional Hypertea socket transport handles client correlation and cleanup.
index.rs -> GET /
orders/index.rs -> GET /orders
orders/new.rs -> GET /orders/new
orders/create.rs -> POST /orders
orders/order_id_/index.rs -> GET /orders/{order_id}
orders/order_id_/edit.rs -> GET /orders/{order_id}/edit
orders/order_id_/update.rs -> POST /orders/{order_id}
orders/order_id_/delete.rs -> POST /orders/{order_id}/delete
docs/all_.rs -> GET /docs/{*all}
Rules:
- File paths are routes.
- Trailing underscores mark dynamic path params. The segment name before the underscore becomes the param name.
- Dynamic segment names do not imply types.
id_,slug_,product_type_, andline_item_id_are names only. - GET is the default.
create.rs,update.rs, anddelete.rsare reserved mutation endpoints.index.rsowns the current directory path. At the mount root, it owns/.not_found_.rsis optional. When present, it owns the mount 404 route.all_.rsowns a terminal catch-all segment namedall.mod.rsand everyshared/directory are ignored.show.rsis rejected. Useorders/order_id_/index.rs.- A route file cannot also be a namespace parent. If
orders/exists, useorders/index.rsinstead oforders.rs. create,update, anddeletecannot be used as intermediate path segments. They are action files only.- Two dynamic child segments at the same directory level are rejected because both would match the same path shape. Static siblings are checked first and may live beside one dynamic fallback.
When a URL needs punctuation that is not valid in a Rust module name, configure that static segment on the mount. Module and helper names keep the Rust segment:
Mount::new("admin", "src/pages/admin", "/admin", "crate::pages::admin")
.with_static_segment_path("accounting_codes", "accounting-codes");The generated app code lives under proute:
src/generated/proute/mod.rs
src/generated/proute/public.rs
src/generated/proute/admin.rs
That keeps imports focused on the app concept:
crate::generated::proute::public
crate::generated::proute::adminuse std::path::Path;
use proute::{Mount, write_mount_files};
fn main() {
write_mount_files(
Path::new("src/generated"),
[
Mount::new("public", "src/pages/public", "/", "crate::pages::public")
.with_language_param("lang"),
Mount::new("admin", "src/pages/admin", "/admin", "crate::pages::admin")
.with_language_param("lang"),
],
)
.expect("generate routes");
}Canonical paths are prefix-free. When a mount has a language param, generated helpers also expose language-prefixed paths and localized helpers that omit the prefix for the primary language.
Generated helper names preserve dynamic trailing underscores:
routes::public::orders_order_id_(123)
// /orders/123
routes::public::orders_order_id__line_items_line_item_id_(123, 456)
// /orders/123/line_items/456Untyped helpers accept impl std::fmt::Display as a URL-generation
convenience. That does not give id_ or any other segment a semantic type; it
only serializes a value into a path segment before percent-encoding it.
With a language param, proute also generates:
routes::public::prefixed_orders_order_id_("fr", 123)
// /fr/orders/123
routes::public::localized_orders_order_id_("en", "en", 123)
// /orders/123
routes::public::localized_orders_order_id_("fr", "en", 123)
// /fr/orders/123Parameterized page modules may define a page-local RouteParams contract:
#[derive(proute::serde::Deserialize)]
pub(crate) struct RouteParams {
pub(crate) order_id: i64,
}
pub(crate) async fn index(
proute::Path(params): proute::Path<RouteParams>,
) -> impl axum::response::IntoResponse {
// params.order_id is already typed here.
}When RouteParams exists, proute validates that its fields exactly match the
dynamic path params for the route. The struct and fields must be pub(crate)
or pub so generated helpers can use the same contract. Generated URL helpers
take one typed argument per route param:
routes::public::orders_order_id_(123)proute::Path<T> delegates to Axum's typed path deserializer and turns any path
deserialization failure into 404 Not Found. A bad typed route param means the
URL does not satisfy the route contract, so handlers do not need local parsing
branches for route shape errors.
Typed helpers serialize contract fields through proute::IntoParam<T>, where
T is the field type from RouteParams. This keeps ids typed while allowing
ergonomic string params:
routes::admin::products_product_type_("leagues")Common primitive and string types are supported directly. App-specific route
param types can implement ToParam when their URL form differs from their
ordinary display form.
proute::FriendlyId<T> is a Rails-style to_param helper for routes that want
SEO text while parsing only the leading typed id:
#[derive(proute::serde::Deserialize)]
pub(crate) struct RouteParams {
pub(crate) order_id: proute::FriendlyId<i64>,
}
routes::public::orders_order_id_(proute::FriendlyId::new(
123,
"Summer Curling Registration",
))
// /orders/123-summer-curling-registrationOn incoming requests, FriendlyId<i64> accepts /orders/123 and
/orders/123-any-slug-text. It parses only the leading 123; bad leading ids
fail extraction and become the standard proute 404 through proute::Path<T>.
Applications can attach an Axum body limit while Proute discovers route source files:
fn upload_limit(source: &Path) -> Option<usize> {
source.ends_with("upload/create.rs").then_some(64 * 1024 * 1024)
}
let mount = Mount::new("public", "src/pages", "/", "crate::pages")
.with_body_limit_for_source(upload_limit);Proute layers the limit on both canonical and language-prefixed forms of the route. Keep the callback narrow and application-owned. Ordinary routes should retain Axum's small default; only handlers that actually accept uploads should receive upload-sized limits.
Friendly slug suffixes default to 60 characters. Generation lowercases ASCII
text, turns separator runs into single hyphens, trims edge hyphens, and cuts at
a word boundary when practical. Empty slugs fall back to the bare id. Routes
that need a different suffix length can call .with_slug_limit(max_chars) when
building the helper params.
Each routable module exposes a handler function by default:
pub(crate) async fn handler(...) -> impl IntoResponseproute validates this during discovery. The handler may be pub(crate) or
pub, and may be async or sync.
The mount can override that name:
Mount::new("public", "src/pages/public", "/", "crate::pages::public")
.with_handler_name("route")Apps that prefer action-named handlers can opt in to deriving the handler name from the route file:
Mount::new("admin", "src/pages/admin", "/admin", "crate::pages::admin")
.with_route_action_handler_names()In that mode, orders/index.rs expects index, orders/create.rs expects
create, orders/order_id_/edit.rs expects edit, and not_found_.rs
expects not_found.
When a mount has a router state type, the generated module includes Axum router functions:
Mount::new("admin", "src/pages/admin", "/admin", "crate::pages::admin")
.with_router_state_type("crate::app::AppState")This emits:
pub fn routes() -> axum::Router<crate::app::AppState>
pub fn prefixed_routes() -> axum::Router<crate::app::AppState>prefixed_routes is generated only for mounts with a language param.
Generated path helpers percent-encode dynamic params, so a value like a/b
is generated as /orders/a%2Fb.
A mount can discover another source owner with a logical route prefix and delegate its runtime routing to that owner's generated module:
Mount::new("admin", "src/pages/admin", "/admin", "crate::pages::admin")
.with_route_action_handler_names()
.with_router_state_type("crate::app::AppState")
.with_ignored_path_prefixes(["lib.rs", "generated"])
.with_feature_source("features/resources/src", "resources", "settings/resources", "resources")The feature's reorder/update.rs retains the route identity
settings/resources/reorder/update and URL /admin/settings/resources/reorder.
Its Rust handler path remains resources::reorder::update::update. Discovery
checks collisions across all sources before generation. The application module
keeps the combined facts and helpers, while its router merges the feature's
routes and, for localized mounts, prefixed_routes functions.
Use generate_router_module for runtime-only owner output. A feature can generate
generic router functions with with_generic_router_state("S", ["support::State"]).
The generated bounds require S: Clone + Send + Sync + 'static and
support::State: axum::extract::FromRef<S>, so the feature need not import the
application's state type. with_route_prefix applies a logical prefix to an
individual mount without changing physical module paths. Source-owner prefixes
participate in paths, names and helpers.
Applications own staging, multi-owner publication, freshness and middleware composition. Generating a router delegate does not validate a separate inventory; use the already validated combined discovery result to select that owner's routes.
The sports monorepo is Proute's editable source of truth. This public repository receives one-way exports of its source and relevant history. Development happens in the monorepo; the public export isn't maintained separately.