Волга
Главная
Agent Skill
API Docs
GitHub
  • English
  • Русский
Главная
Agent Skill
API Docs
GitHub
  • English
  • Русский
  • Главная
  • Старт

    • Быстрый старт
    • Обработчики
    • Параметры маршрута
    • Параметры запроса
    • Группировка маршрутов
  • Запросы и ответы

    • Заголовки (Headers)
    • Работа с JSON
    • Работа с Form Data
    • Работа с файлами
    • Multipart-ответы
    • Сырое тело запроса
    • Cookies
    • Валидация запросов
  • Middleware и инфраструктура

    • Основы Middleware
    • Пользовательские Middleware
    • Параметризованные Middleware
    • Сжатие ответов
    • Распаковка запросов
    • CORS (Cross-Origin Resource Sharing)
    • Статические файлы
    • Rate Limiting
    • Файлы конфигурации
    • OpenAPI
  • Безопасность и доступ

    • Аутентификация и авторизация
    • OAuth 2.1 и OpenID Connect
    • OAuth 2.1 Клиент
    • Гранты «сервис-сервис»
    • DPoP — токены, привязанные к отправителю
  • Надежность и наблюдаемость

    • Центральный обработчик ошибок
    • Логгирование и Трассировка
    • Отмена запросов
    • Плавное завершение работы
  • Протоколы и realtime

    • Версии и методы HTTP
    • HTTPS
    • WebSockets
    • Server-Sent Events (SSE)
  • Продвинутые паттерны

    • Внедрение Зависимостей
    • Пользовательская обработка методов HEAD, OPTIONS и TRACE
  • Agent Skill

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

Волга разбирает полезную нагрузку, но не проверяет её содержимое. Всё, что выходит за рамки serde — непустой ключ, ограниченный размер страницы, диапазон, который должен иметь смысл — раньше приходилось переписывать в начале каждого обработчика.

Начиная с v0.9.9 эти правила живут в одном месте: типаж Validate описывает, что тип считает валидным, а экстрактор Valid<E> его запускает. Valid<E> оборачивает другой экстрактор, вызывает validate() на том, что тот извлёк, и либо передаёт данные обработчику, либо превращает ошибку в ответ ещё до входа в обработчик.

Волга не знает ни одного из правил — она знает только, что вызвала функцию.

Быстрый старт

Типаж Validate и экстрактор Valid<E> доступны всегда. Макрос вывода включается фичей validation-derive, которая входит в full:

[dependencies]
volga = { version = "0.9.9", features = ["validation-derive"] }
use volga::{App, ValidJson, ok};
use volga::validation::Validate;
use serde::Deserialize;

#[derive(Deserialize, Validate)]
struct KeyValue {
    #[validate(length(min = 1, message = "key is required"))]
    key: String,

    #[validate(length(max = 4096))]
    value: String,
}

#[tokio::main]
async fn main() -> std::io::Result<()> {
    let mut app = App::new();

    app.map_post("/put", async |val: ValidJson<KeyValue>| {
        ok!("{}={}", val.key, val.value)
    });

    app.run().await
}

Невалидные данные до обработчика не доходят:

> curl -X POST "http://127.0.0.1:7878/put" -H "Content-Type: application/json" -d "{\"key\":\"\",\"value\":\"1\"}"
key: key is required

По умолчанию отдаётся 400 Bad Request — см. Выбор статуса, если нужен 422.

Экстрактор Valid<E>

Valid<E> оборачивает экстрактор, а не тип данных. Поскольку Json, Query, Form и NamedPath разыменовываются в свою полезную нагрузку, одна обобщённая реализация покрывает их все, и у каждого есть короткий псевдоним:

ПсевдонимРазворачивается вЧто проверяет
ValidJson<T>Valid<Json<T>>тело запроса в формате JSON
ValidQuery<T>Valid<Query<T>>параметры строки запроса
ValidForm<T>Valid<Form<T>>URL-encoded форму
ValidNamedPath<T>Valid<NamedPath<T>>именованные параметры маршрута

Все четыре реэкспортированы из корня крейта, поэтому use volga::ValidJson; и use volga::validation::ValidJson; — это один и тот же импорт.

Совет

Псевдонима над позиционным Path<T> нет: Path<T> из нескольких параметров читает кортеж, а для кортежа ваш крейт не может реализовать Validate. Используйте структуру с Deserialize, как описано в разделе Параметры маршрута, или валидируйте одиночный параметр собственного типа.

