После перехода на частичное обновление пользовательских настроек типичная проблема выглядит так: запрос проходит успешно, но в документе остаются старые поля. Клиент считает null указанием пропустить поле, а сервер трактует его как команду удаления. Набор контрактных тестов на Swift, добавленный в сборочную задачу на облачном Mac, позволяет проверить не только успешную компиляцию, но и то, что обе стороны одинаково понимают содержимое обновления. Здесь рассматривается только преобразование документов по правилам JSON Merge Patch — без обращений к рабочему API и без симулятора.
Закрепляем четыре правила протокола обновления
Правила JSON Merge Patch определены в RFC 7396. Патч-объект обрабатывается по полям: отсутствующие в нём поля сохраняются, поля со значением null удаляются, остальные значения применяются рекурсивно. Если патч не является объектом, он целиком заменяет целевое значение. Поэтому массивы не объединяются по индексам.
Сначала явно зафиксируйте, что интерфейс использует application/merge-patch+json. Определять протокол только по HTTP-методу нельзя. Обычный JSON-запрос, даже отправленный методом PATCH, может подчиняться совершенно другим бизнес-правилам.
В поле объекта
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, а не произвольные объекты приложения. Не помещайте даты или двоичные данные непосредственно в словарь в расчёте на то, что сериализация сама приведёт типы к нужному виду.
Проверяем удаление и замену на фиксированных тестовых векторах
Добавьте следующие проверки в тот же файл. Каждый пример проверяет одно основное правило, а имя провалившейся проверки позволяет сразу сопоставить ошибку с пунктом протокола при ревью кода.
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")
Перед сравнением сортируются только ключи объектов; порядок элементов массивов не меняется. Сравнение сериализованных данных подходит для этих фиксированных примеров, но не является универсальным способом нормализации чисел и не должно использоваться для вычисления подписей между реализациями на разных языках.
Если клиент представляет поле обычным опциональным значением, при кодировании легко свести и «удалить поле», и «не обновлять поле» к одному результату — отсутствию поля в запросе. На уровне формирования запроса нужно явно различать три состояния: оставить без изменений, удалить и присвоить значение. Сформированный 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 {
}
Ещё один случай, который легко упустить, — некорректный JSON. Ошибка разбора должна прекращать обработку запроса. Нельзя перехватывать её и продолжать слияние с пустым словарём: иначе повреждённые входные данные могут быть приняты за допустимое обновление. Правила приёма повторяющихся ключей также нужно отдельно определить на входе, а не рассчитывать на то, что разные парсеры случайно сохранят одно и то же значение.
Подключаем проверку к CI на облачном Mac
Сначала убедитесь, что текущий набор инструментов доступен, затем выполните команды из корня репозитория:
set -eu
xcrun --find swift
xcrun swift ci/merge-patch-check.swift
Скрипт не использует проверки assert, которые могут отключаться оптимизатором: любое несовпадение приводит к выбросу ошибки и ненулевому коду завершения. Запускайте его перед длительной сборкой приложения. Достаточно сохранять имена тестовых примеров и код завершения — записывать реальные пользовательские настройки в журнал не нужно.
При внедрении организуйте проверки на трёх уровнях: ядро прогоняет фиксированные тестовые векторы, слой формирования запросов проверяет фактическое кодирование, а интеграционные тесты API сверяют документ после применения обновления сервером. Кеш мобильного приложения также следует проверять на том же наборе примеров, чтобы интерфейс не выполнял сначала оптимистическое обновление по неверным правилам, а затем не перезаписывал его серверным результатом. На итоговом ревью важно оценивать смысл обновления каждого поля, а не только то, завершился ли запрос успешно.
Часто задаваемые вопросы
Можно ли присвоить полю объекта значение null?
В объекте патча null означает удаление поля. Чтобы сохранить поле со значением null, нужен другой протокол или отдельная явная операция. При этом корневой патч null заменяет весь документ на null.
Объединяются ли элементы массива по индексам?
Нет. Массив из патча целиком заменяет существующий массив, даже если новый массив пуст. Обновление по идентификаторам элементов следует оформить отдельной бизнес-операцией.
Выделенный Mac для сборок вашей команды
Выберите выделенный физический Mac mini на Apple Silicon M4 и используйте собственный набор инструментов и настройки runner. Оплата доступна за день, неделю, месяц или квартал. Актуальная доступность отображается в панели управления в реальном времени.
Арендовать узел для сборки