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

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

Обязательно

Query-параметры и пагинация в FastAPI

Filters · Cursor · Search

Query описывает выборку: имена и пределы фиксируются контрактом, глубокие страницы заменяются курсором.

1. Alias разводит Python и API

1 / 5

В коде остаётся customer_id, наружу используется customerId. Query(alias=...) делает соглашение явным и связывает ограничения с полем.

?customerId=42public contractQuery(alias)ge=1customer_idPython int

2. Фильтры и диапазоны имеют пределы

2 / 5

minPrice и maxPrice проверяются отдельно и вместе, limit не превышает максимум. Значения по умолчанию - часть контракта, а не случайное поведение.

minPrice=100maxPrice=500100 ≤ price ≤ 500limit=20ge=1 · le=100default=20

3. Offset прост, но дорог на глубине

3 / 5

page=500 заставляет базу прочитать и пропустить предыдущие страницы. Для небольшого административного списка это приемлемо, для бесконечной ленты - нет.

page 1...page 500OFFSET 9980: база читает и отбрасывает строки

4. Курсор продолжает от последнего ключа

4 / 5

afterId использует индекс и не зависит от глубины. При сортировке по created_at курсор составной: дата и уникальный id как tie-breaker.

id=98id=99id=100id=101id=102afterId=100

5. Сложный поиск может быть POST

5 / 5

Длинные списки, вложенные условия и тело фильтра не помещаются в URL. POST /orders/search принимает модель запроса и остаётся операцией чтения без изменения состояния.

POST /orders/searchfilters · ranges · idsPydantic SearchRequest200 OKitems + nextCursorбез создания ресурса

Query-параметры — это то, что идёт в URL после вопросительного знака: ?status=CREATED&page=2. В FastAPI они объявляются прямо в сигнатуре функции, а специальный класс Query позволяет настроить их поведение. Разберём с нуля: как назвать параметры, как строить фильтры, постраничную навигацию и поиск.

Имена параметров: почему нужен alias

В Python принято называть переменные в стиле snake_case — например, customer_id. Но в URL REST API принят camelCase — customerId. Это требование не связано с Python, оно относится к самому API-контракту, который используют клиенты.

FastAPI не конвертирует имена автоматически. Если написать:

async def get_orders(customer_id: str | None = None):
    ...

то клиент должен передавать ?customer_id=123 — со snake_case в URL. Это неправильно.

Решение — параметр alias в Query(...):

from fastapi import APIRouter, Query

router = APIRouter(prefix="/api/v1")

@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"),
):
    ...

Теперь в URL пишут ?customerId=123, а внутри функции работают с Python-переменной customer_id. Все довольны.

В новом коде тот же параметр чаще записывают через Annotated: customer_id: Annotated[str | None, Query(alias="customerId")] = None. Смысл тот же, а значение по умолчанию остаётся обычным значением по умолчанию Python, и такой параметр можно вынести в псевдоним типа и переиспользовать.

ЗапросГодитсяПочему
GET /api/v1/orders?customerId=123&dateFrom=2026-01-01даcamelCase — то, что ждут в URL
GET /api/v1/orders?customer_id=123нетsnake_case в URL
GET /api/v1/orders?CustomerID=123нетPascalCase

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

Фильтр по конкретному значению — просто дополнительный параметр. Если значения ограничены заранее известным набором, используют перечисление (StrEnum):

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. Оба конца — включительно: ?amountFrom=100&amountTo=500 означает «от 100 до 500 включительно».

Постраничная навигация (offset-based)

Самый распространённый способ — указать номер страницы и размер страницы. Важная деталь: страницы нумеруются с единицы, не с нуля.

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

page=1 — первая страница. Параметр ge=1 не позволяет передать ноль или отрицательное число.

Ответ возвращает данные вместе с информацией о пагинации:

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

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
}

Частая ошибка — нумерация с нуля (page=0 для первой страницы). Это ломает контракт: клиенты ожидают page=1.

Cursor-пагинация

Offset-пагинация плохо работает с большими объёмами данных и быстро меняющимися списками: пока пользователь листает, вставляются новые записи, и страницы «сдвигаются». Cursor-пагинация решает эту проблему.

offset ORDER BY created_at прочитать 100 020 строк выбросить 100 000 отдать 20 cursor WHERE created_at < :cursor по индексу прочитать 20 отдать 20 и nextCursor

Смещение читает и выбрасывает всё, что перед страницей, курсор входит по индексу и читает ровно страницу; цена курсора — нет перехода на произвольную страницу.

Вместо номера страницы клиент передаёт непрозрачный токен (cursor), который сервер сам формирует и сам же декодирует. Клиент его не парсит — просто берёт из ответа и передаёт в следующем запросе.

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)

