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.

Files changed (128) hide show
  1. django_bolt/__init__.py +147 -0
  2. django_bolt/_core.abi3.so +0 -0
  3. django_bolt/admin/__init__.py +25 -0
  4. django_bolt/admin/admin_detection.py +179 -0
  5. django_bolt/admin/asgi_bridge.py +267 -0
  6. django_bolt/admin/routes.py +91 -0
  7. django_bolt/admin/static.py +155 -0
  8. django_bolt/admin/static_routes.py +111 -0
  9. django_bolt/api.py +1011 -0
  10. django_bolt/apps.py +7 -0
  11. django_bolt/async_collector.py +228 -0
  12. django_bolt/auth/README.md +464 -0
  13. django_bolt/auth/REVOCATION_EXAMPLE.md +391 -0
  14. django_bolt/auth/__init__.py +84 -0
  15. django_bolt/auth/backends.py +236 -0
  16. django_bolt/auth/guards.py +224 -0
  17. django_bolt/auth/jwt_utils.py +212 -0
  18. django_bolt/auth/revocation.py +286 -0
  19. django_bolt/auth/token.py +335 -0
  20. django_bolt/binding.py +363 -0
  21. django_bolt/bootstrap.py +77 -0
  22. django_bolt/cli.py +133 -0
  23. django_bolt/compression.py +104 -0
  24. django_bolt/decorators.py +159 -0
  25. django_bolt/dependencies.py +128 -0
  26. django_bolt/error_handlers.py +305 -0
  27. django_bolt/exceptions.py +294 -0
  28. django_bolt/health.py +129 -0
  29. django_bolt/logging/__init__.py +6 -0
  30. django_bolt/logging/config.py +357 -0
  31. django_bolt/logging/middleware.py +296 -0
  32. django_bolt/management/__init__.py +1 -0
  33. django_bolt/management/commands/__init__.py +0 -0
  34. django_bolt/management/commands/runbolt.py +427 -0
  35. django_bolt/middleware/__init__.py +32 -0
  36. django_bolt/middleware/compiler.py +131 -0
  37. django_bolt/middleware/middleware.py +247 -0
  38. django_bolt/openapi/__init__.py +23 -0
  39. django_bolt/openapi/config.py +196 -0
  40. django_bolt/openapi/plugins.py +439 -0
  41. django_bolt/openapi/routes.py +152 -0
  42. django_bolt/openapi/schema_generator.py +581 -0
  43. django_bolt/openapi/spec/__init__.py +68 -0
  44. django_bolt/openapi/spec/base.py +74 -0
  45. django_bolt/openapi/spec/callback.py +24 -0
  46. django_bolt/openapi/spec/components.py +72 -0
  47. django_bolt/openapi/spec/contact.py +21 -0
  48. django_bolt/openapi/spec/discriminator.py +25 -0
  49. django_bolt/openapi/spec/encoding.py +67 -0
  50. django_bolt/openapi/spec/enums.py +41 -0
  51. django_bolt/openapi/spec/example.py +36 -0
  52. django_bolt/openapi/spec/external_documentation.py +21 -0
  53. django_bolt/openapi/spec/header.py +132 -0
  54. django_bolt/openapi/spec/info.py +50 -0
  55. django_bolt/openapi/spec/license.py +28 -0
  56. django_bolt/openapi/spec/link.py +66 -0
  57. django_bolt/openapi/spec/media_type.py +51 -0
  58. django_bolt/openapi/spec/oauth_flow.py +36 -0
  59. django_bolt/openapi/spec/oauth_flows.py +28 -0
  60. django_bolt/openapi/spec/open_api.py +87 -0
  61. django_bolt/openapi/spec/operation.py +105 -0
  62. django_bolt/openapi/spec/parameter.py +147 -0
  63. django_bolt/openapi/spec/path_item.py +78 -0
  64. django_bolt/openapi/spec/paths.py +27 -0
  65. django_bolt/openapi/spec/reference.py +38 -0
  66. django_bolt/openapi/spec/request_body.py +38 -0
  67. django_bolt/openapi/spec/response.py +48 -0
  68. django_bolt/openapi/spec/responses.py +44 -0
  69. django_bolt/openapi/spec/schema.py +678 -0
  70. django_bolt/openapi/spec/security_requirement.py +28 -0
  71. django_bolt/openapi/spec/security_scheme.py +69 -0
  72. django_bolt/openapi/spec/server.py +34 -0
  73. django_bolt/openapi/spec/server_variable.py +32 -0
  74. django_bolt/openapi/spec/tag.py +32 -0
  75. django_bolt/openapi/spec/xml.py +44 -0
  76. django_bolt/pagination.py +669 -0
  77. django_bolt/param_functions.py +49 -0
  78. django_bolt/params.py +337 -0
  79. django_bolt/request_parsing.py +128 -0
  80. django_bolt/responses.py +214 -0
  81. django_bolt/router.py +48 -0
  82. django_bolt/serialization.py +193 -0
  83. django_bolt/status_codes.py +321 -0
  84. django_bolt/testing/__init__.py +10 -0
  85. django_bolt/testing/client.py +274 -0
  86. django_bolt/testing/helpers.py +93 -0
  87. django_bolt/tests/__init__.py +0 -0
  88. django_bolt/tests/admin_tests/__init__.py +1 -0
  89. django_bolt/tests/admin_tests/conftest.py +6 -0
  90. django_bolt/tests/admin_tests/test_admin_with_django.py +278 -0
  91. django_bolt/tests/admin_tests/urls.py +9 -0
  92. django_bolt/tests/cbv/__init__.py +0 -0
  93. django_bolt/tests/cbv/test_class_views.py +570 -0
  94. django_bolt/tests/cbv/test_class_views_django_orm.py +703 -0
  95. django_bolt/tests/cbv/test_class_views_features.py +1173 -0
  96. django_bolt/tests/cbv/test_class_views_with_client.py +622 -0
  97. django_bolt/tests/conftest.py +165 -0
  98. django_bolt/tests/test_action_decorator.py +399 -0
  99. django_bolt/tests/test_auth_secret_key.py +83 -0
  100. django_bolt/tests/test_decorator_syntax.py +159 -0
  101. django_bolt/tests/test_error_handling.py +481 -0
  102. django_bolt/tests/test_file_response.py +192 -0
  103. django_bolt/tests/test_global_cors.py +172 -0
  104. django_bolt/tests/test_guards_auth.py +441 -0
  105. django_bolt/tests/test_guards_integration.py +303 -0
  106. django_bolt/tests/test_health.py +283 -0
  107. django_bolt/tests/test_integration_validation.py +400 -0
  108. django_bolt/tests/test_json_validation.py +536 -0
  109. django_bolt/tests/test_jwt_auth.py +327 -0
  110. django_bolt/tests/test_jwt_token.py +458 -0
  111. django_bolt/tests/test_logging.py +837 -0
  112. django_bolt/tests/test_logging_merge.py +419 -0
  113. django_bolt/tests/test_middleware.py +492 -0
  114. django_bolt/tests/test_middleware_server.py +230 -0
  115. django_bolt/tests/test_model_viewset.py +323 -0
  116. django_bolt/tests/test_models.py +24 -0
  117. django_bolt/tests/test_pagination.py +1258 -0
  118. django_bolt/tests/test_parameter_validation.py +178 -0
  119. django_bolt/tests/test_syntax.py +626 -0
  120. django_bolt/tests/test_testing_utilities.py +163 -0
  121. django_bolt/tests/test_testing_utilities_simple.py +123 -0
  122. django_bolt/tests/test_viewset_unified.py +346 -0
  123. django_bolt/typing.py +273 -0
  124. django_bolt/views.py +1110 -0
  125. django_bolt-0.1.0.dist-info/METADATA +629 -0
  126. django_bolt-0.1.0.dist-info/RECORD +128 -0
  127. django_bolt-0.1.0.dist-info/WHEEL +4 -0
  128. 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
@@ -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
+