1. Валідаційна межа API
CRUD уже працює в happy-path-сценарії: список читається, елемент створюється, статус змінюється, а видалення теж іде за планом. Але happy-path ще не робить API дорослим. Щойно в ту саму кінцеву точку прилітає зламаний JSON, порожній title або дивний status, швидко з’ясовується: без жорсткої межі на вході контракт надто крихкий. Саме цю межу зараз і поставимо.
Коли ви писали консольні програми, часто виникало відчуття: «Ну користувач же нормальний, він уведе нормальні дані». У backend-середовищі це відчуття швидко розчиняється, як цукор у гарячому чаї, — і лишається тільки чай, а цукор... десь у логах. API приймає дані від зовнішнього світу, а зовнішній світ уміє помилятися, експериментувати і часом навіть вередувати.
Тому валідація — це не «багато if для зануд», а обовʼязковий етап на межі. Її сенс простий: поки ми ще не торкнулися сервісного шару і репозиторію, треба переконатися, що запит хоча б «має шанс» бути обробленим. Інакше ви отримуєте хаос: частина помилок виглядає як NullPointerException, частина — як «чому у нас статус став null», а частина — як мовчазні криві дані в памʼяті.
Якщо сказати трохи технічніше, валідаційна межа розв’язує два завдання. По-перше, захищає вашу систему від сміття на вході — і випадкового, і зловмисного. По-друге, робить поведінку API передбачуваною: однакові проблеми дають однакову відповідь, а клієнтові зрозуміло, що саме потрібно виправити.
2. Помилки JSON: синтаксис і зміст
На вході нашого API є дві схожі проблеми, які новачки часто змішують. І це нормально: на старті все, що не працює, здається «просто помилкою». Але для хорошого API важливо розрізняти причини.
Перший тип — malformed JSON. Це ситуація, коли body не можна навіть розібрати як JSON або не можна прочитати його в очікувану DTO-структуру. Типовий приклад: пропущена кома, зайва дужка, неекрановані лапки. Для сервера це означає буквально: «Я не розумію, що ви мені прислали».
Другий тип — семантично невалідний payload. JSON синтаксично коректний, Jackson його розпарсив, DTO-об’єкт створив, але за змістом дані погані. Наприклад, title порожній, author — лише з пробілів, status — "READING" (а ми домовилися про "PLANNED"/"IN_PROGRESS"/"FINISHED"). Це вже інша ситуація: «Я зрозумів структуру, але не приймаю вміст».
Щоб відчути різницю, порівняймо два тіла запиту.
Ось malformed JSON — зламана кома, і все, привіт:
{
"title": "Clean Code"
"author": "Robert C. Martin"
}
А ось JSON коректний, але семантично невалідний: структура є, а обовʼязкові поля не заповнені нормально.
{
"title": " ",
"author": "",
"status": "SOMEDAY_MAYBE"
}
Ззовні обидва випадки завершаться статусом 400 Bad Request — це помилка запиту, — але повідомлення і деталі мають бути різними. У першому випадку клієнтові потрібно «полагодити JSON», у другому — «полагодити значення».
І ще важливий момент: семантична невалідність — це не тільки «порожній рядок». Це ще й «не той формат», «не те значення», «не входить у допустимий набір», «обовʼязкове поле відсутнє». Саме цим ми зараз і займемося: акуратно, руками і без перетворення проєкту на гігантську validation-платформу.
3. Місце валідації в HttpServer
Коли ми працюємо з HttpServer, у коді обробника легко влаштувати «кашу»: трохи роутингу, трохи читання body, трохи бізнес-логіки, трохи запису відповіді — і все це в одному методі на 200 рядків. Такий метод, звісно, можна назвати handle(), бо він обробляє взагалі все, включно з вашою нервовою системою.
Нам важлива дисципліна: валідація має відбуватися після того, як ми визначили маршрут і прочитали body, але до того, як викликали ReadingListService. Тобто в той момент, коли в нас уже є DTO, але ще нічого не змінилося в системі.
Зручно тримати в голові такий спрощений потік — без деталей про 404/409 і без загального error contract; зараз нам важливий саме порядок кроків:
flowchart TD
A[HttpExchange] --> B["Маршрутизація: method + path"]
B --> C[Читання body як String]
C --> D[Парсинг JSON у Request DTO]
D --> E[Валідація полів DTO]
E -->|ok| F[Виклик сервісу]
E -->|errors| G[Повернення 400]
Якщо ви вставляєте валідацію після сервісу, ви отримуєте класичну біду: частина змін уже могла відбутися, або сервіс устиг «торкнутися» репозиторію, а потім ви раптово зрозуміли, що title був порожній. Так, наш in-memory репозиторій простий, але звичку «спочатку перевірка — потім дія» варто виробити саме тут, поки все маленьке й безпечне.
4. Правила валідації для Create/Update/Patch
Щоб не перетворити лекцію на філософську суперечку «а чи потрібно валідовувати пробіли в кінці рядка», ми зафіксуємо мінімально корисний набір правил, який справді робить API передбачуваним. Важлива ідея: набір правил відрізняється за операціями. POST і PUT працюють із «повним об’єктом», а PATCH /status — тільки зі статусом, і тягнути туди валідацію всіх інших полів безглуздо.
Нижче — компактна таблиця, щоб ви бачили «контракт правил» очима клієнта:
| Endpoint | DTO | Що вважаємо обовʼязковим | Що перевіряємо додатково |
|---|---|---|---|
| POST /api/v1/reading-list | CreateReadingItemRequest | title, author, status | status має бути одним із ReadingStatus, а externalId/comment, якщо їх задано, — не мають бути «порожніми значеннями» |
| PUT /api/v1/reading-list/{id} | UpdateReadingItemRequest | title, author, status | те саме, що й для create (це повна заміна) |
| PATCH /api/v1/reading-list/{id}/status | UpdateStatusRequest | status | status має бути валідним |
Зверніть увагу на важливу межу: тут немає перевірки «чи існує такий id» і немає перевірки «чи унікальний externalId». Бо це вже бізнес-зміст і стан системи. Валідація відповідає за якість вхідних даних, а 404 і 409 залежать від того, що лежить у репозиторії.
5. ReadingListValidator: збираємо всі помилки
Валідацію легко зробити «в лоб»: у handler-методі написати if (...) return; if (...) return; і жити далі. Але це швидко перетворюється на копіпасту й дивні баги, коли ви забули одну перевірку в одному endpoint-і, а в іншому — не забули. Тому краще виділити один маленький клас ReadingListValidator, який живе в пакеті readinglist.validation і вміє перевіряти різні DTO.
Почнемо з найприємнішої частини: helper-методу для рядків. У Java рядки мають три стани, які новачкам здаються однаковими, але для API це три різні проблеми: null, порожній рядок "" і рядок із пробілів " ". На вході API найчастіше ми хочемо вважати «порожнім значенням» усе, що є null або isBlank().
// "Порожнє значення" для API: null, порожній рядок і рядок із пробілів
boolean isBlank(String value) {
// isBlank() повертає true, якщо рядок порожній або складається лише з пробільних символів
return value == null || value.isBlank();
}
Тепер можна написати перевірку для CreateReadingItemRequest. Ми збираємо всі проблеми в List<String>, щоб клієнт отримав повний список «що виправити», а не гру «виправ одну помилку — отримай наступну».
import java.util.ArrayList;
import java.util.List;
List<String> validateForCreate(CreateReadingItemRequest request) {
// Збираємо всі помилки в один список, щоб клієнт виправив усе за один раз
List<String> details = new ArrayList<>();
// Обовʼязкові поля для create (POST): title, author, status
if (isBlank(request.title())) details.add("Поле title є обовʼязковим");
if (isBlank(request.author())) details.add("Поле author є обовʼязковим");
if (isBlank(request.status())) details.add("Поле status є обовʼязковим");
return details;
}
Поки це базова перевірка обовʼязкових полів. Але вже тут є хороша практика: метод нічого не кидає, не падає, не логує і не пише відповідь. Він просто повертає список проблем. Це робить його дуже зручним: його можна повторно використовувати в різних handler-гілках, і він лишається «чистим».
Для UpdateReadingItemRequest правила майже такі самі, бо PUT у нас — повна заміна, а не «частково оновити тільки те, що прийшло».
import java.util.ArrayList;
import java.util.List;
List<String> validateForUpdate(UpdateReadingItemRequest request) {
// PUT = повна заміна ресурсу, тому обовʼязкові поля ті самі, що й у create
List<String> details = new ArrayList<>();
if (isBlank(request.title())) details.add("Поле title є обовʼязковим");
if (isBlank(request.author())) details.add("Поле author є обовʼязковим");
if (isBlank(request.status())) details.add("Поле status є обовʼязковим");
return details;
}
Для PATCH /status, навпаки, не треба перевіряти title/author, бо їх просто немає в DTO. Валідація зобовʼязана відповідати формі запиту — інакше ви будете сваритися на поле, яке клієнт фізично не міг надіслати, і це виглядатиме так, ніби сервер не в собі.
import java.util.ArrayList;
import java.util.List;
List<String> validateForStatusPatch(UpdateStatusRequest request) {
// PATCH /status працює тільки зі статусом: решту полів не валідовуємо
List<String> details = new ArrayList<>();
if (isBlank(request.status())) details.add("Поле status є обовʼязковим");
return details;
}
Ще один маленький, але корисний штрих: externalId і comment у нас необовʼязкові. Але часто корисно перевіряти так: якщо поле задано, то воно має бути змістовним. Тобто externalId = "" краще вважати помилкою, а не «ну гаразд, нехай буде».
if (request.externalId() != null && request.externalId().isBlank()) {
details.add("Якщо externalId задано, воно не повинно бути порожнім");
}
Тут важливо не перегнути: ми не перетворюємо comment на обовʼязкове поле, ми просто забороняємо «порожнє значення», яке виглядає як задане значення, але сенсу не несе.
6. Валідація status і enum
Статус — класичний приклад поля, яке «ніби рядок», але насправді воно з обмеженого набору значень. У доменній моделі в нас є enum ReadingStatus, і завдання валідації — переконатися, що рядок із JSON справді може стати цим enum-ом.
Найпряміший спосіб — ReadingStatus.valueOf(rawStatus). Він кидає IllegalArgumentException, якщо значення невідоме. Ми акуратно ловимо це і перетворюємо на false.
import com.example.readlater.readinglist.domain.ReadingStatus;
boolean isValidStatus(String rawStatus) {
// Порожні значення одразу вважаємо невалідними: це або "немає статусу", або "порожнє значення"
if (rawStatus == null || rawStatus.isBlank()) return false;
try {
// valueOf працює лише для наперед відомих значень enum (і чутливий до регістру)
ReadingStatus.valueOf(rawStatus);
return true;
} catch (IllegalArgumentException ex) {
// Невідомий статус — невалідний
return false;
}
}
Так, valueOf() чутливий до регістру. Якщо клієнт надішле "planned", це буде невалідно. Можна «помʼякшити» API і робити rawStatus.trim().toUpperCase(), але тоді ви берете на себе рішення: «Ми дозволяємо такі вольності». У навчальному проєкті розумно тримати контракт суворим: клієнт має надсилати рівно ті значення, які ви задекларували.
Тепер валідація create/update/patch може додатково перевіряти саме статус:
// Спочатку переконуємося, що статус узагалі заданий (інакше повідомлення буде про обовʼязковість поля)
if (!isBlank(request.status()) && !isValidStatus(request.status())) {
// Якщо статус заданий, але не входить в enum, повертаємо зрозумілий перелік допустимих значень
details.add("Поле status має бути одним із: PLANNED, IN_PROGRESS, FINISHED");
}
Зверніть увагу на маленьку хитрість: ми перевіряємо isBlank() окремо, щоб не додавати дві помилки одразу («поле обовʼязкове» і «поле має бути одним із...»). Це вже про якість повідомлення клієнтові: помилок не має бути занадто багато, і вони не мають дублювати одна одну.
7. malformed JSON у Jackson
Коли ми використовуємо ObjectMapper.readValue(...), є момент, який важливо відчути: Jackson — не валідатор вашого змісту, а парсер і мапер. Він відповідає за те, щоб перетворити рядок JSON на об’єкт. Якщо JSON синтаксично зламаний або взагалі не відповідає очікуваній структурі, він кине виняток, і до ваших перевірок «обовʼязкових полів» ви навіть не дійдете.
На практиці зручно розділяти ці два етапи прямо в коді handler-а: окремо try/catch для читання JSON, окремо — перевірка DTO.
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.ObjectMapper;
CreateReadingItemRequest parseCreate(String body, ObjectMapper mapper) {
try {
// Парсимо JSON -> DTO. Тут ловимо саме "формат/структуру JSON"
return mapper.readValue(body, CreateReadingItemRequest.class);
} catch (JsonProcessingException ex) {
// Перетворюємо виняток Jackson на "наш", щоб далі однаково обробляти помилки
throw new IllegalArgumentException("Некоректний JSON", ex);
}
}
Тут IllegalArgumentException потрібен лише як локальний маркер: parsing-помилка вже відділена від помилок значень. На одному endpoint-і це ще терпимо, але щойно таких гілок стає кілька, їх краще збирати в єдиний error contract і власні типи винятків, а не тримати набір розрізнених write...Error(...).
До речі, дуже життєвий кейс — порожнє тіло запиту. Для POST/PUT/PATCH це зазвичай теж 400, бо ви очікуєте JSON, а отримали «нічого». Jackson може поскаржитися винятком мапінгу. Логічно це все одно означає: «Не вдалося прочитати очікуваний JSON».
Ще один практичний штрих: корисно читати body в UTF-8, інакше можна несподівано отримати «крякозябри» в коментарях.
import java.nio.charset.StandardCharsets;
// Читаємо request body як UTF-8, щоб кирилиця не перетворилася на "крякозябри"
String body = new String(exchange.getRequestBody().readAllBytes(), StandardCharsets.UTF_8);
8. Валідація в handler: до сервісу
Тепер зберемо шматки в міні-скелет методу «створити елемент списку». Тут важливе саме розташування кроків: прочитали body, розпарсили JSON, провалідовували DTO, і тільки потім викликаємо сервіс. Якщо є помилки — повертаємо 400 одразу, не торкаючись бізнес-логіки.
Поки зробимо це максимально прямолінійно. Такий варіант добре показує сам порядок кроків, але довго жити в такому вигляді не має: якщо залишити локальні writeValidationError(...) у кожному handler-і, копіпаста буде розмножуватися швидше за кроликів.
import com.fasterxml.jackson.core.JsonProcessingException;
import com.sun.net.httpserver.HttpExchange;
import java.nio.charset.StandardCharsets;
import java.util.List;
void handleCreate(HttpExchange exchange) throws Exception {
// 1) Читаємо тіло запиту
String body = new String(exchange.getRequestBody().readAllBytes(), StandardCharsets.UTF_8);
// 2) Парсимо JSON -> DTO (якщо JSON зламаний, тут буде виняток)
CreateReadingItemRequest request = objectMapper.readValue(body, CreateReadingItemRequest.class);
// 3) Семантична валідація полів DTO (обовʼязковість, формат, допустимі значення)
List<String> errors = validator.validateForCreate(request);
if (!errors.isEmpty()) {
// Якщо є помилки, бізнес-логіку не чіпаємо — одразу відповідаємо 400
writeValidationError(exchange, errors);
return;
}
// 4) Викликаємо сервіс тільки після успішної валідації
writeJson(exchange, 201, service.create(request));
}
Такий варіант корисний саме тим, що явно показує порядок кроків. Але він проміжний: сенс межі вже видно, а самі гілки помилок поки що ще надто локальні.
Що тут важливо помітити.
По-перше, якщо JSON зламаний, readValue() кине виняток, і ви зобовʼязані перехопити його на рівні handler-а (або трохи вище) та повернути 400. Якщо ви цього не зробите, клієнт отримає 500, а це буде неправда: проблема в запиті, а не в сервері.
По-друге, validateForCreate() повертає список. Це дає змогу показати клієнтові одразу кілька проблем. На практиці це дуже економить час: інакше клієнт виправляє title, надсилає знову, отримує помилку про status, виправляє status, надсилає знову... і так далі, поки в нього не закінчиться кава.
По-третє, service.create(request) ми викликаємо тільки після того, як упевнені: запит хоча б базово коректний. Усе, що повʼязане з 409 Conflict (унікальність externalId) і іншими бізнес-правилами, лишається в сервісі. А валідація — це фільтр на вході.
Якщо хочеться побачити «розділення» malformed JSON і semantic validation прямо в коді, можна додати окремий catch:
try {
// Намагаємося прочитати JSON і отримати DTO
CreateReadingItemRequest req = objectMapper.readValue(body, CreateReadingItemRequest.class);
// Якщо JSON прочитався — валідовуємо зміст (поля DTO)
List<String> errors = validator.validateForCreate(req);
if (!errors.isEmpty()) {
writeValidationError(exchange, errors);
return;
}
// Лише після цього створюємо ресурс
writeJson(exchange, 201, service.create(req));
} catch (JsonProcessingException ex) {
// Ця гілка — лише про "не змогли прочитати JSON"
writeMalformedJsonError(exchange, ex.getOriginalMessage());
}
Тут getOriginalMessage() корисний лише як локальна підказка, поки ми розбираємо саму розвилку. Для стійкого API назовні краще не тягнути сирий текст Jackson, а віддавати нормалізований errorCode і стабільне повідомлення.
Так, тут уже дві гілки, але зате поведінка API стає чесною: «JSON не прочитали» і «прочитали, але не прийняли» — різні відповіді. А щойно таких гілок стає кілька, їх уже хочеться збирати через єдиний ErrorResponse, а не розносити по handler-ах.
9. Межа валідації та 404/409
На цьому етапі дуже легко почати «валити все у валідацію», бо думка проста: «Ну якщо ми перевіряємо, давайте перевіримо взагалі все». І ось тут зʼявляється пастка: валідація не має перетворюватися на бізнес-логіку.
Якщо клієнт надіслав коректний JSON, але id в URL не існує, це не проблема якості payload-а. Це проблема стану системи: ресурсу немає. Отже, це 404 Not Found, і вирішує це зазвичай сервіс або репозиторій, бо лише вони знають, що лежить у даних.
Якщо клієнт надіслав коректний JSON, але externalId конфліктує з уже наявним елементом, це знову не «неправильний JSON» і не «необовʼязкове поле». Це конфлікт бізнес-правила — унікальності, тобто 409 Conflict. І знову ж таки, це залежить від даних у репозиторії, а не від того, «порожній рядок» це чи ні.
Найздоровіший спосіб тримати цю межу в голові звучить так: валідація відповідає на запитання «чи можна цей запит узагалі намагатися обробити», а сервіс відповідає на запитання «що вийде, якщо ми спробуємо». І якщо ви будете змішувати ці рівні, то через тиждень у вас почнеться веселий цирк: частина перевірок унікальності опиниться в одному endpoint-і, частина — в іншому, а частина — взагалі загубиться.
10. Типові помилки під час валідації
Помилка №1: змішати зламаний JSON і порожній title в одну категорію «валідація».
Якщо JSON не читається, у вас немає DTO, і ви не можете перевіряти поля. Це окрема гілка 400, яка каже: «Виправте формат JSON». Семантична валідація починається тільки після успішного readValue().
Помилка №2: валідовувати вже після виклику service-логіки.
Так ви ризикуєте «майже обробити запит», змінити стан — нехай навіть у памʼяті — а потім раптово зрозуміти, що дані погані. Правильна послідовність: спочатку parsing, потім validation, і лише тоді — сервіс.
Помилка №3: перевіряти лише null, але пропускати рядки з пробілів.
В API це дуже часта проблема: клієнт може надіслати "title": " ". З погляду JSON це рядок, з погляду Java це рядок, але з погляду змісту — порожнеча. Для таких полів використовуйте isBlank().
Помилка №4: застосовувати правила POST/PUT до PATCH /status.
PATCH оновлює тільки статус, отже ви валідовуєте тільки статус. Інакше ви почнете видавати клієнтові помилки про author, якого в запиті взагалі немає, і це виглядає як баг сервера.
Помилка №5: намагатися перевіряти у валідаторі те, що залежить від репозиторію.
Унікальність externalId і існування ресурсу за id — це не «якість вхідного JSON», а бізнес-стан. Ці перевірки мають жити в сервісі або репозиторії та приводити до 404/409, а не до 400 «валідації».
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