Урок курса

Полное и частичное обновление

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 как простой словарь не даёт никаких гарантий. Но это уже вопрос за пределами текущего урока.

Два независимых обновления исходной записи Альфа, capacity 5. PUT с name Бета и capacity 10 даёт Бета, 10. PATCH только с capacity 10 сохраняет Альфа. PATCH с name null получает 422 до изменения записи.
Оба варианта начинаются с «Альфа», 5 мест: PUT заменяет name и capacity, PATCH — только присланное поле. Явный null отклоняется с 422 до записи; прежние значения сохраняются.

Задача 1

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

Выберите один вариант
Без регистрации · результат не сохраняется

Задача 2

Разработчик реализовал PUT-маршрут, но забыл вызвать get_room_or_404 перед перезаписью. Вместо этого он сразу пишет rooms_db[room_id] = {"id": room_id, "name": room.name, "capacity": room.capacity}. Клиент отправляет PUT /api/rooms/99, запись с id=99 в rooms_db отсутствует. Что произойдёт?

Выберите один вариант
Без регистрации · результат не сохраняется

Задача 3

В rooms_db сохранено {"id":1,"name":"Альфа","capacity":5}. PATCH сначала проверяет все переданные значения на None и при любом None выбрасывает HTTPException(422, detail="Fields cannot be null"), а затем отдельным циклом изменяет запись. Клиент прислал {"name":"Бета","capacity":null}. Какой ответ он получит и какие name/capacity останутся в хранилище?

Выберите один вариант
Без регистрации · результат не сохраняется

Задача 4

Хранилище содержит одну запись: {"id": 1, "name": "Альфа", "capacity": 5}. Клиент последовательно выполняет два запроса:

  1. PUT /api/rooms/1 с телом {"name": "Бета", "capacity": 8}
  2. PATCH /api/rooms/1 с телом {"name": "Гамма"}

Оба маршрута реализованы корректно. Какое значение поля capacity будет в ответе на второй запрос? Введите число.

Без регистрации · результат не сохраняется

Задача 5

Полное описание переоборудованной переговорной

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

Roomly устроена так, что каждая переговорная хранится под собственным идентификатором, и этот идентификатор остаётся неизменным даже тогда, когда само помещение меняется до неузнаваемости. Чтобы обновить карточку, недостаточно подправить отдельные детали: новое описание должно целиком встать на место прежнего, вытеснив его без остатка. Если какие-то необязательные сведения не включены в новое описание, система не будет молча достраивать карточку из старых данных — она примет именно то, что передал менеджер, и ничего сверх этого.

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

Что уже дано

Это самостоятельный файл main.py для браузерного редактора: код ваших прежних решений не переносится автоматически. Модели Address, RoomIn, RoomOut, верхнеуровневый app, пустой rooms_db и начальный next_id уже в файле. Это отдельное упражнение на полную карточку из урока11, а не объединение всех прежних каталогов и фильтров. POST, GET и get_room_or_404 уже работают. PUT пока возвращает старую карточку: дополните только это действие; PATCH в этой лабе не нужен.

Служебный переходник выполняется перед файлом, но не создаёт app или данные. Он только передаёт запросы и результаты между приложением и скрытой проверкой; менять его не нужно.

Что нужно сделать

Реализуйте полную замену существующей комнаты через PUT /api/rooms/{room_id} с моделью RoomIn и ответом200/RoomOut. Перед заменой используйте готовый get_room_or_404(room_id). Сохраните выбранный серверный id, замените name, capacity и весь address новым описанием. comment принимает новое значение; если его нет в теле PUT, результат содержит comment: null, а не прежний комментарий. Последующий GET возвращает сохранённый результат; остальные комнаты остаются прежними. Новые значения могут совпадать со старыми.

Готовая RoomIn принимает обязательные name (строка длиной2–50), capacity (целое1–50) и address (объект с обязательными строками city, street). comment — необязательная строка или null, default — None. Сохраните эту валидацию: неверное тело получает стандартный422, включая путь вложенного поля. Публичная карточка содержит ровно id, name, capacity, address, comment; internal_note наружу не попадает. Лишние входные поля игнорируются; клиентские id и internal_note не подменяют серверные данные.

Пропуск обязательного поля и недопустимое тело дают422 до изменения любой записи; прежние поля не дополняют неполное тело PUT. Корректный PUT по отсутствующему целочисленному id возвращает404/{"detail":"Room not found"} и не создаёт запись. Нечисловой path-параметр даёт стандартный422. POST продолжает создавать записи с201. Все запросы последовательны, в одном запуске. DELETE, PATCH, база данных и сохранность после перезапуска не требуются.

