Рефакторинг - перенос пакета utils.drf в middleware

This commit is contained in:
2026-07-19 14:33:30 +03:00
parent 1d95a70282
commit 7c13582fdb
6 changed files with 5 additions and 4 deletions

View File

@@ -0,0 +1,14 @@
# DRF Rack
# Библиотека классов расширений для Django REST Framework
#
# Автор: Николай В. Анохин <n.anokhin@xeaf.net>
# Все права защищены. Лицензия: MIT
"""
Пакет классов функционала среднего звена
"""
from .custom_exception_handler import custom_exception_handler
from .custom_logging_middleware import CustomLoggingMiddleware
from .expiring_token_authentication import ExpiringTokenAuthentication

View File

@@ -0,0 +1,150 @@
# DRF Rack
# Библиотека классов расширений для Django REST Framework
#
# Автор: Николай В. Анохин <n.anokhin@xeaf.net>
# Все права защищены. Лицензия: MIT
"""
Описание функции custom_exception_handler
"""
import logging
from rest_framework import status
from rest_framework.exceptions import MethodNotAllowed
from rest_framework.exceptions import NotAuthenticated
from rest_framework.exceptions import PermissionDenied
from rest_framework.exceptions import ValidationError
from rest_framework.request import Request
from rest_framework.response import Response
from net.xeaf.rack.core import CoreException
from net.xeaf.rack.models.responses import ErrorResponse
from net.xeaf.rack.utils.exceptions import ForbiddenException
from net.xeaf.rack.utils.exceptions import NotFoundException
from net.xeaf.rack.utils.exceptions import UnauthorizedException
from wsgiapp import settings
def custom_exception_handler(exc: CoreException | Exception, context: dict) -> Response:
"""
Универсальный обработчик исключений
:param exc: Исключение
:param context: Контекст
:return: Ответ
"""
# Получаем информацию о запросе
request = context.get("request")
assert isinstance(request, Request)
# Это наше исключение
if isinstance(exc, CoreException):
return _make_error_response(
status_code=exc.status_code,
detail=exc.detail,
meta=exc.meta,
pure_status=exc.is_pure_status(),
exc_info=exc)
# Проверка на стандартное исключение валидации
if isinstance(exc, ValidationError):
return _process_validation_exception(exc)
# Проверка на стандартное исключение авторизации
if isinstance(exc, NotAuthenticated):
return _make_error_response(
status_code=status.HTTP_401_UNAUTHORIZED,
detail=UnauthorizedException.Msg.DEFAULT_MESSAGE,
meta=None,
pure_status=False,
exc_info=exc)
# Проверка на стандартное исключение прав доступа
if isinstance(exc, PermissionDenied):
return _make_error_response(
status_code=status.HTTP_403_FORBIDDEN,
detail=ForbiddenException.Msg.DEFAULT_MESSAGE,
meta=None,
pure_status=False,
exc_info=exc)
# Проверка на стандартное исключение отсутствия реализации
if isinstance(exc, MethodNotAllowed):
return _make_error_response(
status_code=status.HTTP_404_NOT_FOUND,
detail=NotFoundException.Msg.DEFAULT_MESSAGE,
meta=None,
pure_status=True,
exc_info=exc)
# Если ничего другое не прошло
return _make_error_response(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=str(exc),
meta=None,
pure_status=True,
exc_info=exc)
def _process_validation_exception(exc: ValidationError) -> ErrorResponse:
"""
Обрабатывает частный случай стандартного исключения валидации
:param exc: Исключение
:return: Ответ
"""
# Обработка списка
if isinstance(exc.detail, list):
if len(exc.detail) == 1:
detail = str(exc.detail[0])
else:
detail = exc.detail
# Обработка словаря
elif isinstance(exc.detail, dict):
detail = {}
for field, error_info in exc.detail.items():
if isinstance(error_info, list):
error = error_info[0]
detail[field] = str(error)
# Обработка остальных типов
else:
detail = str(exc.detail)
return _make_error_response(status_code=status.HTTP_400_BAD_REQUEST, detail=detail, meta=None, pure_status=False, exc_info=exc)
def _make_error_response(status_code: int, detail: list | dict | str | bool | None, meta: dict | None, pure_status: bool,
exc_info: Exception) -> ErrorResponse:
"""
Создает ответ с ошибкой
:param status_code: Код статуса
:param detail: Детали ошибки
:param meta: Дополнительная информация
:param pure_status: Признак отправки чистого кода состояния
:param exc_info: Исключение
:return: Ответ
"""
logger = logging.getLogger(settings.LOGGER_NAME)
if status_code == status.HTTP_400_BAD_REQUEST:
logger.debug(str(exc_info), exc_info=exc_info)
elif status_code == status.HTTP_401_UNAUTHORIZED:
logger.warning(str(exc_info), exc_info=exc_info)
elif status_code == status.HTTP_403_FORBIDDEN:
logger.warning(str(exc_info), exc_info=exc_info)
elif status_code == status.HTTP_404_NOT_FOUND:
logger.error(str(exc_info), exc_info=exc_info)
else:
logger.critical(str(exc_info), exc_info=exc_info)
return ErrorResponse(status_code=status_code, detail=detail, meta=meta, pure_status=pure_status)

