Перейти к содержимому

Практика 6. Практическая работа 6. Пагинация, фильтрация и сортировка

Цель

Научиться обрабатывать query-параметры в FastAPI через Query, реализовать постраничную выдачу больших коллекций с метаданными (total, page), фильтрацию по полям и сортировку. Закрепить работу со значениями по умолчанию и автоматической валидацией параметров запроса.


Теория

Query-параметры передаются в URL после знака ? и разделяются &:

GET /books?page=2&per_page=20&author=Толстой&sort_by=price&order=desc

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

per_page: int = Query(20, ge=1, le=100, description="Размер страницы")

Если клиент передаст недопустимое значение (например, per_page=0), FastAPI вернёт ошибку валидации 422 ещё до входа в тело функции.

  • Пагинация — разбиение коллекции на страницы. Два стиля параметров: page/per_page и limit/offset. Ответ включает метаданные: total, page, per_page, total_pages, has_next, has_prev.
  • Фильтрация — отбор записей по значениям полей (author, min_price, in_stock). Фильтры опциональны: нет параметра — нет условия.
  • Сортировка — порядок выдачи: поле (sort_by) и направление (order = asc/desc).

Порядок применения в обработчике: сначала фильтрация, затем сортировка, и только потом пагинация (срез отфильтрованного и отсортированного списка).


Задание

Шаг 0. Подготовка

Используем проект из практики 1 (FastAPI + uvicorn). Создайте файл main.py с тестовыми данными в памяти.

from typing import Optional, List
from fastapi import FastAPI, Query, HTTPException
from pydantic import BaseModel
app = FastAPI(title="Books API — пагинация, фильтрация, сортировка")
class Book(BaseModel):
id: int
title: str
author: str
year: int
price: float
in_stock: bool
# Тестовые данные вместо БД
BOOKS: List[Book] = [
Book(id=1, title="Война и мир", author="Толстой", year=1869, price=950, in_stock=True),
Book(id=2, title="Анна Каренина", author="Толстой", year=1877, price=700, in_stock=False),
Book(id=3, title="Преступление и наказание", author="Достоевский", year=1866, price=650, in_stock=True),
Book(id=4, title="Отцы и дети", author="Тургенев", year=1862, price=480, in_stock=False),
Book(id=5, title="Мёртвые души", author="Гоголь", year=1842, price=520, in_stock=True),
]

Запуск: uvicorn main:app --reload. Документация — http://127.0.0.1:8000/docs.

Задание 1. Базовая пагинация (page / per_page)

Реализуйте GET /books, который возвращает данные постранично и блок метаданных. Параметры валидируются через Query.

@app.get("/books")
def list_books(
page: int = Query(1, ge=1, description="Номер страницы (с 1)"),
per_page: int = Query(3, ge=1, le=50, description="Размер страницы"),
):
total = len(BOOKS)
start = (page - 1) * per_page
end = start + per_page
return {
"data": BOOKS[start:end],
"pagination": {
"page": page, "per_page": per_page, "total": total,
"total_pages": (total + per_page - 1) // per_page,
"has_next": end < total, "has_prev": page > 1,
},
}

Проверьте: GET /books?page=1&per_page=3, затем page=2. Попробуйте per_page=0 и per_page=100 — убедитесь, что приходит ошибка 422.

Задание 2. Фильтрация по полям

Добавьте опциональные фильтры: по автору, по диапазону цены и по наличию. Объявите новые параметры в сигнатуре list_books и примените их до пагинации. Условие срабатывает, только если параметр передан.

author: Optional[str] = Query(None, description="Фильтр по автору"),
min_price: Optional[float] = Query(None, ge=0, description="Цена от"),
max_price: Optional[float] = Query(None, ge=0, description="Цена до"),
in_stock: Optional[bool] = Query(None, description="Только в наличии"),
):
items = list(BOOKS)
if author:
items = [b for b in items if author.lower() in b.author.lower()]
if min_price is not None:
items = [b for b in items if b.price >= min_price]
if max_price is not None:
items = [b for b in items if b.price <= max_price]
if in_stock is not None:
items = [b for b in items if b.in_stock == in_stock]
total = len(items) # total считается ПОСЛЕ фильтрации
# ... далее тот же блок пагинации, но срез по items, а не BOOKS

