Содержание урока · 14 разделов
- Выпускной проект gin: rest api трекера задач
- Техническое задание: что принимает приёмка
- Архитектура: слои, каталог и направление зависимостей
- Карта эндпоинтов: договор с клиентом
- Домен и контракт Store: пять методов вместо трёх
- Хендлеры: транспорт без бизнес-логики
- Цепочка звеньев: кто где стоит и почему
- Сервер и остановка: финал из урока 24
- Тесты проекта: те же слои, тот же httptest
- Финальный чеклист: из какого урока что пришло
- Восемь граблей выпускного проекта
- Песочница урока
- Коротко: что запомнить
- Вопросы и ответы
Выпускной проект gin: rest api трекера задач#
Проект gin, который мы собираем, называется todoapp: трекер личных задач с JSON-API, куда любой честно зарегистрированный пользователь ходит со своим токеном. Заметьте, что здесь нет ни одной новой библиотеки и ни одного нового механизма — и это главный сигнал зрелости: REST API собирается из тех же звеньев, биндингов и сентинелов, что вы носили по всем урокам, просто в правильном порядке и с правильными границами. Технически сервис живёт в одном модуле: пакет todo несёт домен и всю логику, пакет middleware — гвард и лимиты, пакет config — окружение, cmd/server/main — композиционный корень. Слой репозитория даём в двух реализациях: Postgres для боя и память для тестов. Задача урока — не «написать побольше кода», а показать каркас, который переживёт рост: когда появятся подзадачи, теги и напоминания, вы будете расширять слои, а не переписывать их. Всё, что мы не разжёвываем заново, — конверт, цепочка звеньев, тон хендлеров — берётся из предыдущих уроков по ссылкам: это не повтор, а сборка.
Техническое задание: что принимает приёмка#
- Аутентификация JWT: POST /api/v1/token выдаёт пару access (15 минут) и refresh (сутки) за логин и пароль; все маршруты задач — под гвардом Authorization Bearer из урока 15.
- CRUD задач: создание, список своих, чтение одной, переименование, удаление; чужие задачи не видны никогда — 404 вместо 403, чтобы не раскрывать существование чужих ресурсов.
- Валидация TodoInput тегами binding: заголовок обязателен, от 1 до 140 символов; ошибки валидации уходят клиенту кодом VALIDATION_FAILED с details по полям из урока 09.
- Один формат ошибок: Envelope с APIError{Code, Message, Details} из уроков 05 и 17, единый handleError, сентинелы ErrNotFound и ErrConflict в домене.
- Бизнес-правило сервиса: дубликат заголовка у одного владельца — 409 CONFLICT; в Postgres его продублирует уникальный индекс, в памяти — проход по списку.
- Защита от брутфорса: per-IP лимит из урока 18 на весь роутер, до аутентификации; 429 с Retry-After.
- Здоровье и остановка: /healthz без зависимостей, /readyz с ping-ом базы, graceful shutdown с дедлайном из урока 24, таймауты http.Server.
- Тесты без порта: 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+refresh | 200 + токены | 400, 401, 429 |
| POST /api/v1/todos | создать задачу | 201 + Todo | 400, 401, 409, 429 |
| GET /api/v1/todos | список своих задач | 200 + []Todo | 401, 429 |
| GET /api/v1/todos/:id | одна своя задача | 200 + Todo | 400, 401, 404, 429 |
| PATCH /api/v1/todos/:id | переименовать | 200 + Todo | 400, 401, 404, 409, 429 |
| DELETE /api/v1/todos/:id | удалить | 204 без тела | 400, 401, 404, 429 |
| GET /healthz | жив ли процесс | 200 | — |
| GET /readyz | готов ли к трафику | 200 / 503 | 503 при недоступной базе |
Домен и контракт 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 — ровно те четыре слова, которые делают его транспортным слоем проекта.
Собери путь запроса: 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-JSON | Envelope{Data, Error}, respondData и respondError |
| 08-логи | Logger на slog и Recovery в цепочке движка |
| 09-валидация | VALIDATION_FAILED с details по полям TodoInput |
| 10-группы | /api/v1 с звеном-гвардом и RegisterRoutes хендлера |
| 15-jwt | UserClaims, 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 — контракт с клиентом; несчастные кейсы в таблице не опциональны.
Карточки: итоговый проект
Выпускной экзамен ждёт вас в домашке ниже — соберите todoapp по этому уроку, доведите до зелёного go test ./... -race и пнул graceful shutdown ногой. В браузерной песочнице сайта сетевой код не исполняется, поэтому интерактивы урока — quiz, predict, fill, order и флешкарты, а запуск — только локально, как и весь курс gin. Дальше — другие курсы сайта: слои данных, воркеры, микросервисы; gin-канон, который вы собрали здесь в один сервис, станет фундаментом каждого из них. И последнее: сохраните репозиторий проекта — свой следующий pet-проект вы будете начинать с копии этого main.go.
Песочница урока
Редактируйте код и запускайте его прямо здесь — интерпретатор работает в браузере, ничего устанавливать не нужно. За каждый запуск — +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. Экзамен сдан, когда все семь пунктов закрыты без единого хендлера с бизнес-логикой внутри.
Личные заметкиПусто
Вопросы и ответы по уроку
Где взять полный код проекта?
Урок даёт каркас и все узловые фрагменты: домен, 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 не меняется.
Термины урока
Ключевые понятия из этого урока — короткие определения в глоссарии. Наведите на термин, чтобы вспомнить, откройте карточку для подробностей и ссылки на практику.
Сравните с альтернативами
Практический вопрос после первых уроков: а почему именно эта библиотека? Мы разобрали конкурентов по одним критериям, с кодом одной задачи рядом и персональным подбором под ваши приоритеты.
Изучайте дальше
Следующая ступень учебного пути
Echo: минималистичный быстрый фреймворк
Курс Echo для Go: маршруты, middleware, валидация, JWT, SSE, тестирование и деплой. 25 уроков с примерами и итоговым API-проектом.
- Строить REST API на Echo
- Использовать типизированные запросы/ответы (Request/Response)
- Управлять ошибками через echo.HTTPError
- Писать middleware, включая JWT-аутентификацию