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

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

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

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

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

Итоговый проект по chi собирает весь курс в один работающий каркас — REST API записной книжки, где у заметок есть id, title, body и updated_at. Дальше без новых сущностей: структура по слоям, полный стек middleware, тесты на роутер целиком и graceful shutdown — каждый элемент вы уже собирали руками, здесь он встаёт на своё место.

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

Первый сервис, который я собирал на chi, жил одним файлом: main, хендлеры, запросы к базе и рендер ответов в одной куче. Пока роутов было три, это терпело. На пятом хендлере правка в списке заметок начала ронять создание, curl по всем маршрутам перед релизом стал ритуалом, а тест написать было негде — функция совмещала маршрутизацию, хранилище и транспорт в одной сигнатуре. Итоговый проект по chi — REST API записной книжки — устроен ровно наоборот: те же возможности, что и весь курс, но разложенные по слоям, где каждый элемент вы уже собирали руками. Маршруты и методы — из урока про маршрутизацию, стек middleware — из уроков про встроенные слои и их порядок, единые ошибки — из урока про errorHandler, тесты — из урока про httptest. Новых сущностей здесь нет: каркас собирается из деталей, которые уже лежат на столе, и собирается один раз — чтобы жить дальше в ваших проектах, а не умереть в конце курса.

REST API на chi: структура по слоям#

Дерево проекта короткое: cmd/api/main.go, внутренний пакет internal/notes с тремя файлами — handler.go, service.go, store.go — и тесты рядом. main.go только собирает и поднимает: читает флаги, создаёт хранилище, строит роутер, кладёт его в http.Server и обслуживает graceful shutdown. handler.go — транспорт: декодирует тело, достаёт chi.URLParam, решает, каким кодом ответить, и рендерит JSON. service.go — правила предметной области: обрезать пробелы в title, проставлять updated_at, решать, что вообще считать заметкой. store.go — граница данных: интерфейс Store и реализация хоть над картой в памяти, хоть над postgres; остальной код знает только интерфейс. Каталог internal не даёт чужим проектам импортировать вашу начинку, а нарезка по файлам — не про красоту, а про направление зависимостей: транспорт зовёт правила, правила зовут данные, и никогда наоборот. CRUD-четвёрка из урока про маршруты и методы ложится на subrouter ровно по схеме из урока про subrouters и структуру каталога: ветка /api/notes отвечает за свои маршруты и никого не трогает.

Каркас: слой → файл → ответственность#

СлойФайлОтветственность
Точка входаcmd/api/main.goфлаги, http.Server, graceful shutdown
Транспортinternal/notes/handler.goдекод JSON, chi.URLParam, коды ответов
Правилаinternal/notes/service.goTrimSpace, updated_at, проверки заметки
Данныеinternal/notes/store.goинтерфейс Store: List, Get, Create, Update, Delete
Сборкаinternal/notes/router.goстек middleware и все маршруты одной функцией
Тестыinternal/notes/router_test.gohttptest на весь роутер, мок Store

internal/notes/router.go: одна функция сборки

GO
func newRouter(st Store) http.Handler {
        r := chi.NewRouter()

        r.Use(middleware.RequestID)
        r.Use(middleware.RealIP)
        r.Use(errorHandler) // паники и apiError — в единый JSON
        r.Use(middleware.Logger)
        r.Use(middleware.Timeout(5 * time.Second))

        r.Get("/healthz", health)

        r.Route("/api/notes", func(r chi.Router) {
                r.Get("/", listNotes)
                r.Get("/{noteID}", getNote)
                r.Group(func(r chi.Router) {
                        r.Use(RequireAuth)
                        r.Post("/", createNote)
                        r.Put("/{noteID}", updateNote)
                        r.Delete("/{noteID}", deleteNote)
                })
        })

        return r
}

newRouter — единственная сборка каркаса, и main.go, и тесты зовут именно её: одна функция гарантирует, что тест проверяет то же, что работает в проде. Пять слоёв глобально, health-проба без auth, чтение публично, изменения — за Group с RequireAuth. Маршруты внутри Group получают проверку потому, что зарегистрированы после r.Use в этой ветке. Пощупать каркас руками можно локальным Go: go run ./cmd/api и curl на /healthz и /api/notes.

Проверка: почему auth в Group

