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

PYTHON · REST API Фаза 14. API: REST и контракты

Обязательно

JSON и формат ответов: camelCase, даты и пагинация

Pydantic · ISO 8601 · DTO

Ответ предсказуем: имена не меняются, время имеет пояс, enum читается строкой, пустые необязательные поля не шумят.

1. alias_generator связывает snake_case и camelCase

1 / 5

Python работает с order_id и created_at, клиент получает orderId и createdAt. by_alias=True включает это при сериализации.

Pythonorder_idcreated_atPydanticalias_generatorJSONorderIdcreatedAt

2. Дата всегда содержит часовой пояс

2 / 5

ISO 8601 с Z или смещением однозначно определяет момент. datetime.now(UTC) создаёт aware-значение, а наивная строка оставляет клиенту догадываться.

"2026-11-11T12:00"где этот момент?неоднозначно"2026-11-11T09:00:00Z"UTC явно указанISO 8601

3. Enum сериализуется устойчивой строкой

3 / 5

UPPER_SNAKE_CASE вроде PAID не зависит от числового порядка значений. Клиент может сравнивать и логировать статус без таблицы чисел.

OrderStatusNEWPAIDStrEnumstable valuesJSONstatus: "PAID"

4. Плоский ответ не прячет данные в обёртках

4 / 5

Заказ возвращается объектом с полями, не data.attributes. Необязательные null-поля исключаются, а для PATCH отсутствие и явный null имеют разный смысл.

Лишняя обёрткаdata → attributes → idmeta → links → selfПредметный ответid · status · totalitems · nextCursorexclude_none=True

5. Пагинация отделена от элементов

5 / 5

Список items содержит ресурсы, nextCursor продолжает выборку, total показывают только когда его цена оправдана. 201 сопровождается Location, 204 не имеет тела.

OrderListResponseitems: [OrderResponse]данныеnextCursor: "100"продолжение201 + Location · 204 без тела

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)  # правильно

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

Значение в JSONГодитсяПочему
"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-ответ.

Настройки «не отдавать None» на уровне модели в Pydantic нет: exclude_none — параметр сериализации (model_dump(exclude_none=True)), и ключ с таким именем в ConfigDict молча игнорируется. Поэтому в FastAPI его задают на маршруте, а чтобы не повторять в каждом декораторе, собирают общие параметры в словарь и распаковывают: @router.get("/orders/{order_id}", **JSON_ROUTE).

Исключение: в теле 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": 1,
  "size": 20,
  "totalElements": 143,
  "totalPages": 8
}

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

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

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

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

POST создание 201 Created Location и новый ресурс PUT замена 200 OK ресурс целиком DELETE 204 No Content без тела POST действие 200 OK ресурс после действия

У каждой операции свой код успеха и своё тело: создание отвечает адресом нового ресурса, удаление ничем, действие состоянием после него.

Создание ресурса — 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": ...} усложняет клиентский код без пользы.

Дополнительно: при первом чтении можно пропустить

Глубже: деньги, свои сериализаторы и быстрый JSONрасширенное

В примерах выше суммы записаны как float, и для учебного кода это терпимо, но в деньгах двоичная дробь даёт 1500.0000000000002 ровно там, где не надо. Поле объявляют как Decimal, а Pydantic сериализует его в JSON строкой: "totalAmount": "1500.00" — точность сохраняется, клиент разбирает строку своим десятичным типом. Если контракт требует число, добавляют Field(json_schema_extra=...) в схему и явный сериализатор:

from decimal import Decimal
from pydantic import BaseModel, field_serializer

class OrderResponse(BaseModel):
    total_amount: Decimal

    @field_serializer("total_amount")
    def _amount(self, value: Decimal) -> float:
        return float(value)

@field_serializer нужен и для дат в нестандартном формате, и для чувствительных полей, которые наружу уходят замаскированными. Проверить, что именно уйдёт клиенту, можно без HTTP: model.model_dump(mode="json") даёт те же типы, что и ответ FastAPI, в отличие от обычного model_dump, где datetime остаётся объектом.

Когда ответов много и они большие, стандартный json становится заметен в профиле. FastAPI умеет отдавать через orjson: FastAPI(default_response_class=ORJSONResponse) ускоряет сериализацию в разы и заодно корректно пишет datetime и UUID. Единственная оговорка: orjson не принимает Decimal, так что сериализатор выше понадобится и там.

Коротко

  • 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.
  • Деньги — Decimal, в JSON они уходят строкой; @field_serializer меняет формат отдельного поля; model_dump(mode="json") показывает ответ как его увидит клиент; ORJSONResponse ускоряет большие ответы.

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