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

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

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

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

Валидация параметров и тел запросов

Валидация запросов в go — целиком ваша работа: chi сверяет только маршрут, и дальше в хендлер доезжают строки, нули и пустые поля, которые нужно отфильтровать самим. Разберём strconv.Atoi для id, диапазоны limit и offset, проверку тела JSON после декодера, теги go-playground/validator и выбор между 400 и 422.

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

Тестировщица поймала за вечер три бага, и все три — про одно: сервис верил всему, что ему прислали. GET /notes/abc вернул 200 и пустую карточку — строка abc где-то в глубине кода тихо превратилась в ноль и сошлась с условием id равно нулю. GET /notes/-5 обрушился пятисотой: минус доехал до SQL-запроса, и база ответила ошибкой, которую не ждали. GET /notes?limit=999999999 уложил демо на показе заказчику — лимит без верхней границы ушёл в запрос к базе целым. А POST /notes с пустым title создал заметку-призрак, которую потом не могли найти ни в интерфейсе, ни в выгрузке. Роутер в каждом случае отработал честно: маршрут совпал, хендлер вызван, chi сделал всё, что умел. Сломалось дальше — на границе между «запрос дошёл» и «данным можно верить». Эта граница и есть валидация, и в chi её не пишут за вас: библиотека отдаёт вам сырые значения и право решать.

Валидация запросов в go: chi не навязывает валидатор#

chi — маленький роутер, а не фреймворк: он разбирает путь, кладёт параметры в контекст и вызывает хендлер. Проверять значения — работа приложения, и в этом chi валидацию не берёт на себя ни в каком виде: ни тегов, ни встроенных проверок, ни обязательной структуры запроса. Контраст с gin показательный: там валидатор go-playground встроен по умолчанию и гоняет теги binding до хендлера; в chi ноль магии — что хотите, то и проверяете. Входов данных в сервис два. Первый — параметры пути и query-строка: это всегда строки, разбор которых chi.URLParam из урока про URLParam оставил вам. Второй — тело запроса: JSON после декодера из урока про JSON API. Валидация запросов в go начинается с правила нулевого доверия: ничто из запроса не используется до проверки — ни число, ни длина, ни поле. Все отказы при этом уходят через renderError из урока про централизованные ошибки: единый формат, одинаковые коды, никакого разнобоя.

id: число с диапазоном

GO
func getNote(w http.ResponseWriter, r *http.Request) {
        id, err := strconv.Atoi(chi.URLParam(r, "id"))
        if err != nil {
                renderError(w, r, http.StatusBadRequest, "invalid_id", "id должен быть числом")
                return
        }
        if id <= 0 {
                renderError(w, r, http.StatusNotFound, "not_found", "заметки с таким id нет")
                return
        }
        // id валиден — дальше обычная работа хендлера
}

Atoi возвращает пару и проверяется до любого использования: нечисло и переполнение дают ошибку и ответ 400. Отдельная ветка для диапазона: Atoi спокойно съест -5, это синтаксически валидное число, — отрицательный и нулевой id отсекает бизнес-правило: нумерация с единицы, значит, заметки с таким id не существует, и честнее 404. Проверьте обе ветки curl-ом на локальном Go — песочница сайт сеть не поднимает.

Проверка: несовпадение имени параметра

В шаблоне маршрута написано {noteID}, а хендлер читает chi.URLParam(r, "id"). Что вернёт URLParam и что произойдёт дальше?

limit и offset: диапазоны пагинации#

Query-параметры приходят строками через r.URL.Query() — Get по отсутствующему ключу отдаёт пустую строку, и это отличает отсутствие от мусора. Три случая, и у каждого свой ответ. Параметра нет — легитимное «дайте как обычно»: подставляем дефолт, двадцать записей, и ответ 200. Параметр есть, но это мусор — abc, минус пять, число в двадцать знаков: клиент ошибся осознанно, отвечаем 400 invalid_limit и ничего не выбираем. Параметр валиден, но больше максимума — тут два лагеря: одни сжимают до верхней границы, другие отвечают отказом с текстом «максимум — сто». Верхняя граница обязательна в любом варианте: LIMIT 999999999 отправляет базу листать пол-таблицы, и один такой запрос при выгрузке в память кладёт процесс ровно так же, как толпа горутин из урока про предохранители. offset проверяется на неотрицательность той же веткой. Правило простое: отсутствие — не ошибка, мусор — ошибка, превышение — выбор команды, записанный в контракте.

