Валидация команды
Валидация отвечает на вопрос, допустима ли команда с точки зрения каталога. Успешное чтение JSON ещё не означает, что курс можно создать: название может состоять из пробелов, тема быть неизвестной, а число уроков отрицательным. В этом уроке введём модель входной команды и проверим её поля, сохраняя различие между binding и предметными правилами.
В lesson-08/after добавляется POST /api/courses/validate. Это учебная операция предварительной проверки: она не записывает курс и не изменяет четыре записи seed. Создание появится только в уроке 16. Непосредственный результат здесь — нормализованная допустимая команда или ошибка 400 с именами полей. Подготовка выполнялась чтением; никакой HTTP-вызов не запускался.
Входные данные и допустимое состояние
Модель JSON-команды имеет три поля. Идентификатора нет: при настоящем создании его будет назначать сервер. Полный файл Models/CourseInput.cs содержит входной тип, нормализованный тип и правила:
using System.Text;
namespace CatalogApi;
public sealed record CourseInput(string? Title, string? Topic, int Lessons);
public sealed record CourseDraft(string Title, string Topic, int Lessons);
public static class CourseValidation
{
public static Dictionary<string, string[]> GetErrors(CourseInput input)
{
var errors = new Dictionary<string, string[]>();
var title = input.Title?.Trim() ?? "";
var titleLength = 0;
foreach (var rune in title.EnumerateRunes()) titleLength++;
if (titleLength is < 2 or > 120)
errors["title"] = ["Название должно содержать от 2 до 120 символов."];
if (title.Contains('\0'))
errors["title"] = ["Название не должно содержать нулевой символ."];
if (input.Topic is not ("frontend" or "publishing"))
errors["topic"] = ["Допустимы frontend и publishing."];
if (input.Lessons is < 1 or > 500)
errors["lessons"] = ["Число уроков должно быть от 1 до 500."];
return errors;
}
public static CourseDraft Normalize(CourseInput input) =>
new(input.Title!.Trim(), input.Topic!, input.Lessons);
}
У CourseInput название и тема nullable. Это позволяет явно обработать отсутствующие значения после binding, вместо опасного обращения к Trim у null. Число уроков имеет тип int; если JSON не задаёт поле, значение по умолчанию будет нулём и нарушит правило диапазона. Мы не выдаём отсутствие числа за допустимый курс с нулём уроков.
Для названия убираются крайние пробелы, а затем проверяется длина от 2 до 120. Считаем Unicode-скаляры через EnumerateRunes, чтобы единица совпала с будущим char_length PostgreSQL при UTF-8. Это всё ещё не количество пользовательских графем: видимый символ с комбинирующим знаком может содержать несколько скаляров. Если продукту нужен предел видимых символов, потребуется отдельное согласование правила на клиенте, сервере и в хранилище.
Тема допускает ровно frontend или publishing. В отличие от названия, она не исправляется автоматически: Frontend и строка с добавленным пробелом считаются неверными. Это машинный идентификатор, который клиент должен брать из известного набора. Автоматическое приведение любых похожих значений может скрыть ошибку интеграции и сделать контракт неоднозначным.
Количество уроков ограничено диапазоном 1–500. Предел совпадает с несекретной настройкой курса, которая ранее была закреплена и проверяется при старте. В данной части серия не демонстрирует динамическое изменение лимита; это позволяет сохранить согласованные правила между ответом конфигурации, validator и будущим SQL.
Ошибки собираются по полям
GetErrors возвращает словарь, в котором ключ — имя поля публичного JSON, значение — массив пояснений. Даже если сейчас у поля одно сообщение, форма массива позволяет клиенту работать с единым контрактом. Не следует возвращать ключ Title, если клиент считает поле title: это сделает отображение ошибок зависящим от случайного регистра имён C#.
Все проверки выполняются независимо. Пользователь может получить ошибки названия, темы и числа за один ответ и исправить их вместе. Ранний return после первой ошибки заставил бы повторять отправку для каждого следующего поля. Однако сбор всех ошибок не означает, что нужно запускать дорогие запросы базы при уже неверной простой структуре: правила могут иметь разные уровни стоимости.
Normalize вызывается только после успешной проверки. Поэтому операторы ! у строк опираются на явное условие предыдущего шага, а не доказывают безопасность самостоятельно. Этот метод не является универсальной защитой от произвольного CourseInput: вызывающий код должен соблюдать договорённость. Более строгую форму можно позднее выразить отдельным результатом валидации, который не позволит получить draft при ошибках.
В endpoint добавлено следующее объявление внутри группы каталога:
group.MapPost("/validate", (CourseInput input) =>
{
var errors = CourseValidation.GetErrors(input);
return errors.Count > 0 ? Results.ValidationProblem(errors)
: Results.Ok(CourseValidation.Normalize(input));
});
Framework читает сложный входной объект из тела POST как JSON. GetErrors работает уже с полученным C#-значением. При непригодном JSON или строке вместо числа binding может отказать раньше handler; это не ошибка нашего словаря. Для нормального тела с неверными предметными значениями endpoint формирует ValidationProblem и не выдаёт успешный draft.
Сопоставление двух команд
Пусть клиент отправляет JSON с Content-Type: application/json:
{"title":" HTTP API ","topic":"frontend","lessons":8}
Ожидается 200 и нормализованный объект: название HTTP API без крайних пробелов, тема frontend, число 8. В ответе нет ID, Location или обещания созданного ресурса, поскольку операция лишь проверяет команду. Повторный GET списка всё ещё должен показать исходные четыре курса и сумму 60.
Другой JSON использует допустимые базовые типы, но нарушает все три предметных правила:
{"title":" ","topic":"other","lessons":0}
Ожидается 400 и объект ошибки с коллекцией errors. В ней должны присутствовать title, topic, lessons. Полное служебное оформление Problem Details разберём отдельно; здесь важно, что клиент может сопоставить сообщение конкретному полю формы. Не нужно анализировать человеческую фразу регулярным выражением, чтобы понять, куда поставить ошибку.
Мы используем ручную проверку и не подключаем автоматическую валидацию через AddValidation. Это существенно для .NET 10: наличие атрибутов или новых возможностей framework не заменяет явно показанную конфигурацию. В наших файлах нет дополнительного validation-пакета, и ожидаемое поведение объясняется вызовом GetErrors. Источники binding и результатов описаны в Minimal API parameter binding и Minimal API responses.
Проверка команды не является авторизацией. Даже полностью допустимый курс может быть запрещён конкретному пользователю, но пользователей в этой части проекта ещё нет. Endpoint предназначен для изолированной Development-песочницы. Не следует открывать будущие операции записи в сеть только потому, что они тщательно проверяют длину строки.
Словарь ошибок предназначен для клиента, поэтому формулировки должны объяснять исправимое условие. Сообщение «неправильный объект» не помогает понять, что именно заменить в форме. Вместе с тем не нужно выдавать внутреннее имя SQL CHECK или подробную диагностическую строку библиотеки: клиент работает с полем своей команды, а не с устройством сервера.
В имени курса также запрещён нулевой символ. PostgreSQL text не хранит U+0000, поэтому пропуск такого значения сделал бы последующую запись неожиданным отказом базы вместо понятной ошибки команды. Простая проверка этого условия находится рядом с проверкой длины. Она не означает запрета всего Unicode: обычные кириллические названия и многобайтные символы остаются допустимыми в согласованной UTF-8-модели.
После этого шага входной контракт, правила и нормализованное значение разделены явно. В следующем уроке рассмотрим другую сторону границы — типы результата handler, статус ответа и форму успешного представления.