---
title: "Валидация параметров и тел запросов"
description: "Валидация запросов в go на chi: strconv.Atoi и отрицательный id, диапазоны limit и offset, проверка тела JSON после декодера, go-playground/validator, 400 против 422."
course: "Chi: роутер для Go HTTP-сервисов"
url: "https://golangmaster.ru/courses/chi/lessons/17-urlparam-validation/"
published: "2026-09-19"
updated: "2026-09-27"
minutes: 18
---

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

*Курс «Chi: роутер для Go HTTP-сервисов», урок 17 из 25. ~18 минут. Обновлено 27.09.2026. Источник: https://golangmaster.ru/courses/chi/lessons/17-urlparam-validation/*

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

Тестировщица поймала за вечер три бага, и все три — про одно: сервис верил всему, что ему прислали. 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](/courses/chi/lessons/04-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 и что произойдёт дальше?

- ✅ Пустую строку, затем Atoi вернёт ошибку — первая ветка хендлера ответит 400 invalid_id
- Значение параметра: URLParam сопоставляет имена по смыслу, а не по буквам
- Пустую строку, а Atoi вернёт 0 без ошибки — будет найдена заметка с id 0
- Панику: незнакомое имя параметра — ошибка программирования

URLParam сверяет имя с шаблоном байт в байт и на незнакомое имя отвечает молчаливой пустотой — ни ошибки, ни паники. Дальше работает валидация: Atoi от пустой строки возвращает ошибку, и первая же ветка превращает её в 400. Молчаливая пустота из урока про URLParam обезвреживается в первой строке хендлера — поэтому разбор и стоит первым делом.

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

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

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

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

**Что выведет программа?**

- ✅ 9223372036854775807 strconv.ErrRange: число не влезло в int, вернулся максимум диапазона и ошибка
- 0 strconv.ErrRange: неверное значение отброшено, вернулся ноль
- 99999999999999999999 nil: Atoi работает с числами любой длины
- Панику: строка слишком длинная для int

При переполнении Atoi возвращает и число, и ошибку одновременно: число — максимум диапазона платформы, на 64-битной — 9223372036854775807, ошибка — ErrRange. Полагаться на число нельзя, проверять нужно именно ошибку: ветка err != nil отсеет и нечисло, и двадцатизначный id одним движением, без отдельных проверок длины.

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

Декодер из [урока про JSON API](/courses/chi/lessons/11-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@ — настоящий разбор формата в примере умышленно оставлен библиотеке из следующего блока.

> **Спор: 400 против 422.** Половина команд отвечает 400 на любую плохую заявку, половина делит: синтаксис — 400, семантика — 422. История у 422 вебдовская, в RFC 9110 код живёт под именем Unprocessable Content, и обе политики формально законны. Я делю, как в уроке про централизованные ошибки: 400 — «не смог прочитать», 422 — «прочитал, но так нельзя» — фронтенд и мониторинг ветвят по ним логику по-разному. Граница одна: выберите раз и запишите в таблицу-контракт, потому что смена политики на середине жизни API ломает всех клиентов сразу.

### 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 без собаки, оба поля размечены тегами. Что вернёт вызов?

- ✅ ValidationErrors — срез FieldError, по одному на каждое нарушенное правило
- InvalidValidationError: поля не соответствуют требованиям
- nil: валидатор проверяет только типы, а не содержимое
- Панику: валидатор бросает исключение на первом нарушении

ValidationErrors — обычный срез FieldError: его достают через type assertion и перебирают, Field() даёт имя поля, Tag() — нарушенное правило вроде required или email. InvalidValidationError — другой случай: validate.Struct вызвали не на структуре вовсе. Паники нет — библиотека возвращает ошибки, а не бросает их, и каждую FieldError можно превратить в строку сообщения.

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

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

> **Слой, который решил за все ресурсы.** Пагинацию-слой написали с максимумом сто — и забыли про отчётный эндпоинт, которому нужны выгрузки по пять тысяч строк. Слой молча сжимал limit до сотни, отчёт отдавал первые сто строк, и полгода никто не замечал: отчёт-то есть. Молчаливый clamp — та же болезнь, что пустой URLParam: решение принято за ресурс без его ведома. Фиксирую так: сжимать лимит можно только с ответом, где ограничение видно честно — поля total и limit в теле, — либо отвечать 400 на превышение. Слой вправе ограничивать формат, но не вправе подменять смысл ответа.

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

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

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

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

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

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

```go
var validate = validator.___(validator.WithRequiredStructEnabled())

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

Подсказка: Валидатор создаётся один раз через validator.New, структура проверяется validate.Struct, а после renderError хендлер обязан вернуться.

Правильные ответы в пропусках: `New`, `Struct`, `return`

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

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

Правильный порядок строк:

1. `func getNote(w http.ResponseWriter, r *http.Request) {`
2. `id, err := strconv.Atoi(chi.URLParam(r, "id"))`
3. `if err != nil {`
4. `renderError(w, r, http.StatusBadRequest, "invalid_id", "id должен быть числом")`
5. `return`
6. `}`

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

- **chi.URLParam с чужим именем** — Молчаливая пустота: имя сверяется с шаблоном байт в байт, ошибки нет. Ловится первой строкой хендлера — Atoi от пустой строки даёт ошибку и ответ 400.
- **strconv.Atoi на переполнении** — Возвращает максимум диапазона и ошибку ErrRange одновременно; проверять нужно именно ошибку, число — мусор. Нечисло даёт ErrSyntax с нулём.
- **400 против 422** — 400 — не смогли прочитать: битый JSON, битый id. 422 — прочитали, но поля не проходят: пустой title, кривой email. Одна политика на весь API.
- **validate.Struct(in)** — nil, InvalidValidationError (не структура) или ValidationErrors — срез FieldError с Field() и Tag(). Один экземпляр валидатора на программу: New кэширует разбор структур.
- **Тег omitempty** — Пустое поле пропускает остальные проверки: omitempty,email разрешает и отсутствие адреса, и настоящий. Без него пустой email упадёт на email-теге.
- **Middleware или хендлер** — Правило без знания ресурса (id, пагинация) — в middleware через контекст; ресурсное (обязательный title) — в хендлере. Слой не подменяет смысл ответа.

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

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

- 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 не проверяет поля сам, как 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, смысл полей — в хендлере. Иначе слой превращается в свалку условий, которая растёт с каждым эндпоинтом и конфликтует сама с собой.
