Содержание урока · 14 разделов
- Мост из прошлого урока: соберём из уроков один проект
- rest api fiber: техническое задание проекта
- Контракт: эндпоинты, доступ и коды ответов
- Структура проекта: слои двадцать второго плюс auth
- Домен: заметки вместо задач
- JWT вместо стража: что такое токен и почему stateless
- Маршруты и защита: группа v1, лимитер на входе
- Конфигурация проекта: FromEnv пополняется JWT_SECRET
- Тесты проекта: таблица эндпоинтов становится таблицей кейсов
- Чек-лист готовности fiber проекта
- Итоги курса: двадцать пять уроков одним маршрутом
- Песочница урока
- Коротко: что запомнить
- Вопросы и ответы
Мост из прошлого урока: соберём из уроков один проект#
Начнём с того, чем закончили: «двадцать пятый урок — финальный: итоговый проект «REST API заметок на Fiber»: CRUD, валидация, JWT, лимиты — готовое мини-приложение. Логика прямая: сегодня мы научились измерять и понимать движок, завтра соберём из двадцати четырёх уроков один проект, где слои, тесты, аутентификация и деплой-артефакты встретятся в одном main.go — и прогоним его от go mod init до curl через nginx». Сегодня — тот самый завтра. Инвентаризация перед сборкой: слои и DI — урок двадцать второй; тесты без сети — двадцать первый; валидация адаптером — десятый урок StructValidator; лимиты — семнадцатый; единый ErrorHandler — четырнадцатый; конфигурация и сигналы — двадцатый; артефакт и окружение — двадцать третий. Нового будет ровно один блок — JWT-аутентификация: честная замена заглушке седьмого урока, потому что итоговый проект без реального входа — не итоговый проект. Правило сборки прежнее: инварианты канона — :8080, json-конверты, фабрика buildApp — не ломаются; то, что менялось раньше слоями, теперь меняется проектом целиком.
rest api fiber: техническое задание проекта#
Любой fiber проект начинается не с кода, а с контракта: какие эндпоинты существуют, кто их пускает и что они отвечают. Строим API заметок — сущность Note с полями id, title и body: задачи из канона выросли в заметки, добавилось тело, и это единственная вольность, зато она даст работе настоящую валидацию с двумя полями и лимитами. Схема авторизации: POST /api/v1/login выдаёт JWT-токен за пару логин-пароль (в упражнении учётка одна и хардкодится в конфиге), остальные маршруты под /api/v1 требуют токен в заголовке Authorization. CRUD: список, создание, чтение по id, обновление, удаление. Плюс /healthz — открытый пульс для деплой-обвязки двадцать третьего урока: health-чек прокси и оркестратора не должен требовать токен. Каждый маршрут в таблице ниже подписан кодами успеха и отказов — и это не бюрократия: именно эта таблица превращается в table-driven тесты двадцать первого урока почти механически. Заметьте и версию /v1 в путях — пятнадцатый урок настаивал, что версия API закладывается с первого дня, и итоговый проект слушается. Таков контракт; дальше — структура, которая его выдержит.
Контракт: эндпоинты, доступ и коды ответов#
| Метод и путь | Доступ | Успех | Отказы |
|---|---|---|---|
| POST /api/v1/login | лимитер: 10 попыток в минуту с IP | 200 — {"token": "…"} | 401 неверные данные, 429 слишком часто |
| GET /api/v1/notes | JWT | 200 — {"items": [...]} | 401 без токена |
| POST /api/v1/notes | JWT + валидация | 201 — заметка | 400 битый JSON, 422 поля не прошли |
| GET /api/v1/notes/:id | JWT | 200 — заметка | 404 нет такой |
| PUT /api/v1/notes/:id | JWT + валидация | 200 — обновлённая | 400 битый JSON, 422, 404 |
| DELETE /api/v1/notes/:id | JWT | 204 без тела | 404 нет такой |
| GET /healthz | открыт всем | 200 — {"status": "ok"} | — |
Структура проекта: слои двадцать второго плюс auth#
География проекта — прямое продолжение структурного урока: три слоя handlers → service → repository с внутренним пакетом internal, композиционный корень buildApp, конфиг в internal/config. Единственная новая папка — internal/auth: там живут выдача и проверка JWT, и она выделена не ради красоты, а по должностной инструкции — auth знает про токены, но не знает про HTTP-конверты, как сервис не знает про статусы. Стена internal работает как раньше: чужой модуль не импортирует myapi/internal/..., а направленность слоёв держим конструкторами — auth и repository не оглядываются вверх. Обратите внимание, что хендлеры остались тонкими: разбор fiber.Ctx на входе, вызов сервиса, конверт на выходе; вся аутентификация вынесена из хендлеров в middleware, как учил седьмой урок, — CRUD-хендлер вообще не подозревает о существовании токенов, он просто вызывается уже проверенным. Тестовая база тоже наследуется: buildApp остаётся фабрикой, каждый тест собирает свежий инстанс, и поэтому таблица эндпоинтов выше превращается в таблицу тестовых кейсов без переписывания архитектуры под тесты — признак того, что структура хорошая.
Канон: дерево проекта
GOnotes-api/
├── go.mod // module notes-api; три зависимости
├── cmd/
│ └── api/
│ └── main.go // композиционный корень: FromEnv + buildApp + Listen
├── internal/
│ ├── config/
│ │ └── config.go // Port, ReadTimeout, JWTSecret, TokenTTL, User, Password
│ ├── auth/
│ │ └── jwt.go // MakeToken и RequireJWT поверх golang-jwt
│ ├── service/
│ │ └── note.go // модель Note, шов NoteRepo, правила и ошибки домена
│ ├── repository/
│ │ └── memory.go // MemoryRepo: слайс под мьютексом, реализует NoteRepo
│ └── handlers/
│ ├── handlers.go // fiber.New, ErrorHandler, StructValidator, RegisterRoutes
│ └── note.go // CRUD-хендлеры: вызывают сервис, не знают про JWT
└── handlers_note_test.go // table-driven тесты через app.TestДерево читается сверху вниз как маршрут сборки: main — единственный, кто видит всё; config — единственный, кто читает os.Getenv; auth — единственный, кто знает про jwt; service — единственный, кто знает правила; repository — единственный, кто знает хранение; handlers — единственные, кто знает fiber-контекст запроса. Каждая единственность — шов из структурного урока: заменить память на SQL — один файл; поменять JWT на другое — один файл; переименовать поле ответа — один слой. Имя модуля в go.mod — notes-api, и все внутренние импорты начинаются с notes-api/internal/...: путь internal валиден только внутри дерева модуля, стена бесплатна. Тест лежит в корне рядом с go.mod — он тестирует buildApp целиком, а не отдельный пакет, и потому имеет право.
Домен: заметки вместо задач#
Слой service — то место, где REST API становится не генератором статусов, а приложением с правилами. Модель Note несёт три поля и json-теги при них: ID, Title, Body — форму ответа видно прямо в структуре, как учил девятый урок. Контракт хранения NoteRepo объявлен у потребителя — тот же шов двадцать второго урока: List, Create, Get, Update, Delete — пять методов, которых требует наша таблица эндпоинтов. Ошибки домена — именованные sentinel-ы: ErrNoteNotFound для чтения и удаления мимо существующих, ErrEmptyTitle для пустого названия на входе сервиса — хотя в проекте на входе стоит и валидатор, домен обязан защищаться сам: валидация — про форму запроса, инвариант — про правило. И вот правило соединения двух проверок, которое стоит проговорить раз и навсегда: validator проверяет, что title — строка длиной от 1 до 200, а body не длиннее 2000, ещё до входа в сервис; сервис дополнительно возвращает ErrEmptyTitle, если его вызвали напрямую — из другого сервиса, из воркера, из теста. Транспортный слой переводит доменные ошибки в статусы лестницей из четырнадцатого урока: ErrNoteNotFound → 404 через fiber.NewError, ошибки валидации → 422, битый JSON → 400. Никто не пересекает чужих этажей: сервис не знает про 404, хендлер не знает про мьютекс.
Канон: internal/service/note.go
GOpackage service
import (
"context"
"errors"
)
// Note — модель домена; json-теги задают форму ответа
type Note struct {
ID int `json:"id"`
Title string `json:"title"`
Body string `json:"body"`
}
// NoteRepo — шов: контракт хранения, объявленный потребителем
type NoteRepo interface {
List(ctx context.Context) ([]Note, error)
Create(ctx context.Context, n Note) (Note, error)
Get(ctx context.Context, id int) (Note, error)
Update(ctx context.Context, id int, n Note) (Note, error)
Delete(ctx context.Context, id int) error
}
// Ошибки домена: транспорт переведёт их в статусы
var (
ErrNoteNotFound = errors.New("заметка не найдена")
ErrEmptyTitle = errors.New("заголовок не может быть пустым")
)
// Create — правило: title обязателен, независимо от валидатора
func (s *NoteService) Create(ctx context.Context, n Note) (Note, error) {
if n.Title == "" {
return Note{}, ErrEmptyTitle
}
return s.repo.Create(ctx, n)
}Три решения, на которых стоит файл. Первое: json-теги с backtick-ами живут при модели — конверты ответов строятся из структуры, а не из карт, и форма {"id":…,"title":…,"body":…} гарантирована компилятором. Второе: интерфейс NoteRepo требует пять методов — минимальный набор для таблицы эндпоинтов; методы объявлены на потребителе, MemoryRepo в repository реализует их, не импортируя контракт, — совместимость Go сверит по сигнатурам. Третье: ErrEmptyTitle дублирует валидатор не по недосмотру, а по архитектуре — инвариант домена должен держаться без HTTP; когда-нибудь заметки создаст ночной воркер, и правило останется работать. Заметьте: здесь нет ни одного слова про fiber, JWT или лимиты — сервис пахнет только доменом, и это тот самый тест «в файле нет слова fiber» из структурного урока.
JWT вместо стража: что такое токен и почему stateless#
Страж седьмого урока проверял статический ключ: один секрет на всех, отозвать нельзя, узнать «кто» — тоже. JWT — JSON Web Token — решает обе проблемы без базы сессий. Токен — это три блока base64url через точку: заголовок с алгоритмом, набор claims — подтверждаемых полей вроде sub и exp — и подпись. Сервер выдаёт токен на /login, подписав его секретным ключом, дальше просто проверяет подпись и сроки: хранить выданные токены не нужно — подпись сама гарантирует, что токен выдавали мы и его никто не подменил. Это и называется stateless-аутентификацией: состояние — в самом токене, а не в памяти сервера, поэтому и масштабируется JWT на несколько инстансов без sticky-сессий. Проверка обязана включать три пункта, и пропуск любого — уязвимость: подпись верна (секрет совпадает), алгоритм именно тот, что мы выдаём (иначе атакующий подсунет алгоритм без подписи — классика jwt-атак), срок exp не вышел (иначе токен вечен). Библиотека — golang-jwt/jwt/v5, стандарт де-факто экосистемы; берём v5-модуль с jwt.NewWithClaims и jwt.ParseWithClaims. Секрет придёт из окружения в JWTSecret конфига — не в исходники: урок о деплое уже объяснил, где живут секреты, — файл 0600, --env-file, и никогда git.
Канон: internal/auth/jwt.go — выдача и проверка
GOpackage auth
import (
"errors"
"time"
"github.com/gofiber/fiber/v3"
"github.com/golang-jwt/jwt/v5"
)
// MakeToken подписывает токен HS256 с subject и сроком жизни; экспортиру —
// пакет auth импортируют хендлеры
func MakeToken(secret, user string, ttl time.Duration) (string, error) {
claims := jwt.RegisteredClaims{
Subject: user,
ExpiresAt: jwt.NewNumericDate(time.Now().Add(ttl)),
}
return jwt.NewWithClaims(jwt.SigningMethodHS256, claims).
SignedString([]byte(secret))
}
// RequireJWT — middleware: пустить дальше только с живым токеном
func RequireJWT(secret string) fiber.Handler {
return func(c fiber.Ctx) error {
auth := c.Get("Authorization") // "Bearer eyJhbGci..."
if len(auth) <= len("Bearer ") {
return fiber.NewError(fiber.StatusUnauthorized, "нужен токен в заголовке Authorization")
}
token, err := jwt.ParseWithClaims(auth[len("Bearer "):], &jwt.RegisteredClaims{},
func(t *jwt.Token) (any, error) {
// первый барьер: алгоритм должен быть HMAC-подписью
if _, ok := t.Method.(*jwt.SigningMethodHMAC); !ok {
return nil, errors.New("неожиданный алгоритм подписи")
}
return []byte(secret), nil
})
if err != nil || !token.Valid {
return fiber.NewError(fiber.StatusUnauthorized, "токен недействителен или истёк")
}
return c.Next()
}
}Две функции — два направления одного договора. MakeToken собирает RegisteredClaims с subject и exp и подписывает методом HS256 — HMAC поверх SHA-256; SignedString принимает байтовый секрет, и он же понадобится при проверке, поэтому секрет один на выдачу и приём. RequireJWT — фабрика в каноне седьмого урока: принимает секрет, возвращает fiber.Handler, и секрет живёт в замыкании, не в глобальной переменной. Проверка по шагам: заголовок Authorization с префиксом Bearer, срез после префикса — сам токен; jwt.ParseWithClaims разбирает и проверяет сразу и подпись, и exp, возвращая ошибку при любом провале; callback с проверкой t.Method — тот самый барьер от подмены алгоритма, ключевой for v5: функция ключа обязана сама удостовериться, что метод — HMAC, прежде чем отдать секрет. token.Valid — финальный вердикт библиотеки. Отказ — fiber.NewError(401, ...): ошибка улетит в единый ErrorHandler проекта, и клиент получит честный JSON, а не голый text/plain. Хендлеры CRUD этот слой не видят вовсе: их вызывают уже после c.Next().
Проверьте себя: что проверяет RequireJWT
Клиент прислал Authorization: Bearer eyJ... Что именно проверяет RequireJWT перед c.Next()?
Соберите RequireJWT по памяти
Вставьте пропуски так, чтобы middleware проверял токен по всем трём пунктам договора.
Маршруты и защита: группа v1, лимитер на входе#
Теперь соединим защиту и маршруты, и здесь итоговый проект сводит два урока в одну боевую пару. Логин-эндпоинт — публичный, и именно поэтому он под лимитером семнадцатого урока: перебор паролей — первый сценарий, который rate limiting обязан закрыть, поэтому на /api/v1/login вешается limiter.New с Max 10 и Expiration в минуту, ключ — IP клиента: одиннадцатая попытка в окне получит 429 с Retry-After, о котором семнадцатый урок рассказывал. CRUD-ветка — под RequireJWT: группа r.Group("/api/v1", auth.RequireJWT(cfg.JWTSecret)) прячет проверку на входе группы, и все пять маршрутов заметок наследуют её автоматически — тот же рисунок общих слоёв, что в группах пятнадцатого урока. Отдельная тонкость: лимитер не стоит на всей группе /api/v1 — аутентифицированный CRUD не должен страдать от лимита логина, защита прикладывается точечно. /healthz регистрируется на корне приложения до всяких групп: пульс для nginx и docker не требует токен, иначе деплой-инфраструктура сочтёт живое приложение мёртвым. ErrorHandler в fiber.Config — единая точка отказов четырнадцатого урока: *fiber.Error отдаёт свой код, доменные sentinel-ы переводятся лестницей errors.Is/As, формат один для всего API. StructValidator в конфиге — адаптер десятого урока: без него Bind молча пропускал бы проверку, помните — слот пуст, вилка не воткнута.
Канон: buildApp и RegisterRoutes проекта
GO// internal/handlers/handlers.go — сборка и маршруты проекта
package handlers
import (
"errors"
"time"
"github.com/go-playground/validator/v10"
"github.com/gofiber/fiber/v3"
"github.com/gofiber/fiber/v3/middleware/limiter"
"notes-api/internal/auth"
"notes-api/internal/config"
"notes-api/internal/service"
)
func BuildApp(h *NoteHandler, cfg config.Config) *fiber.App {
app := fiber.New(fiber.Config{
ReadTimeout: cfg.ReadTimeout,
BodyLimit: 64 * 1024, // заметки, а не файлы
StructValidator: &validatorAdapter{validate: validator.New()},
ErrorHandler: func(c fiber.Ctx, err error) error {
code := fiber.StatusInternalServerError
msg := err.Error()
var fe *fiber.Error
switch {
case errors.Is(err, service.ErrNoteNotFound):
code, msg = fiber.StatusNotFound, err.Error()
case errors.As(err, &fe): // *fiber.Error — как в 14-м уроке
code, msg = fe.Code, fe.Message
}
return c.Status(code).JSON(fiber.Map{"error": msg, "code": code})
},
})
RegisterRoutes(app, h)
return app
}
func RegisterRoutes(r fiber.Router, h *NoteHandler) {
// пульс для прокси и docker: без токена и без лимитов
r.Get("/healthz", func(c fiber.Ctx) error {
return c.JSON(fiber.Map{"status": "ok"})
})
// публичный вход под лимитом: 11-я попытка в окне — 429
r.Post("/api/v1/login",
limiter.New(limiter.Config{Max: 10, Expiration: time.Minute}),
h.Login)
// вся CRUD-ветка за стражем JWT
api := r.Group("/api/v1", auth.RequireJWT(h.cfg.JWTSecret))
api.Get("/notes", h.List)
api.Post("/notes", h.Create)
api.Get("/notes/:id", h.Get)
api.Put("/notes/:id", h.Update)
api.Delete("/notes/:id", h.Delete)
}Четыре решения в одном файле. Первое: ErrorHandler — лестница четырнадцатого урока в боевом исполнении: errors.Is распознаёт доменный ErrNoteNotFound и назначает 404, errors.As ловит *fiber.Error — их код и сообщение; всё прочее — 500, и ни одна ветка не показывает клиенту внутренности: тело строится из нашей пары error/code. Второе: BodyLimit 64 KiB — заметкам хватит, и это осознанный предохранитель вместо дефолтных 4 MiB: гигантские тела пусть едут в загрузки одиннадцатого урока. Третье: лимитер стоит точечно на login — Max 10 попыток в минуту с ключом по IP; CRUD-ветка лимит не наследует, потому что сидит в отдельной группе. Четвёртое: RequireJWT повешен вторым аргументом Group — все пять маршрутов под защитой, и новый маршрут ветки получит её автоматически. Обратите внимание на форму, унаследованную от двадцать второго урока: BuildApp(h, cfg) и RegisterRoutes(r fiber.Router, h) — маршруты живут рядом с хендлерами, корень только решает, куда их вешать; хендлер держит и сервис, и конфиг (NewNoteHandler(svc, cfg)), потому что login без конфига не собрать. /healthz открыт намеренно: деплой-обвязка двадцать третьего урока проверяет пульс без токена.
Что увидит клиент при двойном удалении?
Прочитайте код и выберите, что он выведет:
// ErrorHandler проекта: *fiber.Error → c.Status(code).JSON(fiber.Map{"error": msg, "code": code})
// DELETE /api/v1/notes/42 — первая попытка: заметка существовала
// DELETE /api/v1/notes/42 — вторая попытка: уже удалена
// хендлер Delete:
// if err := svc.Delete(ctx, 42); err != nil { return err } // ErrNoteNotFound
// return c.SendStatus(fiber.StatusNoContent)Конфигурация проекта: FromEnv пополняется JWT_SECRET#
Конфигурация проекта собирается по канону двадцатого урока без единого отклонения: os.Getenv вызывается один раз, в одном месте — internal/config.FromEnv, — и раздаёт значения вниз параметрами. Поля структуры Config пополнились четырьмя новыми: JWTSecret — секрет подписи, TokenTTL — срок жизни токена (дефолт — час: короткий срок и есть защита при утечке), User и Password — единственная учётка упражнения для /login. Переменные окружения канона: PORT и FIBER_READ_TIMEOUT из двадцатого урока плюс JWT_SECRET, JWT_TTL, LOGIN_USER, LOGIN_PASSWORD — ровно те, что деплой-урок подкладывает через --env-file или EnvironmentFile юнита. Каждый дефолт проверяется тестом t.Setenv из двадцать первого урока: подставили мусор — получили дефолт, а не тихо сломанный сервис. И главный гигиенический принцип: JWTSecret — не глобальная переменная и не константа в исходниках, а поле структуры, которое FromEnv наполнил, buildApp получила и раздала конструкторам — auth.RequireJWT(cfg.JWTSecret). Так проект читает окружение так же, как канон на :8080, — только переменных стало больше.
Канон: internal/config и cmd/api/main.go
GO// internal/config/config.go — единственный читатель os.Getenv
type Config struct {
Port string // PORT, дефолт "8080"
ReadTimeout time.Duration // FIBER_READ_TIMEOUT, дефолт 5s
JWTSecret string // JWT_SECRET, дефолт "dev-secret" — только для дева!
TokenTTL time.Duration // JWT_TTL, дефолт 1h
User string // LOGIN_USER, дефолт "notes"
Password string // LOGIN_PASSWORD, дефолт "s3cret"
}
// cmd/api/main.go — композиционный корень финального проекта
func main() {
cfg := config.FromEnv()
repo := repository.NewMemoryRepo()
svc := service.NewNoteService(repo)
h := handlers.NewNoteHandler(svc, cfg)
go func() {
log.Println(http.ListenAndServe("localhost:6060", nil)) // pprof из 24
}()
app := handlers.BuildApp(h, cfg)
log.Fatal(app.Listen(":" + cfg.Port))
}Композиционный корень проекта — три конструктора и вызов фабрики, ровно как обещал структурный урок: repo → svc → h → app. FromEnv — единственный, кто трогает окружение; BuildApp принимает хендлер и конфиг и больше ничего не создаёт — зависимости спускаются вниз, ни одна не добывается сама. Хендлер получает и сервис, и конфиг, потому что login сверяет учётку и подписывает токен — без cfg ему не обойтись. JWTSecret с дефолтом dev-secret — честная оговорка: в деве удобно, но прод обязан задать переменную, и деплой-урок уже показал, как это делается файлом окружения; тест на дефолт — t.Setenv("JWT_SECRET", "") и проверка, что FromEnv не взял пустоту как значение. pprof-сервер из урока о производительности поселился рядом как штатная часть корня — тридцать секунд выборки под нагрузкой когда-нибудь сэкономят вам вечер. Обратите внимание на строку Listen: порт приезжает из конфига, канон :8080 — это дефолт, а не запиканный литерал.
Проверьте себя: почему секрет не глобальная переменная
Коллега предлагает упростить: var JWTSecret = os.Getenv("JWT_SECRET") в пакете auth, без конфига и конструкторов. Почему это регрессия?
Тесты проекта: таблица эндпоинтов становится таблицей кейсов#
Самое приятное в финальном проекте — превращение контракта в проверку. Таблица эндпоинтов из начала урока почти дословно раскладывается на table-driven кейсы двадцать первого урока: строка «GET /api/v1/notes, отказ 401 без токена» — это тест-кейс с построением запроса без заголовка и утверждением StatusCode == 401. Полный набор проекта: без токена — 401 на всей ветке; с битым токеном — тоже 401; login с верной парой — 200 и токен в теле; login с неверной — 401; POST с битым JSON — 400; POST с пустым title — 422 от валидатора; POST корректный — 201; GET списка — 200 и конверт items; GET чужого id — 404; PUT и DELETE — по контракту, включая второй DELETE, который вернёт 404. Каждый кейс собирает свежий buildApp — состояние не течёт между тестами, и порядок их выполнения не важен. Валидация в тестах проверяется с двух сторон: статус 422 и тело с упоминанием поля — адаптер десятого урока возвращает fiber.NewError с текстом «поля заполнены неверно», и ErrorHandler проекта заворачивает его в наш формат. Токен в тестах получается честно: сначала POST /api/v1/login, из ответа берётся token, и он же идёт в заголовки — никаких магических констант, тест проходит тот же путь, что и клиент.
Канон: table-driven тест CRUD-ветки
GOfunc TestNotesAPI(t *testing.T) {
tests := []struct {
name string
token bool // слать ли Authorization
method string
path string
body string
want int
}{
{"без токена: список закрыт", false, "GET", "/api/v1/notes", "", 401},
{"без токена: создание закрыто", false, "POST", "/api/v1/notes", "{}", 401},
{"битый JSON", true, "POST", "/api/v1/notes", "{oops", 400},
{"пустой title — валидатор", true, "POST", "/api/v1/notes", `{"title":""}`, 422},
{"создание", true, "POST", "/api/v1/notes", `{"title":"первая","body":"текст"}`, 201},
{"чужой id", true, "GET", "/api/v1/notes/999", "", 404},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
app := buildApp(testConfig(t)) // свежий инстанс на кейс
req := httptest.NewRequest(tt.method, tt.path, strings.NewReader(tt.body))
if tt.token {
tok := loginAndGetToken(t, app) // честный POST /login
req.Header.Set("Authorization", "Bearer "+tok)
}
resp, err := app.Test(req)
if err != nil {
t.Fatalf("app.Test: %v", err)
}
if resp.StatusCode != tt.want {
t.Fatalf("статус: got %d, want %d", resp.StatusCode, tt.want)
}
})
}
}Скелет — из двадцать первого урока: buildApp на каждый кейс, httptest.NewRequest, app.Test, утверждение статуса. Поля кейса читаются как строка контракта: метод, путь, тело, ожидание — и добавление нового эндпоинта начинается со строки в этой таблице, а не с нового тестового файла. loginAndGetToken — маленький помощник, который сам POST-ит /login и вытаскивает token из ответа: тест пользуется фронтом-дверью, как реальный клиент, и если однажды формат токена поменяется, тесты заметят это раньше пользователей. Кейс «битый JSON» ожидает 400 — это ветка Bind; кейс с пустым title — 422, контракт десятого урока; 404 на /notes/999 — ErrNoteNotFound через ErrorHandler. Обратите внимание: ни один тест не слушает порт — app.Test прогоняет полный стек в памяти, поэтому таблица из шести кейсов выполняется за миллисекунды и её не страшно запускать на каждый коммит.
Прогон curl-сценария от login до списка
Прочитайте код и выберите, что он выведет:
# Приложение собрано и слушает :8080. Учётка: notes / s3cret
# 1) GET /api/v1/notes
# 2) POST /api/v1/login {"user":"notes","password":"s3cret"}
# 3) POST /api/v1/notes Authorization: Bearer <token из шага 2>
# {"title":"первая","body":"привет"}
# 4) GET /api/v1/notes Authorization: Bearer <token из шага 2>Соберите порядок сборки проекта
Расставьте шаги, как проект строится реально: от пустой папки до зелёных тестов и бинарника.
Строки
Ваша программа
Нажимайте на строки слева, чтобы собрать программу
Чек-лист готовности fiber проекта#
- Контракт в таблице: каждый эндпоинт знает свой метод, путь, доступ и коды — до первой строчки кода.
- Слои и шов: service не пахнет fiber, repository не знает сервис, auth не знает статусы; всё соединено конструкторами в buildApp.
- JWT: подпись HS256, проверка алгоритма в callback, exp из RegisteredClaims, секрет — из окружения, срок — короткий.
- Валидация: адаптер в StructValidator, теги required/min/max, 422 единым форматом через ErrorHandler.
- Защита: limiter 10/мин с IP на /login, JWT на группе /api/v1, /healthz открыт для деплой-инфраструктуры.
- Конфигурация: единственный FromEnv, дефолты проверены t.Setenv-тестами, секреты не в git.
- Тесты: table-driven поверх app.Test, свежий buildApp на кейс, токен через честный POST /login.
- Артефакт: go build с CGO_ENABLED=0, multi-stage Dockerfile, env-file с JWT_SECRET — рецепт урока о деплое.
Итоги курса: двадцать пять уроков одним маршрутом#
Оглянемся на маршрут, который прошли. Мы начали с вопроса «что такое Fiber и чем он отличается от Gin», подняли первый сервер на :8080, научились маршрутизировать и читать запрос, встраивали и писали middleware, закрыли JSON и валидацию, формы и cookie, шаблоны и ошибки, группы и WebSocket, лимиты и кэш, логи и конфигурацию, тесты и структуру, деплой и производительность — и сегодня собрали всё в rest api fiber с CRUD-заметками, JWT, лимитами и тестами. Если оглядеться по дереву проекта, каждый файл откликнется на урок: config.go — двадцатый, auth/jwt.go — седьмой, выросший, service/note.go — двадцать второй, handlers — четырнадцатый и десятый, тесты — двадцать первый, Dockerfile — двадцать третий, бенчмарки — двадцать четвёртый. Честное слово про песочницу: fiber-приложение в браузерном интерпретаторе сайта не запускается — runner-ов в уроках Fiber нет с первого урока, а интерактивы здесь quiz, predict, fill, order и флешкарты; финальный проект собирается локально, и в этом его честность: это настоящий fiber проект, который можно завернуть в контейнер и показать работодателю. Куда дальше: курс Gin покажет те же принципы на net/http-фундаменте — сравнение станет практикой, а не таблицей; курс go-advanced углубит Go вне HTTP — горутины, каналы, graceful shutdown в рабочем исполнении. Домашнее задание — это и есть выпускной экзамен.
Карточки: rest api fiber — итоговый проект
Песочница урока
Редактируйте код и запускайте его прямо здесь — интерпретатор работает в браузере, ничего устанавливать не нужно. За каждый запуск — +2 XP.
Запустите код, чтобы увидеть вывод.
Коротко: что запомнить
- Итоговый rest api fiber собран из канонов курса: слои и buildApp — двадцать второй урок, тесты через app.Test — двадцать первый, ErrorHandler и 422 — четырнадцатый и десятый, limiter на login — семнадцатый, конфиг — двадцатый, деплой-артефакты — двадцать третий.
- JWT пришёл на смену стражу с golang-jwt/jwt/v5: MakeToken подписывает RegisteredClaims по HS256, RequireJWT проверяет подпись, алгоритм и exp; аутентификация stateless — список токенов не хранится.
- Контракт проекта — таблица эндпоинтов: login под лимитом 10/мин с IP, CRUD под JWT-группой /api/v1, /healthz открыт; строка таблицы = строка table-driven теста.
- Единый ErrorHandler переводит *fiber.Error и доменные sentinel-ы в формат {"code", "error"} — и json сортирует ключи карт по алфавиту: контракт порядка полей держит только структура.
- FromEnv остаётся единственным читателем окружения: PORT, FIBER_READ_TIMEOUT, JWT_SECRET, JWT_TTL, LOGIN_USER, LOGIN_PASSWORD — секреты в env-file, дефолты под t.Setenv; сборка — от go mod init до зелёного go test ./... и бинарника.
Домашнее задание
Выпускной экзамен: соберите проект по шагам урока и доведите его до curl-сценария. Шаг первый: go mod init notes-api, три зависимости, дерево каталогов из канона. Шаг второй: внутренний config с шестью переменными и тестами t.Setenv на каждый дефолт. Шаг третий: service с Note, шовом NoteRepo и sentinel-ами; repository-память под мьютексом; auth с MakeToken и RequireJWT. Шаг четвёртый: handlers с ErrorHandler-форматом {error, code}, StructValidator-адаптером и RegisterRoutes из канона; POST /api/v1/login под limiter.New(Max: 10, Expiration: time.Minute). Шаг пятый: table-driven тест из шести кейсов урока плюс кейс «второй DELETE возвращает 404» и кейс «11-й login подряд — 429». Шаг шестой: прогоните curl-сценарий из predict-блока на живом сервере и сверьте статусы. Бонус: добавьте PUT /api/v1/notes/:id с валидацией и тестом на 422, затем соберите multi-stage Dockerfile из урока о деплое с env-file, где JWT_SECRET настоящий — и пройдите свой же чек-лист целиком.
Личные заметкиПусто
Вопросы и ответы по уроку
Почему для JWT выбран HS256, а не RS256?
HS256 — симметричная подпись: один секрет и на выдаче, и на проверке, — достаточно для одного сервиса, который и выдаёт, и проверяет токены сам. RS256 — асимметричная: приватным ключом подписывает сервис аутентификации, публичным проверяют все остальные, — она нужна, когда токены выдаёт один компонент, а потребляют многие. В проекте выдатель и проверяющий совпадают, поэтому HS256 проще и честнее; переход на RS256 — замена SigningMethod и ключей в одном файле internal/auth.
Что делать, если нужно «отозвать» JWT до истечения срока?
Нативного отзыва у stateless-токена нет — это плата за отсутствие списка сессий. Практика: короткий TTL (час в проекте), refresh-токены для продления, а для экстренного отзыва — чёрный список отозванных jti-идентификаторов в Redis с TTL до конца жизни токена; проверка чёрного списка добавляется в RequireJWT одним блоком перед c.Next(). Если отзывов много и они критичны — возможно, вам нужны серверные сессии из двенадцатого урока, а не JWT.
Почему POST /api/v1/notes с пустым title возвращает 422, а сервис всё равно проверяет title на пустоту?
Это два разных слоя защиты. Валидатор проверяет форму запроса — пустое поле не прошло правила min — и даёт клиенту быстрый ответ 422. Sentinel ErrEmptyTitle в сервисе защищает инвариант домена от всех остальных путей вызова: другой сервис, ночной воркер, будущий рефакторинг, который забудет про валидатор. Дублирование здесь не расточительство, а граница ответственности: HTTP-форма и доменное правило живут в разных слоях и проверяются независимо.
Как расширить проект до нескольких пользователей без переписывания?
Схема рассчитана на рост: в RequireClaims или claims уже есть subject — поставьте туда идентификатор пользователя вместо хардкодной учётки, заведите таблицу users рядом с notes, добавьте поле AuthorID в Note и фильтр по нему в методы NoteRepo. RequireJWT уже вытащил claims — передача идентификатора в хендлер через c.Locals описана в седьмом уроке. Ни один слой выше auth не меняется: шов и слои проекта существуют именно для таких расширений.
Термины урока
Ключевые понятия из этого урока — короткие определения в глоссарии. Наведите на термин, чтобы вспомнить, откройте карточку для подробностей и ссылки на практику.
Сравните с альтернативами
Практический вопрос после первых уроков: а почему именно эта библиотека? Мы разобрали конкурентов по одним критериям, с кодом одной задачи рядом и персональным подбором под ваши приоритеты.
Изучайте дальше
Следующая ступень учебного пути
Gin: самый популярный веб-фреймворк Go
Курс Gin для Go: маршрутизация, binding, валидация, middleware, JWT, WebSocket, тесты и деплой. 25 уроков с кодом и итоговым REST API.
- Уверенно владеть gin.Engine: маршруты, группы, версии
- Привязывать запросы к структурам (ShouldBindJSON и др.)
- Писать middleware и использовать встроенные
- Строить аутентификацию на JWT