From 035c27c41001f18b120f88ea6352fcfb9fc03059 Mon Sep 17 00:00:00 2001 From: Matheus Jacks Date: Fri, 17 Apr 2026 16:13:48 -0300 Subject: [PATCH 1/2] eat: add BodepontoioMixin and exception_handler_v2 for gradual adoption Introduces an opt-in path for projects that want to adopt the standard response format incrementally, without changing all API responses at once. - Add BodepontoioMixin (mixins.py): sets SuccessJSONRenderer and bodepontoio_format = True on a per-view basis - Add exception_handler_v2: formats error responses only for views with bodepontoio_format = True; all other views get plain DRF responses - Apply BodepontoioMixin to all built-in views so they keep working with the new handler - Document gradual adoption in README --- README.md | 37 +++++++ bodepontoio/exceptions.py | 36 ++++++- bodepontoio/mixins.py | 27 +++++ .../tests/test_exception_handler_v2.py | 99 +++++++++++++++++++ bodepontoio/views.py | 27 ++--- 5 files changed, 208 insertions(+), 18 deletions(-) create mode 100644 bodepontoio/mixins.py create mode 100644 bodepontoio/tests/test_exception_handler_v2.py diff --git a/README.md b/README.md index 96c3aad..366f1fe 100644 --- a/README.md +++ b/README.md @@ -54,6 +54,11 @@ REST_FRAMEWORK = { "EXCEPTION_HANDLER": "bodepontoio.exceptions.exception_handler", "DEFAULT_RENDERER_CLASSES": ["bodepontoio.renderers.SuccessJSONRenderer"], } +``` + +> **Adopting gradually?** If you're adding bodepontoio to an existing project and don't want to change the response format for all views at once, see [Gradual adoption](#gradual-adoption) below. + +```python SIMPLE_JWT = { "ROTATE_REFRESH_TOKENS": True, @@ -365,6 +370,38 @@ Possible `type` values: Unknown exception types fall back to the class name in `snake_case`. +## Gradual adoption + +If you're adding bodepontoio to an existing project, you can opt views in to the standard response format one at a time instead of changing the whole project at once. + +**1. Use `exception_handler_v2` instead of `exception_handler`:** + +```python +REST_FRAMEWORK = { + ... + "EXCEPTION_HANDLER": "bodepontoio.exceptions.exception_handler_v2", + # Do NOT set DEFAULT_RENDERER_CLASSES globally +} +``` + +`exception_handler_v2` only formats error responses for views that explicitly opt in. All other views continue returning plain DRF error responses (`{"detail": "..."}`), so installing it globally is safe. + +**2. Add `BodepontoioMixin` to views you want to migrate:** + +```python +from bodepontoio.mixins import BodepontoioMixin +from rest_framework.views import APIView + +class MyView(BodepontoioMixin, APIView): + ... +``` + +The mixin does two things: +- Sets `renderer_classes = [SuccessJSONRenderer]` so success responses are wrapped +- Marks the view with `bodepontoio_format = True` so `exception_handler_v2` formats its error responses + +Views without the mixin are completely unaffected. + ## Pagination ### BodePaginationMixin diff --git a/bodepontoio/exceptions.py b/bodepontoio/exceptions.py index 32b43a0..6a26dfb 100644 --- a/bodepontoio/exceptions.py +++ b/bodepontoio/exceptions.py @@ -43,11 +43,8 @@ def _flatten_errors(data, field=""): return errors -def exception_handler(exc, context): - response = drf_exception_handler(exc, context) - if response is None: - return None - +def _format_response(exc, response): + """Apply bodepontoio error formatting to a DRF response.""" error_type = _get_error_type(exc) if isinstance(exc, exceptions.ValidationError): @@ -65,3 +62,32 @@ def exception_handler(exc, context): } return response + + +def exception_handler(exc, context): + response = drf_exception_handler(exc, context) + if response is None: + return None + + return _format_response(exc, response) + + +def exception_handler_v2(exc, context): + """ + Opt-in version of ``exception_handler``. + + Only applies bodepontoio error formatting to views that have + ``bodepontoio_format = True`` (e.g. via ``BodepontoioMixin``). + All other views receive plain DRF error responses, making this + safe to set globally on projects that are gradually adopting + bodepontoio. + """ + view = context.get("view") + if not getattr(view, "bodepontoio_format", False): + return drf_exception_handler(exc, context) + + response = drf_exception_handler(exc, context) + if response is None: + return None + + return _format_response(exc, response) diff --git a/bodepontoio/mixins.py b/bodepontoio/mixins.py new file mode 100644 index 0000000..88860f1 --- /dev/null +++ b/bodepontoio/mixins.py @@ -0,0 +1,27 @@ +from .renderers import SuccessJSONRenderer + + +class BodepontoioMixin: + """ + Opt-in mixin for views that should use bodepontoio's standard response format. + + Apply this mixin to any view that should: + - Wrap success responses in ``{"success": true, "data": ...}`` + - Format error responses via ``exception_handler_v2`` + + Usage with ``exception_handler_v2``:: + + # settings.py + REST_FRAMEWORK = { + "EXCEPTION_HANDLER": "bodepontoio.exceptions.exception_handler_v2", + } + + # views.py + from bodepontoio.mixins import BodepontoioMixin + + class MyView(BodepontoioMixin, APIView): + ... + """ + + renderer_classes = [SuccessJSONRenderer] + bodepontoio_format = True diff --git a/bodepontoio/tests/test_exception_handler_v2.py b/bodepontoio/tests/test_exception_handler_v2.py new file mode 100644 index 0000000..85d2f90 --- /dev/null +++ b/bodepontoio/tests/test_exception_handler_v2.py @@ -0,0 +1,99 @@ +import pytest +from rest_framework import exceptions, permissions, status +from rest_framework.response import Response +from rest_framework.test import APIRequestFactory +from rest_framework.views import APIView + +from bodepontoio.exceptions import exception_handler_v2 +from bodepontoio.mixins import BodepontoioMixin + +factory = APIRequestFactory() + + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + +def call_handler(exc, view_instance=None): + context = {"view": view_instance} + return exception_handler_v2(exc, context) + + +class _PlainView(APIView): + permission_classes = [permissions.AllowAny] + + def get(self, request): + raise exceptions.NotFound() + + +class _BodeView(BodepontoioMixin, APIView): + permission_classes = [permissions.AllowAny] + + def get(self, request): + raise exceptions.NotFound() + + +class _BodeValidationView(BodepontoioMixin, APIView): + permission_classes = [permissions.AllowAny] + + def get(self, request): + raise exceptions.ValidationError({"email": ["This field is required."]}) + + +# --------------------------------------------------------------------------- +# Unit tests +# --------------------------------------------------------------------------- + +class TestExceptionHandlerV2Unit: + def test_opted_in_view_formats_not_found(self): + exc = exceptions.NotFound() + response = call_handler(exc, view_instance=_BodeView()) + assert response.data["success"] is False + assert response.data["type"] == "not_found" + assert "error" in response.data + + def test_opted_in_view_formats_validation_error(self): + exc = exceptions.ValidationError({"email": ["Required."]}) + response = call_handler(exc, view_instance=_BodeView()) + assert response.data["success"] is False + assert response.data["type"] == "validation_error" + assert isinstance(response.data["errors"], list) + + def test_non_opted_in_view_returns_plain_drf_response(self): + exc = exceptions.NotFound() + response = call_handler(exc, view_instance=_PlainView()) + # Plain DRF formats as {"detail": "Not found."} + assert "success" not in response.data + assert "detail" in response.data + + def test_no_view_in_context_returns_plain_drf_response(self): + exc = exceptions.NotFound() + response = call_handler(exc, view_instance=None) + assert "success" not in response.data + assert "detail" in response.data + + def test_non_drf_exception_returns_none(self): + response = call_handler(ValueError("boom"), view_instance=_BodeView()) + assert response is None + + def test_non_drf_exception_plain_view_returns_none(self): + response = call_handler(ValueError("boom"), view_instance=_PlainView()) + assert response is None + + +# --------------------------------------------------------------------------- +# Integration tests — via test client with override_settings +# --------------------------------------------------------------------------- + +@pytest.mark.django_db +class TestExceptionHandlerV2Integration: + def test_bodepontoio_views_still_format_errors(self, api_client, settings): + settings.REST_FRAMEWORK = { + **settings.REST_FRAMEWORK, + "EXCEPTION_HANDLER": "bodepontoio.exceptions.exception_handler_v2", + } + response = api_client.post("/auth/login/", {"email": "x@x.com", "password": "wrong"}) + body = response.json() + assert body["success"] is False + assert body["type"] == "validation_error" + assert isinstance(body["errors"], list) diff --git a/bodepontoio/views.py b/bodepontoio/views.py index da4f97a..749a9d3 100644 --- a/bodepontoio/views.py +++ b/bodepontoio/views.py @@ -7,6 +7,7 @@ from rest_framework_simplejwt.tokens import RefreshToken from .conf import bodepontoio_settings +from .mixins import BodepontoioMixin from .emails import send_email_confirmation_email, send_login_otp_email, send_password_reset_email from .models import OTPCode from .otp import verify_otp @@ -30,7 +31,7 @@ User = get_user_model() -class PasswordlessLoginConfirmView(APIView): +class PasswordlessLoginConfirmView(BodepontoioMixin, APIView): permission_classes = [permissions.AllowAny] def post(self, request): @@ -60,7 +61,7 @@ def post(self, request): return Response(_get_tokens(user)) -class LoginView(APIView): +class LoginView(BodepontoioMixin, APIView): permission_classes = [permissions.AllowAny] def post(self, request): @@ -80,7 +81,7 @@ def post(self, request): return Response(serializer.data) -class GoogleLoginView(APIView): +class GoogleLoginView(BodepontoioMixin, APIView): permission_classes = [permissions.AllowAny] def post(self, request): @@ -89,7 +90,7 @@ def post(self, request): return Response(serializer.data) -class TokenRefreshView(APIView): +class TokenRefreshView(BodepontoioMixin, APIView): permission_classes = [permissions.AllowAny] def post(self, request): @@ -98,7 +99,7 @@ def post(self, request): return Response(serializer.data) -class LogoutView(APIView): +class LogoutView(BodepontoioMixin, APIView): permission_classes = [permissions.IsAuthenticated] def post(self, request): @@ -117,7 +118,7 @@ def post(self, request): return Response() -class RegisterView(APIView): +class RegisterView(BodepontoioMixin, APIView): permission_classes = [permissions.AllowAny] def post(self, request): @@ -131,7 +132,7 @@ def post(self, request): ) -class PasswordChangeView(APIView): +class PasswordChangeView(BodepontoioMixin, APIView): permission_classes = [permissions.IsAuthenticated] def post(self, request): @@ -144,7 +145,7 @@ def post(self, request): return Response("Senha alterada com sucesso.") -class PasswordResetRequestView(APIView): +class PasswordResetRequestView(BodepontoioMixin, APIView): permission_classes = [permissions.AllowAny] def post(self, request): @@ -162,7 +163,7 @@ def post(self, request): return Response(msg) -class PasswordResetConfirmView(APIView): +class PasswordResetConfirmView(BodepontoioMixin, APIView): permission_classes = [permissions.AllowAny] def post(self, request): @@ -174,7 +175,7 @@ def post(self, request): return Response("Senha redefinida com sucesso.") -class EmailConfirmView(APIView): +class EmailConfirmView(BodepontoioMixin, APIView): permission_classes = [permissions.AllowAny] def get(self, request, uid, token): @@ -186,7 +187,7 @@ def get(self, request, uid, token): return Response("Endereço de e-mail confirmado.") -class OTPEmailConfirmView(APIView): +class OTPEmailConfirmView(BodepontoioMixin, APIView): permission_classes = [permissions.AllowAny] def post(self, request): @@ -210,7 +211,7 @@ def post(self, request): return Response("Endereço de e-mail confirmado.") -class OTPPasswordResetConfirmView(APIView): +class OTPPasswordResetConfirmView(BodepontoioMixin, APIView): permission_classes = [permissions.AllowAny] def post(self, request): @@ -234,7 +235,7 @@ def post(self, request): return Response("Senha redefinida com sucesso.") -class ResendEmailConfirmationView(APIView): +class ResendEmailConfirmationView(BodepontoioMixin, APIView): permission_classes = [permissions.AllowAny] def post(self, request): From 87d5471f71dafaef760a30da8754f21ef61bc3d0 Mon Sep 17 00:00:00 2001 From: Matheus Jacks Date: Fri, 17 Apr 2026 16:16:37 -0300 Subject: [PATCH 2/2] fix: lint issues --- bodepontoio/tests/test_exception_handler_v2.py | 3 +-- bodepontoio/views.py | 2 +- 2 files changed, 2 insertions(+), 3 deletions(-) diff --git a/bodepontoio/tests/test_exception_handler_v2.py b/bodepontoio/tests/test_exception_handler_v2.py index 85d2f90..9ae7fdf 100644 --- a/bodepontoio/tests/test_exception_handler_v2.py +++ b/bodepontoio/tests/test_exception_handler_v2.py @@ -1,6 +1,5 @@ import pytest -from rest_framework import exceptions, permissions, status -from rest_framework.response import Response +from rest_framework import exceptions, permissions from rest_framework.test import APIRequestFactory from rest_framework.views import APIView diff --git a/bodepontoio/views.py b/bodepontoio/views.py index 749a9d3..ad59547 100644 --- a/bodepontoio/views.py +++ b/bodepontoio/views.py @@ -7,8 +7,8 @@ from rest_framework_simplejwt.tokens import RefreshToken from .conf import bodepontoio_settings -from .mixins import BodepontoioMixin from .emails import send_email_confirmation_email, send_login_otp_email, send_password_reset_email +from .mixins import BodepontoioMixin from .models import OTPCode from .otp import verify_otp from .serializers import (