View File

@@ -0,0 +1,207 @@
# DRF Rack
# Библиотека классов расширений для Django REST Framework
#
# Автор: Николай В. Анохин <n.anokhin@xeaf.net>
# Все права защищены. Лицензия: MIT
"""
Описание класса CustomLoggingMiddleware
"""
import json
import logging
from logging import Logger
from typing import Any
from django.conf import settings
from django.http import HttpRequest
from django.http import HttpResponse
from rest_framework import status
class CustomLoggingMiddleware:
"""
Класс для логирования запросов и ответов DRF
"""
DEFAULT_SENSITIVE_KEYS = {'password', 'old_password', 'new_password', 'token', 'access_token'}
""" Словарь ключей, которые нужно замаскировать в логах """
MAX_LOG_LENGTH = 2048
""" Максимальная длина объекта данных лога """
sensitive_keys: set = {}
""" Актуальный словарь ключей, которые нужно замаскировать в логах """
logger: Logger
""" Объект доступа к логгеру """
def __init__(self, get_response):
"""
Инициализация
"""
self.get_response = get_response
self.logger = logging.getLogger(settings.LOGGER_NAME)
self.sensitive_keys = set(self.DEFAULT_SENSITIVE_KEYS)
if settings.LOGGER_SENSITIVE_KEYS:
self.sensitive_keys.update(settings.LOGGER_SENSITIVE_KEYS)
def __call__(self, request: HttpRequest):
"""
Собираем данные запроса и отправляем в лог
"""
# Обрабатываем данные запроса
path = request.path
method = request.method
query_params = self._process_query_params(request)
# Передаем запрос дальше по конвейеру Django / DRF
response = self.get_response(request)
# Обрабатываем данные запроса, ответа и перехваченного исключения
body = self._process_body(request)
exc = getattr(request, "_raised_exception", None)
status_code = response.status_code
request_id = request.META.get('HTTP_X_REQUEST_ID', '')
response_content = self._process_response(response)
# Обрабатываем данные пользователя
user_id = ""
if hasattr(request, 'user'):
if request.user.is_authenticated:
user_id = str(request.user.id)
# Формируем единое сообщение для лога
log_parts = [
f"{status_code} [{method}] {path}"
]
if user_id:
log_parts.append(f"User ID: {user_id}")
if request_id:
log_parts.append(f"Request ID: {request_id}")
if query_params:
log_parts.append(f"Query Params: {query_params}")
if body:
log_parts.append(f"Request Body: {body}")
if response_content:
log_parts.append(f"Response Content: {response_content}")
log_message = "\n".join(log_parts) + "\n"
# Создаем и записываем лог
self._write_log(log_message, status_code, exc)
return response
def _process_query_params(self, request: HttpRequest) -> dict[str, Any]:
"""
Обрабатывает параметры запроса и маскирует конфиденциальные данные
:param request: Объект запроса
:return: Обработанные параметры запроса
"""
raw_query_params = dict(request.GET.lists())
return self._mask_sensitive_data(raw_query_params)
def _process_body(self, request: HttpRequest) -> str:
"""
Обрабатывает тело запроса и маскирует конфиденциальные данные
:param request: Объект запроса
:return: Обработанное тело запроса
"""
if request.body:
return self._process_data(request.body)
return ""
def _process_response(self, response: HttpResponse) -> str:
"""
Обрабатываем данные ответа
:param response: HttpResponse
:return: Строковое представление данных ответа
"""
if hasattr(response, 'content'):
return self._process_data(response.content)
return ""
def _process_data(self, data: Any) -> str:
"""
Обрабатывает данные запроса или ответа и возвращает их в виде строки
:param data: Данные запроса или ответа
:return: Обработанные данные в виде строки
"""
data_len = len(data)
if data_len > self.MAX_LOG_LENGTH:
return f"<Data too large to log (len={data_len})>"
try:
raw_response = data.decode('utf-8')
try:
data_json = json.loads(raw_response)
masked_response = self._mask_sensitive_data(data_json)
result = json.dumps(masked_response, ensure_ascii=False, indent=2)
except json.JSONDecodeError:
result = raw_response
except UnicodeDecodeError:
result = f"<Binary/Streaming Data (len={data_len})>"
return result
def _write_log(self, log_message: str, status_code: int, exc: Exception | None):
"""
Записывает лог с учетом исключения
:param log_message: Сообщение лога
:param status_code: Код статуса ответа
:param exc: Исключение
"""
if exc:
log_message += f"\nException: {str(exc)}"
if status_code == status.HTTP_400_BAD_REQUEST:
self.logger.debug(log_message, exc_info=exc)
elif status_code in [status.HTTP_401_UNAUTHORIZED, status.HTTP_403_FORBIDDEN]: # UNAUTHORIZED, FORBIDDEN
self.logger.warning(log_message, exc_info=exc)
elif status_code == status.HTTP_404_NOT_FOUND:
self.logger.error(log_message, exc_info=exc)
else:
self.logger.critical(log_message, exc_info=exc)
else:
self.logger.debug(log_message)
def _mask_sensitive_data(self, data: Any) -> Any:
"""
Рекурсивно маскирует конфиденциальные данные в словарях и списках
:param data: Данные для маскировки
:return: Маскированные данные
"""
if isinstance(data, dict):
return {
key: '***' if key.lower() in self.sensitive_keys else self._mask_sensitive_data(value)
for key, value in data.items()
}
elif isinstance(data, list):
return [self._mask_sensitive_data(item) for item in data]
return data