Чтение оставили публичным, а POST, PUT и DELETE — за Group с RequireAuth. Почему auth не поставили общим r.Use в корне стека?

Middleware-стек: RequestID, RealIP, errorHandler, Logger и Timeout#

Порядок слоёв в newRouter — не вкусовщина, а рабочий контракт из уроков про встроенные middleware и порядок стека. RequestID первый: он кладёт идентификатор в контекст, его подхватывают Logger и ответы об ошибках, и жалоба пользователя сшивается с журналом за секунды. RealIP стоит рядом и тоже до Logger: адрес в строке лога берётся из RemoteAddr в момент сборки, и слой должен успеть переписать его на настоящий адрес клиента заранее. errorHandler из урока про централизованные ошибки ловит свои apiError и чужие паники, отвечает единым JSON и стоит до Logger — так его ответы попадают в строку лога с честным статусом. Logger пишет одну строку на запрос: метод, путь, статус, длительность, идентификатор. Timeout задаёт дедлайн контексту в пять секунд: зависший хендлер не держит соединение вечно, а значение согласовано с бюджетом прокси из урока про деплой. RequireAuth в корневой стек не входит — он живёт в Group внутри ветки и достаётся только защищённым маршрутам.

Валидация тела и параметров с единым JSON-форматом ошибок#

Хендлер — худшее место для бизнес-правил и лучшее для HTTP-церемоний. Валидация из урока про URLParam и проверку данных распадается на два этажа. Тело: json.NewDecoder в request-структуру, TrimSpace по title, проверка непустоты и длины; синтаксический мусор — это 400, а валидный JSON с пустым title — 422. Параметры: noteID из chi.URLParam обязан быть числом, иначе 400 ещё до обращения к хранилищу. Ответ об ошибке всегда один: helper renderError из урока про централизованные ошибки пишет обёртку error с машинным code и человеческим message, одинаковые для любого провала. Клиент парсит один контракт вместо трёх, фронтенд-обработчик перестаёт падать на неожиданном формате, а тест фиксирует формат жёстче любой договорённости в чате. Коды разведены по смыслу: 400 — синтаксис, 422 — семантика, 404 — нет заметки, 401 — нет токена на защищённой ветке.

internal/notes/handler.go: create и getNote

GO
type createRequest struct {
        Title string
        Body  string
}

func createNote(w http.ResponseWriter, r *http.Request) {
        var in createRequest
        if err := json.NewDecoder(r.Body).Decode(&in); err != nil {
                renderError(w, r, http.StatusBadRequest, "bad_request", "битый JSON")
                return
        }
        in.Title = strings.TrimSpace(in.Title)
        if in.Title == "" || utf8.RuneCountInString(in.Title) > 120 {
                renderError(w, r, http.StatusUnprocessableEntity, "validation_failed", "title пуст или длиннее 120 символов")
                return
        }

        n, err := store.Create(r.Context(), in.Title, in.Body)
        if err != nil {
                renderError(w, r, http.StatusInternalServerError, "internal", "не удалось сохранить заметку")
                return
        }
        writeJSON(w, http.StatusCreated, n)
}

func getNote(w http.ResponseWriter, r *http.Request) {
        id, err := strconv.Atoi(chi.URLParam(r, "noteID"))
        if err != nil {
                renderError(w, r, http.StatusBadRequest, "bad_request", "noteID должен быть числом")
                return
        }
        n, err := store.Get(r.Context(), id)
        if errors.Is(err, ErrNotFound) {
                renderError(w, r, http.StatusNotFound, "not_found", "заметки нет")
                return
        }
        writeJSON(w, http.StatusOK, n)
}

Два хендлера — и весь шаблон транспорта: декод в request-структуру, TrimSpace, проверка длины, ответ через renderError с кодом из таблицы статусов. Нечисловой noteID отсекается до хранилища: мусор в пути не превращается в нечитаемую ошибку на слое данных. JSON-теги у Note и request-структур задают ключи id, title, body и updated_at — здесь они опущены для краткости, а в реальном каркасе пишутся рядом с каждым полем.

Что получит клиент на /api/notes/abc?

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

