JavaRush /Курси /Spring REST & MVC /POST

POST /api/v1/tasks: 201 + Location

Spring REST & MVC
Рівень 24 , Лекція 1
Відкрита

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.

З погляду шарів проєкту зручно тримати такий порядок:

  1. контролер приймає TaskCreateRequest і перевіряє його;
  2. сервіс створює доменну модель Task і призначає server-managed поля;
  3. репозиторій зберігає в in-memory сховищі;
  4. 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, то [], то відсутній зовсім, і це змінюється від запиту до запиту. На рівні клієнта це перетворюється на «кожного разу вгадай, що прийшло». Рішення не в тому, щоб заборонити все, а в тому, щоб стабілізувати: домовитися, як саме поле поводиться у відповіді, і дотримуватися цього правила послідовно.

1
Задача
Spring REST & MVC, 24 рівень, 1 лекція
Недоступна
Створення задачі з `201 Created` і `Location`
Створення задачі з `201 Created` і `Location`
1
Задача
Spring REST & MVC, 24 рівень, 1 лекція
Недоступна
Поля, керовані сервером, під час створення нагадування
Поля, керовані сервером, під час створення нагадування
Коментарі
ЩОБ ПОДИВИТИСЯ ВСІ КОМЕНТАРІ АБО ЗАЛИШИТИ КОМЕНТАР,
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