Что вернёт Atoi на двадцатизначном id?

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

// в пути пришло /notes/99999999999999999999
n, err := strconv.Atoi("99999999999999999999")
fmt.Println(n, err)

Тело JSON после декодера: обязательные поля и длины#

Декодер из урока про JSON API гарантирует только синтаксис: Decode вернул nil — JSON корректен и поля легли в структуру. Осмысленность он не проверяет: объект с пустым title и email-строкой без собаки декодируется без единой ошибки, и в базу уйдёт мусор с честным 201. Второй рубеж — валидация полей после декодера: обязательные значения, длины, формат. Ручной путь — метод validate() []string на структуре: каждая проверка добавляет текст проблемы, пустой срез — тело годно. Тонкость с длинами: len() в go считает байты, и заголовок из шестидесяти русских букв по этой метке «влезет» в лимит сто двадцать — рунный utf8.RuneCountInString считает то, что видит пользователь. Формат email руками — ловушка: полный RFC-паттерн не пишется, strings.Contains со собачкой пропускает ab@, честный разбор адреса даёт mail.ParseAddress из стандартной библиотеки. Рукастый валидатор работает — и ровно на третьей структуре превращается в свалку условий. Что делать дальше — следующий блок.

Ручная валидация и ответ 422

GO
type noteInput struct {
        Title   string
        Email   string
        Content string
}

func (in noteInput) validate() []string {
        var problems []string
        if strings.TrimSpace(in.Title) == "" {
                problems = append(problems, "title: обязателен")
        }
        if utf8.RuneCountInString(in.Title) > 120 {
                problems = append(problems, "title: не длиннее 120 символов")
        }
        if in.Email != "" && !strings.Contains(in.Email, "@") {
                problems = append(problems, "email: нужен настоящий адрес")
        }
        return problems
}

func createNote(w http.ResponseWriter, r *http.Request) {
        var in noteInput
        if err := json.NewDecoder(r.Body).Decode(&in); err != nil {
                renderError(w, r, http.StatusBadRequest, "invalid_json", "тело не декодируется")
                return
        }
        if problems := in.validate(); len(problems) > 0 {
                renderError(w, r, http.StatusUnprocessableEntity, "validation_failed", strings.Join(problems, "; "))
                return
        }
        // тело валидно — сохраняем
}

Два рубежа в одном хендлере: Decode отвечает за синтаксис и битый JSON, validate() — за смысл полей. TrimSpace отсекает строки из одних пробелов, которые формально не пусты. Длина в рунах: len() посчитал бы байты, и заголовок из шестидесяти русских букв прошёл бы как короткий. strings.Contains — грубая прикидка, она честно допускает ab@ — настоящий разбор формата в примере умышленно оставлен библиотеке из следующего блока.

go-playground/validator: теги вместо сотен if#

Ручной validate() масштабируется плохо: на каждый тип запроса — свой метод, правила дублируются между хендлерами, длина и обязательность расходятся по версиям. Стандарт де-факто — go-playground/validator: go get github.com/go-playground/validator/v10, и поля структуры получают второй тег рядом с json — validate. Внутри тега правила через запятую: required, min и max для длины, email для формата, oneof для фиксированного набора значений, gte и lte для чисел, omitempty — «если поле пустое, остальные правила пропустить». Создаётся валидатор один раз на программу: validator.New(validator.WithRequiredStructEnabled()) — экземпляр кэширует разбор структур, и создавать его на каждый запрос значит терять эту экономию. Проверка — validate.Struct(in): nil, InvalidValidationError на неструктуру или ValidationErrors — срез FieldError, где Field() даёт имя поля, Tag() — нарушенное правило. Той же библиотекой gin пользуется под именем binding — опыт переносится без переучивания. Граница честности: теги закрывают формат, но не бизнес — «промокод существует» или «автора можно редактировать» останутся кодом.

