first commit
Some checks failed
CI/CD Pipeline / Run Tests (push) Failing after 0s
CI/CD Pipeline / Code Quality Checks (push) Failing after 1m43s
CI/CD Pipeline / Build Docker Images (push) Has been skipped
CI/CD Pipeline / Push to Gitea Registry (push) Has been skipped

This commit is contained in:
2026-01-21 12:07:35 +01:00
commit e9d7f24aaa
102 changed files with 13890 additions and 0 deletions

374
src/apps/core/openapi.py Normal file
View File

@@ -0,0 +1,374 @@
"""
Утилиты для документирования API (OpenAPI/Swagger).
Предоставляет декораторы и утилиты для улучшения
автоматически генерируемой документации.
"""
from typing import Any
from drf_yasg import openapi
from drf_yasg.utils import swagger_auto_schema
def api_docs(
*,
summary: str,
description: str | None = None,
request_body: Any = None,
responses: dict[int, Any] | None = None,
tags: list[str] | None = None,
operation_id: str | None = None,
deprecated: bool = False,
security: list[dict[str, list[str]]] | None = None,
manual_parameters: list[openapi.Parameter] | None = None,
):
"""
Декоратор для документирования API эндпоинтов.
Упрощённая обёртка над swagger_auto_schema с поддержкой
типовых паттернов документирования.
Args:
summary: Краткое описание эндпоинта (отображается в списке)
description: Подробное описание (отображается при раскрытии)
request_body: Схема тела запроса (serializer или openapi.Schema)
responses: Словарь возможных ответов {status_code: schema}
tags: Теги для группировки в документации
operation_id: Уникальный идентификатор операции
deprecated: Пометить как устаревший
security: Требования безопасности
manual_parameters: Дополнительные параметры запроса
Пример использования:
class UserView(APIView):
@api_docs(
summary="Получить текущего пользователя",
description="Возвращает данные аутентифицированного пользователя",
responses={
200: UserSerializer,
401: "Не авторизован",
},
tags=["Пользователи"],
)
def get(self, request):
...
"""
# Преобразуем упрощённые responses в формат openapi
formatted_responses = {}
if responses:
for code, schema in responses.items():
if isinstance(schema, str):
# Простое текстовое описание
formatted_responses[code] = openapi.Response(description=schema)
elif isinstance(schema, type):
# Serializer class
formatted_responses[code] = openapi.Response(
description=_get_status_description(code),
schema=schema,
)
elif isinstance(schema, openapi.Response):
formatted_responses[code] = schema
else:
formatted_responses[code] = schema
return swagger_auto_schema(
operation_summary=summary,
operation_description=description,
request_body=request_body,
responses=formatted_responses or None,
tags=tags,
operation_id=operation_id,
deprecated=deprecated,
security=security,
manual_parameters=manual_parameters,
)
def _get_status_description(status_code: int) -> str:
"""Возвращает описание HTTP статуса на русском."""
descriptions = {
200: "Успешный запрос",
201: "Ресурс создан",
204: "Успешно, без содержимого",
400: "Некорректный запрос",
401: "Не авторизован",
403: "Доступ запрещён",
404: "Ресурс не найден",
409: "Конфликт",
422: "Ошибка валидации",
429: "Слишком много запросов",
500: "Внутренняя ошибка сервера",
}
return descriptions.get(status_code, f"HTTP {status_code}")
# Предопределённые схемы ответов
class CommonResponses:
"""
Общие схемы ответов для документации.
Пример использования:
@api_docs(
summary="Удалить ресурс",
responses={
204: CommonResponses.NO_CONTENT,
404: CommonResponses.NOT_FOUND,
},
)
def delete(self, request, pk):
...
"""
SUCCESS = openapi.Response(
description="Успешный запрос",
schema=openapi.Schema(
type=openapi.TYPE_OBJECT,
properties={
"success": openapi.Schema(type=openapi.TYPE_BOOLEAN, default=True),
"data": openapi.Schema(type=openapi.TYPE_OBJECT),
},
),
)
CREATED = openapi.Response(
description="Ресурс успешно создан",
schema=openapi.Schema(
type=openapi.TYPE_OBJECT,
properties={
"success": openapi.Schema(type=openapi.TYPE_BOOLEAN, default=True),
"data": openapi.Schema(type=openapi.TYPE_OBJECT),
},
),
)
NO_CONTENT = openapi.Response(description="Успешно, без содержимого")
BAD_REQUEST = openapi.Response(
description="Некорректный запрос",
schema=openapi.Schema(
type=openapi.TYPE_OBJECT,
properties={
"success": openapi.Schema(type=openapi.TYPE_BOOLEAN, default=False),
"errors": openapi.Schema(
type=openapi.TYPE_ARRAY,
items=openapi.Schema(
type=openapi.TYPE_OBJECT,
properties={
"code": openapi.Schema(type=openapi.TYPE_STRING),
"message": openapi.Schema(type=openapi.TYPE_STRING),
},
),
),
},
),
)
UNAUTHORIZED = openapi.Response(
description="Требуется аутентификация",
schema=openapi.Schema(
type=openapi.TYPE_OBJECT,
properties={
"detail": openapi.Schema(
type=openapi.TYPE_STRING,
default="Учётные данные не предоставлены.",
),
},
),
)
FORBIDDEN = openapi.Response(
description="Доступ запрещён",
schema=openapi.Schema(
type=openapi.TYPE_OBJECT,
properties={
"detail": openapi.Schema(
type=openapi.TYPE_STRING,
default="У вас нет прав для выполнения этого действия.",
),
},
),
)
NOT_FOUND = openapi.Response(
description="Ресурс не найден",
schema=openapi.Schema(
type=openapi.TYPE_OBJECT,
properties={
"success": openapi.Schema(type=openapi.TYPE_BOOLEAN, default=False),
"errors": openapi.Schema(
type=openapi.TYPE_ARRAY,
items=openapi.Schema(
type=openapi.TYPE_OBJECT,
properties={
"code": openapi.Schema(
type=openapi.TYPE_STRING, default="not_found"
),
"message": openapi.Schema(
type=openapi.TYPE_STRING, default="Ресурс не найден"
),
},
),
),
},
),
)
VALIDATION_ERROR = openapi.Response(
description="Ошибка валидации",
schema=openapi.Schema(
type=openapi.TYPE_OBJECT,
properties={
"success": openapi.Schema(type=openapi.TYPE_BOOLEAN, default=False),
"errors": openapi.Schema(
type=openapi.TYPE_ARRAY,
items=openapi.Schema(
type=openapi.TYPE_OBJECT,
properties={
"code": openapi.Schema(type=openapi.TYPE_STRING),
"message": openapi.Schema(type=openapi.TYPE_STRING),
"details": openapi.Schema(type=openapi.TYPE_OBJECT),
},
),
),
},
),
)
RATE_LIMITED = openapi.Response(
description="Превышен лимит запросов",
schema=openapi.Schema(
type=openapi.TYPE_OBJECT,
properties={
"detail": openapi.Schema(
type=openapi.TYPE_STRING,
default="Превышен лимит запросов. Повторите позже.",
),
},
),
)
SERVER_ERROR = openapi.Response(
description="Внутренняя ошибка сервера",
schema=openapi.Schema(
type=openapi.TYPE_OBJECT,
properties={
"success": openapi.Schema(type=openapi.TYPE_BOOLEAN, default=False),
"errors": openapi.Schema(
type=openapi.TYPE_ARRAY,
items=openapi.Schema(
type=openapi.TYPE_OBJECT,
properties={
"code": openapi.Schema(
type=openapi.TYPE_STRING, default="internal_error"
),
"message": openapi.Schema(
type=openapi.TYPE_STRING,
default="Внутренняя ошибка сервера",
),
},
),
),
},
),
)
# Параметры запроса
class CommonParameters:
"""
Общие параметры для документации API.
Пример использования:
@api_docs(
summary="Список ресурсов",
manual_parameters=[
CommonParameters.PAGE,
CommonParameters.PAGE_SIZE,
CommonParameters.SEARCH,
],
)
def get(self, request):
...
"""
PAGE = openapi.Parameter(
name="page",
in_=openapi.IN_QUERY,
type=openapi.TYPE_INTEGER,
description="Номер страницы",
default=1,
)
PAGE_SIZE = openapi.Parameter(
name="page_size",
in_=openapi.IN_QUERY,
type=openapi.TYPE_INTEGER,
description="Количество элементов на странице",
default=20,
)
SEARCH = openapi.Parameter(
name="search",
in_=openapi.IN_QUERY,
type=openapi.TYPE_STRING,
description="Поисковый запрос",
)
ORDERING = openapi.Parameter(
name="ordering",
in_=openapi.IN_QUERY,
type=openapi.TYPE_STRING,
description="Поле сортировки (префикс '-' для убывания)",
)
ID = openapi.Parameter(
name="id",
in_=openapi.IN_PATH,
type=openapi.TYPE_INTEGER,
description="ID ресурса",
required=True,
)
def paginated_response(serializer_class: type) -> openapi.Response:
"""
Создаёт схему пагинированного ответа.
Пример использования:
@api_docs(
summary="Список пользователей",
responses={200: paginated_response(UserSerializer)},
)
def get(self, request):
...
"""
return openapi.Response(
description="Пагинированный список",
schema=openapi.Schema(
type=openapi.TYPE_OBJECT,
properties={
"success": openapi.Schema(type=openapi.TYPE_BOOLEAN, default=True),
"data": openapi.Schema(
type=openapi.TYPE_ARRAY,
items=openapi.Schema(type=openapi.TYPE_OBJECT),
),
"meta": openapi.Schema(
type=openapi.TYPE_OBJECT,
properties={
"pagination": openapi.Schema(
type=openapi.TYPE_OBJECT,
properties={
"count": openapi.Schema(type=openapi.TYPE_INTEGER),
"page": openapi.Schema(type=openapi.TYPE_INTEGER),
"page_size": openapi.Schema(type=openapi.TYPE_INTEGER),
"total_pages": openapi.Schema(
type=openapi.TYPE_INTEGER
),
},
),
},
),
},
),
)