pingql/apps/monitor/src/query.rs

220 lines
8.3 KiB
Rust

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>,
pub cert_issuer: Option<String>,
}
pub fn evaluate(query: &Value, response: &Response) -> Result<bool> {
match query {
Value::Object(map) => {
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 });
}
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)?);
}
if let Some(cond) = map.get("size") {
let val = Value::Number(serde_json::Number::from(response.body.len()));
return eval_condition(cond, &val, response);
}
if let Some(cond) = map.get("$time") {
let val = Value::Number(serde_json::Number::from(response.latency_ms.unwrap_or(0)));
return eval_condition(cond, &val, response);
}
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);
}
if let Some(cond) = map.get("$certIssuer") {
let val = response.cert_issuer.as_ref()
.map(|s| Value::String(s.clone()))
.unwrap_or(Value::Null);
return eval_condition(cond, &val, response);
}
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);
}
if let Some(sel_map) = map.get("$html") {
let sel_obj = match sel_map {
Value::Object(m) => m,
_ => bail!("$html expects an object {{ selector: condition }}"),
};
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);
}
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('.') {
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 {
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),
"$ge" => cmp_num(field_val, val, |a,b| a >= b),
"$lt" => cmp_num(field_val, val, |a,b| a < b),
"$le" => cmp_num(field_val, val, |a,b| a <= b),
"$co" => {
let needle = val.as_str().unwrap_or("");
field_val.as_str().map(|s| s.contains(needle)).unwrap_or(false)
}
"$sw" => {
let needle = val.as_str().unwrap_or("");
field_val.as_str().map(|s| s.starts_with(needle)).unwrap_or(false)
}
"$ew" => {
let needle = val.as_str().unwrap_or("");
field_val.as_str().map(|s| s.ends_with(needle)).unwrap_or(false)
}
"$re" => {
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
}
_ => {
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())
}