1. Семантика DELETE
Видалення завжди здається найпростішою частиною CRUD: «ну видалили — і все». Але якщо придивитися уважніше, DELETE — це окрема історія зі своєю семантикою, контрактом і очікуваннями клієнта. У ReadLater це особливо помітно: користувач може додати книгу «про всяк випадок», потім передумати й захотіти прибрати її зі списку — не «змінити», не «позначити», а саме прибрати.
Найважливіший принцип тут такий: DELETE працює з конкретним ресурсом. Не з колекцією і не з «абстрактною дією», а з конкретною сутністю, у якої є id. Тому наш endpoint має такий вигляд:
DELETE /api/v1/reading-list/{id}
Якщо вам захочеться зробити DELETE /api/v1/reading-list з body «видали ось це й ось це», знайте: це не заборонено законами фізики, але для нашого курсу такий підхід дуже швидко заводить у зайву складність. Ми тренуємо базову прикладну семантику: один ресурс — один шлях — одна операція.
Є ще одна інженерна думка, яка допомагає: DELETE — це не «оновлення статусу до DELETED». Ми свідомо не робимо тут «мʼяке видалення» (soft delete) і не зберігаємо історію змін. Ми справді видаляємо елемент із in-memory сховища. Так, після перезапуску застосунку дані все одно зникнуть — і для базового курсу це нормально. Зате ви побачите чисту механіку HTTP і розділення шарів без інфраструктурних відволікань.
2. Контракт DELETE /api/v1/reading-list/{id}
Контракт видалення справді короткий, але саме через це в ньому легко припуститися помилки. У запиті з боку клієнта майже немає полів: він не надсилає JSON-body (принаймні, у нашому API), а передає все через path. У відповіді сервер теж може нічого не надсилати в body — і саме тут з’являється 204 No Content.
Зведімо поведінку в невелику таблицю, щоб мозок не намагався «вгадувати зміст» з коду:
| Сценарій | Що відбувається на сервері | HTTP-статус | Тіло відповіді |
|---|---|---|---|
| Ресурс знайдено і видалено | Map.remove(id) справді щось видалив | 204 No Content | відсутнє |
| Ресурсу немає | видаляти нічого | 404 Not Found | у цій лекції можна обійтися без body |
| id не число | неможливо навіть зрозуміти, що видаляти | 400 Bad Request | у цій лекції можна обійтися без body |
Кілька слів про 204. Цей статус добрий тим, що він чесно каже: «операція успішна, але у відповіді немає вмісту». Це зручно і клієнту, і серверу. Клієнт не намагається розбирати JSON, сервер не серіалізує DTO, а контракт залишається прозорим.
Зверніть увагу, що для 404 і 400 ми тут теж поки що не надсилаємо body. Для DELETE зараз важливіше довести до кінця саму HTTP-семантику видалення; загальний JSON-контракт помилок має сенс зібрати одразу для всього API, а не вигадувати окремо для одного маршруту.
У сирому вигляді запит/відповідь виглядає так:
DELETE /api/v1/reading-list/10 HTTP/1.1
Host: localhost:8080
Accept: application/json
А за успіху:
HTTP/1.1 204 No Content
І все. Жодних { "deleted": true } лише для того, щоб не було порожньо. Порожнє тіло тут — якраз правильно.
3. Репозиторій: Map.remove
У цій частині легко скотитися до «та я й так розумію Map.remove», але давайте проговоримо саме репозиторну сторону — тому що в нас є правило: обробник не повинен напряму звертатися до Map, а сервіс не повинен «знати» про деталі зберігання. Репозиторій — це шар, де зберігання оформлене як прості операції: знайти, зберегти, видалити.
Контракт репозиторію тут теж доповнюється ще одним простим методом:
public interface ReadingListRepository {
// ... методи, які вже є для create/read/update
// Для DELETE потрібна одна відповідь від сховища: чи вдалося прибрати ресурс за id?
boolean deleteById(long id);
}
У Map<Long, ReadingListItem> видалення зазвичай виглядає так: remove(id) повертає видалене значення або null, якщо такого ключа не було. Нам зручно перетворити це на boolean, бо обробнику важлива саме відповідь на запитання «видалилося чи ні».
Мініфрагмент репозиторію:
import com.example.readlater.readinglist.domain.ReadingListItem;
import java.util.Map;
public boolean deleteById(long id) {
// Важливо: Map.remove(id) повертає видалений об’єкт або null, якщо ключа не було.
ReadingListItem removed = storage.remove(id);
// Повертаємо «факт видалення», щоб вище в стеку викликів легко зіставити результат із 204/404.
return removed != null;
}
Тут корисна одна маленька звичка: повертати «факт» (boolean), а не кидати виняток. Винятки стануть у пригоді, коли ви захочете однаково обробляти помилки (і це окрема тема). А поки що нам простіше: сервіс поверне true/false, обробник перетворить це на 204/404.
Якщо ви десь дорогою вирішите, що репозиторій має повертати видалений обʼєкт (наприклад, Optional<ReadingListItem>), це теж робочий варіант. Але для delete-ендпойнта це рідко потрібно: клієнт усе одно не чекає body, і ми лише ускладнимо собі життя без особливої вигоди.
4. Сервіс: операція видалення
Сервісний шар іноді здається «зайвим» у маленькому застосунку: «та я ж можу видалити прямо в обробнику». Але саме на цьому місці в новачків починається повільна, але впевнена деградація проєкту: обробник перетворюється на величезний моноліт, де є і HTTP, і бізнес-правила, і зберігання, і взагалі все.
Навіть якщо delete-операція зараз проста, сервіс потрібен як місце, де живе прикладна операція. Сьогодні вона в один рядок. Завтра вона стане «перед видаленням перевір обмеження». І ви будете раді, що цю зміну не розмазано по обробнику.
Приклад методу сервісу з мінімальним логуванням результату (у розумних межах):
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
public boolean delete(long id) {
// Репозиторій повідомляє лише результат: видалили чи ні (без винятків і без DTO).
boolean deleted = repository.deleteById(id);
// У лог записуємо факти (id і підсумок), щоб потім можна було швидко відтворити поведінку.
log.info("Видалення елемента зі списку читання: id={}, deleted={}", id, deleted);
return deleted;
}
Зверніть увагу на формат логів: id і deleted — це корисні факти. Ми не пишемо «видаляємо елемент списку читання» десять разів у різних місцях; пишемо один раз і по суті.
І так, сервіс не повинен бачити HttpExchange. Щойно ви передали в сервіс HttpExchange, ви змішали web-шар і прикладну логіку. Це як узяти інструктора з водіння й посадити його на пасажирське сидіння велосипеда: начебто можна, але навіщо.
5. Роутинг і обробник DELETE
Видалення — ідеальний приклад того, що HttpServer не робить магію за нас. У Spring MVC ви б написали @DeleteMapping("/api/v1/reading-list/{id}") і отримали б long id як параметр методу. Тут же ми самі маємо переконатися, що прийшов потрібний метод, потрібний шлях, а id взагалі схожий на число.
Це все ще той самий ReadingListHttpHandler: просто до POST, PUT і PATCH додається ще одна гілка, яка відповідає лише за семантику DELETE.
Припустімо, у вашому обробнику є загальний handle(HttpExchange exchange), який розбирає маршрути. Для гілки DELETE можна зробити мінімально зрозумілий фрагмент:
import com.sun.net.httpserver.HttpExchange;
import java.io.IOException;
private boolean tryHandleDelete(HttpExchange exchange) throws IOException {
String path = exchange.getRequestURI().getPath();
// 1) Спочатку перевіряємо HTTP-метод: інакше ми почнемо "видаляти" на GET/POST помилково.
if (!"DELETE".equals(exchange.getRequestMethod())) return false;
// 2) Потім перевіряємо префікс шляху: переконуємося, що це саме наш endpoint.
if (!path.startsWith("/api/v1/reading-list/")) return false;
// 3) Дістаємо id як рядок із останнього сегмента шляху: далі його потрібно перетворити на long.
String idPart = path.substring(path.lastIndexOf('/') + 1);
return handleDeleteById(exchange, idPart);
}
Тут ми не використовуємо регулярні вирази, щоб код залишався зрозумілим на око. Регулярні вирази — потужна штука, але для новачка вони часто перетворюються на заклинання. Звісно, можна робити і через matches(...), але ідея та сама: ми перевіряємо метод і те, що шлях схожий на наш endpoint.
Далі — найважливіше: idPart потрібно перетворити на long і розрізнити «не число» (400) від «число, але ресурсу немає» (404).
6. 204 No Content без body
Коли ви вперше робите 204, рука рефлекторно тягнеться написати JSON-відповідь. Це нормально: ми вже звикли, що backend завжди повертає JSON. Але 204 — якраз той випадок, коли JSON не потрібен. І якщо ви все ж напишете body, то суперечитимете змісту статусу, а деякі клієнти ще й поводитимуться дивно.
У HttpServer є дуже практична деталь: sendResponseHeaders(status, length).
Для відповідей без тіла зазвичай використовують length = -1. Тоді сервер розуміє: response body не буде, і можна не відкривати потік запису.
Зробімо невеликий допоміжний метод, щоб не повторювати один і той самий код:
import com.sun.net.httpserver.HttpExchange;
import java.io.IOException;
private void sendNoContent(HttpExchange exchange) throws IOException {
// 204 + length = -1 означає: тіла немає, нічого писати у response body не потрібно.
exchange.sendResponseHeaders(204, -1);
// Навіть коли тіла немає, exchange все одно потрібно закрити, щоб коректно завершити запит.
exchange.close();
}
А тепер з’єднаємо все в handleDeleteById(HttpExchange exchange, String idPart), яка отримує idPart рядком:
import com.sun.net.httpserver.HttpExchange;
import java.io.IOException;
private boolean handleDeleteById(HttpExchange exchange, String idPart) throws IOException {
try {
// Спочатку намагаємося перетворити id: якщо не вдалося, це помилка формату (400).
long id = Long.parseLong(idPart);
// Якщо id коректний, далі вирішуємо прикладне завдання видалення.
return handleDelete(exchange, id);
} catch (NumberFormatException e) {
// id не число => клієнт передав некоректний шлях, відповідаємо 400 без тіла.
exchange.sendResponseHeaders(400, -1);
exchange.close();
return true;
}
}
Тут ми чесно розрізнили «id не число» як 400. Це не «валідація body», а просто базова коректність маршруту.
І, нарешті, сама delete-операція:
import com.sun.net.httpserver.HttpExchange;
import java.io.IOException;
private boolean handleDelete(HttpExchange exchange, long id) throws IOException {
// Сервіс повертає true/false: видалили (204) чи ресурсу не було (404).
boolean deleted = readingListService.delete(id);
// Важливо: зіставляємо прикладний результат саме з HTTP-статусом.
if (deleted) {
sendNoContent(exchange);
} else {
exchange.sendResponseHeaders(404, -1);
exchange.close();
}
return true;
}
Зверніть увагу: жодного Content-Type, жодного objectMapper, жодного getResponseBody().write(...). Це не «лінощі», а дотримання контракту.
7. Відсутність ресурсу: 404 Not Found
Коли ви реалізуєте delete, майже завжди виникає питання: «якщо ресурсу немає, можливо, все одно повертати 204, щоб операція була ідемпотентною?» Це хороше питання, і воно показує, що ви справді думаєте про поведінку API, а не просто переписуєте шаблон.
Формально DELETE вважається ідемпотентним методом: повторний виклик не повинен далі змінювати стан системи. У нашому випадку це так: якщо елемент уже видалено, то повторний DELETE більше нічого не видалить.
Але ідемпотентність не вимагає, щоб відповідь була однаковою. Вона стосується ефекту на стан. Тому варіант 404 Not Found на повторний delete — допустимий і поширений. І він дуже зрозумілий клієнту: «видаляти нічого, бо такого ресурсу немає».
У межах курсу ми обираємо простішу й навчальнішу семантику: якщо ресурсу немає — 404. Якщо видалили — 204. І цю поведінку легко перевірити через Postman: після видалення GET за тим самим id теж повертатиме 404. У голові складається цілісна картина.
Найголовніше, чого точно не слід робити: повертати 200 OK з тілом «deleted: false». Це перетворює HTTP-статуси на декоративний елемент, а body — на «єдине джерело істини». Нам якраз важливе протилежне: в HTTP первинний зміст живе у статусі та методі.
8. Перевірка в Postman
Перевіряти delete зручно не у вакуумі, а в маленькому сценарії «створив → переконався → видалив → переконався». Тоді ви бачите, що змінюється стан in-memory сховища, а не просто «сервер повернув 204 і вдає».
Сценарій можна пройти так: спочатку ви робите POST /api/v1/reading-list і створюєте елемент. У відповідь сервер поверне 201 Created і, якщо ви все зробили акуратно, заголовок Location, наприклад /api/v1/reading-list/5. Це ваша «адреса» створеного ресурсу.
Потім ви робите GET /api/v1/reading-list/5 і переконуєтеся, що ресурс справді існує та віддається як JSON.
Після цього виконуєте DELETE /api/v1/reading-list/5. У Postman ви маєте побачити статус 204 No Content і порожнє тіло. Якщо Postman показує вам якийсь JSON — значить, ви десь порушили контракт 204 (або випадково написали body).
І фінальний штрих: знову робите GET /api/v1/reading-list/5. Тепер очікувана поведінка — 404 Not Found. А якщо ви зробите GET /api/v1/reading-list, то побачите, що count став меншим (і елемент зник з items). Ось це і є «видалення», а не просто красивий статус.
9. Типові помилки під час DELETE
Помилки в delete-ендпойнтах майже завжди дрібні, але неприємні тим, що ламають контракт або роблять поведінку API дивною для клієнта. Зараз ми зберемо найчастіші граблі саме в контексті HttpServer і нашого навчального проєкту. Це ті місця, де новачки зазвичай сперечаються з HTTP (і програють), або сперечаються з власним кодом (і теж програють, але вже за очками).
Помилка №1: повернути 204 No Content, але все одно надіслати JSON-body.
Таке часто трапляється з інерції: ви звикли робити objectMapper.writeValueAsBytes(...) і «завжди повертати JSON». Але 204 буквально означає «немає вмісту». Якщо ви все ж пишете body, ви суперечите статусу й ризикуєте отримати клієнтський код, який то чи парсить порожнечу, то чи парсить неочікувані байти, то чи починає підозрювати, що сервер «шалить».
Помилка №2: відповідати 200 OK і повертати видалений обʼєкт «на памʼять».
Іноді здається логічним: «ну раз видалили, давайте повернемо те, що видалили». Як ідея це не заборонено, але для нашого API це зайве. Клієнту набагато корисніше отримати короткий 204 і за потреби потім зробити GET (який поверне 404). А вам корисніше не тягнути DTO і серіалізацію в найпростіший endpoint.
Помилка №3: забути закрити exchange (або response body), особливо на гілці 204.
У HttpServer є своя «гігієна»: ви надіслали headers — закрийте exchange. Коли body відсутнє, це особливо легко забути, бо «писати нічого». Але з’єднання і ресурси все одно треба завершити коректно. Інакше ви отримаєте завислі запити та дуже дивну поведінку в Postman («висить, але ж 204 мав би бути швидким»).
Помилка №4: видаляти не за id, а за якоюсь іншою ознакою (наприклад, за externalId).
Контракт endpoint-а каже: видаляємо за {id}. Якщо всередині ви раптом вирішите видалити «за зовнішнім ідентифікатором» або «за назвою», клієнт буде певен, що видаляє ресурс id=5, а ви видалите інший. Це найнебезпечніша помилка: формально сервер відповідає успіхом, але фактично видаляє не те.
Помилка №5: змішати парсинг id і прикладну логіку так, що 400 і 404 починають плутатися.
Правильна логіка проста: якщо id не число — 400. Якщо число, але ресурсу немає — 404. Якщо видалили — 204. Новачки іноді роблять «будь-яка проблема = 404» або «будь-яка проблема = 400», бо так простіше. Але тоді клієнт не розуміє, що саме пішло не так: він помилився у форматі id чи просто видаляє неіснуючий ресурс.
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