Содержание урока · 12 разделов
- rest практики: контракт, который читают без документации
- Контракт /api/v1/tasks: метод × сценарий × ответ
- 201 и 204: статусы, которые echo не раздаёт за вас
- 405 и Allow: роутер отвечает за методы сам
- Версии API: договор, который нельзя менять молча
- Пагинация: offset против курсора и конверт списка
- Идемпотентность: RFC 9110 и POST-ловушка
- Единый формат ошибок и Retry-After: что клиент делает с отказом
- Что дальше: соединение, которое не закрывается
- Песочница урока
- Коротко: что запомнить
- Вопросы и ответы
rest практики: контракт, который читают без документации#
Тема «rest практики» — это не про роутинг, Echo уже умеет его в полной мере, и не про JSON, который мы гоняем с четвёртого урока. Это про контракт между сервером и клиентом, который соблюдается даже тогда, когда документацию никто не открывал. REST — архитектурный стиль с набором ограничений, но в ежедневной работе он сводится к нескольким проверяемым вопросам. Какие статус-коды возвращает каждый сценарий ресурса — и совпадают ли они между вашими собственными эндпоинтами. Где клиент ищет список и что лежит рядом с элементами — только массив или конверт с пагинацией. Что происходит, если тот же самый POST приедет второй раз — по злому умыслу, по таймауту или по нажатию «отправить» второй раз в impatient-клиенте.
У канона на :1323 всё это пока не выписано. GET /api/v1/tasks отдаёт массив — без total, без лимитов, без ответа на вопрос «а сколько всего». POST /api/v1/tasks возвращает 200 вместо честного 201. DELETE молча возвращает 200 с телом, PATCH не существует, а повторный POST создаёт задачу-двойник. Всё это «работает», но любой внешний клиент — мобильное приложение, чужой бэкенд, интегратор — спотыкаться будет именно здесь. Практика restful api go — набор прикладных решений именно для этих вопросов: статус-коды, конверты, ключи идемпотентности, версии. В echo под каждым из них стоит свой механизм, и в этом уроке мы их все разберём по исходникам: от router.go, где прячется автоответ 405 с Allow, до собственного middleware с картой replay.
Контракт /api/v1/tasks: метод × сценарий × ответ#
| Сценарий | Статус | Тело и заголовки |
|---|---|---|
| GET /tasks — список | 200 | Конверт {items, page, limit, total} |
| GET /tasks/:id — есть | 200 | JSON задачи |
| GET /tasks/:id — нет | 404 | Конверт {error, code} из ErrorHandler |
| POST /tasks — создан | 201 | JSON задачи + Location: /api/v1/tasks/42 |
| POST /tasks — повтор с тем же Idempotency-Key | 201 | Сохранённый ответ, хендлер не вызывается |
| POST /tasks — тот же ключ, другое тело | 422 | Конверт {error, code} |
| PATCH /tasks/:id — обновлён | 200 | JSON задачи после обновления |
| DELETE /tasks/:id — удалён | 204 | Тела нет: c.NoContent |
| DELETE /tasks/:id — нет прав | 403 | Конверт {error, code} от requireRole |
| POST /tasks/42 — метод не зарегистрирован | 405 | Allow: OPTIONS, DELETE, GET, PATCH |
| OPTIONS на любой маршрути /tasks | 204 | Allow с полным списком методов ресурса |
201 и 204: статусы, которые echo не раздаёт за вас#
Начнём с простейшего: три статуса, из которых состоит жизнь ресурса. c.JSON(http.StatusCreated, task) — это тот же c.JSON, что и всегда, с числом 201 вместо 200; Echo не различает коды, сериализатору всё равно. А вот у пары NoContent/Redirect в context.go v4.15.0 исходники честнее любой документации, и их стоит процитировать дословно:
func (c *context) NoContent(code int) error { c.response.WriteHeader(code); return nil } — весь метод, одна строка: статус записан в Response, тела нет, сериализатор не вызывался. И Redirect с проверкой диапазона: если код меньше 300 или больше 308, возвращается ErrInvalidRedirectCode, иначе ставится заголовок Location и WriteHeader(code).
Отсюда два практических вывода. Первый: 204 — это контракт «тела не будет», и любой Content-Type, который вы выставили руками до него, будет ложью; RFC для 204 прямо запрещает содержимое, поэтому deleteTask отдаёт ровно статус и ничего больше. Второй: для 201 Echo заголовок Location не ставит — Redirect с его Location про редиректы, а не про создание. Заголовок ставится руками через c.Response().Header().Set(echo.HeaderLocation, ...) — и строго ДО c.JSON: response.go помечает ответ флагом Committed при первой записи, и заголовки после записи уже не уедут. Константа echo.HeaderLocation — это просто "Location", и писать её строкой не запрещено, но константа спасает от опечатки, которую curl покажет только в проде.
Канон: POST /api/v1/tasks возвращает 201 и Location
GO// main.go — создание и удаление по контракту из таблицы
func createTask(c echo.Context) error {
var in taskInput
if err := c.Bind(&in); err != nil {
return echo.NewHTTPError(http.StatusBadRequest, "битое тело")
}
task := store.Create(in.Title) // хранилище из прошлых уроков
// Location ДО записи тела: response.go ставит Committed при первой записи
c.Response().Header().Set(echo.HeaderLocation,
fmt.Sprintf("/api/v1/tasks/%d", task.ID))
return c.JSON(http.StatusCreated, task)
}
func deleteTask(c echo.Context) error {
id, err := strconv.Atoi(c.Param("id"))
if err != nil {
return echo.NewHTTPError(http.StatusBadRequest, "id: только число")
}
if err := store.Delete(id); err != nil {
return echo.NewHTTPError(http.StatusNotFound, "task_not_found")
}
return c.NoContent(http.StatusNoContent) // одна строка: WriteHeader(204)
}
// curl -si -X POST -d '{"title":"купить кофе"}' \
// -H "Authorization: Bearer $TOKEN" localhost:1323/api/v1/tasks
// HTTP/1.1 201 Created
// Location: /api/v1/tasks/42
//
// curl -si -X DELETE -H "Authorization: Bearer $TOKEN_ADMIN" \
// localhost:1323/api/v1/tasks/42
// HTTP/1.1 204 No Content ← тела нет, Content-Type не выставлялсяДва хендлера — два половины контракта. createTask делает три вещи по порядку: Bind и валидация с отказом 400 в конверте ErrorHandler, создание в хранилище, затем заголовок Location со свежим id — строго до c.JSON, потому что Committed после первой записи отрежет всякую правку заголовков. deleteTask показывает противоположный полюс: при успехе — 204 из однострочника NoContent, и это честно: телу взяться неоткуда, сериализатор не вызывался, Content-Type в ответе не появится. Промах хранилища — 404 task_not_found: текст ошибки диагностируем, причина уходит в Internal ErrorHandler из десятого урока. Заметьте, что OPTIONS-запросы к этим маршрутам писать не нужно — их отвечает сам роутер, и следующая рубрика объясняет почему.
Проверьте себя: DELETE без тела
deleteTask находит задачу и возвращает c.NoContent(http.StatusNoContent). Что именно уедет клиенту?
405 и Allow: роутер отвечает за методы сам#
Самая недооценённая REST-практика в Echo уже написана за вас — в router.go. Сценарий: путь /api/v1/tasks/42 зарегистрирован для GET, PATCH и DELETE, а клиент шлёт POST. Что должен вернуть честный REST-сервер? Не 404 — путь существует, ресурсы на нём живут. 405 Method Not Allowed с заголовком Allow, перечисляющим допустимые методы, — это требование стандарта, и Echo исполняет его в router.go v4.15.0: когда узел дерева маршрутов совпал по пути, но метода на узле нет, роутер кладёт в контекст список методов и переключает хендлер на штатную заглушку. Комментарий в исходнике прямо проговаривает разницу: «so we can send http.StatusMethodNotAllowed (405) instead of http.StatusNotFound (404)».
Заглушка MethodNotAllowedHandler объявлена в echo.go и начинается с цитаты RFC: «An origin server MUST generate an Allow field in a 405 (Method Not Allowed) response» — строку Allow она выставляет из значения, которое роутер отложил в контекст под ключом ContextKeyHeaderAllow. А для OPTIONS роутер вообще не зовёт ни ваш хендлер, ни 405: optionsMethodHandler отвечает 204 с заголовком Allow — поэтому клиент может спросить «что умеет этот ресурс» ещё до первого настоящего запроса. Порядок методов в Allow не случайный и не алфавитный: updateAllowHeader собирает строку в жёстком порядке — OPTIONS, CONNECT, DELETE, GET, HEAD, PATCH, POST, PROPFIND, PUT, TRACE. Всё это работает без единой строки вашего кода, но гварды и middleware на маршруте при 405 не исполняются: переключение происходит на этапе роутинга, до вашей цепочки слоёв.
Прогноз: POST на /api/v1/tasks/42
Прочитайте код и выберите, что он выведет:
// v1 — группа /api/v1 из одиннадцатого урока
v1.POST("/tasks", createTask)
v1.GET("/tasks/:id", getTask)
v1.PATCH("/tasks/:id", patchTask)
v1.DELETE("/tasks/:id", deleteTask, requireRole("admin"))
// запрос:
// curl -si -X POST localhost:1323/api/v1/tasks/42
// что вернёт сервер?Версии API: договор, который нельзя менять молча#
Механику групп и префиксов e.Group("/api/v1") курс разобрал в одиннадцатом уроке — вместе с фабрикой versionTag, ставившей заголовок X-API-Version на поддерево. Здесь — прагматика поверх той механики: что вообще попадает в новую версию. Правило простое: аддитивные изменения новой версии не требуют. Новое поле в ответе, новый маршрут, новый опциональный query-параметр — старые клиенты это переживут, их парсеры берут то, что знают. Ломающие изменения — только с новой версией: переименование поля, изменение типа, удаление маршрута, новая обязательная валидация. Клиенты не читают changelog — они читают свои упавшие тесты.
Когда версия объявляется устаревшей, о её смерти нужно говорить в ответах, а не в письме рассылки. Для этого есть два заголовка. Sunset стандартизирован RFC 8594: сервер сообщает HTTP-датой момент, после которого ресурс ожидаемо перестанет отвечать — формат тот же, что у Expires. Deprecation, отмечающий сам факт устаревания датой, существует пока в черновиках IETF группы httpapi — упоминать его можно, полагаться на него рано. Оба ставятся групповым middleware на поддерево v1 — ровно тем способом, каким eleven-й урок ставил X-API-Version: один слой на группу, каждый ответ помечен, клиенты и мониторинг видят дедлайн в каждом ответе до самого дня X. После него включается честная каппа: v1 отвечает 410 Gone конвертом ErrorHandler, v2 продолжает жить — правило N-1, под которым подписываются все.
Канон: Sunset на поддереве v1
GO// main.go — пометка устаревания группой, как versionTag из урока 11
func sunset(date string) echo.MiddlewareFunc {
return func(next echo.HandlerFunc) echo.HandlerFunc {
return func(c echo.Context) error {
// Sunset — RFC 8594: HTTP-дата, как у Expires
c.Response().Header().Set("Sunset", date)
// Deprecation — черновик IETF httpapi: сам факт устаревания
c.Response().Header().Set("Deprecation", "@1735689600")
return next(c)
}
}
}
func main() {
v1 := e.Group("/api/v1", middleware.JWT([]byte(secret)))
v1.Use(sunset("Sat, 01 Nov 2025 00:00:00 GMT"))
// ... маршруты v1: каждый ответ унесёт Sunset и Deprecation
v2 := e.Group("/api/v2", echojwt.WithConfig(cfg), requireRole("admin"))
// ... маршруты v2 без Sunset: свежая версия
}
// curl -si -H "Authorization: Bearer $TOKEN" localhost:1323/api/v1/tasks
// Sunset: Sat, 01 Nov 2025 00:00:00 GMT
// Deprecation: @1735689600Анатомия та же, что у versionTag из одиннадцатого урока: фабрика принимает параметр, тело фабрики исполняется один раз при регистрации, замыкание ставит заголовок до next(c) — до тела ответа, пока Committed не отрезал правки. Групповой Use раскладывает слой на всё поддерево, включая маршруты, добавленные позже. Смысл — в распределении ролей: Sunset говорит «когда умрёт» и его читает мониторинг клиента, Deprecation — «уже устарело» и его читает человек, заведующий SDK. Даты — в формате HTTP-date; Deprecation в черновике использует @-нотацию unix-времени. Дата в будущую субботу намеренно: клиент должен увидеть дедлайн в сотнях ответов до того, как однажды получит 410 Gone.
Пагинация: offset против курсора и конверт списка#
GET /api/v1/tasks, возвращающий весь массив, — ошибка трёх видов. Прод: когда задач станет полмиллиона, первый же клиент упрётся в таймаут и память. Контракт: без total и лимитов клиент не умеет рисовать страницы. Безопасность: неограниченный список — дешёвый способ уронить сервер запросом. Разбор query-параметров c.QueryParam и c.QueryParams курс показал в третьем уроке — здесь договоримся, как их упаковывать.
Формат — конверт: {"items": [...], "page": 2, "limit": 50, "total": 421}. items — массив; page и limit — что клиент просил (после клампа); total — сколько всего. Клиент рисует пагинатор по трем числам, новые поля в конверт добавляются без миграции — тот же аргумент, что с конвертом ошибок из десятого урока. Кто-то любит Link-заголовок из RFC 8288 — «The Link entity-header field provides a means for serialising one or more links in HTTP messages» — с rel="next" и rel="prev"; это эквивалент по информации, выбирайте один стиль и не смешивайте.
Механика смещения — OFFSET: на странице page лимитом limit в базу уходит LIMIT limit OFFSET (page-1)*limit. Два порока. Цена растёт с номером страницы: OFFSET 100000 — база реально проходит и выбрасывает сто тысяч строк. Нестабильность: вставка между страницами сдвигает окно — клиент видит одну задачу дважды на разных страницах. Курсор решает оба: следующий запрос получает last_id и говорит WHERE id > last_id LIMIT limit — цена постоянна, окно стабильно, но нет total и произвольного прыжка на страницу 17. Практика: конверт с offset/limit для админок и UI-таблиц, курсор для лент и внешних API.
Канон: GET /api/v1/tasks?page=&limit= с конвертом
GO// main.go — список под контрактом: клампы, конверт, потолок
func listTasks(c echo.Context) error {
page, err := strconv.Atoi(c.QueryParam("page"))
if err != nil || page < 1 {
page = 1 // молча клампим: мусор и ноль — это «первая страница»
}
limit, err := strconv.Atoi(c.QueryParam("limit"))
if err != nil || limit < 1 {
limit = 20
}
if limit > 100 {
limit = 100 // потолок: бесплатной эскалации не будет
}
items, total := store.List((page-1)*limit, limit)
return c.JSON(http.StatusOK, map[string]any{
"items": items,
"page": page,
"limit": limit,
"total": total,
})
}
// curl -s -H "Authorization: Bearer $TOKEN" \
// "localhost:1323/api/v1/tasks?page=2&limit=50"
// → {"items":[...],"page":2,"limit":50,"total":421}Четыре решения, каждое стоит проговорить. Мусорный page не падает 400-й, а клампится к 1: список — read-only эндпоинт, и клиент с битым параметром заслуживает данных, а не конверта ошибки; на write-эндпоинтах тот же подход был бы ложью. Потолок limit 100 — защита от ?limit=999999999: без него один запрос вытягивает всю таблицу, и это XSS-класс ошибок доступности. Формула смещения (page-1)*limit стандартна именно в этой записи — с округлением вниз через целочисленное деление ошибиться труднее. Конверт собирается map-литералом: для двух-трёх полей достаточно, для больших — заведите тип Page[T], но поле total держите всегда: без него пагинатор не нарисуешь. И ORDER BY в хранилище обязателен: без сортировки страницы пересекаются даже без вставок.
Прогноз: ?page=0&limit=1000
Прочитайте код и выберите, что он выведет:
// listTasks из канона выше
curl -s -H "Authorization: Bearer $TOKEN" \
"localhost:1323/api/v1/tasks?page=0&limit=1000"
// что вернёт хендлер?Проверьте себя: почему курсор
Лента событий выросла до десятков миллионов строк. Что перевешивает курсорную пагинацию против LIMIT/OFFSET?
Идемпотентность: RFC 9110 и POST-ловушка#
Идемпотентность — свойство метода, а не кода. RFC 9110, раздел 9.2.2, формулирует так: «A request method is considered idempotent if the intended effect on the server of multiple identical requests with that method is the same as the effect for a single such request». Одинаковый эффект от одного и пяти одинаковых запросов. GET идемпотентен по определению — читать безопасно. PUT идемпотентен, потому что «положи это состояние» пять раз подряд оставит то же состояние. DELETE идемпотентен в контракте HTTP: повторное удаление того же ресурса даёт тот же итог — его отсутствие (какой статус при этом вернуть — 204 или 404 — уже ваш выбор, лишь бы постоянный). POST — нет: пять POST-ов — пять задач. PATCH стандарт тоже не обещает идемпотентности, хотя ваш конкретный patch-хендлер может её соблюдать.
Зачем это свойство серверу? Сетевые ретраи. Клиент отправил POST, ответ потерялся — таймаут, разрыв, смерть инстанса за балансировщиком. Клиент честно ретраит. Сервер обрабатывает оба запроса, и вместо одной задачи — две: деньги списаны дважды, инвойс создан дважды. Разрешить ретраи безопасно — значит дать клиенту способ сказать «это тот же самый запрос». Индустрия сошлась на заголовке Idempotency-Key: клиент генерирует уникальный ключ (uuid) на логическую операцию и посылает его с каждым повтором; сервер запоминает ответ под ключом и на повтор отдаёт сохранённый ответ, не выполняя операцию второй раз. Схема описана в черновике IETF httpapi — draft-ietf-httpapi-idempotency-key-header — и в своей формулировке честна: заголовок «can be used to carry idempotency key in order to make non-idempotent HTTP methods such as POST or PATCH fault-tolerant».
Канон: Idempotency-Key на POST /api/v1/tasks
GO// main.go — replay-слой для POST: один и тот же POST дважды = одна задача
type replayEntry struct {
fingerprint string // sha256 тела запроса
status int
body []byte
}
var (
replayMu sync.Mutex
replayLog = map[string]replayEntry{}
)
func idempotentPOST(next echo.HandlerFunc) echo.HandlerFunc {
return func(c echo.Context) error {
key := c.Request().Header.Get("Idempotency-Key")
if key == "" { // без ключа — обычный POST без гарантий
return next(c)
}
body, _ := io.ReadAll(c.Request().Body)
c.Request().Body = io.NopCloser(bytes.NewReader(body)) // вернуть телу
sum := sha256.Sum256(body)
replayMu.Lock()
if e, ok := replayLog[key]; ok {
replayMu.Unlock()
if e.fingerprint != fmt.Sprintf("%x", sum) {
// тот же ключ, другое тело — по черновику: 422
return echo.NewHTTPError(http.StatusUnprocessableEntity,
"Idempotency-Key is reused")
}
// ретрай: сохранённый ответ, хендлер не зовётся
c.Response().Header().Set("Idempotent-Replayed", "true")
return c.JSONBlob(e.status, e.body)
}
replayMu.Unlock()
if err := next(c); err != nil {
return err // ошибки НЕ сохраняем: клиент ретраит с тем же ключом
}
replayMu.Lock()
replayLog[key] = replayEntry{
fingerprint: fmt.Sprintf("%x", sum),
status: c.Response().Status,
body: c.Response().Body.Bytes(),
}
replayMu.Unlock()
return nil
}
}
// v1.POST("/tasks", createTask, idempotentPOST) — слой после createTaskПять решений в шестидесяти строках. Первое: ключ читается до всего — пустой заголовок означает «клиент не просил гарантий», и запрос идёт как обычно. Второе: тело читается и возвращается в Request.Body через io.NopCloser — иначе хендлер прочитает EOF; это главный сюрприз слоя, лезущего в тело раньше обработчика. Третье: при совпадении ключа сверяется fingerprint — тот же ключ с другим телом это ошибка клиента, и она отдаётся 422, не молча. Четвёртое: успешный ответ сохраняется из Response — Status и Body после next(c), а клиенту повтора уходит JSONBlob с заголовком-маркером Idempotent-Replayed: так клиент отличает replay от живого ответа. Пятое: ошибки не сохраняются — упавший POST можно честно ретраить тем же ключом. TTL-очистка карты в каноне опущена: в проде ключи живут часы, а не дни, и хранить их нужно с политикой, а не вечно.
Проверьте себя: тот же ключ, другое тело
Клиент отправил POST с Idempotency-Key: K-42, создал задачу. Через минуту повторил запрос с тем же K-42, но заголовок title в теле изменил на другой. Что вернёт канон?
Единый формат ошибок и Retry-After: что клиент делает с отказом#
Конверт {error, code} для всего API курс собрал в десятом уроке — здесь про то, какие статусы в него класть и что клиенту делать с каждым. Спектр «клиент виноват» — 400 на битое тело, 404 на промах id, 405 с Allow на чужой метод. Спектр «сервер/состояние» — 409 Conflict на конкурентную правку одной задачи, 422 на то, что синтаксически верно, но не обрабатывается: «The 422 (Unprocessable Content) status code indicates that the server understands the content type of the request content (hence a 415 (Unsupported Media Type) status code is inappropriate), and the syntax of the request content is correct, but it was unable to process the contained instructions». И 429 Too Many Requests на упоры в rate-limit — сегодня его держит rate limiter перед приложением, а не Echo.
Ключевая пара — Retry-After и идемпотентность. Retry-After с числом секунд или HTTP-датой сопровождает 429 и 503 и говорит клиенту, когда можно ретраить; клиент без него должен строить экспоненциальную задержку с джиттером сам. А ретраить можно только то, что безопасно: GET — всегда; PUT/DELETE — по определению идемпотентности; POST — только с Idempotency-Key из канона выше, иначе дубль. Это и есть связка урока: единый конверт отвечает «что случилось», Retry-After — «когда повторять», идемпотентность — «что будет, если повторить». Для стандартизированной формы тела ошибки есть RFC 9457 с типом application/problem+json — при желании конверт ErrorHandler расширяется полями type и title без ломки клиентов: аддитивное изменение, новая версия не нужна.
Соберите каркас конверта создания и удаления
Восстановите контрактные ответы канона: пропуски идут по порядку.
Соберите жизнь идемпотентного POST
Расставьте шаги от первого запроса до третьего.
Строки
Ваша программа
Нажимайте на строки слева, чтобы собрать программу
Пройдите по нему в порядке урока: у каждого маршрута выписаны коды успеха и отказов, и они совпадают между собой; POST возвращает 201 с Location до записи тела; DELETE — 204 без попыток сериализации; 405 проверен curl-ом с Allow; список отдаёт конверт с total и клампит limit потолком; write-эндпоинты отличают 400 от 404 и 422; POST, который клиент имеет право ретраить, принимает Idempotency-Key; версия, объявленная устаревшей, несёт Sunset в каждом ответе. Каждый пункт — тест, который канон уже умеет писать: об этом — двадцатый урок.
Карточки: RESTful-практики в Echo
Что дальше: соединение, которое не закрывается#
Канон получил контракт. Статусы выписаны: 201 с Location до записи, 204 из однострочника NoContent, 405 с Allow от роутера — без единой вашей строки. Список отдаёт конверт с клампами и потолком; POST принял Idempotency-Key и научился отдавать сохранённый ответ вместо двойника; устаревающая v1 помечена Sunset из RFC 8594. Каждый факт сверен с router.go, context.go, response.go v4.15.0, status.go net/http и стандартами: RFC 9110 про идемпотентность и 422, RFC 8288 про Link, RFC 8594 про Sunset.
Про песочницу честно, как во всём курсе: у курса Echo её нет — код исполняется локально, поэтому runner-ов здесь не будет; соберите канон go run main.go и прогоните матрицу curl: POST с сохранением Location, повтор с тем же ключом и тем же телом, повтор с другим телом, PATCH и POST на /tasks/42 — и убедитесь, что Allow в 405 собран в порядке updateAllowHeader.
Следующий, восемнадцатый урок курса echo — WebSocket в Echo: апгрейд через gorilla Upgrader, hub для broadcast и hijack соединения — контракт из этого урока там встретится с протоколом, который не закрывает соединение вовсе. Навигация по курсу — на странице курса Echo; маршруты и параметры, чью анатомию мы сегодня дожали до контракта, — в уроке про маршрутизацию; группы, префиксы и X-API-Version — в уроке про группы и версии; конверт {error, code}, в который легли все отказы, — в уроке про HTTPError; пары c.Bind и c.JSON — в уроке про запросы и ответы.
Песочница урока
Редактируйте код и запускайте его прямо здесь — интерпретатор работает в браузере, ничего устанавливать не нужно. За каждый запуск — +2 XP.
Запустите код, чтобы увидеть вывод.
Коротко: что запомнить
- Контракт ресурса выписывается до кода: у каждого маршрута — коды успеха и отказов, форма списка, поведение при повторе. rest практики — это прикладные решения: статусы, конверты, идемпотентность, версии; у Echo под каждым стоит свой механизм.
- Статусы без тела: c.NoContent(code) — одна строка WriteHeader в context.go v4.15.0, тела и Content-Type не будет. 201 собирается руками: Location ставится через c.Response().Header().Set(echo.HeaderLocation, ...) строго до c.JSON — response.go помечает ответ Committed при первой записи. c.Redirect проверяет диапазон 300-308 и возвращает ErrInvalidRedirectCode.
- 405 работает без вашего кода: router.go v4.15.0 при совпавшем пути и неизвестном методе кладёт ContextKeyHeaderAllow и переключает хендлер на MethodNotAllowedHandler — комментарий прямо говорит «405 instead of 404». Заглушка цитирует RFC 7231 («An origin server MUST generate an Allow field in a 405 response») и выставляет Allow из updateAllowHeader в порядке OPTIONS, CONNECT, DELETE, GET, HEAD, PATCH, POST, PROPFIND, PUT, TRACE. OPTIONS отвечает optionsMethodHandler — 204 + Allow. Гварды при 405 не исполняются: переключение до цепочки middleware.
- Версии: аддитивные изменения не требуют новой версии, ломающие — только с ней. Sunset — RFC 8594, HTTP-дата конца жизни ресурса; Deprecation — черновик IETF httpapi. Оба ставятся групповым middleware на поддерево (механика versionTag из одиннадцатого урока); после дедлайна — 410 Gone и правило N-1.
- Пагинация: конверт {items, page, limit, total}; read-only клампы — page < 1 → 1, limit вне 1..100 → 20/100; OFFSET = (page-1)*limit. Цена OFFSET растёт с глубиной, вставки между страницами дают дубли и пропуски; курсор WHERE id > last_id LIMIT n держит постоянную цену и стабильное окно, но теряет total. Link-заголовок — RFC 8288, эквивалент по информации.
- Идемпотентность — свойство метода: RFC 9110 9.2.2 — «the intended effect on the server of multiple identical requests with that method is the same as the effect for a single such request». GET/PUT/DELETE идемпотентны, POST/PATCH — нет. Ретрай после таймаута безопасен только с Idempotency-Key (черновик draft-ietf-httpapi-idempotency-key-header): ключ + fingerprint тела; replay отдаёт сохранённый ответ, чужое тело под тем же ключом — 422, ошибки не сохраняются.
- Отказы: конверт {error, code} из десятого урока; 409 на конкурентный конфликт, 422 по RFC 9110 — «понял тип и синтаксис, но не смог обработать инструкции»; 429 и 503 сопровождаются Retry-After (секунды или HTTP-дата), ретраить можно только идемпотентное. Стандартизированное тело ошибки — application/problem+json из RFC 9457, поля type/title — аддитивное изменение без новой версии.
Домашнее задание
Соберите канон урока на :1323 поверх main из прошлых уроков. Первое: проведите POST-цикл curl-ом — создание с сохранением Location из ответа, GET по этому адресу, повторный POST с тем же Idempotency-Key и тем же телом — и убедитесь, что второй ответ содержит Idempotent-Replayed, а задач в списке одна; объясните по коду канона, какая строка предотвратила второй вызов createTask. Второе: повторите с тем же ключом и изменённым title — зафиксируйте 422 и найдите в черновике httpapi формулировку про повторное использование ключа. Третье: снимите Idempotency-Key и повторите двойной POST — задача-двойник создастся; объясните, почему отсутствие ключа — это осознанный отказ от гарантий, а не баг. Четвёртое: проверьте 405 — POST на /api/v1/tasks/42 должен вернуть Allow: OPTIONS, DELETE, GET, PATCH в порядке updateAllowHeader; добавьте HEAD-маршрут и посмотрите, куда он встанет в строке. Пятое: добавьте в listTasks поддержку ?cursor= с WHERE id > cursor LIMIT limit рядом с page/limit и напишите, какой из двух режимов отдаёт total, а какой — нет; проверьте оба curl-ом. Шестое: пометьте v1 Sunset-датой в прошлом и убедитесь, что заголовок виден в каждом ответе поддерева, включая 404 и 405 — и объясните, почему это произошло.
Личные заметкиПусто
Вопросы и ответы по уроку
POST возвращает 201 или 200 — насколько это критично и что ставить при асинхронной обработке?
Для синхронного создания — 201 с Location на свежий ресурс: клиенты и мониторинг отличают «создано» от «обработано» по статусу, а Location избавляет от парсинга тела ради id. Если операция ставится в очередь и результат ещё не готов, честнее 202 Accepted с указанием, где следить за статусом, — тело при этом описывает задачу, а не результат. Константой держите 200-везде-и-точка только тогда, когда у вас нет внешних клиентов; любой SDK и любой прокси с кэшем опираются на коды, и 201/202/204 — часть контракта, а не украшение.
Что выбрать: versioning в пути /api/v1, в заголовке Accept или в query?
В пути — самый предсказуемый и отладочный вариант: версия видна в каждом логе и curl-е, маршруты изолированы группами e.Group, middleware вешаются на поддерево — Echo из одиннадцатого урока даёт это без костылей. Версия в Accept (content negotiation) чище теоретически, но сложнее в отладке и кэшировании, а query-версия протекает в URL-логи и закладки. Практика курса: /api/v1 в пути, X-API-Version для телеметрии, Sunset и Deprecation для объявления дедлайна. Менять стиль версионирования между мажорами — отдельный вид ломающего изменения, не делайте этого.
Насколько Idempotency-Key обязателен и где его хранить, если не в карте в памяти?
Обязателен везде, где клиент имеет право на ретрай POST/PATCH после таймаута: платёжные сценарии, создание ресурсов, отправка сообщений. Карта в памяти канона — учебный минимум: она умирает с процессом и не шарится между репликами. В проде ключи живут часы-дни и хранятся там же, где данные операции: таблица с ключом, fingerprint, статусом и телом ответа под уникальным индексом — тогда конкурентные поводы обрабатываются транзакцией, а не мьютексом, и переживают рестарт. TTL-очистка обязательна: вечные ключи — утечка, а вечный ключ на постоянную операцию — источник 422 через месяц.
Термины урока
Ключевые понятия из этого урока — короткие определения в глоссарии. Наведите на термин, чтобы вспомнить, откройте карточку для подробностей и ссылки на практику.
Сравните с альтернативами
Практический вопрос после первых уроков: а почему именно эта библиотека? Мы разобрали конкурентов по одним критериям, с кодом одной задачи рядом и персональным подбором под ваши приоритеты.
Изучайте дальше
Следующая ступень учебного пути
GORM: ORM-библиотека для Go
Курс GORM: модели, CRUD, ассоциации, транзакции, хуки, пагинация, оптимизация запросов. 25 уроков с примерами и проектом слоя данных.
- Подключать БД и настраивать пул соединений
- Описывать модели и связи: 1:1, 1:N, M:N
- Писать CRUD и сложные выборки chainable-стилем
- Контролировать N+1 через Preload