Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
37 changes: 37 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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
Expand Down
36 changes: 31 additions & 5 deletions bodepontoio/exceptions.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand All @@ -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)
27 changes: 27 additions & 0 deletions bodepontoio/mixins.py
Original file line number Diff line number Diff line change
@@ -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
98 changes: 98 additions & 0 deletions bodepontoio/tests/test_exception_handler_v2.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
import pytest
from rest_framework import exceptions, permissions
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)
27 changes: 14 additions & 13 deletions bodepontoio/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@

from .conf import bodepontoio_settings
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 (
Expand All @@ -30,7 +31,7 @@
User = get_user_model()


class PasswordlessLoginConfirmView(APIView):
class PasswordlessLoginConfirmView(BodepontoioMixin, APIView):
permission_classes = [permissions.AllowAny]

def post(self, request):
Expand Down Expand Up @@ -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):
Expand All @@ -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):
Expand All @@ -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):
Expand All @@ -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):
Expand All @@ -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):
Expand All @@ -131,7 +132,7 @@ def post(self, request):
)


class PasswordChangeView(APIView):
class PasswordChangeView(BodepontoioMixin, APIView):
permission_classes = [permissions.IsAuthenticated]

def post(self, request):
Expand All @@ -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):
Expand All @@ -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):
Expand All @@ -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):
Expand All @@ -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):
Expand All @@ -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):
Expand All @@ -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):
Expand Down
Loading