validate.Struct в хендлере

GO
import (
        "strings"

        "github.com/go-playground/validator/v10"
)

// поля noteInput получают теги рядом с json-разметкой:
//   Title   string  validate:"required,min=3,max=120"
//   Email   string  validate:"omitempty,email"
//   Content string  validate:"omitempty,max=10000"

var validate = validator.New(validator.WithRequiredStructEnabled())

func describe(err error) string {
        var parts []string
        for _, fe := range err.(validator.ValidationErrors) {
                parts = append(parts, fe.Field()+": "+fe.Tag())
        }
        return strings.Join(parts, "; ")
}

// хендлер после Decode:
if err := validate.Struct(in); err != nil {
        renderError(w, r, http.StatusUnprocessableEntity, "validation_failed", describe(err))
        return
}

Один экземпляр валидатора живёт на весь сервис: New кэширует разбор структур, а создание на каждый запрос сжигает эту экономию. Теги в настоящем Go-коде пишутся в бэктиках над полем — здесь они в комментарии, чтобы строка не спорила с разметкой страницы. describe достаёт из FieldError имя поля и тег: сообщение честное, но парсить его клиенту по-прежнему нельзя, для машины есть code validation_failed. Модуль не запускается в песочнице — пробуйте локальным Go: go get, потом запрос с пустым title.

Проверка: что вернёт validate.Struct

validate.Struct(in) вызван на структуре с пустым Title и email без собаки, оба поля размечены тегами. Что вернёт вызов?

Ранняя валидация: middleware или хендлер#

Разбирая любое правило, задайте вопрос: оно про формат или про ресурс? id — число больше нуля, limit — в диапазоне, offset — неотрицателен: эти формулировки одинаковы для всех маршрутов сервиса, и их место — в middleware. Слой пагинации парсит query один раз, кладёт готовую структуру в контекст запроса — механикой context.WithValue и r.WithContext из урока про контекст — и все хендлеры ниже читают числа через helper с ok-формой, не занимаясь разбором. Ресурсные правила — title обязателен, email настоящий, промокод жив — знают только про конкретный ресурс, и им место в хендлере рядом с логикой. Линия раздела грубая, но рабочая: если правило формулируется без слова «заметка», это слой; нужна модель ресурса — хендлер. Выигрыш не в красоте: пагинация написана один раз, новые эндпоинты не копируют clamp, а старые не расходятся в деталях — то, что в ручной версии случалось через спринт.

Что проверяем → где → чем → ответ#

ПроверкаГдеЧемОтвет
id из пути — не число или переполнениехендлер, первая строкаstrconv.Atoi400 invalid_id
id вне диапазона: ноль или отрицательныйхендлербизнес-правило404 not_found
limit и offset отсутствуютmiddleware пагинациидефолт и clamp200 — работаем дальше
limit и offset — мусорmiddleware пагинацииstrconv.Atoi400 invalid_limit
Битый JSON в телехендлерjson.Decoder400 invalid_json
Пустой title, длинный content, кривой emailхендлерvalidate.Struct422 validation_failed

Чек-лист валидации запросов#

  • Ничему из запроса не верить: путь, query, заголовки и тело — строки и байты до проверки.
  • URLParam сверяется с шаблоном по буквам; Atoi проверяется ошибкой, диапазон — бизнес-правилом.
  • Query: отсутствие — дефолт, мусор — 400, превышение максимума — clamp с честным телом или явный отказ.
  • Тело: Decode отвечает за синтаксис и 400, смысл полей — второй рубеж и 422.
  • Общее для всех маршрутов — в middleware через контекст; ресурсное — в хендлере рядом с логикой.
  • Теги validator закрывают формат, бизнес-правила остаются кодом; все отказы — через renderError.

Соберите валидацию хендлера

