1. Эндпоинт GET /api/v1/reading-list/{id}
Если в GET /api/v1/reading-list мы читаем коллекцию, то GET /api/v1/reading-list/{id} — это запрос к одному ресурсу. Звучит как мелочь, но на практике это две разные «ментальные операции»: список можно вернуть пустым, а один элемент либо найден, либо нет. Сейчас мы аккуратно проведём эту разницу через код и статусы, чтобы контракт был предсказуемым.
Для коллекции естественно думать так: «дай мне все элементы, возможно, их ноль». Поэтому пустой список в JSON — это просто «в данный момент у пользователя нет книг в списке», и это не ошибка. Для одного элемента мысль другая: «дай мне конкретную книгу с конкретным id». И здесь пустота обычно означает проблему: либо клиент прислал мусор вместо id, либо ресурс действительно отсутствует.
Представьте, что id — это номерок из гардероба. В запросе GET /api/v1/reading-list/2 клиент говорит: «Верни мне номерок 2». Если он говорит .../abc, это как прийти к гардеробщику и заявить: «Мне номерок “абвгдейка”». Гардеробщик вежливо (или не очень) объяснит, что номерок должен быть числом — это 400 Bad Request. А если клиент говорит .../999, а такого номерка никогда не выдавали, то это 404 Not Found: запрос понятный, номер формально корректный, но объекта нет.
Эта логика очень полезна для клиента. Клиент (Postman, фронтенд, другой сервис) получает от нас понятный сигнал: «ты ошибся в формате» или «ты запросил то, чего нет». И дальше клиент может реагировать по‑разному — например, исправить ввод пользователя или показать «элемент не найден».
2. Извлечение id из пути
Когда начинающий пишет обработчик GET /...{id}, часто возникает соблазн: «ну там же где-то в URL есть цифра, давайте просто Long.parseLong(path)». Это, конечно, эффектно — как прыгнуть в реку с ноутбуком: внимание вы точно привлечёте, но счастья не будет. Нам нужно отделить стабильный префикс пути от того кусочка, который действительно является id.
Мы заранее знаем базовый путь ресурса: /api/v1/reading-list/. Всё, что идёт после него, — кандидат на id. Поэтому самый простой и достаточно честный способ — взять substring от длины префикса. Да, это «ручная работа», но мы же здесь как раз за тем, чтобы увидеть механику, а не спрятать её под ковёр.
Простой помощник для извлечения «сырого id» из пути может выглядеть так:
import com.sun.net.httpserver.HttpExchange;
private static final String ITEM_PREFIX = "/api/v1/reading-list/"; // Важно: префикс заканчивается на '/'
private String extractRawId(HttpExchange exchange) {
// Достаём путь без query-параметров (только path часть URI)
String path = exchange.getRequestURI().getPath();
// Берём «хвост» после известного префикса: это кандидат на id (пока ещё строка!)
return path.substring(ITEM_PREFIX.length());
}
Здесь важна мысль: мы пока не утверждаем, что rawId корректный. Мы честно признаём: это строка из URL. Она может быть 2, может быть abc, может быть даже пустой (например, если клиент пришёл на /api/v1/reading-list/ с лишним слэшем). Наша задача — сначала получить эту строку, а потом уже решать, что с ней делать.
Есть маленький нюанс, который лучше учитывать уже сейчас, даже в учебном проекте: иногда путь может содержать дополнительные сегменты. Пока у нас read-only API, но в целом хорошие роуты не любят, когда «внутри id» внезапно появляется ещё один /. Мы не будем усложнять эту тему сейчас, но помнить о ней полезно: ручная маршрутизация любит аккуратность.
3. Парсинг id и 400 Bad Request
После того как мы выделили rawId, наступает момент истины: превращаем строку в long. Самый прямой способ — Long.parseLong(rawId). И тут впервые в нашем API это становится не просто «внутренней Java-ошибкой», а частью HTTP-контракта. Потому что если парсинг падает, мы обязаны вернуть не stack trace и не «ой», а корректный ответ с 400.
Такой helper полезно держать один раз, а не собирать заново в каждой ветке. Тогда правило для abc, 0 и -1 не начнёт «плавать» между разными endpoint-ами.
Сделаем маленький метод, который отвечает только за одно: «преобразовать строку в long и, по желанию, проверить, что id положительный». Даже если вы позже решите, что 0 — допустимый id (в нашем проекте, скорее всего, нет), вся логика будет в одном месте.
private long parseIdOrThrow(String rawId) {
// 1) Пробуем превратить строку из URL в число
// Здесь может прилететь NumberFormatException — это будет основанием для HTTP 400
long id = Long.parseLong(rawId);
// 2) В нашем проекте id начинаются с 1, поэтому 0 и отрицательные значения считаем невалидными
// Здесь специально бросаем IllegalArgumentException, чтобы handler превратил это в HTTP 400
if (id <= 0) {
throw new IllegalArgumentException("id must be positive");
}
return id;
}
Почему здесь уместна проверка id <= 0? Потому что наш репозиторий генерирует id через AtomicLong.incrementAndGet(), то есть реальные элементы начинаются с 1. Значит 0 и отрицательные числа — это не «редкие, но валидные значения», а почти всегда ошибка клиента. И лучше сказать это клиенту явно, чем молча вернуть 404 и заставить его гадать, что произошло.
Теперь главное: где именно превращать исключение в 400? Не в репозитории, не в сервисе, а в handler. Потому что именно handler — хозяин HTTP-семантики. Внутри Java-кода исключение — это просто «не получилось распарсить». А вот HTTP-уровень должен выбрать, что говорить наружу.
4. Роли: 200, 400, 404
Когда мы строим API «вручную», легко случайно смешать всё со всем. Особенно хочется сделать так: «репозиторий не нашёл — пусть он сразу и вернёт 404». Это выглядит логично, пока вы не поймёте, что репозиторий вообще не должен знать, что такое 404. Репозиторий живёт в мире данных, сервис — в мире прикладной логики, handler — в мире HTTP.
Если удерживать эти роли, код получается спокойнее и предсказуемее. Репозиторий отвечает на вопрос: «Есть ли объект с таким id?». Он возвращает ReadingListItem или null (или Optional, если вы так договорились). Сервис отвечает на вопрос: «Если объект есть, как его превратить в response DTO?». А handler отвечает на вопрос: «Какой статус код и какой JSON я должен вернуть клиенту в зависимости от ситуации?».
Эта цепочка хорошо смотрится даже в виде простой схемы:
sequenceDiagram
participant C as "Client (Postman)"
participant H as ReadingListHttpHandler
participant S as ReadingListService
participant R as InMemoryReadingListRepository
C->>H: GET /api/v1/reading-list/2
H->>H: "extractRawId + parseId"
H->>S: getById(2)
S->>R: findById(2)
R-->>S: "ReadingListItem или null"
S-->>H: "ReadingItemResponse или пусто"
H-->>C: "200 / 404 / 400 + JSON"
Обратите внимание на важный психологический момент: 404 — это не «ошибка сервиса», это корректный результат обработки запроса. То есть у нас не должно быть ощущения «что-то сломалось». Ничего не сломалось: клиент спросил ресурс, которого нет. Мы спокойно и честно ответили.
А вот 400 — это тоже не «мы плохие», это «клиент прислал запрос, который мы не можем обработать, потому что он не соответствует контракту». И это, кстати, отлично защищает ваш API от странных входов: вы не пытаетесь «угадывать смысл» строки abc, вы говорите «нельзя».
5. Сервис getById и ReadingItemResponse
Сейчас мы реализуем метод сервиса, который делает две вещи: просит репозиторий найти элемент и, если элемент найден, маппит его в ReadingItemResponse. Идея простая: handler не должен знать, какие поля у доменного объекта и как их копировать. Handler должен заниматься HTTP: получить запрос, выбрать статус, отдать JSON.
Тот же ReadingListService, который уже собирал ответ коллекции, можно расширить методом getById(...). Отдельный mapper здесь не нужен: тот же toResponse(...) уже задаёт внешний контракт read-only ветки.
Вариант через Optional получается очень читаемым: сервис явно говорит «может быть найдено, а может и нет».
import java.util.Optional;
public Optional<ReadingItemResponse> getById(long id) {
// Репозиторий может вернуть null, поэтому аккуратно оборачиваем в Optional
return Optional.ofNullable(repository.findById(id))
// Если сущность нашлась — превращаем её в DTO для ответа
.map(this::toResponse);
}
В этом коде нет ни слова про HTTP. Он не выбирает между 404 и 400, не формирует ErrorResponse. Он просто возвращает «есть ответ или нет». А уже handler решит, что делать с этим отсутствием.
Отдельно подчеркну: соблазн «давайте сервис сам бросит исключение, а handler пусть ловит» иногда появляется. Но на текущем этапе лучше держать сервис максимально спокойным: сервис — это не “exception factory”, это прикладной слой. Он не должен постоянно «стрелять» наружу в каждой ветке.
6. Handler handleGetById: 3 исхода
В handler мы соединяем всё вместе: достаём rawId, пытаемся распарсить, вызываем сервис, и дальше выбираем статус. Здесь важно не превратить код в 80-строчную «простыню», где и маршрутизация, и парсинг, и сериализация, и бизнес-логика одновременно. Секрет в том, чтобы держать каждый шаг маленьким и читаемым.
Предположим, что общий sendJson(...) уже умеет сериализовать объект в JSON и выставить Content-Type. Тогда handler может выглядеть очень компактно. Важный трюк тут один: try охватывает только парсинг и валидацию id. Тогда мы не маскируем любой внутренний баг под INVALID_ID.
import com.sun.net.httpserver.HttpExchange;
import java.io.IOException;
private void handleGetById(HttpExchange exchange) throws IOException {
// 1) Достаём id из URL как строку (пока без попыток «угадать» что там)
String rawId = extractRawId(exchange);
long id;
try {
// 2) Превращаем rawId в long и валидируем (ошибка -> HTTP 400)
id = parseIdOrThrow(rawId);
} catch (IllegalArgumentException e) {
sendJson(exchange, 400, ErrorResponses.invalidId(rawId), mapper);
return;
}
// 3) Просим сервис вернуть DTO (или пусто, если в репозитории нет элемента)
ReadingItemResponse body = service.getById(id).orElse(null);
// 4) Выбираем HTTP-статус в зависимости от результата
if (body == null) {
sendJson(exchange, 404, ErrorResponses.itemNotFound(id), mapper);
} else {
sendJson(exchange, 200, body, mapper);
}
}
Теперь сделаем маленький helper-класс для формирования ErrorResponse, чтобы handler не утонул в строках. Это не «архитектурная роскошь», а обычная забота о читаемости.
import java.util.List;
public class ErrorResponses {
public static ErrorResponse invalidId(String rawId) {
return new ErrorResponse(
"INVALID_ID",
"Id must be a positive number",
List.of(rawId)
);
}
public static ErrorResponse itemNotFound(long id) {
return new ErrorResponse(
"ITEM_NOT_FOUND",
"Reading list item not found",
List.of(String.valueOf(id))
);
}
}
Обратите внимание: details у нас список строк. Это удобно, потому что вы можете туда положить «сырой» rawId, даже если это abc. А в notFound мы превращаем id в строку через String.valueOf(), чтобы тип details оставался единым и контракт не «прыгал».
Чтобы не держать всё в голове, полезно один раз зафиксировать «матрицу исходов» в виде таблички:
| Ситуация | Пример запроса | HTTP статус | Что возвращаем в body |
|---|---|---|---|
| id распарсился, элемент найден | GET /api/v1/reading-list/2 | 200 OK | ReadingItemResponse |
| id не распарсился или не проходит проверку | GET /api/v1/reading-list/abc | 400 Bad Request | ErrorResponse(INVALID_ID, ...) |
| id распарсился, элемента нет | GET /api/v1/reading-list/999 | 404 Not Found | ErrorResponse(ITEM_NOT_FOUND, ...) |
И вот эта табличка — ваш «контракт». Клиенту теперь очень легко жить.
7. Примеры запросов и JSON‑ответов
Код — это хорошо, но API живёт не в коде, а в том, что видит клиент. Поэтому полезно один раз посмотреть глазами клиента: какие запросы он делает и какие ответы получает. Это сразу отлавливает 90% «ой, мы забыли статус», «ой, у нас не JSON», «ой, мы возвращаем не то».
Успешный запрос:
GET /api/v1/reading-list/1 HTTP/1.1
Accept: application/json
Ответ 200 OK:
{
"id": 1,
"title": "Clean Code",
"author": "Robert C. Martin",
"status": "PLANNED",
"externalId": "OL12345M",
"comment": null
}
Запрос с невалидным id:
GET /api/v1/reading-list/abc HTTP/1.1
Accept: application/json
Ответ 400 Bad Request:
{
"errorCode": "INVALID_ID",
"message": "Id must be a positive number",
"details": ["abc"]
}
Запрос с корректным id, но отсутствующим ресурсом:
GET /api/v1/reading-list/999 HTTP/1.1
Accept: application/json
Ответ 404 Not Found:
{
"errorCode": "ITEM_NOT_FOUND",
"message": "Reading list item not found",
"details": ["999"]
}
Почему важно, что ошибки тоже в JSON? Потому что клиенту проще. Ему не нужно угадывать, что мы иногда отдаём HTML, иногда текст, иногда JSON. Он всегда получает предсказуемую форму и может, например, показать message пользователю, а details — записать в лог или подсветить поле ввода.
И маленькое замечание из мира реальных проектов: если вы сейчас сделаете контракт аккуратным, вы позже (даже без фреймворков) сможете строить автоматическую обработку ошибок на клиенте. Но это уже бонус. Нам сейчас важнее дисциплина.
8. Нюансы: где API капризничает
Есть несколько «классических» мест, где начинающий API легко делает себе боль, даже если общий сценарий уже работает. И проблема в том, что эти места обычно не падают сразу. Они начинают вылезать в виде странных ситуаций: то 400 не возвращается, то 404 в неправильном месте, то один и тот же запрос «то работает, то нет».
Первый нюанс — выделение rawId. Если вы случайно используете неверный префикс или забываете про слэш в конце, то substring начнёт отдавать не то. В такие моменты очень помогает держать префикс в константе (ITEM_PREFIX) и использовать её и в роутинге, и в извлечении id. Так меньше шансов, что вы в одном месте написали /api/v1/reading-list/, а в другом — /api/v1/reading-list.
Второй нюанс — поведение с лишними пробелами или странными символами. В path обычно пробелы не приходят «как есть», они кодируются, и в реальном вебе это отдельная история. Мы в учебном проекте не будем превращать лекцию в курс «URI encoding», но базовая идея такая: не пытайтесь «подчищать» rawId через trim(). Если клиент прислал странный путь, лучше честно вернуть 400, чем пытаться угадывать, что он имел в виду.
Третий нюанс — “404 vs 400”. Очень распространённая ошибка: вернуть 404 на abc, 0 или -1, потому что «элемента с таким id нет». Формально в этом есть житейская логика, но семантически это ложь: у ресурса должен быть корректный положительный числовой id, значит запрос не соответствует контракту, и это 400. Клиенту от этого гораздо легче: он понимает, что нужно исправить запрос, а не искать другой id.
Четвёртый нюанс — не отдавать наружу domain‑модель. Иногда лень писать маппинг, и хочется вернуть ReadingListItem напрямую. Но domain‑модель — это внутренняя сущность приложения, она может меняться по внутренним причинам. Ответ наружу — это контракт. Если вы начнёте смешивать эти уровни, вы потом будете бояться менять внутренности, потому что «сломается API».
И последний нюанс — закрытие response body. В HttpExchange хорошо делать try-with-resources или явно закрывать exchange.getResponseBody(). Если вы забудете, иногда всё равно «как-то работает», но может накопиться мусор. Мы это уже обсуждали в инфраструктуре ответа, поэтому здесь просто напомню: если у вас sendJson — единое место отправки ответа, пусть оно и отвечает за корректное закрытие потока.
9. Типичные ошибки в GET /api/v1/reading-list/{id}
Ошибка №1: возвращать 404 для нечислового или неположительного id (например, abc, 0, -1).
Это выглядит «логично», пока вы не вспомните, что 404 означает «ресурс не найден», а не «я не понял ваш запрос». Если id должен быть положительным числом, то abc, 0 и -1 — это нарушение контракта запроса, и корректнее отвечать 400 Bad Request. Так клиенту проще понять, что нужно исправить.
Ошибка №2: парсить весь path, а не выделенный сегмент id.
Когда в коде появляется Long.parseLong(exchange.getRequestURI().getPath()), это почти гарантированный билет в клуб NumberFormatException. Путь содержит слэши и буквы, и парсить нужно только ту часть, которая реально является id. Лучшее лекарство — константа префикса и substring после неё.
Ошибка №3: переносить HTTP‑семантику (статусы, ErrorResponse) в сервис или репозиторий.
Кажется удобным, чтобы репозиторий «сам решал», что вернуть, но это ломает границы. Репозиторий должен сказать «нашёл/не нашёл», сервис — «как представить данные», handler — «какой HTTP‑ответ сформировать». Когда статусы живут в сервисе, вы быстро получаете кашу и дублирование логики между разными endpoint’ами.
Ошибка №4: возвращать наружу доменный ReadingListItem вместо ReadingItemResponse.
Сегодня это может выглядеть одинаково, но завтра вы добавите во внутренний объект поле, которое не должно уходить наружу (например, внутренние технические метки), или поменяете структуру, и внезапно API «само» поменялось. Явный response DTO — это маленькая плата за спокойную эволюцию кода.
Ошибка №5: забыть про Content-Type: application/json и удивляться, почему клиент «не видит JSON».
Postman и браузеры иногда пытаются догадаться, что вы им отправили, но это гадание. Если вы отдаёте JSON, ставьте Content-Type всегда. Самый простой способ — иметь один sendJson, который делает это неизменно, и не размазывать настройку заголовков по всему handler’у.
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