use crate::r#match::{AllowedMethods, RouteLookup, RouteMatch};
use fastapi_types::Method;
use std::collections::HashMap;
use std::fmt;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum Converter {
#[default]
Str,
Int,
Float,
Uuid,
Path,
}
#[derive(Debug, Clone, PartialEq)]
pub enum ParamValue {
Str(String),
Int(i64),
Float(f64),
Uuid(String),
Path(String),
}
impl ParamValue {
#[must_use]
pub fn as_str(&self) -> &str {
match self {
Self::Str(s) | Self::Uuid(s) | Self::Path(s) => s,
Self::Int(_) | Self::Float(_) => {
""
}
}
}
#[must_use]
pub fn as_int(&self) -> Option<i64> {
match self {
Self::Int(n) => Some(*n),
_ => None,
}
}
#[must_use]
pub fn as_float(&self) -> Option<f64> {
match self {
Self::Float(n) => Some(*n),
_ => None,
}
}
#[must_use]
pub fn into_string(self) -> Option<String> {
match self {
Self::Str(s) | Self::Uuid(s) | Self::Path(s) => Some(s),
Self::Int(_) | Self::Float(_) => None,
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum ConversionError {
InvalidInt {
value: String,
param: String,
},
InvalidFloat {
value: String,
param: String,
},
InvalidUuid {
value: String,
param: String,
},
}
impl fmt::Display for ConversionError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::InvalidInt { value, param } => {
write!(
f,
"path parameter '{param}': '{value}' is not a valid integer"
)
}
Self::InvalidFloat { value, param } => {
write!(
f,
"path parameter '{param}': '{value}' is not a valid float"
)
}
Self::InvalidUuid { value, param } => {
write!(f, "path parameter '{param}': '{value}' is not a valid UUID")
}
}
}
}
impl std::error::Error for ConversionError {}
impl Converter {
#[must_use]
pub fn matches(&self, value: &str) -> bool {
match self {
Self::Str => true,
Self::Int => value.parse::<i64>().is_ok(),
Self::Float => value.parse::<f64>().is_ok(),
Self::Uuid => is_uuid(value),
Self::Path => true,
}
}
pub fn convert(&self, value: &str, param_name: &str) -> Result<ParamValue, ConversionError> {
match self {
Self::Str => Ok(ParamValue::Str(value.to_string())),
Self::Int => {
value
.parse::<i64>()
.map(ParamValue::Int)
.map_err(|_| ConversionError::InvalidInt {
value: value.to_string(),
param: param_name.to_string(),
})
}
Self::Float => value.parse::<f64>().map(ParamValue::Float).map_err(|_| {
ConversionError::InvalidFloat {
value: value.to_string(),
param: param_name.to_string(),
}
}),
Self::Uuid => {
if is_uuid(value) {
Ok(ParamValue::Uuid(value.to_string()))
} else {
Err(ConversionError::InvalidUuid {
value: value.to_string(),
param: param_name.to_string(),
})
}
}
Self::Path => Ok(ParamValue::Path(value.to_string())),
}
}
#[must_use]
pub fn type_name(&self) -> &'static str {
match self {
Self::Str => "string",
Self::Int => "integer",
Self::Float => "float",
Self::Uuid => "UUID",
Self::Path => "path",
}
}
}
fn is_uuid(s: &str) -> bool {
if s.len() != 36 {
return false;
}
let bytes = s.as_bytes();
if bytes[8] != b'-' || bytes[13] != b'-' || bytes[18] != b'-' || bytes[23] != b'-' {
return false;
}
bytes.iter().enumerate().all(|(i, &b)| {
if i == 8 || i == 13 || i == 18 || i == 23 {
true } else {
b.is_ascii_hexdigit()
}
})
}
#[derive(Debug, Clone, Default)]
pub struct ParamInfo {
pub name: String,
pub converter: Converter,
pub title: Option<String>,
pub description: Option<String>,
pub deprecated: bool,
pub example: Option<serde_json::Value>,
pub examples: Vec<(String, serde_json::Value)>,
}
impl ParamInfo {
#[must_use]
pub fn new(name: impl Into<String>, converter: Converter) -> Self {
Self {
name: name.into(),
converter,
title: None,
description: None,
deprecated: false,
example: None,
examples: Vec::new(),
}
}
#[must_use]
pub fn with_title(mut self, title: impl Into<String>) -> Self {
self.title = Some(title.into());
self
}
#[must_use]
pub fn with_description(mut self, description: impl Into<String>) -> Self {
self.description = Some(description.into());
self
}
#[must_use]
pub fn deprecated(mut self) -> Self {
self.deprecated = true;
self
}
#[must_use]
pub fn with_example(mut self, example: serde_json::Value) -> Self {
self.example = Some(example);
self
}
#[must_use]
pub fn with_named_example(mut self, name: impl Into<String>, value: serde_json::Value) -> Self {
self.examples.push((name.into(), value));
self
}
}
#[must_use]
pub fn extract_path_params(path: &str) -> Vec<ParamInfo> {
path.split('/')
.filter(|s| !s.is_empty())
.filter_map(|s| {
if s.starts_with('{') && s.ends_with('}') {
let inner = &s[1..s.len() - 1];
if let Some(name) = inner.strip_prefix('*') {
return Some(ParamInfo::new(name, Converter::Path));
}
let (name, converter) = if let Some(pos) = inner.find(':') {
let conv = match &inner[pos + 1..] {
"int" => Converter::Int,
"float" => Converter::Float,
"uuid" => Converter::Uuid,
"path" => Converter::Path,
_ => Converter::Str,
};
(&inner[..pos], conv)
} else {
(inner, Converter::Str)
};
Some(ParamInfo::new(name, converter))
} else {
None
}
})
.collect()
}
fn sanitize_operation_id(s: &str) -> String {
let mut out = String::with_capacity(s.len());
let mut prev_underscore = false;
for c in s.chars() {
if c.is_ascii_alphanumeric() {
out.push(c.to_ascii_lowercase());
prev_underscore = false;
} else if !prev_underscore {
out.push('_');
prev_underscore = true;
}
}
let trimmed = out.trim_matches('_');
if trimmed.is_empty() {
"root".to_string()
} else {
trimmed.to_string()
}
}
#[derive(Debug, Clone)]
pub struct RouteResponse {
pub status: u16,
pub schema_name: String,
pub description: String,
pub content_type: String,
}
impl RouteResponse {
#[must_use]
pub fn new(
status: u16,
schema_name: impl Into<String>,
description: impl Into<String>,
) -> Self {
Self {
status,
schema_name: schema_name.into(),
description: description.into(),
content_type: "application/json".to_string(),
}
}
#[must_use]
pub fn with_content_type(mut self, content_type: impl Into<String>) -> Self {
self.content_type = content_type.into();
self
}
}
#[derive(Debug, Clone, Default)]
pub struct RouteSecurityRequirement {
pub scheme: String,
pub scopes: Vec<String>,
}
impl RouteSecurityRequirement {
#[must_use]
pub fn new(scheme: impl Into<String>) -> Self {
Self {
scheme: scheme.into(),
scopes: Vec::new(),
}
}
#[must_use]
pub fn with_scopes(
scheme: impl Into<String>,
scopes: impl IntoIterator<Item = impl Into<String>>,
) -> Self {
Self {
scheme: scheme.into(),
scopes: scopes.into_iter().map(Into::into).collect(),
}
}
}
#[derive(Clone)]
pub struct Route {
pub path: String,
pub method: Method,
pub operation_id: String,
pub summary: Option<String>,
pub description: Option<String>,
pub tags: Vec<String>,
pub deprecated: bool,
pub path_params: Vec<ParamInfo>,
pub request_body_schema: Option<String>,
pub request_body_content_type: Option<String>,
pub request_body_required: bool,
pub security: Vec<RouteSecurityRequirement>,
pub responses: Vec<RouteResponse>,
}
impl fmt::Debug for Route {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
let mut s = f.debug_struct("Route");
s.field("path", &self.path)
.field("method", &self.method)
.field("operation_id", &self.operation_id);
if let Some(ref summary) = self.summary {
s.field("summary", summary);
}
if let Some(ref desc) = self.description {
s.field("description", desc);
}
if !self.tags.is_empty() {
s.field("tags", &self.tags);
}
if self.deprecated {
s.field("deprecated", &self.deprecated);
}
if !self.path_params.is_empty() {
s.field("path_params", &self.path_params);
}
if let Some(ref schema) = self.request_body_schema {
s.field("request_body_schema", schema);
}
if let Some(ref content_type) = self.request_body_content_type {
s.field("request_body_content_type", content_type);
}
if self.request_body_required {
s.field("request_body_required", &self.request_body_required);
}
if !self.security.is_empty() {
s.field("security", &self.security);
}
if !self.responses.is_empty() {
s.field("responses", &self.responses);
}
s.finish()
}
}
#[derive(Debug, Clone)]
pub struct RouteConflictError {
pub method: Method,
pub new_path: String,
pub existing_path: String,
}
impl fmt::Display for RouteConflictError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(
f,
"route conflict for {}: {} conflicts with {}",
self.method, self.new_path, self.existing_path
)
}
}
impl std::error::Error for RouteConflictError {}
#[derive(Debug, Clone)]
pub struct InvalidRouteError {
pub path: String,
pub message: String,
}
impl InvalidRouteError {
#[must_use]
pub fn new(path: impl Into<String>, message: impl Into<String>) -> Self {
Self {
path: path.into(),
message: message.into(),
}
}
}
impl fmt::Display for InvalidRouteError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "invalid route path '{}': {}", self.path, self.message)
}
}
impl std::error::Error for InvalidRouteError {}
#[derive(Debug, Clone)]
pub enum RouteAddError {
Conflict(RouteConflictError),
InvalidPath(InvalidRouteError),
}
impl fmt::Display for RouteAddError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::Conflict(err) => err.fmt(f),
Self::InvalidPath(err) => err.fmt(f),
}
}
}
impl std::error::Error for RouteAddError {
fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
match self {
Self::Conflict(err) => Some(err),
Self::InvalidPath(err) => Some(err),
}
}
}
impl From<RouteConflictError> for RouteAddError {
fn from(err: RouteConflictError) -> Self {
Self::Conflict(err)
}
}
impl From<InvalidRouteError> for RouteAddError {
fn from(err: InvalidRouteError) -> Self {
Self::InvalidPath(err)
}
}
impl Route {
pub fn new(method: Method, path: impl Into<String>) -> Self {
let path = path.into();
let method_prefix = method.as_str().to_ascii_lowercase();
let path_part = sanitize_operation_id(&path);
let operation_id = format!("{method_prefix}_{path_part}");
let path_params = extract_path_params(&path);
Self {
path,
method,
operation_id,
summary: None,
description: None,
tags: Vec::new(),
deprecated: false,
path_params,
request_body_schema: None,
request_body_content_type: None,
request_body_required: false,
security: Vec::new(),
responses: Vec::new(),
}
}
#[must_use]
pub fn with_placeholder_handler(method: Method, path: impl Into<String>) -> Self {
Self::new(method, path)
}
#[must_use]
pub fn summary(mut self, summary: impl Into<String>) -> Self {
self.summary = Some(summary.into());
self
}
#[must_use]
pub fn description(mut self, description: impl Into<String>) -> Self {
self.description = Some(description.into());
self
}
#[must_use]
pub fn operation_id(mut self, operation_id: impl Into<String>) -> Self {
self.operation_id = operation_id.into();
self
}
#[must_use]
pub fn tag(mut self, tag: impl Into<String>) -> Self {
self.tags.push(tag.into());
self
}
#[must_use]
pub fn tags(mut self, tags: impl IntoIterator<Item = impl Into<String>>) -> Self {
self.tags.extend(tags.into_iter().map(Into::into));
self
}
#[must_use]
pub fn deprecated(mut self) -> Self {
self.deprecated = true;
self
}
#[must_use]
pub fn request_body(
mut self,
schema: impl Into<String>,
content_type: impl Into<String>,
required: bool,
) -> Self {
self.request_body_schema = Some(schema.into());
self.request_body_content_type = Some(content_type.into());
self.request_body_required = required;
self
}
#[must_use]
pub fn security(
mut self,
scheme: impl Into<String>,
scopes: impl IntoIterator<Item = impl Into<String>>,
) -> Self {
self.security
.push(RouteSecurityRequirement::with_scopes(scheme, scopes));
self
}
#[must_use]
pub fn security_scheme(mut self, scheme: impl Into<String>) -> Self {
self.security.push(RouteSecurityRequirement::new(scheme));
self
}
#[must_use]
pub fn response(
mut self,
status: u16,
schema_name: impl Into<String>,
description: impl Into<String>,
) -> Self {
self.responses
.push(RouteResponse::new(status, schema_name, description));
self
}
#[must_use]
pub fn has_responses(&self) -> bool {
!self.responses.is_empty()
}
#[must_use]
pub fn has_request_body(&self) -> bool {
self.request_body_schema.is_some()
}
#[must_use]
pub fn has_path_params(&self) -> bool {
!self.path_params.is_empty()
}
#[must_use]
pub fn has_security(&self) -> bool {
!self.security.is_empty()
}
}
struct Node {
segment: String,
children: Vec<Node>,
param: Option<ParamInfo>,
routes: HashMap<Method, usize>,
}
impl Node {
fn new(segment: impl Into<String>) -> Self {
Self {
segment: segment.into(),
children: Vec::new(),
param: None,
routes: HashMap::new(),
}
}
fn find_static(&self, segment: &str) -> Option<&Node> {
self.children
.iter()
.find(|c| c.param.is_none() && c.segment == segment)
}
fn find_param(&self) -> Option<&Node> {
self.children.iter().find(|c| c.param.is_some())
}
}
pub struct Router {
root: Node,
routes: Vec<Route>,
}
impl Router {
#[must_use]
pub fn new() -> Self {
Self {
root: Node::new(""),
routes: Vec::new(),
}
}
pub fn add(&mut self, route: Route) -> Result<(), RouteAddError> {
if let Some(conflict) = self.find_conflict(&route) {
return Err(RouteAddError::Conflict(conflict));
}
let route_idx = self.routes.len();
let path = route.path.clone();
let method = route.method;
self.routes.push(route);
let segments = parse_path(&path);
validate_path_segments(&path, &segments)?;
let mut node = &mut self.root;
for seg in segments {
let (segment, param) = match seg {
PathSegment::Static(s) => (s.to_string(), None),
PathSegment::Param { name, converter } => {
let info = ParamInfo::new(name, converter);
(format!("{{{name}}}"), Some(info))
}
};
let child_idx = node.children.iter().position(|c| c.segment == segment);
if let Some(idx) = child_idx {
node = &mut node.children[idx];
} else {
let mut new_node = Node::new(&segment);
new_node.param = param;
node.children.push(new_node);
node = node.children.last_mut().unwrap();
}
}
node.routes.insert(method, route_idx);
Ok(())
}
#[must_use]
pub fn lookup<'a>(&'a self, path: &'a str, method: Method) -> RouteLookup<'a> {
let (node, params) = match self.match_node(path) {
Some(found) => found,
None => return RouteLookup::NotFound,
};
if let Some(&idx) = node.routes.get(&method) {
return RouteLookup::Match(RouteMatch {
route: &self.routes[idx],
params,
});
}
if method == Method::Head
&& let Some(&idx) = node.routes.get(&Method::Get)
{
return RouteLookup::Match(RouteMatch {
route: &self.routes[idx],
params,
});
}
if node.routes.is_empty() {
return RouteLookup::NotFound;
}
let allowed = AllowedMethods::new(node.routes.keys().copied().collect());
RouteLookup::MethodNotAllowed { allowed }
}
#[must_use]
pub fn match_path<'a>(&'a self, path: &'a str, method: Method) -> Option<RouteMatch<'a>> {
match self.lookup(path, method) {
RouteLookup::Match(matched) => Some(matched),
RouteLookup::MethodNotAllowed { .. } | RouteLookup::NotFound => None,
}
}
#[must_use]
pub fn routes(&self) -> &[Route] {
&self.routes
}
pub fn mount(mut self, prefix: &str, child: Router) -> Result<Self, RouteAddError> {
let prefix = prefix.trim_end_matches('/');
for route in child.routes {
let child_path = if route.path == "/" {
String::new()
} else if route.path.starts_with('/') {
route.path.clone()
} else {
format!("/{}", route.path)
};
let full_path = if prefix.is_empty() {
if child_path.is_empty() {
"/".to_string()
} else {
child_path
}
} else if child_path.is_empty() {
prefix.to_string()
} else {
format!("{}{}", prefix, child_path)
};
let path_params = extract_path_params(&full_path);
let mounted = Route {
path: full_path,
method: route.method,
operation_id: route.operation_id,
summary: route.summary,
description: route.description,
tags: route.tags,
deprecated: route.deprecated,
path_params,
request_body_schema: route.request_body_schema,
request_body_content_type: route.request_body_content_type,
request_body_required: route.request_body_required,
security: route.security,
responses: route.responses,
};
self.add(mounted)?;
}
Ok(self)
}
#[must_use]
pub fn nest(self, prefix: &str, child: Router) -> Self {
self.mount(prefix, child)
.expect("route conflict when nesting router")
}
fn find_conflict(&self, route: &Route) -> Option<RouteConflictError> {
for existing in &self.routes {
if existing.method != route.method {
continue;
}
if paths_conflict(&existing.path, &route.path) {
return Some(RouteConflictError {
method: route.method,
new_path: route.path.clone(),
existing_path: existing.path.clone(),
});
}
}
None
}
fn match_node<'a>(&'a self, path: &'a str) -> Option<(&'a Node, Vec<(&'a str, &'a str)>)> {
let mut range_iter = SegmentRangeIter::new(path);
let mut ranges_buf: [(usize, usize); 16] = [(0, 0); 16];
let mut ranges_vec: Vec<(usize, usize)> = Vec::new();
let mut range_count = 0;
for range in &mut range_iter {
match range_count.cmp(&16) {
std::cmp::Ordering::Less => {
ranges_buf[range_count] = range;
}
std::cmp::Ordering::Equal => {
ranges_vec = ranges_buf.to_vec();
ranges_vec.push(range);
}
std::cmp::Ordering::Greater => {
ranges_vec.push(range);
}
}
range_count += 1;
}
let ranges: &[(usize, usize)] = if range_count <= 16 {
&ranges_buf[..range_count]
} else {
&ranges_vec
};
let last_end = ranges.last().map_or(0, |(_, end)| *end);
let mut params = Vec::new();
let mut node = &self.root;
for &(start, end) in ranges {
let segment = &path[start..end];
if let Some(child) = node.find_static(segment) {
node = child;
continue;
}
if let Some(child) = node.find_param()
&& let Some(ref info) = child.param
{
if info.converter == Converter::Path {
let value = &path[start..last_end];
params.push((info.name.as_str(), value));
node = child;
return Some((node, params));
}
if info.converter.matches(segment) {
params.push((info.name.as_str(), segment));
node = child;
continue;
}
}
return None;
}
Some((node, params))
}
}
impl Default for Router {
fn default() -> Self {
Self::new()
}
}
enum PathSegment<'a> {
Static(&'a str),
Param { name: &'a str, converter: Converter },
}
fn parse_path(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];
if let Some(name) = inner.strip_prefix('*') {
return PathSegment::Param {
name,
converter: Converter::Path,
};
}
let (name, converter) = if let Some(pos) = inner.find(':') {
let conv = match &inner[pos + 1..] {
"int" => Converter::Int,
"float" => Converter::Float,
"uuid" => Converter::Uuid,
"path" => Converter::Path,
_ => Converter::Str,
};
(&inner[..pos], conv)
} else {
(inner, Converter::Str)
};
PathSegment::Param { name, converter }
} else {
PathSegment::Static(s)
}
})
.collect()
}
fn validate_path_segments(
path: &str,
segments: &[PathSegment<'_>],
) -> Result<(), InvalidRouteError> {
for (idx, segment) in segments.iter().enumerate() {
if let PathSegment::Param {
name,
converter: Converter::Path,
} = segment
&& idx + 1 != segments.len()
{
return Err(InvalidRouteError::new(
path,
format!("wildcard '{{*{name}}}' or '{{{name}:path}}' must be the final segment"),
));
}
}
Ok(())
}
struct SegmentRangeIter<'a> {
bytes: &'a [u8],
idx: usize,
}
impl<'a> SegmentRangeIter<'a> {
fn new(path: &'a str) -> Self {
Self {
bytes: path.as_bytes(),
idx: 0,
}
}
}
impl Iterator for SegmentRangeIter<'_> {
type Item = (usize, usize);
#[inline]
fn next(&mut self) -> Option<Self::Item> {
while self.idx < self.bytes.len() && self.bytes[self.idx] == b'/' {
self.idx += 1;
}
if self.idx >= self.bytes.len() {
return None;
}
let start = self.idx;
while self.idx < self.bytes.len() && self.bytes[self.idx] != b'/' {
self.idx += 1;
}
Some((start, self.idx))
}
fn size_hint(&self) -> (usize, Option<usize>) {
let remaining = self.bytes.len().saturating_sub(self.idx);
(0, Some(remaining / 2 + 1))
}
}
fn paths_conflict(a: &str, b: &str) -> bool {
let a_segments = parse_path(a);
let b_segments = parse_path(b);
let a_has_path = matches!(
a_segments.last(),
Some(PathSegment::Param {
converter: Converter::Path,
..
})
);
let b_has_path = matches!(
b_segments.last(),
Some(PathSegment::Param {
converter: Converter::Path,
..
})
);
let min_len = a_segments.len().min(b_segments.len());
let mut param_mismatch = false;
for (left, right) in a_segments.iter().take(min_len).zip(b_segments.iter()) {
match (left, right) {
(PathSegment::Static(a), PathSegment::Static(b)) => {
if a != b {
return false;
}
}
(PathSegment::Static(_), PathSegment::Param { .. })
| (PathSegment::Param { .. }, PathSegment::Static(_)) => {
return false;
}
(
PathSegment::Param {
name: left_name,
converter: left_conv,
},
PathSegment::Param {
name: right_name,
converter: right_conv,
},
) => {
if left_name != right_name || left_conv != right_conv {
param_mismatch = true;
}
}
}
}
if a_segments.len() == b_segments.len() {
return true;
}
if param_mismatch {
return true;
}
if a_has_path && a_segments.len() == min_len {
return true;
}
if b_has_path && b_segments.len() == min_len {
return true;
}
false
}
#[cfg(test)]
mod tests {
use super::*;
fn route(method: Method, path: &str) -> Route {
Route::new(method, path)
}
#[test]
fn static_route_match() {
let mut router = Router::new();
router.add(route(Method::Get, "/users")).unwrap();
router.add(route(Method::Get, "/items")).unwrap();
let m = router.match_path("/users", Method::Get);
assert!(m.is_some());
assert_eq!(m.unwrap().route.path, "/users");
let m = router.match_path("/items", Method::Get);
assert!(m.is_some());
assert_eq!(m.unwrap().route.path, "/items");
assert!(router.match_path("/other", Method::Get).is_none());
}
#[test]
fn nested_static_routes() {
let mut router = Router::new();
router.add(route(Method::Get, "/api/v1/users")).unwrap();
router.add(route(Method::Get, "/api/v2/users")).unwrap();
let m = router.match_path("/api/v1/users", Method::Get);
assert!(m.is_some());
assert_eq!(m.unwrap().route.path, "/api/v1/users");
let m = router.match_path("/api/v2/users", Method::Get);
assert!(m.is_some());
assert_eq!(m.unwrap().route.path, "/api/v2/users");
}
#[test]
fn parameter_extraction() {
let mut router = Router::new();
router.add(route(Method::Get, "/users/{user_id}")).unwrap();
let m = router.match_path("/users/123", Method::Get);
assert!(m.is_some());
let m = m.unwrap();
assert_eq!(m.route.path, "/users/{user_id}");
assert_eq!(m.params.len(), 1);
assert_eq!(m.params[0], ("user_id", "123"));
}
#[test]
fn multiple_parameters() {
let mut router = Router::new();
router
.add(route(Method::Get, "/users/{user_id}/posts/{post_id}"))
.unwrap();
let m = router.match_path("/users/42/posts/99", Method::Get);
assert!(m.is_some());
let m = m.unwrap();
assert_eq!(m.params.len(), 2);
assert_eq!(m.params[0], ("user_id", "42"));
assert_eq!(m.params[1], ("post_id", "99"));
}
#[test]
fn int_converter() {
let mut router = Router::new();
router.add(route(Method::Get, "/items/{id:int}")).unwrap();
let m = router.match_path("/items/123", Method::Get);
assert!(m.is_some());
assert_eq!(m.unwrap().params[0], ("id", "123"));
let m = router.match_path("/items/-456", Method::Get);
assert!(m.is_some());
assert!(router.match_path("/items/abc", Method::Get).is_none());
assert!(router.match_path("/items/12.34", Method::Get).is_none());
}
#[test]
fn float_converter() {
let mut router = Router::new();
router
.add(route(Method::Get, "/values/{val:float}"))
.unwrap();
let m = router.match_path("/values/3.14", Method::Get);
assert!(m.is_some());
assert_eq!(m.unwrap().params[0], ("val", "3.14"));
let m = router.match_path("/values/42", Method::Get);
assert!(m.is_some());
assert!(router.match_path("/values/abc", Method::Get).is_none());
}
#[test]
fn uuid_converter() {
let mut router = Router::new();
router
.add(route(Method::Get, "/objects/{id:uuid}"))
.unwrap();
let m = router.match_path("/objects/550e8400-e29b-41d4-a716-446655440000", Method::Get);
assert!(m.is_some());
assert_eq!(
m.unwrap().params[0],
("id", "550e8400-e29b-41d4-a716-446655440000")
);
assert!(
router
.match_path("/objects/not-a-uuid", Method::Get)
.is_none()
);
assert!(router.match_path("/objects/123", Method::Get).is_none());
}
#[test]
fn path_converter_captures_slashes() {
let mut router = Router::new();
router
.add(route(Method::Get, "/files/{path:path}"))
.unwrap();
let m = router.match_path("/files/a/b/c.txt", Method::Get).unwrap();
assert_eq!(m.params[0], ("path", "a/b/c.txt"));
}
#[test]
fn path_converter_must_be_terminal() {
let mut router = Router::new();
let result = router.add(route(Method::Get, "/files/{path:path}/edit"));
assert!(matches!(result, Err(RouteAddError::InvalidPath(_))));
}
#[test]
fn method_dispatch() {
let mut router = Router::new();
router.add(route(Method::Get, "/items")).unwrap();
router.add(route(Method::Post, "/items")).unwrap();
router.add(route(Method::Delete, "/items/{id}")).unwrap();
let m = router.match_path("/items", Method::Get);
assert!(m.is_some());
assert_eq!(m.unwrap().route.method, Method::Get);
let m = router.match_path("/items", Method::Post);
assert!(m.is_some());
assert_eq!(m.unwrap().route.method, Method::Post);
let m = router.match_path("/items/123", Method::Delete);
assert!(m.is_some());
assert_eq!(m.unwrap().route.method, Method::Delete);
assert!(router.match_path("/items", Method::Put).is_none());
}
#[test]
fn lookup_method_not_allowed_includes_head() {
let mut router = Router::new();
router.add(route(Method::Get, "/users")).unwrap();
let result = router.lookup("/users", Method::Post);
match result {
RouteLookup::MethodNotAllowed { allowed } => {
assert!(allowed.contains(Method::Get));
assert!(allowed.contains(Method::Head));
assert_eq!(allowed.header_value(), "GET, HEAD");
}
_ => panic!("expected MethodNotAllowed"),
}
}
#[test]
fn lookup_method_not_allowed_multiple_methods() {
let mut router = Router::new();
router.add(route(Method::Get, "/users")).unwrap();
router.add(route(Method::Post, "/users")).unwrap();
router.add(route(Method::Delete, "/users")).unwrap();
let result = router.lookup("/users", Method::Put);
match result {
RouteLookup::MethodNotAllowed { allowed } => {
assert_eq!(allowed.header_value(), "GET, HEAD, POST, DELETE");
}
_ => panic!("expected MethodNotAllowed"),
}
}
#[test]
fn lookup_not_found_when_path_missing() {
let mut router = Router::new();
router.add(route(Method::Get, "/users")).unwrap();
assert!(matches!(
router.lookup("/missing", Method::Get),
RouteLookup::NotFound
));
}
#[test]
fn lookup_not_found_when_converter_mismatch() {
let mut router = Router::new();
router.add(route(Method::Get, "/items/{id:int}")).unwrap();
assert!(matches!(
router.lookup("/items/abc", Method::Get),
RouteLookup::NotFound
));
}
#[test]
fn static_takes_priority_over_param() {
let mut router = Router::new();
router.add(route(Method::Get, "/users/me")).unwrap();
router.add(route(Method::Get, "/users/{id}")).unwrap();
let m = router.match_path("/users/me", Method::Get);
assert!(m.is_some());
let m = m.unwrap();
assert_eq!(m.route.path, "/users/me");
assert!(m.params.is_empty());
let m = router.match_path("/users/123", Method::Get);
assert!(m.is_some());
let m = m.unwrap();
assert_eq!(m.route.path, "/users/{id}");
assert_eq!(m.params[0], ("id", "123"));
}
#[test]
fn route_match_get_param() {
let mut router = Router::new();
router
.add(route(Method::Get, "/users/{user_id}/posts/{post_id}"))
.unwrap();
let m = router
.match_path("/users/42/posts/99", Method::Get)
.unwrap();
assert_eq!(m.get_param("user_id"), Some("42"));
assert_eq!(m.get_param("post_id"), Some("99"));
assert_eq!(m.get_param("unknown"), None);
}
#[test]
fn converter_matches() {
assert!(Converter::Str.matches("anything"));
assert!(Converter::Str.matches("123"));
assert!(Converter::Int.matches("123"));
assert!(Converter::Int.matches("-456"));
assert!(!Converter::Int.matches("12.34"));
assert!(!Converter::Int.matches("abc"));
assert!(Converter::Float.matches("3.14"));
assert!(Converter::Float.matches("42"));
assert!(!Converter::Float.matches("abc"));
assert!(Converter::Uuid.matches("550e8400-e29b-41d4-a716-446655440000"));
assert!(!Converter::Uuid.matches("not-a-uuid"));
assert!(Converter::Path.matches("any/path/here"));
}
#[test]
fn parse_path_segments() {
let segments = parse_path("/users/{id}/posts/{post_id:int}");
assert_eq!(segments.len(), 4);
match &segments[0] {
PathSegment::Static(s) => assert_eq!(*s, "users"),
_ => panic!("Expected static segment"),
}
match &segments[1] {
PathSegment::Param { name, converter } => {
assert_eq!(*name, "id");
assert_eq!(*converter, Converter::Str);
}
_ => panic!("Expected param segment"),
}
match &segments[2] {
PathSegment::Static(s) => assert_eq!(*s, "posts"),
_ => panic!("Expected static segment"),
}
match &segments[3] {
PathSegment::Param { name, converter } => {
assert_eq!(*name, "post_id");
assert_eq!(*converter, Converter::Int);
}
_ => panic!("Expected param segment"),
}
}
#[test]
fn extract_path_params_simple() {
let params = extract_path_params("/users/{id}");
assert_eq!(params.len(), 1);
assert_eq!(params[0].name, "id");
assert_eq!(params[0].converter, Converter::Str);
}
#[test]
fn extract_path_params_multiple() {
let params = extract_path_params("/users/{id}/posts/{post_id}");
assert_eq!(params.len(), 2);
assert_eq!(params[0].name, "id");
assert_eq!(params[1].name, "post_id");
}
#[test]
fn extract_path_params_typed_int() {
let params = extract_path_params("/items/{item_id:int}");
assert_eq!(params.len(), 1);
assert_eq!(params[0].name, "item_id");
assert_eq!(params[0].converter, Converter::Int);
}
#[test]
fn extract_path_params_typed_float() {
let params = extract_path_params("/prices/{value:float}");
assert_eq!(params.len(), 1);
assert_eq!(params[0].name, "value");
assert_eq!(params[0].converter, Converter::Float);
}
#[test]
fn extract_path_params_typed_uuid() {
let params = extract_path_params("/resources/{uuid:uuid}");
assert_eq!(params.len(), 1);
assert_eq!(params[0].name, "uuid");
assert_eq!(params[0].converter, Converter::Uuid);
}
#[test]
fn extract_path_params_wildcard_asterisk() {
let params = extract_path_params("/files/{*filepath}");
assert_eq!(params.len(), 1);
assert_eq!(params[0].name, "filepath");
assert_eq!(params[0].converter, Converter::Path);
}
#[test]
fn extract_path_params_wildcard_path_converter() {
let params = extract_path_params("/static/{path:path}");
assert_eq!(params.len(), 1);
assert_eq!(params[0].name, "path");
assert_eq!(params[0].converter, Converter::Path);
}
#[test]
fn extract_path_params_mixed_types() {
let params = extract_path_params("/api/{version}/items/{id:int}/details/{slug}");
assert_eq!(params.len(), 3);
assert_eq!(params[0].name, "version");
assert_eq!(params[0].converter, Converter::Str);
assert_eq!(params[1].name, "id");
assert_eq!(params[1].converter, Converter::Int);
assert_eq!(params[2].name, "slug");
assert_eq!(params[2].converter, Converter::Str);
}
#[test]
fn extract_path_params_no_params() {
let params = extract_path_params("/static/path/no/params");
assert!(params.is_empty());
}
#[test]
fn extract_path_params_root() {
let params = extract_path_params("/");
assert!(params.is_empty());
}
#[test]
fn param_info_new() {
let info = ParamInfo::new("id", Converter::Int);
assert_eq!(info.name, "id");
assert_eq!(info.converter, Converter::Int);
assert!(info.title.is_none());
assert!(info.description.is_none());
assert!(!info.deprecated);
assert!(info.example.is_none());
assert!(info.examples.is_empty());
}
#[test]
fn param_info_with_title() {
let info = ParamInfo::new("id", Converter::Str).with_title("User ID");
assert_eq!(info.title.as_deref(), Some("User ID"));
}
#[test]
fn param_info_with_description() {
let info =
ParamInfo::new("page", Converter::Int).with_description("Page number for pagination");
assert_eq!(
info.description.as_deref(),
Some("Page number for pagination")
);
}
#[test]
fn param_info_deprecated() {
let info = ParamInfo::new("old", Converter::Str).deprecated();
assert!(info.deprecated);
}
#[test]
fn param_info_with_example() {
let info = ParamInfo::new("id", Converter::Int).with_example(serde_json::json!(42));
assert_eq!(info.example, Some(serde_json::json!(42)));
}
#[test]
fn param_info_with_named_examples() {
let info = ParamInfo::new("status", Converter::Str)
.with_named_example("active", serde_json::json!("active"))
.with_named_example("inactive", serde_json::json!("inactive"));
assert_eq!(info.examples.len(), 2);
assert_eq!(info.examples[0].0, "active");
assert_eq!(info.examples[1].0, "inactive");
}
#[test]
fn param_info_builder_chain() {
let info = ParamInfo::new("item_id", Converter::Int)
.with_title("Item ID")
.with_description("The unique item identifier")
.deprecated()
.with_example(serde_json::json!(123));
assert_eq!(info.name, "item_id");
assert_eq!(info.converter, Converter::Int);
assert_eq!(info.title.as_deref(), Some("Item ID"));
assert_eq!(
info.description.as_deref(),
Some("The unique item identifier")
);
assert!(info.deprecated);
assert_eq!(info.example, Some(serde_json::json!(123)));
}
#[test]
fn empty_router() {
let router = Router::new();
assert!(router.match_path("/anything", Method::Get).is_none());
assert!(router.routes().is_empty());
}
#[test]
fn routes_accessor() {
let mut router = Router::new();
let _ = router.add(route(Method::Get, "/a"));
let _ = router.add(route(Method::Post, "/b"));
assert_eq!(router.routes().len(), 2);
assert_eq!(router.routes()[0].path, "/a");
assert_eq!(router.routes()[1].path, "/b");
}
#[test]
fn conflict_same_method_same_path() {
let mut router = Router::new();
router.add(route(Method::Get, "/users")).unwrap();
let result = router.add(route(Method::Get, "/users"));
assert!(result.is_err());
let err = match result.unwrap_err() {
RouteAddError::Conflict(err) => err,
RouteAddError::InvalidPath(err) => {
panic!("unexpected invalid path error: {err}")
}
};
assert_eq!(err.method, Method::Get);
assert_eq!(err.new_path, "/users");
assert_eq!(err.existing_path, "/users");
}
#[test]
fn conflict_same_method_same_param_pattern() {
let mut router = Router::new();
router.add(route(Method::Get, "/users/{id}")).unwrap();
let result = router.add(route(Method::Get, "/users/{user_id}"));
assert!(result.is_err());
let err = match result.unwrap_err() {
RouteAddError::Conflict(err) => err,
RouteAddError::InvalidPath(err) => {
panic!("unexpected invalid path error: {err}")
}
};
assert_eq!(err.existing_path, "/users/{id}");
assert_eq!(err.new_path, "/users/{user_id}");
}
#[test]
fn conflict_param_name_mismatch_across_lengths() {
let mut router = Router::new();
router.add(route(Method::Get, "/users/{id}/posts")).unwrap();
let result = router.add(route(Method::Get, "/users/{user_id}"));
assert!(matches!(result, Err(RouteAddError::Conflict(_))));
}
#[test]
fn conflict_different_converter_same_position() {
let mut router = Router::new();
router.add(route(Method::Get, "/items/{id:int}")).unwrap();
let result = router.add(route(Method::Get, "/items/{id:uuid}"));
assert!(matches!(result, Err(RouteAddError::Conflict(_))));
}
#[test]
fn no_conflict_different_methods() {
let mut router = Router::new();
router.add(route(Method::Get, "/users")).unwrap();
router.add(route(Method::Post, "/users")).unwrap();
router.add(route(Method::Put, "/users")).unwrap();
router.add(route(Method::Delete, "/users")).unwrap();
router.add(route(Method::Patch, "/users")).unwrap();
assert_eq!(router.routes().len(), 5);
}
#[test]
fn no_conflict_static_vs_param() {
let mut router = Router::new();
router.add(route(Method::Get, "/users/me")).unwrap();
router.add(route(Method::Get, "/users/{id}")).unwrap();
assert_eq!(router.routes().len(), 2);
}
#[test]
fn no_conflict_different_path_lengths() {
let mut router = Router::new();
router.add(route(Method::Get, "/users")).unwrap();
router.add(route(Method::Get, "/users/{id}")).unwrap();
router.add(route(Method::Get, "/users/{id}/posts")).unwrap();
assert_eq!(router.routes().len(), 3);
}
#[test]
fn conflict_error_display() {
let err = RouteConflictError {
method: Method::Get,
new_path: "/new".to_string(),
existing_path: "/existing".to_string(),
};
let msg = format!("{}", err);
assert!(msg.contains("GET"));
assert!(msg.contains("/new"));
assert!(msg.contains("/existing"));
}
#[test]
fn root_path() {
let mut router = Router::new();
router.add(route(Method::Get, "/")).unwrap();
let m = router.match_path("/", Method::Get);
assert!(m.is_some());
assert_eq!(m.unwrap().route.path, "/");
}
#[test]
fn trailing_slash_handling() {
let mut router = Router::new();
router.add(route(Method::Get, "/users")).unwrap();
let m = router.match_path("/users/", Method::Get);
assert!(m.is_none() || m.is_some());
}
#[test]
fn multiple_consecutive_slashes() {
let mut router = Router::new();
router.add(route(Method::Get, "/users")).unwrap();
let m = router.match_path("//users", Method::Get);
assert!(m.is_some());
assert_eq!(m.unwrap().route.path, "/users");
}
#[test]
fn unicode_in_static_path() {
let mut router = Router::new();
router.add(route(Method::Get, "/用户")).unwrap();
router.add(route(Method::Get, "/données")).unwrap();
let m = router.match_path("/用户", Method::Get);
assert!(m.is_some());
assert_eq!(m.unwrap().route.path, "/用户");
let m = router.match_path("/données", Method::Get);
assert!(m.is_some());
assert_eq!(m.unwrap().route.path, "/données");
}
#[test]
fn unicode_in_param_value() {
let mut router = Router::new();
router.add(route(Method::Get, "/users/{name}")).unwrap();
let m = router.match_path("/users/ç”°ä¸", Method::Get);
assert!(m.is_some());
let m = m.unwrap();
assert_eq!(m.params[0], ("name", "ç”°ä¸"));
}
#[test]
fn special_characters_in_param_value() {
let mut router = Router::new();
router.add(route(Method::Get, "/files/{name}")).unwrap();
let m = router.match_path("/files/my-file_v2", Method::Get);
assert!(m.is_some());
assert_eq!(m.unwrap().params[0], ("name", "my-file_v2"));
let m = router.match_path("/files/document.pdf", Method::Get);
assert!(m.is_some());
assert_eq!(m.unwrap().params[0], ("name", "document.pdf"));
}
#[test]
fn empty_param_value() {
let mut router = Router::new();
router.add(route(Method::Get, "/users/{id}/posts")).unwrap();
let m = router.match_path("/users//posts", Method::Get);
assert!(m.is_none());
}
#[test]
fn very_long_path() {
let mut router = Router::new();
let long_path = "/a/b/c/d/e/f/g/h/i/j/k/l/m/n/o/p/q/r/s/t/u/v/w/x/y/z";
router.add(route(Method::Get, long_path)).unwrap();
let m = router.match_path(long_path, Method::Get);
assert!(m.is_some());
assert_eq!(m.unwrap().route.path, long_path);
}
#[test]
fn many_routes_same_prefix() {
let mut router = Router::new();
for i in 0..100 {
router
.add(route(Method::Get, &format!("/api/v{}", i)))
.unwrap();
}
assert_eq!(router.routes().len(), 100);
for i in 0..100 {
let path = format!("/api/v{}", i);
let m = router.match_path(&path, Method::Get);
assert!(m.is_some());
assert_eq!(m.unwrap().route.path, path);
}
}
#[test]
fn head_matches_get_route() {
let mut router = Router::new();
router.add(route(Method::Get, "/users")).unwrap();
let m = router.match_path("/users", Method::Head);
assert!(m.is_some());
assert_eq!(m.unwrap().route.method, Method::Get);
}
#[test]
fn head_with_explicit_head_route() {
let mut router = Router::new();
router.add(route(Method::Get, "/users")).unwrap();
router.add(route(Method::Head, "/users")).unwrap();
let m = router.match_path("/users", Method::Head);
assert!(m.is_some());
assert_eq!(m.unwrap().route.method, Method::Head);
}
#[test]
fn head_does_not_match_non_get() {
let mut router = Router::new();
router.add(route(Method::Post, "/users")).unwrap();
let result = router.lookup("/users", Method::Head);
match result {
RouteLookup::MethodNotAllowed { allowed } => {
assert!(!allowed.contains(Method::Head));
assert!(allowed.contains(Method::Post));
}
_ => panic!("expected MethodNotAllowed"),
}
}
#[test]
fn int_converter_edge_cases() {
let mut router = Router::new();
router.add(route(Method::Get, "/items/{id:int}")).unwrap();
let m = router.match_path("/items/0", Method::Get);
assert!(m.is_some());
let m = router.match_path("/items/9223372036854775807", Method::Get);
assert!(m.is_some());
let m = router.match_path("/items/-9223372036854775808", Method::Get);
assert!(m.is_some());
let m = router.match_path("/items/007", Method::Get);
assert!(m.is_some());
let m = router.match_path("/items/+123", Method::Get);
assert!(m.is_some());
}
#[test]
fn float_converter_edge_cases() {
let mut router = Router::new();
router
.add(route(Method::Get, "/values/{val:float}"))
.unwrap();
let m = router.match_path("/values/1e10", Method::Get);
assert!(m.is_some());
let m = router.match_path("/values/1e-10", Method::Get);
assert!(m.is_some());
let m = router.match_path("/values/inf", Method::Get);
assert!(m.is_some());
let m = router.match_path("/values/NaN", Method::Get);
assert!(m.is_some());
}
#[test]
fn uuid_converter_case_sensitivity() {
let mut router = Router::new();
router
.add(route(Method::Get, "/objects/{id:uuid}"))
.unwrap();
let m = router.match_path("/objects/550e8400-e29b-41d4-a716-446655440000", Method::Get);
assert!(m.is_some());
let m = router.match_path("/objects/550E8400-E29B-41D4-A716-446655440000", Method::Get);
assert!(m.is_some());
let m = router.match_path("/objects/550e8400-E29B-41d4-A716-446655440000", Method::Get);
assert!(m.is_some());
}
#[test]
fn uuid_converter_invalid_formats() {
let mut router = Router::new();
router
.add(route(Method::Get, "/objects/{id:uuid}"))
.unwrap();
assert!(
router
.match_path("/objects/550e8400-e29b-41d4-a716-44665544000", Method::Get)
.is_none()
);
assert!(
router
.match_path(
"/objects/550e8400-e29b-41d4-a716-4466554400000",
Method::Get
)
.is_none()
);
assert!(
router
.match_path("/objects/550e8400e29b41d4a716446655440000", Method::Get)
.is_none()
);
assert!(
router
.match_path("/objects/550g8400-e29b-41d4-a716-446655440000", Method::Get)
.is_none()
);
}
#[test]
fn unknown_converter_defaults_to_str() {
let segments = parse_path("/items/{id:custom}");
assert_eq!(segments.len(), 2);
match &segments[1] {
PathSegment::Param { name, converter } => {
assert_eq!(*name, "id");
assert_eq!(*converter, Converter::Str);
}
_ => panic!("Expected param segment"),
}
}
#[test]
fn parse_empty_path() {
let segments = parse_path("");
assert!(segments.is_empty());
}
#[test]
fn parse_root_only() {
let segments = parse_path("/");
assert!(segments.is_empty());
}
#[test]
fn parse_leading_trailing_slashes() {
let segments = parse_path("///users///");
assert_eq!(segments.len(), 1);
match &segments[0] {
PathSegment::Static(s) => assert_eq!(*s, "users"),
_ => panic!("Expected static segment"),
}
}
#[test]
fn parse_param_with_colon_no_type() {
let segments = parse_path("/items/{id:}");
assert_eq!(segments.len(), 2);
match &segments[1] {
PathSegment::Param { name, converter } => {
assert_eq!(*name, "id");
assert_eq!(*converter, Converter::Str);
}
_ => panic!("Expected param segment"),
}
}
#[test]
fn lookup_404_empty_router() {
let router = Router::new();
assert!(matches!(
router.lookup("/anything", Method::Get),
RouteLookup::NotFound
));
}
#[test]
fn lookup_404_no_matching_path() {
let mut router = Router::new();
router.add(route(Method::Get, "/users")).unwrap();
router.add(route(Method::Get, "/items")).unwrap();
assert!(matches!(
router.lookup("/other", Method::Get),
RouteLookup::NotFound
));
assert!(matches!(
router.lookup("/user", Method::Get),
RouteLookup::NotFound
)); }
#[test]
fn lookup_404_partial_path_match() {
let mut router = Router::new();
router.add(route(Method::Get, "/api/v1/users")).unwrap();
assert!(matches!(
router.lookup("/api", Method::Get),
RouteLookup::NotFound
));
assert!(matches!(
router.lookup("/api/v1", Method::Get),
RouteLookup::NotFound
));
}
#[test]
fn lookup_404_extra_path_segments() {
let mut router = Router::new();
router.add(route(Method::Get, "/users")).unwrap();
assert!(matches!(
router.lookup("/users/extra", Method::Get),
RouteLookup::NotFound
));
}
#[test]
fn lookup_405_single_method() {
let mut router = Router::new();
router.add(route(Method::Get, "/users")).unwrap();
let result = router.lookup("/users", Method::Post);
match result {
RouteLookup::MethodNotAllowed { allowed } => {
assert_eq!(allowed.methods(), &[Method::Get, Method::Head]);
}
_ => panic!("expected MethodNotAllowed"),
}
}
#[test]
fn lookup_405_all_methods() {
let mut router = Router::new();
router.add(route(Method::Get, "/resource")).unwrap();
router.add(route(Method::Post, "/resource")).unwrap();
router.add(route(Method::Put, "/resource")).unwrap();
router.add(route(Method::Delete, "/resource")).unwrap();
router.add(route(Method::Patch, "/resource")).unwrap();
router.add(route(Method::Options, "/resource")).unwrap();
let result = router.lookup("/resource", Method::Trace);
match result {
RouteLookup::MethodNotAllowed { allowed } => {
let header = allowed.header_value();
assert!(header.contains("GET"));
assert!(header.contains("HEAD"));
assert!(header.contains("POST"));
assert!(header.contains("PUT"));
assert!(header.contains("DELETE"));
assert!(header.contains("PATCH"));
assert!(header.contains("OPTIONS"));
}
_ => panic!("expected MethodNotAllowed"),
}
}
#[test]
fn allowed_methods_deduplication() {
let allowed = AllowedMethods::new(vec![Method::Get, Method::Get, Method::Post]);
assert_eq!(allowed.methods().len(), 3); }
#[test]
fn allowed_methods_sorting() {
let allowed = AllowedMethods::new(vec![Method::Delete, Method::Get, Method::Post]);
assert_eq!(allowed.methods()[0], Method::Get);
assert_eq!(allowed.methods()[1], Method::Head); assert_eq!(allowed.methods()[2], Method::Post);
assert_eq!(allowed.methods()[3], Method::Delete);
}
#[test]
fn allowed_methods_head_not_duplicated() {
let allowed = AllowedMethods::new(vec![Method::Get, Method::Head]);
let count = allowed
.methods()
.iter()
.filter(|&&m| m == Method::Head)
.count();
assert_eq!(count, 1);
}
#[test]
fn allowed_methods_empty() {
let allowed = AllowedMethods::new(vec![]);
assert!(allowed.methods().is_empty());
assert_eq!(allowed.header_value(), "");
}
#[test]
fn wildcard_asterisk_syntax_basic() {
let mut router = Router::new();
router.add(route(Method::Get, "/files/{*path}")).unwrap();
let m = router.match_path("/files/a.txt", Method::Get).unwrap();
assert_eq!(m.params[0], ("path", "a.txt"));
}
#[test]
fn wildcard_asterisk_captures_multiple_segments() {
let mut router = Router::new();
router
.add(route(Method::Get, "/files/{*filepath}"))
.unwrap();
let m = router
.match_path("/files/css/styles/main.css", Method::Get)
.unwrap();
assert_eq!(m.params[0], ("filepath", "css/styles/main.css"));
}
#[test]
fn wildcard_asterisk_with_prefix() {
let mut router = Router::new();
router.add(route(Method::Get, "/api/v1/{*rest}")).unwrap();
let m = router
.match_path("/api/v1/users/123/posts", Method::Get)
.unwrap();
assert_eq!(m.params[0], ("rest", "users/123/posts"));
}
#[test]
fn wildcard_asterisk_empty_capture() {
let mut router = Router::new();
router.add(route(Method::Get, "/files/{*path}")).unwrap();
let m = router.match_path("/files/x", Method::Get).unwrap();
assert_eq!(m.params[0], ("path", "x"));
}
#[test]
fn wildcard_asterisk_must_be_terminal() {
let mut router = Router::new();
let result = router.add(route(Method::Get, "/files/{*path}/edit"));
assert!(matches!(result, Err(RouteAddError::InvalidPath(_))));
}
#[test]
fn wildcard_asterisk_syntax_equivalent_to_path_converter() {
let segments_asterisk = parse_path("/files/{*filepath}");
let segments_converter = parse_path("/files/{filepath:path}");
assert_eq!(segments_asterisk.len(), 2);
assert_eq!(segments_converter.len(), 2);
match (&segments_asterisk[1], &segments_converter[1]) {
(
PathSegment::Param {
name: n1,
converter: c1,
},
PathSegment::Param {
name: n2,
converter: c2,
},
) => {
assert_eq!(*n1, "filepath");
assert_eq!(*n2, "filepath");
assert_eq!(*c1, Converter::Path);
assert_eq!(*c2, Converter::Path);
}
_ => panic!("Expected param segments"),
}
}
#[test]
fn wildcard_asterisk_spa_routing() {
let mut router = Router::new();
router.add(route(Method::Get, "/api/users")).unwrap();
router.add(route(Method::Get, "/api/posts")).unwrap();
router.add(route(Method::Get, "/{*route}")).unwrap();
let m = router.match_path("/api/users", Method::Get).unwrap();
assert_eq!(m.route.path, "/api/users");
let m = router.match_path("/dashboard", Method::Get).unwrap();
assert_eq!(m.params[0], ("route", "dashboard"));
let m = router
.match_path("/users/123/profile", Method::Get)
.unwrap();
assert_eq!(m.params[0], ("route", "users/123/profile"));
}
#[test]
fn wildcard_asterisk_file_serving() {
let mut router = Router::new();
router
.add(route(Method::Get, "/static/{*filepath}"))
.unwrap();
let m = router
.match_path("/static/js/app.bundle.js", Method::Get)
.unwrap();
assert_eq!(m.params[0], ("filepath", "js/app.bundle.js"));
let m = router
.match_path("/static/images/logo.png", Method::Get)
.unwrap();
assert_eq!(m.params[0], ("filepath", "images/logo.png"));
}
#[test]
fn wildcard_asterisk_priority_lowest() {
let mut router = Router::new();
router.add(route(Method::Get, "/users/me")).unwrap();
router.add(route(Method::Get, "/users/{id}")).unwrap();
router.add(route(Method::Get, "/{*path}")).unwrap();
let m = router.match_path("/users/me", Method::Get).unwrap();
assert_eq!(m.route.path, "/users/me");
assert!(m.params.is_empty());
let m = router.match_path("/users/123", Method::Get).unwrap();
assert_eq!(m.route.path, "/users/{id}");
assert_eq!(m.params[0], ("id", "123"));
let m = router.match_path("/other/deep/path", Method::Get).unwrap();
assert_eq!(m.route.path, "/{*path}");
assert_eq!(m.params[0], ("path", "other/deep/path"));
}
#[test]
fn parse_wildcard_asterisk_syntax() {
let segments = parse_path("/files/{*path}");
assert_eq!(segments.len(), 2);
match &segments[0] {
PathSegment::Static(s) => assert_eq!(*s, "files"),
_ => panic!("Expected static segment"),
}
match &segments[1] {
PathSegment::Param { name, converter } => {
assert_eq!(*name, "path");
assert_eq!(*converter, Converter::Path);
}
_ => panic!("Expected param segment"),
}
}
#[test]
fn wildcard_asterisk_conflict_with_path_converter() {
let mut router = Router::new();
router.add(route(Method::Get, "/files/{*path}")).unwrap();
let result = router.add(route(Method::Get, "/files/{filepath:path}"));
assert!(matches!(result, Err(RouteAddError::Conflict(_))));
}
#[test]
fn wildcard_asterisk_different_methods_no_conflict() {
let mut router = Router::new();
router.add(route(Method::Get, "/files/{*path}")).unwrap();
router.add(route(Method::Post, "/files/{*path}")).unwrap();
router.add(route(Method::Delete, "/files/{*path}")).unwrap();
assert_eq!(router.routes().len(), 3);
let m = router.match_path("/files/a/b/c", Method::Get).unwrap();
assert_eq!(m.route.method, Method::Get);
let m = router.match_path("/files/a/b/c", Method::Post).unwrap();
assert_eq!(m.route.method, Method::Post);
let m = router.match_path("/files/a/b/c", Method::Delete).unwrap();
assert_eq!(m.route.method, Method::Delete);
}
#[test]
fn priority_static_before_param() {
let mut router = Router::new();
router.add(route(Method::Get, "/users/{id}")).unwrap();
router.add(route(Method::Get, "/users/me")).unwrap();
let m = router.match_path("/users/me", Method::Get).unwrap();
assert_eq!(m.route.path, "/users/me");
assert!(m.params.is_empty());
let m = router.match_path("/users/123", Method::Get).unwrap();
assert_eq!(m.route.path, "/users/{id}");
assert_eq!(m.params[0], ("id", "123"));
}
#[test]
fn priority_named_param_vs_wildcard_conflict() {
let mut router = Router::new();
router.add(route(Method::Get, "/files/{name}")).unwrap();
let result = router.add(route(Method::Get, "/files/{*path}"));
assert!(
matches!(result, Err(RouteAddError::Conflict(_))),
"Named param and wildcard at same position should conflict"
);
}
#[test]
fn priority_different_prefixes_no_conflict() {
let mut router = Router::new();
router.add(route(Method::Get, "/files/{name}")).unwrap();
router.add(route(Method::Get, "/static/{*path}")).unwrap();
let m = router.match_path("/files/foo.txt", Method::Get).unwrap();
assert_eq!(m.route.path, "/files/{name}");
let m = router
.match_path("/static/css/main.css", Method::Get)
.unwrap();
assert_eq!(m.route.path, "/static/{*path}");
}
#[test]
fn priority_nested_param_before_shallow_wildcard() {
let mut router = Router::new();
router.add(route(Method::Get, "/{*path}")).unwrap();
router.add(route(Method::Get, "/api/users")).unwrap();
let m = router.match_path("/api/users", Method::Get).unwrap();
assert_eq!(m.route.path, "/api/users");
let m = router.match_path("/other/path", Method::Get).unwrap();
assert_eq!(m.route.path, "/{*path}");
}
#[test]
fn priority_multiple_static_depths() {
let mut router = Router::new();
router.add(route(Method::Get, "/api/{*rest}")).unwrap();
router.add(route(Method::Get, "/api/v1/users")).unwrap();
router
.add(route(Method::Get, "/api/v1/{resource}"))
.unwrap();
let m = router.match_path("/api/v1/users", Method::Get).unwrap();
assert_eq!(m.route.path, "/api/v1/users");
let m = router.match_path("/api/v1/items", Method::Get).unwrap();
assert_eq!(m.route.path, "/api/v1/{resource}");
let m = router
.match_path("/api/v2/anything/deep", Method::Get)
.unwrap();
assert_eq!(m.route.path, "/api/{*rest}");
}
#[test]
fn priority_complex_route_set() {
let mut router = Router::new();
router.add(route(Method::Get, "/users/me")).unwrap();
router
.add(route(Method::Get, "/users/{user_id}/profile"))
.unwrap();
router.add(route(Method::Get, "/users/{user_id}")).unwrap();
router.add(route(Method::Get, "/{*path}")).unwrap();
let m = router.match_path("/users/me", Method::Get).unwrap();
assert_eq!(m.route.path, "/users/me");
let m = router.match_path("/users/123", Method::Get).unwrap();
assert_eq!(m.route.path, "/users/{user_id}");
assert_eq!(m.params[0], ("user_id", "123"));
let m = router
.match_path("/users/123/profile", Method::Get)
.unwrap();
assert_eq!(m.route.path, "/users/{user_id}/profile");
assert_eq!(m.params[0], ("user_id", "123"));
let m = router.match_path("/anything/else", Method::Get).unwrap();
assert_eq!(m.route.path, "/{*path}");
assert_eq!(m.params[0], ("path", "anything/else"));
}
#[test]
fn converter_convert_str() {
let result = Converter::Str.convert("hello", "param");
assert!(result.is_ok());
assert_eq!(result.unwrap(), ParamValue::Str("hello".to_string()));
}
#[test]
fn converter_convert_int_valid() {
let result = Converter::Int.convert("42", "id");
assert!(result.is_ok());
assert_eq!(result.unwrap(), ParamValue::Int(42));
}
#[test]
fn converter_convert_int_negative() {
let result = Converter::Int.convert("-123", "id");
assert!(result.is_ok());
assert_eq!(result.unwrap(), ParamValue::Int(-123));
}
#[test]
fn converter_convert_int_invalid() {
let result = Converter::Int.convert("abc", "id");
assert!(result.is_err());
match result.unwrap_err() {
ConversionError::InvalidInt { value, param } => {
assert_eq!(value, "abc");
assert_eq!(param, "id");
}
_ => panic!("Expected InvalidInt error"),
}
}
#[test]
#[allow(clippy::approx_constant)]
fn converter_convert_float_valid() {
let result = Converter::Float.convert("3.14", "val");
assert!(result.is_ok());
assert_eq!(result.unwrap(), ParamValue::Float(3.14));
}
#[test]
fn converter_convert_float_integer() {
let result = Converter::Float.convert("42", "val");
assert!(result.is_ok());
assert_eq!(result.unwrap(), ParamValue::Float(42.0));
}
#[test]
fn converter_convert_float_scientific() {
let result = Converter::Float.convert("1e10", "val");
assert!(result.is_ok());
assert_eq!(result.unwrap(), ParamValue::Float(1e10));
}
#[test]
fn converter_convert_float_invalid() {
let result = Converter::Float.convert("not-a-float", "val");
assert!(result.is_err());
match result.unwrap_err() {
ConversionError::InvalidFloat { value, param } => {
assert_eq!(value, "not-a-float");
assert_eq!(param, "val");
}
_ => panic!("Expected InvalidFloat error"),
}
}
#[test]
fn converter_convert_uuid_valid() {
let result = Converter::Uuid.convert("550e8400-e29b-41d4-a716-446655440000", "id");
assert!(result.is_ok());
assert_eq!(
result.unwrap(),
ParamValue::Uuid("550e8400-e29b-41d4-a716-446655440000".to_string())
);
}
#[test]
fn converter_convert_uuid_invalid() {
let result = Converter::Uuid.convert("not-a-uuid", "id");
assert!(result.is_err());
match result.unwrap_err() {
ConversionError::InvalidUuid { value, param } => {
assert_eq!(value, "not-a-uuid");
assert_eq!(param, "id");
}
_ => panic!("Expected InvalidUuid error"),
}
}
#[test]
fn converter_convert_path() {
let result = Converter::Path.convert("a/b/c.txt", "filepath");
assert!(result.is_ok());
assert_eq!(result.unwrap(), ParamValue::Path("a/b/c.txt".to_string()));
}
#[test]
fn param_value_accessors() {
let val = ParamValue::Str("hello".to_string());
assert_eq!(val.as_str(), "hello");
assert_eq!(val.as_int(), None);
assert_eq!(val.as_float(), None);
assert_eq!(val.into_string(), Some("hello".to_string()));
let val = ParamValue::Int(42);
assert_eq!(val.as_int(), Some(42));
assert_eq!(val.as_float(), None);
assert_eq!(val.into_string(), None);
#[allow(clippy::approx_constant)]
let val = ParamValue::Float(3.14);
#[allow(clippy::approx_constant)]
let expected_pi = Some(3.14);
assert_eq!(val.as_float(), expected_pi);
assert_eq!(val.as_int(), None);
assert_eq!(val.into_string(), None);
let val = ParamValue::Uuid("550e8400-e29b-41d4-a716-446655440000".to_string());
assert_eq!(val.as_str(), "550e8400-e29b-41d4-a716-446655440000");
assert_eq!(
val.into_string(),
Some("550e8400-e29b-41d4-a716-446655440000".to_string())
);
let val = ParamValue::Path("a/b/c".to_string());
assert_eq!(val.as_str(), "a/b/c");
assert_eq!(val.into_string(), Some("a/b/c".to_string()));
}
#[test]
fn conversion_error_display() {
let err = ConversionError::InvalidInt {
value: "abc".to_string(),
param: "id".to_string(),
};
let msg = format!("{}", err);
assert!(msg.contains("id"));
assert!(msg.contains("abc"));
assert!(msg.contains("integer"));
let err = ConversionError::InvalidFloat {
value: "xyz".to_string(),
param: "val".to_string(),
};
let msg = format!("{}", err);
assert!(msg.contains("val"));
assert!(msg.contains("xyz"));
assert!(msg.contains("float"));
let err = ConversionError::InvalidUuid {
value: "bad".to_string(),
param: "uuid".to_string(),
};
let msg = format!("{}", err);
assert!(msg.contains("uuid"));
assert!(msg.contains("bad"));
assert!(msg.contains("UUID"));
}
#[test]
fn converter_type_name() {
assert_eq!(Converter::Str.type_name(), "string");
assert_eq!(Converter::Int.type_name(), "integer");
assert_eq!(Converter::Float.type_name(), "float");
assert_eq!(Converter::Uuid.type_name(), "UUID");
assert_eq!(Converter::Path.type_name(), "path");
}
#[test]
fn route_match_typed_getters() {
let mut router = Router::new();
router
.add(route(Method::Get, "/items/{id:int}/price/{val:float}"))
.unwrap();
let m = router
.match_path("/items/42/price/99.99", Method::Get)
.unwrap();
assert_eq!(m.get_param("id"), Some("42"));
assert_eq!(m.get_param("val"), Some("99.99"));
assert_eq!(m.get_param_int("id"), Some(Ok(42)));
assert_eq!(m.get_param_float("val"), Some(Ok(99.99)));
assert!(m.get_param_int("missing").is_none());
let result = m.get_param_int("val");
assert!(result.is_some());
assert!(result.unwrap().is_err());
}
#[test]
fn route_match_param_count() {
let mut router = Router::new();
router
.add(route(Method::Get, "/users/{user_id}/posts/{post_id}"))
.unwrap();
let m = router.match_path("/users/1/posts/2", Method::Get).unwrap();
assert_eq!(m.param_count(), 2);
assert!(!m.is_empty());
let mut router2 = Router::new();
router2.add(route(Method::Get, "/static")).unwrap();
let m2 = router2.match_path("/static", Method::Get).unwrap();
assert_eq!(m2.param_count(), 0);
assert!(m2.is_empty());
}
#[test]
fn route_match_iter() {
let mut router = Router::new();
router
.add(route(Method::Get, "/a/{x}/b/{y}/c/{z}"))
.unwrap();
let m = router.match_path("/a/1/b/2/c/3", Method::Get).unwrap();
let params: Vec<_> = m.iter().collect();
assert_eq!(params.len(), 3);
assert_eq!(params[0], ("x", "1"));
assert_eq!(params[1], ("y", "2"));
assert_eq!(params[2], ("z", "3"));
}
#[test]
fn route_match_is_param_uuid() {
let mut router = Router::new();
router
.add(route(Method::Get, "/objects/{id:uuid}"))
.unwrap();
let m = router
.match_path("/objects/550e8400-e29b-41d4-a716-446655440000", Method::Get)
.unwrap();
assert_eq!(m.is_param_uuid("id"), Some(true));
assert_eq!(m.is_param_uuid("missing"), None);
}
#[test]
fn route_match_integer_variants() {
let mut router = Router::new();
router.add(route(Method::Get, "/items/{id}")).unwrap();
let m = router.match_path("/items/12345", Method::Get).unwrap();
assert_eq!(m.get_param_int("id"), Some(Ok(12345i64)));
assert_eq!(m.get_param_i32("id"), Some(Ok(12345i32)));
assert_eq!(m.get_param_u64("id"), Some(Ok(12345u64)));
assert_eq!(m.get_param_u32("id"), Some(Ok(12345u32)));
assert_eq!(m.get_param_float("id"), Some(Ok(12345.0f64)));
assert_eq!(m.get_param_f32("id"), Some(Ok(12345.0f32)));
}
#[test]
fn mount_basic() {
let mut child = Router::new();
child.add(route(Method::Get, "/users")).unwrap();
child.add(route(Method::Get, "/items")).unwrap();
let parent = Router::new().mount("/api/v1", child).unwrap();
let m = parent.match_path("/api/v1/users", Method::Get).unwrap();
assert_eq!(m.route.path, "/api/v1/users");
let m = parent.match_path("/api/v1/items", Method::Get).unwrap();
assert_eq!(m.route.path, "/api/v1/items");
}
#[test]
fn mount_with_params() {
let mut child = Router::new();
child.add(route(Method::Get, "/users/{id}")).unwrap();
child
.add(route(Method::Get, "/users/{id}/posts/{post_id}"))
.unwrap();
let parent = Router::new().mount("/api", child).unwrap();
let m = parent.match_path("/api/users/42", Method::Get).unwrap();
assert_eq!(m.route.path, "/api/users/{id}");
assert_eq!(m.params[0], ("id", "42"));
let m = parent
.match_path("/api/users/1/posts/99", Method::Get)
.unwrap();
assert_eq!(m.params.len(), 2);
assert_eq!(m.params[0], ("id", "1"));
assert_eq!(m.params[1], ("post_id", "99"));
}
#[test]
fn mount_preserves_methods() {
let mut child = Router::new();
child.add(route(Method::Get, "/resource")).unwrap();
child.add(route(Method::Post, "/resource")).unwrap();
child.add(route(Method::Delete, "/resource")).unwrap();
let parent = Router::new().mount("/api", child).unwrap();
let m = parent.match_path("/api/resource", Method::Get).unwrap();
assert_eq!(m.route.method, Method::Get);
let m = parent.match_path("/api/resource", Method::Post).unwrap();
assert_eq!(m.route.method, Method::Post);
let m = parent.match_path("/api/resource", Method::Delete).unwrap();
assert_eq!(m.route.method, Method::Delete);
}
#[test]
fn mount_root_route() {
let mut child = Router::new();
child.add(route(Method::Get, "/")).unwrap();
let parent = Router::new().mount("/api", child).unwrap();
let m = parent.match_path("/api", Method::Get).unwrap();
assert_eq!(m.route.path, "/api");
}
#[test]
fn mount_trailing_slash_prefix() {
let mut child = Router::new();
child.add(route(Method::Get, "/users")).unwrap();
let parent = Router::new().mount("/api/", child).unwrap();
let m = parent.match_path("/api/users", Method::Get).unwrap();
assert_eq!(m.route.path, "/api/users");
}
#[test]
fn mount_empty_prefix() {
let mut child = Router::new();
child.add(route(Method::Get, "/users")).unwrap();
let parent = Router::new().mount("", child).unwrap();
let m = parent.match_path("/users", Method::Get).unwrap();
assert_eq!(m.route.path, "/users");
}
#[test]
fn mount_nested() {
let mut inner = Router::new();
inner.add(route(Method::Get, "/items")).unwrap();
let middle = Router::new().mount("/v1", inner).unwrap();
let outer = Router::new().mount("/api", middle).unwrap();
let m = outer.match_path("/api/v1/items", Method::Get).unwrap();
assert_eq!(m.route.path, "/api/v1/items");
}
#[test]
fn mount_conflict_detection() {
let mut child1 = Router::new();
child1.add(route(Method::Get, "/users")).unwrap();
let mut child2 = Router::new();
child2.add(route(Method::Get, "/users")).unwrap();
let parent = Router::new().mount("/api", child1).unwrap();
let result = parent.mount("/api", child2);
assert!(matches!(result, Err(RouteAddError::Conflict(_))));
}
#[test]
fn mount_no_conflict_different_prefixes() {
let mut child1 = Router::new();
child1.add(route(Method::Get, "/users")).unwrap();
let mut child2 = Router::new();
child2.add(route(Method::Get, "/users")).unwrap();
let parent = Router::new()
.mount("/api/v1", child1)
.unwrap()
.mount("/api/v2", child2)
.unwrap();
let m = parent.match_path("/api/v1/users", Method::Get).unwrap();
assert_eq!(m.route.path, "/api/v1/users");
let m = parent.match_path("/api/v2/users", Method::Get).unwrap();
assert_eq!(m.route.path, "/api/v2/users");
}
#[test]
#[should_panic(expected = "route conflict when nesting router")]
fn nest_panics_on_conflict() {
let mut child1 = Router::new();
child1.add(route(Method::Get, "/users")).unwrap();
let mut child2 = Router::new();
child2.add(route(Method::Get, "/users")).unwrap();
let parent = Router::new().nest("/api", child1);
let _ = parent.nest("/api", child2);
}
#[test]
fn mount_with_wildcard() {
let mut child = Router::new();
child.add(route(Method::Get, "/files/{*path}")).unwrap();
let parent = Router::new().mount("/static", child).unwrap();
let m = parent
.match_path("/static/files/css/style.css", Method::Get)
.unwrap();
assert_eq!(m.route.path, "/static/files/{*path}");
assert_eq!(m.params[0], ("path", "css/style.css"));
}
#[test]
fn mount_parent_and_child_routes() {
let mut parent = Router::new();
parent.add(route(Method::Get, "/health")).unwrap();
let mut child = Router::new();
child.add(route(Method::Get, "/users")).unwrap();
let app = parent.mount("/api", child).unwrap();
let m = app.match_path("/health", Method::Get).unwrap();
assert_eq!(m.route.path, "/health");
let m = app.match_path("/api/users", Method::Get).unwrap();
assert_eq!(m.route.path, "/api/users");
}
#[test]
fn percent_encoded_space_in_static_path() {
let mut router = Router::new();
router.add(route(Method::Get, "/hello%20world")).unwrap();
let m = router.match_path("/hello%20world", Method::Get);
assert!(m.is_some());
assert_eq!(m.unwrap().route.path, "/hello%20world");
let m = router.match_path("/hello world", Method::Get);
assert!(m.is_none());
}
#[test]
fn percent_encoded_slash_in_param() {
let mut router = Router::new();
router.add(route(Method::Get, "/files/{name}")).unwrap();
let m = router.match_path("/files/a%2Fb.txt", Method::Get);
assert!(m.is_some());
assert_eq!(m.unwrap().params[0], ("name", "a%2Fb.txt"));
}
#[test]
fn percent_encoded_special_chars_in_param() {
let mut router = Router::new();
router.add(route(Method::Get, "/search/{query}")).unwrap();
let test_cases = vec![
("/search/hello%20world", ("query", "hello%20world")),
("/search/foo%26bar", ("query", "foo%26bar")), ("/search/a%3Db", ("query", "a%3Db")), ("/search/%23hash", ("query", "%23hash")), ("/search/100%25", ("query", "100%25")), ];
for (path, expected) in test_cases {
let m = router.match_path(path, Method::Get);
assert!(m.is_some(), "Failed to match: {}", path);
assert_eq!(m.unwrap().params[0], expected);
}
}
#[test]
fn percent_encoded_unicode_in_param() {
let mut router = Router::new();
router.add(route(Method::Get, "/users/{name}")).unwrap();
let m = router.match_path("/users/%E6%97%A5%E6%9C%AC", Method::Get);
assert!(m.is_some());
assert_eq!(m.unwrap().params[0], ("name", "%E6%97%A5%E6%9C%AC"));
}
#[test]
fn percent_encoded_in_wildcard() {
let mut router = Router::new();
router.add(route(Method::Get, "/files/{*path}")).unwrap();
let m = router.match_path("/files/dir%20name/file%20name.txt", Method::Get);
assert!(m.is_some());
assert_eq!(m.unwrap().params[0], ("path", "dir%20name/file%20name.txt"));
}
#[test]
fn double_percent_encoding() {
let mut router = Router::new();
router.add(route(Method::Get, "/data/{value}")).unwrap();
let m = router.match_path("/data/%2520", Method::Get);
assert!(m.is_some());
assert_eq!(m.unwrap().params[0], ("value", "%2520"));
}
#[test]
fn trailing_slash_strict_mode_static() {
let mut router = Router::new();
router.add(route(Method::Get, "/users")).unwrap();
router.add(route(Method::Get, "/items/")).unwrap();
let m = router.match_path("/users", Method::Get);
assert!(m.is_some());
assert_eq!(m.unwrap().route.path, "/users");
let m = router.match_path("/users/", Method::Get);
if let Some(m) = m {
assert!(m.route.path == "/users" || m.route.path == "/users/");
}
let m = router.match_path("/items/", Method::Get);
assert!(m.is_some());
}
#[test]
fn trailing_slash_on_param_routes() {
let mut router = Router::new();
router.add(route(Method::Get, "/users/{id}")).unwrap();
let m = router.match_path("/users/123", Method::Get);
assert!(m.is_some());
assert_eq!(m.unwrap().params[0], ("id", "123"));
let m = router.match_path("/users/123/", Method::Get);
if let Some(m) = m {
assert_eq!(m.params[0].0, "id");
}
}
#[test]
fn trailing_slash_on_nested_routes() {
let mut router = Router::new();
router.add(route(Method::Get, "/api/v1/users")).unwrap();
let result = router.add(route(Method::Get, "/api/v1/users/"));
assert!(
matches!(result, Err(RouteAddError::Conflict(_))),
"Routes with and without trailing slash should conflict"
);
assert_eq!(router.routes().len(), 1);
}
#[test]
fn multiple_trailing_slashes() {
let mut router = Router::new();
router.add(route(Method::Get, "/data")).unwrap();
let m = router.match_path("/data//", Method::Get);
assert!(m.is_some());
let m = router.match_path("/data///", Method::Get);
assert!(m.is_some()); }
#[test]
fn empty_segment_normalization() {
let mut router = Router::new();
router.add(route(Method::Get, "/a/b/c")).unwrap();
let paths = vec!["/a//b/c", "/a/b//c", "//a/b/c", "/a/b/c//", "//a//b//c//"];
for path in paths {
let m = router.match_path(path, Method::Get);
assert!(m.is_some(), "Failed to match normalized path: {}", path);
assert_eq!(m.unwrap().route.path, "/a/b/c");
}
}
#[test]
fn empty_segment_in_middle_of_params() {
let mut router = Router::new();
router.add(route(Method::Get, "/a/{x}/b/{y}")).unwrap();
let m = router.match_path("/a//1/b/2", Method::Get);
if let Some(m) = m {
assert!(!m.params.is_empty());
}
}
#[test]
fn only_slashes_path() {
let mut router = Router::new();
router.add(route(Method::Get, "/")).unwrap();
let paths = vec!["/", "//", "///", "////"];
for path in paths {
let m = router.match_path(path, Method::Get);
assert!(m.is_some(), "Failed to match root with: {}", path);
}
}
#[test]
fn empty_path_handling() {
let mut router = Router::new();
router.add(route(Method::Get, "/")).unwrap();
let m = router.match_path("", Method::Get);
let _matched = m.is_some();
}
#[test]
fn deep_nesting_50_levels() {
let mut router = Router::new();
let path = format!(
"/{}",
(0..50)
.map(|i| format!("l{}", i))
.collect::<Vec<_>>()
.join("/")
);
router.add(route(Method::Get, &path)).unwrap();
let m = router.match_path(&path, Method::Get);
assert!(m.is_some());
assert_eq!(m.unwrap().route.path, path);
}
#[test]
fn deep_nesting_100_levels() {
let mut router = Router::new();
let path = format!(
"/{}",
(0..100)
.map(|i| format!("d{}", i))
.collect::<Vec<_>>()
.join("/")
);
router.add(route(Method::Get, &path)).unwrap();
let m = router.match_path(&path, Method::Get);
assert!(m.is_some());
assert_eq!(m.unwrap().route.path, path);
}
#[test]
fn deep_nesting_with_params_at_various_depths() {
let mut router = Router::new();
let mut segments = vec![];
for i in 0..20 {
if i == 5 || i == 10 || i == 15 {
segments.push(format!("{{p{}}}", i));
} else {
segments.push(format!("s{}", i));
}
}
let path = format!("/{}", segments.join("/"));
router.add(route(Method::Get, &path)).unwrap();
let mut request_segments = vec![];
for i in 0..20 {
if i == 5 || i == 10 || i == 15 {
request_segments.push(format!("val{}", i));
} else {
request_segments.push(format!("s{}", i));
}
}
let request_path = format!("/{}", request_segments.join("/"));
let m = router.match_path(&request_path, Method::Get);
assert!(m.is_some());
let m = m.unwrap();
assert_eq!(m.params.len(), 3);
assert_eq!(m.params[0], ("p5", "val5"));
assert_eq!(m.params[1], ("p10", "val10"));
assert_eq!(m.params[2], ("p15", "val15"));
}
#[test]
fn deep_nesting_with_wildcard_at_end() {
let mut router = Router::new();
let segments: Vec<_> = (0..30).map(|i| format!("x{}", i)).collect();
let prefix = format!("/{}", segments.join("/"));
let path = format!("{}/{{*rest}}", prefix);
router.add(route(Method::Get, &path)).unwrap();
let request_path = format!("{}/a/b/c/d/e", prefix);
let m = router.match_path(&request_path, Method::Get);
assert!(m.is_some());
assert_eq!(m.unwrap().params[0], ("rest", "a/b/c/d/e"));
}
#[test]
fn many_siblings_500_routes() {
let mut router = Router::new();
for i in 0..500 {
router
.add(route(Method::Get, &format!("/api/endpoint{}", i)))
.unwrap();
}
assert_eq!(router.routes().len(), 500);
for i in [0, 50, 100, 250, 499] {
let path = format!("/api/endpoint{}", i);
let m = router.match_path(&path, Method::Get);
assert!(m.is_some(), "Failed to match: {}", path);
assert_eq!(m.unwrap().route.path, path);
}
}
#[test]
fn many_siblings_with_shared_prefix() {
let mut router = Router::new();
for i in 0..200 {
router
.add(route(Method::Get, &format!("/users/user{:04}", i)))
.unwrap();
}
assert_eq!(router.routes().len(), 200);
for i in [0, 50, 100, 150, 199] {
let path = format!("/users/user{:04}", i);
let m = router.match_path(&path, Method::Get);
assert!(m.is_some());
assert_eq!(m.unwrap().route.path, path);
}
}
#[test]
fn many_siblings_mixed_static_and_param() {
let mut router = Router::new();
for i in 0..100 {
router
.add(route(Method::Get, &format!("/items/item{}", i)))
.unwrap();
}
router.add(route(Method::Get, "/items/{id}")).unwrap();
assert_eq!(router.routes().len(), 101);
let m = router.match_path("/items/item50", Method::Get).unwrap();
assert_eq!(m.route.path, "/items/item50");
let m = router.match_path("/items/other", Method::Get).unwrap();
assert_eq!(m.route.path, "/items/{id}");
assert_eq!(m.params[0], ("id", "other"));
}
#[test]
fn many_siblings_different_methods() {
let mut router = Router::new();
let methods = vec![
Method::Get,
Method::Post,
Method::Put,
Method::Delete,
Method::Patch,
];
for i in 0..50 {
for method in &methods {
router
.add(Route::new(*method, &format!("/resource{}", i)))
.unwrap();
}
}
assert_eq!(router.routes().len(), 250);
let m = router.match_path("/resource25", Method::Get).unwrap();
assert_eq!(m.route.method, Method::Get);
let m = router.match_path("/resource25", Method::Post).unwrap();
assert_eq!(m.route.method, Method::Post);
let m = router.match_path("/resource25", Method::Delete).unwrap();
assert_eq!(m.route.method, Method::Delete);
}
#[test]
fn stress_wide_and_deep() {
let mut router = Router::new();
for a in 0..10 {
for b in 0..10 {
for c in 0..10 {
let path = format!("/a{}/b{}/c{}", a, b, c);
router.add(route(Method::Get, &path)).unwrap();
}
}
}
assert_eq!(router.routes().len(), 1000);
let m = router.match_path("/a0/b0/c0", Method::Get).unwrap();
assert_eq!(m.route.path, "/a0/b0/c0");
let m = router.match_path("/a5/b5/c5", Method::Get).unwrap();
assert_eq!(m.route.path, "/a5/b5/c5");
let m = router.match_path("/a9/b9/c9", Method::Get).unwrap();
assert_eq!(m.route.path, "/a9/b9/c9");
assert!(router.match_path("/a10/b0/c0", Method::Get).is_none());
assert!(router.match_path("/a0/b10/c0", Method::Get).is_none());
}
#[test]
fn unicode_emoji_in_path() {
let mut router = Router::new();
router.add(route(Method::Get, "/emoji/🎉")).unwrap();
let m = router.match_path("/emoji/🎉", Method::Get);
assert!(m.is_some());
assert_eq!(m.unwrap().route.path, "/emoji/🎉");
}
#[test]
fn unicode_rtl_characters() {
let mut router = Router::new();
router.add(route(Method::Get, "/greet/Ù…Ø±ØØ¨Ø§")).unwrap();
let m = router.match_path("/greet/Ù…Ø±ØØ¨Ø§", Method::Get);
assert!(m.is_some());
assert_eq!(m.unwrap().route.path, "/greet/Ù…Ø±ØØ¨Ø§");
}
#[test]
fn unicode_mixed_scripts() {
let mut router = Router::new();
router
.add(route(Method::Get, "/mix/hello世界Привет"))
.unwrap();
let m = router.match_path("/mix/hello世界Привет", Method::Get);
assert!(m.is_some());
}
#[test]
fn unicode_normalization_awareness() {
let mut router = Router::new();
router.add(route(Method::Get, "/café")).unwrap();
let m = router.match_path("/café", Method::Get);
assert!(m.is_some());
}
#[test]
fn unicode_in_param_with_converter() {
let mut router = Router::new();
router.add(route(Method::Get, "/data/{value:str}")).unwrap();
let m = router.match_path("/data/日本語テスト", Method::Get);
assert!(m.is_some());
assert_eq!(m.unwrap().params[0], ("value", "日本語テスト"));
}
#[test]
fn int_converter_overflow() {
let mut router = Router::new();
router.add(route(Method::Get, "/id/{num:int}")).unwrap();
let overflow = "99999999999999999999999999999";
let path = format!("/id/{}", overflow);
let m = router.match_path(&path, Method::Get);
assert!(m.is_none());
}
#[test]
fn float_converter_very_small() {
let mut router = Router::new();
router.add(route(Method::Get, "/val/{v:float}")).unwrap();
let m = router.match_path("/val/1e-308", Method::Get);
assert!(m.is_some());
}
#[test]
fn float_converter_very_large() {
let mut router = Router::new();
router.add(route(Method::Get, "/val/{v:float}")).unwrap();
let m = router.match_path("/val/1e308", Method::Get);
assert!(m.is_some());
}
#[test]
fn uuid_converter_nil_uuid() {
let mut router = Router::new();
router.add(route(Method::Get, "/obj/{id:uuid}")).unwrap();
let m = router.match_path("/obj/00000000-0000-0000-0000-000000000000", Method::Get);
assert!(m.is_some());
}
#[test]
fn uuid_converter_max_uuid() {
let mut router = Router::new();
router.add(route(Method::Get, "/obj/{id:uuid}")).unwrap();
let m = router.match_path("/obj/ffffffff-ffff-ffff-ffff-ffffffffffff", Method::Get);
assert!(m.is_some());
}
#[test]
fn path_with_dots() {
let mut router = Router::new();
router.add(route(Method::Get, "/files/{name}")).unwrap();
let m = router.match_path("/files/file.name.ext", Method::Get);
assert!(m.is_some());
assert_eq!(m.unwrap().params[0], ("name", "file.name.ext"));
}
#[test]
fn path_with_only_special_chars() {
let mut router = Router::new();
router.add(route(Method::Get, "/data/{val}")).unwrap();
let m = router.match_path("/data/-._~", Method::Get);
assert!(m.is_some());
assert_eq!(m.unwrap().params[0], ("val", "-._~"));
}
#[test]
fn path_segment_with_colon() {
let mut router = Router::new();
router.add(route(Method::Get, "/time/{val}")).unwrap();
let m = router.match_path("/time/12:30:45", Method::Get);
assert!(m.is_some());
assert_eq!(m.unwrap().params[0], ("val", "12:30:45"));
}
#[test]
fn path_segment_with_at_sign() {
let mut router = Router::new();
router.add(route(Method::Get, "/user/{handle}")).unwrap();
let m = router.match_path("/user/@username", Method::Get);
assert!(m.is_some());
assert_eq!(m.unwrap().params[0], ("handle", "@username"));
}
#[test]
fn very_long_segment() {
let mut router = Router::new();
router.add(route(Method::Get, "/data/{val}")).unwrap();
let long_val: String = (0..1000).map(|_| 'x').collect();
let path = format!("/data/{}", long_val);
let m = router.match_path(&path, Method::Get);
assert!(m.is_some());
assert_eq!(m.unwrap().params[0].1.len(), 1000);
}
#[test]
fn very_long_path_total() {
let mut router = Router::new();
let segments: Vec<_> = (0..500).map(|i| format!("s{}", i)).collect();
let path = format!("/{}", segments.join("/"));
router.add(route(Method::Get, &path)).unwrap();
let m = router.match_path(&path, Method::Get);
assert!(m.is_some());
}
}