use super::HttpMethod;
use regex::Regex;
use std::collections::HashMap;
use thiserror::Error;
#[derive(Error, Debug)]
pub enum RoutePatternError {
#[error("Invalid pattern syntax: {0}")]
InvalidSyntax(String),
#[error("Multiple catch-all segments not allowed")]
MultipleCatchAll,
#[error("Catch-all must be the last segment")]
CatchAllNotLast,
#[error("Invalid constraint syntax: {0}")]
InvalidConstraint(String),
#[error("Duplicate parameter name: {0}")]
DuplicateParameter(String),
}
#[derive(Debug, Clone)]
pub enum ParamConstraint {
None,
Int,
Uuid,
Alpha,
Slug,
Custom(Regex),
}
impl PartialEq for ParamConstraint {
fn eq(&self, other: &Self) -> bool {
match (self, other) {
(ParamConstraint::None, ParamConstraint::None) => true,
(ParamConstraint::Int, ParamConstraint::Int) => true,
(ParamConstraint::Uuid, ParamConstraint::Uuid) => true,
(ParamConstraint::Alpha, ParamConstraint::Alpha) => true,
(ParamConstraint::Slug, ParamConstraint::Slug) => true,
(ParamConstraint::Custom(regex1), ParamConstraint::Custom(regex2)) => {
regex1.as_str() == regex2.as_str()
}
_ => false,
}
}
}
impl ParamConstraint {
pub fn from_str(s: &str) -> Result<Self, RoutePatternError> {
match s {
"int" => Ok(ParamConstraint::Int),
"uuid" => Ok(ParamConstraint::Uuid),
"alpha" => Ok(ParamConstraint::Alpha),
"slug" => Ok(ParamConstraint::Slug),
_ => {
match Regex::new(s) {
Ok(regex) => Ok(ParamConstraint::Custom(regex)),
Err(e) => Err(RoutePatternError::InvalidConstraint(format!(
"Invalid regex pattern '{}': {}",
s, e
))),
}
}
}
}
pub fn validate(&self, value: &str) -> bool {
if value.is_empty() {
return false;
}
match self {
ParamConstraint::None => true,
ParamConstraint::Int => value.parse::<i64>().is_ok(),
ParamConstraint::Uuid => uuid::Uuid::parse_str(value).is_ok(),
ParamConstraint::Alpha => value.chars().all(|c| c.is_alphabetic()),
ParamConstraint::Slug => value
.chars()
.all(|c| c.is_alphanumeric() || c == '-' || c == '_'),
ParamConstraint::Custom(regex) => regex.is_match(value),
}
}
}
#[derive(Debug, Clone, PartialEq)]
pub enum PathSegment {
Static(String),
Parameter {
name: String,
constraint: ParamConstraint,
},
CatchAll { name: String },
}
#[derive(Debug, Clone)]
pub struct RoutePattern {
pub original_path: String,
pub segments: Vec<PathSegment>,
pub param_names: Vec<String>,
pub has_catch_all: bool,
pub static_segments: usize,
}
impl RoutePattern {
pub fn parse(path: &str) -> Result<Self, RoutePatternError> {
let mut segments = Vec::new();
let mut param_names = Vec::new();
let mut has_catch_all = false;
let mut static_segments = 0;
let mut seen_params = std::collections::HashSet::new();
let path_segments: Vec<&str> = path.split('/').filter(|s| !s.is_empty()).collect();
for (index, segment) in path_segments.iter().enumerate() {
let segment = segment.trim();
if segment.starts_with('{') && segment.ends_with('}') {
let param_def = &segment[1..segment.len() - 1];
let (name, constraint) = Self::parse_parameter_definition(param_def)?;
if seen_params.contains(&name) {
return Err(RoutePatternError::DuplicateParameter(name));
}
seen_params.insert(name.clone());
segments.push(PathSegment::Parameter {
name: name.clone(),
constraint,
});
param_names.push(name);
} else if segment.starts_with('*') {
if has_catch_all {
return Err(RoutePatternError::MultipleCatchAll);
}
if index != path_segments.len() - 1 {
return Err(RoutePatternError::CatchAllNotLast);
}
let name = segment[1..].to_string();
if name.is_empty() {
return Err(RoutePatternError::InvalidSyntax(
"Catch-all segment must have a name".to_string(),
));
}
if seen_params.contains(&name) {
return Err(RoutePatternError::DuplicateParameter(name));
}
seen_params.insert(name.clone());
segments.push(PathSegment::CatchAll { name: name.clone() });
param_names.push(name);
has_catch_all = true;
} else {
if segment.is_empty() {
return Err(RoutePatternError::InvalidSyntax(
"Empty path segments not allowed".to_string(),
));
}
segments.push(PathSegment::Static(segment.to_string()));
static_segments += 1;
}
}
Ok(RoutePattern {
original_path: path.to_string(),
segments,
param_names,
has_catch_all,
static_segments,
})
}
fn parse_parameter_definition(
param_def: &str,
) -> Result<(String, ParamConstraint), RoutePatternError> {
if let Some(colon_pos) = param_def.find(':') {
let name = param_def[..colon_pos].trim().to_string();
let constraint_str = param_def[colon_pos + 1..].trim();
if name.is_empty() {
return Err(RoutePatternError::InvalidSyntax(
"Parameter name cannot be empty".to_string(),
));
}
let constraint = ParamConstraint::from_str(constraint_str)?;
Ok((name, constraint))
} else {
let name = param_def.trim().to_string();
if name.is_empty() {
return Err(RoutePatternError::InvalidSyntax(
"Parameter name cannot be empty".to_string(),
));
}
Ok((name, ParamConstraint::None))
}
}
pub fn matches(&self, path: &str) -> bool {
let path_segments: Vec<&str> = path.split('/').filter(|s| !s.is_empty()).collect();
let mut pattern_idx = 0;
let mut path_idx = 0;
while pattern_idx < self.segments.len() && path_idx < path_segments.len() {
match &self.segments[pattern_idx] {
PathSegment::Static(expected) => {
if expected != path_segments[path_idx] {
return false;
}
pattern_idx += 1;
path_idx += 1;
}
PathSegment::Parameter { constraint, .. } => {
if !constraint.validate(path_segments[path_idx]) {
return false;
}
pattern_idx += 1;
path_idx += 1;
}
PathSegment::CatchAll { .. } => {
return true;
}
}
}
pattern_idx == self.segments.len()
&& (path_idx == path_segments.len() || self.has_catch_all)
}
pub fn extract_params(&self, path: &str) -> HashMap<String, String> {
let mut params = HashMap::new();
let path_segments: Vec<&str> = path.split('/').filter(|s| !s.is_empty()).collect();
let mut pattern_idx = 0;
let mut path_idx = 0;
while pattern_idx < self.segments.len() && path_idx < path_segments.len() {
match &self.segments[pattern_idx] {
PathSegment::Static(_) => {
pattern_idx += 1;
path_idx += 1;
}
PathSegment::Parameter { name, .. } => {
params.insert(name.clone(), path_segments[path_idx].to_string());
pattern_idx += 1;
path_idx += 1;
}
PathSegment::CatchAll { name } => {
let remaining: Vec<&str> = path_segments[path_idx..].to_vec();
params.insert(name.clone(), remaining.join("/"));
break;
}
}
}
params
}
pub fn priority(&self) -> usize {
let mut priority = 0;
for segment in &self.segments {
match segment {
PathSegment::Static(_) => {
priority += 1; }
PathSegment::Parameter { constraint, .. } => {
priority += match constraint {
ParamConstraint::Int | ParamConstraint::Uuid => 5, ParamConstraint::Custom(_) => 6, ParamConstraint::Alpha | ParamConstraint::Slug => 8, ParamConstraint::None => 10, };
}
PathSegment::CatchAll { .. } => {
priority += 100; }
}
}
priority
}
pub fn is_static(&self) -> bool {
self.segments
.iter()
.all(|seg| matches!(seg, PathSegment::Static(_)))
}
}
pub type RouteId = String;
#[derive(Debug, Clone)]
pub struct RouteMatch {
pub route_id: RouteId,
pub params: HashMap<String, String>,
}
#[derive(Debug, Clone)]
pub struct CompiledRoute {
pub id: RouteId,
pub method: HttpMethod,
pub pattern: RoutePattern,
pub priority: usize,
}
impl CompiledRoute {
pub fn new(id: RouteId, method: HttpMethod, pattern: RoutePattern) -> Self {
let priority = pattern.priority();
Self {
id,
method,
pattern,
priority,
}
}
pub fn matches(&self, method: &HttpMethod, path: &str) -> bool {
self.method == *method && self.pattern.matches(path)
}
pub fn extract_params(&self, path: &str) -> HashMap<String, String> {
self.pattern.extract_params(path)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_parse_static_route() {
let pattern = RoutePattern::parse("/users").unwrap();
assert_eq!(pattern.segments.len(), 1);
assert!(matches!(&pattern.segments[0], PathSegment::Static(s) if s == "users"));
assert!(pattern.param_names.is_empty());
assert!(!pattern.has_catch_all);
assert_eq!(pattern.static_segments, 1);
}
#[test]
fn test_parse_parameter_route() {
let pattern = RoutePattern::parse("/users/{id}").unwrap();
assert_eq!(pattern.segments.len(), 2);
assert!(matches!(&pattern.segments[0], PathSegment::Static(s) if s == "users"));
assert!(
matches!(&pattern.segments[1], PathSegment::Parameter { name, constraint }
if name == "id" && matches!(constraint, ParamConstraint::None))
);
assert_eq!(pattern.param_names, vec!["id"]);
assert!(!pattern.has_catch_all);
assert_eq!(pattern.static_segments, 1);
}
#[test]
fn test_parse_constrained_parameter() {
let pattern = RoutePattern::parse("/users/{id:int}").unwrap();
assert!(
matches!(&pattern.segments[1], PathSegment::Parameter { name, constraint }
if name == "id" && matches!(constraint, ParamConstraint::Int))
);
}
#[test]
fn test_parse_catch_all_route() {
let pattern = RoutePattern::parse("/files/*path").unwrap();
assert_eq!(pattern.segments.len(), 2);
assert!(matches!(&pattern.segments[1], PathSegment::CatchAll { name } if name == "path"));
assert!(pattern.has_catch_all);
assert_eq!(pattern.param_names, vec!["path"]);
}
#[test]
fn test_invalid_patterns() {
assert!(RoutePattern::parse("/users/{id}/files/*path/more").is_err()); assert!(RoutePattern::parse("/users/{id}/{id}").is_err()); assert!(RoutePattern::parse("/users/{}").is_err()); assert!(RoutePattern::parse("/files/*").is_err()); }
#[test]
fn test_pattern_matching() {
let pattern = RoutePattern::parse("/users/{id}/posts/{slug}").unwrap();
assert!(pattern.matches("/users/123/posts/hello-world"));
assert!(!pattern.matches("/users/123/posts")); assert!(!pattern.matches("/users/123/posts/hello/world")); assert!(!pattern.matches("/posts/123/posts/hello")); }
#[test]
fn test_parameter_extraction() {
let pattern = RoutePattern::parse("/users/{id}/posts/{slug}").unwrap();
let params = pattern.extract_params("/users/123/posts/hello-world");
assert_eq!(params.get("id"), Some(&"123".to_string()));
assert_eq!(params.get("slug"), Some(&"hello-world".to_string()));
assert_eq!(params.len(), 2);
}
#[test]
fn test_catch_all_extraction() {
let pattern = RoutePattern::parse("/files/*path").unwrap();
let params = pattern.extract_params("/files/docs/images/logo.png");
assert_eq!(
params.get("path"),
Some(&"docs/images/logo.png".to_string())
);
}
#[test]
fn test_constraint_validation() {
assert!(ParamConstraint::Int.validate("123"));
assert!(!ParamConstraint::Int.validate("abc"));
assert!(ParamConstraint::Alpha.validate("hello"));
assert!(!ParamConstraint::Alpha.validate("hello123"));
assert!(ParamConstraint::Slug.validate("hello-world_123"));
assert!(!ParamConstraint::Slug.validate("hello world!"));
let uuid_str = "550e8400-e29b-41d4-a716-446655440000";
assert!(ParamConstraint::Uuid.validate(uuid_str));
assert!(!ParamConstraint::Uuid.validate("not-a-uuid"));
}
#[test]
fn test_pattern_priorities() {
let static_pattern = RoutePattern::parse("/users").unwrap();
let param_pattern = RoutePattern::parse("/users/{id}").unwrap();
let catch_all_pattern = RoutePattern::parse("/users/*path").unwrap();
let mixed_pattern = RoutePattern::parse("/api/v1/users/{id}/posts/{slug}").unwrap();
assert!(static_pattern.priority() < param_pattern.priority());
assert!(param_pattern.priority() < catch_all_pattern.priority());
assert_eq!(mixed_pattern.priority(), 24);
}
#[test]
fn test_constraint_based_priorities() {
let static_route = RoutePattern::parse("/users/123").unwrap();
let int_constraint = RoutePattern::parse("/users/{id:int}").unwrap();
let custom_constraint = RoutePattern::parse("/users/{id:[0-9]+}").unwrap();
let alpha_constraint = RoutePattern::parse("/users/{slug:alpha}").unwrap();
let no_constraint = RoutePattern::parse("/users/{name}").unwrap();
let catch_all = RoutePattern::parse("/users/*path").unwrap();
assert!(static_route.priority() < int_constraint.priority());
assert!(int_constraint.priority() < custom_constraint.priority());
assert!(custom_constraint.priority() < alpha_constraint.priority());
assert!(alpha_constraint.priority() < no_constraint.priority());
assert!(no_constraint.priority() < catch_all.priority());
assert_eq!(static_route.priority(), 2); assert_eq!(int_constraint.priority(), 6); assert_eq!(custom_constraint.priority(), 7); assert_eq!(alpha_constraint.priority(), 9); assert_eq!(no_constraint.priority(), 11); assert_eq!(catch_all.priority(), 101); }
#[test]
fn test_complex_priority_scenarios() {
let api_v1_int = RoutePattern::parse("/api/v1/users/{id:int}").unwrap();
let api_v1_uuid = RoutePattern::parse("/api/v1/users/{id:uuid}").unwrap();
let api_v1_slug = RoutePattern::parse("/api/v1/users/{slug:alpha}").unwrap();
let api_v1_any = RoutePattern::parse("/api/v1/users/{identifier}").unwrap();
assert!(api_v1_int.priority() == api_v1_uuid.priority()); assert!(api_v1_int.priority() < api_v1_slug.priority()); assert!(api_v1_slug.priority() < api_v1_any.priority());
let users_profile = RoutePattern::parse("/users/{id:int}/profile").unwrap();
let users_posts = RoutePattern::parse("/users/{id:int}/posts/{post_id:int}").unwrap();
let users_files = RoutePattern::parse("/users/{id:int}/files/*path").unwrap();
assert!(users_profile.priority() < users_posts.priority()); assert!(users_posts.priority() < users_files.priority());
assert_eq!(users_profile.priority(), 7);
assert_eq!(users_posts.priority(), 12);
assert_eq!(users_files.priority(), 107);
}
#[test]
fn test_compiled_route_matching() {
let pattern = RoutePattern::parse("/users/{id:int}").unwrap();
let route = CompiledRoute::new("test".to_string(), HttpMethod::GET, pattern);
assert!(route.matches(&HttpMethod::GET, "/users/123"));
assert!(!route.matches(&HttpMethod::POST, "/users/123")); assert!(!route.matches(&HttpMethod::GET, "/users/abc")); }
}