|

RFC 6902(JSON Patch)とは?6つの操作とHTTP PATCHの使い方を解説

Web APIでリソースの一部分だけを更新したい場合、HTTPのPATCHメソッドがよく使われます。

しかし、PATCHは「部分更新に使うHTTPメソッド」を定義しているだけであり、リクエストボディをどのような形式にするかまでは決めていません。

そこで利用できるのが、RFC 6902で標準化されているJSON Patchです。

JSON Patchを使うと、次のような更新内容をJSON形式で表現できます。

[
  {
    "op": "replace",
    "path": "/email",
    "value": "new@example.com"
  }
]

この記事では、RFC 6902の基本、6種類の操作、JSON Pointer、HTTP PATCHとの関係、Spring Bootでの実装例まで解説します。

RFC 6902とは

RFC 6902は、JSONドキュメントに対して実行する変更操作を表現するための仕様です。

正式名称は「JavaScript Object Notation (JSON) Patch」で、2013年4月にIETFの標準化トラック文書として公開されました。

JSON Patchのリクエストボディは、操作オブジェクトを並べたJSON配列です。各操作は配列に記述された順番で実行されます。

JSON PatchをHTTPで送信する場合、Content-Typeには次のメディアタイプを指定します。

Content-Type: application/json-patch+json

RFC 6902では、このapplication/json-patch+jsonがJSON Patch用のメディアタイプとして定義されています。

PUT・PATCH・JSON Patchの違い

まず、HTTPメソッドとJSON Patchの関係を整理しておきましょう。

PUT

PUTは、基本的にリソース全体を新しい内容で置き換えるために使います。

たとえば、現在のユーザー情報が次の内容だったとします。

{
  "id": 1001,
  "displayName": "山田太郎",
  "email": "old@example.com",
  "status": "ACTIVE"
}

メールアドレスだけを変更する場合でも、PUTではリソース全体を送信する設計が一般的です。

{
  "id": 1001,
  "displayName": "山田太郎",
  "email": "new@example.com",
  "status": "ACTIVE"
}

PATCH

PATCHは、リソースに対して「どのような変更を加えるか」を送信するHTTPメソッドです。

RFC 5789では、PUTはリソース全体の置き換え、PATCHは既存リソースに適用する変更命令として整理されています。

ただし、PATCH自体はリクエストボディの形式を定めていません。

そのため、次のようなパッチ形式を別途選択します。

  • JSON Patch
  • JSON Merge Patch
  • API独自の更新形式

JSON Patch

JSON Patchは、PATCHリクエストで送る変更命令の標準形式の一つです。

PATCH /users/1001 HTTP/1.1
Content-Type: application/json-patch+json
[
  {
    "op": "replace",
    "path": "/email",
    "value": "new@example.com"
  }
]

この例では、emailnew@example.comへ置き換えるようサーバーに指示しています。

JSON Patchの基本構造

JSON Patchは、複数の操作オブジェクトを格納したJSON配列です。

[
  {
    "op": "replace",
    "path": "/displayName",
    "value": "山田次郎"
  },
  {
    "op": "add",
    "path": "/roles/-",
    "value": "ADMIN"
  }
]

それぞれのフィールドには、次の意味があります。

フィールド説明
op実行する操作
path操作対象の場所
value追加・置換・比較に使用する値
from移動元またはコピー元

opには、次の6種類が定義されています。

操作内容
add値を追加する
remove値を削除する
replace値を置き換える
move値を別の場所へ移動する
copy値を別の場所へコピーする
test現在の値が指定値と一致するか確認する

RFC 6902では、これら6種類以外のopはエラーになります。また、すべての操作にpathが必要です。

pathで使われるJSON Pointerとは

JSON Patchのpathは、RFC 6901で定義されているJSON Pointerという形式で記述します。

次のJSONを例に考えてみます。

{
  "profile": {
    "displayName": "山田太郎"
  },
  "roles": [
    "USER",
    "EDITOR"
  ]
}

displayNameを指定する場合は、次のように記述します。

/profile/displayName

配列の2番目の要素を指定する場合は、インデックスを使います。

/roles/1

配列のインデックスは0から始まるため、/roles/1EDITORを示します。

JSON Pointerでは、/~が特殊文字として扱われます。JSONのプロパティ名にこれらが含まれる場合は、次のようにエスケープします。

文字JSON Pointerでの表記
~~0
/~1

たとえば、プロパティ名がspring/bootの場合は、次のように指定します。

/spring~1boot

JSON Pointerはルートから順番に値をたどり、対象となる値を特定します。

6種類の操作を実例で解説

add:値を追加する

