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

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

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

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

Итоговый проект: REST API на Gin

Вот он, выпускной. Двадцать четыре урока, три сотни приёмов и один вопрос, который теперь отвечает сам себе: как собрать rest api gin-сервис целиком, а не отдельные винтики. Итоговый проект gin — это трекер задач с честной аутентификацией: полный CRUD с владельцами и 404 на чужие записи, JWT-гвард с [verifyKey из урока 15](/courses/gin/lessons/15-jwt/), слои handler/service/repository и композиционный корень из [урока 22](/courses/gin/lessons/22-struktura/), конверт ошибок с handleError из [урока 17](/courses/gin/lessons/17-oshibki/), per-IP лимиты из [урока 18](/courses/gin/lessons/18-rate-limit/), table-driven тесты из [урока 21](/courses/gin/lessons/21-testirovanie/) и graceful shutdown из [урока 24](/courses/gin/lessons/24-deploy/). Мы пройдём по проекту сверху вниз: ТЗ, карта эндпоинтов, домен, хранилище, сервис, хендлеры, цепочка звеньев, старт и остановка — и в конце вас ждёт выпускной экзамен в домашке.

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

Выпускной проект gin: rest api трекера задач#

Проект gin, который мы собираем, называется todoapp: трекер личных задач с JSON-API, куда любой честно зарегистрированный пользователь ходит со своим токеном. Заметьте, что здесь нет ни одной новой библиотеки и ни одного нового механизма — и это главный сигнал зрелости: REST API собирается из тех же звеньев, биндингов и сентинелов, что вы носили по всем урокам, просто в правильном порядке и с правильными границами. Технически сервис живёт в одном модуле: пакет todo несёт домен и всю логику, пакет middleware — гвард и лимиты, пакет config — окружение, cmd/server/main — композиционный корень. Слой репозитория даём в двух реализациях: Postgres для боя и память для тестов. Задача урока — не «написать побольше кода», а показать каркас, который переживёт рост: когда появятся подзадачи, теги и напоминания, вы будете расширять слои, а не переписывать их. Всё, что мы не разжёвываем заново, — конверт, цепочка звеньев, тон хендлеров — берётся из предыдущих уроков по ссылкам: это не повтор, а сборка.

Техническое задание: что принимает приёмка#

  1. Аутентификация JWT: POST /api/v1/token выдаёт пару access (15 минут) и refresh (сутки) за логин и пароль; все маршруты задач — под гвардом Authorization Bearer из урока 15.
  2. CRUD задач: создание, список своих, чтение одной, переименование, удаление; чужие задачи не видны никогда — 404 вместо 403, чтобы не раскрывать существование чужих ресурсов.
  3. Валидация TodoInput тегами binding: заголовок обязателен, от 1 до 140 символов; ошибки валидации уходят клиенту кодом VALIDATION_FAILED с details по полям из урока 09.
  4. Один формат ошибок: Envelope с APIError{Code, Message, Details} из уроков 05 и 17, единый handleError, сентинелы ErrNotFound и ErrConflict в домене.
  5. Бизнес-правило сервиса: дубликат заголовка у одного владельца — 409 CONFLICT; в Postgres его продублирует уникальный индекс, в памяти — проход по списку.
  6. Защита от брутфорса: per-IP лимит из урока 18 на весь роутер, до аутентификации; 429 с Retry-After.
  7. Здоровье и остановка: /healthz без зависимостей, /readyz с ping-ом базы, graceful shutdown с дедлайном из урока 24, таймауты http.Server.
  8. Тесты без порта: httptest, свежий роутер на каждый кейс, table-driven покрытие счастливых и несчастных путей из урока 21.

Архитектура: слои, каталог и направление зависимостей#

Каркас берём тот же, что в уроке про структуру проекта: handler — транспорт (биндинг, claims, статусы), service — бизнес-правила, Store — контракт данных, под которым живут Postgres и память. Направление зависимостей одно: стрелки смотрят вглубь, к домену, и никогда наружу. Интерфейс Store объявлен в пакете todo — рядом с потребителем, а не в пакете реализации: репозиторий не знает, кто его использует, и потому заменяется заглушкой без единого изменения в логике. Сентинелы ErrNotFound и ErrConflict живут тоже в домене: их не генерирует транспорт, их приносит хранилище или правила сервиса, а уже handleError переводит их в статусы — 404 и 409 соответственно. Каталог минимальный: cmd/server для main, internal/todo для handler.go, service.go, store.go, memory.go и postgres.go, internal/middleware для auth.go и limit.go, internal/config для Load. Никакого бога-пакета utils и никакой преждевременной иерархии: когда файлов станет двадцать — делить будете по тому же принципу «кто кого знает». Звенья из урока про middleware подключаются одной цепочкой, группы из урока про маршруты собирают /api/v1.

