diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index cd23062..a5944f1 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -4,6 +4,7 @@ on: pull_request: paths: - "apps/backend/**" + - "packages/drf-std-response/**" - "docs/contributors/**" - "docs/alienmark/**" - "docs/users/**" @@ -46,6 +47,7 @@ jobs: filters: | backend: - 'apps/backend/**' + - 'packages/drf-std-response/**' - 'pyproject.toml' - 'uv.lock' docs-contributors: diff --git a/apps/backend/Dockerfile b/apps/backend/Dockerfile index 50c930d..b0a96b6 100644 --- a/apps/backend/Dockerfile +++ b/apps/backend/Dockerfile @@ -17,7 +17,9 @@ COPY ./apps/backend/pyproject.toml ./apps/backend/pyproject.toml COPY ./docs/alienmark/pyproject.toml ./docs/alienmark/pyproject.toml COPY ./docs/contributors/pyproject.toml ./docs/contributors/pyproject.toml COPY ./docs/users/pyproject.toml ./docs/users/pyproject.toml -RUN uv sync --locked --no-dev --no-install-workspace --package aliencommons-backend +COPY ./packages/drf-std-response/pyproject.toml ./packages/drf-std-response/pyproject.toml +COPY ./packages/drf-std-response/src ./packages/drf-std-response/src +RUN uv sync --locked --no-dev --no-install-project --package aliencommons-backend # Copy Project COPY . . diff --git a/apps/backend/articles/serializers/articles.py b/apps/backend/articles/serializers/articles.py index 1598ecd..f06edfe 100644 --- a/apps/backend/articles/serializers/articles.py +++ b/apps/backend/articles/serializers/articles.py @@ -13,7 +13,7 @@ from core.validators import ( FileTypeValidator, FileSizeValidator ) -from core.exceptions import ServiceError +from drf_std_response import ServiceError from ..models import ( Article, ArticleSource, diff --git a/apps/backend/articles/services/articles.py b/apps/backend/articles/services/articles.py index a33a7b1..d2b7fa3 100644 --- a/apps/backend/articles/services/articles.py +++ b/apps/backend/articles/services/articles.py @@ -14,7 +14,7 @@ ArticleSnapshot, ArticleEvent, ) -from core.exceptions import ServiceError +from drf_std_response import ServiceError from core.utils.alienmark import render_md_to_html from logs.logging import get_logger from notifications.services import notify_subscribed_author_posted diff --git a/apps/backend/articles/tests/test_services.py b/apps/backend/articles/tests/test_services.py index 4453544..356791f 100644 --- a/apps/backend/articles/tests/test_services.py +++ b/apps/backend/articles/tests/test_services.py @@ -19,7 +19,7 @@ unpublish, withdraw, ) -from core.exceptions import ServiceError +from drf_std_response import ServiceError from core.tests.factories import ( create_article_snapshot, create_moderator, diff --git a/apps/backend/articles/views/articles.py b/apps/backend/articles/views/articles.py index 4cdbb5d..2f58237 100644 --- a/apps/backend/articles/views/articles.py +++ b/apps/backend/articles/views/articles.py @@ -3,9 +3,10 @@ from rest_framework import status from rest_framework.decorators import action from rest_framework.permissions import IsAuthenticated +from rest_framework.viewsets import ModelViewSet, ReadOnlyModelViewSet from core.utils.permissions import is_moderator -from core.views.viewsets import MyModelViewSet, MyReadOnlyModelViewSet +from drf_std_response import EnvelopeMixin from ..filters import ArticleFilter from ..models import Article, ArticleEvent, ArticleSnapshot, ArticlePublication from ..permissions import ( @@ -27,7 +28,7 @@ ) -class ArticleViewSet(MyModelViewSet): +class ArticleViewSet(EnvelopeMixin, ModelViewSet): queryset = Article.objects.select_related("author", "source") filter_backends = [filters.DjangoFilterBackend] filterset_class = ArticleFilter @@ -229,7 +230,7 @@ def trash(self, request, pk=None): ) -class ArticlePublicationViewSet(MyReadOnlyModelViewSet): +class ArticlePublicationViewSet(EnvelopeMixin, ReadOnlyModelViewSet): queryset = ArticlePublication.objects.select_related("article").prefetch_related("versions") serializer_class = ArticlePublicationSerializer permission_classes = [IsAuthenticated] @@ -248,7 +249,7 @@ def get_queryset(self): return with_article_publication_comment_count(queryset) -class ArticleSnapshotViewSet(MyReadOnlyModelViewSet): +class ArticleSnapshotViewSet(EnvelopeMixin, ReadOnlyModelViewSet): queryset = ArticleSnapshot.objects.all() serializer_class = ArticleSnapshotSerializer permission_classes = [ModeratorOnly] @@ -269,7 +270,7 @@ def pending_ones(self, request): ) -class ArticleEventReadViewset(MyReadOnlyModelViewSet): +class ArticleEventReadViewset(EnvelopeMixin, ReadOnlyModelViewSet): queryset = ArticleEvent.objects.all() permission_classes = (ArticleEventPermission,) serializer_class = ArticleEventSerializer diff --git a/apps/backend/articles/views/collections.py b/apps/backend/articles/views/collections.py index e265dc3..961b143 100644 --- a/apps/backend/articles/views/collections.py +++ b/apps/backend/articles/views/collections.py @@ -1,8 +1,9 @@ from django.db.models import Count, Max from rest_framework import status from rest_framework.exceptions import PermissionDenied +from rest_framework.viewsets import ModelViewSet -from core.views.viewsets import MyModelViewSet +from drf_std_response import EnvelopeMixin from ..models import Collection, CollectionItem from ..permissions import CollectionItemPermission, CollectionPermission from ..serializers import ( @@ -13,7 +14,7 @@ ) -class CollectionViewSet(MyModelViewSet): +class CollectionViewSet(EnvelopeMixin, ModelViewSet): queryset = Collection.objects.select_related("author") permission_classes = [CollectionPermission] default_serializer_class = CollectionReadSerializer @@ -82,7 +83,7 @@ def partial_update(self, request, *args, **kwargs): return self.update(request, *args, **kwargs) -class CollectionItemViewSet(MyModelViewSet): +class CollectionItemViewSet(EnvelopeMixin, ModelViewSet): queryset = CollectionItem.objects.select_related( "collection", "collection__author", diff --git a/apps/backend/backend/settings/base.py b/apps/backend/backend/settings/base.py index c136aee..16e1f41 100644 --- a/apps/backend/backend/settings/base.py +++ b/apps/backend/backend/settings/base.py @@ -437,7 +437,7 @@ "DEFAULT_PAGINATION_CLASS": "core.pagination.StandardPagination", "PAGE_SIZE": 20, - "EXCEPTION_HANDLER": "core.views.exception_handler.custom_exception_handler", + "EXCEPTION_HANDLER": "drf_std_response.exception_handlers.exception_handler", "DATETIME_FORMAT": "%Y-%m-%dT%H:%M:%S%z", "DATE_FORMAT": "%Y-%m-%d", } diff --git a/apps/backend/backend/settings/test.py b/apps/backend/backend/settings/test.py index 4814d9d..6d4a83f 100644 --- a/apps/backend/backend/settings/test.py +++ b/apps/backend/backend/settings/test.py @@ -80,7 +80,7 @@ "DEFAULT_AUTHENTICATION_CLASSES": [ "rest_framework.authentication.SessionAuthentication", ], - "EXCEPTION_HANDLER": "core.views.exception_handler.custom_exception_handler", + "EXCEPTION_HANDLER": "drf_std_response.exception_handlers.exception_handler", "DEFAULT_PAGINATION_CLASS": "core.pagination.StandardPagination", "PAGE_SIZE": 20, "DATETIME_FORMAT": "%Y-%m-%dT%H:%M:%S%z", diff --git a/apps/backend/bookmarks/views.py b/apps/backend/bookmarks/views.py index 8ae526f..daa403c 100644 --- a/apps/backend/bookmarks/views.py +++ b/apps/backend/bookmarks/views.py @@ -1,7 +1,8 @@ from django.db.models import Count from rest_framework import status +from rest_framework.viewsets import ModelViewSet -from core.views.viewsets import MyModelViewSet +from drf_std_response import EnvelopeMixin from .models import Bookmark, BookmarkFolder from .permissions import BookmarkOwnerOnly from .serializers import ( @@ -12,7 +13,7 @@ ) -class BookmarkFolderViewSet(MyModelViewSet): +class BookmarkFolderViewSet(EnvelopeMixin, ModelViewSet): queryset = BookmarkFolder.objects.select_related("user") permission_classes = [BookmarkOwnerOnly] default_serializer_class = BookmarkFolderReadSerializer @@ -83,7 +84,7 @@ def partial_update(self, request, *args, **kwargs): return self.update(request, *args, **kwargs) -class BookmarkViewSet(MyModelViewSet): +class BookmarkViewSet(EnvelopeMixin, ModelViewSet): queryset = Bookmark.objects.select_related( "user", "folder", diff --git a/apps/backend/comments/serializers.py b/apps/backend/comments/serializers.py index 52ced7a..8621496 100644 --- a/apps/backend/comments/serializers.py +++ b/apps/backend/comments/serializers.py @@ -1,7 +1,7 @@ from rest_framework import serializers from articles.models import ArticlePublication -from core.exceptions import ServiceError +from drf_std_response import ServiceError from core.models import ContentTarget from core.utils.markdown import ( render_markdown_mentions, diff --git a/apps/backend/comments/services.py b/apps/backend/comments/services.py index dacf4d7..6e36b27 100644 --- a/apps/backend/comments/services.py +++ b/apps/backend/comments/services.py @@ -1,7 +1,7 @@ from django.db import transaction from articles.models import ArticlePublication -from core.exceptions import ServiceError +from drf_std_response import ServiceError from core.models import ContentTarget from core.services.content_targets import ( get_or_create_comment_target, diff --git a/apps/backend/comments/views.py b/apps/backend/comments/views.py index ce3f1c4..4001c37 100644 --- a/apps/backend/comments/views.py +++ b/apps/backend/comments/views.py @@ -1,7 +1,8 @@ from django.db.models import Count, Q from rest_framework import status +from rest_framework.viewsets import ModelViewSet -from core.views.viewsets import MyModelViewSet +from drf_std_response import EnvelopeMixin from .models import Comment from .permissions import CommentPermission from .serializers import CommentReadSerializer, CommentWriteSerializer @@ -14,7 +15,7 @@ ) -class CommentViewSet(MyModelViewSet): +class CommentViewSet(EnvelopeMixin, ModelViewSet): queryset = Comment.objects.select_related( "author", "target", diff --git a/apps/backend/core/pagination.py b/apps/backend/core/pagination.py index 29e4915..c3e66c4 100644 --- a/apps/backend/core/pagination.py +++ b/apps/backend/core/pagination.py @@ -1,12 +1,15 @@ from rest_framework.pagination import PageNumberPagination +from rest_framework.response import Response class StandardPagination(PageNumberPagination): def get_paginated_response(self, data): - return { - "count": self.page.paginator.count, - "total_pages": self.page.paginator.num_pages, - "current_page": self.page.number, - "page_size": self.get_page_size(self.request), - "results": data - } + return Response( + { + "count": self.page.paginator.count, + "total_pages": self.page.paginator.num_pages, + "current_page": self.page.number, + "page_size": self.get_page_size(self.request), + "results": data, + } + ) diff --git a/apps/backend/core/responses.py b/apps/backend/core/responses.py deleted file mode 100644 index 24f9de9..0000000 --- a/apps/backend/core/responses.py +++ /dev/null @@ -1,27 +0,0 @@ -from rest_framework.response import Response -from rest_framework import status - - -def format_api_response( - *, - success, message, code, data=None, errors=None, request=None, - status_code=status.HTTP_200_OK, headers=None -): - """ - Return a formatted api response. - This function should always be called in api views and in the custom api exception handler. - """ - - payload = { - "success": success, - "message": message, - "code": code, - "data": data, - "errors": errors, - "meta": { - "request_id": getattr(request, "request_id", None) if request else None, - "timestamp": getattr(request, "timestamp", None) if request else None, - }, - } - - return Response(payload, status=status_code, headers=headers) diff --git a/apps/backend/core/tests/test_exception_handler.py b/apps/backend/core/tests/test_exception_handler.py index 9bea3b5..2ef20ed 100644 --- a/apps/backend/core/tests/test_exception_handler.py +++ b/apps/backend/core/tests/test_exception_handler.py @@ -5,9 +5,9 @@ from rest_framework import status from rest_framework.exceptions import NotAuthenticated, ValidationError -from core.exceptions import ServiceError from core.tests.testcases import BaseTestCase -from core.views.exception_handler import custom_exception_handler +from drf_std_response import ServiceError +from drf_std_response.exception_handlers import exception_handler class CustomExceptionHandlerTests(BaseTestCase): @@ -16,7 +16,7 @@ def setUp(self): self.context = {"request": self.request} def test_service_error_is_wrapped_with_standard_payload(self): - response = custom_exception_handler( + response = exception_handler( ServiceError(detail="Bad state", code="bad_state"), self.context, ) @@ -25,10 +25,14 @@ def test_service_error_is_wrapped_with_standard_payload(self): self.assertFalse(response.data["success"]) self.assertEqual(response.data["message"], "Bad state") self.assertEqual(response.data["code"], "bad_state") + self.assertEqual( + response.data["errors"], + [{"code": "bad_state", "message": "Bad state", "field": None}], + ) self.assertEqual(response.data["meta"]["request_id"], "req-1") def test_validation_error_is_wrapped_with_validation_payload(self): - response = custom_exception_handler( + response = exception_handler( ValidationError({"title": ["required"]}), self.context, ) @@ -37,34 +41,77 @@ def test_validation_error_is_wrapped_with_validation_payload(self): self.assertFalse(response.data["success"]) self.assertEqual(response.data["message"], "Validation failed") self.assertEqual(response.data["code"], "validation_error") - self.assertEqual(response.data["errors"], {"title": ["required"]}) + self.assertEqual( + response.data["errors"], + [{"code": "invalid", "message": "required", "field": "title"}], + ) + + def test_nested_validation_errors_are_flattened_with_field_paths(self): + response = exception_handler( + ValidationError( + { + "shipping_address": { + "non_field_errors": ["Unsupported address"], + }, + "recipients": [ + {"name": ["This field is required."]}, + {"email": ["Enter a valid email address."]}, + ], + } + ), + self.context, + ) + + self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + self.assertEqual( + response.data["errors"], + [ + { + "code": "invalid", + "message": "Unsupported address", + "field": "shipping_address.non_field_errors", + }, + { + "code": "invalid", + "message": "This field is required.", + "field": "recipients.0.name", + }, + { + "code": "invalid", + "message": "Enter a valid email address.", + "field": "recipients.1.email", + }, + ], + ) def test_builtin_api_exception_is_wrapped(self): - response = custom_exception_handler(NotAuthenticated(), self.context) + response = exception_handler(NotAuthenticated(), self.context) self.assertEqual(response.status_code, status.HTTP_401_UNAUTHORIZED) self.assertFalse(response.data["success"]) self.assertEqual(response.data["message"], "Request failed") self.assertEqual(response.data["code"], "not_authenticated") + self.assertEqual(response.data["errors"][0]["field"], None) def test_django_permission_denied_is_wrapped(self): - response = custom_exception_handler(PermissionDenied(), self.context) + response = exception_handler(PermissionDenied(), self.context) self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) self.assertFalse(response.data["success"]) - self.assertEqual(response.data["code"], "error") + self.assertEqual(response.data["code"], "permission_denied") def test_unknown_error_becomes_internal_server_error(self): - response = custom_exception_handler(RuntimeError("boom"), self.context) + response = exception_handler(RuntimeError("boom"), self.context) self.assertEqual(response.status_code, status.HTTP_500_INTERNAL_SERVER_ERROR) self.assertFalse(response.data["success"]) self.assertEqual(response.data["message"], "Internal server error") self.assertEqual(response.data["code"], "internal_server_error") + self.assertEqual(response.data["errors"][0]["field"], None) def test_http404_is_wrapped(self): - response = custom_exception_handler(Http404(), self.context) + response = exception_handler(Http404(), self.context) self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND) self.assertFalse(response.data["success"]) - self.assertEqual(response.data["code"], "error") + self.assertEqual(response.data["code"], "not_found") diff --git a/apps/backend/core/tests/test_markdown.py b/apps/backend/core/tests/test_markdown.py index 9a4fe0b..cc6ac03 100644 --- a/apps/backend/core/tests/test_markdown.py +++ b/apps/backend/core/tests/test_markdown.py @@ -1,4 +1,4 @@ -from core.exceptions import ServiceError +from drf_std_response import ServiceError from core.tests.factories import create_user from core.tests.testcases import BaseTestCase from core.utils.markdown import ( diff --git a/apps/backend/core/tests/test_pagination.py b/apps/backend/core/tests/test_pagination.py index b7689b5..8e35c4d 100644 --- a/apps/backend/core/tests/test_pagination.py +++ b/apps/backend/core/tests/test_pagination.py @@ -5,7 +5,7 @@ class StandardPaginationTests(BaseTestCase): - def test_get_paginated_response_returns_plain_pagination_dict(self): + def test_get_paginated_response_returns_response_with_pagination_data(self): paginator = StandardPagination() paginator.page = SimpleNamespace( paginator=SimpleNamespace(count=21, num_pages=3), @@ -14,10 +14,10 @@ def test_get_paginated_response_returns_plain_pagination_dict(self): paginator.request = object() paginator.get_page_size = lambda request: 10 - payload = paginator.get_paginated_response([{"id": 1}, {"id": 2}]) + response = paginator.get_paginated_response([{"id": 1}, {"id": 2}]) self.assertEqual( - payload, + response.data, { "count": 21, "total_pages": 3, diff --git a/apps/backend/core/tests/test_responses.py b/apps/backend/core/tests/test_responses.py index a566376..0c5221e 100644 --- a/apps/backend/core/tests/test_responses.py +++ b/apps/backend/core/tests/test_responses.py @@ -2,15 +2,15 @@ from rest_framework import status -from core.responses import format_api_response from core.tests.testcases import BaseTestCase +from drf_std_response import format_response -class FormatAPIResponseTests(BaseTestCase): - def test_format_api_response_builds_standard_success_payload(self): +class FormatResponseTests(BaseTestCase): + def test_format_response_builds_standard_success_payload(self): request = SimpleNamespace(request_id="req-1", timestamp="2026-03-17T10:00:00Z") - response = format_api_response( + response = format_response( success=True, message="created", code="created", @@ -36,8 +36,8 @@ def test_format_api_response_builds_standard_success_payload(self): }, ) - def test_format_api_response_defaults_meta_fields_when_request_missing(self): - response = format_api_response( + def test_format_response_defaults_meta_fields_when_request_missing(self): + response = format_response( success=True, message="ok", code="ok", diff --git a/apps/backend/core/tests/test_view_mixins.py b/apps/backend/core/tests/test_view_mixins.py index a898035..3051812 100644 --- a/apps/backend/core/tests/test_view_mixins.py +++ b/apps/backend/core/tests/test_view_mixins.py @@ -3,6 +3,7 @@ from django.test import override_settings from rest_framework import serializers, status +from rest_framework.mixins import ListModelMixin from rest_framework.test import APIRequestFactory from rest_framework.viewsets import GenericViewSet @@ -10,7 +11,7 @@ from core.pagination import StandardPagination from core.tests.factories import create_article from core.tests.testcases import BaseTestCase -from core.views.mixins import FormattedResponseMixin, MyListModelMixin +from drf_std_response import EnvelopeMixin User = get_user_model() @@ -24,7 +25,7 @@ class Meta: fields = ["id", "title"] -class _PaginatedArticleViewSet(FormattedResponseMixin, MyListModelMixin, GenericViewSet): +class _PaginatedArticleViewSet(EnvelopeMixin, ListModelMixin, GenericViewSet): serializer_class = _ArticleSerializer pagination_class = StandardPagination permission_classes = [] @@ -33,6 +34,15 @@ def get_queryset(self): return Article.objects.order_by("created_at") +class _NativeListArticleViewSet(EnvelopeMixin, ListModelMixin, GenericViewSet): + serializer_class = _ArticleSerializer + pagination_class = None + permission_classes = [] + + def get_queryset(self): + return Article.objects.order_by("created_at") + + urlpatterns = [ path( "test-articles/", @@ -43,7 +53,7 @@ def get_queryset(self): @override_settings(ROOT_URLCONF=__name__) -class MyListModelMixinTests(BaseTestCase): +class EnvelopeMixinTests(BaseTestCase): def setUp(self): self.factory = APIRequestFactory() self.user = User.objects.create_user(username="viewer", password="secret123") @@ -67,3 +77,15 @@ def test_paginated_list_response_is_wrapped_with_standard_api_shape(self): self.assertEqual(response.data["data"]["count"], 25) self.assertEqual(response.data["data"]["current_page"], 2) self.assertEqual(len(response.data["data"]["results"]), 5) + + def test_envelope_mixin_wraps_native_drf_list_response(self): + request = self.factory.get("/test-articles/") + request.user = self.user + + response = _NativeListArticleViewSet.as_view({"get": "list"})(request) + + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertTrue(response.data["success"]) + self.assertEqual(response.data["code"], "listed") + self.assertEqual(response.data["message"], "listed") + self.assertEqual(len(response.data["data"]), 25) diff --git a/apps/backend/core/utils/alienmark.py b/apps/backend/core/utils/alienmark.py index 52b24a0..9a409ed 100644 --- a/apps/backend/core/utils/alienmark.py +++ b/apps/backend/core/utils/alienmark.py @@ -1,7 +1,7 @@ import requests from django.conf import settings -from core.exceptions import ServiceError +from drf_std_response import ServiceError def render_md_to_html(markdown: str) -> str: diff --git a/apps/backend/core/utils/markdown.py b/apps/backend/core/utils/markdown.py index 4c61292..bd53071 100644 --- a/apps/backend/core/utils/markdown.py +++ b/apps/backend/core/utils/markdown.py @@ -4,7 +4,7 @@ from django.conf import settings from django.contrib.auth import get_user_model -from core.exceptions import ServiceError +from drf_std_response import ServiceError H1_RE = re.compile(r"^#(?!#)\s+(.+?)\s*$") diff --git a/apps/backend/core/views/__init__.py b/apps/backend/core/views/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/apps/backend/core/views/exception_handler.py b/apps/backend/core/views/exception_handler.py deleted file mode 100644 index 4376db9..0000000 --- a/apps/backend/core/views/exception_handler.py +++ /dev/null @@ -1,74 +0,0 @@ -from rest_framework.views import exception_handler -from rest_framework import status -from rest_framework.exceptions import APIException, ValidationError - -from core.responses import format_api_response -from core.exceptions import ServiceError -from logs.logging import get_logger - -logger = get_logger(__name__) - - -def custom_exception_handler(exc, context): - """ - Returns the response that should be used for any given exception. - - By default, drf handle the REST framework `APIException`, and also - Django's built-in `Http404` and `PermissionDenied` exceptions. - - Any unhandled exceptions may return `None`, which will cause a 500 error - to be raised. - """ - request = context.get('request') - - # Service layer errors - if isinstance(exc, ServiceError): - message = str(getattr(exc, 'detail', "Request failed")) - code = str(getattr(exc, 'code', 'request_failed')) - status_code = getattr(exc, 'status_code', status.HTTP_400_BAD_REQUEST) - - logger.warning(f"Service Error: {message}") - - return format_api_response( - success=False, message=message, code=code, - data=None, errors=None, request=request, - status_code=status_code - ) - - # Serializer layer (validation) errors - if isinstance(exc, ValidationError): - code = str(getattr(exc, 'code', 'validation_error')) - status_code = getattr(exc, 'status_code', status.HTTP_400_BAD_REQUEST) - - logger.warning("Validation Error") - - return format_api_response( - success=False, message="Validation failed", code=code, - data=None, errors=exc.detail, request=request, - status_code=status_code - ) - - # DRF/Django built-in errors: Http404, PermissionDenied, Subclasses of APIException other than ValidationError - response = exception_handler(exc, context) - - # Unknown error - if response is None: - logger.exception("Unknown exception") - - return format_api_response( - success=False, message="Internal server error", code='internal_server_error', - data=None, errors=None, request=request, - status_code=status.HTTP_500_INTERNAL_SERVER_ERROR - ) - - # Wrap DRF exception_handler response into the standard format - return format_api_response( - success=False, - message="Request failed", - code=getattr(exc, "default_code", "error") if isinstance(exc, APIException) else "error", - data=None, - errors=response.data, - request=request, - status_code=response.status_code, - headers=response.headers, - ) diff --git a/apps/backend/core/views/mixins.py b/apps/backend/core/views/mixins.py deleted file mode 100644 index 976bcaa..0000000 --- a/apps/backend/core/views/mixins.py +++ /dev/null @@ -1,149 +0,0 @@ -from rest_framework.mixins import ( - CreateModelMixin, ListModelMixin, RetrieveModelMixin, UpdateModelMixin, DestroyModelMixin -) -from rest_framework import status - -from core.responses import format_api_response - - -class FormattedResponseMixin: - """ - Wrap response with format_api_response(). - """ - def format_success_response( - self, *, message="ok", code="ok", data: dict = None, status_code=status.HTTP_200_OK, headers=None - ): - request = getattr(self, 'request', None) - - return format_api_response( - success=True, - message=message, - code=code, - data=data, - errors=None, - status_code=status_code, - request=request, - headers=headers - ) - - -class MyCreateModelMixin(CreateModelMixin): - """ - Create a model instance, but return a standard api response. - This should always be used with GenericViewSet. - """ - create_success_message = "created" - create_success_code = 'created' - - def create(self, request, *args, **kwargs): - serializer = self.get_serializer(data=request.data) - serializer.is_valid(raise_exception=True) - self.perform_create(serializer) - headers = self.get_success_headers(serializer.data) - - return self.format_success_response( - message=self.create_success_message, - code=self.create_success_code, - data=serializer.data, - status_code=status.HTTP_201_CREATED, - headers=headers - ) - - -class MyListModelMixin(ListModelMixin): - """ - List a queryset, but return a standard api response. - This should always be used with GenericViewSet. - """ - list_success_message = "listed" - list_success_code = 'listed' - - def list(self, request, *args, **kwargs): - queryset = self.filter_queryset(self.get_queryset()) - - page = self.paginate_queryset(queryset) - if page is not None: - serializer = self.get_serializer(page, many=True) - paginated_response = self.get_paginated_response(serializer.data) - - return self.format_success_response( - message=self.list_success_message, - code=self.list_success_code, - data=paginated_response, - status_code=status.HTTP_200_OK, - ) - - serializer = self.get_serializer(queryset, many=True) - - return self.format_success_response( - message=self.list_success_message, - code=self.list_success_code, - data=serializer.data, - status_code=status.HTTP_200_OK, - ) - - -class MyRetrieveModelMixin(RetrieveModelMixin): - """ - Retrieve a model instance, but return a standard api response. - This should always be used with GenericViewSet. - """ - retrieve_success_message = "retrieved" - retrieve_success_code = 'retrieved' - - def retrieve(self, request, *args, **kwargs): - instance = self.get_object() - serializer = self.get_serializer(instance) - - return self.format_success_response( - message=self.retrieve_success_message, - code=self.retrieve_success_code, - data=serializer.data, - status_code=status.HTTP_200_OK, - ) - - -class MyUpdateModelMixin(UpdateModelMixin): - """ - Update a model instance, but return a standard api response. - This should always be used with GenericViewSet. - """ - update_success_message = "updated" - update_success_code = 'updated' - - def update(self, request, *args, **kwargs): - partial = kwargs.pop('partial', False) - instance = self.get_object() - serializer = self.get_serializer(instance, data=request.data, partial=partial) - serializer.is_valid(raise_exception=True) - self.perform_update(serializer) - - if getattr(instance, '_prefetched_objects_cache', None): - instance._prefetched_objects_cache = {} - - return self.format_success_response( - message=self.update_success_message, - code=self.update_success_code, - data=serializer.data, - status_code=status.HTTP_200_OK, - ) - - -class MyDestroyModelMixin(DestroyModelMixin): - """ - Destroy a model instance, but return a standard api response. - This should always be used with GenericViewSet. - """ - destroy_success_message = "deleted" - destroy_success_code = 'deleted' - - def destroy(self, request, *args, **kwargs): - instance = self.get_object() - self.perform_destroy(instance) - - return self.format_success_response( - message=self.destroy_success_message, - code=self.destroy_success_code, - data=None, - status_code=status.HTTP_200_OK, - ) diff --git a/apps/backend/core/views/viewsets.py b/apps/backend/core/views/viewsets.py deleted file mode 100644 index 9270de1..0000000 --- a/apps/backend/core/views/viewsets.py +++ /dev/null @@ -1,37 +0,0 @@ -from rest_framework.viewsets import GenericViewSet - -from .mixins import ( - MyCreateModelMixin, - MyListModelMixin, - MyRetrieveModelMixin, - MyUpdateModelMixin, - MyDestroyModelMixin, - FormattedResponseMixin -) - - -class MyModelViewSet(MyCreateModelMixin, - MyListModelMixin, - MyRetrieveModelMixin, - MyUpdateModelMixin, - MyDestroyModelMixin, - FormattedResponseMixin, - GenericViewSet): - """ - A viewset that provides default `create()`, `retrieve()`, `update()`, - `partial_update()`, `destroy()` and `list()` actions. - Responses are wrapped with format_api_response(). - This viewset should always be used to replace drf ModelViewSet. - """ - - pass - - -class MyReadOnlyModelViewSet(MyListModelMixin, MyRetrieveModelMixin, FormattedResponseMixin, GenericViewSet): - """ - A viewset that provides default `list()` and `retrieve()` actions. - Responses are wrapped with format_api_response(). - This viewset should always be used to replace drf ReadOnlyModelViewSet. - """ - - pass diff --git a/apps/backend/notifications/views.py b/apps/backend/notifications/views.py index e504cbc..1621f63 100644 --- a/apps/backend/notifications/views.py +++ b/apps/backend/notifications/views.py @@ -1,16 +1,16 @@ from rest_framework import status from rest_framework.decorators import action from rest_framework.permissions import IsAuthenticated +from rest_framework.viewsets import ReadOnlyModelViewSet -from core.exceptions import ServiceError -from core.views.viewsets import MyReadOnlyModelViewSet +from drf_std_response import EnvelopeMixin, ServiceError from .models import NotificationDelivery, NotificationEvent from .serializers import NotificationDeliverySerializer from .services import mark_all_deliveries_read, mark_delivery_read -class NotificationDeliveryViewSet(MyReadOnlyModelViewSet): +class NotificationDeliveryViewSet(EnvelopeMixin, ReadOnlyModelViewSet): serializer_class = NotificationDeliverySerializer permission_classes = [IsAuthenticated] queryset = NotificationDelivery.objects.select_related( diff --git a/apps/backend/posts/serializers.py b/apps/backend/posts/serializers.py index affeaf1..6513c33 100644 --- a/apps/backend/posts/serializers.py +++ b/apps/backend/posts/serializers.py @@ -1,7 +1,7 @@ from rest_framework import serializers from django.db import models -from core.exceptions import ServiceError +from drf_std_response import ServiceError from core.utils.markdown import ( render_markdown_mentions, serialize_markdown_mentions, diff --git a/apps/backend/posts/views.py b/apps/backend/posts/views.py index 3ad323a..6cf8117 100644 --- a/apps/backend/posts/views.py +++ b/apps/backend/posts/views.py @@ -1,6 +1,7 @@ from rest_framework import status +from rest_framework.viewsets import ModelViewSet -from core.views.viewsets import MyModelViewSet +from drf_std_response import EnvelopeMixin from .models import CommunityPost from .permissions import CommunityPostPermission @@ -12,7 +13,7 @@ ) -class CommunityPostViewSet(MyModelViewSet): +class CommunityPostViewSet(EnvelopeMixin, ModelViewSet): queryset = CommunityPost.objects.filter(is_deleted=False).select_related("author").order_by("-created_at") permission_classes = [CommunityPostPermission] default_serializer_class = CommunityPostReadSerializer diff --git a/apps/backend/pyproject.toml b/apps/backend/pyproject.toml index 83b88ff..52c1661 100644 --- a/apps/backend/pyproject.toml +++ b/apps/backend/pyproject.toml @@ -11,6 +11,7 @@ dependencies = [ "django-storages[s3]==1.14.6", "django-tasks-rq==0.12.0", "djangorestframework==3.17.1", + "drf-std-response==0.1.0", "environs==15.0.1", "pillow==12.2.0", "psycopg[binary]==3.3.4", diff --git a/apps/backend/reactions/views.py b/apps/backend/reactions/views.py index 4250a52..1c5babb 100644 --- a/apps/backend/reactions/views.py +++ b/apps/backend/reactions/views.py @@ -2,9 +2,10 @@ from rest_framework.decorators import action from rest_framework.generics import get_object_or_404 from rest_framework.permissions import IsAuthenticated +from rest_framework.viewsets import ModelViewSet from articles.models import ArticlePublication -from core.views.viewsets import MyModelViewSet +from drf_std_response import EnvelopeMixin from posts.models import CommunityPost from .models import Reaction from .serializers import ReactionReadSerializer, ReactionWriteSerializer @@ -17,7 +18,7 @@ ) -class ReactionViewSet(MyModelViewSet): +class ReactionViewSet(EnvelopeMixin, ModelViewSet): queryset = Reaction.objects.select_related( "user", "target", diff --git a/apps/backend/reports/views.py b/apps/backend/reports/views.py index 53a152d..9084551 100644 --- a/apps/backend/reports/views.py +++ b/apps/backend/reports/views.py @@ -1,7 +1,8 @@ from rest_framework import status +from rest_framework.viewsets import ModelViewSet from core.utils.permissions import is_moderator -from core.views.viewsets import MyModelViewSet +from drf_std_response import EnvelopeMixin from .models import ContentReport, UserReport from .permissions import ReportPermission @@ -15,7 +16,7 @@ from .services import create_content_report, create_user_report, moderate_report -class BaseReportViewSet(MyModelViewSet): +class BaseReportViewSet(EnvelopeMixin, ModelViewSet): permission_classes = [ReportPermission] moderation_serializer_class = ReportModerationSerializer @@ -130,4 +131,3 @@ def create(self, request, *args, **kwargs): data=output_serializer.data, status_code=status.HTTP_201_CREATED, ) - diff --git a/apps/backend/users/services/users.py b/apps/backend/users/services/users.py index 589faf7..1c6af1c 100644 --- a/apps/backend/users/services/users.py +++ b/apps/backend/users/services/users.py @@ -6,7 +6,7 @@ import random import hashlib -from core.exceptions import ServiceError +from drf_std_response import ServiceError from core.utils.cache import add_cache, set_cache, get_cache, delete_cache, incr_cache from bookmarks.models import BookmarkFolder from users.models import EmailAddress diff --git a/apps/backend/users/tests/test_services.py b/apps/backend/users/tests/test_services.py index 5b7cb67..b05ac4b 100644 --- a/apps/backend/users/tests/test_services.py +++ b/apps/backend/users/tests/test_services.py @@ -3,7 +3,7 @@ from django.conf import settings from bookmarks.models import BookmarkFolder -from core.exceptions import ServiceError +from drf_std_response import ServiceError from core.tests.factories import create_user from core.tests.testcases import BaseTestCase from core.utils.cache import get_cache, set_cache diff --git a/apps/backend/users/views/emails.py b/apps/backend/users/views/emails.py index 22ec6b9..d7fb102 100644 --- a/apps/backend/users/views/emails.py +++ b/apps/backend/users/views/emails.py @@ -2,12 +2,12 @@ from rest_framework.decorators import action from rest_framework.permissions import IsAuthenticated -from core.views.mixins import FormattedResponseMixin +from drf_std_response import EnvelopeMixin from ..serializers import EmailVerifyRequestSerializer, EmailVerifyResponseSerializer from ..services.users import verify_email -class EmailViewSet(FormattedResponseMixin, viewsets.ViewSet): +class EmailViewSet(EnvelopeMixin, viewsets.ViewSet): """ A viewset that collects endpoints which relate to email models. """ diff --git a/apps/backend/users/views/sessions.py b/apps/backend/users/views/sessions.py index 199f2e9..5412529 100644 --- a/apps/backend/users/views/sessions.py +++ b/apps/backend/users/views/sessions.py @@ -7,12 +7,12 @@ from rest_framework.exceptions import AuthenticationFailed from rest_framework.permissions import AllowAny, IsAuthenticated -from core.views.mixins import FormattedResponseMixin +from drf_std_response import EnvelopeMixin from ..serializers import UserLoginSerializer from ..services.sessions import create_user_session, delete_user_session -class SessionViewSet(FormattedResponseMixin, viewsets.ViewSet): +class SessionViewSet(EnvelopeMixin, viewsets.ViewSet): """ A viewset that collects API endpoints which relates to UserSession. diff --git a/apps/backend/users/views/subscriptions.py b/apps/backend/users/views/subscriptions.py index d8f1697..35a0f0c 100644 --- a/apps/backend/users/views/subscriptions.py +++ b/apps/backend/users/views/subscriptions.py @@ -1,12 +1,13 @@ from rest_framework import status from rest_framework.permissions import IsAuthenticated +from rest_framework.viewsets import ModelViewSet -from core.views.viewsets import MyModelViewSet +from drf_std_response import EnvelopeMixin from ..models import UserSubscription from ..serializers import UserSubscriptionReadSerializer, UserSubscriptionWriteSerializer -class UserSubscriptionViewSet(MyModelViewSet): +class UserSubscriptionViewSet(EnvelopeMixin, ModelViewSet): queryset = UserSubscription.objects.select_related("subscriber", "subscribed_to") permission_classes = [IsAuthenticated] default_serializer_class = UserSubscriptionReadSerializer diff --git a/apps/backend/users/views/users.py b/apps/backend/users/views/users.py index c229cc5..7a4f268 100644 --- a/apps/backend/users/views/users.py +++ b/apps/backend/users/views/users.py @@ -4,12 +4,9 @@ from rest_framework.decorators import action from rest_framework.parsers import FormParser, JSONParser, MultiPartParser from rest_framework.permissions import AllowAny, IsAuthenticated +from rest_framework.mixins import ListModelMixin, RetrieveModelMixin -from core.views.mixins import ( - FormattedResponseMixin, - MyListModelMixin, - MyRetrieveModelMixin, -) +from drf_std_response import EnvelopeMixin from ..serializers import ( UserListSerializer, UserRegisterRequestSerializer, @@ -23,9 +20,9 @@ User = get_user_model() -class UserViewSet(MyListModelMixin, - MyRetrieveModelMixin, - FormattedResponseMixin, +class UserViewSet(EnvelopeMixin, + ListModelMixin, + RetrieveModelMixin, viewsets.GenericViewSet): """ A viewset that collects API endpoints which relates to User. diff --git a/docs/contributors/docs/development/node-tooling.en.md b/docs/contributors/docs/development/node-tooling.en.md index a85ce10..1a4b057 100644 --- a/docs/contributors/docs/development/node-tooling.en.md +++ b/docs/contributors/docs/development/node-tooling.en.md @@ -18,6 +18,7 @@ The Node workspace is declared in `pnpm-workspace.yaml`: packages: - "apps/*" - "packages/*" + - "!packages/drf-std-response" ``` At the moment, the relevant Node packages are: diff --git a/docs/contributors/docs/development/node-tooling.zh.md b/docs/contributors/docs/development/node-tooling.zh.md index 9265687..d003c94 100644 --- a/docs/contributors/docs/development/node-tooling.zh.md +++ b/docs/contributors/docs/development/node-tooling.zh.md @@ -18,6 +18,7 @@ Node workspace 在 `pnpm-workspace.yaml` 中声明: packages: - "apps/*" - "packages/*" + - "!packages/drf-std-response" ``` 当前相关的 Node 包是: diff --git a/packages/drf-std-response/README.md b/packages/drf-std-response/README.md new file mode 100644 index 0000000..10177de --- /dev/null +++ b/packages/drf-std-response/README.md @@ -0,0 +1,165 @@ +# drf-std-response + +Standard response envelopes for Django REST Framework. + +`drf-std-response` wraps successful DRF responses and exception responses in one +consistent payload: + +```json +{ + "success": true, + "message": "listed", + "code": "listed", + "data": [], + "errors": null, + "meta": { + "request_id": "req-1", + "timestamp": "2026-03-17T10:00:00Z" + } +} +``` + +## Quickstart + +Use `EnvelopeMixin` before the DRF viewset class: + +```python +from drf_std_response import EnvelopeMixin +from rest_framework.viewsets import ModelViewSet + + +class ArticleViewSet(EnvelopeMixin, ModelViewSet): + ... +``` + +Register the exception handler: + +```python +REST_FRAMEWORK = { + "EXCEPTION_HANDLER": "drf_std_response.exception_handlers.exception_handler", +} +``` + +Raise `ServiceError` from service-layer code when the error should become a +client-facing API response: + +```python +from drf_std_response import ServiceError + + +raise ServiceError(detail="Article cannot be published", code="invalid_state") +``` + +## Success Responses + +`EnvelopeMixin` wraps successful DRF `Response` objects in `finalize_response`. +It works with DRF's native mixins and viewsets, including `ModelViewSet` and +`ReadOnlyModelViewSet`. + +Default action messages and codes are provided for common viewset actions: + +| Action | message | code | +| --- | --- | --- | +| `list` | `listed` | `listed` | +| `retrieve` | `retrieved` | `retrieved` | +| `create` | `created` | `created` | +| `update` | `updated` | `updated` | +| `partial_update` | `updated` | `updated` | +| `destroy` | `deleted` | `deleted` | + +For custom actions, use `format_success_response` when you need a specific +message or code: + +```python +return self.format_success_response( + message="article submitted", + code="article_submitted", + data=serializer.data, +) +``` + +## Error Responses + +Errors use the same outer envelope. Validation, client, service, and server +errors are normalized into a flat `errors` list: + +```json +{ + "success": false, + "message": "Validation failed", + "code": "validation_error", + "data": null, + "errors": [ + { + "code": "required", + "message": "This field is required.", + "field": "title" + } + ], + "meta": { + "request_id": null, + "timestamp": null + } +} +``` + +Nested serializers and list serializers are flattened into dotted field paths: + +```json +[ + { + "code": "unsupported", + "message": "Unsupported address", + "field": "shipping_address.non_field_errors" + }, + { + "code": "invalid", + "message": "Enter a valid email address.", + "field": "recipients.1.email" + } +] +``` + +## Customization + +Customize the exception handler or formatter with Django settings: + +```python +DRF_STD_RESPONSE = { + "EXCEPTION_HANDLER_CLASS": "myapp.api.MyExceptionHandler", + "EXCEPTION_FORMATTER_CLASS": "myapp.api.MyErrorFormatter", + "NESTED_FIELD_SEPARATOR": ".", + "NON_FIELD_ERRORS_KEY": "non_field_errors", +} +``` + +Formatter classes can subclass `ErrorFormatter`: + +```python +from drf_std_response import ErrorFormatter + + +class MyErrorFormatter(ErrorFormatter): + def format_error(self, *, code, message, field): + return { + "code": code, + "detail": message, + "attr": field, + } +``` + +Handler classes can subclass `ExceptionHandler`: + +```python +from drf_std_response.exception_handlers import ExceptionHandler + + +class MyExceptionHandler(ExceptionHandler): + def convert_known_exceptions(self, exc): + ... +``` + +## Acknowledgements + +The structured error-normalization design in this package is inspired by +[`drf-standardized-errors`](https://github.com/ghazi-git/drf-standardized-errors). diff --git a/packages/drf-std-response/pyproject.toml b/packages/drf-std-response/pyproject.toml new file mode 100644 index 0000000..dbd4609 --- /dev/null +++ b/packages/drf-std-response/pyproject.toml @@ -0,0 +1,9 @@ +[project] +name = "drf-std-response" +version = "0.1.0" +description = "Standard response envelope helpers for Django REST Framework." +requires-python = ">=3.14" +dependencies = ["django==6.0.6", "djangorestframework==3.17.1"] + +[tool.uv] +add-bounds = "exact" diff --git a/packages/drf-std-response/src/drf_std_response/__init__.py b/packages/drf-std-response/src/drf_std_response/__init__.py new file mode 100644 index 0000000..4c9a74b --- /dev/null +++ b/packages/drf-std-response/src/drf_std_response/__init__.py @@ -0,0 +1,25 @@ +from .exceptions import ServiceError +from .formatters import ErrorFormatter, format_errors +from .mixins import EnvelopeMixin +from .responses import build_payload, format_response + + +def exception_handler(*args, **kwargs): + from .exception_handlers import exception_handler as _exception_handler + + return _exception_handler(*args, **kwargs) + + +__all__ = [ + "EnvelopeMixin", + "ErrorFormatter", + "ServiceError", + "build_payload", + "exception_handler", + "format_errors", + "format_response", +] + + +def __getattr__(name): + raise AttributeError(f"module {__name__!r} has no attribute {name!r}") diff --git a/packages/drf-std-response/src/drf_std_response/exception_handlers.py b/packages/drf-std-response/src/drf_std_response/exception_handlers.py new file mode 100644 index 0000000..182f757 --- /dev/null +++ b/packages/drf-std-response/src/drf_std_response/exception_handlers.py @@ -0,0 +1,143 @@ +import logging + +from django.core import signals +from django.core.exceptions import PermissionDenied +from django.http import Http404 +from rest_framework import status +from rest_framework.exceptions import ( + APIException, + NotFound, + PermissionDenied as DRFPermissionDenied, + ValidationError, +) + +from .exceptions import ServiceError +from .formatters import ErrorFormatter +from .responses import format_response +from .settings import import_from_setting + +logger = logging.getLogger(__name__) + + +class ExceptionHandler: + """ + Wrap DRF, Django, and service-layer exceptions in the standard payload. + """ + + def __init__(self, formatter=None): + self.formatter = formatter or ErrorFormatter() + + def __call__(self, exc, context): + return self.handle(exc, context) + + def handle(self, exc, context): + request = context.get("request") + exc = self.convert_known_exceptions(exc) + + if isinstance(exc, ServiceError): + return self.handle_service_error(exc, request) + + if isinstance(exc, ValidationError): + return self.handle_validation_error(exc, request) + + from rest_framework.views import exception_handler as drf_exception_handler + + response = drf_exception_handler(exc, context) + + if response is None: + return self.handle_unhandled_error(exc, request) + + return self.handle_api_error(exc, response, request) + + def convert_known_exceptions(self, exc): + if isinstance(exc, Http404): + return NotFound() + if isinstance(exc, PermissionDenied): + return DRFPermissionDenied() + return exc + + def handle_service_error(self, exc, request): + message = str(getattr(exc, "detail", "Request failed")) + code = str(getattr(exc, "code", "request_failed")) + status_code = getattr(exc, "status_code", status.HTTP_400_BAD_REQUEST) + + logger.warning("Service Error: %s", message) + + return format_response( + success=False, + message=message, + code=code, + data=None, + errors=[ + self.formatter.format_error( + code=code, + message=message, + field=None, + ) + ], + request=request, + status_code=status_code, + ) + + def handle_validation_error(self, exc, request): + status_code = getattr(exc, "status_code", status.HTTP_400_BAD_REQUEST) + + logger.warning("Validation Error") + + return format_response( + success=False, + message="Validation failed", + code="validation_error", + data=None, + errors=self.formatter.format_errors(exc.detail), + request=request, + status_code=status_code, + ) + + def handle_unhandled_error(self, exc, request): + logger.error( + "Unknown exception", + exc_info=(type(exc), exc, exc.__traceback__), + ) + signals.got_request_exception.send(sender=None, request=request) + + return format_response( + success=False, + message="Internal server error", + code="internal_server_error", + data=None, + errors=[ + self.formatter.format_error( + code="internal_server_error", + message="Internal server error", + field=None, + ) + ], + request=request, + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + ) + + def handle_api_error(self, exc, response, request): + code = self.get_exception_code(exc) + + return format_response( + success=False, + message="Request failed", + code=code, + data=None, + errors=self.formatter.format_errors(response.data), + request=request, + status_code=response.status_code, + headers=response.headers, + ) + + def get_exception_code(self, exc): + if isinstance(exc, APIException): + return getattr(exc, "default_code", "error") + return "error" + + +def exception_handler(exc, context): + handler_class = import_from_setting("EXCEPTION_HANDLER_CLASS") + formatter_class = import_from_setting("EXCEPTION_FORMATTER_CLASS") + return handler_class(formatter=formatter_class())(exc, context) diff --git a/apps/backend/core/exceptions.py b/packages/drf-std-response/src/drf_std_response/exceptions.py similarity index 81% rename from apps/backend/core/exceptions.py rename to packages/drf-std-response/src/drf_std_response/exceptions.py index 2b4c10b..f4e6e58 100644 --- a/apps/backend/core/exceptions.py +++ b/packages/drf-std-response/src/drf_std_response/exceptions.py @@ -3,12 +3,12 @@ class ServiceError(Exception): """ - The exception class for all service-layer errors. + Exception class for service-layer errors that should become API responses. """ status_code = status.HTTP_400_BAD_REQUEST default_detail = "Request failed" - default_code = 'service_error' + default_code = "service_error" def __init__(self, *, detail=None, code=None, status_code=None): self.detail = self.default_detail if detail is None else detail diff --git a/packages/drf-std-response/src/drf_std_response/formatters.py b/packages/drf-std-response/src/drf_std_response/formatters.py new file mode 100644 index 0000000..a74b6d6 --- /dev/null +++ b/packages/drf-std-response/src/drf_std_response/formatters.py @@ -0,0 +1,78 @@ +from collections.abc import Mapping, Sequence + +from rest_framework.exceptions import ErrorDetail + +from .settings import get_setting + + +class ErrorFormatter: + """ + Convert DRF error details into a flat, client-friendly error list. + """ + + default_code = "error" + + def __init__(self): + self.separator = get_setting("NESTED_FIELD_SEPARATOR") + self.non_field_errors_key = get_setting("NON_FIELD_ERRORS_KEY") + + def format_errors(self, detail): + return [ + self.format_error(code=code, message=message, field=field) + for code, message, field in self.iter_errors(detail) + ] + + def format_error(self, *, code, message, field): + return { + "code": str(code or self.default_code), + "message": str(message), + "field": field, + } + + def iter_errors(self, detail, path=None): + path = [] if path is None else path + + if isinstance(detail, Mapping): + yield from self.iter_mapping_errors(detail, path) + return + + if self.is_error_sequence(detail): + yield from self.iter_sequence_errors(detail, path) + return + + yield self.get_error_code(detail), str(detail), self.format_path(path) + + def iter_mapping_errors(self, detail, path): + for key, value in detail.items(): + if not path and key == "detail": + child_path = path + else: + child_path = path if key is None else [*path, str(key)] + yield from self.iter_errors(value, child_path) + + def iter_sequence_errors(self, detail, path): + for index, value in enumerate(detail): + if isinstance(value, Mapping): + yield from self.iter_errors(value, [*path, str(index)]) + elif self.is_error_sequence(value): + yield from self.iter_errors(value, path) + else: + yield self.get_error_code(value), str(value), self.format_path(path) + + def is_error_sequence(self, detail): + return isinstance(detail, Sequence) and not isinstance( + detail, + (str, bytes, bytearray, ErrorDetail), + ) + + def get_error_code(self, detail): + return getattr(detail, "code", self.default_code) + + def format_path(self, path): + if not path: + return None + return self.separator.join(path) + + +def format_errors(detail): + return ErrorFormatter().format_errors(detail) diff --git a/packages/drf-std-response/src/drf_std_response/mixins.py b/packages/drf-std-response/src/drf_std_response/mixins.py new file mode 100644 index 0000000..6c312eb --- /dev/null +++ b/packages/drf-std-response/src/drf_std_response/mixins.py @@ -0,0 +1,91 @@ +from rest_framework import status +from rest_framework.response import Response + +from .responses import ENVELOPE_RESPONSE_ATTR, build_payload, format_response + + +class EnvelopeMixin: + """ + Wrap successful DRF responses in the standard response envelope. + """ + + envelope_message = "ok" + envelope_code = "ok" + action_messages = { + "list": "listed", + "retrieve": "retrieved", + "create": "created", + "update": "updated", + "partial_update": "updated", + "destroy": "deleted", + } + action_codes = { + "list": "listed", + "retrieve": "retrieved", + "create": "created", + "update": "updated", + "partial_update": "updated", + "destroy": "deleted", + } + + def get_envelope_message(self, response): + action = getattr(self, "action", None) + return self.action_messages.get(action, self.envelope_message) + + def get_envelope_code(self, response): + action = getattr(self, "action", None) + return self.action_codes.get(action, self.envelope_code) + + def should_wrap_response(self, response): + if not isinstance(response, Response): + return False + if getattr(response, ENVELOPE_RESPONSE_ATTR, False): + return False + if response.exception: + return False + if status.is_client_error(response.status_code) or status.is_server_error( + response.status_code + ): + return False + return True + + def format_success_response( + self, + *, + message="ok", + code="ok", + data=None, + status_code=status.HTTP_200_OK, + headers=None, + ): + request = getattr(self, "request", None) + return format_response( + success=True, + message=message, + code=code, + data=data, + errors=None, + status_code=status_code, + request=request, + headers=headers, + ) + + def finalize_response(self, request, response, *args, **kwargs): + response = super().finalize_response(request, response, *args, **kwargs) + + if not self.should_wrap_response(response): + return response + + if response.status_code == status.HTTP_204_NO_CONTENT: + response.status_code = status.HTTP_200_OK + + response.data = build_payload( + success=True, + message=self.get_envelope_message(response), + code=self.get_envelope_code(response), + data=response.data, + errors=None, + request=request, + ) + setattr(response, ENVELOPE_RESPONSE_ATTR, True) + return response diff --git a/packages/drf-std-response/src/drf_std_response/responses.py b/packages/drf-std-response/src/drf_std_response/responses.py new file mode 100644 index 0000000..7a9a886 --- /dev/null +++ b/packages/drf-std-response/src/drf_std_response/responses.py @@ -0,0 +1,60 @@ +from rest_framework import status +from rest_framework.response import Response + + +ENVELOPE_RESPONSE_ATTR = "_drf_std_response_enveloped" + + +def build_payload( + *, + success, + message, + code, + data=None, + errors=None, + request=None, +): + """ + Build the standard API response payload. + """ + return { + "success": success, + "message": message, + "code": code, + "data": data, + "errors": errors, + "meta": { + "request_id": getattr(request, "request_id", None) if request else None, + "timestamp": getattr(request, "timestamp", None) if request else None, + }, + } + + +def format_response( + *, + success, + message, + code, + data=None, + errors=None, + request=None, + status_code=status.HTTP_200_OK, + headers=None, +): + """ + Return a DRF Response containing the standard API response payload. + """ + response = Response( + build_payload( + success=success, + message=message, + code=code, + data=data, + errors=errors, + request=request, + ), + status=status_code, + headers=headers, + ) + setattr(response, ENVELOPE_RESPONSE_ATTR, True) + return response diff --git a/packages/drf-std-response/src/drf_std_response/settings.py b/packages/drf-std-response/src/drf_std_response/settings.py new file mode 100644 index 0000000..c8d3fb8 --- /dev/null +++ b/packages/drf-std-response/src/drf_std_response/settings.py @@ -0,0 +1,21 @@ +from django.conf import settings +from django.utils.module_loading import import_string + + +DEFAULTS = { + "EXCEPTION_HANDLER_CLASS": "drf_std_response.exception_handlers.ExceptionHandler", + "EXCEPTION_FORMATTER_CLASS": "drf_std_response.formatters.ErrorFormatter", + "NESTED_FIELD_SEPARATOR": ".", + "NON_FIELD_ERRORS_KEY": "non_field_errors", +} + + +def get_setting(name): + if not settings.configured: + return DEFAULTS[name] + package_settings = getattr(settings, "DRF_STD_RESPONSE", {}) + return package_settings.get(name, DEFAULTS[name]) + + +def import_from_setting(name): + return import_string(get_setting(name)) diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 475c32a..3dc5f56 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -1,6 +1,7 @@ packages: - "apps/*" - "packages/*" + - "!packages/drf-std-response" allowBuilds: "@parcel/watcher": true diff --git a/pyproject.toml b/pyproject.toml index fcad449..913ac38 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,5 +1,14 @@ [tool.uv] add-bounds = "exact" +[tool.uv.sources] +drf-std-response = { workspace = true } + [tool.uv.workspace] -members = ["apps/backend", "docs/alienmark", "docs/contributors", "docs/users"] +members = [ + "apps/backend", + "docs/alienmark", + "docs/contributors", + "docs/users", + "packages/drf-std-response", +] diff --git a/uv.lock b/uv.lock index 9c453d1..8ff15c7 100644 --- a/uv.lock +++ b/uv.lock @@ -12,6 +12,7 @@ members = [ "aliencommons-contributor-docs", "aliencommons-user-guide", "alienmark-docs", + "drf-std-response", ] [[package]] @@ -27,6 +28,7 @@ dependencies = [ { name = "django-storages", extra = ["s3"] }, { name = "django-tasks-rq" }, { name = "djangorestframework" }, + { name = "drf-std-response" }, { name = "environs" }, { name = "pillow" }, { name = "psycopg", extra = ["binary"] }, @@ -49,6 +51,7 @@ requires-dist = [ { name = "django-storages", extras = ["s3"], specifier = "==1.14.6" }, { name = "django-tasks-rq", specifier = "==0.12.0" }, { name = "djangorestframework", specifier = "==3.17.1" }, + { name = "drf-std-response", editable = "packages/drf-std-response" }, { name = "environs", specifier = "==15.0.1" }, { name = "pillow", specifier = "==12.2.0" }, { name = "psycopg", extras = ["binary"], specifier = "==3.3.4" }, @@ -578,6 +581,21 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/5a/e1/2c516bdc83652b1a60c6119366ac2c0607b479ed05cd6093f916ca8928f8/djangorestframework-3.17.1-py3-none-any.whl", hash = "sha256:c3c74dd3e83a5a3efc37b3c18d92bd6f86a6791c7b7d4dff62bb068500e76457", size = 898844, upload-time = "2026-03-24T16:58:31.845Z" }, ] +[[package]] +name = "drf-std-response" +version = "0.1.0" +source = { editable = "packages/drf-std-response" } +dependencies = [ + { name = "django" }, + { name = "djangorestframework" }, +] + +[package.metadata] +requires-dist = [ + { name = "django", specifier = "==6.0.6" }, + { name = "djangorestframework", specifier = "==3.17.1" }, +] + [[package]] name = "environs" version = "15.0.1"