1. Зачем вообще нужен done endpoint
Когда вы делаете CRUD для tasks, возникает момент истины: «Окей, задачи мы создаём и читаем, а как менять состояние?» В учебных проектах хочется просто написать task.Done = true и жить дальше. Но в HTTP‑API это превращается в отдельный контракт: какой метод, какой путь, что возвращаем, идемпотентно ли действие, что делать при повторных вызовах. И именно на таких «простых» местах чаще всего получается API, которое потом неудобно поддерживать.
Представим, что у нас задача — это ресурс, и у ресурса есть состояние done (выполнена/не выполнена). Клиент (CLI, фронтенд, другой сервис) должен уметь сказать серверу: «вот эту задачу отметь выполненной». Технически это update. Но update в HTTP бывает разным, и дальше мы разберём, как выбрать правильную форму и не запутать ни клиента, ни себя через неделю.
PATCH vs POST: что мы выбираем и почему
Если вы только начинаете, то вполне естественная мысль: «У нас есть действие “mark done”, значит надо POST: /tasks/{id}/done». Это логика “POST = сделать действие”. Она не совсем неправильная, но у неё есть побочные эффекты: вы смешиваете “ресурс” и “команду”, а также рискуете потерять идемпотентность (а потом удивляться, почему ретраи клиента ломают жизнь).
Для “изменить часть ресурса” в HTTP чаще всего подходит PATCH. Мысленно это звучит так: «Я не пересоздаю задачу целиком (не PUT), я не создаю под‑ресурс (не POST), я частично меняю существующий ресурс». И отметка “done” — как раз частичное изменение.
При этом у вас есть два популярных дизайна:
- PATCH /api/v1/tasks/{id} с телом {"done": true}
- PATCH /api/v1/tasks/{id}/done без тела (или с телом, но обычно без)
В рамках этого дня мы держим более прямолинейный и дружелюбный к новичкам вариант: PATCH /api/v1/tasks/{id}/done, потому что он проще читается глазами и проще валидируется (нам не нужно разбирать JSON только ради true). А ещё он хорошо укладывается в идею “endpoint на изменение состояния”.
Чтобы не превращать это в религиозную войну “PATCH vs POST”, зафиксируем полезную табличку — она не истина в последней инстанции, но отлично дисциплинирует мысли:
| Вариант | Пример | Плюсы | Минусы |
|---|---|---|---|
|
|
ближе к “изменению части ресурса”, проще сделать идемпотентным | иногда спорят, что “done” выглядит как команда |
|
|
хорошо читается как действие | чаще ассоциируется с “создать/запустить”, легко сделать неидемпотентным случайно |
Мой практический совет: если действие по смыслу — изменение состояния ресурса, и вы хотите, чтобы повторный вызов не создавал хаоса, используйте PATCH.
2. Идемпотентность: почему повторный запрос не должен делать «ещё больше done»
Слово “идемпотентность” звучит как заклинание из книги по матану (и да, оно пугает). Но в HTTP это очень житейская идея: если клиент повторит запрос из‑за сетевого сбоя или таймаута, результат должен оставаться предсказуемым.
Для “mark done” хочется, чтобы:
- первый вызов сделал задачу выполненной;
- второй/третий/пятидесятый вызов не портил данные и не превращал done в “done done done”.
То есть операция по смыслу идемпотентна: после выполнения состояние одно и то же.
Отсюда вытекает важный контракт доменного слоя: метод MarkDone(id) должен вести себя спокойно, даже если задача уже done=true. Варианты поведения тут бывают разные, но для нашего учебного API удобно следующее:
- если задача существует, мы гарантируем done=true и возвращаем успех;
- если задача не существует, возвращаем ErrNotFound (возможно обёрнутый);
- если id невалидный, это validation‑ошибка ещё до доменного слоя.
4. 200 vs 204: что возвращать клиенту
Теперь самое вкусное: что мы возвращаем в ответ на успешный “mark done”.
Есть два основных варианта:
- 204 No Content — успех без тела ответа
- 200 OK — успех с телом, например вернуть обновлённую задачу
Оба варианта допустимы, но они тянут за собой разный стиль работы клиента.
Вариант A: 204 No Content
Этот вариант говорит: «Я сделал. Ничего больше не скажу». Он хорош тем, что:
- проще реализация (не нужен DTO, не нужен json.NewEncoder(w).Encode(...));
- меньше трафика;
- меньше шансов случайно “дрейфнуть” формат ответа.
Но есть строгий нюанс: при 204 нельзя писать body. То есть вообще. Даже "ok". Даже один пробел. Даже “честное слово, там всего 2 байта”. Некоторые прокси и клиенты будут вести себя странно.
Вариант B: 200 OK + JSON
Этот вариант говорит: «Я сделал и вот тебе итоговое состояние». Он хорош тем, что клиенту не нужно делать дополнительный GET, чтобы увидеть обновлённые поля.
Но здесь вы платите сложностью: вам нужно решить, что именно вернуть. Полную задачу? Только {"id":10,"done":true}? А ещё нужно держать DTO стабильным.
В рамках нашего дня мы выбираем 204 No Content как базовый, “чистый” и легко поддерживаемый контракт. Если вам прям очень хочется вернуть JSON, это тоже можно сделать, но тогда договоритесь об этом явно и придерживайтесь во всём API одинакового стиля.
Небольшая мини‑схема, чтобы закрепить, чем эти варианты отличаются по “ощущению”:
sequenceDiagram
participant C as Client
participant S as Server
C->>S: PATCH /tasks/10/done
alt 204 No Content
S-->>C: 204 (no body)
Note over C: Клиент при желании делает GET
else 200 OK + body
S-->>C: 200 + {"id":10,"done":true,...}
Note over C: Клиент сразу видит итог
end
5. Контракт endpoint’а: метод, путь, вход, выход
Сейчас мы зафиксируем контракт так, чтобы его можно было прочитать как “договор”, а не как “ну примерно так работает”.
Мы реализуем:
- Метод: PATCH
- Путь: /api/v1/tasks/{id}/done
- Path params: {id} — обязателен, целое число > 0
- Тело запроса: отсутствует
- Успех: 204 No Content
- Ошибки:
- 400 (validation) если id пустой/не число/<=0
- 404 (not_found) если задачи нет
- 500 (internal) для прочих неожиданных проблем
Заметьте важную педантичность: “id не число” — это не 404. Это плохой ввод, а значит 400. 404 мы оставляем для ситуации “число нормальное, но такого ресурса нет”.
6. Роутинг в Go 1.22+: регистрируем handler
Прежде чем писать handler, ему нужно дать адрес в ServeMux. Раз у нас Go 1.22+ и мы используем patterns с методом, это выглядит очень читабельно: строка маршрута сама документирует контракт.
package main
import (
"net/http"
)
func routes(s *Server) *http.ServeMux {
mux := http.NewServeMux()
mux.HandleFunc("PATCH /api/v1/tasks/{id}/done", s.handleMarkDone)
return mux
}
Обратите внимание на важную мелочь: мы не “склеиваем строки” и не пытаемся вручную распарсить URL. Path‑параметр {id} потом достаётся через r.PathValue("id"). Это снимает кучу рутины и уменьшает шанс сделать ошибку в разборе пути.
7. Единый разбор {id}: parseID и ValidationError
Смысл parseID в нашем приложении не только в том, чтобы превратить строку в число. Главное — чтобы все endpoint’ы одинаково трактовали плохой id и возвращали одну и ту же форму validation‑ошибки.
Пример (напоминаю: это может быть уже написано ранее; здесь лишь фиксируем идею, какую ошибку хотим вернуть):
package main
import (
"strconv"
)
func parseID(raw string) (int, error) {
id, err := strconv.Atoi(raw)
if err != nil || id <= 0 {
return 0, &ValidationError{
Fields: map[string]string{"id": "must be positive integer"},
}
}
return id, nil
}
Важный момент: parseID возвращает именно ValidationError, чтобы writeError мог стабильно превратить это в 400 с fields. То есть обработчик не решает “что такое ошибка id” — он просто зовёт общий helper.
8. Handler: parse → app → status
Handler для done — отличный пример “короткого и честного кода”. Он должен выглядеть максимально линейно: разобрали id, вызвали доменную операцию, отдали 204. Никаких JSON‑энкодеров, никаких “а давайте ещё лог напечатаем в ответ”.
package main
import (
"net/http"
)
func (s *Server) handleMarkDone(w http.ResponseWriter, r *http.Request) {
id, err := parseID(r.PathValue("id"))
if err != nil {
writeError(w, err)
return
}
if err := s.app.MarkDone(r.Context(), id); err != nil {
writeError(w, err)
return
}
w.WriteHeader(http.StatusNoContent) // 204
}
Если вы поймаете себя на мысли “а может напишем fmt.Fprintln(w, "ok")?”, остановитесь. При 204 это будет нарушением контракта. В мире API “я чуть-чуть написал body” — это как “я чуть-чуть сломал протокол”: вроде мелочь, но потом кто-то будет дебажить это ночью.
9. Доменный слой: как вернуть not_found
HTTP‑слой у нас не должен “понимать базу данных” или детали хранилища. Он умеет маппить доменные классы ошибок в HTTP‑коды. Поэтому доменный слой обязан вернуть ошибку так, чтобы классификация работала стабильно.
Мы договорились, что not found — это sentinel ErrNotFound, распознаваемый через errors.Is. И очень важно: если вы добавляете контекст через fmt.Errorf, используйте %w, чтобы ошибка оставалась распознаваемой в цепочке. В Go это стандартная практика работы с sentinel errors.
Вот минимальный пример доменного метода:
package main
import (
"context"
"fmt"
)
func (a *App) MarkDone(ctx context.Context, id int) error {
ok := a.store.SetDone(id, true)
if !ok {
return fmt.Errorf("mark done %d: %w", id, ErrNotFound)
}
return nil
}
Почему мы так делаем? Потому что errors.Is(err, ErrNotFound) будет работать даже при оборачивании (wrapping), и это намного надёжнее, чем err == ErrNotFound.
10. Если задача уже выполнена: ошибка или успех?
Это место, где многие новички (и не только новички) начинают спорить с реальностью. Кажется логичным: “Если она уже done, значит это ошибка”. Но в API это часто превращается в неудобство: клиенту приходится сначала делать GET, чтобы узнать состояние, потом решать, вызывать PATCH или нет. А если два клиента одновременно? А если UI на телефоне и сеть прыгает?
Для нашего учебного контракта удобно и практично правило: если задача существует, MarkDone всегда успех, даже если она уже done=true. Это делает операцию естественно идемпотентной. Клиент может повторять запрос безопасно, и состояние не ломается.
Если вам очень хочется сообщить клиенту “она уже была выполнена”, это можно сделать только если вы возвращаете 200 с телом и, например, полем updated: false. Но это усложняет контракт, и для базового курса мы это не берём: 204 проще, чище, меньше поверхностей для багов.
11. Альтернативы: POST и ответ 200
Если всё-таки POST
Иногда вы встретите API, где делают POST /tasks/{id}/done. Это особенно часто в системах, где всё построено вокруг “команд” (command‑style API) или событий (event‑style). Там POST означает “создай событие/соверши действие”, и это нормально в своей архитектуре.
Но если вы выбрали POST, вам нужно быть вдвойне внимательным к идемпотентности. Если клиент повторит POST из‑за ретрая, а ваш сервер наивно пишет “событие done” в лог, очередь или историю, вы можете получить два события вместо одного. Иногда это допустимо, иногда ломает аналитику, иногда ломает биллинг (и тогда у вас появляется новый друг — бухгалтер).
Поэтому в рамках нашего проекта PATCH остаётся более безопасным выбором, потому что семантика “частичного обновления” изначально ближе к “сделай done=true”.
Если вернуть 200 OK + JSON
Чтобы вы не думали, что 204 — единственная религия, покажу, как выглядел бы “200 OK + вернуть задачу”. Это полезно хотя бы для понимания, какую цену вы платите за удобство клиента.
Предположим, MarkDone возвращает обновлённый DTO:
package main
import (
"context"
)
type TaskDTO struct {
ID int `json:"id"`
Title string `json:"title"`
Done bool `json:"done"`
}
type TaskApp interface {
MarkDone(ctx context.Context, id int) (TaskDTO, error)
}
Тогда handler будет уже не таким минимальным:
package main
import (
"encoding/json"
"net/http"
)
func (s *Server) handleMarkDone200(w http.ResponseWriter, r *http.Request) {
id, err := parseID(r.PathValue("id"))
if err != nil {
writeError(w, err)
return
}
task, err := s.app.MarkDone(r.Context(), id)
if err != nil {
writeError(w, err)
return
}
w.Header().Set("Content-Type", "application/json; charset=utf-8")
_ = json.NewEncoder(w).Encode(task) // 200 по умолчанию
}
Заметьте, как сразу появляется больше решений: какой DTO, какие поля, что делать с Title (вдруг мы не хотим его раскрывать?), как держать совместимость. Поэтому в текущем модуле мы предпочитаем 204: он учит аккуратному минимализму и стабильным контрактам.
12. Типичные ошибки
Ошибка №1: возвращают 404, когда id не число.
Это одна из самых частых логических ошибок: “ну раз не нашли, то 404”. Но id=abc — это не “не нашли ресурс”, это “клиент прислал мусор”. Поэтому сначала всегда идёт валидация и 400 validation, а 404 not_found — только когда id валиден, но сущности нет.
Ошибка №2: пишут body при 204 No Content.
Часто это выглядит невинно: w.WriteHeader(204) и потом fmt.Fprintln(w, "ok"). На локальных тестах “вроде работает”, а потом клиент или прокси начинает чудить. Если контракт 204 — значит после WriteHeader вы просто выходите из handler’а и не пытаетесь “быть полезным текстиком”.
Ошибка №3: делают err == ErrNotFound и теряют not found при wrapping.
Когда доменный слой добавляет контекст через fmt.Errorf("...: %w", ErrNotFound), сравнение err == ErrNotFound перестаёт работать. Правильный путь для sentinel — errors.Is(err, ErrNotFound), потому что он идёт по цепочке обёрток.
Ошибка №4: реализуют “done” как неидемпотентное действие.
Иногда внутри делают “переключатель” (done = !done) или “если уже done — возвращаем ошибку”. Это превращает повторный запрос (который в реальности случается из‑за ретраев) в источник случайных багов. Для базового API проще и надёжнее контракт “после вызова done=true”.
Ошибка №5: плодят разные разборы {id} в разных handler’ах.
Один handler считает id=0 нормальным, другой — ошибкой; один возвращает {"id":"bad"}, другой — {"path":"bad"}. Снаружи это выглядит как будто у вас два разных API в одном. Лечится просто: единый parseID и единый ValidationError, а дальше один writeError на всех.
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