1. Створення ресурсу як контракт: що саме ми зобов’язуємося зробити
Створення ресурсу в REST — це не просто «додати запис», а домовленість між клієнтом і сервером: куди клієнт надсилає запит, що саме він може надіслати, які поля сервер призначить сам, який статус повернеться і за якими правилами клієнт потім знайде новий ресурс. Якщо домовленість туманна, клієнт пише обхідні рішення, а сервер — пояснення.
Коли ви робите POST /api/v1/tasks, ви проєктуєте не «метод контролера», а контракт створення задачі. У нього завжди є кілька частин, і якщо хоча б одна з них «плаває», користувачі вашого API (включно з майбутнім вами) починають страждати.
Зведімо контракт створення задачі в одну таблицю. Це не бюрократія — це технічне ТЗ, вбудоване прямо в API:
| Частина контракту | Як ми робимо в Task Tracker API | Чому це важливо |
|---|---|---|
| POST /api/v1/tasks | створюємо елемент у колекції | ресурс створюється всередині спільного набору задач |
| POST | операція «створи новий» | семантика HTTP збігається з операцією створення |
| TaskCreateRequest | клієнт описує «що хоче створити» | контракт вхідних даних стає явним |
| id, status, createdAt, updatedAt призначає сервер | server-managed поля не надходять від клієнта | клієнт не повинен «малювати собі» ідентифікатори й дати |
| 201 Created | клієнту явно кажуть: «створили новий ресурс» | успішне створення відрізняється від звичайної відповіді |
| /api/v1/tasks/{id} | клієнт отримує адресу створеного ресурсу | заголовок Location вказує, де знайти результат |
| TaskDetailsResponse (опціонально, але ми використовуємо) | клієнт одразу бачить підсумковий ресурс | зручно одразу отримати повне уявлення створеної задачі |
| ProblemDetail (400, іноді інші) | єдина модель помилок для всього API | клієнт отримує передбачуваний формат помилки |
Якщо тримати це в голові, реалізація стає простою: контролер не вигадує поведінку, а лише виконує контракт. І тоді ви перестаєте сперечатися в команді, повертати 200 чи 201, — бо це вже не справа смаку, а семантика операції.
2. POST по колекції /api/v1/tasks
Дуже хочеться, особливо на початку шляху, зробити щось на кшталт POST /api/v1/tasks/{taskId} і сказати: «ну клієнт сам придумав id, так простіше». Це справді здається простішим… рівно до моменту, коли ви намагаєтеся пояснити, що тоді означає PUT, де межа між створенням і заміною та хто взагалі господар ідентифікаторів. У цей момент простота випаровується, як зарплата після купівлі нового ноутбука «для роботи».
У нашому курсі ми тримаємо базову REST-ідею: колекція живе за шляхом у множині, а конкретний ресурс — усередині неї за id.
Тобто:
— колекція задач: /api/v1/tasks
— конкретна задача: /api/v1/tasks/{taskId}
POST логічно працює саме з колекцією: «додай новий елемент до набору». Клієнт каже: «ось дані нової задачі», а сервер відповідає: «окей, я її створив, ось її адреса». Це і є зміст Location.
Якщо вам потрібна аналогія з життя, POST /tasks — це як прийти до центру надання адміністративних послуг у ролі сервера і сказати: «Будь ласка, зареєструйте мені ось такий документ». Вам не потрібно приносити «офіційний номер документа» — ви приносите дані, а номер і дата реєстрації призначаються системою. Ви ж не кажете: «ось мій паспорт, номер я сам вигадав, я так відчуваю».
Важливий практичний момент: POST зазвичай неідемпотентний. Якщо клієнт надішле запит двічі (наприклад, мережа мигнула, і він вирішив «про всяк випадок повторити»), сервер може створити дві однакові задачі. Це не вада Spring, а нормальна семантика POST. Ми сьогодні не будемо запроваджувати idempotency keys (це вже інший рівень складності), але розуміння того, що повторна відправка POST може створити дублікати, — дуже корисна звичка.
3. TaskCreateRequest: що клієнт може прислати
Коли людина бачить «створити задачу», перша думка часто така: «ну нехай клієнт пришле JSON з усіма полями, які є в задачі». І це класичний шлях до проблеми over-posting / mass assignment (ми це вже обговорювали в модулі про DTO): клієнт раптово отримує можливість керувати тим, чим він керувати не повинен.
Для create-операції нам потрібен DTO, який виражає просту думку: «ось дані, яких достатньо, щоб сервер створив задачу». Усе, що сервер зобов’язаний призначати сам, у create DTO не входить.
У нас це:
— id — тільки сервер
— status — тільки сервер (під час створення, наприклад, TODO)
— createdAt/updatedAt — тільки сервер
— будь-які внутрішні технічні поля — тим більше тільки сервер
Нижче — хороший, «навчально чесний» TaskCreateRequest. Він показує і ідею server-managed полів, і базові обмеження, і те, що деякі поля можуть бути опціональними.
// DTO для створення задачі: тільки те, що клієнт має право прислати.
// Важливо: тут НЕМАЄ id/status/createdAt/updatedAt — їх призначає сервер.
public record TaskCreateRequest(
// Обов’язкова зрозуміла людині назва задачі
@NotBlank @Size(min = 3, max = 120) String title,
// Опис не обов’язковий, але обмежений за довжиною
@Size(max = 2000) String description,
// Пріоритет обов’язковий: не хочемо створювати "без пріоритету" мовчки за замовчуванням
@NotNull TaskPriority priority,
// Дедлайн опціональний
LocalDate dueDate,
// Виконавця може не бути вказано
@Size(max = 80) String assigneeName,
// Приклад вкладеної валідації: обмеження на кількість тегів і довжину кожного тега
@Size(max = 10) List<@Size(min = 1, max = 30) String> tags
) {}
Тут варто зупинитися й проговорити кілька тонких, але дуже життєвих моментів.
По-перше, title — обов’язкове поле, і це відображено прямо на межі контракту. Ми не робимо так: порожня назва, а потім десь у сервісі if. Якщо поле обов’язкове для створення ресурсу, це видно в DTO і перевіряється автоматично.
По-друге, priority у нас обов’язковий (@NotNull), тому що проєктові простіше жити, коли пріоритет визначений. Якщо б ми хотіли дефолт, наприклад MEDIUM, це теж було б рішенням контракту: або ми робимо priority опціональним і призначаємо значення на сервері, або робимо обов’язковим. Важливо саме те, що рішення має бути явним.
По-третє, tags — хороший приклад того, як частина правил живе у Bean Validation, а частина — у предметній, доменній логіці: унікальність без урахування регістру, нормалізація пробілів тощо. Нам не обов’язково сьогодні переосмислювати validation-шар — він у нас уже є, але пам’ятати межу корисно: «структура входу» і «предметний сенс» — не одне й те саме.
4. Серверна частина: id, status, час
Тут важливе лише одне: create-логіка не повинна розповзатися по контролеру. Контролер приймає TaskCreateRequest, а сервіс вирішує, які поля вважаються server-managed і які значення вони отримують під час створення. У нашому проєкті це щонайменше id, status, createdAt, updatedAt, а нова задача стартує як TODO.
З погляду шарів проєкту зручно тримати такий порядок:
- контролер приймає TaskCreateRequest і перевіряє його;
- сервіс створює доменну модель Task і призначає server-managed поля;
- репозиторій зберігає в in-memory сховищі;
- mapper перетворює доменну модель на TaskDetailsResponse.
Мініескіз інтерфейсу write-сервісу, щоб у голові була форма:
// Write-сервіс: відповідає за зміну стану системи (створення/оновлення/видалення).
public interface TaskWriteService {
// Повертаємо DTO вже створеної задачі (включно з полями, якими керує сервер).
TaskDetailsResponse create(TaskCreateRequest request);
}
Тепер сам «серцевик» створення. Зверніть увагу: це не «повна реалізація всіх полів», а концентрований приклад, який показує, де саме народжуються server-managed значення.
import java.time.Instant;
import java.util.UUID;
public TaskDetailsResponse create(TaskCreateRequest request) {
// Ідентифікатор генерує сервер, а не клієнт.
String id = UUID.randomUUID().toString();
// Час теж беремо на сервері, щоб уникнути ситуації «я створив задачу в 1998-му».
Instant now = Instant.now();
// Створюємо доменну модель зі значеннями за замовчуванням (наприклад, status=TODO) та timestamps.
Task task = Task.createNew(id, request, now); // status=TODO, createdAt=now
// Зберігаємо (тут це in-memory, але контракт не залежить від реалізації сховища).
Task saved = taskRepository.save(task);
// Мапимо фактично збережену модель у DTO відповіді.
return taskMapper.toDetailsResponse(saved);
}
Фішка тут не в тому, що ми сховали логіку в createNew (можна й без неї), а в дисципліні: UUID і час беруться на сервері, а не з JSON. Клієнт не повинен «надіслати нам дату створення», так само як не повинен надіслати: «мені зручно, що я створив задачу вчора в 1998-му».
Якщо хочеться побачити, що могло б жити всередині Task.createNew(...), ось дуже коротка ілюстрація: ми беремо дані із запиту й додаємо server-managed поля.
public static Task createNew(String id, TaskCreateRequest req, Instant now) {
// Переносимо «клієнтські» поля із запиту в доменну модель.
Task task = new Task(id, req.title());
// Дефолтний статус під час створення задаємо на сервері.
task.setStatus(TaskStatus.TODO);
// Timestamps — теж server-managed.
task.setCreatedAt(now);
task.setUpdatedAt(now);
return task;
}
Так, це виглядає «занадто просто». І це добре. Створення ресурсу має бути простим і прозорим. Складність ми тримаємо під контролем і додаємо тільки тоді, коли вона справді потрібна.
5. Контролер: 201, Location і тіло відповіді
У create-сценарії контролеру мало просто викликати сервіс: потрібно ще зібрати коректну HTTP-відповідь. Тут нам важливі 201 Created, Location і, якщо потрібно, тіло зі створеною задачею, тому ResponseEntity — природний вибір.
Ось канонічний метод контролера — короткий і без зайвих фокусів:
import jakarta.validation.Valid;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import java.net.URI;
@PostMapping
public ResponseEntity<TaskDetailsResponse> create(@Valid @RequestBody TaskCreateRequest request) {
// 1) Робимо бізнес-операцію в сервісі, а не в контролері.
TaskDetailsResponse created = taskWriteService.create(request);
// 2) Формуємо Location на створений ресурс за фактичним id із відповіді сервісу.
URI location = URI.create("/api/v1/tasks/" + created.id());
// 3) Повертаємо 201 Created + Location + тіло (зручно клієнту).
return ResponseEntity.created(location).body(created);
}
Тут є три важливі ідеї.
Перша: @Valid стоїть саме на request DTO. Це означає, що якщо клієнт надіслав невалідний JSON, наприклад порожній title, запит навіть не дійде до сервісу. Отже, сервіс може мислити більш предметно й не займатися нескінченною перевіркою структури вхідних даних.
Друга: Location будується з id, який повернув сервіс. Ми не беремо id «звідкись іще», не робимо його «з пам’яті» й не використовуємо значення з request body, бо їх там і бути не повинно.
Третя: ми повертаємо 201 Created і тіло. Це один із найзручніших для клієнтів варіантів: після створення їм не потрібно робити окремий GET, щоб дізнатися, який id вийшов і які поля сервер призначив автоматично.
Тепер важливий нюанс, який часто спливає пізніше: URI.create("/api/v1/tasks/...") створює відносний URI. Це часто прийнятно в навчальному проєкті, але в реальному середовищі ви можете опинитися за reverse proxy, з context path застосунку, з HTTPS тощо. Тому більш дорослий спосіб — будувати Location від поточного запиту.
У Spring MVC це робиться так:
import org.springframework.web.servlet.support.ServletUriComponentsBuilder;
// Будуємо Location від поточного запиту (ураховує host/scheme/context path за проксі).
URI location = ServletUriComponentsBuilder.fromCurrentRequest()
// Достроюємо шлях до конкретного ресурсу всередині колекції.
.path("/{taskId}")
// Підставляємо фактичний id створеної задачі.
.buildAndExpand(created.id())
.toUri();
І тоді ваш Location стає абсолютним, на кшталт http://localhost:8080/api/v1/tasks/{id} (або https://... в іншому середовищі). У межах курсу достатньо розуміти принцип: Location має вести на створений ресурс, а спосіб збирання URI — це інженерна деталь, яку можна поліпшувати без зміни контракту.
6. HTTP-відповідь: як перевірити вручну
Гарний REST API перевіряється не лише очима в коді, а й руками через HTTP-клієнт. І для write-операцій це особливо важливо, тому що там багато «невидимих» частин: статус, заголовки, тип вмісту. Якщо ви перевіряєте лише те, що у відповіді прийшов JSON, ви пропускаєте половину контракту.
Ось як має виглядати успішна відповідь на створення задачі, якщо ми використовуємо 201 Created, Location і JSON-тіло:
HTTP/1.1 201 Created
Content-Type: application/json
Location: http://localhost:8080/api/v1/tasks/6c5b9d4e-9f7c-4f7c-9e87-0b4f5aa1b2d3
{
"id": "6c5b9d4e-9f7c-4f7c-9e87-0b4f5aa1b2d3",
"title": "Купити корм коту",
"status": "TODO",
"priority": "MEDIUM",
"dueDate": "2026-03-25",
"assigneeName": "Аня",
"tags": ["home", "cat"],
"createdAt": "2026-03-21T10:15:30Z",
"updatedAt": "2026-03-21T10:15:30Z"
}
Порівняйте це з типовою поганою відповіддю, яка іноді трапляється:
— статус 200 OK (хоча створили новий ресурс);
— Location відсутній;
— у тілі незрозуміло що: чи то «успіх», чи то «id», чи то взагалі рядок "OK".
У нас же контракт читається буквально за заголовками: «створили, ось адреса, ось підсумкова модель».
Тепер давайте закріпимо через .http файл (або Postman — без різниці, але .http зручно зберігати поруч із кодом). Приклад запиту:
### Створити задачу
POST http://localhost:8080/api/v1/tasks
Content-Type: application/json
Accept: application/json
{
"title": "Купити корм коту",
"description": "І не забути про вологий корм",
"priority": "MEDIUM",
"dueDate": "2026-03-25",
"assigneeName": "Аня",
"tags": ["home", "cat"]
}
Очікування для цього запиту прості й перевірні: статус має бути 201, заголовок Location має вказувати на /api/v1/tasks/{id}, а в тілі має бути id та інші server-managed поля (щонайменше status, createdAt, updatedAt).
І відразу корисно перевірити один негативний сценарій — не тому, що сьогодні ми вивчаємо помилки (ми їх уже розібрали), а тому, що create-контракт зобов’язаний включати відповідь на запитання: «що буде, якщо клієнт надіслав сміття». Наприклад:
### Створити невалідну задачу
POST http://localhost:8080/api/v1/tasks
Content-Type: application/json
Accept: application/problem+json
{
"title": " ",
"priority": "MEDIUM"
}
Тут ви маєте побачити 400 Bad Request і application/problem+json. А всередині — ваш ProblemDetail із code=INVALID_INPUT і деталями помилок полів. Сенс цього кроку дуже практичний: клієнт розуміє, що він зробив не так, і може автоматично підсвітити поле в UI, а не просто показати «помилка сервера, спробуйте пізніше».
7. Типові помилки під час створення задач
Помилка №1: Location збирається з «жорсткого рядка», який не збігається з реальним базовим шляхом.
Іноді в проєкті змінюється префікс (/api/v1), додається context path або API починає жити за проксі, і Location: /api/v1/tasks/... раптом стає «майже правильним». Якщо ви бачите, що середовища відрізняються, використовуйте побудову URI від поточного запиту через ServletUriComponentsBuilder, щоб Location залишався чесним.
Помилка №2: повертається 201 Created, але тіло відповіді суперечить тому, що реально збережено.
Наприклад, сервер «у відповіді» ставить status=TODO, а в збереженій моделі чомусь IN_PROGRESS (або навпаки). Найчастіше це наслідок того, що частина логіки живе в mapper, частина в сервісі, частина в контролері. Виправляється це дисципліною: server-managed поля призначаються в одному місці, а DTO відображають фактичну модель.
Помилка №3: у create-методі сервіс повертає DTO, але контролер будує Location за чимось іншим.
Якщо Location будується не з того id, який повернувся як істина (наприклад, береться з якихось локальних змінних або повторно генерується), ви отримуєте відповідь, яка сама собі суперечить: клієнт іде за Location, а там 404. Так, таке буває. І так, виглядає це як «магія». Насправді — просто дві різні точки істини.
Помилка №4: create-endpoint не перевіряють вручну, тому що «ну тести ж потім будуть».
Навіть із тестами корисно один раз побачити очима: 201, Location, Content-Type, JSON. Це займає хвилину, але економить години, коли ви раптом розумієте, що клієнту не вистачає заголовка або що status за замовчуванням виставляється не там. Ручний запит — це швидкий sanity check контракту.
Помилка №5: «плаваюча» семантика опціональних полів.
Наприклад, tags то null, то [], то відсутній зовсім, і це змінюється від запиту до запиту. На рівні клієнта це перетворюється на «кожного разу вгадай, що прийшло». Рішення не в тому, щоб заборонити все, а в тому, щоб стабілізувати: домовитися, як саме поле поводиться у відповіді, і дотримуватися цього правила послідовно.
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