PATCH /reading-list/{id}/status

Java Server
Рівень 25 , Лекція 3
Відкрита

1. Окремий PATCH для статусу замість PUT

Коли у вас зʼявляється PUT, дуже легко піддатися спокусі: «Ну й чудово, тепер будь-яка правка — через PUT». Але на практиці статус — це поле, яке змінюють найчастіше. І щойно ви починаєте змінювати статус через PUT, ви змушуєте клієнта щоразу передавати повний набір полів, а сервер — щоразу «перезаписувати» весь об’єкт.

Це погано не тому, що «так не заведено в REST» — ми не на філософському семінарі, — а тому, що так зростає шанс помилки. Наприклад, клієнт надіслав PUT із правильним status, але випадково забув comment — і ви стерли коментар. Ніхто цього не хотів, але так сталося. Окремий PATCH у нашому курсі — це спосіб зробити контракт передбачуваним і безпечним: одна операція змінює одну річ.

І ще корисна людська аналогія. PUT — це як «переписати анкету цілком». PATCH для статусу — як «поставити галочку в одному полі». Якщо вам потрібно поставити галочку «FINISHED», не хочеться переписувати все резюме заново.

2. Контракт PATCH для статусу

Щоб кінцева точка була простою для розуміння і для вашої майбутньої підтримки коду, корисно прямо словами проговорити контракт: що лежить у шляху, що лежить у тілі, що повертаємо й які базові відповіді можливі. У цьому курсі ми тримаємо PATCH вузьким: змінюємо лише статус, тому шлях закінчується на /status.

Нижче — мінімальна таблиця контракту. Це не «документація рівня OpenAPI», а зручна шпаргалка, щоб ваш обробник не перетворився на «вгадай, що я мав на увазі».

Частина контракту Значення
Метод PATCH
Шлях /api/v1/reading-list/{id}/status
Звідки беремо id із path (
{id}
)
Тіло запиту JSON з одним полем status
Успіх 200 OK + JSON оновленого ресурсу
Якщо ресурсу не існує 404 Not Found (щонайменше статус)
Якщо id невалідний або JSON некоректний 400 Bad Request (щонайменше статус)

Приклад запиту — це те, що клієнт надсилає серверу. Зверніть увагу: це не повний об’єкт, а лише те, що ми дозволяємо змінювати цією кінцевою точкою.

PATCH /api/v1/reading-list/3/status
Content-Type: application/json

{ "status": "FINISHED" }

Приклад успішної відповіді — те, що сервер повертає. Ми повертаємо повний ReadingItemResponse, щоб клієнт одразу бачив актуальний стан ресурсу. Так, можна було б повернути 204 No Content, але тут для навчання корисніше побачити результат.

{
  "id": 3,
  "title": "Clean Code",
  "author": "Robert C. Martin",
  "status": "FINISHED",
  "externalId": "OL12345M",
  "comment": "Знайти паперове видання"
}

Зверніть увагу на важливу думку: PATCH у нашому дизайні не означає «онови якось, я сам не знаю як». Він означає «онови конкретний фрагмент стану, який описано в контракті».

3. UpdateStatusRequest: маленький DTO

З погляду новачка може виникнути запитання: «навіщо окремий DTO, якщо там лише одне поле?». Відповідь доволі практична: DTO фіксує форму вхідного JSON і захищає вас від бажання приймати «будь-що», а потім розбиратися, що саме надійшло. Один DTO на один сценарій — це дисципліна, яка дуже допомагає, коли API починає рости.

Ми робимо request DTO окремим типом, щоб у коді був видимий намір. UpdateReadingItemRequest — «я змінюю все», UpdateStatusRequest — «я змінюю лише статус». І коли ви за місяць відкриєте проєкт, мозку буде простіше: за назвою класу вже зрозуміла семантика, без читання маршрутизації й тіла методу.

Мінімальна реалізація DTO в пакеті readinglist.dto:

package com.example.readlater.readinglist.dto;

