Skip to content

Commit c619f74

Browse files
committed
migrated to DRF exception to support DRF extention libraries
1 parent 641852d commit c619f74

File tree

7 files changed

+319
-43
lines changed

7 files changed

+319
-43
lines changed

ninja_extra/controllers/base.py

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,7 @@
2222
from ninja.security.base import AuthBase
2323
from ninja.types import DictStrAny
2424

25-
from ninja_extra.exceptions import APIException, NotFound, PermissionDenied
25+
from ninja_extra.exceptions import APIException, NotFound, PermissionDenied, bad_request
2626
from ninja_extra.operation import PathView
2727
from ninja_extra.permissions import AllowAny, BasePermission
2828
from ninja_extra.shortcuts import (
@@ -69,10 +69,9 @@ def __new__(mcs, name: str, bases: tuple, namespace: dict):
6969
tag = str(cls.__name__).lower().replace("controller", "")
7070
cls.tags = [tag]
7171

72-
if len(bases) > 1:
73-
for base_cls in reversed(bases):
74-
if issubclass(base_cls, APIController):
75-
compute_api_route_function(base_cls, cls)
72+
for base_cls in reversed(bases):
73+
if base_cls is not APIController and issubclass(base_cls, APIController):
74+
compute_api_route_function(base_cls, cls)
7675

7776
compute_api_route_function(cls)
7877
if not is_decorated_with_inject(cls.__init__):
@@ -110,6 +109,7 @@ class APIController(ABC, metaclass=APIControllerModelMetaclass):
110109
Ok = Ok
111110
Id = Id
112111
Detail = Detail
112+
bad_request = bad_request
113113

114114
@classmethod
115115
def get_router(cls) -> ControllerRouter:

ninja_extra/exceptions.py

Lines changed: 208 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -1,63 +1,254 @@
1-
from typing import Dict, Optional, Union
1+
"""
2+
DRF Exceptions
3+
"""
4+
from typing import Any, Dict, List, Optional, Type, Union, no_type_check
25

6+
from django.http import HttpRequest, HttpResponse, JsonResponse
37
from django.utils.encoding import force_str
48
from django.utils.translation import gettext_lazy as _
59
from ninja.errors import HttpError
610

711
from ninja_extra import status
812

913

14+
@no_type_check
15+
def _get_error_details(
16+
data: Union[List, Dict, "ErrorDetail"],
17+
default_code: Optional[Union[str, int]] = None,
18+
) -> Union[List["ErrorDetail"], "ErrorDetail", Dict[Any, "ErrorDetail"]]:
19+
"""
20+
Descend into a nested data structure, forcing any
21+
lazy translation strings or strings into `ErrorDetail`.
22+
"""
23+
if isinstance(data, list):
24+
ret = [_get_error_details(item, default_code) for item in data]
25+
return ret
26+
elif isinstance(data, dict):
27+
ret = {
28+
key: _get_error_details(value, default_code) for key, value in data.items()
29+
}
30+
return ret
31+
32+
text = force_str(data)
33+
code = getattr(data, "code", default_code)
34+
return ErrorDetail(text, code)
35+
36+
37+
@no_type_check
38+
def _get_codes(detail: Union[List, Dict, "ErrorDetail"]) -> Union[str, Dict]:
39+
if isinstance(detail, list):
40+
return [_get_codes(item) for item in detail]
41+
elif isinstance(detail, dict):
42+
return {key: _get_codes(value) for key, value in detail.items()}
43+
return detail.code
44+
45+
46+
@no_type_check
47+
def _get_full_details(detail: Union[List, Dict, "ErrorDetail"]) -> Dict:
48+
if isinstance(detail, list):
49+
return [_get_full_details(item) for item in detail]
50+
elif isinstance(detail, dict):
51+
return {key: _get_full_details(value) for key, value in detail.items()}
52+
return {"message": detail, "code": detail.code}
53+
54+
55+
class ErrorDetail(str):
56+
"""
57+
A string-like object that can additionally have a code.
58+
"""
59+
60+
code = None
61+
62+
def __new__(
63+
cls, string: str, code: Optional[Union[str, int]] = None
64+
) -> "ErrorDetail":
65+
self = super().__new__(cls, string)
66+
self.code = code
67+
return self
68+
69+
def __eq__(self, other: object) -> bool:
70+
r = super().__eq__(other)
71+
try:
72+
return r and self.code == other.code # type: ignore
73+
except AttributeError:
74+
return r
75+
76+
def __ne__(self, other: object) -> bool:
77+
return not self.__eq__(other)
78+
79+
def __repr__(self) -> str:
80+
return "ErrorDetail(string=%r, code=%r)" % (
81+
str(self),
82+
self.code,
83+
)
84+
85+
def __hash__(self) -> Any:
86+
return hash(str(self))
87+
88+
1089
class APIException(HttpError):
1190
"""
12-
Subclasses should provide `.status_code` and `.message` properties.
91+
Base class for REST framework exceptions.
92+
Subclasses should provide `.status_code` and `.default_detail` properties.
1393
"""
1494

1595
status_code = status.HTTP_500_INTERNAL_SERVER_ERROR
16-
message = _("A server error occurred.")
96+
default_detail = _("A server error occurred.")
97+
default_code = "error"
1798

1899
def __init__(
19100
self,
20-
message: Union[str, Dict[str, str], None] = None,
21-
status_code: Optional[int] = None,
101+
detail: Optional[Union[List, Dict, "ErrorDetail", str]] = None,
102+
code: Optional[Union[str, int]] = None,
22103
) -> None:
23-
self.message = message or self.message # type: ignore
24-
self.status_code = status_code or self.status_code
25-
super().__init__(status_code=self.status_code, message=self.message)
104+
if detail is None:
105+
detail = self.default_detail
106+
if code is None:
107+
code = self.default_code
108+
109+
self.detail = _get_error_details(detail, code)
26110

27111
def __str__(self) -> str:
28-
return self.message
112+
return str(self.detail)
113+
114+
def get_codes(self) -> Union[str, Dict[Any, Any]]:
115+
"""
116+
Return only the code part of the error details.
117+
118+
Eg. {"name": ["required"]}
119+
"""
120+
return _get_codes(self.detail) # type: ignore
121+
122+
def get_full_details(self) -> Dict[Any, Any]:
123+
"""
124+
Return both the message & code parts of the error details.
125+
126+
Eg. {"name": [{"message": "This field is required.", "code": "required"}]}
127+
"""
128+
return _get_full_details(self.detail) # type: ignore
129+
130+
131+
# The recommended style for using `ValidationError` is to keep it namespaced
132+
# under `serializers`, in order to minimize potential confusion with Django's
133+
# built in `ValidationError`. For example:
134+
#
135+
# from rest_framework import serializers
136+
# raise serializers.ValidationError('Value was invalid')
137+
138+
139+
class ValidationError(APIException):
140+
status_code = status.HTTP_400_BAD_REQUEST
141+
default_detail = _("Invalid input.")
142+
default_code = "invalid"
143+
144+
def __init__(
145+
self,
146+
detail: Optional[Union[List, Dict, "ErrorDetail", str]] = None,
147+
code: Optional[Union[str, int]] = None,
148+
):
149+
if detail is None:
150+
detail = self.default_detail
151+
if code is None:
152+
code = self.default_code
153+
154+
# For validation failures, we may collect many errors together,
155+
# so the details should always be coerced to a list if not already.
156+
if not isinstance(detail, dict) and not isinstance(detail, list):
157+
detail = [detail]
158+
159+
self.detail = _get_error_details(detail, code)
160+
161+
162+
class ParseError(APIException):
163+
status_code = status.HTTP_400_BAD_REQUEST
164+
default_detail = _("Malformed request.")
165+
default_code = "parse_error"
29166

30167

31168
class AuthenticationFailed(APIException):
32169
status_code = status.HTTP_401_UNAUTHORIZED
33-
message = _("Incorrect authentication credentials.")
170+
default_detail = _("Incorrect authentication credentials.")
171+
default_code = "authentication_failed"
34172

35173

36174
class NotAuthenticated(APIException):
37175
status_code = status.HTTP_401_UNAUTHORIZED
38-
message = _("Authentication credentials were not provided.")
176+
default_detail = _("Authentication credentials were not provided.")
177+
default_code = "not_authenticated"
39178

40179

41180
class PermissionDenied(APIException):
42181
status_code = status.HTTP_403_FORBIDDEN
43-
message = _("You do not have permission to perform this action.")
182+
default_detail = _("You do not have permission to perform this action.")
183+
default_code = "permission_denied"
44184

45185

46186
class NotFound(APIException):
47187
status_code = status.HTTP_404_NOT_FOUND
48-
message = _("Not found.")
188+
default_detail = _("Not found.")
189+
default_code = "not_found"
49190

50191

51192
class MethodNotAllowed(APIException):
52193
status_code = status.HTTP_405_METHOD_NOT_ALLOWED
53194
default_detail = _('Method "{method}" not allowed.')
195+
default_code = "method_not_allowed"
54196

55197
def __init__(
56198
self,
57199
method: str,
58-
detail: Optional[str] = None,
59-
status_code: Optional[int] = None,
60-
) -> None:
200+
detail: Optional[Union[List, Dict, "ErrorDetail", str]] = None,
201+
code: Optional[Union[str, int]] = None,
202+
):
61203
if detail is None:
62204
detail = force_str(self.default_detail).format(method=method)
63-
super().__init__(status_code=status_code, message=detail)
205+
super().__init__(detail, code)
206+
207+
208+
class NotAcceptable(APIException):
209+
status_code = status.HTTP_406_NOT_ACCEPTABLE
210+
default_detail = _("Could not satisfy the request Accept header.")
211+
default_code = "not_acceptable"
212+
213+
def __init__(
214+
self,
215+
detail: Optional[Union[List, Dict, "ErrorDetail"]] = None,
216+
code: Optional[Union[str, int]] = None,
217+
available_renderers: Optional[str] = None,
218+
):
219+
self.available_renderers = available_renderers
220+
super().__init__(detail, code)
221+
222+
223+
class UnsupportedMediaType(APIException):
224+
status_code = status.HTTP_415_UNSUPPORTED_MEDIA_TYPE
225+
default_detail = _('Unsupported media type "{media_type}" in request.')
226+
default_code = "unsupported_media_type"
227+
228+
def __init__(
229+
self,
230+
media_type: str,
231+
detail: Optional[Union[List, Dict, "ErrorDetail", str]] = None,
232+
code: Optional[Union[str, int]] = None,
233+
):
234+
if detail is None:
235+
detail = force_str(self.default_detail).format(media_type=media_type)
236+
super().__init__(detail, code)
237+
238+
239+
def server_error(request: HttpRequest, *args: Any, **kwargs: Any) -> HttpResponse:
240+
"""
241+
Generic 500 error handler.
242+
"""
243+
data = {"error": "Server Error (500)"}
244+
return JsonResponse(data, status=status.HTTP_500_INTERNAL_SERVER_ERROR)
245+
246+
247+
def bad_request(
248+
request: HttpRequest, exception: Type[APIException], *args: Any, **kwargs: Any
249+
) -> HttpResponse:
250+
"""
251+
Generic 400 error handler.
252+
"""
253+
data = {"error": "Bad Request (400)"}
254+
return JsonResponse(data, status=status.HTTP_400_BAD_REQUEST)

