JSON Patchで差分更新を安全に設計する|test・配列・冪等性・Python

「JSON Patchで差分更新を安全に設計する|test・配列・冪等性・Python」の内容を表す技術イラスト

APIで一部のfieldだけを更新するとき、単純なJSON objectでは「値を設定する」「memberを削除する」「現在値が一致するときだけ変更する」といった操作を明確に表せないことがあります。

JSON Patchは、JSON documentへの変更をoperationのarrayで表す形式です。更新内容だけでなく、操作順、対象path、事前条件をdataとして受け渡せます。一方、array indexのずれや再送による二重追加、権限外fieldの変更には注意が必要です。

この記事では、JSON Pointer、6種類のoperation、test、原子性、冪等性、Python実装を、設計データ更新の例で整理します。

目次

JSON Patchで解決すること

RFC 6902のJSON Patch documentは、operation objectを並べたJSON arrayです。HTTP PATCHで送る場合のmedia typeはapplication/json-patch+jsonです。

更新前のassetを次のobjectとします。

{
  "id": "asset-42",
  "version": 7,
  "name": "Hydraulic unit A",
  "tags": ["hydraulic"],
  "note": null
}

名称変更、tag追加、現在versionの確認を一つのpatchで表せます。

[
  {"op": "test", "path": "/version", "value": 7},
  {"op": "replace", "path": "/name", "value": "Hydraulic unit B"},
  {"op": "add", "path": "/tags/-", "value": "inspected"}
]

各array要素が1 operationです。opが操作、pathが対象位置、valueが比較または設定する値です。上から順に適用され、途中結果が次のoperationの入力になります。

JSON Merge Patchとの違い

JSON Merge Patchは更新後に近いobjectを送り、JSON Patchは変更手順をarrayで送ります。

観点JSON PatchJSON Merge Patch
bodyoperationのarray更新後に近いobject
member削除removenull
JSON nullの設定valueにnullを指定可能nullが削除を表す
配列の部分操作indexや末尾追加原則array全体を置換
事前条件testを使用可能形式内にはない

JSON Patchなら、noteをJSON nullへ設定する操作と、note memberを削除する操作を分けられます。

[{"op": "replace", "path": "/note", "value": null}]
[{"op": "remove", "path": "/note"}]

DBのNULLと、objectにmemberが存在しない状態も別に扱います。

6種類のoperationを理解する

RFC 6902は次のoperationを定義しています。

op必要なmember動作注意点
addpath、value値を追加既存object memberなら置換
removepath対象を削除対象が必要
replacepath、value既存値を置換対象が必要
movefrom、path値を移動fromを削除する
copyfrom、path値を複製from側にも認可が必要
testpath、value現在値を比較不一致なら失敗

removeを同じdocumentへ2回適用すると、2回目は対象がないため失敗します。独自のincrementやappend_uniqueをRFC 6902のoperationとして混在させず、必要なら専用endpointまたは独自media typeとして定義します。

pathはJSON Pointerで表す

pathとfromにはRFC 6901のJSON Pointerを使います。rootからmemberをたどり、各tokenの前へ/を付けます。

/name          objectのname
/tags/0        tags arrayの先頭要素
/tags/-        tags arrayの末尾へ追加するときの位置
/dimensions/x  dimensions object内のx

key内の/は~1、~は~0へescapeします。受信側では文字列のprefixだけでなく、解決後のfieldにも認可を適用します。

testで古いデータによる上書きを防ぐ

testは対象値と指定値を比較し、不一致なら後続operationを適用しません。

[
  {"op": "test", "path": "/version", "value": 7},
  {"op": "replace", "path": "/material", "value": "S45C"}
]

現在versionが8なら、このpatchは失敗します。clientは最新版を再取得し、自分の変更と他の変更を比較します。失敗後にtestだけを8へ書き換えて自動再送すると、競合検出を無効にしてしまいます。

強いETagとIf-Matchを併用する場合、ETagはrepresentation全体、testはdocument内部の値に対する事前条件として役割を決めます。

全operationを原子的に扱う

RFC 5789は、serverがPATCHの変更集合を原子的に適用するよう定めています。5個中4個だけ成功させる処理ではありません。

Content-Typeとbody sizeを確認
  ↓
JSONと各operationを検証
  ↓
現在documentのcopyへ全操作を適用
  ↓
結果をschema・業務規則で検証
  ↓
DB transactionでversion条件付き更新

memory上のobjectを途中まで直接変更せず、全検証が成功した結果だけを保存します。

Pythonでallowlist付きpatchを適用する

次の例はpython-json-patchを使い、許可するoperationとpathの組み合わせを限定します。idやversionの書き換えは禁止し、先頭のtest /versionだけを許可します。

from copy import deepcopy

import jsonpatch


ALLOWED = {
    ("replace", "/name"),
    ("add", "/tags/-"),
}


