1. Артефакти навколо коду
На цей момент API вже поводиться передбачувано не лише на happy-path: у нього є валідаційна межа, єдиний ErrorResponse і чесна різниця між 404, 405 та 409. Отже, настав час винести контракт назовні — у колекцію, README і, якщо хочете, у невеликий dist-архів.
Коли ви вперше дописуєте CRUD, дуже хочеться з полегшенням видихнути й сказати: «Ну все, готово, воно ж працює». Але «працює» — це стан у вашій голові, на вашому ноутбуці й у вашій поточній вкладці Postman. У бекенд-світі цінується інше: щоб результат можна було відтворити, перевірити й передати далі без телепатії.
Є просте правило: якщо проєкт не можна підняти й перевірити за інструкцією, то в нього немає зовнішньої форми. Він схожий на смачний суп… який існує лише в каструлі на вашій плиті. Начебто реальний, але щойно ви пішли — ніхто не знає, що саме ви туди поклали й скільки це варилося.
Давайте зафіксуємо, які три артефакти роблять наш ReadLater Starter завершеним:
| Артефакт | На яке запитання відповідає | Чому це важливо саме зараз |
|---|---|---|
| Postman collection | «Як перевірити API руками за 2–5 хвилин?» | Це наш smoke-інструмент замість тестового фреймворку (пам’ятаєте межі курсу). |
| README.md | «Як зібрати й запустити проєкт без магії IDE?» | Завтра ви забудете деталі. README — це пам’ять проєкту, а не ваша. |
| Packaging (опційно) | «Як віддати результат “одним файлом”, щоб він запускався?» | Іноді зручно мати ZIP «для перевірки» без Gradle і без вихідних кодів. |
Зверніть увагу на важливу думку: Postman collection і README — це не прикраси, а частина API-контракту та життєвого циклу застосунку. Spring потім автоматизує купу рутини, але він не замінить дисципліну: як проєкт запускається і перевіряється.
2. Postman collection як контракт API
Postman collection наприкінці курсу — це не «набір запитів, які я колись натискав». Це, по суті, маленька інструкція в машинному вигляді: які endpoints існують, які статуси вони повертають, як виглядають тіла запитів і відповідей, а також які негативні сценарії ми зобов’язані вміти розрізняти. В ідеалі будь-яка людина може імпортувати collection і за кілька хвилин зрозуміти, що API живий.
Організація collection та environments
Найсильніше Postman ламається, коли кожен запит живе своїм життям: URL захардкожені, імена «New Request (42)», а змінні існують лише у фантазії автора. Тому починаємо з дисципліни: одна collection, одна змінна baseUrl, зрозумілі імена запитів і мінімальна структура папок за сценаріями, а не за настроєм.
Зазвичай достатньо такої структури (у вигляді дерева, щоб було видно порядок):
ReadLater Starter — Local API
├─ Здоров’я
│ └─ GET /health
├─ Список читання
│ ├─ GET /api/v1/reading-list
│ ├─ GET /api/v1/reading-list/{id}
│ ├─ POST /api/v1/reading-list
│ ├─ PUT /api/v1/reading-list/{id}
│ ├─ PATCH /api/v1/reading-list/{id}/status
│ └─ DELETE /api/v1/reading-list/{id}
└─ Негативні
├─ 400 Validation error
├─ 404 Not found
├─ 405 Method not allowed (+Allow)
├─ 409 Conflict (externalId)
└─ 500 Internal error (формат відповіді)
Ключовий трюк, який економить години життя: усюди пишемо URL через змінну середовища.
{{baseUrl}}/api/v1/reading-list
І заводимо кілька змінних (краще прямо в Environment, щоб не тримати їх у голові):
| Змінна | Приклад | Для чого |
|---|---|---|
| baseUrl | http://localhost:8080 | Базова адреса сервера. Змінюється найчастіше. |
| itemId | 1 | ID створеного елемента, щоб потім не копіювати його вручну. |
| externalId | OL12345M | Зручно для сценарію 409 Conflict. |
Якщо ви зараз подумали: «Та я й так пам’ятаю, що в мене localhost:8080», вітаю — це класична пастка. Завтра виявиться 8081. Післязавтра ви запустите два сервіси. А потім подивитеся на це через рік і спитаєте: «А чому тут у кожному запиті різні порти?»
Chained requests: зберігаємо id і використовуємо далі
Коли ви перевіряєте CRUD вручну, найнудніша частина — копіювати id з відповіді POST і вставляти його в GET/PUT/PATCH/DELETE. Postman уміє зробити це за вас: у тесті після POST ви зберігаєте id у змінну, і далі всі запити використовують {{itemId}}. Це рівно те саме зв’язування, тільки на рівні smoke-перевірки.
Спочатку зробимо зрозумілий body для створення. Зверніть увагу: JSON маленький і передбачуваний — це важливо для smoke-сценарію.
{
"title": "Clean Code",
"author": "Robert C. Martin",
"status": "PLANNED",
"externalId": "OL12345M",
"comment": "Знайти паперове видання"
}
Тепер додамо мінімальний скрипт «Tests» у Postman для запиту POST /api/v1/reading-list (це JavaScript, але дуже маленький і дуже корисний):
// Перевіряємо, що створення справді відпрацювало як створення, а не як "200 OK на все підряд"
pm.test("201 Створено", () => {
pm.response.to.have.status(201);
});
// Зберігаємо id створеного елемента, щоб не копіювати його вручну в наступні запити
const body = pm.response.json();
pm.environment.set("itemId", body.id);
Після цього ваш запит GET /api/v1/reading-list/{{itemId}} стає справді зручним. І це вже схоже на нормальний інженерний процес: ви не «клацаєте хаотично», а проганяєте сценарій.
До речі, для PATCH /status добре мати окремий невеликий body, щоб не змішувати повне оновлення й часткове:
{
"status": "FINISHED"
}
А для PUT (full update) — повний набір полів, тому що це «заміна», а не «додамо те, що є»:
{
"title": "Clean Code (2nd edition)",
"author": "Robert C. Martin",
"status": "IN_PROGRESS",
"externalId": "OL12345M",
"comment": "Перевидання, перевірити відгуки"
}
Negative-path сценарії: 400/404/405/409/500
Гарна фінальна collection — це та, де негативні сценарії не заховані «в голові». Ми не просто сподіваємося, що помилки будуть правильними; ми тримаємо в collection конкретні запити, які гарантовано приводять до очікуваного статусу та очікуваного ErrorResponse. Саме тут API стає контрактом, а не поведінкою «як вийшло».
Ось зручна таблиця негативних сценаріїв для нашого проєкту (і так, вона невелика — цього достатньо для курсу):
| Сценарій | Приклад запиту | Очікуємо | Що дивимось у відповіді |
|---|---|---|---|
| 400 validation | POST /api/v1/reading-list з порожнім title | 400 | errorCode=VALIDATION_ERROR, у details є причина |
| 404 not found | GET /api/v1/reading-list/999999 | 404 | errorCode=NOT_FOUND і стабільне повідомлення |
| 405 method not allowed | PUT /api/v1/reading-list | 405 | Allow містить GET, POST (або ваш набір) |
| 409 conflict | два POST /api/v1/reading-list з однаковим externalId | 409 | errorCode=CONFLICT (і без стектрейсу) |
| 500 internal | «неочікувана» помилка | 500 | errorCode=INTERNAL_ERROR, повідомлення коротке, деталі порожні |
Сценарій 400 найпростіше зробити так: надсилаємо валідний JSON-синтаксис, але ламаємо зміст. Наприклад, title — рядок із пробілів. Це важливий випадок: null і isBlank() — різні речі.
{
"title": " ",
"author": "Robert C. Martin",
"status": "PLANNED"
}
І очікуємо, що відповідь буде в єдиному форматі (приблизно так):
{
"errorCode": "VALIDATION_ERROR",
"message": "Запит не пройшов перевірку",
"details": [
"Поле title обов’язкове"
]
}
Сценарій 405 добрий тим, що він взагалі не має доходити до parsing/validation. Ми перевіряємо його на рівні маршрутизації: шлях відомий, метод — ні. І, як дорослий API, ми повертаємо Allow. У Postman можна навіть зробити маленьку перевірку, що header справді приїхав:
// 405 — це про роутинг, тому окремою перевіркою фіксуємо і статус, і наявність Allow
pm.test("405 + заголовок Allow", () => {
pm.response.to.have.status(405);
pm.expect(pm.response.headers.has("Allow")).to.be.true;
});
Про 500 скажу чесно: у маленькому навчальному проєкті ви не завжди можете «примусово відтворити» внутрішню помилку так само стабільно, як 404 або 409. Але сенс сценарію не в тому, щоб милуватися 500, а в тому, щоб якщо він станеться (а він станеться — хоча б через майбутній рефакторинг), клієнт побачив нормальний JSON, а не «Oops» і не стектрейс на 200 рядків. Тому в collection має сенс тримати запит «500 internal error (format check)» і час від часу переконуватися, що формат помилки залишається коректним.
3. README: інструкція з запуску та перевірки
README — це не «пост у стилі: я написав проєкт, усім дякую». У нашому курсі README — це інструкція для запуску та перевірки. Уявіть, що ваш проєкт завтра має перевірити інший студент, або ви самі через тиждень у стані «я нічого не пам’ятаю, але треба швидко підняти». README має відповідати на запитання без діалогу з автором, інакше це не README, а загадка.
Мінімальний шаблон README
Шаблон README краще тримати коротким, але конкретним. У ньому важливі команди через Gradle Wrapper, режими запуску та мінімальні підказки щодо конфігурації. Не треба перетворювати README на роман. Але й «Run it somehow» теж не підходить — це надто чесно навіть за мірками програмістів.
Приклад мінімального блоку «Запуск» (зверніть увагу: команди короткі та перевіряються):
## Запуск
Збирання:
./gradlew clean build
Запуск server-mode:
./gradlew run --args="server"
Далі має сенс додати «швидкий smoke» прямо текстом, щоб людина не шукала очима:
## Швидка перевірка
1) GET {{baseUrl}}/health -> 200
2) POST /api/v1/reading-list -> 201 + Location
3) GET /api/v1/reading-list -> 200 + count
Так, тут є нумерація, але вона виконує роль інструкції. У README без неї виходить вода, а наша мета — відтворюваність.
Окремо важливо зафіксувати, де лежать артефакти:
## Артефакти
- Postman collection: postman/readlater.postman_collection.json
- Конфіг: src/main/resources/application.properties
І ще один практичний фрагмент — приклад конфігурації. Навіть якщо у вас за замовчуванням усе «і так працює», через місяць ви забудете ключі.
# На якій адресі/порті піднімається сервер у server-mode
server.host=localhost
server.port=8080
# У якому режимі працюємо з каталогом: mock / real (або ваш варіант)
catalog.api.mode=mock
catalog.api.base-url=https://example-catalog
Smoke-checklist: «пʼять хвилин — і сервіс живий»
Перевірка проєкту має бути короткою й однаковою кожного разу. Тому добре працює концепція smoke-checklist: невеликий порядок кроків, який ви можете виконати навіть у втомленому стані. У нашому курсі це особливо важливо, тому що ми свідомо не будуємо окремий тестовий контур — замість нього в нас Postman і зрозумілі команди запуску.
Найзручніше тримати checklist або прямо в README, або окремим блоком поруч, але в межах курсу README достатньо. Наприклад, можна оформити це таблицею:
| Крок | Дія | Очікуваний результат |
|---|---|---|
| 1 | ./gradlew run --args="server" | У логах видно host/port, сервер піднявся |
| 2 | GET /health | 200 OK і JSON зі статусом UP |
| 3 | POST /api/v1/reading-list | 201 Created, у відповіді є id |
| 4 | GET /api/v1/reading-list/{{id}} | 200 OK і коректний об’єкт |
| 5 | PUT /api/v1/reading-list/{{id}} або PATCH /api/v1/reading-list/{{id}}/status | 200 OK, зміни видно в наступному GET |
| 6 | DELETE /api/v1/reading-list/{{id}} | 204 No Content, після цього GET дає 404 |
Це виглядає як формальність, доки ви не спробуєте перевірити проєкт через тиждень, коли в голові вже Spring, SQL і думки про сенс життя. Тоді така таблиця — чиста психотерапія.
4. Мінімум файлів у репозиторії
Іноді новачки думають, що репозиторій — це лише src/ і build.gradle.kts. Усе інше сприймається як зайва бюрократія. Але backend-проєкт — це артефакт, який живе не тільки в коді: у нього є конфігурація, логи, sample-дані, smoke-інструменти. Якщо ці речі розкидані де завгодно, проєкт важко перевіряти й важко підтримувати навіть самому собі.
Для ReadLater Starter достатньо такого мінімального складу (приблизно так це виглядає «доросло», але без переускладнення):
.
├─ build.gradle.kts
├─ settings.gradle.kts
├─ gradlew / gradlew.bat
├─ README.md
├─ postman/
│ └─ readlater.postman_collection.json
├─ samples/ (або src/main/resources/mock/)
│ ├─ catalog-search.json
│ └─ catalog-details.json
└─ src/
└─ main/
├─ java/...
└─ resources/
├─ application.properties
└─ logback.xml
Тут важливий сенс: Postman-артефакт лежить поруч із кодом, тому що це частина проєкту; конфіг лежить у ресурсах, тому що це нормальна точка для зовнішньої поведінки; samples лежать там, де їх можна знайти й використати в mock-режимі. А README лежить у корені, тому що так влаштоване життя.
5. Packaging: ZIP без Gradle
Packaging наприкінці курсу — штука опційна. Це не обов’язковий DevOps, не деплой і не контейнеризація, а лише зручний спосіб зібрати проєкт у переносний набір файлів. Іноді це допомагає викладачеві перевіряти роботи, іноді — вам самим переносити проєкт між машинами, а іноді — просто відчути, що «ось він, артефакт».
Звичайний jar і runtime classpath
Коли ви робите ./gradlew jar, Gradle збирає звичайний jar. У ньому є ваш код, але немає залежностей (Jackson, SLF4J, Logback). І це нормально: ми в цьому курсі не підключаємо Shadow plugin і не робимо fat-jar, тому що це окрема тема й окремі нюанси.
Тому якщо ви спробуєте «чесно» запустити лише jar, то рано чи пізно побачите знайоме сумне:
NoClassDefFoundError: com/fasterxml/jackson/...
Рішення на рівні простого дистрибутива таке: у ZIP кладемо jar і кладемо поруч усі runtime-залежності, а запуск робимо через classpath wildcard. Команда виходить зрозуміла:
java -cp "libs/*" com.example.readlater.app.ReadLaterApplication server
І це виглядає майже як мініінсталятор: є папка libs/, є команда запуску, є README і Postman.
Gradle Zip task: packageDist
Gradle уміє пакувати файли в ZIP без додаткових плагінів. Ми просто реєструємо задачу packageDist, кладемо в архів jar, runtime classpath, README і папку postman/. Це невелика інфраструктура, але вона дуже прозора: ви бачите, що саме пакуєте, і немає відчуття магії.
Приклад задачі в build.gradle.kts (усе навмисно коротко):
import org.gradle.api.tasks.bundling.Zip
tasks.register<Zip>("packageDist") {
// Кладемо зібраний jar у папку libs всередині архіву
from(tasks.jar) { into("libs") }
// Кладемо runtime-залежності поруч, щоб запускати через -cp "libs/*"
from(configurations.runtimeClasspath) { into("libs") }
// Додаємо інструкцію із запуску поруч — інакше ZIP перетворюється на "вгадай як"
from("README.md")
// Додаємо smoke-інструмент (Postman collection) як частину артефакту
from("postman") { into("postman") }
// Явно задаємо, куди складати ZIP і як його назвати (щоб шлях був передбачуваним)
destinationDirectory.set(layout.buildDirectory.dir("dist"))
archiveFileName.set("readlater-starter.zip")
}
Після цього ви можете виконати:
./gradlew packageDist
І отримати build/dist/readlater-starter.zip. Усередині буде достатньо файлів, щоб «взяти й спробувати». І це важливо: упаковка має бути невеликою, а не перетворюватися на копію репозиторію з усім сміттям.
Мініскрипти запуску: run.sh та run.bat
Якщо вже робите ZIP, людині буде приємніше запускати не довгу команду, а короткий скрипт. Але ми не перетворюємо це на окремий інженерний пласт: скрипт — на 5–10 рядків, без розумних перевірок на все підряд.
Приклад run.sh (для Linux/macOS):
#!/usr/bin/env bash
set -e
# Перший аргумент — режим, за замовчуванням "server" (щоб запуск без параметрів працював)
MODE="${1:-server}"
# Запускаємо застосунок із classpath на libs/* (jar + залежності лежать поруч)
java -cp "libs/*" com.example.readlater.app.ReadLaterApplication "$MODE"
І приклад run.bat (для Windows, теж максимально просто):
@echo off
rem Перший аргумент — режим, якщо не задано, використовуємо server
set MODE=%1
if "%MODE%"=="" set MODE=server
rem Запуск через classpath на libs/* (jar + залежності в одній папці)
java -cp "libs/*" com.example.readlater.app.ReadLaterApplication %MODE%
В ідеалі ви кладете їх в архів разом із README. І знову тримаємо межу: це не справжній інсталятор, а маленька зручна кнопка «запусти».
6. Перевірка завершеності проєкту
Дуже корисна перевірка — «тест на завтрашнього себе». Уявіть, що ви закрили IDE, видалили всі run-конфіги, забули, який порт був учора, і раптом вам потрібно показати проєкт. Якщо в цей момент ви можете відкрити README, запустити команди і прогнати Postman collection — значить, проєкт справді завершений як навчальний артефакт.
Можна перевірити це буквально за невеликою таблицею, без героїзму:
| Перевірка | Що робимо | Що має бути видно |
|---|---|---|
| CLI build | ./gradlew clean build | Збирання зелене, jar збирається |
| Server run | ./gradlew run --args="server" | Сервер стартує на порту з конфігурації |
| Health | GET /health | 200 і JSON UP |
| CRUD smoke | колекція Postman | happy-path проходить без ручного копіпасту id |
| Negative | папка Negative в collection | 400/404/405/409 повертають ErrorResponse |
Якщо все це виконується, далі вже можна спокійно закривати курс. Бо ви не просто «написали API», а зробили результат, який можна показати, перевірити й розвивати далі.
7. Типові помилки
Помилка №1: README «лише для IDE».
Коли запуск описано через IntelliJ («натисніть зелену кнопку»), проєкт перестає бути відтворюваним поза вашою середою. Будь-який інший розробник упирається в налаштування IDE замість запуску застосунку. Базовий сценарій має будуватися навколо ./gradlew — це і є контракт запуску. IDE — лише зручний інтерфейс, а не основний спосіб роботи.
Помилка №2: Postman collection лише з happy-path.
Наявність GET і POST — це ще не контракт. Якщо в колекції немає негативних кейсів (400, 404, 405, 409) і перевірки Allow для 405, ви не тестуєте поведінку системи в реальних умовах. Зовнішній клієнт помиляється — і саме в цей момент проявляється якість API. Негативні сценарії мають бути такими ж «першокласними», як і успішні.
Помилка №3: захардкожений localhost:8080 у кожному запиті.
Поки все працює на одному порту, це здається нормою. Але будь-яка зміна перетворюється на ручне виправлення десятків запитів. Один baseUrl у environment повністю розв’язує цю проблему й робить колекцію переносною між машинами, стендами та середовищами.
Помилка №4: неправильні очікування від jar під час packaging.
Звичайний jar, зібраний Gradle, не містить залежностей — і це коректно. Помилка виникає тоді, коли його намагаються запускати «як є». Якщо ви робите ZIP-дистрибутив, у вас два шляхи: або зібрати fat-jar (якщо це дозволено), або покласти всі runtime-залежності в libs/ і запускати через classpath. Усе інше — спроба обійти реальність збирання.
Помилка №5: архів «з усім підряд».
Коли в ZIP потрапляють .idea/, build/, тимчасові файли та випадкові артефакти, ви ускладнюєте перевірку й збільшуєте шум. Packaging — це про мінімальний, чистий набір: jar, залежності, README, Postman. Усе, що не потрібно для запуску й перевірки, має залишатися в репозиторії, а не в доставці.
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