Опирается на правила:
R-FLD-1..7,R-RSP-1..8и X-коды из REST API Style Guide → раздел JSON и формат ответов.
Важно знать
- camelCase —
ConfigDict(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_camel | R-FLD-1 | ConfigDict(alias_generator=to_camel) |
datetime без timezone | R-FLD-2 | datetime.now(timezone.utc) |
class Status(str, Enum): confirmed = "confirmed" | R-FLD-3 | StrEnum + UPPER_SNAKE |
Нет response_model_exclude_none=True | R-RSP-X1 | обязательно на маршруте |
"" для отсутствия поля | R-RSP-X2 | поле отсутствует |
Optional[str] = None в ответе как null | R-RSP-X3 | exclude_none |
{"success": True, "data": ...} envelope | R-RSP-X4 | плоский объект |
items: list | None = None (null вместо []) | R-RSP-7 | items: list = [] |
id: str без суффикса | R-FLD-5 | order_id: str |
Куда дальше
- REST API → JSON (нормативно) — формулировки.
- URL и ресурсы — HTTP-методы и коды.
- Query-параметры и пагинация — структура
content. - Ошибки RFC 9457 — формат error response.
- Заголовки и трассировка —
Locationпри 201. - Версионирование — добавление optional поля non-breaking.