Клиент берёт значение из nextCursor и передаёт в следующий запрос как ?cursor=.... Внутреннее устройство токена — дело сервера.

Сортировка

Параметр sort принимает название поля и направление через запятую. Можно передать несколько значений — каждое отдельным параметром:

@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

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

Для поиска по тексту используют параметр q:

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

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

Когда нужно передать несколько значений одного фильтра, используют повтор параметра:

GET /api/v1/orders?status=CREATED&status=CONFIRMED     ✓
GET /api/v1/orders?status=CREATED,CONFIRMED            ✗ — через запятую

В FastAPI это объявляется через list[T]:

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

FastAPI автоматически генерирует правильный OpenAPI-контракт (style: form, explode: true). Вариант через запятую — частая ошибка: он требует ручного парсинга и ломается, если в самом значении есть запятая.

Когда нужен POST /search

GET-запрос хорошо работает для плоских фильтров. Но если нужны вложенные объекты, массивы из десятков значений или логика AND/OR — URL становится неудобным и может превысить допустимую длину.

В таких случаях используют отдельный эндпоинт POST /resources/search с Pydantic-телом:

from pydantic import BaseModel, Field

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,
    response_model=PaginatedOrders,
    response_model_exclude_none=True,
)
async def search_orders(body: OrderSearchRequest) -> PaginatedOrders:
    ...

Ориентир для выбора:

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

Путь — /resources/search (не /query, не /find). Ответ всегда 200 OK, формат — такой же, как у GET /resources.

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

snake_case в URL — параметр customer_id без alias приводит к ?customer_id=... в URL. Правильно: Query(alias="customerId").

Нумерация страниц с нуля — page=0 для первой страницы сбивает клиентов с толку. Правильно: page=1.

Запись действия в query — ?action=cancel вместо нормального эндпоинта. Правильно: POST /orders/{id}/cancel.

Декодирование cursor на клиенте — cursor непрозрачен для клиента. Его содержимое может измениться, это не часть контракта.

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

Глубже: пределы и значения по умолчанию как часть контрактарасширенное

Всё, что не ограничено, однажды придёт максимальным. Клиент попросит size=100000, пришлёт фильтр с десятью тысячами идентификаторов и диапазон дат в десять лет, и не по злому умыслу, а потому что так получилось в цикле. Пределы это часть контракта, и в FastAPI они объявляются рядом с параметром:

async def list_orders(
    size: Annotated[int, Query(ge=1, le=100)] = 20,
    status: Annotated[list[OrderStatus], Query(max_length=10)] = [],
    q: Annotated[str | None, Query(max_length=200)] = None,
): ...

le=100 режет размер страницы, max_length у списка ограничивает число значений (одиннадцатый status= даст 422, которую обработчик ошибок переводит в 400), max_length у строки длину поиска. Ширину диапазона дат проверяют валидатором: createdTo - createdFrom не больше 93 дней, иначе 400 RANGE_TOO_WIDE с подсказкой разбить запрос.

Значения по умолчанию такая же часть контракта: size=20, сортировка по createdAt по убыванию, период «последние 30 дней», если не задан. Они попадают в OpenAPI автоматически и должны быть одинаковыми у всех списков сервиса, иначе клиент учит их заново на каждом эндпоинте.

Что не ограничивают валидацией параметра: число результатов COUNT(*) для totalElements на таблице в сто миллионов строк. Его либо считают приблизительно, либо отдают только hasNext с курсорной пагинацией, об этом раздел выше.

Коротко

  • Query-параметры в FastAPI объявляют в сигнатуре функции; Query(alias="camelCase") задаёт имя в URL.
  • Фильтры — дополнительные параметры; StrEnum ограничивает допустимые значения; диапазоны через From/To (включительно).
  • Offset-пагинация: page (с единицы) + size; ответ содержит totalElements и totalPages.
  • Cursor-пагинация: клиент передаёт непрозрачный токен из поля nextCursor предыдущего ответа — не декодирует.
  • Множественные значения — повторением параметра (?status=A&status=B), не через запятую.
  • Для сложных запросов с вложенной структурой или длинными массивами — POST /resources/search с Pydantic-телом и ответом 200 OK.
  • Пределы — часть контракта: Query(le=100) у size, Query(max_length=...) у списков и строк, ширина диапазона дат в валидаторе; всё, что не ограничено, однажды придёт максимальным.

Что пощупать

Фильтр по цене в практикуме remodov/marketplace-system-python объявлен одной строкой: Query(None, alias="maxPrice", gt=0) с типом Decimal, так что мусор получает 400 с именем поля ещё до роутера. Репозиторий сравнивает Product._price <= max_price и сортирует по колонке в базе.

Код: router.py, repository.py.

Сделаем сами

Ветка step-02-read-endpoint - параметра нет, test_filter.py красный.

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