refactor: nested $json/$select syntax, migrate stored queries
This commit is contained in:
parent
ab2cbaa5cc
commit
fe7a0bf19b
|
|
@ -75,45 +75,32 @@ pub fn evaluate(query: &Value, response: &Response) -> Result<bool> {
|
||||||
return eval_condition(cond, &val, response);
|
return eval_condition(cond, &val, response);
|
||||||
}
|
}
|
||||||
|
|
||||||
// $json — JSONPath shorthand
|
// $json — { "$json": { "$.path": { "$op": val } } }
|
||||||
if let Some(json_path) = map.get("$json") {
|
if let Some(json_path_map) = map.get("$json") {
|
||||||
let path_str = json_path.as_str().unwrap_or("");
|
let path_map = match json_path_map {
|
||||||
let resolved = resolve_json_path(&response.body, path_str);
|
Value::Object(m) => m,
|
||||||
for (op, val) in map {
|
_ => bail!("$json expects an object {{ path: condition }}"),
|
||||||
if op == "$json" { continue; }
|
};
|
||||||
if !eval_op(op, &resolved, val, response)? { return Ok(false); }
|
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);
|
return Ok(true);
|
||||||
}
|
}
|
||||||
|
|
||||||
// CSS selector shorthand: { "$select": "...", "$eq": "..." }
|
// $select — { "$select": { "css.selector": { "$op": val } } }
|
||||||
if let Some(sel) = map.get("$select") {
|
if let Some(sel_map) = map.get("$select") {
|
||||||
let sel_str = sel.as_str().unwrap_or("");
|
let sel_obj = match sel_map {
|
||||||
let selected = css_select(&response.body, sel_str);
|
Value::Object(m) => m,
|
||||||
if let Some(op_val) = map.get("$eq") {
|
_ => bail!("$select expects an object {{ selector: condition }}"),
|
||||||
return Ok(selected.as_deref() == op_val.as_str());
|
};
|
||||||
|
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(true);
|
||||||
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());
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Field-level checks
|
// Field-level checks
|
||||||
|
|
|
||||||
|
|
@ -48,10 +48,10 @@ class QueryBuilder {
|
||||||
return { [field]: { [operator]: parsedVal } };
|
return { [field]: { [operator]: parsedVal } };
|
||||||
}
|
}
|
||||||
if (field === '$select') {
|
if (field === '$select') {
|
||||||
return { '$select': rule.selectorValue || '', [operator]: parsedVal };
|
return { '$select': { [rule.selectorValue || '*']: { [operator]: parsedVal } } };
|
||||||
}
|
}
|
||||||
if (field === '$json') {
|
if (field === '$json') {
|
||||||
return { '$json': rule.jsonPath || '', [operator]: parsedVal };
|
return { '$json': { [rule.jsonPath || '$']: { [operator]: parsedVal } } };
|
||||||
}
|
}
|
||||||
if (field === 'headers.*') {
|
if (field === 'headers.*') {
|
||||||
const headerField = `headers.${rule.headerName || 'content-type'}`;
|
const headerField = `headers.${rule.headerName || 'content-type'}`;
|
||||||
|
|
@ -117,19 +117,23 @@ class QueryBuilder {
|
||||||
}
|
}
|
||||||
|
|
||||||
if ('$select' in clause) {
|
if ('$select' in clause) {
|
||||||
const selectorValue = clause.$select;
|
const selMap = clause.$select;
|
||||||
for (const [op, val] of Object.entries(clause)) {
|
if (selMap && typeof selMap === 'object') {
|
||||||
if (op !== '$select') {
|
const [selectorValue, condition] = Object.entries(selMap)[0] || ['*', {}];
|
||||||
return { field: '$select', operator: op, value: String(val), headerName: '', selectorValue, jsonPath: '' };
|
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) {
|
if ('$json' in clause) {
|
||||||
const jsonPath = clause.$json;
|
const pathMap = clause.$json;
|
||||||
for (const [op, val] of Object.entries(clause)) {
|
if (pathMap && typeof pathMap === 'object') {
|
||||||
if (op !== '$json') {
|
const [jsonPath, condition] = Object.entries(pathMap)[0] || ['$', {}];
|
||||||
return { field: '$json', operator: op, value: String(val), headerName: '', selectorValue: '', jsonPath };
|
if (condition && typeof condition === 'object') {
|
||||||
|
const [operator, value] = Object.entries(condition)[0] || ['$eq', ''];
|
||||||
|
return { field: '$json', operator, value: String(value), headerName: '', selectorValue: '', jsonPath };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -117,22 +117,22 @@ export function evaluate(query: unknown, ctx: EvalContext): boolean {
|
||||||
return evalCondition(q.$certExpiry, val);
|
return evalCondition(q.$certExpiry, val);
|
||||||
}
|
}
|
||||||
|
|
||||||
// $select — CSS selector shorthand
|
// $select — { "$select": { "css.selector": { "$op": val } } }
|
||||||
if ("$select" in q) {
|
if ("$select" in q) {
|
||||||
// Server-side: we don't have a DOM parser here, so we just validate structure.
|
// Server-side: no DOM parser available, pass through (Rust runner evaluates)
|
||||||
// Actual evaluation happens in Rust runner. This returns true for validation purposes.
|
// Validate structure only
|
||||||
|
const selMap = q.$select as Record<string, unknown>;
|
||||||
|
if (typeof selMap !== "object" || Array.isArray(selMap)) throw new Error("$select expects { selector: condition }");
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
// $json — JSONPath shorthand
|
// $json — { "$json": { "$.path": { "$op": val } } }
|
||||||
if ("$json" in q) {
|
if ("$json" in q) {
|
||||||
const expr = q.$json as string;
|
const pathMap = q.$json as Record<string, unknown>;
|
||||||
const cond = q as Record<string, unknown>;
|
if (typeof pathMap !== "object" || Array.isArray(pathMap)) throw new Error("$json expects { path: condition }");
|
||||||
const resolved = resolveJsonPath(ctx.body, expr);
|
for (const [path, condition] of Object.entries(pathMap)) {
|
||||||
// Apply operators from remaining keys
|
const resolved = resolveJsonPath(ctx.body, path);
|
||||||
for (const [op, opVal] of Object.entries(cond)) {
|
if (!evalCondition(condition, resolved)) return false;
|
||||||
if (op === "$json") continue;
|
|
||||||
if (!evalOp(op, resolved, opVal)) return false;
|
|
||||||
}
|
}
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue