JSON Patch test: как не заменить цену после чужого изменения
Практический guide по RFC 6902: ставим test перед replace, воспроизводим свежую и устаревшую цену, проверяем атомарный отказ и различия типов JSON на CPython 3.12.

В этой статье
- Входные условия
- Минимальный запрос: проверить 1000, затем записать 900
- Что именно сравнивает test
- Воспроизводимая учебная модель
- Наблюдаемый результат
- Почему поздняя ошибка не оставляет половину изменения
- Какой HTTP-статус ждать
- test и If-Match решают разные задачи
- Как встроить проверку в интеграцию магазина
- Приёмка
- Границы примера
- Input conditions
- Minimal request: check 1000, then write 900
- What exactly does test compare?
- Reproducible educational model
- Observed result
- Why a late error leaves no half-change
- Which HTTP status to expect
- test and If-Match solve different tasks
- How to integrate a check into a store integration
- Acceptance testing
- Example boundaries
Если API принимает JSON Patch, поставьте операцию test перед replace. Клиент сообщает не только новую цену, но и значение, которое он видел. Если цена уже изменилась, test завершается ошибкой, а весь PATCH не применяется. Так устаревший процесс не затирает более свежее изменение.
Этот приём работает только при явно заявленной поддержке media type application/json-patch+json. Он не превращает любой PATCH в JSON Patch и не доказывает наличие такого формата в конкретном методе 1С-Битрикс: Управление сайтом или внешнего сервиса.
Входные условия
- В контракте API указан application/json-patch+json.
- Клиент знает ожидаемое текущее значение поля.
- Сервер применяет документ JSON Patch целиком, а не по частям.
- Для изменения всей версии ресурса отдельно рассмотрен HTTP-заголовок If-Match.
- Тест выполняется на учебном объекте без сети, рабочего магазина и персональных данных.
Минимальный запрос: проверить 1000, затем записать 900
Пусть интеграция прочитала товар SKU-42 с ценой 1000 и остатком 5. Перед отправкой другая система могла изменить цену — это и нужно обнаружить.
{"sku":"SKU-42","price":1000,"stock":5}
Документ JSON Patch содержит две операции. Сначала test требует, чтобы /price всё ещё равнялся 1000. Только после успешной проверки выполняется replace.
[{"op":"test","path":"/price","value":1000},{"op":"replace","path":"/price","value":900}]
Что именно сравнивает test
Правила сравнения задаёт RFC 6902, раздел test. Типы JSON должны совпасть; исключение — числа, которые сравниваются по числовому значению. Порядок элементов массива важен, а порядок свойств объекта — нет.
Фактическое значение | Ожидаемое значение | Результат | Причина |
|---|---|---|---|
1000 | 1000.0 | успех | оба значения — числа и численно равны |
1000 | значение типа string «1000» | отказ | число и строка — разные типы |
true | 1 | отказ | boolean и number — разные типы |
объект a=1, b=2 | объект b=2, a=1 | успех | порядок свойств объекта не важен |
массив north, south | массив south, north | отказ | порядок массива значим |
Для Python здесь есть подвох: True == 1 возвращает истину, хотя в JSON это boolean и number. Поэтому учебная модель сначала сравнивает типы JSON и лишь затем значения.
Воспроизводимая учебная модель
Проверка выполнена 20 октября 2026 года на CPython 3.12.14 только со стандартной библиотекой. Модель поддерживает test и replace для одного ключа объекта — ровно тот объём, который нужен для проверки механизма. Это не универсальная библиотека JSON Patch.
from copy import deepcopy
from typing import Any
class PatchRejected(Exception):
pass
def json_type(value: Any) -> str:
if value is None:
return "null"
if isinstance(value, bool):
return "boolean"
if isinstance(value, (int, float)):
return "number"
if isinstance(value, str):
return "string"
if isinstance(value, list):
return "array"
if isinstance(value, dict):
return "object"
raise TypeError("not a JSON value")
def json_equal(left: Any, right: Any) -> bool:
if json_type(left) != json_type(right):
return False
if isinstance(left, list):
return len(left) == len(right) and all(
json_equal(a, b) for a, b in zip(left, right)
)
if isinstance(left, dict):
return left.keys() == right.keys() and all(
json_equal(left[key], right[key]) for key in left
)
return left == right
def read_key(document: dict, path: str) -> Any:
if not path.startswith("/") or "/" in path[1:]:
raise PatchRejected("teaching model supports one object key")
key = path[1:].replace("~1", "/").replace("~0", "~")
if key not in document:
raise PatchRejected(f"path not found: {path}")
return document[key]
def apply_patch(document: dict, operations: list[dict]) -> dict:
candidate = deepcopy(document)
for operation in operations:
op = operation["op"]
path = operation["path"]
if op == "test":
if not json_equal(read_key(candidate, path), operation["value"]):
raise PatchRejected(f"test failed at {path}")
elif op == "replace":
read_key(candidate, path)
key = path[1:].replace("~1", "/").replace("~0", "~")
candidate[key] = deepcopy(operation["value"])
else:
raise PatchRejected(f"unsupported operation: {op}")
return candidate
Запускаем три показательных сценария: свежая цена, устаревшая цена и поздний отказ после уже выполненного во временной копии replace.
document = {"sku": "SKU-42", "price": 1000, "stock": 5}
patch = [
{"op": "test", "path": "/price", "value": 1000},
{"op": "replace", "path": "/price", "value": 900},
]
print(apply_patch(document, patch))
stale = {"sku": "SKU-42", "price": 1050, "stock": 5}
try:
apply_patch(stale, patch)
except PatchRejected as error:
print(f"stale_price: reject: {error}; unchanged={stale['price'] == 1050}")
late_failure = [
{"op": "replace", "path": "/price", "value": 900},
{"op": "test", "path": "/stock", "value": 99},
]
try:
apply_patch(document, late_failure)
except PatchRejected as error:
print(f"atomic_failure: reject: {error}; unchanged={document['price'] == 1000}")
Наблюдаемый результат
{'sku': 'SKU-42', 'price': 900, 'stock': 5}
stale_price: reject: test failed at /price; unchanged=True
atomic_failure: reject: test failed at /stock; unchanged=True
Процесс завершился с кодом 0. В полном прогоне из восьми сценариев дополнительно подтверждены: 1000 равно 1000.0; строка 1000 не равна числу 1000; true не равно 1; перестановка свойств объекта не меняет равенство; перестановка элементов массива меняет.

Почему поздняя ошибка не оставляет половину изменения
JSON Patch прекращает обработку при неуспешной операции. Вместе с требованием атомарности HTTP PATCH это означает: если весь patch-документ не может быть применён, сервер не должен сохранять предыдущие операции из него. Это следует из RFC 6902 и RFC 5789.
В модели изменения выполняются в глубокой копии. Только успешный проход возвращает новый документ. Если test для stock ожидает 99 при фактическом 5, ранее выполненный replace цены остаётся внутри отброшенной копии; исходная цена сохраняется равной 1000.
Какой HTTP-статус ждать
Нельзя считать, что неуспешный test всегда означает один и тот же HTTP-код. Контракт конкретного API должен определить ответ и тело ошибки. RFC 5789 приводит 409 Conflict для конфликта состояния без отдельного HTTP-условия и 412 Precondition Failed для отказа явно переданного HTTP-условия, например If-Match.
Механизм | Где находится условие | Ориентир по отказу |
|---|---|---|
JSON Patch test | в теле patch-документа | контракт API; конфликт состояния может быть 409 |
If-Match | в HTTP-заголовке | 412 при несоответствии условия |
Неподдерживаемый media type | Content-Type запроса | 415, если формат не поддерживается |
test и If-Match решают разные задачи
Операция test проверяет конкретное значение внутри JSON-документа. If-Match привязывает изменение к версии всего представления ресурса по ETag. Подробный воспроизводимый пример есть в материале ETag и If-Match: как не затереть складской остаток при двух обновлениях API.
Критерий | JSON Patch test | If-Match |
|---|---|---|
Область проверки | одно или несколько значений JSON | версия представления ресурса |
Где передаётся | тело application/json-patch+json | HTTP-заголовок |
Когда полезен | условие на конкретное поле | отказ после любой смены версии |
Можно ли сочетать | да | да |
Если контракт позволяет оба механизма, If-Match защищает версию ресурса на уровне HTTP, а test документирует условие бизнес-операции внутри patch-документа. Один механизм не стоит молча выдавать за другой.
Как встроить проверку в интеграцию магазина
- 1. Зафиксируйте точный endpoint, версию API, права и поддерживаемый media type.
- 2. Прочитайте ресурс и сохраните ожидаемое значение поля; при наличии ETag сохраните и его.
- 3. Соберите patch-документ: test перед соответствующим replace.
- 4. Отправьте Content-Type application/json-patch+json; If-Match добавляйте только по договорённому контракту.
- 5. При отказе не повторяйте тот же replace вслепую: заново прочитайте ресурс и разберите конфликт.
- 6. После успеха прочитайте ресурс или используйте документированный ответ, чтобы подтвердить итоговое значение.
Приёмка
- Свежая цена 1000: test проходит, итоговая цена 900.
- Фактическая цена 1050: test отклоняется, цена остаётся 1050.
- Поздний test складского остатка не проходит: ранее указанный replace цены не сохраняется.
- Число 1000 и строка 1000 различаются.
- true не принимается за число 1.
- Одинаковые объекты с иным порядком свойств проходят test.
- Массив с другим порядком элементов не проходит test.
- Неизвестная операция и отсутствующий путь завершают patch ошибкой.
- Журнал не содержит токенов, cookies, персональных данных и полного ответа внешней системы.
Границы примера
Учебный код не выполняет HTTP-запрос, не проверяет аутентификацию, TLS, лимит размера тела и конкретную реализацию сервера. В нём нет add, remove, move и copy, вложенных указателей и массивных индексов. Для рабочего проекта берите библиотеку и сервер с подтверждённой поддержкой RFC 6902, а этот пример используйте как набор проверок для test, типов JSON и атомарного отказа.
Критерий готовности простой: устаревшее ожидаемое значение делает конфликт видимым, ни одна предыдущая операция того же patch-документа не остаётся применённой, а интеграция перечитывает ресурс вместо слепого повтора.
If the API accepts JSON Patch, place a test operation before replace. The client reports not only the new price but also the value it saw. If the price has already changed, the test fails with an error, and the entire PATCH is not applied. This prevents a stale process from overwriting a more recent change.
This technique works only when the media type application/json-patch+json is explicitly declared. It does not convert any PATCH into a JSON Patch, nor does it prove the existence of such a format in a specific 1C-Bitrix: Site Management method or an external service.
Input conditions
- The API contract specifies application/json-patch+json.
- The client knows the expected current value of the field.
- The server applies the JSON Patch document in its entirety, not in parts.
- For changing the entire resource version, the HTTP If-Match header is considered separately.
- The test runs on a training object without network access, a live store, or personal data.
Minimal request: check 1000, then write 900
Suppose the integration read product SKU-42 with a price of 1000 and a stock of 5. Before sending, another system might have changed the price—that is exactly what needs to be detected.
{"sku":"SKU-42","price":1000,"stock":5}
The JSON Patch document contains two operations. First, the test operation requires that /price still equals 1000. Only after a successful check is the replace operation executed.
[{"op":"test","path":"/price","value":1000},{"op":"replace","path":"/price","value":900}]
What exactly does test compare?
Comparison rules are defined by RFC 6902, section test. JSON types must match; the exception is numbers, which are compared by numeric value. The order of array elements matters, but the order of object properties does not.
Actual value | Expected value | Result | Reason |
|---|---|---|---|
1000 | 1000.0 | success | both values are numbers and numerically equal |
1000 | string value "1000" | failure | number and string are different types |
true | 1 | failure | boolean and number are different types |
object a=1, b=2 | object b=2, a=1 | success | the order of object properties does not matter |
array north, south | south, north array | failure | array order matters |
There is a trap in Python here: True == 1 returns true, even though in JSON these are distinct boolean and number types. Therefore, the educational model first compares JSON types and only then compares values.
Reproducible educational model
The check was performed on October 20, 2026, on CPython 3.12.14 using only the standard library. The model supports test and replace for a single object key—exactly the scope needed to verify the mechanism. This is not a universal JSON Patch library.
from copy import deepcopy
from typing import Any
class PatchRejected(Exception):
pass
def json_type(value: Any) -> str:
if value is None:
return "null"
if isinstance(value, bool):
return "boolean"
if isinstance(value, (int, float)):
return "number"
if isinstance(value, str):
return "string"
if isinstance(value, list):
return "array"
if isinstance(value, dict):
return "object"
raise TypeError("not a JSON value")
def json_equal(left: Any, right: Any) -> bool:
if json_type(left) != json_type(right):
return False
if isinstance(left, list):
return len(left) == len(right) and all(
json_equal(a, b) for a, b in zip(left, right)
)
if isinstance(left, dict):
return left.keys() == right.keys() and all(
json_equal(left[key], right[key]) for key in left
)
return left == right
def read_key(document: dict, path: str) -> Any:
if not path.startswith("/") or "/" in path[1:]:
raise PatchRejected("teaching model supports one object key")
key = path[1:].replace("~1", "/").replace("~0", "~")
if key not in document:
raise PatchRejected(f"path not found: {path}")
return document[key]
def apply_patch(document: dict, operations: list[dict]) -> dict:
candidate = deepcopy(document)
for operation in operations:
op = operation["op"]
path = operation["path"]
if op == "test":
if not json_equal(read_key(candidate, path), operation["value"]):
raise PatchRejected(f"test failed at {path}")
elif op == "replace":
read_key(candidate, path)
key = path[1:].replace("~1", "/").replace("~0", "~")
candidate[key] = deepcopy(operation["value"])
else:
raise PatchRejected(f"unsupported operation: {op}")
return candidate
We run three illustrative scenarios: a fresh price, an outdated price, and a late failure after a replace has already been executed in a temporary copy.
document = {"sku": "SKU-42", "price": 1000, "stock": 5}
patch = [
{"op": "test", "path": "/price", "value": 1000},
{"op": "replace", "path": "/price", "value": 900},
]
print(apply_patch(document, patch))
stale = {"sku": "SKU-42", "price": 1050, "stock": 5}
try:
apply_patch(stale, patch)
except PatchRejected as error:
print(f"stale_price: reject: {error}; unchanged={stale['price'] == 1050}")
late_failure = [
{"op": "replace", "path": "/price", "value": 900},
{"op": "test", "path": "/stock", "value": 99},
]
try:
apply_patch(document, late_failure)
except PatchRejected as error:
print(f"atomic_failure: reject: {error}; unchanged={document['price'] == 1000}")
Observed result
{'sku': 'SKU-42', 'price': 900, 'stock': 5}
stale_price: reject: test failed at /price; unchanged=True
atomic_failure: reject: test failed at /stock; unchanged=True
The process completed with code 0. In a full run of eight scenarios, the following were additionally confirmed: 1000 equals 1000.0; string 1000 does not equal number 1000; true does not equal 1; reordering object properties does not change equality; reordering array elements changes equality.

Why a late error leaves no half-change
JSON Patch stops processing upon a failed operation. Combined with the atomicity requirement of HTTP PATCH, this means: if the entire patch document cannot be applied, the server must not persist any preceding operations from it. This follows from RFC 6902 and RFC 5789.
In the model, changes are executed on a deep copy. Only a successful pass returns the new document. If test for stock expects 99 but the actual value is 5, the previously executed price replace remains inside the discarded copy; the original price remains equal to 1000.
Which HTTP status to expect
Do not assume that an unsuccessful test always results in the same HTTP code. The specific API contract must define the response and error body. RFC 5789 specifies 409 Conflict for a state conflict without a separate HTTP condition and 412 Precondition Failed for rejection of an explicitly passed HTTP condition, such as If-Match.
Mechanism | Where the condition is located | Failure reference |
|---|---|---|
JSON Patch test | in the body of the patch document | API contract; a state conflict may result in 409 |
If-Match | in the HTTP header | 412 if the condition does not match |
Unsupported media type | Content-Type of the request | 415 if the format is not supported |
test and If-Match solve different tasks
The test operation checks a specific value within a JSON document. If-Match binds the change to the version of the entire resource representation via ETag. A detailed, reproducible example is available in the article ETag and If-Match: how not to overwrite stock levels during two API updates.
Criterion | JSON Patch test | If-Match |
|---|---|---|
Check scope | one or more JSON values | resource representation version |
Where it is passed | body of application/json-patch+json | HTTP header |
When useful | condition on a specific field | failure after any version change |
Can they be combined | yes | yes |
If the contract allows both mechanisms, If-Match protects the resource version at the HTTP level, while test documents the business operation condition inside the patch document. One mechanism must not be silently passed off as the other.
How to integrate a check into a store integration
- 1. Record the exact endpoint, API version, permissions, and supported media type.
- 2. Read the resource and save the expected field value; if an ETag is present, save that as well.
- 3. Assemble the patch document: place test before the corresponding replace.
- 4. Send Content-Type application/json-patch+json; add If-Match only per the agreed contract.
- 5. On failure, do not blindly repeat the same replace: re-read the resource and resolve the conflict.
- 6. On success, read the resource or use the documented response to confirm the final value.
Acceptance testing
- Fresh price 1000: test passes, final price 900.
- Actual price 1050: test fails, price remains 1050.
- Late warehouse stock test fails: the previously specified price replace is not saved.
- The number 1000 and the string 1000 are different.
- true is not accepted as the number 1.
- Identical objects with different property order pass test.
- An array with a different element order fails test.
- An unknown operation or a missing path ends the patch with an error.
- The log contains no tokens, cookies, personal data, or the full response from the external system.
Example boundaries
The test code does not perform HTTP requests, does not check authentication, TLS, body size limits, or specific server implementations. It lacks add, remove, move, and copy operations, nested pointers, and array indices. For a production project, use a library and server with verified RFC 6902 support, and use this example as a set of checks for test, JSON types, and atomic failure.
The readiness criterion is simple: an outdated expected value makes the conflict visible, no previous operation of the same patch document remains applied, and the integration re-reads the resource instead of blindly repeating.





Обсуждение 0
Делись опытом и задавай вопросы. Комментарии без ссылок появляются после проверки редактором.
Пока никто не написал. Начни обсуждение.