Карта эндпоинтов: договор с клиентом#

Метод и путьНазначениеУспехОшибки
POST /api/v1/tokenлогин, пара access+refresh200 + токены400, 401, 429
POST /api/v1/todosсоздать задачу201 + Todo400, 401, 409, 429
GET /api/v1/todosсписок своих задач200 + []Todo401, 429
GET /api/v1/todos/:idодна своя задача200 + Todo400, 401, 404, 429
PATCH /api/v1/todos/:idпереименовать200 + Todo400, 401, 404, 409, 429
DELETE /api/v1/todos/:idудалить204 без тела400, 401, 404, 429
GET /healthzжив ли процесс200—
GET /readyzготов ли к трафику200 / 503503 при недоступной базе

Домен и контракт Store: пять методов вместо трёх#

Домен — три структуры и два сентинела, ничего лишнего. Todo — то, что хранится и отдаётся; TodoInput — то, что приходит от клиента с тегами binding из урока про привязку; UserClaims из JWT-урока прилетает звеном-гвардом и несёт UserID, по которому фильтруется всё хранилище. Обратите внимание на сигнатуры: Get и Delete принимают id и ownerID парой — «найди, но только среди моих». Это сознательное решение безопасности: чужая задача и несуществующая задача неразличимы, обе отвечают 404 NOT_FOUND, и перебор чужих идентификаторов не даёт информации. Контракт Store вырос с трёх методов из тестового урока до пяти — полный CRUD; это нормальная жизнь интерфейса, методы добавляются, когда появляются требования, а не «на будущее». В конверте ответов — Envelope из урока про JSON: Data для успеха, Error *APIError{Code, Message, Details} для отказа, respondData и respondError как единственные выходы в клиент.

Домен, сентинелы и Store

GO
// internal/todo/domain.go — домен и сентинелы
type Todo struct {
        ID        int64
        OwnerID   int64
        Title     string
        CreatedAt time.Time
}

type TodoInput struct {
        Title string `json:"title" binding:"required,min=1,max=140"`
}

var (
        ErrNotFound = errors.New("todo: задача не найдена")          // → 404 NOT_FOUND
        ErrConflict = errors.New("todo: дубликат заголовка")          // → 409 CONFLICT
        ErrBadID    = errors.New("todo: id должен быть числом")       // → 400 BAD_REQUEST
)

// internal/todo/store.go — контракт данных, объявлен у потребителя
type Store interface {
        Get(ctx context.Context, id, ownerID int64) (Todo, error)
        List(ctx context.Context, ownerID int64) ([]Todo, error)
        Create(ctx context.Context, t Todo) (Todo, error)
        Update(ctx context.Context, t Todo) (Todo, error)
        Delete(ctx context.Context, id, ownerID int64) error
}

// internal/todo/memory.go — реализация для тестов и локального пуска
type MemoryStore struct {
        mu   sync.RWMutex
        next int64
        byID map[int64]Todo
}

func NewMemoryStore() *MemoryStore { return &MemoryStore{byID: map[int64]Todo{}} }

func (s *MemoryStore) Create(_ context.Context, t Todo) (Todo, error) {
        s.mu.Lock()
        defer s.mu.Unlock()
        s.next++
        t.ID, t.CreatedAt = s.next, time.Now()
        s.byID[t.ID] = t
        return t, nil
}

func (s *MemoryStore) Get(_ context.Context, id, ownerID int64) (Todo, error) {
        s.mu.RLock()
        defer s.mu.RUnlock()
        t, ok := s.byID[id]
        if !ok || t.OwnerID != ownerID {
                return Todo{}, ErrNotFound // чужая задача = несуществующая
        }
        return t, nil
}

// List, Update и Delete — по той же схеме: RLock на чтение, Lock на запись,
// фильтр по ownerID, ErrNotFound при промахе; List возвращает непустой срез.

Сентинелы объявлены errors.New в домене — это значения для errors.Is в handleError, а не тексты для клиента. MemoryStore — тупая карта под RWMutex: никакого бизнес-правила дубликата здесь нет, потому что это забота сервиса; хранилище умеет только «положить, найти, переписать, убрать». Фильтр по ownerID внутри каждого метода — двойной замок: даже если хендлер забудет проверить владельца, слой данных не отдаст чужое.

Сервис: бизнес-правила живут здесь

GO
// internal/todo/service.go
type Service struct {
        st Store
}

func NewService(st Store) *Service { return &Service{st: st} }

func (s *Service) Create(ctx context.Context, ownerID int64, in TodoInput) (Todo, error) {
        todos, err := s.st.List(ctx, ownerID)
        if err != nil {
                return Todo{}, fmt.Errorf("list для дубликатов: %w", err)
        }
        for _, t := range todos { // дубликат — доменное правило, не транспорт
                if strings.EqualFold(t.Title, in.Title) {
                        return Todo{}, ErrConflict
                }
        }
        return s.st.Create(ctx, Todo{OwnerID: ownerID, Title: in.Title})
}

func (s *Service) Update(ctx context.Context, ownerID, id int64, in TodoInput) (Todo, error) {
        t, err := s.st.Get(ctx, id, ownerID)
        if err != nil {
                return Todo{}, fmt.Errorf("get %d: %w", id, err)
        }
        t.Title = in.Title
        if _, err := s.st.Update(ctx, t); err != nil {
                return Todo{}, fmt.Errorf("update %d: %w", id, err)
        }
        return t, nil
}

func (s *Service) Get(ctx context.Context, ownerID, id int64) (Todo, error) {
        t, err := s.st.Get(ctx, id, ownerID)
        if err != nil {
                return Todo{}, fmt.Errorf("get %d: %w", id, err)
        }
        return t, nil
}

func (s *Service) Delete(ctx context.Context, ownerID, id int64) error {
        return s.st.Delete(ctx, id, ownerID)
}

Проверка дубликата — доменное правило, поэтому она в сервисе: EqualFold делает сравнение без учёта регистра. В Postgres-реализации это правило продублирует уникальный индекс на (owner_id, lower(title)), и нарушение прилетит ошибкой драйвера — её перевод в ErrConflict будет одной обёрткой %w в postgres.go. Оборачивания fmt.Errorf с %w — из урока 17: контекст прилетает наверх, errors.Is всё ещё находит сентинел. Методы короткие и скучные — это комплимент: скучный сервис не с surprise-логикой.

Проверка: где объявлен Store

Почему интерфейс Store объявлен в пакете todo рядом с сервисом и хендлером, а не в пакете postgres-реализации?

Хендлеры: транспорт без бизнес-логики#

Хендлер в проекте gin делает ровно четыре вещи: биндит вход, достаёт claims из контекста, зовёт сервис и отвечает — либо respondData, либо handleError. Всё остальное не его. ShouldBindJSON с тегами из урока про валидацию возвращает ошибку — рука сама тянется к handleError(c, err): ветка errors.As на validator.ValidationErrors соберёт details по полям и ответит 400 VALIDATION_FAILED, как в уроке про ошибки. c.MustGet("claims").(*UserClaims) безопасен только потому, что маршруты зарегистрированы в группе с Auth() — гвард либо положил claims, либо оборвал цепочку ещё раньше; регистрационные маршруты в эту группу не входят, и MustGet там запрещён. Разбор :id из пути — приём из урока про параметры: strconv.ParseInt с ошибкой, которую мы переводим в сентинел ErrBadID, — для него в handleError появилась новая case-ветка BAD_REQUEST, ведь словарь кодов растёт вместе с API. Обратите внимание на PATCH: он переименовывает, а не «частично обновляет всё» — для трекера этого достаточно, а полная семантика JSON Patch — отдельная спецификация и отдельный вечер.

Handler: CRUD одним куском

GO
// internal/todo/handler.go
type Handler struct {
        svc *Service
}

func NewHandler(svc *Service) *Handler { return &Handler{svc: svc} }

// RegisterRoutes вешает маршруты на группу; группа уже под Auth().
func (h *Handler) RegisterRoutes(v1 *gin.RouterGroup) {
        todos := v1.Group("/todos")
        todos.POST("", h.create)
        todos.GET("", h.list)
        todos.GET("/:id", h.get)
        todos.PATCH("/:id", h.update)
        todos.DELETE("/:id", h.remove)
}

func (h *Handler) create(c *gin.Context) {
        var in TodoInput
        if err := c.ShouldBindJSON(&in); err != nil {
                handleError(c, err) // 400 VALIDATION_FAILED с details
                return
        }
        claims := c.MustGet("claims").(*UserClaims)
        t, err := h.svc.Create(c.Request.Context(), claims.UserID, in)
        if err != nil {
                handleError(c, err) // 409 CONFLICT на дубликат
                return
        }
        respondData(c, http.StatusCreated, t)
}