ninja_extra/main.py

Lines changed: 24 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,18 @@
11
from importlib import import_module
2-
from typing import Callable, Optional, Sequence, Type, Union
2+
from typing import Callable, List, Optional, Sequence, Tuple, Type, Union
33

44
from django.core.exceptions import ImproperlyConfigured
55
from django.http import HttpRequest, HttpResponse
6+
from django.urls import URLPattern, URLResolver
67
from django.utils.module_loading import module_has_submodule
78
from ninja import NinjaAPI
89
from ninja.constants import NOT_SET
910
from ninja.parser import Parser
1011
from ninja.renderers import BaseRenderer
1112

13+
from ninja_extra import exceptions
1214
from ninja_extra.controllers.base import APIController
1315
from ninja_extra.controllers.router import ControllerRegistry, ControllerRouter
14-
from ninja_extra.exceptions import APIException
1516

1617
__all__ = [
1718
"NinjaExtraAPI",
@@ -32,6 +33,7 @@ def __init__(
3233
auth: Union[Sequence[Callable], Callable, object] = NOT_SET,
3334
renderer: Optional[BaseRenderer] = None,
3435
parser: Optional[Parser] = None,
36+
app_name: str = "ninja",
3537
) -> None:
3638
super(NinjaExtraAPI, self).__init__(
3739
title=title,
@@ -45,21 +47,27 @@ def __init__(
4547
renderer=renderer,
4648
parser=parser,
4749
)
50+
self.app_name = app_name
51+
self.exception_handler(exceptions.APIException)(self.api_exception_handler)
4852

49-
@self.exception_handler(APIException)
50-
def api_exception_handler(
51-
request: HttpRequest, exc: APIException
52-
) -> HttpResponse:
53-
message = (
54-
{"message": exc.message}
55-
if not isinstance(exc.message, dict)
56-
else exc.message
57-
)
58-
return self.create_response(
59-
request,
60-
message,
61-
status=exc.status_code,
62-
)
53+
def api_exception_handler(
54+
self, request: HttpRequest, exc: exceptions.APIException
55+
) -> HttpResponse:
56+
if isinstance(exc.detail, (list, dict)):
57+
data = exc.detail
58+
else:
59+
data = {"detail": exc.detail}
60+
61+
return self.create_response(request, data, status=exc.status_code)
62+
63+
@property
64+
def urls(self) -> Tuple[List[Union[URLResolver, URLPattern]], str, str]:
65+
_url_tuple = super().urls
66+
return (
67+
_url_tuple[0],
68+
self.app_name,
69+
str(_url_tuple[len(_url_tuple) - 1]),
70+
)
6371

6472
def register_controllers(self, *controllers: Type[APIController]) -> None:
6573
for controller in controllers:

ninja_extra/shortcuts.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -41,7 +41,7 @@ def get_object_or_exception(
4141
message = "{} with {} was not found".format(
4242
queryset.model._meta.object_name, _format_dict(kwargs)
4343
)
44-
raise exception(message=message) from ex
44+
raise exception(detail=message) from ex
4545

4646

4747
def _format_dict(table: DictStrAny) -> str:

0 commit comments

Comments
 (0)