Урок курса
Полное и частичное обновление
FastAPI для начинающих: API с базой данных и тестамиВ прошлом уроке мы добавили get_room_or_404 — вспомогательную функцию, которая либо возвращает найденную переговорную, либо сразу отдаёт 404. Теперь эта функция будет работать в обоих обработчиках обновления: PUT для полной замены и PATCH для изменения только присланных полей.
PUT /api/rooms/{room_id}: замена name и capacity
PUT в этом API означает полную замену записи: клиент обязан прислать все поля, а сервер перезаписывает их целиком, не сохраняя ничего из старых значений.
Для тела запроса используем уже знакомую RoomIn — она требует name и capacity с теми же ограничениями, что и при создании. Маршрут возвращает обновлённую переговорную через response_model=RoomOut.
@app.put("/api/rooms/{room_id}", response_model=RoomOut)
def update_room(room_id: int, room: RoomIn):
existing = get_room_or_404(room_id)
rooms_db[room_id] = {"id": room_id, "name": room.name, "capacity": room.capacity}
return rooms_db[room_id]
Порядок действий здесь принципиален. Сначала get_room_or_404 проверяет существование записи — если её нет, функция сразу выбрасывает HTTPException(404) и дальше ничего не происходит. Только после успешной проверки обработчик записывает новый словарь в rooms_db. id берётся из пути, а не из тела запроса — так он не может быть перезаписан клиентом случайно.
Запись с id=1 уже существует, а с id=99 — нет. Результат PUT зависит от тела и выбранного id:
Полное тело — запись меняется, ответ 200:
PUT /api/rooms/1
{"name": "Переговорная Альфа", "capacity": 12}
Сервер вернёт {"id": 1, "name": "Переговорная Альфа", "capacity": 12}. Последующий GET /api/rooms/1 покажет те же данные.
Пропущено обязательное поле — 422, запись не тронута:
PUT /api/rooms/1
{"name": "Переговорная Альфа"}
RoomIn требует capacity, и FastAPI вернёт 422 ещё до вызова функции. rooms_db не изменится.
Несуществующий id — 404:
PUT /api/rooms/99
{"name": "Переговорная Альфа", "capacity": 12}
get_room_or_404(99) выбрасывает 404. Это поведение нашего конкретного API: мы решили не создавать новую запись по произвольному id через PUT. Другие API могут делать иначе — это не стандарт HTTP, а контракт конкретного сервиса.
RoomPatch: выбор полей для частичного обновления
PATCH отличается от PUT тем, что клиент присылает только те поля, которые хочет изменить. Остальные поля должны остаться прежними.
Для этого нужна отдельная модель — RoomPatch. Оба поля в ней необязательны:
from typing import Optional
from pydantic import BaseModel, Field
class RoomPatch(BaseModel):
name: Optional[str] = Field(None, min_length=2, max_length=50)
capacity: Optional[int] = Field(None, ge=1, le=50)
Здесь None — это значение по умолчанию, которое Pydantic подставляет, если поле не пришло в запросе. Но одного этого недостаточно, чтобы понять, что именно хотел клиент: пропустил поле или явно передал null.
Отличить пропуск от явного null помогает model_fields_set — атрибут экземпляра модели, в котором Pydantic хранит имена полей, фактически присланных в запросе. Это поведение мы разбирали раньше; здесь оно становится основой логики частичного обновления.
Три показательных случая:
Только name:
{"name": "Бета"}
model_fields_set → {"name"}. Обработчик обновит только name; capacity останется прежним.
Только capacity:
{"capacity": 8}
model_fields_set → {"capacity"}. name не тронут.
Пустой объект:
{}
model_fields_set → пустое множество. Обработчик не изменит ничего и вернёт запись как есть.
Отдельный случай — явный null для одного из полей:
{"name": "Бета", "capacity": null}
model_fields_set → {"name", "capacity"}, потому что capacity был явно указан. room.capacity при этом равен None. Optional не запрещает null автоматически — поэтому обработчик должен отклонить явный null до изменения сохранённой записи.
PATCH: отказ при null до изменения rooms_db
Обработчик PATCH работает в два прохода по model_fields_set. Первый проход — только проверка, без записи. Второй — только запись, без повторной проверки.
@app.patch("/api/rooms/{room_id}", response_model=RoomOut)
def partial_update_room(room_id: int, room: RoomPatch):
existing = get_room_or_404(room_id)
# Проход 1: проверить все присланные поля на null
for field in room.model_fields_set:
if getattr(room, field) is None:
raise HTTPException(status_code=422, detail="Fields cannot be null")
# Проход 2: записать только после успешной проверки
for field in room.model_fields_set:
existing[field] = getattr(room, field)
return existing
Почему два цикла, а не один? Если объединить проверку и запись в одном проходе, запрос вроде {"name": "Бета", "capacity": null} может успеть обновить name до того, как цикл доберётся до capacity и бросит исключение. Порядок обхода set не гарантирован, поэтому результат зависел бы от случайности. Разделение циклов исключает частичные изменения: либо обновляются все присланные поля, либо ни одно.
При существующей записи PATCH принимает часть полей или пустой объект, но отклоняет явный null:
Частичное обновление — 200, второе поле не тронуто:
PATCH /api/rooms/1
{"capacity": 20}
Предположим, до запроса запись была {"id": 1, "name": "Переговорная Альфа", "capacity": 12}. После запроса GET /api/rooms/1 вернёт {"id": 1, "name": "Переговорная Альфа", "capacity": 20}. name остался прежним.
Пустой объект — 200, запись не изменилась:
PATCH /api/rooms/1
{}
model_fields_set пуст, оба цикла не выполняются. GET /api/rooms/1 вернёт ту же запись.
Явный null — 422, оба поля не тронуты:
PATCH /api/rooms/1
{"name": "Бета", "capacity": null}
Первый цикл обнаружит None для одного из полей и выбросит HTTPException(422, detail="Fields cannot be null"). До второго цикла дело не дойдёт. GET /api/rooms/1 покажет прежние значения — name тоже не изменился, хотя и прошёл бы проверку сам по себе.
Сама по себе эта проверка не защищает от конкурентных запросов: если два PATCH придут одновременно, rooms_db как простой словарь не даёт никаких гарантий. Но это уже вопрос за пределами текущего урока.

Попробуйте решить
Хранилище содержит {"id":2,"name":"Сигма","capacity":12}. Клиент отправляет PUT /api/rooms/2 с телом {"name":"Омега"} — поле capacity отсутствует. RoomIn объявляет оба поля обязательными. Какой статус вернёт сервер и изменится ли запись в хранилище?
Продолжить с проверкой и прогрессом
Откройте интерактивный раннер с заданиями урока.
