Соберём API пользователей на Gin: настроим маршруты, проверим JSON и вернём ошибки в едином формате. Разберём, как middleware передаёт управление обработчику и что происходит после Abort(). Код можно запустить локально и проверить через curl и httptest.
Что Gin добавляет к net/http
Gin работает поверх стандартного HTTP-стека Go. Обработчик получает *gin.Context: через него обработчик читает параметры запроса, записывает JSON и передаёт данные между middleware. Маршрутизатор реализует http.Handler, поэтому его можно передать в http.Server или проверить без открытия сетевого порта.
Сигнатура обработчика в Gin выглядит так:
func(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"status": "ok"})
}
Короткие фрагменты показывают части программы. Полный main.go приведён ниже. gin.H служит сокращением для map[string]any; для ответа с фиксированными полями удобнее объявить структуру.
В нашем API Gin отвечает за HTTP: получает входные данные и превращает результат в ответ. Правила работы с пользователями можно вынести в отдельный сервис, который ничего не знает о Gin.
Стандартный net/http тоже поддерживает маршруты с методами и параметрами пути, начиная с Go 1.22. Поэтому выбирать Gin только ради маршрута GET /users/{id} необязательно. Он пригодится, если команде удобны его контекст, привязка данных к структурам и готовые middleware. Возможности стандартного маршрутизатора описаны в документации Go.
Установка и первый маршрут
Создайте отдельный модуль:
mkdir gin-example
cd gin-example
go mod init example.com/gin-example
go get github.com/gin-gonic/gin@v1.12.0
Команды используют Gin v1.12.0, чтобы пример можно было воспроизвести. После установки сохраните go.mod и go.sum вместе с кодом проекта. Для примеров используйте Go 1.25 или новее.
Минимальный сервер помещается в один файл:
package main
import (
"log"
"net/http"
"github.com/gin-gonic/gin"
)
func main() {
r := gin.Default()
r.GET("/health", func(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"status": "ok"})
})
if err := r.Run("127.0.0.1:8080"); err != nil {
log.Fatal(err)
}
}
Сохраните код в main.go, запустите go run . и откройте http://127.0.0.1:8080/health. Сервер вернёт {"status":"ok"} со статусом 200.
gin.Default() создаёт маршрутизатор с Logger и Recovery. Первый пишет журнал запросов, второй перехватывает панику при обработке запроса. gin.New() создаёт маршрутизатор без этих middleware. С gin.New() вы задаёте состав и порядок цепочки. Поведение конструкторов описано в справочнике Gin.
Маршруты, параметры и группы
Маршрут определяется HTTP-методом и шаблоном пути. Для чтения пользователя зарегистрируем GET /users/:id, для создания используем POST /users.
Двоеточие обозначает параметр одного сегмента. Запрос /users/42 даст строку "42" в c.Param("id"). Gin не превращает её в число автоматически. Если идентификатор должен быть положительным целым числом, обработчик проверяет это отдельно.
Параметры после вопросительного знака читаются другим методом:
r.GET("/users", func(c *gin.Context) {
limit := c.DefaultQuery("limit", "20")
c.JSON(http.StatusOK, gin.H{"limit": limit})
})
Для /users?limit=5 переменная limit получит строку "5". Значение по умолчанию применяется, если параметра нет. Запрос с ?limit= передаст пустую строку. Чтобы ограничить размер выборки, сначала преобразуйте строку в число, затем проверьте допустимый диапазон. Сам DefaultQuery этого не делает.
Группа объединяет общий префикс и middleware:
api := r.Group("/api/v1")
api.GET("/users/:id", getUser)
api.POST("/users", createUser)
Функции getUser и createUser объявим в полном примере. Адрес первого маршрута будет /api/v1/users/42. Группе можно назначить middleware через api.Use(...), а отдельному маршруту передать их перед обработчиком.
Подключайте middleware до регистрации маршрутов, к которым они относятся. Тогда настройка читается сверху вниз: общая цепочка, группы, обработчики. Синтаксис параметров и групп приведён в официальных примерах маршрутизации.
Как выполняется middleware
Middleware имеет ту же сигнатуру, что и обработчик: func(*gin.Context). Обычно оно выполняет работу до обработчика, вызывает c.Next(), затем обрабатывает результат.
Например, измерение времени запроса:
func timing() gin.HandlerFunc {
return func(c *gin.Context) {
started := time.Now()
c.Next()
log.Printf("method=%s route=%s status=%d duration=%s",
c.Request.Method, c.FullPath(),
c.Writer.Status(), time.Since(started))
}
}
Фрагменту нужны импорты time, log и Gin. c.FullPath() возвращает шаблон совпавшего маршрута, например /api/v1/users/:id. Для запроса без совпавшего маршрута он может быть пустым.
Если зарегистрированы middleware A, middleware B и обработчик, вызовы с Next() идут так:
| Этап | Что выполняется |
|---|---|
| 1 | Код A до Next() |
| 2 | Код B до Next() |
| 3 | Обработчик |
| 4 | Код B после Next() |
| 5 | Код A после Next() |
Не используйте отсутствие Next() как способ запретить доступ: Gin может продолжить цепочку после возврата из middleware. Для остановки следующих обработчиков вызовите Abort() или метод, который вызывает его внутри.
После Abort() нужен return, если ниже есть код, который не должен выполниться. Остановка цепочки не завершает текущую функцию. Уже начавшие работу внешние middleware также продолжат свой код после Next().
По этой причине проверка доступа обычно заканчивается так:
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{
"error": gin.H{"code": "unauthorized", "message": "Требуется вход"},
})
return
Это фрагмент ветки отказа внутри middleware. Проверку токена он не реализует. В рабочем API перед ним должна быть проверка подписи, срока действия и нужных прав доступа. Контракт Next и Abort можно проверить в исходном коде контекста Gin.
JSON и проверка входных данных
Для создания пользователя примем имя:
type createUserRequest struct {
Name string `json:"name" binding:"required,max=100"`
}
Тег json задаёт имя поля в JSON. Тег binding задаёт правила валидации: строка должна быть непустой и содержать не больше 100 символов. Поле Name экспортировано, хотя сам тип доступен только внутри пакета. Подробнее о полях и тегах читайте в статье про структуры Go.
В обработчике вызовем c.ShouldBindJSON(&req). Метод разбирает тело и проверяет теги валидации. Если JSON повреждён или имя пустое, он возвращает ошибку. HTTP-ответ в этом случае выбирает наш код.
У похожего метода BindJSON другое поведение: при ошибке он прерывает цепочку и записывает статус 400. Если API должен всегда возвращать ошибки в одном JSON-формате, проще использовать ShouldBindJSON. Различия описаны в документации привязки и валидации.
required не означает «содержит хотя бы одну букву». Строка из пробелов проходит проверку непустой строки. В примере после привязки данных вызовем strings.TrimSpace и отдельно отклоним пустой результат.
Ошибку валидатора не стоит автоматически отправлять клиенту через err.Error(): такое сообщение привязывает контракт API к именам Go-полей и формулировкам библиотеки. Клиенту вернём стабильный код и понятное описание. Причину сохраним для серверного журнала.
Единая обработка ошибок
Зададим формат ответа:
{"error":{"code":"user_not_found","message":"Пользователь не найден"}}
Клиентская программа обрабатывает код, человек читает сообщение. HTTP-статус описывает результат запроса: неправильный идентификатор получает 400, отсутствующий пользователь получает 404, неожиданная ошибка получает 500.
В Gin вызов c.Error(err) добавляет ошибку в c.Errors. Он не отправляет ответ и не останавливает цепочку. Поэтому заведём функцию fail: она прикрепит ошибку и вызовет Abort(). В месте вызова обработчик сразу сделает return.
Middleware после Next() прочитает последнюю ошибку. Для нашего типа apiError оно возьмёт подготовленные статус, код и сообщение. Остальные ошибки оно запишет в журнал, а клиенту сообщит о внутренней ошибке.
Перед отправкой проверим c.Writer.Written(). Если ответ уже начат, переписать его статус нельзя. В этой ситуации middleware только запишет ошибку в журнал. Обработчик должен выбрать один исход: записать успешный ответ или передать ошибку middleware.
Полный пример API
Замените содержимое main.go следующим кодом. В примере нет базы данных: чтение возвращает одного заданного пользователя, а создание только проверяет данные и отвечает ими. Запрос POST ничего не сохраняет, поэтому получить созданного пользователя следующим GET не получится.
package main
import (
"errors"
"log"
"net/http"
"strconv"
"strings"
"time"
"github.com/gin-gonic/gin"
)
type apiError struct {
Status int
Code string
Message string
Cause error
}
func (e *apiError) Error() string { return e.Message }
func (e *apiError) Unwrap() error { return e.Cause }
func fail(c *gin.Context, status int, code, message string, cause error) {
_ = c.Error(&apiError{status, code, message, cause})
c.Abort()
}
func errorHandler() gin.HandlerFunc {
return func(c *gin.Context) {
c.Next()
last := c.Errors.Last()
if last == nil {
return
}
log.Printf("request failed: err=%v cause=%v",
last.Err, errors.Unwrap(last.Err))
if c.Writer.Written() {
return
}
status := http.StatusInternalServerError
code, message := "internal_error", "Внутренняя ошибка сервера"
var appErr *apiError
if errors.As(last.Err, &appErr) {
status, code, message = appErr.Status, appErr.Code, appErr.Message
}
c.JSON(status, gin.H{
"error": gin.H{"code": code, "message": message},
})
}
}
func getUser(c *gin.Context) {
id, err := strconv.Atoi(c.Param("id"))
if err != nil || id <= 0 {
fail(c, http.StatusBadRequest, "invalid_id",
"Идентификатор должен быть положительным целым числом", err)
return
}
if id != 1 {
fail(c, http.StatusNotFound, "user_not_found",
"Пользователь не найден", nil)
return
}
c.JSON(http.StatusOK, gin.H{"id": 1, "name": "Анна"})
}
type createUserRequest struct {
Name string `json:"name" binding:"required,max=100"`
}
func createUser(c *gin.Context) {
var req createUserRequest
if err := c.ShouldBindJSON(&req); err != nil {
fail(c, http.StatusBadRequest, "invalid_body",
"Нужен JSON с непустым полем name длиной до 100 символов", err)
return
}
req.Name = strings.TrimSpace(req.Name)
if req.Name == "" {
fail(c, http.StatusBadRequest, "invalid_name",
"Имя не должно состоять из пробелов", nil)
return
}
c.JSON(http.StatusCreated, gin.H{"name": req.Name})
}
func newRouter() *gin.Engine {
r := gin.New()
r.Use(gin.Logger(), errorHandler(), gin.CustomRecovery(
func(c *gin.Context, recovered any) {
if c.Writer.Written() {
c.Abort()
return
}
c.AbortWithStatusJSON(http.StatusInternalServerError, gin.H{
"error": gin.H{
"code": "internal_error",
"message": "Внутренняя ошибка сервера",
},
})
},
))
r.NoRoute(func(c *gin.Context) {
fail(c, http.StatusNotFound, "route_not_found", "Маршрут не найден", nil)
})
r.HandleMethodNotAllowed = true
r.NoMethod(func(c *gin.Context) {
fail(c, http.StatusMethodNotAllowed, "method_not_allowed",
"Метод не поддерживается", nil)
})
api := r.Group("/api/v1")
api.GET("/users/:id", getUser)
api.POST("/users", createUser)
return r
}
func main() {
srv := &http.Server{
Addr: "127.0.0.1:8080",
Handler: newRouter(),
ReadHeaderTimeout: 5 * time.Second,
}
if err := srv.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {
log.Fatal(err)
}
}
Порядок middleware определяет, кто получит результат обработки. Logger оборачивает обработку запроса и видит итоговый статус. errorHandler формирует ответы для обычных ошибок. CustomRecovery перехватывает панику ниже по цепочке и возвращает JSON в том же формате. Для JSON в нашем формате нужен собственный обработчик recovery.
Паника в отдельной горутине не попадёт в recovery этой цепочки. Ожидаемые ошибки, например отсутствие пользователя, передавайте обычным возвратом ошибки из сервиса.
Обработчики NoRoute и NoMethod сохраняют общий формат даже для неизвестного адреса и неверного метода. Для ответа 405 включён HandleMethodNotAllowed. Без этой настройки запрос с неподходящим методом по умолчанию может попасть в обработку 404.
У сервера явно задан тайм-аут чтения заголовков. Это не ограничение времени работы обработчика. Тайм-ауты запросов к базе данных и внешним API задавайте отдельно через стандартный context.Context, который доступен как c.Request.Context(). Поля HTTP-сервера описаны в документации net/http.
Проверка через curl
Запустите сервер командой go run .. В другом терминале отправьте запросы:
curl -i http://127.0.0.1:8080/api/v1/users/1
curl -i http://127.0.0.1:8080/api/v1/users/2
curl -i http://127.0.0.1:8080/api/v1/users/abc
curl -i -X POST http://127.0.0.1:8080/api/v1/users \
-H 'Content-Type: application/json' \
-d '{"name":"Анна"}'
curl -i -X POST http://127.0.0.1:8080/api/v1/users \
-H 'Content-Type: application/json' \
-d '{"name":" "}'
Ожидаемые статусы по порядку: 200, 404, 400, 201, 400. Флаг -i показывает заголовки вместе с телом. Так вы заметите ошибку в JSON при успешном HTTP-статусе.
Для повреждённого JSON и пустого имени API возвращает общий invalid_body. Если клиент должен подсвечивать конкретные поля формы, следующим шагом добавьте список ошибок полей. Его формат задайте в контракте API, а ошибки валидатора преобразуйте в этот формат на стороне сервера.
Тесты без запуска сервера
Gin можно проверить через стандартный httptest. Создайте рядом файл main_test.go:
package main
import (
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"testing"
)
func TestErrors(t *testing.T) {
r := newRouter()
tests := []struct {
name, method, path, body string
status int
code string
}{
{"bad id", "GET", "/api/v1/users/abc", "", 400, "invalid_id"},
{"missing user", "GET", "/api/v1/users/2", "", 404, "user_not_found"},
{"bad JSON", "POST", "/api/v1/users", "{", 400, "invalid_body"},
{"empty name", "POST", "/api/v1/users", `{}`, 400, "invalid_body"},
{"blank name", "POST", "/api/v1/users", `{"name":" "}`, 400, "invalid_name"},
{"missing route", "GET", "/missing", "", 404, "route_not_found"},
{"wrong method", "DELETE", "/api/v1/users/1", "", 405, "method_not_allowed"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
req := httptest.NewRequest(tt.method, tt.path, strings.NewReader(tt.body))
req.Header.Set("Content-Type", "application/json")
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
if w.Code != tt.status {
t.Fatalf("status: got %d, want %d", w.Code, tt.status)
}
var response struct {
Error struct { Code string `json:"code"` } `json:"error"`
}
if err := json.Unmarshal(w.Body.Bytes(), &response); err != nil {
t.Fatal(err)
}
if response.Error.Code != tt.code {
t.Fatalf("code: got %q, want %q", response.Error.Code, tt.code)
}
})
}
}
func TestGetUser(t *testing.T) {
w := httptest.NewRecorder()
newRouter().ServeHTTP(w, httptest.NewRequest(http.MethodGet, "/api/v1/users/1", nil))
if w.Code != http.StatusOK {
t.Fatalf("status: got %d, want 200", w.Code)
}
}
Запустите go test ./.... Табличный тест проверяет HTTP-статус и машинный код ошибки. Проверка только статуса пропустила бы ответ с правильным числом и сломанным JSON. Проверка только тела не заметила бы ошибочный статус.
Добавьте проверки успешного создания, слишком длинного имени и восстановления после паники. Для сервиса с базой данных отдельно проверьте преобразование ошибок хранилища в ошибки API. Сетевой порт для этих тестов не нужен: ServeHTTP получает запрос и записывает ответ прямо в память. Такой способ описан в документации httptest.
Что изменить перед использованием в сервисе
Уберите заданного в коде пользователя и передайте обработчикам сервис через конструктор. HTTP-слой должен разбирать запрос и выбирать статус, а сервис должен проверять правила приложения и обращаться к хранилищу. Так бизнес-логику можно будет тестировать без Gin.
Ограничьте размер тела запроса до разбора JSON, например через http.MaxBytesReader. Пример проверяет имя после чтения тела, но не ограничивает размер запроса. Ошибку превышения размера следует отдельно преобразовать в 413, если это предусмотрено контрактом API.
При развёртывании за reverse proxy задайте доверенные прокси через SetTrustedProxies. От этой настройки зависит, каким заголовкам Gin доверяет при определении IP клиента. Укажите адреса прокси из вашей сети.
Не передавайте исходный *gin.Context в фоновую задачу, которая переживает запрос. Для такой задачи лучше сформировать отдельные входные данные и передать их в очередь. Если горутине нужны сведения из Gin-контекста, используйте c.Copy(). Копия не позволяет дописывать HTTP-ответ после завершения запроса. Подробности есть в рекомендациях Gin по горутинам.
Итог
Начните с явной регистрации маршрутов и небольших обработчиков. Входной JSON проверяйте через ShouldBindJSON, ошибки собирайте до записи ответа. Middleware с Next() удобно использовать для действий до и после обработчика; для отказа вызывайте Abort() и завершайте текущую функцию через return.
В примере единый формат ошибок покрывает валидацию, отсутствие пользователя, неизвестный маршрут и неподдерживаемый метод. Эти ветки стоит сохранить в тестах, когда вместо заданных данных появятся сервис и база данных.
Теги: