bruno-query 深度解析:为 Bruno 响应体打造带深层导航、过滤与映射的 get 查询器
【免费下载链接】brunoOpensource IDE For Exploring and Testing API's (lightweight alternative to Postman/Insomnia)项目地址: https://gitcode.com/GitHub_Trending/br/bruno
本文档对应仓库内 packages/bruno-query/readme.md 这一独立 npm 子包。它围绕一个核心函数get(data, path, ...fns)展开,用字符串路径表达从嵌套 JSON/数组/对象中取值、过滤、映射的全部需求,并作为 Bruno 脚本运行时res()取值能力的基础设施被@usebruno/query所引用。读完本文,你将掌握其全部路径语法(.、..、[0]、[?])、谓词/映射器的三种写法及其底层实现原理,并能在 Bruno 断言脚本中熟练使用这些表达式提取响应数据。
模块定位:一个只导出get的轻量查询器
在 Bruno 的多包仓库结构中,packages/bruno-query/package.json 显示这个包以@usebruno/query命名、版本0.1.0、MIT 协议发布,同时提供 CJS(dist/cjs/index.js)、ESM(dist/esm/index.js)与类型声明(dist/index.d.ts)三份产物。它的全部公开 API 只有一个:
export function get(source: any, path: string, ...fns: PredicateOrMapper[])其余类型定义均为内部实现细节。source是待查询的数据(通常来自 API 响应体),path是描述导航规则的字符串,fns是与[?]占位符一一对应的"谓词函数 / 对象谓词 / 映射函数"集合。
其作用范围并不局限于 Bruno 应用自身:从 packages/bruno-js/package.json 的依赖声明看,脚本运行时包bruno-js直接依赖"@usebruno/query": "0.1.0",packages/bruno-js/src/bruno-response.js 与 packages/bruno-js/src/utils.js 均以require('@usebruno/query')引入。因此它既是可独立发布到 npm 的通用数据查询小工具,也是 Bruno 脚本生态中解析响应体的基础组件。
核心语法速览:从路径字符串到取值结果
以下语法均由 readme 原文档定义,并在 packages/bruno-query/tests/index.spec.ts 中得到逐条验证。
1. 普通数组导航:自动遍历所有层级的同名属性
get(data, 'customer.orders.items.amount')当路径上某个字段对应的是数组时(例如orders是订单数组),get会自动对该数组逐项递归取值,最终把[10, 20, 30, 40]这样的扁平数组返回给你。测试用例customer.orders.items.amount → [10, 20, 30, 40]即验证了这一点——四个订单项的amount被聚合进了一个数组。
2. 深层导航:..双点号跳过中间层级
get(data, '..items.amount')..表示"从当前位置出发,在后续嵌套对象中进行深度优先搜索",因此无需写全customer.orders.items.amount的完整链条。在测试数据中,..items.amount与..amount的结果同为[10, 20, 30, 40],..id甚至能把两层订单里的id('order-1'、1、2、'order-2'、3、4)全部捞出来,可见其搜索是多层级递归的。
3. 数组索引:[N]按下标取元素
get(data, '..items[0].amount') // → 10 get(data, '..items[5].amount') // → undefined(下标越界,安全返回)索引符支持绝对索引。对于..items..amount[0]这类用法,会对深度搜索得到的扁平数组取首个元素;测试中也覆盖了越界场景..items[5].amount → undefined,越界不会抛异常而是返回undefined。
4. 数组过滤:[?]+ 函数谓词
get(data, '..items[?].amount', i => i.amount > 20)[?]是一个"惰性操作符",真正做什么取决于fns参数中传入的函数:如果函数返回布尔值,则当作谓词使用——true保留该项,继续沿路径取值。上例可筛出amount > 20的条目并取其amount,得到[30, 40]之类的子集。
5. 对象谓词:用字面量对象做等值匹配过滤
get(data, '..items[?]', { id: 2, amount: 20 })当[?]对应的参数是普通对象而非函数时,它等价于写i => i.id === 2 && i.amount === 20的函数谓词——对象的每个键值对按===逐一比对、全部命中才算通过。这正是 readme 中"same as (i => i.id === 2 && i.amount === 20)"的含义,测试用例..items[?].amount → [40](配合{ id: 4, amount: 40 })即为佐证。
6. 数组映射:[?]+ 映射函数
get(data, '..items..amount[?]', amt => amt + 10)如果传入的函数返回的是非布尔值(且非null),则视为映射函数:返回值被收集进结果数组,替代原值。例如..items..amount[?]+amt => amt + 1会把所有金额映射为[11, 21, 31, 41]。
源码级原理:路径编译与逐 token 求值
packages/bruno-query/src/index.ts 中get的实现分为两步。
第一步:字符串路径编译为 token 序列
const paths = path .replace(/\s+/g, '') // 去空白 .split(/(\.{1,2}|\[\?\]|\[\d+\])/g) // 按分隔符拆分 .filter((s) => s.length > 0) .map((str) => { str = str.replace(/\[|\]/g, ''); const index = parseInt(str); return isNaN(index) ? str : index; // 纯数字转 number });正则(\.{1,2}|\[\?\]|\[\d+\])把路径切成四类 token:
.或..—— 导航分隔符,.与..的唯一区别记录在lookbehind变量中;[?]—— 处理时去掉方括号后得到字符串?,作为"消费一个 fns 参数"的标记;[N]—— 去掉方括号后用parseInt转为数字,成为数组下标;- 普通属性名 —— 保持为字符串。
值得一提:去空白意味着路径中不能依赖空格语义,customer . orders与customer.orders等价;同时parseInt('2')得到数字2,而parseInt('id')为NaN于是保留字符串'id',据此区分索引与属性。
第二步:主循环按 token 类型分派
while (source != null && index < paths.length) { const token = paths[index++]; switch (true) { case token === '..': case token === '.': break; // 分隔符本身不做事 case token === '?': const fun = fns[funIndex++]; // 顺序消费谓词/映射器 if (fun == null) throw new Error(`missing function for ${lookbehind}`); source = filterOrMap(source, fun); break; case typeof token === 'number': source = normalize(source[token]); // 数组下标 break; default: source = getValue(source, token as string, lookbehind === '..'); // 属性读取 } lookbehind = token; } return source;三个关键设计从源码可以明确读出:
..触发深度搜索:lookbehind === '..'被作为第三个参数传给getValue,只有当上一个 token 是..时才执行深层递归取值,普通的.只做单层属性读取(源码第 144-145 行)。[?]与fns按位置一一对应:每遇到一个?就通过fns[funIndex++]顺序取用下一个函数;若用户写了[?]却没有传对应参数,会直接抛出missing function for <上一个token>的运行时错误。- 索引 token 走数组读取:数字 token 直接执行
normalize(source[token])。
normalize:递归扁平化
function normalize(value: any) { if (!Array.isArray(value)) return value; const values = [] as any[]; value.forEach((item) => { const value = normalize(item); if (value != null) { values.push(...(Array.isArray(value) ? value : [value])); } }); return values.length ? values : undefined; }数组在导航过程中可能层层嵌套(对数组的每个元素再次取值又产生数组)。normalize递归把所有层级扁平化并跳过null/undefined;若最终一个值都没有则返回undefined。这也是为什么..address返回的是[{ city: 'bangalore' }]而非{ city: 'bangalore' }——多对象深度搜索的结果统一被包成扁平数组,测试用例'..address'一行的注释 "// .. will return array" 明确说明了这一约定。
getValue:单层读取与深层递归
getValue 负责属性取值:
source是数组时,对其每个元素递归getValue(item, prop, deep)后整体normalize,实现"自动遍历数组逐项取值";source是对象时,先直接取source[prop];若处于deep(即上一个 token 是..)模式,则遍历对象所有其他键,对每个值为对象/数组的键递归深层搜索,且"一旦在某个分支直接命中了该属性就不会再往该值内部递归"(这正是..amount能跨层收集所有金额而不会重复挖掘amount内部结构的原因);source既非对象又非数组时直接返回undefined,保证安全兜底。
filterOrMap:谓词与映射的统一分发
function filterOrMap(source: any, funOrObj: PredicateOrMapper) { const fun = typeof funOrObj === 'object' ? objectPredicate(funOrObj) : funOrObj; ... for (const item of list) { if (item == null) continue; const value = fun(item); if (value === true) result.push(item); // 谓词:通过则保留原值 else if (value != null && value !== false) result.push(value); // 映射:收集返回值 } return normalize(isArray ? result : result[0]); }这段实现揭示了一个有趣的事实:谓词与映射没有独立语法,全靠返回值的类型区分。返回严格true时保留原 item;返回false、null、undefined时丢弃;返回其它值(如数字、字符串、对象)则当作映射结果收集。当source不是数组时,代码先把它包成[source]处理、结束后再取result[0],因此[?]也能作用在单个值上——测试中的..items..amount[0][?]+amt => amt + 1结果11即此场景。对象谓词由 objectPredicate 生成:逐键用!==比较,任一不符即返回false。
边界行为与注意事项(源自测试用例)
packages/bruno-query/tests/index.spec.ts 用it.each参数化表驱动测试覆盖了大量边界,整理如下:
| 表达式 | 预期结果 | 说明 |
|---|---|---|
customer.address.city | 'bangalore' | 常规嵌套取值 |
customer.orders.items.amount | [10,20,30,40] | 自动遍历数组 |
..items.amount[0] | 10 | 深度搜索后取下标 |
..items[5].amount | undefined | 越界安全返回 |
customer.orders.foo/..customer.foo | undefined | 不存在的属性安全返回 |
..address | [{ city: 'bangalore' }] | 深度搜索返回数组 |
..items[?].amount+{ id: 5, amount: 40 } | undefined | 对象谓词无匹配项 |
..items..amount[0][?]+amt => amt > 30 | undefined | 单值过滤不命中时无输出 |
实践要点可归纳为四条:
- 路径中不要依赖空白——编译阶段
replace(/\s+/g, '')会移除所有空格; [?]与...fns必须数量匹配,否则抛missing function for <token>;- 深度搜索
..会跨层级汇总所有同名属性,结果通常是扁平数组;若想取其中第一个,用[0]收尾(如..address[0]); - 返回
undefined是常态而非异常,取值前最好先判断,这正好契合 Bruno 脚本中"先判断再断言"的写法。
在 Bruno 脚本生态中的实际落地
bruno-query的价值最终体现在 Bruno 的断言与测试脚本中。它在 packages/bruno-js/src/bruno-response.js 中被用得非常巧妙:BrunoResponse构造函数把实例包装成可调用对象,callable = (...args) => get(this.body, ...args),于是脚本里拿到的res本身就能当函数调用,等价于对响应体执行@usebruno/query的get。
同样在 packages/bruno-js/src/utils.js 的createResponseParser中,res = (expr, ...fns) => get(response.data, expr, ...fns)把 query 直接桥接到响应解析器上。也就是说,你写在断言脚本里的res('..items[?].amount', i => i.amount > 20)这类调用,底层就是get的这套路径引擎在跑。
另一个值得注意的实现约束来自 packages/bruno-js/src/sandbox/quickjs/shims/bruno-response.js:注释明确写着"Safe because @usebruno/query's get() invokes filters synchronously"。这意味着在 QuickJS 沙箱环境里传给[?]的谓词/映射函数必须是同步的,get自身也是同步求值的——设计上是刻意与异步过滤划清界限的。
构建、测试与发布
包内 packages/bruno-query/rollup.config.js 定义了双构建管线:第一段把 src/index.ts 同时打包为 CJS 与 ESM(经terser压缩、sourcemap开启),第二段用rollup-plugin-dts把dist/esm/index.d.ts收敛为统一的dist/index.d.ts类型入口。
发布前默认走npm test && npm run build(见 package.json 的prepack钩子),测试执行jest。readme 也给出了面向 npm Registry 的标准发布命令:
npm publish --access=public由于该包同时服务 Electron 主应用脚本与 CLI 运行时的bruno-js依赖链,发布@usebruno/query新版本时,需要同步在 packages/bruno-js/package.json 中升级对应依赖版本并回归jest测试,以确保响应取值行为的一致性。
【免费下载链接】brunoOpensource IDE For Exploring and Testing API's (lightweight alternative to Postman/Insomnia)项目地址: https://gitcode.com/GitHub_Trending/br/bruno
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考