|
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 |
2 | 5 |
|
| 6 | +from django.http import HttpRequest, HttpResponse, JsonResponse |
3 | 7 | from django.utils.encoding import force_str |
4 | 8 | from django.utils.translation import gettext_lazy as _ |
5 | 9 | from ninja.errors import HttpError |
6 | 10 |
|
7 | 11 | from ninja_extra import status |
8 | 12 |
|
9 | 13 |
|
| 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 | + |
10 | 89 | class APIException(HttpError): |
11 | 90 | """ |
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. |
13 | 93 | """ |
14 | 94 |
|
15 | 95 | 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" |
17 | 98 |
|
18 | 99 | def __init__( |
19 | 100 | 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, |
22 | 103 | ) -> 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) |
26 | 110 |
|
27 | 111 | 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" |
29 | 166 |
|
30 | 167 |
|
31 | 168 | class AuthenticationFailed(APIException): |
32 | 169 | status_code = status.HTTP_401_UNAUTHORIZED |
33 | | - message = _("Incorrect authentication credentials.") |
| 170 | + default_detail = _("Incorrect authentication credentials.") |
| 171 | + default_code = "authentication_failed" |
34 | 172 |
|
35 | 173 |
|
36 | 174 | class NotAuthenticated(APIException): |
37 | 175 | 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" |
39 | 178 |
|
40 | 179 |
|
41 | 180 | class PermissionDenied(APIException): |
42 | 181 | 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" |
44 | 184 |
|
45 | 185 |
|
46 | 186 | class NotFound(APIException): |
47 | 187 | status_code = status.HTTP_404_NOT_FOUND |
48 | | - message = _("Not found.") |
| 188 | + default_detail = _("Not found.") |
| 189 | + default_code = "not_found" |
49 | 190 |
|
50 | 191 |
|
51 | 192 | class MethodNotAllowed(APIException): |
52 | 193 | status_code = status.HTTP_405_METHOD_NOT_ALLOWED |
53 | 194 | default_detail = _('Method "{method}" not allowed.') |
| 195 | + default_code = "method_not_allowed" |
54 | 196 |
|
55 | 197 | def __init__( |
56 | 198 | self, |
57 | 199 | 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 | + ): |
61 | 203 | if detail is None: |
62 | 204 | 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) |
0 commit comments