Skip to main content

synapse_pingora/utils/
path_normalizer.rs

1use lazy_static::lazy_static;
2use regex::Regex;
3
4lazy_static! {
5    // UUID: 8-4-4-4-12 hex pattern
6    static ref UUID_REGEX: Regex = Regex::new(
7        r"^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$"
8    ).unwrap();
9
10    // Numeric IDs: pure digits, 1+ chars
11    static ref NUMERIC_ID_REGEX: Regex = Regex::new(
12        r"^\d+$"
13    ).unwrap();
14
15    // Base64-ish IDs: alphanumeric with possible padding, 16+ chars
16    static ref BASE64_ID_REGEX: Regex = Regex::new(
17        r"^[A-Za-z0-9_-]{16,}$"
18    ).unwrap();
19
20    // MongoDB ObjectId: 24 hex chars
21    static ref OBJECTID_REGEX: Regex = Regex::new(
22        r"^[0-9a-fA-F]{24}$"
23    ).unwrap();
24}
25
26/// Normalize a URL path by replacing dynamic segments with {id}
27pub fn normalize_path(path: &str) -> String {
28    if path == "/" || path.is_empty() {
29        return path.to_string();
30    }
31
32    let mut path_str = path.to_string();
33    // Remove trailing slash
34    if path_str.len() > 1 && path_str.ends_with('/') {
35        path_str.pop();
36    }
37
38    let parts: Vec<&str> = path_str.split('/').collect();
39    let mut normalized_parts = Vec::new();
40
41    for part in parts {
42        if part.is_empty() {
43            normalized_parts.push(part.to_string());
44            continue;
45        }
46
47        if UUID_REGEX.is_match(part)
48            || OBJECTID_REGEX.is_match(part)
49            || NUMERIC_ID_REGEX.is_match(part)
50            || BASE64_ID_REGEX.is_match(part)
51        {
52            normalized_parts.push("{id}".to_string());
53        } else {
54            normalized_parts.push(part.to_string());
55        }
56    }
57
58    normalized_parts.join("/")
59}
60
61/// Create endpoint key combining method and normalized path
62pub fn endpoint_key(method: &str, path: &str) -> String {
63    format!("{} {}", method.to_uppercase(), normalize_path(path))
64}
65
66#[cfg(test)]
67mod tests {
68    use super::*;
69
70    #[test]
71    fn test_normalize_numeric_ids() {
72        assert_eq!(normalize_path("/api/users/123"), "/api/users/{id}");
73        assert_eq!(
74            normalize_path("/api/users/123/posts/456"),
75            "/api/users/{id}/posts/{id}"
76        );
77    }
78
79    #[test]
80    fn test_normalize_uuids() {
81        assert_eq!(
82            normalize_path("/api/orders/550e8400-e29b-41d4-a716-446655440000"),
83            "/api/orders/{id}"
84        );
85    }
86
87    #[test]
88    fn test_normalize_objectids() {
89        assert_eq!(
90            normalize_path("/api/docs/507f1f77bcf86cd799439011"),
91            "/api/docs/{id}"
92        );
93    }
94
95    #[test]
96    fn test_preserve_static_paths() {
97        assert_eq!(normalize_path("/api/health"), "/api/health");
98        assert_eq!(normalize_path("/api/v1/config"), "/api/v1/config");
99    }
100
101    #[test]
102    fn test_root_and_empty() {
103        assert_eq!(normalize_path("/"), "/");
104        assert_eq!(normalize_path(""), "");
105    }
106
107    #[test]
108    fn test_trailing_slash() {
109        assert_eq!(normalize_path("/api/users/123/"), "/api/users/{id}");
110        assert_eq!(normalize_path("/api/health/"), "/api/health");
111    }
112
113    #[test]
114    fn test_endpoint_key() {
115        assert_eq!(endpoint_key("GET", "/api/users/123"), "GET /api/users/{id}");
116    }
117}