Перейти к содержанию

Результаты и HTTP-ответы

Результат обработчика связывает предметный исход с HTTP-ответом: статусом, заголовками и телом. Возвращённый объект, пустой ответ и ошибка отсутствующего ресурса имеют разный смысл для клиента. В этом уроке рассмотрим способы возвращения данных и сделаем варианты отдельного курса видимыми в типе C#-метода.

Исходное приложение умеет читать каталог, фильтровать список и проверять JSON-команду без записи. В lesson-09/after меняется GET /api/courses/{id}. Четыре курса сохраняются. Сервер и запросы при подготовке не запускались; показанные статусы являются ожидаемыми результатами выбранных ветвей кода.

Значение и HTTP-результат

Прямой возврат объекта удобен для простого успешного endpoint: framework сериализует его как JSON. Но у поиска одного курса есть два разных исхода. Если запись существует, требуется 200 с объектом; если отсутствует — 404 без успешного объекта. Нельзя вернуть null и одновременно считать, что клиент обязательно получит именно нужную ошибку каталога.

В предыдущем снимке мы выбирали Results.NotFound() и Results.Ok(course) внутри lambda. Методы Results позволяют выразить HTTP-поведение через общий интерфейс результата. Это удобно, когда endpoint содержит разные ветви. При этом читателю сигнатуры отдельного метода не видно, какой именно набор конкретных вариантов он может вернуть.

Для GET отдельного курса перенесём handler в именованный метод и используем TypedResults. Внутри существующего CatalogEndpoints появились следующие части:

group.MapGet("/{id}", FindCourse);

private static Results<Ok<Course>, NotFound> FindCourse(string id,
    ICatalogRepository repository)
{
    var course = repository.Find(id);
    return course is null ? TypedResults.NotFound() : TypedResults.Ok(course);
}

Для этого файл подключает Microsoft.AspNetCore.Http.HttpResults. Тип Results<Ok<Course>, NotFound> является допустимым объединением результатов handler. Он сообщает компилятору и читающему код человеку, что ожидаются успешный объект Course или отсутствие ресурса. Это не тот же класс, что статический помощник Results: совпадение части имени не делает их одинаковыми сущностями.

Если попытаться вернуть из такого метода несогласованный тип, например отдельный Created<Course>, потребуется изменить объявленный контракт результата. Это полезная граница: добавленный исход становится заметным при редактировании метода. Однако тип не доказывает, что выбранный статус соответствует предметной ситуации. Такое соответствие мы всё равно задаём и объясняем сами.

Документация Microsoft о Minimal API responses описывает Results, TypedResults и объединения типов. В нашем примере их задача узкая: сделать варианты ответа поиска видимыми. Генерация документации API появится в продолжении курса и не считается автоматически выполненной только из-за типизированного результата.

Успех означает конкретное представление

GET /api/courses/html-css ожидаемо даёт статус 200 и объект с ID html-css, темой frontend и числом 16. Успешный объект содержит данные ресурса. GET списка также даёт 200, но его тело — массив. Клиент должен знать контракт операции, поскольку одинаковый статус не означает одинаковую форму JSON.

Предварительная проверка команды из прошлого урока возвращает нормализованный draft без ID. Она тоже успешна с 200, но не создаёт ресурс. Поэтому заменить её ответ на 201 только ради впечатления «успешной отправки» было бы ошибкой: 201 сообщает о создании. Позднее POST создания вернёт новый ID и Location, и это будет другое действие.

204 означает отсутствие содержимого ответа. Такой результат полезен для успешного удаления, если клиенту не нужен объект в теле. Вместе с ним не следует обещать JSON со словом success: содержание противоречило бы выбранной форме. Пока операций удаления в полном снимке нет; мы обсуждаем семантику, которую применим в уроке CRUD.

Число статуса не является текстом внутри JSON. Объект {"status":404} при фактическом HTTP 200 остаётся успешным транспортным ответом с произвольным полем. Клиенты, прокси и инструменты смотрят на строку статуса HTTP. Поэтому нужно выбирать реальный результат, а не маскировать ошибку успешным ответом с полем error.

Заголовок не заменяет тело

HTTP-заголовки передают метаданные. Content-Type объясняет, какой формат имеет тело. Location при создании может указывать на адрес нового ресурса. В нашем успешном чтении JSON-тип определяется соответствующим result, а адрес курса известен из пути запроса. Не нужно добавлять произвольный Location для каждого GET без предметной причины.

Возвращая Results.Text, приложение передаёт текстовый результат. Этот способ позже используется для небольшой заметки каталога. Строка, содержащая фигурные скобки, не становится полноценным JSON-контрактом только по внешнему виду. Клиенту нужны правильный media type и согласованная форма данных, поэтому JSON следует получать через предназначенный результат или сериализуемую модель.

Типизированный успешный ответ также не ограничивает доступ к данным автоматически. Если в Course появится служебное поле и handler продолжит сериализовать весь объект, оно может попасть клиенту. В уроке о DTO отделим модель хранения от публичного представления. На текущем шаге четыре свойства Course сознательно являются публичными.

Отсутствие и ошибка входа

Поиск /api/courses/unknown выбирает правильный маршрут и получает null из repository, после чего возвращает NotFound. Это ожидаемый предметный исход, а не исключение. Программе не требуется падать или писать stack trace, чтобы сообщить, что запрошенного ID нет.

Неверный фильтр minLessons=0 относится к другой ситуации: запрос содержит недопустимое значение и получает 400. Пустой результат допустимого фильтра остаётся 200 с []. Если клиент различает эти три формы, он может показать «курс не найден», ошибку поля или «совпадений нет» без угадывания человеческого текста.

В текущем методе 404 не содержит объяснения в теле. Это достаточно для демонстрации набора результатов, но клиенту полезен единый формат ошибок. Изменение на Problem Details потребуется отразить и в типе объединения, и в обработке клиента: вместо пустого NotFound появится ProblemHttpResult со статусом 404.

Не следует путать тип результата с типом самого handler. Task в будущем будет описывать ожидание, а Ok<Course> — смысл уже подготовленного HTTP-ответа. Они относятся к разным измерениям одной операции. Асинхронный поиск способен вернуть тот же 200 или 404, что синхронный, если внешний контракт сохранился. Такое разделение позволяет менять механизм получения данных, не заставляя клиента угадывать внутреннюю реализацию по статусам.

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