use crate::request::Method;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum TrailingSlashMode {
#[default]
Strict,
Redirect,
RedirectWithSlash,
MatchBoth,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Converter {
Str,
Int,
Float,
Uuid,
Path,
}
impl Converter {
#[must_use]
pub fn matches(&self, value: &str) -> bool {
match self {
Self::Str => value != ".." && value != ".",
Self::Int => value.parse::<i64>().is_ok(),
Self::Float => value.parse::<f64>().is_ok_and(f64::is_finite),
Self::Uuid => is_uuid(value),
Self::Path => !path_has_traversal(value),
}
}
#[must_use]
pub fn parse(s: &str) -> Self {
match s {
"int" => Self::Int,
"float" => Self::Float,
"uuid" => Self::Uuid,
"path" => Self::Path,
_ => Self::Str,
}
}
}
fn path_has_traversal(value: &str) -> bool {
value.split('/').any(|seg| seg == ".." || seg == ".")
}
fn is_uuid(s: &str) -> bool {
if s.len() != 36 {
return false;
}
let parts: Vec<_> = s.split('-').collect();
if parts.len() != 5 {
return false;
}
parts[0].len() == 8
&& parts[1].len() == 4
&& parts[2].len() == 4
&& parts[3].len() == 4
&& parts[4].len() == 12
&& parts
.iter()
.all(|p| p.chars().all(|c| c.is_ascii_hexdigit()))
}
#[derive(Debug, Clone)]
pub struct ParamInfo {
pub name: String,
pub converter: Converter,
}
#[derive(Debug, Clone)]
pub enum PathSegment {
Static(String),
Param(ParamInfo),
}
#[derive(Debug, Clone)]
pub struct RoutePattern {
pub pattern: String,
pub segments: Vec<PathSegment>,
pub has_path_converter: bool,
}
impl RoutePattern {
#[must_use]
pub fn parse(pattern: &str) -> Self {
let segments = parse_path_segments(pattern);
let has_path_converter = matches!(
segments.last(),
Some(PathSegment::Param(ParamInfo {
converter: Converter::Path,
..
}))
);
Self {
pattern: pattern.to_string(),
segments,
has_path_converter,
}
}
#[must_use]
pub fn match_path<'a>(&self, path: &'a str) -> Option<Vec<(String, &'a str)>> {
let path_ranges = segment_ranges(path);
let mut path_segments: Vec<&'a str> = Vec::with_capacity(path_ranges.len());
for (start, end) in &path_ranges {
path_segments.push(&path[*start..*end]);
}
let mut params = Vec::new();
let mut path_idx = 0;
let last_end = path_ranges.last().map_or(0, |(_, end)| *end);
for segment in &self.segments {
match segment {
PathSegment::Static(expected) => {
if path_idx >= path_segments.len() || path_segments[path_idx] != expected {
return None;
}
path_idx += 1;
}
PathSegment::Param(info) => {
if path_idx >= path_segments.len() {
return None;
}
if info.converter == Converter::Path {
let start = path_ranges[path_idx].0;
let value = &path[start..last_end];
if path_has_traversal(value) {
return None;
}
params.push((info.name.clone(), value));
path_idx = path_segments.len();
} else {
let value = path_segments[path_idx];
if !info.converter.matches(value) {
return None;
}
params.push((info.name.clone(), value));
path_idx += 1;
}
}
}
}
if path_idx != path_segments.len() && !self.has_path_converter {
return None;
}
Some(params)
}
#[must_use]
pub fn could_match(&self, path: &str) -> bool {
self.match_path(path).is_some()
}
}
fn parse_path_segments(path: &str) -> Vec<PathSegment> {
path.split('/')
.filter(|s| !s.is_empty())
.map(|s| {
if s.starts_with('{') && s.ends_with('}') {
let inner = &s[1..s.len() - 1];
let (name, converter) = if let Some(pos) = inner.find(':') {
let conv = Converter::parse(&inner[pos + 1..]);
(inner[..pos].to_string(), conv)
} else {
(inner.to_string(), Converter::Str)
};
PathSegment::Param(ParamInfo { name, converter })
} else {
PathSegment::Static(s.to_string())
}
})
.collect()
}
fn segment_ranges(path: &str) -> Vec<(usize, usize)> {
let bytes = path.as_bytes();
let mut ranges = Vec::new();
let mut idx = 0;
while idx < bytes.len() {
while idx < bytes.len() && bytes[idx] == b'/' {
idx += 1;
}
if idx >= bytes.len() {
break;
}
let start = idx;
while idx < bytes.len() && bytes[idx] != b'/' {
idx += 1;
}
ranges.push((start, idx));
}
ranges
}
#[derive(Debug)]
pub enum RouteLookup<'a, T> {
Match {
route: &'a T,
params: Vec<(String, String)>,
},
MethodNotAllowed {
allowed: Vec<Method>,
},
Redirect {
target: String,
},
NotFound,
}
pub struct RouteTable<T> {
routes: Vec<(Method, RoutePattern, T)>,
}
impl<T> RouteTable<T> {
#[must_use]
pub fn new() -> Self {
Self { routes: Vec::new() }
}
pub fn add(&mut self, method: Method, pattern: &str, data: T) {
let parsed = RoutePattern::parse(pattern);
self.routes.push((method, parsed, data));
}
#[must_use]
pub fn lookup(&self, path: &str, method: Method) -> RouteLookup<'_, T> {
for (route_method, pattern, data) in &self.routes {
if let Some(params) = pattern.match_path(path) {
let owned_params: Vec<(String, String)> = params
.into_iter()
.map(|(name, value)| (name, value.to_string()))
.collect();
if *route_method == method {
return RouteLookup::Match {
route: data,
params: owned_params,
};
}
if method == Method::Head && *route_method == Method::Get {
return RouteLookup::Match {
route: data,
params: owned_params,
};
}
}
}
let mut allowed_methods: Vec<Method> = Vec::new();
for (route_method, pattern, _) in &self.routes {
if pattern.could_match(path) && !allowed_methods.contains(route_method) {
allowed_methods.push(*route_method);
}
}
if !allowed_methods.is_empty() {
if allowed_methods.contains(&Method::Get) && !allowed_methods.contains(&Method::Head) {
allowed_methods.push(Method::Head);
}
allowed_methods.sort_by_key(|m| method_order(*m));
return RouteLookup::MethodNotAllowed {
allowed: allowed_methods,
};
}
RouteLookup::NotFound
}
#[must_use]
pub fn lookup_with_trailing_slash(
&self,
path: &str,
method: Method,
mode: TrailingSlashMode,
) -> RouteLookup<'_, T> {
let result = self.lookup(path, method);
if !matches!(result, RouteLookup::NotFound) {
return result;
}
if mode == TrailingSlashMode::Strict {
return result;
}
let has_trailing_slash = path.len() > 1 && path.ends_with('/');
let alt_path = if has_trailing_slash {
&path[..path.len() - 1]
} else {
return self.lookup_with_trailing_slash_add(path, method, mode);
};
let alt_result = self.lookup(alt_path, method);
match (&alt_result, mode) {
(RouteLookup::Match { .. }, TrailingSlashMode::Redirect) => {
RouteLookup::Redirect {
target: alt_path.to_string(),
}
}
(RouteLookup::Match { route, params }, TrailingSlashMode::MatchBoth) => {
RouteLookup::Match {
route,
params: params.clone(),
}
}
(RouteLookup::MethodNotAllowed { allowed: _ }, TrailingSlashMode::Redirect) => {
RouteLookup::Redirect {
target: alt_path.to_string(),
}
}
(RouteLookup::MethodNotAllowed { allowed }, TrailingSlashMode::MatchBoth) => {
RouteLookup::MethodNotAllowed {
allowed: allowed.clone(),
}
}
_ => result, }
}
fn lookup_with_trailing_slash_add(
&self,
path: &str,
method: Method,
mode: TrailingSlashMode,
) -> RouteLookup<'_, T> {
let with_slash = format!("{}/", path);
let alt_result = self.lookup(&with_slash, method);
match (&alt_result, mode) {
(RouteLookup::Match { .. }, TrailingSlashMode::RedirectWithSlash) => {
RouteLookup::Redirect { target: with_slash }
}
(RouteLookup::Match { route, params }, TrailingSlashMode::MatchBoth) => {
RouteLookup::Match {
route,
params: params.clone(),
}
}
(
RouteLookup::MethodNotAllowed { allowed: _ },
TrailingSlashMode::RedirectWithSlash,
) => {
RouteLookup::Redirect { target: with_slash }
}
(RouteLookup::MethodNotAllowed { allowed }, TrailingSlashMode::MatchBoth) => {
RouteLookup::MethodNotAllowed {
allowed: allowed.clone(),
}
}
_ => RouteLookup::NotFound,
}
}
#[must_use]
pub fn len(&self) -> usize {
self.routes.len()
}
#[must_use]
pub fn is_empty(&self) -> bool {
self.routes.is_empty()
}
}
impl<T> Default for RouteTable<T> {
fn default() -> Self {
Self::new()
}
}
#[must_use]
pub fn method_order(method: Method) -> u8 {
match method {
Method::Get => 0,
Method::Head => 1,
Method::Post => 2,
Method::Put => 3,
Method::Delete => 4,
Method::Patch => 5,
Method::Options => 6,
Method::Trace => 7,
}
}
#[must_use]
pub fn format_allow_header(methods: &[Method]) -> String {
methods
.iter()
.map(|m| m.as_str())
.collect::<Vec<_>>()
.join(", ")
}
use std::collections::HashMap;
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum UrlError {
RouteNotFound { name: String },
MissingParam { name: String, param: String },
InvalidParam {
name: String,
param: String,
value: String,
},
}
impl std::fmt::Display for UrlError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::RouteNotFound { name } => {
write!(f, "route '{}' not found", name)
}
Self::MissingParam { name, param } => {
write!(f, "route '{}' requires parameter '{}'", name, param)
}
Self::InvalidParam { name, param, value } => {
write!(
f,
"route '{}' parameter '{}': invalid value '{}'",
name, param, value
)
}
}
}
}
impl std::error::Error for UrlError {}
#[derive(Debug, Clone, Default)]
pub struct UrlRegistry {
routes: HashMap<String, RoutePattern>,
root_path: String,
}
impl UrlRegistry {
#[must_use]
pub fn new() -> Self {
Self {
routes: HashMap::new(),
root_path: String::new(),
}
}
#[must_use]
pub fn with_root_path(root_path: impl Into<String>) -> Self {
let mut path = root_path.into();
while path.ends_with('/') {
path.pop();
}
Self {
routes: HashMap::new(),
root_path: path,
}
}
pub fn set_root_path(&mut self, root_path: impl Into<String>) {
let mut path = root_path.into();
while path.ends_with('/') {
path.pop();
}
self.root_path = path;
}
#[must_use]
pub fn root_path(&self) -> &str {
&self.root_path
}
pub fn register(&mut self, name: impl Into<String>, pattern: &str) {
let name = name.into();
let parsed = RoutePattern::parse(pattern);
self.routes.insert(name, parsed);
}
#[must_use]
pub fn has_route(&self, name: &str) -> bool {
self.routes.contains_key(name)
}
#[must_use]
pub fn get_pattern(&self, name: &str) -> Option<&str> {
self.routes.get(name).map(|p| p.pattern.as_str())
}
pub fn url_for(
&self,
name: &str,
params: &[(&str, &str)],
query: &[(&str, &str)],
) -> Result<String, UrlError> {
let pattern = self
.routes
.get(name)
.ok_or_else(|| UrlError::RouteNotFound {
name: name.to_string(),
})?;
let param_map: HashMap<&str, &str> = params.iter().copied().collect();
let mut path = String::new();
if !self.root_path.is_empty() {
path.push_str(&self.root_path);
}
let has_segments = !pattern.segments.is_empty();
for segment in &pattern.segments {
path.push('/');
match segment {
PathSegment::Static(s) => {
path.push_str(s);
}
PathSegment::Param(info) => {
let value = *param_map.get(info.name.as_str()).ok_or_else(|| {
UrlError::MissingParam {
name: name.to_string(),
param: info.name.clone(),
}
})?;
if !info.converter.matches(value) {
return Err(UrlError::InvalidParam {
name: name.to_string(),
param: info.name.clone(),
value: value.to_string(),
});
}
if info.converter == Converter::Path {
path.push_str(value);
} else {
path.push_str(&url_encode_path_segment(value));
}
}
}
}
if path.is_empty() || (!has_segments && !self.root_path.is_empty()) {
path.push('/');
}
if !query.is_empty() {
path.push('?');
for (i, (key, value)) in query.iter().enumerate() {
if i > 0 {
path.push('&');
}
path.push_str(&url_encode(key));
path.push('=');
path.push_str(&url_encode(value));
}
}
Ok(path)
}
#[must_use]
pub fn len(&self) -> usize {
self.routes.len()
}
#[must_use]
pub fn is_empty(&self) -> bool {
self.routes.is_empty()
}
pub fn route_names(&self) -> impl Iterator<Item = &str> {
self.routes.keys().map(String::as_str)
}
}
#[must_use]
pub fn url_encode(s: &str) -> String {
let mut result = String::with_capacity(s.len() * 3);
for byte in s.bytes() {
match byte {
b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'.' | b'_' | b'~' => {
result.push(byte as char);
}
_ => {
result.push('%');
result.push(
char::from_digit(u32::from(byte >> 4), 16)
.unwrap()
.to_ascii_uppercase(),
);
result.push(
char::from_digit(u32::from(byte & 0xF), 16)
.unwrap()
.to_ascii_uppercase(),
);
}
}
}
result
}
#[must_use]
pub fn url_encode_path_segment(s: &str) -> String {
let mut result = String::with_capacity(s.len() * 3);
for byte in s.bytes() {
match byte {
b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'.' | b'_' | b'~' | b'/' => {
result.push(byte as char);
}
_ => {
result.push('%');
result.push(
char::from_digit(u32::from(byte >> 4), 16)
.unwrap()
.to_ascii_uppercase(),
);
result.push(
char::from_digit(u32::from(byte & 0xF), 16)
.unwrap()
.to_ascii_uppercase(),
);
}
}
}
result
}
#[must_use]
pub fn url_decode(s: &str) -> Option<String> {
let mut result = Vec::with_capacity(s.len());
let mut bytes = s.bytes();
while let Some(byte) = bytes.next() {
if byte == b'%' {
let hi = bytes.next()?;
let lo = bytes.next()?;
let hi = char::from(hi).to_digit(16)?;
let lo = char::from(lo).to_digit(16)?;
result.push((hi * 16 + lo) as u8);
} else if byte == b'+' {
result.push(b' ');
} else {
result.push(byte);
}
}
String::from_utf8(result).ok()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn converter_str_matches_anything() {
assert!(Converter::Str.matches("hello"));
assert!(Converter::Str.matches("123"));
assert!(Converter::Str.matches(""));
}
#[test]
fn converter_int_matches_integers() {
assert!(Converter::Int.matches("123"));
assert!(Converter::Int.matches("-456"));
assert!(Converter::Int.matches("0"));
assert!(!Converter::Int.matches("12.34"));
assert!(!Converter::Int.matches("abc"));
assert!(!Converter::Int.matches(""));
}
#[test]
fn converter_float_matches_floats() {
assert!(Converter::Float.matches("3.14"));
assert!(Converter::Float.matches("42"));
assert!(Converter::Float.matches("-1.5"));
assert!(Converter::Float.matches("1e10"));
assert!(!Converter::Float.matches("abc"));
}
#[test]
fn converter_uuid_matches_uuids() {
assert!(Converter::Uuid.matches("550e8400-e29b-41d4-a716-446655440000"));
assert!(Converter::Uuid.matches("550E8400-E29B-41D4-A716-446655440000"));
assert!(!Converter::Uuid.matches("not-a-uuid"));
assert!(!Converter::Uuid.matches("550e8400e29b41d4a716446655440000")); }
#[test]
fn parse_static_path() {
let pattern = RoutePattern::parse("/users");
assert_eq!(pattern.segments.len(), 1);
assert!(matches!(&pattern.segments[0], PathSegment::Static(s) if s == "users"));
}
#[test]
fn parse_path_with_param() {
let pattern = RoutePattern::parse("/users/{id}");
assert_eq!(pattern.segments.len(), 2);
assert!(matches!(&pattern.segments[0], PathSegment::Static(s) if s == "users"));
assert!(
matches!(&pattern.segments[1], PathSegment::Param(info) if info.name == "id" && info.converter == Converter::Str)
);
}
#[test]
fn parse_path_with_typed_param() {
let pattern = RoutePattern::parse("/items/{id:int}");
assert_eq!(pattern.segments.len(), 2);
assert!(
matches!(&pattern.segments[1], PathSegment::Param(info) if info.name == "id" && info.converter == Converter::Int)
);
}
#[test]
fn parse_path_with_path_converter() {
let pattern = RoutePattern::parse("/files/{path:path}");
assert!(pattern.has_path_converter);
}
#[test]
fn match_static_path() {
let pattern = RoutePattern::parse("/users");
assert!(pattern.match_path("/users").is_some());
assert!(pattern.match_path("/items").is_none());
}
#[test]
fn match_path_extracts_params() {
let pattern = RoutePattern::parse("/users/{id}");
let params = pattern.match_path("/users/42").unwrap();
assert_eq!(params.len(), 1);
assert_eq!(params[0].0, "id");
assert_eq!(params[0].1, "42");
}
#[test]
fn match_path_validates_int_converter() {
let pattern = RoutePattern::parse("/items/{id:int}");
assert!(pattern.match_path("/items/123").is_some());
assert!(pattern.match_path("/items/abc").is_none());
}
#[test]
fn match_path_validates_uuid_converter() {
let pattern = RoutePattern::parse("/objects/{id:uuid}");
assert!(
pattern
.match_path("/objects/550e8400-e29b-41d4-a716-446655440000")
.is_some()
);
assert!(pattern.match_path("/objects/not-a-uuid").is_none());
}
#[test]
fn match_path_converter_captures_slashes() {
let pattern = RoutePattern::parse("/files/{path:path}");
let params = pattern.match_path("/files/a/b/c.txt").unwrap();
assert_eq!(params[0].0, "path");
assert_eq!(params[0].1, "a/b/c.txt");
}
#[test]
fn match_multiple_params() {
let pattern = RoutePattern::parse("/users/{user_id}/posts/{post_id}");
let params = pattern.match_path("/users/42/posts/99").unwrap();
assert_eq!(params.len(), 2);
assert_eq!(params[0].0, "user_id");
assert_eq!(params[0].1, "42");
assert_eq!(params[1].0, "post_id");
assert_eq!(params[1].1, "99");
}
#[test]
fn route_table_lookup_match() {
let mut table: RouteTable<&str> = RouteTable::new();
table.add(Method::Get, "/users/{id}", "get_user");
table.add(Method::Post, "/users", "create_user");
match table.lookup("/users/42", Method::Get) {
RouteLookup::Match { route, params } => {
assert_eq!(*route, "get_user");
assert_eq!(params[0].0, "id");
assert_eq!(params[0].1, "42");
}
_ => panic!("Expected match"),
}
}
#[test]
fn route_table_lookup_method_not_allowed() {
let mut table: RouteTable<&str> = RouteTable::new();
table.add(Method::Get, "/users", "get_users");
table.add(Method::Post, "/users", "create_user");
match table.lookup("/users", Method::Delete) {
RouteLookup::MethodNotAllowed { allowed } => {
assert!(allowed.contains(&Method::Get));
assert!(allowed.contains(&Method::Head));
assert!(allowed.contains(&Method::Post));
}
_ => panic!("Expected MethodNotAllowed"),
}
}
#[test]
fn route_table_lookup_not_found() {
let mut table: RouteTable<&str> = RouteTable::new();
table.add(Method::Get, "/users", "get_users");
assert!(matches!(
table.lookup("/items", Method::Get),
RouteLookup::NotFound
));
}
#[test]
fn route_table_head_matches_get() {
let mut table: RouteTable<&str> = RouteTable::new();
table.add(Method::Get, "/users", "get_users");
match table.lookup("/users", Method::Head) {
RouteLookup::Match { route, .. } => {
assert_eq!(*route, "get_users");
}
_ => panic!("Expected match for HEAD on GET route"),
}
}
#[test]
fn format_allow_header_formats_methods() {
let methods = vec![Method::Get, Method::Head, Method::Post];
assert_eq!(format_allow_header(&methods), "GET, HEAD, POST");
}
#[test]
fn options_request_returns_method_not_allowed_with_allowed_methods() {
let mut table: RouteTable<&str> = RouteTable::new();
table.add(Method::Get, "/users", "get_users");
table.add(Method::Post, "/users", "create_user");
match table.lookup("/users", Method::Options) {
RouteLookup::MethodNotAllowed { allowed } => {
assert!(allowed.contains(&Method::Get));
assert!(allowed.contains(&Method::Head));
assert!(allowed.contains(&Method::Post));
}
_ => panic!("Expected MethodNotAllowed for OPTIONS request"),
}
}
#[test]
fn options_request_on_nonexistent_path_returns_not_found() {
let mut table: RouteTable<&str> = RouteTable::new();
table.add(Method::Get, "/users", "get_users");
match table.lookup("/items", Method::Options) {
RouteLookup::NotFound => {}
_ => panic!("Expected NotFound for OPTIONS on non-existent path"),
}
}
#[test]
fn explicit_options_handler_matches() {
let mut table: RouteTable<&str> = RouteTable::new();
table.add(Method::Get, "/api/resource", "get_resource");
table.add(Method::Options, "/api/resource", "options_resource");
match table.lookup("/api/resource", Method::Options) {
RouteLookup::Match { route, .. } => {
assert_eq!(*route, "options_resource");
}
_ => panic!("Expected match for explicit OPTIONS handler"),
}
}
#[test]
fn method_order_returns_expected_ordering() {
assert!(method_order(Method::Get) < method_order(Method::Post));
assert!(method_order(Method::Head) < method_order(Method::Post));
assert!(method_order(Method::Options) < method_order(Method::Trace));
assert!(method_order(Method::Delete) < method_order(Method::Options));
}
#[test]
fn url_registry_new() {
let registry = UrlRegistry::new();
assert!(registry.is_empty());
assert_eq!(registry.len(), 0);
assert_eq!(registry.root_path(), "");
}
#[test]
fn url_registry_with_root_path() {
let registry = UrlRegistry::with_root_path("/api/v1");
assert_eq!(registry.root_path(), "/api/v1");
}
#[test]
fn url_registry_with_root_path_normalizes_trailing_slash() {
let registry = UrlRegistry::with_root_path("/api/v1/");
assert_eq!(registry.root_path(), "/api/v1");
let registry2 = UrlRegistry::with_root_path("/api///");
assert_eq!(registry2.root_path(), "/api");
}
#[test]
fn url_registry_register_and_lookup() {
let mut registry = UrlRegistry::new();
registry.register("get_user", "/users/{id}");
assert!(registry.has_route("get_user"));
assert!(!registry.has_route("nonexistent"));
assert_eq!(registry.get_pattern("get_user"), Some("/users/{id}"));
assert_eq!(registry.len(), 1);
}
#[test]
fn url_for_static_route() {
let mut registry = UrlRegistry::new();
registry.register("home", "/");
registry.register("about", "/about");
let url = registry.url_for("home", &[], &[]).unwrap();
assert_eq!(url, "/");
let url = registry.url_for("about", &[], &[]).unwrap();
assert_eq!(url, "/about");
}
#[test]
fn url_for_with_path_param() {
let mut registry = UrlRegistry::new();
registry.register("get_user", "/users/{id}");
let url = registry.url_for("get_user", &[("id", "42")], &[]).unwrap();
assert_eq!(url, "/users/42");
}
#[test]
fn url_for_with_multiple_params() {
let mut registry = UrlRegistry::new();
registry.register("get_post", "/users/{user_id}/posts/{post_id}");
let url = registry
.url_for("get_post", &[("user_id", "42"), ("post_id", "99")], &[])
.unwrap();
assert_eq!(url, "/users/42/posts/99");
}
#[test]
fn url_for_with_typed_param() {
let mut registry = UrlRegistry::new();
registry.register("get_item", "/items/{id:int}");
let url = registry.url_for("get_item", &[("id", "123")], &[]).unwrap();
assert_eq!(url, "/items/123");
let result = registry.url_for("get_item", &[("id", "abc")], &[]);
assert!(matches!(result, Err(UrlError::InvalidParam { .. })));
}
#[test]
fn url_for_with_uuid_param() {
let mut registry = UrlRegistry::new();
registry.register("get_object", "/objects/{id:uuid}");
let url = registry
.url_for(
"get_object",
&[("id", "550e8400-e29b-41d4-a716-446655440000")],
&[],
)
.unwrap();
assert_eq!(url, "/objects/550e8400-e29b-41d4-a716-446655440000");
}
#[test]
fn url_for_with_query_params() {
let mut registry = UrlRegistry::new();
registry.register("search", "/search");
let url = registry
.url_for("search", &[], &[("q", "hello"), ("page", "1")])
.unwrap();
assert_eq!(url, "/search?q=hello&page=1");
}
#[test]
fn url_for_encodes_query_params() {
let mut registry = UrlRegistry::new();
registry.register("search", "/search");
let url = registry
.url_for("search", &[], &[("q", "hello world"), ("filter", "a&b=c")])
.unwrap();
assert_eq!(url, "/search?q=hello%20world&filter=a%26b%3Dc");
}
#[test]
fn url_for_encodes_path_params() {
let mut registry = UrlRegistry::new();
registry.register("get_file", "/files/{name}");
let url = registry
.url_for("get_file", &[("name", "my file.txt")], &[])
.unwrap();
assert_eq!(url, "/files/my%20file.txt");
}
#[test]
fn url_for_with_root_path() {
let mut registry = UrlRegistry::with_root_path("/api/v1");
registry.register("get_user", "/users/{id}");
let url = registry.url_for("get_user", &[("id", "42")], &[]).unwrap();
assert_eq!(url, "/api/v1/users/42");
}
#[test]
fn url_for_route_not_found() {
let registry = UrlRegistry::new();
let result = registry.url_for("nonexistent", &[], &[]);
assert!(matches!(result, Err(UrlError::RouteNotFound { name }) if name == "nonexistent"));
}
#[test]
fn url_for_missing_param() {
let mut registry = UrlRegistry::new();
registry.register("get_user", "/users/{id}");
let result = registry.url_for("get_user", &[], &[]);
assert!(matches!(
result,
Err(UrlError::MissingParam { name, param }) if name == "get_user" && param == "id"
));
}
#[test]
fn url_for_with_path_converter() {
let mut registry = UrlRegistry::new();
registry.register("get_file", "/files/{path:path}");
let url = registry
.url_for("get_file", &[("path", "docs/images/logo.png")], &[])
.unwrap();
assert_eq!(url, "/files/docs/images/logo.png");
}
#[test]
fn url_encode_basic() {
assert_eq!(url_encode("hello"), "hello");
assert_eq!(url_encode("hello world"), "hello%20world");
assert_eq!(url_encode("a&b=c"), "a%26b%3Dc");
assert_eq!(url_encode("100%"), "100%25");
}
#[test]
fn url_encode_unicode() {
assert_eq!(url_encode("日本"), "%E6%97%A5%E6%9C%AC");
assert_eq!(url_encode("café"), "caf%C3%A9");
}
#[test]
fn url_encode_path_segment_preserves_slashes() {
assert_eq!(url_encode("a/b/c"), "a%2Fb%2Fc");
assert_eq!(url_encode_path_segment("a/b/c"), "a/b/c");
assert_eq!(url_encode_path_segment("a b/c"), "a%20b/c");
assert_eq!(url_encode_path_segment("a&b/c"), "a%26b/c");
}
#[test]
fn url_decode_basic() {
assert_eq!(url_decode("hello"), Some("hello".to_string()));
assert_eq!(url_decode("hello%20world"), Some("hello world".to_string()));
assert_eq!(url_decode("a%26b%3Dc"), Some("a&b=c".to_string()));
}
#[test]
fn url_decode_plus_as_space() {
assert_eq!(url_decode("hello+world"), Some("hello world".to_string()));
}
#[test]
fn url_decode_invalid() {
assert_eq!(url_decode("hello%2"), None);
assert_eq!(url_decode("hello%"), None);
assert_eq!(url_decode("hello%GG"), None);
}
#[test]
fn url_error_display() {
let err = UrlError::RouteNotFound {
name: "test".to_string(),
};
assert_eq!(format!("{}", err), "route 'test' not found");
let err = UrlError::MissingParam {
name: "get_user".to_string(),
param: "id".to_string(),
};
assert_eq!(
format!("{}", err),
"route 'get_user' requires parameter 'id'"
);
let err = UrlError::InvalidParam {
name: "get_item".to_string(),
param: "id".to_string(),
value: "abc".to_string(),
};
assert_eq!(
format!("{}", err),
"route 'get_item' parameter 'id': invalid value 'abc'"
);
}
#[test]
fn url_registry_route_names_iterator() {
let mut registry = UrlRegistry::new();
registry.register("a", "/a");
registry.register("b", "/b");
registry.register("c", "/c");
let names: Vec<_> = registry.route_names().collect();
assert_eq!(names.len(), 3);
assert!(names.contains(&"a"));
assert!(names.contains(&"b"));
assert!(names.contains(&"c"));
}
#[test]
fn url_registry_set_root_path() {
let mut registry = UrlRegistry::new();
registry.register("home", "/");
let url1 = registry.url_for("home", &[], &[]).unwrap();
assert_eq!(url1, "/");
registry.set_root_path("/api");
let url2 = registry.url_for("home", &[], &[]).unwrap();
assert_eq!(url2, "/api/");
}
#[test]
fn trailing_slash_strict_mode_matches_both_due_to_segment_parsing() {
let mut table = RouteTable::new();
table.add(Method::Get, "/users", "users");
assert!(matches!(
table.lookup_with_trailing_slash("/users", Method::Get, TrailingSlashMode::Strict),
RouteLookup::Match {
route: &"users",
..
}
));
assert!(matches!(
table.lookup_with_trailing_slash("/users/", Method::Get, TrailingSlashMode::Strict),
RouteLookup::Match {
route: &"users",
..
}
));
}
#[test]
fn trailing_slash_redirect_mode_exact_match_no_redirect() {
let mut table = RouteTable::new();
table.add(Method::Get, "/users", "users");
assert!(matches!(
table.lookup_with_trailing_slash("/users", Method::Get, TrailingSlashMode::Redirect),
RouteLookup::Match {
route: &"users",
..
}
));
assert!(matches!(
table.lookup_with_trailing_slash("/users/", Method::Get, TrailingSlashMode::Redirect),
RouteLookup::Match {
route: &"users",
..
}
));
}
#[test]
fn trailing_slash_match_both_mode() {
let mut table = RouteTable::new();
table.add(Method::Get, "/users", "users");
assert!(matches!(
table.lookup_with_trailing_slash("/users", Method::Get, TrailingSlashMode::MatchBoth),
RouteLookup::Match {
route: &"users",
..
}
));
assert!(matches!(
table.lookup_with_trailing_slash("/users/", Method::Get, TrailingSlashMode::MatchBoth),
RouteLookup::Match {
route: &"users",
..
}
));
}
#[test]
fn trailing_slash_root_path_not_redirected() {
let mut table = RouteTable::new();
table.add(Method::Get, "/", "root");
assert!(matches!(
table.lookup_with_trailing_slash("/", Method::Get, TrailingSlashMode::Redirect),
RouteLookup::Match { route: &"root", .. }
));
}
#[test]
fn trailing_slash_with_path_params() {
let mut table = RouteTable::new();
table.add(Method::Get, "/users/{id}", "get_user");
match table.lookup_with_trailing_slash(
"/users/42/",
Method::Get,
TrailingSlashMode::MatchBoth,
) {
RouteLookup::Match { params, .. } => {
assert_eq!(params.len(), 1);
assert_eq!(params[0], ("id".to_string(), "42".to_string()));
}
other => panic!("expected Match, got {:?}", other),
}
}
#[test]
fn trailing_slash_not_found_stays_not_found() {
let mut table = RouteTable::new();
table.add(Method::Get, "/users", "users");
assert!(matches!(
table.lookup_with_trailing_slash(
"/nonexistent",
Method::Get,
TrailingSlashMode::Redirect
),
RouteLookup::NotFound
));
assert!(matches!(
table.lookup_with_trailing_slash(
"/nonexistent/",
Method::Get,
TrailingSlashMode::Redirect
),
RouteLookup::NotFound
));
}
#[test]
fn trailing_slash_mode_default_is_strict() {
assert_eq!(TrailingSlashMode::default(), TrailingSlashMode::Strict);
}
#[test]
fn app_config_trailing_slash_mode() {
use crate::app::AppConfig;
let config = AppConfig::new();
assert_eq!(config.trailing_slash_mode, TrailingSlashMode::Strict);
let config = AppConfig::new().trailing_slash_mode(TrailingSlashMode::Redirect);
assert_eq!(config.trailing_slash_mode, TrailingSlashMode::Redirect);
let config = AppConfig::new().trailing_slash_mode(TrailingSlashMode::MatchBoth);
assert_eq!(config.trailing_slash_mode, TrailingSlashMode::MatchBoth);
}
#[test]
fn converter_str_rejects_dot_dot_traversal() {
assert!(!Converter::Str.matches(".."));
assert!(!Converter::Str.matches("."));
assert!(Converter::Str.matches("users"));
assert!(Converter::Str.matches("file.txt"));
assert!(Converter::Str.matches("my..name"));
}
#[test]
fn converter_path_rejects_traversal_components() {
assert!(!Converter::Path.matches("../etc/passwd"));
assert!(!Converter::Path.matches("foo/../../bar"));
assert!(!Converter::Path.matches("./hidden"));
assert!(!Converter::Path.matches(".."));
assert!(Converter::Path.matches("a/b/c.txt"));
assert!(Converter::Path.matches("docs/readme.md"));
}
#[test]
fn converter_float_rejects_nan_and_infinity() {
assert!(!Converter::Float.matches("NaN"));
assert!(!Converter::Float.matches("inf"));
assert!(!Converter::Float.matches("-inf"));
assert!(!Converter::Float.matches("infinity"));
assert!(!Converter::Float.matches("-infinity"));
assert!(Converter::Float.matches("3.14"));
assert!(Converter::Float.matches("-1.5"));
assert!(Converter::Float.matches("1e10"));
assert!(Converter::Float.matches("42"));
}
#[test]
fn route_table_rejects_traversal_in_str_param() {
let mut table = RouteTable::new();
table.add(Method::Get, "/files/{name}", "handler");
assert!(matches!(
table.lookup("/files/readme.txt", Method::Get),
RouteLookup::Match { .. }
));
assert!(matches!(
table.lookup("/files/..", Method::Get),
RouteLookup::NotFound
));
}
#[test]
fn route_table_rejects_traversal_in_path_param() {
let mut table = RouteTable::new();
table.add(Method::Get, "/files/{filepath:path}", "handler");
if let RouteLookup::Match { params, .. } =
table.lookup("/files/docs/readme.md", Method::Get)
{
assert_eq!(params[0].1, "docs/readme.md");
} else {
panic!("Expected match for normal path");
}
assert!(matches!(
table.lookup("/files/../etc/passwd", Method::Get),
RouteLookup::NotFound
));
assert!(matches!(
table.lookup("/files/a/../../etc/shadow", Method::Get),
RouteLookup::NotFound
));
}
#[test]
fn path_has_traversal_helper() {
assert!(path_has_traversal(".."));
assert!(path_has_traversal("."));
assert!(path_has_traversal("../foo"));
assert!(path_has_traversal("foo/.."));
assert!(path_has_traversal("foo/../bar"));
assert!(path_has_traversal("./bar"));
assert!(!path_has_traversal("foo/bar"));
assert!(!path_has_traversal("foo.bar"));
assert!(!path_has_traversal("a..b"));
}
}