Хорошо спроектированный REST API — это продукт. Им пользуются разработчики, и их опыт так же важен, как UX мобильного приложения. Плохой API тормозит интеграции, генерирует тикеты в поддержку и копит технический долг. Разберём, как сделать API, которым приятно пользоваться.
Именование ресурсов
REST строится вокруг ресурсов — существительных, а не глаголов. HTTP-методы уже выражают действие.
| Правильно | Неправильно |
|---|---|
| GET /orders | GET /getOrders |
| POST /orders | POST /createOrder |
| DELETE /orders/42 | POST /deleteOrder |
| PATCH /orders/42/status | POST /changeOrderStatus |
Правила именования:
- Всегда во множественном числе:
/users,/products,/invoices - Строчные буквы, разделитель — дефис:
/order-items, неorderItems - Вложенность — для выражения принадлежности, но не глубже двух уровней
/users/123/orders ✅ заказы конкретного пользователя
/users/123/orders/456 ✅ конкретный заказ пользователя
/users/123/orders/456/items/789/reviews ❌ слишком глубоко
Для глубокой вложенности используйте плоские ресурсы с фильтрацией:
GET /order-items?orderId=456
GET /reviews?itemId=789
HTTP-методы и их семантика
| Метод | Семантика | Идемпотентность | |---|---|---| | GET | Получить ресурс / коллекцию | ✅ | | POST | Создать ресурс | ❌ | | PUT | Полностью заменить ресурс | ✅ | | PATCH | Частично обновить ресурс | ✅* | | DELETE | Удалить ресурс | ✅ |
*PATCH идемпотентен по спецификации, но реализация зависит от вас.
Версионирование
Версионирование нужно, чтобы вносить breaking changes не ломая существующих клиентов.
Версия в URL (рекомендуется)
https://api.example.com/v1/users
https://api.example.com/v2/users
Плюсы: просто, видно в браузере, легко логировать, удобно для документации.
Версия в заголовке
GET /users HTTP/1.1
Accept: application/vnd.example.v2+json
Минусы: сложнее тестировать и кешировать.
Стратегия миграции
Не удаляйте старые версии сразу. Типичный lifecycle:
- Выпускаете v2
- Объявляете v1 deprecated (добавляете заголовок
Sunset: Sat, 31 Dec 2026 00:00:00 GMT) - Уведомляете пользователей минимум за 6 месяцев
- Отключаете v1
# FastAPI: заголовок Sunset для deprecated версий
from fastapi import APIRouter, Response
from datetime import datetime
v1_router = APIRouter(prefix="/v1")
@v1_router.middleware("http")
async def add_deprecation_header(request, call_next):
response = await call_next(request)
response.headers["Sunset"] = "Sat, 31 Dec 2026 00:00:00 GMT"
response.headers["Deprecation"] = "true"
response.headers["Link"] = '</v2/docs>; rel="successor-version"'
return response
Аутентификация и авторизация
JWT (JSON Web Tokens)
Подходит для stateless API. Access token живёт 15-60 минут, refresh token — 30 дней.
# Генерация токенов
import jwt
from datetime import datetime, timedelta
def create_access_token(user_id: str) -> str:
payload = {
"sub": user_id,
"type": "access",
"iat": datetime.utcnow(),
"exp": datetime.utcnow() + timedelta(minutes=30)
}
return jwt.encode(payload, settings.SECRET_KEY, algorithm="HS256")
def create_refresh_token(user_id: str) -> str:
payload = {
"sub": user_id,
"type": "refresh",
"jti": str(uuid4()), # уникальный ID для инвалидации
"exp": datetime.utcnow() + timedelta(days=30)
}
return jwt.encode(payload, settings.SECRET_KEY, algorithm="HS256")
# Защищённый endpoint
from fastapi import Depends, HTTPException, status
from fastapi.security import HTTPBearer
security = HTTPBearer()
async def get_current_user(token: str = Depends(security)):
try:
payload = jwt.decode(token.credentials, settings.SECRET_KEY, algorithms=["HS256"])
if payload.get("type") != "access":
raise HTTPException(status_code=401, detail="Неверный тип токена")
return await user_service.get_by_id(payload["sub"])
except jwt.ExpiredSignatureError:
raise HTTPException(status_code=401, detail="Токен истёк")
except jwt.InvalidTokenError:
raise HTTPException(status_code=401, detail="Недействительный токен")
OAuth 2.0
Для интеграций с третьими сторонами используйте OAuth 2.0 с PKCE (для публичных клиентов):
1. GET /oauth/authorize?response_type=code&client_id=...&code_challenge=...
2. Пользователь логинится и даёт разрешение
3. Редирект на callback с code
4. POST /oauth/token с code + code_verifier → access_token + refresh_token
Пагинация
Offset-based
Простая, но медленная на больших таблицах:
GET /users?page=3&limit=20
{
"data": [...],
"pagination": {
"page": 3,
"limit": 20,
"total": 1543,
"totalPages": 78
}
}
Проблема: при добавлении новых записей страницы смещаются, появляются дубликаты.
Cursor-based (рекомендуется для больших данных)
GET /users?limit=20&cursor=eyJpZCI6MTAwfQ==
Cursor — это base64-закодированный указатель на последний элемент:
import base64, json
def encode_cursor(item_id: int) -> str:
return base64.b64encode(json.dumps({"id": item_id}).encode()).decode()
def decode_cursor(cursor: str) -> dict:
return json.loads(base64.b64decode(cursor).decode())
# Запрос
async def get_users(limit: int = 20, cursor: str | None = None):
query = db.users.select()
if cursor:
last_id = decode_cursor(cursor)["id"]
query = query.where(User.id > last_id)
users = await query.limit(limit + 1).all()
has_next = len(users) > limit
return {
"data": users[:limit],
"pagination": {
"hasNext": has_next,
"nextCursor": encode_cursor(users[limit - 1].id) if has_next else None
}
}
Стандарт кодов ошибок
Используйте HTTP-коды правильно и добавляйте machine-readable коды ошибок:
{
"error": {
"code": "VALIDATION_ERROR",
"message": "Ошибка валидации данных",
"details": [
{
"field": "email",
"code": "INVALID_FORMAT",
"message": "Некорректный формат email"
},
{
"field": "phone",
"code": "REQUIRED",
"message": "Поле обязательно для заполнения"
}
],
"requestId": "req_01J9K2M3N4P5Q6R7S8T9"
}
}
| HTTP-код | Когда использовать | |---|---| | 200 | Успех (GET, PUT, PATCH) | | 201 | Ресурс создан (POST) | | 204 | Успех без тела (DELETE) | | 400 | Ошибка клиента (валидация) | | 401 | Не аутентифицирован | | 403 | Нет прав доступа | | 404 | Ресурс не найден | | 409 | Конфликт (дубликат) | | 422 | Бизнес-логика нарушена | | 429 | Rate limit превышен | | 500 | Внутренняя ошибка сервера |
OpenAPI / Swagger
Документация должна быть актуальной — лучший способ это гарантировать, генерировать её из кода:
# FastAPI автоматически генерирует OpenAPI
from fastapi import FastAPI
from pydantic import BaseModel, EmailStr
app = FastAPI(
title="Example API",
version="2.0.0",
description="API для управления пользователями и заказами"
)
class CreateUserRequest(BaseModel):
name: str
email: EmailStr
phone: str | None = None
class UserResponse(BaseModel):
id: str
name: str
email: str
createdAt: datetime
@app.post(
"/v2/users",
response_model=UserResponse,
status_code=201,
summary="Создать пользователя",
tags=["users"]
)
async def create_user(body: CreateUserRequest) -> UserResponse:
"""
Создаёт нового пользователя в системе.
- **name**: Полное имя пользователя
- **email**: Уникальный email адрес
- **phone**: Номер телефона в формате +7XXXXXXXXXX (опционально)
"""
...
Документация будет доступна по /docs (Swagger UI) и /redoc.
Rate Limiting
Защищает API от злоупотреблений и обеспечивает fair use:
# Redis-based rate limiter
from redis.asyncio import Redis
import time
class RateLimiter:
def __init__(self, redis: Redis, limit: int, window: int):
self.redis = redis
self.limit = limit # максимум запросов
self.window = window # за этот период (секунды)
async def is_allowed(self, key: str) -> tuple[bool, dict]:
now = time.time()
window_start = now - self.window
pipe = self.redis.pipeline()
pipe.zremrangebyscore(key, 0, window_start)
pipe.zadd(key, {str(now): now})
pipe.zcard(key)
pipe.expire(key, self.window)
results = await pipe.execute()
count = results[2]
remaining = max(0, self.limit - count)
reset_at = int(now) + self.window
return count <= self.limit, {
"X-RateLimit-Limit": self.limit,
"X-RateLimit-Remaining": remaining,
"X-RateLimit-Reset": reset_at
}
# FastAPI middleware
@app.middleware("http")
async def rate_limit_middleware(request: Request, call_next):
user_id = request.state.user_id if hasattr(request.state, "user_id") else request.client.host
limiter = RateLimiter(redis, limit=100, window=60) # 100 запросов в минуту
allowed, headers = await limiter.is_allowed(f"rate:{user_id}")
if not allowed:
return JSONResponse(
status_code=429,
content={"error": {"code": "RATE_LIMIT_EXCEEDED", "message": "Слишком много запросов"}},
headers=headers
)
response = await call_next(request)
for k, v in headers.items():
response.headers[k] = str(v)
return response
Итог
Хороший REST API — это предсказуемые URL, правильные HTTP-коды, понятные ошибки, актуальная документация и защита от злоупотреблений. Эти принципы применимы независимо от языка и фреймворка.
При разработке фронтенда, потребляющего такой API, рекомендуем использовать React Query — подробнее в статье Разработка SaaS на React.

