use ic_http_certification::{HttpRequest, HttpResponse, Method};
use std::collections::HashMap;
use crate::middleware::MiddlewareFn;
use crate::route_config::RouteConfig;
pub type RouteParams = HashMap<String, String>;
pub type HandlerFn = fn(HttpRequest, RouteParams) -> HttpResponse<'static>;
type MatchResult<'a> = (
&'a HashMap<Method, HandlerFn>,
&'a HashMap<Method, HandlerResultFn>,
RouteParams,
String,
);
pub type HandlerResultFn = fn(HttpRequest, RouteParams) -> HandlerResult;
pub enum HandlerResult {
Response(HttpResponse<'static>),
NotModified,
}
impl From<HttpResponse<'static>> for HandlerResult {
fn from(resp: HttpResponse<'static>) -> Self {
HandlerResult::Response(resp)
}
}
#[derive(Debug, PartialEq, Eq)]
pub enum NodeType {
Static(String),
Param(String),
Wildcard,
}
pub enum RouteResult {
Found(HandlerFn, RouteParams, Option<HandlerResultFn>, String),
MethodNotAllowed(Vec<Method>),
NotFound,
}
pub struct RouteNode {
pub node_type: NodeType,
pub static_children: HashMap<String, RouteNode>,
pub param_child: Option<Box<RouteNode>>,
pub wildcard_child: Option<Box<RouteNode>>,
pub handlers: HashMap<Method, HandlerFn>,
result_handlers: HashMap<Method, HandlerResultFn>,
middlewares: Vec<(String, MiddlewareFn)>,
not_found_handler: Option<HandlerFn>,
route_configs: HashMap<String, RouteConfig>,
}
impl RouteNode {
pub fn new(node_type: NodeType) -> Self {
Self {
node_type,
static_children: HashMap::new(),
param_child: None,
wildcard_child: None,
handlers: HashMap::new(),
result_handlers: HashMap::new(),
middlewares: Vec::new(),
not_found_handler: None,
route_configs: HashMap::new(),
}
}
pub fn set_middleware(&mut self, prefix: &str, mw: MiddlewareFn) {
let normalized = normalize_prefix(prefix);
if let Some(entry) = self.middlewares.iter_mut().find(|(p, _)| *p == normalized) {
entry.1 = mw;
} else {
self.middlewares.push((normalized, mw));
}
self.middlewares.sort_by_key(|(p, _)| segment_count(p));
}
pub fn set_not_found(&mut self, handler: HandlerFn) {
self.not_found_handler = Some(handler);
}
pub fn not_found_handler(&self) -> Option<HandlerFn> {
self.not_found_handler
}
pub fn set_route_config(&mut self, path: &str, config: RouteConfig) {
self.route_configs.insert(path.to_string(), config);
}
pub fn get_route_config(&self, path: &str) -> Option<&RouteConfig> {
self.route_configs.get(path)
}
pub fn skip_certified_paths(&self) -> Vec<String> {
self.route_configs
.iter()
.filter(|(_, config)| {
matches!(
config.certification,
crate::certification::CertificationMode::Skip
)
})
.map(|(path, _)| path.clone())
.collect()
}
pub fn insert(&mut self, path: &str, method: Method, handler: HandlerFn) {
let node = self.get_or_create_node(path);
node.handlers.insert(method, handler);
}
pub fn insert_result(&mut self, path: &str, method: Method, handler: HandlerResultFn) {
let node = self.get_or_create_node(path);
node.result_handlers.insert(method, handler);
}
fn get_or_create_node(&mut self, path: &str) -> &mut RouteNode {
let segments: Vec<&str> = path.split('/').filter(|s| !s.is_empty()).collect();
let mut current = self;
for seg in segments {
match seg {
"*" => {
if current.wildcard_child.is_none() {
current.wildcard_child = Some(Box::new(RouteNode::new(NodeType::Wildcard)));
}
current = current.wildcard_child.as_mut().unwrap();
}
s if s.starts_with(':') => {
let name = s[1..].to_string();
if current.param_child.is_none() {
current.param_child = Some(Box::new(RouteNode::new(NodeType::Param(name))));
}
current = current.param_child.as_mut().unwrap();
}
s => {
current = current
.static_children
.entry(s.to_string())
.or_insert_with(|| RouteNode::new(NodeType::Static(s.to_string())));
}
}
}
current
}
pub fn execute_with_middleware(
&self,
path: &str,
handler: HandlerFn,
req: HttpRequest,
params: RouteParams,
) -> HttpResponse<'static> {
let matching: Vec<MiddlewareFn> = self
.middlewares
.iter()
.filter(|(prefix, _)| path_matches_prefix(path, prefix))
.map(|(_, mw)| *mw)
.collect();
if matching.is_empty() {
return handler(req, params);
}
build_chain(&matching, handler, req, ¶ms)
}
pub fn execute_not_found_with_middleware(
&self,
path: &str,
req: HttpRequest,
) -> Option<HttpResponse<'static>> {
let handler = self.not_found_handler?;
let params = RouteParams::new();
Some(self.execute_with_middleware(path, handler, req, params))
}
pub fn resolve(&self, path: &str, method: &Method) -> RouteResult {
let segments: Vec<_> = path.split('/').filter(|s| !s.is_empty()).collect();
match self._match(&segments) {
Some((handlers, result_handlers, params, pattern)) => {
if let Some(&handler) = handlers.get(method) {
let result_handler = result_handlers.get(method).copied();
RouteResult::Found(handler, params, result_handler, pattern)
} else {
let allowed: Vec<Method> = handlers.keys().cloned().collect();
RouteResult::MethodNotAllowed(allowed)
}
}
None => RouteResult::NotFound,
}
}
pub fn match_path(&self, path: &str) -> Option<MatchResult<'_>> {
let segments: Vec<_> = path.split('/').filter(|s| !s.is_empty()).collect();
self._match(&segments)
}
fn _match(&self, segments: &[&str]) -> Option<MatchResult<'_>> {
if segments.is_empty() {
if !self.handlers.is_empty() {
return Some((
&self.handlers,
&self.result_handlers,
HashMap::new(),
"/".to_string(),
));
}
if let Some(ref wc) = self.wildcard_child {
if !wc.handlers.is_empty() {
let mut params = HashMap::new();
params.insert("*".to_string(), String::new());
return Some((&wc.handlers, &wc.result_handlers, params, "/*".to_string()));
}
}
return None;
}
let head = segments[0];
let tail = &segments[1..];
debug_log!("head: {:?}", head);
if let Some(child) = self.static_children.get(head) {
if let Some((h, rh, p, pattern)) = child._match(tail) {
debug_log!("Static match: {:?}", segments);
let full_pattern = if pattern == "/" {
format!("/{head}")
} else {
format!("/{head}{pattern}")
};
return Some((h, rh, p, full_pattern));
}
}
if let Some(ref child) = self.param_child {
if let NodeType::Param(ref name) = child.node_type {
if let Some((h, rh, mut p, pattern)) = child._match(tail) {
p.insert(name.clone(), head.to_string());
debug_log!("Param match: {:?}", segments);
let full_pattern = if pattern == "/" {
format!("/:{name}")
} else {
format!("/:{name}{pattern}")
};
return Some((h, rh, p, full_pattern));
}
}
}
if let Some(ref child) = self.wildcard_child {
if !segments.is_empty() && !child.handlers.is_empty() {
debug_log!("Wildcard match: {:?}", segments);
let remaining = segments.join("/");
let mut params = HashMap::new();
params.insert("*".to_string(), remaining);
return Some((
&child.handlers,
&child.result_handlers,
params,
"/*".to_string(),
));
}
}
None
}
}
fn path_matches_prefix(path: &str, prefix: &str) -> bool {
if prefix == "/" {
return true;
}
path == prefix || path.starts_with(&format!("{prefix}/"))
}
fn build_chain(
middlewares: &[MiddlewareFn],
handler: HandlerFn,
req: HttpRequest,
params: &RouteParams,
) -> HttpResponse<'static> {
match middlewares.split_first() {
None => handler(req, params.clone()),
Some((&mw, rest)) => {
let next =
|inner_req: HttpRequest, inner_params: &RouteParams| -> HttpResponse<'static> {
build_chain(rest, handler, inner_req, inner_params)
};
mw(req, params, &next)
}
}
}
fn normalize_prefix(prefix: &str) -> String {
let trimmed = prefix.trim_matches('/');
if trimmed.is_empty() {
"/".to_string()
} else {
format!("/{trimmed}")
}
}
fn segment_count(prefix: &str) -> usize {
prefix.split('/').filter(|s| !s.is_empty()).count()
}
#[cfg(test)]
mod tests {
use super::*;
use ic_http_certification::{Method, StatusCode};
use std::{borrow::Cow, str};
fn test_request(path: &str) -> HttpRequest<'_> {
HttpRequest::builder()
.with_method(Method::GET)
.with_url(path)
.build()
}
fn response_with_text(text: &str) -> HttpResponse<'static> {
HttpResponse::builder()
.with_body(Cow::Owned(text.as_bytes().to_vec()))
.with_status_code(StatusCode::OK)
.build()
}
fn resolve_get(root: &RouteNode, path: &str) -> (HandlerFn, RouteParams) {
match root.resolve(path, &Method::GET) {
RouteResult::Found(h, p, _, _) => (h, p),
other => panic!(
"expected Found for GET {path}, got {}",
route_result_name(&other)
),
}
}
fn route_result_name(r: &RouteResult) -> &'static str {
match r {
RouteResult::Found(_, _, _, _) => "Found",
RouteResult::MethodNotAllowed(_) => "MethodNotAllowed",
RouteResult::NotFound => "NotFound",
}
}
fn matched_root(_: HttpRequest, _: RouteParams) -> HttpResponse<'static> {
response_with_text("root")
}
fn matched_404(_: HttpRequest, _: RouteParams) -> HttpResponse<'static> {
response_with_text("404")
}
fn matched_index2(_: HttpRequest, _: RouteParams) -> HttpResponse<'static> {
response_with_text("index2")
}
fn matched_about(_: HttpRequest, _: RouteParams) -> HttpResponse<'static> {
response_with_text("about")
}
fn matched_deep(_: HttpRequest, params: RouteParams) -> HttpResponse<'static> {
response_with_text(&format!("deep: {params:?}"))
}
fn matched_folder(_: HttpRequest, _: RouteParams) -> HttpResponse<'static> {
response_with_text("folder")
}
fn setup_router() -> RouteNode {
let mut root = RouteNode::new(NodeType::Static("".into()));
root.insert("/", Method::GET, matched_root);
root.insert("/*", Method::GET, matched_404);
root.insert("/index2", Method::GET, matched_index2);
root.insert("/about", Method::GET, matched_about);
root.insert("/deep/:pageId", Method::GET, matched_deep);
root.insert("/deep/:pageId/:subpageId", Method::GET, matched_deep);
root.insert("/alsodeep/:pageId/edit", Method::GET, matched_deep);
root.insert("/folder/*", Method::GET, matched_folder);
root
}
fn body_str(resp: HttpResponse<'static>) -> String {
str::from_utf8(resp.body())
.unwrap_or("<invalid utf-8>")
.to_string()
}
#[test]
fn test_root_match() {
let root = setup_router();
let (handler, params) = resolve_get(&root, "/");
assert_eq!(body_str(handler(test_request("/"), params)), "root");
}
#[test]
fn test_404_match() {
let root = setup_router();
let (handler, _) = resolve_get(&root, "/nonexistent");
assert_eq!(
body_str(handler(test_request("/nonexistent"), HashMap::new())),
"404"
);
}
#[test]
fn test_exact_match() {
let root = setup_router();
let (handler, params) = resolve_get(&root, "/index2");
assert_eq!(body_str(handler(test_request("/index2"), params)), "index2");
}
#[test]
fn test_pathless_layout_route_a() {
let mut root = RouteNode::new(NodeType::Static("".into()));
root.insert("/about", Method::GET, matched_about);
let (handler, params) = resolve_get(&root, "/about");
assert_eq!(body_str(handler(test_request("/about"), params)), "about");
}
#[test]
fn test_dynamic_match() {
let root = setup_router();
let (handler, params) = resolve_get(&root, "/deep/page1");
let body = body_str(handler(test_request("/deep/page1"), params));
assert!(body.contains("page1"));
}
#[test]
fn test_posts_postid_edit() {
let root = setup_router();
let (handler, params) = resolve_get(&root, "/alsodeep/page1/edit");
let body = body_str(handler(test_request("/alsodeep/page1/edit"), params));
assert!(body.contains("page1"));
}
#[test]
fn test_nested_dynamic_match() {
let root = setup_router();
let (handler, params) = resolve_get(&root, "/deep/page2/subpage1");
let body = body_str(handler(test_request("/deep/page2/subpage1"), params));
assert!(body.contains("page2"));
assert!(body.contains("subpage1"));
}
#[test]
fn test_wildcard_match() {
let root = setup_router();
let (handler, _) = resolve_get(&root, "/folder/anything");
assert_eq!(
body_str(handler(test_request("/folder/anything"), HashMap::new())),
"folder"
);
}
#[test]
fn test_folder_root_wildcard_match() {
let root = setup_router();
let (handler, _) = resolve_get(&root, "/folder/any");
assert_eq!(
body_str(handler(test_request("/folder/any"), HashMap::new())),
"folder"
);
}
#[test]
fn test_deep_wildcard_multi_segments() {
let root = setup_router();
let (handler, _) = resolve_get(&root, "/folder/a/b/c/d");
assert_eq!(
body_str(handler(test_request("/folder/a/b/c/d"), HashMap::new())),
"folder"
);
}
#[test]
fn test_trailing_slash_static_match() {
let root = setup_router();
let (handler, _) = resolve_get(&root, "/index2/");
assert_eq!(
body_str(handler(test_request("/index2/"), HashMap::new())),
"index2"
);
}
#[test]
fn test_double_slash_matches_normalized() {
let root = setup_router();
let (handler, _) = resolve_get(&root, "//index2");
assert_eq!(
body_str(handler(test_request("//index2"), HashMap::new())),
"index2"
);
}
#[test]
fn test_root_wildcard_captures_full_path() {
let root = setup_router();
let (_, params) = resolve_get(&root, "/a/b/c");
assert_eq!(params.get("*").unwrap(), "a/b/c");
}
#[test]
fn test_folder_wildcard_captures_tail() {
let root = setup_router();
let (handler, params) = resolve_get(&root, "/folder/docs/report.pdf");
assert_eq!(params.get("*").unwrap(), "docs/report.pdf");
assert_eq!(
body_str(handler(
test_request("/folder/docs/report.pdf"),
params.clone()
)),
"folder"
);
}
fn matched_user_files(_: HttpRequest, params: RouteParams) -> HttpResponse<'static> {
response_with_text(&format!("user_files: {params:?}"))
}
#[test]
fn test_mixed_params_and_wildcard() {
let mut root = RouteNode::new(NodeType::Static("".into()));
root.insert("/users/:id/files/*", Method::GET, matched_user_files);
let (_, params) = resolve_get(&root, "/users/42/files/docs/report.pdf");
assert_eq!(params.get("id").unwrap(), "42");
assert_eq!(params.get("*").unwrap(), "docs/report.pdf");
}
#[test]
fn test_empty_wildcard_match() {
let mut root = RouteNode::new(NodeType::Static("".into()));
root.insert("/files/*", Method::GET, matched_folder);
let (handler, params) = resolve_get(&root, "/files/");
assert_eq!(params.get("*").unwrap(), "");
assert_eq!(
body_str(handler(test_request("/files/"), params.clone())),
"folder"
);
}
fn matched_post_handler(_: HttpRequest, _: RouteParams) -> HttpResponse<'static> {
response_with_text("post_handler")
}
fn matched_get_handler(_: HttpRequest, _: RouteParams) -> HttpResponse<'static> {
response_with_text("get_handler")
}
#[test]
fn test_method_dispatch_get_and_post() {
let mut root = RouteNode::new(NodeType::Static("".into()));
root.insert("/api/users", Method::GET, matched_get_handler);
root.insert("/api/users", Method::POST, matched_post_handler);
match root.resolve("/api/users", &Method::GET) {
RouteResult::Found(handler, params, _, _) => {
assert_eq!(
body_str(handler(test_request("/api/users"), params)),
"get_handler"
);
}
other => panic!("expected Found, got {}", route_result_name(&other)),
}
match root.resolve("/api/users", &Method::POST) {
RouteResult::Found(handler, params, _, _) => {
let req = HttpRequest::builder()
.with_method(Method::POST)
.with_url("/api/users")
.build();
assert_eq!(body_str(handler(req, params)), "post_handler");
}
other => panic!("expected Found, got {}", route_result_name(&other)),
}
}
#[test]
fn test_method_not_allowed() {
let mut root = RouteNode::new(NodeType::Static("".into()));
root.insert("/api/users", Method::GET, matched_get_handler);
root.insert("/api/users", Method::POST, matched_post_handler);
match root.resolve("/api/users", &Method::PUT) {
RouteResult::MethodNotAllowed(allowed) => {
let mut names: Vec<&str> = allowed.iter().map(|m| m.as_str()).collect();
names.sort();
assert_eq!(names, vec!["GET", "POST"]);
}
other => panic!(
"expected MethodNotAllowed, got {}",
route_result_name(&other)
),
}
}
#[test]
fn test_unknown_path_returns_not_found() {
let mut root = RouteNode::new(NodeType::Static("".into()));
root.insert("/api/users", Method::GET, matched_get_handler);
assert!(matches!(
root.resolve("/api/nonexistent", &Method::GET),
RouteResult::NotFound
));
}
#[test]
fn test_all_seven_methods() {
let methods = [
Method::GET,
Method::POST,
Method::PUT,
Method::PATCH,
Method::DELETE,
Method::HEAD,
Method::OPTIONS,
];
let mut root = RouteNode::new(NodeType::Static("".into()));
for method in &methods {
root.insert("/test", method.clone(), matched_get_handler);
}
for method in &methods {
match root.resolve("/test", method) {
RouteResult::Found(_, _, _, _) => {}
other => panic!(
"expected Found for method {}, got {}",
method.as_str(),
route_result_name(&other)
),
}
}
}
use std::cell::RefCell;
thread_local! {
static LOG: RefCell<Vec<String>> = const { RefCell::new(Vec::new()) };
}
fn clear_log() {
LOG.with(|l| l.borrow_mut().clear());
}
fn get_log() -> Vec<String> {
LOG.with(|l| l.borrow().clone())
}
fn log_entry(msg: &str) {
LOG.with(|l| l.borrow_mut().push(msg.to_string()));
}
fn logging_handler(_: HttpRequest, _: RouteParams) -> HttpResponse<'static> {
log_entry("handler");
response_with_text("handler_response")
}
fn root_middleware(
req: HttpRequest,
params: &RouteParams,
next: &dyn Fn(HttpRequest, &RouteParams) -> HttpResponse<'static>,
) -> HttpResponse<'static> {
log_entry("root_mw_before");
let resp = next(req, params);
log_entry("root_mw_after");
resp
}
fn api_middleware(
req: HttpRequest,
params: &RouteParams,
next: &dyn Fn(HttpRequest, &RouteParams) -> HttpResponse<'static>,
) -> HttpResponse<'static> {
log_entry("api_mw_before");
let resp = next(req, params);
log_entry("api_mw_after");
resp
}
fn api_v2_middleware(
req: HttpRequest,
params: &RouteParams,
next: &dyn Fn(HttpRequest, &RouteParams) -> HttpResponse<'static>,
) -> HttpResponse<'static> {
log_entry("api_v2_mw_before");
let resp = next(req, params);
log_entry("api_v2_mw_after");
resp
}
#[test]
fn test_root_middleware_runs_on_all_requests() {
clear_log();
let mut root = RouteNode::new(NodeType::Static("".into()));
root.insert("/", Method::GET, logging_handler);
root.insert("/about", Method::GET, logging_handler);
root.insert("/api/users", Method::GET, logging_handler);
root.set_middleware("/", root_middleware);
let (handler, params) = resolve_get(&root, "/");
root.execute_with_middleware("/", handler, test_request("/"), params);
assert!(get_log().contains(&"root_mw_before".to_string()));
assert!(get_log().contains(&"handler".to_string()));
assert!(get_log().contains(&"root_mw_after".to_string()));
clear_log();
let (handler, params) = resolve_get(&root, "/about");
root.execute_with_middleware("/about", handler, test_request("/about"), params);
assert!(get_log().contains(&"root_mw_before".to_string()));
assert!(get_log().contains(&"handler".to_string()));
clear_log();
let (handler, params) = resolve_get(&root, "/api/users");
root.execute_with_middleware("/api/users", handler, test_request("/api/users"), params);
assert!(get_log().contains(&"root_mw_before".to_string()));
assert!(get_log().contains(&"handler".to_string()));
}
#[test]
fn test_scoped_middleware_only_matching_prefix() {
clear_log();
let mut root = RouteNode::new(NodeType::Static("".into()));
root.insert("/api/users", Method::GET, logging_handler);
root.insert("/pages/home", Method::GET, logging_handler);
root.set_middleware("/api", api_middleware);
let (handler, params) = resolve_get(&root, "/api/users");
root.execute_with_middleware("/api/users", handler, test_request("/api/users"), params);
assert!(get_log().contains(&"api_mw_before".to_string()));
assert!(get_log().contains(&"handler".to_string()));
clear_log();
let (handler, params) = resolve_get(&root, "/pages/home");
root.execute_with_middleware("/pages/home", handler, test_request("/pages/home"), params);
assert!(!get_log().contains(&"api_mw_before".to_string()));
assert!(get_log().contains(&"handler".to_string()));
}
#[test]
fn test_middleware_chain_order() {
clear_log();
let mut root = RouteNode::new(NodeType::Static("".into()));
root.insert("/api/v2/data", Method::GET, logging_handler);
root.set_middleware("/", root_middleware);
root.set_middleware("/api", api_middleware);
root.set_middleware("/api/v2", api_v2_middleware);
let (handler, params) = resolve_get(&root, "/api/v2/data");
root.execute_with_middleware(
"/api/v2/data",
handler,
test_request("/api/v2/data"),
params,
);
let log = get_log();
assert_eq!(
log,
vec![
"root_mw_before",
"api_mw_before",
"api_v2_mw_before",
"handler",
"api_v2_mw_after",
"api_mw_after",
"root_mw_after",
]
);
}
#[test]
fn test_middleware_short_circuit() {
fn auth_middleware(
_req: HttpRequest,
_params: &RouteParams,
_next: &dyn Fn(HttpRequest, &RouteParams) -> HttpResponse<'static>,
) -> HttpResponse<'static> {
log_entry("auth_reject");
HttpResponse::builder()
.with_status_code(StatusCode::UNAUTHORIZED)
.with_body(Cow::Owned(b"Unauthorized".to_vec()))
.build()
}
clear_log();
let mut root = RouteNode::new(NodeType::Static("".into()));
root.insert("/secret", Method::GET, logging_handler);
root.set_middleware("/", auth_middleware);
let (handler, params) = resolve_get(&root, "/secret");
let resp =
root.execute_with_middleware("/secret", handler, test_request("/secret"), params);
assert_eq!(resp.status_code(), StatusCode::UNAUTHORIZED);
let log = get_log();
assert!(log.contains(&"auth_reject".to_string()));
assert!(!log.contains(&"handler".to_string()));
}
#[test]
fn test_middleware_modifies_response() {
fn header_middleware(
req: HttpRequest,
params: &RouteParams,
next: &dyn Fn(HttpRequest, &RouteParams) -> HttpResponse<'static>,
) -> HttpResponse<'static> {
let resp = next(req, params);
let mut headers = resp.headers().to_vec();
headers.push(("x-custom".to_string(), "injected".to_string()));
HttpResponse::builder()
.with_status_code(resp.status_code())
.with_headers(headers)
.with_body(resp.body().to_vec())
.build()
}
let mut root = RouteNode::new(NodeType::Static("".into()));
root.insert("/test", Method::GET, logging_handler);
root.set_middleware("/", header_middleware);
let (handler, params) = resolve_get(&root, "/test");
let resp = root.execute_with_middleware("/test", handler, test_request("/test"), params);
let custom_header = resp
.headers()
.iter()
.find(|(k, _)| k == "x-custom")
.map(|(_, v)| v.clone());
assert_eq!(custom_header, Some("injected".to_string()));
assert_eq!(body_str(resp), "handler_response");
}
#[test]
fn test_set_middleware_replaces_previous() {
fn mw_a(
req: HttpRequest,
params: &RouteParams,
next: &dyn Fn(HttpRequest, &RouteParams) -> HttpResponse<'static>,
) -> HttpResponse<'static> {
log_entry("mw_a");
next(req, params)
}
fn mw_b(
req: HttpRequest,
params: &RouteParams,
next: &dyn Fn(HttpRequest, &RouteParams) -> HttpResponse<'static>,
) -> HttpResponse<'static> {
log_entry("mw_b");
next(req, params)
}
clear_log();
let mut root = RouteNode::new(NodeType::Static("".into()));
root.insert("/test", Method::GET, logging_handler);
root.set_middleware("/", mw_a);
root.set_middleware("/", mw_b);
let (handler, params) = resolve_get(&root, "/test");
root.execute_with_middleware("/test", handler, test_request("/test"), params);
let log = get_log();
assert!(!log.contains(&"mw_a".to_string()));
assert!(log.contains(&"mw_b".to_string()));
}
#[test]
fn test_middleware_works_in_both_paths() {
clear_log();
let mut root = RouteNode::new(NodeType::Static("".into()));
fn post_handler(_: HttpRequest, _: RouteParams) -> HttpResponse<'static> {
log_entry("post_handler");
response_with_text("posted")
}
root.insert("/api/data", Method::GET, logging_handler);
root.insert("/api/data", Method::POST, post_handler);
root.set_middleware("/api", api_middleware);
let (handler, params) = resolve_get(&root, "/api/data");
let resp =
root.execute_with_middleware("/api/data", handler, test_request("/api/data"), params);
assert_eq!(body_str(resp), "handler_response");
assert!(get_log().contains(&"api_mw_before".to_string()));
clear_log();
match root.resolve("/api/data", &Method::POST) {
RouteResult::Found(handler, params, _, _) => {
let req = HttpRequest::builder()
.with_method(Method::POST)
.with_url("/api/data")
.build();
let resp = root.execute_with_middleware("/api/data", handler, req, params);
assert_eq!(body_str(resp), "posted");
assert!(get_log().contains(&"api_mw_before".to_string()));
assert!(get_log().contains(&"post_handler".to_string()));
}
other => panic!("expected Found, got {}", route_result_name(&other)),
}
}
fn custom_404_handler(_: HttpRequest, _: RouteParams) -> HttpResponse<'static> {
HttpResponse::builder()
.with_status_code(StatusCode::NOT_FOUND)
.with_headers(vec![("content-type".to_string(), "text/html".to_string())])
.with_body(Cow::Owned(b"<h1>Custom Not Found</h1>".to_vec()))
.build()
}
#[test]
fn test_custom_404_returns_custom_response() {
let mut root = RouteNode::new(NodeType::Static("".into()));
root.insert("/exists", Method::GET, matched_get_handler);
root.set_not_found(custom_404_handler);
let resp = root
.execute_not_found_with_middleware("/nonexistent", test_request("/nonexistent"))
.expect("expected custom 404 response");
assert_eq!(resp.status_code(), StatusCode::NOT_FOUND);
assert_eq!(body_str(resp), "<h1>Custom Not Found</h1>");
}
#[test]
fn test_default_404_without_custom_handler() {
let mut root = RouteNode::new(NodeType::Static("".into()));
root.insert("/exists", Method::GET, matched_get_handler);
let resp =
root.execute_not_found_with_middleware("/nonexistent", test_request("/nonexistent"));
assert!(resp.is_none(), "expected None when no custom 404 is set");
}
#[test]
fn test_custom_404_receives_full_request() {
fn inspecting_404(req: HttpRequest, _: RouteParams) -> HttpResponse<'static> {
let url = req.url().to_string();
response_with_text(&format!("404 for: {url}"))
}
let mut root = RouteNode::new(NodeType::Static("".into()));
root.set_not_found(inspecting_404);
let req = HttpRequest::builder()
.with_method(Method::GET)
.with_url("/some/missing/path")
.build();
let resp = root
.execute_not_found_with_middleware("/some/missing/path", req)
.expect("expected custom 404 response");
let body = body_str(resp);
assert!(
body.contains("/some/missing/path"),
"expected URL in response body, got: {body}"
);
}
#[test]
fn test_custom_404_json_content_type() {
fn json_404(_: HttpRequest, _: RouteParams) -> HttpResponse<'static> {
HttpResponse::builder()
.with_status_code(StatusCode::NOT_FOUND)
.with_headers(vec![(
"content-type".to_string(),
"application/json".to_string(),
)])
.with_body(Cow::Owned(br#"{"error":"not found"}"#.to_vec()))
.build()
}
let mut root = RouteNode::new(NodeType::Static("".into()));
root.set_not_found(json_404);
let resp = root
.execute_not_found_with_middleware("/api/missing", test_request("/api/missing"))
.expect("expected custom 404 response");
assert_eq!(resp.status_code(), StatusCode::NOT_FOUND);
let ct = resp
.headers()
.iter()
.find(|(k, _)| k == "content-type")
.map(|(_, v)| v.clone());
assert_eq!(ct, Some("application/json".to_string()));
assert_eq!(body_str(resp), r#"{"error":"not found"}"#);
}
#[test]
fn test_root_middleware_runs_before_custom_404() {
fn logging_404(_: HttpRequest, _: RouteParams) -> HttpResponse<'static> {
log_entry("custom_404");
response_with_text("custom 404")
}
clear_log();
let mut root = RouteNode::new(NodeType::Static("".into()));
root.insert("/exists", Method::GET, logging_handler);
root.set_middleware("/", root_middleware);
root.set_not_found(logging_404);
let resp = root
.execute_not_found_with_middleware("/nonexistent", test_request("/nonexistent"))
.expect("expected custom 404 response");
let log = get_log();
assert_eq!(
log,
vec!["root_mw_before", "custom_404", "root_mw_after"],
"middleware should wrap the custom 404 handler"
);
assert_eq!(body_str(resp), "custom 404");
}
#[test]
fn from_http_response_for_handler_result() {
let response = HttpResponse::builder()
.with_status_code(StatusCode::OK)
.with_body(Cow::Owned(b"hello".to_vec()))
.build();
let result: HandlerResult = response.into();
match result {
HandlerResult::Response(resp) => {
assert_eq!(resp.status_code(), StatusCode::OK);
assert_eq!(resp.body(), b"hello");
}
HandlerResult::NotModified => panic!("expected Response, got NotModified"),
}
}
#[test]
fn test_empty_segments_ignored() {
let root = setup_router();
let (handler, _) = resolve_get(&root, "/about///");
assert_eq!(
body_str(handler(test_request("/about"), HashMap::new())),
"about"
);
}
#[test]
fn test_url_encoded_characters_in_static_path() {
let mut root = RouteNode::new(NodeType::Static("".into()));
root.insert("/hello%20world", Method::GET, matched_about);
let (handler, params) = resolve_get(&root, "/hello%20world");
assert_eq!(
body_str(handler(test_request("/hello%20world"), params)),
"about"
);
}
#[test]
fn test_url_encoded_characters_in_param() {
let mut root = RouteNode::new(NodeType::Static("".into()));
root.insert("/posts/:id", Method::GET, matched_deep);
let (_, params) = resolve_get(&root, "/posts/hello%20world");
assert_eq!(params.get("id").unwrap(), "hello%20world");
}
#[test]
fn test_very_long_path() {
let mut root = RouteNode::new(NodeType::Static("".into()));
let segments: Vec<String> = (0..100).map(|i| format!("s{i}")).collect();
let path = format!("/{}", segments.join("/"));
root.insert(&path, Method::GET, matched_about);
let (handler, params) = resolve_get(&root, &path);
assert_eq!(body_str(handler(test_request(&path), params)), "about");
}
#[test]
fn test_very_long_path_not_found() {
let root = RouteNode::new(NodeType::Static("".into()));
let segments: Vec<String> = (0..100).map(|i| format!("s{i}")).collect();
let path = format!("/{}", segments.join("/"));
assert!(matches!(
root.resolve(&path, &Method::GET),
RouteResult::NotFound
));
}
#[test]
fn test_many_parameters() {
fn many_param_handler(_: HttpRequest, params: RouteParams) -> HttpResponse<'static> {
response_with_text(&format!(
"{}/{}/{}/{}",
params.get("a").unwrap(),
params.get("b").unwrap(),
params.get("c").unwrap(),
params.get("d").unwrap(),
))
}
let mut root = RouteNode::new(NodeType::Static("".into()));
root.insert("/:a/:b/:c/:d", Method::GET, many_param_handler);
let (handler, params) = resolve_get(&root, "/w/x/y/z");
assert_eq!(params.get("a").unwrap(), "w");
assert_eq!(params.get("b").unwrap(), "x");
assert_eq!(params.get("c").unwrap(), "y");
assert_eq!(params.get("d").unwrap(), "z");
assert_eq!(
body_str(handler(test_request("/w/x/y/z"), params)),
"w/x/y/z"
);
}
#[test]
fn test_static_precedence_over_param() {
fn static_handler(_: HttpRequest, _: RouteParams) -> HttpResponse<'static> {
response_with_text("static")
}
fn param_handler(_: HttpRequest, _: RouteParams) -> HttpResponse<'static> {
response_with_text("param")
}
let mut root = RouteNode::new(NodeType::Static("".into()));
root.insert("/items/special", Method::GET, static_handler);
root.insert("/items/:id", Method::GET, param_handler);
let (handler, _) = resolve_get(&root, "/items/special");
assert_eq!(
body_str(handler(test_request("/items/special"), HashMap::new())),
"static"
);
let (handler, params) = resolve_get(&root, "/items/other");
assert_eq!(
body_str(handler(test_request("/items/other"), params)),
"param"
);
}
#[test]
fn test_param_precedence_over_wildcard() {
fn param_handler(_: HttpRequest, params: RouteParams) -> HttpResponse<'static> {
response_with_text(&format!("param:{}", params.get("id").unwrap()))
}
fn wildcard_handler(_: HttpRequest, _: RouteParams) -> HttpResponse<'static> {
response_with_text("wildcard")
}
let mut root = RouteNode::new(NodeType::Static("".into()));
root.insert("/items/:id", Method::GET, param_handler);
root.insert("/items/*", Method::GET, wildcard_handler);
let (handler, params) = resolve_get(&root, "/items/42");
assert_eq!(
body_str(handler(test_request("/items/42"), params.clone())),
"param:42"
);
let (handler, params) = resolve_get(&root, "/items/42/extra");
assert_eq!(params.get("*").unwrap(), "42/extra");
assert_eq!(
body_str(handler(test_request("/items/42/extra"), params)),
"wildcard"
);
}
#[test]
fn test_root_not_found_when_only_nested() {
let mut root = RouteNode::new(NodeType::Static("".into()));
root.insert("/api/data", Method::GET, matched_about);
assert!(matches!(
root.resolve("/", &Method::GET),
RouteResult::NotFound
));
}
#[test]
fn test_insert_result_and_resolve() {
fn handler(_: HttpRequest, _: RouteParams) -> HttpResponse<'static> {
response_with_text("ok")
}
fn result_handler(_: HttpRequest, _: RouteParams) -> HandlerResult {
HandlerResult::NotModified
}
let mut root = RouteNode::new(NodeType::Static("".into()));
root.insert("/test", Method::GET, handler);
root.insert_result("/test", Method::GET, result_handler);
match root.resolve("/test", &Method::GET) {
RouteResult::Found(_, _, Some(rh), _) => {
let result = rh(test_request("/test"), HashMap::new());
assert!(matches!(result, HandlerResult::NotModified));
}
RouteResult::Found(_, _, None, _) => panic!("expected result handler to be present"),
other => panic!("expected Found, got {}", route_result_name(&other)),
}
}
#[test]
fn test_match_path_returns_handlers() {
let mut root = RouteNode::new(NodeType::Static("".into()));
root.insert("/items/:id", Method::GET, matched_get_handler);
root.insert("/items/:id", Method::POST, matched_post_handler);
let (handlers, _, params, _) = root.match_path("/items/42").expect("should match");
assert_eq!(params.get("id").unwrap(), "42");
assert!(handlers.contains_key(&Method::GET));
assert!(handlers.contains_key(&Method::POST));
assert_eq!(handlers.len(), 2);
}
#[test]
fn test_match_path_returns_none() {
let root = RouteNode::new(NodeType::Static("".into()));
assert!(root.match_path("/nonexistent").is_none());
}
#[test]
fn test_middleware_modifies_request_before_handler() {
fn inject_header_mw(
req: HttpRequest,
params: &RouteParams,
next: &dyn Fn(HttpRequest, &RouteParams) -> HttpResponse<'static>,
) -> HttpResponse<'static> {
let mut headers = req.headers().to_vec();
headers.push(("x-injected".to_string(), "mw-value".to_string()));
let modified = HttpRequest::builder()
.with_method(req.method().clone())
.with_url(req.url())
.with_headers(headers)
.with_body(req.body().to_vec())
.build();
next(modified, params)
}
fn header_checking_handler(req: HttpRequest, _: RouteParams) -> HttpResponse<'static> {
let has_header = req
.headers()
.iter()
.any(|(k, v)| k == "x-injected" && v == "mw-value");
if has_header {
response_with_text("header_present")
} else {
response_with_text("header_missing")
}
}
let mut root = RouteNode::new(NodeType::Static("".into()));
root.insert("/check", Method::GET, header_checking_handler);
root.set_middleware("/", inject_header_mw);
let (handler, params) = resolve_get(&root, "/check");
let resp = root.execute_with_middleware("/check", handler, test_request("/check"), params);
assert_eq!(body_str(resp), "header_present");
}
#[test]
fn test_multiple_middleware_on_not_found() {
fn nf_handler(_: HttpRequest, _: RouteParams) -> HttpResponse<'static> {
log_entry("not_found_handler");
response_with_text("not found")
}
clear_log();
let mut root = RouteNode::new(NodeType::Static("".into()));
root.insert("/api/data", Method::GET, logging_handler);
root.set_middleware("/", root_middleware);
root.set_middleware("/api", api_middleware);
root.set_not_found(nf_handler);
let resp = root
.execute_not_found_with_middleware("/api/missing", test_request("/api/missing"))
.expect("expected not-found response");
let log = get_log();
assert_eq!(
log,
vec![
"root_mw_before",
"api_mw_before",
"not_found_handler",
"api_mw_after",
"root_mw_after",
],
"both root and /api middleware should wrap the not-found handler"
);
assert_eq!(body_str(resp), "not found");
}
#[test]
fn test_not_found_only_root_middleware_for_non_api() {
fn nf_handler(_: HttpRequest, _: RouteParams) -> HttpResponse<'static> {
log_entry("not_found_handler");
response_with_text("not found")
}
clear_log();
let mut root = RouteNode::new(NodeType::Static("".into()));
root.insert("/api/data", Method::GET, logging_handler);
root.set_middleware("/", root_middleware);
root.set_middleware("/api", api_middleware);
root.set_not_found(nf_handler);
let resp = root
.execute_not_found_with_middleware("/other/missing", test_request("/other/missing"))
.expect("expected not-found response");
let log = get_log();
assert_eq!(
log,
vec!["root_mw_before", "not_found_handler", "root_mw_after"],
"/api middleware should NOT fire for /other/missing"
);
assert_eq!(body_str(resp), "not found");
}
#[test]
fn test_middleware_ordering_independent_of_registration_order() {
clear_log();
let mut root = RouteNode::new(NodeType::Static("".into()));
root.insert("/api/v2/data", Method::GET, logging_handler);
root.set_middleware("/api/v2", api_v2_middleware);
root.set_middleware("/api", api_middleware);
root.set_middleware("/", root_middleware);
let (handler, params) = resolve_get(&root, "/api/v2/data");
root.execute_with_middleware(
"/api/v2/data",
handler,
test_request("/api/v2/data"),
params,
);
let log = get_log();
assert_eq!(
log,
vec![
"root_mw_before",
"api_mw_before",
"api_v2_mw_before",
"handler",
"api_v2_mw_after",
"api_mw_after",
"root_mw_after",
],
"order should be root→api→api_v2 regardless of registration order"
);
}
#[test]
fn test_no_middleware_handler_runs_directly() {
clear_log();
let mut root = RouteNode::new(NodeType::Static("".into()));
root.insert("/test", Method::GET, logging_handler);
let (handler, params) = resolve_get(&root, "/test");
let resp = root.execute_with_middleware("/test", handler, test_request("/test"), params);
let log = get_log();
assert_eq!(log, vec!["handler"]);
assert_eq!(body_str(resp), "handler_response");
}
#[test]
fn test_normalize_prefix_canonical() {
assert_eq!(normalize_prefix("/"), "/");
assert_eq!(normalize_prefix(""), "/");
assert_eq!(normalize_prefix("/api"), "/api");
assert_eq!(normalize_prefix("/api/"), "/api");
assert_eq!(normalize_prefix("api"), "/api");
assert_eq!(normalize_prefix("api/v2/"), "/api/v2");
}
#[test]
fn test_segment_count() {
assert_eq!(segment_count("/"), 0);
assert_eq!(segment_count("/api"), 1);
assert_eq!(segment_count("/api/v2"), 2);
assert_eq!(segment_count("/api/v2/data"), 3);
}
#[test]
fn test_path_matches_prefix() {
assert!(path_matches_prefix("/api/data", "/"));
assert!(path_matches_prefix("/", "/"));
assert!(path_matches_prefix("/api", "/api"));
assert!(path_matches_prefix("/api/data", "/api"));
assert!(path_matches_prefix("/api/v2/data", "/api"));
assert!(!path_matches_prefix("/api-v2", "/api"));
assert!(!path_matches_prefix("/apidata", "/api"));
assert!(!path_matches_prefix("/other", "/api"));
}
mod proptests {
use super::*;
use proptest::prelude::*;
fn dummy_handler(_: HttpRequest, _: RouteParams) -> HttpResponse<'static> {
response_with_text("dummy")
}
proptest! {
#[test]
fn inserted_routes_are_always_found(path in "/[a-z]{1,5}(/[a-z]{1,5}){0,4}") {
let mut root = RouteNode::new(NodeType::Static("".into()));
root.insert(&path, Method::GET, dummy_handler);
match root.resolve(&path, &Method::GET) {
RouteResult::Found(_, _, _, _) => {},
_ => panic!("expected Found for inserted path: {path}"),
}
}
#[test]
fn non_inserted_routes_are_not_found(
inserted in "/[a-z]{1,10}",
queried in "/[a-z]{1,10}"
) {
prop_assume!(inserted != queried);
let mut root = RouteNode::new(NodeType::Static("".into()));
root.insert(&inserted, Method::GET, dummy_handler);
match root.resolve(&queried, &Method::GET) {
RouteResult::NotFound => {},
_ => panic!("expected NotFound for non-inserted route: {queried} (inserted: {inserted})"),
}
}
#[test]
fn param_routes_capture_any_segment(
prefix in "/[a-z]{1,5}",
value in "[a-z0-9]{1,20}"
) {
let route = format!("{prefix}/:id");
let path = format!("{prefix}/{value}");
let mut root = RouteNode::new(NodeType::Static("".into()));
root.insert(&route, Method::GET, dummy_handler);
match root.resolve(&path, &Method::GET) {
RouteResult::Found(_, params, _, _) => {
prop_assert_eq!(params.get("id").map(|s| s.as_str()), Some(value.as_str()));
},
other => panic!("expected Found, got {}", route_result_name(&other)),
}
}
#[test]
fn wildcard_routes_capture_remaining_path(
prefix in "/[a-z]{1,5}",
tail in "[a-z0-9]{1,5}(/[a-z0-9]{1,5}){0,3}"
) {
let route = format!("{prefix}/*");
let path = format!("{prefix}/{tail}");
let mut root = RouteNode::new(NodeType::Static("".into()));
root.insert(&route, Method::GET, dummy_handler);
match root.resolve(&path, &Method::GET) {
RouteResult::Found(_, params, _, _) => {
prop_assert_eq!(params.get("*").map(|s| s.as_str()), Some(tail.as_str()));
},
other => panic!("expected Found, got {}", route_result_name(&other)),
}
}
#[test]
fn wrong_method_returns_method_not_allowed(path in "/[a-z]{1,5}(/[a-z]{1,5}){0,3}") {
let mut root = RouteNode::new(NodeType::Static("".into()));
root.insert(&path, Method::GET, dummy_handler);
match root.resolve(&path, &Method::POST) {
RouteResult::MethodNotAllowed(allowed) => {
prop_assert!(allowed.contains(&Method::GET));
},
other => panic!("expected MethodNotAllowed, got {}", route_result_name(&other)),
}
}
#[test]
fn multi_param_routes_capture_all(
a in "[a-z0-9]{1,10}",
b in "[a-z0-9]{1,10}"
) {
let mut root = RouteNode::new(NodeType::Static("".into()));
root.insert("/x/:first/:second", Method::GET, dummy_handler);
let path = format!("/x/{a}/{b}");
match root.resolve(&path, &Method::GET) {
RouteResult::Found(_, params, _, _) => {
prop_assert_eq!(params.get("first").map(|s| s.as_str()), Some(a.as_str()));
prop_assert_eq!(params.get("second").map(|s| s.as_str()), Some(b.as_str()));
},
other => panic!("expected Found, got {}", route_result_name(&other)),
}
}
}
}
#[test]
fn set_and_get_route_config() {
let mut root = RouteNode::new(NodeType::Static("".into()));
root.insert("/api/users", Method::GET, matched_get_handler);
let config = RouteConfig {
certification: crate::certification::CertificationMode::skip(),
ttl: Some(std::time::Duration::from_secs(60)),
headers: vec![],
};
root.set_route_config("/api/users", config);
let rc = root
.get_route_config("/api/users")
.expect("should find config");
assert!(matches!(
rc.certification,
crate::certification::CertificationMode::Skip
));
assert_eq!(rc.ttl, Some(std::time::Duration::from_secs(60)));
}
#[test]
fn get_route_config_returns_none_for_unknown() {
let root = RouteNode::new(NodeType::Static("".into()));
assert!(root.get_route_config("/nonexistent").is_none());
}
#[test]
fn set_route_config_replaces_existing() {
let mut root = RouteNode::new(NodeType::Static("".into()));
root.insert("/test", Method::GET, matched_get_handler);
let config1 = RouteConfig {
certification: crate::certification::CertificationMode::skip(),
ttl: None,
headers: vec![],
};
root.set_route_config("/test", config1);
let config2 = RouteConfig {
certification: crate::certification::CertificationMode::authenticated(),
ttl: Some(std::time::Duration::from_secs(300)),
headers: vec![],
};
root.set_route_config("/test", config2);
let rc = root.get_route_config("/test").expect("should find config");
assert!(matches!(
rc.certification,
crate::certification::CertificationMode::Full(_)
));
assert_eq!(rc.ttl, Some(std::time::Duration::from_secs(300)));
}
#[test]
fn routes_without_config_default_to_response_only() {
let mut root = RouteNode::new(NodeType::Static("".into()));
root.insert("/page", Method::GET, matched_get_handler);
assert!(root.get_route_config("/page").is_none());
}
#[test]
fn resolve_returns_correct_pattern_for_static_route() {
let mut root = RouteNode::new(NodeType::Static("".into()));
root.insert("/api/users", Method::GET, matched_get_handler);
match root.resolve("/api/users", &Method::GET) {
RouteResult::Found(_, _, _, pattern) => {
assert_eq!(pattern, "/api/users");
}
other => panic!("expected Found, got {}", route_result_name(&other)),
}
}
#[test]
fn resolve_returns_correct_pattern_for_param_route() {
let mut root = RouteNode::new(NodeType::Static("".into()));
root.insert("/users/:id", Method::GET, matched_get_handler);
match root.resolve("/users/42", &Method::GET) {
RouteResult::Found(_, params, _, pattern) => {
assert_eq!(pattern, "/users/:id");
assert_eq!(params.get("id").unwrap(), "42");
}
other => panic!("expected Found, got {}", route_result_name(&other)),
}
}
#[test]
fn resolve_returns_correct_pattern_for_wildcard_route() {
let mut root = RouteNode::new(NodeType::Static("".into()));
root.insert("/files/*", Method::GET, matched_get_handler);
match root.resolve("/files/a/b/c", &Method::GET) {
RouteResult::Found(_, params, _, pattern) => {
assert_eq!(pattern, "/files/*");
assert_eq!(params.get("*").unwrap(), "a/b/c");
}
other => panic!("expected Found, got {}", route_result_name(&other)),
}
}
#[test]
fn resolve_returns_correct_pattern_for_root_route() {
let mut root = RouteNode::new(NodeType::Static("".into()));
root.insert("/", Method::GET, matched_get_handler);
match root.resolve("/", &Method::GET) {
RouteResult::Found(_, _, _, pattern) => {
assert_eq!(pattern, "/");
}
other => panic!("expected Found, got {}", route_result_name(&other)),
}
}
#[test]
fn resolve_returns_correct_pattern_for_nested_param() {
let mut root = RouteNode::new(NodeType::Static("".into()));
root.insert(
"/posts/:postId/comments/:commentId",
Method::GET,
matched_get_handler,
);
match root.resolve("/posts/10/comments/20", &Method::GET) {
RouteResult::Found(_, params, _, pattern) => {
assert_eq!(pattern, "/posts/:postId/comments/:commentId");
assert_eq!(params.get("postId").unwrap(), "10");
assert_eq!(params.get("commentId").unwrap(), "20");
}
other => panic!("expected Found, got {}", route_result_name(&other)),
}
}
#[test]
fn route_config_lookup_via_pattern() {
let mut root = RouteNode::new(NodeType::Static("".into()));
root.insert("/users/:id", Method::GET, matched_get_handler);
let config = RouteConfig {
certification: crate::certification::CertificationMode::skip(),
ttl: None,
headers: vec![],
};
root.set_route_config("/users/:id", config);
match root.resolve("/users/42", &Method::GET) {
RouteResult::Found(_, _, _, pattern) => {
let rc = root.get_route_config(&pattern).expect("should find config");
assert!(matches!(
rc.certification,
crate::certification::CertificationMode::Skip
));
}
other => panic!("expected Found, got {}", route_result_name(&other)),
}
}
#[test]
fn test_get_or_create_node_creates_intermediate_nodes() {
let mut root = RouteNode::new(NodeType::Static("".into()));
let _node = root.get_or_create_node("/api/v2/data");
assert_eq!(root.static_children.len(), 1);
let api = root.static_children.get("api").expect("api child");
assert_eq!(api.node_type, NodeType::Static("api".into()));
assert_eq!(api.static_children.len(), 1);
let v2 = api.static_children.get("v2").expect("v2 child");
assert_eq!(v2.node_type, NodeType::Static("v2".into()));
assert_eq!(v2.static_children.len(), 1);
let data = v2.static_children.get("data").expect("data child");
assert_eq!(data.node_type, NodeType::Static("data".into()));
}
#[test]
fn test_get_or_create_node_idempotent() {
let mut root = RouteNode::new(NodeType::Static("".into()));
let node = root.get_or_create_node("/api/users");
node.handlers.insert(Method::GET, matched_get_handler);
let node2 = root.get_or_create_node("/api/users");
assert!(
node2.handlers.contains_key(&Method::GET),
"second call should return the same node with the handler intact"
);
assert_eq!(root.static_children.len(), 1);
let api = root.static_children.get("api").expect("api child");
assert_eq!(api.static_children.len(), 1);
}
#[test]
fn test_get_or_create_node_root_path() {
let mut root = RouteNode::new(NodeType::Static("".into()));
let node = root.get_or_create_node("/");
node.handlers.insert(Method::GET, matched_get_handler);
assert!(root.handlers.contains_key(&Method::GET));
assert!(root.static_children.is_empty());
assert!(root.param_child.is_none());
assert!(root.wildcard_child.is_none());
}
#[test]
fn test_get_or_create_node_param_and_wildcard() {
let mut root = RouteNode::new(NodeType::Static("".into()));
let _node = root.get_or_create_node("/users/:id/files/*");
assert_eq!(root.static_children.len(), 1);
let users = root.static_children.get("users").expect("users child");
assert_eq!(users.node_type, NodeType::Static("users".into()));
let param = users.param_child.as_ref().expect("param child");
assert_eq!(param.node_type, NodeType::Param("id".into()));
assert_eq!(param.static_children.len(), 1);
let files = param.static_children.get("files").expect("files child");
assert_eq!(files.node_type, NodeType::Static("files".into()));
let wc = files.wildcard_child.as_ref().expect("wildcard child");
assert_eq!(wc.node_type, NodeType::Wildcard);
}
#[test]
fn test_param_with_percent_encoded_space_resolves_raw() {
let mut root = RouteNode::new(NodeType::Static("".into()));
root.insert("/posts/:id", Method::GET, matched_deep);
let (_, params) = resolve_get(&root, "/posts/hello%20world");
assert_eq!(
params.get("id").unwrap(),
"hello%20world",
"trie should store the raw percent-encoded value; decoding happens in generated code"
);
}
#[test]
fn test_wildcard_with_percent_encoded_space_resolves_raw() {
let mut root = RouteNode::new(NodeType::Static("".into()));
root.insert("/files/*", Method::GET, matched_folder);
let (_, params) = resolve_get(&root, "/files/hello%20world/doc.pdf");
assert_eq!(
params.get("*").unwrap(),
"hello%20world/doc.pdf",
"trie should store the raw percent-encoded wildcard value"
);
}
#[test]
fn multiple_param_children_first_wins() {
fn handler_a(_: HttpRequest, params: RouteParams) -> HttpResponse<'static> {
response_with_text(&format!("a:{}", params.get("a").unwrap_or(&String::new())))
}
fn handler_b(_: HttpRequest, params: RouteParams) -> HttpResponse<'static> {
response_with_text(&format!("b:{}", params.get("b").unwrap_or(&String::new())))
}
let mut root = RouteNode::new(NodeType::Static("".into()));
root.insert("/items/:a", Method::GET, handler_a);
root.insert("/items/:b", Method::POST, handler_b);
let (handler, params) = resolve_get(&root, "/items/42");
assert_eq!(params.get("a"), Some(&"42".to_string()));
assert_eq!(params.get("b"), None);
assert_eq!(body_str(handler(test_request("/items/42"), params)), "a:42");
match root.resolve("/items/99", &Method::POST) {
RouteResult::Found(handler, params, _, _) => {
assert_eq!(params.get("a"), Some(&"99".to_string()));
assert_eq!(params.get("b"), None);
assert_eq!(body_str(handler(test_request("/items/99"), params)), "b:");
}
other => panic!("expected Found, got {}", route_result_name(&other)),
}
}
#[test]
fn wildcard_consumes_remaining_segments() {
let mut root = RouteNode::new(NodeType::Static("".into()));
root.insert("/files/*", Method::GET, matched_folder);
let (_, params) = resolve_get(&root, "/files/a");
assert_eq!(params.get("*").unwrap(), "a");
let (_, params) = resolve_get(&root, "/files/a/b/c");
assert_eq!(params.get("*").unwrap(), "a/b/c");
let (_, params) = resolve_get(&root, "/files/a/b/c/d/e");
assert_eq!(params.get("*").unwrap(), "a/b/c/d/e");
}
#[test]
fn post_wildcard_segments_unreachable() {
fn edit_handler(_: HttpRequest, _: RouteParams) -> HttpResponse<'static> {
response_with_text("edit")
}
let mut root = RouteNode::new(NodeType::Static("".into()));
root.insert("/files/*", Method::GET, matched_folder);
root.insert("/files/*/edit", Method::GET, edit_handler);
let (handler, params) = resolve_get(&root, "/files/something/edit");
assert_eq!(params.get("*").unwrap(), "something/edit");
assert_eq!(
body_str(handler(test_request("/files/something/edit"), params)),
"folder"
);
}
#[test]
fn empty_path_resolves_to_root() {
let mut root = RouteNode::new(NodeType::Static("".into()));
root.insert("/", Method::GET, matched_root);
let (handler, params) = resolve_get(&root, "/");
assert!(params.is_empty());
assert_eq!(body_str(handler(test_request("/"), params)), "root");
}
#[test]
fn trailing_slash_normalization() {
let mut root = RouteNode::new(NodeType::Static("".into()));
root.insert("/about", Method::GET, matched_about);
let (handler, _) = resolve_get(&root, "/about");
assert_eq!(
body_str(handler(test_request("/about"), HashMap::new())),
"about"
);
let (handler, _) = resolve_get(&root, "/about/");
assert_eq!(
body_str(handler(test_request("/about/"), HashMap::new())),
"about"
);
}
}