Проверьте GET /books?author=Толстой и GET /books?min_price=500&in_stock=true.

Задание 3. Сортировка (sort_by / order)

Добавьте параметры сортировки. Поле ограничьте списком допустимых значений через Enum, направление — шаблоном ^(asc|desc)$.

from enum import Enum
class SortField(str, Enum):
title = "title"
year = "year"
price = "price"

Добавьте параметры в сигнатуру и шаг сортировки между фильтрацией и пагинацией:

sort_by: SortField = Query(SortField.title, description="Поле сортировки"),
order: str = Query("asc", pattern="^(asc|desc)$", description="asc или desc"),
):
# ... фильтрация ...
items.sort(key=lambda b: getattr(b, sort_by.value), reverse=(order == "desc"))
# ... пагинация ...

Использование Enum гарантирует: попытка sort_by=secret вернёт 422, а в Swagger UI поле станет выпадающим списком. Проверьте GET /books?sort_by=price&order=desc.

Задание 4. Валидация согласованности параметров

Query проверяет каждый параметр по отдельности, но не их связь между собой. Добавьте ручную проверку: если min_price > max_price, вернуть 400.

if min_price is not None and max_price is not None and min_price > max_price:
raise HTTPException(
status_code=400,
detail="min_price не может быть больше max_price",
)

Поместите эту проверку в начале обработчика, до фильтрации. Проверьте GET /books?min_price=900&max_price=100.

Задание 5. Строгая модель ответа (response_model)

Опишите Pydantic-модели для метаданных и общего ответа, чтобы зафиксировать контракт и улучшить автодокументацию.

class PageMeta(BaseModel):
page: int
per_page: int
total: int
total_pages: int
has_next: bool
has_prev: bool
class BookPage(BaseModel):
data: List[Book]
pagination: PageMeta
@app.get("/books", response_model=BookPage)
def list_books(...):
...

Откройте /docs и убедитесь, что схема ответа описана.

Задание 6 (необязательное). Стиль limit / offset

Реализуйте альтернативный эндпойнт GET /books/items с параметрами limit и offset вместо page/per_page:

@app.get("/books/items")
def list_items(
limit: int = Query(10, ge=1, le=100, description="Сколько вернуть"),
offset: int = Query(0, ge=0, description="Сколько пропустить"),
):
return {
"data": BOOKS[offset:offset + limit],
"meta": {"limit": limit, "offset": offset, "total": len(BOOKS)},
}

Сравните оба стиля: page/per_page нагляднее для UI с номерами страниц, limit/offset гибче и ближе к языку запросов БД (LIMIT ... OFFSET ...).


Критерии оценки

  • 20% — базовая пагинация работает, ответ содержит data и метаданные (total, page, per_page, total_pages).
  • 20% — фильтры по полям применяются корректно и опциональны; total считается после фильтрации.
  • 20% — сортировка по sort_by/order работает в обоих направлениях, поле ограничено Enum.
  • 15% — параметры валидируются через Query (ge, le, pattern), недопустимые значения дают 422.
  • 15% — ручная проверка согласованности параметров (min_price/max_price) даёт 400 с понятным detail.
  • 10% — описан response_model, документация в /docs корректна.

Вопросы для самопроверки

  1. Чем отличаются стили пагинации page/per_page и limit/offset? Как они связаны между собой формулой?
  2. В каком порядке нужно применять фильтрацию, сортировку и пагинацию и почему?
  3. Почему total в метаданных должен считаться после фильтрации, а не до неё?
  4. Как с помощью Query ограничить per_page диапазоном от 1 до 100? Какой код состояния вернёт FastAPI при нарушении?
  5. Зачем для sort_by использовать Enum вместо обычной строки?
  6. Почему проверку min_price > max_price нельзя выполнить средствами Query и какой код состояния тут уместен — 400 или 422?
  7. Как вычислить total_pages, has_next и has_prev из page, per_page и total?
  8. Что даёт указание response_model для эндпойнта со страничным ответом?