Response-модели и разделение входа и выхода

response_model описывает форму ответа: FastAPI фильтрует и валидирует возвращаемые данные по этой модели, что позволяет не отдавать наружу лишнее и чувствительное.

Главный принцип: модель входа и модель выхода — это разные модели. Клиент присылает пароль, но в ответ его возвращать нельзя. response_model гарантирует, что наружу уйдёт только разрешённое.

Соблазн использовать одну модель и для приёма, и для отдачи данных велик, но опасен. На входе вам нужен пароль, согласие с офертой, временные технические поля. На выходе — публичная проекция: id, имя, дата регистрации, но никак не хеш пароля. Если отдавать ту же модель, легко случайно «протечь» секретами. Поэтому в FastAPI принято заводить как минимум две модели и указывать выходную через response_model.

from fastapi import FastAPI
from pydantic import BaseModel, EmailStr

app = FastAPI()

class UserIn(BaseModel):       # вход: есть пароль
    email: EmailStr
    password: str

class UserOut(BaseModel):      # выход: пароля нет
    id: int
    email: EmailStr

@app.post("/users", response_model=UserOut)
async def create_user(user: UserIn):
    saved = {"id": 1, "email": user.email, "password": user.password}
    return saved   # FastAPI отфильтрует словарь по UserOut и НЕ вернёт password

Даже если обработчик вернёт словарь с лишними полями, FastAPI прогонит его через UserOut и оставит только id и email. Это работает как фильтр и страховка. Дополнительно response_model_exclude_none=True убирает из ответа поля со значением None, а response_model_exclude/include точечно управляют составом.

Как работает под капотом

FastAPI берёт то, что вернул обработчик, и валидирует это уже как выходную модель — то есть строит из результата экземпляр response_model и сериализует только его поля. Лишние ключи отбрасываются. Смоделируем фильтрацию ответа на stdlib:

def project(data, allowed_fields):
    # оставляем только поля, описанные в response_model
    return {k: data[k] for k in allowed_fields if k in data}

# то, что вернул обработчик (есть лишнее и секретное)
raw = {"id": 1, "email": "[email protected]", "password": "supersecret", "internal_flag": True}

allowed = ["id", "email"]            # поля UserOut
response = project(raw, allowed)
print("уйдёт клиенту:", response)
print("password просочился?", "password" in response)

Попробуй сам ▶ Поле password отсеяно — именно так response_model защищает от утечки чувствительных данных.

Частые ошибки

Первая и опасная — одна модель на вход и выход, из-за чего секреты утекают в ответ. Вторая — думать, что возврат «лишних» полей вызовет ошибку: FastAPI их просто отфильтрует, поэтому утечку легко не заметить без явной выходной модели. Третья — забывать про response_model там, где это важно для контракта и документации. Четвёртая — пытаться валидацией ответа чинить ошибки логики: response_model не для бизнес-правил, а для формы данных.

Best practices

  • Всегда разделяйте модели: XxxIn для входа, XxxOut для выхода.
  • Указывайте response_model явно — это контракт ответа и документация.
  • Никогда не включайте пароли, токены, внутренние флаги в выходные модели.
  • Управляйте составом ответа через exclude_none, exclude, include при необходимости.

Наследование моделей: Base, Create, Read

Чтобы не дублировать поля между входными и выходными моделями, применяют наследование. Заводят базовую модель с общими полями (ItemBase с name и price), от неё — входную ItemCreate, добавляющую то, что нужно только при создании, и выходную ItemRead, добавляющую серверные поля вроде id и created_at. Этот шаблон Base/Create/Read стал де-факто стандартом: общие поля описаны один раз, а различия видны явно. Он отлично сочетается с from_attributes=True в выходной модели, позволяя строить ответ прямо из ORM-объекта. Дисциплина «у каждой операции своя модель, общее — в базовой» делает контракт API читаемым и устойчивым к ошибкам: добавив поле в базу, вы автоматически получаете его и на входе, и на выходе, не рискуя забыть про одну из моделей.

Итог: response_model валидирует и фильтрует ответ, защищая от утечки лишнего. Разделение входных и выходных моделей — обязательная практика для безопасного API.

Проверьте себя
1. Что произойдёт, если обработчик с response_model=UserOut вернёт словарь, где есть лишнее поле password?
AFastAPI вернёт ошибку 500
BFastAPI отфильтрует ответ по UserOut, и password не попадёт клиенту
Cpassword вернётся клиенту как есть
DЗапрос зависнет
2. Почему рекомендуется разделять модели входа (UserIn) и выхода (UserOut)?
AТак быстрее работает сервер
BЭто требование Python
CЧтобы не возвращать наружу чувствительные поля (пароли, токены), нужные только на входе
DЧтобы документация была короче