雲端 Mac 上的 iOS JSON Pointer 路徑解析契約測試

雲端 Mac 上的 iOS JSON Pointer 路徑解析契約測試

雲端 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 或不帶前導零的十進位整數;連字號不代表可讀取的元素,越界及整數溢位也必須失敗。

用自己的 CI/CD 流程驗證雲端 Mac

ArmRDP 提供專屬實體 Mac mini,並非虛擬機。可按天租用,先確認工具鏈與建置結果,再選擇適合團隊的租期。

立即租用雲端 Mac

設定有據可查,建置結果可重現

確認機型、租期與連線步驟,再將工作交給專屬實體節點。