func (h *Handler) get(c *gin.Context) {
        id, err := strconv.ParseInt(c.Param("id"), 10, 64)
        if err != nil {
                handleError(c, fmt.Errorf("%w: %s", ErrBadID, c.Param("id")))
                return
        }
        claims := c.MustGet("claims").(*UserClaims)
        t, err := h.svc.Get(c.Request.Context(), claims.UserID, id)
        if err != nil {
                handleError(c, err) // 404 NOT_FOUND, даже если задача чужая
                return
        }
        respondData(c, http.StatusOK, t)
}

// list, update и remove — по той же схеме:
// remove отвечает c.Status(http.StatusNoContent) без тела.

Четыре действия, ноль условий о домене: ни один хендлер не знает, откуда берутся ErrConflict и ErrNotFound — он просто передаёт err дальше. c.Request.Context() пробрасывает отмену вниз до хранилища: клиент закрыл соединение — запрос в Postgres получит cancellation. respondData и respondError из урока про JSON — единственные точки выхода; ни одного c.JSON с голой структурой в проекте, поэтому формат ответов совпадает во всех эндпоинтах автоматически.

Проверка: что получит клиент

Пользователь дважды отправляет POST /api/v1/todos с телом {"title":"выучить gin"}. Второй запрос — что вернёт сервис и что увидит клиент?

Цепочка звеньев: кто где стоит и почему#

Порядок звеньев — это порядок защиты и ответственности, и в итоговом проекте он финальный. gin.New() без Default: звенья подключаем явно, как в уроке про логирование. Первым — Logger со slog: он пишет строку access-лога после c.Next() и видит итоговый статус, включая отказы лимитёра. Вторым — Recovery: паника в любом звене ниже, включая сам лимитёр, будет поймана. Третьим — CORS из урока 16: заголовки должны стоять на всех ответах, в том числе на 429 и 401, иначе браузер не покажет фронтенду настоящую ошибку. Четвёртым — per-IP лимит из урока про rate limiting: он до аутентификации, потому что брутфорс пароля приходит с неаутентифицированным трафиком, и неаутентифицированные запросы должны упираться в 429, не тратя ни токенов JWT-проверок, ни запросов к базе. Дальше — группа /api/v1 из урока про группы со звеном Auth(): гвард проверяет подпись verifyKey, exp и кладёт claims в контекст. Внутри группы — RegisterRoutes хендлера. Пробы /healthz и /readyz вешаются на движок вне группы: оркестратору не нужен JWT, чтобы узнать, жив ли процесс.

Композиционный корень: main собирает всё

GO
// cmd/server/main.go — единственное место, где всё знают друг о друге
func main() {
        cfg, err := config.Load() // PORT, DSN, JWT_SECRET: падаем на пустом
        if err != nil {
                log.Fatalf("config: %v", err)
        }
        gin.SetMode(cfg.Mode) // release в проде из урока 24

        pool, err := pgxpool.New(context.Background(), cfg.DSN)
        if err != nil {
                log.Fatalf("pgxpool: %v", err)
        }

        repo := todo.NewPostgresStore(pool) // прод-реализация Store
        svc := todo.NewService(repo)
        h := todo.NewHandler(svc)

        engine := gin.New()
        engine.Use(
            middleware.Logger(slog.Default()),
            middleware.Recovery(slog.Default()),
            middleware.CORS(),
            middleware.PerIPLimit(cfg.RPS, cfg.Burst), // 18-й урок
        )
        v1 := engine.Group("/api/v1")
        v1.Use(middleware.Auth()) // гвард 15-го урока на всю группу
        h.RegisterRoutes(v1)

        engine.GET("/healthz", healthz(version))
        engine.GET("/readyz", readyz(pool))
        log.Fatal(run(engine, cfg, pool)) // сервер и graceful — дальше
}

main — единственный файл, знающий все типы целиком: пул, репозиторий, сервис, хендлер, звенья. Всё остальное собирается конструкторами NewService/NewHandler/PerIPLimit — DI руками из урока 22, никакого фреймворка внедрения не нужно, пока граф влезает в один экран. Чтобы переключиться на память (тесты, локальный демо-режим), меняется одна строка: todo.NewPostgresStore(pool) на todo.NewMemoryStore() — именно ради этого контракт Store живёт у потребителя. config.Load из 24-го проверяет JWT_SECRET на пустоту и падает честно: сервис, подписывающий токены пустым ключом, не должен стартовать.

Предскажи ответ: запрос без токена

Прочитайте код и выберите, что он выведет:

v1 := engine.Group("/api/v1")
v1.Use(middleware.Auth())
h.RegisterRoutes(v1)

// клиент отправляет:
// POST /api/v1/todos
// {"title":"собрать выпускной проект"}
// Заголовок Authorization отсутствует

Per-IP лимит: звено из урока 18 в проекте

GO
// internal/middleware/limit.go
type ipLimiter struct {
        mu       sync.Mutex
        visitors map[string]*rate.Limiter
        rps      rate.Limit
        burst    int
}

func PerIPLimit(rps rate.Limit, burst int) gin.HandlerFunc {
        l := &ipLimiter{visitors: map[string]*rate.Limiter{}, rps: rps, burst: burst}
        go l.cleaner(time.Minute) // выкидываем молчащих гостей
        return func(c *gin.Context) {
                lim := l.get(c.ClientIP())
                res := lim.Reserve() // бронируем токен, чтобы знать Delay
                if res == nil {
                        tooMany(c, time.Second)
                        return
                }
                if d := res.Delay(); d > 0 {
                        res.Cancel() // бронь возвращаем — не ждём
                        tooMany(c, d)
                        return
                }
                c.Next()
        }
}

func (l *ipLimiter) get(ip string) *rate.Limiter {
        l.mu.Lock()
        defer l.mu.Unlock()
        lim, ok := l.visitors[ip]
        if !ok {
                lim = rate.NewLimiter(l.rps, l.burst)
                l.visitors[ip] = lim
        }
        return lim
}

func tooMany(c *gin.Context, wait time.Duration) {
        c.Header("Retry-After", strconv.Itoa(int(math.Ceil(wait.Seconds()))))
        respondError(c, http.StatusTooManyRequests, "RATE_LIMITED", "слишком много запросов")
        c.Abort()
}

Всё это — перенос канона 18-го урока: ведро из x/time/rate на IP, Reserve ради точного Retry-After вместо магической единицы, Abort после ответа. respondError здесь из общего набора — отказ лимитёра выглядит так же, как отказ домена, и фронтенд разбирает его тем же парсером конверта. Не забудьте про SetTrustedProxies: за ingress или nginx ClientIP честен только при правильно объявленном доверенном прокси, иначе лимит считает балансировщик одним гостем на всех. Карта визитёров растёт вечно без cleaner'а — выкидывайте неактивных по таймеру.

Проверка: где стоит лимит относительно гварда

PerIPLimit подключён к движку, Auth() — к группе /api/v1. Почему лимит стоит раньше аутентификации?

Сервер и остановка: финал из урока 24#

Старт — не r.Run, а явный http.Server с бюджетами: ReadHeaderTimeout против slowloris, ReadTimeout на тело, WriteTimeout с оглядкой на потоки, IdleTimeout для keep-alive — полный разбор был в уроке про деплой. Остановка — graceful: signal.NotifyContext ловит SIGTERM от docker stop и Ctrl+C в разработке, main уходит в дренаж, srv.Shutdown с дедлайном дожидается активных запросов, и только после возвращения Shutdown закрывается пул базы. Порядок «сервер — потом пулы» — не суеверие: пока Shutdown не вернулся, хендлеры могут ещё ходить в базу, и закрытый пул превратил бы их последние запросы в 500. Пробы такие, как договаривались: /healthz отвечает 200 всегда и без зависимостей — живой процесс с умершей базой должен оставаться живым процессом; /readyz делает pool.Ping с секундным контекстом и при провале отдаёт 503, снимая реплику с балансировки. Второй SIGTERM — валит процесс сразу: stop() снимает обработчик, и повторный сигнал работает как «хватит, умираем», что спасает при зависшем дренаже.

run(): старт, сигнал, дренаж, пулы

GO
// cmd/server/main.go — продолжение
func run(engine *gin.Engine, cfg config.Config, pool *pgxpool.Pool) error {
        srv := &http.Server{
                Addr:              cfg.Addr,
                Handler:           engine,
                ReadHeaderTimeout: 5 * time.Second,
                ReadTimeout:       15 * time.Second,
                WriteTimeout:      30 * time.Second,
                IdleTimeout:       120 * time.Second,
        }
        ctx, stop := signal.NotifyContext(context.Background(),
                os.Interrupt, syscall.SIGTERM)
        defer stop()

        errCh := make(chan error, 1)
        go func() {
                if err := srv.ListenAndServe(); err != nil &&
                        !errors.Is(err, http.ErrServerClosed) {
                        errCh <- err
                }
        }()

        select {
        case err := <-errCh: // порт занят и прочие ошибки старта
                return err
        case <-ctx.Done(): // SIGTERM: начинаем дренаж
        }
        stop() // следующий сигнал рубит жёстко — это фича

        drainCtx, cancel := context.WithTimeout(context.Background(), 25*time.Second)
        defer cancel()
        if err := srv.Shutdown(drainCtx); err != nil {
                return fmt.Errorf("shutdown: %w", err)
        }
        return pool.Close() // базу закрываем последней
}

