← назад к разделу

REST API — это договор между сервером и клиентом. Если поле в ответе называется created_at в одном запросе и createdAt в другом, клиент сломается. Если дата приходит без часового пояса — она бесполезна. Этот гайд о том, как FastAPI и Pydantic помогают сделать JSON-ответы предсказуемыми.

Почему поля в JSON называются иначе, чем в Python

Python-код пишут в snake_case: order_id, created_at, total_amount. Это соглашение языка, и отступать от него неудобно.

Но клиенты REST API — чаще всего JavaScript и TypeScript, где принято camelCase: orderId, createdAt, totalAmount. Если сервер отдаёт order_id, фронтенд-разработчик либо терпит неудобное имя, либо везде переименовывает — это лишняя работа с обеих сторон.

Pydantic решает это через alias_generator: Python-поля остаются snake_case, а в JSON автоматически превращаются в camelCase.

from pydantic import BaseModel, ConfigDict
from pydantic.alias_generators import to_camel
from datetime import datetime

class OrderResponse(BaseModel):
    order_id: str
    created_at: datetime
    total_amount: float

    model_config = ConfigDict(
        alias_generator=to_camel,
        populate_by_name=True,
    )

Этот код вернёт:

{
  "orderId": "550e8400-...",
  "createdAt": "2026-05-26T10:30:00Z",
  "totalAmount": 1500.00
}

populate_by_name=True — дополнительная опция, которая позволяет создавать модель как по snake_case-имени, так и по camelCase-псевдониму. Это удобно внутри Python-кода.

Даты и время: почему важен часовой пояс

Дата без часового пояса — неточная дата. "2026-05-26 10:30:00" — это Москва? UTC? Локальное время сервера? Когда приложение работает в нескольких регионах или клиент находится в другом часовом поясе, такие строки порождают ошибки.

Правило простое: всегда UTC, всегда ISO 8601 с суффиксом Z.

Pydantic v2 сериализует datetime в ISO 8601 автоматически. Достаточно правильно создать объект:

from datetime import datetime, timezone

created_at = datetime.now(timezone.utc)  # правильно

Что хорошо и что плохо:

"2026-05-26T10:30:00Z"       ✓ — UTC, ISO 8601
"2026-05-26"                 ✓ — только дата, без времени
"2026-05-26 10:30:00"        ✗ — нет T, нет часового пояса
"2026-05-26T10:30:00"        ✗ — нет часового пояса

Статусы и категории: enum как строки

Поле status у заказа может принимать фиксированный набор значений: CREATED, CONFIRMED, SHIPPED. Хранить их как обычные строки — опасно: опечатка "confirmd" пройдёт валидацию незаметно.

Python решает это через enum. Для JSON-сериализации в FastAPI подходит StrEnum — его значения сериализуются напрямую как строки, без дополнительных настроек:

from enum import StrEnum

class OrderStatus(StrEnum):
    CREATED = "CREATED"
    CONFIRMED = "CONFIRMED"
    IN_PROGRESS = "IN_PROGRESS"
    SHIPPED = "SHIPPED"
    DELIVERED = "DELIVERED"
    CANCELLED = "CANCELLED"

В JSON поле придёт как "CONFIRMED" или "IN_PROGRESS" — строки в UPPER_SNAKE_CASE. Клиент может проверить значение простым сравнением.

Идентификаторы: суффикс Id

Поля-идентификаторы называются с суффиксом Id: orderId, customerId, productId. Это соглашение помогает сразу понять, что перед нами — ссылка на сущность, а не просто строка.

class OrderResponse(BaseModel):
    order_id: str      # → orderId в JSON
    customer_id: str   # → customerId
    product_id: str    # → productId

    model_config = ConfigDict(alias_generator=to_camel, populate_by_name=True)

Null в ответах: поле лучше убрать совсем

Когда у заказа нет комментария, что лучше вернуть: "comment": null или вообще не включать поле comment в ответ?

null — это неоднозначно. Клиент не знает: значение не существует, не задано, удалено или что-то пошло не так. Отсутствие поля читается однозначно: данных нет.

В FastAPI это настраивается параметром response_model_exclude_none=True:

@router.get(
    "/orders/{order_id}",
    response_model=OrderResponse,
    response_model_exclude_none=True,
)
async def get_order(order_id: str) -> OrderResponse:
    ...

Поля со значением None просто не попадут в JSON-ответ.

Альтернатива — настроить это на уровне модели через ConfigDict:

class OrderResponse(BaseModel):
    order_id: str
    comment: str | None = None

    model_config = ConfigDict(
        alias_generator=to_camel,
        populate_by_name=True,
        exclude_none=True,
    )

Исключение: в теле PATCH-запроса null имеет особый смысл — команда удалить поле. Это стандарт JSON Merge Patch (RFC 7396):

