use std::sync::Arc;
use crate::app::ScopedGroup;
use crate::config::AutumnConfig;
use crate::error_pages::{self, SharedRenderer};
use crate::extract::State;
use crate::middleware::RequestIdLayer;
use crate::middleware::dev;
use crate::middleware::exception_filter::{ExceptionFilter, ExceptionFilterLayer};
use crate::route::Route;
use crate::state::AppState;
use axum::middleware::Next;
use axum::response::IntoResponse;
use http::StatusCode;
use thiserror::Error;
pub const DEFAULT_FAVICON_PATH: &str = "/favicon.ico";
#[derive(Debug, Error, PartialEq, Eq)]
pub enum RouterBuildError {
#[error("invalid session backend configuration: {0}")]
InvalidSessionBackend(#[from] crate::session::SessionBackendConfigError),
#[error("framework route overlap at {path}: {existing} conflicts with {incoming}")]
FrameworkRouteOverlap {
path: String,
existing: &'static str,
incoming: &'static str,
},
#[cfg(feature = "openapi")]
#[error("invalid OpenAPI {field} path: {value:?} (must start with '/' and be non-empty)")]
InvalidOpenApiPath {
field: &'static str,
value: String,
},
#[cfg(feature = "openapi")]
#[error(
"openapi_json_path and swagger_ui_path both resolve to {path:?}; they must differ or `swagger_ui_path` must be `None`"
)]
DuplicateOpenApiPath {
path: String,
},
#[cfg(feature = "openapi")]
#[error(
"OpenAPI {field} path {path:?} collides with an existing GET route; choose a different `OpenApiConfig::{field}`"
)]
OpenApiPathCollision {
field: &'static str,
path: String,
},
}
#[allow(dead_code)]
pub fn build_router(
route_list: Vec<Route>,
config: &AutumnConfig,
state: AppState,
) -> axum::Router {
try_build_router(route_list, config, state)
.unwrap_or_else(|error| panic!("invalid router configuration: {error}"))
}
pub struct RouterContext {
pub exception_filters: Vec<Arc<dyn ExceptionFilter>>,
pub scoped_groups: Vec<ScopedGroup>,
pub merge_routers: Vec<axum::Router<AppState>>,
pub nest_routers: Vec<(String, axum::Router<AppState>)>,
pub custom_layers: Vec<crate::app::CustomLayerRegistration>,
pub error_page_renderer: Option<SharedRenderer>,
pub session_store: Option<Arc<dyn crate::session::BoxedSessionStore>>,
#[cfg(feature = "openapi")]
pub openapi: Option<crate::openapi::OpenApiConfig>,
}
pub fn try_build_router(
route_list: Vec<Route>,
config: &AutumnConfig,
state: AppState,
) -> Result<axum::Router, RouterBuildError> {
let startup_barrier_state = state.clone();
let router = try_build_router_inner(
route_list,
config,
state,
RouterContext {
exception_filters: Vec::new(),
scoped_groups: Vec::new(),
merge_routers: Vec::new(),
nest_routers: Vec::new(),
custom_layers: Vec::new(),
error_page_renderer: None,
session_store: None,
#[cfg(feature = "openapi")]
openapi: None,
},
)?;
Ok(apply_startup_barrier(
router,
config,
&startup_barrier_state,
))
}
#[allow(dead_code)]
pub fn build_router_merged(
route_list: Vec<Route>,
config: &AutumnConfig,
state: AppState,
merge_routers: Vec<axum::Router<AppState>>,
nest_routers: Vec<(String, axum::Router<AppState>)>,
) -> axum::Router {
try_build_router_merged(route_list, config, state, merge_routers, nest_routers)
.unwrap_or_else(|error| panic!("invalid router configuration: {error}"))
}
#[allow(dead_code)]
pub fn try_build_router_merged(
route_list: Vec<Route>,
config: &AutumnConfig,
state: AppState,
merge_routers: Vec<axum::Router<AppState>>,
nest_routers: Vec<(String, axum::Router<AppState>)>,
) -> Result<axum::Router, RouterBuildError> {
let startup_barrier_state = state.clone();
let router = try_build_router_inner(
route_list,
config,
state,
RouterContext {
exception_filters: Vec::new(),
scoped_groups: Vec::new(),
merge_routers,
nest_routers,
custom_layers: Vec::new(),
error_page_renderer: None,
session_store: None,
#[cfg(feature = "openapi")]
openapi: None,
},
)?;
Ok(apply_startup_barrier(
router,
config,
&startup_barrier_state,
))
}
pub fn try_build_router_inner(
route_list: Vec<Route>,
config: &AutumnConfig,
state: AppState,
ctx: RouterContext,
) -> Result<axum::Router, RouterBuildError> {
#[cfg(feature = "openapi")]
reject_openapi_path_collisions(
ctx.openapi.as_ref(),
&route_list,
&ctx.scoped_groups,
&ctx.merge_routers,
&ctx.nest_routers,
config,
)?;
#[cfg(feature = "openapi")]
let openapi_router =
build_openapi_router(&route_list, &ctx.scoped_groups, ctx.openapi.as_ref())?;
let mut router = group_and_mount_routes(route_list);
let dev_reload_enabled = dev::is_enabled_with_env(&crate::config::OsEnv);
router = mount_framework_routes(router, dev_reload_enabled);
let (mounted_probe_paths, router_with_probes) = mount_probe_endpoints(router, config);
router = router_with_probes;
router = mount_actuator_endpoints(router, config, &mounted_probe_paths)?;
#[cfg(feature = "openapi")]
if let Some(openapi_router) = openapi_router {
router = router.merge(openapi_router);
}
let env = crate::config::OsEnv;
let static_dir = crate::app::project_dir("static", &env);
router = router.nest_service("/static", tower_http::services::ServeDir::new(&static_dir));
router = mount_scoped_groups(router, ctx.scoped_groups);
router = mount_raw_routers(router, ctx.merge_routers, ctx.nest_routers);
router = apply_middleware(
router,
config,
&state,
ctx.exception_filters,
ctx.custom_layers,
ctx.error_page_renderer,
ctx.session_store,
)?;
if dev_reload_enabled {
router = router
.layer(axum::middleware::from_fn(dev::disable_static_cache))
.layer(axum::middleware::from_fn(dev::inject_live_reload));
}
Ok(router.with_state(state))
}
#[cfg(feature = "openapi")]
fn extract_path_params(path: &str) -> Vec<String> {
let mut out = Vec::new();
let bytes = path.as_bytes();
let mut i = 0;
while i < bytes.len() {
if bytes[i] == b'{' {
if let Some(end_rel) = bytes[i + 1..].iter().position(|b| *b == b'}') {
let inner = &path[i + 1..i + 1 + end_rel];
let name = inner.split(':').next().unwrap_or(inner).trim();
if !name.is_empty() {
out.push(name.to_owned());
}
i += 1 + end_rel + 1;
continue;
}
}
i += 1;
}
out
}
#[cfg(feature = "openapi")]
fn build_openapi_router(
route_list: &[Route],
scoped_groups: &[ScopedGroup],
openapi_config: Option<&crate::openapi::OpenApiConfig>,
) -> Result<Option<axum::Router<AppState>>, RouterBuildError> {
let Some(config) = openapi_config else {
return Ok(None);
};
validate_route_path("openapi_json_path", &config.openapi_json_path)?;
if let Some(path) = &config.swagger_ui_path {
validate_route_path("swagger_ui_path", path)?;
if path == &config.openapi_json_path {
return Err(RouterBuildError::DuplicateOpenApiPath { path: path.clone() });
}
}
let mut docs: Vec<crate::openapi::ApiDoc> = Vec::new();
for route in route_list {
docs.push(route.api_doc.clone());
}
for group in scoped_groups {
let prefix_params = extract_path_params(&group.prefix);
for route in &group.routes {
let mut doc = route.api_doc.clone();
let full = join_nested_path(&group.prefix, route.api_doc.path);
doc.path = Box::leak(full.into_boxed_str());
if !prefix_params.is_empty() {
let mut merged: Vec<&'static str> = prefix_params
.iter()
.map(|p| &*Box::leak(p.clone().into_boxed_str()))
.collect();
for existing in route.api_doc.path_params {
if !merged.iter().any(|n| n == existing) {
merged.push(existing);
}
}
doc.path_params = Box::leak(merged.into_boxed_slice());
}
docs.push(doc);
}
}
let refs: Vec<&crate::openapi::ApiDoc> = docs.iter().collect();
let spec = crate::openapi::generate_spec(config, &refs);
let spec_json = serde_json::to_string_pretty(&spec)
.unwrap_or_else(|e| format!("{{\"error\": \"failed to serialize spec: {e}\"}}"));
let spec_body = Arc::new(spec_json);
let json_path = config.openapi_json_path.clone();
let swagger_path = config.swagger_ui_path.clone();
let title = config.title.clone();
let mut router = axum::Router::<AppState>::new().route(
&json_path,
axum::routing::get(move || {
let spec = spec_body.clone();
async move {
use axum::response::IntoResponse;
(
[(http::header::CONTENT_TYPE, "application/json")],
(*spec).clone(),
)
.into_response()
}
}),
);
if let Some(path) = swagger_path {
let [css_path, bundle_path, initializer_path] =
crate::openapi::swagger_ui_asset_paths(&path);
let html_body = Arc::new(crate::openapi::swagger_ui_html(
&title,
&css_path,
&bundle_path,
&initializer_path,
));
let initializer_body = Arc::new(crate::openapi::swagger_ui_initializer_js(&json_path));
router = router.route(
&path,
axum::routing::get(move || {
let html = html_body.clone();
async move {
use axum::response::IntoResponse;
(
[(http::header::CONTENT_TYPE, "text/html; charset=utf-8")],
(*html).clone(),
)
.into_response()
}
}),
);
router = router.route(
&css_path,
axum::routing::get(|| async move {
use axum::response::IntoResponse;
(
[(http::header::CONTENT_TYPE, "text/css; charset=utf-8")],
crate::openapi::SWAGGER_UI_CSS,
)
.into_response()
}),
);
router = router.route(
&bundle_path,
axum::routing::get(|| async move {
use axum::body::Bytes;
use axum::response::IntoResponse;
(
[(
http::header::CONTENT_TYPE,
"application/javascript; charset=utf-8",
)],
Bytes::from_static(crate::openapi::SWAGGER_UI_BUNDLE),
)
.into_response()
}),
);
router = router.route(
&initializer_path,
axum::routing::get(move || {
let js = initializer_body.clone();
async move {
use axum::response::IntoResponse;
(
[(
http::header::CONTENT_TYPE,
"application/javascript; charset=utf-8",
)],
(*js).clone(),
)
.into_response()
}
}),
);
}
tracing::debug!(
openapi_json = %json_path,
swagger_ui = ?config.swagger_ui_path,
swagger_ui_version = crate::openapi::SWAGGER_UI_VERSION,
"Mounted OpenAPI endpoints"
);
Ok(Some(router))
}
#[cfg(feature = "openapi")]
fn join_nested_path(prefix: &str, child: &str) -> String {
let prefix_trimmed = prefix.trim_end_matches('/');
if child == "/" || child.is_empty() {
if prefix_trimmed.is_empty() {
"/".to_owned()
} else {
prefix_trimmed.to_owned()
}
} else if child.starts_with('/') {
format!("{prefix_trimmed}{child}")
} else {
format!("{prefix_trimmed}/{child}")
}
}
#[cfg(feature = "openapi")]
fn validate_route_path(field: &'static str, value: &str) -> Result<(), RouterBuildError> {
let reject = |reason_fragment: &str| {
Err(RouterBuildError::InvalidOpenApiPath {
field,
value: format!("{value:?} {reason_fragment}"),
})
};
if value.is_empty() {
return reject("(must be non-empty)");
}
if !value.starts_with('/') {
return reject("(must start with '/')");
}
if value.contains("//") {
return reject("(must not contain '//')");
}
let mut depth: i32 = 0;
for ch in value.chars() {
match ch {
'{' => depth += 1,
'}' => {
depth -= 1;
if depth < 0 {
return reject("(unbalanced '}')");
}
}
'*' => return reject("(wildcard '*' is not allowed in an OpenAPI mount path)"),
_ => {}
}
}
if depth != 0 {
return reject("(unbalanced '{')");
}
if value.contains('{') {
return reject("(OpenAPI mount paths must be static; `{…}` captures are not allowed)");
}
Ok(())
}
#[cfg(feature = "openapi")]
fn reject_openapi_path_collisions(
openapi_config: Option<&crate::openapi::OpenApiConfig>,
route_list: &[Route],
scoped_groups: &[ScopedGroup],
merge_routers: &[axum::Router<AppState>],
nest_routers: &[(String, axum::Router<AppState>)],
config: &AutumnConfig,
) -> Result<(), RouterBuildError> {
let Some(openapi) = openapi_config else {
return Ok(());
};
let mut claimed: std::collections::HashSet<String> = std::collections::HashSet::new();
for route in route_list {
if route.method == http::Method::GET {
claimed.insert(route.path.to_owned());
}
}
for group in scoped_groups {
for route in &group.routes {
if route.method == http::Method::GET {
claimed.insert(join_nested_path(&group.prefix, route.path));
}
}
}
claimed.insert(config.health.path.clone());
claimed.insert(config.health.live_path.clone());
claimed.insert(config.health.ready_path.clone());
claimed.insert(config.health.startup_path.clone());
for path in
crate::actuator::actuator_endpoint_paths(&config.actuator.prefix, config.actuator.sensitive)
{
claimed.insert(path);
}
#[cfg(feature = "htmx")]
{
claimed.insert(crate::htmx::HTMX_JS_PATH.to_owned());
claimed.insert(crate::htmx::HTMX_CSRF_JS_PATH.to_owned());
}
if dev::is_enabled_with_env(&crate::config::OsEnv) {
claimed.insert(dev::LIVE_RELOAD_PATH.to_owned());
claimed.insert(dev::LIVE_RELOAD_SCRIPT_PATH.to_owned());
}
check_openapi_path_against(
"openapi_json_path",
&openapi.openapi_json_path,
&claimed,
nest_routers,
)?;
if let Some(path) = &openapi.swagger_ui_path {
check_openapi_path_against("swagger_ui_path", path, &claimed, nest_routers)?;
let mut claimed_with_openapi = claimed.clone();
claimed_with_openapi.insert(openapi.openapi_json_path.clone());
for asset_path in crate::openapi::swagger_ui_asset_paths(path) {
check_openapi_path_against(
"swagger_ui_path",
&asset_path,
&claimed_with_openapi,
nest_routers,
)?;
}
}
if !merge_routers.is_empty() {
tracing::warn!(
openapi_json_path = %openapi.openapi_json_path,
swagger_ui_path = ?openapi.swagger_ui_path,
merged_routers = merge_routers.len(),
"OpenAPI mount collision check skipped for AppBuilder::merge routers: \
axum does not expose their route table, so overlapping GET handlers \
will still panic at startup. Choose OpenAPI paths that don't overlap \
with any merged router's handlers."
);
}
Ok(())
}
#[cfg(feature = "openapi")]
fn check_openapi_path_against(
field: &'static str,
path: &str,
claimed: &std::collections::HashSet<String>,
nest_routers: &[(String, axum::Router<AppState>)],
) -> Result<(), RouterBuildError> {
if claimed.contains(path) {
return Err(RouterBuildError::OpenApiPathCollision {
field,
path: path.to_owned(),
});
}
for (prefix, _) in nest_routers {
let prefix_slash = format!("{prefix}/");
if path == prefix || path.starts_with(&prefix_slash) {
return Err(RouterBuildError::OpenApiPathCollision {
field,
path: path.to_owned(),
});
}
}
Ok(())
}
fn group_and_mount_routes(route_list: Vec<Route>) -> axum::Router<AppState> {
let mut grouped: indexmap::IndexMap<&str, axum::routing::MethodRouter<AppState>> =
indexmap::IndexMap::new();
for route in &route_list {
tracing::debug!(
method = %route.method,
path = route.path,
name = route.name,
"Mounted route"
);
}
for route in route_list {
grouped
.entry(route.path)
.and_modify(|existing| {
*existing = std::mem::take(existing).merge(route.handler.clone());
})
.or_insert(route.handler);
}
let mut router = axum::Router::new();
for (path, method_router) in grouped {
router = router.route(path, method_router);
}
router
}
fn mount_framework_routes(
mut router: axum::Router<AppState>,
dev_reload_enabled: bool,
) -> axum::Router<AppState> {
#[cfg(feature = "htmx")]
{
router = router.route(crate::htmx::HTMX_JS_PATH, axum::routing::get(htmx_handler));
router = router.route(
crate::htmx::HTMX_CSRF_JS_PATH,
axum::routing::get(htmx_csrf_handler),
);
tracing::debug!(
method = "GET",
path = crate::htmx::HTMX_JS_PATH,
name = format!("htmx {}", crate::htmx::HTMX_VERSION),
"Mounted route"
);
tracing::debug!(
method = "GET",
path = crate::htmx::HTMX_CSRF_JS_PATH,
name = "htmx csrf helper",
"Mounted route"
);
}
if dev_reload_enabled {
router = router.route(
dev::LIVE_RELOAD_PATH,
axum::routing::get(dev::live_reload_state_handler),
);
router = router.route(
dev::LIVE_RELOAD_SCRIPT_PATH,
axum::routing::get(dev::live_reload_script_handler),
);
tracing::debug!(
state_path = dev::LIVE_RELOAD_PATH,
script_path = dev::LIVE_RELOAD_SCRIPT_PATH,
"Mounted dev live reload endpoints"
);
}
router
}
fn mount_probe_endpoints(
mut router: axum::Router<AppState>,
config: &AutumnConfig,
) -> (std::collections::HashSet<String>, axum::Router<AppState>) {
let mut mounted_probe_paths = std::collections::HashSet::new();
if mounted_probe_paths.insert(config.health.live_path.clone()) {
router = router.route(
&config.health.live_path,
axum::routing::get(crate::probe::live_handler::<AppState>),
);
}
if mounted_probe_paths.insert(config.health.ready_path.clone()) {
router = router.route(
&config.health.ready_path,
axum::routing::get(crate::probe::ready_handler::<AppState>),
);
}
if mounted_probe_paths.insert(config.health.startup_path.clone()) {
router = router.route(
&config.health.startup_path,
axum::routing::get(crate::probe::startup_handler::<AppState>),
);
}
if mounted_probe_paths.insert(config.health.path.clone()) {
router = router.route(
&config.health.path,
axum::routing::get(crate::health::handler),
);
}
tracing::debug!(
health = %config.health.path,
live = %config.health.live_path,
ready = %config.health.ready_path,
startup = %config.health.startup_path,
"Mounted probe endpoints"
);
(mounted_probe_paths, router)
}
fn mount_actuator_endpoints(
mut router: axum::Router<AppState>,
config: &AutumnConfig,
mounted_probe_paths: &std::collections::HashSet<String>,
) -> Result<axum::Router<AppState>, RouterBuildError> {
let actuator_sensitive = config.actuator.sensitive;
let actuator_paths =
crate::actuator::actuator_endpoint_paths(&config.actuator.prefix, actuator_sensitive);
if let Some(path) = actuator_paths
.iter()
.find(|path| mounted_probe_paths.contains(path.as_str()))
{
return Err(RouterBuildError::FrameworkRouteOverlap {
path: path.clone(),
existing: "probe endpoint",
incoming: "actuator endpoint",
});
}
router = router.merge(crate::actuator::actuator_router_with_prefix(
&config.actuator.prefix,
actuator_sensitive,
));
tracing::debug!(
sensitive = actuator_sensitive,
prefix = %config.actuator.prefix,
"Mounted actuator endpoints"
);
Ok(router)
}
fn mount_scoped_groups(
mut router: axum::Router<AppState>,
scoped_groups: Vec<ScopedGroup>,
) -> axum::Router<AppState> {
for group in scoped_groups {
let mut sub_router = axum::Router::new();
for route in group.routes {
tracing::debug!(
method = %route.method,
path = route.path,
name = route.name,
scope = %group.prefix,
"Mounted scoped route"
);
sub_router = sub_router.route(route.path, route.handler);
}
sub_router = (group.apply_layer)(sub_router);
router = router.nest(&group.prefix, sub_router);
}
router
}
fn mount_raw_routers(
mut router: axum::Router<AppState>,
merge_routers: Vec<axum::Router<AppState>>,
nest_routers: Vec<(String, axum::Router<AppState>)>,
) -> axum::Router<AppState> {
for raw_router in merge_routers {
tracing::debug!("Merged raw Axum router");
router = router.merge(raw_router);
}
for (prefix, raw_router) in nest_routers {
tracing::debug!(prefix = %prefix, "Nested raw Axum router");
router = router.nest(&prefix, raw_router);
}
router
}
fn apply_cors_middleware(
mut router: axum::Router<AppState>,
config: &AutumnConfig,
) -> axum::Router<AppState> {
if !config.cors.allowed_origins.is_empty() {
let cors = build_cors_layer(&config.cors);
tracing::info!(
origins = ?config.cors.allowed_origins,
credentials = config.cors.allow_credentials,
"CORS enabled"
);
router = router.layer(cors);
}
router
}
fn apply_csrf_middleware(
mut router: axum::Router<AppState>,
config: &AutumnConfig,
) -> axum::Router<AppState> {
if config.security.csrf.enabled {
let csrf_layer = crate::security::CsrfLayer::from_config(&config.security.csrf);
tracing::info!("CSRF protection enabled");
router = router.layer(csrf_layer);
}
router
}
fn apply_rate_limit_middleware(
mut router: axum::Router<AppState>,
config: &AutumnConfig,
) -> axum::Router<AppState> {
if config.security.rate_limit.enabled {
let layer = crate::security::RateLimitLayer::from_config(&config.security.rate_limit);
tracing::info!(
rps = config.security.rate_limit.requests_per_second,
burst = config.security.rate_limit.burst,
"Rate limiting enabled"
);
router = router.layer(layer);
}
router
}
fn apply_upload_middleware(
router: axum::Router<AppState>,
config: &AutumnConfig,
) -> axum::Router<AppState> {
let upload_config = config.security.upload.clone();
tracing::info!(
max_request_size_bytes = upload_config.max_request_size_bytes,
max_file_size_bytes = upload_config.max_file_size_bytes,
allowed_mime_types = ?upload_config.allowed_mime_types,
"Multipart upload safeguards enabled"
);
router.layer(axum::middleware::from_fn(
move |mut req: axum::extract::Request, next: axum::middleware::Next| {
let upload_config = upload_config.clone();
async move {
req.extensions_mut().insert(upload_config);
next.run(req).await
}
},
))
}
fn apply_middleware(
mut router: axum::Router<AppState>,
config: &AutumnConfig,
state: &AppState,
exception_filters: Vec<Arc<dyn ExceptionFilter>>,
custom_layers: Vec<crate::app::CustomLayerRegistration>,
error_page_renderer: Option<SharedRenderer>,
session_store: Option<Arc<dyn crate::session::BoxedSessionStore>>,
) -> Result<axum::Router<AppState>, RouterBuildError> {
router = apply_cors_middleware(router, config);
router = apply_csrf_middleware(router, config);
router = apply_rate_limit_middleware(router, config);
router = apply_upload_middleware(router, config);
let security_headers =
crate::security::SecurityHeadersLayer::from_config(&config.security.headers);
tracing::debug!("Security headers enabled");
router = router.fallback(crate::middleware::error_page_filter::fallback_404_handler);
let custom_layer_count = custom_layers.len();
for registered in custom_layers.into_iter().rev() {
router = (registered.apply)(router);
}
if custom_layer_count > 0 {
tracing::debug!(count = custom_layer_count, "Custom Tower layers applied");
}
let router = router.layer(RequestIdLayer).layer(security_headers);
let router = crate::session::apply_session_layer(
router,
&config.session,
config.profile.as_deref(),
session_store,
)?;
tracing::debug!(backend = ?config.session.backend, "Session management enabled");
let is_dev = config
.profile
.as_deref()
.map_or(cfg!(debug_assertions), |p| p == "dev");
let renderer = error_page_renderer.unwrap_or_else(error_pages::default_renderer);
let error_page_filter =
crate::middleware::error_page_filter::ErrorPageFilter { renderer, is_dev };
let mut all_filters: Vec<Arc<dyn ExceptionFilter>> = vec![Arc::new(error_page_filter)];
all_filters.extend(exception_filters);
let count = all_filters.len();
tracing::debug!(
count,
"Registered exception filters (including error page filter)"
);
let router = router
.layer(crate::middleware::error_page_filter::ErrorPageContextLayer)
.layer(ExceptionFilterLayer::new(all_filters))
.layer(crate::middleware::MetricsLayer::new(state.metrics.clone()));
Ok(router)
}
#[allow(dead_code)]
pub fn build_router_with_static(
route_list: Vec<Route>,
config: &AutumnConfig,
state: AppState,
dist_dir: Option<&std::path::Path>,
) -> axum::Router {
try_build_router_with_static(route_list, config, state, dist_dir)
.unwrap_or_else(|error| panic!("invalid router configuration: {error}"))
}
#[allow(dead_code)]
pub fn try_build_router_with_static(
route_list: Vec<Route>,
config: &AutumnConfig,
state: AppState,
dist_dir: Option<&std::path::Path>,
) -> Result<axum::Router, RouterBuildError> {
try_build_router_with_static_inner(
route_list,
config,
state,
dist_dir,
RouterContext {
exception_filters: Vec::new(),
scoped_groups: Vec::new(),
merge_routers: Vec::new(),
nest_routers: Vec::new(),
custom_layers: Vec::new(),
error_page_renderer: None,
session_store: None,
#[cfg(feature = "openapi")]
openapi: None,
},
)
}
pub fn try_build_router_with_static_inner(
route_list: Vec<Route>,
config: &AutumnConfig,
state: AppState,
dist_dir: Option<&std::path::Path>,
ctx: RouterContext,
) -> Result<axum::Router, RouterBuildError> {
let startup_barrier_state = state.clone();
let app_router = try_build_router_inner(route_list, config, state, ctx)?;
let Some(dist) = dist_dir else {
return Ok(apply_startup_barrier(
app_router,
config,
&startup_barrier_state,
));
};
let Some(layer) = crate::static_gen::StaticFileLayer::new(dist) else {
tracing::debug!(
dist = %dist.display(),
"No valid manifest.json in dist dir; skipping static file layer"
);
return Ok(apply_startup_barrier(
app_router,
config,
&startup_barrier_state,
));
};
let has_isr = layer
.manifest()
.routes
.values()
.any(|e| e.revalidate.is_some());
let layer = if has_isr {
layer.with_router(app_router.clone())
} else {
layer
};
for (route, entry) in &layer.manifest().routes {
tracing::debug!(
route = %route,
file = %entry.file,
revalidate = ?entry.revalidate,
"Static route"
);
}
let layer = Arc::new(layer);
let static_layer = layer;
let router = app_router.layer(axum::middleware::from_fn(
move |req: axum::extract::Request, next: axum::middleware::Next| {
let static_layer = static_layer.clone();
async move {
let is_get = req.method() == http::Method::GET;
let is_head = req.method() == http::Method::HEAD;
if is_get || is_head {
let path = req.uri().path();
let normalized = if path.len() > 1 && path.ends_with('/') {
&path[..path.len() - 1]
} else {
path
};
if let Some(file_path) = static_layer.resolve(normalized) {
if let Ok(contents) = tokio::fs::read(&file_path).await {
let body = if is_head {
axum::body::Body::empty()
} else {
axum::body::Body::from(contents)
};
return http::Response::builder()
.status(http::StatusCode::OK)
.header(http::header::CONTENT_TYPE, "text/html; charset=utf-8")
.body(body)
.expect("infallible response builder");
}
}
}
next.run(req).await
}
},
));
let router = router.layer(crate::security::SecurityHeadersLayer::from_config(
&config.security.headers,
));
Ok(apply_startup_barrier(
router,
config,
&startup_barrier_state,
))
}
#[derive(Clone)]
struct StartupBarrierState {
app_state: AppState,
live_path: String,
ready_path: String,
startup_path: String,
health_path: String,
actuator_paths: Vec<String>,
actuator_subtree_paths: Vec<String>,
}
impl StartupBarrierState {
fn from_config(config: &AutumnConfig, app_state: &AppState) -> Self {
let actuator_subtree_paths = if config.actuator.sensitive {
vec![crate::actuator::actuator_route_path(
&config.actuator.prefix,
"/loggers",
)]
} else {
Vec::new()
};
Self {
app_state: app_state.clone(),
live_path: config.health.live_path.clone(),
ready_path: config.health.ready_path.clone(),
startup_path: config.health.startup_path.clone(),
health_path: config.health.path.clone(),
actuator_paths: crate::actuator::actuator_endpoint_paths(
&config.actuator.prefix,
config.actuator.sensitive,
),
actuator_subtree_paths,
}
}
fn allows_path(&self, path: &str) -> bool {
path == self.live_path
|| path == self.ready_path
|| path == self.startup_path
|| path == self.health_path
|| self.actuator_paths.iter().any(|allowed| path == allowed)
|| self
.actuator_subtree_paths
.iter()
.any(|allowed| path_matches_route_prefix(path, allowed))
}
}
fn apply_startup_barrier(
router: axum::Router,
config: &AutumnConfig,
state: &AppState,
) -> axum::Router {
let barrier_state = StartupBarrierState::from_config(config, state);
let router = router.layer(axum::middleware::from_fn_with_state(
barrier_state,
startup_barrier,
));
#[cfg(feature = "telemetry-otlp")]
let router = router.layer(crate::middleware::TraceContextLayer);
router
}
async fn startup_barrier(
State(state): State<StartupBarrierState>,
request: axum::extract::Request,
next: Next,
) -> axum::response::Response {
if crate::app::is_static_build_mode()
|| state.app_state.probes().is_startup_complete()
|| state.allows_path(request.uri().path())
{
next.run(request).await
} else {
(
StatusCode::SERVICE_UNAVAILABLE,
"Service is still starting up",
)
.into_response()
}
}
fn path_matches_route_prefix(path: &str, prefix: &str) -> bool {
path == prefix
|| path
.strip_prefix(prefix)
.is_some_and(|rest| rest.is_empty() || rest.starts_with('/'))
}
pub fn build_cors_layer(cors: &crate::config::CorsConfig) -> tower_http::cors::CorsLayer {
use http::header::HeaderName;
use tower_http::cors::{AllowOrigin, CorsLayer};
let layer = if cors.allowed_origins.iter().any(|o| o == "*") {
CorsLayer::new().allow_origin(AllowOrigin::any())
} else {
let origins: Vec<http::HeaderValue> = cors
.allowed_origins
.iter()
.filter_map(|o| match o.parse() {
Ok(v) => Some(v),
Err(e) => {
tracing::warn!(origin = %o, error = %e, "CORS: ignoring malformed allowed_origin");
None
}
})
.collect();
CorsLayer::new().allow_origin(origins)
};
let methods: Vec<http::Method> = cors
.allowed_methods
.iter()
.filter_map(|m| match m.parse() {
Ok(v) => Some(v),
Err(e) => {
tracing::warn!(method = %m, error = %e, "CORS: ignoring malformed allowed_method");
None
}
})
.collect();
let headers: Vec<HeaderName> = cors
.allowed_headers
.iter()
.filter_map(|h| match h.parse() {
Ok(v) => Some(v),
Err(e) => {
tracing::warn!(header = %h, error = %e, "CORS: ignoring malformed allowed_header");
None
}
})
.collect();
layer
.allow_methods(methods)
.allow_headers(headers)
.allow_credentials(cors.allow_credentials)
.max_age(std::time::Duration::from_secs(cors.max_age_secs))
}
#[cfg(feature = "htmx")]
pub async fn htmx_handler() -> axum::response::Response {
use axum::response::IntoResponse;
(
[
(http::header::CONTENT_TYPE, "application/javascript"),
(
http::header::CACHE_CONTROL,
"public, max-age=31536000, immutable",
),
],
crate::htmx::HTMX_JS,
)
.into_response()
}
#[cfg(feature = "htmx")]
pub async fn htmx_csrf_handler() -> axum::response::Response {
use axum::response::IntoResponse;
(
[
(http::header::CONTENT_TYPE, "application/javascript"),
(
http::header::CACHE_CONTROL,
"public, max-age=31536000, immutable",
),
],
crate::htmx::HTMX_CSRF_JS,
)
.into_response()
}
#[cfg(test)]
mod tests {
use super::*;
use axum::body::Body;
use axum::http::{Request, StatusCode};
use tower::ServiceExt;
fn test_state() -> AppState {
AppState {
extensions: std::sync::Arc::new(std::sync::RwLock::new(
std::collections::HashMap::new(),
)),
#[cfg(feature = "db")]
pool: None,
profile: Some("test".to_owned()),
started_at: std::time::Instant::now(),
health_detailed: false,
probes: crate::probe::ProbeState::ready_for_test(),
metrics: crate::middleware::MetricsCollector::new(),
log_levels: crate::actuator::LogLevels::new("info"),
task_registry: crate::actuator::TaskRegistry::new(),
config_props: crate::actuator::ConfigProperties::default(),
#[cfg(feature = "ws")]
channels: crate::channels::Channels::new(32),
#[cfg(feature = "ws")]
shutdown: tokio_util::sync::CancellationToken::new(),
}
}
#[tokio::test]
async fn build_router_mounts_actuator_at_configured_prefix() {
let mut config = AutumnConfig::default();
config.actuator.prefix = "/ops".to_owned();
config.actuator.sensitive = true;
let app = build_router(Vec::new(), &config, test_state());
let prefixed = app
.clone()
.oneshot(
Request::builder()
.uri("/ops/health")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(prefixed.status(), StatusCode::OK);
let legacy = app
.oneshot(
Request::builder()
.uri("/actuator/health")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(legacy.status(), StatusCode::NOT_FOUND);
}
#[test]
fn try_build_router_rejects_invalid_session_backend_config() {
let mut config = AutumnConfig::default();
config.session.backend = crate::session::SessionBackend::Redis;
let error = try_build_router(Vec::new(), &config, test_state())
.expect_err("missing redis config should fail checked router build");
assert!(matches!(
error,
RouterBuildError::InvalidSessionBackend(
crate::session::SessionBackendConfigError::MissingRedisUrl
)
));
}
#[test]
fn try_build_router_with_static_rejects_invalid_session_backend_config() {
let mut config = AutumnConfig::default();
config.session.backend = crate::session::SessionBackend::Redis;
let error = try_build_router_with_static(Vec::new(), &config, test_state(), None)
.expect_err("missing redis config should fail checked static router build");
assert!(matches!(
error,
RouterBuildError::InvalidSessionBackend(
crate::session::SessionBackendConfigError::MissingRedisUrl
)
));
}
#[test]
fn try_build_router_returns_error_for_probe_actuator_path_overlap() {
let mut config = AutumnConfig::default();
config.actuator.prefix = "/".to_owned();
let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
try_build_router(Vec::new(), &config, test_state())
}));
assert!(result.is_ok(), "try_build_router panicked on route overlap");
assert!(
result.unwrap().is_err(),
"route overlap should be reported as a checked router build error"
);
}
#[tokio::test]
async fn apply_cors_middleware_skipped_when_no_origins() {
let config = AutumnConfig::default();
assert!(config.cors.allowed_origins.is_empty());
let base: axum::Router<AppState> =
axum::Router::new().route("/test", axum::routing::get(|| async { "ok" }));
let router = apply_cors_middleware(base, &config).with_state(test_state());
let response = router
.oneshot(
Request::builder()
.uri("/test")
.header("Origin", "https://example.com")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::OK);
assert!(
response
.headers()
.get("access-control-allow-origin")
.is_none(),
"CORS header must be absent when no origins are configured"
);
}
#[tokio::test]
async fn apply_cors_middleware_present_when_origins_configured() {
let mut config = AutumnConfig::default();
config.cors.allowed_origins = vec!["https://example.com".to_owned()];
let base: axum::Router<AppState> =
axum::Router::new().route("/test", axum::routing::get(|| async { "ok" }));
let router = apply_cors_middleware(base, &config).with_state(test_state());
let response = router
.oneshot(
Request::builder()
.uri("/test")
.header("Origin", "https://example.com")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::OK);
assert!(
response
.headers()
.get("access-control-allow-origin")
.is_some(),
"CORS header must be present when origins are configured"
);
}
#[tokio::test]
async fn apply_cors_middleware_handles_preflight_request() {
let mut config = AutumnConfig::default();
config.cors.allowed_origins = vec!["https://example.com".to_owned()];
let base: axum::Router<AppState> =
axum::Router::new().route("/api/widgets", axum::routing::post(|| async { "ok" }));
let router = apply_cors_middleware(base, &config).with_state(test_state());
let response = router
.oneshot(
Request::builder()
.method("OPTIONS")
.uri("/api/widgets")
.header("Origin", "https://example.com")
.header("Access-Control-Request-Method", "POST")
.header("Access-Control-Request-Headers", "Content-Type")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
let headers = response.headers();
assert_eq!(
headers
.get("access-control-allow-origin")
.and_then(|v| v.to_str().ok()),
Some("https://example.com"),
"preflight must echo the allowed origin"
);
assert!(
headers.get("access-control-allow-methods").is_some(),
"preflight must advertise allowed methods"
);
assert!(
headers.get("access-control-allow-headers").is_some(),
"preflight must advertise allowed headers"
);
assert!(
headers.get("access-control-max-age").is_some(),
"preflight must advertise max-age so browsers can cache it"
);
}
#[tokio::test]
async fn apply_csrf_middleware_skipped_when_disabled() {
let config = AutumnConfig::default();
assert!(!config.security.csrf.enabled);
let base: axum::Router<AppState> =
axum::Router::new().route("/form", axum::routing::post(|| async { "posted" }));
let router = apply_csrf_middleware(base, &config).with_state(test_state());
let response = router
.oneshot(
Request::builder()
.method("POST")
.uri("/form")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::OK);
}
#[tokio::test]
async fn apply_rate_limit_middleware_skipped_when_disabled() {
let config = AutumnConfig::default();
assert!(!config.security.rate_limit.enabled);
let base: axum::Router<AppState> =
axum::Router::new().route("/ping", axum::routing::get(|| async { "pong" }));
let router = apply_rate_limit_middleware(base, &config).with_state(test_state());
for _ in 0..5 {
let response = router
.clone()
.oneshot(Request::builder().uri("/ping").body(Body::empty()).unwrap())
.await
.unwrap();
assert_eq!(response.status(), StatusCode::OK);
}
}
#[tokio::test]
async fn apply_rate_limit_middleware_returns_429_when_exhausted() {
let mut config = AutumnConfig::default();
config.security.rate_limit.enabled = true;
config.security.rate_limit.requests_per_second = 0.1;
config.security.rate_limit.burst = 1;
config.security.rate_limit.trust_forwarded_headers = true;
let base: axum::Router<AppState> =
axum::Router::new().route("/ping", axum::routing::get(|| async { "pong" }));
let router = apply_rate_limit_middleware(base, &config).with_state(test_state());
let ok = router
.clone()
.oneshot(
Request::builder()
.uri("/ping")
.header("X-Forwarded-For", "203.0.113.9")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(ok.status(), StatusCode::OK);
let blocked = router
.oneshot(
Request::builder()
.uri("/ping")
.header("X-Forwarded-For", "203.0.113.9")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(blocked.status(), StatusCode::TOO_MANY_REQUESTS);
assert!(blocked.headers().get("retry-after").is_some());
}
#[tokio::test]
async fn apply_csrf_middleware_blocks_without_token_when_enabled() {
let mut config = AutumnConfig::default();
config.security.csrf.enabled = true;
let base: axum::Router<AppState> =
axum::Router::new().route("/form", axum::routing::post(|| async { "posted" }));
let router = apply_csrf_middleware(base, &config).with_state(test_state());
let response = router
.oneshot(
Request::builder()
.method("POST")
.uri("/form")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_ne!(
response.status(),
StatusCode::OK,
"POST without CSRF token should be rejected when CSRF is enabled"
);
}
#[cfg(feature = "openapi")]
#[test]
fn join_nested_path_normalizes_like_axum() {
assert_eq!(super::join_nested_path("/api", "/"), "/api");
assert_eq!(super::join_nested_path("/api/", "/"), "/api");
assert_eq!(super::join_nested_path("/api", "/users"), "/api/users");
assert_eq!(super::join_nested_path("/api/", "/users"), "/api/users");
assert_eq!(super::join_nested_path("", "/"), "/");
assert_eq!(super::join_nested_path("", "/users"), "/users");
}
#[cfg(feature = "openapi")]
#[tokio::test]
async fn try_build_router_detects_scoped_root_collision() {
use crate::openapi::{ApiDoc, OpenApiConfig};
async fn child() -> &'static str {
"inner"
}
let group = crate::app::ScopedGroup {
prefix: "/api".to_owned(),
routes: vec![Route {
method: http::Method::GET,
path: "/",
handler: axum::routing::get(child),
name: "root",
api_doc: ApiDoc {
method: "GET",
path: "/",
operation_id: "root",
success_status: 200,
..Default::default()
},
}],
apply_layer: Box::new(|r| r),
};
let openapi = OpenApiConfig::new("Demo", "1.0.0").openapi_json_path("/api");
let config = AutumnConfig::default();
let ctx = RouterContext {
exception_filters: Vec::new(),
scoped_groups: vec![group],
merge_routers: Vec::new(),
nest_routers: Vec::new(),
custom_layers: Vec::new(),
error_page_renderer: None,
session_store: None,
openapi: Some(openapi),
};
let err = super::try_build_router_inner(Vec::new(), &config, test_state(), ctx)
.expect_err("scope '/api' + child '/' should collide with openapi path '/api'");
assert!(matches!(
err,
RouterBuildError::OpenApiPathCollision {
field: "openapi_json_path",
..
}
));
}
#[cfg(feature = "openapi")]
#[test]
fn extract_path_params_matches_macro_behavior() {
assert_eq!(
super::extract_path_params("/orgs/{org_id}/users/{id}"),
vec!["org_id".to_owned(), "id".to_owned()]
);
assert!(super::extract_path_params("/static").is_empty());
assert_eq!(
super::extract_path_params("/users/{id:[0-9]+}"),
vec!["id".to_owned()]
);
}
#[cfg(feature = "openapi")]
#[tokio::test]
async fn openapi_merges_scoped_prefix_path_params() {
use crate::openapi::{ApiDoc, OpenApiConfig};
async fn handler() -> &'static str {
"ok"
}
let child = Route {
method: http::Method::GET,
path: "/users/{id}",
handler: axum::routing::get(handler),
name: "child",
api_doc: ApiDoc {
method: "GET",
path: "/users/{id}",
operation_id: "child",
path_params: &["id"],
success_status: 200,
..Default::default()
},
};
let group = crate::app::ScopedGroup {
prefix: "/orgs/{org_id}".to_owned(),
routes: vec![child],
apply_layer: Box::new(|r| r),
};
let config = OpenApiConfig::new("Demo", "1.0.0");
let router = super::build_openapi_router(&[], &[group], Some(&config))
.expect("openapi sub-router builds")
.expect("openapi sub-router present when config is Some");
let state = test_state();
let router = router.with_state(state);
let response = router
.oneshot(
Request::builder()
.uri("/v3/api-docs")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::OK);
let body = axum::body::to_bytes(response.into_body(), usize::MAX)
.await
.unwrap();
let spec: serde_json::Value = serde_json::from_slice(&body).unwrap();
let params = &spec["paths"]["/orgs/{org_id}/users/{id}"]["get"]["parameters"];
let names: Vec<&str> = params
.as_array()
.unwrap()
.iter()
.map(|p| p["name"].as_str().unwrap())
.collect();
assert!(names.contains(&"org_id"), "missing org_id: {names:?}");
assert!(names.contains(&"id"), "missing id: {names:?}");
}
#[cfg(feature = "openapi")]
#[test]
fn openapi_rejects_json_path_without_leading_slash() {
let config =
crate::openapi::OpenApiConfig::new("Demo", "1.0.0").openapi_json_path("openapi.json");
let err = super::build_openapi_router(&[], &[], Some(&config))
.expect_err("non-slash path should be rejected");
assert!(matches!(
err,
RouterBuildError::InvalidOpenApiPath {
field: "openapi_json_path",
..
}
));
}
#[cfg(feature = "openapi")]
#[test]
fn openapi_rejects_path_with_captures() {
let config =
crate::openapi::OpenApiConfig::new("Demo", "1.0.0").openapi_json_path("/docs/{id}");
let err = super::build_openapi_router(&[], &[], Some(&config))
.expect_err("captures should be rejected");
assert!(matches!(err, RouterBuildError::InvalidOpenApiPath { .. }));
}
#[cfg(feature = "openapi")]
#[test]
fn openapi_rejects_path_with_unbalanced_brace() {
let config =
crate::openapi::OpenApiConfig::new("Demo", "1.0.0").openapi_json_path("/docs/{id");
let err = super::build_openapi_router(&[], &[], Some(&config))
.expect_err("unbalanced brace should be rejected");
assert!(matches!(err, RouterBuildError::InvalidOpenApiPath { .. }));
}
#[cfg(feature = "openapi")]
#[test]
fn openapi_rejects_path_with_wildcard() {
let config =
crate::openapi::OpenApiConfig::new("Demo", "1.0.0").openapi_json_path("/docs/*rest");
let err = super::build_openapi_router(&[], &[], Some(&config))
.expect_err("wildcard should be rejected");
assert!(matches!(err, RouterBuildError::InvalidOpenApiPath { .. }));
}
#[cfg(feature = "openapi")]
#[test]
fn openapi_rejects_path_with_double_slash() {
let config =
crate::openapi::OpenApiConfig::new("Demo", "1.0.0").openapi_json_path("//docs");
let err = super::build_openapi_router(&[], &[], Some(&config))
.expect_err("double-slash should be rejected");
assert!(matches!(err, RouterBuildError::InvalidOpenApiPath { .. }));
}
#[cfg(feature = "openapi")]
#[test]
fn openapi_rejects_swagger_ui_path_without_leading_slash() {
let config = crate::openapi::OpenApiConfig::new("Demo", "1.0.0")
.swagger_ui_path(Some("docs".to_owned()));
let err = super::build_openapi_router(&[], &[], Some(&config))
.expect_err("non-slash path should be rejected");
assert!(matches!(
err,
RouterBuildError::InvalidOpenApiPath {
field: "swagger_ui_path",
..
}
));
}
#[cfg(feature = "openapi")]
#[test]
fn openapi_rejects_empty_json_path() {
let config = crate::openapi::OpenApiConfig::new("Demo", "1.0.0").openapi_json_path("");
let err = super::build_openapi_router(&[], &[], Some(&config))
.expect_err("empty path should be rejected");
assert!(matches!(err, RouterBuildError::InvalidOpenApiPath { .. }));
}
#[cfg(feature = "openapi")]
#[test]
fn openapi_accepts_valid_paths() {
let config = crate::openapi::OpenApiConfig::new("Demo", "1.0.0")
.openapi_json_path("/api-docs")
.swagger_ui_path(Some("/ui".to_owned()));
let out = super::build_openapi_router(&[], &[], Some(&config))
.expect("valid paths must not error");
assert!(out.is_some());
}
#[cfg(feature = "openapi")]
#[test]
fn openapi_rejects_duplicate_json_and_swagger_paths() {
let config = crate::openapi::OpenApiConfig::new("Demo", "1.0.0")
.openapi_json_path("/docs")
.swagger_ui_path(Some("/docs".to_owned()));
let err = super::build_openapi_router(&[], &[], Some(&config))
.expect_err("colliding paths should be rejected before axum panics");
assert!(matches!(
err,
RouterBuildError::DuplicateOpenApiPath { ref path } if path == "/docs"
));
}
#[cfg(feature = "openapi")]
async fn collision_test_handler() -> &'static str {
"user"
}
#[cfg(feature = "openapi")]
#[tokio::test]
async fn try_build_router_rejects_openapi_path_colliding_with_user_route() {
let mut config = AutumnConfig::default();
config.actuator.prefix = "/ops".to_owned();
let openapi =
crate::openapi::OpenApiConfig::new("Demo", "1.0.0").openapi_json_path("/my-api-docs");
let user_route = Route {
method: http::Method::GET,
path: "/my-api-docs",
handler: axum::routing::get(collision_test_handler),
name: "collides",
api_doc: crate::openapi::ApiDoc {
method: "GET",
path: "/my-api-docs",
operation_id: "collides",
success_status: 200,
..Default::default()
},
};
let ctx = RouterContext {
exception_filters: Vec::new(),
scoped_groups: Vec::new(),
merge_routers: Vec::new(),
nest_routers: Vec::new(),
custom_layers: Vec::new(),
error_page_renderer: None,
session_store: None,
openapi: Some(openapi),
};
let err = super::try_build_router_inner(vec![user_route], &config, test_state(), ctx)
.expect_err("user-owned path should prevent OpenAPI mount");
assert!(matches!(
err,
RouterBuildError::OpenApiPathCollision { field: "openapi_json_path", ref path } if path == "/my-api-docs"
));
}
#[cfg(feature = "openapi")]
#[tokio::test]
async fn try_build_router_rejects_openapi_path_colliding_with_framework_route() {
let config = AutumnConfig::default(); let openapi = crate::openapi::OpenApiConfig::new("Demo", "1.0.0")
.openapi_json_path("/actuator/health");
let ctx = RouterContext {
exception_filters: Vec::new(),
scoped_groups: Vec::new(),
merge_routers: Vec::new(),
nest_routers: Vec::new(),
custom_layers: Vec::new(),
error_page_renderer: None,
session_store: None,
openapi: Some(openapi),
};
let err = super::try_build_router_inner(Vec::new(), &config, test_state(), ctx)
.expect_err("framework-owned path should prevent OpenAPI mount");
assert!(matches!(
err,
RouterBuildError::OpenApiPathCollision {
field: "openapi_json_path",
..
}
));
}
#[cfg(feature = "openapi")]
#[tokio::test]
async fn try_build_router_rejects_swagger_ui_asset_path_colliding_with_user_route() {
let config = AutumnConfig::default();
let openapi = crate::openapi::OpenApiConfig::new("Demo", "1.0.0");
let user_route = Route {
method: http::Method::GET,
path: "/swagger-ui/swagger-ui.css",
handler: axum::routing::get(collision_test_handler),
name: "swagger-ui-asset-collides",
api_doc: crate::openapi::ApiDoc {
method: "GET",
path: "/swagger-ui/swagger-ui.css",
operation_id: "swagger_ui_asset_collides",
success_status: 200,
..Default::default()
},
};
let ctx = RouterContext {
exception_filters: Vec::new(),
scoped_groups: Vec::new(),
merge_routers: Vec::new(),
nest_routers: Vec::new(),
custom_layers: Vec::new(),
error_page_renderer: None,
session_store: None,
openapi: Some(openapi),
};
let err = super::try_build_router_inner(vec![user_route], &config, test_state(), ctx)
.expect_err("swagger ui asset path should be reserved");
assert!(matches!(
err,
RouterBuildError::OpenApiPathCollision {
field: "swagger_ui_path",
ref path,
} if path == "/swagger-ui/swagger-ui.css"
));
}
#[cfg(all(feature = "openapi", feature = "htmx"))]
#[tokio::test]
async fn try_build_router_rejects_openapi_path_colliding_with_htmx_csrf_route() {
let config = AutumnConfig::default();
let openapi = crate::openapi::OpenApiConfig::new("Demo", "1.0.0")
.openapi_json_path(crate::htmx::HTMX_CSRF_JS_PATH);
let ctx = RouterContext {
exception_filters: Vec::new(),
scoped_groups: Vec::new(),
merge_routers: Vec::new(),
nest_routers: Vec::new(),
custom_layers: Vec::new(),
error_page_renderer: None,
session_store: None,
openapi: Some(openapi),
};
let err = super::try_build_router_inner(Vec::new(), &config, test_state(), ctx)
.expect_err("htmx csrf helper path should be reserved");
assert!(matches!(
err,
RouterBuildError::OpenApiPathCollision {
field: "openapi_json_path",
ref path,
} if path == crate::htmx::HTMX_CSRF_JS_PATH
));
}
#[cfg(feature = "openapi")]
#[tokio::test]
async fn try_build_router_rejects_openapi_path_under_nest_prefix() {
let config = AutumnConfig::default();
let openapi =
crate::openapi::OpenApiConfig::new("Demo", "1.0.0").openapi_json_path("/api/docs");
let nested = axum::Router::<AppState>::new()
.route("/inner", axum::routing::get(|| async { "inner" }));
let ctx = RouterContext {
exception_filters: Vec::new(),
scoped_groups: Vec::new(),
merge_routers: Vec::new(),
nest_routers: vec![("/api".to_owned(), nested)],
custom_layers: Vec::new(),
error_page_renderer: None,
session_store: None,
openapi: Some(openapi),
};
let err = super::try_build_router_inner(Vec::new(), &config, test_state(), ctx)
.expect_err("OpenAPI path under a nest prefix should collide");
assert!(matches!(
err,
RouterBuildError::OpenApiPathCollision {
field: "openapi_json_path",
ref path,
} if path == "/api/docs"
));
}
#[cfg(feature = "openapi")]
#[test]
fn try_build_router_rejects_openapi_path_on_dev_live_reload() {
temp_env::with_vars(
[
("AUTUMN_DEV_RELOAD", Some("1")),
("AUTUMN_DEV_RELOAD_STATE", Some("/tmp/autumn-reload-test")),
],
|| {
let config = AutumnConfig::default();
let openapi = crate::openapi::OpenApiConfig::new("Demo", "1.0.0")
.openapi_json_path("/__autumn/live-reload");
let ctx = RouterContext {
exception_filters: Vec::new(),
scoped_groups: Vec::new(),
merge_routers: Vec::new(),
nest_routers: Vec::new(),
custom_layers: Vec::new(),
error_page_renderer: None,
session_store: None,
openapi: Some(openapi),
};
let err = super::try_build_router_inner(Vec::new(), &config, test_state(), ctx)
.expect_err("dev reload path should be reserved");
assert!(matches!(
err,
RouterBuildError::OpenApiPathCollision {
field: "openapi_json_path",
..
}
));
},
);
}
}