Skip to content

Commit 0dac42c

Browse files
committed
Refactored Exception Module, Merged handlers to module and changed a new interface for creating Exception handlers
1 parent 867fa96 commit 0dac42c

File tree

7 files changed

+483
-0
lines changed

7 files changed

+483
-0
lines changed

ellar/core/exceptions/__init__.py

Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,98 @@
1+
import typing as t
2+
3+
from starlette import status
4+
from starlette.exceptions import HTTPException, WebSocketException
5+
6+
from .base import APIException, ErrorDetail
7+
from .validation import RequestValidationError, WebSocketRequestValidationError
8+
9+
__all__ = [
10+
"HTTPException",
11+
"ImproperConfiguration",
12+
"APIException",
13+
"WebSocketRequestValidationError",
14+
"RequestValidationError",
15+
"AuthenticationFailed",
16+
"NotAuthenticated",
17+
"PermissionDenied",
18+
"NotFound",
19+
"MethodNotAllowed",
20+
"NotAcceptable",
21+
"UnsupportedMediaType",
22+
"WebSocketException",
23+
]
24+
25+
26+
class ImproperConfiguration(Exception):
27+
pass
28+
29+
30+
class AuthenticationFailed(APIException):
31+
status_code = status.HTTP_401_UNAUTHORIZED
32+
default_detail = "Incorrect authentication credentials."
33+
default_code = "authentication_failed"
34+
35+
36+
class NotAuthenticated(APIException):
37+
status_code = status.HTTP_401_UNAUTHORIZED
38+
default_detail = "Authentication credentials were not provided."
39+
default_code = "not_authenticated"
40+
41+
42+
class PermissionDenied(APIException):
43+
status_code = status.HTTP_403_FORBIDDEN
44+
default_detail = "You do not have permission to perform this action."
45+
default_code = "permission_denied"
46+
47+
48+
class NotFound(APIException):
49+
status_code = status.HTTP_404_NOT_FOUND
50+
default_detail = "Not found."
51+
default_code = "not_found"
52+
53+
54+
class MethodNotAllowed(APIException):
55+
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)
68+
69+
70+
class NotAcceptable(APIException):
71+
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)
83+
84+
85+
class UnsupportedMediaType(APIException):
86+
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)

ellar/core/exceptions/base.py

Lines changed: 128 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,128 @@
1+
import typing as t
2+
3+
from starlette import status
4+
from starlette.exceptions import HTTPException as StarletteHTTPException
5+
6+
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
24+
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"
90+
91+
def __init__(
92+
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,
97+
) -> None:
98+
if detail is None:
99+
detail = self.default_detail
100+
if code is None:
101+
code = self.default_code
102+
103+
if status_code is None:
104+
status_code = self.status_code
105+
106+
super(APIException, self).__init__(
107+
status_code=status_code,
108+
detail=_get_error_details(detail, code),
109+
headers=headers,
110+
)
111+
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
120+
121+
def get_full_details(
122+
self,
123+
) -> t.Union[t.Dict, t.List[t.Dict]]:
124+
"""
125+
Return both the message & code parts of the error details.
126+
Eg. {"name": [{"message": "This field is required.", "code": "required"}]}
127+
"""
128+
return _get_full_details(t.cast(ErrorDetail, self.detail)) # type: ignore
Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
import typing as t
2+
3+
from starlette.concurrency import run_in_threadpool
4+
from starlette.responses import Response
5+
6+
from ellar.helper import is_async_callable
7+
8+
from ..context import IExecutionContext
9+
from .interfaces import IExceptionHandler
10+
11+
12+
class CallableExceptionHandler(IExceptionHandler):
13+
"""
14+
Default Exception Handler Setup for functions
15+
16+
usage:
17+
18+
```python
19+
20+
class CustomException(Exception):
21+
pass
22+
23+
24+
def exception_handler_fun(ctx: IExecutionContext, exc: Exception):
25+
return PlainResponse('Bad Request', status_code=400)
26+
27+
exception_400_handler = CallableExceptionHandler(
28+
exc_class_or_status_code=400, callable_exception_handler= exception_handler_fun
29+
)
30+
exception_custom_handler = CallableExceptionHandler(
31+
exc_class_or_status_code=CustomException, callable_exception_handler= exception_handler_fun
32+
)
33+
34+
# in Config.py
35+
EXCEPTION_HANDLERS = [exception_handler, exception_custom_handler]
36+
37+
```
38+
"""
39+
40+
__slots__ = ("callable_exception_handler", "is_async", "func_args")
41+
exception_type_or_code: t.Union[t.Type[Exception], int] = 400
42+
43+
def __init__(
44+
self,
45+
*func_args: t.Any,
46+
exc_class_or_status_code: t.Union[t.Type[Exception], int],
47+
callable_exception_handler: t.Callable[
48+
[IExecutionContext, Exception],
49+
t.Union[t.Awaitable[Response], Response, t.Any],
50+
]
51+
) -> None:
52+
self.callable_exception_handler = callable_exception_handler
53+
self.is_async = False
54+
self.func_args = func_args
55+
56+
if not isinstance(exc_class_or_status_code, int):
57+
assert issubclass(exc_class_or_status_code, Exception)
58+
59+
self.exception_type_or_code = exc_class_or_status_code
60+
61+
if is_async_callable(callable_exception_handler):
62+
self.is_async = True
63+
64+
async def catch(
65+
self, ctx: IExecutionContext, exc: Exception
66+
) -> t.Union[Response, t.Any]:
67+
args = tuple(list(self.func_args) + [ctx, exc])
68+
if self.is_async:
69+
return await self.callable_exception_handler(*args) # type:ignore
70+
return await run_in_threadpool(self.callable_exception_handler, *args)

