雲端 Mac 上的 iOS JSON Pointer 路徑解析契約測試
伺服器將驗證錯誤定位到 /items/0/name,iOS 用戶端卻標示了錯誤的欄位。這類問題往往不在介面綁定,而是路徑解析器丟掉了空鍵、把 01 當成陣列索引,或將欄位不存在與合法的 null 混為一談。在雲端 Mac 上,可以先用獨立的 Swift 腳本確立這些語意,再把測試案例移入應用程式的測試目標,不必啟動整套介面流程。
一、先確立讀取契約
本文實作的是 RFC 6901 的 JSON 字串形式:空字串指向整份文件,其餘路徑都必須以 / 開頭。它不接受以 # 開頭的 URI 片段形式,也不負責修改資料,更不是 JSONPath 查詢語言。
分割路徑時必須保留空片段。路徑 / 表示存取空鍵,/a/ 表示存取物件 a 中的空鍵,不能順手移除結尾的斜線。物件成員名稱一律以字串比較;只有目前節點確實是陣列時,才將數字解讀為索引。
路徑存在且值為 null,代表讀取成功。路徑不存在,才是失敗。不要用同一個可選值表達這兩種結果。
對外顯示的錯誤訊息最後可以統一為「無法定位欄位」,但測試層應保留語法錯誤、成員不存在、索引錯誤與節點型別錯誤的區別,方便追查 API 契約的變動。
二、實作嚴格的跳脫處理與節點走訪
將以下程式碼儲存為 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 目標的整合測試。如果應用程式直接走訪自訂的解碼模型,就需要為該模型建立相同的測試案例,不能假設兩種資料表示方式天生一致。
輸入來自外部 API 時,還必須在送入解析器之前,限制文件大小、路徑長度與片段數量。上限應依業務欄位的規模設定,並測試剛好達到上限與超過上限的值,不能把範例中的短路徑當成資源耗用的邊界。
五、失敗時檢查輸入,不退回猜測模式
遇到定位失敗時,先記錄錯誤類別、片段序號與目前節點型別,不要記錄完整的業務文件。路徑本身也可能含有使用者資料,必須先去識別化,才適合放進建置附件。
接著確認伺服器提供的是 JSON Pointer,還是其他路徑語法,再檢查是否經過額外的 URL 解碼。在本文支援的字串形式中,百分比符號只是一般字元,不能擅自多做一次解碼。
最後,將每個修復過的失敗輸入加入固定的測試案例集。驗收標準應是合法路徑能準確讀取、不合法路徑會明確失敗,而不是「盡量找到相似欄位」。如此一來,錯誤提示、設定讀取與欄位對應才能共用一套穩定的路徑契約。
常見問題
空字串路徑與單獨一個斜線是否相同?
不同。空字串傳回整份文件;單獨一個斜線存取根物件中名稱為空字串的成員,成員不存在時必須失敗。
讀取陣列時可以接受 01 或連字號作為索引嗎?
本解析器拒絕這兩種寫法。索引只接受 0 或不帶前導零的十進位整數;連字號不代表可讀取的元素,越界及整數溢位也必須失敗。