// маршрут: r.Get("/{noteID}", getNote)
func getNote(w http.ResponseWriter, r *http.Request) {
        id, err := strconv.Atoi(chi.URLParam(r, "noteID"))
        if err != nil {
                renderError(w, r, http.StatusBadRequest, "bad_request", "noteID должен быть числом")
                return
        }
        n, err := store.Get(r.Context(), id)
        if errors.Is(err, ErrNotFound) {
                renderError(w, r, http.StatusNotFound, "not_found", "заметки нет")
                return
        }
        writeJSON(w, http.StatusOK, n)
}

Тесты: httptest на роутер целиком#

Тесты из урока про тестирование chi поднимают не хендлер поодиночке, а весь каркас: newRouter с той же сборкой, что и в main, httptest.NewRequest без сокета и httptest.NewRecorder вместо ResponseWriter. r.ServeHTTP(rr, req) прогоняет запрос через весь стек — RequestID генерируется, URLParam наполняется только роутером, RequireAuth срабатывает на защищённых маршрутах — и это ловит класс багов, невидимый на уровне функций: опечатку в шаблоне маршрута, забытый Use в Group, неверный Content-Type. Мок Store через интерфейс делает набор автономным: Store — интерфейс с пятью методами, в тестах подставляется mockStore над картой, и никакая база не нужна. Набор для каркаса компактен: 201 на создание, 200 на чтение, 404 по errors.Is, 400 на нечисловой noteID, 422 на пустой title, 401 на изменение без токена. Каждый случай — десяток строк, весь набор крутится за миллисекунды.

internal/notes/router_test.go: мок и полный прогон

GO
type mockStore struct {
        notes map[int]Note
}

func (m *mockStore) Get(ctx context.Context, id int) (Note, error) {
        n, ok := m.notes[id]
        if !ok {
                return Note{}, ErrNotFound
        }
        return n, nil
}

// List, Create, Update, Delete — по той же схеме над картой

func TestGetNote(t *testing.T) {
        st := &mockStore{notes: map[int]Note{7: {ID: 7, Title: "черновик"}}}
        r := newRouter(st) // тот же каркас, что и в main

        req := httptest.NewRequest(http.MethodGet, "/api/notes/7", nil)
        rr := httptest.NewRecorder()

        r.ServeHTTP(rr, req)

        if rr.Code != http.StatusOK {
                t.Fatalf("код: %d, хотим 200", rr.Code)
        }
        if ct := rr.Header().Get("Content-Type"); ct != "application/json" {
                t.Fatalf("Content-Type: %q, хотим application/json", ct)
        }
}

mockStore реализует интерфейс Store над картой: тест подменяет только хранилище, весь остальной каркас настоящий. Запрос идёт через r.ServeHTTP — то же дерево маршрутов, что и в main, поэтому опечатка в шаблоне или забытый RequireAuth упадут прямо здесь. Прогон — локальным Go командой go test ./...: без портов, сети и базы, параллельно и за миллисекунды. Заготовка та же, что в уроке про httptest, — она просто наезжает на готовый каркас.

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

Тест гоняет запрос через newRouter с mockStore вызовом r.ServeHTTP. Что он проверил?

Graceful shutdown и выкат: каркас закрывается#

Два последних болта закрывают эксплуатацию. Graceful shutdown из одноимённого урока: srv.ListenAndServe уходит в горутину, signal.NotifyContext ловит SIGTERM от systemd или docker stop, srv.Shutdown с бюджетом в десять секунд даёт долетающим запросам закончиться, а новые не принимает. Таймауты http.Server — ReadTimeout, WriteTimeout, IdleTimeout — ставятся рядом с middleware.Timeout и согласуются с ним, чтобы дедлайны не противоречили друг другу. Выкат за прокси из урока про деплой: chi слушает 127.0.0.1:8080, nginx держит TLS и передаёт заголовки клиента, middleware.RealIP в начале стека восстанавливает адрес, /healthz без токена опрашивается прокси. Каркас к этому ничего не требует: health-проба и вежливое выключение уже в коде, поэтому инстанс снимается с балансировки до остановки, а выкат не рвёт активные запросы.

Соберите ветку /api/notes

Верните три строки каркаса: префикс ветки, auth-слой и имя параметра маршрута.

r.Route("", func(r chi.Router) { r.Get("/", listNotes) r.Group(func(r chi.Router) { r.Use() r.Post("/", createNote) }) r.Get("/{}", getNote) })

Соберите стек newRouter

Расставьте вызовы стека в правильном порядке — от идентификатора запроса до auth на защищённых ветках.

