271 lines
9.8 KiB
Rust
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())
|
|
}
|