elif_http/routing/
pattern.rs

1//! Route pattern matching system for elif.rs
2//!
3//! This module provides the core route pattern parsing and matching functionality
4//! that is independent of Axum and uses pure elif types.
5
6use super::HttpMethod;
7use regex::Regex;
8use std::collections::HashMap;
9use thiserror::Error;
10
11/// Errors that can occur during route pattern operations
12#[derive(Error, Debug)]
13pub enum RoutePatternError {
14    #[error("Invalid pattern syntax: {0}")]
15    InvalidSyntax(String),
16    #[error("Multiple catch-all segments not allowed")]
17    MultipleCatchAll,
18    #[error("Catch-all must be the last segment")]
19    CatchAllNotLast,
20    #[error("Invalid constraint syntax: {0}")]
21    InvalidConstraint(String),
22    #[error("Duplicate parameter name: {0}")]
23    DuplicateParameter(String),
24}
25
26/// Parameter constraints for validation
27#[derive(Debug, Clone)]
28pub enum ParamConstraint {
29    /// No constraint - any non-empty string
30    None,
31    /// Must be a valid integer
32    Int,
33    /// Must be a valid UUID
34    Uuid,
35    /// Must contain only alphabetic characters
36    Alpha,
37    /// Must be a valid slug (alphanumeric + hyphens/underscores)
38    Slug,
39    /// Custom regex pattern
40    Custom(Regex),
41}
42
43impl PartialEq for ParamConstraint {
44    fn eq(&self, other: &Self) -> bool {
45        match (self, other) {
46            (ParamConstraint::None, ParamConstraint::None) => true,
47            (ParamConstraint::Int, ParamConstraint::Int) => true,
48            (ParamConstraint::Uuid, ParamConstraint::Uuid) => true,
49            (ParamConstraint::Alpha, ParamConstraint::Alpha) => true,
50            (ParamConstraint::Slug, ParamConstraint::Slug) => true,
51            (ParamConstraint::Custom(regex1), ParamConstraint::Custom(regex2)) => {
52                regex1.as_str() == regex2.as_str()
53            }
54            _ => false,
55        }
56    }
57}
58
59impl ParamConstraint {
60    /// Parse constraint from string (e.g., "int", "uuid", "alpha")
61    pub fn from_str(s: &str) -> Result<Self, RoutePatternError> {
62        match s {
63            "int" => Ok(ParamConstraint::Int),
64            "uuid" => Ok(ParamConstraint::Uuid),
65            "alpha" => Ok(ParamConstraint::Alpha),
66            "slug" => Ok(ParamConstraint::Slug),
67            _ => {
68                // Try to parse as regex
69                match Regex::new(s) {
70                    Ok(regex) => Ok(ParamConstraint::Custom(regex)),
71                    Err(e) => Err(RoutePatternError::InvalidConstraint(format!(
72                        "Invalid regex pattern '{}': {}",
73                        s, e
74                    ))),
75                }
76            }
77        }
78    }
79
80    /// Validate a parameter value against this constraint
81    pub fn validate(&self, value: &str) -> bool {
82        if value.is_empty() {
83            return false;
84        }
85
86        match self {
87            ParamConstraint::None => true,
88            ParamConstraint::Int => value.parse::<i64>().is_ok(),
89            ParamConstraint::Uuid => uuid::Uuid::parse_str(value).is_ok(),
90            ParamConstraint::Alpha => value.chars().all(|c| c.is_alphabetic()),
91            ParamConstraint::Slug => value
92                .chars()
93                .all(|c| c.is_alphanumeric() || c == '-' || c == '_'),
94            ParamConstraint::Custom(regex) => regex.is_match(value),
95        }
96    }
97}
98
99/// A single path segment in a route pattern
100#[derive(Debug, Clone, PartialEq)]
101pub enum PathSegment {
102    /// Static text segment
103    Static(String),
104    /// Parameter segment with optional constraint
105    Parameter {
106        name: String,
107        constraint: ParamConstraint,
108    },
109    /// Catch-all segment (must be last)
110    CatchAll { name: String },
111}
112
113/// Parsed route pattern with compiled segments
114#[derive(Debug, Clone)]
115pub struct RoutePattern {
116    /// The original path string
117    pub original_path: String,
118    /// Parsed path segments
119    pub segments: Vec<PathSegment>,
120    /// Parameter names in order
121    pub param_names: Vec<String>,
122    /// Whether this pattern has a catch-all segment
123    pub has_catch_all: bool,
124    /// Number of static segments (for priority calculation)
125    pub static_segments: usize,
126}
127
128impl RoutePattern {
129    /// Parse a route pattern from a path string
130    pub fn parse(path: &str) -> Result<Self, RoutePatternError> {
131        let mut segments = Vec::new();
132        let mut param_names = Vec::new();
133        let mut has_catch_all = false;
134        let mut static_segments = 0;
135        let mut seen_params = std::collections::HashSet::new();
136
137        let path_segments: Vec<&str> = path.split('/').filter(|s| !s.is_empty()).collect();
138
139        for (index, segment) in path_segments.iter().enumerate() {
140            let segment = segment.trim();
141
142            if segment.starts_with('{') && segment.ends_with('}') {
143                // Parameter segment: {name} or {name:constraint}
144                let param_def = &segment[1..segment.len() - 1];
145                let (name, constraint) = Self::parse_parameter_definition(param_def)?;
146
147                // Check for duplicate parameters
148                if seen_params.contains(&name) {
149                    return Err(RoutePatternError::DuplicateParameter(name));
150                }
151                seen_params.insert(name.clone());
152
153                segments.push(PathSegment::Parameter {
154                    name: name.clone(),
155                    constraint,
156                });
157                param_names.push(name);
158            } else if segment.starts_with('*') {
159                // Catch-all segment: *name
160                if has_catch_all {
161                    return Err(RoutePatternError::MultipleCatchAll);
162                }
163
164                // Catch-all must be the last segment
165                if index != path_segments.len() - 1 {
166                    return Err(RoutePatternError::CatchAllNotLast);
167                }
168
169                let name = segment[1..].to_string();
170                if name.is_empty() {
171                    return Err(RoutePatternError::InvalidSyntax(
172                        "Catch-all segment must have a name".to_string(),
173                    ));
174                }
175
176                // Check for duplicate parameters
177                if seen_params.contains(&name) {
178                    return Err(RoutePatternError::DuplicateParameter(name));
179                }
180                seen_params.insert(name.clone());
181
182                segments.push(PathSegment::CatchAll { name: name.clone() });
183                param_names.push(name);
184                has_catch_all = true;
185            } else {
186                // Static segment
187                if segment.is_empty() {
188                    return Err(RoutePatternError::InvalidSyntax(
189                        "Empty path segments not allowed".to_string(),
190                    ));
191                }
192                segments.push(PathSegment::Static(segment.to_string()));
193                static_segments += 1;
194            }
195        }
196
197        Ok(RoutePattern {
198            original_path: path.to_string(),
199            segments,
200            param_names,
201            has_catch_all,
202            static_segments,
203        })
204    }
205
206    /// Parse parameter definition (e.g., "id", "id:int", "slug:alpha")
207    fn parse_parameter_definition(
208        param_def: &str,
209    ) -> Result<(String, ParamConstraint), RoutePatternError> {
210        if let Some(colon_pos) = param_def.find(':') {
211            let name = param_def[..colon_pos].trim().to_string();
212            let constraint_str = param_def[colon_pos + 1..].trim();
213
214            if name.is_empty() {
215                return Err(RoutePatternError::InvalidSyntax(
216                    "Parameter name cannot be empty".to_string(),
217                ));
218            }
219
220            let constraint = ParamConstraint::from_str(constraint_str)?;
221            Ok((name, constraint))
222        } else {
223            let name = param_def.trim().to_string();
224            if name.is_empty() {
225                return Err(RoutePatternError::InvalidSyntax(
226                    "Parameter name cannot be empty".to_string(),
227                ));
228            }
229            Ok((name, ParamConstraint::None))
230        }
231    }
232
233    /// Check if this pattern matches a given path
234    pub fn matches(&self, path: &str) -> bool {
235        let path_segments: Vec<&str> = path.split('/').filter(|s| !s.is_empty()).collect();
236
237        let mut pattern_idx = 0;
238        let mut path_idx = 0;
239
240        while pattern_idx < self.segments.len() && path_idx < path_segments.len() {
241            match &self.segments[pattern_idx] {
242                PathSegment::Static(expected) => {
243                    if expected != path_segments[path_idx] {
244                        return false;
245                    }
246                    pattern_idx += 1;
247                    path_idx += 1;
248                }
249
250                PathSegment::Parameter { constraint, .. } => {
251                    if !constraint.validate(path_segments[path_idx]) {
252                        return false;
253                    }
254                    pattern_idx += 1;
255                    path_idx += 1;
256                }
257
258                PathSegment::CatchAll { .. } => {
259                    // Catch-all matches everything remaining
260                    return true;
261                }
262            }
263        }
264
265        // For exact match: all pattern segments consumed and all path segments consumed
266        // For catch-all: pattern is consumed (catch-all handled above)
267        pattern_idx == self.segments.len()
268            && (path_idx == path_segments.len() || self.has_catch_all)
269    }
270
271    /// Extract parameter values from a path that matches this pattern
272    pub fn extract_params(&self, path: &str) -> HashMap<String, String> {
273        let mut params = HashMap::new();
274
275        let path_segments: Vec<&str> = path.split('/').filter(|s| !s.is_empty()).collect();
276
277        let mut pattern_idx = 0;
278        let mut path_idx = 0;
279
280        while pattern_idx < self.segments.len() && path_idx < path_segments.len() {
281            match &self.segments[pattern_idx] {
282                PathSegment::Static(_) => {
283                    pattern_idx += 1;
284                    path_idx += 1;
285                }
286
287                PathSegment::Parameter { name, .. } => {
288                    params.insert(name.clone(), path_segments[path_idx].to_string());
289                    pattern_idx += 1;
290                    path_idx += 1;
291                }
292
293                PathSegment::CatchAll { name } => {
294                    // Collect all remaining segments for catch-all
295                    let remaining: Vec<&str> = path_segments[path_idx..].to_vec();
296                    params.insert(name.clone(), remaining.join("/"));
297                    break;
298                }
299            }
300        }
301
302        params
303    }
304
305    /// Calculate priority for route matching (lower = higher priority)
306    ///
307    /// Priority system accounts for constraint specificity:
308    /// - Static segment: 1 (highest priority)
309    /// - Parameter with specific constraint (Int, Uuid): 5
310    /// - Parameter with general constraint (Alpha, Slug): 8  
311    /// - Parameter with custom regex constraint: 6 (between specific and general)
312    /// - Parameter with no constraint: 10
313    /// - Catch-all segment: 100 (lowest priority)
314    pub fn priority(&self) -> usize {
315        let mut priority = 0;
316
317        for segment in &self.segments {
318            match segment {
319                PathSegment::Static(_) => {
320                    priority += 1; // Highest priority - exact match
321                }
322                PathSegment::Parameter { constraint, .. } => {
323                    priority += match constraint {
324                        ParamConstraint::Int | ParamConstraint::Uuid => 5, // Specific constraints
325                        ParamConstraint::Custom(_) => 6, // Custom regex (medium-high)
326                        ParamConstraint::Alpha | ParamConstraint::Slug => 8, // General constraints
327                        ParamConstraint::None => 10,     // No constraint (most general)
328                    };
329                }
330                PathSegment::CatchAll { .. } => {
331                    priority += 100; // Lowest priority - catches everything
332                }
333            }
334        }
335
336        priority
337    }
338
339    /// Check if this is a static route (no parameters or catch-all)
340    pub fn is_static(&self) -> bool {
341        self.segments
342            .iter()
343            .all(|seg| matches!(seg, PathSegment::Static(_)))
344    }
345}
346
347/// Unique identifier for a route
348pub type RouteId = String;
349
350/// Information about a matched route
351#[derive(Debug, Clone)]
352pub struct RouteMatch {
353    pub route_id: RouteId,
354    pub params: HashMap<String, String>,
355}
356
357/// A compiled route ready for matching
358#[derive(Debug, Clone)]
359pub struct CompiledRoute {
360    pub id: RouteId,
361    pub method: HttpMethod,
362    pub pattern: RoutePattern,
363    pub priority: usize,
364}
365
366impl CompiledRoute {
367    pub fn new(id: RouteId, method: HttpMethod, pattern: RoutePattern) -> Self {
368        let priority = pattern.priority();
369        Self {
370            id,
371            method,
372            pattern,
373            priority,
374        }
375    }
376
377    /// Check if this route matches the given method and path
378    pub fn matches(&self, method: &HttpMethod, path: &str) -> bool {
379        self.method == *method && self.pattern.matches(path)
380    }
381
382    /// Extract parameters from a matching path
383    pub fn extract_params(&self, path: &str) -> HashMap<String, String> {
384        self.pattern.extract_params(path)
385    }
386}
387
388#[cfg(test)]
389mod tests {
390    use super::*;
391
392    #[test]
393    fn test_parse_static_route() {
394        let pattern = RoutePattern::parse("/users").unwrap();
395        assert_eq!(pattern.segments.len(), 1);
396        assert!(matches!(&pattern.segments[0], PathSegment::Static(s) if s == "users"));
397        assert!(pattern.param_names.is_empty());
398        assert!(!pattern.has_catch_all);
399        assert_eq!(pattern.static_segments, 1);
400    }
401
402    #[test]
403    fn test_parse_parameter_route() {
404        let pattern = RoutePattern::parse("/users/{id}").unwrap();
405        assert_eq!(pattern.segments.len(), 2);
406        assert!(matches!(&pattern.segments[0], PathSegment::Static(s) if s == "users"));
407        assert!(
408            matches!(&pattern.segments[1], PathSegment::Parameter { name, constraint } 
409            if name == "id" && matches!(constraint, ParamConstraint::None))
410        );
411        assert_eq!(pattern.param_names, vec!["id"]);
412        assert!(!pattern.has_catch_all);
413        assert_eq!(pattern.static_segments, 1);
414    }
415
416    #[test]
417    fn test_parse_constrained_parameter() {
418        let pattern = RoutePattern::parse("/users/{id:int}").unwrap();
419        assert!(
420            matches!(&pattern.segments[1], PathSegment::Parameter { name, constraint } 
421            if name == "id" && matches!(constraint, ParamConstraint::Int))
422        );
423    }
424
425    #[test]
426    fn test_parse_catch_all_route() {
427        let pattern = RoutePattern::parse("/files/*path").unwrap();
428        assert_eq!(pattern.segments.len(), 2);
429        assert!(matches!(&pattern.segments[1], PathSegment::CatchAll { name } if name == "path"));
430        assert!(pattern.has_catch_all);
431        assert_eq!(pattern.param_names, vec!["path"]);
432    }
433
434    #[test]
435    fn test_invalid_patterns() {
436        assert!(RoutePattern::parse("/users/{id}/files/*path/more").is_err()); // Catch-all not last
437        assert!(RoutePattern::parse("/users/{id}/{id}").is_err()); // Duplicate parameter
438        assert!(RoutePattern::parse("/users/{}").is_err()); // Empty parameter name
439        assert!(RoutePattern::parse("/files/*").is_err()); // Empty catch-all name
440    }
441
442    #[test]
443    fn test_pattern_matching() {
444        let pattern = RoutePattern::parse("/users/{id}/posts/{slug}").unwrap();
445
446        assert!(pattern.matches("/users/123/posts/hello-world"));
447        assert!(!pattern.matches("/users/123/posts")); // Missing slug
448        assert!(!pattern.matches("/users/123/posts/hello/world")); // Too many segments
449        assert!(!pattern.matches("/posts/123/posts/hello")); // Wrong static segment
450    }
451
452    #[test]
453    fn test_parameter_extraction() {
454        let pattern = RoutePattern::parse("/users/{id}/posts/{slug}").unwrap();
455        let params = pattern.extract_params("/users/123/posts/hello-world");
456
457        assert_eq!(params.get("id"), Some(&"123".to_string()));
458        assert_eq!(params.get("slug"), Some(&"hello-world".to_string()));
459        assert_eq!(params.len(), 2);
460    }
461
462    #[test]
463    fn test_catch_all_extraction() {
464        let pattern = RoutePattern::parse("/files/*path").unwrap();
465        let params = pattern.extract_params("/files/docs/images/logo.png");
466
467        assert_eq!(
468            params.get("path"),
469            Some(&"docs/images/logo.png".to_string())
470        );
471    }
472
473    #[test]
474    fn test_constraint_validation() {
475        assert!(ParamConstraint::Int.validate("123"));
476        assert!(!ParamConstraint::Int.validate("abc"));
477
478        assert!(ParamConstraint::Alpha.validate("hello"));
479        assert!(!ParamConstraint::Alpha.validate("hello123"));
480
481        assert!(ParamConstraint::Slug.validate("hello-world_123"));
482        assert!(!ParamConstraint::Slug.validate("hello world!"));
483
484        // UUID validation
485        let uuid_str = "550e8400-e29b-41d4-a716-446655440000";
486        assert!(ParamConstraint::Uuid.validate(uuid_str));
487        assert!(!ParamConstraint::Uuid.validate("not-a-uuid"));
488    }
489
490    #[test]
491    fn test_pattern_priorities() {
492        let static_pattern = RoutePattern::parse("/users").unwrap();
493        let param_pattern = RoutePattern::parse("/users/{id}").unwrap();
494        let catch_all_pattern = RoutePattern::parse("/users/*path").unwrap();
495        let mixed_pattern = RoutePattern::parse("/api/v1/users/{id}/posts/{slug}").unwrap();
496
497        assert!(static_pattern.priority() < param_pattern.priority());
498        assert!(param_pattern.priority() < catch_all_pattern.priority());
499
500        // Mixed pattern: 4 static + 2 unconstrained parameters = 4*1 + 2*10 = 24
501        assert_eq!(mixed_pattern.priority(), 24);
502    }
503
504    #[test]
505    fn test_constraint_based_priorities() {
506        // Test that more specific constraints have higher priority (lower numbers)
507        let static_route = RoutePattern::parse("/users/123").unwrap();
508        let int_constraint = RoutePattern::parse("/users/{id:int}").unwrap();
509        let custom_constraint = RoutePattern::parse("/users/{id:[0-9]+}").unwrap();
510        let alpha_constraint = RoutePattern::parse("/users/{slug:alpha}").unwrap();
511        let no_constraint = RoutePattern::parse("/users/{name}").unwrap();
512        let catch_all = RoutePattern::parse("/users/*path").unwrap();
513
514        // Priority order: static < int < custom < alpha < none < catch-all
515        assert!(static_route.priority() < int_constraint.priority());
516        assert!(int_constraint.priority() < custom_constraint.priority());
517        assert!(custom_constraint.priority() < alpha_constraint.priority());
518        assert!(alpha_constraint.priority() < no_constraint.priority());
519        assert!(no_constraint.priority() < catch_all.priority());
520
521        // Verify exact priority values
522        assert_eq!(static_route.priority(), 2); // 2 static segments: 2*1 = 2
523        assert_eq!(int_constraint.priority(), 6); // 1 static + 1 int param: 1*1 + 1*5 = 6
524        assert_eq!(custom_constraint.priority(), 7); // 1 static + 1 custom param: 1*1 + 1*6 = 7
525        assert_eq!(alpha_constraint.priority(), 9); // 1 static + 1 alpha param: 1*1 + 1*8 = 9
526        assert_eq!(no_constraint.priority(), 11); // 1 static + 1 unconstrained param: 1*1 + 1*10 = 11
527        assert_eq!(catch_all.priority(), 101); // 1 static + 1 catch-all: 1*1 + 1*100 = 101
528    }
529
530    #[test]
531    fn test_complex_priority_scenarios() {
532        // Test realistic routing scenarios where order matters
533
534        // Scenario 1: API versioning with different constraint specificity
535        let api_v1_int = RoutePattern::parse("/api/v1/users/{id:int}").unwrap();
536        let api_v1_uuid = RoutePattern::parse("/api/v1/users/{id:uuid}").unwrap();
537        let api_v1_slug = RoutePattern::parse("/api/v1/users/{slug:alpha}").unwrap();
538        let api_v1_any = RoutePattern::parse("/api/v1/users/{identifier}").unwrap();
539
540        // More specific constraints should have higher priority
541        assert!(api_v1_int.priority() == api_v1_uuid.priority()); // Both specific constraints
542        assert!(api_v1_int.priority() < api_v1_slug.priority()); // Specific < general
543        assert!(api_v1_slug.priority() < api_v1_any.priority()); // General < unconstrained
544
545        // Scenario 2: Mixed static and dynamic routing
546        let users_profile = RoutePattern::parse("/users/{id:int}/profile").unwrap();
547        let users_posts = RoutePattern::parse("/users/{id:int}/posts/{post_id:int}").unwrap();
548        let users_files = RoutePattern::parse("/users/{id:int}/files/*path").unwrap();
549
550        // More static segments = higher priority
551        assert!(users_profile.priority() < users_posts.priority()); // profile is more specific
552        assert!(users_posts.priority() < users_files.priority()); // files has catch-all
553
554        // Verify calculations
555        // users_profile: 2 static + 1 int = 2*1 + 1*5 = 7
556        assert_eq!(users_profile.priority(), 7);
557        // users_posts: 2 static + 2 int = 2*1 + 2*5 = 12
558        assert_eq!(users_posts.priority(), 12);
559        // users_files: 2 static + 1 int + 1 catch-all = 2*1 + 1*5 + 1*100 = 107
560        assert_eq!(users_files.priority(), 107);
561    }
562
563    #[test]
564    fn test_compiled_route_matching() {
565        let pattern = RoutePattern::parse("/users/{id:int}").unwrap();
566        let route = CompiledRoute::new("test".to_string(), HttpMethod::GET, pattern);
567
568        assert!(route.matches(&HttpMethod::GET, "/users/123"));
569        assert!(!route.matches(&HttpMethod::POST, "/users/123")); // Wrong method
570        assert!(!route.matches(&HttpMethod::GET, "/users/abc")); // Constraint violation
571    }
572}