import com.example.readlater.readinglist.domain.ReadingStatus;

// DTO для PATCH /{id}/status: приймаємо лише одне поле, яке дозволено змінювати цією кінцевою точкою
public record UpdateStatusRequest(ReadingStatus status) {
    // Важливо: використовуємо enum, щоб контракт був строгим і помилки ловилися якомога раніше
}

І важливий нюанс про enum. Ми вже визначили ReadingStatus як enum (наприклад, PLANNED, IN_PROGRESS, FINISHED). Це означає, що якщо клієнт надішле щось на кшталт "status": "DONE", Jackson не зможе це десеріалізувати й викине виняток. З одного боку, неприємно. З іншого — це добрий ранній сигнал: клієнт не дотримується контракту.

4. Роутинг для PATCH /{id}/status

З ручним HttpServer у нас майже завжди один із двох підходів: або створювати багато окремих контекстів, або в одному обробнику робити розгалуження за method + path. У курсі ми йдемо другим шляхом, щоб ви відчули, скільки роботи потім забере Spring MVC.

Проблема тут проста: у нас уже є /api/v1/reading-list (колекція), /api/v1/reading-list/{id} (елемент), а тепер додається /api/v1/reading-list/{id}/status. Потрібно акуратно визначити, що саме надійшло, і витягти id. Робити це через split("/") можна, але дуже легко помилитися в індексі, а потім ловити баги рівня «чому в id лежить ‘reading-list’».

Надійніше для навчального проєкту — використати регулярний вираз, який одразу перевіряє форму шляху й дістає число. А щоб не змішувати «не той маршрут» і «той маршрут, але зламаний id», зручно тримати дві перевірки: загальну форму шляху й числовий id.

import java.util.OptionalLong;
import java.util.regex.Matcher;
import java.util.regex.Pattern;

// Роут рівно під PATCH статусу: лише число в {id} і строгий кінець /status
private static final Pattern STATUS_PATCH_ROUTE =
        Pattern.compile("^/api/v1/reading-list/(\\d+)/status$");

// Загальна форма маршруту: один сегмент на місці {id}, навіть якщо він кривий
private static final Pattern STATUS_PATCH_SHAPE =
        Pattern.compile("^/api/v1/reading-list/([^/]+)/status$");

private boolean isStatusPatchPath(String path) {
    // Якщо форма збіглася, значить це саме наша кінцева точка, а не якийсь інший маршрут
    return STATUS_PATCH_SHAPE.matcher(path).matches();
}

private OptionalLong tryExtractIdForStatusPatch(String path) {
    // Спочатку перевіряємо "строгу" форму: тут {id} обов’язково має бути числом
    Matcher m = STATUS_PATCH_ROUTE.matcher(path);
    if (!m.matches()) return OptionalLong.empty();

    // Якщо підходить — дістаємо id із першої групи (\d+)
    return OptionalLong.of(Long.parseLong(m.group(1)));
}

Тепер у вашому handle(HttpExchange exchange) ви можете зробити перевірку зрозуміло: якщо метод PATCH і шлях схожий на status-route, то некоректний id стане 400, а не неявним 404.

String method = exchange.getRequestMethod();
String path = exchange.getRequestURI().getPath();

// Важливо: спочатку фільтруємо за методом, потім за маршрутом
if ("PATCH".equals(method) && isStatusPatchPath(path)) {
    OptionalLong idOpt = tryExtractIdForStatusPatch(path);
    if (idOpt.isEmpty()) {
        // Форма маршруту наша, але id зламаний -> це 400, а не "маршрут не знайдено"
        exchange.sendResponseHeaders(400, -1);
        exchange.close();
        return;
    }

    // Знайшли валідний id — передаємо керування в окремий метод обробника
    handlePatchStatus(exchange, idOpt.getAsLong());
    return;
}

