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/ із зайвим слешем). Наше завдання — спочатку отримати цей рядок, а вже потім вирішувати, що з ним робити.
Є маленький нюанс, який краще враховувати вже зараз, навіть у навчальному проєкті: іноді шлях може містити додаткові сегменти. Поки що в нас API лише для читання, але загалом хороші роути не люблять, коли всередині id раптом з’являється ще один /. Ми не будемо ускладнювати цю тему зараз, але пам’ятати про неї корисно: ручна маршрутизація любить акуратність.
3. Парсинг id і 400 Bad Request
Після того як ми виділили rawId, настає момент істини: перетворюємо рядок на long. Найпряміший спосіб — Long.parseLong(rawId). І тут уперше в нашому API це стає не просто «внутрішньою Java-помилкою», а частиною HTTP-контракту. Бо якщо парсинг падає, ми зобов’язані повернути не stack trace і не «ой», а коректну відповідь із 400.
Такий допоміжний метод корисно тримати в одному місці, а не збирати заново в кожній гілці. Тоді правило для abc, 0 і -1 не почне «плавати» між різними кінцевими точками.
Зробімо невеликий метод, який відповідає лише за одне: перетворити рядок на 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 має бути додатним числом");
}
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, якщо ви так домовилися). Сервіс відповідає на запитання: «Якщо об’єкт є, як перетворити його на DTO відповіді?». А handler відповідає на запитання: «Який статус-код і який JSON я маю повернути клієнту залежно від ситуації?».
Ця ланка добре виглядає навіть у вигляді простої схеми:
sequenceDiagram
participant C as "Клієнт (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 або null"
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);
}
}
Тепер зробімо невеликий допоміжний клас для формування ErrorResponse, щоб handler не потонув у рядках. Це не «архітектурна розкіш», а звичайна турбота про читабельність.
import java.util.List;
public class ErrorResponses {
public static ErrorResponse invalidId(String rawId) {
return new ErrorResponse(
"INVALID_ID",
"ID має бути додатним числом",
List.of(rawId)
);
}
public static ErrorResponse itemNotFound(long id) {
return new ErrorResponse(
"ITEM_NOT_FOUND",
"Елемент списку читання не знайдено",
List.of(String.valueOf(id))
);
}
}
Зверніть увагу: details у нас — список рядків. Це зручно, бо ви можете покласти туди «сирий» rawId, навіть якщо це abc. А в itemNotFound ми перетворюємо id на рядок через String.valueOf(), щоб тип details залишався єдиним і контракт не «стрибав».
Щоб не тримати все в голові, корисно один раз зафіксувати «матрицю результатів» у вигляді таблички:
| Ситуація | Приклад запиту | HTTP-статус | Що повертаємо в тілі відповіді |
|---|---|---|---|
| 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 має бути додатним числом",
"details": ["abc"]
}
Запит із коректним id, але відсутнім ресурсом:
GET /api/v1/reading-list/999 HTTP/1.1
Accept: application/json
Відповідь 404 Not Found:
{
"errorCode": "ITEM_NOT_FOUND",
"message": "Елемент списку читання не знайдено",
"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-відповідь сформувати». Коли статуси живуть у сервісі, ви швидко отримуєте кашу і дублювання логіки між різними кінцевими точками.
Помилка №4: повертати назовні доменний ReadingListItem замість ReadingItemResponse.
Сьогодні це може виглядати однаково, але завтра ви додасте у внутрішній об’єкт поле, яке не має йти назовні (наприклад, внутрішні технічні мітки), або зміните структуру, і раптом API «сам» змінився. Явний response DTO — це невелика плата за спокійну еволюцію коду.
Помилка №5: забути про Content-Type: application/json і дивуватися, чому клієнт «не бачить JSON».
Postman і браузери іноді намагаються здогадатися, що ви їм надіслали, але це ворожіння. Якщо ви віддаєте JSON, ставте Content-Type завжди. Найпростіший спосіб — мати один sendJson, який робить це незмінно, і не розмазувати налаштування заголовків по всьому handlerʼу.
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