Перейти к содержимому
ГоМастер

Поиск по урокам

Найдите урок, курс, термин или раздел

Урок 17 из 25 ~20 мин +50 XP

RESTful-практики: версии API, статус-коды, пагинация и идемпотентность

rest практики — тема, к которой канон шёл шестнадцать уроков. Маршруты и параметры появились в [третьем уроке](/courses/echo/lessons/03-marshrutizaciya/), группы и версии — в [одиннадцатом](/courses/echo/lessons/11-gruppy/), конверт ошибок {error, code} — в [десятом](/courses/echo/lessons/10-httperror/), аутентификация парой токенов — в [пятнадцатом](/courses/echo/lessons/15-jwt/), а CORS из прошлого урока впустил на /api/v1 чужие SPA. Ресурсы, гварды и заголовки построены — но контракт самого API до сих пор не выписан: что в теле списка, какой статус у создания, что видит клиент, когда POST ушёл дважды из-за ретрая, и как менять всё это, не сломав клиентов. В этом уроке мы договариваемся с клиентом по-взрослому: статус-коды с Location и Allow — включая автоответ роутера Echo на чужой метод, который мы разберём по router.go v4.15.0, — пагинация с конвертом и потолками, идемпотентность с заголовком Idempotency-Key против дубликатов, версии API и заголовки Sunset из RFC 8594. Каждый факт сверен с исходниками Echo и стандартами: от однострочника c.NoContent до цитаты RFC 9110 про идемпотентность. Приступаем — restful api go начинается тогда, когда ответы можно предсказать без документации.

Содержание урока · 12 разделов

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 — есть200JSON задачи
GET /tasks/:id — нет404Конверт {error, code} из ErrorHandler
POST /tasks — создан201JSON задачи + Location: /api/v1/tasks/42
POST /tasks — повтор с тем же Idempotency-Key201Сохранённый ответ, хендлер не вызывается
POST /tasks — тот же ключ, другое тело422Конверт {error, code}
PATCH /tasks/:id — обновлён200JSON задачи после обновления
DELETE /tasks/:id — удалён204Тела нет: c.NoContent
DELETE /tasks/:id — нет прав403Конверт {error, code} от requireRole
POST /tasks/42 — метод не зарегистрирован405Allow: OPTIONS, DELETE, GET, PATCH
OPTIONS на любой маршрути /tasks204Allow с полным списком методов ресурса

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 без ломки клиентов: аддитивное изменение, новая версия не нужна.

Соберите каркас конверта создания и удаления

Восстановите контрактные ответы канона: пропуски идут по порядку.

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) c.Response().Header().Set(, fmt.Sprintf("/api/v1/tasks/%d", task.ID)) return c.JSON(, 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 (http.StatusNotFound, "task_not_found") } return c.() }

Соберите жизнь идемпотентного POST

Расставьте шаги от первого запроса до третьего.

Строки

Ваша программа

Нажимайте на строки слева, чтобы собрать программу

Карточки: RESTful-практики в Echo

Термин

c.NoContent(204): что делает

нажмите, чтобы перевернуть
Определение

Одна строка context.go v4.15.0: c.response.WriteHeader(code); return nil. Тела нет, сериализатор не вызывался, Content-Type не появился. 204 по RFC содержимого не несёт. Повторная запись после WriteHeader невозможна — response.go помечает ответ Committed.

1 / 6

Что дальше: соединение, которое не закрывается#

Канон получил контракт. Статусы выписаны: 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 — и объясните, почему это произошло.

Личные заметкиПусто
Хранятся только в вашем браузере (Echo · RESTful-практики: версии API, статус-коды, пагинация и идемпотентность)

Вопросы и ответы по уроку

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 через месяц.

Термины урока

Ключевые понятия из этого урока — короткие определения в глоссарии. Наведите на термин, чтобы вспомнить, откройте карточку для подробностей и ссылки на практику.

Сравните с альтернативами

Практический вопрос после первых уроков: а почему именно эта библиотека? Мы разобрали конкурентов по одним критериям, с кодом одной задачи рядом и персональным подбором под ваши приоритеты.

Следующая ступень учебного пути

GORM25 уроков~14 чпрактика25 уроков уже доступны

GORM: ORM-библиотека для Go

Курс GORM: модели, CRUD, ассоциации, транзакции, хуки, пагинация, оптимизация запросов. 25 уроков с примерами и проектом слоя данных.

  • Подключать БД и настраивать пул соединений
  • Описывать модели и связи: 1:1, 1:N, M:N
  • Писать CRUD и сложные выборки chainable-стилем
  • Контролировать N+1 через Preload