use serde::{Deserialize, Serialize};
use crate::app::ScopedGroup;
use crate::route::Route;
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum RouteSource {
User,
Plugin(String),
Framework,
}
impl std::fmt::Display for RouteSource {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::User => write!(f, "user"),
Self::Plugin(name) => write!(f, "plugin:{name}"),
Self::Framework => write!(f, "framework"),
}
}
}
impl Serialize for RouteSource {
fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
serializer.serialize_str(&self.to_string())
}
}
impl<'de> Deserialize<'de> for RouteSource {
fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
let s = String::deserialize(deserializer)?;
Ok(if s == "framework" {
Self::Framework
} else if let Some(name) = s.strip_prefix("plugin:") {
Self::Plugin(name.to_owned())
} else {
Self::User
})
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct RouteInfo {
pub method: String,
pub path: String,
pub handler: String,
pub source: RouteSource,
pub middleware: Vec<String>,
}
pub(crate) fn collect_route_infos(
routes: &[Route],
route_sources: &[RouteSource],
scoped_groups: &[ScopedGroup],
) -> Vec<RouteInfo> {
let mut infos = Vec::with_capacity(routes.len());
for (i, route) in routes.iter().enumerate() {
let source = route_sources.get(i).cloned().unwrap_or(RouteSource::User);
infos.push(RouteInfo {
method: route.method.to_string(),
path: route.path.to_owned(),
handler: route.name.to_owned(),
source,
middleware: Vec::new(),
});
}
for group in scoped_groups {
for route in &group.routes {
let full_path = join_scope_path(&group.prefix, route.path);
infos.push(RouteInfo {
method: route.method.to_string(),
path: full_path,
handler: route.name.to_owned(),
source: group.source.clone(),
middleware: Vec::new(),
});
}
}
infos
}
pub(crate) fn append_framework_routes(
infos: &mut Vec<RouteInfo>,
config: &crate::config::AutumnConfig,
) {
let mut probe_paths = std::collections::HashSet::new();
for (path, name) in [
(config.health.live_path.as_str(), "live"),
(config.health.ready_path.as_str(), "ready"),
(config.health.startup_path.as_str(), "startup"),
(config.health.path.as_str(), "health"),
] {
if probe_paths.insert(path) {
infos.push(RouteInfo {
method: "GET".to_owned(),
path: path.to_owned(),
handler: name.to_owned(),
source: RouteSource::Framework,
middleware: Vec::new(),
});
}
}
for path in
crate::actuator::actuator_endpoint_paths(&config.actuator.prefix, config.actuator.sensitive)
{
infos.push(RouteInfo {
method: "GET".to_owned(),
path,
handler: "actuator".to_owned(),
source: RouteSource::Framework,
middleware: Vec::new(),
});
}
#[cfg(feature = "htmx")]
{
infos.push(RouteInfo {
method: "GET".to_owned(),
path: crate::htmx::HTMX_JS_PATH.to_owned(),
handler: "htmx".to_owned(),
source: RouteSource::Framework,
middleware: Vec::new(),
});
infos.push(RouteInfo {
method: "GET".to_owned(),
path: crate::htmx::HTMX_CSRF_JS_PATH.to_owned(),
handler: "htmx_csrf".to_owned(),
source: RouteSource::Framework,
middleware: Vec::new(),
});
}
#[cfg(feature = "mail")]
if config
.mail
.preview_routes_enabled(config.profile.as_deref())
{
for (path, handler) in [
(crate::mail::MAIL_PREVIEW_PATH, "mail_preview"),
(
"/_autumn/mail/messages/{message_id}",
"mail_preview_message",
),
(
"/_autumn/mail/previews/{mailer}/{method}",
"mail_preview_template",
),
] {
infos.push(RouteInfo {
method: "GET".to_owned(),
path: path.to_owned(),
handler: handler.to_owned(),
source: RouteSource::Framework,
middleware: Vec::new(),
});
}
}
infos.push(RouteInfo {
method: "GET".to_owned(),
path: "/static/{*path}".to_owned(),
handler: "static_files".to_owned(),
source: RouteSource::Framework,
middleware: Vec::new(),
});
}
#[cfg(feature = "openapi")]
pub(crate) fn append_openapi_routes(
infos: &mut Vec<RouteInfo>,
openapi: &crate::openapi::OpenApiConfig,
) {
infos.push(RouteInfo {
method: "GET".to_owned(),
path: openapi.openapi_json_path.clone(),
handler: "openapi_json".to_owned(),
source: RouteSource::Framework,
middleware: Vec::new(),
});
if let Some(ui_path) = &openapi.swagger_ui_path {
infos.push(RouteInfo {
method: "GET".to_owned(),
path: ui_path.clone(),
handler: "swagger_ui".to_owned(),
source: RouteSource::Framework,
middleware: Vec::new(),
});
}
}
pub(crate) fn append_dev_reload_routes(infos: &mut Vec<RouteInfo>) {
if crate::middleware::dev::is_enabled_with_env(&crate::config::OsEnv) {
for (path, handler) in [
(crate::middleware::dev::LIVE_RELOAD_PATH, "dev_live_reload"),
(
crate::middleware::dev::LIVE_RELOAD_SCRIPT_PATH,
"dev_live_reload_js",
),
] {
infos.push(RouteInfo {
method: "GET".to_owned(),
path: path.to_owned(),
handler: handler.to_owned(),
source: RouteSource::Framework,
middleware: Vec::new(),
});
}
}
}
pub(crate) fn sort_route_infos(infos: &mut [RouteInfo]) {
infos.sort_by(|a, b| a.path.cmp(&b.path).then_with(|| a.method.cmp(&b.method)));
}
fn join_scope_path(prefix: &str, path: &str) -> String {
let prefix = prefix.trim_end_matches('/');
if path == "/" || path.is_empty() {
if prefix.is_empty() {
"/".to_owned()
} else {
prefix.to_owned()
}
} else if path.starts_with('/') {
format!("{prefix}{path}")
} else {
format!("{prefix}/{path}")
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::config::AutumnConfig;
use axum::routing::get;
use http::Method;
fn dummy_api_doc() -> crate::openapi::ApiDoc {
crate::openapi::ApiDoc {
method: "GET",
path: "/dummy",
operation_id: "dummy",
success_status: 200,
..Default::default()
}
}
fn make_route(method: Method, path: &'static str, name: &'static str) -> Route {
async fn handler() -> &'static str {
"ok"
}
Route {
method,
path,
handler: get(handler),
name,
api_doc: dummy_api_doc(),
repository: None,
}
}
#[test]
fn collect_route_infos_empty_produces_empty() {
let infos = collect_route_infos(&[], &[], &[]);
assert!(infos.is_empty());
}
#[test]
fn collect_route_infos_single_user_route() {
let routes = vec![make_route(Method::GET, "/posts", "list_posts")];
let sources = vec![RouteSource::User];
let infos = collect_route_infos(&routes, &sources, &[]);
assert_eq!(infos.len(), 1);
assert_eq!(infos[0].method, "GET");
assert_eq!(infos[0].path, "/posts");
assert_eq!(infos[0].handler, "list_posts");
assert_eq!(infos[0].source, RouteSource::User);
assert!(infos[0].middleware.is_empty());
}
#[test]
fn collect_route_infos_multiple_methods_same_path() {
let routes = vec![
make_route(Method::GET, "/posts", "list_posts"),
make_route(Method::POST, "/posts", "create_post"),
];
let sources = vec![RouteSource::User, RouteSource::User];
let infos = collect_route_infos(&routes, &sources, &[]);
assert_eq!(infos.len(), 2);
}
#[test]
fn collect_route_infos_scoped_group_prepends_prefix() {
let group = ScopedGroup {
prefix: "/api".to_owned(),
routes: vec![make_route(Method::GET, "/posts", "api_list_posts")],
source: RouteSource::User,
apply_layer: Box::new(|r| r),
};
let infos = collect_route_infos(&[], &[], &[group]);
assert_eq!(infos.len(), 1);
assert_eq!(infos[0].path, "/api/posts");
assert_eq!(infos[0].handler, "api_list_posts");
}
#[test]
fn collect_route_infos_scoped_root_child() {
let group = ScopedGroup {
prefix: "/api".to_owned(),
routes: vec![make_route(Method::GET, "/", "api_root")],
source: RouteSource::User,
apply_layer: Box::new(|r| r),
};
let infos = collect_route_infos(&[], &[], &[group]);
assert_eq!(infos.len(), 1);
assert_eq!(infos[0].path, "/api");
}
#[test]
fn collect_route_infos_marks_user_source() {
let routes = vec![make_route(Method::POST, "/items", "create_item")];
let sources = vec![RouteSource::User];
let infos = collect_route_infos(&routes, &sources, &[]);
assert_eq!(infos[0].source, RouteSource::User);
}
#[test]
fn collect_route_infos_plugin_source_from_parallel_slice() {
let routes = vec![make_route(Method::GET, "/admin", "admin_index")];
let sources = vec![RouteSource::Plugin("admin".to_owned())];
let infos = collect_route_infos(&routes, &sources, &[]);
assert_eq!(infos[0].source, RouteSource::Plugin("admin".to_owned()));
}
#[test]
fn collect_route_infos_plugin_source_on_scoped_group() {
let group = ScopedGroup {
prefix: "/admin".to_owned(),
routes: vec![make_route(Method::GET, "/users", "admin_users")],
source: RouteSource::Plugin("admin".to_owned()),
apply_layer: Box::new(|r| r),
};
let infos = collect_route_infos(&[], &[], &[group]);
assert_eq!(infos[0].source, RouteSource::Plugin("admin".to_owned()));
assert_eq!(infos[0].path, "/admin/users");
}
#[test]
fn collect_route_infos_missing_source_defaults_to_user() {
let routes = vec![make_route(Method::GET, "/x", "x")];
let infos = collect_route_infos(&routes, &[], &[]);
assert_eq!(infos[0].source, RouteSource::User);
}
#[test]
fn sort_route_infos_by_path_then_method() {
let mut infos = vec![
RouteInfo {
method: "POST".to_owned(),
path: "/posts".to_owned(),
handler: "create".to_owned(),
source: RouteSource::User,
middleware: vec![],
},
RouteInfo {
method: "GET".to_owned(),
path: "/posts".to_owned(),
handler: "list".to_owned(),
source: RouteSource::User,
middleware: vec![],
},
RouteInfo {
method: "GET".to_owned(),
path: "/about".to_owned(),
handler: "about".to_owned(),
source: RouteSource::User,
middleware: vec![],
},
];
sort_route_infos(&mut infos);
assert_eq!(infos[0].path, "/about");
assert_eq!(infos[1].path, "/posts");
assert_eq!(infos[1].method, "GET");
assert_eq!(infos[2].path, "/posts");
assert_eq!(infos[2].method, "POST");
}
#[test]
fn sort_route_infos_stable_on_equal() {
let mut infos = vec![
RouteInfo {
method: "GET".to_owned(),
path: "/z".to_owned(),
handler: "z".to_owned(),
source: RouteSource::User,
middleware: vec![],
},
RouteInfo {
method: "GET".to_owned(),
path: "/a".to_owned(),
handler: "a".to_owned(),
source: RouteSource::User,
middleware: vec![],
},
];
sort_route_infos(&mut infos);
assert_eq!(infos[0].path, "/a");
assert_eq!(infos[1].path, "/z");
}
#[test]
fn route_source_user_serializes_to_string() {
let s = serde_json::to_string(&RouteSource::User).unwrap();
assert_eq!(s, "\"user\"");
}
#[test]
fn route_source_framework_serializes_to_string() {
let s = serde_json::to_string(&RouteSource::Framework).unwrap();
assert_eq!(s, "\"framework\"");
}
#[test]
fn route_source_plugin_serializes_with_name() {
let s = serde_json::to_string(&RouteSource::Plugin("admin".to_owned())).unwrap();
assert_eq!(s, "\"plugin:admin\"");
}
#[test]
fn route_source_roundtrips_user() {
let original = RouteSource::User;
let json = serde_json::to_string(&original).unwrap();
let decoded: RouteSource = serde_json::from_str(&json).unwrap();
assert_eq!(decoded, original);
}
#[test]
fn route_source_roundtrips_plugin() {
let original = RouteSource::Plugin("harvest".to_owned());
let json = serde_json::to_string(&original).unwrap();
let decoded: RouteSource = serde_json::from_str(&json).unwrap();
assert_eq!(decoded, original);
}
#[test]
fn route_source_roundtrips_framework() {
let original = RouteSource::Framework;
let json = serde_json::to_string(&original).unwrap();
let decoded: RouteSource = serde_json::from_str(&json).unwrap();
assert_eq!(decoded, original);
}
#[test]
fn append_framework_routes_includes_probe_paths() {
let config = AutumnConfig::default();
let mut infos = Vec::new();
append_framework_routes(&mut infos, &config);
let paths: Vec<&str> = infos.iter().map(|i| i.path.as_str()).collect();
assert!(
paths.contains(&config.health.path.as_str()),
"health path missing: {paths:?}"
);
assert!(
paths.contains(&config.health.live_path.as_str()),
"live path missing: {paths:?}"
);
assert!(
paths.contains(&config.health.ready_path.as_str()),
"ready path missing: {paths:?}"
);
assert!(
paths.contains(&config.health.startup_path.as_str()),
"startup path missing: {paths:?}"
);
}
#[test]
fn append_framework_routes_marks_framework_source() {
let config = AutumnConfig::default();
let mut infos = Vec::new();
append_framework_routes(&mut infos, &config);
for info in &infos {
assert_eq!(
info.source,
RouteSource::Framework,
"expected Framework source for {}: {:?}",
info.path,
info.source
);
}
}
#[test]
fn append_framework_routes_custom_health_path() {
let mut config = AutumnConfig::default();
config.health.path = "/ping".to_owned();
let mut infos = Vec::new();
append_framework_routes(&mut infos, &config);
let paths: Vec<&str> = infos.iter().map(|i| i.path.as_str()).collect();
assert!(
paths.contains(&"/ping"),
"custom health path missing: {paths:?}"
);
}
#[test]
fn join_scope_path_normal() {
assert_eq!(join_scope_path("/api", "/posts"), "/api/posts");
}
#[test]
fn join_scope_path_root_child() {
assert_eq!(join_scope_path("/api", "/"), "/api");
}
#[test]
fn join_scope_path_empty_child() {
assert_eq!(join_scope_path("/api", ""), "/api");
}
#[test]
fn join_scope_path_trailing_slash_on_prefix() {
assert_eq!(join_scope_path("/api/", "/posts"), "/api/posts");
}
#[test]
fn join_scope_path_empty_prefix() {
assert_eq!(join_scope_path("", "/posts"), "/posts");
}
#[test]
fn join_scope_path_root_prefix_root_child() {
assert_eq!(join_scope_path("", "/"), "/");
}
#[test]
fn route_info_roundtrips_json() {
let info = RouteInfo {
method: "GET".to_owned(),
path: "/posts/{id}".to_owned(),
handler: "posts::show".to_owned(),
source: RouteSource::User,
middleware: vec!["secured".to_owned()],
};
let json = serde_json::to_string(&info).unwrap();
let decoded: RouteInfo = serde_json::from_str(&json).unwrap();
assert_eq!(decoded.method, "GET");
assert_eq!(decoded.path, "/posts/{id}");
assert_eq!(decoded.handler, "posts::show");
assert_eq!(decoded.source, RouteSource::User);
assert_eq!(decoded.middleware, vec!["secured"]);
}
#[cfg(feature = "openapi")]
#[test]
fn append_openapi_routes_adds_json_and_ui_paths() {
let config = crate::openapi::OpenApiConfig::new("Test", "1.0.0");
let mut infos = Vec::new();
append_openapi_routes(&mut infos, &config);
let paths: Vec<&str> = infos.iter().map(|i| i.path.as_str()).collect();
assert!(
paths.contains(&"/openapi.json"),
"openapi json path missing: {paths:?}"
);
assert!(
paths.contains(&"/swagger-ui"),
"swagger ui path missing: {paths:?}"
);
for info in &infos {
assert_eq!(info.source, RouteSource::Framework);
assert_eq!(info.method, "GET");
}
}
#[cfg(feature = "openapi")]
#[test]
fn append_openapi_routes_custom_paths() {
let config = crate::openapi::OpenApiConfig::new("Test", "1.0.0")
.openapi_json_path("/docs/openapi.json")
.swagger_ui_path(Some("/docs/ui".to_owned()));
let mut infos = Vec::new();
append_openapi_routes(&mut infos, &config);
let paths: Vec<&str> = infos.iter().map(|i| i.path.as_str()).collect();
assert!(paths.contains(&"/docs/openapi.json"));
assert!(paths.contains(&"/docs/ui"));
}
#[cfg(feature = "openapi")]
#[test]
fn append_openapi_routes_no_swagger_ui_when_none() {
let config = crate::openapi::OpenApiConfig::new("Test", "1.0.0").swagger_ui_path(None);
let mut infos = Vec::new();
append_openapi_routes(&mut infos, &config);
assert_eq!(infos.len(), 1);
assert_eq!(infos[0].path, "/openapi.json");
}
#[test]
fn append_framework_routes_includes_static_catch_all() {
let config = AutumnConfig::default();
let mut infos = Vec::new();
append_framework_routes(&mut infos, &config);
let static_route = infos.iter().find(|r| r.path == "/static/{*path}");
assert!(
static_route.is_some(),
"framework routes should include /static/{{*path}}"
);
let r = static_route.unwrap();
assert_eq!(r.method, "GET");
assert_eq!(r.handler, "static_files");
assert_eq!(r.source, RouteSource::Framework);
}
#[test]
fn append_dev_reload_routes_empty_when_dev_disabled() {
let guard = std::env::var("AUTUMN_DEV");
if guard.is_ok() {
return;
}
let mut infos = Vec::new();
append_dev_reload_routes(&mut infos);
assert!(
infos.is_empty(),
"expected no dev routes when AUTUMN_DEV unset"
);
}
}