Практика 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, Listfrom fastapi import FastAPI, Query, HTTPExceptionfrom 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корректна.
Вопросы для самопроверки
- Чем отличаются стили пагинации
page/per_pageиlimit/offset? Как они связаны между собой формулой? - В каком порядке нужно применять фильтрацию, сортировку и пагинацию и почему?
- Почему
totalв метаданных должен считаться после фильтрации, а не до неё? - Как с помощью
Queryограничитьper_pageдиапазоном от 1 до 100? Какой код состояния вернёт FastAPI при нарушении? - Зачем для
sort_byиспользоватьEnumвместо обычной строки? - Почему проверку
min_price > max_priceнельзя выполнить средствамиQueryи какой код состояния тут уместен —400или422? - Как вычислить
total_pages,has_nextиhas_prevизpage,per_pageиtotal? - Что даёт указание
response_modelдля эндпойнта со страничным ответом?