use std::collections::HashMap;
#[derive(Debug, Clone, PartialEq, Eq)]
#[allow(dead_code)] pub(crate) struct PathParam {
pub name: String,
pub value: String,
}
#[derive(Debug, Clone)]
pub struct ClientPathPattern {
pattern: String,
regex: regex::Regex,
param_names: Vec<String>,
is_exact: bool,
}
const MAX_CLIENT_PATTERN_LENGTH: usize = 1024;
const MAX_CLIENT_PATH_SEGMENTS: usize = 32;
const MAX_CLIENT_REGEX_SIZE: usize = 1 << 20;
impl ClientPathPattern {
pub fn new(pattern: &str) -> Result<Self, String> {
if pattern.len() > MAX_CLIENT_PATTERN_LENGTH {
return Err(format!(
"Pattern length {} exceeds maximum allowed length of {} bytes",
pattern.len(),
MAX_CLIENT_PATTERN_LENGTH
));
}
let segment_count = pattern.split('/').count();
if segment_count > MAX_CLIENT_PATH_SEGMENTS {
return Err(format!(
"Pattern has {} path segments, exceeding maximum of {}",
segment_count, MAX_CLIENT_PATH_SEGMENTS
));
}
let (regex_str, param_names) = Self::compile_pattern(pattern);
let regex = regex::RegexBuilder::new(®ex_str)
.size_limit(MAX_CLIENT_REGEX_SIZE)
.build()
.map_err(|e| format!("Failed to compile pattern regex: {}", e))?;
Ok(Self {
pattern: pattern.to_string(),
regex,
param_names,
is_exact: !pattern.contains('{'),
})
}
fn compile_pattern(pattern: &str) -> (String, Vec<String>) {
let mut regex_str = String::from("^");
let mut param_names = Vec::new();
let mut chars = pattern.chars().peekable();
while let Some(c) = chars.next() {
match c {
'{' => {
let mut param = String::new();
let mut is_wildcard = false;
while let Some(&next) = chars.peek() {
if next == '}' {
chars.next(); break;
}
if next == ':' {
chars.next(); if chars.peek() == Some(&'*') {
chars.next(); is_wildcard = true;
}
continue;
}
param.push(chars.next().unwrap());
}
param_names.push(param.clone());
if is_wildcard {
regex_str.push_str(&format!("(?P<{}>.*)", param));
} else {
regex_str.push_str(&format!("(?P<{}>[^/]+)", param));
}
}
'/' | '.' | '+' | '*' | '?' | '(' | ')' | '[' | ']' | '^' | '$' | '|' | '\\' => {
regex_str.push('\\');
regex_str.push(c);
}
_ => {
regex_str.push(c);
}
}
}
regex_str.push('$');
(regex_str, param_names)
}
pub fn pattern(&self) -> &str {
&self.pattern
}
pub fn param_names(&self) -> &[String] {
&self.param_names
}
pub fn matches(&self, path: &str) -> Option<(HashMap<String, String>, Vec<String>)> {
self.regex.captures(path).map(|caps| {
let params: HashMap<String, String> = self
.param_names
.iter()
.filter_map(|name| {
caps.name(name)
.map(|m| (name.clone(), m.as_str().to_string()))
})
.collect();
let param_values: Vec<String> = self
.param_names
.iter()
.filter_map(|name| caps.name(name).map(|m| m.as_str().to_string()))
.collect();
(params, param_values)
})
}
pub fn reverse(&self, params: &HashMap<String, String>) -> Option<String> {
let mut result = self.pattern.clone();
for name in &self.param_names {
let value = params.get(name)?;
let placeholder = format!("{{{}}}", name);
let wildcard_placeholder = format!("{{{}:*}}", name);
if result.contains(&placeholder) {
result = result.replace(&placeholder, value);
} else if result.contains(&wildcard_placeholder) {
result = result.replace(&wildcard_placeholder, value);
} else {
return None;
}
}
Some(result)
}
pub fn is_match(&self, path: &str) -> bool {
self.regex.is_match(path)
}
pub fn is_exact(&self) -> bool {
self.is_exact
}
}
impl PartialEq for ClientPathPattern {
fn eq(&self, other: &Self) -> bool {
self.pattern == other.pattern
}
}
impl Eq for ClientPathPattern {}
impl std::fmt::Display for ClientPathPattern {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "{}", self.pattern)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_exact_pattern() {
let pattern = ClientPathPattern::new("/users/").unwrap();
assert!(pattern.is_exact());
assert!(pattern.is_match("/users/"));
assert!(!pattern.is_match("/users/123/"));
}
#[test]
fn test_single_param() {
let pattern = ClientPathPattern::new("/users/{id}/").unwrap();
assert!(!pattern.is_exact());
assert!(pattern.is_match("/users/42/"));
assert!(pattern.is_match("/users/abc/"));
assert!(!pattern.is_match("/users/"));
let (params, param_values) = pattern.matches("/users/42/").unwrap();
assert_eq!(params.get("id"), Some(&"42".to_string()));
assert_eq!(param_values, vec!["42".to_string()]);
}
#[test]
fn test_multiple_params() {
let pattern = ClientPathPattern::new("/users/{user_id}/posts/{post_id}/").unwrap();
let (params, param_values) = pattern.matches("/users/42/posts/123/").unwrap();
assert_eq!(params.get("user_id"), Some(&"42".to_string()));
assert_eq!(params.get("post_id"), Some(&"123".to_string()));
assert_eq!(param_values, vec!["42".to_string(), "123".to_string()]);
}
#[test]
fn test_wildcard_param() {
let pattern = ClientPathPattern::new("/static/{path:*}").unwrap();
let (params, param_values) = pattern.matches("/static/css/styles/main.css").unwrap();
assert_eq!(params.get("path"), Some(&"css/styles/main.css".to_string()));
assert_eq!(param_values, vec!["css/styles/main.css".to_string()]);
}
#[test]
fn test_reverse_simple() {
let pattern = ClientPathPattern::new("/users/{id}/").unwrap();
let mut params = HashMap::new();
params.insert("id".to_string(), "42".to_string());
assert_eq!(pattern.reverse(¶ms), Some("/users/42/".to_string()));
}
#[test]
fn test_reverse_multiple_params() {
let pattern = ClientPathPattern::new("/users/{user_id}/posts/{post_id}/").unwrap();
let mut params = HashMap::new();
params.insert("user_id".to_string(), "42".to_string());
params.insert("post_id".to_string(), "123".to_string());
assert_eq!(
pattern.reverse(¶ms),
Some("/users/42/posts/123/".to_string())
);
}
#[test]
fn test_reverse_missing_param() {
let pattern = ClientPathPattern::new("/users/{id}/").unwrap();
let params = HashMap::new();
assert_eq!(pattern.reverse(¶ms), None);
}
#[test]
fn test_param_names() {
let pattern = ClientPathPattern::new("/a/{x}/b/{y}/c/{z}/").unwrap();
assert_eq!(pattern.param_names(), &["x", "y", "z"]);
}
#[test]
fn test_special_chars_escaped() {
let pattern = ClientPathPattern::new("/api/v1.0/").unwrap();
assert!(pattern.is_match("/api/v1.0/"));
assert!(!pattern.is_match("/api/v1X0/"));
}
#[test]
fn test_pattern_display() {
let pattern = ClientPathPattern::new("/users/{id}/").unwrap();
assert_eq!(format!("{}", pattern), "/users/{id}/");
}
#[test]
fn test_pattern_equality() {
let p1 = ClientPathPattern::new("/users/{id}/").unwrap();
let p2 = ClientPathPattern::new("/users/{id}/").unwrap();
let p3 = ClientPathPattern::new("/users/{user_id}/").unwrap();
assert_eq!(p1, p2);
assert_ne!(p1, p3);
}
#[test]
fn test_pattern_rejects_excessive_length() {
let long_pattern = "/".to_string() + &"a".repeat(1025);
let result = ClientPathPattern::new(&long_pattern);
assert!(result.is_err());
assert!(
result
.unwrap_err()
.contains("exceeds maximum allowed length")
);
}
#[test]
fn test_pattern_rejects_excessive_segments() {
let segments: Vec<&str> = (0..35).map(|_| "seg").collect();
let pattern = format!("/{}/", segments.join("/"));
let result = ClientPathPattern::new(&pattern);
assert!(result.is_err());
assert!(result.unwrap_err().contains("exceeding maximum"));
}
}