1. Семантика: порожньо, 404 і 400
Дуже легко сплутати ці три сценарії, бо зовні вони всі схожі на «ну… щось не вийшло». Але для API це різні історії, і кожна має мати свій статус та свій формат відповіді. Уявіть собі бібліотеку: GET /tasks — це «покажи каталог», а GET /tasks/{id} — це «дай конкретну книжку з полиці за інвентарним номером». Якщо книжок у каталозі немає, бібліотека все одно існує. А от якщо ви просите книжку за номером, а її немає, — це вже інша ситуація.
Для collection endpointʼа (у нашому випадку /api/v1/tasks) порожній результат означає лише те, що на поточний запит немає елементів. Це не «ресурс не знайдено», бо ресурс «колекція задач» як концепція існує завжди: зараз у ньому просто нуль елементів або на вибраній сторінці немає жодного запису. Тому в нормальному REST API це залишається 200 OK і валідним PagedResponse, де items — порожній список.
404 Not Found у цьому випадку — це коли ви адресували конкретний ресурс, а його немає. Наприклад, GET /api/v1/tasks/{taskId} для неіснуючого taskId. Або коли ви взагалі звертаєтеся не до того шляху, тобто не до того endpoint.
400 Bad Request — це коли запит не проходить як запит: ви надіслали відʼємне значення для сторінки, розмір сторінки 0, надто велике значення size, сортування за недозволеним полем, напрямок upsideDown або page=abc. І ось тут найважливіше: 400 — це не «нічого не знайдено», а «запит невалідний».
Щоб зафіксувати це не лише словами, корисно тримати в голові невелику матрицю:
| Ситуація | Приклад запиту | Що повертаємо | Чому так |
|---|---|---|---|
| Валідний запит, але елементів немає | GET /api/v1/tasks?page=10&size=20 | 200 OK + PagedResponse з порожнім items | Колекція існує, просто на цій сторінці порожньо |
| Ресурс не знайдено | GET /api/v1/tasks/{taskId} | 404 Not Found + ProblemDetail | Конкретного ресурсу за адресою немає |
| Запит невалідний | GET /api/v1/tasks?size=0 або |
400 Bad Request + ProblemDetail | Клієнт порушив контракт параметрів |
Зверніть увагу, що в першому рядку таблиці ми не пишемо «помилка», хоча користувачу може бути прикро побачити порожню відповідь. Але API створене не для емоцій, а для передбачуваності.
Це розрізнення варто тримати жорстко від самого початку: набір параметрів списку ще зростатиме, а статусна семантика колекції не має розмиватися.
2. 200 OK і порожній items
Порожній результат — це не «особливий випадок, який треба обробляти окремою гілкою в контролері». Це нормальна гілка виконання list endpointʼа. І в цьому є практичний сенс: клієнт може показати порожній стан — «поки задач немає» — і не вмикати аварійну логіку обробки помилок. Помилка — це коли порушено контракт, а не коли даних немає.
У нашому контракті порожній результат має виглядати так само стабільно, як і непорожній: тіло відповіді — це все той самий PagedResponse<TaskSummaryResponse>, просто items порожній. І ось тут важлива дисципліна: items має бути списком, а не null. null — це подарунок клієнту у вигляді NPE/Cannot read property 'length' of null і дивних ifʼів на фронті. Порожній список — це «мені нічого показати», а null — це «я взагалі не знаю, що це за поле».
Є два «порожні» сценарії, які важливо розрізняти в голові, хоча не обовʼязково розрізняти їх статусами:
Перший — порожня колекція. Це коли totalElements == 0, тобто задач узагалі немає. Тоді items порожній, totalElements = 0, а totalPages за вибраним нами правилом теж буде 0. Так, хтось робить totalPages=1 навіть за нуля елементів, але тоді метадані перетворюються на філософію. У нашому навчальному проєкті логіка простіша: сторінок немає, бо немає елементів.
{
"items": [],
"page": 0,
"size": 20,
"totalElements": 0,
"totalPages": 0,
"sort": "updatedAt,desc"
}
Другий — порожня сторінка за непорожньої колекції. Це коли елементи в принципі є, але вибрана сторінка «дуже далеко за горизонтом». Наприклад, у нас усього 7 задач, size=5, отже сторінок 2 (0 і 1), а клієнт запросив page=10. Це не помилка контракту: page не порушує діапазон, бо ми не вводили max page. Запит валідний, просто результат порожній.
{
"items": [],
"page": 10,
"size": 5,
"totalElements": 7,
"totalPages": 2,
"sort": "updatedAt,desc"
}
Чому це корисно? Бо клієнт може чесно зрозуміти ситуацію без вгадування. Він бачить: totalPages=2, page=10 — отже, запитано надто далеко, і треба перестати гортати. Якби ми замість цього повертали 404 або 400, клієнту довелося б вирішувати, це «помилка», «немає даних» чи «шлях не знайдено».
Окремо важливо, щоб відповідь відображала реально застосовані page, size і sort. Якщо клієнт не надіслав sort, а ми застосували значення за замовчуванням, то у відповіді має бути sort: "updatedAt,desc". Це не «дублювання», а спосіб прибрати двозначність: клієнт бачить не лише дані, а й контекст, за якими правилами їх було отримано.
3. 400 Bad Request для page/size/sort
Порожній результат — це про дані. 400 Bad Request — це про контракт. І саме тут починаються найулюбленіші помилки початківців: «давайте просто підправимо вхідні параметри й зробимо вигляд, що все добре». Наприклад, якщо клієнт надіслав size=1000, сервер тихо робить size=100, щоб «не сваритися». Звучить дружньо, але насправді ви створюєте приховану магію, через яку клієнт не розуміє, що робить неправильно, а потім це вилазить у вигляді багів: «у нас іноді повертається не та кількість елементів».
Ми домовилися про просте правило: відсутній параметр — це привід узяти значення за замовчуванням. Невалідний параметр — це привід повернути 400 через наш уже наявний error contract на базі ProblemDetail.
На вході до list-endpoint уже є явний TaskSearchCriteria: page допускає тільки значення >= 0, size — діапазон 1..100, а sort проходить окрему перевірку формату та дозволеного переліку. Тут важливо не повторювати весь код ще раз, а втримати сенс: null означає «параметр не прийшов», а будь-яке порушення діапазону або дозволеного переліку — це invalid input. Саме такий DTO потім спокійно переживає розширення списку новими умовами відбору: змінюється вміст criteria, а не статусна логіка endpointʼа.
Тепер сценарії виглядають так. Якщо page не прийшов, він null, і наша нормалізація спокійно перетворює його на 0. Якщо page=-1, це вже порушення контракту, і воно має піти в 400. Якщо size=0 — теж 400. Якщо size=1000 — також 400. Ми нічого не «лагодимо» мовчки, бо контракт має бути зрозумілим.
Із sort трохи цікавіше: Bean Validation тут не вирішує все, бо формат field,dir ми розбираємо вручну, і whitelist теж перевіряємо вручну. Тому помилку на кшталт «непідтримуване поле сортування» ми перетворюємо на наш доменний виняток «invalid input» — наприклад, InvalidInputException — і далі віддаємо ProblemDetail через @ControllerAdvice. Невідоме поле, неправильний напрямок або зламаний формат — це 400, а не порожній список і не тихий fallback на значення за замовчуванням.
Ключова ідея цієї лекції: помилка в sort — це не «нічого не знайдено» і не «поставимо значення за замовчуванням та промовчимо». Це невалідний запит, і він має приводити до 400 в єдиному форматі помилок.
А ще важливо памʼятати про типи. Якщо клієнт надіслав page=abc, то це не «не пройшов @Min», бо до Bean Validation справа може не дійти: Spring спочатку намагається сконвертувати параметр запиту в число, і це падає на етапі звʼязування або перетворення типів. Але з погляду зовнішнього контракту це все одно invalid input. Тому наш глобальний error layer має перетворювати і validation errors, і type mismatch, і «unsupported sort» у передбачуваний ProblemDetail.
Приклад відповіді (спрощений, але за змістом правильний) на size=0:
{
"type": "about:blank",
"title": "Невалідний запит",
"status": 400,
"detail": "Перевірку не пройдено",
"instance": "/api/v1/tasks",
"code": "INVALID_INPUT",
"fieldErrors": [
{
"field": "size",
"message": "має бути більшим або дорівнювати 1"
}
]
}
І приклад на «непідтримуване поле сортування»:
{
"type": "about:blank",
"title": "Невалідний запит",
"status": 400,
"detail": "Непідтримуване поле сортування: hackerField",
"instance": "/api/v1/tasks",
"code": "INVALID_INPUT"
}
Зверніть увагу, як це допомагає клієнту. У першому випадку він може підсвітити конкретне поле size. У другому — показати користувачу, що не можна сортувати за цим полем, або виправити UI, наприклад прибрати заборонене поле зі списку. Якби ми повертали 200 і порожній список, клієнт не зрозумів би взагалі нічого.
4. Приклади .http для 200/400/404
Коли ви проєктуєте семантику, дуже корисно перевіряти її не лише очима коду, а й очима клієнта. У нашому проєкті це зручно робити через .http файли (або Postman), бо там добре видно: запит → статус → тіло. І відразу стає зрозуміло, де у вас «все однаково і незрозуміло», а де клієнт справді отримує сигнал.
Ось приклад валідного запиту на «далеку» сторінку. Він має бути успішним, але з порожніми items:
### Порожня сторінка: запит валідний, items порожні
GET http://localhost:8080/api/v1/tasks?page=10&size=5&sort=updatedAt,desc
Accept: application/json
# Очікуємо: 200 OK (навіть якщо items = [])
Якщо задачі існують, ви побачите 200 OK і items: [], а також чесні totalElements/totalPages. Якщо задач узагалі немає, ви все одно побачите 200 OK, просто totalElements буде 0.
Тепер приклад невалідного розміру сторінки. Це вже не «порожньо», а «зламали контракт»:
### Невалідний size: має бути 1..100
GET http://localhost:8080/api/v1/tasks?page=0&size=0&sort=updatedAt,desc
Accept: application/json
# Очікуємо: 400 Bad Request + ProblemDetail з деталями щодо поля size
Очікуємо 400 Bad Request і Content-Type: application/problem+json (або еквівалентний формат Problem Details), а всередині — ваш code=INVALID_INPUT і деталі.
І третій приклад — помилка в sort. Тут особливо важливо не «проковтнути» друкарську помилку, бо інакше клієнт роками випадково сортуватиме «як вийшло».
### Непідтримуване поле сортування: немає у whitelist
GET http://localhost:8080/api/v1/tasks?page=0&size=20&sort=hackerField,asc
Accept: application/json
# Очікуємо: 400 Bad Request, бо sort порушує whitelist
Очікуємо 400 Bad Request, а в detail — зрозуміле повідомлення, що поле не підтримується.
Саме так і має працювати зріла колекція: набір параметрів може зростати, але розрізнення 200/400/404 залишається незмінним.
Щоб ще раз підкреслити різницю з 404, корисно порівняти це з detail endpointʼом:
### Не знайдено один ресурс
GET http://localhost:8080/api/v1/tasks/00000000-0000-0000-0000-000000000000
Accept: application/json
# Очікуємо: 404 Not Found, бо адресуємо конкретний ресурс за id
Ось це вже 404 Not Found, бо адресовано конкретний task resource. І це інший клас сценарію, ніж «список порожній».
Якщо тримати ці чотири запити поруч, у вас дуже швидко зафіксується правильна логіка: collection endpoint — завжди 200 за валідного запиту, навіть якщо «нічого»; 400 — лише за контрактні помилки запиту; 404 — за відсутності конкретного ресурсу.
5. Типові помилки під час семантики list
У цій темі помилки підступні тим, що вони рідко виглядають як «застосунок упав». Зазвичай усе працює, просто клієнт живе у світі недомовленостей. Тому краще впіймати ці моменти зараз, поки проєкт навчальний, а не тоді, коли у вас три клієнти, пʼять інтеграцій і один дуже нервовий продакт.
Помилка №1: повертати 404 Not Found для порожнього списку.
Це найпопулярніша плутанина. 404 означає, що за цим URI немає ресурсу. Але /api/v1/tasks — це ресурс колекції, він є завжди. Якщо задач немає, це не «ресурс не знайдено», а «колекція порожня». Клієнту від 404 стане тільки гірше, бо він увімкне аварійний сценарій замість порожнього стану.
Помилка №2: повертати 204 No Content для порожнього списку.
Іноді здається логічним: «нічого немає — отже, no content». Але у нас контракт списку — це PagedResponse, і навіть за порожніх items клієнту потрібні метадані (page, size, totalPages, sort). 204 за змістом забороняє тіло відповіді, а отже ви ламаєте власний контракт і змушуєте клієнта вгадувати.
Помилка №3: мовчки виправляти невалідні size/sort замість 400.
Якщо клієнт надіслав size=1000, а сервер «виправив» його на 100, клієнт і далі надсилатиме 1000. Те саме з sort: мовчазний fallback на значення за замовчуванням перетворює друкарську помилку на прихований режим роботи. Через місяць ніхто не памʼятає, чому результати «сортуються не так». Набагато чесніше й корисніше повернути 400 і змусити клієнта дотримуватися контракту.
Помилка №4: робити items = null замість порожнього списку.
Навіть якщо в Java вам простіше повернути null, для JSON-контракту це майже завжди помилка. Клієнт буде змушений писати зайві перевірки, а в деяких мовах і фреймворках це ще й призведе до падінь. Порожній список — це нормальна форма даних, і вона має бути стабільною.
Помилка №5: рахувати totalElements за розміром поточної сторінки.
Це ламає сенс пагінації. Метадані мають описувати всю вибірку, а не лише поточний зріз. Інакше клієнт не зможе зрозуміти, скільки всього сторінок, і не зможе побудувати нормальну навігацію. У нашому алгоритмі totalElements береться з повної (відсортованої) колекції, а items — зі slice.
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