ユーザー設定を部分更新に切り替えたとき、よく起きるのはリクエストの失敗ではなく、成功したはずなのに古いフィールドが残る不具合です。クライアントが null を「無視する値」として扱う一方で、サーバーは「削除」と解釈していることがあります。クラウド Mac のビルドジョブに Swift の契約テストを組み込めば、コンパイルが通るかどうかだけでなく、更新内容に対する双方の解釈が一致しているかも検証できます。ここでは JSON Merge Patch によるドキュメントの変換だけを扱い、本番 API への接続もシミュレーターも使いません。
更新プロトコルの4つのルールを明確にする
JSON Merge Patch のルールは RFC 7396 で定義されています。オブジェクトのパッチはフィールドごとに処理します。パッチに含まれないフィールドはそのまま残し、値が null のフィールドは削除し、それ以外の値は再帰的に適用します。パッチがオブジェクトでなければ、対象の値全体を置き換えます。そのため、配列がインデックス単位でマージされることはありません。
まず、API が application/merge-patch+json を使用することを明記しましょう。リクエストメソッドだけでプロトコルを判断してはいけません。通常の JSON リクエストでは、同じ PATCH メソッドを使っていても、まったく異なる業務ルールが適用される場合があります。
オブジェクトのメンバーに指定した
nullは削除を表し、ルートのパッチとして指定したnullはドキュメント全体を null にします。業務上、明示的な null 値を保存する必要があるなら、同じ位置のnullに代入と削除の両方の意味を持たせることはできません。
空のオブジェクトと空の配列も区別する必要があります。{} を既存のオブジェクトに適用すると内容は維持されますが、[] は対象全体を空の配列に置き換えます。対象がもともとオブジェクトでない場合、空のオブジェクトのパッチを適用した結果は空のオブジェクトになります。
独立した Swift のマージ処理を実装する
リポジトリに ci/merge-patch-check.swift を作成し、次のコードを記述します。JSON の解析には Foundation を使い、フラグメントを許可します。これにより、オブジェクトと配列だけでなく、数値、文字列、ルートの null もテスト対象にできます。
import Foundation
enum PatchError: Error {
case depthExceeded
case mismatch(String)
}
func parse(_ text: String) throws -> Any {
try JSONSerialization.jsonObject(
with: Data(text.utf8),
options: [.fragmentsAllowed]
)
}
func merge(
_ target: Any,
_ patch: Any,
depth: Int = 0
) throws -> Any {
guard depth <= 64 else {
throw PatchError.depthExceeded
}
guard let fields = patch as? [String: Any] else {
return patch
}
var result = target as? [String: Any] ?? [:]
for (key, value) in fields {
if value is NSNull {
result.removeValue(forKey: key)
} else {
result[key] = try merge(
result[key] ?? NSNull(),
value,
depth: depth + 1
)
}
}
return result
}
func canonical(_ value: Any) throws -> Data {
try JSONSerialization.data(
withJSONObject: value,
options: [.sortedKeys, .fragmentsAllowed]
)
}
func check(
_ name: String,
_ target: String,
_ patch: String,
_ expected: String
) throws {
let actual = try merge(try parse(target), try parse(patch))
let expectedValue = try parse(expected)
guard try canonical(actual) == canonical(expectedValue) else {
throw PatchError.mismatch(name)
}
}
深さの上限は、この実装で定めたリソース管理上の方針であり、プロトコル自体のルールではありません。ここではルートの呼び出しを深さゼロとし、再帰呼び出しの深さが 64 を超えると失敗します。実際のリクエストを受け付ける入口では、ペイロードのバイト数と全体のネストの深さも制限する必要があります。配列はそのまま置き換えられるため、このオブジェクトの再帰処理による深さのカウントだけでは、配列内部まで検査できません。
このマージ処理が受け付けるのは、JSON を解析して得た値だけです。任意の業務オブジェクトは対象にしていません。日付やバイナリデータをそのまま辞書に格納し、シリアライズ時に型が自動で補正されることを期待しないでください。
固定テストベクトルで削除と置換を検証する
同じファイルに、次のチェックを追加します。各サンプルでは主に1つの意味論を検証し、失敗時の名前からコードレビューで確認すべきプロトコルの規定を特定できるようにします。
try check(
"delete-and-preserve",
#"{"theme":"dark","badge":3}"#,
#"{"badge":null}"#,
#"{"theme":"dark"}"#
)
try check(
"nested-object",
#"{"ui":{"theme":"dark","scale":2}}"#,
#"{"ui":{"scale":3}}"#,
#"{"ui":{"theme":"dark","scale":3}}"#
)
try check(
"replace-array",
#"{"roles":["reader","writer"]}"#,
#"{"roles":["reader"]}"#,
#"{"roles":["reader"]}"#
)
try check("root-null", #"{"enabled":true}"#, "null", "null")
try check("empty-object", "[1,2]", "{}", "{}")
try check("empty-array", #"{"items":[1]}"#, #"{"items":[]}"#, #"{"items":[]}"#)
try check("missing-delete", "{}", #"{"absent":null}"#, "{}")
try check("scalar-root", #"{"count":1}"#, "7", "7")
比較前に並べ替えるのはオブジェクトのキーだけで、配列の順序は変更しません。このシリアライズ結果による比較は、ここに示した固定サンプル向けのものです。汎用的な数値の正規化手法ではなく、言語をまたいで共通の署名を計算する用途にも使うべきではありません。
クライアントでフィールドを通常の Optional としてモデル化すると、エンコード時に「フィールドを削除する」と「フィールドを更新しない」の両方が、フィールドの省略になりがちです。リクエスト構築層では、変更なし、削除、値の設定という3つの状態を明確に区別し、生成された JSON を別途検証する必要があります。マージ処理だけを検証しても、リクエストが正しくエンコードされているとは証明できません。
リソース上限と繰り返し適用のチェックを追加する
同じパッチを繰り返し適用しても、結果は変わらないはずです。この性質を確認すると、配列を誤って追加したり、数値を誤って加算したりする実装を見つけられます。ただし、API 全体で通知の重複送信などの副作用が発生しないことまでは証明できません。
さらに次のチェックを追加し、深すぎるオブジェクトが黙って切り詰められるのではなく、明示的なエラーになることを確認します。
let target = try parse(#"{"ui":{"scale":1}}"#)
let patch = try parse(#"{"ui":{"scale":2},"old":null}"#)
let once = try merge(target, patch)
let twice = try merge(once, patch)
guard try canonical(once) == canonical(twice) else {
throw PatchError.mismatch("idempotence")
}
var deep: Any = 1
for _ in 0..<66 {
deep = ["child": deep]
}
do {
_ = try merge(NSNull(), deep)
throw PatchError.mismatch("depth-limit")
} catch PatchError.depthExceeded {
}
見落としやすいもう1つのケースが、不正な JSON です。解析に失敗したらリクエストの処理を中止しなければなりません。エラーを捕捉して空の辞書に置き換え、そのままマージを続けると、壊れた入力を正当な更新として扱ってしまうおそれがあります。重複キーを受け付ける際の方針も入口で別途定め、異なるパーサーがたまたま同じ値を残すことに依存しないようにしましょう。
クラウド Mac のコミットチェックに組み込む
まず、現在のツールチェーンが利用できることを確認し、リポジトリのルートディレクトリで実行します。
set -eu
xcrun --find swift
xcrun swift ci/merge-patch-check.swift
このスクリプトでは、最適化によって無効になる可能性のあるアサーションを使いません。比較が1つでも失敗するとエラーがスローされ、ゼロ以外の終了ステータスを返します。時間のかかるアプリのビルドより前に実行し、サンプル名と終了ステータスを残せば十分です。実際のユーザー設定をログに出力する必要はありません。
導入時には3つの層で検証します。マージ処理では固定テストベクトルを実行し、リクエスト構築層では実際のエンコード結果を確認し、API 統合層ではサーバーが適用した後のドキュメントを照合します。モバイル側のキャッシュにも同じサンプルを使い、画面が誤ったルールで楽観的に更新された後、サーバーの結果で上書きされる事態を防ぎましょう。最終レビューで確認すべきなのは、リクエストが成功を返したかどうかではなく、各フィールドの更新が何を意味するかです。
よくある質問
オブジェクトのフィールドを null に設定できますか?
パッチオブジェクト内の null はフィールドの削除を意味します。フィールドを残して null を設定するには、別のプロトコルか明示的な操作が必要です。ただし、ルートのパッチが null なら文書全体が null に置き換わります。
配列は要素の位置ごとにマージされますか?
されません。パッチの配列は既存の配列全体を置き換え、空配列も同じ扱いです。要素の識別子単位で更新する場合は、別の業務操作として定義します。
チーム専用のビルド環境をMac 1台に集約
Apple Silicon M4搭載の専有Mac miniを選び、チーム独自のツールチェーンとRunner設定をそのまま利用できます。日単位・週単位・月単位・四半期単位で契約でき、利用可能状況はコンソールでリアルタイムに確認できます。
ビルドノードをレンタル