Поделиться
Поделиться

Хорошо спроектированный 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:

  1. Выпускаете v2
  2. Объявляете v1 deprecated (добавляете заголовок Sunset: Sat, 31 Dec 2026 00:00:00 GMT)
  3. Уведомляете пользователей минимум за 6 месяцев
  4. Отключаете 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.