feat(filters): JSONPath field filters, operators, AST groups, fragments, and test-match

Add a field filter type reading any payload value by JSONPath with array expansion, 12 comparison operators (eq/ne/contains/startsWith/endsWith/regex/gt/gte/lt/lte/in/exists), a visual AST builder (all/any/not) in the route editor, chip-based multi-value input, a stateless POST /admin/api/test-match dry-run, and named filter fragments stored in D1 (d1_fragments, migration 0010) inlined into route ASTs on insert.
This commit is contained in:
RhenCloud 2026-08-18 12:19:08 +08:00
parent c955db03c3
commit c090281cb2
No known key found for this signature in database
GPG key ID: A574A617378C4E0B
30 changed files with 1793 additions and 422 deletions

View file

@ -0,0 +1,181 @@
import type { Filter, FilterNode, FilterOp, FilterType } from "~/types";
export interface LeafForm {
type: FilterType;
path: string;
op: FilterOp;
values: string[];
exclude: boolean;
}
export type NodeKind = "leaf" | "all" | "any" | "not";
export interface NodeForm {
kind: NodeKind;
leaf: LeafForm;
children: NodeForm[];
child: NodeForm | null;
}
export function blankLeafForm(): LeafForm {
return { type: "event", path: "", op: "eq", values: [], exclude: false };
}
export function blankNode(kind: NodeKind): NodeForm {
return {
kind,
leaf: blankLeafForm(),
children: kind === "all" || kind === "any" ? [blankNode("leaf")] : [],
child: kind === "not" ? blankNode("leaf") : null,
};
}
function matchToValues(match: string | string[] | undefined): string[] {
if (match === undefined || match === null) return [];
return Array.isArray(match) ? [...match] : [match];
}
function leafToForm(f: Filter): LeafForm {
return {
type: f.type,
path: f.path ?? "",
op: f.op ?? "eq",
values: matchToValues(f.match),
exclude: !!f.exclude,
};
}
export function nodeToForm(node: FilterNode): NodeForm {
if ("all" in node) {
return { kind: "all", leaf: blankLeafForm(), children: node.all.map(nodeToForm), child: null };
}
if ("any" in node) {
return { kind: "any", leaf: blankLeafForm(), children: node.any.map(nodeToForm), child: null };
}
if ("not" in node) {
return { kind: "not", leaf: blankLeafForm(), children: [], child: nodeToForm(node.not) };
}
return { kind: "leaf", leaf: leafToForm(node), children: [], child: null };
}
export function formToLeaf(lf: LeafForm): Filter | null {
const values = lf.values.map((v) => v.trim()).filter((v) => v.length > 0);
if (lf.op !== "exists" && values.length === 0) return null;
if (lf.type === "field" && lf.path.trim().length === 0) return null;
const filter: Filter = { type: lf.type };
if (lf.type === "field") filter.path = lf.path.trim();
if (lf.op !== "eq") filter.op = lf.op;
if (lf.op !== "exists") filter.match = values.length === 1 ? values[0] : values;
if (lf.exclude) filter.exclude = true;
return filter;
}
export function formToNode(nf: NodeForm): FilterNode | null {
if (nf.kind === "leaf") return formToLeaf(nf.leaf);
if (nf.kind === "all" || nf.kind === "any") {
const children = nf.children.map(formToNode).filter((c): c is FilterNode => c !== null);
if (children.length === 0) return null;
return nf.kind === "all" ? { all: children } : { any: children };
}
if (nf.kind === "not") {
const child = nf.child ? formToNode(nf.child) : null;
if (!child) return null;
return { not: child };
}
return null;
}
export function isTrivialNode(nf: NodeForm): boolean {
if (nf.kind === "leaf") {
return nf.leaf.type !== "field" && nf.leaf.op === "eq" && !nf.leaf.exclude;
}
if (nf.kind === "all") return nf.children.every(isTrivialNode);
return false;
}
export function flattenLeaves(nf: NodeForm): Filter[] {
const out: Filter[] = [];
const walk = (n: NodeForm): void => {
if (n.kind === "leaf") {
const f = formToLeaf(n.leaf);
if (f) out.push(f);
return;
}
if (n.kind === "all" || n.kind === "any") n.children.forEach(walk);
else if (n.kind === "not" && n.child) walk(n.child);
};
walk(nf);
return out;
}
export function nodeFormToRouteFilters(nf: NodeForm): { filters: Filter[]; ast?: FilterNode } {
if (isTrivialNode(nf)) {
return { filters: flattenLeaves(nf) };
}
const ast = formToNode(nf);
return { filters: [], ast: ast ?? undefined };
}
export function collectEvents(node: FilterNode): string[] {
const out: string[] = [];
const walk = (n: FilterNode): void => {
if ("all" in n) {
n.all.forEach(walk);
return;
}
if ("any" in n) {
n.any.forEach(walk);
return;
}
if ("not" in n) {
walk(n.not);
return;
}
if (n.type === "event") {
const m = n.match;
if (Array.isArray(m)) out.push(...m);
else if (typeof m === "string" && m) out.push(m);
}
};
walk(node);
return out;
}
export function describeLeaf(f: Filter, t: (key: string) => string): string {
const label = f.type === "field" ? (f.path ?? f.type) : t("filter." + f.type);
const op = f.op ?? "eq";
const match = f.match;
const values = match === undefined ? [] : Array.isArray(match) ? match : [match];
const value =
values.map((v) => JSON.stringify(v)).join(` ${t("filterNode.or")} `) || "\u2205";
let base: string;
if (f.type === "keyword") {
base = `${label} ${t("filterNode.matches")} ${value}`;
} else if (op === "exists") {
base = `${label} ${t("filterOp.exists")}`;
} else if (op === "eq") {
base = `${label} ${t("filterNode.is")} ${value}`;
} else {
base = `${label} ${t("filterOp." + op)} ${value}`;
}
return f.exclude ? `${t("filterNode.not")} (${base})` : base;
}
export function describeNode(node: FilterNode, t: (key: string) => string): string {
if ("all" in node) {
return node.all
.map((c) => describeNode(c, t))
.filter((s) => s.length)
.join(` ${t("filterNode.and")} `);
}
if ("any" in node) {
const parts = node.any
.map((c) => describeNode(c, t))
.filter((s) => s.length);
return parts.length ? `(${parts.join(` ${t("filterNode.or")} `)})` : "";
}
if ("not" in node) {
return `${t("filterNode.not")} (${describeNode(node.not, t)})`;
}
return describeLeaf(node, t);
}

