FastAPI вернул внутреннее поле: ограничиваем ответ отдельной моделью

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

Можно вручную перечислить поля при каждом возврате. Однако следующий обработчик легко скопирует старую строку с целым словарём. Удобнее объявить форму публичного ответа рядом с маршрутом: FastAPI проверит результат и оставит в JSON только поля этой формы. Такое объявление называется response_model. Оно также задаёт схему ответа для OpenAPI, поэтому клиент и сервер видят один контракт.

Две модели вместо общей записи

Для создания пользователя нужны входные данные, для хранения — дополнительные служебные значения, а для страницы профиля — небольшой открытый набор. Эти множества пересекаются, но не обязаны совпадать. В примере публичная модель содержит id и name. Хранилище специально возвращает больше: это помогает проверить сам механизм фильтрации, а не случайное отсутствие секрета в тестовой записи.

Маршрут возвращает обычный словарь, поэтому аннотация функции остаётся общей. Конкретный внешний тип указан в декораторе. FastAPI применяет его после выполнения обработчика. Настройка работает на обычном пути сериализации; если самостоятельно вернуть готовый JSONResponse, разработчик берёт формирование ответа на себя. Для такого маршрута защита моделью уже не должна считаться автоматически действующей.

from fastapi import FastAPI
from fastapi.testclient import TestClient
from pydantic import BaseModel

app = FastAPI()
record = {"id": 7, "name": "Анна", "password_hash": "test-hash", "moderation_note": "internal"}

class UserPublic(BaseModel):
    id: int
    name: str

@app.get("/profile", response_model=UserPublic)
def profile() -> dict:
    return record

with TestClient(app) as client:
    response = client.get("/profile")
    assert response.status_code == 200
    assert response.json() == {"id": 7, "name": "Анна"}
    record["internal_score"] = 98
    assert client.get("/profile").json() == {"id": 7, "name": "Анна"}
    schema = client.get("/openapi.json").json()
    fields = schema["components"]["schemas"]["UserPublic"]["properties"]
    assert set(fields) == {"id", "name"}
print("public fields:", sorted(response.json()))

Сохраните код в profile_demo.py и запустите Python в окружении с FastAPI и HTTPX. Первый запрос получает именно два открытых поля. Затем тест добавляет в запись ещё один служебный атрибут и снова проверяет весь JSON на точное равенство. Это важнее отдельной проверки отсутствия password_hash: новый приватный атрибут тоже должен остаться внутри сервиса.

Проверяем ответ, который действительно получит клиент

Создание экземпляра UserPublic само по себе проверило бы только Pydantic. Здесь запрос проходит через маршрут, объявленную модель и сериализацию FastAPI. Поэтому тест обнаружит, например, случайное удаление response_model из декоратора. Для вашего API возьмите реальную служебную запись, заполните приватные поля различимыми тестовыми значениями и сравните полный ответ с разрешённым набором.

Если поле публичной модели обязательно, а обработчик его не вернул, это ошибка сервера: он нарушил собственный контракт. Не стоит лечить её превращением всех полей в необязательные. Сначала проверьте, откуда берётся значение и обещает ли API всегда его возвращать. При работе с ORM отдельно настройте чтение атрибутов модели и проверьте этот путь тем же HTTP-тестом.

Удаление поля из ответа и разрешение доступа решают разные задачи. Прежде чем отдавать даже открытые имя и номер, маршрут должен убедиться, что текущий пользователь вправе читать нужный профиль. А внутренние поля по-прежнему могут попасть в журнал, если логировать исходную запись целиком. Модель ответа контролирует конкретную границу — JSON клиенту; проверку доступа и содержимое логов следует рассматривать там, где они формируются.

После исправления полезно открыть /docs: схема профиля должна перечислять только публичные поля. Затем оставьте тест в проекте. При расширении хранилища он подтвердит, что контракт не расширился вместе с внутренней записью. Детали настройки доступны в разделе FastAPI о моделях ответа.

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

Как развить пример в приложение

Следующий шаг — вынести получение пользователя в зависимость, подключить базу и проверять права. В бесплатной программе FastAPI есть Pydantic, SQLAlchemy, авторизация и тесты через TestClient. Она помогает собрать эти части в один API.

Когда задача сместится к устройству самого Python, полезен платный курс «Python для профессионалов» — откройте программу. В нём разбираются специальные возможности объектов, менеджеры контекста и асинхронный код. Эти знания пригодятся, чтобы осмысленно проектировать собственные классы, управлять ресурсами зависимостей и понимать выполнение обработчиков. Сам FastAPI здесь показан в отдельной бесплатной программе.

Продолжить на практике

  • Python для профессионалов ↗

    Python для профессионалов — курс для людей, стремящихся углубить свои знания языка. Вы изучите итераторы, генераторы, декораторы, современные возможности Python (3.8+), освоите метапрограммирование, асинхронность, внутреннее устройство интерпретатора, инструменты оптимизации и профилирования, а также получите уверенность в использовании продвинутых возможностей стандартной библиотеки.