Skip to content

Commit 884ea4b

Browse files
committed
Moved from plain text error response to JSON response
1 parent d8e3eef commit 884ea4b

File tree

7 files changed

+88
-166
lines changed

7 files changed

+88
-166
lines changed

ellar/core/exceptions/__init__.py

Lines changed: 18 additions & 44 deletions
Original file line numberDiff line numberDiff line change
@@ -3,10 +3,13 @@
33
from starlette import status
44
from starlette.exceptions import HTTPException, WebSocketException
55

6-
from .base import APIException, ErrorDetail
6+
from .base import APIException
7+
from .interfaces import IExceptionHandler, IExceptionMiddlewareService
78
from .validation import RequestValidationError, WebSocketRequestValidationError
89

910
__all__ = [
11+
"IExceptionHandler",
12+
"IExceptionMiddlewareService",
1013
"HTTPException",
1114
"ImproperConfiguration",
1215
"APIException",
@@ -29,70 +32,41 @@ class ImproperConfiguration(Exception):
2932

3033
class AuthenticationFailed(APIException):
3134
status_code = status.HTTP_401_UNAUTHORIZED
32-
default_detail = "Incorrect authentication credentials."
33-
default_code = "authentication_failed"
35+
code = "authentication_failed"
36+
37+
def __init__(
38+
self, detail: t.Union[t.List, t.Dict, str] = None, **kwargs: t.Any
39+
) -> None:
40+
if detail is None:
41+
detail = "Incorrect authentication credentials."
42+
super(AuthenticationFailed, self).__init__(detail=detail, **kwargs)
3443

3544

3645
class NotAuthenticated(APIException):
3746
status_code = status.HTTP_401_UNAUTHORIZED
38-
default_detail = "Authentication credentials were not provided."
39-
default_code = "not_authenticated"
47+
code = "not_authenticated"
4048

4149

4250
class PermissionDenied(APIException):
4351
status_code = status.HTTP_403_FORBIDDEN
44-
default_detail = "You do not have permission to perform this action."
45-
default_code = "permission_denied"
52+
code = "permission_denied"
4653

4754

4855
class NotFound(APIException):
4956
status_code = status.HTTP_404_NOT_FOUND
50-
default_detail = "Not found."
51-
default_code = "not_found"
57+
code = "not_found"
5258

5359

5460
class MethodNotAllowed(APIException):
5561
status_code = status.HTTP_405_METHOD_NOT_ALLOWED
56-
default_detail = 'Method "{method}" not allowed.'
57-
default_code = "method_not_allowed"
58-
59-
def __init__(
60-
self,
61-
method: str,
62-
detail: t.Optional[t.Union[t.List, t.Dict, ErrorDetail, str]] = None,
63-
code: t.Optional[t.Union[str, int]] = None,
64-
):
65-
if detail is None:
66-
detail = str(self.default_detail).format(method=method)
67-
super().__init__(detail, code)
62+
code = "method_not_allowed"
6863

6964

7065
class NotAcceptable(APIException):
7166
status_code = status.HTTP_406_NOT_ACCEPTABLE
72-
default_detail = "Could not satisfy the request Accept header."
73-
default_code = "not_acceptable"
74-
75-
def __init__(
76-
self,
77-
detail: t.Optional[t.Union[t.List, t.Dict, ErrorDetail, str]] = None,
78-
code: t.Optional[t.Union[str, int]] = None,
79-
available_renderers: t.Optional[str] = None,
80-
):
81-
self.available_renderers = available_renderers
82-
super().__init__(detail, code)
67+
code = "not_acceptable"
8368

8469

8570
class UnsupportedMediaType(APIException):
8671
status_code = status.HTTP_415_UNSUPPORTED_MEDIA_TYPE
87-
default_detail = 'Unsupported media type "{media_type}" in request.'
88-
default_code = "unsupported_media_type"
89-
90-
def __init__(
91-
self,
92-
media_type: str,
93-
detail: t.Optional[t.Union[t.List, t.Dict, ErrorDetail, str]] = None,
94-
code: t.Optional[t.Union[str, int]] = None,
95-
):
96-
if detail is None:
97-
detail = str(self.default_detail).format(media_type=media_type)
98-
super().__init__(detail, code)
72+
code = "unsupported_media_type"

ellar/core/exceptions/base.py

Lines changed: 32 additions & 111 deletions
Original file line numberDiff line numberDiff line change
@@ -1,128 +1,49 @@
1+
import http
12
import typing as t
23

34
from starlette import status
4-
from starlette.exceptions import HTTPException as StarletteHTTPException
55

66

7-
@t.no_type_check
8-
def _get_error_details(
9-
data: t.Union[t.List, t.Dict, "ErrorDetail"],
10-
default_code: t.Optional[t.Union[str, int]] = None,
11-
) -> t.Union[t.List["ErrorDetail"], "ErrorDetail", t.Dict[t.Any, "ErrorDetail"]]:
12-
"""
13-
Descend into a nested data structure, forcing any
14-
lazy translation strings or strings into `ErrorDetail`.
15-
"""
16-
if isinstance(data, list):
17-
ret = [_get_error_details(item, default_code) for item in data]
18-
return ret
19-
elif isinstance(data, dict):
20-
ret = {
21-
key: _get_error_details(value, default_code) for key, value in data.items()
22-
}
23-
return ret
7+
class APIException(Exception):
8+
__slots__ = ("headers", "description", "detail", "http_status")
249

25-
text = str(data)
26-
code = getattr(data, "code", default_code)
27-
return ErrorDetail(text, code)
28-
29-
30-
@t.no_type_check
31-
def _get_codes(
32-
detail: t.Union[t.List, t.Dict, "ErrorDetail"]
33-
) -> t.Union[str, t.Dict, t.List[t.Dict]]:
34-
if isinstance(detail, list):
35-
return [_get_codes(item) for item in detail]
36-
elif isinstance(detail, dict):
37-
return {key: _get_codes(value) for key, value in detail.items()}
38-
return detail.code
39-
40-
41-
@t.no_type_check
42-
def _get_full_details(
43-
detail: t.Union[t.List, t.Dict, "ErrorDetail"]
44-
) -> t.Union[t.Dict, t.List[t.Dict]]:
45-
if isinstance(detail, list):
46-
return [_get_full_details(item) for item in detail]
47-
elif isinstance(detail, dict):
48-
return {key: _get_full_details(value) for key, value in detail.items()}
49-
return {"message": detail, "code": detail.code}
50-
51-
52-
class ErrorDetail(str):
53-
"""
54-
A string-like object that can additionally have a code.
55-
"""
56-
57-
code = None
58-
59-
def __new__(
60-
cls, string: str, code: t.Optional[t.Union[str, int]] = None
61-
) -> "ErrorDetail":
62-
self = super().__new__(cls, string)
63-
self.code = code
64-
return self
65-
66-
def __eq__(self, other: object) -> bool:
67-
r = super().__eq__(other)
68-
try:
69-
return r and self.code == other.code # type: ignore
70-
except AttributeError:
71-
return r
72-
73-
def __ne__(self, other: object) -> bool:
74-
return not self.__eq__(other)
75-
76-
def __repr__(self) -> str:
77-
return "ErrorDetail(string=%r, code=%r)" % (
78-
str(self),
79-
self.code,
80-
)
81-
82-
def __hash__(self) -> t.Any:
83-
return hash(str(self))
84-
85-
86-
class APIException(StarletteHTTPException):
87-
status_code = status.HTTP_500_INTERNAL_SERVER_ERROR
88-
default_detail = "A server error occurred."
89-
default_code = "error"
10+
status_code = status.HTTP_400_BAD_REQUEST
11+
code = "bad_request"
9012

9113
def __init__(
9214
self,
93-
detail: t.Optional[t.Union[t.List, t.Dict, "ErrorDetail", str]] = None,
94-
code: t.Optional[t.Union[str, int]] = None,
95-
status_code: t.Optional[int] = None,
96-
headers: t.Optional[t.Dict[str, t.Any]] = None,
15+
detail: t.Union[t.List, t.Dict, str] = None,
16+
description: str = None,
17+
headers: t.Dict[str, t.Any] = None,
18+
status_code: int = None,
9719
) -> None:
98-
if detail is None:
99-
detail = self.default_detail
100-
if code is None:
101-
code = self.default_code
20+
assert self.status_code
21+
self.status_code = status_code or self.status_code
22+
self.http_status = http.HTTPStatus(self.status_code)
23+
self.description = description
10224

103-
if status_code is None:
104-
status_code = self.status_code
25+
if detail is None:
26+
detail = self.http_status.phrase
10527

106-
super(APIException, self).__init__(
107-
status_code=status_code,
108-
detail=_get_error_details(detail, code),
109-
headers=headers,
110-
)
28+
self.detail = detail
29+
self.headers = headers
11130

112-
def get_codes(
113-
self,
114-
) -> t.Union[str, t.Dict, t.List]:
115-
"""
116-
Return only the code part of the error details.
117-
Eg. {"name": ["required"]}
118-
"""
119-
return _get_codes(t.cast(ErrorDetail, self.detail)) # type: ignore
31+
def __repr__(self) -> str:
32+
class_name = self.__class__.__name__
33+
return f"{class_name}(status_code={self.status_code!r}, detail={self.detail!r})"
12034

121-
def get_full_details(
122-
self,
123-
) -> t.Union[t.Dict, t.List[t.Dict]]:
35+
def get_full_details(self) -> t.Union[t.Dict, t.List[t.Dict]]:
12436
"""
12537
Return both the message & code parts of the error details.
126-
Eg. {"name": [{"message": "This field is required.", "code": "required"}]}
12738
"""
128-
return _get_full_details(t.cast(ErrorDetail, self.detail)) # type: ignore
39+
return dict(
40+
detail=self.detail,
41+
code=self.code,
42+
description=self.description or self.http_status.description,
43+
)
44+
45+
def get_details(self) -> t.Dict:
46+
result = dict(detail=self.detail)
47+
if self.description:
48+
result.update(description=self.description)
49+
return result

ellar/core/exceptions/handlers.py

Lines changed: 17 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
55
HTTPException as StarletteHTTPException,
66
WebSocketException as StarletteWebSocketException,
77
)
8-
from starlette.responses import PlainTextResponse, Response
8+
from starlette.responses import Response
99