def apply_asset_patch(current: dict, operations: list[dict]) -> dict:
    if not isinstance(operations, list) or not 1 <= len(operations) <= 20:
        raise ValueError("patchは1〜20件のarrayが必要です")

    expected = {"op": "test", "path": "/version", "value": current["version"]}
    if operations[0] != expected:
        raise ValueError("先頭に現在versionのtestが必要です")

    for operation in operations[1:]:
        if not isinstance(operation, dict):
            raise TypeError("operationはobjectである必要があります")
        if (operation.get("op"), operation.get("path")) not in ALLOWED:
            raise ValueError("許可されていない更新です")

    try:
        candidate = jsonpatch.apply_patch(
            deepcopy(current), operations, in_place=False
        )
    except jsonpatch.JsonPatchException as exc:
        raise ValueError("patchを適用できません") from exc

    if not isinstance(candidate.get("name"), str) or not candidate["name"].strip():
        raise ValueError("nameは空でない文字列が必要です")
    if not isinstance(candidate.get("tags"), list):
        raise ValueError("tagsはarrayが必要です")

    candidate["version"] = current["version"] + 1
    return candidate


current = {
    "id": "asset-42",
    "version": 7,
    "name": "Hydraulic unit A",
    "tags": ["hydraulic"],
}

patch = [
    {"op": "test", "path": "/version", "value": 7},
    {"op": "replace", "path": "/name", "value": "Hydraulic unit B"},
    {"op": "add", "path": "/tags/-", "value": "inspected"},
]

updated = apply_asset_patch(current, patch)
assert current["version"] == 7
assert updated["version"] == 8
assert updated["tags"] == ["hydraulic", "inspected"]

libraryがpatchを適用できたことと、結果が業務schemaへ適合することは別です。実務では全fieldを再検証し、DBのversion条件付きUPDATEにつなげます。

配列indexと再送に注意する

array indexは先行operationで変化します。/tags/0を削除すると、元のindex 1はindex 0へ移動します。操作対象が集合なら、各要素へ安定したIDを持たせて別resourceにする方が安全です。

同じpatchを再実行したときの結果もoperationで異なります。

operation例再実行時
同じ値へのreplace結果は同じになりやすい
object memberへのadd同じkeyなら置換
/tags/-へのadd要素が再び増える
remove2回目は対象不存在で失敗
move元位置が消えて失敗し得る

versionのtestは二重適用を防げますが、初回成功後にresponseだけ失われた再送も失敗します。初回結果を再返却したい場合は、request IDまたはidempotency keyと処理結果をserver側へ保存します。

異常系とセキュリティを設計する

最低限、test不一致、未知のop、権限外path、存在しないreplace対象、~0・~1を含むkey、null設定とmember削除、同一requestの再送、DB競合をテストします。

JSON Patchは任意pathを書き換えられるため、受信bodyをそのままdomain objectへ適用するとmass assignmentにつながります。

  • operationとpathの組み合わせをallowlist化する
  • moveとcopyのfromにも認可を適用する
  • role、owner_id、approvedなどはfield単位で保護する
  • operation数、nesting、array size、文字列長を制限する
  • tokenや内部メモをpatch対象とresponseから分離する
  • patchをlogへ残す場合は機密値を除去する

pathを知っていることは更新権限の証明ではありません。resource単位の認可後にfield単位の認可も行います。

DB・CAD・設計データへ再利用する

JSON Patchをそのまま動的SQLへ変換せず、許可されたpathをdomain fieldと更新列へmappingします。SQLではparameter bindingを使います。

CAD属性やBOMでarray indexが不安定なら、部品ID、属性ID、revisionを持つresourceへ分けます。巨大なCAD fileはpatchへ含めず、file ID、content hash、metadataを変更し、binary本体は別storageでversion管理します。

監査にはresource ID、更新前後のversion、patch、request ID、実行者を記録します。秘密情報を除いたpatch履歴は、変更追跡や再現testへ利用できます。

まとめ

JSON Patchは、部分更新を操作列として表すdata形式です。値だけでなく順序、path、事前条件を契約にできます。

  • bodyをoperation objectのarrayとして検証する
  • pathにはJSON Pointerを使う
  • add・remove・replaceの存在条件を区別する
  • testまたはIf-Matchで古いdataの上書きを防ぐ
  • 全operationをcopy上で検証し、DBへ原子的に保存する
  • array indexと再送時の非冪等性に注意する
  • opとpathをallowlist化し、適用後のdocumentも再検証する
  • request IDで通信再送による二重処理を防ぐ

この契約をAPI、DB、Python、CAD metadataへ共通化すれば、競合や再送があっても意図しない上書きや二重追加を防げます。

参考情報

参考になったらシェアしてください
  • URLをコピーしました!
  • URLをコピーしました!

この記事を書いた人

機械設計・油圧・CAD・Python・AIなど、ものづくりに関わる技術を扱っています。工学知識を整理・構造化し、設計や自動化に再利用できる形へ変えていくことを目指しています。

目次