Переименование JSON-поля в FastAPI: принимаем старое имя, возвращаем новое

Frontend перешёл на displayName, а мобильное приложение ещё отправляет full_name. Сервер должен пережить период обновления клиентов. Дублировать поле в модели неудобно: появляется два значения одного свойства, и дальше приходится решать, какое сохранить. Проще оставить одно внутреннее имя и отдельно определить допустимые имена входа и имя выхода.

Разделим внутреннюю модель и сетевой договор

Внутри Python удобно использовать full_name. На входе validation_alias с AliasChoices принимает два известных ключа. На выходе serialization_alias задаёт displayName. FastAPI сериализует response_model с алиасами по умолчанию; в примере настройка response_model_by_alias=True указана явно, чтобы договор был виден рядом с маршрутом.

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

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

Проверим период совместимости

Для запуска нужны FastAPI, HTTPX и Pydantic v2. model_validator(mode="before") получает исходный объект до извлечения алиасов. Он проверяет только двусмысленность ключей; само имя и его тип по-прежнему проверяет объявленное поле. В обработчике нет ветвления по версиям клиента.

Три HTTP-запроса фиксируют договор: оба одиночных имени работают и дают одинаковый новый JSON, совместная передача получает 422. Проверка полного ответа важна: простое наличие displayName не заметит, что сервер заодно начал отдавать старый full_name.

from fastapi import FastAPI
from fastapi.testclient import TestClient
from pydantic import BaseModel, Field, AliasChoices, model_validator

class Profile(BaseModel):
    full_name: str = Field(
        min_length=1,
        validation_alias=AliasChoices("displayName", "full_name"),
        serialization_alias="displayName",
    )

    @model_validator(mode="before")
    @classmethod
    def one_name(cls, value):
        if isinstance(value, dict) and {"displayName", "full_name"} <= value.keys():
            raise ValueError("send one name field")
        return value

app = FastAPI()
@app.post("/profiles", response_model=Profile, response_model_by_alias=True)
def create(profile: Profile):
    assert profile.full_name
    return profile

with TestClient(app) as client:
    old = client.post("/profiles", json={"full_name": "Anna"})
    new = client.post("/profiles", json={"displayName": "Anna"})
    assert old.status_code == new.status_code == 200
    assert old.json() == new.json() == {"displayName": "Anna"}
    both = client.post("/profiles", json={"full_name": "A", "displayName": "B"})
    assert both.status_code == 422
print(old.json(), new.json(), both.status_code)

Как завершить миграцию

При создании модели напрямую из Python помните, что validation_alias действует и на model_validate. Внутреннее имя не становится дополнительным разрешённым ключом автоматически при любой конфигурации. Здесь full_name явно включён в AliasChoices, поэтому такой вызов поддерживается. Если в большом проекте работают общие генераторы алиасов, проверьте приоритет локальных полей отдельным тестом.

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

Измерять использование старого ключа удобнее на границе запроса до нормализации. После создания Profile оба варианта становятся одним full_name, и происхождение значения исчезает. Для статистики достаточно счётчика формата и версии клиента; само имя пользователя журналировать не требуется. Когда старые запросы прекратятся, удалите AliasChoices и тест старой формы в одном изменении.

Отдельно проверьте частичные обновления. Если PATCH различает пропущенное поле и явный null, сохраните эту семантику при переименовании. Алиас меняет имя, а не правила отсутствия значения. Не объединяйте миграцию имени с изменением обязательности, если клиентам сложно одновременно обновить оба поведения.

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

Перед выпуском проверьте сохранённые примеры запросов клиентов. Они должны покрывать старую форму отдельно от новой. Если все тесты создают объект Profile напрямую, совместимость сетевых ключей останется непроверенной: ошибка может находиться именно в разборе HTTP-тела или сериализации response_model. Поэтому два запроса из примера стоит сохранить как регрессионный сценарий миграции.

Следующая практика

Закрепите границу между JSON и объектом Python в открытой практике POST. Затем добавьте к её модели старое имя поля и тест единой формы ответа. Откройте практику POST и JSON-модели. Перейдите к бесплатному курсу FastAPI.

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