Лекция 6. REST API: продвинутые темы
1. Введение
На предыдущих занятиях мы разобрали основы REST: ресурсы и URI, HTTP-методы, коды состояния, формат JSON и базовое проектирование эндпойнтов. Этого достаточно для простого CRUD-сервиса, но реальные API в production решают ряд дополнительных задач. В этой лекции мы рассмотрим продвинутые темы, без которых нельзя считать API завершённым: пагинацию, фильтрацию и сортировку; версионирование; единый формат ошибок; rate limiting; автодокументацию (OpenAPI/Swagger); тестирование (pytest + TestClient); лучшие практики REST.
Тема аутентификации и авторизации подробно разбирается в отдельных лекциях (10–11), поэтому здесь мы её только упоминаем в контексте защиты эндпойнтов.
2. Пагинация, фильтрация и сортировка
Когда коллекция содержит десятки тысяч записей, возвращать их одним ответом нельзя: это медленно, тратит память и трафик, перегружает клиента. Поэтому большие списки разбивают на части и дают клиенту инструменты для уточнения выборки.
2.1. Пагинация
Пагинация — разбиение набора данных на страницы.
Offset-based (по смещению) — самый распространённый подход через параметры page и per_page (или limit и offset):
GET /api/v1/books?page=2&per_page=20Ответ обычно включает блок метаданных о страницах:
{ "data": [ ... ], "pagination": { "page": 2, "per_page": 20, "total": 150, "total_pages": 8, "has_next": true, "has_prev": true }}Cursor-based (по курсору) — вместо номера страницы передаётся «указатель» на последнюю полученную запись:
GET /api/v1/books?cursor=eyJpZCI6MTIzfQ&limit=20Курсорная пагинация эффективнее на больших объёмах и устойчива к вставкам/удалениям: она не страдает от «сдвига» записей между запросами, в отличие от offset-подхода. Минус — нельзя перейти на произвольную страницу.
2.2. Фильтрация
Фильтрация — отбор записей по значениям полей. Передаётся через query-параметры:
GET /api/v1/books?author=Толстой&year=1869&min_price=100&max_price=10002.3. Сортировка
Сортировка задаёт порядок выдачи. Распространены два стиля:
GET /api/v1/books?sort_by=price&order=descGET /api/v1/books?sort=price,-year # цена по возрастанию, год по убыванию2.4. Реализация в FastAPI
FastAPI принимает query-параметры как аргументы функции. Query задаёт значения по умолчанию, ограничения и описание для документации:
from typing import Optionalfrom fastapi import FastAPI, Query
app = FastAPI()
@app.get("/api/v1/books")def list_books( page: int = Query(1, ge=1, description="Номер страницы"), per_page: int = Query(20, ge=1, le=100, description="Размер страницы"), author: Optional[str] = Query(None, description="Фильтр по автору"), sort_by: str = Query("title", description="Поле сортировки"), order: str = Query("asc", pattern="^(asc|desc)$"),): books = db.get_books() if author: # фильтрация books = [b for b in books if b.author == author] books.sort(key=lambda b: getattr(b, sort_by), # сортировка reverse=(order == "desc")) total = len(books) start = (page - 1) * per_page # пагинация return { "data": books[start:start + per_page], "pagination": { "page": page, "per_page": per_page, "total": total, "total_pages": (total + per_page - 1) // per_page, "has_next": start + per_page < total, "has_prev": page > 1, }, }Обратите внимание на ограничения: per_page не превышает 100 (le=100), page не меньше 1 (ge=1), а order ограничен шаблоном. Если клиент передаст недопустимое значение, FastAPI автоматически вернёт ошибку валидации 422 ещё до входа в тело функции.
3. Версионирование API
Со временем API меняется: добавляются поля, переименовываются ресурсы, меняется формат ответов. Если делать это «вживую», сломаются клиенты, которые уже используют старый контракт. Версионирование решает проблему: разные версии API сосуществуют, и клиенты мигрируют постепенно.
3.1. URI-версионирование
Версия указывается прямо в пути:
GET /api/v1/usersGET /api/v2/usersПлюсы: просто, наглядно, легко тестировать в браузере и логах. Минусы: формально нарушает идею REST (один и тот же ресурс получает разные URI), приводит к дублированию кода.
В FastAPI удобно вынести каждую версию в отдельный APIRouter:
from fastapi import APIRouter, FastAPI
app = FastAPI()v1 = APIRouter(prefix="/api/v1", tags=["v1"])v2 = APIRouter(prefix="/api/v2", tags=["v2"])
@v1.get("/users")def list_users_v1(): return [{"id": 1, "name": "Иван"}]
@v2.get("/users")def list_users_v2(): return {"data": [{"id": 1, "full_name": "Иван Иванов"}]} # новый формат
app.include_router(v1)app.include_router(v2)3.2. Версионирование через заголовок
Версия передаётся в HTTP-заголовке, URI остаётся «чистым»:
GET /api/usersAccept: application/vnd.api.v1+jsonПлюсы: соответствует принципам REST (URI ресурса не меняется). Минусы: менее очевидно, сложнее проверить вручную.
3.3. Версионирование через query-параметр
GET /api/users?version=1Простой, но не рекомендуемый для production способ: версия легко теряется, мешает кэшированию.
3.4. Рекомендации
- Для большинства проектов выбирайте URI-версионирование — оно самое понятное.
- Включайте версию в путь с самого начала:
/api/v1/.... - Поддерживайте минимум 1–2 предыдущие версии и заранее объявляйте сроки их вывода из эксплуатации (deprecation).
- Документируйте изменения между версиями (changelog).
4. Единый формат ошибок и их обработка
Клиенту нужно предсказуемо понимать, что пошло не так. Если один эндпойнт возвращает {"error": "..."}, другой — {"message": "..."}, а третий — просто текст, обрабатывать ошибки на стороне клиента становится мучительно. Поэтому формат ошибок стандартизируют.
4.1. Единый формат
{ "error": { "code": "VALIDATION_ERROR", "message": "Человекочитаемое сообщение об ошибке", "details": { "field": "email", "reason": "invalid_format" }, "timestamp": "2026-01-15T10:30:00Z", "path": "/api/v1/users" }}Ключевые поля: машиночитаемый code, понятный человеку message и опциональные details с подробностями (например, какие поля не прошли валидацию).
4.2. Базовая обработка через HTTPException
Для простых случаев достаточно HTTPException:
from fastapi import FastAPI, HTTPException, status
app = FastAPI()
@app.get("/api/v1/books/{book_id}")def get_book(book_id: int): book = db.get_book(book_id) if book is None: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, detail="Книга не найдена", ) return book4.3. Глобальные обработчики исключений
Чтобы привести все ошибки к единому формату, регистрируют обработчики через @app.exception_handler. Так мы перехватываем и встроенные HTTPException, и ошибки валидации:
from fastapi import FastAPI, Request, statusfrom fastapi.exceptions import RequestValidationErrorfrom fastapi.responses import JSONResponse
app = FastAPI()
def error_body(code, message, request, details=None): return {"error": {"code": code, "message": message, "details": details, "path": str(request.url.path)}}
@app.exception_handler(RequestValidationError)async def validation_handler(request: Request, exc: RequestValidationError): return JSONResponse( status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, content=error_body("VALIDATION_ERROR", "Ошибка валидации данных", request, details=exc.errors()), )Аналогично можно зарегистрировать обработчик для StarletteHTTPException (404, 403 и т. д.) и для собственных классов исключений.
4.4. Правило про коды состояния
Самая частая ошибка новичков — возвращать ошибку с кодом 200 OK. Код состояния должен отражать результат:
❌ 200 OK {"error": "User not found"}✅ 404 Not Found {"error": {"code": "NOT_FOUND", ...}}Напомним соответствие: 400 — некорректный запрос, 401 — нужна аутентификация, 403 — нет прав, 404 — не найдено, 409 — конфликт, 422 — ошибка валидации, 429 — слишком много запросов, 500 — ошибка сервера.
5. Ограничение частоты запросов (Rate Limiting)
Rate limiting — ограничение количества запросов от одного клиента за интервал времени.
Зачем нужно:
- защита от злоупотреблений и перебора (brute force);
- справедливое распределение ресурсов между клиентами;
- снижение риска перегрузки и DDoS.
5.1. Реализация со slowapi
Для FastAPI распространена библиотека slowapi. Лимит задаётся декоратором, а ключ (по кому считать) — функцией key_func, обычно по IP-адресу:
from fastapi import FastAPI, Requestfrom slowapi import Limiter, _rate_limit_exceeded_handlerfrom slowapi.util import get_remote_addressfrom slowapi.errors import RateLimitExceeded
limiter = Limiter(key_func=get_remote_address)app = FastAPI()app.state.limiter = limiterapp.add_exception_handler(RateLimitExceeded, _rate_limit_exceeded_handler)
@app.get("/api/v1/books")@limiter.limit("10/minute")async def list_books(request: Request): return {"data": [...]}При превышении лимита клиент получает 429 Too Many Requests.
5.2. Информирующие заголовки
Хороший API сообщает клиенту состояние лимита, чтобы тот мог сам притормозить:
HTTP/1.1 200 OKX-RateLimit-Limit: 100X-RateLimit-Remaining: 99X-RateLimit-Reset: 1642233600X-RateLimit-Limit— общий лимит за окно;X-RateLimit-Remaining— сколько запросов ещё осталось;X-RateLimit-Reset— момент (Unix-время) сброса счётчика.
Дополнительно при ответе 429 полезно отдавать заголовок Retry-After с числом секунд до следующей попытки.
6. Автодокументация (OpenAPI / Swagger)
OpenAPI (ранее Swagger) — стандарт машиночитаемого описания REST API. По нему генерируются интерактивная документация, клиентские SDK и тесты.
Большое преимущество FastAPI: документация по OpenAPI генерируется автоматически из аннотаций типов, моделей Pydantic и параметров эндпойнтов.
6.1. Готовые интерфейсы
Достаточно создать приложение — и две страницы документации уже доступны:
from fastapi import FastAPI
app = FastAPI( title="Books API", description="REST API для управления книгами", version="1.0.0", docs_url="/docs", # Swagger UI redoc_url="/redoc", # ReDoc)- Swagger UI:
http://localhost:8000/docs— интерактивная страница, где можно отправлять запросы. - ReDoc:
http://localhost:8000/redoc— статичная, удобная для чтения. - Сама схема:
http://localhost:8000/openapi.json.
6.2. Обогащение документации
Качество документации зависит от того, насколько подробно описаны модели и эндпойнты. Используйте Field с описаниями и примерами, а также параметры декоратора маршрута:
from pydantic import BaseModel, Field
class BookCreate(BaseModel): title: str = Field(..., description="Название книги", examples=["Война и мир"]) author: str = Field(..., description="Автор", examples=["Л.Н. Толстой"]) year: int = Field(..., ge=0, description="Год издания", examples=[1869])
@app.post( "/api/v1/books", response_model=BookResponse, status_code=status.HTTP_201_CREATED, summary="Создать книгу", description="Создаёт новую книгу и возвращает её представление.", tags=["books"],)async def create_book(book: BookCreate): return db.create_book(book)response_model определяет схему ответа, summary/description — поясняющие тексты, tags — группировку эндпойнтов в документации.
7. Тестирование API
Тесты позволяют убедиться, что API работает по контракту, и защищают от регрессий при изменениях. FastAPI предоставляет TestClient (на базе httpx/requests), который обращается к приложению напрямую, без запуска реального сервера.
7.1. Базовый тест с TestClient
from fastapi.testclient import TestClientfrom main import app
client = TestClient(app)
def test_create_book(): response = client.post( "/api/v1/books", json={"title": "Test", "author": "Author", "year": 2024}, ) assert response.status_code == 201 assert response.json()["title"] == "Test"7.2. Уровни тестов
Unit-тесты проверяют отдельный эндпойнт или функцию изолированно.
Integration-тесты проверяют связку из нескольких шагов — например, создание и последующее получение ресурса:
def test_get_book_by_id(): created = client.post( "/api/v1/books", json={"title": "Test", "author": "Author", "year": 2024}, ) book_id = created.json()["id"]
response = client.get(f"/api/v1/books/{book_id}") assert response.status_code == 200 assert response.json()["id"] == book_idE2E-тесты проходят полный жизненный цикл ресурса: создание → обновление (PATCH) → удаление (DELETE) → проверка, что ресурс больше недоступен (GET возвращает 404).
7.3. Фикстуры pytest
Чтобы не повторять подготовку, используют фикстуры. Например, общий клиент:
import pytestfrom fastapi.testclient import TestClientfrom main import app
@pytest.fixturedef client(): return TestClient(app)
def test_list_books(client): response = client.get("/api/v1/books?page=1&per_page=10") assert response.status_code == 200 assert "data" in response.json()Также стоит тестировать «негативные» сценарии: несуществующий ресурс (404), невалидные данные (422), превышение лимита (429). Хорошие тесты проверяют не только «счастливый путь», но и поведение при ошибках.
8. Лучшие практики REST API
Соберём ключевые рекомендации в единый чек-лист.
8.1. Что делать
- Правильные HTTP-методы: GET — чтение, POST — создание, PUT — полная замена, PATCH — частичное обновление, DELETE — удаление.
- Правильные коды состояния:
200/201/204для успеха,4xxдля ошибок клиента,5xxдля ошибок сервера. - Существительные во множественном числе в URI:
/users, а не/getUser. - Версионируйте API с самого начала:
/api/v1/.... - Всегда используйте пагинацию для коллекций.
- Стандартизируйте ответы и ошибки — единый формат во всём API.
- Документируйте через OpenAPI/Swagger, добавляйте примеры и описания.
- Покрывайте тестами позитивные и негативные сценарии.
- Защищайте API: rate limiting, HTTPS в production, аутентификация (см. лекции 10–11).
- Используйте кэширование — заголовки
Cache-Control,ETagдля условных запросов.
8.2. Типичные ошибки
❌ POST /api/createUser ✅ POST /api/v1/users❌ GET /api/users/123/delete ✅ DELETE /api/v1/users/123❌ 200 OK {"error": "not found"} ✅ 404 Not Found {"error": {...}}❌ /api/users (без версии) ✅ /api/v1/users❌ Иногда {"user":...}, иногда {"data":...} ✅ Всегда единый форматГлавная мысль: API — это контракт. Чем он более предсказуем, консистентен и хорошо описан, тем проще с ним работать клиентам и тем дешевле его поддерживать.
Краткие итоги
- Пагинация, фильтрация, сортировка делают работу с большими коллекциями эффективной. Offset-пагинация проста, cursor-пагинация устойчива и быстра на больших объёмах. В FastAPI всё это реализуется через параметры
Queryс валидацией. - Версионирование защищает клиентов от ломающих изменений. Самый практичный способ — URI-версионирование (
/api/v1/...), удобно организуемое черезAPIRouter. - Единый формат ошибок с полями
code,message,detailsи корректными кодами состояния делает API предсказуемым. Глобальные обработчики исключений приводят все ошибки к одному виду. - Rate limiting (например, через
slowapi) защищает сервис; при превышении возвращается429, а заголовкиX-RateLimit-*информируют клиента. - FastAPI автоматически генерирует OpenAPI и предоставляет Swagger UI (
/docs) и ReDoc (/redoc); качество документации зависит от аннотаций иField. - Тестирование через
pytestиTestClientпокрывает unit-, integration- и E2E-сценарии, включая обработку ошибок. - Лучшие практики REST сводятся к консистентности, правильному использованию HTTP и хорошей документации.
Вопросы для самопроверки
- В чём разница между offset-based и cursor-based пагинацией? Когда какой подход предпочтительнее?
- Как с помощью
Queryограничить значение параметраper_pageдиапазоном от 1 до 100? Что вернёт FastAPI при нарушении? - Перечислите три стратегии версионирования API. Какие у каждой плюсы и минусы?
- Как с помощью
APIRouterорганизовать сосуществование версийv1иv2? - Какие поля должен содержать единый формат ошибки и зачем нужен машиночитаемый
code? - Почему нельзя возвращать ошибку с кодом состояния
200 OK? Какой код подходит для несуществующего ресурса, а какой — для ошибки валидации? - Что такое rate limiting и какой код состояния возвращается при превышении лимита? Какие заголовки информируют клиента?
- По каким источникам FastAPI строит документацию OpenAPI? Чем отличаются
/docsи/redoc? - Чем
TestClientудобен для тестирования и в чём разница между unit-, integration- и E2E-тестами? - Назовите пять лучших практик проектирования REST API и пять типичных ошибок.