JavaRush /Курсы /Spring REST & MVC /PATCH-контракт Task...

PATCH-контракт Task Tracker API

Spring REST & MVC
13 уровень , 4 лекция
Открыта

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, иначе вы разрушаете саму идею “сервер управляет ресурсом”.

1
Задача
Spring REST & MVC, 13 уровень, 4 лекция
Недоступна
Whitelist полей для PATCH body
Whitelist полей для PATCH body
1
Задача
Spring REST & MVC, 13 уровень, 4 лекция
Недоступна
Фиксированный PATCH-контракт для `TaskCard`
Фиксированный PATCH-контракт для `TaskCard`
1
Опрос
PATCH Контракты, 13 уровень, 4 лекция
Недоступен
PATCH Контракты
Семантика частичного обновления
Комментарии
ЧТОБЫ ПОСМОТРЕТЬ ВСЕ КОММЕНТАРИИ ИЛИ ОСТАВИТЬ КОММЕНТАРИЙ,
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