diff --git a/apps/monitor/src/query.rs b/apps/monitor/src/query.rs index f0365b8..3801883 100644 --- a/apps/monitor/src/query.rs +++ b/apps/monitor/src/query.rs @@ -75,45 +75,32 @@ pub fn evaluate(query: &Value, response: &Response) -> Result { return eval_condition(cond, &val, response); } - // $json — JSONPath shorthand - if let Some(json_path) = map.get("$json") { - let path_str = json_path.as_str().unwrap_or(""); - let resolved = resolve_json_path(&response.body, path_str); - for (op, val) in map { - if op == "$json" { continue; } - if !eval_op(op, &resolved, val, response)? { return Ok(false); } + // $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); } - // CSS selector shorthand: { "$select": "...", "$eq": "..." } - if let Some(sel) = map.get("$select") { - let sel_str = sel.as_str().unwrap_or(""); - let selected = css_select(&response.body, sel_str); - if let Some(op_val) = map.get("$eq") { - return Ok(selected.as_deref() == op_val.as_str()); + // $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 }}"), + }; + for (selector, condition) in sel_obj { + let selected = css_select(&response.body, selector) + .map(Value::String) + .unwrap_or(Value::Null); + if !eval_condition(condition, &selected, response)? { return Ok(false); } } - if let Some(op_val) = map.get("$ne") { - return Ok(selected.as_deref() != op_val.as_str()); - } - if let Some(op_val) = map.get("$contains") { - let needle = op_val.as_str().unwrap_or(""); - return Ok(selected.map(|s| s.contains(needle)).unwrap_or(false)); - } - if let Some(op_val) = map.get("$startsWith") { - let needle = op_val.as_str().unwrap_or(""); - return Ok(selected.map(|s| s.starts_with(needle)).unwrap_or(false)); - } - if let Some(op_val) = map.get("$endsWith") { - let needle = op_val.as_str().unwrap_or(""); - return Ok(selected.map(|s| s.ends_with(needle)).unwrap_or(false)); - } - if let Some(op_val) = map.get("$regex") { - let pattern = op_val.as_str().unwrap_or(""); - let re = Regex::new(pattern).unwrap_or_else(|_| Regex::new("$^").unwrap()); - return Ok(selected.map(|s| re.is_match(&s)).unwrap_or(false)); - } - return Ok(selected.is_some()); + return Ok(true); } // Field-level checks diff --git a/apps/web/src/dashboard/query-builder.js b/apps/web/src/dashboard/query-builder.js index 9c669cb..7691581 100644 --- a/apps/web/src/dashboard/query-builder.js +++ b/apps/web/src/dashboard/query-builder.js @@ -48,10 +48,10 @@ class QueryBuilder { return { [field]: { [operator]: parsedVal } }; } if (field === '$select') { - return { '$select': rule.selectorValue || '', [operator]: parsedVal }; + return { '$select': { [rule.selectorValue || '*']: { [operator]: parsedVal } } }; } if (field === '$json') { - return { '$json': rule.jsonPath || '', [operator]: parsedVal }; + return { '$json': { [rule.jsonPath || '$']: { [operator]: parsedVal } } }; } if (field === 'headers.*') { const headerField = `headers.${rule.headerName || 'content-type'}`; @@ -117,19 +117,23 @@ class QueryBuilder { } if ('$select' in clause) { - const selectorValue = clause.$select; - for (const [op, val] of Object.entries(clause)) { - if (op !== '$select') { - return { field: '$select', operator: op, value: String(val), headerName: '', selectorValue, jsonPath: '' }; + const selMap = clause.$select; + if (selMap && typeof selMap === 'object') { + const [selectorValue, condition] = Object.entries(selMap)[0] || ['*', {}]; + if (condition && typeof condition === 'object') { + const [operator, value] = Object.entries(condition)[0] || ['$eq', '']; + return { field: '$select', operator, value: String(value), headerName: '', selectorValue, jsonPath: '' }; } } } if ('$json' in clause) { - const jsonPath = clause.$json; - for (const [op, val] of Object.entries(clause)) { - if (op !== '$json') { - return { field: '$json', operator: op, value: String(val), headerName: '', selectorValue: '', jsonPath }; + const pathMap = clause.$json; + if (pathMap && typeof pathMap === 'object') { + const [jsonPath, condition] = Object.entries(pathMap)[0] || ['$', {}]; + if (condition && typeof condition === 'object') { + const [operator, value] = Object.entries(condition)[0] || ['$eq', '']; + return { field: '$json', operator, value: String(value), headerName: '', selectorValue: '', jsonPath }; } } } diff --git a/apps/web/src/query/index.ts b/apps/web/src/query/index.ts index a85175a..70c1bd4 100644 --- a/apps/web/src/query/index.ts +++ b/apps/web/src/query/index.ts @@ -117,22 +117,22 @@ export function evaluate(query: unknown, ctx: EvalContext): boolean { return evalCondition(q.$certExpiry, val); } - // $select — CSS selector shorthand + // $select — { "$select": { "css.selector": { "$op": val } } } if ("$select" in q) { - // Server-side: we don't have a DOM parser here, so we just validate structure. - // Actual evaluation happens in Rust runner. This returns true for validation purposes. + // Server-side: no DOM parser available, pass through (Rust runner evaluates) + // Validate structure only + const selMap = q.$select as Record; + if (typeof selMap !== "object" || Array.isArray(selMap)) throw new Error("$select expects { selector: condition }"); return true; } - // $json — JSONPath shorthand + // $json — { "$json": { "$.path": { "$op": val } } } if ("$json" in q) { - const expr = q.$json as string; - const cond = q as Record; - const resolved = resolveJsonPath(ctx.body, expr); - // Apply operators from remaining keys - for (const [op, opVal] of Object.entries(cond)) { - if (op === "$json") continue; - if (!evalOp(op, resolved, opVal)) return false; + const pathMap = q.$json as Record; + if (typeof pathMap !== "object" || Array.isArray(pathMap)) throw new Error("$json expects { path: condition }"); + for (const [path, condition] of Object.entries(pathMap)) { + const resolved = resolveJsonPath(ctx.body, path); + if (!evalCondition(condition, resolved)) return false; } return true; }