1. DELETE у CRUD і чому навколо нього сперечаються
DELETE здається простим: «Ну ми ж просто видаляємо — що може піти не так?». А потім раптом зʼясовується, що саме на DELETE клієнти найчастіше будують автоматизацію, повторні спроби й «само полагодилося». І тут починаються тонкощі. Десь DELETE вважають командою «видали за будь-яку ціну», десь — суворою операцією «видали наявне, а якщо не знайшов — так і скажи». Наша задача — зафіксувати зрозумілий контракт і реалізувати його без сюрпризів.
У межах нашого навчального API для задач (tasks) DELETE — фінальний штрих у CRUD-наборі: створити, отримати список, змінити стан і… прибрати із системи. Важливо, що ми робимо це у стилі Go: короткий обробник, єдина політика помилок і жодної «магії» в статусах.
Ідемпотентність DELETE: ефект важливіший за відповідь
Термін «ідемпотентність» звучить так, ніби його вигадали, щоб програмісти могли лякати одне одного на співбесідах. Насправді сенс простий: якщо ви повторите той самий запит кілька разів, ефект буде таким самим, як і після одного запиту. Не «відповідь буде та сама байт-у-байт», а саме ефект на сервері.
Для DELETE це особливо важливо, тому що в реальному світі запит може дійти до сервера, а відповідь — загубитися: мережа моргнула, мобільний інтернет пішов пити чай, проксі замислився над сенсом життя. Клієнт робить повторну спробу — і це нормально. Якщо операція ідемпотентна, повтор не має призводити до «подвійного видалення» (що б це не означало) або до появи нових проблем.
Невелика табличка, щоб краще вклалося в голові:
| HTTP-метод | «Безпечний» (safe) | Ідемпотентний |
|---|---|---|
|
так | так |
|
ні | зазвичай ні |
|
ні | так |
|
ні | залежить |
|
ні | так |
«Безпечний» означає «не змінює стан сервера» (наприклад, GET можна повторювати хоч сто разів — сервер не повинен від цього «створити ще одну задачу»). DELETE змінює стан, тому він не safe, але зазвичай ідемпотентний.
2. Контракт DELETE /api/v1/tasks/{id}
Щоб не грати у «вгадай статус за настроєм сервера», ми фіксуємо контракт. У нас є шлях із {id}, і ми хочемо, щоб він працював однаково в усіх обробниках, де фігурує ідентифікатор.
Контракт:
- Метод і шлях: DELETE /api/v1/tasks/{id}
- {id} обов’язковий і має бути коректним додатним цілим числом
- Успіх: 204 No Content (і без тіла)
- Якщо задачі немає: 404 Not Found через нашу єдину error envelope (обгортку помилки)
- Якщо {id} поганий: 400 Bad Request (це валідація)
- Будь-яка неочікувана помилка: 500 Internal Server Error (і повідомлення назовні фіксоване та безпечне)
У табличному вигляді:
| Ситуація | HTTP-статус | error.code | Тіло |
|---|---|---|---|
| Видалили задачу | |
— | відсутнє |
| Задачі немає | |
|
JSON-обгортка помилки |
| id не число / <= 0 | |
|
JSON-обгортка помилки (поля) |
| Внутрішня помилка | |
|
JSON-обгортка помилки |
Чому ми робимо так, а не «завжди 204»? Про це буде окремий розділ нижче, бо саме тут люди починають сперечатися так, ніби від цього залежить доля цивілізації.
4. Реалізація: id, доменні помилки й обробник
Розбір {id}: чому це 400, а не 404
Дуже поширена плутанина в новачків: «Якщо клієнт надіслав дивний id, значить задачу не знайдено, отже 404». Звучить логічно, але це дві різні проблеми.
Якщо клієнт надіслав /api/v1/tasks/abc, сервер навіть не може чесно відповісти «не знайдено», бо він не розуміє, що шукати. Це не «задачі немає», це «вхідні дані некоректні». Тому це валідація → 400.
Якщо клієнт надіслав /api/v1/tasks/123, і 123 — коректне число, але такої задачі немає — це вже чесне not found → 404.
Саме тому ми раніше ввели єдиний parseID: один спосіб розбирати id, одні й ті самі правила, одні й ті самі помилки. Приклад — спрощений, щоб було видно ідею; у вас уже може бути ця допоміжна функція з попередніх лекцій:
package httpapi
import (
"strconv"
)
func parseID(s string) (int, error) {
id, err := strconv.Atoi(s)
if err != nil || id <= 0 {
return 0, &ValidationError{
Fields: map[string]string{"id": "має бути додатним цілим числом"},
}
}
return id, nil
}
Зверніть увагу на нюанс: ми повертаємо не просто помилку, а саме *ValidationError, щоб наша спільна функція writeError могла стабільно перетворити це на 400 validation із fields.
Доменні помилки: ErrNotFound — це норма
На HTTP-рівні нам не потрібні деталі зберігання — map, файл, база даних чи кришталева куля. HTTP-рівень має розуміти лише класи помилок, які він уміє перетворювати на відповіді. Для нашого випадку їх три: validation, not_found, internal.
Ідея така: рівень застосунку намагається видалити задачу. Якщо її не було — це не «внутрішня помилка», а цілком нормальний доменний сценарій «ресурс не знайдено». Тому рівень застосунку повертає ErrNotFound (можливо, загорнутий). У Go це трапляється постійно: помилка несе і клас (ErrNotFound), і контекст (fmt.Errorf("delete %d: %w", id, ErrNotFound)), а розпізнавання класу відбувається через errors.Is. Схожу ідею — перетворювати помилки на коди (зокрема 404) та дружні повідомлення — часто реалізують через централізований обробник, щоб не розмазувати логіку по всьому коду.
Мініприклад доменного видалення (знову ж таки, невеликими шматками, як ми любимо в навчальному коді):
package app
import (
"context"
"fmt"
)
func (a *App) DeleteTask(ctx context.Context, id int) error {
ok := a.store.DeleteByID(id)
if !ok {
return fmt.Errorf("delete task %d: %w", id, ErrNotFound)
}
return nil
}
Тут є два важливі моменти. По-перше, ми не повертаємо bool назовні: HTTP-рівню зручніше працювати з error, бо в нього вже є спільна функція writeError. По-друге, ми загортаємо ErrNotFound, щоб зберегти контекст (у логах і діагностиці це дуже допомагає), але клас помилки лишається розпізнаваним.
HTTP-обробник: 10 рядків коду замість «про всяк випадок»
Хороший обробник DELETE схожий на добрий чай: короткий, прозорий і без сюрпризів у ньому. Спочатку розбираємо id, потім викликаємо доменну операцію, далі повертаємо 204. Помилки — лише через спільну функцію writeError.
Почнімо з реєстрації маршруту (патерни Go 1.22+ у ServeMux):
package main
import (
"net/http"
)
func main() {
mux := http.NewServeMux()
// s — наш *Server із залежностями (рівень app і допоміжні функції).
mux.HandleFunc("DELETE /api/v1/tasks/{id}", s.handleDeleteTask)
_ = http.ListenAndServe(":8080", mux)
}
Тепер сам обробник:
package httpapi
import (
"net/http"
)
func (s *Server) handleDeleteTask(w http.ResponseWriter, r *http.Request) {
id, err := parseID(r.PathValue("id"))
if err != nil {
writeError(w, err)
return
}
if err := s.app.DeleteTask(r.Context(), id); err != nil {
writeError(w, err)
return
}
w.WriteHeader(http.StatusNoContent)
}
Якщо ви дивитеся на ці 12 рядків і думаєте «і все?» — так, усе. І це якраз добрий знак. У Go прийнято робити обробники лінійними: помилка → ранній return, успіх → одна зрозуміла відповідь.
204 No Content: чому після нього не можна писати body
204 No Content звучить так, ніби сервер просто лінується відповідати. Насправді це сильний сигнал: успішно, але тіла немає і бути не повинно. Це зручно клієнтам: не потрібно намагатися розібрати JSON, не потрібно зберігати «status: deleted», і логіка простіша — успіх є успіх.
Класична помилка: поставити WriteHeader(204), а потім за звичкою викликати json.NewEncoder(w).Encode(...). Іноді це навіть «ніби працює», але це той випадок, коли краще не перевіряти межі реальності на міцність: проксі, клієнти й тести можуть поводитися по-різному.
Якщо хочеться зробити код ще більш «самодокументованим», можна додати маленьку допоміжну функцію:
package httpapi
import "net/http"
func writeNoContent(w http.ResponseWriter) {
w.WriteHeader(http.StatusNoContent)
}
І тоді обробник читатиметься майже як текст:
func (s *Server) handleDeleteTask(w http.ResponseWriter, r *http.Request) {
id, err := parseID(r.PathValue("id"))
if err != nil {
writeError(w, err)
return
}
if err := s.app.DeleteTask(r.Context(), id); err != nil {
writeError(w, err)
return
}
writeNoContent(w)
}
Це дрібниця, але для новачків такий промовистий код часто читається легше.
5. Чи потрібно повертати 204, якщо задачі немає
Ось тут починається найцікавіше. Ідемпотентність говорить про ефект, а не про відповідь. Видалити вже видалене — це все ще «видалене». Тому деякі API справді відповідають 204, навіть якщо ресурсу не було: «Після операції ресурсу немає — чого ти ще хочеш?».
Але в межах нашого підходу ми явно обираємо інший контракт: якщо задачі немає — 404 not_found. Чому?
- По-перше, це допомагає клієнту відрізнити два сценарії: «ми видалили» і «видаляти було нічого». Іноді це важливо для UX. Наприклад, користувач видаляє задачу зі списку, але його список був застарілий: отримати 404 — корисний сигнал «вона вже зникла».
- По-друге, це робить поведінку узгодженою з іншими кінцевими точками за {id}. Якщо в нас є {id} і ресурсу немає, ми всюди кажемо not_found. Це простіше пояснити, простіше тестувати й простіше підтримувати.
- По-третє, це зменшує шанс сховати баг. Уявіть, що клієнт помилився й видаляє не той id. Якщо сервер завжди відповідає 204, клієнт довго може не помічати, що він бʼє повз.
При цьому ідемпотентність не ламається, бо повторний виклик DELETE на неіснуючому ресурсі повертатиме все той самий 404 (ефект усе одно один: ресурсу немає). І, до речі, саме тому так важливо не робити випадкові відповіді на один і той самий ввід.
6. Типові помилки
Помилка №1: плутати «не знайдено» і «поганий ввід».
Коли id не число або id <= 0, це не 404 і не «задачі немає», а помилка валідації вводу. Якщо повертати 404 на некоректний id, клієнт думатиме, що він правильно сформував запит, просто ресурсу немає. А насправді запит був некоректним.
Помилка №2: порівнювати помилки через err == ErrNotFound замість errors.Is.
Щойно ви загортаєте помилку через %w, пряме порівняння ламається. У результаті 404 перетворюється на 500 internal, і клієнт отримує дивну відповідь. Сентинельні помилки в Go майже завжди перевіряють через errors.Is, інакше обгортання втрачає сенс.
Помилка №3: писати тіло відповіді при 204 No Content.
Найпідступніше те, що іноді ви не побачите проблему одразу: локально все «нормально», а потім раптом в інтеграції клієнт перестає розуміти відповідь, або тести починають падати через зайві байти. Якщо статус 204, після WriteHeader краще вважати обробник завершеним за змістом.
Помилка №4: робити в обробнику DELETE «свою» обробку помилок замість спільної функції writeError.
Здається, що «ну тут усього одна кінцева точка, можна прямо тут написати http.Error». Але саме так і розповзаються різні формати помилок по проєкту. До того ж http.Error легко починає віддавати назовні err.Error(), а для 500 це погана ідея: внутрішні деталі мають залишатися всередині сервера. Централізований підхід до перетворення помилок і дружніх повідомлень користувачу — нормальна практика, щоб не розмазувати логіку по коду.
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