Опирается на правила: R-QRY-1..9 и R-QRY-X1..X5 из REST API Style Guide → раздел Query-параметры и пагинация.

Важно знать

  • camelCase для имён параметров — через Query(alias="pageSize").
  • ФильтрацияQuery(alias="customerId"), Query(alias="status").
  • ДиапазоныQuery(alias="dateFrom") / Query(alias="dateTo").
  • Offset-basedpage (1-based!) + size.
  • Cursor-basedcursor (непрозрачный токен) + size. Клиент не парсит.
  • Множественные значенияlist[str] = Query(...) (повтор, не CSV).
  • Сложные запросыPOST /resources/search с Pydantic-телом.

FastAPI code-first: query-параметры описываются в сигнатуре функции с Query(...). Имена аргументов функции — snake_case (PEP 8), но в URL нужен camelCase. Решение — Query(alias="camelCase").

Имена параметров

R-QRY-1: camelCase через alias.

from fastapi import APIRouter, Query

router = APIRouter(prefix="/api/v1", redirect_slashes=False)

@router.get("/orders")
async def get_orders(
    customer_id: str | None = Query(default=None, alias="customerId"),
    date_from: str | None = Query(default=None, alias="dateFrom"),
    date_to: str | None = Query(default=None, alias="dateTo"),
):
    ...
GET /api/v1/orders?customerId=123&dateFrom=2026-01-01     ✓
GET /api/v1/orders?customer_id=123                        ✗ — snake_case
GET /api/v1/orders?CustomerID=123                         ✗ — PascalCase

Фильтрация и диапазоны

R-QRY-2..3:

from enum import StrEnum

class OrderStatus(StrEnum):
    CREATED = "CREATED"
    CONFIRMED = "CONFIRMED"
    SHIPPED = "SHIPPED"

@router.get("/orders")
async def get_orders(
    status: OrderStatus | None = Query(default=None),
    customer_id: str | None = Query(default=None, alias="customerId"),
    date_from: str | None = Query(default=None, alias="dateFrom"),
    date_to: str | None = Query(default=None, alias="dateTo"),
    amount_from: float | None = Query(default=None, alias="amountFrom"),
    amount_to: float | None = Query(default=None, alias="amountTo"),
):
    ...

From/To — инклюзивный интервал [from, to].

Offset-based пагинация

R-QRY-4: page (1-based) + size.

@router.get("/orders")
async def get_orders(
    page: int = Query(default=1, ge=1, alias="page"),
    size: int = Query(default=20, ge=1, le=100, alias="size"),
):
    offset = (page - 1) * size
    ...

Контракт API — 1-based. page=1 — первая страница.

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

from pydantic import BaseModel

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": 243,
  "totalPages": 13
}

R-QRY-X2: page=0 в публичном контракте — запрещено.

Cursor-based пагинация

R-QRY-5: cursor (opaque) + size.

import base64, json

@router.get("/orders")
async def get_orders(
    size: int = Query(default=20, ge=1, le=100),
    cursor: str | None = Query(default=None),
):
    decoded = None
    if cursor:
        decoded = json.loads(base64.b64decode(cursor))
    ...

def encode_cursor(last_id: str, last_created_at: str) -> str:
    payload = {"id": last_id, "createdAt": last_created_at}
    return base64.b64encode(json.dumps(payload).encode()).decode()
class CursorPage(BaseModel):
    content: list[OrderResponse]
    size: int
    next_cursor: str | None = None
    prev_cursor: str | None = None
    has_next: bool
    has_prev: bool

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

Клиент передаёт cursor из поля nextCursor предыдущего ответа — не парсит и не конструирует его содержимое. R-QRY-X5: парсинг cursor на клиенте — запрещено.

Сортировка

R-QRY-6: sort=field,direction.

@router.get("/orders")
async def get_orders(
    sort: list[str] = Query(default=["createdAt,desc"]),
):
    ...
GET /api/v1/orders?sort=createdAt,desc
GET /api/v1/orders?sort=totalAmount,asc&sort=createdAt,desc

Полнотекстовый поиск

R-QRY-7: параметр q.

@router.get("/products")
async def search_products(q: str | None = Query(default=None)):
    ...

Множественные значения

R-QRY-8: повтор параметра через list[T] = Query(...).

@router.get("/orders")
async def get_orders(
    status: list[OrderStatus] = Query(default=[]),
):
    ...

FastAPI автоматически генерирует style: form, explode: true в OpenAPI — повтор параметра:

GET /api/v1/orders?status=CREATED&status=CONFIRMED     ✓
GET /api/v1/orders?status=CREATED,CONFIRMED            ✗ — comma-separated

R-QRY-X3: comma-separated — запрещено. Требует ручного парсинга, ломается при запятой в значении, не соответствует умолчанию OpenAPI.

POST /resources/search

R-QRY-9: для сложных запросов.

from pydantic import BaseModel

class OrderDateRange(BaseModel):
    from_: str | None = Field(default=None, alias="from")
    to: str | None = None

class CustomerFilter(BaseModel):
    region_ids: list[int] = Field(default=[], alias="regionIds")
    segment: str | None = None

class SortField(BaseModel):
    field: str
    direction: str = "DESC"

class OrderSearchRequest(BaseModel):
    statuses: list[OrderStatus] = []
    date_range: OrderDateRange | None = Field(default=None, alias="dateRange")
    customer: CustomerFilter | None = None
    sort: list[SortField] = Field(default=[SortField(field="createdAt")])
    page: int = 1
    size: int = 20

    model_config = ConfigDict(populate_by_name=True)

@router.post(
    "/orders/search",
    status_code=200,
    operation_id="searchOrders",
    tags=["Orders"],
    summary="Поиск заказов",
    response_model=PaginatedOrders,
    response_model_exclude_none=True,
)
async def search_orders(body: OrderSearchRequest) -> PaginatedOrders:
    ...

Критерии перехода с GET на POST /search:

СитуацияЧто выбрать
Плоские поля (status, date)GET
Вложенные объектыPOST /search
Массив 10+ значений в одном фильтреPOST /search
AND/OR-комбинацииPOST /search
Запрос нужно сохранятьPOST /search

URL: /resources/search (не /query, не /find). Ответ — 200 OK, формат тот же что GET /resources.

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

АнтипаттернПравилоЧто взамен
?customer_id= snake_caseR-QRY-X1Query(alias="customerId")
page=0 (0-based в контракте)R-QRY-X2page=1
?status=A,B comma-separatedR-QRY-X3?status=A&status=B
?action=cancel бизнес-логика в queryR-QRY-X4POST /orders/{id}/cancel
Клиент декодирует cursorR-QRY-X5opaque token
GET search с длинным URLR-QRY-9POST /search
int без alias для page_sizeR-QRY-1Query(alias="pageSize")

Куда дальше

  • REST API → Query-параметры (нормативно) — формулировки.
  • URL и ресурсы — формат пути.
  • JSON и формат ответов — content + пагинация, model_config.
  • Заголовки и трассировка — Idempotency-Key для POST /search.
  • Batch, async, локализация — async-поиск.