DELETE /api/v1/reading-list/ {id}: 204

Java Server
25 уровень , 4 лекция
Открыта

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 хранилища. Да, после перезапуска приложения всё равно всё исчезнет — и это в рамках bridge-курса нормально. Зато вы увидите чистую механику 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 error-contract имеет смысл собирать сразу на весь 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”, но давайте проговорим именно репозиторную сторону — потому что у нас есть правило: handler не должен напрямую лазить в Map, а сервис не должен “знать” про детали хранения. Репозиторий — это слой, где “хранение” оформлено как простые операции: найти, сохранить, удалить.

Контракт репозитория здесь тоже подрастает на ещё один прямой метод:

public interface ReadingListRepository {
    // ... методы, которые уже есть для create/read/update

    // Для DELETE нужен один вопрос к хранилищу: удалось ли убрать ресурс по id?
    boolean deleteById(long id);
}

В Map<Long, ReadingListItem> удаление обычно выглядит так: remove(id) возвращает удалённое значение или null, если такого ключа не было. Нам удобно превратить это в boolean, потому что handler-у важен именно ответ на вопрос “удалилось или нет”.

Мини-фрагмент репозитория:

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, handler превратит это в 204/404.

Если вы где-то по пути решите, что репозиторий должен возвращать удалённый объект (например, Optional<ReadingListItem>), это тоже рабочий вариант. Но для delete-endpoint-а это редко нужно: клиент всё равно не ждёт body, и мы только усложним себе жизнь без особой выгоды.

4. Сервис: операция удаления

Сервисный слой иногда кажется “лишним” в маленьком приложении: “да я же могу удалить прямо в handler-е”. Но ровно на этом месте у новичков начинается медленная, но уверенная деградация проекта: handler превращается в огромный монолит, где и HTTP, и бизнес-правила, и хранение, и вообще “всё”.

Даже если delete-операция сейчас проста, сервис нужен как место, где живёт прикладная операция. Сегодня она одна строка. Завтра она станет “перед удалением проверь ограничения”. И вы будете рады, что это изменение не размазано по роутингу.

Пример метода сервиса с минимальным логированием результата (в разумных рамках):

import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

public boolean delete(long id) {
    // Репозиторий сообщает только результат: удалили или нет (без исключений и без DTO).
    boolean deleted = repository.deleteById(id);

    // В лог кладём факты (id и итог), чтобы потом можно было быстро отладить поведение.
    log.info("Delete reading list item: id={}, deleted={}", id, deleted);

    return deleted;
}

Обратите внимание на формат логов: id и deleted — это полезные факты. Мы не пишем “удаляем элемент списка чтения” десять раз в разных местах; пишем один раз и по делу.

И да, сервис не должен видеть HttpExchange. Как только вы передали в сервис HttpExchange, вы смешали web-слой и прикладную логику. Это как взять инструктора по вождению и посадить его на пассажирское сиденье велосипеда: вроде можно, но зачем.

5. Роутинг и handler DELETE

Удаление — идеальный пример того, что HttpServer не делает за нас магию. В Spring MVC вы бы написали @DeleteMapping("/api/v1/reading-list/{id}") и получили long id как параметр метода. Здесь же мы сами должны убедиться, что пришёл нужный метод, нужный путь, а id вообще похож на число.

Это всё ещё тот же ReadingListHttpHandler: просто к POST, PUT и PATCH добавляется ещё одна ветка, которая отвечает только за delete-семантику.

Допустим, в вашем handler-е есть общий 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);
}

Здесь мы не используем regex, чтобы код оставался “читаемым глазами”. Регулярки — штука мощная, но для новичка они часто превращаются в заклинание. Можно, конечно, делать и через 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 не будет, и можно не открывать поток записи.

Сделаем маленький helper, чтобы не повторять один и тот же код:

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-endpoint-е почти всегда “мелкие”, но они неприятны тем, что ломают контракт либо делают поведение 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 или просто удаляет несуществующий ресурс.

1
Задача
Java Server, 25 уровень, 4 лекция
Недоступна
Удаление заметки с ответом 204 No Content
Удаление заметки с ответом 204 No Content
1
Задача
Java Server, 25 уровень, 4 лекция
Недоступна
Удаление cart-item с различием 400 и 404
Удаление cart-item с различием 400 и 404
1
Опрос
REST API, 25 уровень, 4 лекция
Недоступен
REST API
Создание и изменение ресурсов
Комментарии
ЧТОБЫ ПОСМОТРЕТЬ ВСЕ КОММЕНТАРИИ ИЛИ ОСТАВИТЬ КОММЕНТАРИЙ,
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