Тут важлива людська користь: ви не просто парсите рядок, а спочатку перевіряєте, що запит узагалі схожий на потрібний маршрут. Це помітно зменшує шанс, що ви обробите «не той» шлях «не тим» кодом.

5. Читання body і JSON у UpdateStatusRequest

У PATCH у нашому контракті є тіло запиту, отже потрібно зробити те, що ви вже робили для POST і PUT: прочитати байти, перетворити їх на рядок, зазвичай у UTF-8, а потім попросити Jackson прочитати DTO. Звучить просто, але є дві типові «ями»: порожнє тіло й JSON, який не відповідає DTO.

Порожнє тіло трапляється частіше, ніж здається. Наприклад, клієнт забув додати body, або Postman надіслав запит без raw JSON. Тоді readAllBytes() поверне порожній масив, а Jackson, найімовірніше, упаде під час десеріалізації. Друга яма — неправильний статус (рядок, який не збігається з enum) або некоректний JSON (зайва кома, лапки не там).

Ми можемо обробити це максимально чесно й мінімально: якщо не змогли розпарсити — це 400. Без глибоких деталей щодо контракту помилок, але принаймні статус-код має бути логічним.

import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.ObjectMapper;
import java.nio.charset.StandardCharsets;

private UpdateStatusRequest readStatusRequest(HttpExchange exchange, ObjectMapper objectMapper)
        throws IOException, JsonProcessingException {

    // Читаємо тіло запиту як UTF-8 рядок (PATCH у нас передбачає JSON у body)
    String body = new String(exchange.getRequestBody().readAllBytes(), StandardCharsets.UTF_8);

    // Просимо Jackson розпарсити строго в UpdateStatusRequest (якщо не вийшло — прилетить JsonProcessingException)
    return objectMapper.readValue(body, UpdateStatusRequest.class);
}

І приклад використання з мінімальним try/catch:

try {
    // Якщо JSON кривий або status не збігається з enum — упадемо в catch
    UpdateStatusRequest request = readStatusRequest(exchange, objectMapper);

    // далі передаємо в service
} catch (JsonProcessingException e) {
    // Некоректний JSON / неправильне значення enum: чесно відповідаємо 400
    exchange.sendResponseHeaders(400, -1);
    exchange.close();
}

Так, це виглядає як ручна робота. Саме так. І це той момент, де в майбутньому Spring MVC скаже: «дякую, я сам привʼяжу JSON до DTO й поверну 400, якщо не вийде». Але поки що ми чесно бачимо механіку.

6. Service-логіка: змінюємо лише status

Коли студент уперше робить PATCH, часта помилка — створити новий об’єкт «із того, що прийшло», і зберегти його. Але в нас у request лише одне поле. Якщо ви створите новий ReadingListItem тільки зі статусом, ви або обнулите решту полів, або почнете витягувати їх із повітря. І те, і інше — не те, що нам потрібно.

Тут правильна семантика така: ми шукаємо наявний об’єкт за id, змінюємо в нього одне поле, зберігаємо об’єкт назад, повертаємо оновлений об’єкт. Ніяких перевірок externalId на унікальність тут не потрібно, тому що ми externalId не змінюємо. Тобто ми спеціально робимо «вузький» сервісний метод, щоб код не був схожий на «оновити все про всяк випадок».

Приклад ReadingListService.updateStatus:

import java.util.Optional;

public Optional<ReadingListItem> updateStatus(long id, UpdateStatusRequest request) {
    // 1) Шукаємо наявний елемент: якщо його немає — PATCH робити нічого
    Optional<ReadingListItem> existing = repository.findById(id);
    if (existing.isEmpty()) {
        return Optional.empty();
    }

    // 2) Змінюємо лише одне поле, яке дозволено контрактом
    ReadingListItem item = existing.get();
    item.setStatus(request.status());

    // 3) Зберігаємо й повертаємо оновлену версію
    return Optional.of(repository.save(item));
}