View File

@@ -0,0 +1,75 @@
# DRF Rack
# Библиотека классов расширений для Django REST Framework
#
# Автор: Николай В. Анохин <n.anokhin@xeaf.net>
# Все права защищены. Лицензия: MIT
"""
Описание класса ExpiringTokenAuthentication
"""
from typing import Any
from rest_framework.authentication import TokenAuthentication
from rest_framework.request import Request
from net.xeaf.rack.managers import AccountManager
from net.xeaf.rack.managers import LocaleManager
from net.xeaf.rack.managers import SessionManager
from net.xeaf.rack.models import AccountModel
from net.xeaf.rack.models import SessionModel
class ExpiringTokenAuthentication(TokenAuthentication):
"""
Реализует механизм авторизации для Токенов со сроком действия
"""
AUTH_TOKEN_PARAM = "x-auth-token"
""" Идентификатор параметра токена """
def authenticate(self, request: Request) -> tuple[AccountModel | None, SessionModel | None] | tuple[Any, Any] | None:
"""
Метод авторизации
:param request: Информация о запросе
"""
LocaleManager.adjust_request_language(request)
key = request.query_params.get(self.AUTH_TOKEN_PARAM, None)
if key is not None:
return self.authenticate_credentials(key)
return super().authenticate(request)
def authenticate_credentials(self, key: str) -> tuple[AccountModel | None, SessionModel | None]:
"""
Проверка Токена авторизации
:param key: Токен авторизации
"""
session = SessionManager[SessionModel].find_by_token(token=key)
if not session or session.is_expired():
return None, None
account = session.account
if not self._check_account(account):
return None, None
SessionManager.renew_session(session)
return session.account, session
@classmethod
def _check_account(cls, account: AccountModel) -> bool:
"""
Проверка учетной записи
:param account: Учетная запись
"""
# Можно прошедшим проверку и не удаленным
reject = AccountManager.check_account_auth(account)
result = reject is None and not account.is_deleted()
return result