addは、オブジェクトのプロパティや配列の要素を追加する操作です。

変更前のJSONが次の内容だったとします。

{
  "displayName": "山田太郎"
}

次のJSON Patchを適用します。

[
  {
    "op": "add",
    "path": "/email",
    "value": "taro@example.com"
  }
]

適用後は次のようになります。

{
  "displayName": "山田太郎",
  "email": "taro@example.com"
}

配列の末尾へ値を追加する場合は、-を使います。

[
  {
    "op": "add",
    "path": "/roles/-",
    "value": "ADMIN"
  }
]

addで既存のオブジェクトプロパティを指定した場合、その値は置き換えられます。一方、親となるオブジェクトや配列が存在しない場合はエラーです。

remove:値を削除する

removeは、指定した値を削除します。

[
  {
    "op": "remove",
    "path": "/email"
  }
]

配列要素を削除する場合は、インデックスを指定します。

[
  {
    "op": "remove",
    "path": "/roles/0"
  }
]

削除対象のパスは、操作実行時点で存在している必要があります。

配列から要素を削除すると、後ろの要素のインデックスが左へ詰められる点にも注意が必要です。

replace:値を置き換える

replaceは、既存の値を新しい値へ置き換えます。

[
  {
    "op": "replace",
    "path": "/displayName",
    "value": "山田次郎"
  }
]

addでも既存プロパティを上書きできますが、replaceでは対象のパスが存在しなければエラーになります。

そのため、既存項目の更新であることを明確にしたい場合は、replaceを使うと意図が伝わりやすくなります。

move:値を移動する

moveは、fromで指定した値をpathへ移動します。

[
  {
    "op": "move",
    "from": "/temporaryEmail",
    "path": "/email"
  }
]

この操作は、移動元を削除してから、同じ値を移動先へ追加する処理に相当します。

移動元のパスは存在している必要があります。また、親要素をその子要素の中へ移動することはできません。

copy:値をコピーする

copyは、fromで指定した値をpathへコピーします。

[
  {
    "op": "copy",
    "from": "/billingAddress",
    "path": "/shippingAddress"
  }
]

moveとは異なり、コピー元の値は削除されません。

コピー元のパスは、操作実行時点で存在している必要があります。

test:現在の値を確認する

testは、指定したパスの値がvalueと一致しているか確認します。

[
  {
    "op": "test",
    "path": "/status",
    "value": "ACTIVE"
  },
  {
    "op": "replace",
    "path": "/status",
    "value": "SUSPENDED"
  }
]

この例では、現在のstatusACTIVEの場合だけ、SUSPENDEDへ更新します。

testが失敗すると、それ以降の操作は実行されず、パッチ全体が失敗します。文字列と数値など、JSONの型が異なる値も一致とは判定されません。

複数の操作は上から順番に実行される

JSON Patchでは、配列内の操作が上から順番に適用されます。

たとえば、変更前のJSONが次の内容だったとします。

{
  "displayName": "山田太郎",
  "email": "old@example.com",
  "roles": [
    "USER"
  ]
}

次のパッチを適用します。

[
  {
    "op": "test",
    "path": "/email",
    "value": "old@example.com"
  },
  {
    "op": "replace",
    "path": "/email",
    "value": "new@example.com"
  },
  {
    "op": "add",
    "path": "/roles/-",
    "value": "ADMIN"
  }
]

適用後は次のようになります。

{
  "displayName": "山田太郎",
  "email": "new@example.com",
  "roles": [
    "USER",
    "ADMIN"
  ]
}

途中の操作が失敗した場合、パッチ全体は成功とみなされません。

HTTP PATCHとして使用する場合、サーバーは一連の変更を原子的に適用する必要があります。つまり、一部だけ更新された状態を残してはいけません。

JSON Patchは冪等なのか

HTTP PATCHは、仕様上、常に冪等なメソッドではありません。

ただし、送信する操作の内容によっては冪等にできます。

たとえば、次のreplaceは何度実行しても同じ結果になります。

[
  {
    "op": "replace",
    "path": "/status",
    "value": "ACTIVE"
  }
]

一方、次のように配列の末尾へ値を追加する操作は、再実行するたびに要素が増えます。

[
  {
    "op": "add",
    "path": "/roles/-",
    "value": "ADMIN"
  }
]

リトライの可能性があるAPIでは、同じパッチが複数回実行された場合の結果を考慮する必要があります。

ETagとIf-Matchで更新競合を防ぐ

JSON Patchでは、クライアントが取得した時点からリソースが変更されていると、意図しないデータを更新してしまう可能性があります。

その対策として利用できるのが、ETagとIf-Matchです。

