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
django_bolt/api.py
ADDED
|
@@ -0,0 +1,1011 @@
|
|
|
1
|
+
import inspect
|
|
2
|
+
import msgspec
|
|
3
|
+
import re
|
|
4
|
+
import time
|
|
5
|
+
from typing import Any, Callable, Dict, List, Tuple, Optional, get_origin, get_args, Annotated, get_type_hints
|
|
6
|
+
|
|
7
|
+
from .bootstrap import ensure_django_ready
|
|
8
|
+
from django_bolt import _core
|
|
9
|
+
from .responses import StreamingResponse
|
|
10
|
+
from .exceptions import HTTPException
|
|
11
|
+
from .params import Param, Depends as DependsMarker
|
|
12
|
+
from .typing import FieldDefinition
|
|
13
|
+
|
|
14
|
+
# Import modularized components
|
|
15
|
+
from .binding import (
|
|
16
|
+
coerce_to_response_type,
|
|
17
|
+
coerce_to_response_type_async,
|
|
18
|
+
convert_primitive,
|
|
19
|
+
create_extractor,
|
|
20
|
+
)
|
|
21
|
+
from .typing import is_msgspec_struct, is_optional
|
|
22
|
+
from .request_parsing import parse_form_data
|
|
23
|
+
from .dependencies import resolve_dependency
|
|
24
|
+
from .serialization import serialize_response
|
|
25
|
+
from .middleware.compiler import compile_middleware_meta
|
|
26
|
+
|
|
27
|
+
Request = Dict[str, Any]
|
|
28
|
+
Response = Tuple[int, List[Tuple[str, str]], bytes]
|
|
29
|
+
|
|
30
|
+
# Global registry for BoltAPI instances (used by autodiscovery)
|
|
31
|
+
_BOLT_API_REGISTRY = []
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _extract_path_params(path: str) -> set[str]:
|
|
35
|
+
"""
|
|
36
|
+
Extract path parameter names from a route pattern.
|
|
37
|
+
|
|
38
|
+
Examples:
|
|
39
|
+
"/users/{user_id}" -> {"user_id"}
|
|
40
|
+
"/posts/{post_id}/comments/{comment_id}" -> {"post_id", "comment_id"}
|
|
41
|
+
"""
|
|
42
|
+
return set(re.findall(r'\{(\w+)\}', path))
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def extract_parameter_value(
|
|
46
|
+
param: Dict[str, Any],
|
|
47
|
+
request: Dict[str, Any],
|
|
48
|
+
params_map: Dict[str, Any],
|
|
49
|
+
query_map: Dict[str, Any],
|
|
50
|
+
headers_map: Dict[str, str],
|
|
51
|
+
cookies_map: Dict[str, str],
|
|
52
|
+
form_map: Dict[str, Any],
|
|
53
|
+
files_map: Dict[str, Any],
|
|
54
|
+
meta: Dict[str, Any],
|
|
55
|
+
body_obj: Any,
|
|
56
|
+
body_loaded: bool
|
|
57
|
+
) -> Tuple[Any, Any, bool]:
|
|
58
|
+
"""
|
|
59
|
+
Extract value for a handler parameter (backward compatibility function).
|
|
60
|
+
|
|
61
|
+
This function maintains backward compatibility while using the new
|
|
62
|
+
extractor-based system internally.
|
|
63
|
+
"""
|
|
64
|
+
name = param["name"]
|
|
65
|
+
annotation = param["annotation"]
|
|
66
|
+
default = param["default"]
|
|
67
|
+
source = param["source"]
|
|
68
|
+
alias = param.get("alias")
|
|
69
|
+
key = alias or name
|
|
70
|
+
|
|
71
|
+
# Handle different sources
|
|
72
|
+
if source == "path":
|
|
73
|
+
if key in params_map:
|
|
74
|
+
return convert_primitive(str(params_map[key]), annotation), body_obj, body_loaded
|
|
75
|
+
raise ValueError(f"Missing required path parameter: {key}")
|
|
76
|
+
|
|
77
|
+
elif source == "query":
|
|
78
|
+
if key in query_map:
|
|
79
|
+
return convert_primitive(str(query_map[key]), annotation), body_obj, body_loaded
|
|
80
|
+
elif default is not inspect.Parameter.empty or is_optional(annotation):
|
|
81
|
+
return (None if default is inspect.Parameter.empty else default), body_obj, body_loaded
|
|
82
|
+
raise ValueError(f"Missing required query parameter: {key}")
|
|
83
|
+
|
|
84
|
+
elif source == "header":
|
|
85
|
+
lower_key = key.lower()
|
|
86
|
+
if lower_key in headers_map:
|
|
87
|
+
return convert_primitive(str(headers_map[lower_key]), annotation), body_obj, body_loaded
|
|
88
|
+
elif default is not inspect.Parameter.empty or is_optional(annotation):
|
|
89
|
+
return (None if default is inspect.Parameter.empty else default), body_obj, body_loaded
|
|
90
|
+
raise ValueError(f"Missing required header: {key}")
|
|
91
|
+
|
|
92
|
+
elif source == "cookie":
|
|
93
|
+
if key in cookies_map:
|
|
94
|
+
return convert_primitive(str(cookies_map[key]), annotation), body_obj, body_loaded
|
|
95
|
+
elif default is not inspect.Parameter.empty or is_optional(annotation):
|
|
96
|
+
return (None if default is inspect.Parameter.empty else default), body_obj, body_loaded
|
|
97
|
+
raise ValueError(f"Missing required cookie: {key}")
|
|
98
|
+
|
|
99
|
+
elif source == "form":
|
|
100
|
+
if key in form_map:
|
|
101
|
+
return convert_primitive(str(form_map[key]), annotation), body_obj, body_loaded
|
|
102
|
+
elif default is not inspect.Parameter.empty or is_optional(annotation):
|
|
103
|
+
return (None if default is inspect.Parameter.empty else default), body_obj, body_loaded
|
|
104
|
+
raise ValueError(f"Missing required form field: {key}")
|
|
105
|
+
|
|
106
|
+
elif source == "file":
|
|
107
|
+
if key in files_map:
|
|
108
|
+
return files_map[key], body_obj, body_loaded
|
|
109
|
+
elif default is not inspect.Parameter.empty or is_optional(annotation):
|
|
110
|
+
return (None if default is inspect.Parameter.empty else default), body_obj, body_loaded
|
|
111
|
+
raise ValueError(f"Missing required file: {key}")
|
|
112
|
+
|
|
113
|
+
elif source == "body":
|
|
114
|
+
# Handle body parameter
|
|
115
|
+
if meta.get("body_struct_param") == name:
|
|
116
|
+
if not body_loaded:
|
|
117
|
+
body_bytes: bytes = request["body"]
|
|
118
|
+
if is_msgspec_struct(meta["body_struct_type"]):
|
|
119
|
+
from .binding import get_msgspec_decoder
|
|
120
|
+
from .exceptions import RequestValidationError, parse_msgspec_decode_error
|
|
121
|
+
decoder = get_msgspec_decoder(meta["body_struct_type"])
|
|
122
|
+
try:
|
|
123
|
+
value = decoder.decode(body_bytes)
|
|
124
|
+
except msgspec.ValidationError:
|
|
125
|
+
# Re-raise ValidationError as-is (field validation errors handled by error_handlers.py)
|
|
126
|
+
# IMPORTANT: Must catch ValidationError BEFORE DecodeError since ValidationError subclasses DecodeError
|
|
127
|
+
raise
|
|
128
|
+
except msgspec.DecodeError as e:
|
|
129
|
+
# JSON parsing error (malformed JSON) - return 422 with error details including line/column
|
|
130
|
+
error_detail = parse_msgspec_decode_error(e, body_bytes)
|
|
131
|
+
raise RequestValidationError(
|
|
132
|
+
errors=[error_detail],
|
|
133
|
+
body=body_bytes,
|
|
134
|
+
) from e
|
|
135
|
+
else:
|
|
136
|
+
from .exceptions import RequestValidationError, parse_msgspec_decode_error
|
|
137
|
+
try:
|
|
138
|
+
value = msgspec.json.decode(body_bytes, type=meta["body_struct_type"])
|
|
139
|
+
except msgspec.ValidationError:
|
|
140
|
+
# Re-raise ValidationError as-is (field validation errors handled by error_handlers.py)
|
|
141
|
+
# IMPORTANT: Must catch ValidationError BEFORE DecodeError since ValidationError subclasses DecodeError
|
|
142
|
+
raise
|
|
143
|
+
except msgspec.DecodeError as e:
|
|
144
|
+
# JSON parsing error (malformed JSON) - return 422 with error details including line/column
|
|
145
|
+
error_detail = parse_msgspec_decode_error(e, body_bytes)
|
|
146
|
+
raise RequestValidationError(
|
|
147
|
+
errors=[error_detail],
|
|
148
|
+
body=body_bytes,
|
|
149
|
+
) from e
|
|
150
|
+
return value, value, True
|
|
151
|
+
else:
|
|
152
|
+
return body_obj, body_obj, body_loaded
|
|
153
|
+
else:
|
|
154
|
+
if default is not inspect.Parameter.empty or is_optional(annotation):
|
|
155
|
+
return (None if default is inspect.Parameter.empty else default), body_obj, body_loaded
|
|
156
|
+
raise ValueError(f"Missing required parameter: {name}")
|
|
157
|
+
|
|
158
|
+
else:
|
|
159
|
+
# Unknown source
|
|
160
|
+
if default is not inspect.Parameter.empty or is_optional(annotation):
|
|
161
|
+
return (None if default is inspect.Parameter.empty else default), body_obj, body_loaded
|
|
162
|
+
raise ValueError(f"Missing required parameter: {name}")
|
|
163
|
+
|
|
164
|
+
class BoltAPI:
|
|
165
|
+
def __init__(
|
|
166
|
+
self,
|
|
167
|
+
prefix: str = "",
|
|
168
|
+
middleware: Optional[List[Any]] = None,
|
|
169
|
+
middleware_config: Optional[Dict[str, Any]] = None,
|
|
170
|
+
enable_logging: bool = True,
|
|
171
|
+
logging_config: Optional[Any] = None,
|
|
172
|
+
compression: Optional[Any] = None,
|
|
173
|
+
openapi_config: Optional[Any] = None,
|
|
174
|
+
) -> None:
|
|
175
|
+
self._routes: List[Tuple[str, str, int, Callable]] = []
|
|
176
|
+
self._handlers: Dict[int, Callable] = {}
|
|
177
|
+
self._handler_meta: Dict[Callable, Dict[str, Any]] = {}
|
|
178
|
+
self._handler_middleware: Dict[int, Dict[str, Any]] = {} # Middleware metadata per handler
|
|
179
|
+
self._next_handler_id = 0
|
|
180
|
+
self.prefix = prefix.rstrip("/") # Remove trailing slash
|
|
181
|
+
|
|
182
|
+
# Global middleware configuration
|
|
183
|
+
self.middleware = middleware or []
|
|
184
|
+
self.middleware_config = middleware_config or {}
|
|
185
|
+
|
|
186
|
+
# Logging configuration (opt-in, setup happens at server startup)
|
|
187
|
+
self.enable_logging = enable_logging
|
|
188
|
+
self._logging_middleware = None
|
|
189
|
+
|
|
190
|
+
if self.enable_logging:
|
|
191
|
+
# Create logging middleware (actual logging setup happens at server startup)
|
|
192
|
+
if logging_config is not None:
|
|
193
|
+
from .logging.middleware import LoggingMiddleware
|
|
194
|
+
self._logging_middleware = LoggingMiddleware(logging_config)
|
|
195
|
+
else:
|
|
196
|
+
# Use default logging configuration
|
|
197
|
+
from .logging.middleware import create_logging_middleware
|
|
198
|
+
self._logging_middleware = create_logging_middleware()
|
|
199
|
+
|
|
200
|
+
# Compression configuration
|
|
201
|
+
# compression=None means disabled, not providing compression arg means default enabled
|
|
202
|
+
if compression is False:
|
|
203
|
+
# Explicitly disabled
|
|
204
|
+
self.compression = None
|
|
205
|
+
elif compression is None:
|
|
206
|
+
# Not provided, use default
|
|
207
|
+
from .compression import CompressionConfig
|
|
208
|
+
self.compression = CompressionConfig()
|
|
209
|
+
else:
|
|
210
|
+
# Custom config provided
|
|
211
|
+
self.compression = compression
|
|
212
|
+
|
|
213
|
+
# OpenAPI configuration - enabled by default with sensible defaults
|
|
214
|
+
if openapi_config is None:
|
|
215
|
+
# Create default OpenAPI config
|
|
216
|
+
from .openapi import OpenAPIConfig, SwaggerRenderPlugin, RedocRenderPlugin, ScalarRenderPlugin, RapidocRenderPlugin, StoplightRenderPlugin, JsonRenderPlugin, YamlRenderPlugin
|
|
217
|
+
try:
|
|
218
|
+
# Try to get Django project name from settings
|
|
219
|
+
from django.conf import settings
|
|
220
|
+
title = getattr(settings, 'PROJECT_NAME', None) or getattr(settings, 'SITE_NAME', None) or "API"
|
|
221
|
+
except:
|
|
222
|
+
title = "API"
|
|
223
|
+
|
|
224
|
+
self.openapi_config = OpenAPIConfig(
|
|
225
|
+
title=title,
|
|
226
|
+
version="1.0.0",
|
|
227
|
+
path="/docs",
|
|
228
|
+
render_plugins=[
|
|
229
|
+
SwaggerRenderPlugin(path="/"),
|
|
230
|
+
RedocRenderPlugin(path="/redoc"),
|
|
231
|
+
ScalarRenderPlugin(path="/scalar"),
|
|
232
|
+
RapidocRenderPlugin(path="/rapidoc"),
|
|
233
|
+
StoplightRenderPlugin(path="/stoplight"),
|
|
234
|
+
]
|
|
235
|
+
)
|
|
236
|
+
else:
|
|
237
|
+
self.openapi_config = openapi_config
|
|
238
|
+
|
|
239
|
+
self._openapi_schema: Optional[Dict[str, Any]] = None
|
|
240
|
+
self._openapi_routes_registered = False
|
|
241
|
+
|
|
242
|
+
# Django admin configuration (controlled by --no-admin flag)
|
|
243
|
+
self._admin_routes_registered = False
|
|
244
|
+
self._static_routes_registered = False
|
|
245
|
+
self._asgi_handler = None
|
|
246
|
+
|
|
247
|
+
# Register this instance globally for autodiscovery
|
|
248
|
+
_BOLT_API_REGISTRY.append(self)
|
|
249
|
+
|
|
250
|
+
def get(self, path: str, *, response_model: Optional[Any] = None, status_code: Optional[int] = None, guards: Optional[List[Any]] = None, auth: Optional[List[Any]] = None):
|
|
251
|
+
return self._route_decorator("GET", path, response_model=response_model, status_code=status_code, guards=guards, auth=auth)
|
|
252
|
+
|
|
253
|
+
def post(self, path: str, *, response_model: Optional[Any] = None, status_code: Optional[int] = None, guards: Optional[List[Any]] = None, auth: Optional[List[Any]] = None):
|
|
254
|
+
return self._route_decorator("POST", path, response_model=response_model, status_code=status_code, guards=guards, auth=auth)
|
|
255
|
+
|
|
256
|
+
def put(self, path: str, *, response_model: Optional[Any] = None, status_code: Optional[int] = None, guards: Optional[List[Any]] = None, auth: Optional[List[Any]] = None):
|
|
257
|
+
return self._route_decorator("PUT", path, response_model=response_model, status_code=status_code, guards=guards, auth=auth)
|
|
258
|
+
|
|
259
|
+
def patch(self, path: str, *, response_model: Optional[Any] = None, status_code: Optional[int] = None, guards: Optional[List[Any]] = None, auth: Optional[List[Any]] = None):
|
|
260
|
+
return self._route_decorator("PATCH", path, response_model=response_model, status_code=status_code, guards=guards, auth=auth)
|
|
261
|
+
|
|
262
|
+
def delete(self, path: str, *, response_model: Optional[Any] = None, status_code: Optional[int] = None, guards: Optional[List[Any]] = None, auth: Optional[List[Any]] = None):
|
|
263
|
+
return self._route_decorator("DELETE", path, response_model=response_model, status_code=status_code, guards=guards, auth=auth)
|
|
264
|
+
|
|
265
|
+
def head(self, path: str, *, response_model: Optional[Any] = None, status_code: Optional[int] = None, guards: Optional[List[Any]] = None, auth: Optional[List[Any]] = None):
|
|
266
|
+
return self._route_decorator("HEAD", path, response_model=response_model, status_code=status_code, guards=guards, auth=auth)
|
|
267
|
+
|
|
268
|
+
def options(self, path: str, *, response_model: Optional[Any] = None, status_code: Optional[int] = None, guards: Optional[List[Any]] = None, auth: Optional[List[Any]] = None):
|
|
269
|
+
return self._route_decorator("OPTIONS", path, response_model=response_model, status_code=status_code, guards=guards, auth=auth)
|
|
270
|
+
|
|
271
|
+
def view(
|
|
272
|
+
self,
|
|
273
|
+
path: str,
|
|
274
|
+
*,
|
|
275
|
+
methods: Optional[List[str]] = None,
|
|
276
|
+
guards: Optional[List[Any]] = None,
|
|
277
|
+
auth: Optional[List[Any]] = None,
|
|
278
|
+
status_code: Optional[int] = None
|
|
279
|
+
):
|
|
280
|
+
"""
|
|
281
|
+
Register a class-based view as a decorator.
|
|
282
|
+
|
|
283
|
+
Usage:
|
|
284
|
+
@api.view("/users")
|
|
285
|
+
class UserView(APIView):
|
|
286
|
+
async def get(self) -> list[User]:
|
|
287
|
+
return User.objects.all()[:10]
|
|
288
|
+
|
|
289
|
+
This method discovers available HTTP method handlers from the view class
|
|
290
|
+
and registers them with the router. It supports the same parameter extraction,
|
|
291
|
+
dependency injection, guards, and authentication as function-based handlers.
|
|
292
|
+
|
|
293
|
+
Args:
|
|
294
|
+
path: URL path pattern (e.g., "/users/{user_id}")
|
|
295
|
+
methods: Optional list of HTTP methods to register (defaults to all implemented methods)
|
|
296
|
+
guards: Optional per-route guard overrides (merged with class-level guards)
|
|
297
|
+
auth: Optional per-route auth overrides (merged with class-level auth)
|
|
298
|
+
status_code: Optional per-route status code override
|
|
299
|
+
|
|
300
|
+
Returns:
|
|
301
|
+
Decorator function that registers the view class
|
|
302
|
+
|
|
303
|
+
Raises:
|
|
304
|
+
ValueError: If view class doesn't implement any requested methods
|
|
305
|
+
"""
|
|
306
|
+
from .views import APIView
|
|
307
|
+
|
|
308
|
+
def decorator(view_cls: type) -> type:
|
|
309
|
+
# Validate that view_cls is an APIView subclass
|
|
310
|
+
if not issubclass(view_cls, APIView):
|
|
311
|
+
raise TypeError(
|
|
312
|
+
f"View class {view_cls.__name__} must inherit from APIView"
|
|
313
|
+
)
|
|
314
|
+
|
|
315
|
+
# Determine which methods to register
|
|
316
|
+
if methods is None:
|
|
317
|
+
# Auto-discover all implemented methods
|
|
318
|
+
available_methods = view_cls.get_allowed_methods()
|
|
319
|
+
if not available_methods:
|
|
320
|
+
raise ValueError(
|
|
321
|
+
f"View class {view_cls.__name__} does not implement any HTTP methods"
|
|
322
|
+
)
|
|
323
|
+
methods_to_register = [m.lower() for m in available_methods]
|
|
324
|
+
else:
|
|
325
|
+
# Validate requested methods are implemented
|
|
326
|
+
methods_to_register = [m.lower() for m in methods]
|
|
327
|
+
available_methods = {m.lower() for m in view_cls.get_allowed_methods()}
|
|
328
|
+
for method in methods_to_register:
|
|
329
|
+
if method not in available_methods:
|
|
330
|
+
raise ValueError(
|
|
331
|
+
f"View class {view_cls.__name__} does not implement method '{method}'"
|
|
332
|
+
)
|
|
333
|
+
|
|
334
|
+
# Register each method
|
|
335
|
+
for method in methods_to_register:
|
|
336
|
+
method_upper = method.upper()
|
|
337
|
+
|
|
338
|
+
# Create handler using as_view()
|
|
339
|
+
handler = view_cls.as_view(method)
|
|
340
|
+
|
|
341
|
+
# Merge guards: route-level overrides class-level
|
|
342
|
+
merged_guards = guards
|
|
343
|
+
if merged_guards is None and hasattr(handler, '__bolt_guards__'):
|
|
344
|
+
merged_guards = handler.__bolt_guards__
|
|
345
|
+
|
|
346
|
+
# Merge auth: route-level overrides class-level
|
|
347
|
+
merged_auth = auth
|
|
348
|
+
if merged_auth is None and hasattr(handler, '__bolt_auth__'):
|
|
349
|
+
merged_auth = handler.__bolt_auth__
|
|
350
|
+
|
|
351
|
+
# Merge status_code: route-level overrides class-level
|
|
352
|
+
merged_status_code = status_code
|
|
353
|
+
if merged_status_code is None and hasattr(handler, '__bolt_status_code__'):
|
|
354
|
+
merged_status_code = handler.__bolt_status_code__
|
|
355
|
+
|
|
356
|
+
# Register using existing route decorator
|
|
357
|
+
route_decorator = self._route_decorator(
|
|
358
|
+
method_upper,
|
|
359
|
+
path,
|
|
360
|
+
response_model=None, # Use method's return annotation
|
|
361
|
+
status_code=merged_status_code,
|
|
362
|
+
guards=merged_guards,
|
|
363
|
+
auth=merged_auth
|
|
364
|
+
)
|
|
365
|
+
|
|
366
|
+
# Apply decorator to register the handler
|
|
367
|
+
route_decorator(handler)
|
|
368
|
+
|
|
369
|
+
# Scan for custom action methods (methods decorated with @action)
|
|
370
|
+
# Note: api.view() doesn't have base path context for @action decorator
|
|
371
|
+
# Custom actions with @action should use api.viewset() instead
|
|
372
|
+
self._register_custom_actions(view_cls, base_path=None, lookup_field=None)
|
|
373
|
+
|
|
374
|
+
return view_cls
|
|
375
|
+
|
|
376
|
+
return decorator
|
|
377
|
+
|
|
378
|
+
def viewset(
|
|
379
|
+
self,
|
|
380
|
+
path: str,
|
|
381
|
+
*,
|
|
382
|
+
guards: Optional[List[Any]] = None,
|
|
383
|
+
auth: Optional[List[Any]] = None,
|
|
384
|
+
status_code: Optional[int] = None,
|
|
385
|
+
lookup_field: str = "pk"
|
|
386
|
+
):
|
|
387
|
+
"""
|
|
388
|
+
Register a ViewSet with automatic CRUD route generation as a decorator.
|
|
389
|
+
|
|
390
|
+
Usage:
|
|
391
|
+
@api.viewset("/users")
|
|
392
|
+
class UserViewSet(ViewSet):
|
|
393
|
+
async def list(self) -> list[User]:
|
|
394
|
+
return User.objects.all()[:100]
|
|
395
|
+
|
|
396
|
+
async def retrieve(self, id: int) -> User:
|
|
397
|
+
return await User.objects.aget(id=id)
|
|
398
|
+
|
|
399
|
+
@action(methods=["POST"], detail=True)
|
|
400
|
+
async def activate(self, id: int):
|
|
401
|
+
user = await User.objects.aget(id=id)
|
|
402
|
+
user.is_active = True
|
|
403
|
+
await user.asave()
|
|
404
|
+
return user
|
|
405
|
+
|
|
406
|
+
This method auto-generates routes for standard DRF-style actions:
|
|
407
|
+
- list: GET /path (200 OK)
|
|
408
|
+
- create: POST /path (201 Created)
|
|
409
|
+
- retrieve: GET /path/{pk} (200 OK)
|
|
410
|
+
- update: PUT /path/{pk} (200 OK)
|
|
411
|
+
- partial_update: PATCH /path/{pk} (200 OK)
|
|
412
|
+
- destroy: DELETE /path/{pk} (204 No Content)
|
|
413
|
+
|
|
414
|
+
Args:
|
|
415
|
+
path: Base URL path (e.g., "/users")
|
|
416
|
+
guards: Optional guards to apply to all routes
|
|
417
|
+
auth: Optional auth backends to apply to all routes
|
|
418
|
+
status_code: Optional default status code (overrides action-specific defaults)
|
|
419
|
+
lookup_field: Field name for object lookup (default: "pk")
|
|
420
|
+
|
|
421
|
+
Returns:
|
|
422
|
+
Decorator function that registers the viewset
|
|
423
|
+
"""
|
|
424
|
+
from .views import ViewSet
|
|
425
|
+
from .status_codes import HTTP_201_CREATED, HTTP_204_NO_CONTENT
|
|
426
|
+
|
|
427
|
+
def decorator(viewset_cls: type) -> type:
|
|
428
|
+
# Validate that viewset_cls is a ViewSet subclass
|
|
429
|
+
if not issubclass(viewset_cls, ViewSet):
|
|
430
|
+
raise TypeError(
|
|
431
|
+
f"ViewSet class {viewset_cls.__name__} must inherit from ViewSet"
|
|
432
|
+
)
|
|
433
|
+
|
|
434
|
+
# Use lookup_field from ViewSet class if not provided
|
|
435
|
+
actual_lookup_field = lookup_field
|
|
436
|
+
if actual_lookup_field == "pk" and hasattr(viewset_cls, 'lookup_field'):
|
|
437
|
+
actual_lookup_field = viewset_cls.lookup_field
|
|
438
|
+
|
|
439
|
+
# Define standard action mappings with HTTP-compliant status codes
|
|
440
|
+
# Format: action_name: (method, path, action_override, default_status_code)
|
|
441
|
+
action_routes = {
|
|
442
|
+
# Collection routes (no pk)
|
|
443
|
+
'list': ('GET', path, None, None),
|
|
444
|
+
'create': ('POST', path, None, HTTP_201_CREATED),
|
|
445
|
+
|
|
446
|
+
# Detail routes (with pk)
|
|
447
|
+
'retrieve': ('GET', f"{path}/{{{actual_lookup_field}}}", 'retrieve', None),
|
|
448
|
+
'update': ('PUT', f"{path}/{{{actual_lookup_field}}}", 'update', None),
|
|
449
|
+
'partial_update': ('PATCH', f"{path}/{{{actual_lookup_field}}}", 'partial_update', None),
|
|
450
|
+
'destroy': ('DELETE', f"{path}/{{{actual_lookup_field}}}", 'destroy', HTTP_204_NO_CONTENT),
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
# Register routes for each implemented action
|
|
454
|
+
for action_name, (http_method, route_path, action_override, action_status_code) in action_routes.items():
|
|
455
|
+
# Check if the viewset implements this action
|
|
456
|
+
if not hasattr(viewset_cls, action_name):
|
|
457
|
+
continue
|
|
458
|
+
|
|
459
|
+
action_method = getattr(viewset_cls, action_name)
|
|
460
|
+
if not inspect.iscoroutinefunction(action_method):
|
|
461
|
+
continue
|
|
462
|
+
|
|
463
|
+
# Use action name (e.g., "list") not HTTP method name (e.g., "get")
|
|
464
|
+
handler = viewset_cls.as_view(http_method.lower(), action=action_override or action_name)
|
|
465
|
+
|
|
466
|
+
# Merge guards and auth
|
|
467
|
+
merged_guards = guards
|
|
468
|
+
if merged_guards is None and hasattr(handler, '__bolt_guards__'):
|
|
469
|
+
merged_guards = handler.__bolt_guards__
|
|
470
|
+
|
|
471
|
+
merged_auth = auth
|
|
472
|
+
if merged_auth is None and hasattr(handler, '__bolt_auth__'):
|
|
473
|
+
merged_auth = handler.__bolt_auth__
|
|
474
|
+
|
|
475
|
+
# Status code priority: explicit status_code param > handler attribute > action default
|
|
476
|
+
merged_status_code = status_code
|
|
477
|
+
if merged_status_code is None and hasattr(handler, '__bolt_status_code__'):
|
|
478
|
+
merged_status_code = handler.__bolt_status_code__
|
|
479
|
+
if merged_status_code is None:
|
|
480
|
+
merged_status_code = action_status_code
|
|
481
|
+
|
|
482
|
+
# Register the route
|
|
483
|
+
route_decorator = self._route_decorator(
|
|
484
|
+
http_method,
|
|
485
|
+
route_path,
|
|
486
|
+
response_model=None,
|
|
487
|
+
status_code=merged_status_code,
|
|
488
|
+
guards=merged_guards,
|
|
489
|
+
auth=merged_auth
|
|
490
|
+
)
|
|
491
|
+
route_decorator(handler)
|
|
492
|
+
|
|
493
|
+
# Scan for custom actions (@action decorator)
|
|
494
|
+
self._register_custom_actions(viewset_cls, base_path=path, lookup_field=actual_lookup_field)
|
|
495
|
+
|
|
496
|
+
return viewset_cls
|
|
497
|
+
|
|
498
|
+
return decorator
|
|
499
|
+
|
|
500
|
+
def _register_custom_actions(self, view_cls: type, base_path: Optional[str], lookup_field: Optional[str]):
|
|
501
|
+
"""
|
|
502
|
+
Scan a ViewSet class for custom action methods and register them.
|
|
503
|
+
|
|
504
|
+
Custom actions are methods decorated with @action decorator.
|
|
505
|
+
|
|
506
|
+
Args:
|
|
507
|
+
view_cls: The ViewSet class to scan
|
|
508
|
+
base_path: Base path for the ViewSet (e.g., "/users")
|
|
509
|
+
lookup_field: Lookup field name for detail actions (e.g., "id", "pk")
|
|
510
|
+
"""
|
|
511
|
+
import inspect
|
|
512
|
+
import types
|
|
513
|
+
from .decorators import ActionHandler
|
|
514
|
+
|
|
515
|
+
# Get class-level auth and guards (if any)
|
|
516
|
+
class_auth = getattr(view_cls, 'auth', None)
|
|
517
|
+
class_guards = getattr(view_cls, 'guards', None)
|
|
518
|
+
|
|
519
|
+
# Scan all attributes in the class
|
|
520
|
+
for name in dir(view_cls):
|
|
521
|
+
# Skip private attributes and standard action methods
|
|
522
|
+
if name.startswith('_') or name.lower() in [
|
|
523
|
+
'get', 'post', 'put', 'patch', 'delete', 'head', 'options',
|
|
524
|
+
'list', 'retrieve', 'create', 'update', 'partial_update', 'destroy'
|
|
525
|
+
]:
|
|
526
|
+
continue
|
|
527
|
+
|
|
528
|
+
attr = getattr(view_cls, name)
|
|
529
|
+
|
|
530
|
+
# Check if it's an ActionHandler instance (decorated with @action)
|
|
531
|
+
if isinstance(attr, ActionHandler):
|
|
532
|
+
# Validate that we have base_path for auto-generation
|
|
533
|
+
if base_path is None:
|
|
534
|
+
raise ValueError(
|
|
535
|
+
f"Custom action {view_cls.__name__}.{name} uses @action decorator, "
|
|
536
|
+
f"but ViewSet was registered with api.view() instead of api.viewset(). "
|
|
537
|
+
f"Use api.viewset() for automatic action path generation."
|
|
538
|
+
)
|
|
539
|
+
|
|
540
|
+
# Extract the unbound function from the ActionHandler
|
|
541
|
+
unbound_fn = attr.fn
|
|
542
|
+
|
|
543
|
+
# Auto-generate route path based on detail flag
|
|
544
|
+
if attr.detail:
|
|
545
|
+
# Instance-level action: /base_path/{lookup_field}/action_name
|
|
546
|
+
# Example: /users/{id}/activate
|
|
547
|
+
action_path = f"{base_path}/{{{lookup_field}}}/{attr.path}"
|
|
548
|
+
else:
|
|
549
|
+
# Collection-level action: /base_path/action_name
|
|
550
|
+
# Example: /users/active
|
|
551
|
+
action_path = f"{base_path}/{attr.path}"
|
|
552
|
+
|
|
553
|
+
# Register route for each HTTP method
|
|
554
|
+
for http_method in attr.methods:
|
|
555
|
+
# Create a wrapper that calls the method as an instance method
|
|
556
|
+
async def custom_action_handler(
|
|
557
|
+
*args,
|
|
558
|
+
__unbound_fn=unbound_fn,
|
|
559
|
+
__view_cls=view_cls,
|
|
560
|
+
**kwargs
|
|
561
|
+
):
|
|
562
|
+
"""Wrapper for custom action method."""
|
|
563
|
+
view = __view_cls()
|
|
564
|
+
# Bind the unbound method to the view instance
|
|
565
|
+
bound_method = types.MethodType(__unbound_fn, view)
|
|
566
|
+
return await bound_method(*args, **kwargs)
|
|
567
|
+
|
|
568
|
+
# Preserve signature and annotations from original method
|
|
569
|
+
sig = inspect.signature(unbound_fn)
|
|
570
|
+
params = list(sig.parameters.values())[1:] # Skip 'self'
|
|
571
|
+
custom_action_handler.__signature__ = sig.replace(parameters=params)
|
|
572
|
+
custom_action_handler.__annotations__ = {
|
|
573
|
+
k: v for k, v in unbound_fn.__annotations__.items() if k != 'self'
|
|
574
|
+
}
|
|
575
|
+
custom_action_handler.__name__ = f"{view_cls.__name__}.{name}"
|
|
576
|
+
custom_action_handler.__doc__ = unbound_fn.__doc__
|
|
577
|
+
custom_action_handler.__module__ = unbound_fn.__module__
|
|
578
|
+
|
|
579
|
+
# Merge class-level auth/guards with action-specific auth/guards
|
|
580
|
+
# Action-specific takes precedence if explicitly set
|
|
581
|
+
final_auth = attr.auth if attr.auth is not None else class_auth
|
|
582
|
+
final_guards = attr.guards if attr.guards is not None else class_guards
|
|
583
|
+
|
|
584
|
+
# Register the custom action
|
|
585
|
+
decorator = self._route_decorator(
|
|
586
|
+
http_method,
|
|
587
|
+
action_path,
|
|
588
|
+
response_model=attr.response_model,
|
|
589
|
+
status_code=attr.status_code,
|
|
590
|
+
guards=final_guards,
|
|
591
|
+
auth=final_auth
|
|
592
|
+
)
|
|
593
|
+
decorator(custom_action_handler)
|
|
594
|
+
|
|
595
|
+
def _route_decorator(self, method: str, path: str, *, response_model: Optional[Any] = None, status_code: Optional[int] = None, guards: Optional[List[Any]] = None, auth: Optional[List[Any]] = None):
|
|
596
|
+
def decorator(fn: Callable):
|
|
597
|
+
# Enforce async handlers
|
|
598
|
+
if not inspect.iscoroutinefunction(fn):
|
|
599
|
+
raise TypeError(f"Handler {fn.__name__} must be async. Use 'async def' instead of 'def'")
|
|
600
|
+
|
|
601
|
+
handler_id = self._next_handler_id
|
|
602
|
+
self._next_handler_id += 1
|
|
603
|
+
|
|
604
|
+
# Apply prefix to path (conversion happens in Rust)
|
|
605
|
+
full_path = self.prefix + path if self.prefix else path
|
|
606
|
+
|
|
607
|
+
self._routes.append((method, full_path, handler_id, fn))
|
|
608
|
+
self._handlers[handler_id] = fn
|
|
609
|
+
|
|
610
|
+
# Pre-compile lightweight binder for this handler with HTTP method validation
|
|
611
|
+
meta = self._compile_binder(fn, method, full_path)
|
|
612
|
+
# Allow explicit response model override
|
|
613
|
+
if response_model is not None:
|
|
614
|
+
meta["response_type"] = response_model
|
|
615
|
+
if status_code is not None:
|
|
616
|
+
meta["default_status_code"] = int(status_code)
|
|
617
|
+
self._handler_meta[fn] = meta
|
|
618
|
+
|
|
619
|
+
# Compile middleware metadata for this handler (including guards and auth)
|
|
620
|
+
middleware_meta = compile_middleware_meta(
|
|
621
|
+
fn, method, full_path,
|
|
622
|
+
self.middleware, self.middleware_config,
|
|
623
|
+
guards=guards, auth=auth
|
|
624
|
+
)
|
|
625
|
+
if middleware_meta:
|
|
626
|
+
self._handler_middleware[handler_id] = middleware_meta
|
|
627
|
+
|
|
628
|
+
return fn
|
|
629
|
+
return decorator
|
|
630
|
+
|
|
631
|
+
def _compile_binder(self, fn: Callable, http_method: str = "", path: str = "") -> Dict[str, Any]:
|
|
632
|
+
"""
|
|
633
|
+
Compile parameter binding metadata for a handler function.
|
|
634
|
+
|
|
635
|
+
This method:
|
|
636
|
+
1. Parses function signature and type hints
|
|
637
|
+
2. Creates FieldDefinition for each parameter
|
|
638
|
+
3. Infers parameter sources (path, query, body, etc.)
|
|
639
|
+
4. Validates HTTP method compatibility
|
|
640
|
+
5. Pre-compiles extractors for performance
|
|
641
|
+
|
|
642
|
+
Args:
|
|
643
|
+
fn: Handler function
|
|
644
|
+
http_method: HTTP method (GET, POST, etc.)
|
|
645
|
+
path: Route path pattern
|
|
646
|
+
|
|
647
|
+
Returns:
|
|
648
|
+
Metadata dictionary for parameter binding
|
|
649
|
+
|
|
650
|
+
Raises:
|
|
651
|
+
TypeError: If GET/HEAD/DELETE/OPTIONS handlers have body parameters
|
|
652
|
+
"""
|
|
653
|
+
sig = inspect.signature(fn)
|
|
654
|
+
type_hints = get_type_hints(fn, include_extras=True)
|
|
655
|
+
|
|
656
|
+
# Extract path parameters from route pattern
|
|
657
|
+
path_params = _extract_path_params(path)
|
|
658
|
+
|
|
659
|
+
meta: Dict[str, Any] = {
|
|
660
|
+
"sig": sig,
|
|
661
|
+
"fields": [],
|
|
662
|
+
"path_params": path_params,
|
|
663
|
+
"http_method": http_method,
|
|
664
|
+
}
|
|
665
|
+
|
|
666
|
+
# Quick path: single parameter that looks like request
|
|
667
|
+
params = list(sig.parameters.values())
|
|
668
|
+
if len(params) == 1 and params[0].name in {"request", "req"}:
|
|
669
|
+
meta["mode"] = "request_only"
|
|
670
|
+
return meta
|
|
671
|
+
|
|
672
|
+
# Parse each parameter into FieldDefinition
|
|
673
|
+
field_definitions: List[FieldDefinition] = []
|
|
674
|
+
|
|
675
|
+
for param in params:
|
|
676
|
+
name = param.name
|
|
677
|
+
annotation = type_hints.get(name, param.annotation)
|
|
678
|
+
|
|
679
|
+
# Extract explicit markers from Annotated or default
|
|
680
|
+
explicit_marker = None
|
|
681
|
+
|
|
682
|
+
# Check Annotated[T, ...]
|
|
683
|
+
origin = get_origin(annotation)
|
|
684
|
+
if origin is Annotated:
|
|
685
|
+
args = get_args(annotation)
|
|
686
|
+
annotation = args[0] if args else annotation # Unwrap to get actual type
|
|
687
|
+
for meta_val in args[1:]:
|
|
688
|
+
if isinstance(meta_val, (Param, DependsMarker)):
|
|
689
|
+
explicit_marker = meta_val
|
|
690
|
+
break
|
|
691
|
+
|
|
692
|
+
# Check default value for marker
|
|
693
|
+
if explicit_marker is None and isinstance(param.default, (Param, DependsMarker)):
|
|
694
|
+
explicit_marker = param.default
|
|
695
|
+
|
|
696
|
+
# Create FieldDefinition with inference
|
|
697
|
+
field = FieldDefinition.from_parameter(
|
|
698
|
+
parameter=param,
|
|
699
|
+
annotation=annotation,
|
|
700
|
+
path_params=path_params,
|
|
701
|
+
http_method=http_method,
|
|
702
|
+
explicit_marker=explicit_marker,
|
|
703
|
+
)
|
|
704
|
+
|
|
705
|
+
field_definitions.append(field)
|
|
706
|
+
|
|
707
|
+
# HTTP Method Validation: Ensure GET/HEAD/DELETE/OPTIONS don't have body params
|
|
708
|
+
body_fields = [f for f in field_definitions if f.source == "body"]
|
|
709
|
+
if http_method in ("GET", "HEAD", "DELETE", "OPTIONS") and body_fields:
|
|
710
|
+
param_names = [f.name for f in body_fields]
|
|
711
|
+
raise TypeError(
|
|
712
|
+
f"Handler {fn.__name__} for {http_method} {path} cannot have body parameters.\n"
|
|
713
|
+
f"Found body parameters: {param_names}\n"
|
|
714
|
+
f"Solutions:\n"
|
|
715
|
+
f" 1. Change HTTP method to POST/PUT/PATCH\n"
|
|
716
|
+
f" 2. Use Query() marker for query parameters\n"
|
|
717
|
+
f" 3. Use simple types (str, int) which auto-infer as query params"
|
|
718
|
+
)
|
|
719
|
+
|
|
720
|
+
# Convert FieldDefinitions to dict format for backward compatibility
|
|
721
|
+
# (We'll optimize this away in Phase 4)
|
|
722
|
+
for field in field_definitions:
|
|
723
|
+
meta["fields"].append({
|
|
724
|
+
"name": field.name,
|
|
725
|
+
"annotation": field.annotation,
|
|
726
|
+
"default": field.default,
|
|
727
|
+
"kind": field.kind,
|
|
728
|
+
"source": field.source,
|
|
729
|
+
"alias": field.alias,
|
|
730
|
+
"embed": field.embed,
|
|
731
|
+
"dependency": field.dependency,
|
|
732
|
+
"field_def": field, # Store FieldDefinition for future use
|
|
733
|
+
})
|
|
734
|
+
|
|
735
|
+
# Detect single body parameter for fast path
|
|
736
|
+
if len(body_fields) == 1:
|
|
737
|
+
body_field = body_fields[0]
|
|
738
|
+
if body_field.is_msgspec_struct:
|
|
739
|
+
meta["body_struct_param"] = body_field.name
|
|
740
|
+
meta["body_struct_type"] = body_field.annotation
|
|
741
|
+
|
|
742
|
+
# Capture return type for response validation/serialization
|
|
743
|
+
if sig.return_annotation is not inspect._empty:
|
|
744
|
+
meta["response_type"] = sig.return_annotation
|
|
745
|
+
|
|
746
|
+
meta["mode"] = "mixed"
|
|
747
|
+
|
|
748
|
+
# Maintain backward compatibility with old "params" key
|
|
749
|
+
meta["params"] = meta["fields"]
|
|
750
|
+
|
|
751
|
+
# Performance: Check if handler needs form/file parsing
|
|
752
|
+
# This allows us to skip expensive form parsing for 95% of endpoints
|
|
753
|
+
needs_form_parsing = any(f.source in ("form", "file") for f in field_definitions)
|
|
754
|
+
meta["needs_form_parsing"] = needs_form_parsing
|
|
755
|
+
|
|
756
|
+
return meta
|
|
757
|
+
|
|
758
|
+
async def _build_handler_arguments(self, meta: Dict[str, Any], request: Dict[str, Any]) -> Tuple[List[Any], Dict[str, Any]]:
|
|
759
|
+
"""Build arguments for handler invocation."""
|
|
760
|
+
args: List[Any] = []
|
|
761
|
+
kwargs: Dict[str, Any] = {}
|
|
762
|
+
|
|
763
|
+
# Access PyRequest mappings
|
|
764
|
+
params_map = request["params"]
|
|
765
|
+
query_map = request["query"]
|
|
766
|
+
headers_map = request.get("headers", {})
|
|
767
|
+
cookies_map = request.get("cookies", {})
|
|
768
|
+
|
|
769
|
+
# Parse form/multipart data ONLY if handler uses Form() or File() parameters
|
|
770
|
+
# This optimization skips parsing for 95% of endpoints (JSON/GET endpoints)
|
|
771
|
+
if meta.get("needs_form_parsing", False):
|
|
772
|
+
form_map, files_map = parse_form_data(request, headers_map)
|
|
773
|
+
else:
|
|
774
|
+
form_map, files_map = {}, {}
|
|
775
|
+
|
|
776
|
+
# Body decode cache
|
|
777
|
+
body_obj: Any = None
|
|
778
|
+
body_loaded: bool = False
|
|
779
|
+
dep_cache: Dict[Any, Any] = {}
|
|
780
|
+
|
|
781
|
+
for p in meta["params"]:
|
|
782
|
+
name = p["name"]
|
|
783
|
+
source = p["source"]
|
|
784
|
+
depends_marker = p.get("dependency")
|
|
785
|
+
|
|
786
|
+
if source == "request":
|
|
787
|
+
value = request
|
|
788
|
+
elif source == "dependency":
|
|
789
|
+
dep_fn = depends_marker.dependency if depends_marker else None
|
|
790
|
+
if dep_fn is None:
|
|
791
|
+
raise ValueError(f"Depends for parameter {name} requires a callable")
|
|
792
|
+
value = await resolve_dependency(
|
|
793
|
+
dep_fn, depends_marker, request, dep_cache,
|
|
794
|
+
params_map, query_map, headers_map, cookies_map,
|
|
795
|
+
self._handler_meta, self._compile_binder,
|
|
796
|
+
meta.get("http_method", ""), meta.get("path", "")
|
|
797
|
+
)
|
|
798
|
+
else:
|
|
799
|
+
value, body_obj, body_loaded = extract_parameter_value(
|
|
800
|
+
p, request, params_map, query_map, headers_map, cookies_map,
|
|
801
|
+
form_map, files_map, meta, body_obj, body_loaded
|
|
802
|
+
)
|
|
803
|
+
|
|
804
|
+
# Respect positional-only/keyword-only kinds
|
|
805
|
+
if p["kind"] in (inspect.Parameter.POSITIONAL_ONLY, inspect.Parameter.POSITIONAL_OR_KEYWORD):
|
|
806
|
+
args.append(value)
|
|
807
|
+
else:
|
|
808
|
+
kwargs[name] = value
|
|
809
|
+
|
|
810
|
+
return args, kwargs
|
|
811
|
+
|
|
812
|
+
|
|
813
|
+
def _handle_http_exception(self, he: HTTPException) -> Response:
|
|
814
|
+
"""Handle HTTPException and return response."""
|
|
815
|
+
try:
|
|
816
|
+
body = msgspec.json.encode({"detail": he.detail})
|
|
817
|
+
headers = [("content-type", "application/json")]
|
|
818
|
+
except Exception:
|
|
819
|
+
body = str(he.detail).encode()
|
|
820
|
+
headers = [("content-type", "text/plain; charset=utf-8")]
|
|
821
|
+
|
|
822
|
+
if he.headers:
|
|
823
|
+
headers.extend([(k.lower(), v) for k, v in he.headers.items()])
|
|
824
|
+
|
|
825
|
+
return int(he.status_code), headers, body
|
|
826
|
+
|
|
827
|
+
def _handle_generic_exception(self, e: Exception, request: Dict[str, Any] = None) -> Response:
|
|
828
|
+
"""Handle generic exception using error_handlers module."""
|
|
829
|
+
from . import error_handlers
|
|
830
|
+
# Use the error handler which respects Django DEBUG setting
|
|
831
|
+
return error_handlers.handle_exception(e, debug=False, request=request) # debug will be checked dynamically
|
|
832
|
+
|
|
833
|
+
async def _dispatch(self, handler: Callable, request: Dict[str, Any], handler_id: int = None) -> Response:
|
|
834
|
+
"""Async dispatch that calls the handler and returns response tuple.
|
|
835
|
+
|
|
836
|
+
Args:
|
|
837
|
+
handler: The route handler function
|
|
838
|
+
request: The request dictionary
|
|
839
|
+
handler_id: Handler ID to lookup original API (for merged APIs)
|
|
840
|
+
"""
|
|
841
|
+
# For merged APIs, use the original API's logging middleware
|
|
842
|
+
# This preserves per-API logging, auth, and middleware config (Litestar-style)
|
|
843
|
+
logging_middleware = self._logging_middleware
|
|
844
|
+
if handler_id is not None and hasattr(self, '_handler_api_map'):
|
|
845
|
+
original_api = self._handler_api_map.get(handler_id)
|
|
846
|
+
if original_api and original_api._logging_middleware:
|
|
847
|
+
logging_middleware = original_api._logging_middleware
|
|
848
|
+
|
|
849
|
+
# Start timing only if we might log
|
|
850
|
+
start_time = None
|
|
851
|
+
if logging_middleware:
|
|
852
|
+
# Determine if INFO logs are enabled or a slow-only threshold exists
|
|
853
|
+
logger = logging_middleware.logger
|
|
854
|
+
should_time = False
|
|
855
|
+
try:
|
|
856
|
+
if logger.isEnabledFor(__import__('logging').INFO):
|
|
857
|
+
should_time = True
|
|
858
|
+
except Exception:
|
|
859
|
+
pass
|
|
860
|
+
if not should_time:
|
|
861
|
+
# If slow-only is configured, we still need timing
|
|
862
|
+
should_time = bool(getattr(logging_middleware.config, 'min_duration_ms', None))
|
|
863
|
+
if should_time:
|
|
864
|
+
start_time = time.time()
|
|
865
|
+
|
|
866
|
+
# Log request if logging enabled (DEBUG-level guard happens inside)
|
|
867
|
+
logging_middleware.log_request(request)
|
|
868
|
+
|
|
869
|
+
try:
|
|
870
|
+
meta = self._handler_meta.get(handler)
|
|
871
|
+
if meta is None:
|
|
872
|
+
meta = self._compile_binder(handler)
|
|
873
|
+
self._handler_meta[handler] = meta
|
|
874
|
+
|
|
875
|
+
# Fast path for request-only handlers
|
|
876
|
+
if meta.get("mode") == "request_only":
|
|
877
|
+
result = await handler(request)
|
|
878
|
+
else:
|
|
879
|
+
# Build handler arguments
|
|
880
|
+
args, kwargs = await self._build_handler_arguments(meta, request)
|
|
881
|
+
result = await handler(*args, **kwargs)
|
|
882
|
+
|
|
883
|
+
# Serialize response
|
|
884
|
+
response = await serialize_response(result, meta)
|
|
885
|
+
|
|
886
|
+
# Log response if logging enabled
|
|
887
|
+
if logging_middleware and start_time is not None:
|
|
888
|
+
duration = time.time() - start_time
|
|
889
|
+
status_code = response[0] if isinstance(response, tuple) else 200
|
|
890
|
+
logging_middleware.log_response(request, status_code, duration)
|
|
891
|
+
|
|
892
|
+
return response
|
|
893
|
+
|
|
894
|
+
except HTTPException as he:
|
|
895
|
+
# Log exception if logging enabled
|
|
896
|
+
if logging_middleware and start_time is not None:
|
|
897
|
+
duration = time.time() - start_time
|
|
898
|
+
logging_middleware.log_response(request, he.status_code, duration)
|
|
899
|
+
|
|
900
|
+
return self._handle_http_exception(he)
|
|
901
|
+
except Exception as e:
|
|
902
|
+
# Log exception if logging enabled
|
|
903
|
+
if logging_middleware:
|
|
904
|
+
logging_middleware.log_exception(request, e, exc_info=True)
|
|
905
|
+
|
|
906
|
+
return self._handle_generic_exception(e, request=request)
|
|
907
|
+
|
|
908
|
+
def _get_openapi_schema(self) -> Dict[str, Any]:
|
|
909
|
+
"""Get or generate OpenAPI schema.
|
|
910
|
+
|
|
911
|
+
Returns:
|
|
912
|
+
OpenAPI schema as dictionary.
|
|
913
|
+
"""
|
|
914
|
+
if self._openapi_schema is None:
|
|
915
|
+
from .openapi.schema_generator import SchemaGenerator
|
|
916
|
+
|
|
917
|
+
generator = SchemaGenerator(self, self.openapi_config)
|
|
918
|
+
openapi = generator.generate()
|
|
919
|
+
self._openapi_schema = openapi.to_schema()
|
|
920
|
+
|
|
921
|
+
return self._openapi_schema
|
|
922
|
+
|
|
923
|
+
def _register_openapi_routes(self) -> None:
|
|
924
|
+
"""Register OpenAPI documentation routes.
|
|
925
|
+
|
|
926
|
+
Delegates to OpenAPIRouteRegistrar for cleaner separation of concerns.
|
|
927
|
+
"""
|
|
928
|
+
from .openapi.routes import OpenAPIRouteRegistrar
|
|
929
|
+
|
|
930
|
+
registrar = OpenAPIRouteRegistrar(self)
|
|
931
|
+
registrar.register_routes()
|
|
932
|
+
|
|
933
|
+
def _register_admin_routes(self, host: str = "localhost", port: int = 8000) -> None:
|
|
934
|
+
"""Register Django admin routes via ASGI bridge.
|
|
935
|
+
|
|
936
|
+
Delegates to AdminRouteRegistrar for cleaner separation of concerns.
|
|
937
|
+
|
|
938
|
+
Args:
|
|
939
|
+
host: Server hostname for ASGI scope
|
|
940
|
+
port: Server port for ASGI scope
|
|
941
|
+
"""
|
|
942
|
+
from .admin.routes import AdminRouteRegistrar
|
|
943
|
+
|
|
944
|
+
registrar = AdminRouteRegistrar(self)
|
|
945
|
+
registrar.register_routes(host, port)
|
|
946
|
+
|
|
947
|
+
def _register_static_routes(self) -> None:
|
|
948
|
+
"""Register static file serving routes for Django admin.
|
|
949
|
+
|
|
950
|
+
Delegates to StaticRouteRegistrar for cleaner separation of concerns.
|
|
951
|
+
"""
|
|
952
|
+
from .admin.static_routes import StaticRouteRegistrar
|
|
953
|
+
|
|
954
|
+
registrar = StaticRouteRegistrar(self)
|
|
955
|
+
registrar.register_routes()
|
|
956
|
+
|
|
957
|
+
def serve(self, host: str = "0.0.0.0", port: int = 8000) -> None:
|
|
958
|
+
"""Start the async server with registered routes"""
|
|
959
|
+
info = ensure_django_ready()
|
|
960
|
+
print(
|
|
961
|
+
f"[django-bolt] Django setup: mode={info.get('mode')} debug={info.get('debug')}\n"
|
|
962
|
+
f"[django-bolt] DB: {info.get('database')} name={info.get('database_name')}\n"
|
|
963
|
+
f"[django-bolt] Settings: {info.get('settings_module') or 'embedded'}"
|
|
964
|
+
)
|
|
965
|
+
|
|
966
|
+
# Register Django admin routes if enabled
|
|
967
|
+
if self.enable_admin:
|
|
968
|
+
self._register_admin_routes(host, port)
|
|
969
|
+
if self._admin_routes_registered:
|
|
970
|
+
from .admin.admin_detection import detect_admin_url_prefix
|
|
971
|
+
admin_prefix = detect_admin_url_prefix() or 'admin'
|
|
972
|
+
print(f"[django-bolt] Django admin available at http://{host}:{port}/{admin_prefix}/")
|
|
973
|
+
|
|
974
|
+
# Also register static file routes for admin
|
|
975
|
+
self._register_static_routes()
|
|
976
|
+
if self._static_routes_registered:
|
|
977
|
+
print(f"[django-bolt] Static files serving enabled")
|
|
978
|
+
|
|
979
|
+
# Register OpenAPI routes if configured
|
|
980
|
+
if self.openapi_config:
|
|
981
|
+
self._register_openapi_routes()
|
|
982
|
+
print(f"[django-bolt] OpenAPI docs available at http://{host}:{port}{self.openapi_config.path}")
|
|
983
|
+
|
|
984
|
+
# Register all routes with Rust router
|
|
985
|
+
rust_routes = [
|
|
986
|
+
(method, path, handler_id, handler)
|
|
987
|
+
for method, path, handler_id, handler in self._routes
|
|
988
|
+
]
|
|
989
|
+
|
|
990
|
+
# Register routes in Rust
|
|
991
|
+
_core.register_routes(rust_routes)
|
|
992
|
+
|
|
993
|
+
# Register middleware metadata if any exists
|
|
994
|
+
if self._handler_middleware:
|
|
995
|
+
middleware_data = [
|
|
996
|
+
(handler_id, meta)
|
|
997
|
+
for handler_id, meta in self._handler_middleware.items()
|
|
998
|
+
]
|
|
999
|
+
_core.register_middleware_metadata(middleware_data)
|
|
1000
|
+
print(f"[django-bolt] Registered middleware for {len(middleware_data)} handlers")
|
|
1001
|
+
|
|
1002
|
+
print(f"[django-bolt] Registered {len(self._routes)} routes")
|
|
1003
|
+
print(f"[django-bolt] Starting async server on http://{host}:{port}")
|
|
1004
|
+
|
|
1005
|
+
# Get compression config
|
|
1006
|
+
compression_config = None
|
|
1007
|
+
if self.compression is not None:
|
|
1008
|
+
compression_config = self.compression.to_rust_config()
|
|
1009
|
+
|
|
1010
|
+
# Start async server
|
|
1011
|
+
_core.start_server_async(self._dispatch, host, port, compression_config)
|