Ввод и вывод

stdin не используется. Печатать через print в stdout ничего не нужно. После выполнения файла проверяющая система обращается к верхнеуровневому app: публичный переходник вызывает TestClient внутри процесса приложения и передаёт результат скрытой проверке. Запускать Uvicorn, сеть, клиентский скрипт или pytest не требуется.

Если у созданной комнаты id=1, PUT /api/rooms/1 с {"name":"Липа","capacity":8,"address":{"city":"Омск","street":"Лесная"}} возвращает200 и эту полную карточку с id: 1, comment: null. GET по тому же id возвращает такой же результат.

РешениеPython

Без регистрации. Гостевая проверка не сохраняет прогресс. Для сохранения перейдите к уроку.

Отправьте решение, чтобы увидеть результаты тестов.

Задача 6

Новое название без потери сведений

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

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

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

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

Что уже дано

Это самостоятельный файл main.py для браузерного редактора: код ваших прежних решений не переносится автоматически. Модели Address, RoomIn, RoomOut, верхнеуровневый app, пустой rooms_db и начальный next_id уже в файле. Это отдельное упражнение на полную карточку из урока11, а не объединение всех прежних каталогов и фильтров. POST, GET, helper и полный PUT уже работают. Готовая RoomPatch содержит только name/capacity. Дополните PATCH; готовые действия переписывать не нужно.

Служебный переходник выполняется перед файлом, но не создаёт app или данные. Он только передаёт запросы и результаты между приложением и скрытой проверкой; менять его не нужно.

Что нужно сделать

Реализуйте PATCH /api/rooms/{room_id}: ответ200 с полной RoomOut, а GET затем возвращает сохранённую карточку. RoomPatch допускает любое подмножество name и capacity; {} оставляет комнату без изменений. Используйте model_fields_set, чтобы отличать пропуск от явного null. До любых изменений записи проверьте все присланные значения: явный null любого из этих двух полей даёт422 с {"detail":"Fields cannot be null"}. Смешанный запрос с допустимым значением и null также не меняет ничего, независимо от порядка полей.

Непереданные name/capacity, серверный id, address и comment остаются прежними. address и comment не входят в эту PATCH-модель; как и другие лишние поля, они игнорируются. Для переданных ненулевых значений сохраняются ограничения name2–50 и capacity1–50: недопустимый тип или граница дают стандартный422 до изменения записи. Отказ не изменяет ни выбранную комнату, ни соседние записи.

Сначала используйте get_room_or_404(room_id): корректный PATCH по отсутствующему целочисленному id возвращает404/{"detail":"Room not found"}. Нечисловой path-параметр даёт стандартный422. Готовые POST/GET/PUT сохраняют свои контракты. Готовая RoomIn принимает обязательные name (строка длиной2–50), capacity (целое1–50) и address (объект с обязательными строками city, street). comment — необязательная строка или null, default — None. Сохраните эту валидацию: неверное тело получает стандартный422, включая путь вложенного поля. Публичная карточка содержит ровно id, name, capacity, address, comment; internal_note наружу не попадает. Лишние входные поля игнорируются; клиентские id и internal_note не подменяют серверные данные.

Работа последовательна в одном запуске; DELETE, база данных, изменение адреса через PATCH и сохранность после перезапуска не нужны.

Ввод и вывод

stdin не используется. Печатать через print в stdout ничего не нужно. После выполнения файла проверяющая система обращается к верхнеуровневому app: публичный переходник вызывает TestClient внутри процесса приложения и передаёт результат скрытой проверке. Запускать Uvicorn, сеть, клиентский скрипт или pytest не требуется.

Если созданная карточка с id=1 содержит name: "Липа", capacity: 4, PATCH /api/rooms/1 с {"capacity":8} возвращает200: вместимость стала8, остальные поля сохранены. Тело {"name":"Кедр","capacity":null} получает422; оба прежних значения остаются в карточке.

РешениеPython

Без регистрации. Гостевая проверка не сохраняет прогресс. Для сохранения перейдите к уроку.

Отправьте решение, чтобы увидеть результаты тестов.

Задачи к уроку

Все задачи курса →

Продолжить с сохранением прогресса

Перейдите к уроку в учебном кабинете.

Перейти к интерактивному уроку