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
swiftpy/http/router.py
ADDED
|
@@ -0,0 +1,1050 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import inspect
|
|
4
|
+
import math
|
|
5
|
+
import re
|
|
6
|
+
from collections.abc import Callable
|
|
7
|
+
from dataclasses import dataclass, field
|
|
8
|
+
from typing import Any, Literal, TypeVar, get_args, get_origin, get_type_hints
|
|
9
|
+
|
|
10
|
+
from swiftpy.core.config import ConfigurationError
|
|
11
|
+
from swiftpy.core.container import Container
|
|
12
|
+
from swiftpy.core.exceptions import MethodNotAllowedError, ValidationError
|
|
13
|
+
from swiftpy.filesystem.uploads.uploaded_file import UploadedFile
|
|
14
|
+
from swiftpy.http.request import Request
|
|
15
|
+
from swiftpy.http.response import Response
|
|
16
|
+
from swiftpy.validation.form_request import FormRequest
|
|
17
|
+
from swiftpy.websockets.connection import WebSocket, WebSocketConnection
|
|
18
|
+
|
|
19
|
+
RouteHandler = Any
|
|
20
|
+
F = TypeVar("F", bound=Callable[..., Any])
|
|
21
|
+
|
|
22
|
+
_PARAM_TOKEN_RE = re.compile(r"\{([^{}]*)\}")
|
|
23
|
+
|
|
24
|
+
_BUILTIN_TYPES: dict[str, type[Any]] = {
|
|
25
|
+
"int": int,
|
|
26
|
+
"str": str,
|
|
27
|
+
"float": float,
|
|
28
|
+
"bool": bool,
|
|
29
|
+
"bytes": bytes,
|
|
30
|
+
"dict": dict,
|
|
31
|
+
"list": list,
|
|
32
|
+
"set": set,
|
|
33
|
+
"tuple": tuple,
|
|
34
|
+
"Any": Any,
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
ParamKind = Literal[
|
|
38
|
+
"request", "path", "form", "di", "default", "websocket", "file", "files"
|
|
39
|
+
]
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
class RouteRegistrationError(ConfigurationError, TypeError):
|
|
43
|
+
"""Raised at route registration or compilation time when a route or handler is invalid."""
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
class PathParamCoercionError(ValidationError):
|
|
47
|
+
"""Raised when a URL path parameter cannot be coerced to its declared type."""
|
|
48
|
+
|
|
49
|
+
def __init__(self, param: str, value: str, expected_type: type[Any]) -> None:
|
|
50
|
+
self.param = param
|
|
51
|
+
self.value = value
|
|
52
|
+
self.expected_type = expected_type
|
|
53
|
+
msg = (
|
|
54
|
+
f"Path parameter '{param}' with value '{value}' could not be "
|
|
55
|
+
f"converted to {expected_type.__name__}"
|
|
56
|
+
)
|
|
57
|
+
super().__init__(errors={param: [msg]}, message=msg)
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def _coerce_bool(value: str) -> bool:
|
|
61
|
+
norm = value.strip().lower()
|
|
62
|
+
if norm in ("true", "1", "yes"):
|
|
63
|
+
return True
|
|
64
|
+
if norm in ("false", "0", "no"):
|
|
65
|
+
return False
|
|
66
|
+
raise ValueError(f"Invalid boolean value: {value!r}")
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def _coerce_float(value: str) -> float:
|
|
70
|
+
parsed = float(value)
|
|
71
|
+
if not math.isfinite(parsed):
|
|
72
|
+
raise ValueError(f"Non-finite float value: {value!r}")
|
|
73
|
+
return parsed
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def _collect_caller_locals(handler: Callable[..., Any]) -> dict[str, Any]:
|
|
77
|
+
localns: dict[str, Any] = {}
|
|
78
|
+
target_code = getattr(handler, "__code__", None)
|
|
79
|
+
target_file = getattr(target_code, "co_filename", None)
|
|
80
|
+
frame = inspect.currentframe()
|
|
81
|
+
try:
|
|
82
|
+
curr = frame.f_back if frame is not None else None
|
|
83
|
+
depth = 0
|
|
84
|
+
while curr is not None and depth < 8:
|
|
85
|
+
if target_file is None or curr.f_code.co_filename == target_file:
|
|
86
|
+
for k, v in curr.f_locals.items():
|
|
87
|
+
if k not in localns:
|
|
88
|
+
localns[k] = v
|
|
89
|
+
curr = curr.f_back
|
|
90
|
+
depth += 1
|
|
91
|
+
finally:
|
|
92
|
+
del frame
|
|
93
|
+
return localns
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def _resolve_handler_type_hints(
|
|
97
|
+
handler: Callable[..., Any],
|
|
98
|
+
sig: inspect.Signature,
|
|
99
|
+
extra_locals: dict[str, Any] | None = None,
|
|
100
|
+
) -> dict[str, Any]:
|
|
101
|
+
merged_locals: dict[str, Any] = dict(_BUILTIN_TYPES)
|
|
102
|
+
if extra_locals:
|
|
103
|
+
merged_locals.update(extra_locals)
|
|
104
|
+
try:
|
|
105
|
+
closure_vars = inspect.getclosurevars(handler).nonlocals
|
|
106
|
+
merged_locals.update(closure_vars)
|
|
107
|
+
except (TypeError, ValueError):
|
|
108
|
+
pass
|
|
109
|
+
|
|
110
|
+
globalns: dict[str, Any] = getattr(handler, "__globals__", {})
|
|
111
|
+
|
|
112
|
+
try:
|
|
113
|
+
hints = get_type_hints(handler, globalns=globalns, localns=merged_locals)
|
|
114
|
+
except (NameError, TypeError, ValueError):
|
|
115
|
+
hints = {}
|
|
116
|
+
|
|
117
|
+
resolved: dict[str, Any] = {}
|
|
118
|
+
for name, param in sig.parameters.items():
|
|
119
|
+
ann = hints.get(name, param.annotation)
|
|
120
|
+
if isinstance(ann, str):
|
|
121
|
+
if ann in merged_locals:
|
|
122
|
+
ann = merged_locals[ann]
|
|
123
|
+
elif ann in globalns:
|
|
124
|
+
ann = globalns[ann]
|
|
125
|
+
resolved[name] = ann
|
|
126
|
+
return resolved
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
@dataclass(slots=True, frozen=True)
|
|
130
|
+
class ParamSpec:
|
|
131
|
+
name: str
|
|
132
|
+
kind: ParamKind
|
|
133
|
+
annotation: Any
|
|
134
|
+
default: Any = inspect.Parameter.empty
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
@dataclass(slots=True)
|
|
138
|
+
class Route:
|
|
139
|
+
method: str
|
|
140
|
+
path: str
|
|
141
|
+
handler: RouteHandler
|
|
142
|
+
middleware: list[type[Any]]
|
|
143
|
+
is_websocket: bool = False
|
|
144
|
+
order: int = 0
|
|
145
|
+
max_body_size: int | None = None
|
|
146
|
+
max_content_length: int | None = None
|
|
147
|
+
max_json_depth: int | None = None
|
|
148
|
+
localns: dict[str, Any] | None = field(default=None, repr=False)
|
|
149
|
+
pattern: re.Pattern[str] = field(init=False)
|
|
150
|
+
param_names: tuple[str, ...] = field(init=False)
|
|
151
|
+
normalized_path: str = field(init=False)
|
|
152
|
+
precedence_key: tuple[int, tuple[int, ...], int, int] = field(init=False)
|
|
153
|
+
signature: inspect.Signature = field(init=False, repr=False)
|
|
154
|
+
type_hints: dict[str, Any] = field(init=False, repr=False)
|
|
155
|
+
param_coercers: dict[str, tuple[type[Any], Callable[[str], Any]]] = field(
|
|
156
|
+
init=False, repr=False
|
|
157
|
+
)
|
|
158
|
+
param_specs: tuple[ParamSpec, ...] = field(default=(), init=False, repr=False)
|
|
159
|
+
has_var_keyword: bool = field(default=False, init=False, repr=False)
|
|
160
|
+
compiled_handler: RouteHandler | None = field(default=None, init=False, repr=False)
|
|
161
|
+
analysis_deferred: bool = field(default=False, init=False, repr=False)
|
|
162
|
+
|
|
163
|
+
def __post_init__(self) -> None:
|
|
164
|
+
self.method = self.method.upper()
|
|
165
|
+
if not self.path.startswith("/"):
|
|
166
|
+
raise RouteRegistrationError(
|
|
167
|
+
f"Route path must start with '/': {self.path!r}"
|
|
168
|
+
)
|
|
169
|
+
|
|
170
|
+
regex_parts: list[str] = []
|
|
171
|
+
normalized_parts: list[str] = []
|
|
172
|
+
param_names: list[str] = []
|
|
173
|
+
last_idx = 0
|
|
174
|
+
|
|
175
|
+
for match in _PARAM_TOKEN_RE.finditer(self.path):
|
|
176
|
+
literal = self.path[last_idx : match.start()]
|
|
177
|
+
if "{" in literal or "}" in literal:
|
|
178
|
+
raise RouteRegistrationError(
|
|
179
|
+
f"Malformed route path '{self.path}': unmatched brace."
|
|
180
|
+
)
|
|
181
|
+
regex_parts.append(re.escape(literal))
|
|
182
|
+
normalized_parts.append(literal)
|
|
183
|
+
|
|
184
|
+
param_name = match.group(1)
|
|
185
|
+
if not param_name or not param_name.isidentifier():
|
|
186
|
+
raise RouteRegistrationError(
|
|
187
|
+
f"Invalid path parameter name '{param_name}' in route '{self.path}'."
|
|
188
|
+
)
|
|
189
|
+
if param_name in param_names:
|
|
190
|
+
raise RouteRegistrationError(
|
|
191
|
+
f"Duplicate path parameter '{param_name}' in route '{self.path}'."
|
|
192
|
+
)
|
|
193
|
+
param_names.append(param_name)
|
|
194
|
+
regex_parts.append(f"(?P<{param_name}>[^/]+)")
|
|
195
|
+
normalized_parts.append("{}")
|
|
196
|
+
last_idx = match.end()
|
|
197
|
+
|
|
198
|
+
tail = self.path[last_idx:]
|
|
199
|
+
if "{" in tail or "}" in tail:
|
|
200
|
+
raise RouteRegistrationError(
|
|
201
|
+
f"Malformed route path '{self.path}': unmatched brace."
|
|
202
|
+
)
|
|
203
|
+
regex_parts.append(re.escape(tail))
|
|
204
|
+
normalized_parts.append(tail)
|
|
205
|
+
|
|
206
|
+
self.pattern = re.compile(f"^{''.join(regex_parts)}$")
|
|
207
|
+
self.param_names = tuple(param_names)
|
|
208
|
+
self.normalized_path = "".join(normalized_parts)
|
|
209
|
+
|
|
210
|
+
segments = [seg for seg in self.path.split("/") if seg]
|
|
211
|
+
segment_kinds = tuple(
|
|
212
|
+
1 if _PARAM_TOKEN_RE.search(seg) else 0 for seg in segments
|
|
213
|
+
)
|
|
214
|
+
num_dynamic = sum(segment_kinds)
|
|
215
|
+
static_chars = sum(len(_PARAM_TOKEN_RE.sub("", seg)) for seg in segments)
|
|
216
|
+
self.precedence_key = (
|
|
217
|
+
num_dynamic,
|
|
218
|
+
segment_kinds,
|
|
219
|
+
-static_chars,
|
|
220
|
+
self.order,
|
|
221
|
+
)
|
|
222
|
+
|
|
223
|
+
self.signature = inspect.signature(self.handler)
|
|
224
|
+
self.type_hints = _resolve_handler_type_hints(
|
|
225
|
+
self.handler,
|
|
226
|
+
self.signature,
|
|
227
|
+
self.localns,
|
|
228
|
+
)
|
|
229
|
+
self.has_var_keyword = any(
|
|
230
|
+
p.kind == inspect.Parameter.VAR_KEYWORD
|
|
231
|
+
for p in self.signature.parameters.values()
|
|
232
|
+
)
|
|
233
|
+
|
|
234
|
+
coercers: dict[str, tuple[type[Any], Callable[[str], Any]]] = {}
|
|
235
|
+
for p_name in self.param_names:
|
|
236
|
+
ann = self.type_hints.get(p_name, inspect.Parameter.empty)
|
|
237
|
+
if ann is bool:
|
|
238
|
+
coercers[p_name] = (bool, _coerce_bool)
|
|
239
|
+
elif ann is int:
|
|
240
|
+
coercers[p_name] = (int, int)
|
|
241
|
+
elif ann is float:
|
|
242
|
+
coercers[p_name] = (float, _coerce_float)
|
|
243
|
+
elif ann is str or ann is inspect.Parameter.empty or ann is Any:
|
|
244
|
+
coercers[p_name] = (str, str)
|
|
245
|
+
elif isinstance(ann, type) and callable(ann):
|
|
246
|
+
coercers[p_name] = (ann, ann)
|
|
247
|
+
else:
|
|
248
|
+
coercers[p_name] = (str, str)
|
|
249
|
+
self.param_coercers = coercers
|
|
250
|
+
|
|
251
|
+
|
|
252
|
+
def _normalize_route_prefix(prefix: str) -> str:
|
|
253
|
+
"""Validate and normalize a router/group prefix (empty string for root `'/'`)."""
|
|
254
|
+
clean_prefix = prefix.strip()
|
|
255
|
+
if not clean_prefix or clean_prefix == "/":
|
|
256
|
+
return ""
|
|
257
|
+
if any(ch.isspace() for ch in clean_prefix):
|
|
258
|
+
raise RouteRegistrationError(
|
|
259
|
+
f"Router prefix must not contain whitespace: {prefix!r}"
|
|
260
|
+
)
|
|
261
|
+
if any(token in clean_prefix for token in ("{", "}")):
|
|
262
|
+
raise RouteRegistrationError(
|
|
263
|
+
f"Router prefix must be a literal path, not a dynamic pattern: {prefix!r}"
|
|
264
|
+
)
|
|
265
|
+
if "//" in clean_prefix:
|
|
266
|
+
raise RouteRegistrationError(
|
|
267
|
+
f"Router prefix must not contain empty path segments: {prefix!r}"
|
|
268
|
+
)
|
|
269
|
+
stripped = clean_prefix.strip("/")
|
|
270
|
+
return f"/{stripped}" if stripped else ""
|
|
271
|
+
|
|
272
|
+
|
|
273
|
+
def _join_route_path(prefix: str, child_path: str) -> str:
|
|
274
|
+
"""Join a normalized prefix with a child route path while preserving trailing slashes."""
|
|
275
|
+
clean_prefix = prefix.strip("/")
|
|
276
|
+
base = f"/{clean_prefix}" if clean_prefix else ""
|
|
277
|
+
|
|
278
|
+
if child_path in ("", "/"):
|
|
279
|
+
return base or "/"
|
|
280
|
+
|
|
281
|
+
stripped_child = child_path.strip("/")
|
|
282
|
+
if not stripped_child:
|
|
283
|
+
return base or "/"
|
|
284
|
+
|
|
285
|
+
suffix = f"{stripped_child}/" if child_path.endswith("/") else stripped_child
|
|
286
|
+
return f"{base}/{suffix}"
|
|
287
|
+
|
|
288
|
+
|
|
289
|
+
class RouteGroup:
|
|
290
|
+
def __init__(
|
|
291
|
+
self,
|
|
292
|
+
prefix: str,
|
|
293
|
+
router: Router,
|
|
294
|
+
middleware: list[type[Any]] | None = None,
|
|
295
|
+
) -> None:
|
|
296
|
+
normalized = _normalize_route_prefix(prefix)
|
|
297
|
+
self.prefix = normalized or "/"
|
|
298
|
+
self._normalized_prefix = normalized
|
|
299
|
+
self.router = router
|
|
300
|
+
self.middleware = middleware or []
|
|
301
|
+
|
|
302
|
+
def _add_route(
|
|
303
|
+
self,
|
|
304
|
+
method: str,
|
|
305
|
+
path: str,
|
|
306
|
+
handler: RouteHandler,
|
|
307
|
+
*,
|
|
308
|
+
is_websocket: bool = False,
|
|
309
|
+
max_body_size: int | None = None,
|
|
310
|
+
max_content_length: int | None = None,
|
|
311
|
+
max_json_depth: int | None = None,
|
|
312
|
+
localns: dict[str, Any] | None = None,
|
|
313
|
+
) -> None:
|
|
314
|
+
full_path = _join_route_path(self._normalized_prefix, path)
|
|
315
|
+
self.router.add_route(
|
|
316
|
+
method=method,
|
|
317
|
+
path=full_path,
|
|
318
|
+
handler=handler,
|
|
319
|
+
middleware=list(self.middleware),
|
|
320
|
+
is_websocket=is_websocket,
|
|
321
|
+
max_body_size=max_body_size,
|
|
322
|
+
max_content_length=max_content_length,
|
|
323
|
+
max_json_depth=max_json_depth,
|
|
324
|
+
localns=localns,
|
|
325
|
+
)
|
|
326
|
+
|
|
327
|
+
def get(
|
|
328
|
+
self,
|
|
329
|
+
path: str,
|
|
330
|
+
*,
|
|
331
|
+
max_body_size: int | None = None,
|
|
332
|
+
max_content_length: int | None = None,
|
|
333
|
+
max_json_depth: int | None = None,
|
|
334
|
+
) -> Callable[[F], F]:
|
|
335
|
+
def decorator(func: F) -> F:
|
|
336
|
+
self._add_route(
|
|
337
|
+
"GET",
|
|
338
|
+
path,
|
|
339
|
+
func,
|
|
340
|
+
max_body_size=max_body_size,
|
|
341
|
+
max_content_length=max_content_length,
|
|
342
|
+
max_json_depth=max_json_depth,
|
|
343
|
+
localns=_collect_caller_locals(func),
|
|
344
|
+
)
|
|
345
|
+
return func
|
|
346
|
+
|
|
347
|
+
return decorator
|
|
348
|
+
|
|
349
|
+
def post(
|
|
350
|
+
self,
|
|
351
|
+
path: str,
|
|
352
|
+
*,
|
|
353
|
+
max_body_size: int | None = None,
|
|
354
|
+
max_content_length: int | None = None,
|
|
355
|
+
max_json_depth: int | None = None,
|
|
356
|
+
) -> Callable[[F], F]:
|
|
357
|
+
def decorator(func: F) -> F:
|
|
358
|
+
self._add_route(
|
|
359
|
+
"POST",
|
|
360
|
+
path,
|
|
361
|
+
func,
|
|
362
|
+
max_body_size=max_body_size,
|
|
363
|
+
max_content_length=max_content_length,
|
|
364
|
+
max_json_depth=max_json_depth,
|
|
365
|
+
localns=_collect_caller_locals(func),
|
|
366
|
+
)
|
|
367
|
+
return func
|
|
368
|
+
|
|
369
|
+
return decorator
|
|
370
|
+
|
|
371
|
+
def put(
|
|
372
|
+
self,
|
|
373
|
+
path: str,
|
|
374
|
+
*,
|
|
375
|
+
max_body_size: int | None = None,
|
|
376
|
+
max_content_length: int | None = None,
|
|
377
|
+
max_json_depth: int | None = None,
|
|
378
|
+
) -> Callable[[F], F]:
|
|
379
|
+
def decorator(func: F) -> F:
|
|
380
|
+
self._add_route(
|
|
381
|
+
"PUT",
|
|
382
|
+
path,
|
|
383
|
+
func,
|
|
384
|
+
max_body_size=max_body_size,
|
|
385
|
+
max_content_length=max_content_length,
|
|
386
|
+
max_json_depth=max_json_depth,
|
|
387
|
+
localns=_collect_caller_locals(func),
|
|
388
|
+
)
|
|
389
|
+
return func
|
|
390
|
+
|
|
391
|
+
return decorator
|
|
392
|
+
|
|
393
|
+
def patch(
|
|
394
|
+
self,
|
|
395
|
+
path: str,
|
|
396
|
+
*,
|
|
397
|
+
max_body_size: int | None = None,
|
|
398
|
+
max_content_length: int | None = None,
|
|
399
|
+
max_json_depth: int | None = None,
|
|
400
|
+
) -> Callable[[F], F]:
|
|
401
|
+
def decorator(func: F) -> F:
|
|
402
|
+
self._add_route(
|
|
403
|
+
"PATCH",
|
|
404
|
+
path,
|
|
405
|
+
func,
|
|
406
|
+
max_body_size=max_body_size,
|
|
407
|
+
max_content_length=max_content_length,
|
|
408
|
+
max_json_depth=max_json_depth,
|
|
409
|
+
localns=_collect_caller_locals(func),
|
|
410
|
+
)
|
|
411
|
+
return func
|
|
412
|
+
|
|
413
|
+
return decorator
|
|
414
|
+
|
|
415
|
+
def delete(
|
|
416
|
+
self,
|
|
417
|
+
path: str,
|
|
418
|
+
*,
|
|
419
|
+
max_body_size: int | None = None,
|
|
420
|
+
max_content_length: int | None = None,
|
|
421
|
+
max_json_depth: int | None = None,
|
|
422
|
+
) -> Callable[[F], F]:
|
|
423
|
+
def decorator(func: F) -> F:
|
|
424
|
+
self._add_route(
|
|
425
|
+
"DELETE",
|
|
426
|
+
path,
|
|
427
|
+
func,
|
|
428
|
+
max_body_size=max_body_size,
|
|
429
|
+
max_content_length=max_content_length,
|
|
430
|
+
max_json_depth=max_json_depth,
|
|
431
|
+
localns=_collect_caller_locals(func),
|
|
432
|
+
)
|
|
433
|
+
return func
|
|
434
|
+
|
|
435
|
+
return decorator
|
|
436
|
+
|
|
437
|
+
def head(
|
|
438
|
+
self,
|
|
439
|
+
path: str,
|
|
440
|
+
*,
|
|
441
|
+
max_body_size: int | None = None,
|
|
442
|
+
max_content_length: int | None = None,
|
|
443
|
+
max_json_depth: int | None = None,
|
|
444
|
+
) -> Callable[[F], F]:
|
|
445
|
+
def decorator(func: F) -> F:
|
|
446
|
+
self._add_route(
|
|
447
|
+
"HEAD",
|
|
448
|
+
path,
|
|
449
|
+
func,
|
|
450
|
+
max_body_size=max_body_size,
|
|
451
|
+
max_content_length=max_content_length,
|
|
452
|
+
max_json_depth=max_json_depth,
|
|
453
|
+
localns=_collect_caller_locals(func),
|
|
454
|
+
)
|
|
455
|
+
return func
|
|
456
|
+
|
|
457
|
+
return decorator
|
|
458
|
+
|
|
459
|
+
def options(
|
|
460
|
+
self,
|
|
461
|
+
path: str,
|
|
462
|
+
*,
|
|
463
|
+
max_body_size: int | None = None,
|
|
464
|
+
max_content_length: int | None = None,
|
|
465
|
+
max_json_depth: int | None = None,
|
|
466
|
+
) -> Callable[[F], F]:
|
|
467
|
+
def decorator(func: F) -> F:
|
|
468
|
+
self._add_route(
|
|
469
|
+
"OPTIONS",
|
|
470
|
+
path,
|
|
471
|
+
func,
|
|
472
|
+
max_body_size=max_body_size,
|
|
473
|
+
max_content_length=max_content_length,
|
|
474
|
+
max_json_depth=max_json_depth,
|
|
475
|
+
localns=_collect_caller_locals(func),
|
|
476
|
+
)
|
|
477
|
+
return func
|
|
478
|
+
|
|
479
|
+
return decorator
|
|
480
|
+
|
|
481
|
+
def websocket(self, path: str) -> Callable[[F], F]:
|
|
482
|
+
def decorator(func: F) -> F:
|
|
483
|
+
self._add_route(
|
|
484
|
+
"WEBSOCKET",
|
|
485
|
+
path,
|
|
486
|
+
func,
|
|
487
|
+
is_websocket=True,
|
|
488
|
+
localns=_collect_caller_locals(func),
|
|
489
|
+
)
|
|
490
|
+
return func
|
|
491
|
+
|
|
492
|
+
return decorator
|
|
493
|
+
|
|
494
|
+
|
|
495
|
+
class Router:
|
|
496
|
+
"""
|
|
497
|
+
HTTP and WebSocket router with static-first precedence, precomputed handler
|
|
498
|
+
signatures, and strict path parameter coercion.
|
|
499
|
+
Trailing-slash policy: redirect-free exact match (no silent normalization).
|
|
500
|
+
"""
|
|
501
|
+
|
|
502
|
+
def __init__(self, container: Container | None = None) -> None:
|
|
503
|
+
self.container = container
|
|
504
|
+
self.routes: list[Route] = []
|
|
505
|
+
self._http_routes: list[Route] = []
|
|
506
|
+
self._routes_by_method: dict[str, list[Route]] = {}
|
|
507
|
+
self._websocket_routes: list[Route] = []
|
|
508
|
+
self._static_routes: dict[str, dict[str, Route]] = {}
|
|
509
|
+
self._static_websocket_routes: dict[str, Route] = {}
|
|
510
|
+
self._dynamic_routes_by_method: dict[str, list[Route]] = {}
|
|
511
|
+
self._dynamic_websocket_routes: list[Route] = []
|
|
512
|
+
self._registered_keys: set[tuple[str, str]] = set()
|
|
513
|
+
self._counter = 0
|
|
514
|
+
|
|
515
|
+
def _can_resolve_di(
|
|
516
|
+
self,
|
|
517
|
+
annotation: Any,
|
|
518
|
+
container: Container | None = None,
|
|
519
|
+
) -> bool:
|
|
520
|
+
target_container = container if container is not None else self.container
|
|
521
|
+
if target_container is None:
|
|
522
|
+
return False
|
|
523
|
+
return target_container.can_resolve(annotation)
|
|
524
|
+
|
|
525
|
+
def _analyze_handler(
|
|
526
|
+
self,
|
|
527
|
+
route: Route,
|
|
528
|
+
*,
|
|
529
|
+
container: Container | None = None,
|
|
530
|
+
) -> tuple[ParamSpec, ...]:
|
|
531
|
+
specs: list[ParamSpec] = []
|
|
532
|
+
ws_assigned = False
|
|
533
|
+
|
|
534
|
+
for name, param in route.signature.parameters.items():
|
|
535
|
+
if param.kind in (
|
|
536
|
+
inspect.Parameter.VAR_POSITIONAL,
|
|
537
|
+
inspect.Parameter.VAR_KEYWORD,
|
|
538
|
+
):
|
|
539
|
+
continue
|
|
540
|
+
|
|
541
|
+
ann = route.type_hints.get(name, param.annotation)
|
|
542
|
+
|
|
543
|
+
if route.is_websocket:
|
|
544
|
+
is_ws_type = (
|
|
545
|
+
ann is WebSocketConnection
|
|
546
|
+
or ann is WebSocket
|
|
547
|
+
or (isinstance(ann, type) and issubclass(ann, WebSocketConnection))
|
|
548
|
+
)
|
|
549
|
+
if is_ws_type or (
|
|
550
|
+
not ws_assigned
|
|
551
|
+
and name in ("websocket", "ws", "conn", "connection")
|
|
552
|
+
and (ann is inspect.Parameter.empty or ann is Any)
|
|
553
|
+
):
|
|
554
|
+
ws_assigned = True
|
|
555
|
+
specs.append(
|
|
556
|
+
ParamSpec(name=name, kind="websocket", annotation=ann)
|
|
557
|
+
)
|
|
558
|
+
elif name in route.param_names:
|
|
559
|
+
specs.append(ParamSpec(name=name, kind="path", annotation=ann))
|
|
560
|
+
elif self._can_resolve_di(ann, container=container):
|
|
561
|
+
specs.append(ParamSpec(name=name, kind="di", annotation=ann))
|
|
562
|
+
elif param.default is not inspect.Parameter.empty:
|
|
563
|
+
specs.append(
|
|
564
|
+
ParamSpec(
|
|
565
|
+
name=name,
|
|
566
|
+
kind="default",
|
|
567
|
+
annotation=ann,
|
|
568
|
+
default=param.default,
|
|
569
|
+
)
|
|
570
|
+
)
|
|
571
|
+
else:
|
|
572
|
+
raise RouteRegistrationError(
|
|
573
|
+
f"WebSocket handler '{route.handler.__qualname__}' has "
|
|
574
|
+
f"unsatisfiable parameter '{name}' (annotation: {ann!r})."
|
|
575
|
+
)
|
|
576
|
+
continue
|
|
577
|
+
|
|
578
|
+
if isinstance(ann, type) and issubclass(ann, Request):
|
|
579
|
+
specs.append(ParamSpec(name=name, kind="request", annotation=ann))
|
|
580
|
+
elif isinstance(ann, type) and issubclass(ann, UploadedFile):
|
|
581
|
+
specs.append(ParamSpec(name=name, kind="file", annotation=ann))
|
|
582
|
+
elif (
|
|
583
|
+
get_origin(ann) is list
|
|
584
|
+
and get_args(ann)
|
|
585
|
+
and isinstance(get_args(ann)[0], type)
|
|
586
|
+
and issubclass(get_args(ann)[0], UploadedFile)
|
|
587
|
+
):
|
|
588
|
+
specs.append(ParamSpec(name=name, kind="files", annotation=ann))
|
|
589
|
+
elif name in route.param_names:
|
|
590
|
+
specs.append(ParamSpec(name=name, kind="path", annotation=ann))
|
|
591
|
+
elif isinstance(ann, type) and issubclass(ann, FormRequest):
|
|
592
|
+
specs.append(ParamSpec(name=name, kind="form", annotation=ann))
|
|
593
|
+
elif self._can_resolve_di(ann, container=container):
|
|
594
|
+
specs.append(ParamSpec(name=name, kind="di", annotation=ann))
|
|
595
|
+
elif param.default is not inspect.Parameter.empty:
|
|
596
|
+
specs.append(
|
|
597
|
+
ParamSpec(
|
|
598
|
+
name=name,
|
|
599
|
+
kind="default",
|
|
600
|
+
annotation=ann,
|
|
601
|
+
default=param.default,
|
|
602
|
+
)
|
|
603
|
+
)
|
|
604
|
+
else:
|
|
605
|
+
raise RouteRegistrationError(
|
|
606
|
+
f"Handler '{route.handler.__qualname__}' has unsatisfiable "
|
|
607
|
+
f"parameter '{name}' (annotation: {ann!r}). Route handler "
|
|
608
|
+
"parameters must be path parameters, Request, FormRequest "
|
|
609
|
+
"subclasses, or container-resolvable types."
|
|
610
|
+
)
|
|
611
|
+
|
|
612
|
+
return tuple(specs)
|
|
613
|
+
|
|
614
|
+
def ensure_route_analyzed(
|
|
615
|
+
self,
|
|
616
|
+
route: Route,
|
|
617
|
+
*,
|
|
618
|
+
container: Container | None = None,
|
|
619
|
+
) -> tuple[ParamSpec, ...]:
|
|
620
|
+
"""Resolve deferred handler parameter specs once a DI container is available."""
|
|
621
|
+
if route.analysis_deferred:
|
|
622
|
+
target = container or self.container or Container()
|
|
623
|
+
route.param_specs = self._analyze_handler(route, container=target)
|
|
624
|
+
route.analysis_deferred = False
|
|
625
|
+
return route.param_specs
|
|
626
|
+
|
|
627
|
+
def add_compiled_route(self, route: Route) -> Route:
|
|
628
|
+
"""Register an already-compiled Route object into all routing indexes."""
|
|
629
|
+
key = (route.method, route.normalized_path)
|
|
630
|
+
self._registered_keys.add(key)
|
|
631
|
+
if route not in self.routes:
|
|
632
|
+
self.routes.append(route)
|
|
633
|
+
|
|
634
|
+
is_static = not bool(route.param_names)
|
|
635
|
+
if route.is_websocket:
|
|
636
|
+
if route not in self._websocket_routes:
|
|
637
|
+
self._websocket_routes.append(route)
|
|
638
|
+
self._websocket_routes.sort(key=lambda r: r.precedence_key)
|
|
639
|
+
if is_static:
|
|
640
|
+
self._static_websocket_routes[route.path] = route
|
|
641
|
+
else:
|
|
642
|
+
if route not in self._dynamic_websocket_routes:
|
|
643
|
+
self._dynamic_websocket_routes.append(route)
|
|
644
|
+
self._dynamic_websocket_routes.sort(key=lambda r: r.precedence_key)
|
|
645
|
+
else:
|
|
646
|
+
if route not in self._http_routes:
|
|
647
|
+
self._http_routes.append(route)
|
|
648
|
+
self._http_routes.sort(key=lambda r: r.precedence_key)
|
|
649
|
+
method_list = self._routes_by_method.setdefault(route.method, [])
|
|
650
|
+
if route not in method_list:
|
|
651
|
+
method_list.append(route)
|
|
652
|
+
method_list.sort(key=lambda r: r.precedence_key)
|
|
653
|
+
if is_static:
|
|
654
|
+
self._static_routes.setdefault(route.method, {})[route.path] = route
|
|
655
|
+
else:
|
|
656
|
+
dyn_list = self._dynamic_routes_by_method.setdefault(route.method, [])
|
|
657
|
+
if route not in dyn_list:
|
|
658
|
+
dyn_list.append(route)
|
|
659
|
+
dyn_list.sort(key=lambda r: r.precedence_key)
|
|
660
|
+
|
|
661
|
+
return route
|
|
662
|
+
|
|
663
|
+
def add_route(
|
|
664
|
+
self,
|
|
665
|
+
method: str,
|
|
666
|
+
path: str,
|
|
667
|
+
handler: RouteHandler,
|
|
668
|
+
middleware: list[type[Any]] | None = None,
|
|
669
|
+
*,
|
|
670
|
+
is_websocket: bool = False,
|
|
671
|
+
max_body_size: int | None = None,
|
|
672
|
+
max_content_length: int | None = None,
|
|
673
|
+
max_json_depth: int | None = None,
|
|
674
|
+
localns: dict[str, Any] | None = None,
|
|
675
|
+
) -> Route:
|
|
676
|
+
norm_method = method.upper().strip()
|
|
677
|
+
if not norm_method:
|
|
678
|
+
raise RouteRegistrationError("HTTP method must not be empty.")
|
|
679
|
+
if norm_method == "WEBSOCKET" and not is_websocket:
|
|
680
|
+
raise RouteRegistrationError(
|
|
681
|
+
"Method 'WEBSOCKET' is internal-only; use websocket() to register WebSocket routes."
|
|
682
|
+
)
|
|
683
|
+
|
|
684
|
+
effective_locals = (
|
|
685
|
+
localns if localns is not None else _collect_caller_locals(handler)
|
|
686
|
+
)
|
|
687
|
+
route = Route(
|
|
688
|
+
method="WEBSOCKET" if is_websocket else norm_method,
|
|
689
|
+
path=path,
|
|
690
|
+
handler=handler,
|
|
691
|
+
middleware=middleware or [],
|
|
692
|
+
is_websocket=is_websocket,
|
|
693
|
+
order=self._counter,
|
|
694
|
+
max_body_size=max_body_size,
|
|
695
|
+
max_content_length=max_content_length,
|
|
696
|
+
max_json_depth=max_json_depth,
|
|
697
|
+
localns=effective_locals,
|
|
698
|
+
)
|
|
699
|
+
|
|
700
|
+
key = (route.method, route.normalized_path)
|
|
701
|
+
if key in self._registered_keys:
|
|
702
|
+
raise RouteRegistrationError(
|
|
703
|
+
f"Duplicate route registered for {route.method} {path}"
|
|
704
|
+
)
|
|
705
|
+
|
|
706
|
+
if self.container is None:
|
|
707
|
+
# Standalone routers may declare app-bound DI dependencies before
|
|
708
|
+
# mounting onto an Application; defer strict DI checks until include_router().
|
|
709
|
+
route.analysis_deferred = True
|
|
710
|
+
else:
|
|
711
|
+
route.param_specs = self._analyze_handler(route)
|
|
712
|
+
self._counter += 1
|
|
713
|
+
return self.add_compiled_route(route)
|
|
714
|
+
|
|
715
|
+
def route(
|
|
716
|
+
self,
|
|
717
|
+
method: str,
|
|
718
|
+
path: str,
|
|
719
|
+
*,
|
|
720
|
+
middleware: list[type[Any]] | None = None,
|
|
721
|
+
is_websocket: bool = False,
|
|
722
|
+
max_body_size: int | None = None,
|
|
723
|
+
max_content_length: int | None = None,
|
|
724
|
+
max_json_depth: int | None = None,
|
|
725
|
+
) -> Callable[[F], F]:
|
|
726
|
+
if method.upper().strip() == "WEBSOCKET" and not is_websocket:
|
|
727
|
+
raise RouteRegistrationError(
|
|
728
|
+
"Method 'WEBSOCKET' is internal-only; use websocket() to register WebSocket routes."
|
|
729
|
+
)
|
|
730
|
+
|
|
731
|
+
def decorator(func: F) -> F:
|
|
732
|
+
self.add_route(
|
|
733
|
+
method,
|
|
734
|
+
path,
|
|
735
|
+
func,
|
|
736
|
+
middleware=middleware,
|
|
737
|
+
is_websocket=is_websocket,
|
|
738
|
+
max_body_size=max_body_size,
|
|
739
|
+
max_content_length=max_content_length,
|
|
740
|
+
max_json_depth=max_json_depth,
|
|
741
|
+
localns=_collect_caller_locals(func),
|
|
742
|
+
)
|
|
743
|
+
return func
|
|
744
|
+
|
|
745
|
+
return decorator
|
|
746
|
+
|
|
747
|
+
def get(
|
|
748
|
+
self,
|
|
749
|
+
path: str,
|
|
750
|
+
*,
|
|
751
|
+
middleware: list[type[Any]] | None = None,
|
|
752
|
+
max_body_size: int | None = None,
|
|
753
|
+
max_content_length: int | None = None,
|
|
754
|
+
max_json_depth: int | None = None,
|
|
755
|
+
) -> Callable[[F], F]:
|
|
756
|
+
return self.route(
|
|
757
|
+
"GET",
|
|
758
|
+
path,
|
|
759
|
+
middleware=middleware,
|
|
760
|
+
max_body_size=max_body_size,
|
|
761
|
+
max_content_length=max_content_length,
|
|
762
|
+
max_json_depth=max_json_depth,
|
|
763
|
+
)
|
|
764
|
+
|
|
765
|
+
def post(
|
|
766
|
+
self,
|
|
767
|
+
path: str,
|
|
768
|
+
*,
|
|
769
|
+
middleware: list[type[Any]] | None = None,
|
|
770
|
+
max_body_size: int | None = None,
|
|
771
|
+
max_content_length: int | None = None,
|
|
772
|
+
max_json_depth: int | None = None,
|
|
773
|
+
) -> Callable[[F], F]:
|
|
774
|
+
return self.route(
|
|
775
|
+
"POST",
|
|
776
|
+
path,
|
|
777
|
+
middleware=middleware,
|
|
778
|
+
max_body_size=max_body_size,
|
|
779
|
+
max_content_length=max_content_length,
|
|
780
|
+
max_json_depth=max_json_depth,
|
|
781
|
+
)
|
|
782
|
+
|
|
783
|
+
def put(
|
|
784
|
+
self,
|
|
785
|
+
path: str,
|
|
786
|
+
*,
|
|
787
|
+
middleware: list[type[Any]] | None = None,
|
|
788
|
+
max_body_size: int | None = None,
|
|
789
|
+
max_content_length: int | None = None,
|
|
790
|
+
max_json_depth: int | None = None,
|
|
791
|
+
) -> Callable[[F], F]:
|
|
792
|
+
return self.route(
|
|
793
|
+
"PUT",
|
|
794
|
+
path,
|
|
795
|
+
middleware=middleware,
|
|
796
|
+
max_body_size=max_body_size,
|
|
797
|
+
max_content_length=max_content_length,
|
|
798
|
+
max_json_depth=max_json_depth,
|
|
799
|
+
)
|
|
800
|
+
|
|
801
|
+
def patch(
|
|
802
|
+
self,
|
|
803
|
+
path: str,
|
|
804
|
+
*,
|
|
805
|
+
middleware: list[type[Any]] | None = None,
|
|
806
|
+
max_body_size: int | None = None,
|
|
807
|
+
max_content_length: int | None = None,
|
|
808
|
+
max_json_depth: int | None = None,
|
|
809
|
+
) -> Callable[[F], F]:
|
|
810
|
+
return self.route(
|
|
811
|
+
"PATCH",
|
|
812
|
+
path,
|
|
813
|
+
middleware=middleware,
|
|
814
|
+
max_body_size=max_body_size,
|
|
815
|
+
max_content_length=max_content_length,
|
|
816
|
+
max_json_depth=max_json_depth,
|
|
817
|
+
)
|
|
818
|
+
|
|
819
|
+
def delete(
|
|
820
|
+
self,
|
|
821
|
+
path: str,
|
|
822
|
+
*,
|
|
823
|
+
middleware: list[type[Any]] | None = None,
|
|
824
|
+
max_body_size: int | None = None,
|
|
825
|
+
max_content_length: int | None = None,
|
|
826
|
+
max_json_depth: int | None = None,
|
|
827
|
+
) -> Callable[[F], F]:
|
|
828
|
+
return self.route(
|
|
829
|
+
"DELETE",
|
|
830
|
+
path,
|
|
831
|
+
middleware=middleware,
|
|
832
|
+
max_body_size=max_body_size,
|
|
833
|
+
max_content_length=max_content_length,
|
|
834
|
+
max_json_depth=max_json_depth,
|
|
835
|
+
)
|
|
836
|
+
|
|
837
|
+
def head(
|
|
838
|
+
self,
|
|
839
|
+
path: str,
|
|
840
|
+
*,
|
|
841
|
+
middleware: list[type[Any]] | None = None,
|
|
842
|
+
max_body_size: int | None = None,
|
|
843
|
+
max_content_length: int | None = None,
|
|
844
|
+
max_json_depth: int | None = None,
|
|
845
|
+
) -> Callable[[F], F]:
|
|
846
|
+
return self.route(
|
|
847
|
+
"HEAD",
|
|
848
|
+
path,
|
|
849
|
+
middleware=middleware,
|
|
850
|
+
max_body_size=max_body_size,
|
|
851
|
+
max_content_length=max_content_length,
|
|
852
|
+
max_json_depth=max_json_depth,
|
|
853
|
+
)
|
|
854
|
+
|
|
855
|
+
def options(
|
|
856
|
+
self,
|
|
857
|
+
path: str,
|
|
858
|
+
*,
|
|
859
|
+
middleware: list[type[Any]] | None = None,
|
|
860
|
+
max_body_size: int | None = None,
|
|
861
|
+
max_content_length: int | None = None,
|
|
862
|
+
max_json_depth: int | None = None,
|
|
863
|
+
) -> Callable[[F], F]:
|
|
864
|
+
return self.route(
|
|
865
|
+
"OPTIONS",
|
|
866
|
+
path,
|
|
867
|
+
middleware=middleware,
|
|
868
|
+
max_body_size=max_body_size,
|
|
869
|
+
max_content_length=max_content_length,
|
|
870
|
+
max_json_depth=max_json_depth,
|
|
871
|
+
)
|
|
872
|
+
|
|
873
|
+
def websocket(
|
|
874
|
+
self,
|
|
875
|
+
path: str,
|
|
876
|
+
*,
|
|
877
|
+
middleware: list[type[Any]] | None = None,
|
|
878
|
+
) -> Callable[[F], F]:
|
|
879
|
+
return self.route(
|
|
880
|
+
"WEBSOCKET",
|
|
881
|
+
path,
|
|
882
|
+
middleware=middleware,
|
|
883
|
+
is_websocket=True,
|
|
884
|
+
)
|
|
885
|
+
|
|
886
|
+
@staticmethod
|
|
887
|
+
def _coerce_matched_params(
|
|
888
|
+
route: Route, raw_params: dict[str, str]
|
|
889
|
+
) -> dict[str, Any]:
|
|
890
|
+
coerced: dict[str, Any] = {}
|
|
891
|
+
for name, raw_val in raw_params.items():
|
|
892
|
+
coercer_entry = route.param_coercers.get(name)
|
|
893
|
+
if coercer_entry is None:
|
|
894
|
+
coerced[name] = raw_val
|
|
895
|
+
continue
|
|
896
|
+
expected_type, coercer = coercer_entry
|
|
897
|
+
try:
|
|
898
|
+
coerced[name] = coercer(raw_val)
|
|
899
|
+
except (ValueError, TypeError):
|
|
900
|
+
raise PathParamCoercionError(name, raw_val, expected_type) from None
|
|
901
|
+
return coerced
|
|
902
|
+
|
|
903
|
+
def match_websocket(
|
|
904
|
+
self,
|
|
905
|
+
path: str,
|
|
906
|
+
) -> tuple[Route | None, dict[str, Any]]:
|
|
907
|
+
static_route = self._static_websocket_routes.get(path)
|
|
908
|
+
if static_route is not None:
|
|
909
|
+
return static_route, {}
|
|
910
|
+
|
|
911
|
+
for route in self._dynamic_websocket_routes:
|
|
912
|
+
m = route.pattern.match(path)
|
|
913
|
+
if m is not None:
|
|
914
|
+
return route, self._coerce_matched_params(route, m.groupdict())
|
|
915
|
+
return None, {}
|
|
916
|
+
|
|
917
|
+
def match(
|
|
918
|
+
self,
|
|
919
|
+
method: str,
|
|
920
|
+
path: str,
|
|
921
|
+
) -> tuple[
|
|
922
|
+
Route | None,
|
|
923
|
+
dict[str, Any],
|
|
924
|
+
]:
|
|
925
|
+
norm_method = method.upper()
|
|
926
|
+
|
|
927
|
+
# 1. Never match WEBSOCKET routes over HTTP
|
|
928
|
+
if norm_method != "WEBSOCKET":
|
|
929
|
+
# Fast path: O(1) exact static lookup
|
|
930
|
+
static_candidates = self._static_routes.get(norm_method)
|
|
931
|
+
if static_candidates is not None and path in static_candidates:
|
|
932
|
+
return static_candidates[path], {}
|
|
933
|
+
|
|
934
|
+
dynamic_candidates = self._dynamic_routes_by_method.get(norm_method, ())
|
|
935
|
+
for route in dynamic_candidates:
|
|
936
|
+
m = route.pattern.match(path)
|
|
937
|
+
if m is not None:
|
|
938
|
+
return route, self._coerce_matched_params(route, m.groupdict())
|
|
939
|
+
|
|
940
|
+
# 2. Automatic HEAD fallback to GET
|
|
941
|
+
if norm_method == "HEAD":
|
|
942
|
+
static_get = self._static_routes.get("GET")
|
|
943
|
+
if static_get is not None and path in static_get:
|
|
944
|
+
return static_get[path], {}
|
|
945
|
+
get_candidates = self._dynamic_routes_by_method.get("GET", ())
|
|
946
|
+
for route in get_candidates:
|
|
947
|
+
m = route.pattern.match(path)
|
|
948
|
+
if m is not None:
|
|
949
|
+
return route, self._coerce_matched_params(route, m.groupdict())
|
|
950
|
+
|
|
951
|
+
# 3. Check if any HTTP methods match this path (for automatic OPTIONS or 405)
|
|
952
|
+
matched_methods: set[str] = set()
|
|
953
|
+
matched_routes: list[Route] = []
|
|
954
|
+
for meth, static_map in self._static_routes.items():
|
|
955
|
+
if meth != "WEBSOCKET" and path in static_map:
|
|
956
|
+
matched_methods.add(meth)
|
|
957
|
+
matched_routes.append(static_map[path])
|
|
958
|
+
|
|
959
|
+
for route in self._http_routes:
|
|
960
|
+
if route.param_names and route.pattern.match(path) is not None:
|
|
961
|
+
matched_methods.add(route.method)
|
|
962
|
+
matched_routes.append(route)
|
|
963
|
+
|
|
964
|
+
if matched_methods:
|
|
965
|
+
if "GET" in matched_methods:
|
|
966
|
+
matched_methods.add("HEAD")
|
|
967
|
+
|
|
968
|
+
if norm_method == "OPTIONS":
|
|
969
|
+
options_methods = sorted(matched_methods | {"OPTIONS"})
|
|
970
|
+
allow_header = ", ".join(options_methods)
|
|
971
|
+
inherited_middleware = list(
|
|
972
|
+
dict.fromkeys(
|
|
973
|
+
mw for matched_route in matched_routes for mw in matched_route.middleware
|
|
974
|
+
)
|
|
975
|
+
)
|
|
976
|
+
|
|
977
|
+
async def _options_handler(request: Request) -> Response:
|
|
978
|
+
return Response(
|
|
979
|
+
b"",
|
|
980
|
+
status_code=204,
|
|
981
|
+
headers={"Allow": allow_header},
|
|
982
|
+
)
|
|
983
|
+
|
|
984
|
+
synthetic_route = Route(
|
|
985
|
+
method="OPTIONS",
|
|
986
|
+
path="/",
|
|
987
|
+
handler=_options_handler,
|
|
988
|
+
middleware=inherited_middleware,
|
|
989
|
+
)
|
|
990
|
+
synthetic_route.param_specs = self._analyze_handler(
|
|
991
|
+
synthetic_route, container=self.container
|
|
992
|
+
)
|
|
993
|
+
return synthetic_route, {}
|
|
994
|
+
|
|
995
|
+
unique_methods = sorted(matched_methods)
|
|
996
|
+
allow_header = ", ".join(unique_methods)
|
|
997
|
+
raise MethodNotAllowedError(
|
|
998
|
+
f"Method '{method}' not allowed for path '{path}'. Allowed: {allow_header}",
|
|
999
|
+
headers={"Allow": allow_header},
|
|
1000
|
+
)
|
|
1001
|
+
|
|
1002
|
+
return None, {}
|
|
1003
|
+
|
|
1004
|
+
def group(
|
|
1005
|
+
self,
|
|
1006
|
+
prefix: str,
|
|
1007
|
+
middleware: list[type[Any]] | None = None,
|
|
1008
|
+
) -> Callable[[Callable[[RouteGroup], None]], Callable[[RouteGroup], None]]:
|
|
1009
|
+
def decorator(
|
|
1010
|
+
func: Callable[[RouteGroup], None],
|
|
1011
|
+
) -> Callable[[RouteGroup], None]:
|
|
1012
|
+
group = RouteGroup(
|
|
1013
|
+
router=self,
|
|
1014
|
+
prefix=prefix,
|
|
1015
|
+
middleware=middleware,
|
|
1016
|
+
)
|
|
1017
|
+
func(group)
|
|
1018
|
+
return func
|
|
1019
|
+
|
|
1020
|
+
return decorator
|
|
1021
|
+
|
|
1022
|
+
def include_router(
|
|
1023
|
+
self,
|
|
1024
|
+
router: Router,
|
|
1025
|
+
prefix: str = "",
|
|
1026
|
+
middleware: list[type[Any]] | None = None,
|
|
1027
|
+
) -> None:
|
|
1028
|
+
"""
|
|
1029
|
+
Include routes from another router instance under an optional path prefix and middleware.
|
|
1030
|
+
"""
|
|
1031
|
+
clean_prefix = _normalize_route_prefix(prefix)
|
|
1032
|
+
|
|
1033
|
+
parent_mw = list(middleware or [])
|
|
1034
|
+
|
|
1035
|
+
for child_route in router.routes:
|
|
1036
|
+
full_path = _join_route_path(clean_prefix, child_route.path)
|
|
1037
|
+
|
|
1038
|
+
combined_middleware = parent_mw + list(child_route.middleware)
|
|
1039
|
+
|
|
1040
|
+
self.add_route(
|
|
1041
|
+
method=child_route.method,
|
|
1042
|
+
path=full_path,
|
|
1043
|
+
handler=child_route.handler,
|
|
1044
|
+
middleware=combined_middleware,
|
|
1045
|
+
is_websocket=child_route.is_websocket,
|
|
1046
|
+
max_body_size=child_route.max_body_size,
|
|
1047
|
+
max_content_length=child_route.max_content_length,
|
|
1048
|
+
max_json_depth=child_route.max_json_depth,
|
|
1049
|
+
localns=child_route.localns,
|
|
1050
|
+
)
|