До 0.14.1 ValidNamedPath<T> назывался ValidPath<T> и читался как проверенный Path<T>. Старое имя по-прежнему компилируется, но с предупреждением об устаревании.

Обёртка передаёт дальше источник данных внутреннего экстрактора, поэтому проверяемое тело по-прежнему читается из тела запроса, а проверяемый query — из его частей. Оба могут проверяться в одном обработчике и прерываются на первой ошибке в порядке аргументов:

app.map_post("/items", async |filter: ValidQuery<Filter>, val: ValidJson<KeyValue>| {
    ok!("{}:{}", filter.per_page, val.key)
});

Valid<E> разыменовывается во внутренний экстрактор, а into_inner() его разворачивает.

Валидация параметра пути

Начиная с 0.13.0 одиночный параметр пути собственного типа можно валидировать без обёртки-структуры. Реализуйте FromPathArg, чтобы его прочитать, и Validate, чтобы его проверить, и принимайте его как Valid<Path<T>>:

use volga::{App, Path, error::Error, ok};
use volga::http::endpoints::args::{FromPathArg, PathArg};
use volga::validation::{Valid, Validate, ValidationError};

struct Page(u32);

impl FromPathArg for Page {
    fn from_path_arg(arg: &PathArg) -> Result<Self, Error> {
        arg.parse().map(Page)
    }
}

impl Validate for Page {
    type Error = ValidationError;

    fn validate(&self) -> Result<(), Self::Error> {
        if self.0 == 0 {
            return Err(ValidationError::field("page", "must be at least 1"));
        }
        Ok(())
    }
}

#[tokio::main]
async fn main() -> std::io::Result<()> {
    let mut app = App::new();

    // GET /pages/3 -> 3
    // GET /pages/0 -> 400 "page: must be at least 1"
    app.map_get("/pages/{page}", |Valid(Path(Page(page))): Valid<Path<Page>>| ok!("{page}"));

    app.run().await
}

Значение, которое не разбирается, отклоняет FromPathArg с ответом 400 — ещё до вызова validate().

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

#[derive(Validate)] генерирует ровно тот же validate(), что написали бы вручную, исходя из ограничений, объявленных на полях. Это не второй способ валидации: точкой расширения остаётся типаж, поэтому выведенная и написанная вручную реализации для Valid<E> неразличимы.

ПравилоК чему применяетсяПример
length(min, max, equal)строки и коллекции#[validate(length(min = 1, max = 64))]
range(min, max)всё, что упорядочено#[validate(range(min = 1, max = 100))]
nestedполе, которое проверяет себя само#[validate(nested)]
custom = "path::to::fn"что угодно#[validate(custom = "is_supported_sort")]
rename = ".."имя, под которым сообщается об ошибке#[validate(rename = "explicit")]

length и range дополнительно принимают необязательный message = "..", который заменяет текст по умолчанию. Строки измеряются в символах (Unicode scalar values) — именно так считают minLength / maxLength в OpenAPI; коллекции измеряются в элементах.

Все ошибки собираются, а не прерываются на первой, и каждое ограничение и сообщение отрисовывается на этапе раскрытия макроса, поэтому неудачная проверка кладёт &'static str и ничего не аллоцирует.

Пользовательские и межполевые правила

Функция custom принимает ссылку на поле и возвращает Result<(), ValidationError>. Она вызывается напрямую, поэтому проверка, написанная для &str, примет и поле типа &String за счёт deref-приведения:

use volga::validation::ValidationError;

fn is_supported_sort(value: &str) -> Result<(), ValidationError> {
    match value {
        "asc" | "desc" => Ok(()),
        other => Err(ValidationError::message(format!("`{other}` is not a sort order"))),
    }
}

Правило, связывающее два поля, не может жить ни на одном из них, поэтому для таких случаев контейнер принимает #[validate(schema = "path::to::fn")] — именно поэтому макрос является синтаксическим сахаром над типажом, а не его заменой:

use serde::Deserialize;
use volga::validation::{Validate, ValidationError};

#[derive(Deserialize, Validate)]
#[validate(schema = "from_is_before_to")]
struct Filter {
    #[validate(range(min = 1, max = 100))]
    per_page: u32,

    from: Option<u32>,
    to: Option<u32>,
}