І маленький, але корисний штрих у доменній моделі: нехай setStatus приймає ReadingStatus, а не рядок. Тоді ви не дозволите «випадково» засунути в домен "DONE" і жити далі з кривими даними.

public void setStatus(ReadingStatus status) {
    // Домен приймає лише валідний enum, а не довільний рядок
    this.status = status;
}

Якщо хочеться, можна додати Objects.requireNonNull(status, "status"), але глибока валідація й те, як красиво на це відповідати клієнту, — це окрема розмова. Тут ми тримаємо фокус на семантиці PATCH.

7. Handler: 200 OK і ReadingItemResponse

Коли сервіс повернув оновлений доменний об’єкт, обробник має зробити фінальний крок: перетворити доменну сутність у response DTO, серіалізувати його в JSON і надіслати клієнту з правильними заголовками. Якщо ви на цьому кроці «зріжете кути», клієнт потім страждатиме від дрібниць, наприклад від відсутнього Content-Type, і не зрозуміє, що прийшов JSON.

Це продовження того ж ReadingListHttpHandler: у нього вже є create- і put-гілки, а тепер з’являється ще й handlePatchStatus(...). Response-частина тут така ж, як у POST і PUT: назовні виходить ReadingItemResponse, а не доменна сутність.

Нижче — маленький, але зручний helper для надсилання JSON. Він робить три речі: серіалізує об’єкт, ставить Content-Type, пише статус і тіло. І, що важливо, акуратно закриває OutputStream.

import com.fasterxml.jackson.databind.ObjectMapper;
import com.sun.net.httpserver.HttpExchange;
import java.io.IOException;
import java.io.OutputStream;

private void sendJson(HttpExchange exchange, int status, Object body, ObjectMapper objectMapper)
        throws IOException {

    // Серіалізуємо відповідь у байти (так зручніше правильно виставити Content-Length)
    byte[] json = objectMapper.writeValueAsBytes(body);

    // Явно кажемо клієнту, що віддаємо JSON
    exchange.getResponseHeaders().set("Content-Type", "application/json; charset=utf-8");
    exchange.sendResponseHeaders(status, json.length);

    // Важливо закрити response body, інакше клієнт може не отримати відповідь коректно
    try (OutputStream os = exchange.getResponseBody()) {
        os.write(json);
    }
}

А ось як може виглядати handlePatchStatus повністю, спрощена версія без ускладненої обробки помилок:

import java.util.Optional;

private void handlePatchStatus(HttpExchange exchange, long id) throws IOException {
    UpdateStatusRequest request;
    try {
        // Парсимо JSON із body в DTO (контракт каже: чекаємо лише status)
        request = readStatusRequest(exchange, objectMapper);
    } catch (JsonProcessingException e) {
        // Якщо JSON не розпарсився — це помилка запиту, відповідаємо 400
        exchange.sendResponseHeaders(400, -1);
        exchange.close();
        return;
    }

    // Робимо доменну операцію: знайти елемент і змінити статус
    Optional<ReadingListItem> updated = readingListService.updateStatus(id, request);
    if (updated.isEmpty()) {
        // Якщо елемента немає — це 404
        exchange.sendResponseHeaders(404, -1);
        exchange.close();
        return;
    }

    // Мапимо доменну сутність у response DTO, щоб не світити внутрішню кухню домену назовні
    ReadingItemResponse response = mapper.toResponse(updated.get());

    // Повертаємо 200 і JSON повного ресурсу
    sendJson(exchange, 200, response, objectMapper);
}

Зверніть увагу, як чітко тут розділяються ролі. Handler займається HTTP: прочитати body, відправити статус і JSON. Service займається доменною логікою: знайти, змінити одне поле, зберегти. Repository займається зберіганням. Це не «для краси», це щоб проєкт не перетворився на один величезний метод handle() завдовжки в 400 рядків.

8. Потік запиту й розмова про ідемпотентність

