1use 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
27static REGEX_CACHE: OnceLock<RwLock<HashMap<String, Regex>>> = OnceLock::new();
29
30fn get_cached_regex(pattern: &str) -> Option<Regex> {
32 let cache = REGEX_CACHE.get_or_init(|| RwLock::new(HashMap::new()));
33
34 {
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 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#[derive(Debug, Clone)]
59pub struct RouteMatch<'a> {
60 pub route: &'a WebhookRoute,
62 pub path_params: HashMap<String, String>,
64}
65
66pub struct RouteMatcher {
68 routes: Vec<CompiledRoute>,
70}
71
72struct CompiledRoute {
74 index: usize,
76 pattern: Regex,
78 param_names: Vec<String>,
80 methods: Vec<HttpMethod>,
82}
83
84impl RouteMatcher {
85 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 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 if !compiled.methods.contains(method) {
106 continue;
107 }
108
109 if let Some(captures) = compiled.pattern.captures(path) {
111 let mut path_params = HashMap::new();
112
113 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
131fn 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 param_names.push(param.to_string());
146 pattern.push_str("([^/]+)");
147 } else if let Some(param) = segment.strip_prefix('*') {
148 param_names.push(param.to_string());
150 pattern.push_str("(.+)");
151 } else {
152 pattern.push_str(®ex::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
173pub 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
185fn evaluate_condition(
187 condition: Option<&MappingCondition>,
188 headers: &HashMap<String, String>,
189 payload: &JsonValue,
190) -> bool {
191 let Some(cond) = condition else {
192 return true;
194 };
195
196 let value = if let Some(ref header_name) = cond.header {
198 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 resolve_json_path(payload, field_path).and_then(|v| v.as_str())
207 } else {
208 return false;
210 };
211
212 let Some(value_str) = value else {
213 return false;
215 };
216
217 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 return false;
240 }
241 }
242 }
243
244 true
245}
246
247fn resolve_json_path<'a>(value: &'a JsonValue, path: &str) -> Option<&'a JsonValue> {
249 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
266pub 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
278pub 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 let result = matcher.match_route("/events", &HttpMethod::Post, &routes);
375 assert!(result.is_some());
376
377 let result = matcher.match_route("/events", &HttpMethod::Put, &routes);
379 assert!(result.is_some());
380
381 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}