fn from_is_before_to(filter: &Filter) -> Result<(), ValidationError> {
    if let (Some(from), Some(to)) = (filter.from, filter.to)
        && from > to
    {
        return Err(ValidationError::field("from", "must not be after `to`"));
    }
    Ok(())
}

Сначала выполняются правила полей, затем функции schema контейнера.

Вложенные типы

nested объединяет ошибки дочернего типа под именем parent.child, а вложенную коллекцию — под parent[0].child. Обёртки Option<T> и Vec<T> раскрываются:

use serde::Deserialize;
use volga::validation::Validate;

#[derive(Deserialize, Validate)]
struct Item {
    #[validate(length(min = 1))]
    name: String,
}

#[derive(Deserialize, Validate)]
struct Order {
    #[validate(nested)]
    head: Item,

    #[validate(nested)]
    items: Vec<Item>,

    #[validate(nested)]
    note: Option<Item>,
}

Для Order с пустым items[1].name будет сообщён именно этот путь.

Что макрос отвергает

Атрибут, который макрос смог бы выполнить лишь наполовину, отвергается на этапе компиляции, а не применяется частично:

  • equal рядом с min или max — equal задаёт проверку сам по себе, поэтому соседнее ограничение никто бы не прочитал.
  • Ограничение range, которое не является ни литералом, ни константой. Ограничение читается один раз при описании маршрута и ещё раз на каждом запросе; всё, что могло бы ответить по-разному в этих двух случаях, опубликовало бы контракт, который сервер не соблюдает.
  • Нелитеральное ограничение range без message = ".." — нет текста, который можно подставить в сообщение по умолчанию, а имя константы вместо её значения ничего не скажет клиенту.

Правило, которое действительно меняется, — это custom.

Реализация Validate вручную

Макрос необязателен. Прямая реализация типажа делает то же самое, и именно её стоит выбрать для типа с правилами, которые макрос не покрывает:

use volga::validation::{Validate, ValidationError};
use serde::Deserialize;

#[derive(Deserialize)]
struct KeyValue {
    key: String,
    value: String,
}

impl Validate for KeyValue {
    type Error = ValidationError;

    fn validate(&self) -> Result<(), Self::Error> {
        let mut err = ValidationError::new();
        if self.key.is_empty() {
            err.push("key", "key is required");
        }
        if self.value.len() > 4096 {
            err.push("value", "value is too long");
        }
        err.into_result()
    }
}

Validate::Error не обязан быть ValidationError: подойдёт любой тип, который преобразуется в Error из Volga, и отвечает он статусом, который даёт это преобразование. Для собственного типа ошибки это одна реализация IntoError — начиная с 0.12.0 она заменяет написанный вручную From<T> for Error, который сама и предоставляет.

ValidationError

ValidationError — это то, во что реализации Validate накапливают ошибки.

МетодЧто делает
new()пустая ошибка для накопления
push(field, message) / push_message(message)добавляет ошибку с полем или без него
into_result()Ok(()), если ничего не накопилось, иначе Err(self)
field(name, message) / message(text)одна ошибка, для варианта «падать сразу»
entries()перебор накопленных пар (Option<&str>, &str)
merge(other) / merge_at(prefix, other)вливает одну ошибку в другую, при необходимости под префиксом
with_status(status)переопределяет статус, которым отвечает ошибка
is_empty() / len() / status()чтение накопленного состояния

Выбор статуса

ValidationError отвечает 400 Bad Request. Ошибки разбора уже отвечают 400, поэтому такое значение по умолчанию делает некорректно сформированные и невалидные данные неразличимыми по статусу. Сервис, которому нужен 422, говорит об этом явно:

ValidationError::field("key", "key is required")
    .with_status(StatusCode::UNPROCESSABLE_ENTITY)

Статус, запрошенный влитой ошибкой, сохраняется, поэтому with_status работает из функции schema или custom ровно так же, как из написанной вручную реализации.

Имена в сообщениях об ошибках

Ошибки сообщаются под тем именем, которое прислал клиент, а не под именем поля в Rust. #[serde(rename = "..")] и #[serde(rename_all = "..")] читаются с типа, а #[validate(rename = "..")] перекрывает оба:

use serde::Deserialize;
use volga::validation::Validate;

#[derive(Deserialize, Validate)]
#[serde(rename_all = "camelCase")]
struct Renamed {
    #[validate(length(min = 1))]
    page_size: String,          // сообщается как `pageSize`