1010
from ellar.core.context import IExecutionContext
1111
from ellar.core.exceptions import APIException, RequestValidationError
@@ -20,10 +20,19 @@ class HTTPExceptionHandler(IExceptionHandler):
2020
async def catch(
2121
self, ctx: IExecutionContext, exc: StarletteHTTPException
2222
) -> t.Union[Response, t.Any]:
23+
assert isinstance(exc, StarletteHTTPException)
24+
config = ctx.get_app().config
25+
2326
if exc.status_code in {204, 304}:
2427
return Response(status_code=exc.status_code, headers=exc.headers)
25-
return PlainTextResponse(
26-
exc.detail, status_code=exc.status_code, headers=exc.headers
28+
29+
if isinstance(exc.detail, (list, dict)):
30+
data = exc.detail
31+
else:
32+
data = dict(detail=exc.detail, status_code=exc.status_code)
33+
34+
return config.DEFAULT_JSON_CLASS(
35+
data, status_code=exc.status_code, headers=exc.headers
2736
)
2837

2938

@@ -44,14 +53,16 @@ class APIExceptionHandler(IExceptionHandler):
4453
async def catch(
4554
self, ctx: IExecutionContext, exc: APIException
4655
) -> t.Union[Response, t.Any]:
56+
assert isinstance(exc, APIException)
57+
4758
config = ctx.get_app().config
48-
headers = getattr(exc, "headers", {})
4959
if isinstance(exc.detail, (list, dict)):
5060
data = exc.detail
5161
else:
52-
data = {"detail": exc.detail}
62+
data = exc.get_details()
63+
5364
return config.DEFAULT_JSON_CLASS(
54-
serialize_object(data), status_code=exc.status_code, headers=headers
65+
serialize_object(data), status_code=exc.status_code, headers=exc.headers
5566
)
5667

5768

ellar/core/exceptions/interfaces.py

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,11 @@ async def catch(
2727
def __init_subclass__(cls, **kwargs: t.Any) -> None:
2828
assert (
2929
cls.exception_type_or_code
30-
), f"exception_type_or_code must be defined. {cls}"
30+
), f"'exception_type_or_code' must be defined. {cls}"
31+
if not isinstance(cls.exception_type_or_code, int):
32+
assert issubclass(
33+
cls.exception_type_or_code, Exception
34+
), "'exception_type_or_code' is not a valid type"
3135

3236

3337
class IExceptionMiddlewareService:

ellar/core/guard/base.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,9 @@
1111

1212

1313
class GuardCanActivate(ABC, metaclass=ABCMeta):
14-
exception_class: t.Type[HTTPException] = HTTPException
14+
exception_class: t.Union[
15+
t.Type[HTTPException], t.Type[APIException]
16+
] = HTTPException
1517
status_code: int = HTTP_403_FORBIDDEN
1618
detail: str = "Not authenticated"
1719

ellar/core/main.py

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -267,10 +267,14 @@ def _finalize_app_initialization(self) -> None:
267267

268268
def add_exception_handler(
269269
self,
270-
exception_handler: IExceptionHandler,
270+
*exception_handlers: IExceptionHandler,
271271
) -> None:
272-
if exception_handler not in self.config.EXCEPTION_HANDLERS:
273-
self.config.EXCEPTION_HANDLERS.append(exception_handler)
272+
_added_any = False
273+
for exception_handler in exception_handlers:
274+
if exception_handler not in self.config.EXCEPTION_HANDLERS:
275+
self.config.EXCEPTION_HANDLERS.append(exception_handler)
276+
_added_any = True
277+
if _added_any:
274278
self.rebuild_middleware_stack()
275279

276280
def rebuild_middleware_stack(self) -> None:

ellar/core/middleware/di.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
HTTPConnection as StarletteHTTPConnection,
77
Request as StarletteRequest,
88
)
9+
from starlette.responses import JSONResponse
910
from starlette.websockets import WebSocket as StarletteWebSocket
1011

1112
from ellar.constants import SCOPE_EXECUTION_CONTEXT_PROVIDER, SCOPE_SERVICE_PROVIDER
@@ -110,3 +111,8 @@ async def error_handler(self, request: Request, exc: Exception) -> Response:
110111
assert self._500_error_handler
111112
response = await self._500_error_handler.catch(ctx=execute_context, exc=exc)
112113
return response
114+
115+
def error_response(self, request: StarletteRequest, exc: Exception) -> Response:
116+
return JSONResponse(
117+
dict(detail="Internal server error", status_code=500), status_code=500
118+
)

0 commit comments

Comments
 (0)