1. REST-словник і спільна мова
Якщо ви хоч раз сперечалися в команді про те, «як правильно зробити REST», то знаєте цей біль: одна людина каже «ресурс» і має на увазі JSON-об’єкт, інша — «таблицю в базі», третя — «кінцеву точку», а четверта — «будь-яку сутність, у якої є id». Розмова виходить як діалог двох мікросервісів без спільного контракту: усі щось надсилають, але ніхто нікого не розуміє.
У цій лекції ми фіксуємо мінімальний набір термінів, який дасть нам змогу далі проєктувати Task Tracker API усвідомлено. Нам важливо розрізняти що існує в предметній області (ресурс), як це існує в API як набір (колекція), як це показується і приймається в обміні (представлення) і що живе лише в контексті батька (підресурс). Жодної філософії — тільки інженерна ясність.
Щоб було простіше, тримайте в голові одну думку: REST-словник потрібен не заради «правильності», а щоб потім швидко відповідати на запитання на кшталт «це окрема сутність чи частина іншої?» і «цей JSON — це ресурс чи просто представлення?».
Ресурс: сенс і ідентичність
Коли слово «ресурс» чуєш уперше, дуже хочеться подумки підставити «об’єкт у коді» або «запис у базі». Це природно: ми звикли мислити тим, що можна помацати. Але в REST ресурс — це передусім значуща частина предметної області, з якою працює клієнт, і в неї є стала ідентичність — зазвичай ідентифікатор.
Ключове тут ось що: ресурс — це «річ» на рівні сенсу. У нашому проєкті «задача» — ресурс не тому, що в нас буде клас Task (він буде) і не тому, що буде таблиця tasks (її поки не буде), а тому, що клієнт мислить систему так: «ось є задачі, я хочу подивитися одну, змінити одну, видалити одну». Тобто для клієнта «задача» — окрема адресована сутність.
Дуже корисний критерій: ресурс — це те, на що ви можете вказати фразою «ось ця штука» і не втратити сенс. «Ця задача», «цей коментар», «це вкладення». А ось «сортування за пріоритетом» — не ресурс, а параметр роботи з колекцією. «Статус» — теж не окремий ресурс у нашому проєкті, а частина стану задачі, принаймні на цьому етапі.
Мініприклад на Java — не про Spring, а просто про сенс «є ідентичність»:
import java.util.UUID;
public class IdExample {
public static void main(String[] args) {
// UUID тут відіграє роль сталої ідентичності ресурсу (taskId),
// а не "просто рядка" (важливо саме те, що це адресований ідентифікатор).
String taskId = UUID.fromString("2f1d1b7a-3c8a-4f2b-9f2a-0e6f3c8b2d11").toString();
// Клієнтові важливо, що за цим id можна послатися на "ту саму" задачу.
System.out.println(taskId); // 2f1d1b7a-3c8a-4f2b-9f2a-0e6f3c8b2d11
}
}
Тут не важливо, де зберігається задача. Важливо, що ресурс «Task» у нашому проєкті буде адресованим: у нього є id (UUID — рядок за ТЗ проєкту), і це дає клієнту змогу сказати: «дай мені задачу з таким id».
2. Колекція та окремий ресурс
Коли ми говоримо «задачі» (tasks), мова зазвичай іде про два різні типи запитів клієнта, і за змістом вони дуже відрізняються. Перший — «покажи мені список задач» (колекція), другий — «покажи мені конкретну задачу» (окремий ресурс). Якщо не розрізняти ці випадки, API починає плутатися: ми випадково намагаємося застосувати до одного об’єкта правила колекції, а до списку — правила одиничного ресурсу.
Колекція — це набір однотипних ресурсів. Важливий момент: колекція — це не просто List<Task> у Java. У REST-мисленні колекція — це частина зовнішнього контракту: клієнт може переглядати набір, додавати до нього елементи, шукати в ньому потрібне. Навіть якщо всередині ми зберігаємо дані як завгодно — масивом, мапою, файлом чи навіть хардкодом, бо проєкт навчальний, — для клієнта це все одно колекція задач.
Окремий ресурс — це конкретний елемент колекції. І тут уже вмикаються інші очікування: у елемента є конкретний id, є стан, і операції над ним зазвичай відбуваються «за адресою».
Поки не обговорюємо, як саме мають виглядати URI, — це буде далі. Просто зафіксуємо: це два різні об’єкти розмови.
import java.util.List;
public class CollectionVsItemExample {
public static void main(String[] args) {
// Шлях до колекції: тут ми "говоримо про множину" (список/пошук/додавання).
String tasksCollection = "/tasks";
// Шлях до елемента: тут ми "говоримо про конкретну річ" за її ідентифікатором.
String oneTask = "/tasks/{taskId}";
List<String> forms = List.of(tasksCollection, oneTask);
// Наочно видно, що це два різні об'єкти обговорення: колекція проти елемента.
System.out.println(forms); // [/tasks, /tasks/{taskId}]
}
}
Навіть цей простий приклад корисний: /tasks — це не «шлях до задачі», а шлях до колекції задач. А /tasks/{taskId} — це вже шлях до конкретної задачі. Домен один, але запитання клієнта різні.
3. Представлення: як ресурс виглядає назовні
Мабуть, найбільш недооціненим словом у REST-словнику є «представлення». Новачок часто думає так: «Ну ресурс — це JSON, який я віддаю». І поки проєкт малий, здається, що це працює. Але щойно у ресурсу з’являється хоч якесь життя — різні поля, різні сценарії, різні форми відповіді, — з’ясовується: ресурс — це одне, а його JSON-форма — інше.
Представлення ресурсу (representation) — це форма даних, у якій ресурс показується клієнту або приймається від клієнта. Це може бути JSON, а в нашому курсі — це основний формат, але в загальному випадку представлення може бути й іншим: наприклад, у вкладення може бути JSON-метадані та бінарний вміст файла. Ми детально займемося файлами пізніше, але думку корисно зафіксувати вже зараз: representation — це те, «як виглядає ресурс в обміні».
Що важливо для проєкту Task Tracker API: одна й та сама задача може мати різні представлення. Для списку задач зазвичай зручно показувати «коротку картку» (id, title, status), а для докладного перегляду — «детальну картку» (плюс опис, теги, терміни тощо). Це не «хитрий трюк», а звичайна потреба клієнта.
Мініприклад — поки не називаємо це DTO і не йдемо глибоко в контрактний дизайн, а просто фіксуємо ідею різних представлень:
import java.time.LocalDate;
// "Ресурсний сенс": задача як сутність домену (внутрішня/багата форма).
record Task(String id, String title, String description, LocalDate dueDate) {}
// "Представлення": спрощена форма для конкретного сценарію (наприклад, список задач).
record TaskSummaryView(String id, String title) {}
public class RepresentationExample {
static TaskSummaryView toSummary(Task task) {
// Мапінг у представлення: свідомо показуємо назовні лише потрібні поля.
return new TaskSummaryView(task.id(), task.title());
}
}
Task тут — умовний «ресурсний сенс» (задача), а TaskSummaryView — одне з представлень. На рівні REST-мислення ми вчимося розрізняти «що існує» і «як ми це показуємо».
4. Ресурс і представлення: приклад Task Tracker
Плутанина «ресурс = JSON» зазвичай спливає в найнеприємніший момент: коли ви захотіли додати внутрішнє поле, змінити формат відповіді або приховати щось від клієнта. Усередині застосунку ви можете зберігати більше даних, ніж показуєте назовні, і це нормально. Назовні має виходити контрольований контракт, а ресурс як смислова сутність при цьому лишається тим самим.
Давайте закріпимо це розрізнення на одному домені. Візьмемо задачу як ресурс і подумаємо, як вона може виглядати назовні.
| Поняття | Що це за змістом | Приклад у Task Tracker | Що важливо пам’ятати |
|---|---|---|---|
| Ресурс | Значуща сутність предметної області | «Задача» | Це не обов’язково Java-клас і не обов’язково таблиця |
| Представлення | Форма даних «в обміні» | «Коротка картка задачі» для списку, «детальна картка» для перегляду | Представлень може бути кілька |
| Ідентичність | Що робить ресурс «тим самим» | taskId (UUID — рядок) | Ідентифікатор стабілізує взаємодію клієнта з API |
Ще один невеликий приклад коду, щоб відчути, як внутрішня модель може бути багатшою за зовнішній вигляд. Ми спеціально додамо «службове» поле і не включимо його до представлення:
import java.time.Instant;
// Внутрішня модель: містить службові дані, які не зобов'язані бути публічними.
record TaskInternal(String id, String title, String internalNote, Instant createdAt) {}
// Публічне представлення: контрольований контракт для клієнта.
record TaskPublicView(String id, String title, Instant createdAt) {}
public class InternalVsPublicExample {
static TaskPublicView toPublic(TaskInternal task) {
// Важливо: internalNote навмисно не виноситься назовні — це частина "контрактної гігієни".
return new TaskPublicView(task.id(), task.title(), task.createdAt());
}
}
У реальному проєкті internalNote може бути чим завгодно: технічною міткою, внутрішнім прапорцем міграції, деталями зберігання у файловому сховищі тощо. Головне: наявність поля всередині не означає, що воно зобов’язане потрапити до зовнішнього контракту. Саме це розрізнення між ресурсом і представленням потім рятує API від хаосу.
5. Підресурс: об’єкт у контексті батьківського ресурсу
Тепер ми підходимо до слова, яке найчастіше ламає новачкам голову: «підресурс». Підресурс — це теж ресурс, але такий, який природно існує в контексті іншого ресурсу, і найчастіше його життєвий цикл пов’язаний із батьківським ресурсом. Це не «шматок JSON усередині JSON», а окрема сутність, просто не самостійна на верхньому рівні нашої моделі.
У Task Tracker API коментар майже неможливо обговорювати у вакуумі. Фраза «видали коментар c-123» без уточнення задачі звучить підозріло: а що це за коментар, до чого він належить? Аналогічно і з вкладенням: вкладення саме по собі користувачеві майже не потрібне, воно потрібне як вкладення до задачі.
Це і є основний сенс підресурсу: батьківський контекст важливий для сенсу. Тому ми читаємо comments і attachments як підресурси задачі.
Приклад на рівні моделі — знову ж таки, це не про Spring і не про кінцевий дизайн, а про сам сенс: «є зв’язок із батьківським id»:
import java.time.Instant;
// Підресурс (comment) має власну ідентичність (id),
// але сенс/життєвий цикл прив'язано до батьківського ресурсу (taskId).
record Comment(String id, String taskId, String authorName, String text, Instant createdAt) {}
public class SubresourceExample {
static boolean belongsToTask(Comment comment, String taskId) {
// Перевірка прив'язки до батька — в API це буде природно виражено через URI,
// а в моделі часто виражається через поле taskId.
return comment.taskId().equals(taskId);
}
}
Наявність taskId у коментарі підкреслює ідею: коментар має власну ідентичність (id), але живе «під» задачею (taskId). Це модель, яка потім дуже природно лягає на ресурсну карту API.
6. Підресурси та lookup: comments, attachments, tags
Коли ви починаєте проєктувати API, дуже легко зробити все «однаково важливим» і перетворити домен на нескінченний CRUD-зоопарк. У новачка це зазвичай виглядає так: «Раз є коментарі — значить, потрібен повний верхньорівневий CRUD для comments. Раз є вкладення — значить, ще один. Раз є теги — значить, і їх туди ж». І ось через два дні у вас уже не Task Tracker, а колекція розрізнених контролерів.
У Task Tracker API корисніше одразу розкласти ролі. Task — основний ресурс. Comment і Attachment — допоміжні підресурси задачі: у них є власні id, але сенс тримається на батьківській задачі. Клієнтові зазвичай потрібен не «коментар взагалі», а коментар конкретної задачі; не «файл світу», а вкладення конкретної задачі.
Із тегами картина інша. У межах курсу теги — це значення, які живуть усередині задач і допомагають шукати та класифікувати їх. Тому tags зручніше мислити як lookup-колекцію значень: клієнтові потрібен список доступних варіантів, а не окремий модуль керування тегами.
Невеликий приклад, щоб «теги як значення» відчулися наочно:
import java.util.Set;
import java.util.TreeSet;
public class TagsExample {
public static void main(String[] args) {
// Як "значення" теги зручно нормалізувати й уніфікувати.
// Тут для демонстрації використовуємо набір із порівнянням без урахування регістру.
Set<String> tags = new TreeSet<>(String.CASE_INSENSITIVE_ORDER);
tags.add("backend");
tags.add("Backend"); // Дублікат з іншою капіталізацією.
// Ідея: якщо теги — це значення, то їхня унікальність і нормалізація важливіші за CRUD-життєвий цикл.
System.out.println(tags); // [backend]
}
}
Цього розрізнення вже достатньо, щоб не плутати три ролі: основний ресурс, підресурс і lookup-значення.
7. Опорна схема ресурсної моделі
Коли ці слова розкладені по місцях, проєктна поверхня читається без здогадок:
- task — основний ресурс, з яким клієнт працює як з окремою адресованою сутністю;
- comments і attachments — підресурси task, тому що їхній сенс тримається на батькові;
- tags — lookup-колекція значень, а не паралельний CRUD-світ;
- представлення живуть поруч із ресурсами і не зобов’язані збігатися з внутрішньою моделлю.
Цієї схеми вже достатньо, щоб перейти до наступного інженерного питання: як на колекцію і один ресурс лягають GET, POST, PUT, PATCH, DELETE? Інакше кажучи, словник у нас уже є — тепер потрібна граматика операцій.
8. Типові помилки в термінах
Помилка №1: називати ресурсом будь-який JSON-фрагмент.
Часто новачок бачить поле status або priority у задачі й каже: «о, це ресурс». У результаті з’являються дивні ідеї на кшталт «давайте зробимо /statuses і /priorities і взагалі все винесемо в окремі сутності». Зазвичай це не покращує API, а лише роздуває його. Ресурс — це не «будь-яка частина даних», а значуща одиниця домену, з якою клієнт реально взаємодіє як із «річчю».
Помилка №2: плутати колекцію та окремий ресурс, а потім дивуватися дивним операціям.
Якщо ви мислите «список задач» як «одну задачу, тільки багато», в API з’являються гібриди: десь ви намагаєтеся «видалити список», десь — «оновити всі задачі одним запитом», десь — «отримати задачу, але без id». Правильне мислення простіше: колекція відповідає на запитання «які є?», а окремий ресурс — «що це за конкретна річ?».
Помилка №3: вважати, що ресурс і представлення зобов’язані збігатися один до одного.
Це призводить до монстра TaskDtoForEverything, який одночасно і для списку, і для деталей, і для створення, і для оновлення, і ще з раптовими полями, яких клієнт «не має чіпати, але ми йому їх віддали, сподіваємося, він не зачепить». У нормальному API ресурс стабільний за сенсом, а представлення добираються під сценарії клієнта. Сьогодні ми це фіксуємо термінологічно, а пізніше перетворимо на практику DTO та контрактів.
Помилка №4: робити підресурсом усе підряд.
Іноді здається, що раз у Task є assigneeName, то «користувач» — підресурс, отже, /tasks/{id}/assignee. Потім з’являються /tasks/{id}/priority, /tasks/{id}/status, /tasks/{id}/title, і API перетворюється на набір «ручок» до окремих полів. Підресурс — це окрема сутність із сенсом і власним життєвим циклом, а не «будь-яке поле в JSON».
Помилка №5: роздувати другорядні елементи до масштабу головного ресурсу.
У нашому проєкті теги корисні, але вони не є «головною сутністю рівня Task». Якщо перетворити Tag на повний CRUD-модуль, ми отримаємо великий обсяг коду та обговорень, який не дає нових знань про REST-контракт, а просто збільшує площу проєкту. У навчальному домені потрібно вміти вчасно зупинитися — це навичка не гірша, ніж написати ще один контролер.
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