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

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