    #[serde(rename = "sortOrder")]
    #[validate(length(min = 1))]
    sort: String,               // сообщается как `sortOrder`
}

#[validate(rename)] переименовывает только сообщение об ошибке — ограничение обязано остаться привязанным к имени, под которым свойство известно схеме, иначе правило молча выпало бы из спецификации OpenAPI. Поле с #[serde(flatten)] сообщается на том уровне, на котором его прислал клиент, без имени, которое оно носит в Rust.

Композиция с Option и Result

Option<Valid<..>> и Result<Valid<..>, Error> компонуются так же, как вокруг любого другого экстрактора. Первый проглатывает ошибку, второй — способ обработать её самому:

use volga::{App, ValidJson, error::Error, ok, status};

// `None`, если данные отсутствуют или невалидны
app.map_post("/put", async |val: Option<ValidJson<KeyValue>>| match val {
    Some(val) => ok!("{}", val.key),
    None => ok!("none"),
});

// Ошибка в руках обработчика
app.map_post("/items", async |val: Result<ValidJson<KeyValue>, Error>| match val {
    Ok(val) => ok!("{}", val.key),
    Err(err) => status!(422, "{err}"),
});

Problem Details

С фичей problem-details ValidationError отрисовывается по RFC 9457 с расширением errors, которое сопоставляет каждому полю собранные для него сообщения. map_problem() подхватывает это без дополнительной настройки:

let mut app = App::new();

app.map_problem();

app.map_post("/put", async |val: ValidJson<KeyValue>| {
    ok!("{}={}", val.key, val.value)
});
> curl -X POST "http://127.0.0.1:7878/put" -H "Content-Type: application/json" -d "{\"key\":\"\",\"value\":\"...\"}"
{
  "type": "https://tools.ietf.org/html/rfc9110#section-15.5.1",
  "title": "Bad Request",
  "status": 400,
  "detail": "key: key is required; value: value is too long",
  "errors": {
    "key": ["key is required"],
    "value": ["value is too long"]
  }
}

Ошибки, не привязанные к полю, группируются под пустым ключом. Об остальной поддержке Problem Details см. Глобальная обработка ошибок.

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

Собственный тип ошибки библиотеки валидации, IntoError и volga::error::Error — все внешние для вашего крейта, поэтому ни IntoError, ни From там реализовать нельзя. Invalid<E> — это newtype, который решает задачу одним словом:

use volga::validation::{Invalid, Validate};

impl Validate for KeyValue {
    type Error = Invalid<TheirError>;

    fn validate(&self) -> Result<(), Self::Error> {
        self.check().map_err(Invalid)
    }
}

Этим отношения Волги с такими библиотеками и исчерпываются: ни зависимости, ни фичи, ни обобщённой реализации поверх чужого типажа — ровно столько места, чтобы поместить её за написанной вручную реализацией Validate. Invalid отвечает 400 Bad Request, а into_inner() возвращает исходную ошибку.

OpenAPI

Объявленные правила не только применяются, но и публикуются. Validate::constraints() сообщает, что объявлено на полях, а Valid<E> передаёт это в описание OpenAPI — на свойство схемы тела запроса или на параметр, добавленный тем же экстрактором, и никогда на параметр, принадлежащий другому аргументу обработчика. Поэтому обёртывание экстрактора не выбрасывает молча тело запроса или параметры запроса из спецификации.

Какое ключевое слово получит правило размера, решается по схеме, а не по написанию поля, поэтому minLength, minItems и minProperties описывают именно то, что должны, даже когда тип достигается через псевдоним. Ограничение, объявленное дважды, публикуется как пересечение, из-за чего порядок написания правил не имеет значения.

То, что нельзя опубликовать точно, не публикуется вовсе, а не округляется: числовое ограничение сохраняет разрядность и знаковость, ограничение с плавающей точкой читается на той разрядности, на которой сравнивается, а то, что не помещается ни в одно число JSON — 128-битное ограничение больше u64::MAX — не публикуется. Задокументированное и применяемое ограничения не могут разойтись.

У constraints() есть реализация по умолчанию, возвращающая пустой список, поэтому написанной вручную реализации Validate это не касается.

Совет

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

Полный пример

Последнее обновление: 21.10.2026, 16:26
Prev
Cookies