ListenAndServe возвращает http.ErrServerClosed после Shutdown — это не ошибка, фильтруем через errors.Is, иначе дренаж запишет в errCh ложную тревогу. Дедлайн 25 секунд при grace-period платформы 30 — из урока 24: запас на закрытие пулов. readyz и healthz получают свои хендлеры снаружи run и живут на том же srv; Shutdown дождётся и их длинных ping-ов — держите таймауты проб короткими.

Собери хендлер создания

Впишите пропущенные вызовы хендлера create — ровно те четыре слова, которые делают его транспортным слоем проекта.

func (h *Handler) create(c *gin.Context) { var in TodoInput if err := c.(&in); err != nil { (c, err) return } claims := c.MustGet("claims").(*UserClaims) t, err := h.svc.(c.Request.Context(), claims.UserID, in) if err != nil { handleError(c, err) return } respondData(c, http., t) }

Собери путь запроса: POST /api/v1/todos

Расставь по порядку всё, что происходит с запросом на создание задачи — от TCP-пакета до ответа 201.

Строки

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

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

Тесты проекта: те же слои, тот же httptest#

Проект gin без тестов — не выпускной, а черновик, и слои оплатили себя тестами сполна. Хендлеры проверяются без порта: Engine — это http.Handler, httptest.NewRequest и NewRecorder из урока 21 прогоняют запрос через роутер за микросекунды. Группа /api/v1 в тестах получает не настоящий гвард, а заглушку, кладущую claims с фиксированным UserID: JWT из урока 15 уже проверен в его собственных тестах, а здесь нас интересуют ветки CRUD. Подмена Store — то же самое: setupRouter(st Store) строит MemoryStore или stubStore{err: ErrNotFound} в зависимости от кейса, и каждый t.Run получает свежий роутер — состояние не течёт между кейсами. Table-driven таблица накрывает и счастливые, и несчастные пути: создание, дубликат, промах, битый id — именно «несчастные» кейсы охраняют контракт ошибок: коды NOT_FOUND, CONFLICT, BAD_REQUEST в теле конверта — это проверка handleError, а не удача. Полный прогон — go test ./... с -race: RWMutex в MemoryStore и карта лимитёра как раз для того, чтобы гонки ловились детектором, а не продом.

Table-driven на весь CRUD

GO
// internal/todo/handler_test.go
func setupRouter(st Store) *gin.Engine {
        gin.SetMode(gin.TestMode)
        engine := gin.New()
        v1 := engine.Group("/api/v1")
        v1.Use(func(c *gin.Context) { // гвард уже проверен в своих тестах
                c.Set("claims", &UserClaims{UserID: 42})
                c.Next()
        })
        NewHandler(NewService(st)).RegisterRoutes(v1)
        return engine
}

func TestTodosAPI(t *testing.T) {
        tests := []struct {
                name, method, path, body string
                st                       Store
                want                     int
                wantErr                  string
        }{
                {"создание", "POST", "/api/v1/todos", `{"title":"выучить gin"}`,
                        NewMemoryStore(), http.StatusCreated, ""},
                {"дубликат", "POST", "/api/v1/todos", `{"title":"выучить gin"}`,
                        stubStore{list: []Todo{{ID: 1, OwnerID: 42, Title: "выучить gin"}}},
                        http.StatusConflict, "CONFLICT"},
                {"промах", "GET", "/api/v1/todos/7", "",
                        stubStore{err: ErrNotFound}, http.StatusNotFound, "NOT_FOUND"},
                {"битый id", "GET", "/api/v1/todos/abc", "",
                        NewMemoryStore(), http.StatusBadRequest, "BAD_REQUEST"},
        }
        for _, tt := range tests {
                t.Run(tt.name, func(t *testing.T) {
                        router := setupRouter(tt.st) // свежий стор на кейс
                        w := httptest.NewRecorder()
                        req := httptest.NewRequest(tt.method, tt.path,
                                strings.NewReader(tt.body))
                        req.Header.Set("Content-Type", "application/json")
                        router.ServeHTTP(w, req)
                        if w.Code != tt.want {
                                t.Fatalf("code=%d want=%d body=%s", w.Code, tt.want, w.Body)
                        }
                        if tt.wantErr != "" &&
                                !strings.Contains(w.Body.String(), tt.wantErr) {
                                t.Fatalf("нет кода %s в %s", tt.wantErr, w.Body)
                        }
                })
        }
}

