pingql/apps/api/src/query/index.ts

331 lines
11 KiB
TypeScript

/**
* PingQL Query Engine — TypeScript implementation
*
* MongoDB-inspired query language for evaluating HTTP response conditions.
* Mirrors the Rust implementation but also powers the visual query builder.
*/
// ── Types ──────────────────────────────────────────────────────────────
export interface QueryField {
name: string;
description: string;
type: "number" | "string" | "boolean" | "object";
operators: string[];
}
export interface EvalContext {
status: number;
body: string;
headers: Record<string, string>;
latency_ms?: number;
cert_expiry_days?: number;
}
export interface ValidationError {
path: string;
message: string;
}
// ── Available fields ───────────────────────────────────────────────────
export function getAvailableFields(): QueryField[] {
return [
{
name: "status",
description: "HTTP status code (e.g. 200, 404, 500)",
type: "number",
operators: ["$eq", "$ne", "$gt", "$gte", "$lt", "$lte", "$in"],
},
{
name: "body",
description: "Response body as text",
type: "string",
operators: ["$eq", "$ne", "$contains", "$startsWith", "$endsWith", "$regex", "$exists"],
},
{
name: "headers.*",
description: "Response header value (e.g. headers.content-type)",
type: "string",
operators: ["$eq", "$ne", "$contains", "$startsWith", "$endsWith", "$regex", "$exists"],
},
{
name: "$select",
description: "CSS selector — returns text content of first matching element",
type: "string",
operators: ["$eq", "$ne", "$contains", "$startsWith", "$endsWith", "$regex"],
},
{
name: "$json",
description: "JSONPath expression evaluated against response body (e.g. $.data.status)",
type: "string",
operators: ["$eq", "$ne", "$gt", "$gte", "$lt", "$lte", "$contains", "$regex"],
},
{
name: "$responseTime",
description: "Request latency in milliseconds",
type: "number",
operators: ["$eq", "$gt", "$gte", "$lt", "$lte"],
},
{
name: "$certExpiry",
description: "Days until SSL certificate expires",
type: "number",
operators: ["$eq", "$gt", "$gte", "$lt", "$lte"],
},
];
}
// ── Evaluate ───────────────────────────────────────────────────────────
export function evaluate(query: unknown, ctx: EvalContext): boolean {
if (query === null || query === undefined) return true;
if (typeof query !== "object" || Array.isArray(query)) {
throw new Error("Query must be an object");
}
const q = query as Record<string, unknown>;
// $consider — "up" (default) or "down": flips result if conditions match
if ("$consider" in q) {
const consider = q.$consider as string;
const rest = Object.fromEntries(Object.entries(q).filter(([k]) => k !== "$consider"));
const matches = evaluate(rest, ctx);
return consider === "down" ? !matches : matches;
}
// $and
if ("$and" in q) {
const clauses = q.$and;
if (!Array.isArray(clauses)) throw new Error("$and expects array");
return clauses.every((c) => evaluate(c, ctx));
}
// $or
if ("$or" in q) {
const clauses = q.$or;
if (!Array.isArray(clauses)) throw new Error("$or expects array");
return clauses.some((c) => evaluate(c, ctx));
}
// $not
if ("$not" in q) {
return !evaluate(q.$not, ctx);
}
// $responseTime
if ("$responseTime" in q) {
const val = ctx.latency_ms ?? 0;
return evalCondition(q.$responseTime, val);
}
// $certExpiry
if ("$certExpiry" in q) {
const val = ctx.cert_expiry_days ?? Infinity;
return evalCondition(q.$certExpiry, val);
}
// $select — { "$select": { "css.selector": { "$op": val } } }
if ("$select" in q) {
// Server-side: no DOM parser available, pass through (Rust runner evaluates)
// 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;
}
// $json — { "$json": { "$.path": { "$op": val } } }
if ("$json" in q) {
const pathMap = q.$json as Record<string, unknown>;
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;
}
// Field-level checks
for (const [field, condition] of Object.entries(q)) {
if (field.startsWith("$")) continue; // skip unknown $ ops
const fieldVal = resolveField(field, ctx);
if (!evalCondition(condition, fieldVal)) return false;
}
return true;
}
function resolveField(field: string, ctx: EvalContext): unknown {
switch (field) {
case "status":
case "status_code":
return ctx.status;
case "body":
return ctx.body;
default:
if (field.startsWith("headers.")) {
const key = field.slice(8).toLowerCase();
return ctx.headers[key] ?? null;
}
return null;
}
}
function resolveJsonPath(body: string, expr: string): unknown {
try {
const obj = JSON.parse(body);
// Simple dot-notation JSONPath: $.foo.bar[0].baz
const path = expr.replace(/^\$\.?/, "");
if (!path) return obj;
const parts = path.split(/\.|\[(\d+)\]/).filter(Boolean);
let current: unknown = obj;
for (const part of parts) {
if (current === null || current === undefined) return null;
current = (current as Record<string, unknown>)[part];
}
return current ?? null;
} catch {
return null;
}
}
function evalCondition(condition: unknown, fieldVal: unknown): boolean {
if (condition === null || condition === undefined) return true;
// Direct equality shorthand: { "status": 200 }
if (typeof condition === "number" || typeof condition === "string" || typeof condition === "boolean") {
return fieldVal === condition;
}
if (typeof condition === "object" && !Array.isArray(condition)) {
const ops = condition as Record<string, unknown>;
for (const [op, opVal] of Object.entries(ops)) {
if (!evalOp(op, fieldVal, opVal)) return false;
}
return true;
}
return true;
}
function evalOp(op: string, fieldVal: unknown, opVal: unknown): boolean {
switch (op) {
case "$eq":
return fieldVal === opVal;
case "$ne":
return fieldVal !== opVal;
case "$gt":
return toNum(fieldVal) > toNum(opVal);
case "$gte":
return toNum(fieldVal) >= toNum(opVal);
case "$lt":
return toNum(fieldVal) < toNum(opVal);
case "$lte":
return toNum(fieldVal) <= toNum(opVal);
case "$contains":
return typeof fieldVal === "string" && typeof opVal === "string" && fieldVal.includes(opVal);
case "$startsWith":
return typeof fieldVal === "string" && typeof opVal === "string" && fieldVal.startsWith(opVal);
case "$endsWith":
return typeof fieldVal === "string" && typeof opVal === "string" && fieldVal.endsWith(opVal);
case "$regex": {
if (typeof fieldVal !== "string" || typeof opVal !== "string") return false;
if (opVal.length > 200) return false;
try {
return new RegExp(opVal).test(fieldVal);
} catch {
return false;
}
}
case "$exists":
return opVal ? fieldVal !== null && fieldVal !== undefined : fieldVal === null || fieldVal === undefined;
case "$in":
return Array.isArray(opVal) && opVal.includes(fieldVal);
default:
return true; // unknown op — skip
}
}
function toNum(v: unknown): number {
return typeof v === "number" ? v : Number(v) || 0;
}
// ── Validate ───────────────────────────────────────────────────────────
const VALID_OPS = new Set([
"$eq", "$ne", "$gt", "$gte", "$lt", "$lte",
"$contains", "$startsWith", "$endsWith", "$regex",
"$exists", "$in",
"$select", "$json",
"$and", "$or", "$not",
"$responseTime", "$certExpiry",
]);
const VALID_FIELDS = new Set([
"status", "status_code", "body",
]);
export function validateQuery(query: unknown, path = ""): ValidationError[] {
const errors: ValidationError[] = [];
if (query === null || query === undefined) return errors;
if (typeof query !== "object" || Array.isArray(query)) {
errors.push({ path: path || "$", message: "Query must be an object" });
return errors;
}
const q = query as Record<string, unknown>;
for (const [key, value] of Object.entries(q)) {
const keyPath = path ? `${path}.${key}` : key;
if (key === "$and" || key === "$or") {
if (!Array.isArray(value)) {
errors.push({ path: keyPath, message: `${key} expects an array` });
} else {
value.forEach((clause, i) => {
errors.push(...validateQuery(clause, `${keyPath}[${i}]`));
});
}
} else if (key === "$not") {
errors.push(...validateQuery(value, keyPath));
} else if (key === "$responseTime" || key === "$certExpiry") {
if (typeof value !== "object" || value === null || Array.isArray(value)) {
errors.push({ path: keyPath, message: `${key} expects an operator object (e.g. { "$lt": 500 })` });
} else {
for (const op of Object.keys(value as Record<string, unknown>)) {
if (!VALID_OPS.has(op)) {
errors.push({ path: `${keyPath}.${op}`, message: `Unknown operator: ${op}` });
}
}
}
} else if (key === "$select" || key === "$json") {
if (typeof value !== "string") {
errors.push({ path: keyPath, message: `${key} expects a string` });
}
} else if (key.startsWith("$")) {
// It's an operator inside a field condition — skip validation here
} else {
// Field name
if (!VALID_FIELDS.has(key) && !key.startsWith("headers.")) {
errors.push({ path: keyPath, message: `Unknown field: ${key}. Use status, body, or headers.*` });
}
if (typeof value === "object" && value !== null && !Array.isArray(value)) {
const ops = value as Record<string, unknown>;
for (const op of Object.keys(ops)) {
if (!op.startsWith("$")) continue;
if (!VALID_OPS.has(op)) {
errors.push({ path: `${keyPath}.${op}`, message: `Unknown operator: ${op}` });
}
if (op === "$regex" && typeof ops[op] === "string" && (ops[op] as string).length > 200) {
errors.push({ path: `${keyPath}.${op}`, message: "Regex pattern too long (max 200 characters)" });
}
}
}
}
}
return errors;
}