Строки

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

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

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

Термин

Слои каркаса

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

handler.go — транспорт: декод, URLParam, коды ответов. service.go — правила: TrimSpace, updated_at. store.go — данные: интерфейс Store. Зависимости только вниз, от транспорта к данным.

1 / 6

Чек-лист: каркас готов к прод#

  • Стек полный и в правильном порядке: RequestID, RealIP, errorHandler, Logger, Timeout.
  • RequireAuth только внутри Group: /healthz и чтение публичны, изменения по токену.
  • Все ошибки — один контракт error с code и message через renderError.
  • Валидация до хранилища: битый JSON и нечисловой noteID — 400, пустой title — 422.
  • Store — интерфейс: mockStore в тестах, mapStore в main, postgres когда дорастёте.
  • Тесты на роутер целиком через ServeHTTP: go test ./... зелёный без сети и базы.
  • Graceful shutdown ловит SIGTERM, таймауты http.Server согласованы с Timeout и прокси.

Что получилось в сухом остатке: REST API на chi с четырьмя операциями над заметками, пять файлов Go и ни одной новой сущности сверх курса. Каркас намеренно тонкий — на него наращивается всё, что шло дальше по программе: postgres вместо mapStore подменяется в одной точке main.go, Throttle добавляется одной строкой Use, CORS встаёт перед auth по правилу порядка слоёв, версионирование API — новой веткой Route. Дальше только ваши проекты: скопируйте структуру, соберите стек, прогоните тесты и выкатите за прокси. Каждый болт этого каркаса вы уже держали в руках по отдельности — осталось затянуть их вместе.

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

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

Вывод

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

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

  • Каркас REST API записной книжки — пять файлов: main.go собирает и поднимает, internal/notes делит транспорт, правила и данные.
  • newRouter — одна функция сборки: её зовут и main, и тесты, поэтому прод и прогон идут по одному стеку.
  • Стек: RequestID, RealIP, errorHandler, Logger, Timeout глобально; RequireAuth — только в Group на изменениях.
  • Ошибки в одном контракте error с code и message: 400, 422, 404 и 401 разведены по смыслу.
  • Store — интерфейс: mockStore в тестах, mapStore в проде; httptest гоняет роутер целиком через ServeHTTP.
  • Graceful shutdown и выкат за прокси замыкают каркас: SIGTERM ловится, /healthz отвечает, RealIP восстанавливает адрес.

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

Развейте каркас: добавьте реализацию Store поверх JSON-файла, не меняя интерфейс, и вынесите порт в переменные окружения. Допишите в router_test.go два кейса — 404 на несуществующий noteID и 401 на PUT без токена. Прогоните go test ./... и поднимите каркас за nginx из урока про деплой.

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

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

Почему Store — интерфейс, если данных пока немного?

Интерфейс — не про объём данных, а про границы теста. Handler и service зависят от пяти методов, а не от реализации: mockStore подставляется за минуту, тесты идут без базы, а переход на postgres — новый файл store_postgres.go без правок логики. Цена — одна декларация, выгода — автономность тестов.

Можно ли обойтись без слоя service в маленьком API?

Технически да — хендлеры могут звать Store напрямую. Но тогда правила — TrimSpace, updated_at, перевод ErrNotFound — расползаются по хендлерам и дублируются между create и update. Service на сотню строк возвращает себя первым же тестом: подменять и проверять приходится одно место, а не каждую ручку отдельно.

Достаточно ли этого стека middleware для продакшена?

Для простого API — да: идентификация, адрес, ошибки, лог и дедлайн закрывают базовые риски. Дальше слои добавляются по мере роста: Throttle и NoCache из встроенных, CORS перед auth, если появится браузерный фронтенд, метрики — своим слоем. Правило из урока про порядок: сначала вопрос «кто кого видит», потом строка r.Use.

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

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

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

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

Redis в Go: кэш, сессии и очереди на go-redis

Курс Redis для Go-разработчиков: кэширование запросов, сессии, счётчики, Pub/Sub, Streams и rate limiter. 25 уроков с примерами кода go-redis v9.

  • Подключаться к Redis из Go и настраивать пул соединений
  • Кэшировать результаты запросов и придумывать TTL
  • Работать с хэшами, списками, множествами и ZSET
  • Строить сессии, счётчики, лидерборды и rate limiter