django-bolt 0.1.0__cp310-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Potentially problematic release.
This version of django-bolt might be problematic. Click here for more details.
- django_bolt/__init__.py +147 -0
- django_bolt/_core.abi3.so +0 -0
- django_bolt/admin/__init__.py +25 -0
- django_bolt/admin/admin_detection.py +179 -0
- django_bolt/admin/asgi_bridge.py +267 -0
- django_bolt/admin/routes.py +91 -0
- django_bolt/admin/static.py +155 -0
- django_bolt/admin/static_routes.py +111 -0
- django_bolt/api.py +1011 -0
- django_bolt/apps.py +7 -0
- django_bolt/async_collector.py +228 -0
- django_bolt/auth/README.md +464 -0
- django_bolt/auth/REVOCATION_EXAMPLE.md +391 -0
- django_bolt/auth/__init__.py +84 -0
- django_bolt/auth/backends.py +236 -0
- django_bolt/auth/guards.py +224 -0
- django_bolt/auth/jwt_utils.py +212 -0
- django_bolt/auth/revocation.py +286 -0
- django_bolt/auth/token.py +335 -0
- django_bolt/binding.py +363 -0
- django_bolt/bootstrap.py +77 -0
- django_bolt/cli.py +133 -0
- django_bolt/compression.py +104 -0
- django_bolt/decorators.py +159 -0
- django_bolt/dependencies.py +128 -0
- django_bolt/error_handlers.py +305 -0
- django_bolt/exceptions.py +294 -0
- django_bolt/health.py +129 -0
- django_bolt/logging/__init__.py +6 -0
- django_bolt/logging/config.py +357 -0
- django_bolt/logging/middleware.py +296 -0
- django_bolt/management/__init__.py +1 -0
- django_bolt/management/commands/__init__.py +0 -0
- django_bolt/management/commands/runbolt.py +427 -0
- django_bolt/middleware/__init__.py +32 -0
- django_bolt/middleware/compiler.py +131 -0
- django_bolt/middleware/middleware.py +247 -0
- django_bolt/openapi/__init__.py +23 -0
- django_bolt/openapi/config.py +196 -0
- django_bolt/openapi/plugins.py +439 -0
- django_bolt/openapi/routes.py +152 -0
- django_bolt/openapi/schema_generator.py +581 -0
- django_bolt/openapi/spec/__init__.py +68 -0
- django_bolt/openapi/spec/base.py +74 -0
- django_bolt/openapi/spec/callback.py +24 -0
- django_bolt/openapi/spec/components.py +72 -0
- django_bolt/openapi/spec/contact.py +21 -0
- django_bolt/openapi/spec/discriminator.py +25 -0
- django_bolt/openapi/spec/encoding.py +67 -0
- django_bolt/openapi/spec/enums.py +41 -0
- django_bolt/openapi/spec/example.py +36 -0
- django_bolt/openapi/spec/external_documentation.py +21 -0
- django_bolt/openapi/spec/header.py +132 -0
- django_bolt/openapi/spec/info.py +50 -0
- django_bolt/openapi/spec/license.py +28 -0
- django_bolt/openapi/spec/link.py +66 -0
- django_bolt/openapi/spec/media_type.py +51 -0
- django_bolt/openapi/spec/oauth_flow.py +36 -0
- django_bolt/openapi/spec/oauth_flows.py +28 -0
- django_bolt/openapi/spec/open_api.py +87 -0
- django_bolt/openapi/spec/operation.py +105 -0
- django_bolt/openapi/spec/parameter.py +147 -0
- django_bolt/openapi/spec/path_item.py +78 -0
- django_bolt/openapi/spec/paths.py +27 -0
- django_bolt/openapi/spec/reference.py +38 -0
- django_bolt/openapi/spec/request_body.py +38 -0
- django_bolt/openapi/spec/response.py +48 -0
- django_bolt/openapi/spec/responses.py +44 -0
- django_bolt/openapi/spec/schema.py +678 -0
- django_bolt/openapi/spec/security_requirement.py +28 -0
- django_bolt/openapi/spec/security_scheme.py +69 -0
- django_bolt/openapi/spec/server.py +34 -0
- django_bolt/openapi/spec/server_variable.py +32 -0
- django_bolt/openapi/spec/tag.py +32 -0
- django_bolt/openapi/spec/xml.py +44 -0
- django_bolt/pagination.py +669 -0
- django_bolt/param_functions.py +49 -0
- django_bolt/params.py +337 -0
- django_bolt/request_parsing.py +128 -0
- django_bolt/responses.py +214 -0
- django_bolt/router.py +48 -0
- django_bolt/serialization.py +193 -0
- django_bolt/status_codes.py +321 -0
- django_bolt/testing/__init__.py +10 -0
- django_bolt/testing/client.py +274 -0
- django_bolt/testing/helpers.py +93 -0
- django_bolt/tests/__init__.py +0 -0
- django_bolt/tests/admin_tests/__init__.py +1 -0
- django_bolt/tests/admin_tests/conftest.py +6 -0
- django_bolt/tests/admin_tests/test_admin_with_django.py +278 -0
- django_bolt/tests/admin_tests/urls.py +9 -0
- django_bolt/tests/cbv/__init__.py +0 -0
- django_bolt/tests/cbv/test_class_views.py +570 -0
- django_bolt/tests/cbv/test_class_views_django_orm.py +703 -0
- django_bolt/tests/cbv/test_class_views_features.py +1173 -0
- django_bolt/tests/cbv/test_class_views_with_client.py +622 -0
- django_bolt/tests/conftest.py +165 -0
- django_bolt/tests/test_action_decorator.py +399 -0
- django_bolt/tests/test_auth_secret_key.py +83 -0
- django_bolt/tests/test_decorator_syntax.py +159 -0
- django_bolt/tests/test_error_handling.py +481 -0
- django_bolt/tests/test_file_response.py +192 -0
- django_bolt/tests/test_global_cors.py +172 -0
- django_bolt/tests/test_guards_auth.py +441 -0
- django_bolt/tests/test_guards_integration.py +303 -0
- django_bolt/tests/test_health.py +283 -0
- django_bolt/tests/test_integration_validation.py +400 -0
- django_bolt/tests/test_json_validation.py +536 -0
- django_bolt/tests/test_jwt_auth.py +327 -0
- django_bolt/tests/test_jwt_token.py +458 -0
- django_bolt/tests/test_logging.py +837 -0
- django_bolt/tests/test_logging_merge.py +419 -0
- django_bolt/tests/test_middleware.py +492 -0
- django_bolt/tests/test_middleware_server.py +230 -0
- django_bolt/tests/test_model_viewset.py +323 -0
- django_bolt/tests/test_models.py +24 -0
- django_bolt/tests/test_pagination.py +1258 -0
- django_bolt/tests/test_parameter_validation.py +178 -0
- django_bolt/tests/test_syntax.py +626 -0
- django_bolt/tests/test_testing_utilities.py +163 -0
- django_bolt/tests/test_testing_utilities_simple.py +123 -0
- django_bolt/tests/test_viewset_unified.py +346 -0
- django_bolt/typing.py +273 -0
- django_bolt/views.py +1110 -0
- django_bolt-0.1.0.dist-info/METADATA +629 -0
- django_bolt-0.1.0.dist-info/RECORD +128 -0
- django_bolt-0.1.0.dist-info/WHEEL +4 -0
- django_bolt-0.1.0.dist-info/entry_points.txt +2 -0
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Decorators for Django-Bolt.
|
|
3
|
+
|
|
4
|
+
Provides decorators for ViewSet custom actions similar to Django REST Framework's @action decorator.
|
|
5
|
+
"""
|
|
6
|
+
from typing import Any, Callable, List, Optional
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class ActionHandler:
|
|
10
|
+
"""
|
|
11
|
+
Marker class for ViewSet custom actions decorated with @action.
|
|
12
|
+
|
|
13
|
+
When @action is used inside a ViewSet class, it returns an ActionHandler instance
|
|
14
|
+
that stores metadata about the action. The api.viewset() method discovers these
|
|
15
|
+
ActionHandler instances and auto-generates routes.
|
|
16
|
+
|
|
17
|
+
Similar to Django REST Framework's @action decorator approach.
|
|
18
|
+
|
|
19
|
+
Attributes:
|
|
20
|
+
fn: The wrapped function
|
|
21
|
+
methods: List of HTTP methods (e.g., ["GET", "POST"])
|
|
22
|
+
detail: Whether this is a detail (instance-level) or list (collection-level) action
|
|
23
|
+
path: Custom path segment (defaults to function name)
|
|
24
|
+
auth: Optional authentication backends
|
|
25
|
+
guards: Optional permission guards
|
|
26
|
+
response_model: Optional response model for serialization
|
|
27
|
+
status_code: Optional HTTP status code
|
|
28
|
+
"""
|
|
29
|
+
|
|
30
|
+
__slots__ = ('fn', 'methods', 'detail', 'path', 'auth', 'guards', 'response_model', 'status_code')
|
|
31
|
+
|
|
32
|
+
def __init__(
|
|
33
|
+
self,
|
|
34
|
+
fn: Callable,
|
|
35
|
+
methods: List[str],
|
|
36
|
+
detail: bool,
|
|
37
|
+
path: Optional[str] = None,
|
|
38
|
+
auth: Optional[List[Any]] = None,
|
|
39
|
+
guards: Optional[List[Any]] = None,
|
|
40
|
+
response_model: Optional[Any] = None,
|
|
41
|
+
status_code: Optional[int] = None,
|
|
42
|
+
):
|
|
43
|
+
self.fn = fn
|
|
44
|
+
self.methods = [m.upper() for m in methods] # Normalize to uppercase
|
|
45
|
+
self.detail = detail
|
|
46
|
+
self.path = path or fn.__name__ # Default to function name
|
|
47
|
+
self.auth = auth
|
|
48
|
+
self.guards = guards
|
|
49
|
+
self.response_model = response_model
|
|
50
|
+
self.status_code = status_code
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def __call__(self, *args, **kwargs):
|
|
54
|
+
"""Make the handler callable (delegates to wrapped function)."""
|
|
55
|
+
return self.fn(*args, **kwargs)
|
|
56
|
+
|
|
57
|
+
def __repr__(self):
|
|
58
|
+
methods_str = '|'.join(self.methods)
|
|
59
|
+
detail_str = 'detail' if self.detail else 'list'
|
|
60
|
+
return f"ActionHandler({methods_str}, {detail_str}, path={self.path}, fn={self.fn.__name__})"
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def action(
|
|
64
|
+
methods: List[str],
|
|
65
|
+
detail: bool,
|
|
66
|
+
path: Optional[str] = None,
|
|
67
|
+
*,
|
|
68
|
+
auth: Optional[List[Any]] = None,
|
|
69
|
+
guards: Optional[List[Any]] = None,
|
|
70
|
+
response_model: Optional[Any] = None,
|
|
71
|
+
status_code: Optional[int] = None
|
|
72
|
+
) -> Callable:
|
|
73
|
+
"""
|
|
74
|
+
Decorator for ViewSet custom actions (DRF-style).
|
|
75
|
+
|
|
76
|
+
Marks a ViewSet method as a custom action with automatic route generation.
|
|
77
|
+
|
|
78
|
+
Auto-generated paths:
|
|
79
|
+
- detail=True (instance-level): /{resource}/{pk}/{action_name}
|
|
80
|
+
Example: @action(methods=["POST"], detail=True) -> POST /users/{id}/activate
|
|
81
|
+
|
|
82
|
+
- detail=False (collection-level): /{resource}/{action_name}
|
|
83
|
+
Example: @action(methods=["GET"], detail=False) -> GET /users/active
|
|
84
|
+
|
|
85
|
+
Multiple methods on single action:
|
|
86
|
+
- @action(methods=["GET", "POST"], detail=True, path="preferences")
|
|
87
|
+
Generates both: GET /users/{id}/preferences and POST /users/{id}/preferences
|
|
88
|
+
|
|
89
|
+
Args:
|
|
90
|
+
methods: List of HTTP methods (e.g., ["GET"], ["POST"], ["GET", "POST"])
|
|
91
|
+
detail: True for instance-level (requires pk), False for collection-level
|
|
92
|
+
path: Optional custom action name (defaults to function name)
|
|
93
|
+
auth: Optional authentication backends (overrides class-level auth)
|
|
94
|
+
guards: Optional permission guards (overrides class-level guards)
|
|
95
|
+
response_model: Optional response model for serialization
|
|
96
|
+
status_code: Optional HTTP status code
|
|
97
|
+
|
|
98
|
+
Returns:
|
|
99
|
+
ActionHandler instance that wraps the function with metadata
|
|
100
|
+
|
|
101
|
+
Example:
|
|
102
|
+
@api.viewset("/users")
|
|
103
|
+
class UserViewSet(ViewSet):
|
|
104
|
+
async def list(self, request) -> list[UserMini]:
|
|
105
|
+
return User.objects.all()[:100]
|
|
106
|
+
|
|
107
|
+
# Instance-level action: POST /users/{id}/activate
|
|
108
|
+
@action(methods=["POST"], detail=True)
|
|
109
|
+
async def activate(self, request, id: int) -> UserFull:
|
|
110
|
+
user = await User.objects.aget(id=id)
|
|
111
|
+
user.is_active = True
|
|
112
|
+
await user.asave()
|
|
113
|
+
return user
|
|
114
|
+
|
|
115
|
+
# Collection-level action: GET /users/active
|
|
116
|
+
@action(methods=["GET"], detail=False)
|
|
117
|
+
async def active(self, request) -> list[UserMini]:
|
|
118
|
+
return User.objects.filter(is_active=True)[:100]
|
|
119
|
+
|
|
120
|
+
# Custom path: GET/POST /users/{id}/preferences
|
|
121
|
+
@action(methods=["GET", "POST"], detail=True, path="preferences")
|
|
122
|
+
async def user_preferences(self, request, id: int, data: dict | None = None):
|
|
123
|
+
if data: # POST
|
|
124
|
+
# update preferences
|
|
125
|
+
pass
|
|
126
|
+
else: # GET
|
|
127
|
+
# return preferences
|
|
128
|
+
pass
|
|
129
|
+
|
|
130
|
+
Notes:
|
|
131
|
+
- Actions inherit class-level auth and guards unless explicitly overridden
|
|
132
|
+
- The function must be async
|
|
133
|
+
- Path parameters are automatically extracted from the route
|
|
134
|
+
- For detail=True actions, the lookup field parameter (e.g., 'id', 'pk') is required
|
|
135
|
+
"""
|
|
136
|
+
def decorator(fn: Callable) -> ActionHandler:
|
|
137
|
+
"""Wrap the function with ActionHandler metadata."""
|
|
138
|
+
# Validate methods
|
|
139
|
+
valid_methods = {'GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'HEAD', 'OPTIONS'}
|
|
140
|
+
for method in methods:
|
|
141
|
+
if method.upper() not in valid_methods:
|
|
142
|
+
raise ValueError(
|
|
143
|
+
f"Invalid HTTP method '{method}'. "
|
|
144
|
+
f"Valid methods: {', '.join(sorted(valid_methods))}"
|
|
145
|
+
)
|
|
146
|
+
|
|
147
|
+
# Create and return ActionHandler
|
|
148
|
+
return ActionHandler(
|
|
149
|
+
fn=fn,
|
|
150
|
+
methods=methods,
|
|
151
|
+
detail=detail,
|
|
152
|
+
path=path,
|
|
153
|
+
auth=auth,
|
|
154
|
+
guards=guards,
|
|
155
|
+
response_model=response_model,
|
|
156
|
+
status_code=status_code
|
|
157
|
+
)
|
|
158
|
+
|
|
159
|
+
return decorator
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
"""Dependency injection utilities."""
|
|
2
|
+
import inspect
|
|
3
|
+
from typing import Any, Callable, Dict, List
|
|
4
|
+
from .params import Depends as DependsMarker
|
|
5
|
+
from .binding import convert_primitive
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
async def resolve_dependency(
|
|
9
|
+
dep_fn: Callable,
|
|
10
|
+
depends_marker: DependsMarker,
|
|
11
|
+
request: Dict[str, Any],
|
|
12
|
+
dep_cache: Dict[Any, Any],
|
|
13
|
+
params_map: Dict[str, Any],
|
|
14
|
+
query_map: Dict[str, Any],
|
|
15
|
+
headers_map: Dict[str, str],
|
|
16
|
+
cookies_map: Dict[str, str],
|
|
17
|
+
handler_meta: Dict[Callable, Dict[str, Any]],
|
|
18
|
+
compile_binder: Callable,
|
|
19
|
+
http_method: str,
|
|
20
|
+
path: str
|
|
21
|
+
) -> Any:
|
|
22
|
+
"""
|
|
23
|
+
Resolve a dependency injection.
|
|
24
|
+
|
|
25
|
+
Args:
|
|
26
|
+
dep_fn: Dependency function to resolve
|
|
27
|
+
depends_marker: Depends marker with cache settings
|
|
28
|
+
request: Request dict
|
|
29
|
+
dep_cache: Cache for resolved dependencies
|
|
30
|
+
params_map: Path parameters
|
|
31
|
+
query_map: Query parameters
|
|
32
|
+
headers_map: Request headers
|
|
33
|
+
cookies_map: Request cookies
|
|
34
|
+
handler_meta: Metadata cache for handlers
|
|
35
|
+
compile_binder: Function to compile parameter binding metadata
|
|
36
|
+
http_method: HTTP method of the handler using this dependency
|
|
37
|
+
path: Path of the handler using this dependency
|
|
38
|
+
|
|
39
|
+
Returns:
|
|
40
|
+
Resolved dependency value
|
|
41
|
+
"""
|
|
42
|
+
if depends_marker.use_cache and dep_fn in dep_cache:
|
|
43
|
+
return dep_cache[dep_fn]
|
|
44
|
+
|
|
45
|
+
dep_meta = handler_meta.get(dep_fn)
|
|
46
|
+
if dep_meta is None:
|
|
47
|
+
# Compile dependency metadata with the actual HTTP method and path
|
|
48
|
+
# Dependencies MUST be validated against HTTP method constraints
|
|
49
|
+
# e.g., a dependency with Body() can't be used in GET handlers
|
|
50
|
+
dep_meta = compile_binder(dep_fn, http_method, path)
|
|
51
|
+
handler_meta[dep_fn] = dep_meta
|
|
52
|
+
|
|
53
|
+
if dep_meta.get("mode") == "request_only":
|
|
54
|
+
value = await dep_fn(request)
|
|
55
|
+
else:
|
|
56
|
+
value = await call_dependency(
|
|
57
|
+
dep_fn, dep_meta, request, params_map,
|
|
58
|
+
query_map, headers_map, cookies_map
|
|
59
|
+
)
|
|
60
|
+
|
|
61
|
+
if depends_marker.use_cache:
|
|
62
|
+
dep_cache[dep_fn] = value
|
|
63
|
+
|
|
64
|
+
return value
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
async def call_dependency(
|
|
68
|
+
dep_fn: Callable,
|
|
69
|
+
dep_meta: Dict[str, Any],
|
|
70
|
+
request: Dict[str, Any],
|
|
71
|
+
params_map: Dict[str, Any],
|
|
72
|
+
query_map: Dict[str, Any],
|
|
73
|
+
headers_map: Dict[str, str],
|
|
74
|
+
cookies_map: Dict[str, str]
|
|
75
|
+
) -> Any:
|
|
76
|
+
"""Call a dependency function with resolved parameters."""
|
|
77
|
+
dep_args: List[Any] = []
|
|
78
|
+
dep_kwargs: Dict[str, Any] = {}
|
|
79
|
+
|
|
80
|
+
for dp in dep_meta["params"]:
|
|
81
|
+
dname = dp["name"]
|
|
82
|
+
dan = dp["annotation"]
|
|
83
|
+
dsrc = dp["source"]
|
|
84
|
+
dalias = dp.get("alias")
|
|
85
|
+
|
|
86
|
+
if dsrc == "request":
|
|
87
|
+
dval = request
|
|
88
|
+
else:
|
|
89
|
+
dval = extract_dependency_value(dp, params_map, query_map, headers_map, cookies_map)
|
|
90
|
+
|
|
91
|
+
if dp["kind"] in (inspect.Parameter.POSITIONAL_ONLY, inspect.Parameter.POSITIONAL_OR_KEYWORD):
|
|
92
|
+
dep_args.append(dval)
|
|
93
|
+
else:
|
|
94
|
+
dep_kwargs[dname] = dval
|
|
95
|
+
|
|
96
|
+
return await dep_fn(*dep_args, **dep_kwargs)
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def extract_dependency_value(
|
|
100
|
+
param: Dict[str, Any],
|
|
101
|
+
params_map: Dict[str, Any],
|
|
102
|
+
query_map: Dict[str, Any],
|
|
103
|
+
headers_map: Dict[str, str],
|
|
104
|
+
cookies_map: Dict[str, str]
|
|
105
|
+
) -> Any:
|
|
106
|
+
"""Extract value for a dependency parameter."""
|
|
107
|
+
dname = param["name"]
|
|
108
|
+
dan = param["annotation"]
|
|
109
|
+
dsrc = param["source"]
|
|
110
|
+
dalias = param.get("alias")
|
|
111
|
+
key = dalias or dname
|
|
112
|
+
|
|
113
|
+
if key in params_map:
|
|
114
|
+
return convert_primitive(str(params_map[key]), dan)
|
|
115
|
+
elif key in query_map:
|
|
116
|
+
return convert_primitive(str(query_map[key]), dan)
|
|
117
|
+
elif dsrc == "header":
|
|
118
|
+
raw = headers_map.get(key.lower())
|
|
119
|
+
if raw is None:
|
|
120
|
+
raise ValueError(f"Missing required header: {key}")
|
|
121
|
+
return convert_primitive(str(raw), dan)
|
|
122
|
+
elif dsrc == "cookie":
|
|
123
|
+
raw = cookies_map.get(key)
|
|
124
|
+
if raw is None:
|
|
125
|
+
raise ValueError(f"Missing required cookie: {key}")
|
|
126
|
+
return convert_primitive(str(raw), dan)
|
|
127
|
+
else:
|
|
128
|
+
return None
|
|
@@ -0,0 +1,305 @@
|
|
|
1
|
+
"""Error handlers for Django-Bolt.
|
|
2
|
+
|
|
3
|
+
Provides default exception handlers that convert Python exceptions into
|
|
4
|
+
structured HTTP error responses.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import msgspec
|
|
8
|
+
import traceback
|
|
9
|
+
from typing import Any, Dict, List, Tuple, Optional
|
|
10
|
+
from .exceptions import (
|
|
11
|
+
HTTPException,
|
|
12
|
+
RequestValidationError,
|
|
13
|
+
ResponseValidationError,
|
|
14
|
+
ValidationException,
|
|
15
|
+
InternalServerError,
|
|
16
|
+
)
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def format_error_response(
|
|
20
|
+
status_code: int,
|
|
21
|
+
detail: Any,
|
|
22
|
+
headers: Optional[Dict[str, str]] = None,
|
|
23
|
+
extra: Optional[Dict[str, Any] | List[Any]] = None,
|
|
24
|
+
) -> Tuple[int, List[Tuple[str, str]], bytes]:
|
|
25
|
+
"""Format an error response.
|
|
26
|
+
|
|
27
|
+
Args:
|
|
28
|
+
status_code: HTTP status code
|
|
29
|
+
detail: Error detail (string or structured data)
|
|
30
|
+
headers: Optional HTTP headers
|
|
31
|
+
extra: Optional extra data to include in response
|
|
32
|
+
|
|
33
|
+
Returns:
|
|
34
|
+
Tuple of (status_code, headers, body)
|
|
35
|
+
"""
|
|
36
|
+
error_body: Dict[str, Any] = {"detail": detail}
|
|
37
|
+
|
|
38
|
+
if extra is not None:
|
|
39
|
+
error_body["extra"] = extra
|
|
40
|
+
|
|
41
|
+
body_bytes = msgspec.json.encode(error_body)
|
|
42
|
+
|
|
43
|
+
response_headers = [("content-type", "application/json")]
|
|
44
|
+
if headers:
|
|
45
|
+
response_headers.extend(headers.items())
|
|
46
|
+
|
|
47
|
+
return status_code, response_headers, body_bytes
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def http_exception_handler(exc: HTTPException) -> Tuple[int, List[Tuple[str, str]], bytes]:
|
|
51
|
+
"""Handle HTTPException and convert to error response.
|
|
52
|
+
|
|
53
|
+
Args:
|
|
54
|
+
exc: HTTPException instance
|
|
55
|
+
|
|
56
|
+
Returns:
|
|
57
|
+
Tuple of (status_code, headers, body)
|
|
58
|
+
"""
|
|
59
|
+
return format_error_response(
|
|
60
|
+
status_code=exc.status_code,
|
|
61
|
+
detail=exc.detail,
|
|
62
|
+
headers=exc.headers,
|
|
63
|
+
extra=exc.extra,
|
|
64
|
+
)
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def msgspec_validation_error_to_dict(error: msgspec.ValidationError) -> List[Dict[str, Any]]:
|
|
68
|
+
"""Convert msgspec ValidationError to structured error list.
|
|
69
|
+
|
|
70
|
+
Args:
|
|
71
|
+
error: msgspec.ValidationError instance
|
|
72
|
+
|
|
73
|
+
Returns:
|
|
74
|
+
List of error dictionaries with 'loc', 'msg', 'type' fields
|
|
75
|
+
"""
|
|
76
|
+
# msgspec.ValidationError doesn't provide structured error info like pydantic
|
|
77
|
+
# We'll do our best to parse the error message
|
|
78
|
+
error_msg = str(error)
|
|
79
|
+
|
|
80
|
+
# Try to extract field path from error message
|
|
81
|
+
# Example: "Expected `int`, got `str` - at `$[0].age`"
|
|
82
|
+
# Example: "Object missing required field `name`"
|
|
83
|
+
|
|
84
|
+
errors = []
|
|
85
|
+
|
|
86
|
+
# Check if error message contains field location
|
|
87
|
+
if " - at `" in error_msg:
|
|
88
|
+
msg_part, loc_part = error_msg.split(" - at `", 1)
|
|
89
|
+
loc_path = loc_part.rstrip("`")
|
|
90
|
+
# Parse location like $[0].age into ["body", 0, "age"]
|
|
91
|
+
loc_parts = ["body"]
|
|
92
|
+
# Simple parsing - can be improved
|
|
93
|
+
loc_parts.append(loc_path.replace("$", "").replace("[", ".").replace("]", "").strip("."))
|
|
94
|
+
elif "missing required field" in error_msg.lower():
|
|
95
|
+
# Extract field name from message
|
|
96
|
+
import re
|
|
97
|
+
match = re.search(r"`(\w+)`", error_msg)
|
|
98
|
+
field = match.group(1) if match else "unknown"
|
|
99
|
+
errors.append({
|
|
100
|
+
"loc": ["body", field],
|
|
101
|
+
"msg": error_msg,
|
|
102
|
+
"type": "missing_field",
|
|
103
|
+
})
|
|
104
|
+
return errors
|
|
105
|
+
else:
|
|
106
|
+
# Generic error without location
|
|
107
|
+
errors.append({
|
|
108
|
+
"loc": ["body"],
|
|
109
|
+
"msg": error_msg,
|
|
110
|
+
"type": "validation_error",
|
|
111
|
+
})
|
|
112
|
+
return errors
|
|
113
|
+
|
|
114
|
+
errors.append({
|
|
115
|
+
"loc": loc_parts if isinstance(loc_parts, list) else ["body"],
|
|
116
|
+
"msg": error_msg.split(" - at `")[0] if " - at `" in error_msg else error_msg,
|
|
117
|
+
"type": "validation_error",
|
|
118
|
+
})
|
|
119
|
+
|
|
120
|
+
return errors
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def request_validation_error_handler(
|
|
124
|
+
exc: RequestValidationError,
|
|
125
|
+
) -> Tuple[int, List[Tuple[str, str]], bytes]:
|
|
126
|
+
"""Handle RequestValidationError and convert to 422 response.
|
|
127
|
+
|
|
128
|
+
Args:
|
|
129
|
+
exc: RequestValidationError instance
|
|
130
|
+
|
|
131
|
+
Returns:
|
|
132
|
+
Tuple of (status_code, headers, body)
|
|
133
|
+
"""
|
|
134
|
+
errors = exc.errors()
|
|
135
|
+
|
|
136
|
+
# Convert errors to structured format if needed
|
|
137
|
+
formatted_errors = []
|
|
138
|
+
for error in errors:
|
|
139
|
+
if isinstance(error, dict):
|
|
140
|
+
formatted_errors.append(error)
|
|
141
|
+
elif isinstance(error, msgspec.ValidationError):
|
|
142
|
+
formatted_errors.extend(msgspec_validation_error_to_dict(error))
|
|
143
|
+
else:
|
|
144
|
+
# Generic error
|
|
145
|
+
formatted_errors.append({
|
|
146
|
+
"loc": ["body"],
|
|
147
|
+
"msg": str(error),
|
|
148
|
+
"type": "validation_error",
|
|
149
|
+
})
|
|
150
|
+
|
|
151
|
+
return format_error_response(
|
|
152
|
+
status_code=422,
|
|
153
|
+
detail=formatted_errors,
|
|
154
|
+
)
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def response_validation_error_handler(
|
|
158
|
+
exc: ResponseValidationError,
|
|
159
|
+
) -> Tuple[int, List[Tuple[str, str]], bytes]:
|
|
160
|
+
"""Handle ResponseValidationError and convert to 500 response.
|
|
161
|
+
|
|
162
|
+
Args:
|
|
163
|
+
exc: ResponseValidationError instance
|
|
164
|
+
|
|
165
|
+
Returns:
|
|
166
|
+
Tuple of (status_code, headers, body)
|
|
167
|
+
"""
|
|
168
|
+
# Log the error (if logging is configured)
|
|
169
|
+
errors = exc.errors()
|
|
170
|
+
|
|
171
|
+
formatted_errors = []
|
|
172
|
+
for error in errors:
|
|
173
|
+
if isinstance(error, dict):
|
|
174
|
+
formatted_errors.append(error)
|
|
175
|
+
elif isinstance(error, msgspec.ValidationError):
|
|
176
|
+
formatted_errors.extend(msgspec_validation_error_to_dict(error))
|
|
177
|
+
else:
|
|
178
|
+
formatted_errors.append({
|
|
179
|
+
"loc": ["response"],
|
|
180
|
+
"msg": str(error),
|
|
181
|
+
"type": "validation_error",
|
|
182
|
+
})
|
|
183
|
+
|
|
184
|
+
return format_error_response(
|
|
185
|
+
status_code=500,
|
|
186
|
+
detail="Response validation error",
|
|
187
|
+
extra={"validation_errors": formatted_errors},
|
|
188
|
+
)
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
def generic_exception_handler(
|
|
192
|
+
exc: Exception,
|
|
193
|
+
debug: bool = False,
|
|
194
|
+
request: Optional[Any] = None, # noqa: ARG001 - kept for API compatibility
|
|
195
|
+
) -> Tuple[int, List[Tuple[str, str]], bytes]:
|
|
196
|
+
"""Handle generic exceptions and convert to 500 response.
|
|
197
|
+
|
|
198
|
+
Args:
|
|
199
|
+
exc: Exception instance
|
|
200
|
+
debug: Whether to include traceback in response
|
|
201
|
+
request: Optional request dict or Django request object for ExceptionReporter
|
|
202
|
+
|
|
203
|
+
Returns:
|
|
204
|
+
Tuple of (status_code, headers, body)
|
|
205
|
+
"""
|
|
206
|
+
detail = "Internal Server Error"
|
|
207
|
+
extra = None
|
|
208
|
+
|
|
209
|
+
if debug:
|
|
210
|
+
# Try to use Django's ExceptionReporter HTML page
|
|
211
|
+
try:
|
|
212
|
+
from django.views.debug import ExceptionReporter
|
|
213
|
+
|
|
214
|
+
# ExceptionReporter works fine with None request (avoids URL resolution issues)
|
|
215
|
+
reporter = ExceptionReporter(None, type(exc), exc, exc.__traceback__)
|
|
216
|
+
html_content = reporter.get_traceback_html()
|
|
217
|
+
|
|
218
|
+
# Return HTML response instead of JSON
|
|
219
|
+
return (
|
|
220
|
+
500,
|
|
221
|
+
[("content-type", "text/html; charset=utf-8")],
|
|
222
|
+
html_content.encode("utf-8")
|
|
223
|
+
)
|
|
224
|
+
except Exception:
|
|
225
|
+
# Fallback to standard traceback formatting in JSON
|
|
226
|
+
pass
|
|
227
|
+
|
|
228
|
+
# Fallback to JSON with traceback
|
|
229
|
+
tb_lines = traceback.format_exception(type(exc), exc, exc.__traceback__)
|
|
230
|
+
# Split into individual lines for better JSON display, stripping trailing newlines
|
|
231
|
+
tb_formatted = [line.rstrip('\n') for line in ''.join(tb_lines).split('\n') if line.strip()]
|
|
232
|
+
extra = {
|
|
233
|
+
"exception": str(exc),
|
|
234
|
+
"exception_type": type(exc).__name__,
|
|
235
|
+
"traceback": tb_formatted,
|
|
236
|
+
}
|
|
237
|
+
detail = f"{type(exc).__name__}: {str(exc)}"
|
|
238
|
+
|
|
239
|
+
return format_error_response(
|
|
240
|
+
status_code=500,
|
|
241
|
+
detail=detail,
|
|
242
|
+
extra=extra,
|
|
243
|
+
)
|
|
244
|
+
|
|
245
|
+
|
|
246
|
+
def handle_exception(
|
|
247
|
+
exc: Exception,
|
|
248
|
+
debug: Optional[bool] = None,
|
|
249
|
+
request: Optional[Any] = None,
|
|
250
|
+
) -> Tuple[int, List[Tuple[str, str]], bytes]:
|
|
251
|
+
"""Main exception handler that routes to specific handlers.
|
|
252
|
+
|
|
253
|
+
Args:
|
|
254
|
+
exc: Exception instance
|
|
255
|
+
debug: Whether to include debug information. If None, will check Django DEBUG setting.
|
|
256
|
+
If explicitly False, will not show debug info even if Django DEBUG=True.
|
|
257
|
+
request: Optional request object for Django ExceptionReporter
|
|
258
|
+
|
|
259
|
+
Returns:
|
|
260
|
+
Tuple of (status_code, headers, body)
|
|
261
|
+
"""
|
|
262
|
+
# Check Django's DEBUG setting dynamically only if debug is not explicitly set
|
|
263
|
+
if debug is None:
|
|
264
|
+
try:
|
|
265
|
+
from django.conf import settings
|
|
266
|
+
if settings.configured:
|
|
267
|
+
debug = settings.DEBUG
|
|
268
|
+
else:
|
|
269
|
+
debug = False
|
|
270
|
+
except (ImportError, AttributeError):
|
|
271
|
+
debug = False
|
|
272
|
+
|
|
273
|
+
if isinstance(exc, HTTPException):
|
|
274
|
+
return http_exception_handler(exc)
|
|
275
|
+
elif isinstance(exc, RequestValidationError):
|
|
276
|
+
return request_validation_error_handler(exc)
|
|
277
|
+
elif isinstance(exc, ResponseValidationError):
|
|
278
|
+
return response_validation_error_handler(exc)
|
|
279
|
+
elif isinstance(exc, ValidationException):
|
|
280
|
+
# Generic validation exception
|
|
281
|
+
return request_validation_error_handler(
|
|
282
|
+
RequestValidationError(exc.errors())
|
|
283
|
+
)
|
|
284
|
+
elif isinstance(exc, msgspec.ValidationError):
|
|
285
|
+
# Direct msgspec validation error
|
|
286
|
+
errors = msgspec_validation_error_to_dict(exc)
|
|
287
|
+
return format_error_response(
|
|
288
|
+
status_code=422,
|
|
289
|
+
detail=errors,
|
|
290
|
+
)
|
|
291
|
+
elif isinstance(exc, FileNotFoundError):
|
|
292
|
+
# FileNotFoundError from FileResponse - return 404
|
|
293
|
+
return format_error_response(
|
|
294
|
+
status_code=404,
|
|
295
|
+
detail=str(exc) or "File not found",
|
|
296
|
+
)
|
|
297
|
+
elif isinstance(exc, PermissionError):
|
|
298
|
+
# PermissionError from FileResponse path validation - return 403
|
|
299
|
+
return format_error_response(
|
|
300
|
+
status_code=403,
|
|
301
|
+
detail=str(exc) or "Permission denied",
|
|
302
|
+
)
|
|
303
|
+
else:
|
|
304
|
+
# Generic exception
|
|
305
|
+
return generic_exception_handler(exc, debug=debug, request=request)
|