django-bolt 0.1.0__cp310-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Potentially problematic release.
This version of django-bolt might be problematic. Click here for more details.
- django_bolt/__init__.py +147 -0
- django_bolt/_core.abi3.so +0 -0
- django_bolt/admin/__init__.py +25 -0
- django_bolt/admin/admin_detection.py +179 -0
- django_bolt/admin/asgi_bridge.py +267 -0
- django_bolt/admin/routes.py +91 -0
- django_bolt/admin/static.py +155 -0
- django_bolt/admin/static_routes.py +111 -0
- django_bolt/api.py +1011 -0
- django_bolt/apps.py +7 -0
- django_bolt/async_collector.py +228 -0
- django_bolt/auth/README.md +464 -0
- django_bolt/auth/REVOCATION_EXAMPLE.md +391 -0
- django_bolt/auth/__init__.py +84 -0
- django_bolt/auth/backends.py +236 -0
- django_bolt/auth/guards.py +224 -0
- django_bolt/auth/jwt_utils.py +212 -0
- django_bolt/auth/revocation.py +286 -0
- django_bolt/auth/token.py +335 -0
- django_bolt/binding.py +363 -0
- django_bolt/bootstrap.py +77 -0
- django_bolt/cli.py +133 -0
- django_bolt/compression.py +104 -0
- django_bolt/decorators.py +159 -0
- django_bolt/dependencies.py +128 -0
- django_bolt/error_handlers.py +305 -0
- django_bolt/exceptions.py +294 -0
- django_bolt/health.py +129 -0
- django_bolt/logging/__init__.py +6 -0
- django_bolt/logging/config.py +357 -0
- django_bolt/logging/middleware.py +296 -0
- django_bolt/management/__init__.py +1 -0
- django_bolt/management/commands/__init__.py +0 -0
- django_bolt/management/commands/runbolt.py +427 -0
- django_bolt/middleware/__init__.py +32 -0
- django_bolt/middleware/compiler.py +131 -0
- django_bolt/middleware/middleware.py +247 -0
- django_bolt/openapi/__init__.py +23 -0
- django_bolt/openapi/config.py +196 -0
- django_bolt/openapi/plugins.py +439 -0
- django_bolt/openapi/routes.py +152 -0
- django_bolt/openapi/schema_generator.py +581 -0
- django_bolt/openapi/spec/__init__.py +68 -0
- django_bolt/openapi/spec/base.py +74 -0
- django_bolt/openapi/spec/callback.py +24 -0
- django_bolt/openapi/spec/components.py +72 -0
- django_bolt/openapi/spec/contact.py +21 -0
- django_bolt/openapi/spec/discriminator.py +25 -0
- django_bolt/openapi/spec/encoding.py +67 -0
- django_bolt/openapi/spec/enums.py +41 -0
- django_bolt/openapi/spec/example.py +36 -0
- django_bolt/openapi/spec/external_documentation.py +21 -0
- django_bolt/openapi/spec/header.py +132 -0
- django_bolt/openapi/spec/info.py +50 -0
- django_bolt/openapi/spec/license.py +28 -0
- django_bolt/openapi/spec/link.py +66 -0
- django_bolt/openapi/spec/media_type.py +51 -0
- django_bolt/openapi/spec/oauth_flow.py +36 -0
- django_bolt/openapi/spec/oauth_flows.py +28 -0
- django_bolt/openapi/spec/open_api.py +87 -0
- django_bolt/openapi/spec/operation.py +105 -0
- django_bolt/openapi/spec/parameter.py +147 -0
- django_bolt/openapi/spec/path_item.py +78 -0
- django_bolt/openapi/spec/paths.py +27 -0
- django_bolt/openapi/spec/reference.py +38 -0
- django_bolt/openapi/spec/request_body.py +38 -0
- django_bolt/openapi/spec/response.py +48 -0
- django_bolt/openapi/spec/responses.py +44 -0
- django_bolt/openapi/spec/schema.py +678 -0
- django_bolt/openapi/spec/security_requirement.py +28 -0
- django_bolt/openapi/spec/security_scheme.py +69 -0
- django_bolt/openapi/spec/server.py +34 -0
- django_bolt/openapi/spec/server_variable.py +32 -0
- django_bolt/openapi/spec/tag.py +32 -0
- django_bolt/openapi/spec/xml.py +44 -0
- django_bolt/pagination.py +669 -0
- django_bolt/param_functions.py +49 -0
- django_bolt/params.py +337 -0
- django_bolt/request_parsing.py +128 -0
- django_bolt/responses.py +214 -0
- django_bolt/router.py +48 -0
- django_bolt/serialization.py +193 -0
- django_bolt/status_codes.py +321 -0
- django_bolt/testing/__init__.py +10 -0
- django_bolt/testing/client.py +274 -0
- django_bolt/testing/helpers.py +93 -0
- django_bolt/tests/__init__.py +0 -0
- django_bolt/tests/admin_tests/__init__.py +1 -0
- django_bolt/tests/admin_tests/conftest.py +6 -0
- django_bolt/tests/admin_tests/test_admin_with_django.py +278 -0
- django_bolt/tests/admin_tests/urls.py +9 -0
- django_bolt/tests/cbv/__init__.py +0 -0
- django_bolt/tests/cbv/test_class_views.py +570 -0
- django_bolt/tests/cbv/test_class_views_django_orm.py +703 -0
- django_bolt/tests/cbv/test_class_views_features.py +1173 -0
- django_bolt/tests/cbv/test_class_views_with_client.py +622 -0
- django_bolt/tests/conftest.py +165 -0
- django_bolt/tests/test_action_decorator.py +399 -0
- django_bolt/tests/test_auth_secret_key.py +83 -0
- django_bolt/tests/test_decorator_syntax.py +159 -0
- django_bolt/tests/test_error_handling.py +481 -0
- django_bolt/tests/test_file_response.py +192 -0
- django_bolt/tests/test_global_cors.py +172 -0
- django_bolt/tests/test_guards_auth.py +441 -0
- django_bolt/tests/test_guards_integration.py +303 -0
- django_bolt/tests/test_health.py +283 -0
- django_bolt/tests/test_integration_validation.py +400 -0
- django_bolt/tests/test_json_validation.py +536 -0
- django_bolt/tests/test_jwt_auth.py +327 -0
- django_bolt/tests/test_jwt_token.py +458 -0
- django_bolt/tests/test_logging.py +837 -0
- django_bolt/tests/test_logging_merge.py +419 -0
- django_bolt/tests/test_middleware.py +492 -0
- django_bolt/tests/test_middleware_server.py +230 -0
- django_bolt/tests/test_model_viewset.py +323 -0
- django_bolt/tests/test_models.py +24 -0
- django_bolt/tests/test_pagination.py +1258 -0
- django_bolt/tests/test_parameter_validation.py +178 -0
- django_bolt/tests/test_syntax.py +626 -0
- django_bolt/tests/test_testing_utilities.py +163 -0
- django_bolt/tests/test_testing_utilities_simple.py +123 -0
- django_bolt/tests/test_viewset_unified.py +346 -0
- django_bolt/typing.py +273 -0
- django_bolt/views.py +1110 -0
- django_bolt-0.1.0.dist-info/METADATA +629 -0
- django_bolt-0.1.0.dist-info/RECORD +128 -0
- django_bolt-0.1.0.dist-info/WHEEL +4 -0
- django_bolt-0.1.0.dist-info/entry_points.txt +2 -0
|
@@ -0,0 +1,427 @@
|
|
|
1
|
+
import importlib
|
|
2
|
+
import sys
|
|
3
|
+
from django.core.management.base import BaseCommand, CommandError
|
|
4
|
+
from django.conf import settings
|
|
5
|
+
from django.apps import apps
|
|
6
|
+
|
|
7
|
+
from django_bolt.api import BoltAPI
|
|
8
|
+
from django_bolt import _core
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class Command(BaseCommand):
|
|
12
|
+
help = "Run Django-Bolt server with autodiscovered APIs"
|
|
13
|
+
|
|
14
|
+
def add_arguments(self, parser):
|
|
15
|
+
parser.add_argument(
|
|
16
|
+
"--host", default="0.0.0.0", help="Host to bind to (default: 0.0.0)"
|
|
17
|
+
)
|
|
18
|
+
parser.add_argument(
|
|
19
|
+
"--port", type=int, default=8000, help="Port to bind to (default: 8000)"
|
|
20
|
+
)
|
|
21
|
+
parser.add_argument(
|
|
22
|
+
"--processes", type=int, default=1, help="Number of processes (default: 1)"
|
|
23
|
+
)
|
|
24
|
+
parser.add_argument(
|
|
25
|
+
"--workers", type=int, default=2, help="Workers per process (default: 2)"
|
|
26
|
+
)
|
|
27
|
+
parser.add_argument(
|
|
28
|
+
"--no-admin",
|
|
29
|
+
action="store_true",
|
|
30
|
+
help="Disable Django admin integration (admin enabled by default)",
|
|
31
|
+
)
|
|
32
|
+
parser.add_argument(
|
|
33
|
+
"--dev",
|
|
34
|
+
action="store_true",
|
|
35
|
+
help="Enable auto-reload on file changes (development mode)"
|
|
36
|
+
)
|
|
37
|
+
|
|
38
|
+
def handle(self, *args, **options):
|
|
39
|
+
processes = options['processes']
|
|
40
|
+
dev_mode = options.get('dev', False)
|
|
41
|
+
|
|
42
|
+
# Dev mode: force single process + single worker + enable auto-reload
|
|
43
|
+
if dev_mode:
|
|
44
|
+
options['workers'] = 1
|
|
45
|
+
if processes > 1:
|
|
46
|
+
self.stdout.write(
|
|
47
|
+
self.style.WARNING(
|
|
48
|
+
"[django-bolt] Dev mode enabled: forcing --processes=1 for auto-reload"
|
|
49
|
+
)
|
|
50
|
+
)
|
|
51
|
+
options['processes'] = 1
|
|
52
|
+
|
|
53
|
+
self.stdout.write(
|
|
54
|
+
self.style.SUCCESS("[django-bolt] 🔥 Development mode enabled (auto-reload on file changes)")
|
|
55
|
+
)
|
|
56
|
+
self.run_with_autoreload(options)
|
|
57
|
+
else:
|
|
58
|
+
# Production mode (current logic)
|
|
59
|
+
if processes > 1:
|
|
60
|
+
self.start_multiprocess(options)
|
|
61
|
+
else:
|
|
62
|
+
self.start_single_process(options)
|
|
63
|
+
|
|
64
|
+
def run_with_autoreload(self, options):
|
|
65
|
+
"""Run server with auto-reload using Django's autoreload system"""
|
|
66
|
+
try:
|
|
67
|
+
from django.utils import autoreload
|
|
68
|
+
except ImportError:
|
|
69
|
+
self.stdout.write(
|
|
70
|
+
self.style.ERROR(
|
|
71
|
+
"[django-bolt] Error: Django autoreload not available. "
|
|
72
|
+
"Upgrade Django or use --no-dev mode."
|
|
73
|
+
)
|
|
74
|
+
)
|
|
75
|
+
import sys
|
|
76
|
+
sys.exit(1)
|
|
77
|
+
|
|
78
|
+
# Use Django's autoreload system which is optimized
|
|
79
|
+
# It only restarts the Python interpreter when necessary
|
|
80
|
+
# and reuses the same process for faster reloads
|
|
81
|
+
def run_server():
|
|
82
|
+
self.start_single_process(options)
|
|
83
|
+
|
|
84
|
+
autoreload.run_with_reloader(run_server)
|
|
85
|
+
|
|
86
|
+
def start_multiprocess(self, options):
|
|
87
|
+
"""Start multiple processes with SO_REUSEPORT"""
|
|
88
|
+
import os
|
|
89
|
+
import sys
|
|
90
|
+
import signal
|
|
91
|
+
|
|
92
|
+
processes = options['processes']
|
|
93
|
+
self.stdout.write(f"[django-bolt] Starting {processes} processes with SO_REUSEPORT")
|
|
94
|
+
|
|
95
|
+
# Store child PIDs for cleanup
|
|
96
|
+
child_pids = []
|
|
97
|
+
|
|
98
|
+
def signal_handler(signum, frame):
|
|
99
|
+
self.stdout.write("\n[django-bolt] Shutting down processes...")
|
|
100
|
+
for pid in child_pids:
|
|
101
|
+
try:
|
|
102
|
+
os.kill(pid, signal.SIGTERM)
|
|
103
|
+
except ProcessLookupError:
|
|
104
|
+
pass
|
|
105
|
+
sys.exit(0)
|
|
106
|
+
|
|
107
|
+
signal.signal(signal.SIGINT, signal_handler)
|
|
108
|
+
signal.signal(signal.SIGTERM, signal_handler)
|
|
109
|
+
|
|
110
|
+
# Fork processes
|
|
111
|
+
for i in range(processes):
|
|
112
|
+
pid = os.fork()
|
|
113
|
+
if pid == 0:
|
|
114
|
+
# Child process
|
|
115
|
+
os.environ['DJANGO_BOLT_REUSE_PORT'] = '1'
|
|
116
|
+
os.environ['DJANGO_BOLT_PROCESS_ID'] = str(i)
|
|
117
|
+
self.start_single_process(options, process_id=i)
|
|
118
|
+
sys.exit(0)
|
|
119
|
+
else:
|
|
120
|
+
# Parent process
|
|
121
|
+
child_pids.append(pid)
|
|
122
|
+
self.stdout.write(f"[django-bolt] Started process {i} (PID: {pid})")
|
|
123
|
+
|
|
124
|
+
# Parent waits for children
|
|
125
|
+
try:
|
|
126
|
+
while True:
|
|
127
|
+
pid, status = os.wait()
|
|
128
|
+
self.stdout.write(f"[django-bolt] Process {pid} exited with status {status}")
|
|
129
|
+
if pid in child_pids:
|
|
130
|
+
child_pids.remove(pid)
|
|
131
|
+
if not child_pids:
|
|
132
|
+
break
|
|
133
|
+
except KeyboardInterrupt:
|
|
134
|
+
pass
|
|
135
|
+
|
|
136
|
+
def start_single_process(self, options, process_id=None):
|
|
137
|
+
"""Start a single process server"""
|
|
138
|
+
# Setup Django logging once at server startup (one-shot, respects existing LOGGING)
|
|
139
|
+
from django_bolt.logging.config import setup_django_logging
|
|
140
|
+
setup_django_logging()
|
|
141
|
+
|
|
142
|
+
# Initialize FileResponse settings cache once at server startup
|
|
143
|
+
from django_bolt.responses import initialize_file_response_settings
|
|
144
|
+
initialize_file_response_settings()
|
|
145
|
+
|
|
146
|
+
if process_id is not None:
|
|
147
|
+
self.stdout.write(f"[django-bolt] Process {process_id}: Starting autodiscovery...")
|
|
148
|
+
else:
|
|
149
|
+
self.stdout.write("[django-bolt] Starting autodiscovery...")
|
|
150
|
+
|
|
151
|
+
# Autodiscover BoltAPI instances
|
|
152
|
+
apis = self.autodiscover_apis()
|
|
153
|
+
|
|
154
|
+
if not apis:
|
|
155
|
+
self.stdout.write(
|
|
156
|
+
self.style.WARNING("No BoltAPI instances found. Create api.py files with api = BoltAPI()")
|
|
157
|
+
)
|
|
158
|
+
return
|
|
159
|
+
|
|
160
|
+
# Merge all APIs and collect routes FIRST
|
|
161
|
+
merged_api = self.merge_apis(apis)
|
|
162
|
+
|
|
163
|
+
# Register OpenAPI routes AFTER merging (so schema includes all routes)
|
|
164
|
+
openapi_enabled = False
|
|
165
|
+
openapi_config = None
|
|
166
|
+
|
|
167
|
+
# Find first API with OpenAPI config
|
|
168
|
+
for api_path, api in apis:
|
|
169
|
+
if api.openapi_config:
|
|
170
|
+
openapi_config = api.openapi_config
|
|
171
|
+
openapi_enabled = True
|
|
172
|
+
break
|
|
173
|
+
|
|
174
|
+
# Register OpenAPI routes on merged API if any API had OpenAPI enabled
|
|
175
|
+
if openapi_enabled and openapi_config:
|
|
176
|
+
# Transfer OpenAPI config to merged API
|
|
177
|
+
merged_api.openapi_config = openapi_config
|
|
178
|
+
merged_api._register_openapi_routes()
|
|
179
|
+
|
|
180
|
+
if process_id is not None:
|
|
181
|
+
self.stdout.write(f"[django-bolt] Process {process_id}: OpenAPI docs enabled at {openapi_config.path}")
|
|
182
|
+
else:
|
|
183
|
+
self.stdout.write(self.style.SUCCESS(f"[django-bolt] OpenAPI docs enabled at {openapi_config.path}"))
|
|
184
|
+
|
|
185
|
+
# Register Django admin routes if not disabled
|
|
186
|
+
# Admin is controlled solely by --no-admin command-line flag
|
|
187
|
+
admin_enabled = not options.get('no_admin', False)
|
|
188
|
+
|
|
189
|
+
if admin_enabled:
|
|
190
|
+
# Register admin routes
|
|
191
|
+
merged_api._register_admin_routes(options['host'], options['port'])
|
|
192
|
+
|
|
193
|
+
if merged_api._admin_routes_registered:
|
|
194
|
+
from django_bolt.admin.admin_detection import detect_admin_url_prefix
|
|
195
|
+
admin_prefix = detect_admin_url_prefix() or 'admin'
|
|
196
|
+
if process_id is not None:
|
|
197
|
+
self.stdout.write(f"[django-bolt] Process {process_id}: Django admin enabled at http://{options['host']}:{options['port']}/{admin_prefix}/")
|
|
198
|
+
else:
|
|
199
|
+
self.stdout.write(self.style.SUCCESS(f"[django-bolt] Django admin enabled at http://{options['host']}:{options['port']}/{admin_prefix}/"))
|
|
200
|
+
|
|
201
|
+
# Also register static file routes for admin
|
|
202
|
+
merged_api._register_static_routes()
|
|
203
|
+
if merged_api._static_routes_registered:
|
|
204
|
+
if process_id is not None:
|
|
205
|
+
self.stdout.write(f"[django-bolt] Process {process_id}: Static files serving enabled")
|
|
206
|
+
else:
|
|
207
|
+
self.stdout.write("[django-bolt] Static files serving enabled")
|
|
208
|
+
|
|
209
|
+
if process_id is not None:
|
|
210
|
+
self.stdout.write(f"[django-bolt] Process {process_id}: Found {len(merged_api._routes)} routes from {len(apis)} APIs")
|
|
211
|
+
else:
|
|
212
|
+
self.stdout.write(
|
|
213
|
+
self.style.SUCCESS(f"[django-bolt] Found {len(merged_api._routes)} routes")
|
|
214
|
+
)
|
|
215
|
+
|
|
216
|
+
# Register routes with Rust
|
|
217
|
+
rust_routes = []
|
|
218
|
+
for method, path, handler_id, handler in merged_api._routes:
|
|
219
|
+
# Ensure matchit path syntax
|
|
220
|
+
from django_bolt.api import BoltAPI
|
|
221
|
+
convert = getattr(merged_api, "_convert_path", None)
|
|
222
|
+
norm_path = convert(path) if callable(convert) else path
|
|
223
|
+
rust_routes.append((method, norm_path, handler_id, handler))
|
|
224
|
+
|
|
225
|
+
_core.register_routes(rust_routes)
|
|
226
|
+
|
|
227
|
+
# Register middleware metadata if present
|
|
228
|
+
if merged_api._handler_middleware:
|
|
229
|
+
middleware_data = [
|
|
230
|
+
(handler_id, meta)
|
|
231
|
+
for handler_id, meta in merged_api._handler_middleware.items()
|
|
232
|
+
]
|
|
233
|
+
_core.register_middleware_metadata(middleware_data)
|
|
234
|
+
if process_id is not None:
|
|
235
|
+
self.stdout.write(f"[django-bolt] Process {process_id}: Registered middleware for {len(middleware_data)} handlers")
|
|
236
|
+
else:
|
|
237
|
+
self.stdout.write(f"[django-bolt] Registered middleware for {len(middleware_data)} handlers")
|
|
238
|
+
|
|
239
|
+
if process_id is not None:
|
|
240
|
+
self.stdout.write(self.style.SUCCESS(f"[django-bolt] Process {process_id}: Starting server on http://{options['host']}:{options['port']}"))
|
|
241
|
+
self.stdout.write(f"[django-bolt] Process {process_id}: Workers: {options['workers']}")
|
|
242
|
+
else:
|
|
243
|
+
self.stdout.write(self.style.SUCCESS(f"[django-bolt] Starting server on http://{options['host']}:{options['port']}"))
|
|
244
|
+
self.stdout.write(f"[django-bolt] Workers: {options['workers']}, Processes: {options['processes']}")
|
|
245
|
+
self.stdout.write(self.style.SUCCESS(f"[django-bolt] OpenAPI docs enabled at http://{options['host']}:{options['port']}/docs/"))
|
|
246
|
+
# Set environment variable for Rust to read worker count
|
|
247
|
+
import os
|
|
248
|
+
os.environ['DJANGO_BOLT_WORKERS'] = str(options['workers'])
|
|
249
|
+
|
|
250
|
+
# Determine compression config (server-level in Actix)
|
|
251
|
+
# Priority: Django setting > first API with compression config
|
|
252
|
+
compression_config = None
|
|
253
|
+
if hasattr(settings, 'BOLT_COMPRESSION'):
|
|
254
|
+
# Use Django setting if provided (highest priority)
|
|
255
|
+
if settings.BOLT_COMPRESSION is not None and settings.BOLT_COMPRESSION is not False:
|
|
256
|
+
compression_config = settings.BOLT_COMPRESSION.to_rust_config()
|
|
257
|
+
else:
|
|
258
|
+
# Check if any API has compression configured
|
|
259
|
+
for api_path, api in apis:
|
|
260
|
+
if hasattr(api, 'compression') and api.compression is not None:
|
|
261
|
+
compression_config = api.compression.to_rust_config()
|
|
262
|
+
break
|
|
263
|
+
|
|
264
|
+
# Start the server
|
|
265
|
+
_core.start_server_async(merged_api._dispatch, options["host"], options["port"], compression_config)
|
|
266
|
+
|
|
267
|
+
def autodiscover_apis(self):
|
|
268
|
+
"""Discover BoltAPI instances from installed apps.
|
|
269
|
+
|
|
270
|
+
Deduplicates by object identity to ensure each handler uses the FIRST
|
|
271
|
+
API instance created (with correct config), not duplicates from re-imports.
|
|
272
|
+
"""
|
|
273
|
+
apis = []
|
|
274
|
+
|
|
275
|
+
# Check explicit settings first
|
|
276
|
+
if hasattr(settings, 'BOLT_API'):
|
|
277
|
+
for api_path in settings.BOLT_API:
|
|
278
|
+
api = self.import_api(api_path)
|
|
279
|
+
if api:
|
|
280
|
+
apis.append((api_path, api))
|
|
281
|
+
return self._deduplicate_apis(apis)
|
|
282
|
+
|
|
283
|
+
# Try project-level API first (common pattern)
|
|
284
|
+
project_name = settings.ROOT_URLCONF.split('.')[0] # Extract project name from ROOT_URLCONF
|
|
285
|
+
project_candidates = [
|
|
286
|
+
f"{project_name}.api:api",
|
|
287
|
+
f"{project_name}.bolt_api:api",
|
|
288
|
+
]
|
|
289
|
+
|
|
290
|
+
for candidate in project_candidates:
|
|
291
|
+
api = self.import_api(candidate)
|
|
292
|
+
if api:
|
|
293
|
+
apis.append((candidate, api))
|
|
294
|
+
|
|
295
|
+
# Track which apps we've already imported (to avoid duplicates)
|
|
296
|
+
imported_apps = {api_path.split(':')[0].split('.')[0] for api_path, _ in apis}
|
|
297
|
+
|
|
298
|
+
# Autodiscover from installed apps
|
|
299
|
+
for app_config in apps.get_app_configs():
|
|
300
|
+
# Skip django_bolt itself
|
|
301
|
+
if app_config.name == 'django_bolt':
|
|
302
|
+
continue
|
|
303
|
+
|
|
304
|
+
# Skip if we already imported this app at project level
|
|
305
|
+
app_base = app_config.name.split('.')[0]
|
|
306
|
+
if app_base in imported_apps:
|
|
307
|
+
continue
|
|
308
|
+
|
|
309
|
+
# Check if app config has bolt_api hint
|
|
310
|
+
if hasattr(app_config, 'bolt_api'):
|
|
311
|
+
api = self.import_api(app_config.bolt_api)
|
|
312
|
+
if api:
|
|
313
|
+
apis.append((app_config.bolt_api, api))
|
|
314
|
+
continue
|
|
315
|
+
|
|
316
|
+
# Try standard locations
|
|
317
|
+
app_name = app_config.name
|
|
318
|
+
candidates = [
|
|
319
|
+
f"{app_name}.api:api",
|
|
320
|
+
f"{app_name}.bolt_api:api",
|
|
321
|
+
]
|
|
322
|
+
|
|
323
|
+
for candidate in candidates:
|
|
324
|
+
api = self.import_api(candidate)
|
|
325
|
+
if api:
|
|
326
|
+
apis.append((candidate, api))
|
|
327
|
+
break # Only take first match per app
|
|
328
|
+
|
|
329
|
+
return self._deduplicate_apis(apis)
|
|
330
|
+
|
|
331
|
+
def _deduplicate_apis(self, apis):
|
|
332
|
+
"""Deduplicate APIs by object identity.
|
|
333
|
+
|
|
334
|
+
This ensures each handler uses the FIRST API instance created (with original
|
|
335
|
+
config), not duplicates from module re-imports. Critical for preserving
|
|
336
|
+
per-API logging, auth, and middleware configs.
|
|
337
|
+
"""
|
|
338
|
+
seen_ids = set()
|
|
339
|
+
deduplicated = []
|
|
340
|
+
for api_path, api in apis:
|
|
341
|
+
api_id = id(api)
|
|
342
|
+
if api_id not in seen_ids:
|
|
343
|
+
seen_ids.add(api_id)
|
|
344
|
+
deduplicated.append((api_path, api))
|
|
345
|
+
else:
|
|
346
|
+
self.stdout.write(f"[django-bolt] Skipped duplicate API instance from {api_path}")
|
|
347
|
+
return deduplicated
|
|
348
|
+
|
|
349
|
+
def import_api(self, dotted_path):
|
|
350
|
+
"""Import a BoltAPI instance from dotted path like 'myapp.api:api'"""
|
|
351
|
+
try:
|
|
352
|
+
if ':' not in dotted_path:
|
|
353
|
+
return None
|
|
354
|
+
|
|
355
|
+
module_path, attr_name = dotted_path.split(':', 1)
|
|
356
|
+
module = importlib.import_module(module_path)
|
|
357
|
+
|
|
358
|
+
if not hasattr(module, attr_name):
|
|
359
|
+
return None
|
|
360
|
+
|
|
361
|
+
api = getattr(module, attr_name)
|
|
362
|
+
|
|
363
|
+
# Verify it's a BoltAPI instance
|
|
364
|
+
if isinstance(api, BoltAPI):
|
|
365
|
+
return api
|
|
366
|
+
|
|
367
|
+
except (ImportError, AttributeError, ValueError):
|
|
368
|
+
pass
|
|
369
|
+
|
|
370
|
+
return None
|
|
371
|
+
|
|
372
|
+
def merge_apis(self, apis):
|
|
373
|
+
"""Merge multiple BoltAPI instances into one, preserving per-API context.
|
|
374
|
+
|
|
375
|
+
Uses Litestar-style approach: each handler maintains reference to its original
|
|
376
|
+
API instance, allowing it to use that API's logging, auth, and middleware config.
|
|
377
|
+
"""
|
|
378
|
+
if len(apis) == 1:
|
|
379
|
+
return apis[0][1] # Return the single API
|
|
380
|
+
|
|
381
|
+
# Create a new merged API without logging (handlers will use their original APIs)
|
|
382
|
+
merged = BoltAPI(enable_logging=False)
|
|
383
|
+
route_map = {} # Track conflicts
|
|
384
|
+
|
|
385
|
+
# Map handler_id -> original API instance (preserves per-API context)
|
|
386
|
+
merged._handler_api_map = {}
|
|
387
|
+
|
|
388
|
+
# Track next available handler_id to avoid collisions
|
|
389
|
+
next_handler_id = 0
|
|
390
|
+
|
|
391
|
+
for api_path, api in apis:
|
|
392
|
+
self.stdout.write(f"[django-bolt] Merging API from {api_path}")
|
|
393
|
+
|
|
394
|
+
for method, path, old_handler_id, handler in api._routes:
|
|
395
|
+
route_key = f"{method} {path}"
|
|
396
|
+
|
|
397
|
+
if route_key in route_map:
|
|
398
|
+
raise CommandError(
|
|
399
|
+
f"Route conflict: {route_key} defined in both "
|
|
400
|
+
f"{route_map[route_key]} and {api_path}"
|
|
401
|
+
)
|
|
402
|
+
|
|
403
|
+
# CRITICAL: Assign NEW unique handler_id to avoid collisions
|
|
404
|
+
# Each API starts handler_ids at 0, so we must renumber during merge
|
|
405
|
+
new_handler_id = next_handler_id
|
|
406
|
+
next_handler_id += 1
|
|
407
|
+
|
|
408
|
+
route_map[route_key] = api_path
|
|
409
|
+
merged._routes.append((method, path, new_handler_id, handler))
|
|
410
|
+
merged._handlers[new_handler_id] = handler
|
|
411
|
+
|
|
412
|
+
# CRITICAL: Store reference to original API for this handler
|
|
413
|
+
# This preserves logging, auth, middleware, and all per-API config
|
|
414
|
+
merged._handler_api_map[new_handler_id] = api
|
|
415
|
+
|
|
416
|
+
# Merge handler metadata
|
|
417
|
+
if handler in api._handler_meta:
|
|
418
|
+
merged._handler_meta[handler] = api._handler_meta[handler]
|
|
419
|
+
|
|
420
|
+
# Merge middleware metadata (use NEW handler_id)
|
|
421
|
+
if old_handler_id in api._handler_middleware:
|
|
422
|
+
merged._handler_middleware[new_handler_id] = api._handler_middleware[old_handler_id]
|
|
423
|
+
|
|
424
|
+
# Update next handler ID
|
|
425
|
+
merged._next_handler_id = next_handler_id
|
|
426
|
+
|
|
427
|
+
return merged
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Django-Bolt Middleware System.
|
|
3
|
+
|
|
4
|
+
Provides decorators and classes for adding middleware to routes.
|
|
5
|
+
Middleware can be global or per-route.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from .middleware import (
|
|
9
|
+
Middleware,
|
|
10
|
+
MiddlewareGroup,
|
|
11
|
+
MiddlewareConfig,
|
|
12
|
+
middleware,
|
|
13
|
+
rate_limit,
|
|
14
|
+
cors,
|
|
15
|
+
skip_middleware,
|
|
16
|
+
no_compress,
|
|
17
|
+
CORSMiddleware,
|
|
18
|
+
RateLimitMiddleware,
|
|
19
|
+
)
|
|
20
|
+
|
|
21
|
+
__all__ = [
|
|
22
|
+
"Middleware",
|
|
23
|
+
"MiddlewareGroup",
|
|
24
|
+
"MiddlewareConfig",
|
|
25
|
+
"middleware",
|
|
26
|
+
"rate_limit",
|
|
27
|
+
"cors",
|
|
28
|
+
"skip_middleware",
|
|
29
|
+
"no_compress",
|
|
30
|
+
"CORSMiddleware",
|
|
31
|
+
"RateLimitMiddleware",
|
|
32
|
+
]
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
"""Middleware compilation utilities."""
|
|
2
|
+
from typing import Any, Callable, Dict, List, Optional, Set
|
|
3
|
+
from ..auth.backends import get_default_authentication_classes
|
|
4
|
+
from ..auth.guards import get_default_permission_classes
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
def compile_middleware_meta(
|
|
8
|
+
handler: Callable,
|
|
9
|
+
method: str,
|
|
10
|
+
path: str,
|
|
11
|
+
global_middleware: List[Any],
|
|
12
|
+
global_middleware_config: Dict[str, Any],
|
|
13
|
+
guards: Optional[List[Any]] = None,
|
|
14
|
+
auth: Optional[List[Any]] = None
|
|
15
|
+
) -> Optional[Dict[str, Any]]:
|
|
16
|
+
"""Compile middleware metadata for a handler, including guards and auth."""
|
|
17
|
+
# Check for handler-specific middleware
|
|
18
|
+
handler_middleware = []
|
|
19
|
+
skip_middleware: Set[str] = set()
|
|
20
|
+
|
|
21
|
+
if hasattr(handler, '__bolt_middleware__'):
|
|
22
|
+
handler_middleware = handler.__bolt_middleware__
|
|
23
|
+
|
|
24
|
+
if hasattr(handler, '__bolt_skip_middleware__'):
|
|
25
|
+
skip_middleware = handler.__bolt_skip_middleware__
|
|
26
|
+
|
|
27
|
+
# Merge global and handler middleware
|
|
28
|
+
all_middleware = []
|
|
29
|
+
|
|
30
|
+
# Add global middleware first
|
|
31
|
+
for mw in global_middleware:
|
|
32
|
+
mw_dict = middleware_to_dict(mw)
|
|
33
|
+
if mw_dict and mw_dict.get('type') not in skip_middleware:
|
|
34
|
+
all_middleware.append(mw_dict)
|
|
35
|
+
|
|
36
|
+
# Add global config-based middleware
|
|
37
|
+
if global_middleware_config:
|
|
38
|
+
for mw_type, config in global_middleware_config.items():
|
|
39
|
+
if mw_type not in skip_middleware:
|
|
40
|
+
mw_dict = {'type': mw_type}
|
|
41
|
+
mw_dict.update(config)
|
|
42
|
+
all_middleware.append(mw_dict)
|
|
43
|
+
|
|
44
|
+
# Add handler-specific middleware
|
|
45
|
+
for mw in handler_middleware:
|
|
46
|
+
mw_dict = middleware_to_dict(mw)
|
|
47
|
+
if mw_dict:
|
|
48
|
+
all_middleware.append(mw_dict)
|
|
49
|
+
|
|
50
|
+
# Compile authentication backends
|
|
51
|
+
auth_backends = []
|
|
52
|
+
if auth is not None:
|
|
53
|
+
# Per-route auth override
|
|
54
|
+
for auth_backend in auth:
|
|
55
|
+
if hasattr(auth_backend, 'to_metadata'):
|
|
56
|
+
auth_backends.append(auth_backend.to_metadata())
|
|
57
|
+
else:
|
|
58
|
+
# Use global default authentication classes
|
|
59
|
+
for auth_backend in get_default_authentication_classes():
|
|
60
|
+
if hasattr(auth_backend, 'to_metadata'):
|
|
61
|
+
auth_backends.append(auth_backend.to_metadata())
|
|
62
|
+
|
|
63
|
+
# Compile guards/permissions
|
|
64
|
+
guard_list = []
|
|
65
|
+
if guards is not None:
|
|
66
|
+
# Per-route guards override
|
|
67
|
+
for guard in guards:
|
|
68
|
+
# Check if it's an instance with to_metadata method
|
|
69
|
+
if hasattr(guard, 'to_metadata') and callable(getattr(guard, 'to_metadata', None)):
|
|
70
|
+
try:
|
|
71
|
+
# Try calling as instance method
|
|
72
|
+
guard_list.append(guard.to_metadata())
|
|
73
|
+
except TypeError:
|
|
74
|
+
# If it fails, might be a class, try instantiating
|
|
75
|
+
try:
|
|
76
|
+
instance = guard()
|
|
77
|
+
guard_list.append(instance.to_metadata())
|
|
78
|
+
except Exception:
|
|
79
|
+
pass
|
|
80
|
+
elif isinstance(guard, type):
|
|
81
|
+
# It's a class reference, instantiate it
|
|
82
|
+
try:
|
|
83
|
+
instance = guard()
|
|
84
|
+
if hasattr(instance, 'to_metadata'):
|
|
85
|
+
guard_list.append(instance.to_metadata())
|
|
86
|
+
except Exception:
|
|
87
|
+
pass
|
|
88
|
+
else:
|
|
89
|
+
# Use global default permission classes
|
|
90
|
+
for guard in get_default_permission_classes():
|
|
91
|
+
if hasattr(guard, 'to_metadata'):
|
|
92
|
+
guard_list.append(guard.to_metadata())
|
|
93
|
+
|
|
94
|
+
# Only include metadata if something is configured
|
|
95
|
+
# Note: include result even when only skip flags are present so Rust can
|
|
96
|
+
# honor route-level skips like `compression`.
|
|
97
|
+
if not all_middleware and not auth_backends and not guard_list and not skip_middleware:
|
|
98
|
+
return None
|
|
99
|
+
|
|
100
|
+
result = {
|
|
101
|
+
'method': method,
|
|
102
|
+
'path': path
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
if all_middleware:
|
|
106
|
+
result['middleware'] = all_middleware
|
|
107
|
+
|
|
108
|
+
# Always include skip flags if present (even without middleware/auth/guards)
|
|
109
|
+
if skip_middleware:
|
|
110
|
+
result['skip'] = list(skip_middleware)
|
|
111
|
+
|
|
112
|
+
if auth_backends:
|
|
113
|
+
result['auth_backends'] = auth_backends
|
|
114
|
+
|
|
115
|
+
if guard_list:
|
|
116
|
+
result['guards'] = guard_list
|
|
117
|
+
|
|
118
|
+
return result
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def middleware_to_dict(mw: Any) -> Optional[Dict[str, Any]]:
|
|
122
|
+
"""Convert middleware specification to dictionary."""
|
|
123
|
+
if isinstance(mw, dict):
|
|
124
|
+
return mw
|
|
125
|
+
elif hasattr(mw, '__dict__'):
|
|
126
|
+
# Convert middleware object to dict
|
|
127
|
+
return {
|
|
128
|
+
'type': mw.__class__.__name__.lower().replace('middleware', ''),
|
|
129
|
+
**mw.__dict__
|
|
130
|
+
}
|
|
131
|
+
return None
|