Кейс «дубликат» кормит сервис stubStore'ом, чей List возвращает готовую задачу владельца 42 — сервис честно находит EqualFold-совпадение и возвращает ErrConflict, а тест ловит 409 с кодом CONFLICT в теле. Кейс «битый id» проверяет свежую ветку handleError — BAD_REQUEST. Ни одной строчки мок-фреймворка: stubStore — структура с заготовленными значениями, как в 21-м уроке. Тесты гоняются с настоящим MemoryStore тоже: два кейса из таблицы специально его используют, чтобы CRUD на карте был под гонкой-детектором.

Предскажи ответ: промах хранилища

Прочитайте код и выберите, что он выведет:

// stubStore{err: ErrNotFound} подставлен в setupRouter:
// Get и List возвращают заготовленную ошибку

// тест шлёт:
// GET /api/v1/todos/7
// Authorization: Bearer <валидный токен>

Финальный чеклист: из какого урока что пришло#

УрокЧто вошло в проект
02-серверEngine как http.Handler — тесты без порта и SetMode
04-биндингShouldBindJSON и связка TodoInput с тегами
05-JSONEnvelope{Data, Error}, respondData и respondError
08-логиLogger на slog и Recovery в цепочке движка
09-валидацияVALIDATION_FAILED с details по полям TodoInput
10-группы/api/v1 с звеном-гвардом и RegisterRoutes хендлера
15-jwtUserClaims, verifyKey, Auth() и пара access+refresh
17-ошибкиСентинелы ErrNotFound/ErrConflict и единый handleError
18-лимитыPerIPLimit на x/time/rate с 429 и Retry-After
21-тестыstubStore, table-driven, свежий роутер на кейс
22-структураСлои handler/service/Store и композиционный корень
24-деплойhttp.Server с таймаутами, graceful shutdown, healthz/readyz

Восемь граблей выпускного проекта#

  • Бизнес-правило в хендлере: как только create сам решает, что дубликат, — сервис умер, тестировать домен без HTTP невозможно; правило живёт в service.
  • 403 на чужую задачу: Get без ownerID отдаёт чужое и раскрывает существование ресурсов; фильтр владельца — в сигнатурах Store.
  • MustGet вне гварда: хендлер, зарегистрированный не под Auth(), паникует на claims; контракт «под группой claims есть» держится маршрутизацией.
  • Лимит после гварда: брутфорс /api/v1/token бесплатно ест хэши паролей; PerIPLimit подключается к движку, а не к группе.
  • r.Run вместо http.Server: таймауты не поставлены — slowloris вешает горутины; WriteTimeout 30 секунд против slow-клиентов.
  • Shutdown без дедлайна: один websocket-клиент или долгий запрос держит дренаж вечно; 25 секунд — и уходим.
  • pool.Close до Shutdown: хендлеры ещё ходят в закрытую базу; сначала сервер, потом пулы.
  • Новый код без теста: каждая ветка handleError — контракт с клиентом; несчастные кейсы в таблице не опциональны.

Карточки: итоговый проект

Термин

Store в проекте

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

Интерфейс из пяти методов (Get/List/Create/Update/Delete), объявленный в пакете todo у потребителя. Пара (id, ownerID) в сигнатурах — фильтр владельца на уровне данных: чужая задача неотличима от несуществующей, обе дают ErrNotFound → 404. Реализации: PostgresStore для боя, MemoryStore для тестов и демо.

1 / 6

Песочница урока

Редактируйте код и запускайте его прямо здесь — интерпретатор работает в браузере, ничего устанавливать не нужно. За каждый запуск — +2 XP.

Вывод

Запустите код, чтобы увидеть вывод.

Коротко: что запомнить

  • Итоговый проект gin — трекер задач: полный CRUD с владельцами, JWT-аутентификация, валидация, лимиты, health-пробы и тесты — без единой новой библиотеки.
  • Слои из урока 22 держат проект: хендлер биндит и отвечает, сервис держит бизнес-правила (дубликат → ErrConflict), Store — контракт у потребителя с фильтром ownerID в сигнатурах.
  • Один формат ошибок: сентинелы ErrNotFound/ErrConflict/ErrBadID, единый handleError и конверт Envelope — 404 на чужие задачи, 409 на дубликаты, 400 на битый id.
  • Цепочка звеньев по старшинству: Logger → Recovery → CORS → PerIPLimit на движке, Auth() на группе /api/v1; лимит до аутентификации, потому что брутфорс дешевле остановить в 429.
  • main — композиционный корень: config.Load с честным падением, pgxpool, конструкторы, группы; подмена Postgres на память — одна строка.
  • Остановка как в проде: http.Server с таймаутами, NotifyContext, Shutdown с дедлайном 25 секунд, pool.Close последним; тесты — table-driven на httptest со stubStore и свежим роутером на кейс.

