Разработка REST API на Go с net/http и chi

Курс о создании HTTP API на Golang с использованием стандартного пакета net/http и роутера chi. Разберём маршрутизацию, middleware, валидацию, обработку ошибок, работу с JSON, тестирование и базовые практики продакшн-готовности.

1. Основы HTTP в Go: net/http, Handler и Server

Основы HTTP в Go: net/http, Handler и Server

В этом курсе мы будем разрабатывать REST API на Go, используя стандартный пакет net/http и роутер chi. Эта статья — фундамент: вы разберётесь, как Go принимает HTTP-запрос, как устроены обработчики (handlers), и как правильно запускать HTTP-сервер.

Полезные источники для справки:

  • Документация net/http
  • MDN: Обзор HTTP
  • Что такое HTTP-запрос и HTTP-ответ

    HTTP — протокол запрос-ответ. Клиент (например, браузер или мобильное приложение) отправляет запрос, сервер возвращает ответ.

    Запрос обычно состоит из:

  • метода (GET, POST, PUT, DELETE)
  • пути (например, /users/42)
  • заголовков (headers), например Content-Type
  • тела (body), обычно для POST/PUT (например, JSON)
  • Ответ обычно состоит из:

  • статус-кода (например, 200, 404, 500)
  • заголовков
  • тела ответа (например, JSON)
  • В REST API мы чаще всего:

  • читаем JSON из тела запроса
  • записываем JSON в тело ответа
  • управляем статус-кодами и заголовками
  • Как net/http обрабатывает запрос

    Пакет net/http строится вокруг идеи: на каждый запрос вызывается обработчик.

    !Схема жизненного цикла запроса: от клиента до handler и обратно

    Ключевые роли:

  • http.Server: принимает соединения, читает запросы, управляет таймаутами.
  • Handler: ваш код, который формирует ответ.
  • Mux (маршрутизатор): выбирает, какой обработчик вызвать по пути и методу.
  • Интерфейс http.Handler и функция http.HandlerFunc

    В Go обработчик — это объект, который реализует интерфейс http.Handler:

    Здесь:

  • http.ResponseWriter — то, во что вы пишете ответ (статус, заголовки, тело)
  • *http.Request — входящий запрос (метод, путь, заголовки, тело, контекст)
  • Чаще всего удобнее использовать http.HandlerFunc — это адаптер, позволяющий писать обработчик обычной функцией:

    Обратите внимание:

  • http.HandleFunc регистрирует путь и функцию
  • http.ListenAndServe запускает сервер
  • второй аргумент nil означает: использовать стандартный DefaultServeMux
  • Документация по обработчикам:

  • http.Handler
  • http.HandlerFunc
  • ResponseWriter: статус, заголовки, тело

    http.ResponseWriter позволяет управлять ответом.

    Статус-код

  • По умолчанию, если вы начали писать тело через Write, статус станет 200 OK.
  • Если нужен другой статус — вызовите WriteHeader до Write.
  • Заголовки

    Заголовки задаются через Header():

    Важно: заголовки нужно выставить до WriteHeader и до записи тела, иначе они могут не примениться.

    Тело ответа

    Запись тела — через Write:

    Request: метод, путь, query, тело

    *http.Request содержит данные запроса.

    Метод и путь

    Query-параметры

    Для URL /search?q=go&page=2:

    Тело запроса

    Тело доступно как поток r.Body. Его нужно читать (и обычно закрывать) аккуратно. В следующих статьях мы будем разбирать JSON, а пока важный принцип: тело может быть большим, поэтому чтение должно быть контролируемым.

    Маршрутизация: ServeMux как базовый роутер

    В net/http есть простой роутер — http.ServeMux. Он сопоставляет путь с обработчиком.

    Пример с несколькими маршрутами:

    Ограничения ServeMux (почему позже нужен chi):

  • нет удобной маршрутизации по методу (GET /users, POST /users)
  • нет параметров пути вида /users/{id}
  • middleware приходится организовывать вручную
  • chi решает эти задачи, но понимание Handler и Server остаётся основой.

    http.Server: зачем он нужен поверх ListenAndServe

    http.ListenAndServe(":8080", handler) — это удобный шорткат. Под капотом он создаёт http.Server.

    Явный http.Server нужен, чтобы управлять:

  • таймаутами (защита от медленных клиентов и зависаний)
  • максимальным размером заголовков
  • корректным завершением работы (graceful shutdown)
  • Рекомендуемые таймауты

    Пример безопасной конфигурации для API:

    Смысл таймаутов:

  • ReadHeaderTimeout: ограничивает время на чтение заголовков
  • ReadTimeout: ограничивает время на чтение всего запроса
  • WriteTimeout: ограничивает время на запись ответа
  • IdleTimeout: сколько держать keep-alive соединение без активности
  • Документация:

  • http.Server
  • Частые ошибки новичков

  • Писать w.Write до установки нужных заголовков и статус-кода.
  • Игнорировать таймауты у сервера.
  • Смешивать бизнес-логику и HTTP-слой в одном обработчике без структуры.
  • Не проверять r.Method в обработчиках при использовании ServeMux.
  • Как это связано с REST API и следующими темами

    Дальше в курсе мы будем строить REST API поверх этих базовых примитивов:

  • добавим chi для маршрутизации по методам и параметрам пути
  • научимся принимать и отдавать JSON
  • разберём middleware (логирование, recovery, auth)
  • настроим корректные ошибки и единый формат ответов
  • Фундамент остаётся тем же: сервер вызывает handler, handler читает *http.Request и пишет в http.ResponseWriter.

    2. Маршрутизация и структура проекта с chi

    Маршрутизация и структура проекта с chi

    В прошлой статье вы разобрались, как net/http вызывает handler, как читать *http.Request и писать ответ через http.ResponseWriter, а также зачем нужен http.Server с таймаутами. Теперь добавим недостающий слой для удобного REST API: маршрутизацию по методам, параметры пути и группировку маршрутов. Для этого используем роутер chi.

    Полезные источники:

  • chi: GitHub репозиторий
  • chi: документация (pkg.go.dev)
  • chi middleware: документация (pkg.go.dev)
  • Зачем нужен chi поверх net/http

    Стандартный http.ServeMux хорош для простых случаев, но в REST API обычно нужно:

  • Роутинг по HTTP-методам: GET /users, POST /users
  • Параметры пути: /users/{id}
  • Группировка и подроутеры: /api/v1, /admin
  • Единый набор middleware на группу маршрутов
  • chi решает эти задачи, оставаясь совместимым с net/http.

    > Важно: chi не заменяет net/http. Он всего лишь предоставляет удобный http.Handler, который вы передаёте в http.Server.

    !Показывает как chi выбирает обработчик и как применяются группы и middleware

    Установка и базовый роутер

    Добавьте зависимость:

    Минимальный пример сервера с chi:

    Что здесь важно:

  • chi.NewRouter() возвращает объект, который реализует http.Handler
  • методы Get, Post, Put, Delete регистрируют маршрут сразу с методом
  • http.ListenAndServe принимает r как обработчик, потому что это http.Handler
  • Маршруты по методам

    В REST API один и тот же путь обычно поддерживает разные методы:

    Если вы используете один handler на несколько методов, chi позволяет объявить это явно:

    Это помогает держать HTTP-слой читаемым: по файлу с роутами видно, какие эндпоинты доступны.

    Параметры пути

    Параметры пути в chi задаются фигурными скобками:

  • маршрут: /users/{id}
  • значение параметра доступно через chi.URLParam(r, "id")
  • Пример:

    Практический смысл:

  • путь отражает ресурс: /users/{id}
  • id приходит как строка
  • преобразование id в число и валидация обычно делаются в handler, чтобы вернуть корректный HTTP-ответ (например, 400 Bad Request), но формат ошибок мы детально разберём позже
  • Группировка маршрутов: Route и Group

    Когда API растёт, маршруты удобно группировать.

    Route: подроутер с общим префиксом

    Внутри Route вы описываете пути относительно префикса.

    Group: группа для общих middleware

    Group похож на Route, но чаще используется, когда нужно повесить middleware на набор маршрутов:

    Разница по смыслу:

  • Route обычно подчёркивает иерархию URL
  • Group обычно подчёркивает общие правила обработки (middleware)
  • Mount: подключение независимых роутеров

    Когда вы хотите разделить код по модулям, удобно собирать отдельные роутеры и подключать их:

    Это хороший шаг к структуре проекта: каждый модуль может поставлять свой роутер.

    Middleware в chi

    Middleware — это обработчики, которые оборачивают ваш основной handler и выполняются до или после него. Примеры задач:

  • логирование запросов
  • восстановление после паники (panic)
  • установка заголовков
  • аутентификация и авторизация
  • В chi middleware подключаются так:

    Что это даёт в разработке API:

  • вы не дублируете одну и ту же служебную логику в каждом handler
  • вы можете применять middleware на весь роутер, на группу или на конкретный подроутер
  • Детально middleware (в том числе свои собственные) будут разбираться в отдельной теме, но уже сейчас полезно подключить Recoverer, чтобы сервер не падал от паники в одном запросе.

    Рекомендуемая структура проекта

    Ниже пример структуры, которая хорошо масштабируется для REST API и не смешивает HTTP-слой с бизнес-логикой:

  • cmd/api/main.go — точка входа, сборка зависимостей, запуск http.Server
  • internal/httpapi/router.go — сборка chi.Router, регистрация маршрутов и middleware
  • internal/httpapi/handlers/... — HTTP-обработчики, парсинг входных данных, формирование HTTP-ответов
  • internal/service/... — бизнес-логика (что делать)
  • internal/storage/... — доступ к данным (как хранить)
  • Пример дерева:

    Ключевая идея разделения:

  • handler отвечает за HTTP-детали: методы, параметры пути, коды статуса, заголовки
  • service отвечает за сценарии: создать пользователя, получить пользователя
  • storage отвечает за чтение и запись данных
  • Пример: сборка роутера и запуск сервера

    router.go

    Почему здесь интерфейс UsersHandler полезен:

  • роутер зависит от контракта, а не от конкретной реализации
  • проще тестировать: можно подставить фейковый handler
  • можно разделять ответственность: router не должен знать детали реализации
  • main.go

    Связь с предыдущей статьёй:

  • вы по-прежнему запускаете http.Server и настраиваете таймауты
  • отличие в том, что Handler теперь собран через chi и включает маршрутизацию и middleware
  • Как тестировать маршрутизацию

    Даже без поднятия реального сервера можно проверить, что маршрут работает, через net/http/httptest.

    Пример идеи:

    Это полезно, потому что chi.Router остаётся обычным http.Handler.

    Частые ошибки при работе с chi

  • Писать обработчики так, будто они знают про конкретный роутер, вместо того чтобы принимать только w и r.
  • Хранить бизнес-логику в handler и дублировать её между эндпоинтами.
  • Подключать middleware без понимания области действия: на весь роутер, на группу или на подроутер.
  • Делать маршруты несогласованными: например, смешивать /user/{id} и /users/{id} без причины.
  • Что дальше

    Теперь у вас есть удобная маршрутизация и заготовка структуры проекта. Следующий логичный шаг для REST API:

  • научиться принимать и отдавать JSON
  • ввести единый формат ошибок и ответов
  • аккуратно валидировать входные данные
  • расширить middleware (логирование, авторизация)
  • Основа остаётся прежней: http.Server принимает запрос, а chi выбирает нужный handler, который читает *http.Request и пишет ответ в http.ResponseWriter.

    3. JSON API: кодирование, декодирование, контракты

    JSON API: кодирование, декодирование, контракты

    REST API почти всегда обменивается данными в JSON. В прошлых статьях вы разобрались с фундаментом net/http (как вызывается handler, как писать ответ) и добавили маршрутизацию и middleware через chi. Теперь соберём следующий критичный слой: как безопасно и предсказуемо принимать JSON из запроса и отдавать JSON в ответ, соблюдая контракт API.

    Полезные источники:

  • Документация encoding/json
  • RFC 8259: JSON
  • MDN: Content-Type
  • !Схема показывает последовательность шагов при обработке JSON-запроса и формировании JSON-ответа

    Что такое контракт JSON API

    Контракт API — это договорённость между клиентом и сервером о том:

  • какие эндпоинты существуют (например, POST /api/v1/users)
  • какие поля приходят в JSON-запросе и какие возвращаются в JSON-ответе
  • какие коды статуса возвращаются в успешных и ошибочных случаях
  • какие правила валидации применяются
  • В Go контракт чаще всего выражается через:

  • структуры (struct) для входных и выходных данных
  • JSON-теги (json:"...") для сопоставления полей
  • единый формат ошибок (мы начнём его вводить уже здесь)
  • Заголовки и базовые правила JSON-обмена

    Для корректного JSON API придерживайтесь следующих правил:

  • Клиент отправляет JSON с заголовком Content-Type: application/json.
  • Сервер отвечает JSON с заголовком Content-Type: application/json; charset=utf-8.
  • Сервер всегда возвращает предсказуемое тело ответа: либо успешный JSON, либо JSON-ошибку в едином формате.
  • Важно помнить поведение net/http из первой статьи:

  • заголовки нужно выставлять до WriteHeader и до записи тела
  • если вы начали писать тело через Write, статус автоматически станет 200 OK
  • Модели: разделяйте вход и выход

    Одна из частых ошибок — использовать одну и ту же структуру для:

  • входного запроса (что клиент может прислать)
  • сущности внутри сервиса или хранилища
  • выходного ответа (что сервер возвращает)
  • Лучше разделять модели по смыслу.

    Пример: создание пользователя.

  • вход: name обязателен
  • выход: сервер выдаёт id и name
  • Так контракт становится очевиднее: клиент не может «подсунуть» id, потому что во входной структуре его просто нет.

    Кодирование JSON в ответ: json.Encoder

    Самый удобный и безопасный способ писать JSON-ответ — json.NewEncoder(w).Encode(v).

  • он пишет данные потоково
  • не требует вручную собирать []byte
  • автоматически добавляет перевод строки в конце (обычно это нормально для API)
  • Сделаем маленькую утилиту для ответа:

    Замечание про ошибки: здесь _ = enc.Encode(v) упрощён для примера. В реальном коде часто логируют ошибку сериализации и отдают 500. Важно понимать причину: после WriteHeader и начала записи тела вы уже не можете «передумать» и корректно отправить другой JSON.

    Декодирование JSON из запроса: json.Decoder

    Тело запроса — это поток r.Body. Декодировать его удобно так:

    Но для API важны дополнительные меры.

    Ограничивайте размер тела запроса

    Без ограничения клиент может отправить очень большой JSON и создать нагрузку на память и CPU.

    Используйте http.MaxBytesReader:

    После этого Decode вернёт ошибку, если тело больше лимита.

    Запрещайте неизвестные поля

    По умолчанию encoding/json молча игнорирует поля, которых нет в структуре. Это удобно при совместимости, но опасно для строгого контракта: клиент может ошибиться в названии поля, а сервер «проглотит» ошибку.

    Для строгого API полезно включать:

    Тогда лишнее поле приведёт к ошибке декодирования и вы сможете вернуть 400 Bad Request.

    Проверяйте, что в body ровно один JSON-объект

    Иногда клиент может отправить несколько JSON подряд (или мусор после JSON). Decode прочитает только первый объект.

    Шаблон проверки:

    На практике чаще используют второй Decode в пустую структуру и ожидают io.EOF, но это чуть более многословно. Главное — идея: тело должно соответствовать контракту и не содержать «хвостов».

    Единый формат ошибок в JSON

    Даже если вы ещё не делаете полноценную систему ошибок, полезно сразу зафиксировать контракт на ошибки.

    Простой вариант:

    И функция:

    Тогда клиент всегда понимает, где искать сообщение об ошибке.

    Пример handler: POST /api/v1/users

    Ниже пример обработчика, который:

  • проверяет Content-Type
  • ограничивает размер body
  • строго декодирует JSON
  • валидирует данные
  • возвращает JSON-ответ
  • Обратите внимание на границы ответственности:

  • handler занимается HTTP и JSON: заголовки, лимиты, декодирование, коды статуса
  • service занимается бизнес-логикой (создать пользователя)
  • Это продолжает структуру из статьи про chi: router вызывает handler, handler вызывает service.

    Подключение маршрута в chi

    В роутере (из предыдущей темы) это выглядит так:

    JSON-теги, omitempty и указатели

    Переименование полей

    Без тега json:"name" Go будет использовать имя поля (Name). В API принято использовать lowerCamelCase или snake_case. encoding/json по умолчанию делает некоторые преобразования, но на контракт лучше не полагаться.

    omitempty

    Тег omitempty не включает поле в JSON, если оно «пустое».

    Пустыми считаются, например:

  • "" для строк
  • 0 для чисел
  • false для bool
  • nil для срезов, мап, указателей
  • Важно для контракта: если вы используете omitempty, клиент должен быть готов к отсутствию поля.

    Когда нужен указатель

    Иногда важно различать:

  • поле не передали
  • поле передали, но оно пустое значение
  • Для входных структур это часто решают указателями:

    Тогда:

  • Name == nil означает «клиент не прислал поле name»
  • Name != nil означает «клиент явно прислал поле name» (даже если это пустая строка)
  • Это полезно для PATCH.

    Типичные проблемы и как их избегать

  • Забыли выставить Content-Type в ответе
  • - клиент может неправильно интерпретировать тело
  • Не ограничили r.Body
  • - риск больших запросов и чрезмерной нагрузки
  • Не включили DisallowUnknownFields при строгом контракте
  • - ошибки клиента могут быть незаметны
  • Смешали входные модели и сущности хранения
  • - клиент получает лишние поля или может отправить то, что не должен
  • Возвращаете разные форматы ошибок
  • - клиенту сложнее обрабатывать ошибки одинаково

    Что дальше

    Теперь у вас есть практическая база для JSON API:

  • как кодировать ответы через encoding/json
  • как строго и безопасно декодировать запросы
  • как фиксировать контракт структурами и тегами
  • как начать вводить единый формат ошибок
  • Следующий шаг (логичное развитие для API на chi) — middleware и инфраструктурные вещи вокруг JSON API: логирование, request id, recovery, а также более системная обработка ошибок и валидация входных данных.

    4. Middleware: логирование, recover, auth, CORS

    Middleware: логирование, recover, auth, CORS

    В прошлых статьях вы собрали HTTP-сервер на net/http, настроили маршрутизацию через chi и научились безопасно принимать/отдавать JSON. Теперь добавим инфраструктурный слой, который почти всегда нужен REST API в реальной эксплуатации: middleware.

    Middleware решают задачи, которые не относятся напрямую к бизнес-логике конкретного эндпоинта:

  • логирование запросов и ответов
  • защита от падения процесса из-за panic (recover)
  • аутентификация (auth)
  • CORS для браузерных клиентов
  • Полезные источники:

  • Документация chi
  • Middleware chi
  • go-chi/cors
  • MDN: CORS
  • !Схема показывает цепочку middleware и то, что они оборачивают handler

    Что такое middleware в терминах net/http и chi

    В основе всё тот же интерфейс из первой статьи: http.Handler. Middleware — это функция, которая берёт один http.Handler и возвращает другой http.Handler, оборачивая исходный.

    В chi это выражается типом func(http.Handler) http.Handler и подключается через r.Use(...).

    Ключевые свойства middleware:

  • выполняются в порядке подключения для входящего запроса
  • при выходе ответа «разворачиваются» в обратном порядке, потому что это вложенные обёртки
  • могут:
  • - прервать обработку запроса и вернуть ответ сразу - добавить данные в context запроса - изменить заголовки ответа - залогировать информацию о запросе

    Где подключать middleware в структуре проекта

    Если вы используете структуру из статьи про chi:

  • cmd/api/main.go создаёт зависимости и запускает http.Server
  • internal/httpapi/router.go собирает chi.Router
  • То middleware почти всегда подключаются именно в router.go, потому что это HTTP-инфраструктура, а не бизнес-логика.

    В chi можно применять middleware на разных уровнях:

  • на весь роутер: r.Use(...)
  • на группу маршрутов: r.Group(func(r chi.Router) { r.Use(...); ... })
  • на подроутер с префиксом: r.Route(...)
  • Базовый набор middleware для API

    Для большинства REST API разумный стартовый набор выглядит так:

  • middleware.RequestID — добавляет X-Request-Id и значение в context
  • middleware.RealIP — пытается извлечь реальный IP клиента из заголовков прокси
  • middleware.Logger — пишет базовый access log
  • middleware.Recoverer — перехватывает panic и возвращает 500
  • Пример подключения:

    Логирование запросов

    Зачем логировать на уровне middleware

    Логирование в каждом handler вручную быстро становится проблемой:

  • логика дублируется
  • логи получаются несогласованными
  • сложно добавить общие поля вроде request id или IP
  • Access-лог на уровне middleware решает эти задачи и даёт минимум данных для расследований:

  • метод и путь
  • статус-код
  • длительность обработки
  • размер ответа
  • request id
  • Что даёт middleware.Logger

    middleware.Logger из chi пишет базовую строку лога на каждый запрос. Это хороший старт, но в реальных проектах часто переходят на структурированные логи. Важно другое: весь доступ к HTTP-метаданным централизован.

    Если вы хотите больше контроля над форматом, посмотрите middleware.RequestLogger в документации chi и реализуйте интерфейс логгера, который подходит вашему проекту.

    Recover: защита от panic

    Что происходит без recover

    Если в handler или ниже по стеку случится panic, и вы её не перехватите, процесс может завершиться. Для API это означает простой сервиса.

    middleware.Recoverer

    middleware.Recoverer перехватывает panic, пишет информацию в лог и возвращает 500 Internal Server Error.

    Важный нюанс ответственности:

  • Recoverer не «лечит» ошибки; он предотвращает крах процесса
  • паника обычно означает баг; её нужно расследовать по логам
  • Рекомендация: подключайте Recoverer почти всегда, включая dev-окружение.

    Auth middleware: Bearer token и context

    В REST API аутентификация часто делается middleware, потому что проверка токена — это правило для группы эндпоинтов, а не часть бизнес-логики каждого handler.

    Минимальный контракт

    Рассмотрим простой вариант:

  • клиент передаёт заголовок Authorization: Bearer <token>
  • сервер проверяет токен
  • если токен валиден, кладёт идентификатор пользователя в context
  • если токен невалиден или отсутствует, возвращает 401 Unauthorized в JSON
  • Пример: middleware аутентификации

    Ниже пример без внешних библиотек. Он показывает механику: чтение заголовка, проверка, запись в context.

    Почему ключ ctxKeyUserID{} сделан отдельным типом:

  • это снижает риск коллизий ключей между пакетами
  • в context лучше не использовать строки как ключи
  • Как использовать user id в handler

    Подключение auth middleware к группе маршрутов

    Так вы явно фиксируете контракт: часть маршрутов требует авторизации, часть — нет.

    CORS: доступ к API из браузера

    Когда CORS нужен

    CORS нужен, если:

  • ваш фронтенд работает в браузере
  • фронтенд и API находятся на разных origin, например https://app.example.com и https://api.example.com
  • Без корректного CORS браузер заблокирует запрос на уровне клиента.

    Preflight запросы OPTIONS

    Для некоторых запросов браузер делает preflight:

  • отправляет OPTIONS запрос на тот же URL
  • проверяет заголовки ответа Access-Control-Allow-*
  • только потом отправляет реальный запрос
  • Это не «фича фронтенда», а обязательная часть модели безопасности браузера.

    Подключение CORS через go-chi/cors

    Пакет github.com/go-chi/cors предоставляет готовый middleware.

    Практические рекомендации:

  • не ставьте AllowedOrigins: []string{"*"} вместе с AllowCredentials: true
  • разрешайте ровно те origin, которые реально нужны
  • перечисляйте методы и заголовки, которые ваш фронтенд использует
  • Подробности механики и заголовков смотрите в документации:

  • MDN: CORS
  • Порядок middleware и типичные ошибки

    Порядок подключения важен, потому что middleware оборачивают друг друга.

    Типичные правила:

  • RequestID ставьте одним из первых, чтобы он был доступен всем (и попадал в логи)
  • логирование ставьте до middleware, которые могут прерывать запрос, чтобы логировались и 401, и ошибки
  • Recoverer ставьте достаточно высоко, чтобы ловить паники в большинстве слоёв
  • auth обычно ставят на группу защищённых маршрутов, а не на весь роутер
  • Частые ошибки:

  • возвращать 401 в auth middleware без JSON-формата, хотя остальное API отвечает JSON
  • забывать обработку OPTIONS при ручной реализации CORS
  • делать CORS слишком «широким» и случайно разрешить нежелательные источники
  • класть в context большие объекты вместо минимально нужных идентификаторов
  • Тестирование middleware через httptest

    Middleware удобно тестировать без запуска реального сервера: роутер остаётся обычным http.Handler.

    Пример идеи для auth:

    Так вы проверяете контракт: какой статус возвращается, какой формат ответа, какие заголовки выставляются.

    Что дальше

    На этом этапе у вас есть полный «скелет» REST API:

  • net/http сервер с таймаутами
  • chi маршрутизация и группы
  • JSON-контракты и единый формат ошибок
  • middleware для логирования, recover, auth и CORS
  • Следующий логичный шаг — более системная обработка ошибок и валидация, а также тестирование хендлеров и middleware как единого HTTP-слоя.

    5. Валидация, ошибки и единый формат ответов

    Валидация, ошибки и единый формат ответов

    REST API полезен ровно настолько, насколько он предсказуем для клиента. В предыдущих темах вы научились:

  • поднимать сервер на net/http
  • маршрутизировать запросы через chi
  • принимать и отдавать JSON
  • подключать middleware (логирование, recover, auth, CORS)
  • Теперь соберём слой, который превращает набор эндпоинтов в удобный продукт: валидацию, системную обработку ошибок и единый формат ответов.

    !Общий пайплайн: как запрос превращается в ответ, где именно происходят валидация и маппинг ошибок

    Что мы считаем ошибкой в API

    Чтобы API было единообразным, полезно разделять ошибки на понятные категории:

  • Ошибки запроса: клиент отправил неправильные данные (невалидный JSON, не тот тип, отсутствует обязательное поле).
  • Ошибки домена: данные формально корректны, но операция невозможна по правилам системы (например, пользователь с таким именем уже существует).
  • Ошибки инфраструктуры: проблемы сервера (база недоступна, баг в коде, таймаут внешнего сервиса).
  • Эта классификация нужна для правильного выбора:

  • HTTP статус-кода
  • формата и содержания JSON-ошибки
  • того, что можно показывать клиенту, а что нужно только логировать
  • Единый формат ошибок

    В теме про JSON мы ввели простой формат {"error":"..."}. Этого достаточно для учебного старта, но в реальных API быстро становится тесно: клиенту нужно различать типы ошибок, а вам нужно расследовать проблемы по логам.

    Ниже практичный минимальный формат, который остаётся простым и при этом решает основные задачи.

    Контракт ошибки

    Смысл полей:

  • code: стабильный машинно-читабельный код (например, validation_failed), по нему клиент строит логику.
  • message: человеко-читабельное сообщение.
  • fields: ошибки по конкретным полям (для валидации).
  • request_id: идентификатор запроса для трассировки (мы уже подключали middleware.RequestID).
  • > Частая ошибка: использовать только message и пытаться парсить её на клиенте. Контракт должен быть стабильным, поэтому клиенту нужен отдельный code.

    Хелперы для записи JSON

    Сделаем небольшой пакет httpx, который централизует ответы.

    Важный принцип: обработчики перестают вручную собирать JSON-ошибки и становятся короче.

    Полезные ссылки:

  • Документация encoding/json
  • Документация chi middleware
  • Какие HTTP статусы возвращать

    Статус-код должен быть частью контракта: по нему клиент быстро понимает, ошибка на его стороне или на стороне сервера.

    Таблица с типичным маппингом для REST API:

    | Ситуация | Статус | Когда использовать | |---|---:|---| | Невалидный JSON, неверный Content-Type, неправильный id в пути | 400 Bad Request | Запрос невозможно корректно разобрать | | Не прошла бизнес-валидация (обязательные поля, длины, диапазоны, правила) | 422 Unprocessable Entity | JSON корректен, но данные не проходят правила | | Не авторизован | 401 Unauthorized | Нет/неверный токен, требуется вход | | Нет прав | 403 Forbidden | Токен валиден, но доступ запрещён | | Ресурс не найден | 404 Not Found | Не существует сущность | | Конфликт (уже существует) | 409 Conflict | Нарушение уникальности или конфликт состояния | | Неожиданная ошибка сервера | 500 Internal Server Error | Всё остальное, что клиент не может исправить |

    > В учебных проектах часто возвращают 400 на любую валидацию. Это допустимо, но 422 помогает клиенту отличать синтаксис запроса от смысла данных.

    Валидация: где она должна жить

    Валидация почти всегда делится на два уровня.

    Валидация HTTP-уровня (в handler)

    Задачи HTTP-уровня:

  • проверить Content-Type и ограничения на тело (через http.MaxBytesReader)
  • строго декодировать JSON (например, с DisallowUnknownFields)
  • распарсить path/query параметры и вернуть понятный 400
  • Это часть транспортного контракта: клиент должен понимать, как отправлять запрос.

    Валидация доменного уровня (в service)

    Задачи доменного уровня:

  • проверять правила предметной области (например, имя должно быть уникальным)
  • решать, что считать конфликтом, что считать отсутствием ресурса
  • Это часть бизнес-логики. Её важно держать вне handler, иначе правила размазываются по коду и начинают расходиться между эндпоинтами.

    Ошибки в Go: как строить маппинг на HTTP

    В Go принято возвращать ошибки как значение error. Чтобы handler мог понять, что именно произошло в сервисе, удобно использовать сентинельные ошибки и errors.Is.

    Контракт ошибок сервиса

    Внутри сервиса вы можете оборачивать ошибки (например, добавляя контекст), но снаружи handler всё равно сможет распознать тип через errors.Is.

    Ссылка:

  • Документация errors
  • Типизированная ошибка валидации

    Для удобного возврата ошибок по полям сделаем отдельный тип.

    Теперь сервис может вернуть ValidationError, а handler может извлечь поля через errors.As.

    Пример: обработчик создаёт пользователя и маппит ошибки

    Ниже пример POST /api/v1/users, где:

  • handler отвечает за HTTP и JSON
  • service отвечает за доменные правила
  • ошибки преобразуются в единый JSON-формат
  • Что здесь важно:

  • клиент получает предсказуемые ошибки по единому контракту
  • доменные ошибки не превращаются в случайные строки
  • внутренние причины ошибок не утекли клиенту
  • Пример: обработчик GET и ошибки параметров пути

    Ошибки path/query параметров почти всегда относятся к 400 Bad Request.

    Как не утекать внутренними деталями

    Правило для публичного API:

  • клиенту отдаём стабильный код и безопасное сообщение
  • подробности пишем в логи (желательно вместе с request_id)
  • Причина простая:

  • текст внутренних ошибок может раскрывать устройство системы
  • сообщения могут меняться при рефакторинге и ломать клиентов
  • Если вы хотите стандартизировать ошибки ещё сильнее, посмотрите формат Problem Details:

  • RFC 9457: Problem Details for HTTP APIs
  • Как это связано с middleware из прошлой темы

    Эта статья опирается на middleware, которые мы подключали ранее:

  • middleware.RequestID помогает добавить request_id в JSON-ошибку
  • middleware.Logger пишет access-логи, а request_id позволяет связать их с конкретным ответом
  • middleware.Recoverer страхует от паник, но это не замена правильной обработки ошибок
  • Минимальные правила качества для обработчиков

    Чтобы HTTP-слой не деградировал со временем, придерживайтесь короткого чек-листа:

  • один формат ошибок на всё API
  • все handler возвращают JSON (включая ошибки)
  • валидируйте входные данные до вызова сервиса
  • сервис возвращает типизированные ошибки, handler маппит их на HTTP
  • не возвращайте клиенту внутренние err.Error() для неожиданных ошибок
  • Что дальше

    У вас есть полный слой контрактов:

  • JSON запросы и ответы
  • единый формат ошибок
  • понятный маппинг ошибок сервиса в HTTP статусы
  • Следующий логичный шаг после этого слоя — тестирование HTTP-слоя: handlers, middleware и маршрутов через httptest, чтобы контракт не ломался при изменениях.

    6. Тестирование HTTP-обработчиков и роутов

    Тестирование HTTP-обработчиков и роутов

    Когда вы пишете REST API на Go, важно тестировать не только бизнес-логику сервиса, но и HTTP-слой: маршруты, middleware, JSON-контракты, статусы и заголовки. Именно этот слой является публичным контрактом для клиента.

    В предыдущих темах вы:

  • подняли сервер на net/http
  • настроили маршрутизацию через chi
  • ввели JSON-контракты и единый формат ошибок
  • добавили middleware (логирование, recover, auth, CORS)
  • Теперь цель — научиться проверять этот контракт автоматически через тесты.

    !Как тест «прогоняет» запрос через роутер и получает ответ без запуска реального сервера

    Что именно тестировать в HTTP-слое

    Обычно проверяют три уровня.

  • Handler-контракт
  • Router-контракт
  • Middleware-контракт
  • Чтобы тесты были полезными, фиксируйте в них то, что важно клиенту.

  • статус-код
  • заголовки (например, Content-Type, X-Request-Id)
  • формат тела ответа (успех и ошибка)
  • поведение на невалидных данных
  • соответствие маршрута методу и пути
  • Инструменты: testing и httptest

    В стандартной библиотеке Go есть всё необходимое.

  • пакет testing
  • пакет net/http/httptest
  • Ключевые примитивы:

  • httptest.NewRequest(method, url, body) создаёт запрос
  • httptest.NewRecorder() создаёт объект, в который будет записан ответ
  • любой http.Handler (включая chi.Router) можно вызвать напрямую: handler.ServeHTTP(rec, req)
  • Это означает: для тестирования роутов и обработчиков не нужно поднимать реальный сервер и слушать порт.

    Главный принцип: тестируйте через роутер, а не через прямой вызов handler

    Технически вы можете вызвать метод обработчика напрямую, но в большинстве REST API это даёт ложное чувство уверенности.

    Если вы тестируете через chi.Router, вы сразу проверяете:

  • что маршрут реально зарегистрирован
  • что парсинг path-параметров работает
  • что нужные middleware подключены и применяются
  • что формируется единый формат ответа, который вы ввели ранее
  • Сборка тестируемого роутера с фейковыми зависимостями

    Чтобы тесты были быстрыми и предсказуемыми, в HTTP-тестах обычно не используют реальные базы данных и внешние сервисы. Вместо этого подставляют фейки.

    Подход из предыдущих статей помогает напрямую:

  • handler зависит от интерфейса сервиса
  • роутер зависит от handler
  • В тесте вы можете собрать реальный роутер, но подставить фейковый сервис.

    Пример минимальных контрактов

    Пример: тест POST /api/v1/users (JSON + статус + Content-Type)

    Ниже пример теста, который проверяет успешный сценарий и часть контракта ответа.

    Что здесь важно как идея:

  • проверяем статус-код
  • проверяем наличие Content-Type
  • проверяем JSON как данные, а не как строку
  • Табличные тесты: один шаблон на много сценариев

    Для API часто нужно проверить много похожих случаев: пустое поле, неправильный JSON, неправильный Content-Type, слишком большой body. В Go это удобно делается table-driven tests.

    Пример структуры подхода:

    Даже если в примере newTestRouter() упрощён, сама техника применима напрямую к вашему проекту с реальным роутером, реальными middleware и handler, но с фейковыми сервисами.

    Тестирование единого формата ошибок

    В прошлой теме вы ввели единый формат ошибок, например:

  • error.code
  • error.message
  • error.fields
  • error.request_id
  • Проверяйте этот контракт в тестах так же, как проверяете успешные ответы: через JSON-декодирование.

    Пример структуры для декодирования:

    Далее вы можете проверить, что:

  • Code равен ожидаемому значению
  • Fields["name"] присутствует для 422
  • RequestID непустой, если подключён middleware.RequestID
  • Тестирование middleware на роутере

    Middleware тестируют тем же способом: через запрос к роутеру.

    Auth middleware: проверка 401 и успешного прохода

    Идея теста:

  • без заголовка Authorization ожидаем 401
  • с валидным токеном ожидаем, что запрос доходит до handler
  • Пример наброска (без привязки к конкретной реализации):

    RequestID middleware: проверка заголовка

    middleware.RequestID обычно добавляет X-Request-Id. Его можно проверить:

    Если вы также включаете request_id в JSON-ошибки, полезно проверять и заголовок, и JSON-поле.

    Тестирование роутов и path-параметров

    Параметры пути (/users/{id}) удобнее всего тестировать через роутер, а не через прямой вызов handler, потому что chi сам добавляет данные маршрута в контекст запроса.

    Минимальный пример:

    Дальше вы проверяете статус и тело. Это автоматически фиксирует контракт: маршрут зарегистрирован и реально обрабатывается.

    Практические советы, чтобы тесты не стали хрупкими

  • Не сравнивайте JSON как строку, декодируйте и сравнивайте структуры
  • Фиксируйте контракт, а не реализацию
  • Избегайте зависимости от времени и случайности
  • Не используйте внешние ресурсы в HTTP-тестах, подставляйте фейки
  • Тестируйте через chi.Router, чтобы проверять маршруты и middleware одновременно
  • Что дальше

    После того как у вас появились тесты HTTP-слоя, вы можете безопаснее рефакторить:

  • менять внутреннюю структуру handler и сервисов
  • перестраивать роутер и группы chi
  • добавлять middleware
  • При этом автоматические тесты будут сигнализировать, что вы случайно сломали контракт API: статус, формат ошибок, заголовки или маршрутизацию.

    7. Продакшн-практики: конфиг, таймауты, метрики

    Продакшн-практики: конфиг, таймауты, метрики

    К этому моменту вы уже умеете собрать REST API на Go: net/http сервер с таймаутами, роутер на chi, JSON-контракты, middleware, единый формат ошибок и тесты HTTP-слоя. Следующий шаг — сделать сервис пригодным для реальной эксплуатации: управляемый конфиг, правильные таймауты и метрики.

    !Как конфиг, таймауты и метрики «встраиваются» в уже собранный HTTP-слой

    Зачем нужны продакшн-практики

    В продакшне важны три свойства:

  • Предсказуемость: одинаковая конфигурация в разных окружениях (dev, stage, prod) задаётся явно.
  • Устойчивость: сервис не зависает из-за медленных клиентов/сетевых проблем и корректно завершается.
  • Наблюдаемость: по метрикам и логам можно быстро понять, что именно пошло не так.
  • Эти практики напрямую опираются на прошлые темы:

  • В статье про net/http вы уже видели таймауты http.Server.
  • В статье про middleware вы подключали RequestID, Logger, Recoverer.
  • В статье про единый формат ошибок вы добавили request_id в JSON-ошибки.
  • Теперь соберём это в «скелет продакшн-сервиса».

    Конфиг: откуда брать параметры и как их проверять

    Принципы конфигурации

    Практичный базовый подход:

  • конфиг задаётся через переменные окружения (и опционально флаги)
  • значения имеют дефолты, но критичные параметры могут быть обязательными
  • конфиг валидируется на старте, чтобы сервис не запускался в неправильном состоянии
  • Это хорошо сочетается с принципами The Twelve-Factor App.

  • The Twelve-Factor App: Config
  • Что обычно должно быть в конфиге API

    Минимальный набор для REST API:

  • HTTP_ADDR — адрес прослушивания, например :8080
  • таймауты сервера: READ_TIMEOUT, WRITE_TIMEOUT, IDLE_TIMEOUT, READ_HEADER_TIMEOUT
  • SHUTDOWN_TIMEOUT — сколько времени даём на graceful shutdown
  • настройки CORS (если нужно)
  • настройки auth (секреты, ключи, issuer)
  • флаг окружения: ENV=dev|stage|prod (например, чтобы включать дополнительные диагностические endpoints только в dev)
  • Пример: конфиг только на стандартной библиотеке

    Ниже пример, который читает переменные окружения, применяет дефолты и валидирует значения.

    Ссылки на стандартные пакеты:

  • os
  • time
  • strconv
  • Важные детали:

  • time.ParseDuration принимает строки вроде "250ms", "2s", "1m".
  • Валидация на старте дешевле, чем разбор «странного поведения» в рантайме.
  • Дефолт при ошибке парсинга (как в примере) — допустим на старте курса, но в продакшне часто лучше падать с ошибкой, чтобы конфиг был строго корректным.
  • Где хранить конфиг в проекте

    Логично вынести конфиг в отдельный пакет, например:

  • internal/config — загрузка и валидация
  • cmd/api/main.go — вызов config.Load() и сборка зависимостей
  • Так router, handlers и service получают только нужные параметры, а не читают переменные окружения напрямую.

    Таймауты: как не зависнуть и не «съесть» ресурсы

    Таймауты в HTTP-сервисе — это защита от:

  • медленных клиентов
  • сетевых проблем
  • бесконечных запросов
  • перегрузки из-за висящих соединений
  • Таймауты http.Server

    Документация:

  • net/http: Server
  • Рекомендованный минимальный набор:

  • ReadHeaderTimeout — ограничивает время чтения заголовков
  • ReadTimeout — ограничивает чтение всего запроса
  • WriteTimeout — ограничивает запись ответа
  • IdleTimeout — ограничивает простаивание keep-alive соединения
  • Пример запуска сервера с конфигом:

    Что такое graceful shutdown:

  • сервер перестаёт принимать новые соединения
  • активным запросам даётся время завершиться
  • по истечении таймаута завершение принудительное
  • Документация:

  • os/signal
  • syscall
  • Таймауты на уровне запросов

    Таймауты http.Server защищают от «медленных клиентов», но у вас могут быть зависимости:

  • база данных
  • внешние HTTP-сервисы
  • очереди
  • Правило: у каждой операции должен быть предел ожидания.

    Практичный минимум для внешних HTTP-запросов — задавать таймаут у http.Client:

  • net/http: Client
  • Если сервис вызывает внешний API и не ограничивает ожидание, то зависшие запросы могут накапливаться и «съедать» горутины.

    Лимиты на тело запроса

    Из темы про JSON: http.MaxBytesReader — тоже продакшн-защита. Сохраняйте этот паттерн для любых эндпоинтов, принимающих body.

  • net/http: MaxBytesReader
  • Метрики: как измерять поведение API

    Метрики отвечают на вопросы:

  • сколько запросов приходит
  • сколько ошибок
  • какие эндпоинты медленные
  • как меняется нагрузка со временем
  • Что такое метрики в контексте HTTP

    Базовые метрики для API:

  • количество запросов (обычно counter)
  • длительность обработки (обычно histogram)
  • размер ответа (опционально)
  • Практика: измерять минимум такие метки (labels):

  • метод (GET, POST)
  • маршрут (желательно паттерн, например /api/v1/users/{id}, а не реальный путь /api/v1/users/123)
  • статус-код
  • Prometheus: стандартный подход

    Самый распространённый стек для Go-сервисов:

  • сбор метрик в формате Prometheus
  • эндпоинт /metrics
  • Ссылки:

  • Prometheus: Getting started
  • prometheus/client_golang
  • prometheus/promhttp
  • Установка:

    Middleware метрик для chi

    Сделаем middleware, который измеряет длительность и считает запросы.

    Ключевой момент: чтобы знать статус-код, нужно обернуть http.ResponseWriter.

    Обратите внимание на RoutePattern():

  • это именно паттерн маршрута, а не реальный путь
  • это защищает метрики от взрыва кардинальности (когда для каждого id появляется отдельная метка)
  • Подключение метрик в роутер

    В router.go (где вы уже подключаете RequestID, Logger, Recoverer) добавьте метрики и endpoint:

    Практическое замечание:

  • в продакшне /metrics часто закрывают от внешнего мира (например, сетью, прокси или auth), потому что метрики могут раскрывать внутренние детали
  • Альтернатива: expvar для простых случаев

    Если вы хотите вообще без внешних зависимостей, есть пакет expvar.

  • expvar
  • Он проще, но обычно менее удобен для полноценного мониторинга. Для большинства сервисов Prometheus — более практичный выбор.

    Рекомендуемый минимальный чек-лист для вашего API

  • Конфиг
  • - переменные окружения + дефолты - строгая валидация на старте
  • Таймауты
  • - настроены ReadHeaderTimeout, ReadTimeout, WriteTimeout, IdleTimeout - graceful shutdown через Server.Shutdown с отдельным таймаутом - ограничение body через http.MaxBytesReader для JSON эндпоинтов
  • Метрики
  • - счётчик запросов и длительность - route label основан на RoutePattern(), а не на конкретном URL - endpoint /metrics включается конфигом и по возможности защищён

    Что дальше

    С этими практиками ваш сервис становится ближе к реальному продакшну: им можно управлять конфигом, он устойчив к зависаниям, и его поведение видно через метрики.

    Дальше обычно добавляют:

  • более структурированные логи (JSON-логи)
  • трассировку (distributed tracing)
  • readiness/liveness для оркестратора
  • Но даже без этого ваш текущий стек (net/http + chi + middleware + контракты + тесты + конфиг/таймауты/метрики) уже является хорошей базой для реального REST API.