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

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

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

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

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

Это финал. Двадцать четыре урока мы собирали детали — маршруты и middleware, JSON и валидацию, тесты и деплой, а в прошлый раз разобрались, за счёт чего движок быстр и как это измерять, — и настал день сложить их в один работающий прибор. Тема «rest api fiber» — итоговый проект: REST API заметок на Fiber, fiber проект, в котором CRUD живёт в слоях двадцать второго урока, валидация — в адаптере десятого, аутентификация выросла из стража седьмого урока в JWT, лимиты защитили вход семнадцатым, ошибки ушли в единый ErrorHandler четырнадцатого, а конфигурация читается FromEnv-ом двадцатого и деплоится артефактами двадцать третьего. Сборка api на Go в таком объёме — это не новые знания, а дисциплина соединения: каждый слой уже был отрепетирован отдельно, сегодня мы встретим их в одном main.go и прогоним проект от go mod init до curl через тесты. Все факты сверены с исходниками fiber v3.5.0 и канонами уроков курса; ссылки на первоисточники — по ходу сборки.

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

Мост из прошлого урока: соберём из уроков один проект#

Начнём с того, чем закончили: «двадцать пятый урок — финальный: итоговый проект «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 попыток в минуту с IP200 — {"token": "…"}401 неверные данные, 429 слишком часто
GET /api/v1/notesJWT200 — {"items": [...]}401 без токена
POST /api/v1/notesJWT + валидация201 — заметка400 битый JSON, 422 поля не прошли
GET /api/v1/notes/:idJWT200 — заметка404 нет такой
PUT /api/v1/notes/:idJWT + валидация200 — обновлённая400 битый JSON, 422, 404
DELETE /api/v1/notes/:idJWT204 без тела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 остаётся фабрикой, каждый тест собирает свежий инстанс, и поэтому таблица эндпоинтов выше превращается в таблицу тестовых кейсов без переписывания архитектуры под тесты — признак того, что структура хорошая.

Канон: дерево проекта

GO
notes-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

GO
package 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 — выдача и проверка

GO
package 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 проверял токен по всем трём пунктам договора.

func RequireJWT(secret string) fiber.Handler { return func(c fiber.Ctx) error { auth := c.Get("") // "Bearer eyJhbGci..." if len(auth) <= len("Bearer ") { return fiber.(fiber.StatusUnauthorized, "нужен токен") } token, err := jwt.(auth[len("Bearer "):], &jwt.RegisteredClaims{}, func(t *jwt.Token) (any, error) { if _, ok := t.Method.(*jwt.); !ok { return nil, errors.New("неожиданный алгоритм") } return []byte(secret), nil }) if err != nil || !token.Valid { return fiber.NewError(fiber.StatusUnauthorized, "токен недействителен") } return c.Next() } }

Маршруты и защита: группа 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-ветки

GO
func 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 — итоговый проект

Термин

JWT и HS256

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

Токен = header.payload.signature; MakeToken подписывает RegisteredClaims (sub, exp) через jwt.NewWithClaims(SigningMethodHS256). SignedString — байтовый секрет, один на выдачу и приём.

1 / 6

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

Редактируйте код и запускайте его прямо здесь — интерпретатор работает в браузере, ничего устанавливать не нужно. За каждый запуск — +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 настоящий — и пройдите свой же чек-лист целиком.

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

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

Почему для 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 не меняется: шов и слои проекта существуют именно для таких расширений.

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

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

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

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

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

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

Gin: самый популярный веб-фреймворк Go

Курс Gin для Go: маршрутизация, binding, валидация, middleware, JWT, WebSocket, тесты и деплой. 25 уроков с кодом и итоговым REST API.

  • Уверенно владеть gin.Engine: маршруты, группы, версии
  • Привязывать запросы к структурам (ShouldBindJSON и др.)
  • Писать middleware и использовать встроенные
  • Строить аутентификацию на JWT