Домашнее задание

ВЫПУСКНОЙ ЭКЗАМЕН. Соберите todoapp по канону урока и доведите до приёмки: (1) Домен и слои — Todo, TodoInput с тегами binding, сентинелы ErrNotFound/ErrConflict/ErrBadID, интерфейс Store из пяти методов в пакете todo, MemoryStore под RWMutex и PostgresStore на pgxpool с фильтром ownerID в каждом методе. (2) Сервис с правилом дубликата на strings.EqualFold и обёртками fmt.Errorf с %w. (3) Хендлеры: registerRoutes на группе /api/v1, bind → claims → svc → respondData/handleError, разбор :id через strconv с переводом в ErrBadID. (4) Аутентификация: POST /api/v1/token выдаёт access 15 минут и refresh сутки, verifyKey проверяет alg и секрет из config.Load (пустой JWT_SECRET — падение на старте), гвард Auth() с 401/подсказкой. (5) Защита: PerIPLimit на движке до гварда с 429, Retry-After и cleaner'ом карты, CORS на все ответы, Logger и Recovery первыми. (6) Пробы и остановка: /healthz с версией, /readyz с pool.Ping и таймаутом, http.Server с четырьмя таймаутами, NotifyContext → Shutdown 25 секунд → pool.Close. (7) Тесты: table-driven минимум на 6 кейсов (создание, дубликат, промах, битый id, невалидное тело, 401 заглушки гварда), stubStore на ошибки, свежий роутер в каждом t.Run. Приёмка: go vet ./... и go test ./... -race зелёные; curl без токена получает 401 с конвертом, с токеном — 201; второй тот же POST — 409 CONFLICT; чужой id — 404 NOT_FOUND; kill -TERM даёт дренаж в логе и код выхода 0. Экзамен сдан, когда все семь пунктов закрыты без единого хендлера с бизнес-логикой внутри.

Личные заметкиПусто
Хранятся только в вашем браузере (Gin · Итоговый проект: REST API на Gin)

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

Где взять полный код проекта?

Урок даёт каркас и все узловые фрагменты: домен, Store, сервис, хендлеры, лимитёр, main и тесты. Недостающие куски — respondData/respondError, CORS, Logger, Recovery, Auth() — берутся дословно из уроков 05, 08, 15, 16, 17: это и есть проверка, что вы умеете собирать из канона, а не копировать готовый репозиторий.

Postgres обязателен или можно сдать проект на памяти?

MemoryStore — полноправная реализация Store, на ней работает весь CRUD и все тесты. Postgres нужен, когда вы хотите настоящий бой: пул pgxpool, уникальный индекс на дубликаты и /readyz с ping-ом. Экзамен сдавайте на том, что ближе к цели: каркас один и тот же, замена — одна строка в main.

Почему хендлеры такие скучные?

Потому что это их работа: биндить, доставать claims, звать сервис, отвечать. Вся доменная логика — в сервисе, весь перевод ошибок — в handleError, весь транспортный протокол — в звеньях. Скучный хендлер — читаемый хендлер, а читаемый хендлер тестируется таблицей из четырёх полей.

Что развивать в проекте дальше?

Естественные расширения каждого пройденного направления: пагинация списка и фильтры в List, флаг done и PUT-семантика, WebSocket-канал обновлений из урока 20, метрики Prometheus звеном, release-профилирование из урока 23 и Dockerfile из урока 24 для деплоя. Каждый новый эндпоинт — по той же схеме: домен, сервис, хендлер, тест-кейсы, и ни одна старая ветка handleError не меняется.

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

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

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

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

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

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

Echo: минималистичный быстрый фреймворк

Курс Echo для Go: маршруты, middleware, валидация, JWT, SSE, тестирование и деплой. 25 уроков с примерами и итоговым API-проектом.

  • Строить REST API на Echo
  • Использовать типизированные запросы/ответы (Request/Response)
  • Управлять ошибками через echo.HTTPError
  • Писать middleware, включая JWT-аутентификацию