diff --git a/net/xeaf/rack/core/__init__.py b/net/xeaf/rack/core/__init__.py index 4e957d1..656fa5e 100644 --- a/net/xeaf/rack/core/__init__.py +++ b/net/xeaf/rack/core/__init__.py @@ -10,3 +10,4 @@ from .core_enum import CoreEnum from .core_exception import CoreException +from .core_response import CoreResponse diff --git a/net/xeaf/rack/core/core_response.py b/net/xeaf/rack/core/core_response.py new file mode 100644 index 0000000..93b8416 --- /dev/null +++ b/net/xeaf/rack/core/core_response.py @@ -0,0 +1,51 @@ +# DRF Rack +# Библиотека классов расширений для Django REST Framework +# +# Автор: Николай В. Анохин +# Все права защищены. Лицензия: MIT + +""" +Описание класса CoreResponse +""" + +from django.utils import timezone +from rest_framework import status +from rest_framework.response import Response + + +class CoreResponse(Response): + """ + Базовый класс для всех классов ответов API проекта + """ + + def __init__(self, internal_status: int, external_status: int, data: list | dict | str | bool | None, meta: dict | None = None): + """ + Инициализация + + :param internal_status: Внутренний код состояния + :param external_status: Внешний код состояния + :param data: Набор возвращаемых данных + :param meta: Дополнительные данные + """ + + result: dict[str, list | dict | str | float | int | bool] = { + "status": internal_status, + "timestamp": timezone.now().timestamp() + } + + # Формируем данные ответа + if data is not None: + if internal_status < status.HTTP_400_BAD_REQUEST: + result["data"] = data + else: + result["detail"] = data + + # Набор дополнительных данных + if meta: + result["meta"] = meta + + # Формируем ответ + super().__init__( + data=result, + status=external_status + ) diff --git a/net/xeaf/rack/models/responses/__init__.py b/net/xeaf/rack/models/responses/__init__.py new file mode 100644 index 0000000..b3066ac --- /dev/null +++ b/net/xeaf/rack/models/responses/__init__.py @@ -0,0 +1,16 @@ +# DRF Rack +# Библиотека классов расширений для Django REST Framework +# +# Автор: Николай В. Анохин +# Все права защищены. Лицензия: MIT + +""" +Пакет описания классов моделей ответов по протоколу HTTP +""" + +from .bool_response import BoolResponse +from .data_response import DataResponse +from .empty_response import EmptyResponse +from .error_response import ErrorResponse +from .exception_response import ExceptionResponse +from .list_response import ListResponse diff --git a/net/xeaf/rack/models/responses/bool_response.py b/net/xeaf/rack/models/responses/bool_response.py new file mode 100644 index 0000000..147ae5a --- /dev/null +++ b/net/xeaf/rack/models/responses/bool_response.py @@ -0,0 +1,33 @@ +# DRF Rack +# Библиотека классов расширений для Django REST Framework +# +# Автор: Николай В. Анохин +# Все права защищены. Лицензия: MIT + +""" +Описание класса BoolResponse +""" + +from rest_framework import status + +from net.xeaf.rack.core import CoreResponse + + +class BoolResponse(CoreResponse): + """ + Ответ с логическим значением + """ + + def __init__(self, value: bool, meta: dict | None = None): + """ + Инициализация + + :param value: Логическое значение + :param meta: Дополнительная информация + """ + + super(BoolResponse, self).__init__( + internal_status=status.HTTP_200_OK, + external_status=status.HTTP_200_OK, + data=value, + meta=meta) diff --git a/net/xeaf/rack/models/responses/data_response.py b/net/xeaf/rack/models/responses/data_response.py new file mode 100644 index 0000000..59ebe77 --- /dev/null +++ b/net/xeaf/rack/models/responses/data_response.py @@ -0,0 +1,33 @@ +# DRF Rack +# Библиотека классов расширений для Django REST Framework +# +# Автор: Николай В. Анохин +# Все права защищены. Лицензия: MIT + +""" +Описание класса DataResponse +""" + +from rest_framework import status + +from net.xeaf.rack.core import CoreResponse + + +class DataResponse(CoreResponse): + """ + Ответ с одним объектом данных + """ + + def __init__(self, data: dict, meta: dict | None = None): + """ + Инициализация + + :param data: Объект данных + :param meta: Дополнительная информация + """ + + super(DataResponse, self).__init__( + internal_status=status.HTTP_200_OK, + external_status=status.HTTP_200_OK, + data=data, + meta=meta) diff --git a/net/xeaf/rack/models/responses/empty_response.py b/net/xeaf/rack/models/responses/empty_response.py new file mode 100644 index 0000000..73e0ff7 --- /dev/null +++ b/net/xeaf/rack/models/responses/empty_response.py @@ -0,0 +1,30 @@ +# DRF Rack +# Библиотека классов расширений для Django REST Framework +# +# Автор: Николай В. Анохин +# Все права защищены. Лицензия: MIT + +""" +Описание класса EmptyResponse +""" + +from rest_framework import status + +from net.xeaf.rack.core import CoreResponse + + +class EmptyResponse(CoreResponse): + """ + Пустой положительный ответ + """ + + def __init__(self): + """ + Инициализация + """ + + super(EmptyResponse, self).__init__( + internal_status=status.HTTP_200_OK, + external_status=status.HTTP_200_OK, + data=None, + meta=None) diff --git a/net/xeaf/rack/models/responses/error_response.py b/net/xeaf/rack/models/responses/error_response.py new file mode 100644 index 0000000..bd74c7f --- /dev/null +++ b/net/xeaf/rack/models/responses/error_response.py @@ -0,0 +1,49 @@ +# DRF Rack +# Библиотека классов расширений для Django REST Framework +# +# Автор: Николай В. Анохин +# Все права защищены. Лицензия: MIT + +""" +Описание класса ErrorResponse +""" + +from rest_framework import status + +from net.xeaf.rack.core import CoreResponse + + +class ErrorResponse(CoreResponse): + """ + Ответ с информацией об ошибке + """ + + def __init__(self, status_code: int, detail: list | dict | str | bool | None, meta: dict | None = None): + """ + Инициализация + + :param status_code: Код состояния + :param detail: Информация об ошибке + :param meta: Дополнительные данные + """ + + super(ErrorResponse, self).__init__( + internal_status=status_code, + external_status=self._calc_external_status(status_code), + data=detail, + meta=meta) + + @classmethod + def _calc_external_status(cls, status_code: int) -> int: + """ + Вычисляет значение внешнего возвращаемого статуса + + :param status_code: Код состояния + + :return: Внешний код состояния + """ + + if status_code < status.HTTP_500_INTERNAL_SERVER_ERROR: + return status.HTTP_200_OK + + return status_code diff --git a/net/xeaf/rack/models/responses/exception_response.py b/net/xeaf/rack/models/responses/exception_response.py new file mode 100644 index 0000000..00347ed --- /dev/null +++ b/net/xeaf/rack/models/responses/exception_response.py @@ -0,0 +1,40 @@ +# DRF Rack +# Библиотека классов расширений для Django REST Framework +# +# Автор: Николай В. Анохин +# Все права защищены. Лицензия: MIT + +""" +Описание класса ExceptionResponse +""" + +from rest_framework import status + +from net.xeaf.rack.core import CoreException +from net.xeaf.rack.core import CoreResponse + + +class ExceptionResponse(CoreResponse): + """ + Ответ с информацией об исключении + """ + + def __init__(self, exc: CoreException, meta: dict | None = None): + """ + Инициализация + + :param exc: Исключение + :param meta: Дополнительная информация + """ + + # Проверяем требование передать реальный статус + if exc.is_pure_status(): + external_status = exc.status_code + else: + external_status = status.HTTP_200_OK + + super(ExceptionResponse, self).__init__( + internal_status=exc.status_code, + external_status=external_status, + data=exc.detail, + meta=meta) diff --git a/net/xeaf/rack/models/responses/list_response.py b/net/xeaf/rack/models/responses/list_response.py new file mode 100644 index 0000000..a3cfe7e --- /dev/null +++ b/net/xeaf/rack/models/responses/list_response.py @@ -0,0 +1,33 @@ +# DRF Rack +# Библиотека классов расширений для Django REST Framework +# +# Автор: Николай В. Анохин +# Все права защищены. Лицензия: MIT + +""" +Описание класса ListResponse +""" + +from rest_framework import status + +from net.xeaf.rack.core import CoreResponse + + +class ListResponse(CoreResponse): + """ + Ответ со списком объектов данных + """ + + def __init__(self, data: list, meta: dict | None = None): + """ + Инициализация + + :param data: Список объектов данных + :param meta: Дополнительная информация + """ + + super(ListResponse, self).__init__( + internal_status=status.HTTP_200_OK, + external_status=status.HTTP_200_OK, + data=data, + meta=meta) diff --git a/net/xeaf/rack/tests/core/__init__.py b/net/xeaf/rack/tests/core/__init__.py index 29ccbb3..2157389 100644 --- a/net/xeaf/rack/tests/core/__init__.py +++ b/net/xeaf/rack/tests/core/__init__.py @@ -10,3 +10,4 @@ from .core_enum_tests import CoreEnumTests from .core_exception_tests import CoreExceptionTests +from .core_response_tests import CoreResponseTests diff --git a/net/xeaf/rack/tests/core/core_response_tests.py b/net/xeaf/rack/tests/core/core_response_tests.py new file mode 100644 index 0000000..7e15c16 --- /dev/null +++ b/net/xeaf/rack/tests/core/core_response_tests.py @@ -0,0 +1,98 @@ +# DRF Rack +# Библиотека классов расширений для Django REST Framework +# +# Автор: Николай В. Анохин +# Все права защищены. Лицензия: MIT + +""" +Описание класса CoreResponseTests +""" + +from django.test import SimpleTestCase +from rest_framework import status + +from net.xeaf.rack.core import CoreResponse + + +class CoreResponseTests(SimpleTestCase): + """ + Тесты для класса CoreResponse + """ + + def test_init_success_format(self): + """ + Проверяет формат успешного ответа (код < 400): данные улетают в 'data' + """ + + test_data = {"user_id": 42} + internal_code = status.HTTP_200_OK + external_code = status.HTTP_200_OK + + response = CoreResponse( + internal_status=internal_code, + external_status=external_code, + data=test_data + ) + + # Проверяем внешний HTTP-статус в заголовках + self.assertEqual(response.status_code, external_code) + + # Проверяем структуру тела JSON-ответа (внутреннее свойство DRF .data) + self.assertEqual(response.data["status"], internal_code) + self.assertIn("timestamp", response.data) + # Так как это успех, данные обязаны быть в ключе "data" + self.assertEqual(response.data["data"], test_data) + self.assertNotIn("detail", response.data) + self.assertNotIn("meta", response.data) + + def test_init_error_format(self): + """ + Проверяет формат ответа с ошибкой (код >= 400): данные улетают в 'detail' + """ + + error_msg = "Некорректные параметры запроса" + internal_code = status.HTTP_400_BAD_REQUEST + external_code = status.HTTP_200_OK # Маскируем под 200 OK для фронтенда + + response = CoreResponse( + internal_status=internal_code, + external_status=external_code, + data=error_msg + ) + + self.assertEqual(response.status_code, external_code) + self.assertEqual(response.data["status"], internal_code) + # Так как это ошибка, данные обязаны быть в ключе "detail" + self.assertEqual(response.data["detail"], error_msg) + self.assertNotIn("data", response.data) + + def test_init_with_meta(self): + """ + Проверяет корректную интеграцию словаря meta в тело ответа + """ + + meta_data = {"page": 1, "has_next": False} + + response = CoreResponse( + internal_status=status.HTTP_200_OK, + external_status=status.HTTP_200_OK, + data={"items": []}, + meta=meta_data + ) + + self.assertIn("meta", response.data) + self.assertEqual(response.data["meta"], meta_data) + + def test_init_with_empty_data(self): + """ + Проверяет, что если data=None или пустой, ключи 'data' и 'detail' не создаются + """ + + response = CoreResponse( + internal_status=status.HTTP_204_NO_CONTENT, + external_status=status.HTTP_204_NO_CONTENT, + data=None + ) + + self.assertNotIn("data", response.data) + self.assertNotIn("detail", response.data) diff --git a/net/xeaf/rack/tests/models/responses/__init__.py b/net/xeaf/rack/tests/models/responses/__init__.py new file mode 100644 index 0000000..e826be3 --- /dev/null +++ b/net/xeaf/rack/tests/models/responses/__init__.py @@ -0,0 +1,16 @@ +# DRF Rack +# Библиотека классов расширений для Django REST Framework +# +# Автор: Николай В. Анохин +# Все права защищены. Лицензия: MIT + +""" +Пакет описания тестов классов ответов по протоколу HTTP +""" + +from .bool_response_tests import BoolResponseTests +from .data_response_tests import DataResponseTests +from .empty_response_tests import EmptyResponseTests +from .error_response_tests import ErrorResponseTests +from .exception_response_tests import ExceptionResponseTests +from .list_response_tests import ListResponseTests diff --git a/net/xeaf/rack/tests/models/responses/bool_response_tests.py b/net/xeaf/rack/tests/models/responses/bool_response_tests.py new file mode 100644 index 0000000..81bdebf --- /dev/null +++ b/net/xeaf/rack/tests/models/responses/bool_response_tests.py @@ -0,0 +1,51 @@ +# DRF Rack +# Библиотека классов расширений для Django REST Framework +# +# Автор: Николай В. Анохин +# Все права защищены. Лицензия: MIT + +""" +Описание класса BoolResponseTests +""" + +from django.test import SimpleTestCase +from rest_framework import status + +from net.xeaf.rack.models.responses import BoolResponse + + +class BoolResponseTests(SimpleTestCase): + """ + Тесты для класса BoolResponse + """ + + def test_init_with_true_value(self): + """ + Проверяет корректное создание ответа со значением True + """ + + test_meta = {"action": "verify"} + + response = BoolResponse(value=True, meta=test_meta) + + # Проверяем заголовки и статус + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(response.data["status"], status.HTTP_200_OK) + + # Булево значение должно лежать в ключе "data", так как код 200 (< 400) + self.assertIs(response.data["data"], True) + self.assertEqual(response.data["meta"], test_meta) + + def test_init_with_false_value(self): + """ + Проверяет корректное создание ответа со значением False + """ + + response = BoolResponse(value=False) + + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(response.data["status"], status.HTTP_200_OK) + + # Проверяем, что False не интерпретируется как отсутствие данных + self.assertIs(response.data["data"], False) + diff --git a/net/xeaf/rack/tests/models/responses/data_response_tests.py b/net/xeaf/rack/tests/models/responses/data_response_tests.py new file mode 100644 index 0000000..e028777 --- /dev/null +++ b/net/xeaf/rack/tests/models/responses/data_response_tests.py @@ -0,0 +1,41 @@ +# DRF Rack +# Библиотека классов расширений для Django REST Framework +# +# Автор: Николай В. Анохин +# Все права защищены. Лицензия: MIT + +""" +Описание класса DataResponseTests +""" + +from django.test import SimpleTestCase +from rest_framework import status + +from net.xeaf.rack.models.responses import DataResponse + + +class DataResponseTests(SimpleTestCase): + """ + Тесты для класса DataResponse + """ + + def test_init_with_dict_data(self): + """ + Проверяет корректное создание ответа со словарем данных и дефолтными статус-кодами + """ + + test_dict = {"id": 42, "name": "Main Project", "is_active": True} + test_meta = {"version": "1.0.0"} + + response = DataResponse(data=test_dict, meta=test_meta) + + # Проверяем честный HTTP-статус в заголовках + self.assertEqual(response.status_code, status.HTTP_200_OK) + + # Проверяем контракт тела JSON + self.assertEqual(response.data["status"], status.HTTP_200_OK) + # Так как код 200 (< 400), объект обязан лежать именно в "data" + self.assertEqual(response.data["data"], test_dict) + + # Проверяем проброс метаданных + self.assertEqual(response.data["meta"], test_meta) diff --git a/net/xeaf/rack/tests/models/responses/empty_response_tests.py b/net/xeaf/rack/tests/models/responses/empty_response_tests.py new file mode 100644 index 0000000..9250f53 --- /dev/null +++ b/net/xeaf/rack/tests/models/responses/empty_response_tests.py @@ -0,0 +1,39 @@ +# DRF Rack +# Библиотека классов расширений для Django REST Framework +# +# Автор: Николай В. Анохин +# Все права защищены. Лицензия: MIT + +""" +Описание класса EmptyResponseTests +""" + +from django.test import SimpleTestCase +from rest_framework import status + +from net.xeaf.rack.models.responses import EmptyResponse + + +class EmptyResponseTests(SimpleTestCase): + """ + Тесты для класса EmptyResponse + """ + + def test_init_sugar_configuration(self): + """ + Проверяет жестко зашитую конфигурацию пустого успешного ответа + """ + + response = EmptyResponse() + + # Проверяем честный HTTP-статус в заголовках + self.assertEqual(response.status_code, status.HTTP_200_OK) + + # Проверяем контракт тела ответа в JSON + self.assertEqual(response.data["status"], status.HTTP_200_OK) + self.assertIn("timestamp", response.data) + + # Гарантируем, что ключи данных и деталей не создались, так как data=None + self.assertNotIn("data", response.data) + self.assertNotIn("detail", response.data) + self.assertNotIn("meta", response.data) diff --git a/net/xeaf/rack/tests/models/responses/error_response_tests.py b/net/xeaf/rack/tests/models/responses/error_response_tests.py new file mode 100644 index 0000000..8aef431 --- /dev/null +++ b/net/xeaf/rack/tests/models/responses/error_response_tests.py @@ -0,0 +1,65 @@ +# DRF Rack +# Библиотека классов расширений для Django REST Framework +# +# Автор: Николай В. Анохин +# Все права защищены. Лицензия: MIT + +""" +Описание класса ErrorResponseTests +""" + +from django.test import SimpleTestCase +from rest_framework import status + +from net.xeaf.rack.models.responses import ErrorResponse + + +class ErrorResponseTests(SimpleTestCase): + """ + Тесты для класса ErrorResponse + """ + + def test_calc_external_status_for_client_error(self): + """ + Ошибки типа 4xx (меньше 500) должны маскироваться под 200 OK + """ + + # Вызываем protected метод класса напрямую + result = ErrorResponse._calc_external_status(status.HTTP_400_BAD_REQUEST) + self.assertEqual(result, status.HTTP_200_OK) + + result_403 = ErrorResponse._calc_external_status(status.HTTP_403_FORBIDDEN) + self.assertEqual(result_403, status.HTTP_200_OK) + + def test_calc_external_status_for_server_error(self): + """ + Ошибки типа 5xx (500 и выше) должны возвращать свой честный HTTP код + """ + + result = ErrorResponse._calc_external_status(status.HTTP_500_INTERNAL_SERVER_ERROR) + self.assertEqual(result, status.HTTP_500_INTERNAL_SERVER_ERROR) + + result_503 = ErrorResponse._calc_external_status(status.HTTP_503_SERVICE_UNAVAILABLE) + self.assertEqual(result_503, status.HTTP_503_SERVICE_UNAVAILABLE) + + def test_init_integration(self): + """ + Проверяет базовый контракт сборки ответа с ошибкой + """ + + error_msg = "Доступ запрещен" + internal_code = status.HTTP_403_FORBIDDEN + + response = ErrorResponse( + status_code=internal_code, + detail=error_msg + ) + + # Проверяем, что метод класса отработал внутри init и вернул 200 OK в заголовки + self.assertEqual(response.status_code, status.HTTP_200_OK) + + # Проверяем структуру тела ответа + self.assertEqual(response.data["status"], internal_code) + # Так как код ошибки >= 400, данные обязаны лежать в ключе "detail" + self.assertEqual(response.data["detail"], error_msg) + self.assertNotIn("data", response.data) diff --git a/net/xeaf/rack/tests/models/responses/exception_response_tests.py b/net/xeaf/rack/tests/models/responses/exception_response_tests.py new file mode 100644 index 0000000..7d72a0f --- /dev/null +++ b/net/xeaf/rack/tests/models/responses/exception_response_tests.py @@ -0,0 +1,64 @@ +# DRF Rack +# Библиотека классов расширений для Django REST Framework +# +# Автор: Николай В. Анохин +# Все права защищены. Лицензия: MIT + +""" +Описание класса ExceptionResponseTests +""" + +from django.test import SimpleTestCase +from rest_framework import status + +from net.xeaf.rack.core import CoreException +from net.xeaf.rack.models.responses import ExceptionResponse + + +class ExceptionResponseTests(SimpleTestCase): + """ + Тесты для класса ExceptionResponse + """ + + def test_init_with_standard_exception(self): + """ + Если у исключения is_pure_status() == False, + внешний HTTP статус должен быть 200 OK, но внутренний — реальный код ошибки, + а данные обязаны лежать в ключе 'detail' + """ + + # Симулируем обычную ошибку (is_pure_status по умолчанию выдает False) + exc = CoreException(status_code=status.HTTP_400_BAD_REQUEST, detail="Неверный логин") + + response = ExceptionResponse(exc=exc) + + # Проверяем маскировку под 200 OK для фронтенда в заголовках + self.assertEqual(response.status_code, status.HTTP_200_OK) + + # Проверяем JSON контракт + self.assertEqual(response.data["status"], status.HTTP_400_BAD_REQUEST) + # Так как реальный статус ошибки >= 400, данные ДОЛЖНЫ быть в detail + self.assertEqual(response.data["detail"], "Неверный логин") + self.assertNotIn("data", response.data) + + def test_init_with_pure_http_exception(self): + """ + Если у исключения is_pure_status() == True, + и внешний HTTP статус, и внутренний код должны быть равны коду ошибки + """ + + # Создаем тестовый класс, переопределяющий метод на True (как твой CriticalException) + class PureException(CoreException): + def is_pure_status(self) -> bool: + return True + + exc = PureException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="Крах БД") + + response = ExceptionResponse(exc=exc) + + # Проверяем, что маскировка отключилась и в заголовках честный HTTP статус + self.assertEqual(response.status_code, status.HTTP_500_INTERNAL_SERVER_ERROR) + + # Внутренний контракт тоже содержит реальный код ошибки + self.assertEqual(response.data["status"], status.HTTP_500_INTERNAL_SERVER_ERROR) + self.assertEqual(response.data["detail"], "Крах БД") diff --git a/net/xeaf/rack/tests/models/responses/list_response_tests.py b/net/xeaf/rack/tests/models/responses/list_response_tests.py new file mode 100644 index 0000000..9b09333 --- /dev/null +++ b/net/xeaf/rack/tests/models/responses/list_response_tests.py @@ -0,0 +1,41 @@ +# DRF Rack +# Библиотека классов расширений для Django REST Framework +# +# Автор: Николай В. Анохин +# Все права защищены. Лицензия: MIT + +""" +Описание класса ListResponseTests +""" + +from django.test import SimpleTestCase +from rest_framework import status + +from net.xeaf.rack.models.responses import ListResponse + + +class ListResponseTests(SimpleTestCase): + """ + Тесты для класса ListResponse + """ + + def test_init_with_list_data(self): + """ + Проверяет корректное создание ответа со списком и дефолтными статус-кодами + """ + + test_list = [{"id": 1, "name": "Project A"}, {"id": 2, "name": "Project B"}] + test_meta = {"total_count": 2} + + response = ListResponse(data=test_list, meta=test_meta) + + # Проверяем честный HTTP-статус в заголовках + self.assertEqual(response.status_code, status.HTTP_200_OK) + + # Проверяем контракт тела JSON + self.assertEqual(response.data["status"], status.HTTP_200_OK) + # Так как код 200 (< 400), список обязан лежать именно в "data" + self.assertEqual(response.data["data"], test_list) + + # Проверяем, что метаданные тоже успешно долетели через super() + self.assertEqual(response.data["meta"], test_meta)