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

Лекция 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=1000

2.3. Сортировка

Сортировка задаёт порядок выдачи. Распространены два стиля:

GET /api/v1/books?sort_by=price&order=desc
GET /api/v1/books?sort=price,-year # цена по возрастанию, год по убыванию

2.4. Реализация в FastAPI

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

from typing import Optional
from 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/users
GET /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/users
Accept: 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 book

4.3. Глобальные обработчики исключений

Чтобы привести все ошибки к единому формату, регистрируют обработчики через @app.exception_handler. Так мы перехватываем и встроенные HTTPException, и ошибки валидации:

from fastapi import FastAPI, Request, status
from fastapi.exceptions import RequestValidationError
from 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, Request
from slowapi import Limiter, _rate_limit_exceeded_handler
from slowapi.util import get_remote_address
from slowapi.errors import RateLimitExceeded
limiter = Limiter(key_func=get_remote_address)
app = FastAPI()
app.state.limiter = limiter
app.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 OK
X-RateLimit-Limit: 100
X-RateLimit-Remaining: 99
X-RateLimit-Reset: 1642233600
  • X-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 TestClient
from 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_id

E2E-тесты проходят полный жизненный цикл ресурса: создание → обновление (PATCH) → удаление (DELETE) → проверка, что ресурс больше недоступен (GET возвращает 404).

7.3. Фикстуры pytest

Чтобы не повторять подготовку, используют фикстуры. Например, общий клиент:

import pytest
from fastapi.testclient import TestClient
from main import app
@pytest.fixture
def 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. Что делать

  1. Правильные HTTP-методы: GET — чтение, POST — создание, PUT — полная замена, PATCH — частичное обновление, DELETE — удаление.
  2. Правильные коды состояния: 200/201/204 для успеха, 4xx для ошибок клиента, 5xx для ошибок сервера.
  3. Существительные во множественном числе в URI: /users, а не /getUser.
  4. Версионируйте API с самого начала: /api/v1/....
  5. Всегда используйте пагинацию для коллекций.
  6. Стандартизируйте ответы и ошибки — единый формат во всём API.
  7. Документируйте через OpenAPI/Swagger, добавляйте примеры и описания.
  8. Покрывайте тестами позитивные и негативные сценарии.
  9. Защищайте API: rate limiting, HTTPS в production, аутентификация (см. лекции 10–11).
  10. Используйте кэширование — заголовки 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 и хорошей документации.

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

  1. В чём разница между offset-based и cursor-based пагинацией? Когда какой подход предпочтительнее?
  2. Как с помощью Query ограничить значение параметра per_page диапазоном от 1 до 100? Что вернёт FastAPI при нарушении?
  3. Перечислите три стратегии версионирования API. Какие у каждой плюсы и минусы?
  4. Как с помощью APIRouter организовать сосуществование версий v1 и v2?
  5. Какие поля должен содержать единый формат ошибки и зачем нужен машиночитаемый code?
  6. Почему нельзя возвращать ошибку с кодом состояния 200 OK? Какой код подходит для несуществующего ресурса, а какой — для ошибки валидации?
  7. Что такое rate limiting и какой код состояния возвращается при превышении лимита? Какие заголовки информируют клиента?
  8. По каким источникам FastAPI строит документацию OpenAPI? Чем отличаются /docs и /redoc?
  9. Чем TestClient удобен для тестирования и в чём разница между unit-, integration- и E2E-тестами?
  10. Назовите пять лучших практик проектирования REST API и пять типичных ошибок.