pingql/apps/monitor/src/query.rs

271 lines
9.8 KiB
Rust

/// 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<String, String>,
pub latency_ms: Option<u64>,
pub cert_expiry_days: Option<i64>,
}
/// Returns true if `query` matches `response`. No query = always up.
pub fn evaluate(query: &Value, response: &Response) -> Result<bool> {
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<String, Value> = 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::<String>().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::<usize>() {
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<bool> {
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<bool> {
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<String> {
let doc = Html::parse_document(html);
let sel = Selector::parse(selector).ok()?;
doc.select(&sel).next().map(|el| el.text().collect::<String>().trim().to_string())
}