JavaRush /Курси /Java Server /Вхідна межа: JSON і...

Вхідна межа: JSON і валідація

Java Server
Рівень 26 , Лекція 0
Відкрита

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 «валідації».

Коментарі
ЩОБ ПОДИВИТИСЯ ВСІ КОМЕНТАРІ АБО ЗАЛИШИТИ КОМЕНТАР,
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