云端 Mac 上的 iOS JSON Pointer 路径解析契约测试

云端 Mac 上的 iOS JSON Pointer 路径解析契约测试

服务端把校验错误定位到 /items/0/name,iOS 客户端却高亮了错误字段。常见原因不是界面绑定,而是路径解析器把空键丢掉、把 01 当成数组下标,或把字段缺失与合法 null 合并处理。在云端 Mac 上,可以先用独立 Swift 脚本固定这些语义,再把样本移入应用测试目标,不必启动整个界面流程。

一、先固定读取契约

本文实现 RFC 6901 的 JSON 字符串形式:空字符串指向整个文档,其他路径必须以 / 开头。它不接受以 # 开头的 URI 片段形式,也不负责修改数据,更不是 JSONPath 查询语言。

分隔后必须保留空片段。路径 / 表示访问空键,/a/ 表示访问对象 a 中的空键,不能把末尾斜杠顺手删掉。对象成员名始终按字符串比较;只有当前节点确实是数组,才解释数字下标。

路径存在且值为 null 是成功读取。路径不存在是失败。不要用同一个可选值表达这两种结果。

错误提示可以最终归为“无法定位字段”,但测试层应保留语法错误、成员缺失、下标错误和节点类型错误,便于追查接口契约变化。

二、实现严格转义与节点遍历

把下面代码保存为 PointerCheck.swift。它只依赖 Foundation,使用当前选定工具链运行,不需要额外安装包。转义扫描只接受 ~0 和 ~1,分别还原为 ~ 和 /。

import Foundation

enum PointerError: Error {
    case syntax
    case missing
    case index
    case scalar
}

func decodeToken(_ token: Substring) throws -> String {
    var result = ""
    var cursor = token.startIndex

    while cursor < token.endIndex {
        let character = token[cursor]
        cursor = token.index(after: cursor)

        if character != "~" {
            result.append(character)
            continue
        }

        guard cursor < token.endIndex else {
            throw PointerError.syntax
        }

        switch token[cursor] {
        case "0": result.append("~")
        case "1": result.append("/")
        default: throw PointerError.syntax
        }
        cursor = token.index(after: cursor)
    }
    return result
}

func resolve(_ pointer: String, in document: Any) throws -> Any {
    if pointer.isEmpty {
        return document
    }
    guard pointer.first == "/" else {
        throw PointerError.syntax
    }

    let tokens = try pointer.dropFirst()
        .split(separator: "/", omittingEmptySubsequences: false)
        .map { try decodeToken($0) }

    var current = document

    for token in tokens {
        if let object = current as? [String: Any] {
            guard let value = object[token] else {
                throw PointerError.missing
            }
            current = value
        } else if let array = current as? [Any] {
            let digits = token.utf8
            guard !digits.isEmpty,
                  digits.allSatisfy({ $0 >= 48 && $0 <= 57 }),
                  token == "0" || !token.hasPrefix("0"),
                  let index = Int(token),
                  index < array.count else {
                throw PointerError.index
            }
            current = array[index]
        } else {
            throw PointerError.scalar
        }
    }
    return current
}

不能先替换 ~0 再替换 ~1:原始 ~01 应还原为字面量 ~1,而不是斜杠。逐段扫描还能拒绝孤立的波浪号与 ~2,避免非法路径被静默“修好”。

数组分支检查 ASCII 数字,拒绝正负号、空白和其他数字字符。Int 转换失败也视为下标错误,不能强制解包。对象中的 "01" 则是合法键名,不应套用数组规则。

三、用小样本覆盖容易混淆的边界

继续追加下面的验收代码。样本同时包含空键、特殊字符键、数组、标量及 null,每个断言只验证一个结论。

let source = #"{"a/b":{"~key":[null,7]},"":9,"~1":11,"01":12}"#
let document = try JSONSerialization.jsonObject(
    with: Data(source.utf8),
    options: [.fragmentsAllowed]
)

let nullValue = try resolve("/a~1b/~0key/0", in: document)
precondition(nullValue is NSNull)

let emptyKey = try resolve("/", in: document)
precondition((emptyKey as? NSNumber)?.intValue == 9)

let escapedKey = try resolve("/~01", in: document)
precondition((escapedKey as? NSNumber)?.intValue == 11)

let numericKey = try resolve("/01", in: document)
precondition((numericKey as? NSNumber)?.intValue == 12)

let root = try resolve("", in: document)
precondition(root is [String: Any])

let rejected = [
    "a", "#/a", "/bad~2", "/bad~",
    "/absent", "/a~1b/~0key/01",
    "/a~1b/~0key/-", "/a~1b/~0key/2",
    "/a~1b/~0key/999999999999999999999999999999",
    "/a~1b/~0key/1/child"
]

for pointer in rejected {
    var failed = false
    do {
        _ = try resolve(pointer, in: document)
    } catch is PointerError {
        failed = true
    }
    precondition(failed, "Unexpected resolution success")
}

再补一组根数组和根标量样本。根标量配合空路径应成功;继续向下读取则必须失败。字符串比较保持原样,不自动做大小写折叠或 Unicode 归一化,否则可能读到另一成员。

四、接进云端构建,而不是只跑示例

在 SSH 会话中运行:

set -eu
xcrun --find swift
xcrun swift PointerCheck.swift

脚本成功时静默退出,断言失败时返回非零状态。不要用优化编译后可能移除检查的方式运行这份验收脚本。正式接入应用时,将解析器放入共享模块,把断言迁为测试目标中的断言,并通过项目已有测试入口执行。

独立脚本验证的是 Foundation 数据树上的读取语义,不能替代 iOS 目标的集成测试。应用如果直接遍历自定义解码模型,需要为它建立同样的样本,而不是假定两种表示天然一致。

输入来自外部接口时,还要在进入解析器前限制文档大小、路径长度和片段数量。阈值应由业务字段规模确定,并测试上限值与超限值,不能把示例里的短路径当作资源消耗边界。

五、失败时检查输入,不回退猜测

遇到定位失败,先记录错误类别、片段序号和当前节点类型,不记录完整业务文档。路径本身也可能包含用户数据,只有经过脱敏后才适合进入构建附件。

随后核对服务端提供的是 JSON Pointer 还是其他路径语法,再检查它是否经历了额外的 URL 解码。百分号在本文支持的字符串形式中只是普通字符,不能擅自解码一次。

最后,把每个修复过的失败输入加入固定样本集。验收标准应是合法路径准确读取、非法路径明确失败,而不是“尽量找到相似字段”。这样错误提示、配置读取与字段映射才能共享一套稳定的路径契约。

常见问题

空字符串路径与单独一个斜杠是否等价?

不等价。空字符串返回整个文档;单独一个斜杠访问根对象中名称为空字符串的成员,成员不存在时必须失败。

读取数组时可以接受 01 或短横线作为下标吗?

本解析器拒绝这两种写法。数组下标只接受 0 或不带前导零的十进制整数;短横线不代表可读取的元素,越界和整数溢出也必须失败。

用自己的流水线验证云端 Mac

ArmRDP 提供独享物理 Mac mini,非虚拟机。按天起租,先核对工具链与构建结果,再选择适合团队的租期。

开始租用云端 Mac

让配置有据可查,让构建可以复现

核对机型、租期与接入步骤,再把任务交给独享物理节点。