Skip to main content

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