django-bolt 0.1.0__cp310-abi3-win_amd64.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.pyd +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/params.py
ADDED
|
@@ -0,0 +1,337 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Parameter markers and validation constraints for Django-Bolt.
|
|
3
|
+
|
|
4
|
+
Provides explicit parameter source annotations and validation metadata.
|
|
5
|
+
"""
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
from dataclasses import dataclass, field
|
|
9
|
+
from typing import Any, Callable, Optional, Pattern
|
|
10
|
+
|
|
11
|
+
__all__ = [
|
|
12
|
+
"Param",
|
|
13
|
+
"Query",
|
|
14
|
+
"Path",
|
|
15
|
+
"Body",
|
|
16
|
+
"Header",
|
|
17
|
+
"Cookie",
|
|
18
|
+
"Form",
|
|
19
|
+
"File",
|
|
20
|
+
"Depends",
|
|
21
|
+
]
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
@dataclass(frozen=True)
|
|
25
|
+
class Param:
|
|
26
|
+
"""
|
|
27
|
+
Base parameter marker with validation constraints.
|
|
28
|
+
|
|
29
|
+
Used internally by Query, Path, Body, etc. markers.
|
|
30
|
+
"""
|
|
31
|
+
|
|
32
|
+
source: str
|
|
33
|
+
"""Parameter source: 'query', 'path', 'body', 'header', 'cookie', 'form', 'file'"""
|
|
34
|
+
|
|
35
|
+
alias: Optional[str] = None
|
|
36
|
+
"""Alternative name for the parameter in the request"""
|
|
37
|
+
|
|
38
|
+
embed: Optional[bool] = None
|
|
39
|
+
"""Whether to embed body parameter in wrapper object"""
|
|
40
|
+
|
|
41
|
+
# Numeric constraints
|
|
42
|
+
gt: Optional[float] = None
|
|
43
|
+
"""Greater than (exclusive minimum)"""
|
|
44
|
+
|
|
45
|
+
ge: Optional[float] = None
|
|
46
|
+
"""Greater than or equal (inclusive minimum)"""
|
|
47
|
+
|
|
48
|
+
lt: Optional[float] = None
|
|
49
|
+
"""Less than (exclusive maximum)"""
|
|
50
|
+
|
|
51
|
+
le: Optional[float] = None
|
|
52
|
+
"""Less than or equal (inclusive maximum)"""
|
|
53
|
+
|
|
54
|
+
multiple_of: Optional[float] = None
|
|
55
|
+
"""Value must be multiple of this number"""
|
|
56
|
+
|
|
57
|
+
# String/collection constraints
|
|
58
|
+
min_length: Optional[int] = None
|
|
59
|
+
"""Minimum length for strings or collections"""
|
|
60
|
+
|
|
61
|
+
max_length: Optional[int] = None
|
|
62
|
+
"""Maximum length for strings or collections"""
|
|
63
|
+
|
|
64
|
+
pattern: Optional[str] = None
|
|
65
|
+
"""Regex pattern for string validation"""
|
|
66
|
+
|
|
67
|
+
# Metadata
|
|
68
|
+
description: Optional[str] = None
|
|
69
|
+
"""Parameter description for documentation"""
|
|
70
|
+
|
|
71
|
+
example: Any = None
|
|
72
|
+
"""Example value for documentation"""
|
|
73
|
+
|
|
74
|
+
deprecated: bool = False
|
|
75
|
+
"""Mark parameter as deprecated"""
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def Query(
|
|
79
|
+
default: Any = ...,
|
|
80
|
+
*,
|
|
81
|
+
alias: Optional[str] = None,
|
|
82
|
+
gt: Optional[float] = None,
|
|
83
|
+
ge: Optional[float] = None,
|
|
84
|
+
lt: Optional[float] = None,
|
|
85
|
+
le: Optional[float] = None,
|
|
86
|
+
min_length: Optional[int] = None,
|
|
87
|
+
max_length: Optional[int] = None,
|
|
88
|
+
pattern: Optional[str] = None,
|
|
89
|
+
description: Optional[str] = None,
|
|
90
|
+
example: Any = None,
|
|
91
|
+
deprecated: bool = False,
|
|
92
|
+
) -> Any:
|
|
93
|
+
"""
|
|
94
|
+
Mark parameter as query parameter.
|
|
95
|
+
|
|
96
|
+
Args:
|
|
97
|
+
default: Default value (... for required)
|
|
98
|
+
alias: Alternative parameter name in URL
|
|
99
|
+
gt: Value must be greater than this
|
|
100
|
+
ge: Value must be greater than or equal to this
|
|
101
|
+
lt: Value must be less than this
|
|
102
|
+
le: Value must be less than or equal to this
|
|
103
|
+
min_length: Minimum string/collection length
|
|
104
|
+
max_length: Maximum string/collection length
|
|
105
|
+
pattern: Regex pattern to match
|
|
106
|
+
description: Parameter description
|
|
107
|
+
example: Example value
|
|
108
|
+
deprecated: Mark as deprecated
|
|
109
|
+
|
|
110
|
+
Returns:
|
|
111
|
+
Param marker instance
|
|
112
|
+
"""
|
|
113
|
+
return Param(
|
|
114
|
+
source="query",
|
|
115
|
+
alias=alias,
|
|
116
|
+
gt=gt,
|
|
117
|
+
ge=ge,
|
|
118
|
+
lt=lt,
|
|
119
|
+
le=le,
|
|
120
|
+
min_length=min_length,
|
|
121
|
+
max_length=max_length,
|
|
122
|
+
pattern=pattern,
|
|
123
|
+
description=description,
|
|
124
|
+
example=example,
|
|
125
|
+
deprecated=deprecated,
|
|
126
|
+
)
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def Path(
|
|
130
|
+
default: Any = ...,
|
|
131
|
+
*,
|
|
132
|
+
alias: Optional[str] = None,
|
|
133
|
+
gt: Optional[float] = None,
|
|
134
|
+
ge: Optional[float] = None,
|
|
135
|
+
lt: Optional[float] = None,
|
|
136
|
+
le: Optional[float] = None,
|
|
137
|
+
min_length: Optional[int] = None,
|
|
138
|
+
max_length: Optional[int] = None,
|
|
139
|
+
pattern: Optional[str] = None,
|
|
140
|
+
description: Optional[str] = None,
|
|
141
|
+
example: Any = None,
|
|
142
|
+
deprecated: bool = False,
|
|
143
|
+
) -> Any:
|
|
144
|
+
"""
|
|
145
|
+
Mark parameter as path parameter.
|
|
146
|
+
|
|
147
|
+
Args:
|
|
148
|
+
default: Must be ... (path params are always required)
|
|
149
|
+
alias: Alternative parameter name
|
|
150
|
+
gt: Value must be greater than this
|
|
151
|
+
ge: Value must be greater than or equal to this
|
|
152
|
+
lt: Value must be less than this
|
|
153
|
+
le: Value must be less than or equal to this
|
|
154
|
+
min_length: Minimum string length
|
|
155
|
+
max_length: Maximum string length
|
|
156
|
+
pattern: Regex pattern to match
|
|
157
|
+
description: Parameter description
|
|
158
|
+
example: Example value
|
|
159
|
+
deprecated: Mark as deprecated
|
|
160
|
+
|
|
161
|
+
Returns:
|
|
162
|
+
Param marker instance
|
|
163
|
+
"""
|
|
164
|
+
if default is not ...:
|
|
165
|
+
raise ValueError("Path parameters cannot have default values")
|
|
166
|
+
|
|
167
|
+
return Param(
|
|
168
|
+
source="path",
|
|
169
|
+
alias=alias,
|
|
170
|
+
gt=gt,
|
|
171
|
+
ge=ge,
|
|
172
|
+
lt=lt,
|
|
173
|
+
le=le,
|
|
174
|
+
min_length=min_length,
|
|
175
|
+
max_length=max_length,
|
|
176
|
+
pattern=pattern,
|
|
177
|
+
description=description,
|
|
178
|
+
example=example,
|
|
179
|
+
deprecated=deprecated,
|
|
180
|
+
)
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
def Body(
|
|
184
|
+
default: Any = ...,
|
|
185
|
+
*,
|
|
186
|
+
alias: Optional[str] = None,
|
|
187
|
+
embed: bool = False,
|
|
188
|
+
description: Optional[str] = None,
|
|
189
|
+
example: Any = None,
|
|
190
|
+
) -> Any:
|
|
191
|
+
"""
|
|
192
|
+
Mark parameter as request body.
|
|
193
|
+
|
|
194
|
+
Args:
|
|
195
|
+
default: Default value (... for required)
|
|
196
|
+
alias: Alternative parameter name
|
|
197
|
+
embed: Whether to wrap in {<alias>: <value>}
|
|
198
|
+
description: Parameter description
|
|
199
|
+
example: Example value
|
|
200
|
+
|
|
201
|
+
Returns:
|
|
202
|
+
Param marker instance
|
|
203
|
+
"""
|
|
204
|
+
return Param(
|
|
205
|
+
source="body",
|
|
206
|
+
alias=alias,
|
|
207
|
+
embed=embed,
|
|
208
|
+
description=description,
|
|
209
|
+
example=example,
|
|
210
|
+
)
|
|
211
|
+
|
|
212
|
+
|
|
213
|
+
def Header(
|
|
214
|
+
default: Any = ...,
|
|
215
|
+
*,
|
|
216
|
+
alias: Optional[str] = None,
|
|
217
|
+
description: Optional[str] = None,
|
|
218
|
+
example: Any = None,
|
|
219
|
+
deprecated: bool = False,
|
|
220
|
+
) -> Any:
|
|
221
|
+
"""
|
|
222
|
+
Mark parameter as HTTP header.
|
|
223
|
+
|
|
224
|
+
Args:
|
|
225
|
+
default: Default value (... for required)
|
|
226
|
+
alias: Alternative header name
|
|
227
|
+
description: Parameter description
|
|
228
|
+
example: Example value
|
|
229
|
+
deprecated: Mark as deprecated
|
|
230
|
+
|
|
231
|
+
Returns:
|
|
232
|
+
Param marker instance
|
|
233
|
+
"""
|
|
234
|
+
return Param(
|
|
235
|
+
source="header",
|
|
236
|
+
alias=alias,
|
|
237
|
+
description=description,
|
|
238
|
+
example=example,
|
|
239
|
+
deprecated=deprecated,
|
|
240
|
+
)
|
|
241
|
+
|
|
242
|
+
|
|
243
|
+
def Cookie(
|
|
244
|
+
default: Any = ...,
|
|
245
|
+
*,
|
|
246
|
+
alias: Optional[str] = None,
|
|
247
|
+
description: Optional[str] = None,
|
|
248
|
+
example: Any = None,
|
|
249
|
+
deprecated: bool = False,
|
|
250
|
+
) -> Any:
|
|
251
|
+
"""
|
|
252
|
+
Mark parameter as cookie value.
|
|
253
|
+
|
|
254
|
+
Args:
|
|
255
|
+
default: Default value (... for required)
|
|
256
|
+
alias: Alternative cookie name
|
|
257
|
+
description: Parameter description
|
|
258
|
+
example: Example value
|
|
259
|
+
deprecated: Mark as deprecated
|
|
260
|
+
|
|
261
|
+
Returns:
|
|
262
|
+
Param marker instance
|
|
263
|
+
"""
|
|
264
|
+
return Param(
|
|
265
|
+
source="cookie",
|
|
266
|
+
alias=alias,
|
|
267
|
+
description=description,
|
|
268
|
+
example=example,
|
|
269
|
+
deprecated=deprecated,
|
|
270
|
+
)
|
|
271
|
+
|
|
272
|
+
|
|
273
|
+
def Form(
|
|
274
|
+
default: Any = ...,
|
|
275
|
+
*,
|
|
276
|
+
alias: Optional[str] = None,
|
|
277
|
+
description: Optional[str] = None,
|
|
278
|
+
example: Any = None,
|
|
279
|
+
) -> Any:
|
|
280
|
+
"""
|
|
281
|
+
Mark parameter as form data field.
|
|
282
|
+
|
|
283
|
+
Args:
|
|
284
|
+
default: Default value (... for required)
|
|
285
|
+
alias: Alternative form field name
|
|
286
|
+
description: Parameter description
|
|
287
|
+
example: Example value
|
|
288
|
+
|
|
289
|
+
Returns:
|
|
290
|
+
Param marker instance
|
|
291
|
+
"""
|
|
292
|
+
return Param(
|
|
293
|
+
source="form",
|
|
294
|
+
alias=alias,
|
|
295
|
+
description=description,
|
|
296
|
+
example=example,
|
|
297
|
+
)
|
|
298
|
+
|
|
299
|
+
|
|
300
|
+
def File(
|
|
301
|
+
default: Any = ...,
|
|
302
|
+
*,
|
|
303
|
+
alias: Optional[str] = None,
|
|
304
|
+
description: Optional[str] = None,
|
|
305
|
+
) -> Any:
|
|
306
|
+
"""
|
|
307
|
+
Mark parameter as file upload.
|
|
308
|
+
|
|
309
|
+
Args:
|
|
310
|
+
default: Default value (... for required)
|
|
311
|
+
alias: Alternative form field name
|
|
312
|
+
description: Parameter description
|
|
313
|
+
|
|
314
|
+
Returns:
|
|
315
|
+
Param marker instance
|
|
316
|
+
"""
|
|
317
|
+
return Param(
|
|
318
|
+
source="file",
|
|
319
|
+
alias=alias,
|
|
320
|
+
description=description,
|
|
321
|
+
)
|
|
322
|
+
|
|
323
|
+
|
|
324
|
+
@dataclass(frozen=True)
|
|
325
|
+
class Depends:
|
|
326
|
+
"""
|
|
327
|
+
Dependency injection marker.
|
|
328
|
+
|
|
329
|
+
Marks a parameter as a dependency that will be resolved
|
|
330
|
+
by calling the specified function.
|
|
331
|
+
"""
|
|
332
|
+
|
|
333
|
+
dependency: Optional[Callable[..., Any]] = None
|
|
334
|
+
"""Function to call for dependency resolution"""
|
|
335
|
+
|
|
336
|
+
use_cache: bool = True
|
|
337
|
+
"""Whether to cache the dependency result per request"""
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
"""Request parsing utilities for form and multipart data."""
|
|
2
|
+
from typing import Any, Dict, Tuple
|
|
3
|
+
from io import BytesIO
|
|
4
|
+
import multipart
|
|
5
|
+
|
|
6
|
+
# Cache for max upload size (read once from Django settings)
|
|
7
|
+
_MAX_UPLOAD_SIZE = None
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def get_max_upload_size() -> int:
|
|
11
|
+
"""Get max upload size from Django settings (cached after first call)."""
|
|
12
|
+
global _MAX_UPLOAD_SIZE
|
|
13
|
+
if _MAX_UPLOAD_SIZE is None:
|
|
14
|
+
try:
|
|
15
|
+
from django.conf import settings
|
|
16
|
+
_MAX_UPLOAD_SIZE = getattr(settings, 'BOLT_MAX_UPLOAD_SIZE', 10 * 1024 * 1024) # 10MB default
|
|
17
|
+
except ImportError:
|
|
18
|
+
_MAX_UPLOAD_SIZE = 10 * 1024 * 1024
|
|
19
|
+
return _MAX_UPLOAD_SIZE
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def parse_form_data(request: Dict[str, Any], headers_map: Dict[str, str]) -> Tuple[Dict[str, Any], Dict[str, Any]]:
|
|
23
|
+
"""Parse form and multipart data from request using python "multipart" library."""
|
|
24
|
+
content_type = headers_map.get("content-type", "")
|
|
25
|
+
|
|
26
|
+
# Early return if not form data (optimization for JSON/empty requests)
|
|
27
|
+
if not content_type.startswith(("application/x-www-form-urlencoded", "multipart/form-data")):
|
|
28
|
+
return {}, {}
|
|
29
|
+
|
|
30
|
+
form_map: Dict[str, Any] = {}
|
|
31
|
+
files_map: Dict[str, Any] = {}
|
|
32
|
+
|
|
33
|
+
if content_type.startswith("application/x-www-form-urlencoded"):
|
|
34
|
+
from urllib.parse import parse_qs
|
|
35
|
+
body_bytes: bytes = request["body"]
|
|
36
|
+
form_data = parse_qs(body_bytes.decode("utf-8"))
|
|
37
|
+
# parse_qs returns lists, but for single values we want the value directly
|
|
38
|
+
form_map = {k: v[0] if len(v) == 1 else v for k, v in form_data.items()}
|
|
39
|
+
elif content_type.startswith("multipart/form-data"):
|
|
40
|
+
form_map, files_map = parse_multipart_data(request, content_type)
|
|
41
|
+
|
|
42
|
+
return form_map, files_map
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def parse_multipart_data(request: Dict[str, Any], content_type: str) -> Tuple[Dict[str, Any], Dict[str, Any]]:
|
|
46
|
+
"""
|
|
47
|
+
Parse multipart form data using python "multipart" library.
|
|
48
|
+
|
|
49
|
+
SECURITY: Uses battle-tested multipart library with proper boundary validation,
|
|
50
|
+
size limits, and header parsing.
|
|
51
|
+
"""
|
|
52
|
+
|
|
53
|
+
form_map: Dict[str, Any] = {}
|
|
54
|
+
files_map: Dict[str, Any] = {}
|
|
55
|
+
|
|
56
|
+
# Parse content-type header to get boundary
|
|
57
|
+
content_type_parsed, content_type_options = multipart.parse_options_header(content_type)
|
|
58
|
+
|
|
59
|
+
if content_type_parsed != 'multipart/form-data':
|
|
60
|
+
return form_map, files_map
|
|
61
|
+
|
|
62
|
+
boundary = content_type_options.get('boundary')
|
|
63
|
+
if not boundary:
|
|
64
|
+
return form_map, files_map
|
|
65
|
+
|
|
66
|
+
# SECURITY: Validate boundary (multipart does this, but explicit check)
|
|
67
|
+
if not boundary or len(boundary) > 200: # RFC 2046 suggests max 70, we allow 200
|
|
68
|
+
return form_map, files_map
|
|
69
|
+
|
|
70
|
+
# Get max upload size (cached from Django settings)
|
|
71
|
+
max_size = get_max_upload_size()
|
|
72
|
+
|
|
73
|
+
body_bytes: bytes = request["body"]
|
|
74
|
+
|
|
75
|
+
# SECURITY: Check body size before parsing
|
|
76
|
+
if len(body_bytes) > max_size:
|
|
77
|
+
raise ValueError(f"Upload size {len(body_bytes)} exceeds maximum {max_size} bytes")
|
|
78
|
+
|
|
79
|
+
# Create a file-like object from bytes
|
|
80
|
+
body_file = BytesIO(body_bytes)
|
|
81
|
+
|
|
82
|
+
# Parse using multipart library
|
|
83
|
+
try:
|
|
84
|
+
parser = multipart.MultipartParser(
|
|
85
|
+
body_file,
|
|
86
|
+
boundary=boundary.encode() if isinstance(boundary, str) else boundary,
|
|
87
|
+
content_length=len(body_bytes),
|
|
88
|
+
memory_limit=max_size,
|
|
89
|
+
disk_limit=0, # Don't allow disk spooling for security
|
|
90
|
+
part_limit=100 # Limit number of parts
|
|
91
|
+
)
|
|
92
|
+
|
|
93
|
+
# Iterate through parts
|
|
94
|
+
for part in parser:
|
|
95
|
+
name = part.name
|
|
96
|
+
if not name:
|
|
97
|
+
continue
|
|
98
|
+
|
|
99
|
+
# Check if it's a file or form field
|
|
100
|
+
if part.filename:
|
|
101
|
+
# It's a file upload
|
|
102
|
+
content = part.file.read()
|
|
103
|
+
file_info = {
|
|
104
|
+
"filename": part.filename,
|
|
105
|
+
"content": content,
|
|
106
|
+
"content_type": part.content_type,
|
|
107
|
+
"size": len(content)
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
if name in files_map:
|
|
111
|
+
if isinstance(files_map[name], list):
|
|
112
|
+
files_map[name].append(file_info)
|
|
113
|
+
else:
|
|
114
|
+
files_map[name] = [files_map[name], file_info]
|
|
115
|
+
else:
|
|
116
|
+
files_map[name] = file_info
|
|
117
|
+
else:
|
|
118
|
+
# It's a form field
|
|
119
|
+
form_map[name] = part.value
|
|
120
|
+
|
|
121
|
+
except Exception as e:
|
|
122
|
+
# Return empty maps on parse error (don't expose internal errors)
|
|
123
|
+
import logging
|
|
124
|
+
import traceback
|
|
125
|
+
logging.warning(f"Multipart parsing failed: {e}\n{traceback.format_exc()}")
|
|
126
|
+
return {}, {}
|
|
127
|
+
|
|
128
|
+
return form_map, files_map
|
django_bolt/responses.py
ADDED
|
@@ -0,0 +1,214 @@
|
|
|
1
|
+
import msgspec
|
|
2
|
+
from typing import Any, Dict, Optional, List
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
|
|
5
|
+
# Cache for BOLT_ALLOWED_FILE_PATHS - loaded once at server startup
|
|
6
|
+
_ALLOWED_FILE_PATHS_CACHE: Optional[List[Path]] = None
|
|
7
|
+
_ALLOWED_FILE_PATHS_INITIALIZED = False
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def initialize_file_response_settings():
|
|
11
|
+
"""
|
|
12
|
+
Initialize FileResponse settings cache at server startup.
|
|
13
|
+
This should be called once when the server starts to cache BOLT_ALLOWED_FILE_PATHS.
|
|
14
|
+
"""
|
|
15
|
+
global _ALLOWED_FILE_PATHS_CACHE, _ALLOWED_FILE_PATHS_INITIALIZED
|
|
16
|
+
|
|
17
|
+
if _ALLOWED_FILE_PATHS_INITIALIZED:
|
|
18
|
+
return
|
|
19
|
+
|
|
20
|
+
try:
|
|
21
|
+
from django.conf import settings
|
|
22
|
+
if hasattr(settings, 'BOLT_ALLOWED_FILE_PATHS'):
|
|
23
|
+
allowed_paths = settings.BOLT_ALLOWED_FILE_PATHS
|
|
24
|
+
if allowed_paths:
|
|
25
|
+
# Resolve all paths once at startup
|
|
26
|
+
_ALLOWED_FILE_PATHS_CACHE = [Path(p).resolve() for p in allowed_paths]
|
|
27
|
+
else:
|
|
28
|
+
_ALLOWED_FILE_PATHS_CACHE = None
|
|
29
|
+
else:
|
|
30
|
+
_ALLOWED_FILE_PATHS_CACHE = None
|
|
31
|
+
except ImportError:
|
|
32
|
+
# Django not configured, allow any path (development mode)
|
|
33
|
+
_ALLOWED_FILE_PATHS_CACHE = None
|
|
34
|
+
|
|
35
|
+
_ALLOWED_FILE_PATHS_INITIALIZED = True
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class Response:
|
|
39
|
+
"""
|
|
40
|
+
Generic HTTP response with custom headers.
|
|
41
|
+
|
|
42
|
+
Use this when you need to return a response with custom headers (like Allow for OPTIONS).
|
|
43
|
+
|
|
44
|
+
Examples:
|
|
45
|
+
# OPTIONS handler with Allow header
|
|
46
|
+
@api.options("/items")
|
|
47
|
+
async def options_items():
|
|
48
|
+
return Response({}, headers={"Allow": "GET, POST, PUT, DELETE"})
|
|
49
|
+
|
|
50
|
+
# Custom response with additional headers
|
|
51
|
+
@api.get("/data")
|
|
52
|
+
async def get_data():
|
|
53
|
+
return Response(
|
|
54
|
+
{"result": "data"},
|
|
55
|
+
status_code=200,
|
|
56
|
+
headers={"X-Custom-Header": "value"}
|
|
57
|
+
)
|
|
58
|
+
"""
|
|
59
|
+
def __init__(
|
|
60
|
+
self,
|
|
61
|
+
content: Any = None,
|
|
62
|
+
status_code: int = 200,
|
|
63
|
+
headers: Optional[Dict[str, str]] = None,
|
|
64
|
+
media_type: str = "application/json"
|
|
65
|
+
):
|
|
66
|
+
self.content = content if content is not None else {}
|
|
67
|
+
self.status_code = status_code
|
|
68
|
+
self.headers = headers or {}
|
|
69
|
+
self.media_type = media_type
|
|
70
|
+
|
|
71
|
+
def to_bytes(self) -> bytes:
|
|
72
|
+
if self.media_type == "application/json":
|
|
73
|
+
return msgspec.json.encode(self.content)
|
|
74
|
+
elif isinstance(self.content, str):
|
|
75
|
+
return self.content.encode()
|
|
76
|
+
elif isinstance(self.content, bytes):
|
|
77
|
+
return self.content
|
|
78
|
+
else:
|
|
79
|
+
return str(self.content).encode()
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
class JSON:
|
|
83
|
+
def __init__(self, data: Any, status_code: int = 200, headers: Optional[Dict[str, str]] = None):
|
|
84
|
+
self.data = data
|
|
85
|
+
self.status_code = status_code
|
|
86
|
+
self.headers = headers or {}
|
|
87
|
+
|
|
88
|
+
def to_bytes(self) -> bytes:
|
|
89
|
+
return msgspec.json.encode(self.data)
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
class PlainText:
|
|
94
|
+
def __init__(self, text: str, status_code: int = 200, headers: Optional[Dict[str, str]] = None):
|
|
95
|
+
self.text = text
|
|
96
|
+
self.status_code = status_code
|
|
97
|
+
self.headers = headers or {}
|
|
98
|
+
|
|
99
|
+
def to_bytes(self) -> bytes:
|
|
100
|
+
return self.text.encode()
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
class HTML:
|
|
104
|
+
def __init__(self, html: str, status_code: int = 200, headers: Optional[Dict[str, str]] = None):
|
|
105
|
+
self.html = html
|
|
106
|
+
self.status_code = status_code
|
|
107
|
+
self.headers = headers or {}
|
|
108
|
+
|
|
109
|
+
def to_bytes(self) -> bytes:
|
|
110
|
+
return self.html.encode()
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
class Redirect:
|
|
114
|
+
def __init__(self, url: str, status_code: int = 307, headers: Optional[Dict[str, str]] = None):
|
|
115
|
+
self.url = url
|
|
116
|
+
self.status_code = status_code
|
|
117
|
+
self.headers = headers or {}
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
class File:
|
|
121
|
+
def __init__(self, path: str, *, media_type: Optional[str] = None, filename: Optional[str] = None, status_code: int = 200, headers: Optional[Dict[str, str]] = None):
|
|
122
|
+
self.path = path
|
|
123
|
+
self.media_type = media_type
|
|
124
|
+
self.filename = filename
|
|
125
|
+
self.status_code = status_code
|
|
126
|
+
self.headers = headers or {}
|
|
127
|
+
|
|
128
|
+
def read_bytes(self) -> bytes:
|
|
129
|
+
with open(self.path, "rb") as f:
|
|
130
|
+
return f.read()
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
class UploadFile:
|
|
134
|
+
def __init__(self, name: str, filename: Optional[str], content_type: Optional[str], path: str):
|
|
135
|
+
self.name = name
|
|
136
|
+
self.filename = filename
|
|
137
|
+
self.content_type = content_type
|
|
138
|
+
self.path = path
|
|
139
|
+
|
|
140
|
+
def read(self) -> bytes:
|
|
141
|
+
with open(self.path, "rb") as f:
|
|
142
|
+
return f.read()
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
class FileResponse:
|
|
147
|
+
def __init__(
|
|
148
|
+
self,
|
|
149
|
+
path: str,
|
|
150
|
+
*,
|
|
151
|
+
media_type: Optional[str] = None,
|
|
152
|
+
filename: Optional[str] = None,
|
|
153
|
+
status_code: int = 200,
|
|
154
|
+
headers: Optional[Dict[str, str]] = None,
|
|
155
|
+
):
|
|
156
|
+
# SECURITY: Validate and canonicalize path to prevent traversal
|
|
157
|
+
|
|
158
|
+
# Convert to absolute path and resolve any .. or symlinks
|
|
159
|
+
try:
|
|
160
|
+
resolved_path = Path(path).resolve()
|
|
161
|
+
except (OSError, RuntimeError) as e:
|
|
162
|
+
raise ValueError(f"Invalid file path: {e}")
|
|
163
|
+
|
|
164
|
+
# Check if the file exists and is a regular file (not a directory or special file)
|
|
165
|
+
if not resolved_path.exists():
|
|
166
|
+
raise FileNotFoundError(f"File not found: {path}")
|
|
167
|
+
|
|
168
|
+
if not resolved_path.is_file():
|
|
169
|
+
raise ValueError(f"Path is not a regular file: {path}")
|
|
170
|
+
|
|
171
|
+
# Check against allowed directories if configured (using cached value)
|
|
172
|
+
if _ALLOWED_FILE_PATHS_CACHE is not None:
|
|
173
|
+
# Ensure the resolved path is within one of the allowed directories
|
|
174
|
+
is_allowed = False
|
|
175
|
+
for allowed_path in _ALLOWED_FILE_PATHS_CACHE:
|
|
176
|
+
try:
|
|
177
|
+
# Check if resolved_path is relative to allowed_path
|
|
178
|
+
resolved_path.relative_to(allowed_path)
|
|
179
|
+
is_allowed = True
|
|
180
|
+
break
|
|
181
|
+
except ValueError:
|
|
182
|
+
# Not a subpath, continue checking
|
|
183
|
+
continue
|
|
184
|
+
|
|
185
|
+
if not is_allowed:
|
|
186
|
+
raise PermissionError(
|
|
187
|
+
f"File path '{path}' is not within allowed directories. "
|
|
188
|
+
f"Configure BOLT_ALLOWED_FILE_PATHS in Django settings."
|
|
189
|
+
)
|
|
190
|
+
|
|
191
|
+
self.path = str(resolved_path)
|
|
192
|
+
self.media_type = media_type
|
|
193
|
+
self.filename = filename
|
|
194
|
+
self.status_code = status_code
|
|
195
|
+
self.headers = headers or {}
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
class StreamingResponse:
|
|
200
|
+
def __init__(
|
|
201
|
+
self,
|
|
202
|
+
content: Any,
|
|
203
|
+
*,
|
|
204
|
+
status_code: int = 200,
|
|
205
|
+
media_type: Optional[str] = None,
|
|
206
|
+
headers: Optional[Dict[str, str]] = None,
|
|
207
|
+
):
|
|
208
|
+
# content can be an iterator/generator, iterable, or a callable returning an iterator
|
|
209
|
+
self.content = content
|
|
210
|
+
self.status_code = status_code
|
|
211
|
+
self.media_type = media_type or "application/octet-stream"
|
|
212
|
+
self.headers = headers or {}
|
|
213
|
+
# do not enforce type of content here; Rust side will adapt common iterator/callable patterns
|
|
214
|
+
|