Восстановите три вызова: создание валидатора, проверку структуры и выход из ветки ошибки.

var validate = validator.(validator.WithRequiredStructEnabled()) // хендлер после Decode: if err := validate.(&in); err != nil { renderError(w, r, http.StatusUnprocessableEntity, "validation_failed", describe(err)) }

Соберите начало getNote

Расставьте строки: от сигнатуры до закрытия ветки ошибки на нечисловом id.

Строки

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

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

Карточки: валидация параметров и тел

Термин

chi.URLParam с чужим именем

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

Молчаливая пустота: имя сверяется с шаблоном байт в байт, ошибки нет. Ловится первой строкой хендлера — Atoi от пустой строки даёт ошибку и ответ 400.

1 / 6

Каркас говорит с клиентом одним языком: битый id — 400, кривые поля — 422, всё через единый рендер ошибок, мусор не доезжает до базы и до логики. Параметры пути и query проверяются на границе — общее в middleware, ресурсное в хендлере, — тело проверяется сразу после декодера, и фронтенд ловит одну структуру ошибки вместо трёх. Дальше по курсу вернёмся к слоям: порядок middleware решает, что запрос успеет пройти до хендлера и что увидит строка лога — а неправильный порядок умеет делать 500 в логе зелёным 200. Валидация пишется один раз, а живёт в каждом ответе: держите её в таблице-контракте и тестах, а не в памяти последнего, кто правил хендлер.

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

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

Вывод

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

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

  • chi проверяет только маршрут: валидация запросов в go — работа хендлеров и middleware, а не роутера.
  • URLParam возвращает строки: Atoi с проверкой ошибки, диапазон — бизнес-правило; чужое имя параметра — молчаливая пустота.
  • Query: отсутствие — дефолт, мусор — 400, превышение максимума — clamp с честным телом или явный отказ.
  • Декодер гарантирует синтаксис JSON; смысл полей — второй рубеж: обязательные поля, длины в рунах, формат адреса.
  • Ручная валидация ветвится быстро; go-playground/validator закрывает формат тегами, FieldError даёт поле и правило.
  • Форматные правила — в middleware через контекст, ресурсные — в хендлере; слой не подменяет смысл ответа.
  • 400 — не смогли прочитать, 422 — прочитали и отказали; оба ответа — через renderError в едином формате.

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

Добавьте в каркас заметок валидацию по всей глубине: id через Atoi с веткой диапазона, middleware пагинации с дефолтом, максимумом и контекстом, теги validate на noteInput и ответ 422 через renderError. Прогоните curl-ом четыре запроса: /notes/abc, /notes/-5, /notes?limit=abc и POST с пустым title — каждый должен отвечать своим честным кодом из таблицы урока.

Личные заметкиПусто
Хранятся только в вашем браузере (Chi · Валидация параметров и тел запросов)

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

Почему chi не проверяет поля сам, как gin?

chi — минималистичный роутер: маршрутизация, URLParam, группы и middleware; валидация — зона приложения. В gin тот же go-playground/validator встроен по умолчанию под тегом binding, поэтому разница видна сразу. В chi валидатор подключается руками — той же библиотекой, просто без магии фреймворка и с прямым вызовом validate.Struct в хендлере.

Что отвечать на отрицательный id — 400 или 404?

Синтаксически -5 — валидное число, Atoi ошибку не вернёт. Дальше решает контракт ресурса: если нумерация начинается с единицы, заметки с id -5 не существует — честнее 404 not_found. Если отрицательные значения для ресурса бессмысленны в принципе, отвечайте 400. Главное — зафиксировать выбор в таблице статусов и не менять его между хендлерами.

Можно ли валидировать всё тело прямо в middleware?

Технически да: слой читает тело, проверяет и передаёт дальше, но тело придётся буферизовать и подменять r.Body, а правила у каждого ресурса свои. Разумная граница: формат и общие параметры — в middleware, смысл полей — в хендлере. Иначе слой превращается в свалку условий, которая растёт с каждым эндпоинтом и конфликтует сама с собой.

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

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

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

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

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

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

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