1. PATCH без контракта: угадайка
У нас уже есть все куски этой задачи: PATCH работает поверх текущего Task, поле может быть absent, explicit null или с новым значением, patch DTO должен быть узким, а plain DTO сам по себе не видит факт присутствия поля. Теперь это нужно собрать в один рабочий договор для /api/v1/tasks/{taskId}. Иначе в одном месте null будет молча игнорироваться, в другом очищать значение, а в третьем через body внезапно пролезут server-managed поля.
Поэтому дальше фиксируем один рабочий вариант: какие поля patchable, что значат отсутствие поля, null и новое значение, как режем неизвестные поля и какой carrier доходит до сервиса. Если на эти вопросы нет одного ответа, PATCH быстро превращается в угадайку и для клиента, и для сервера.
2. PATCH /api/v1/tasks/{taskId}
Перед тем как говорить о JSON-примерах, полезно на секунду “прибить гвоздями” сам факт контракта. У нас есть один конкретный endpoint, и он должен быть читаемым, предсказуемым и скучным (в хорошем смысле). Скучный API — это API, который не умеет удивлять вас в пятницу вечером.
В канонической версии Task Tracker API мы фиксируем такую базовую рамку:
- URI: /api/v1/tasks/{taskId}
- Метод: PATCH
- Тело запроса: application/json
- Ответ: 200 OK + обновлённый TaskDetailsResponse (мы не возвращаем “просто ОК, верь мне”, потому что клиенту обычно удобно сразу видеть итоговое состояние ресурса)
- taskId — это идентификатор ресурса в path, а не часть JSON body (и уж точно не “можно я его поменяю?”).
Здесь PATCH — это не JSON Patch и не JSON Merge Patch документ. Мы используем обычный JSON-объект с заранее оговорёнными patchable-полями и per-field rules для absent / null / value.
И ещё одна граница: переходы статуса и другие бизнес-операции мы не прячем внутрь “общего PATCH”, чтобы не обходить правила ресурса.
3. Patchable-поля: список без сюрпризов
Контракту нужен забор. Иначе API, как кот, сам договорится о границах.
Для Task мы фиксируем patchable-набор таким:
- title
- description
- assigneeName
- dueDate
- priority
- tags
И принципиально не включаем сюда: id, createdAt, updatedAt, status и прочие server-managed или бизнес-чувствительные части состояния.
В коде удобно держать этот список явной константой. Это не “магия”, а документ, который компилятор проверяет лучше, чем наша память.
import java.util.Set;
public final class TaskPatchContract {
// Явный whitelist полей, которые вообще разрешено патчить.
// Важно: это именно JSON-имена, а не названия сеттеров/полей в доменной модели.
public static final Set<String> ALLOWED_FIELDS = Set.of(
"title", "description", "assigneeName", "dueDate", "priority", "tags"
);
// Утилитный класс: экземпляры не нужны.
private TaskPatchContract() {}
}
Обратите внимание: здесь перечислены именно JSON-имена полей. Мы фиксируем внешний договор, а не внутреннюю форму доменной модели.
4. Семантика absent/value/null
Теперь фиксируем per-field семантику. Универсальная формула if (field != null) красиво выглядит только до первого поля, которое надо очищать, и до первого обязательного поля, которому null вообще нельзя.
| Поле | Если поля нет в JSON | Если поле есть и значение не null | Если поле есть и значение null |
|---|---|---|---|
| title | не менять | заменить на новое значение | ошибка (нельзя “стереть” обязательное поле) |
| description | не менять | заменить | очистить (description = null) |
| assigneeName | не менять | заменить | очистить (assigneeName = null) |
| dueDate | не менять | заменить | очистить (dueDate = null) |
| priority | не менять | заменить | ошибка (приоритет — не “пустота”, а значение enum) |
| tags | не менять | полная замена списка | ошибка (очистка — только через []) |
Ключевой момент: мы осознанно выбираем простые правила, даже если они не самые “гибкие”. Гибкость в patch-семантике — это как свобода в конфигурации Vim: если вы только учитесь, то свобода быстро превращается в страдание. Наши правила должны быть понятны новичку и воспроизводимы в тестах.
5. Допустимые payload’ы
После такой таблицы payload читается без гаданий. Вот несколько опорных вариантов.
Пример “поменять только заголовок”:
{
"title": "Refine task endpoint"
}
Пример “очистить исполнителя” — и это отдельный смысл, а не случайная потеря данных:
{
"assigneeName": null
}
Пример “заменить теги целиком” — не “добавить один”, а именно заменить список как значение:
{
"tags": ["api", "rest"]
}
Пример “очистить все теги” — через пустой массив, а не null:
{
"tags": []
}
И наконец пример “ничего не менять”:
{}
Формально это no-op: поля отсутствуют, значит ничего не трогаем. На практике такой запрос чаще означает, что клиент вообще не собрал patch, но сам контракт здесь всё равно остаётся предсказуемым.
6. Запреты и неизвестные поля
Контракт полезен ещё и тем, что режет всё лишнее: server-managed и неизвестные поля.
Server-managed поля
Вот такие payload’ы должны считаться некорректными:
{
"id": "task-1",
"createdAt": "2026-03-18T10:00:00Z"
}
Причина проста: id, createdAt, updatedAt — это часть внутреннего управления ресурсом сервером. Как только клиент получает право их менять, у вас ломается история ресурса, появляются труднообъяснимые баги и тот самый “создатель задач из будущего”.
Неизвестные поля
Ещё один класс запретных запросов — “поле, которого нет в контракте”. Например:
{
"title": "Refine task endpoint",
"unexpectedField": true
}
В PATCH это особенно важно, потому что неполный документ соблазняет сервер “промолчать и проигнорировать”. Но если клиент написал assigneName вместо assigneeName, silent ignore просто прячет ошибку. Поле не обновится, а расследование “почему не работает” начнётся уже после запроса.
Поэтому лучше выбрать одно правило для всего API и придерживаться его. В нашем варианте unknown fields — это ошибка.
Единое правило по неизвестным полям
Важно не сделать типичную ошибку: “в PATCH мы строгие, а в POST терпим”, или наоборот. Клиенту неинтересно знать вашу внутреннюю логику — ему нужен предсказуемый внешний договор. Поэтому правило должно быть одинаковым.
Если мы выбираем strict-input, это удобно зафиксировать в конфигурации:
spring:
jackson:
deserialization:
# Если клиент прислал поле, которого нет в DTO/контракте — лучше упасть сразу,
# чем "молча проигнорировать" и потом расследовать опечатки.
fail-on-unknown-properties: true
Это не “тонкая настройка ObjectMapper ради PATCH”, а дисциплина контракта. Мы просто говорим: “Если клиент прислал поле, которого нет в договоре, мы не будем делать вид, что ничего не произошло”.
И даже при строгом режиме whitelist всё равно полезен: “поле известно Jackson” и “поле разрешено патчить” — не одно и то же. Сегодня мы фиксируем именно PATCH-список.
7. Чтение PATCH: absent vs null
Теперь самая практическая часть: как в коде отличить “поля нет” от “поле явно равно null”, если обычный TaskPatchRequest при десериализации даст null в обоих случаях? Вот тут и проявляется полезность JsonNode как “слоя наблюдения”.
flowchart TD
A["PATCH /api/v1/tasks/{taskId}"] --> B["Читаем body как JsonNode"]
B --> C{"Есть неизвестные поля?"}
C -->|да| X["400: invalid patch field"]
C -->|нет| D["Конвертируем JsonNode -> TaskPatchRequest"]
D --> E["Применяем patch по правилам absent/value/null"]
E --> F["200 OK + TaskDetailsResponse"]
Заметьте: JsonNode здесь нужен не для того, чтобы “сделать контракт динамическим”, а чтобы сохранить смысл. Контракт всё ещё типизированный и узкий, просто мы не теряем информацию о присутствии полей.
DTO остаётся: TaskPatchRequest
Patch-DTO нам всё равно нужен, потому что он фиксирует набор полей и типы.
import java.time.LocalDate;
import java.util.List;
public record TaskPatchRequest(
// null здесь не всегда "ничего не меняем": смысл null определяется контрактом.
String title,
String description,
String assigneeName,
LocalDate dueDate,
TaskPriority priority,
List<String> tags
) {}
Внешний контракт остаётся: “JSON → patch-модель”. Просто мы добавим к ней информацию “какие поля реально присутствовали”.
Снимаем “присутствие полей” и валидируем whitelist
Сначала читаем JsonNode и собираем presentFields. Заодно проверяем, что все поля из whitelist, иначе сразу останавливаемся.
import com.fasterxml.jackson.databind.JsonNode;
import java.util.HashSet;
import java.util.Iterator;
import java.util.Set;
public static Set<String> readPresentFields(JsonNode root, Set<String> allowed) {
// Здесь мы отличаем "поля вообще не было" от "поле было, но оно null".
// Одновременно делаем whitelist-проверку, чтобы PATCH не превратился в "принимай всё подряд".
Set<String> present = new HashSet<>();
Iterator<String> names = root.fieldNames();
while (names.hasNext()) {
String name = names.next();
// Неизвестные поля — это ошибка контракта (обычно опечатка или попытка патчить запрещённое).
if (!allowed.contains(name)) throw new IllegalArgumentException("Unknown patch field: " + name);
present.add(name);
}
return present;
}
Эта проверка делает контракт “узким” на практике. Мы не даём PATCH превратиться в “универсальный input на все случаи жизни”.
Конвертируем JsonNode в TaskPatchRequest
После проверки whitelist мы превращаем дерево в DTO. Если JSON кривой (например, dueDate в неправильном формате или priority не из enum), Jackson всё равно упадёт — и это нормально: это не “бизнес-конфликт”, это просто некорректный input.
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
public static TaskPatchRequest toPatchRequest(ObjectMapper mapper, JsonNode root) {
try {
// treeToValue сохраняет типизированный DTO, но сам факт присутствия полей мы держим отдельно.
return mapper.treeToValue(root, TaskPatchRequest.class);
} catch (Exception e) {
// Здесь важно отдавать понятную ошибку "пэйлоад сломан", а не маскировать её под бизнес-валидацию.
throw new IllegalArgumentException("Malformed PATCH payload", e);
}
}
Сервису и доменной модели мы отдаём не JsonNode, а нормальную Java-модель.
Упаковываем всё в один объект “patch command”
Чтобы не таскать по проекту “dto плюс сет полей”, можно собрать это в один маленький объект.
import java.util.Set;
public record TaskPatchCommand(TaskPatchRequest request, Set<String> presentFields) {
// Утилита для читаемого кода: спрашиваем "поле было в JSON?", а не "значение != null?".
public boolean has(String field) {
return presentFields.contains(field);
}
}
Это удобная “переноска смысла”: теперь сервис может спрашивать не “значение null?”, а “поле вообще было в JSON?”.
8. Контроллер и применение правил
Последний шаг — показать, как этот контракт “оживает” в коде Task Tracker API. Нам не нужен огромный контроллер; нам нужен тонкий метод, который читает вход, собирает patch command и делегирует в сервис.
Контроллер: читаем JsonNode, строим TaskPatchCommand
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
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.RestController;
@RestController
public class TaskController {
private final ObjectMapper objectMapper;
private final TaskService taskService;
public TaskController(ObjectMapper objectMapper, TaskService taskService) {
this.objectMapper = objectMapper;
this.taskService = taskService;
}
@PatchMapping("/api/v1/tasks/{taskId}")
public TaskDetailsResponse patchTask(@PathVariable String taskId, @RequestBody JsonNode body) {
// 1) Сначала выясняем, какие поля реально присутствуют в JSON, и сразу валидируем whitelist.
var present = readPresentFields(body, TaskPatchContract.ALLOWED_FIELDS);
// 2) Потом конвертируем JSON в типизированный DTO (типы/форматы проверятся Jackson'ом).
var request = toPatchRequest(objectMapper, body);
// 3) В сервис отдаём "команду", а не JsonNode: сервис работает по контракту, а не по дереву.
return taskService.patch(taskId, new TaskPatchCommand(request, present));
}
}
Да, здесь JsonNode прямо в controller signature. Это допустимо, потому что это всё ещё web-layer, и именно тут мы работаем с “формой входного JSON”. Сервису мы не отдаём “дерево”, сервису мы отдаём TaskPatchCommand.
А TaskDetailsResponse можно получить через mapper; я сократил пример до сути. В реальном проекте, конечно, вы вернёте response DTO через taskMapper.
Применение patch-правил: “явно, по полям, по договорённости”
Теперь самое главное: сервис применяет patch не “в лоб”, а по контракту.
import java.util.List;
public Task applyPatch(Task current, TaskPatchCommand patch) {
// Принцип: сначала проверяем, что поле было в JSON (absent vs present),
// и только потом смотрим на значение (value vs null) и применяем правило контракта.
if (patch.has("title")) {
if (patch.request().title() == null) throw new IllegalArgumentException("title cannot be null");
current.setTitle(patch.request().title());
}
if (patch.has("description")) {
current.setDescription(patch.request().description()); // null = clear (согласно контракту)
}
if (patch.has("tags")) {
List<String> tags = patch.request().tags();
// Для tags мы запрещаем null, чтобы "очистка" была только через [] (это проще тестировать и объяснять).
if (tags == null) throw new IllegalArgumentException("tags must be an array");
current.setTags(List.copyOf(tags)); // полная замена списка, без "умного" merge
}
return current;
}
Важная мысль: мы не проверяем “!= null → обновить”. Мы проверяем “has(field) → применить по правилам”, и только потом смотрим на значение. Вот это и есть та самая дисциплина, ради которой мы весь день говорили про absent vs null.
9. Типичные ошибки при PATCH-контракте
Ошибка №1: “PATCH — это просто урезанный PUT, можно принять TaskDetailsResponse как input”.
Это выглядит удобно, но превращает контракт в кашу: клиент получает право присылать поля, которые не должен контролировать (server-managed, derived, служебные). Даже если вы “потом это проигнорируете”, сам факт “поля в контракте” уже создаёт ожидания у клиента. Лучше держать отдельный TaskPatchRequest и не смешивать вход и выход.
Ошибка №2: “Если поле null, значит не меняем” (или наоборот “если null, значит очищаем”) — одно правило на всё.
Универсальные правила в patch-семантике почти всегда ломают контракт. Для title null — ошибка, для assigneeName null — команда очистки, а для tags null мы специально запретили, чтобы очистка делалась через []. Если всё привести к одной трактовке, вы либо потеряете возможность очистки, либо начнёте стирать обязательные поля.
Ошибка №3: Игнорировать неизвестные поля “для дружелюбности”.
Это дружелюбие обычно заканчивается тем, что клиент месяц шлёт assigneName, ничего не происходит, и все думают, что “PATCH сломан”. В реальном API строгий режим чаще экономит время, чем тратит: он делает ошибки заметными и быстрыми. Если уж вы выбираете tolerant input, делайте это осознанно и одинаково во всём API.
Ошибка №4: Не фиксировать семантику коллекций и пытаться “умно” мёржить теги.
Как только вы говорите “можно прислать один тег, и он добавится”, у вас появляется вопрос: что делать с дублями, как удалять тег, как обеспечить идемпотентность, как отличать “заменить” от “добавить”. В учебном и большинстве коммерческих API проще и надёжнее договориться: “поле tags пришло — заменяем список целиком”. И точка.
Ошибка №5: Пытаться поддержать patch для id, createdAt, updatedAt “ну просто на всякий случай”.
В этот момент PATCH начинает работать как “дырка в админку”. Даже если у вас нет security в проекте, вы всё равно учитесь проектировать внешний контракт. Server-managed поля должны оставаться server-managed, иначе вы разрушаете саму идею “сервер управляет ресурсом”.
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