phantom_frame/
path_matcher.rs

1/// Path matching module with wildcard support
2///
3/// Supports wildcard patterns where * can appear anywhere in the pattern
4/// Example patterns: "/api/*", "/*/users", "/api/*/data"
5/// Also supports method prefixes: "POST /api/*", "GET *", "PUT /hello"
6
7/// Parse a pattern into optional method and path parts
8/// Returns (method, path_pattern)
9/// Examples:
10///   "POST /api/*" -> (Some("POST"), "/api/*")
11///   "/api/*" -> (None, "/api/*")
12///   "GET *" -> (Some("GET"), "*")
13fn parse_pattern(pattern: &str) -> (Option<&str>, &str) {
14    let pattern = pattern.trim();
15    
16    // Check if pattern starts with an HTTP method
17    let methods = ["GET", "POST", "PUT", "DELETE", "PATCH", "HEAD", "OPTIONS", "CONNECT", "TRACE"];
18    
19    for method in &methods {
20        if pattern.starts_with(method) {
21            let rest = &pattern[method.len()..];
22            // Must be followed by whitespace
23            if rest.starts_with(' ') || rest.starts_with('\t') {
24                let path_pattern = rest.trim_start();
25                return (Some(method), path_pattern);
26            }
27        }
28    }
29    
30    (None, pattern)
31}
32
33/// Check if a path matches a wildcard pattern
34/// * can appear anywhere and matches any sequence of characters
35/// If method is provided, pattern can optionally specify a method prefix like "POST /api/*"
36pub fn matches_pattern(path: &str, pattern: &str) -> bool {
37    matches_pattern_with_method(None, path, pattern)
38}
39
40/// Check if a request (method + path) matches a pattern
41/// Pattern can be just a path or "METHOD /path"
42/// Examples:
43///   matches_pattern_with_method(Some("POST"), "/api/users", "POST /api/*") -> true
44///   matches_pattern_with_method(Some("GET"), "/api/users", "POST /api/*") -> false
45///   matches_pattern_with_method(Some("GET"), "/api/users", "/api/*") -> true (no method constraint)
46pub fn matches_pattern_with_method(method: Option<&str>, path: &str, pattern: &str) -> bool {
47    let (pattern_method, path_pattern) = parse_pattern(pattern);
48    
49    // If pattern specifies a method, it must match
50    if let Some(required_method) = pattern_method {
51        if let Some(actual_method) = method {
52            if required_method != actual_method {
53                return false;
54            }
55        } else {
56            // Pattern requires a method but none was provided
57            return false;
58        }
59    }
60    
61    // Now match the path part using the existing logic
62    matches_path_pattern(path, path_pattern)
63}
64
65/// Internal function to match just the path against a pattern
66fn matches_path_pattern(path: &str, pattern: &str) -> bool {
67    // Split pattern by * to get segments
68    let segments: Vec<&str> = pattern.split('*').collect();
69    
70    if segments.len() == 1 {
71        // No wildcards, exact match
72        return path == pattern;
73    }
74    
75    let mut current_pos = 0;
76    
77    for (i, segment) in segments.iter().enumerate() {
78        if i == 0 {
79            // First segment must match at the start
80            if !segment.is_empty() && !path.starts_with(segment) {
81                return false;
82            }
83            current_pos = segment.len();
84        } else if i == segments.len() - 1 {
85            // Last segment must match at the end
86            if !segment.is_empty() && !path.ends_with(segment) {
87                return false;
88            }
89            // Also ensure that the last segment appears after current_pos
90            if !segment.is_empty() {
91                if let Some(pos) = path[current_pos..].find(segment) {
92                    if current_pos + pos + segment.len() != path.len() {
93                        return false;
94                    }
95                } else {
96                    return false;
97                }
98            }
99        } else {
100            // Middle segments must appear in order
101            if let Some(pos) = path[current_pos..].find(segment) {
102                current_pos += pos + segment.len();
103            } else {
104                return false;
105            }
106        }
107    }
108    
109    true
110}
111
112/// Check if a request should be cached based on include and exclude patterns
113/// - If include_paths is empty, all paths are included
114/// - If exclude_paths is empty, no paths are excluded
115/// - exclude_paths overrides include_paths
116/// - Patterns can include method prefixes: "POST /api/*", "GET *", etc.
117pub fn should_cache_path(
118    method: &str,
119    path: &str,
120    include_paths: &[String],
121    exclude_paths: &[String],
122) -> bool {
123    // Check exclude patterns first (they override includes)
124    if !exclude_paths.is_empty() {
125        for pattern in exclude_paths {
126            if matches_pattern_with_method(Some(method), path, pattern) {
127                return false;
128            }
129        }
130    }
131    
132    // If include_paths is empty, include everything (that wasn't excluded)
133    if include_paths.is_empty() {
134        return true;
135    }
136    
137    // Check if path matches any include pattern
138    for pattern in include_paths {
139        if matches_pattern_with_method(Some(method), path, pattern) {
140            return true;
141        }
142    }
143    
144    false
145}
146
147#[cfg(test)]
148mod tests {
149    use super::*;
150
151    #[test]
152    fn test_exact_match() {
153        assert!(matches_pattern("/api/users", "/api/users"));
154        assert!(!matches_pattern("/api/users", "/api/posts"));
155    }
156
157    #[test]
158    fn test_wildcard_at_end() {
159        assert!(matches_pattern("/api/users", "/api/*"));
160        assert!(matches_pattern("/api/users/123", "/api/*"));
161        assert!(!matches_pattern("/apiv2/users", "/api/*"));
162    }
163
164    #[test]
165    fn test_wildcard_at_start() {
166        assert!(matches_pattern("/api/users", "*/users"));
167        assert!(matches_pattern("/v1/api/users", "*/users"));
168        assert!(!matches_pattern("/api/posts", "*/users"));
169    }
170
171    #[test]
172    fn test_wildcard_in_middle() {
173        assert!(matches_pattern("/api/v1/users", "/api/*/users"));
174        assert!(matches_pattern("/api/v2/users", "/api/*/users"));
175        assert!(!matches_pattern("/api/v1/posts", "/api/*/users"));
176    }
177
178    #[test]
179    fn test_multiple_wildcards() {
180        assert!(matches_pattern("/api/v1/users/123", "/api/*/users/*"));
181        assert!(matches_pattern("/api/v2/users/456", "/api/*/users/*"));
182        assert!(!matches_pattern("/api/v1/posts/123", "/api/*/users/*"));
183    }
184
185    #[test]
186    fn test_wildcard_only() {
187        assert!(matches_pattern("/anything", "*"));
188        assert!(matches_pattern("/api/users/123", "*"));
189    }
190
191    #[test]
192    fn test_should_cache_path_empty_filters() {
193        // Empty include and exclude should cache everything
194        assert!(should_cache_path("GET", "/api/users", &[], &[]));
195        assert!(should_cache_path("POST", "/anything", &[], &[]));
196    }
197
198    #[test]
199    fn test_should_cache_path_include_only() {
200        let include = vec!["/api/*".to_string(), "/public/*".to_string()];
201        let exclude = vec![];
202        
203        assert!(should_cache_path("GET", "/api/users", &include, &exclude));
204        assert!(should_cache_path("GET", "/public/index.html", &include, &exclude));
205        assert!(!should_cache_path("GET", "/private/data", &include, &exclude));
206    }
207
208    #[test]
209    fn test_should_cache_path_exclude_only() {
210        let include = vec![];
211        let exclude = vec!["/admin/*".to_string(), "/private/*".to_string()];
212        
213        assert!(should_cache_path("GET", "/api/users", &include, &exclude));
214        assert!(!should_cache_path("GET", "/admin/dashboard", &include, &exclude));
215        assert!(!should_cache_path("GET", "/private/data", &include, &exclude));
216    }
217
218    #[test]
219    fn test_should_cache_path_exclude_overrides_include() {
220        let include = vec!["/api/*".to_string()];
221        let exclude = vec!["/api/admin/*".to_string()];
222        
223        assert!(should_cache_path("GET", "/api/users", &include, &exclude));
224        assert!(!should_cache_path("GET", "/api/admin/users", &include, &exclude));
225    }
226
227    #[test]
228    fn test_method_pattern_matching() {
229        // Test exact method match
230        assert!(matches_pattern_with_method(Some("POST"), "/api/users", "POST /api/users"));
231        assert!(!matches_pattern_with_method(Some("GET"), "/api/users", "POST /api/users"));
232        
233        // Test method with wildcard
234        assert!(matches_pattern_with_method(Some("POST"), "/api/users", "POST /api/*"));
235        assert!(matches_pattern_with_method(Some("POST"), "/api/posts", "POST /api/*"));
236        assert!(!matches_pattern_with_method(Some("POST"), "/not-api/posts", "POST /api/*"));
237        assert!(!matches_pattern_with_method(Some("GET"), "/api/users", "POST /api/*"));
238        
239        // Test wildcard method matching (pattern without method should match any)
240        assert!(matches_pattern_with_method(Some("GET"), "/api/users", "/api/*"));
241        assert!(matches_pattern_with_method(Some("POST"), "/api/users", "/api/*"));
242        
243        // Test "POST *" pattern
244        assert!(matches_pattern_with_method(Some("POST"), "/anything", "POST *"));
245        assert!(matches_pattern_with_method(Some("POST"), "/api/users/123", "POST *"));
246        assert!(!matches_pattern_with_method(Some("GET"), "/anything", "POST *"));
247    }
248
249    #[test]
250    fn test_should_cache_with_method_filters() {
251        let include = vec!["/api/*".to_string()];
252        let exclude = vec!["POST /api/*".to_string(), "PUT /api/*".to_string()];
253        
254        // GET should be cached
255        assert!(should_cache_path("GET", "/api/users", &include, &exclude));
256        // POST should not be cached (excluded)
257        assert!(!should_cache_path("POST", "/api/users", &include, &exclude));
258        // PUT should not be cached (excluded)
259        assert!(!should_cache_path("PUT", "/api/users", &include, &exclude));
260        // DELETE should be cached (not excluded)
261        assert!(should_cache_path("DELETE", "/api/users", &include, &exclude));
262    }
263
264    #[test]
265    fn test_exclude_all_posts() {
266        let include = vec![];
267        let exclude = vec!["POST *".to_string()];
268        
269        // All POST requests should be excluded
270        assert!(!should_cache_path("POST", "/api/users", &include, &exclude));
271        assert!(!should_cache_path("POST", "/anything", &include, &exclude));
272        
273        // Other methods should be cached
274        assert!(should_cache_path("GET", "/api/users", &include, &exclude));
275        assert!(should_cache_path("PUT", "/api/users", &include, &exclude));
276    }
277
278    #[test]
279    fn test_include_only_get_requests() {
280        let include = vec!["GET *".to_string()];
281        let exclude = vec![];
282        
283        // Only GET requests should be included
284        assert!(should_cache_path("GET", "/api/users", &include, &exclude));
285        assert!(should_cache_path("GET", "/anything", &include, &exclude));
286        
287        // Other methods should not be cached
288        assert!(!should_cache_path("POST", "/api/users", &include, &exclude));
289        assert!(!should_cache_path("PUT", "/api/users", &include, &exclude));
290    }
291}