PATCH /users/1001 HTTP/1.1
Content-Type: application/json-patch+json
If-Match: "user-1001-v5"
[
  {
    "op": "replace",
    "path": "/email",
    "value": "new@example.com"
  }
]

サーバー側のETagがすでに変わっている場合、更新を拒否します。

RFC 5789でも、競合による破損を防ぐ方法として、強いETagをIf-Matchヘッダーに設定する条件付きリクエストが推奨されています。

JSON Merge Patchとの違い

JSONを部分更新する別の仕様として、RFC 7396のJSON Merge Patchがあります。

JSON Merge Patchでは、更新後に近い形のJSONを送信します。

Content-Type: application/merge-patch+json
{
  "displayName": "山田次郎",
  "email": null
}

この場合、displayNameは置き換えられ、emailは削除されます。

JSON Merge Patchは記述がシンプルですが、nullを削除として扱うため、明示的にnullを保存したいデータには向きません。また、配列内の一部分だけを変更する操作も得意ではありません。

両者の違いを整理すると、次のようになります。

項目JSON PatchJSON Merge Patch
RFCRFC 6902RFC 7396
Content-Typeapplication/json-patch+jsonapplication/merge-patch+json
リクエスト形式操作の配列更新後に近いJSON
配列操作得意部分操作が難しい
値の移動・コピー可能不可
条件確認testで可能標準操作なし
記述量やや多い少ない

単純なオブジェクト更新であればJSON Merge Patch、配列操作や厳密な変更命令が必要であればJSON Patchが適しています。

Spring BootでJSON Patchを実装する

Spring Bootでは、JacksonとJSON Patchライブラリを組み合わせて実装できます。

Java向けの実装例として、com.github.java-json-tools:json-patchがあります。RFC 6902の6種類の操作とtestをサポートしており、Maven CentralとプロジェクトのREADMEではバージョン1.13が案内されています。

build.gradle

dependencies {
    implementation 'com.github.java-json-tools:json-patch:1.13'
}

Kotlin DSLの場合は次のようになります。

dependencies {
    implementation("com.github.java-json-tools:json-patch:1.13")
}

更新対象のDTO

public record UserPatchTarget(
        String displayName,
        String email
) {
}

Controllerの実装例

package com.example.api.user;

import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.github.fge.jsonpatch.JsonPatch;
import com.github.fge.jsonpatch.JsonPatchException;
import lombok.RequiredArgsConstructor;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.PatchMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.server.ResponseStatusException;

import java.io.IOException;
import java.util.Set;

import static org.springframework.http.HttpStatus.BAD_REQUEST;
import static org.springframework.http.HttpStatus.FORBIDDEN;

@RestController
@RequestMapping("/users")
@RequiredArgsConstructor
public class UserController {

    private static final Set<String> ALLOWED_PATHS = Set.of(
            "/displayName",
            "/email"
    );

    private final ObjectMapper objectMapper;
    private final UserService userService;

    @PatchMapping(
            value = "/{userId}",
            consumes = "application/json-patch+json"
    )
    public ResponseEntity<UserPatchTarget> patchUser(
            @PathVariable long userId,
            @RequestBody JsonNode patchDocument
    ) throws IOException, JsonPatchException {

        validatePatchDocument(patchDocument);

        UserPatchTarget current =
                userService.findPatchTarget(userId);

        JsonPatch jsonPatch =
                JsonPatch.fromJson(patchDocument);

        JsonNode currentJson =
                objectMapper.valueToTree(current);

        JsonNode patchedJson =
                jsonPatch.apply(currentJson);

        UserPatchTarget patched =
                objectMapper.treeToValue(
                        patchedJson,
                        UserPatchTarget.class
                );

        UserPatchTarget updated =
                userService.update(userId, patched);

        return ResponseEntity.ok(updated);
    }

    private void validatePatchDocument(JsonNode patchDocument) {
        if (!patchDocument.isArray()) {
            throw new ResponseStatusException(
                    BAD_REQUEST,
                    "JSON Patchは配列で指定してください"
            );
        }

        for (JsonNode operation : patchDocument) {
            String path = operation.path("path").asText();

            if (!ALLOWED_PATHS.contains(path)) {
                throw new ResponseStatusException(
                        FORBIDDEN,
                        "更新が許可されていないパスです: " + path
                );
            }

            if (operation.has("from")) {
                String from = operation.path("from").asText();

                if (!ALLOWED_PATHS.contains(from)) {
                    throw new ResponseStatusException(
                            FORBIDDEN,
                            "参照が許可されていないパスです: " + from
                    );
                }
            }
        }
    }
}

