/// PingQL query evaluation against a check response. /// /// Query shape (MongoDB-inspired): /// /// Simple equality: /// { "status": 200 } /// /// Operators: /// { "status": { "$eq": 200 } } /// { "status": { "$ne": 500 } } /// { "status": { "$gte": 200, "$lt": 300 } } /// { "body": { "$contains": "healthy" } } /// { "body": { "$startsWith": "OK" } } /// { "body": { "$endsWith": "done" } } /// { "body": { "$regex": "ok|healthy" } } /// { "body": { "$exists": true } } /// { "status": { "$in": [200, 201, 204] } } /// /// CSS selector (HTML parsing): /// { "$select": "span.status", "$eq": "operational" } /// /// JSONPath: /// { "$json": "$.data.status", "$eq": "ok" } /// /// Response time: /// { "$responseTime": { "$lt": 500 } } /// /// Certificate expiry: /// { "$certExpiry": { "$gt": 30 } } /// /// Logical: /// { "$and": [ { "status": 200 }, { "body": { "$contains": "ok" } } ] } /// { "$or": [ { "status": 200 }, { "status": 204 } ] } /// { "$not": { "status": 500 } } use anyhow::{bail, Result}; use regex::Regex; use scraper::{Html, Selector}; use serde_json::Value; pub struct Response { pub status: u16, pub body: String, pub headers: std::collections::HashMap, pub latency_ms: Option, pub cert_expiry_days: Option, } /// Returns true if `query` matches `response`. No query = always up. pub fn evaluate(query: &Value, response: &Response) -> Result { match query { Value::Object(map) => { // $consider — "up" (default) or "down": flips result if conditions match if let Some(consider) = map.get("$consider") { let is_down = consider.as_str() == Some("down"); let rest: serde_json::Map = map.iter() .filter(|(k, _)| k.as_str() != "$consider") .map(|(k, v)| (k.clone(), v.clone())) .collect(); let matches = evaluate(&Value::Object(rest), response).unwrap_or(false); return Ok(if is_down { !matches } else { matches }); } // $and / $or / $not if let Some(and) = map.get("$and") { let Value::Array(clauses) = and else { bail!("$and expects array") }; return Ok(clauses.iter().all(|c| evaluate(c, response).unwrap_or(false))); } if let Some(or) = map.get("$or") { let Value::Array(clauses) = or else { bail!("$or expects array") }; return Ok(clauses.iter().any(|c| evaluate(c, response).unwrap_or(false))); } if let Some(not) = map.get("$not") { return Ok(!evaluate(not, response)?); } // $responseTime if let Some(cond) = map.get("$responseTime") { let val = Value::Number(serde_json::Number::from(response.latency_ms.unwrap_or(0))); return eval_condition(cond, &val, response); } // $certExpiry if let Some(cond) = map.get("$certExpiry") { let val = Value::Number(serde_json::Number::from(response.cert_expiry_days.unwrap_or(0))); return eval_condition(cond, &val, response); } // $json — { "$json": { "$.path": { "$op": val } } } if let Some(json_path_map) = map.get("$json") { let path_map = match json_path_map { Value::Object(m) => m, _ => bail!("$json expects an object {{ path: condition }}"), }; for (path, condition) in path_map { let resolved = resolve_json_path(&response.body, path); if !eval_condition(condition, &resolved, response)? { return Ok(false); } } return Ok(true); } // $select — { "$select": { "css.selector": { "$op": val } } } if let Some(sel_map) = map.get("$select") { let sel_obj = match sel_map { Value::Object(m) => m, _ => bail!("$select expects an object {{ selector: condition }}"), }; // Parse HTML once for all selectors let doc = Html::parse_document(&response.body); for (selector, condition) in sel_obj { let sel = Selector::parse(selector) .map_err(|_| anyhow::anyhow!("Invalid CSS selector: {selector}"))?; let selected = doc.select(&sel).next() .map(|el| Value::String(el.text().collect::().trim().to_string())) .unwrap_or(Value::Null); if !eval_condition(condition, &selected, response)? { return Ok(false); } } return Ok(true); } // Field-level checks for (field, condition) in map { let field_val = resolve_field(field, response); if !eval_condition(condition, &field_val, response)? { return Ok(false); } } Ok(true) } _ => bail!("Query must be an object"), } } fn resolve_field(field: &str, r: &Response) -> Value { match field { "status" | "status_code" => Value::Number(r.status.into()), "body" => Value::String(r.body.clone()), f if f.starts_with("headers.") => { let key = f.trim_start_matches("headers.").to_lowercase(); r.headers.get(&key) .map(|v| Value::String(v.clone())) .unwrap_or(Value::Null) } _ => Value::Null, } } fn resolve_json_path(body: &str, path: &str) -> Value { let obj: Value = match serde_json::from_str(body) { Ok(v) => v, Err(_) => return Value::Null, }; let path = path.trim_start_matches("$").trim_start_matches("."); if path.is_empty() { return obj; } let mut current = &obj; for part in path.split('.') { // Handle array indexing like "items[0]" if let Some(idx_start) = part.find('[') { let key = &part[..idx_start]; if !key.is_empty() { current = match current.get(key) { Some(v) => v, None => return Value::Null, }; } let idx_str = part[idx_start + 1..].trim_end_matches(']'); if let Ok(idx) = idx_str.parse::() { current = match current.get(idx) { Some(v) => v, None => return Value::Null, }; } else { return Value::Null; } } else { current = match current.get(part) { Some(v) => v, None => return Value::Null, }; } } current.clone() } fn eval_condition(condition: &Value, field_val: &Value, response: &Response) -> Result { match condition { // Shorthand: { "status": 200 } Value::Number(n) => Ok(field_val.as_f64() == n.as_f64()), Value::String(s) => Ok(field_val.as_str() == Some(s.as_str())), Value::Bool(b) => Ok(field_val.as_bool() == Some(*b)), Value::Object(ops) => { for (op, val) in ops { if !eval_op(op, field_val, val, response)? { return Ok(false); } } Ok(true) } _ => Ok(true), } } fn eval_op(op: &str, field_val: &Value, val: &Value, response: &Response) -> Result { let ok = match op { "$eq" => field_val == val, "$ne" => field_val != val, "$gt" => cmp_num(field_val, val, |a,b| a > b), "$gte" => cmp_num(field_val, val, |a,b| a >= b), "$lt" => cmp_num(field_val, val, |a,b| a < b), "$lte" => cmp_num(field_val, val, |a,b| a <= b), "$contains" => { let needle = val.as_str().unwrap_or(""); field_val.as_str().map(|s| s.contains(needle)).unwrap_or(false) } "$startsWith" => { let needle = val.as_str().unwrap_or(""); field_val.as_str().map(|s| s.starts_with(needle)).unwrap_or(false) } "$endsWith" => { let needle = val.as_str().unwrap_or(""); field_val.as_str().map(|s| s.ends_with(needle)).unwrap_or(false) } "$regex" => { let pattern = val.as_str().unwrap_or(""); if pattern.len() > 200 { return Ok(false); } let re = match Regex::new(pattern) { Ok(r) => r, Err(_) => return Ok(false), }; field_val.as_str().map(|s| re.is_match(s)).unwrap_or(false) } "$exists" => { let should_exist = val.as_bool().unwrap_or(true); let exists = !field_val.is_null(); exists == should_exist } "$in" => { if let Value::Array(arr) = val { arr.contains(field_val) } else { false } } "$select" => { // Nested: { "body": { "$select": "css", "$eq": "val" } } let sel_str = val.as_str().unwrap_or(""); let selected = css_select(&response.body, sel_str); // If no comparison operator follows, just check existence selected.is_some() } _ => { tracing::warn!("Unknown query operator: {op}"); false } }; Ok(ok) } fn cmp_num(a: &Value, b: &Value, f: impl Fn(f64, f64) -> bool) -> bool { match (a.as_f64(), b.as_f64()) { (Some(x), Some(y)) => f(x, y), _ => false, } } fn css_select(html: &str, selector: &str) -> Option { let doc = Html::parse_document(html); let sel = Selector::parse(selector).ok()?; doc.select(&sel).next().map(|el| el.text().collect::().trim().to_string()) }