1. Навіщо мислити ресурсами, а не командами
Коли ви починаєте робити HTTP‑API, дуже легко скотитися до стилю «зручних команд»: /createTask, /getTask, /markTaskDone. Це схоже на пульт керування: кнопка «Створити», кнопка «Отримати», кнопка «Зробити красиво». Проблема в тому, що HTTP уже придумав для вас кнопки — це методи. А шлях має описувати яка саме сутність стоїть за цією адресою.
Ресурсний підхід — це коли ви уявляєте API як «вітрину сутностей». Є колекція задач, є конкретна задача, є подання стану «виконано». Тоді клієнту простіше передбачати API за формою, а не вивчати 30 унікальних URL‑команд.
Хороша новина: вам не потрібно знати всю REST‑філософію. Достатньо тримати в голові одну просту фразу:
URL відповідає на запитання «що це?», а метод — «що зробити?»
Мініпорівняння: командний стиль vs ресурсний
| Що хочемо | Командний URL (погано) | Ресурсний URL (нормально) |
|---|---|---|
| отримати список задач | |
|
| створити задачу | |
|
| отримати задачу 7 | |
|
| позначити задачу 7 виконаною | |
|
Командний стиль виглядає «зрозумілим» рівно доти, доки API не починає розростатися. Потім стартує дрейф: createTask, addTask, newTask, makeTask — і раптом у вас в API з’являється лінгвістичний курс із синонімів англійських дієслів.
2. Версія в шляху: навіщо потрібен /api/v1
Версіювання в шляху часто здається бюрократією: «ми ж лише почали, то навіщо версії?». Але проблема в тому, що API майже неможливо не змінювати. Ви захочете додати поле, перейменувати його, змінити формат або «трішки» змінити поведінку помилок. І ось тут починається найцікавіше: старі клієнти можуть жити місяцями.
Є залізне правило: якщо клієнт уже інтегрувався, ваша «маленька правка» може обернутися падінням у продакшені в пʼятницю ввечері. А пʼятниця ввечері — це священний час, коли продакшен краще взагалі не чіпати.
У світі Go дуже добре відчувається важливість сумісності: якщо ви змінюєте публічний API (функції, типи), ви ризикуєте зламати чужий код, який на нього спирається. Це прямо описують як проблему сумісності: «не можна забирати API, інакше програми зламаються». З HTTP‑контрактами та сама історія, просто «чужа програма» — це клієнт вашого API.
Тому /api/v1 — це спосіб чесно сказати: «ось правила версії 1». Якщо колись знадобляться несумісні зміни, у вас є варіант /api/v2, і старі клієнти не мусять страждати.
Невеликий приклад на Go: фіксуємо префікс версії
package main
const apiV1 = "/api/v1"
func tasksPath() string {
return apiV1 + "/tasks"
}
Тут немає магії — просто звичка: усі шляхи будуємо від єдиного префікса. Це знижує ризик друкарських помилок і допомагає тримати контракт цілісним.
3. Колекція задач: /api/v1/tasks
Шлях /api/v1/tasks — це колекція задач. Уявіть полицю в магазині: на ній стоять усі товари. З полицею зазвичай роблять дві речі: дивляться, що на ній є, і ставлять новий товар.
Так само й в API: список задач — це запит до колекції, а створення задачі — це додавання до неї. Розрізняєте ці дії ви методом, а шлях лишається тим самим.
Тут важлива психологічна річ: коли шлях один, а метод змінюється, API сприймається як форма, а не як зоопарк посилань. Клієнту легше запамʼятати /tasks і далі вже логічно здогадатися: GET — читаю, POST — створюю.
Приклад: описуємо endpoint як об’єкт контракту
package main
type Endpoint struct {
Method string
Path string
}
var listTasks = Endpoint{Method: "GET", Path: "/api/v1/tasks"}
var createTask = Endpoint{Method: "POST", Path: "/api/v1/tasks"}
Якщо ви зараз подумали: «навіщо мені це в Go‑коді, ми ж іще сервер не пишемо?», то відповідь проста: так ви тренуєте м’яз «спочатку контракт». Цей самий список потім можна буде використати і в документації, і в тестах, і навіть у логах.
Чому tasks у множині
/tasks, а не /task, — тому що колекція передбачає множину. Так, це радше правило гарного тону, але воно реально допомагає мисленню. Якщо бачите множину, ви очікуєте список. Якщо бачите /tasks/7, ви очікуєте один елемент.
4. Одна задача: /api/v1/tasks/{id}
Якщо /tasks — це полиця, то /tasks/{id} — це картка конкретної задачі. Тут {id} — це шаблон у документації, а не реальні фігурні дужки в URL.
Тобто в контракті ви пишете:
- /api/v1/tasks/{id}
а в реальному запиті клієнт підставляє число:
- /api/v1/tasks/7
Що таке {id} за змістом
id — це ідентифікатор. У межах нашого навчального домену найпростіше домовитися, що id — це ціле число (наприклад, int). Важливо саме домовитися й зафіксувати: клієнт має розуміти, що саме він підставляє в шлях.
Якщо не зафіксувати формат, почнеться хаос: один клієнт шле UUID, інший — числа, третій — 0007, а четвертий — "сім" (бо він оптиміст).
Приклад: функція, яка матеріалізує шлях із {id}
package main
import "fmt"
func taskPath(id int) string {
return fmt.Sprintf("/api/v1/tasks/%d", id)
}
Такі функції здаються дрібницею, але вони дисциплінують. Коли ви пишете taskPath(7), ви не забудете /api/v1, не переплутаєте tasks із task і не зробите зайвий слеш. А ще такі функції потім дуже зручно використовувати в тестах: ви порівнюєте рядок, а не збираєте його вручну в кожному місці.
5. Стан done: поле і шлях /done
Із done зазвичай починається перша доросла дискусія про дизайн API. Чому? Тому що done — це наче просто булеве поле в задачі (done: true/false), але користувачеві хочеться окремої кнопки «Позначити виконаною», а розробнику — окремого ендпоінта, де все просто й зрозуміло.
У нашому домені ми хочемо підтримати зрозумілий сценарій: «позначити задачу виконаною». І в межах курсу ми беремо такий приклад шляху стану:
- /api/v1/tasks/{id}/done
Це читається майже як фраза: «done для задачі {id}». Тут /done виступає як підресурс або «подання стану». Ми не робимо вигляд, що done — окрема сутність рівня /done/{id}. Ми тримаємо його усередині задачі.
Чому /done — це нормально
Раніше ми критикували «дієслова в URL» (/createTask). Але /done — інший випадок: це не команда до сервера, а частина моделі ресурсу — його стан. Ми ніби кажемо: «у задачі є ознака done, і для неї є окреме місце в шляху».
Можна думати так: /api/v1/tasks/7/done — це «ручка» стану виконаності для задачі 7.
Варіанти, які зазвичай порівнюють
Є два популярні підходи:
- Робити зміну через оновлення задачі (наприклад, часткове) і передавати {"done": true} у тілі.
- Робити окремий ендпоінт стану: /tasks/{id}/done.
У межах цієї лекції ми фіксуємо саме дизайн шляху /done. А от точну семантику методу (POST/PATCH) і деталі статусів краще тримати в межах загального контракту, але без глибокого занурення в реалізацію.
Приклад: функція шляху done для конкретної задачі
package main
import "fmt"
func taskDonePath(id int) string {
return fmt.Sprintf("/api/v1/tasks/%d/done", id)
}
Тепер у вас є три стабільні «цеглинки»:
- tasksPath() — колекція
- taskPath(id) — один ресурс
- taskDonePath(id) — стан done
Це і є «скелет» URL‑простору нашого API.
Невелика схема: як усе пов’язано
Коли в голові все це змішується, допомагає проста візуалізація. Тут немає жодної магії REST — лише дерево.
flowchart TD
A["/api/v1/tasks
Колекція задач"] --> B["/api/v1/tasks/{id}
Одна задача"]
B --> C["/api/v1/tasks/{id}/done
Стан done"]
Дерево показує важливу річ: /done живе під задачею, а не поруч із нею. Це знижує ризик помилок дизайну, де done раптом починає поводитися як окрема сутність без змісту.
6. Консистентність словника: tasks, done, {id}
Дуже часто API псують не складні баги, а дрібна неузгодженість. Один ендпоінт використовує /tasks, інший /todo, третій /items. У вашій голові це одне й те саме, а клієнт — не телепат. Він читає документацію буквально.
Тому в навчальному домені ми тримаємо один словник:
- сутність: tasks
- ідентифікатор: id
- стан: done
- версія: /api/v1
Якщо вам хочеться додати «краси» й назвати задачі todos, зупиніться й запитайте себе: «А що виграє клієнт?». Зазвичай відповідь одна: «нічого, окрім зайвого місця для помилки».
Тут корисна аналогія з іменами змінних у Go: якщо ви в одному місці пишете taskID, а в іншому id, а в третьому task_id, код стає важчим для читання. Дизайн URL — це те саме, тільки «змінні» читають інші програми.
7. Мапа API в Go-коді
Навіть до того, як у вас з’явиться реальний HTTP‑сервер, корисно тримати контракт у вигляді структури даних. Це допомагає не сперечатися на словах («ну там десь /done»), а бачити конкретні рядки.
І ще це зменшує шанс друкарських помилок. Друкарська помилка в URL — один із найприкріших багів: усе «майже правильно», але 404, і ви годину шукаєте зайву літеру.
Крок 1: заведемо операції та список маршрутів
package main
type Route struct {
Method string
Pattern string
Op string
}
func apiRoutes() []Route {
return []Route{
{Method: "GET", Pattern: "/api/v1/tasks", Op: "list_tasks"},
{Method: "POST", Pattern: "/api/v1/tasks", Op: "create_task"},
{Method: "GET", Pattern: "/api/v1/tasks/{id}", Op: "get_task"},
{Method: "POST", Pattern: "/api/v1/tasks/{id}/done", Op: "mark_done"},
}
}
Зверніть увагу: ми використовуємо {id} саме як документаційний шаблон. Це зручно для читання й обговорення.
Крок 2: зробимо друк «мапи» як міні-документацію
package main
import "fmt"
func printRoutes(routes []Route) {
for _, r := range routes {
fmt.Printf("%s %-25s # %s\n", r.Method, r.Pattern, r.Op)
}
}
Крок 3: зберемо все в main
package main
func main() {
printRoutes(apiRoutes())
}
Якщо запустити, вивід буде приблизно такий:
GET /api/v1/tasks # list_tasks
POST /api/v1/tasks # create_task
GET /api/v1/tasks/{id} # get_task
POST /api/v1/tasks/{id}/done # mark_done
Це виглядає майже як чернетка README. І в цьому його сила: контракт має бути простим, щоб уміщатися «на один екран».
Чому змінювати шляхи майже завжди боляче
Шляхи — це частина контракту, і їх важко змінювати без наслідків. Навіть якщо ви «просто перейменували» /tasks на /todo, клієнти перестануть працювати.
У Go‑світі тема сумісності — не абстракція. Якщо публічний API змінюється, чужий код ламається, і це дуже практична проблема: не можна «забрати API назад» без поломок. HTTP‑контракт поводиться так само: ви не можете непомітно прибрати endpoint, якщо на нього хтось зав’язаний.
І є ще один хороший приклад того, як сумісність впливає на дизайн: коли в стандартній бібліотеці Go додавали context.Context, не можна було просто змінити сигнатуру http.Client.Do, бо це зламало б купу коду. Тому підтримку контексту вбудували в http.Request так, щоб не ламати старі виклики.
Це чудовий урок: дизайн інтерфейсу й API часто визначається не «ідеальною архітектурою», а відповідальністю перед користувачами.
8. Типові помилки під час дизайну шляхів задач
Помилка № 1: командні URL (/createTask, /markDone) замість ресурсів.
Зазвичай це трапляється через бажання зробити «як у RPC»: окрема команда на кожну дію. У підсумку у вас з’являється зоопарк шляхів, і клієнт не може передбачити API за формою. Лікується простим правилом: шлях — іменник (ресурс), дія — HTTP‑метод, а винятки на кшталт /done мають бути частиною моделі стану, а не випадковою командою.
Помилка № 2: відсутність єдиного словника (/tasks, /todo, /items) і дрейф назв.
Спочатку здається, що це дрібниця. Потім раптом виявляється, що різні команди розробки називали одне й те саме по-різному, і клієнт пише костилі «якщо /tasks не працює, спробуй /todo». Нормальний підхід — обрати один домен (tasks) і тримати його всюди: у шляхах, у DTO, у документації, у прикладах.
Помилка № 3: {id} не визначено як формат, і кожен клієнт розуміє його по-своєму.
Якщо ви не сказали, що id — це ціле число (або UUID), то клієнти почнуть вгадувати. Особливо весело, коли один клієнт шле "7", а інший шле "007", а третій шле "seven". У контракті потрібно явно зафіксувати: {id} — це, наприклад, додатне ціле число. Навіть якщо ви поки що не реалізували перевірку, домовленість уже має існувати.
Помилка № 4: плутанина між станом і ресурсом для done.
Іноді done раптом перетворюють на окремий ресурс верхнього рівня (/api/v1/done/{id}), і API стає дивним: «де тут задача, а де ознака?». У нашому домені done — це стан задачі, тому він живе під /tasks/{id}: /tasks/{id}/done. Так клієнту простіше зрозуміти, що змінюється саме стан конкретної задачі.
Помилка № 5: відсутність версії (/api/tasks замість /api/v1/tasks) і надія «ми завжди будемо акуратними».
Надія шляхетна, але реальність перемагає: API змінюється. Без версії вам або доведеться ламати старих клієнтів, або тягнути купу зворотної сумісності без явного розділення правил. Версія в шляху — простий і чесний спосіб сказати: «ось контракт, за який ми відповідаємо».
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