View file

@ -0,0 +1,34 @@
import type { NamedFragment } from "~/types";
export function useFragmentsApi() {
const { needLogin } = useAuthState();
const fragments = ref<NamedFragment[]>([]);
const loading = ref(false);
const error = ref("");
async function load(groupId: string): Promise<void> {
loading.value = true;
error.value = "";
needLogin.value = false;
try {
const data = await apiFetch<{ fragments?: NamedFragment[] }>(
`/admin/api/groups/${encodeURIComponent(groupId)}/fragments`,
);
fragments.value = data.fragments ?? [];
} catch (err) {
if (!needLogin.value) error.value = err instanceof Error ? err.message : String(err);
} finally {
loading.value = false;
}
}
async function save(groupId: string, next: NamedFragment[]): Promise<void> {
await apiFetch(`/admin/api/groups/${encodeURIComponent(groupId)}/fragments`, {
method: "PUT",
body: JSON.stringify({ fragments: next }),
});
fragments.value = next;
}
return { fragments, loading, needLogin, error, load, save };
}

View file

@ -153,6 +153,28 @@ const en: Dict = {
"filter.action": "Action",
"filter.branch": "Branch",
"filter.keyword": "Keyword",
"filter.field": "Field",
"filterOp.eq": "equals",
"filterOp.ne": "not equals",
"filterOp.contains": "contains",
"filterOp.startsWith": "starts with",
"filterOp.endsWith": "ends with",
"filterOp.regex": "matches regex",
"filterOp.gt": "greater than",
"filterOp.gte": "greater or equal",
"filterOp.lt": "less than",
"filterOp.lte": "less or equal",
"filterOp.in": "in",
"filterOp.exists": "exists",
"filterNode.all": "All of",
"filterNode.any": "Any of",
"filterNode.not": "not",
"filterNode.is": "is",
"filterNode.matches": "matches",
"filterNode.and": "and",
"filterNode.or": "or",
"filterNode.unwrap": "unwrap",
"filterNode.empty": "No conditions",
"routeEditor.editTitle": "Edit route",
"routeEditor.newTitle": "New route",
"routeEditor.eyebrow": "Route",
@ -219,6 +241,30 @@ const en: Dict = {
"routeEditor.errIdFormat": "ID must be a-z / 0-9 / dashes",
"routeEditor.errName": "Name is required",
"routeEditor.errChannel": "Target {n} channel ID is required",
"routeEditor.addLeaf": "Add condition",
"routeEditor.addGroup": "Add group",
"routeEditor.addNot": "Add not",
"routeEditor.remove": "Remove",
"routeEditor.op": "Operator",
"routeEditor.pathPlaceholder": "payload.path",
"routeEditor.valuesPlaceholder": "Type a value and press Enter",
"routeEditor.testMatch": "Test match",
"routeEditor.testEvent": "Event (optional)",
"routeEditor.testPayloadPlaceholder": "Paste a JSON webhook payload",
"routeEditor.testRun": "Run test",
"routeEditor.testMatched": "Matches",
"routeEditor.testNotMatched": "Does not match",
"routeEditor.testInvalidJson": "Invalid JSON",
"routeEditor.fragments": "Fragments",
"routeEditor.fragmentsEmpty": "No fragments yet",
"routeEditor.fragmentsInsert": "Insert",
"routeEditor.fragmentsDelete": "Delete",
"routeEditor.fragmentsSave": "Save as fragment",
"routeEditor.fragmentsNamePlaceholder": "Fragment name",
"routeEditor.fragmentsNameRequired": "Fragment name is required",
"routeEditor.errPath": "Field filters need a path",
"routeEditor.errValues": "Add at least one value",
"routeEditor.errGroupEmpty": "Group needs at least one condition",
"groupEditor.editTitle": "Edit group",
"groupEditor.newTitle": "New group",
"groupEditor.eyebrow": "Group",
@ -479,6 +525,28 @@ const zh: Dict = {
"filter.action": "动作",
"filter.branch": "分支",
"filter.keyword": "关键词",
"filter.field": "字段",
"filterOp.eq": "等于",
"filterOp.ne": "不等于",
"filterOp.contains": "包含",
"filterOp.startsWith": "开头是",
"filterOp.endsWith": "结尾是",
"filterOp.regex": "正则匹配",
"filterOp.gt": "大于",
"filterOp.gte": "大于等于",
"filterOp.lt": "小于",
"filterOp.lte": "小于等于",
"filterOp.in": "属于",
"filterOp.exists": "存在",
"filterNode.all": "全部满足",
"filterNode.any": "任一满足",
"filterNode.not": "非",
"filterNode.is": "是",
"filterNode.matches": "匹配",
"filterNode.and": "且",
"filterNode.or": "或",
"filterNode.unwrap": "取消非",
"filterNode.empty": "暂无条件",
"routeEditor.editTitle": "编辑路由",
"routeEditor.newTitle": "新建路由",
"routeEditor.eyebrow": "路由",
@ -544,6 +612,30 @@ const zh: Dict = {
"routeEditor.errIdFormat": "ID 只能是 a-z / 0-9 / 短横线",
"routeEditor.errName": "名称为必填项",
"routeEditor.errChannel": "第 {n} 个目标频道 ID 为必填项",
"routeEditor.addLeaf": "添加条件",
"routeEditor.addGroup": "添加分组",
"routeEditor.addNot": "添加非",
"routeEditor.remove": "删除",
"routeEditor.op": "操作符",
"routeEditor.pathPlaceholder": "payload.path",
"routeEditor.valuesPlaceholder": "输入值后回车",
"routeEditor.testMatch": "测试匹配",
"routeEditor.testEvent": "事件类型(可选)",
"routeEditor.testPayloadPlaceholder": "粘贴 JSON webhook 事件样本",
"routeEditor.testRun": "运行测试",
"routeEditor.testMatched": "匹配",
"routeEditor.testNotMatched": "不匹配",
"routeEditor.testInvalidJson": "无效的 JSON",
"routeEditor.fragments": "过滤器片段",
"routeEditor.fragmentsEmpty": "暂无片段",
"routeEditor.fragmentsInsert": "插入",
"routeEditor.fragmentsDelete": "删除",
"routeEditor.fragmentsSave": "存为片段",
"routeEditor.fragmentsNamePlaceholder": "片段名称",
"routeEditor.fragmentsNameRequired": "请填写片段名称",
"routeEditor.errPath": "字段过滤需要路径",
"routeEditor.errValues": "至少需要一个值",
"routeEditor.errGroupEmpty": "分组至少需要一个条件",
"groupEditor.editTitle": "编辑分组",
"groupEditor.newTitle": "新建分组",
"groupEditor.eyebrow": "分组",