Іноді здається, що PATCH — це «завжди щось складне й небезпечне». Насправді все залежить від контракту. Ми спеціально обрали вузький контракт «встановити статус у конкретне значення». У такому вигляді наш PATCH стає доволі передбачуваним: надіслали FINISHED — отримали FINISHED. Повторили запит ще раз — отримали те саме. Тобто в цьому конкретному дизайні операція близька до ідемпотентної за наслідком: повтор не змінює результат.

Щоб закріпити механіку, корисно подивитися на потік запиту як на ланцюжок викликів. Ось схема, яка збігається з тим, як реально працює наш код:

sequenceDiagram
    participant C as "Клієнт (Postman)"
    participant S as HttpServer
    participant H as Handler
    participant SV as ReadingListService
    participant R as "Репозиторій (in-memory)"

    C->>S: "PATCH /api/v1/reading-list/3/status {status:FINISHED}"
    S->>H: "handle(exchange)"
    H->>H: "розібрати шлях + прочитати JSON -> UpdateStatusRequest"
    H->>SV: updateStatus(3, request)
    SV->>R: findById(3)
    R-->>SV: ReadingListItem
    SV->>SV: "item.setStatus(FINISHED)"
    SV->>R: save(item)
    R-->>SV: updated item
    SV-->>H: Optional(updated)
    H-->>C: "200 OK + ReadingItemResponse(JSON)"

Якщо ви прямо зараз відчуваєте легку втому від кількості ручних кроків, вітаю: ви на правильному шляху. Саме це відчуття «скільки ж тут рутини» потім перетвориться на розуміння цінності Spring MVC, маршрутизації й зв’язування.

9. Типові помилки під час status-patch

Помилка №1: змінювати статус через PUT, надсилаючи повний об’єкт «за звичкою».
Спочатку це здається зручним: одна кінцева точка на всі випадки. Потім з’являються «випадкові стирання» полів. Клієнт надіслав PUT ради зміни статусу й не включив comment, а сервер чесно замінив comment на null. Винним ніби є клієнт, але насправді контракт був незручним і провокував помилку.

Помилка №2: у PATCH створювати новий ReadingListItem і втрачати решту полів.
Це виглядає так: ви розпарсили UpdateStatusRequest, а потім зробили new ReadingListItem(id, null, null, status, null, null) і зберегли. Репозиторій радий, але дані загинули. Правильна стратегія — шукати наявний об’єкт і змінювати в нього одне поле.

Помилка №3: витягувати id зі шляху через «магічні індекси» в split("/").
Сьогодні у вас шлях /api/v1/reading-list/3/status, і «здається», що parts[4] завжди буде id. Завтра ви додали префікс або забули початковий слеш — і індекс поїхав. Регулярний вираз або акуратний розбір шляху здаються нудними, але нудьга в бекенді — це часто комплімент.

Помилка №4: приймати статус як String і зберігати його як String у домені.
Так ви втрачаєте контроль над контрактом. З enum у вас є «стіна» з компілятора й Jackson: не можна зберегти невідоме значення, не можна «помилитися» в статусі в коді. З рядком усе стане «гнучким», а за тиждень ви виявите в пам’яті елементи зі статусом "FINISHDED".

Помилка №5: забути Content-Type: application/json у відповіді.
У браузері або Postman це може «майже працювати», тому що вони часто здогадуються. А ось нормальний клієнт або ваш майбутній фронтенд цілком має право сказати: «я не розумію, що мені надійшло». Якщо ви віддаєте JSON — кажіть про це явно.

1
Задача
Java Server, 25 рівень, 3 лекція
Недоступна
Зміна лише статусу задачі через PATCH
Зміна лише статусу задачі через PATCH
1
Задача
Java Server, 25 рівень, 3 лекція
Недоступна
PATCH статусу замовлення з обробкою некоректного JSON
PATCH статусу замовлення з обробкою некоректного JSON
Коментарі
ЩОБ ПОДИВИТИСЯ ВСІ КОМЕНТАРІ АБО ЗАЛИШИТИ КОМЕНТАР,
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