Условное изменение ресурса
Условный GET помог проверить сохранённую копию. При редактировании возникает другая задача: два человека могут открыть один курс, затем один сохранит новую версию, а другой отправит старые значения поверх неё. Идемпотентность PUT не предотвращает такую потерю обновления, поскольку полный документ второго редактора всё равно задаёт устаревшее состояние.
В этом уроке добавим обязательное условие If-Match для изменения единичного курса. Результат — сервис применяет документ только к ожидаемому сильному валидатору, а конфликтующий редактор получает отказ и возможность согласовать данные. Сценарий описывает требуемую атомарную работу; хранилище, сервер и проверки исполнения здесь не запускались.
Два редактора читают одну версию
Редакторы A и B независимо получают JSON c001 с title «Современный JavaScript», topic frontend и lessons 40. Оба ответа имеют сильный ETag "c001-json-r1". Клиенты сохраняют метку вместе с исходными значениями формы. Она обозначает прочитанное выбранное JSON-представление, а не произвольный номер записи из интерфейса.
Редактор A меняет только заголовок и отправляет полный документ с условием:
PUT /api/courses/c001 HTTP/1.1
Host: catalog.example
Content-Type: application/json
Accept: application/json, application/problem+json
If-Match: "c001-json-r1"
{"title":"JavaScript для веба","topic":"frontend","lessons":40}
Текущее JSON-представление ещё соответствует r1. Сервис проверяет условие и сохраняет новый документ. Ожидаемый успех — 200 с актуальным представлением. Наш ответ PUT не включает новый ETag: сервер нормализует вход и формирует представление чтения с дополнительным id, поэтому не заявляет сохранение входных байтов без преобразования.
HTTP/1.1 200 OK
Content-Type: application/json
Cache-Control: no-store
{"id":"c001","title":"JavaScript для веба","topic":"frontend","lessons":40}
Такое отсутствие метки не является недописанным примером. RFC 9110 ограничивает валидаторы успешного ответа PUT при преобразовании входного представления. Новый сильный ETag "c001-json-r2" редактор получит последующим GET выбранного JSON. Правила валидаторов ответа PUT.
Устаревшее условие отказывает
Редактор B ещё видит прежний заголовок, но решил изменить lessons на 41. Его полный документ содержит старое title и прежний If-Match r1:
PUT /api/courses/c001 HTTP/1.1
Host: catalog.example
Content-Type: application/json
Accept: application/json, application/problem+json
If-Match: "c001-json-r1"
{"title":"Современный JavaScript","topic":"frontend","lessons":41}
Текущее выбранное представление уже r2. Условие не совпадает, и сервис не применяет документ. Возвращается 412 Precondition Failed с описанием условия. В каталоге остаётся «JavaScript для веба» и 40 уроков: работа A не потеряна, а работа B ещё не сохранена.
{"type":"https://catalog.example/problems/version-conflict","title":"Версия курса изменилась","status":412,"detail":"Получите текущий курс и согласуйте своё изменение."}
Нельзя автоматически заменить r1 на r2 и повторить прежнее тело. Тогда механизм защиты превратится в формальность, а старое title всё равно перезапишет новое. Клиент должен получить текущее представление, сохранить локальное намерение B и показать различия. Разрешение конфликта требует осознанного выбора значений, а не только свежего заголовка.
Для нашего случая B принимает новый title A и сохраняет собственное lessons 41. Он отправляет полный согласованный документ с If-Match r2. Если между чтением и отправкой никто больше не изменил запись, операция принимается. Следующий GET показывает новое title и 41 урок с новой JSON-меткой r3. При ещё одном конкуренте цикл отказа повторится, что является правильным результатом.
Проверка и сохранение едины
Сервер не может проверить ETag, отпустить защиту состояния, а потом отдельно записать документ. Между двумя действиями другой обработчик успеет изменить ресурс, и обе попытки окажутся «прошедшими проверку». Условие должно оцениваться в той же атомарной границе, в которой принимается обновление.
Реализация может использовать транзакцию с подходящим ограничением или условное обновление по версии и числу изменённых строк. Это зависит от выбранного хранилища. Урок не даёт универсальную SQL-команду для всех баз; требование конкретно: из двух операций на r1 максимум одна изменяет выбранное состояние, а следующая видит несовпадение.
Сильный валидатор должен соответствовать реальному выбранному представлению. Если генерация JSON способна менять байты без изменения внутренней версии строки, нужно включить это в модель ETag. Наш стенд ограничен фиксированной сериализацией и одним JSON-вариантом без компрессии. HTML имеет отдельную метку и не служит токеном JSON-редактирования.
If-Match использует сильное сравнение. Две одинаковые непрозрачные части с префиксом W/ не дают нужного совпадения. Клиент, получивший только слабый ETag, не может просто убрать префикс и объявить представление сильным. Сервису понадобится другая корректная граница для редактирования или выдача сильного валидатора соответствующего варианта. Условие If-Match.
Три состояния при согласовании формы
Для осознанного исправления конфликта клиенту полезны исходное представление, локально введённое и новое серверное. В нашем сценарии исходное title известно обоим редакторам. A изменил его на сервере, а B локально изменил только lessons. Сравнение трёх состояний показывает, что намерения затрагивают разные поля, и позволяет предложить объединённый документ без возврата старого названия.
Если оба редактора изменили title, автоматическое объединение уже не имеет очевидного смысла. Клиент сохраняет оба варианта и даёт человеку выбрать либо ввести новое значение. Ответ 412 не обязан содержать готовое решение: его задача — остановить применение к неподходящей версии. Разрешение содержательного конфликта относится к продукту.
Даже после объединения нужен новый If-Match из последнего чтения. Окно сравнения не блокирует других редакторов навсегда. Если к моменту отправки появилась ещё одна версия, сервис снова отказывает. Это не бесполезное повторение проверки, а сохранение объявленной границы: ни один документ не применяется поверх состояния, которое клиент не принимал как основание.
Таким образом, серверная атомарность и клиентское сохранение намерения дополняют друг друга. Первая предотвращает потерю обновления, второе помогает человеку завершить своё действие после честно объяснённого отказа.
Обязательное условие и удаление
Наш API теперь требует конкретный сильный If-Match для PUT и DELETE. Если условие отсутствует, сервер возвращает 428 Precondition Required, объясняя, что нужно сначала получить текущую версию. Ответ не сохраняется кешем; в учебном договоре указываем no-store.
HTTP/1.1 428 Precondition Required
Content-Type: application/problem+json
Cache-Control: no-store
{"type":"https://catalog.example/problems/precondition-required","title":"Нужно условие версии","status":428,"detail":"Получите JSON курса и отправьте его сильный ETag в If-Match."}
428 означает отсутствие требуемого условия, 412 — его несовпадение. Это разные действия клиента: в первом случае он ещё не предоставил основание для безопасного изменения, во втором прочитанная версия уже устарела. Общий 409 не заменяет точный результат проверки HTTP-предусловия в этой модели. Статус 428.
Форма If-Match: * в HTTP проверяет наличие представления, а не конкретную прочитанную версию. Она не защищает от потери обновления так же, как точный ETag. Наш сервис не принимает её как достаточное редакторское условие и возвращает 400 с причиной specific-version-required: требуется конкретная сильная метка. Такой прикладной выбор нужно различать с общей допустимой формой заголовка.
Удаление тоже может нуждаться в версии: человек увидел старый курс и не знает, что другой редактор только что переработал его. DELETE с устаревшей меткой отказывает, сохраняя запись. Если цель уже отсутствует, обычное разрешение цели может дать 404; прежний валидатор не восстанавливает её. Порядок авторизации, маршрута и применимых условий должен оставаться согласованным.
Наконец, после потери успешного ответа PUT повтор с прежним If-Match может получить 412. Это не доказывает, что первая попытка отказала: она могла уже изменить версию. Клиент получает текущие данные и сопоставляет их со своим намерением. Ключ создания и условие версии решают разные задачи, поэтому одинаковый обработчик «повторить любой отказ» здесь недопустим.
Теперь каталог имеет договор чтения, выбора формы, проверки входа, повтора создания, устойчивой выборки и конкурентной замены. Эти состояния можно реализовать в выбранном серверном стеке позднее. При чтении примеров проверяйте не только коды: главное — какое изменение принято, какое отклонено и какой результат клиент действительно способен знать.