PATCH /api/v1/orders/{order_id}
Content-Type: application/merge-patch+json

{ "comment": null }

Здесь null — намеренная операция. В ответах сервера null всё равно не нужен.

Структура ответа: без лишних обёрток

Часто видят такой формат:

{
  "success": true,
  "data": { "orderId": "550e8400-..." },
  "error": null
}

Это называют «конвертом» (envelope). Идея понятна: унифицировать формат. Но HTTP уже делает это за счёт кодов ответа: 200 — успех, 404 — не найдено, 422 — ошибка валидации. Добавлять "success": true — дублировать информацию.

Клиент вынужден писать response.data.orderId вместо response.orderId. Это лишний уровень вложенности.

Правильный формат — плоский объект:

class OrderResponse(BaseModel):
    order_id: str
    status: OrderStatus
    total_amount: float
    created_at: datetime
    items: list[OrderItemResponse] = []

    model_config = ConfigDict(alias_generator=to_camel, populate_by_name=True)
{
  "orderId": "550e8400-...",
  "status": "CONFIRMED",
  "totalAmount": 1500.00,
  "createdAt": "2026-05-26T10:30:00Z",
  "items": []
}

Списки и пагинация

Когда эндпоинт возвращает коллекцию, она оборачивается в объект с полем content и метаданными пагинации:

class PaginatedOrders(BaseModel):
    content: list[OrderResponse]
    page: int
    size: int
    total_elements: int
    total_pages: int

    model_config = ConfigDict(alias_generator=to_camel, populate_by_name=True)
{
  "content": [...],
  "page": 0,
  "size": 20,
  "totalElements": 143,
  "totalPages": 8
}

Важный момент: пустая коллекция — это [], не null:

class OrderResponse(BaseModel):
    items: list[OrderItemResponse] = []   # пустой список по умолчанию
{ "items": [] }

Коды ответа для разных операций

Разные HTTP-операции возвращают разные коды:

Создание ресурса — 201 с заголовком Location:

from fastapi import Response

@router.post(
    "/orders",
    status_code=201,
    response_model=OrderResponse,
    response_model_exclude_none=True,
)
async def create_order(body: CreateOrderRequest, response: Response) -> OrderResponse:
    order = await use_cases.create_order(body)
    response.headers["Location"] = f"/api/v1/orders/{order.order_id}"
    return OrderResponse.model_validate(order)

Обновление — 200 с обновлённым ресурсом:

@router.put(
    "/orders/{order_id}",
    status_code=200,
    response_model=OrderResponse,
    response_model_exclude_none=True,
)
async def update_order(order_id: str, body: UpdateOrderRequest) -> OrderResponse:
    ...

Удаление — 204 без тела:

@router.delete("/orders/{order_id}", status_code=204)
async def delete_order(order_id: str):
    await use_cases.delete_order(order_id)
    return Response(status_code=204)

Возвращать {"success": True} при удалении не нужно — код 204 уже говорит об успехе.

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

Нет alias_generator — поля в JSON остаются в snake_case. Фронтенд получает order_id вместо orderId.

datetime без часового пояса — используйте datetime.now(timezone.utc), а не datetime.now(). Без timezone.utc время локальное, часовой пояс теряется.

Optional[str] вместо str | None — в Pydantic v2 предпочтителен синтаксис str | None = None.

null в коллекцииitems: list | None = None означает, что при пустом результате придёт null. Правильно: items: list = [].

Envelope{"success": True, "data": ...} усложняет клиентский код без пользы.

Коротко

  • camelCase в JSON настраивается через ConfigDict(alias_generator=to_camel, populate_by_name=True) — Python остаётся в snake_case, клиент получает camelCase.
  • Даты — всегда UTC и ISO 8601; Pydantic v2 сериализует datetime автоматически; создавайте через datetime.now(timezone.utc).
  • Enum-значенияStrEnum с UPPER_SNAKE_CASE; сериализуется как строка без дополнительных настроек.
  • Идентификаторы — суффикс Id: orderId, customerId.
  • null в ответах запрещён — поле лучше убрать совсем через response_model_exclude_none=True.
  • null в PATCH-теле — законная операция: команда удалить поле (JSON Merge Patch RFC 7396).
  • Envelope запрещён — плоский объект, HTTP-статус уже говорит об успехе или ошибке.
  • Коллекция{ "content": [...] } с метаданными пагинации; пустая коллекция — [], не null.

Что почитать дальше

  • URL и ресурсы в FastAPI — HTTP-методы и структура маршрутов.
  • Query-параметры и пагинация в FastAPI — как передать параметры страницы.
  • Ошибки и RFC 9457 в FastAPI — формат error-ответов.
  • Заголовки и трассировка в FastAPI — Location и другие заголовки.