Опирается на правила: R-FLD-1..7, R-RSP-1..8 и X-коды из REST API Style Guide → раздел JSON и формат ответов.

Важно знать

  • camelCaseConfigDict(alias_generator=to_camel, populate_by_name=True).
  • Даты и время — Pydantic сериализует datetime в ISO 8601 автоматически.
  • Enum-значенияclass OrderStatus(StrEnum) с UPPER_SNAKE_CASE.
  • null в 2xx запрещёнresponse_model_exclude_none=True на маршруте.
  • Envelope запрещён — плоский объект без { success, data }.
  • Коллекция{ "content": [...] } + метаданные пагинации.
  • null в PATCH body — команда удалить поле (JSON Merge Patch RFC 7396).
  • Optional в response-моделях — только через field: Type | None = None, не Optional[Type].

FastAPI code-first: Pydantic-модели — источник контракта. Настройки Pydantic напрямую определяют формат JSON. Ошибки в конфигурации модели — ошибки в контракте.

Настройка camelCase

R-FLD-1: через model_config.

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

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

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

Результат в JSON:

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

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

Даты и время

R-FLD-2: ISO 8601.

from datetime import datetime, timezone
from pydantic import BaseModel

class OrderResponse(BaseModel):
    created_at: datetime
    shipped_at: datetime | None = None

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

Pydantic v2 сериализует datetime в ISO 8601 автоматически. Для UTC всегда используй datetime.now(timezone.utc) или datetime с tzinfo:

"2026-05-26T10:30:00Z"       ✓
"2026-05-26"                 ✓ (date-only)
"2026-05-26 10:30:00"        ✗ — без T, без timezone

Enum UPPER_SNAKE_CASE

R-FLD-3: StrEnum.

from enum import StrEnum

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

class PaymentMethod(StrEnum):
    CREDIT_CARD = "CREDIT_CARD"
    SBP = "SBP"
    CASH_ON_DELIVERY = "CASH_ON_DELIVERY"

StrEnum сериализуется как строка напрямую — не нужен use_enum_values=True. Значение в JSON: "CONFIRMED", "IN_PROGRESS".

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

R-FLD-5:

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 в PATCH body — удалить поле

R-FLD-6: семантика запроса.

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

{ "comment": null }

null в теле — команда удалить поле comment. JSON Merge Patch (RFC 7396).

class PatchOrderRequest(BaseModel):
    comment: str | None = None

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

Не путать с null в ответе — запрещён (R-RSP-X1).

null в 2xx — запрещён

R-RSP-X1: поле отсутствует, а не null.

@router.get(
    "/orders/{order_id}",
    response_model=OrderResponse,
    response_model_exclude_none=True,   # ← обязательно
)
async def get_order(order_id: str) -> OrderResponse:
    ...

response_model_exclude_none=True — поля со значением None не попадают в JSON.

Альтернатива — на уровне модели:

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

    model_config = ConfigDict(
        alias_generator=to_camel,
        populate_by_name=True,
        exclude_none=True,  # глобально для модели
    )

Формат ответов

R-RSP-1..6:

Единичный ресурс — плоский объект

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": []
}

Без обёртки { "data": ..., "success": true }.

Коллекция — 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 — стандартное имя поля с данными. Это структура пагинированного ответа, не envelope.

Создание — 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 No Content

from fastapi import Response

@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}.

Пустые коллекции

R-RSP-7:

class OrderResponse(BaseModel):
    items: list[OrderItemResponse] = []   # ✓ — пустой список, не None
{ "items": [] }        ✓
{ "items": null }      ✗

Envelope — запрещён

R-RSP-X4:

# ✗ — envelope
class WrappedResponse(BaseModel):
    success: bool
    data: OrderResponse
    error: str | None = None

# ✓ — плоский
class OrderResponse(BaseModel):
    order_id: str
    status: OrderStatus

HTTP-статус уже сообщает success vs error. Клиент пишет order.order_id, не response.data.order_id.

Что запрещено

АнтипаттернПравилоЧто взамен
Нет alias_generator=to_camelR-FLD-1ConfigDict(alias_generator=to_camel)
datetime без timezoneR-FLD-2datetime.now(timezone.utc)
class Status(str, Enum): confirmed = "confirmed"R-FLD-3StrEnum + UPPER_SNAKE
Нет response_model_exclude_none=TrueR-RSP-X1обязательно на маршруте
"" для отсутствия поляR-RSP-X2поле отсутствует
Optional[str] = None в ответе как nullR-RSP-X3exclude_none
{"success": True, "data": ...} envelopeR-RSP-X4плоский объект
items: list | None = None (null вместо [])R-RSP-7items: list = []
id: str без суффиксаR-FLD-5order_id: str

Куда дальше

  • REST API → JSON (нормативно) — формулировки.
  • URL и ресурсы — HTTP-методы и коды.
  • Query-параметры и пагинация — структура content.
  • Ошибки RFC 9457 — формат error response.
  • Заголовки и трассировка — Location при 201.
  • Версионирование — добавление optional поля non-breaking.