Skip to main content

drasi_source_http/
route_matcher.rs

1// Copyright 2025 The Drasi Authors.
2//
3// Licensed under the Apache License, Version 2.0 (the "License");
4// you may not use this file except in compliance with the License.
5// You may obtain a copy of the License at
6//
7//     http://www.apache.org/licenses/LICENSE-2.0
8//
9// Unless required by applicable law or agreed to in writing, software
10// distributed under the License is distributed on an "AS IS" BASIS,
11// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12// See the License for the specific language governing permissions and
13// limitations under the License.
14
15//! Route matching and condition evaluation for webhooks.
16//!
17//! Handles:
18//! - Route pattern matching with path parameters
19//! - Mapping condition evaluation (header/field matching)
20
21use crate::config::{HttpMethod, MappingCondition, WebhookMapping, WebhookRoute};
22use regex::Regex;
23use serde_json::Value as JsonValue;
24use std::collections::HashMap;
25use std::sync::{OnceLock, RwLock};
26
27/// Global cache for compiled regex patterns used in conditions
28static REGEX_CACHE: OnceLock<RwLock<HashMap<String, Regex>>> = OnceLock::new();
29
30/// Get or compile a regex pattern from the cache
31fn get_cached_regex(pattern: &str) -> Option<Regex> {
32    let cache = REGEX_CACHE.get_or_init(|| RwLock::new(HashMap::new()));
33
34    // Try to read from cache first
35    {
36        let read_guard = cache.read().ok()?;
37        if let Some(re) = read_guard.get(pattern) {
38            return Some(re.clone());
39        }
40    }
41
42    // Not in cache, compile and store
43    match Regex::new(pattern) {
44        Ok(re) => {
45            if let Ok(mut write_guard) = cache.write() {
46                write_guard.insert(pattern.to_string(), re.clone());
47            }
48            Some(re)
49        }
50        Err(e) => {
51            log::warn!("Invalid regex pattern '{pattern}': {e}");
52            None
53        }
54    }
55}
56
57/// Result of route matching
58#[derive(Debug, Clone)]
59pub struct RouteMatch<'a> {
60    /// The matched route configuration
61    pub route: &'a WebhookRoute,
62    /// Extracted path parameters
63    pub path_params: HashMap<String, String>,
64}
65
66/// Route matcher for webhook routes
67pub struct RouteMatcher {
68    /// Compiled route patterns
69    routes: Vec<CompiledRoute>,
70}
71
72/// A compiled route pattern
73struct CompiledRoute {
74    /// Original route index
75    index: usize,
76    /// Compiled regex for matching
77    pattern: Regex,
78    /// Names of path parameters in order
79    param_names: Vec<String>,
80    /// Allowed HTTP methods
81    methods: Vec<HttpMethod>,
82}
83
84impl RouteMatcher {
85    /// Create a new route matcher from webhook routes
86    pub fn new(routes: &[WebhookRoute]) -> Self {
87        let compiled: Vec<CompiledRoute> = routes
88            .iter()
89            .enumerate()
90            .filter_map(|(idx, route)| compile_route(idx, route))
91            .collect();
92
93        Self { routes: compiled }
94    }
95
96    /// Match an incoming request against configured routes
97    pub fn match_route<'a>(
98        &self,
99        path: &str,
100        method: &HttpMethod,
101        routes: &'a [WebhookRoute],
102    ) -> Option<RouteMatch<'a>> {
103        for compiled in &self.routes {
104            // Check method first (faster)
105            if !compiled.methods.contains(method) {
106                continue;
107            }
108
109            // Try to match the path
110            if let Some(captures) = compiled.pattern.captures(path) {
111                let mut path_params = HashMap::new();
112
113                // Extract named parameters
114                for (i, name) in compiled.param_names.iter().enumerate() {
115                    if let Some(value) = captures.get(i + 1) {
116                        path_params.insert(name.clone(), value.as_str().to_string());
117                    }
118                }
119
120                return Some(RouteMatch {
121                    route: &routes[compiled.index],
122                    path_params,
123                });
124            }
125        }
126
127        None
128    }
129}
130
131/// Compile a route pattern into a regex
132fn compile_route(index: usize, route: &WebhookRoute) -> Option<CompiledRoute> {
133    let mut pattern = String::from("^");
134    let mut param_names = Vec::new();
135
136    for segment in route.path.split('/') {
137        if segment.is_empty() {
138            continue;
139        }
140
141        pattern.push('/');
142
143        if let Some(param) = segment.strip_prefix(':') {
144            // Path parameter
145            param_names.push(param.to_string());
146            pattern.push_str("([^/]+)");
147        } else if let Some(param) = segment.strip_prefix('*') {
148            // Wildcard parameter (matches multiple segments)
149            param_names.push(param.to_string());
150            pattern.push_str("(.+)");
151        } else {
152            // Literal segment - escape regex special chars
153            pattern.push_str(&regex::escape(segment));
154        }
155    }
156
157    pattern.push('$');
158
159    match Regex::new(&pattern) {
160        Ok(regex) => Some(CompiledRoute {
161            index,
162            pattern: regex,
163            param_names,
164            methods: route.methods.clone(),
165        }),
166        Err(e) => {
167            log::error!("Failed to compile route pattern '{}': {}", route.path, e);
168            None
169        }
170    }
171}
172
173/// Evaluate mapping conditions to find matching mappings
174pub fn find_matching_mappings<'a>(
175    mappings: &'a [WebhookMapping],
176    headers: &HashMap<String, String>,
177    payload: &JsonValue,
178) -> Vec<&'a WebhookMapping> {
179    mappings
180        .iter()
181        .filter(|m| evaluate_condition(m.when.as_ref(), headers, payload))
182        .collect()
183}
184
185/// Evaluate a single mapping condition
186fn evaluate_condition(
187    condition: Option<&MappingCondition>,
188    headers: &HashMap<String, String>,
189    payload: &JsonValue,
190) -> bool {
191    let Some(cond) = condition else {
192        // No condition means always match
193        return true;
194    };
195
196    // Get the value to check
197    let value = if let Some(ref header_name) = cond.header {
198        // Case-insensitive header lookup
199        let lower_name = header_name.to_lowercase();
200        headers
201            .iter()
202            .find(|(k, _)| k.to_lowercase() == lower_name)
203            .map(|(_, v)| v.as_str())
204    } else if let Some(ref field_path) = cond.field {
205        // Get value from payload
206        resolve_json_path(payload, field_path).and_then(|v| v.as_str())
207    } else {
208        // No source specified
209        return false;
210    };
211
212    let Some(value_str) = value else {
213        // Value not found
214        return false;
215    };
216
217    // Check conditions
218    if let Some(ref expected) = cond.equals {
219        if value_str != expected {
220            return false;
221        }
222    }
223
224    if let Some(ref substring) = cond.contains {
225        if !value_str.contains(substring) {
226            return false;
227        }
228    }
229
230    if let Some(ref regex_pattern) = cond.regex {
231        match get_cached_regex(regex_pattern) {
232            Some(re) => {
233                if !re.is_match(value_str) {
234                    return false;
235                }
236            }
237            None => {
238                // Regex compilation failed (already logged in get_cached_regex)
239                return false;
240            }
241        }
242    }
243
244    true
245}
246
247/// Resolve a dot-separated path in a JSON value
248fn resolve_json_path<'a>(value: &'a JsonValue, path: &str) -> Option<&'a JsonValue> {
249    // Handle "payload." prefix
250    let path = path.strip_prefix("payload.").unwrap_or(path);
251
252    let mut current = value;
253    for part in path.split('.') {
254        current = match current {
255            JsonValue::Object(obj) => obj.get(part)?,
256            JsonValue::Array(arr) => {
257                let index: usize = part.parse().ok()?;
258                arr.get(index)?
259            }
260            _ => return None,
261        };
262    }
263    Some(current)
264}
265
266/// Convert Axum method to our HttpMethod enum
267pub fn convert_method(method: &axum::http::Method) -> Option<HttpMethod> {
268    match *method {
269        axum::http::Method::GET => Some(HttpMethod::Get),
270        axum::http::Method::POST => Some(HttpMethod::Post),
271        axum::http::Method::PUT => Some(HttpMethod::Put),
272        axum::http::Method::PATCH => Some(HttpMethod::Patch),
273        axum::http::Method::DELETE => Some(HttpMethod::Delete),
274        _ => None,
275    }
276}
277
278/// Convert Axum headers to HashMap
279pub fn headers_to_map(headers: &axum::http::HeaderMap) -> HashMap<String, String> {
280    headers
281        .iter()
282        .filter_map(|(name, value)| {
283            value
284                .to_str()
285                .ok()
286                .map(|v| (name.to_string(), v.to_string()))
287        })
288        .collect()
289}
290
291#[cfg(test)]
292mod tests {
293    use super::*;
294    use crate::config::{ElementTemplate, ElementType, OperationType};
295
296    fn create_test_route(path: &str, methods: Vec<HttpMethod>) -> WebhookRoute {
297        WebhookRoute {
298            path: path.to_string(),
299            methods,
300            auth: None,
301            error_behavior: None,
302            mappings: vec![WebhookMapping {
303                when: None,
304                operation: Some(OperationType::Insert),
305                operation_from: None,
306                operation_map: None,
307                element_type: ElementType::Node,
308                effective_from: None,
309                template: ElementTemplate {
310                    id: "test".to_string(),
311                    labels: vec!["Test".to_string()],
312                    properties: None,
313                    from: None,
314                    to: None,
315                },
316            }],
317        }
318    }
319
320    #[test]
321    fn test_simple_route_matching() {
322        let routes = vec![
323            create_test_route("/webhooks/github", vec![HttpMethod::Post]),
324            create_test_route("/webhooks/shopify", vec![HttpMethod::Post]),
325        ];
326
327        let matcher = RouteMatcher::new(&routes);
328
329        let result = matcher.match_route("/webhooks/github", &HttpMethod::Post, &routes);
330        assert!(result.is_some());
331        assert_eq!(result.unwrap().route.path, "/webhooks/github");
332
333        let result = matcher.match_route("/webhooks/shopify", &HttpMethod::Post, &routes);
334        assert!(result.is_some());
335        assert_eq!(result.unwrap().route.path, "/webhooks/shopify");
336
337        let result = matcher.match_route("/webhooks/unknown", &HttpMethod::Post, &routes);
338        assert!(result.is_none());
339    }
340
341    #[test]
342    fn test_route_with_path_params() {
343        let routes = vec![create_test_route(
344            "/users/:user_id/events/:event_id",
345            vec![HttpMethod::Post],
346        )];
347
348        let matcher = RouteMatcher::new(&routes);
349
350        let result = matcher.match_route("/users/123/events/456", &HttpMethod::Post, &routes);
351        assert!(result.is_some());
352
353        let route_match = result.unwrap();
354        assert_eq!(
355            route_match.path_params.get("user_id"),
356            Some(&"123".to_string())
357        );
358        assert_eq!(
359            route_match.path_params.get("event_id"),
360            Some(&"456".to_string())
361        );
362    }
363
364    #[test]
365    fn test_route_method_filtering() {
366        let routes = vec![create_test_route(
367            "/events",
368            vec![HttpMethod::Post, HttpMethod::Put],
369        )];
370
371        let matcher = RouteMatcher::new(&routes);
372
373        // POST should match
374        let result = matcher.match_route("/events", &HttpMethod::Post, &routes);
375        assert!(result.is_some());
376
377        // PUT should match
378        let result = matcher.match_route("/events", &HttpMethod::Put, &routes);
379        assert!(result.is_some());
380
381        // GET should not match
382        let result = matcher.match_route("/events", &HttpMethod::Get, &routes);
383        assert!(result.is_none());
384    }
385
386    #[test]
387    fn test_condition_header_equals() {
388        let condition = MappingCondition {
389            header: Some("X-Event-Type".to_string()),
390            field: None,
391            equals: Some("push".to_string()),
392            contains: None,
393            regex: None,
394        };
395
396        let mut headers = HashMap::new();
397        headers.insert("X-Event-Type".to_string(), "push".to_string());
398
399        let payload = JsonValue::Null;
400
401        assert!(evaluate_condition(Some(&condition), &headers, &payload));
402
403        headers.insert("X-Event-Type".to_string(), "pull".to_string());
404        assert!(!evaluate_condition(Some(&condition), &headers, &payload));
405    }
406
407    #[test]
408    fn test_condition_header_case_insensitive() {
409        let condition = MappingCondition {
410            header: Some("x-event-type".to_string()),
411            field: None,
412            equals: Some("push".to_string()),
413            contains: None,
414            regex: None,
415        };
416
417        let mut headers = HashMap::new();
418        headers.insert("X-Event-Type".to_string(), "push".to_string());
419
420        let payload = JsonValue::Null;
421
422        assert!(evaluate_condition(Some(&condition), &headers, &payload));
423    }
424
425    #[test]
426    fn test_condition_field_equals() {
427        let condition = MappingCondition {
428            header: None,
429            field: Some("payload.action".to_string()),
430            equals: Some("created".to_string()),
431            contains: None,
432            regex: None,
433        };
434
435        let headers = HashMap::new();
436        let payload = serde_json::json!({
437            "action": "created"
438        });
439
440        assert!(evaluate_condition(Some(&condition), &headers, &payload));
441
442        let payload = serde_json::json!({
443            "action": "deleted"
444        });
445        assert!(!evaluate_condition(Some(&condition), &headers, &payload));
446    }
447
448    #[test]
449    fn test_condition_contains() {
450        let condition = MappingCondition {
451            header: Some("User-Agent".to_string()),
452            field: None,
453            equals: None,
454            contains: Some("GitHub".to_string()),
455            regex: None,
456        };
457
458        let mut headers = HashMap::new();
459        headers.insert(
460            "User-Agent".to_string(),
461            "GitHub-Hookshot/abc123".to_string(),
462        );
463
464        let payload = JsonValue::Null;
465
466        assert!(evaluate_condition(Some(&condition), &headers, &payload));
467
468        headers.insert("User-Agent".to_string(), "curl/7.0".to_string());
469        assert!(!evaluate_condition(Some(&condition), &headers, &payload));
470    }
471
472    #[test]
473    fn test_condition_regex() {
474        let condition = MappingCondition {
475            header: None,
476            field: Some("payload.version".to_string()),
477            equals: None,
478            contains: None,
479            regex: Some(r"^v\d+\.\d+\.\d+$".to_string()),
480        };
481
482        let headers = HashMap::new();
483
484        let payload = serde_json::json!({
485            "version": "v1.2.3"
486        });
487        assert!(evaluate_condition(Some(&condition), &headers, &payload));
488
489        let payload = serde_json::json!({
490            "version": "1.2.3"
491        });
492        assert!(!evaluate_condition(Some(&condition), &headers, &payload));
493    }
494
495    #[test]
496    fn test_no_condition_always_matches() {
497        let headers = HashMap::new();
498        let payload = JsonValue::Null;
499
500        assert!(evaluate_condition(None, &headers, &payload));
501    }
502
503    #[test]
504    fn test_find_matching_mappings() {
505        let mappings = vec![
506            WebhookMapping {
507                when: Some(MappingCondition {
508                    header: Some("X-Event".to_string()),
509                    field: None,
510                    equals: Some("push".to_string()),
511                    contains: None,
512                    regex: None,
513                }),
514                operation: Some(OperationType::Insert),
515                operation_from: None,
516                operation_map: None,
517                element_type: ElementType::Node,
518                effective_from: None,
519                template: ElementTemplate {
520                    id: "push".to_string(),
521                    labels: vec!["Push".to_string()],
522                    properties: None,
523                    from: None,
524                    to: None,
525                },
526            },
527            WebhookMapping {
528                when: Some(MappingCondition {
529                    header: Some("X-Event".to_string()),
530                    field: None,
531                    equals: Some("pull".to_string()),
532                    contains: None,
533                    regex: None,
534                }),
535                operation: Some(OperationType::Insert),
536                operation_from: None,
537                operation_map: None,
538                element_type: ElementType::Node,
539                effective_from: None,
540                template: ElementTemplate {
541                    id: "pull".to_string(),
542                    labels: vec!["Pull".to_string()],
543                    properties: None,
544                    from: None,
545                    to: None,
546                },
547            },
548        ];
549
550        let mut headers = HashMap::new();
551        headers.insert("X-Event".to_string(), "push".to_string());
552        let payload = JsonValue::Null;
553
554        let matches = find_matching_mappings(&mappings, &headers, &payload);
555        assert_eq!(matches.len(), 1);
556        assert_eq!(matches[0].template.id, "push");
557
558        headers.insert("X-Event".to_string(), "pull".to_string());
559        let matches = find_matching_mappings(&mappings, &headers, &payload);
560        assert_eq!(matches.len(), 1);
561        assert_eq!(matches[0].template.id, "pull");
562    }
563
564    #[test]
565    fn test_resolve_json_path() {
566        let json = serde_json::json!({
567            "user": {
568                "name": "John",
569                "address": {
570                    "city": "NYC"
571                }
572            },
573            "items": ["a", "b", "c"]
574        });
575
576        assert_eq!(
577            resolve_json_path(&json, "user.name"),
578            Some(&JsonValue::String("John".to_string()))
579        );
580        assert_eq!(
581            resolve_json_path(&json, "user.address.city"),
582            Some(&JsonValue::String("NYC".to_string()))
583        );
584        assert_eq!(
585            resolve_json_path(&json, "items.0"),
586            Some(&JsonValue::String("a".to_string()))
587        );
588        assert_eq!(resolve_json_path(&json, "missing"), None);
589    }
590
591    #[test]
592    fn test_convert_method() {
593        assert_eq!(
594            convert_method(&axum::http::Method::GET),
595            Some(HttpMethod::Get)
596        );
597        assert_eq!(
598            convert_method(&axum::http::Method::POST),
599            Some(HttpMethod::Post)
600        );
601        assert_eq!(
602            convert_method(&axum::http::Method::PUT),
603            Some(HttpMethod::Put)
604        );
605        assert_eq!(
606            convert_method(&axum::http::Method::PATCH),
607            Some(HttpMethod::Patch)
608        );
609        assert_eq!(
610            convert_method(&axum::http::Method::DELETE),
611            Some(HttpMethod::Delete)
612        );
613        assert_eq!(convert_method(&axum::http::Method::HEAD), None);
614    }
615}