use crate::config::{HttpMethod, MappingCondition, WebhookMapping, WebhookRoute};
use regex::Regex;
use serde_json::Value as JsonValue;
use std::collections::HashMap;
use std::sync::{OnceLock, RwLock};
static REGEX_CACHE: OnceLock<RwLock<HashMap<String, Regex>>> = OnceLock::new();
fn get_cached_regex(pattern: &str) -> Option<Regex> {
let cache = REGEX_CACHE.get_or_init(|| RwLock::new(HashMap::new()));
{
let read_guard = cache.read().ok()?;
if let Some(re) = read_guard.get(pattern) {
return Some(re.clone());
}
}
match Regex::new(pattern) {
Ok(re) => {
if let Ok(mut write_guard) = cache.write() {
write_guard.insert(pattern.to_string(), re.clone());
}
Some(re)
}
Err(e) => {
log::warn!("Invalid regex pattern '{pattern}': {e}");
None
}
}
}
#[derive(Debug, Clone)]
pub struct RouteMatch<'a> {
pub route: &'a WebhookRoute,
pub path_params: HashMap<String, String>,
}
pub struct RouteMatcher {
routes: Vec<CompiledRoute>,
}
struct CompiledRoute {
index: usize,
pattern: Regex,
param_names: Vec<String>,
methods: Vec<HttpMethod>,
}
impl RouteMatcher {
pub fn new(routes: &[WebhookRoute]) -> Self {
let compiled: Vec<CompiledRoute> = routes
.iter()
.enumerate()
.filter_map(|(idx, route)| compile_route(idx, route))
.collect();
Self { routes: compiled }
}
pub fn match_route<'a>(
&self,
path: &str,
method: &HttpMethod,
routes: &'a [WebhookRoute],
) -> Option<RouteMatch<'a>> {
for compiled in &self.routes {
if !compiled.methods.contains(method) {
continue;
}
if let Some(captures) = compiled.pattern.captures(path) {
let mut path_params = HashMap::new();
for (i, name) in compiled.param_names.iter().enumerate() {
if let Some(value) = captures.get(i + 1) {
path_params.insert(name.clone(), value.as_str().to_string());
}
}
return Some(RouteMatch {
route: &routes[compiled.index],
path_params,
});
}
}
None
}
}
fn compile_route(index: usize, route: &WebhookRoute) -> Option<CompiledRoute> {
let mut pattern = String::from("^");
let mut param_names = Vec::new();
for segment in route.path.split('/') {
if segment.is_empty() {
continue;
}
pattern.push('/');
if let Some(param) = segment.strip_prefix(':') {
param_names.push(param.to_string());
pattern.push_str("([^/]+)");
} else if let Some(param) = segment.strip_prefix('*') {
param_names.push(param.to_string());
pattern.push_str("(.+)");
} else {
pattern.push_str(®ex::escape(segment));
}
}
pattern.push('$');
match Regex::new(&pattern) {
Ok(regex) => Some(CompiledRoute {
index,
pattern: regex,
param_names,
methods: route.methods.clone(),
}),
Err(e) => {
log::error!("Failed to compile route pattern '{}': {}", route.path, e);
None
}
}
}
pub fn find_matching_mappings<'a>(
mappings: &'a [WebhookMapping],
headers: &HashMap<String, String>,
payload: &JsonValue,
) -> Vec<&'a WebhookMapping> {
mappings
.iter()
.filter(|m| evaluate_condition(m.when.as_ref(), headers, payload))
.collect()
}
fn evaluate_condition(
condition: Option<&MappingCondition>,
headers: &HashMap<String, String>,
payload: &JsonValue,
) -> bool {
let Some(cond) = condition else {
return true;
};
let value = if let Some(ref header_name) = cond.header {
let lower_name = header_name.to_lowercase();
headers
.iter()
.find(|(k, _)| k.to_lowercase() == lower_name)
.map(|(_, v)| v.as_str())
} else if let Some(ref field_path) = cond.field {
resolve_json_path(payload, field_path).and_then(|v| v.as_str())
} else {
return false;
};
let Some(value_str) = value else {
return false;
};
if let Some(ref expected) = cond.equals {
if value_str != expected {
return false;
}
}
if let Some(ref substring) = cond.contains {
if !value_str.contains(substring) {
return false;
}
}
if let Some(ref regex_pattern) = cond.regex {
match get_cached_regex(regex_pattern) {
Some(re) => {
if !re.is_match(value_str) {
return false;
}
}
None => {
return false;
}
}
}
true
}
fn resolve_json_path<'a>(value: &'a JsonValue, path: &str) -> Option<&'a JsonValue> {
let path = path.strip_prefix("payload.").unwrap_or(path);
let mut current = value;
for part in path.split('.') {
current = match current {
JsonValue::Object(obj) => obj.get(part)?,
JsonValue::Array(arr) => {
let index: usize = part.parse().ok()?;
arr.get(index)?
}
_ => return None,
};
}
Some(current)
}
pub fn convert_method(method: &axum::http::Method) -> Option<HttpMethod> {
match *method {
axum::http::Method::GET => Some(HttpMethod::Get),
axum::http::Method::POST => Some(HttpMethod::Post),
axum::http::Method::PUT => Some(HttpMethod::Put),
axum::http::Method::PATCH => Some(HttpMethod::Patch),
axum::http::Method::DELETE => Some(HttpMethod::Delete),
_ => None,
}
}
pub fn headers_to_map(headers: &axum::http::HeaderMap) -> HashMap<String, String> {
headers
.iter()
.filter_map(|(name, value)| {
value
.to_str()
.ok()
.map(|v| (name.to_string(), v.to_string()))
})
.collect()
}
#[cfg(test)]
mod tests {
use super::*;
use crate::config::{ElementTemplate, ElementType, OperationType};
fn create_test_route(path: &str, methods: Vec<HttpMethod>) -> WebhookRoute {
WebhookRoute {
path: path.to_string(),
methods,
auth: None,
error_behavior: None,
mappings: vec![WebhookMapping {
when: None,
operation: Some(OperationType::Insert),
operation_from: None,
operation_map: None,
element_type: ElementType::Node,
effective_from: None,
template: ElementTemplate {
id: "test".to_string(),
labels: vec!["Test".to_string()],
properties: None,
from: None,
to: None,
},
}],
}
}
#[test]
fn test_simple_route_matching() {
let routes = vec![
create_test_route("/webhooks/github", vec![HttpMethod::Post]),
create_test_route("/webhooks/shopify", vec![HttpMethod::Post]),
];
let matcher = RouteMatcher::new(&routes);
let result = matcher.match_route("/webhooks/github", &HttpMethod::Post, &routes);
assert!(result.is_some());
assert_eq!(result.unwrap().route.path, "/webhooks/github");
let result = matcher.match_route("/webhooks/shopify", &HttpMethod::Post, &routes);
assert!(result.is_some());
assert_eq!(result.unwrap().route.path, "/webhooks/shopify");
let result = matcher.match_route("/webhooks/unknown", &HttpMethod::Post, &routes);
assert!(result.is_none());
}
#[test]
fn test_route_with_path_params() {
let routes = vec![create_test_route(
"/users/:user_id/events/:event_id",
vec![HttpMethod::Post],
)];
let matcher = RouteMatcher::new(&routes);
let result = matcher.match_route("/users/123/events/456", &HttpMethod::Post, &routes);
assert!(result.is_some());
let route_match = result.unwrap();
assert_eq!(
route_match.path_params.get("user_id"),
Some(&"123".to_string())
);
assert_eq!(
route_match.path_params.get("event_id"),
Some(&"456".to_string())
);
}
#[test]
fn test_route_method_filtering() {
let routes = vec![create_test_route(
"/events",
vec![HttpMethod::Post, HttpMethod::Put],
)];
let matcher = RouteMatcher::new(&routes);
let result = matcher.match_route("/events", &HttpMethod::Post, &routes);
assert!(result.is_some());
let result = matcher.match_route("/events", &HttpMethod::Put, &routes);
assert!(result.is_some());
let result = matcher.match_route("/events", &HttpMethod::Get, &routes);
assert!(result.is_none());
}
#[test]
fn test_condition_header_equals() {
let condition = MappingCondition {
header: Some("X-Event-Type".to_string()),
field: None,
equals: Some("push".to_string()),
contains: None,
regex: None,
};
let mut headers = HashMap::new();
headers.insert("X-Event-Type".to_string(), "push".to_string());
let payload = JsonValue::Null;
assert!(evaluate_condition(Some(&condition), &headers, &payload));
headers.insert("X-Event-Type".to_string(), "pull".to_string());
assert!(!evaluate_condition(Some(&condition), &headers, &payload));
}
#[test]
fn test_condition_header_case_insensitive() {
let condition = MappingCondition {
header: Some("x-event-type".to_string()),
field: None,
equals: Some("push".to_string()),
contains: None,
regex: None,
};
let mut headers = HashMap::new();
headers.insert("X-Event-Type".to_string(), "push".to_string());
let payload = JsonValue::Null;
assert!(evaluate_condition(Some(&condition), &headers, &payload));
}
#[test]
fn test_condition_field_equals() {
let condition = MappingCondition {
header: None,
field: Some("payload.action".to_string()),
equals: Some("created".to_string()),
contains: None,
regex: None,
};
let headers = HashMap::new();
let payload = serde_json::json!({
"action": "created"
});
assert!(evaluate_condition(Some(&condition), &headers, &payload));
let payload = serde_json::json!({
"action": "deleted"
});
assert!(!evaluate_condition(Some(&condition), &headers, &payload));
}
#[test]
fn test_condition_contains() {
let condition = MappingCondition {
header: Some("User-Agent".to_string()),
field: None,
equals: None,
contains: Some("GitHub".to_string()),
regex: None,
};
let mut headers = HashMap::new();
headers.insert(
"User-Agent".to_string(),
"GitHub-Hookshot/abc123".to_string(),
);
let payload = JsonValue::Null;
assert!(evaluate_condition(Some(&condition), &headers, &payload));
headers.insert("User-Agent".to_string(), "curl/7.0".to_string());
assert!(!evaluate_condition(Some(&condition), &headers, &payload));
}
#[test]
fn test_condition_regex() {
let condition = MappingCondition {
header: None,
field: Some("payload.version".to_string()),
equals: None,
contains: None,
regex: Some(r"^v\d+\.\d+\.\d+$".to_string()),
};
let headers = HashMap::new();
let payload = serde_json::json!({
"version": "v1.2.3"
});
assert!(evaluate_condition(Some(&condition), &headers, &payload));
let payload = serde_json::json!({
"version": "1.2.3"
});
assert!(!evaluate_condition(Some(&condition), &headers, &payload));
}
#[test]
fn test_no_condition_always_matches() {
let headers = HashMap::new();
let payload = JsonValue::Null;
assert!(evaluate_condition(None, &headers, &payload));
}
#[test]
fn test_find_matching_mappings() {
let mappings = vec![
WebhookMapping {
when: Some(MappingCondition {
header: Some("X-Event".to_string()),
field: None,
equals: Some("push".to_string()),
contains: None,
regex: None,
}),
operation: Some(OperationType::Insert),
operation_from: None,
operation_map: None,
element_type: ElementType::Node,
effective_from: None,
template: ElementTemplate {
id: "push".to_string(),
labels: vec!["Push".to_string()],
properties: None,
from: None,
to: None,
},
},
WebhookMapping {
when: Some(MappingCondition {
header: Some("X-Event".to_string()),
field: None,
equals: Some("pull".to_string()),
contains: None,
regex: None,
}),
operation: Some(OperationType::Insert),
operation_from: None,
operation_map: None,
element_type: ElementType::Node,
effective_from: None,
template: ElementTemplate {
id: "pull".to_string(),
labels: vec!["Pull".to_string()],
properties: None,
from: None,
to: None,
},
},
];
let mut headers = HashMap::new();
headers.insert("X-Event".to_string(), "push".to_string());
let payload = JsonValue::Null;
let matches = find_matching_mappings(&mappings, &headers, &payload);
assert_eq!(matches.len(), 1);
assert_eq!(matches[0].template.id, "push");
headers.insert("X-Event".to_string(), "pull".to_string());
let matches = find_matching_mappings(&mappings, &headers, &payload);
assert_eq!(matches.len(), 1);
assert_eq!(matches[0].template.id, "pull");
}
#[test]
fn test_resolve_json_path() {
let json = serde_json::json!({
"user": {
"name": "John",
"address": {
"city": "NYC"
}
},
"items": ["a", "b", "c"]
});
assert_eq!(
resolve_json_path(&json, "user.name"),
Some(&JsonValue::String("John".to_string()))
);
assert_eq!(
resolve_json_path(&json, "user.address.city"),
Some(&JsonValue::String("NYC".to_string()))
);
assert_eq!(
resolve_json_path(&json, "items.0"),
Some(&JsonValue::String("a".to_string()))
);
assert_eq!(resolve_json_path(&json, "missing"), None);
}
#[test]
fn test_convert_method() {
assert_eq!(
convert_method(&axum::http::Method::GET),
Some(HttpMethod::Get)
);
assert_eq!(
convert_method(&axum::http::Method::POST),
Some(HttpMethod::Post)
);
assert_eq!(
convert_method(&axum::http::Method::PUT),
Some(HttpMethod::Put)
);
assert_eq!(
convert_method(&axum::http::Method::PATCH),
Some(HttpMethod::Patch)
);
assert_eq!(
convert_method(&axum::http::Method::DELETE),
Some(HttpMethod::Delete)
);
assert_eq!(convert_method(&axum::http::Method::HEAD), None);
}
}