ライブラリでは、JsonPatch.fromJson()でJSONからパッチを生成し、apply()で対象のJsonNodeへ適用できます。

実際のアプリケーションでは、パッチ適用後のDTOに対してBean Validationなどを実行し、不正な状態になっていないか確認してください。

Entityへ直接パッチを適用しない

JSON PatchをJPA Entityへ直接適用すると、更新させたくない項目まで変更される危険があります。

たとえば、次のような項目です。

  • ユーザーID
  • 権限
  • 契約状態
  • 作成日時
  • 更新者
  • テナントID

クライアントから次のパッチが送られてきた場合を考えてみましょう。

[
  {
    "op": "replace",
    "path": "/role",
    "value": "ADMIN"
  }
]

サーバーがすべてのパスを無条件に受け入れていると、一般ユーザーが自分の権限を変更できてしまう可能性があります。

そのため、次の流れで実装するのが安全です。

JSON Patchを受信
    ↓
op・path・fromを検証
    ↓
更新専用DTOへパッチを適用
    ↓
Bean Validationを実行
    ↓
業務ルールを検証
    ↓
Entityへ反映
    ↓
DBを更新

特にpathfromは、更新可能な項目をホワイトリスト方式で制限するのがおすすめです。

エラー時のHTTPステータスコード

PATCHリクエストで使用する主なステータスコードは、次のように整理できます。

ステータス使用例
400 Bad RequestJSON Patchの構文が不正
404 Not Found更新対象のリソースが存在しない
409 Conflict現在のリソース状態と操作が競合
412 Precondition FailedIf-Matchなどの事前条件に失敗
415 Unsupported Media TypeJSON Patchをサポートしていない
422 Unprocessable Content構文は正しいが、適用後の内容が不正

RFC 5789では、不正なパッチ形式には400、未対応の形式には415、状態競合には409、条件付きリクエストの失敗には412などが示されています。

エラーレスポンスには、何番目の操作が失敗したのか、どのパスに問題があったのかを含めると、クライアント側で原因を特定しやすくなります。

実装時の注意点

更新可能なパスを制限する

受信したJSON Patchをそのまま適用せず、更新を許可するパスを明示します。

Set<String> allowedPaths = Set.of(
        "/displayName",
        "/email"
);

操作の個数を制限する

非常に大量の操作を含むパッチは、CPUやメモリを消費する可能性があります。

if (patchDocument.size() > 20) {
    throw new ResponseStatusException(
            BAD_REQUEST,
            "一度に指定できる操作は20件までです"
    );
}

配列のインデックス変更に注意する

配列操作は上から順番に実行されます。

[
  {
    "op": "remove",
    "path": "/items/0"
  },
  {
    "op": "remove",
    "path": "/items/1"
  }
]

最初の削除によって配列のインデックスが変わるため、2つ目の/items/1が指す要素も変わります。

複数の配列要素を削除する場合は、後ろのインデックスから削除するなどの工夫が必要です。

パッチ適用後にバリデーションする

JSONとして正しくても、業務的に正しいとは限りません。

たとえば、次のような更新はJSON Patchとしては正常でも、アプリケーションでは拒否すべき可能性があります。

[
  {
    "op": "replace",
    "path": "/email",
    "value": "not-an-email"
  }
]

JSON Patch適用後に、Bean Validationと業務ルールの両方を確認しましょう。

トランザクション内で更新する

JSON Patchは、途中まで適用して一部だけ保存する設計にしてはいけません。

パッチの検証、適用、業務チェック、DB更新を一つのトランザクションとして扱い、途中で失敗した場合はロールバックします。

まとめ

RFC 6902のJSON Patchは、JSONリソースに対する変更内容を標準形式で表現する仕様です。

JSON Patchでは、次の6種類の操作を利用できます。

  • add
  • remove
  • replace
  • move
  • copy
  • test

単純な項目変更だけでなく、配列への追加、値の移動、コピー、現在値の確認まで表現できる点が大きな特徴です。

一方で、更新可能なパスを制限せずに利用すると、権限や内部管理項目まで変更される危険があります。

実際のWeb APIでは、次の点を意識して実装しましょう。

  • Content-Typeにapplication/json-patch+jsonを指定する
  • 更新可能なpathfromを制限する
  • Entityではなく更新専用DTOへ適用する
  • 適用後にバリデーションする
  • ETagとIf-Matchで更新競合を防ぐ
  • 一連の更新をトランザクション内で処理する

変更内容を厳密に表現したいAPIや、配列を含むリソースを部分更新したい場合、JSON Patchは有力な選択肢になります。

是非フォローしてください

最新の情報をお伝えします

類似投稿