ellar/core/exceptions/handlers.py

Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
1+
import typing as t
2+
3+
from starlette import status
4+
from starlette.exceptions import (
5+
HTTPException as StarletteHTTPException,
6+
WebSocketException as StarletteWebSocketException,
7+
)
8+
from starlette.responses import PlainTextResponse, Response
9+
10+
from ellar.core.context import IExecutionContext
11+
from ellar.core.exceptions import APIException, RequestValidationError
12+
from ellar.serializer import serialize_object
13+
14+
from .interfaces import IExceptionHandler
15+
16+
17+
class HTTPExceptionHandler(IExceptionHandler):
18+
exception_type_or_code = StarletteHTTPException
19+
20+
async def catch(
21+
self, ctx: IExecutionContext, exc: StarletteHTTPException
22+
) -> t.Union[Response, t.Any]:
23+
if exc.status_code in {204, 304}:
24+
return Response(status_code=exc.status_code, headers=exc.headers)
25+
return PlainTextResponse(
26+
exc.detail, status_code=exc.status_code, headers=exc.headers
27+
)
28+
29+
30+
class WebSocketExceptionHandler(IExceptionHandler):
31+
exception_type_or_code = StarletteWebSocketException
32+
33+
async def catch(
34+
self, ctx: IExecutionContext, exc: StarletteWebSocketException
35+
) -> t.Union[Response, t.Any]:
36+
websocket = ctx.switch_to_websocket()
37+
await websocket.close(code=exc.code, reason=exc.reason)
38+
return None
39+
40+
41+
class APIExceptionHandler(IExceptionHandler):
42+
exception_type_or_code = APIException
43+
44+
async def catch(
45+
self, ctx: IExecutionContext, exc: APIException
46+
) -> t.Union[Response, t.Any]:
47+
config = ctx.get_app().config
48+
headers = getattr(exc, "headers", {})
49+
if isinstance(exc.detail, (list, dict)):
50+
data = exc.detail
51+
else:
52+
data = {"detail": exc.detail}
53+
return config.DEFAULT_JSON_CLASS(
54+
serialize_object(data), status_code=exc.status_code, headers=headers
55+
)
56+
57+
58+
class RequestValidationErrorHandler(IExceptionHandler):
59+
exception_type_or_code = RequestValidationError
60+
61+
async def catch(
62+
self, ctx: IExecutionContext, exc: RequestValidationError
63+
) -> t.Union[Response, t.Any]:
64+
config = ctx.get_app().config
65+
return config.DEFAULT_JSON_CLASS(
66+
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
67+
content={"detail": serialize_object(exc.errors())},
68+
)

0 commit comments

Comments
 (0)