Hurl JSON Body 断言设计剖析:从文本级 Diff 走向语义化 JSONPath 错误
【免费下载链接】hurlHurl, run and test HTTP requests with plain text.项目地址: https://gitcode.com/GitHub_Trending/hu/hurl
Hurl 允许在响应体中直接编写 JSON 结构作为隐式断言,本文基于仓库中的设计文档 docs/spec/runner/assert_json_body.md 展开,剖析当前"文本级比较"实现的两个痛点、仓库中对应的源码实现细节,以及设计文档提出的"语义化 JSONPath 错误"替代方案与逐项示例。读完本文,你将理解 Hurl 中jsonpath断言在 body 场景下的语义、错误信息中行号与类型的映射规则,以及该方案与完整 JSON Diff 工具(如 jd)之间的设计取舍。
动机:为什么 JSON body 断言不能只做"文本比较"
设计文档开篇即点明现状:目前 Hurl 对 JSON 响应是从文本(textual)角度而非语义(semantic)角度进行比较的。这种实现存在两个主要弊端,下面逐一展开。
弊端一:等价 JSON 会因格式差异报错
两个语义完全等价的 JSON,仅仅因为缩进、空白不同或字段顺序不同,就会触发断言失败。文档给出的示例:
GET http://localhost:8000/json { "greeting": "Hello" }如果服务端把响应压缩成单行{"greeting":"Hello"},运行hurl test.hurl就会得到:
error: Assert body value --> /tmp/test.hurl:3:1 | | GET http://localhost:8000/greeting | ... 3 | { | -{ | - "greeting": "Hello" | -} | +{"greeting":"Hello"} | }注意这里错误标题是Assert body value,输出的是带-/+标记的统一 diff(unified diff)片段——这正是下文将要介绍的当前实现路径。
弊端二:真正不同时,diff 被字段顺序"污染"
当两个 JSON 确实不同时,diff 虽然能指出差异,但如果字段顺序不同,错误信息会充满噪音,难以定位真正变化的字段。文档示例:
GET http://localhost:8000/bob { "name": "Bob", "age": 22 }服务端返回的 JSON 为{"age": 20, "name": "Bob"}(字段顺序颠倒且age不同),错误输出:
error: Assert body value --> /tmp/test2.hurl:4:1 | | GET http://localhost:8000/bob | ... 4 | "name": "Bob", | - "name": "Bob", | - "age": 22 | -} | + "age": 20, | + "name": "Bob" | +} | +整个对象块几乎都被标红,读者很难一眼看出真正的差异只是age字段的取值。
现状实现:源码中的"文本级 diff"证据
设计文档描述的现状,在仓库源码中可以找到完整的对应实现。先看错误类型定义,packages/hurl/src/runner/error.rs 中声明了两种与 body 断言相关的错误:
AssertBodyDiffError { hunks, body_source_info, ... }, AssertBodyValueError { actual, expected },两者在错误描述(description())中都映射为同一句 "Assert body value"(见 error.rs),这解释了上面示例中错误标题的由来。AssertBodyDiffError持有的是hunks(diff 块集合),而AssertBodyValueError只持有实际值与期望值的字符串。
真正生成 diff 的逻辑位于 packages/hurl/src/runner/diff.rs:它基于similarcrate 的TextDiff::from_lines做按行的文本 diff,再转成 unified diff,且context_radius(0)(不保留上下文行),最后把每个 diff 块封装为DiffHunk { content, start, source_line }:
pub fn diff(old: &str, new: &str) -> Vec<DiffHunk> { let text_diff = TextDiff::from_lines(old, new); let mut unified_diff = text_diff.unified_diff(); let unified_diff = unified_diff.context_radius(0); ... for change in hunk.iter_changes() { let sign = match change.tag() { ChangeTag::Delete => "-", ChangeTag::Insert => "+", ChangeTag::Equal => " ", }; ... } }在断言执行端,packages/hurl/src/runner/assert.rs 的ImplicitBody分支完整实现了这段逻辑:先比较actual == expected,相等则通过;不相等时再判断use_diff(expected, actual)——需要生成 diff 就走AssertBodyDiffError,并利用第一个 hunk 的source_line计算出错误在源文件中的行号;否则退化为AssertBodyValueError。换句话说,当前 Hurl 的 JSON body 隐式断言,本质上仍是"渲染后的文本相等性比较 + 行级 unified diff"。
这一点也由 error.rs 中的单元测试test_assert_error_newline佐证:期望输出中包含Assert body value标题以及形如4 | <p>Hello</p>+| +的 diff 渲染,说明该错误路径是真实可达并被测试覆盖的。
语义化方案:复用 Hurl 已有的 JSONPath 错误语义
既然文本级 diff 不理想,设计文档提出的方案是:当实际 JSON 与期望不同时,生成 Hurl 标准的jsonpath断言错误,而不是输出整段 diff。这样天然具备语义性——不关心空白与字段顺序,只关心"路径指向的值"是否匹配。
例如,某个请求:
--> test.hurl:4:0 | | GET http://localhost:8000/modify | ... 4 | jsonpath "$.age" | actual: int <20> | expected: int <22>错误信息直接给出路径、实际值与期望值,且带有类型标注(int),比整段 diff 清晰得多。这种错误形式与 Hurl 现有的显式 JSONPath 断言(docs/asserting-response.md 中的jsonpathassert)完全一致。
数组:只在元素数量上报告差异
对于数组,当期望数组与实际数组大小不同时,方案并不逐个元素展开 diff,而是只报告count(元素个数)不匹配:
error: Assert JSON Body --> test.hurl:41:0 | | GET http://localhost:8000/add_json | ... 31 | jsonpath "$.phone_numbers" count == 2 | actual: integer <3> | expected: integer <2>设计文档特别说明:如果数组新增或删除了某个元素,逐个元素给出expected: not something之类的信息会"难以理解"(not easy to understand),因此在元素数量层面收敛错误,语义最清晰。
JSONPath 查询的底层实现
语义化错误方案之所以可行,是因为 Hurl 运行器本就内置了 JSONPath 查询能力。在 packages/hurl/src/runner/query.rs 中,eval_query_jsonpath先从响应缓存BodyCache中取 JSON(未解析则通过parse_cache_json用serde_json::from_str解析响应文本,解析失败返回QueryInvalidJson错误),随后交给filter::eval_jsonpath_json求值。这意味着:
- JSON 响应会先被反序列化为 JSON 文档再查询,天然与文本格式无关;
- 显式
jsonpath断言与语义化 body 错误可以共享同一套查询与值比较基础设施; - 由 integration/hurl/tests_ok/assert/assert_json.hurl 可见,
jsonpath断言支持==、!=、>、count ==、exists、isList、contains、nth 0 ==、[?(@.id=='error1')]过滤表达式等丰富谓词,语义化错误方案可以完全复用这些谓词与类型系统(int、integer、string、not something等)。
需要留意的是,仓库中还存在一个影响 JSONPath 返回形态的选项:--no-jsonpath-coercion(见 docs/manual/hurl.md)。默认开启的 JSONPath coercion 会把"空结果返回为无值、单结果返回为标量",关闭后 JSONPath 结果总是以数组返回;use_jsonpath_coercion这一开关会一路传递到查询执行处(assert.rs 中的options.use_jsonpath_coercion)。在设计语义化 body 错误时,这一行为同样会影响count == n之类断言的语义,值得实现时一并考虑。
完整示例:6 个语义化错误 Case 详解
设计文档给出了一个经典的"人物资料"期望 JSON,作为所有 case 的基准:
{ "first_name": "John", "last_name": "Smith", "is_alive": true, "age": 27, "address": { "street_address": "21 2nd Street", "city": "New York", "state": "NY", "postal_code": "10021-3100" }, "phone_numbers": [ { "type": "home", "number": "212 555-1234" }, { "type": "office", "number": "646 555-4567" } ], "children": [ "Catherine", "Thomas", "Trevor" ], "spouse": null }Case 1:标量字段被修改(age modified)
当age从 22 变成 20 时:
24 | jsonpath "$.age" | actual: int <20> | expected: int <22>错误指向$.age这一路径,实际值<20>与期望值<22>的类型均为int。
Case 2:字段被删除(is_alive field deleted)
当is_alive字段整体消失时:
23 | jsonpath "$.is_alive" | actual: not something | expected: true实际值为not something,表示该路径在响应 JSON 中不存在——这正是设计文档在数组场景中想避免的那种"对单个元素使用not something"表达,但在字段缺失场景下它语义准确、清晰。
Case 3:字段被新增(new country field added)
当响应多出了一个期望中没有的country字段时:
47 | jsonpath "$.country" | actual: spain | expected: not something设计文档特别指出:这里的行号对应"该字段若出现在源 Hurl 文件中应处于的行"(The line number matches the line for which it could be added in the source Hurl file)。即错误行号不是从响应 JSON 推导,而是映射回期望 JSON 在.hurl源文件中的位置,保证用户能快速定位到自己的断言源码。
Case 4:嵌套数组元素被修改(first phone number modified)
当第一个电话号码从212 555-1234变成210 555-1234时:
34 | jsonpath "$.phone_numbers[0].number" == "212 555-1234" | actual: string <210 555-1234> | expected: string <212 555-1234>这里展示了带下标([0])的路径表达式,实际值与期望值都标注了string类型。
Case 5:数组元素被删除(deleting a phone number)
当phone_numbers从 3 个元素变成 2 个时:
31 | jsonpath "$.phone_numbers" count == 2 | actual: integer <2> | expected: integer <3>Case 6:数组元素被新增(adding a phone number)
当phone_numbers从 2 个元素变成 3 个时:
31 | jsonpath "$.phone_numbers" count == 2 | actual: integer <3> | expected: integer <2>Case 5 与 Case 6 再次强调:数组只报告元素个数差异,且行号对应源 Hurl 文件中该数组的起始行(The line number matches the start of the array in the source Hurl file)。这样无论是增还是删,报错位置都稳定、可预期,用户修改断言时不需要去猜"错误到底落在哪个元素上"。
边界与取舍:为什么不叫"JSON Diff"
设计文档在 "Additional" 一节明确划定了方案边界:
生成的错误信息并不能完整重建出实际 JSON,因此不把该方案称为 JSON Diff。
团队最初确实考虑过产出类似 jd 格式的完整 diff:
jd object1.json object2.json @ ["age"] - 20 + 22结论是:字段值修改在这种格式下可读性不错,但数组元素的增删在 Hurl 的输出格式中"太难理解"(too hard to understand)。因此最终采用"逐路径报错 + 数组按数量报错"的折中方案——信息量足以定位问题,错误又足够聚焦。
这个取舍也体现在错误渲染的实现上:AssertBodyDiffError在fixme()中只取第一个 hunk渲染(error.rs),并保留// FIXME: this variant can not be called because message doesn't call it的注释——从源码结构看,文本 diff 路径正在被有意收敛,为语义化错误留出空间。而语义化方案所需的"行号映射"基础设施(DiffHunk.source_line、Pos定位)在 diff.rs 与 error.rs 中已经齐备:错误位置由source_info.start.line + source_line计算得出,与设计文档"行号匹配源 Hurl 文件"的要求完全吻合。
延伸阅读
- 本文设计文档原文:docs/spec/runner/assert_json_body.md
- JSON body 断言语法(隐式 body 断言与
json多行语法):docs/asserting-response.md - JSONPath 断言与谓词全表(
==、count、exists、contains等):docs/asserting-response.md - 运行器断言执行逻辑:
ImplicitBody分支见 packages/hurl/src/runner/assert.rs - 行级文本 diff 生成:packages/hurl/src/runner/diff.rs
- 错误类型与渲染(
Assert body value):packages/hurl/src/runner/error.rs - JSONPath 查询执行与 JSON 解析缓存:packages/hurl/src/runner/query.rs
- JSONPath 断言语料(集成测试):integration/hurl/tests_ok/assert/assert_json.hurl
--no-jsonpath-coercion选项说明:docs/manual/hurl.md
当前仓库中 JSON body 的隐式断言仍以文本级比较实现(对应AssertBodyDiffError/AssertBodyValueError两条错误路径),本文所剖析的设计方案为后续演进方向提供了明确的语义化路线图;理解这套"路径级报错、数组按数量收敛、行号回映射到源文件"的错误模型,将帮助你写出更健壮、更易定位失败的 Hurl 测试。
【免费下载链接】hurlHurl, run and test HTTP requests with plain text.项目地址: https://gitcode.com/GitHub_Trending/hu/hurl
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考