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

Создание и изменение объектов

CRUD объединяет создание, чтение, изменение и удаление ресурса. Для HTTP API важен не только SQL, но и согласование входной команды, адреса, статуса и публичного ответа. В этом уроке добавим три изменяющие операции каталога, опираясь на уже введённые validation, асинхронный repository и DTO.

Исходное состояние — отдельная PostgreSQL-песочница с четырьмя курсами и служебной заметкой. Снимок lesson-16/after содержит полный конечный проект. Он предназначен только для Development на loopback, ещё не имеет входа пользователей, политик и CSRF-защиты. Не выставляйте эти операции в сеть. SQL, сервер, команды и тесты при подготовке не выполнялись; последовательность ниже описывает ожидаемые результаты.

Создание назначает новый адрес

POST /api/courses принимает знакомый CourseInput, собирает ошибки и только после успешной проверки вызывает CreateAsync. Фрагмент внутри группы endpoints выглядит так:

group.MapPost("", async (CourseInput input, ICatalogRepository repository, CancellationToken token) =>
{
    var errors = CourseValidation.GetErrors(input);
    if (errors.Count > 0) return Results.ValidationProblem(errors);
    var entity = await repository.CreateAsync(CourseValidation.Normalize(input), token);
    return Results.Created($"/api/courses/{entity.Id}", CourseMapping.ToResponse(entity));
});

Сервер создаёт ID через Guid.NewGuid().ToString("N"): это строка из 32 шестнадцатеричных символов. Она не зависит от названия курса и не задаётся клиентом. В SQL значения передаются параметрами, а RETURNING возвращает сохранённую строку. Handler преобразует её в DTO, затем формирует 201 и относительный Location нового курса.

Команда для учебного чтения имеет три поля:

{"title":"  HTTP API  ","topic":"frontend","lessons":8}

Ожидается создание курса с названием HTTP API, темой frontend, числом 8 и новым серверным ID. Обозначение <new-id> ниже не является буквальным идентификатором: читатель берёт реальное значение из тела или Location. GET /api/courses/<new-id> должен вернуть созданное представление, а список — пять записей с общей суммой 68.

POST не является идемпотентным в этом контракте. Повтор одинакового тела создаёт ещё один отдельный ресурс, поскольку генерируется новый ID. Если клиент потерял ответ, нельзя автоматически считать первую попытку неуспешной и бездумно повторить её. Надёжные повторы потребуют отдельного ключа операции или другого согласованного механизма; он здесь не реализован.

Замена публичных полей

PUT /api/courses/{id} принимает полную команду из названия, темы и числа уроков. Это замена именно разрешённых публичных изменяемых полей, а не частичная правка произвольного JSON. Если клиент пропустит обязательное значение, validator отклонит его, вместо сохранения прежнего значения по скрытому правилу.

В SQL ID берётся из пути и используется в WHERE, а остальные значения приходят параметрами. internal_notes не меняется. Поэтому замена публичного названия JavaScript не стирает внутреннюю заметку редактора. Успешный UPDATE возвращает строку через RETURNING; отсутствие возвращённой строки означает, что ID не найден, и handler выбирает 404.

Для созданного курса отправим полный JSON:

{"title":"HTTP API для каталога","topic":"frontend","lessons":10}

Ожидается 200 и обновлённый DTO с тем же <new-id>. В таблице остаётся пять записей, общая сумма становится 70. Повтор точно такой же PUT устанавливает те же значения, но это не означает защиту от конкурентного редактирования. Если два редактора прочитали прежнюю строку и последовательно отправили разные команды, поздняя замена может затереть раннюю.

В текущем уроке нет версии объекта, If-Match или optimistic-lock условия. Не следует обещать обнаружение устаревшего изменения только из-за применения PUT. Конкурентный контракт будет самостоятельной темой продолжения, где изменится и команда, и условие записи, и ответ на конфликт.

Удаление и повтор обращения

DELETE /api/courses/{id} вызывает параметризованную команду DELETE и читает число затронутых строк. Если удалена одна, handler возвращает 204 без тела. Если строка отсутствовала, возвращается знакомый 404 Problem Details. Repository не пытается сначала выполнить отдельный SELECT, потому что результат самой изменяющей команды уже сообщает нужный исход.

После удаления <new-id> ожидаются четыре исходные записи и сумма 60. GET удалённого курса даёт 404. Повтор DELETE тоже даёт 404, хотя состояние уже соответствует удалению. Это не противоречит идемпотентности эффекта DELETE: повтор не создаёт новое изменение, но конкретные статусы последовательных попыток могут различаться.

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

Ресурсы, ошибки и отмена

Полный PostgresCatalogRepository.cs в конечном снимке включает все методы. В каждой операции command и reader имеют ограниченное время жизни через await using. Значения команды передаются отдельно от SQL, token поступает в ожидание базы. Data source остаётся singleton, repository — scoped; ни один запрос не удерживает общий открытый reader для других клиентов.

Одна SQL-команда INSERT, UPDATE или DELETE выполняется атомарно как отдельный оператор PostgreSQL. Но это не демонстрация транзакции нескольких предметных шагов. Если позднее потребуется одновременно создать курс, уроки и запись аудита, понадобится явное согласование всех действий в одной транзакции. Наличие асинхронных методов не соединяет их автоматически.

Отмена token не доказывает откат уже совершённой записи. База могла выполнить и зафиксировать команду до прекращения ожидания ответа клиентом. Поэтому после неопределённого результата нужно восстановить наблюдение по договорённому адресу или механизму операции. Возврат 500, потерянное соединение и отсутствие видимого ответа нельзя автоматически считать доказательством отсутствия нового ресурса.

Предметные ошибки входа дают 400 до SQL. Отсутствующий ID при замене или удалении даёт 404. Неожиданная ошибка базы проходит к общей границе 500, а не маскируется успешным ответом. Редкая коллизия нового ID остаётся неожиданной ошибкой текущего маленького контракта; здесь нет обещания безусловно успешного создания при любом состоянии таблицы.

Нельзя проверять работоспособность статьи настоящими данными сайта. Для последующего исполнения используется отдельная пустая песочница, схема 14 плюс дополнение 15 и только учебная запись из этого урока. Общая модель доступа ещё впереди: аутентификация определит пользователя, политики — разрешённые действия, CSRF — условия изменяющего запроса с cookies. CORS сам по себе не решит ни одну из этих предметных обязанностей.

Порядок ручного учебного сценария выбран так, чтобы изменения оставались понятными: создание увеличивает сумму на восемь, замена меняет восемь на десять, удаление возвращает исходный набор. Если пропустить шаг и использовать ID из другого запуска, арифметика перестанет соответствовать тексту. Это не случайная ошибка API, а другое начальное состояние данных. Сохраняйте ID именно текущего ответа и не подставляйте фиксированную строку вместо серверного значения.

После этого шага каталог имеет согласованные формы CRUD, а внутренние данные не выдаются напрямую. Следующая часть серии начнётся с переноса фильтрации, порядка и страницы ближе к запросу базы; её уроки пока запланированы и не представлены здесь как опубликованное продолжение.