JSON Pointer, Patch і Merge Patch
Головне: Три короткі специфікації утворюють протокол зміни: Pointer локалізує, Patch редагує точно, Merge Patch зливає частково. Неправильний вибір боляче бʼє по масивах і конкурентності.

Коротко: використовуйте JSON Pointer для локалізації за позицією та JSONPath для фільтрації чи рекурсії; вставлення/видалення масивів, записи null і перевірки версій доручайте JSON Patch, а Merge Patch залишайте для пласких конфігурацій і ресурсів-форм.
Локалізація й зміна — різні речі, і специфікації розділили їх на три документи. Звична ціна їх змішування — «весь масив замінився», і ніхто цього не помічає.
1. JSON Pointer: найкоротший синтаксис локалізації
RFC 6901 робить рівно одне: вказує на вузол у документі рядком.
"" → весь документ
"/store" → член store під коренем
"/store/book" → на один рівень глибше
"/store/book/0/title" → елемент 0 масиву
Три правила:
- Порожній рядок вказує на сам кореневий вузол, а не на «порожній вузол»;
- Кожен
/розділяє один токен посилання; масиви використовують десяткові індекси (провідні нулі не дозволені, тож01невалідний); - Усередині токена
~пишеться як~0, а/— як~1— замініть~перед/, інакше отримаєте подвійне екранування.
/**
* Escapes a single reference token into a JSON Pointer segment.
* @param token Original key name or index
* @returns Escaped token
*/
function escapePointerToken(token: string): string {
return token.replace(/~/g, '~0').replace(/\//g, '~1');
}
/**
* Joins an array of key names into a JSON Pointer.
* @param tokens Array of key names or indices
* @returns Pointer string beginning with /
*/
function buildPointer(tokens: Array<string | number>): string {
return tokens.map((token) => `/${escapePointerToken(String(token))}`).join('');
}
Цінність найкраще видно, коли сам ключ містить /: покажчик для {"a/b": 1} — це /a~1b, а не /a/b (останнє означає b під a).
Використовуючи його в URL, треба ще й процентне кодування, бо # і ~ мають спеціальне значення у фрагменті.
2. JSON Patch: атомарна послідовність шести операцій
RFC 6902 описує зміни масивом операцій, медіатип application/json-patch+json:
[
{ "op": "test", "path": "/version", "value": 7 },
{ "op": "replace", "path": "/name", "value": "新的名字" },
{ "op": "add", "path": "/tags/-", "value": "json" },
{ "op": "remove", "path": "/temp" },
{ "op": "copy", "from": "/name", "path": "/alias" },
{ "op": "move", "from": "/tags/0", "path": "/tags/1" }
]
| Операція | Семантика | Межа |
|---|---|---|
add |
Додає член або вставляє елемент масиву | Індекс масиву може дорівнювати довжині (додавання в кінець); - означає кінець |
remove |
Видаляє | Шлях має існувати |
replace |
Замінює наявне значення | Шлях має існувати (рівноцінно remove + add, але вимагає існування шляху) |
move |
Переміщує | Не можна перемістити вузол у власний підшлях |
copy |
Копіює | Джерело має існувати |
test |
Перевіряє рівність | Якщо збоїть, весь патч не застосовується; використовується для оптимістичного блокування |
Ключова властивість — атомарність: якщо будь-яка операція зазнає збою, весь патч відкочується, тож немає проміжного стану «перші три застосовано, четверта збоїла».
Це робить його чудовим тілом запиту PATCH.
2.1 Використання test для оптимістичної конкурентності
[
{ "op": "test", "path": "/etag", "value": "a3f9c1" },
{ "op": "replace", "path": "/status", "value": "paid" }
]
Клієнт спершу GET-ить etag і записує його в операцію test. За конкурентних записів test другого запиту зазнає збою, і сервер повертає
409 Conflict або 412 Precondition Failed. Це набагато безпечніше за «перемагає останній запис» і не потребує розподіленого блокування.
2.2 Операції з масивами найсхильніші до помилок
// Хочемо вставити за індексом 1
{ "op": "add", "path": "/items/1", "value": {...} } // Правильно: вставка; оригінальний індекс 1 і все після зсувається назад
// Хочемо замінити індекс 1
{ "op": "replace", "path": "/items/1", "value": {...} } // Правильно: заміна
add за індексом масиву n — це вставка, а не перезапис; індекс поза діапазоном (n > length) має спричиняти помилку.
Деякі реалізації поблажливі до цього, тож той самий патч поводиться по-різному на різних серверах.
3. Merge Patch: фрагмент, що виглядає як ціль
RFC 7386 має лише чотири правила:
- Ціль — обʼєкт, і патч — обʼєкт → зливати рекурсивно;
- Значення ключа в патчі —
null→ видалити той ключ із цілі; - Значення ключа в патчі — не обʼєкт → замінити безпосередньо;
- Сам патч — не обʼєкт → замінити всю ціль.
// Ціль
{ "a": "b", "c": { "d": "e", "f": "g" } }
// Патч
{ "a": "z", "c": { "f": null } }
// Результат
{ "a": "z", "c": { "d": "e" } }
Медіатип — application/merge-patch+json. Воно читається як «часткове оновлення», що робить його особливо легким для неправильного використання:
- Масиви можна лише замінювати цілком. Хочете видалити
list[2]? Merge Patch цього не може; - Не можна записати
null.nullуже зайнятий значенням видалення; - Воно не може виразити третій стан між «залишити поле недоторканим» і «видалити поле» (наприклад, явно встановити його порожнім).
4. Матриця можливостей: що обрати
| Вимога | JSON Patch | Merge Patch |
|---|---|---|
| Точно замінити конкретний елемент масиву | ✓ | ✗ |
| Видалити один елемент із масиву | ✓ | ✗ |
| Частково оновити кілька полів | ✓ (кілька replace) | ✓ (найстисліше) |
Записати значення null |
✓ | ✗ |
| Контроль конкурентності (перевірка версії) | ✓ (test) | ✗ (лише через If-Match) |
| Читабельність патча | Помірна | Добра (виглядає як самі дані) |
| Складність реалізації | Висока | Низька |
| Придатні форми ресурсів | Глибоко вкладені, спископодібні | Пласка конфігурація, форми |
Практичні правила:
- Форми, сторінки налаштувань, ресурси конфігурації → Merge Patch; фронтенд надсилає лише змінені поля;
- Додавання й видалення зі списків, переходи станів скінченного автомата, усе, що потребує захисту конкурентності → JSON Patch;
- Обидва можуть співіснувати: різні кінцеві точки оголошують різні
Content-Type, а сервер диспетчеризує за типом.
5. Нотатки щодо реалізації
import { applyPatch, compare } from 'fast-json-patch';
const document = { version: 7, items: [{ id: 1 }, { id: 2 }] };
const patch = compare(document, { version: 8, items: [{ id: 1 }] });
// Операції test перевіряються за замовчуванням; встановлення mutateDocument у true змінює на місці, використовуйте обережно
const result = applyPatch(document, patch, true, false);
console.log(result.newDocument);
Чотири легко проґавлені пункти:
- Не змінюйте спільні обʼєкти на місці; збійний патч залишає напівзмінений стан (якщо реалізація не гарантує відкат);
- Перевірка шляхів: патчі ззовні мають перевіряти кожен
path/fromі відхиляти ключі на кшталт__proto__іconstructor; - Обмеження розміру: обмежте кількість записів патча, щоб один патч не виконував сотні тисяч операцій;
- Аудит: патчі природно підходять для журналів аудиту — вони є повним описом «що змінилося».
6. Семантика помилок
| Ситуація | Пропонований код статусу | Пояснення |
|---|---|---|
| Патч не є масивом / структурно невалідний | 400 Bad Request |
Формат тіла запиту неправильний |
Вузол, на який вказує path, не існує |
422 Unprocessable Entity |
Синтаксично валідний, але семантично невиконуваний |
Операція test зазнала збою |
409 або 412 |
Указує на конфлікт версій |
| Немає дозволу змінювати той шлях | 403 Forbidden |
Не маскуйте це через 404, якщо тільки не хочете навмисно приховати існування ресурсу |
Розрізнення 400 і 422 важливе: перше означає «я не можу зрозуміти ваш патч», друге — «я розумію його, але ці дані не дозволяють такої зміни».
7. Чек-лист
- [ ] Кінцева точка оголошує правильний
Content-Type(json-patch+json/merge-patch+json); - [ ] Застосування патча атомарне, і збій не залишає часткового результату;
- [ ] Зовнішні патчі перевіряються за білим списком шляхів і обмеженням кількості записів;
- [ ] Контроль конкурентності використовує
testабоIf-Match; - [ ] Патчі записуються в журнал аудиту;
- [ ] Для сценаріїв із масивами явно обрано JSON Patch; не розраховуйте на Merge Patch.
Часті питання
Як вирішити між JSON Patch і Merge Patch?
Використовуйте JSON Patch, коли треба замінити чи видалити конкретний елемент масиву, записати значення null або перевірити версію; використовуйте Merge Patch для форм і ресурсів конфігурації, що оновлюють лише кілька полів. Merge Patch не може видалити один елемент масиву й не може записати null, бо null уже означає видалення.
Чому add за індексом масиву n — не перезапис?
Бо add вставляє в масив: елемент за індексом n і все після нього зсуваються назад. Щоб замінити наявний елемент, треба використати replace, і шлях має спершу існувати. Індекс поза діапазоном (n > length) має спричиняти помилку; поблажливі реалізації роблять той самий патч різним на різних серверах.
Чи змінювати оригінальний обʼєкт на місці?
Ні. Збійний патч залишає напівзмінений стан, якщо реалізація не гарантує відкат. Застосуйте патч до глибоко копійованої або структурно клонованої копії, потім замініть посилання після успіху.