use std::collections::HashMap;
use serde::{Deserialize, Serialize};
use crate::router::ParsedPath;
#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "UPPERCASE")]
pub enum HttpMethod {
GET,
POST,
PUT,
DELETE,
PATCH,
OPTIONS,
}
impl HttpMethod {
pub fn parse(s: &str) -> Result<Self, RouteConfigError> {
match s.to_uppercase().as_str() {
"GET" => Ok(HttpMethod::GET),
"POST" => Ok(HttpMethod::POST),
"PUT" => Ok(HttpMethod::PUT),
"DELETE" => Ok(HttpMethod::DELETE),
"PATCH" => Ok(HttpMethod::PATCH),
"OPTIONS" => Ok(HttpMethod::OPTIONS),
other => Err(RouteConfigError::InvalidMethod(other.to_string())),
}
}
pub fn to_axum_method(&self) -> axum::http::Method {
match self {
HttpMethod::GET => axum::http::Method::GET,
HttpMethod::POST => axum::http::Method::POST,
HttpMethod::PUT => axum::http::Method::PUT,
HttpMethod::DELETE => axum::http::Method::DELETE,
HttpMethod::PATCH => axum::http::Method::PATCH,
HttpMethod::OPTIONS => axum::http::Method::OPTIONS,
}
}
}
impl std::fmt::Display for HttpMethod {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
HttpMethod::GET => write!(f, "GET"),
HttpMethod::POST => write!(f, "POST"),
HttpMethod::PUT => write!(f, "PUT"),
HttpMethod::DELETE => write!(f, "DELETE"),
HttpMethod::PATCH => write!(f, "PATCH"),
HttpMethod::OPTIONS => write!(f, "OPTIONS"),
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct HandlerRef {
pub controller: String,
pub action: String,
}
impl HandlerRef {
pub fn parse(s: &str) -> Result<Self, RouteConfigError> {
let s = s.trim();
if s.is_empty() {
return Err(RouteConfigError::EmptyHandler);
}
let (controller, action) = if let Some((c, a)) = s.split_once('@') {
(c, a)
} else if let Some((c, a)) = s.split_once('/') {
(c, a)
} else {
(s, crate::router::DEFAULT_ACTION)
};
let controller = controller.trim();
let action = action.trim();
if controller.is_empty() {
return Err(RouteConfigError::EmptyController);
}
if action.is_empty() {
return Err(RouteConfigError::EmptyAction);
}
if !is_valid_identifier(controller) {
return Err(RouteConfigError::InvalidController(controller.to_string()));
}
if !is_valid_identifier(action) {
return Err(RouteConfigError::InvalidAction(action.to_string()));
}
Ok(Self {
controller: controller.to_string(),
action: action.to_string(),
})
}
pub fn to_handler_string(&self) -> String {
format!("{}@{}", self.controller, self.action)
}
}
fn is_valid_identifier(s: &str) -> bool {
let mut chars = s.chars();
match chars.next() {
Some(first) if first.is_ascii_alphabetic() || first == '_' => {}
_ => return false,
}
chars.all(|c| c.is_ascii_alphanumeric() || c == '_')
}
impl std::fmt::Display for HandlerRef {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "{}@{}", self.controller, self.action)
}
}
#[derive(Debug, thiserror::Error)]
pub enum RouteConfigError {
#[error("YAML parse error: {0}")]
YamlParse(#[from] serde_yml::Error),
#[error("JSON parse error: {0}")]
JsonParse(#[from] serde_json::Error),
#[error("invalid HTTP method: {0}")]
InvalidMethod(String),
#[error("empty handler string")]
EmptyHandler,
#[error("empty controller name in handler")]
EmptyController,
#[error("empty action name in handler")]
EmptyAction,
#[error("invalid controller name: {0}")]
InvalidController(String),
#[error("invalid action name: {0}")]
InvalidAction(String),
#[error("handler parse error: {0}")]
HandlerParse(String),
#[error("route conflict: {method} {path} already registered")]
Conflict {
method: String,
path: String,
},
#[error("failed to read route config file: {0}")]
FileRead(#[source] std::io::Error),
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct RouteRule {
pub method: HttpMethod,
pub path: String,
pub handler: String,
#[serde(default)]
pub middleware: Vec<String>,
#[serde(default)]
pub name: Option<String>,
}
impl RouteRule {
pub fn new(method: HttpMethod, path: impl Into<String>, handler: impl Into<String>) -> Self {
Self {
method,
path: path.into(),
handler: handler.into(),
middleware: Vec::new(),
name: None,
}
}
pub fn handler_ref(&self) -> Result<HandlerRef, RouteConfigError> {
HandlerRef::parse(&self.handler)
}
pub fn with_middleware(mut self, name: impl Into<String>) -> Self {
self.middleware.push(name.into());
self
}
pub fn with_name(mut self, name: impl Into<String>) -> Self {
self.name = Some(name.into());
self
}
}
#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize)]
pub struct RouteConfig {
#[serde(default)]
pub routes: Vec<RouteRule>,
#[serde(default)]
pub groups: Vec<RouteGroup>,
}
impl RouteConfig {
pub fn new() -> Self {
Self::default()
}
pub fn add_route(&mut self, rule: RouteRule) {
self.routes.push(rule);
}
pub fn add_group(&mut self, group: RouteGroup) {
self.groups.push(group);
}
pub fn flatten(&self) -> Vec<RouteRule> {
let mut result = self.routes.clone();
for group in &self.groups {
for rule in &group.routes {
let mut flattened = rule.clone();
flattened.path = join_path(&group.prefix, &flattened.path);
let mut mw = group.middleware.clone();
mw.extend(flattened.middleware);
flattened.middleware = mw;
result.push(flattened);
}
}
result
}
pub fn find_conflicts(&self) -> Vec<(RouteRule, RouteRule)> {
let flattened = self.flatten();
let mut seen: HashMap<(String, String), usize> = HashMap::new();
let mut conflicts = Vec::new();
for (i, rule) in flattened.iter().enumerate() {
let key = (rule.method.to_string(), rule.path.clone());
if let Some(&prev_idx) = seen.get(&key) {
conflicts.push((flattened[prev_idx].clone(), flattened[i].clone()));
} else {
seen.insert(key, i);
}
}
conflicts
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct RouteGroup {
pub prefix: String,
#[serde(default)]
pub routes: Vec<RouteRule>,
#[serde(default)]
pub middleware: Vec<String>,
}
impl RouteGroup {
pub fn new(prefix: impl Into<String>) -> Self {
Self {
prefix: prefix.into(),
routes: Vec::new(),
middleware: Vec::new(),
}
}
pub fn add_route(&mut self, rule: RouteRule) -> &mut Self {
self.routes.push(rule);
self
}
pub fn with_middleware(mut self, name: impl Into<String>) -> Self {
self.middleware.push(name.into());
self
}
}
fn join_path(prefix: &str, path: &str) -> String {
let prefix = prefix.trim_end_matches('/');
let path = path.trim_start_matches('/');
if path.is_empty() {
prefix.to_string()
} else if prefix.is_empty() {
format!("/{path}")
} else {
format!("{prefix}/{path}")
}
}
#[tracing::instrument]
pub fn load_routes_from_yaml_str(yaml: &str) -> Result<RouteConfig, RouteConfigError> {
let config: RouteConfig = serde_yml::from_str(yaml)?;
Ok(config)
}
#[tracing::instrument]
pub fn load_routes_from_json_str(json: &str) -> Result<RouteConfig, RouteConfigError> {
let config: RouteConfig = serde_json::from_str(json)?;
Ok(config)
}
#[tracing::instrument(skip(path))]
pub async fn load_routes_from_yaml_file(
path: impl AsRef<std::path::Path>,
) -> Result<RouteConfig, RouteConfigError> {
let content = tokio::fs::read_to_string(path)
.await
.map_err(RouteConfigError::FileRead)?;
load_routes_from_yaml_str(&content)
}
#[tracing::instrument(skip(path))]
pub async fn load_routes_from_json_file(
path: impl AsRef<std::path::Path>,
) -> Result<RouteConfig, RouteConfigError> {
let content = tokio::fs::read_to_string(path)
.await
.map_err(RouteConfigError::FileRead)?;
load_routes_from_json_str(&content)
}
pub trait ControllerRouter {
fn router_rules(&self) -> Vec<RouteRule>;
fn router_prefix(&self) -> &str {
""
}
fn router_middleware(&self) -> Vec<String> {
Vec::new()
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ConventionRoute {
pub app: String,
pub controller: String,
pub action: String,
pub method: HttpMethod,
pub path: String,
}
impl ConventionRoute {
pub fn from_uri(uri: &str) -> Option<Self> {
let parsed = crate::router::parse_path(uri);
if parsed.app == crate::router::DEFAULT_APP
&& parsed.controller == crate::router::DEFAULT_CONTROLLER
&& parsed.action == crate::router::DEFAULT_ACTION
{
return None;
}
let path = format!(
"/{}/{}/{}",
parsed.app,
parsed.controller.to_lowercase(),
parsed.action
);
Some(Self {
app: parsed.app,
controller: parsed.controller,
action: parsed.action,
method: HttpMethod::GET,
path,
})
}
pub fn from_parsed(parsed: ParsedPath) -> Option<Self> {
let uri = format!(
"/{}/{}/{}",
parsed.app,
parsed.controller.to_lowercase(),
parsed.action
);
Self::from_uri(&uri)
}
}
#[derive(Debug, Clone, Default)]
pub struct RouteRegistry {
pub attribute_routes: Vec<RouteRule>,
pub config_routes: Vec<RouteRule>,
pub convention_routes: Vec<ConventionRoute>,
}
impl RouteRegistry {
pub fn new() -> Self {
Self::default()
}
pub fn add_attribute_route(&mut self, rule: RouteRule) -> &mut Self {
self.attribute_routes.push(rule);
self
}
pub fn add_attribute_routes(
&mut self,
rules: impl IntoIterator<Item = RouteRule>,
) -> &mut Self {
self.attribute_routes.extend(rules);
self
}
pub fn add_config_routes(&mut self, config: &RouteConfig) -> &mut Self {
self.config_routes.extend(config.flatten());
self
}
pub fn add_convention_route(&mut self, route: ConventionRoute) -> &mut Self {
self.convention_routes.push(route);
self
}
#[tracing::instrument(skip(self))]
pub fn convention_as_rules(&self) -> Vec<RouteRule> {
self.convention_routes
.iter()
.map(|c| RouteRule {
method: c.method.clone(),
path: c.path.clone(),
handler: format!("{}@{}", c.controller, c.action),
middleware: Vec::new(),
name: Some(format!(
"convention.{}.{}.{}",
c.app, c.controller, c.action
)),
})
.collect()
}
#[tracing::instrument(skip(self))]
pub fn merged_rules(&self) -> Vec<RouteRule> {
let mut seen: HashMap<(String, String), RouteRule> = HashMap::new();
for rule in self.convention_as_rules() {
let key = (rule.method.to_string(), rule.path.clone());
seen.insert(key, rule);
}
for rule in &self.config_routes {
let key = (rule.method.to_string(), rule.path.clone());
seen.insert(key, rule.clone());
}
for rule in &self.attribute_routes {
let key = (rule.method.to_string(), rule.path.clone());
seen.insert(key, rule.clone());
}
seen.into_values().collect()
}
pub fn attribute_conflicts(&self) -> Vec<(RouteRule, RouteRule)> {
find_conflicts_in(&self.attribute_routes)
}
pub fn config_conflicts(&self) -> Vec<(RouteRule, RouteRule)> {
find_conflicts_in(&self.config_routes)
}
pub fn total_count(&self) -> usize {
self.attribute_routes.len() + self.config_routes.len() + self.convention_routes.len()
}
}
fn find_conflicts_in(rules: &[RouteRule]) -> Vec<(RouteRule, RouteRule)> {
let mut seen: HashMap<(String, String), usize> = HashMap::new();
let mut conflicts = Vec::new();
for (i, rule) in rules.iter().enumerate() {
let key = (rule.method.to_string(), rule.path.clone());
if let Some(&prev_idx) = seen.get(&key) {
conflicts.push((rules[prev_idx].clone(), rules[i].clone()));
} else {
seen.insert(key, i);
}
}
conflicts
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_http_method_parse_uppercase() {
assert_eq!(HttpMethod::parse("GET").unwrap(), HttpMethod::GET);
assert_eq!(HttpMethod::parse("POST").unwrap(), HttpMethod::POST);
assert_eq!(HttpMethod::parse("PUT").unwrap(), HttpMethod::PUT);
assert_eq!(HttpMethod::parse("DELETE").unwrap(), HttpMethod::DELETE);
assert_eq!(HttpMethod::parse("PATCH").unwrap(), HttpMethod::PATCH);
assert_eq!(HttpMethod::parse("OPTIONS").unwrap(), HttpMethod::OPTIONS);
}
#[test]
fn test_http_method_parse_lowercase() {
assert_eq!(HttpMethod::parse("get").unwrap(), HttpMethod::GET);
assert_eq!(HttpMethod::parse("post").unwrap(), HttpMethod::POST);
}
#[test]
fn test_http_method_parse_mixed_case() {
assert_eq!(HttpMethod::parse("Get").unwrap(), HttpMethod::GET);
assert_eq!(HttpMethod::parse("pOsT").unwrap(), HttpMethod::POST);
}
#[test]
fn test_http_method_parse_invalid() {
assert!(HttpMethod::parse("invalid").is_err());
assert!(HttpMethod::parse("").is_err());
assert!(HttpMethod::parse("CONNECT").is_err());
assert!(HttpMethod::parse("TRACE").is_err());
}
#[test]
fn test_http_method_to_axum() {
assert_eq!(HttpMethod::GET.to_axum_method(), axum::http::Method::GET);
assert_eq!(HttpMethod::POST.to_axum_method(), axum::http::Method::POST);
assert_eq!(HttpMethod::PUT.to_axum_method(), axum::http::Method::PUT);
assert_eq!(
HttpMethod::DELETE.to_axum_method(),
axum::http::Method::DELETE
);
assert_eq!(
HttpMethod::PATCH.to_axum_method(),
axum::http::Method::PATCH
);
assert_eq!(
HttpMethod::OPTIONS.to_axum_method(),
axum::http::Method::OPTIONS
);
}
#[test]
fn test_http_method_display() {
assert_eq!(HttpMethod::GET.to_string(), "GET");
assert_eq!(HttpMethod::POST.to_string(), "POST");
assert_eq!(HttpMethod::PUT.to_string(), "PUT");
}
#[test]
fn test_http_method_serde() {
let json = serde_json::to_string(&HttpMethod::GET).unwrap();
assert_eq!(json, "\"GET\"");
let m: HttpMethod = serde_json::from_str("\"POST\"").unwrap();
assert_eq!(m, HttpMethod::POST);
}
#[test]
fn test_handler_ref_parse_at_separator() {
let h = HandlerRef::parse("User@list").unwrap();
assert_eq!(h.controller, "User");
assert_eq!(h.action, "list");
}
#[test]
fn test_handler_ref_parse_slash_separator() {
let h = HandlerRef::parse("User/list").unwrap();
assert_eq!(h.controller, "User");
assert_eq!(h.action, "list");
}
#[test]
fn test_handler_ref_parse_only_controller() {
let h = HandlerRef::parse("User").unwrap();
assert_eq!(h.controller, "User");
assert_eq!(h.action, "index"); }
#[test]
fn test_handler_ref_parse_with_whitespace() {
let h = HandlerRef::parse(" User @ list ").unwrap();
assert_eq!(h.controller, "User");
assert_eq!(h.action, "list");
}
#[test]
fn test_handler_ref_parse_empty() {
assert!(HandlerRef::parse("").is_err());
assert!(HandlerRef::parse(" ").is_err());
}
#[test]
fn test_handler_ref_parse_empty_controller() {
assert!(HandlerRef::parse("@list").is_err());
assert!(HandlerRef::parse("/list").is_err());
}
#[test]
fn test_handler_ref_parse_empty_action() {
assert!(HandlerRef::parse("User@").is_err());
assert!(HandlerRef::parse("User/").is_err());
}
#[test]
fn test_handler_ref_to_string() {
let h = HandlerRef {
controller: "User".to_string(),
action: "list".to_string(),
};
assert_eq!(h.to_string(), "User@list");
}
#[test]
fn test_handler_ref_parse_rejects_path_traversal() {
assert!(matches!(
HandlerRef::parse("../Secret@admin"),
Err(RouteConfigError::InvalidController(_))
));
assert!(matches!(
HandlerRef::parse("..@admin"),
Err(RouteConfigError::InvalidController(_))
));
assert!(matches!(
HandlerRef::parse("User@../evil"),
Err(RouteConfigError::InvalidAction(_))
));
}
#[test]
fn test_handler_ref_parse_rejects_double_at() {
assert!(matches!(
HandlerRef::parse("User@list@extra"),
Err(RouteConfigError::InvalidAction(_))
));
}
#[test]
fn test_handler_ref_parse_rejects_space_injection() {
assert!(matches!(
HandlerRef::parse("Us er@list"),
Err(RouteConfigError::InvalidController(_))
));
assert!(matches!(
HandlerRef::parse("User@li st"),
Err(RouteConfigError::InvalidAction(_))
));
}
#[test]
fn test_handler_ref_parse_rejects_leading_digit() {
assert!(matches!(
HandlerRef::parse("1User@list"),
Err(RouteConfigError::InvalidController(_))
));
assert!(matches!(
HandlerRef::parse("User@1list"),
Err(RouteConfigError::InvalidAction(_))
));
}
#[test]
fn test_handler_ref_parse_accepts_underscore_and_alphanumeric() {
let h = HandlerRef::parse("_Private@_index").unwrap();
assert_eq!(h.controller, "_Private");
assert_eq!(h.action, "_index");
let h = HandlerRef::parse("User@action_1").unwrap();
assert_eq!(h.controller, "User");
assert_eq!(h.action, "action_1");
let h = HandlerRef::parse("CustomerList@getListById").unwrap();
assert_eq!(h.controller, "CustomerList");
assert_eq!(h.action, "getListById");
}
#[test]
fn test_handler_ref_parse_rejects_special_chars() {
assert!(HandlerRef::parse("User:list@action").is_err());
assert!(HandlerRef::parse("User;list@action").is_err());
assert!(HandlerRef::parse(r"User\list@action").is_err());
assert!(HandlerRef::parse("User@act\nion").is_err());
}
#[test]
fn test_route_rule_new() {
let rule = RouteRule::new(HttpMethod::GET, "/users", "User@list");
assert_eq!(rule.method, HttpMethod::GET);
assert_eq!(rule.path, "/users");
assert_eq!(rule.handler, "User@list");
assert!(rule.middleware.is_empty());
assert!(rule.name.is_none());
}
#[test]
fn test_route_rule_handler_ref() {
let rule = RouteRule::new(HttpMethod::GET, "/users", "User@list");
let h = rule.handler_ref().unwrap();
assert_eq!(h.controller, "User");
assert_eq!(h.action, "list");
}
#[test]
fn test_route_rule_with_middleware() {
let rule = RouteRule::new(HttpMethod::GET, "/users", "User@list")
.with_middleware("auth")
.with_middleware("log");
assert_eq!(rule.middleware, vec!["auth", "log"]);
}
#[test]
fn test_route_rule_with_name() {
let rule = RouteRule::new(HttpMethod::GET, "/users", "User@list").with_name("user.list");
assert_eq!(rule.name, Some("user.list".to_string()));
}
#[test]
fn test_route_group_new() {
let g = RouteGroup::new("/api/v1");
assert_eq!(g.prefix, "/api/v1");
assert!(g.routes.is_empty());
assert!(g.middleware.is_empty());
}
#[test]
fn test_route_group_add_route() {
let mut g = RouteGroup::new("/api");
g.add_route(RouteRule::new(HttpMethod::GET, "/items", "Item@list"));
assert_eq!(g.routes.len(), 1);
}
#[test]
fn test_route_group_with_middleware() {
let g = RouteGroup::new("/api")
.with_middleware("auth")
.with_middleware("log");
assert_eq!(g.middleware, vec!["auth", "log"]);
}
#[test]
fn test_join_path_basic() {
assert_eq!(join_path("/api", "/users"), "/api/users");
assert_eq!(join_path("/api/", "/users"), "/api/users");
assert_eq!(join_path("/api", "users"), "/api/users");
assert_eq!(join_path("/api/", "users"), "/api/users");
}
#[test]
fn test_join_path_empty_prefix() {
assert_eq!(join_path("", "/users"), "/users");
assert_eq!(join_path("", "users"), "/users");
}
#[test]
fn test_join_path_empty_path() {
assert_eq!(join_path("/api", ""), "/api");
assert_eq!(join_path("/api/", ""), "/api");
}
#[test]
fn test_join_path_both_empty() {
assert_eq!(join_path("", ""), "");
}
#[test]
fn test_route_config_flatten_no_groups() {
let mut config = RouteConfig::new();
config.add_route(RouteRule::new(HttpMethod::GET, "/users", "User@list"));
config.add_route(RouteRule::new(HttpMethod::POST, "/users", "User@create"));
let flat = config.flatten();
assert_eq!(flat.len(), 2);
assert_eq!(flat[0].path, "/users");
assert_eq!(flat[1].path, "/users");
}
#[test]
fn test_route_config_flatten_with_group() {
let mut config = RouteConfig::new();
let mut group = RouteGroup::new("/api/v1");
group.add_route(RouteRule::new(HttpMethod::GET, "/items", "Item@list"));
group.add_route(RouteRule::new(HttpMethod::POST, "/items", "Item@create"));
config.add_group(group);
let flat = config.flatten();
assert_eq!(flat.len(), 2);
assert_eq!(flat[0].path, "/api/v1/items");
assert_eq!(flat[1].path, "/api/v1/items");
}
#[test]
fn test_route_config_flatten_group_middleware_prepended() {
let mut config = RouteConfig::new();
let mut group = RouteGroup::new("/api");
group.middleware = vec!["auth".to_string(), "log".to_string()];
let mut rule = RouteRule::new(HttpMethod::GET, "/items", "Item@list");
rule.middleware = vec!["cache".to_string()];
group.routes.push(rule);
config.add_group(group);
let flat = config.flatten();
assert_eq!(flat[0].middleware, vec!["auth", "log", "cache"]);
}
#[test]
fn test_route_config_flatten_mixed() {
let mut config = RouteConfig::new();
config.add_route(RouteRule::new(HttpMethod::GET, "/health", "Health@check"));
let mut group = RouteGroup::new("/api");
group.add_route(RouteRule::new(HttpMethod::GET, "/items", "Item@list"));
config.add_group(group);
let flat = config.flatten();
assert_eq!(flat.len(), 2);
assert!(flat.iter().any(|r| r.path == "/health"));
assert!(flat.iter().any(|r| r.path == "/api/items"));
}
#[test]
fn test_route_config_no_conflicts() {
let mut config = RouteConfig::new();
config.add_route(RouteRule::new(HttpMethod::GET, "/users", "User@list"));
config.add_route(RouteRule::new(HttpMethod::POST, "/users", "User@create"));
assert!(config.find_conflicts().is_empty());
}
#[test]
fn test_route_config_conflict_same_method_path() {
let mut config = RouteConfig::new();
config.add_route(RouteRule::new(HttpMethod::GET, "/users", "User@list"));
config.add_route(RouteRule::new(HttpMethod::GET, "/users", "User@all"));
let conflicts = config.find_conflicts();
assert_eq!(conflicts.len(), 1);
let (a, b) = &conflicts[0];
assert_eq!(a.handler, "User@list");
assert_eq!(b.handler, "User@all");
}
#[test]
fn test_route_config_no_conflict_different_method() {
let mut config = RouteConfig::new();
config.add_route(RouteRule::new(HttpMethod::GET, "/users", "User@list"));
config.add_route(RouteRule::new(HttpMethod::DELETE, "/users", "User@delete"));
assert!(config.find_conflicts().is_empty());
}
#[test]
fn test_route_config_conflict_in_group() {
let mut config = RouteConfig::new();
let mut group = RouteGroup::new("/api");
group.add_route(RouteRule::new(HttpMethod::GET, "/items", "Item@list"));
group.add_route(RouteRule::new(HttpMethod::GET, "/items", "Item@all"));
config.add_group(group);
let conflicts = config.find_conflicts();
assert_eq!(conflicts.len(), 1);
}
#[test]
fn test_route_config_conflict_between_top_and_group() {
let mut config = RouteConfig::new();
config.add_route(RouteRule::new(HttpMethod::GET, "/api/items", "Item@list"));
let mut group = RouteGroup::new("/api");
group.add_route(RouteRule::new(HttpMethod::GET, "/items", "Item@all"));
config.add_group(group);
let conflicts = config.find_conflicts();
assert_eq!(conflicts.len(), 1);
}
#[test]
fn test_load_routes_from_yaml_str_simple() {
let yaml = r#"
routes:
- method: GET
path: /users
handler: User@list
- method: POST
path: /users
handler: User@create
"#;
let config = load_routes_from_yaml_str(yaml).unwrap();
assert_eq!(config.routes.len(), 2);
assert_eq!(config.routes[0].method, HttpMethod::GET);
assert_eq!(config.routes[0].path, "/users");
assert_eq!(config.routes[0].handler, "User@list");
assert_eq!(config.routes[1].method, HttpMethod::POST);
}
#[test]
fn test_load_routes_from_yaml_str_with_groups() {
let yaml = r#"
routes:
- method: GET
path: /health
handler: Health@check
groups:
- prefix: /api/v1
middleware: [auth, log]
routes:
- method: GET
path: /items
handler: Item@list
- method: POST
path: /items
handler: Item@create
"#;
let config = load_routes_from_yaml_str(yaml).unwrap();
assert_eq!(config.routes.len(), 1);
assert_eq!(config.groups.len(), 1);
assert_eq!(config.groups[0].prefix, "/api/v1");
assert_eq!(config.groups[0].middleware, vec!["auth", "log"]);
assert_eq!(config.groups[0].routes.len(), 2);
let flat = config.flatten();
assert_eq!(flat.len(), 3);
assert!(flat.iter().any(|r| r.path == "/health"));
assert!(flat.iter().any(|r| r.path == "/api/v1/items"));
}
#[test]
fn test_load_routes_from_yaml_str_with_name_and_middleware() {
let yaml = r#"
routes:
- method: GET
path: /users/{id}
handler: User@show
middleware: [auth, cache]
name: user.show
"#;
let config = load_routes_from_yaml_str(yaml).unwrap();
assert_eq!(config.routes.len(), 1);
let rule = &config.routes[0];
assert_eq!(rule.middleware, vec!["auth", "cache"]);
assert_eq!(rule.name, Some("user.show".to_string()));
}
#[test]
fn test_load_routes_from_yaml_str_empty() {
let yaml = "";
let config = load_routes_from_yaml_str(yaml).unwrap();
assert_eq!(config.routes.len(), 0);
assert_eq!(config.groups.len(), 0);
}
#[test]
fn test_load_routes_from_yaml_str_invalid_method() {
let yaml = r#"
routes:
- method: INVALID
path: /users
handler: User@list
"#;
let result = load_routes_from_yaml_str(yaml);
assert!(result.is_err());
}
#[test]
fn test_load_routes_from_yaml_str_invalid_yaml() {
let yaml = "not: valid: yaml: at: all";
let result = load_routes_from_yaml_str(yaml);
assert!(result.is_err());
}
#[test]
fn test_load_routes_from_json_str_simple() {
let json = r#"{
"routes": [
{"method": "GET", "path": "/users", "handler": "User@list"},
{"method": "POST", "path": "/users", "handler": "User@create"}
]
}"#;
let config = load_routes_from_json_str(json).unwrap();
assert_eq!(config.routes.len(), 2);
assert_eq!(config.routes[0].method, HttpMethod::GET);
assert_eq!(config.routes[1].method, HttpMethod::POST);
}
#[test]
fn test_load_routes_from_json_str_with_groups() {
let json = r#"{
"routes": [
{"method": "GET", "path": "/health", "handler": "Health@check"}
],
"groups": [
{
"prefix": "/api",
"middleware": ["auth"],
"routes": [
{"method": "GET", "path": "/items", "handler": "Item@list"}
]
}
]
}"#;
let config = load_routes_from_json_str(json).unwrap();
assert_eq!(config.routes.len(), 1);
assert_eq!(config.groups.len(), 1);
assert_eq!(config.groups[0].prefix, "/api");
}
#[test]
fn test_load_routes_from_json_str_empty() {
let json = "{}";
let config = load_routes_from_json_str(json).unwrap();
assert_eq!(config.routes.len(), 0);
assert_eq!(config.groups.len(), 0);
}
#[test]
fn test_load_routes_from_json_str_invalid() {
let json = "{not valid json";
let result = load_routes_from_json_str(json);
assert!(result.is_err());
}
#[test]
fn test_convention_route_from_uri_with_app() {
let r = ConventionRoute::from_uri("/oapc/customer/index").unwrap();
assert_eq!(r.app, "oapc");
assert_eq!(r.controller, "Customer");
assert_eq!(r.action, "index");
assert_eq!(r.path, "/oapc/customer/index");
assert_eq!(r.method, HttpMethod::GET);
}
#[test]
fn test_convention_route_from_uri_admin_app() {
let r = ConventionRoute::from_uri("/admin/login/index").unwrap();
assert_eq!(r.app, "admin");
assert_eq!(r.controller, "Login");
assert_eq!(r.action, "index");
}
#[test]
fn test_convention_route_from_uri_root_returns_none() {
assert!(ConventionRoute::from_uri("/").is_none());
assert!(ConventionRoute::from_uri("").is_none());
}
#[test]
fn test_convention_route_from_uri_single_segment() {
let r = ConventionRoute::from_uri("/customer").unwrap();
assert_eq!(r.app, "index");
assert_eq!(r.controller, "Customer");
assert_eq!(r.action, "index");
}
#[test]
fn test_convention_route_from_parsed() {
let parsed = ParsedPath::new("api", "User", "list");
let r = ConventionRoute::from_parsed(parsed).unwrap();
assert_eq!(r.app, "api");
assert_eq!(r.controller, "User");
assert_eq!(r.action, "list");
}
#[test]
fn test_route_registry_new() {
let r = RouteRegistry::new();
assert!(r.attribute_routes.is_empty());
assert!(r.config_routes.is_empty());
assert!(r.convention_routes.is_empty());
assert_eq!(r.total_count(), 0);
}
#[test]
fn test_route_registry_add_attribute_route() {
let mut r = RouteRegistry::new();
r.add_attribute_route(RouteRule::new(HttpMethod::GET, "/users", "User@list"));
assert_eq!(r.attribute_routes.len(), 1);
assert_eq!(r.total_count(), 1);
}
#[test]
fn test_route_registry_add_attribute_routes_batch() {
let mut r = RouteRegistry::new();
r.add_attribute_routes(vec![
RouteRule::new(HttpMethod::GET, "/users", "User@list"),
RouteRule::new(HttpMethod::POST, "/users", "User@create"),
]);
assert_eq!(r.attribute_routes.len(), 2);
}
#[test]
fn test_route_registry_add_config_routes() {
let mut r = RouteRegistry::new();
let mut config = RouteConfig::new();
config.add_route(RouteRule::new(HttpMethod::GET, "/items", "Item@list"));
config.add_route(RouteRule::new(HttpMethod::POST, "/items", "Item@create"));
r.add_config_routes(&config);
assert_eq!(r.config_routes.len(), 2);
}
#[test]
fn test_route_registry_add_convention_route() {
let mut r = RouteRegistry::new();
let cr = ConventionRoute::from_uri("/oapc/customer/index").unwrap();
r.add_convention_route(cr);
assert_eq!(r.convention_routes.len(), 1);
}
#[test]
fn test_route_registry_convention_as_rules() {
let mut r = RouteRegistry::new();
r.add_convention_route(ConventionRoute::from_uri("/oapc/customer/index").unwrap());
r.add_convention_route(ConventionRoute::from_uri("/admin/login/index").unwrap());
let rules = r.convention_as_rules();
assert_eq!(rules.len(), 2);
assert_eq!(rules[0].handler, "Customer@index");
assert_eq!(rules[1].handler, "Login@index");
assert_eq!(
rules[0].name,
Some("convention.oapc.Customer.index".to_string())
);
}
#[test]
fn test_route_registry_merged_rules_attribute_overrides_config() {
let mut r = RouteRegistry::new();
r.add_config_routes(&RouteConfig {
routes: vec![RouteRule::new(HttpMethod::GET, "/users", "User@old")],
groups: vec![],
});
r.add_attribute_route(RouteRule::new(HttpMethod::GET, "/users", "User@new"));
let merged = r.merged_rules();
assert_eq!(merged.len(), 1);
assert_eq!(merged[0].handler, "User@new");
}
#[test]
fn test_route_registry_merged_rules_config_overrides_convention() {
let mut r = RouteRegistry::new();
r.add_convention_route(ConventionRoute::from_uri("/oapc/customer/index").unwrap());
r.add_config_routes(&RouteConfig {
routes: vec![RouteRule::new(
HttpMethod::GET,
"/oapc/customer/index",
"Customer@custom",
)],
groups: vec![],
});
let merged = r.merged_rules();
assert_eq!(merged.len(), 1);
assert_eq!(merged[0].handler, "Customer@custom");
}
#[test]
fn test_route_registry_merged_rules_different_paths_no_override() {
let mut r = RouteRegistry::new();
r.add_attribute_route(RouteRule::new(HttpMethod::GET, "/users", "User@list"));
r.add_config_routes(&RouteConfig {
routes: vec![RouteRule::new(HttpMethod::GET, "/items", "Item@list")],
groups: vec![],
});
r.add_convention_route(ConventionRoute::from_uri("/oapc/customer/index").unwrap());
let merged = r.merged_rules();
assert_eq!(merged.len(), 3);
}
#[test]
fn test_route_registry_attribute_conflicts() {
let mut r = RouteRegistry::new();
r.add_attribute_route(RouteRule::new(HttpMethod::GET, "/users", "User@list"));
r.add_attribute_route(RouteRule::new(HttpMethod::GET, "/users", "User@all"));
let conflicts = r.attribute_conflicts();
assert_eq!(conflicts.len(), 1);
}
#[test]
fn test_route_registry_config_conflicts() {
let mut r = RouteRegistry::new();
r.add_config_routes(&RouteConfig {
routes: vec![
RouteRule::new(HttpMethod::GET, "/users", "User@list"),
RouteRule::new(HttpMethod::GET, "/users", "User@all"),
],
groups: vec![],
});
let conflicts = r.config_conflicts();
assert_eq!(conflicts.len(), 1);
}
#[test]
fn test_route_registry_no_conflicts() {
let mut r = RouteRegistry::new();
r.add_attribute_route(RouteRule::new(HttpMethod::GET, "/users", "User@list"));
r.add_attribute_route(RouteRule::new(HttpMethod::POST, "/users", "User@create"));
assert!(r.attribute_conflicts().is_empty());
}
#[test]
fn test_route_registry_total_count() {
let mut r = RouteRegistry::new();
r.add_attribute_route(RouteRule::new(HttpMethod::GET, "/a", "A@index"));
r.add_config_routes(&RouteConfig {
routes: vec![RouteRule::new(HttpMethod::GET, "/b", "B@index")],
groups: vec![],
});
r.add_convention_route(ConventionRoute::from_uri("/oapc/c/d").unwrap());
assert_eq!(r.total_count(), 3);
}
#[test]
fn test_integration_three_layer_routing() {
let mut r = RouteRegistry::new();
r.add_attribute_routes(vec![
RouteRule::new(HttpMethod::GET, "/users", "User@list"),
RouteRule::new(HttpMethod::POST, "/users", "User@create"),
RouteRule::new(HttpMethod::GET, "/users/{id}", "User@show"),
]);
let yaml = r#"
routes:
- method: GET
path: /items
handler: Item@list
- method: POST
path: /items
handler: Item@create
groups:
- prefix: /api/v1
middleware: [auth]
routes:
- method: GET
path: /orders
handler: Order@list
"#;
let config = load_routes_from_yaml_str(yaml).unwrap();
r.add_config_routes(&config);
r.add_convention_route(ConventionRoute::from_uri("/oapc/customer/index").unwrap());
r.add_convention_route(ConventionRoute::from_uri("/admin/login/index").unwrap());
assert_eq!(r.attribute_routes.len(), 3);
assert_eq!(r.config_routes.len(), 3); assert_eq!(r.convention_routes.len(), 2);
assert_eq!(r.total_count(), 8);
let merged = r.merged_rules();
assert_eq!(merged.len(), 8);
assert!(r.attribute_conflicts().is_empty());
assert!(r.config_conflicts().is_empty());
}
#[test]
fn test_integration_layer_override_priority() {
let mut r = RouteRegistry::new();
r.add_convention_route(ConventionRoute {
app: "index".to_string(),
controller: "User".to_string(),
action: "list".to_string(),
method: HttpMethod::GET,
path: "/users".to_string(),
});
r.add_config_routes(&RouteConfig {
routes: vec![RouteRule::new(HttpMethod::GET, "/users", "User@config")],
groups: vec![],
});
r.add_attribute_route(RouteRule::new(HttpMethod::GET, "/users", "User@attribute"));
let merged = r.merged_rules();
assert_eq!(merged.len(), 1);
assert_eq!(merged[0].handler, "User@attribute");
}
}