mirror of
https://github.com/ReCloudStudio/WebHooker.git
synced 2026-09-22 16:11:29 +00:00
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:
parent
c955db03c3
commit
c090281cb2
30 changed files with 1793 additions and 422 deletions
181
app/composables/useFilterNode.ts
Normal file
181
app/composables/useFilterNode.ts
Normal 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);
|
||||
}
|
||||
34
app/composables/useFragments.ts
Normal file
34
app/composables/useFragments.ts
Normal 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 };
|
||||
}
|
||||
|
|
@ -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": "分组",
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue