swiftpy-framework 0.1.0__py3-none-any.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.
- swiftpy/.gitignore +0 -0
- swiftpy/__init__.py +572 -0
- swiftpy/admin/__init__.py +50 -0
- swiftpy/admin/auth.py +522 -0
- swiftpy/admin/registry.py +567 -0
- swiftpy/admin/views.py +1583 -0
- swiftpy/ai/__init__.py +199 -0
- swiftpy/ai/_deterministic.py +40 -0
- swiftpy/ai/_fake.py +604 -0
- swiftpy/ai/agents/__init__.py +30 -0
- swiftpy/ai/agents/base_agent.py +506 -0
- swiftpy/ai/agents/memory.py +864 -0
- swiftpy/ai/client.py +1011 -0
- swiftpy/ai/conversation.py +171 -0
- swiftpy/ai/embeddings.py +321 -0
- swiftpy/ai/exceptions.py +75 -0
- swiftpy/ai/history_strategies.py +157 -0
- swiftpy/ai/middleware.py +918 -0
- swiftpy/ai/observability/__init__.py +28 -0
- swiftpy/ai/observability/drivers.py +763 -0
- swiftpy/ai/observability/observer.py +364 -0
- swiftpy/ai/pool.py +102 -0
- swiftpy/ai/pricing.py +101 -0
- swiftpy/ai/prompts/__init__.py +6 -0
- swiftpy/ai/prompts/loader.py +343 -0
- swiftpy/ai/providers/__init__.py +22 -0
- swiftpy/ai/providers/anthropic_provider.py +353 -0
- swiftpy/ai/providers/base.py +273 -0
- swiftpy/ai/providers/openai_provider.py +275 -0
- swiftpy/ai/rag/__init__.py +53 -0
- swiftpy/ai/rag/context_formatter.py +263 -0
- swiftpy/ai/rag/pipeline_builder.py +544 -0
- swiftpy/ai/rag/reranker.py +484 -0
- swiftpy/ai/rag/result.py +78 -0
- swiftpy/ai/rag/retriever.py +315 -0
- swiftpy/ai/redis_store.py +227 -0
- swiftpy/ai/streaming.py +115 -0
- swiftpy/ai/tools/__init__.py +22 -0
- swiftpy/ai/tools/base_tool.py +489 -0
- swiftpy/ai/tools/first_party.py +384 -0
- swiftpy/ai/tools/tool_loop.py +417 -0
- swiftpy/ai/vectorstore/__init__.py +46 -0
- swiftpy/ai/vectorstore/base.py +542 -0
- swiftpy/ai/vectorstore/chroma_adapter.py +435 -0
- swiftpy/ai/vectorstore/in_memory_adapter.py +321 -0
- swiftpy/ai/vectorstore/lancedb_adapter.py +370 -0
- swiftpy/ai/vectorstore/pgvector_adapter.py +496 -0
- swiftpy/ai/vectorstore/pinecone_adapter.py +445 -0
- swiftpy/ai/vectorstore/qdrant_adapter.py +418 -0
- swiftpy/auth/__init__.py +50 -0
- swiftpy/auth/api_key_guard.py +544 -0
- swiftpy/auth/auth_manager.py +259 -0
- swiftpy/auth/exceptions.py +71 -0
- swiftpy/auth/jwt_guard.py +230 -0
- swiftpy/auth/jwt_issuer.py +658 -0
- swiftpy/auth/token_blacklist.py +300 -0
- swiftpy/cache/__init__.py +14 -0
- swiftpy/cache/cache.py +251 -0
- swiftpy/cache/driver.py +216 -0
- swiftpy/cache/drivers/memory.py +199 -0
- swiftpy/cache/drivers/redis.py +421 -0
- swiftpy/console/__init__.py +0 -0
- swiftpy/console/cli.py +68 -0
- swiftpy/console/commands/__init__.py +0 -0
- swiftpy/console/commands/cache.py +164 -0
- swiftpy/console/commands/make.py +935 -0
- swiftpy/console/commands/migrate.py +280 -0
- swiftpy/console/commands/new.py +1806 -0
- swiftpy/console/commands/queue.py +306 -0
- swiftpy/console/commands/seed.py +252 -0
- swiftpy/console/commands/start.py +171 -0
- swiftpy/console/commands/storage.py +104 -0
- swiftpy/console/loader.py +21 -0
- swiftpy/console/stubs/__init__.py +26 -0
- swiftpy/console/stubs/controller.stub +14 -0
- swiftpy/console/stubs/event.stub +14 -0
- swiftpy/console/stubs/job.stub +22 -0
- swiftpy/console/stubs/listener.stub +14 -0
- swiftpy/console/stubs/mail.stub +20 -0
- swiftpy/console/stubs/middleware.stub +16 -0
- swiftpy/console/stubs/migration.alter.stub +17 -0
- swiftpy/console/stubs/migration.api_keys.stub +22 -0
- swiftpy/console/stubs/migration.blank.stub +11 -0
- swiftpy/console/stubs/migration.create.stub +15 -0
- swiftpy/console/stubs/migration.stub +11 -0
- swiftpy/console/stubs/migration.update.stub +17 -0
- swiftpy/console/stubs/model.stub +17 -0
- swiftpy/console/stubs/provider.stub +18 -0
- swiftpy/console/stubs/repository.stub +10 -0
- swiftpy/console/stubs/request.stub +16 -0
- swiftpy/console/stubs/resource.stub +21 -0
- swiftpy/console/stubs/schema.stub +12 -0
- swiftpy/console/stubs/seeder.stub +14 -0
- swiftpy/console/stubs/service.stub +10 -0
- swiftpy/core/__init__.py +0 -0
- swiftpy/core/application.py +1306 -0
- swiftpy/core/bootstrap.py +30 -0
- swiftpy/core/config.py +432 -0
- swiftpy/core/container.py +596 -0
- swiftpy/core/container_exceptions.py +18 -0
- swiftpy/core/context.py +148 -0
- swiftpy/core/exceptions.py +352 -0
- swiftpy/core/providers/base.py +35 -0
- swiftpy/database/__init__.py +41 -0
- swiftpy/database/exceptions.py +42 -0
- swiftpy/database/identifiers.py +105 -0
- swiftpy/database/migrations/__init__.py +36 -0
- swiftpy/database/migrations/blueprint.py +431 -0
- swiftpy/database/migrations/lock.py +104 -0
- swiftpy/database/migrations/migration.py +22 -0
- swiftpy/database/migrations/migrator.py +507 -0
- swiftpy/database/migrations/schema.py +435 -0
- swiftpy/database/orm/__init__.py +33 -0
- swiftpy/database/orm/fields.py +92 -0
- swiftpy/database/orm/model.py +2034 -0
- swiftpy/database/orm/registry.py +33 -0
- swiftpy/database/orm/relationships.py +836 -0
- swiftpy/database/pagination/__init__.py +16 -0
- swiftpy/database/pagination/contracts.py +60 -0
- swiftpy/database/pagination/paginator.py +112 -0
- swiftpy/database/pagination/simple_paginator.py +97 -0
- swiftpy/database/pool.py +218 -0
- swiftpy/database/query_builder.py +1803 -0
- swiftpy/database/seeder.py +39 -0
- swiftpy/database/transaction.py +276 -0
- swiftpy/events/__init__.py +10 -0
- swiftpy/events/dispatcher.py +308 -0
- swiftpy/events/redis_bus.py +552 -0
- swiftpy/filesystem/__init__.py +227 -0
- swiftpy/filesystem/disk.py +378 -0
- swiftpy/filesystem/drivers/__init__.py +11 -0
- swiftpy/filesystem/drivers/base.py +156 -0
- swiftpy/filesystem/drivers/local.py +559 -0
- swiftpy/filesystem/drivers/s3.py +655 -0
- swiftpy/filesystem/events.py +37 -0
- swiftpy/filesystem/exceptions.py +127 -0
- swiftpy/filesystem/manager.py +138 -0
- swiftpy/filesystem/metadata.py +22 -0
- swiftpy/filesystem/paths.py +99 -0
- swiftpy/filesystem/service_provider.py +17 -0
- swiftpy/filesystem/uploads/__init__.py +13 -0
- swiftpy/filesystem/uploads/handlers.py +362 -0
- swiftpy/filesystem/uploads/uploaded_file.py +222 -0
- swiftpy/filesystem/uploads/validators.py +186 -0
- swiftpy/filesystem/visibility.py +22 -0
- swiftpy/helpers/__init__.py +42 -0
- swiftpy/helpers/helper.py +180 -0
- swiftpy/http/exceptions.py +41 -0
- swiftpy/http/middleware.py +307 -0
- swiftpy/http/request.py +375 -0
- swiftpy/http/response.py +442 -0
- swiftpy/http/router.py +1050 -0
- swiftpy/mail/__init__.py +58 -0
- swiftpy/mail/attachments.py +255 -0
- swiftpy/mail/config.py +71 -0
- swiftpy/mail/envelope.py +183 -0
- swiftpy/mail/exceptions.py +90 -0
- swiftpy/mail/facade.py +312 -0
- swiftpy/mail/mailable.py +53 -0
- swiftpy/mail/manager.py +240 -0
- swiftpy/mail/message.py +186 -0
- swiftpy/mail/models.py +19 -0
- swiftpy/mail/queue/__init__.py +5 -0
- swiftpy/mail/queue/job.py +279 -0
- swiftpy/mail/service_provider.py +19 -0
- swiftpy/mail/templates/__init__.py +5 -0
- swiftpy/mail/templates/renderer.py +173 -0
- swiftpy/mail/transports/__init__.py +21 -0
- swiftpy/mail/transports/api/__init__.py +13 -0
- swiftpy/mail/transports/api/base.py +194 -0
- swiftpy/mail/transports/api/mailgun.py +158 -0
- swiftpy/mail/transports/api/resend.py +156 -0
- swiftpy/mail/transports/api/sendgrid.py +172 -0
- swiftpy/mail/transports/base.py +24 -0
- swiftpy/mail/transports/console.py +91 -0
- swiftpy/mail/transports/sendmail.py +128 -0
- swiftpy/mail/transports/smtp.py +466 -0
- swiftpy/queue/__init__.py +13 -0
- swiftpy/queue/dispatcher.py +813 -0
- swiftpy/queue/job.py +541 -0
- swiftpy/queue/worker.py +556 -0
- swiftpy/support/__init__.py +12 -0
- swiftpy/support/keys.py +108 -0
- swiftpy/support/redis_config.py +223 -0
- swiftpy/support/strings.py +114 -0
- swiftpy/testing/__init__.py +20 -0
- swiftpy/testing/fakes/__init__.py +11 -0
- swiftpy/testing/fakes/ai_fake.py +21 -0
- swiftpy/testing/fakes/vectorstore_fake.py +194 -0
- swiftpy/testing/fixtures.py +124 -0
- swiftpy/testing/http_client.py +730 -0
- swiftpy/testing/test_case.py +126 -0
- swiftpy/validation/__init__.py +97 -0
- swiftpy/validation/exceptions.py +26 -0
- swiftpy/validation/form_request.py +302 -0
- swiftpy/validation/protocols.py +33 -0
- swiftpy/validation/rules/__init__.py +89 -0
- swiftpy/validation/rules/builtin.py +391 -0
- swiftpy/validation/rules/exists.py +86 -0
- swiftpy/validation/rules/registry.py +290 -0
- swiftpy/validation/rules/required.py +55 -0
- swiftpy/validation/rules/unique.py +138 -0
- swiftpy/validation/validator.py +612 -0
- swiftpy/websockets/__init__.py +19 -0
- swiftpy/websockets/connection.py +317 -0
- swiftpy/websockets/manager.py +271 -0
- swiftpy_framework-0.1.0.dist-info/METADATA +603 -0
- swiftpy_framework-0.1.0.dist-info/RECORD +211 -0
- swiftpy_framework-0.1.0.dist-info/WHEEL +4 -0
- swiftpy_framework-0.1.0.dist-info/entry_points.txt +2 -0
- swiftpy_framework-0.1.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,307 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import re
|
|
5
|
+
import uuid
|
|
6
|
+
from collections.abc import Awaitable, Callable, Sequence
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
from swiftpy.core.config import Config, ConfigurationError
|
|
10
|
+
from swiftpy.core.container import Container
|
|
11
|
+
from swiftpy.core.context import Context
|
|
12
|
+
from swiftpy.http.exceptions import ClientDisconnect
|
|
13
|
+
from swiftpy.http.request import Request
|
|
14
|
+
from swiftpy.http.response import Response
|
|
15
|
+
|
|
16
|
+
Handler = Callable[..., Awaitable[Response]]
|
|
17
|
+
|
|
18
|
+
_REQUEST_ID_RE = re.compile(r"^[A-Za-z0-9._\-:]{1,128}$")
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class Middleware:
|
|
22
|
+
async def handle(self, request: Request, next_handler: Handler) -> Response:
|
|
23
|
+
return await next_handler(request)
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class MiddlewarePipeline:
|
|
27
|
+
def __init__(
|
|
28
|
+
self, container: Container, middleware: Sequence[type[Middleware]]
|
|
29
|
+
) -> None:
|
|
30
|
+
self.container = container
|
|
31
|
+
self.middleware = list(middleware)
|
|
32
|
+
|
|
33
|
+
def wrap(
|
|
34
|
+
self,
|
|
35
|
+
handler: Handler,
|
|
36
|
+
*,
|
|
37
|
+
exception_handler: Callable[[Exception, Request], Awaitable[Response]] | None = None,
|
|
38
|
+
) -> Handler:
|
|
39
|
+
wrapped = handler
|
|
40
|
+
|
|
41
|
+
for middleware_cls in reversed(self.middleware):
|
|
42
|
+
current = wrapped
|
|
43
|
+
|
|
44
|
+
async def wrapped_handler(
|
|
45
|
+
request: Request,
|
|
46
|
+
*args: Any,
|
|
47
|
+
middleware_cls: type[Middleware] = middleware_cls,
|
|
48
|
+
current: Handler = current,
|
|
49
|
+
**kwargs: Any,
|
|
50
|
+
) -> Response:
|
|
51
|
+
middleware = self.container.resolve(middleware_cls)
|
|
52
|
+
|
|
53
|
+
async def next_handler(req: Request) -> Response:
|
|
54
|
+
if exception_handler is None:
|
|
55
|
+
return await current(req, *args, **kwargs)
|
|
56
|
+
try:
|
|
57
|
+
return await current(req, *args, **kwargs)
|
|
58
|
+
except ClientDisconnect:
|
|
59
|
+
raise
|
|
60
|
+
except Exception as exc: # noqa: BLE001
|
|
61
|
+
return await exception_handler(exc, req)
|
|
62
|
+
|
|
63
|
+
return await middleware.handle(request, next_handler)
|
|
64
|
+
|
|
65
|
+
wrapped = wrapped_handler
|
|
66
|
+
|
|
67
|
+
return wrapped
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
class SecurityHeadersMiddleware(Middleware):
|
|
71
|
+
"""
|
|
72
|
+
Opt-in middleware that attaches standard defensive HTTP security headers.
|
|
73
|
+
"""
|
|
74
|
+
|
|
75
|
+
DEFAULT_HEADERS: tuple[tuple[str, str], ...] = (
|
|
76
|
+
("X-Content-Type-Options", "nosniff"),
|
|
77
|
+
("X-Frame-Options", "DENY"),
|
|
78
|
+
("Referrer-Policy", "strict-origin-when-cross-origin"),
|
|
79
|
+
("X-XSS-Protection", "0"),
|
|
80
|
+
("Cross-Origin-Opener-Policy", "same-origin"),
|
|
81
|
+
)
|
|
82
|
+
|
|
83
|
+
async def handle(self, request: Request, next_handler: Handler) -> Response:
|
|
84
|
+
response = await next_handler(request)
|
|
85
|
+
for header_name, header_val in self.DEFAULT_HEADERS:
|
|
86
|
+
if header_name not in response.headers:
|
|
87
|
+
response.headers[header_name] = header_val
|
|
88
|
+
return response
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
class RequestIdMiddleware(Middleware):
|
|
92
|
+
"""
|
|
93
|
+
Opt-in middleware that assigns or propagates a validated `X-Request-ID` header
|
|
94
|
+
and stores `request_id` in `request.state` and `Context`.
|
|
95
|
+
"""
|
|
96
|
+
|
|
97
|
+
HEADER_NAME: str = "X-Request-ID"
|
|
98
|
+
|
|
99
|
+
async def handle(self, request: Request, next_handler: Handler) -> Response:
|
|
100
|
+
incoming = request.headers.get(self.HEADER_NAME)
|
|
101
|
+
if incoming and _REQUEST_ID_RE.fullmatch(incoming):
|
|
102
|
+
request_id = incoming
|
|
103
|
+
else:
|
|
104
|
+
request_id = str(uuid.uuid4())
|
|
105
|
+
|
|
106
|
+
request.state["request_id"] = request_id
|
|
107
|
+
Context.set("request_id", request_id)
|
|
108
|
+
|
|
109
|
+
response = await next_handler(request)
|
|
110
|
+
response.headers[self.HEADER_NAME] = request_id
|
|
111
|
+
return response
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
class CORSMiddleware(Middleware):
|
|
115
|
+
"""
|
|
116
|
+
Opt-in Cross-Origin Resource Sharing (CORS) middleware configured via `config/cors.py`.
|
|
117
|
+
Defaults to deny-all (`allowed_origins = []`).
|
|
118
|
+
"""
|
|
119
|
+
|
|
120
|
+
def __init__(self, config: Config) -> None:
|
|
121
|
+
self._config = config
|
|
122
|
+
self._parse_cors_settings()
|
|
123
|
+
|
|
124
|
+
def _get_cors_config(self) -> dict[str, Any]:
|
|
125
|
+
raw = self._config.get("cors", {})
|
|
126
|
+
return raw if isinstance(raw, dict) else {}
|
|
127
|
+
|
|
128
|
+
@staticmethod
|
|
129
|
+
def _normalize_list(
|
|
130
|
+
value: Any,
|
|
131
|
+
*,
|
|
132
|
+
default: Sequence[str] = (),
|
|
133
|
+
field: str = "",
|
|
134
|
+
) -> list[str]:
|
|
135
|
+
if value is None:
|
|
136
|
+
return list(default)
|
|
137
|
+
if isinstance(value, str):
|
|
138
|
+
stripped = value.strip()
|
|
139
|
+
if not stripped:
|
|
140
|
+
return list(default) if default else []
|
|
141
|
+
if stripped.startswith("[") and stripped.endswith("]"):
|
|
142
|
+
try:
|
|
143
|
+
parsed = json.loads(stripped)
|
|
144
|
+
except (json.JSONDecodeError, ValueError) as exc:
|
|
145
|
+
raise ConfigurationError(
|
|
146
|
+
f"CORS configuration field '{field}' contains invalid JSON list: '{value}'"
|
|
147
|
+
) from exc
|
|
148
|
+
if not isinstance(parsed, list):
|
|
149
|
+
raise ConfigurationError(
|
|
150
|
+
f"CORS configuration field '{field}' expects a JSON array"
|
|
151
|
+
)
|
|
152
|
+
items = [str(item).strip() for item in parsed if str(item).strip()]
|
|
153
|
+
else:
|
|
154
|
+
items = [item.strip() for item in stripped.split(",") if item.strip()]
|
|
155
|
+
return items if items or not default else list(default)
|
|
156
|
+
if isinstance(value, (list, tuple, set)):
|
|
157
|
+
items = [str(item).strip() for item in value if str(item).strip()]
|
|
158
|
+
return items if items or not default else list(default)
|
|
159
|
+
raise ConfigurationError(
|
|
160
|
+
f"CORS configuration field '{field}' must be a sequence or comma-separated string, got {type(value).__name__}"
|
|
161
|
+
)
|
|
162
|
+
|
|
163
|
+
@staticmethod
|
|
164
|
+
def _normalize_bool(value: Any, default: bool = False) -> bool:
|
|
165
|
+
if value is None:
|
|
166
|
+
return default
|
|
167
|
+
if isinstance(value, bool):
|
|
168
|
+
return value
|
|
169
|
+
if isinstance(value, str):
|
|
170
|
+
return value.strip().lower() in ("true", "1", "yes", "on")
|
|
171
|
+
return bool(value)
|
|
172
|
+
|
|
173
|
+
def _parse_cors_settings(
|
|
174
|
+
self,
|
|
175
|
+
) -> tuple[list[str], list[str], list[str], list[str], bool, int | None]:
|
|
176
|
+
cfg = self._get_cors_config()
|
|
177
|
+
allowed_origins = self._normalize_list(
|
|
178
|
+
cfg.get("allowed_origins"),
|
|
179
|
+
field="allowed_origins",
|
|
180
|
+
)
|
|
181
|
+
allowed_methods = self._normalize_list(
|
|
182
|
+
cfg.get("allowed_methods"),
|
|
183
|
+
default=("GET", "HEAD", "POST"),
|
|
184
|
+
field="allowed_methods",
|
|
185
|
+
)
|
|
186
|
+
allowed_headers = self._normalize_list(
|
|
187
|
+
cfg.get("allowed_headers"),
|
|
188
|
+
field="allowed_headers",
|
|
189
|
+
)
|
|
190
|
+
expose_headers = self._normalize_list(
|
|
191
|
+
cfg.get("expose_headers"),
|
|
192
|
+
field="expose_headers",
|
|
193
|
+
)
|
|
194
|
+
allow_credentials = self._normalize_bool(
|
|
195
|
+
cfg.get("allow_credentials"),
|
|
196
|
+
default=False,
|
|
197
|
+
)
|
|
198
|
+
if "*" in allowed_origins and allow_credentials:
|
|
199
|
+
raise ConfigurationError(
|
|
200
|
+
"Invalid CORS configuration: 'allowed_origins' cannot include '*' when 'allow_credentials' is True."
|
|
201
|
+
)
|
|
202
|
+
max_age: int | None = (
|
|
203
|
+
int(cfg["max_age"]) if cfg.get("max_age") is not None else 600
|
|
204
|
+
)
|
|
205
|
+
return (
|
|
206
|
+
allowed_origins,
|
|
207
|
+
allowed_methods,
|
|
208
|
+
allowed_headers,
|
|
209
|
+
expose_headers,
|
|
210
|
+
allow_credentials,
|
|
211
|
+
max_age,
|
|
212
|
+
)
|
|
213
|
+
|
|
214
|
+
@staticmethod
|
|
215
|
+
def _is_origin_allowed(origin: str, allowed_origins: Sequence[str]) -> bool:
|
|
216
|
+
if not allowed_origins:
|
|
217
|
+
return False
|
|
218
|
+
if "*" in allowed_origins:
|
|
219
|
+
return True
|
|
220
|
+
return origin in allowed_origins
|
|
221
|
+
|
|
222
|
+
async def handle(self, request: Request, next_handler: Handler) -> Response:
|
|
223
|
+
(
|
|
224
|
+
allowed_origins,
|
|
225
|
+
allowed_methods,
|
|
226
|
+
allowed_headers,
|
|
227
|
+
expose_headers,
|
|
228
|
+
allow_credentials,
|
|
229
|
+
max_age,
|
|
230
|
+
) = self._parse_cors_settings()
|
|
231
|
+
|
|
232
|
+
origin = request.headers.get("origin")
|
|
233
|
+
if not origin:
|
|
234
|
+
return await next_handler(request)
|
|
235
|
+
|
|
236
|
+
is_preflight = (
|
|
237
|
+
request.method.upper() == "OPTIONS"
|
|
238
|
+
and "access-control-request-method" in request.headers
|
|
239
|
+
)
|
|
240
|
+
origin_allowed = self._is_origin_allowed(origin, allowed_origins)
|
|
241
|
+
|
|
242
|
+
if is_preflight:
|
|
243
|
+
if not origin_allowed:
|
|
244
|
+
return Response(b"", status_code=204)
|
|
245
|
+
|
|
246
|
+
headers: dict[str, str] = {}
|
|
247
|
+
if "*" in allowed_origins and not allow_credentials:
|
|
248
|
+
headers["Access-Control-Allow-Origin"] = "*"
|
|
249
|
+
else:
|
|
250
|
+
headers["Access-Control-Allow-Origin"] = origin
|
|
251
|
+
headers["Vary"] = "Origin"
|
|
252
|
+
|
|
253
|
+
headers["Access-Control-Allow-Methods"] = ", ".join(allowed_methods)
|
|
254
|
+
if allowed_headers:
|
|
255
|
+
if "*" in allowed_headers:
|
|
256
|
+
req_hdrs = request.headers.get("access-control-request-headers")
|
|
257
|
+
if req_hdrs:
|
|
258
|
+
headers["Access-Control-Allow-Headers"] = req_hdrs
|
|
259
|
+
else:
|
|
260
|
+
headers["Access-Control-Allow-Headers"] = ", ".join(allowed_headers)
|
|
261
|
+
|
|
262
|
+
if allow_credentials:
|
|
263
|
+
headers["Access-Control-Allow-Credentials"] = "true"
|
|
264
|
+
if max_age is not None:
|
|
265
|
+
headers["Access-Control-Max-Age"] = str(max_age)
|
|
266
|
+
|
|
267
|
+
return Response(b"", status_code=204, headers=headers)
|
|
268
|
+
|
|
269
|
+
response = await next_handler(request)
|
|
270
|
+
if not origin_allowed:
|
|
271
|
+
return response
|
|
272
|
+
|
|
273
|
+
if "*" in allowed_origins and not allow_credentials:
|
|
274
|
+
response.headers["Access-Control-Allow-Origin"] = "*"
|
|
275
|
+
else:
|
|
276
|
+
response.headers["Access-Control-Allow-Origin"] = origin
|
|
277
|
+
existing_vary = response.headers.get("Vary")
|
|
278
|
+
if not existing_vary:
|
|
279
|
+
response.headers["Vary"] = "Origin"
|
|
280
|
+
elif "Origin" not in [v.strip() for v in existing_vary.split(",")]:
|
|
281
|
+
response.headers["Vary"] = f"{existing_vary}, Origin"
|
|
282
|
+
|
|
283
|
+
if allow_credentials:
|
|
284
|
+
response.headers["Access-Control-Allow-Credentials"] = "true"
|
|
285
|
+
if expose_headers:
|
|
286
|
+
response.headers["Access-Control-Expose-Headers"] = ", ".join(
|
|
287
|
+
expose_headers
|
|
288
|
+
)
|
|
289
|
+
|
|
290
|
+
return response
|
|
291
|
+
|
|
292
|
+
|
|
293
|
+
SecurityHeaders = SecurityHeadersMiddleware
|
|
294
|
+
RequestId = RequestIdMiddleware
|
|
295
|
+
CORS = CORSMiddleware
|
|
296
|
+
|
|
297
|
+
__all__ = [
|
|
298
|
+
"CORS",
|
|
299
|
+
"CORSMiddleware",
|
|
300
|
+
"Handler",
|
|
301
|
+
"Middleware",
|
|
302
|
+
"MiddlewarePipeline",
|
|
303
|
+
"RequestId",
|
|
304
|
+
"RequestIdMiddleware",
|
|
305
|
+
"SecurityHeaders",
|
|
306
|
+
"SecurityHeadersMiddleware",
|
|
307
|
+
]
|
swiftpy/http/request.py
ADDED
|
@@ -0,0 +1,375 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
from collections.abc import AsyncIterator, Iterator, Mapping
|
|
5
|
+
from typing import Any, cast
|
|
6
|
+
from urllib.parse import parse_qs
|
|
7
|
+
|
|
8
|
+
from swiftpy.core.config import Config
|
|
9
|
+
from swiftpy.core.container import get_container
|
|
10
|
+
from swiftpy.filesystem.uploads.handlers import FormData, MultipartParser
|
|
11
|
+
from swiftpy.filesystem.uploads.uploaded_file import UploadedFile
|
|
12
|
+
from swiftpy.http.exceptions import (
|
|
13
|
+
ClientDisconnect,
|
|
14
|
+
HttpException,
|
|
15
|
+
PayloadTooLargeError,
|
|
16
|
+
)
|
|
17
|
+
|
|
18
|
+
DEFAULT_MAX_BODY_SIZE: int = 2 * 1024 * 1024 # 2 MiB
|
|
19
|
+
DEFAULT_MAX_CONTENT_LENGTH: int = 2 * 1024 * 1024 # 2 MiB
|
|
20
|
+
DEFAULT_MAX_JSON_DEPTH: int = 32
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def _validate_json_depth(
|
|
24
|
+
obj: Any,
|
|
25
|
+
current_depth: int = 1,
|
|
26
|
+
max_depth: int = DEFAULT_MAX_JSON_DEPTH,
|
|
27
|
+
) -> None:
|
|
28
|
+
"""Validate iteratively that nested JSON structures do not exceed maximum nesting depth."""
|
|
29
|
+
stack: list[tuple[Any, int]] = [(obj, current_depth)]
|
|
30
|
+
while stack:
|
|
31
|
+
node, depth = stack.pop()
|
|
32
|
+
if depth > max_depth:
|
|
33
|
+
raise HttpException(
|
|
34
|
+
f"JSON payload exceeds maximum allowed depth of {max_depth}",
|
|
35
|
+
status_code=400,
|
|
36
|
+
)
|
|
37
|
+
if isinstance(node, dict):
|
|
38
|
+
next_depth = depth + 1
|
|
39
|
+
for val in node.values():
|
|
40
|
+
if isinstance(val, (dict, list)):
|
|
41
|
+
stack.append((val, next_depth))
|
|
42
|
+
elif isinstance(node, list):
|
|
43
|
+
next_depth = depth + 1
|
|
44
|
+
for item in node:
|
|
45
|
+
if isinstance(item, (dict, list)):
|
|
46
|
+
stack.append((item, next_depth))
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
class RequestHeaders(Mapping[str, str]):
|
|
50
|
+
"""
|
|
51
|
+
Case-insensitive, immutable HTTP request header container decoded as latin-1 per ASGI spec.
|
|
52
|
+
Preserves duplicate headers via `.getlist(name)` while using last-wins semantics for
|
|
53
|
+
dict-style single-value lookups (`headers[name]`, `headers.get(name)`).
|
|
54
|
+
"""
|
|
55
|
+
|
|
56
|
+
__slots__ = ("_by_lower", "_items", "_list_by_lower")
|
|
57
|
+
|
|
58
|
+
def __init__(self, raw_headers: list[tuple[bytes, bytes]] | None = None) -> None:
|
|
59
|
+
items: list[tuple[str, str]] = []
|
|
60
|
+
by_lower: dict[str, str] = {}
|
|
61
|
+
list_by_lower: dict[str, list[str]] = {}
|
|
62
|
+
|
|
63
|
+
for raw_k, raw_v in raw_headers or ():
|
|
64
|
+
key = raw_k.decode("latin-1")
|
|
65
|
+
val = raw_v.decode("latin-1")
|
|
66
|
+
lower_key = key.lower()
|
|
67
|
+
items.append((key, val))
|
|
68
|
+
by_lower[lower_key] = val
|
|
69
|
+
list_by_lower.setdefault(lower_key, []).append(val)
|
|
70
|
+
|
|
71
|
+
self._items: list[tuple[str, str]] = items
|
|
72
|
+
self._by_lower: dict[str, str] = by_lower
|
|
73
|
+
self._list_by_lower: dict[str, list[str]] = list_by_lower
|
|
74
|
+
|
|
75
|
+
def __getitem__(self, name: str) -> str:
|
|
76
|
+
lower = name.lower()
|
|
77
|
+
if lower not in self._by_lower:
|
|
78
|
+
raise KeyError(name)
|
|
79
|
+
return self._by_lower[lower]
|
|
80
|
+
|
|
81
|
+
def __iter__(self) -> Iterator[str]:
|
|
82
|
+
return iter(self._by_lower)
|
|
83
|
+
|
|
84
|
+
def __len__(self) -> int:
|
|
85
|
+
return len(self._by_lower)
|
|
86
|
+
|
|
87
|
+
def __contains__(self, name: object) -> bool:
|
|
88
|
+
if not isinstance(name, str):
|
|
89
|
+
return False
|
|
90
|
+
return name.lower() in self._by_lower
|
|
91
|
+
|
|
92
|
+
def getlist(self, name: str) -> list[str]:
|
|
93
|
+
"""Return all values for `name` (case-insensitive) in arrival order."""
|
|
94
|
+
return list(self._list_by_lower.get(name.lower(), ()))
|
|
95
|
+
|
|
96
|
+
def raw_items(self) -> list[tuple[str, str]]:
|
|
97
|
+
"""Return all `(name, value)` header pairs in arrival order."""
|
|
98
|
+
return list(self._items)
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
class Request:
|
|
102
|
+
def __init__(
|
|
103
|
+
self,
|
|
104
|
+
scope: dict[str, Any],
|
|
105
|
+
receive: Any,
|
|
106
|
+
*,
|
|
107
|
+
max_body_size: int = DEFAULT_MAX_BODY_SIZE,
|
|
108
|
+
max_content_length: int | None = None,
|
|
109
|
+
max_json_depth: int = DEFAULT_MAX_JSON_DEPTH,
|
|
110
|
+
) -> None:
|
|
111
|
+
self.scope = scope
|
|
112
|
+
self._receive = receive
|
|
113
|
+
self.max_body_size = max_body_size
|
|
114
|
+
self.max_content_length = max_content_length
|
|
115
|
+
self.max_json_depth = max_json_depth
|
|
116
|
+
|
|
117
|
+
self._headers: RequestHeaders | None = None
|
|
118
|
+
self._query_params: dict[str, str] | None = None
|
|
119
|
+
self._query_param_list: dict[str, list[str]] | None = None
|
|
120
|
+
self._body: bytes | None = None
|
|
121
|
+
self._json: Any = None
|
|
122
|
+
self._json_parsed: bool = False
|
|
123
|
+
self._form: FormData | None = None
|
|
124
|
+
|
|
125
|
+
self.state: dict[str, Any] = {}
|
|
126
|
+
|
|
127
|
+
@property
|
|
128
|
+
def method(self) -> str:
|
|
129
|
+
return cast(str, self.scope["method"])
|
|
130
|
+
|
|
131
|
+
@property
|
|
132
|
+
def path(self) -> str:
|
|
133
|
+
return cast(str, self.scope["path"])
|
|
134
|
+
|
|
135
|
+
@property
|
|
136
|
+
def headers(self) -> RequestHeaders:
|
|
137
|
+
if self._headers is None:
|
|
138
|
+
self._headers = RequestHeaders(self.scope.get("headers", []))
|
|
139
|
+
return self._headers
|
|
140
|
+
|
|
141
|
+
@property
|
|
142
|
+
def query_param_list(self) -> dict[str, list[str]]:
|
|
143
|
+
"""Parsed dictionary of URL query string parameters with all values as lists (cached)."""
|
|
144
|
+
if self._query_param_list is None:
|
|
145
|
+
raw_qs: bytes = self.scope.get("query_string", b"")
|
|
146
|
+
if not raw_qs:
|
|
147
|
+
self._query_param_list = {}
|
|
148
|
+
else:
|
|
149
|
+
decoded = raw_qs.decode("utf-8", errors="replace")
|
|
150
|
+
self._query_param_list = parse_qs(
|
|
151
|
+
decoded,
|
|
152
|
+
keep_blank_values=True,
|
|
153
|
+
encoding="utf-8",
|
|
154
|
+
errors="replace",
|
|
155
|
+
)
|
|
156
|
+
return self._query_param_list
|
|
157
|
+
|
|
158
|
+
@property
|
|
159
|
+
def query_params(self) -> dict[str, str]:
|
|
160
|
+
"""Parsed dictionary of URL query string parameters (first value for each key, cached)."""
|
|
161
|
+
if self._query_params is None:
|
|
162
|
+
self._query_params = {
|
|
163
|
+
k: v[0] for k, v in self.query_param_list.items() if v
|
|
164
|
+
}
|
|
165
|
+
return self._query_params
|
|
166
|
+
|
|
167
|
+
async def body(self, *, max_size: int | None = None) -> bytes:
|
|
168
|
+
limit = max_size if max_size is not None else self.max_body_size
|
|
169
|
+
|
|
170
|
+
content_length_values = self.headers.getlist("content-length")
|
|
171
|
+
if content_length_values:
|
|
172
|
+
parsed_lengths: list[int] = []
|
|
173
|
+
for raw_cl in content_length_values:
|
|
174
|
+
try:
|
|
175
|
+
parsed_cl = int(raw_cl)
|
|
176
|
+
except ValueError:
|
|
177
|
+
raise HttpException(
|
|
178
|
+
"Invalid Content-Length header", status_code=400
|
|
179
|
+
) from None
|
|
180
|
+
if parsed_cl < 0:
|
|
181
|
+
raise HttpException(
|
|
182
|
+
"Invalid Content-Length header", status_code=400
|
|
183
|
+
)
|
|
184
|
+
parsed_lengths.append(parsed_cl)
|
|
185
|
+
if len(content_length_values) > 1 and (
|
|
186
|
+
len(set(parsed_lengths)) > 1
|
|
187
|
+
or len({val.strip() for val in content_length_values}) > 1
|
|
188
|
+
):
|
|
189
|
+
raise HttpException(
|
|
190
|
+
"Conflicting Content-Length headers", status_code=400
|
|
191
|
+
)
|
|
192
|
+
content_length = parsed_lengths[0]
|
|
193
|
+
cl_limit = (
|
|
194
|
+
self.max_content_length
|
|
195
|
+
if self.max_content_length is not None
|
|
196
|
+
else limit
|
|
197
|
+
)
|
|
198
|
+
if cl_limit is not None and content_length > cl_limit:
|
|
199
|
+
raise PayloadTooLargeError(
|
|
200
|
+
f"Request content length exceeds maximum size of {cl_limit} bytes"
|
|
201
|
+
)
|
|
202
|
+
if limit is not None and content_length > limit:
|
|
203
|
+
raise PayloadTooLargeError(
|
|
204
|
+
f"Request body exceeds maximum size of {limit} bytes"
|
|
205
|
+
)
|
|
206
|
+
|
|
207
|
+
if self._body is None:
|
|
208
|
+
chunks: list[bytes] = []
|
|
209
|
+
total_bytes = 0
|
|
210
|
+
|
|
211
|
+
while True:
|
|
212
|
+
message = await self._receive()
|
|
213
|
+
msg_type = message.get("type")
|
|
214
|
+
|
|
215
|
+
if msg_type == "http.disconnect":
|
|
216
|
+
raise ClientDisconnect(
|
|
217
|
+
"Client disconnected before request body was completely received"
|
|
218
|
+
)
|
|
219
|
+
|
|
220
|
+
chunk: bytes = message.get("body", b"")
|
|
221
|
+
total_bytes += len(chunk)
|
|
222
|
+
if limit is not None and total_bytes > limit:
|
|
223
|
+
raise PayloadTooLargeError(
|
|
224
|
+
f"Request body exceeds maximum size of {limit} bytes"
|
|
225
|
+
)
|
|
226
|
+
|
|
227
|
+
chunks.append(chunk)
|
|
228
|
+
|
|
229
|
+
if not message.get("more_body", False):
|
|
230
|
+
break
|
|
231
|
+
|
|
232
|
+
self._body = b"".join(chunks)
|
|
233
|
+
elif limit is not None and len(self._body) > limit:
|
|
234
|
+
raise PayloadTooLargeError(
|
|
235
|
+
f"Request body exceeds maximum size of {limit} bytes"
|
|
236
|
+
)
|
|
237
|
+
|
|
238
|
+
return self._body
|
|
239
|
+
|
|
240
|
+
async def json(self) -> Any:
|
|
241
|
+
if not self._json_parsed:
|
|
242
|
+
raw_bytes = await self.body()
|
|
243
|
+
if not raw_bytes:
|
|
244
|
+
self._json = {}
|
|
245
|
+
else:
|
|
246
|
+
try:
|
|
247
|
+
raw_text = raw_bytes.decode("utf-8")
|
|
248
|
+
self._json = json.loads(raw_text) if raw_text else {}
|
|
249
|
+
except (
|
|
250
|
+
UnicodeDecodeError,
|
|
251
|
+
json.JSONDecodeError,
|
|
252
|
+
RecursionError,
|
|
253
|
+
ValueError,
|
|
254
|
+
):
|
|
255
|
+
raise HttpException(
|
|
256
|
+
"Invalid JSON payload", status_code=400
|
|
257
|
+
) from None
|
|
258
|
+
_validate_json_depth(self._json, max_depth=self.max_json_depth)
|
|
259
|
+
self._json_parsed = True
|
|
260
|
+
|
|
261
|
+
return self._json
|
|
262
|
+
|
|
263
|
+
def _resolve_upload_limits(self) -> tuple[int, int, int, int, str | None]:
|
|
264
|
+
"""Resolve upload parser limits from filesystems.uploads config and Request overrides."""
|
|
265
|
+
default_max_file = 100 * 1024 * 1024
|
|
266
|
+
default_max_req = 200 * 1024 * 1024
|
|
267
|
+
default_mem_limit = 1024 * 1024
|
|
268
|
+
|
|
269
|
+
uploads_cfg: Mapping[str, Any] | None = None
|
|
270
|
+
try:
|
|
271
|
+
container = get_container()
|
|
272
|
+
if container.has(Config):
|
|
273
|
+
cfg = container.resolve(Config)
|
|
274
|
+
raw_cfg = cfg.get("filesystems.uploads")
|
|
275
|
+
if isinstance(raw_cfg, Mapping):
|
|
276
|
+
uploads_cfg = raw_cfg
|
|
277
|
+
except RuntimeError:
|
|
278
|
+
uploads_cfg = None
|
|
279
|
+
|
|
280
|
+
max_file_size = default_max_file
|
|
281
|
+
memory_limit = default_mem_limit
|
|
282
|
+
max_field_size = default_mem_limit
|
|
283
|
+
temp_dir: str | None = None
|
|
284
|
+
cfg_max_request: int | None = None
|
|
285
|
+
|
|
286
|
+
if uploads_cfg is not None:
|
|
287
|
+
raw_file_limit = uploads_cfg.get("max_file_size")
|
|
288
|
+
if isinstance(raw_file_limit, int) and not isinstance(raw_file_limit, bool) and raw_file_limit > 0:
|
|
289
|
+
max_file_size = raw_file_limit
|
|
290
|
+
raw_mem = uploads_cfg.get("memory_limit")
|
|
291
|
+
if isinstance(raw_mem, int) and not isinstance(raw_mem, bool) and raw_mem > 0:
|
|
292
|
+
memory_limit = raw_mem
|
|
293
|
+
max_field_size = memory_limit
|
|
294
|
+
raw_field_limit = uploads_cfg.get("max_field_size")
|
|
295
|
+
if isinstance(raw_field_limit, int) and not isinstance(raw_field_limit, bool) and raw_field_limit > 0:
|
|
296
|
+
max_field_size = raw_field_limit
|
|
297
|
+
raw_req_limit = uploads_cfg.get("max_request_size")
|
|
298
|
+
if isinstance(raw_req_limit, int) and not isinstance(raw_req_limit, bool) and raw_req_limit > 0:
|
|
299
|
+
cfg_max_request = raw_req_limit
|
|
300
|
+
raw_temp_dir = uploads_cfg.get("temp_dir")
|
|
301
|
+
if isinstance(raw_temp_dir, str) and raw_temp_dir.strip():
|
|
302
|
+
temp_dir = raw_temp_dir
|
|
303
|
+
|
|
304
|
+
if self.max_body_size != DEFAULT_MAX_BODY_SIZE and self.max_body_size is not None:
|
|
305
|
+
max_request_size = (
|
|
306
|
+
min(self.max_body_size, cfg_max_request)
|
|
307
|
+
if cfg_max_request is not None
|
|
308
|
+
else self.max_body_size
|
|
309
|
+
)
|
|
310
|
+
elif cfg_max_request is not None:
|
|
311
|
+
max_request_size = cfg_max_request
|
|
312
|
+
else:
|
|
313
|
+
max_request_size = (
|
|
314
|
+
self.max_body_size if self.max_body_size is not None else default_max_req
|
|
315
|
+
)
|
|
316
|
+
|
|
317
|
+
return max_file_size, max_request_size, memory_limit, max_field_size, temp_dir
|
|
318
|
+
|
|
319
|
+
async def form(self) -> FormData:
|
|
320
|
+
if self._form is None:
|
|
321
|
+
content_type = self.headers.get("content-type", "")
|
|
322
|
+
if "multipart/form-data" in content_type.lower():
|
|
323
|
+
boundary = MultipartParser.extract_boundary(content_type)
|
|
324
|
+
(
|
|
325
|
+
max_file_size,
|
|
326
|
+
max_request_size,
|
|
327
|
+
memory_limit,
|
|
328
|
+
max_field_size,
|
|
329
|
+
temp_dir,
|
|
330
|
+
) = self._resolve_upload_limits()
|
|
331
|
+
parser = MultipartParser(
|
|
332
|
+
boundary,
|
|
333
|
+
max_file_size=max_file_size,
|
|
334
|
+
max_request_size=max_request_size,
|
|
335
|
+
memory_limit=memory_limit,
|
|
336
|
+
max_field_size=max_field_size,
|
|
337
|
+
temp_dir=temp_dir,
|
|
338
|
+
)
|
|
339
|
+
if self._body is not None:
|
|
340
|
+
async def _body_stream(raw: bytes) -> AsyncIterator[bytes]:
|
|
341
|
+
chunk_size = 64 * 1024
|
|
342
|
+
for i in range(0, len(raw), chunk_size):
|
|
343
|
+
yield raw[i : i + chunk_size]
|
|
344
|
+
|
|
345
|
+
self._form = await parser.parse(_body_stream(self._body))
|
|
346
|
+
else:
|
|
347
|
+
self._form = await parser.parse(self._receive)
|
|
348
|
+
else:
|
|
349
|
+
raw_bytes = await self.body()
|
|
350
|
+
form_data = FormData()
|
|
351
|
+
if raw_bytes:
|
|
352
|
+
decoded = raw_bytes.decode("utf-8", errors="replace")
|
|
353
|
+
parsed = parse_qs(decoded, keep_blank_values=True)
|
|
354
|
+
for k, vals in parsed.items():
|
|
355
|
+
for v in vals:
|
|
356
|
+
form_data.add_field(k, v)
|
|
357
|
+
self._form = form_data
|
|
358
|
+
return self._form
|
|
359
|
+
|
|
360
|
+
async def file(self, name: str) -> UploadedFile | None:
|
|
361
|
+
form_data = await self.form()
|
|
362
|
+
return form_data.get_file(name)
|
|
363
|
+
|
|
364
|
+
async def files(
|
|
365
|
+
self, name: str | None = None
|
|
366
|
+
) -> list[UploadedFile] | dict[str, list[UploadedFile]]:
|
|
367
|
+
form_data = await self.form()
|
|
368
|
+
if name is not None:
|
|
369
|
+
return form_data.get_files(name)
|
|
370
|
+
return dict(form_data.files_list)
|
|
371
|
+
|
|
372
|
+
async def close(self) -> None:
|
|
373
|
+
if self._form is not None:
|
|
374
|
+
await self._form.close()
|
|
375
|
+
self._form = None
|