クラウド Mac で検証する iOS JSON Pointer の解析契約

クラウド Mac で検証する iOS JSON Pointer の解析契約

クラウド Mac で検証する iOS JSON Pointer の解析契約

サーバーが検証エラーの場所を /items/0/name と返しているのに、iOS クライアントでは別のフィールドが強調表示される。このような不具合は、UI のバインディングではなく、パス解析器が空のキーを捨てている、01 を配列の添字として扱っている、あるいはフィールドの欠落と正当な null を同じ結果として扱っていることが原因になりがちです。クラウド Mac では、まず単独の Swift スクリプトでこうした意味論を確定させ、そのサンプルをアプリのテストターゲットへ移せます。UI の一連の操作を起動する必要はありません。

一、読み取り契約を先に定める

この記事で実装するのは、RFC 6901 の JSON 文字列形式です。空文字列は文書全体を指し、それ以外のパスは / で始まる必要があります。# で始まる URI フラグメント形式は受け付けません。また、データを書き換えるものでも、JSONPath のようなクエリ言語でもありません。

パスを分割するときは、空のセグメントも残す必要があります。/ は空のキーへのアクセスを、/a/ はオブジェクト a 内の空のキーへのアクセスを表します。末尾のスラッシュを不用意に削除してはいけません。オブジェクトのメンバー名は常に文字列として比較し、現在のノードが実際に配列である場合に限って、数値を添字として解釈します。

パスが存在し、その値が null なら読み取りは成功です。パスが存在しなければ失敗です。この 2 つの結果を同じ Optional 値で表してはいけません。

ユーザー向けのエラーメッセージは最終的に「フィールドを特定できません」にまとめても構いません。ただし、テスト層では構文エラー、メンバーの欠落、添字エラー、ノードの型エラーを区別し、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 を含め、各アサーションでは 1 つの結論だけを検証します。

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、または先頭にゼロのない十進整数だけです。ハイフン、範囲外の値、整数のオーバーフローも失敗として扱います。

いつものパイプラインでクラウドMacを検証

ArmRDPでは、仮想マシンではない専有の物理Mac miniをご利用いただけます。1日単位でレンタルできるので、ツールチェーンやビルド結果を確認してから、チームに合った利用期間をお選びください。

クラウドMacをレンタル

構成を明確に、ビルドを再現可能に

機種、利用期間、接続手順を確認してから、タスクを専有物理ノードにお任せください。