xitzin 0.2.0__py3-none-any.whl → 0.4.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.
- xitzin/__init__.py +9 -1
- xitzin/application.py +278 -9
- xitzin/auth.py +28 -2
- xitzin/cgi.py +21 -10
- xitzin/exceptions.py +18 -0
- xitzin/middleware.py +171 -6
- xitzin/requests.py +103 -0
- xitzin/responses.py +2 -3
- xitzin/routing.py +165 -0
- xitzin/scgi.py +447 -0
- xitzin/tasks.py +176 -0
- xitzin/testing.py +111 -1
- {xitzin-0.2.0.dist-info → xitzin-0.4.0.dist-info}/METADATA +7 -4
- xitzin-0.4.0.dist-info/RECORD +18 -0
- {xitzin-0.2.0.dist-info → xitzin-0.4.0.dist-info}/WHEEL +2 -2
- xitzin-0.2.0.dist-info/RECORD +0 -16
xitzin/middleware.py
CHANGED
|
@@ -6,9 +6,13 @@ and modify responses before they are sent to clients.
|
|
|
6
6
|
|
|
7
7
|
from __future__ import annotations
|
|
8
8
|
|
|
9
|
+
import asyncio
|
|
9
10
|
import time
|
|
10
11
|
from abc import ABC
|
|
11
|
-
from
|
|
12
|
+
from collections import OrderedDict
|
|
13
|
+
from functools import lru_cache
|
|
14
|
+
from inspect import iscoroutinefunction
|
|
15
|
+
from typing import TYPE_CHECKING, Any, Awaitable, Callable
|
|
12
16
|
|
|
13
17
|
from nauyaca.protocol.response import GeminiResponse
|
|
14
18
|
from nauyaca.protocol.status import StatusCode
|
|
@@ -40,7 +44,11 @@ class BaseMiddleware(ABC):
|
|
|
40
44
|
print(f"Response: {response.status}")
|
|
41
45
|
return response
|
|
42
46
|
|
|
43
|
-
|
|
47
|
+
logging_mw = LoggingMiddleware()
|
|
48
|
+
|
|
49
|
+
@app.middleware
|
|
50
|
+
async def logging(request, call_next):
|
|
51
|
+
return await logging_mw(request, call_next)
|
|
44
52
|
"""
|
|
45
53
|
|
|
46
54
|
async def before_request(
|
|
@@ -98,7 +106,11 @@ class TimingMiddleware(BaseMiddleware):
|
|
|
98
106
|
Stores the elapsed time in request.state.elapsed_time.
|
|
99
107
|
|
|
100
108
|
Example:
|
|
101
|
-
|
|
109
|
+
timing_mw = TimingMiddleware()
|
|
110
|
+
|
|
111
|
+
@app.middleware
|
|
112
|
+
async def timing(request, call_next):
|
|
113
|
+
return await timing_mw(request, call_next)
|
|
102
114
|
|
|
103
115
|
@app.gemini("/")
|
|
104
116
|
def home(request: Request):
|
|
@@ -124,7 +136,11 @@ class LoggingMiddleware(BaseMiddleware):
|
|
|
124
136
|
"""Middleware that logs requests and responses.
|
|
125
137
|
|
|
126
138
|
Example:
|
|
127
|
-
|
|
139
|
+
logging_mw = LoggingMiddleware()
|
|
140
|
+
|
|
141
|
+
@app.middleware
|
|
142
|
+
async def logging(request, call_next):
|
|
143
|
+
return await logging_mw(request, call_next)
|
|
128
144
|
"""
|
|
129
145
|
|
|
130
146
|
def __init__(self, logger: Callable[[str], None] | None = None) -> None:
|
|
@@ -157,7 +173,11 @@ class RateLimitMiddleware(BaseMiddleware):
|
|
|
157
173
|
Limits requests per client based on certificate fingerprint or IP.
|
|
158
174
|
|
|
159
175
|
Example:
|
|
160
|
-
|
|
176
|
+
rate_limit_mw = RateLimitMiddleware(max_requests=10, window_seconds=60)
|
|
177
|
+
|
|
178
|
+
@app.middleware
|
|
179
|
+
async def rate_limit(request, call_next):
|
|
180
|
+
return await rate_limit_mw(request, call_next)
|
|
161
181
|
"""
|
|
162
182
|
|
|
163
183
|
def __init__(
|
|
@@ -182,7 +202,9 @@ class RateLimitMiddleware(BaseMiddleware):
|
|
|
182
202
|
"""Get a unique identifier for the client."""
|
|
183
203
|
if request.client_cert_fingerprint:
|
|
184
204
|
return f"cert:{request.client_cert_fingerprint}"
|
|
185
|
-
#
|
|
205
|
+
# Use IP address when available for anonymous clients
|
|
206
|
+
if request.remote_addr:
|
|
207
|
+
return f"ip:{request.remote_addr}"
|
|
186
208
|
return "unknown"
|
|
187
209
|
|
|
188
210
|
def _is_rate_limited(self, client_id: str) -> bool:
|
|
@@ -217,3 +239,146 @@ class RateLimitMiddleware(BaseMiddleware):
|
|
|
217
239
|
)
|
|
218
240
|
|
|
219
241
|
return None
|
|
242
|
+
|
|
243
|
+
|
|
244
|
+
class UserSessionMiddleware(BaseMiddleware):
|
|
245
|
+
"""Middleware that loads and caches user data from certificate fingerprints.
|
|
246
|
+
|
|
247
|
+
Stores the loaded user in request.state.user. Uses an LRU cache to avoid
|
|
248
|
+
repeated database lookups for the same user across requests.
|
|
249
|
+
|
|
250
|
+
Supports both sync and async user_loader functions. Sync loaders are
|
|
251
|
+
executed in a thread pool to avoid blocking the event loop.
|
|
252
|
+
|
|
253
|
+
Example with sync loader:
|
|
254
|
+
from xitzin.middleware import UserSessionMiddleware
|
|
255
|
+
|
|
256
|
+
def load_user(fingerprint: str) -> User | None:
|
|
257
|
+
with Session(engine) as session:
|
|
258
|
+
return session.exec(
|
|
259
|
+
select(User).where(User.fingerprint == fingerprint)
|
|
260
|
+
).first()
|
|
261
|
+
|
|
262
|
+
user_mw = UserSessionMiddleware(load_user)
|
|
263
|
+
|
|
264
|
+
@app.middleware
|
|
265
|
+
async def user_session(request, call_next):
|
|
266
|
+
return await user_mw(request, call_next)
|
|
267
|
+
|
|
268
|
+
Example with async loader:
|
|
269
|
+
async def load_user(fingerprint: str) -> User | None:
|
|
270
|
+
async with async_session() as session:
|
|
271
|
+
result = await session.execute(
|
|
272
|
+
select(User).where(User.fingerprint == fingerprint)
|
|
273
|
+
)
|
|
274
|
+
return result.scalar_one_or_none()
|
|
275
|
+
|
|
276
|
+
user_mw = UserSessionMiddleware(load_user)
|
|
277
|
+
"""
|
|
278
|
+
|
|
279
|
+
def __init__(
|
|
280
|
+
self,
|
|
281
|
+
user_loader: Callable[[str], Any] | Callable[[str], Awaitable[Any]],
|
|
282
|
+
cache_size: int = 100,
|
|
283
|
+
) -> None:
|
|
284
|
+
"""Create user session middleware.
|
|
285
|
+
|
|
286
|
+
Args:
|
|
287
|
+
user_loader: Function that takes a fingerprint and returns a user
|
|
288
|
+
object (or None if not found). Can be sync or async. Sync
|
|
289
|
+
loaders are executed in a thread pool to avoid blocking.
|
|
290
|
+
cache_size: Maximum number of users to cache. Defaults to 100.
|
|
291
|
+
"""
|
|
292
|
+
self._user_loader = user_loader
|
|
293
|
+
self._cache_size = cache_size
|
|
294
|
+
self._is_async = iscoroutinefunction(user_loader)
|
|
295
|
+
|
|
296
|
+
# For sync loaders, use lru_cache
|
|
297
|
+
# For async loaders, use a simple OrderedDict-based LRU cache
|
|
298
|
+
if self._is_async:
|
|
299
|
+
self._async_cache: OrderedDict[str, Any] = OrderedDict()
|
|
300
|
+
self._cache_hits = 0
|
|
301
|
+
self._cache_misses = 0
|
|
302
|
+
else:
|
|
303
|
+
self._sync_cached_loader = lru_cache(maxsize=cache_size)(user_loader)
|
|
304
|
+
|
|
305
|
+
async def _get_user_async(self, fingerprint: str) -> Any:
|
|
306
|
+
"""Get user with async loader and caching."""
|
|
307
|
+
if fingerprint in self._async_cache:
|
|
308
|
+
self._cache_hits += 1
|
|
309
|
+
# Move to end (most recently used)
|
|
310
|
+
self._async_cache.move_to_end(fingerprint)
|
|
311
|
+
return self._async_cache[fingerprint]
|
|
312
|
+
|
|
313
|
+
self._cache_misses += 1
|
|
314
|
+
user = await self._user_loader(fingerprint)
|
|
315
|
+
|
|
316
|
+
# Add to cache
|
|
317
|
+
self._async_cache[fingerprint] = user
|
|
318
|
+
self._async_cache.move_to_end(fingerprint)
|
|
319
|
+
|
|
320
|
+
# Evict oldest if over capacity
|
|
321
|
+
while len(self._async_cache) > self._cache_size:
|
|
322
|
+
self._async_cache.popitem(last=False)
|
|
323
|
+
|
|
324
|
+
return user
|
|
325
|
+
|
|
326
|
+
async def _get_user_sync(self, fingerprint: str) -> Any:
|
|
327
|
+
"""Get user with sync loader, running in executor."""
|
|
328
|
+
loop = asyncio.get_running_loop()
|
|
329
|
+
return await loop.run_in_executor(None, self._sync_cached_loader, fingerprint)
|
|
330
|
+
|
|
331
|
+
async def before_request(
|
|
332
|
+
self, request: "Request"
|
|
333
|
+
) -> "Request | GeminiResponse | None":
|
|
334
|
+
fingerprint = request.client_cert_fingerprint
|
|
335
|
+
if fingerprint:
|
|
336
|
+
if self._is_async:
|
|
337
|
+
request.state.user = await self._get_user_async(fingerprint)
|
|
338
|
+
else:
|
|
339
|
+
request.state.user = await self._get_user_sync(fingerprint)
|
|
340
|
+
else:
|
|
341
|
+
request.state.user = None
|
|
342
|
+
return None
|
|
343
|
+
|
|
344
|
+
def clear_cache(self) -> None:
|
|
345
|
+
"""Clear all cached users.
|
|
346
|
+
|
|
347
|
+
Call this after updating user data to ensure fresh lookups.
|
|
348
|
+
|
|
349
|
+
Example:
|
|
350
|
+
def update_user(user: User):
|
|
351
|
+
with Session(engine) as session:
|
|
352
|
+
session.add(user)
|
|
353
|
+
session.commit()
|
|
354
|
+
user_middleware.clear_cache()
|
|
355
|
+
"""
|
|
356
|
+
if self._is_async:
|
|
357
|
+
self._async_cache.clear()
|
|
358
|
+
self._cache_hits = 0
|
|
359
|
+
self._cache_misses = 0
|
|
360
|
+
else:
|
|
361
|
+
self._sync_cached_loader.cache_clear()
|
|
362
|
+
|
|
363
|
+
def cache_info(self) -> Any:
|
|
364
|
+
"""Return cache statistics.
|
|
365
|
+
|
|
366
|
+
Returns information about cache hits, misses, and size.
|
|
367
|
+
|
|
368
|
+
Example:
|
|
369
|
+
info = user_middleware.cache_info()
|
|
370
|
+
print(f"Cache hits: {info.hits}, misses: {info.misses}")
|
|
371
|
+
"""
|
|
372
|
+
if self._is_async:
|
|
373
|
+
from collections import namedtuple
|
|
374
|
+
|
|
375
|
+
CacheInfo = namedtuple(
|
|
376
|
+
"CacheInfo", ["hits", "misses", "maxsize", "currsize"]
|
|
377
|
+
)
|
|
378
|
+
return CacheInfo(
|
|
379
|
+
hits=self._cache_hits,
|
|
380
|
+
misses=self._cache_misses,
|
|
381
|
+
maxsize=self._cache_size,
|
|
382
|
+
currsize=len(self._async_cache),
|
|
383
|
+
)
|
|
384
|
+
return self._sync_cached_loader.cache_info()
|
xitzin/requests.py
CHANGED
|
@@ -9,6 +9,7 @@ from typing import TYPE_CHECKING, Any
|
|
|
9
9
|
from urllib.parse import unquote_plus
|
|
10
10
|
|
|
11
11
|
from nauyaca.protocol.request import GeminiRequest
|
|
12
|
+
from nauyaca.protocol.request import TitanRequest as NauyacaTitanRequest
|
|
12
13
|
|
|
13
14
|
if TYPE_CHECKING:
|
|
14
15
|
from cryptography.x509 import Certificate
|
|
@@ -148,3 +149,105 @@ class Request:
|
|
|
148
149
|
|
|
149
150
|
def __repr__(self) -> str:
|
|
150
151
|
return f"Request({self._raw_request.raw_url!r})"
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
class TitanRequest:
|
|
155
|
+
"""Wraps a Nauyaca TitanRequest for Titan upload handlers.
|
|
156
|
+
|
|
157
|
+
Handlers receive this object as their first argument for @app.titan routes.
|
|
158
|
+
|
|
159
|
+
Example:
|
|
160
|
+
@app.titan("/upload/{filename}", auth_tokens=["secret"])
|
|
161
|
+
def upload(request: TitanRequest, content: bytes,
|
|
162
|
+
mime_type: str, token: str | None, filename: str):
|
|
163
|
+
if request.is_delete():
|
|
164
|
+
return "# Deleted"
|
|
165
|
+
Path(f"./uploads/{filename}").write_bytes(content)
|
|
166
|
+
return "# Upload successful"
|
|
167
|
+
|
|
168
|
+
Attributes:
|
|
169
|
+
app: The Xitzin application instance.
|
|
170
|
+
state: Arbitrary state storage for this request.
|
|
171
|
+
path: The URL path component.
|
|
172
|
+
content: The uploaded content bytes.
|
|
173
|
+
mime_type: Content MIME type.
|
|
174
|
+
token: Authentication token (if provided).
|
|
175
|
+
size: Content size in bytes.
|
|
176
|
+
"""
|
|
177
|
+
|
|
178
|
+
def __init__(
|
|
179
|
+
self, raw_request: NauyacaTitanRequest, app: Xitzin | None = None
|
|
180
|
+
) -> None:
|
|
181
|
+
self._raw_request = raw_request
|
|
182
|
+
self._app = app
|
|
183
|
+
self._state = RequestState()
|
|
184
|
+
|
|
185
|
+
@property
|
|
186
|
+
def app(self) -> Xitzin:
|
|
187
|
+
"""The Xitzin application handling this request."""
|
|
188
|
+
if self._app is None:
|
|
189
|
+
msg = "Request is not bound to an application"
|
|
190
|
+
raise RuntimeError(msg)
|
|
191
|
+
return self._app
|
|
192
|
+
|
|
193
|
+
@property
|
|
194
|
+
def state(self) -> RequestState:
|
|
195
|
+
"""Arbitrary state storage for this request."""
|
|
196
|
+
return self._state
|
|
197
|
+
|
|
198
|
+
@property
|
|
199
|
+
def path(self) -> str:
|
|
200
|
+
"""The URL path component."""
|
|
201
|
+
return self._raw_request.path
|
|
202
|
+
|
|
203
|
+
@property
|
|
204
|
+
def content(self) -> bytes:
|
|
205
|
+
"""The uploaded content bytes."""
|
|
206
|
+
return self._raw_request.content
|
|
207
|
+
|
|
208
|
+
@property
|
|
209
|
+
def mime_type(self) -> str:
|
|
210
|
+
"""Content MIME type."""
|
|
211
|
+
return self._raw_request.mime_type
|
|
212
|
+
|
|
213
|
+
@property
|
|
214
|
+
def token(self) -> str | None:
|
|
215
|
+
"""Authentication token (if provided)."""
|
|
216
|
+
return self._raw_request.token
|
|
217
|
+
|
|
218
|
+
@property
|
|
219
|
+
def size(self) -> int:
|
|
220
|
+
"""Content size in bytes."""
|
|
221
|
+
return self._raw_request.size
|
|
222
|
+
|
|
223
|
+
def is_delete(self) -> bool:
|
|
224
|
+
"""Check if this is a delete request (zero-byte upload)."""
|
|
225
|
+
return self._raw_request.is_delete()
|
|
226
|
+
|
|
227
|
+
@property
|
|
228
|
+
def hostname(self) -> str:
|
|
229
|
+
"""The server hostname from the URL."""
|
|
230
|
+
return self._raw_request.hostname
|
|
231
|
+
|
|
232
|
+
@property
|
|
233
|
+
def port(self) -> int:
|
|
234
|
+
"""The server port from the URL."""
|
|
235
|
+
return self._raw_request.port
|
|
236
|
+
|
|
237
|
+
@property
|
|
238
|
+
def client_cert(self) -> Certificate | None:
|
|
239
|
+
"""The client's TLS certificate, if provided."""
|
|
240
|
+
return self._raw_request.client_cert
|
|
241
|
+
|
|
242
|
+
@property
|
|
243
|
+
def client_cert_fingerprint(self) -> str | None:
|
|
244
|
+
"""SHA-256 fingerprint of the client certificate."""
|
|
245
|
+
return self._raw_request.client_cert_fingerprint
|
|
246
|
+
|
|
247
|
+
@property
|
|
248
|
+
def raw_url(self) -> str:
|
|
249
|
+
"""The original URL from the request."""
|
|
250
|
+
return self._raw_request.raw_url
|
|
251
|
+
|
|
252
|
+
def __repr__(self) -> str:
|
|
253
|
+
return f"TitanRequest({self._raw_request.raw_url!r})"
|
xitzin/responses.py
CHANGED
|
@@ -14,7 +14,6 @@ from nauyaca.protocol.status import StatusCode
|
|
|
14
14
|
|
|
15
15
|
if TYPE_CHECKING:
|
|
16
16
|
from .application import Xitzin
|
|
17
|
-
from .requests import Request
|
|
18
17
|
|
|
19
18
|
|
|
20
19
|
class ResponseConvertible(Protocol):
|
|
@@ -156,7 +155,7 @@ class Link:
|
|
|
156
155
|
return self.to_gemtext()
|
|
157
156
|
|
|
158
157
|
|
|
159
|
-
def convert_response(result: Any, request:
|
|
158
|
+
def convert_response(result: Any, request: Any = None) -> GeminiResponse:
|
|
160
159
|
"""Convert a handler return value to a GeminiResponse.
|
|
161
160
|
|
|
162
161
|
Handlers can return:
|
|
@@ -168,7 +167,7 @@ def convert_response(result: Any, request: Request | None = None) -> GeminiRespo
|
|
|
168
167
|
|
|
169
168
|
Args:
|
|
170
169
|
result: The return value from a handler.
|
|
171
|
-
request: The current request (for URL tracking).
|
|
170
|
+
request: The current request (Request or TitanRequest, for URL tracking).
|
|
172
171
|
|
|
173
172
|
Returns:
|
|
174
173
|
A GeminiResponse instance.
|
xitzin/routing.py
CHANGED
|
@@ -282,6 +282,143 @@ class MountedRoute:
|
|
|
282
282
|
return f"MountedRoute({self.path_prefix!r}, name={self.name!r})"
|
|
283
283
|
|
|
284
284
|
|
|
285
|
+
class TitanRoute:
|
|
286
|
+
"""Route for Titan upload handlers with integrated authentication.
|
|
287
|
+
|
|
288
|
+
Similar to Route, but designed for Titan uploads with:
|
|
289
|
+
- Token-based authentication
|
|
290
|
+
- Explicit content/mime_type/token parameters passed to handlers
|
|
291
|
+
|
|
292
|
+
Example:
|
|
293
|
+
route = TitanRoute(
|
|
294
|
+
"/upload/{filename}",
|
|
295
|
+
upload_handler,
|
|
296
|
+
auth_tokens=["secret123"]
|
|
297
|
+
)
|
|
298
|
+
"""
|
|
299
|
+
|
|
300
|
+
def __init__(
|
|
301
|
+
self,
|
|
302
|
+
path: str,
|
|
303
|
+
handler: Callable[..., Any],
|
|
304
|
+
*,
|
|
305
|
+
name: str | None = None,
|
|
306
|
+
auth_tokens: list[str] | None = None,
|
|
307
|
+
) -> None:
|
|
308
|
+
"""Create a new Titan route.
|
|
309
|
+
|
|
310
|
+
Args:
|
|
311
|
+
path: Path template with optional parameters (e.g., "/upload/{filename}").
|
|
312
|
+
handler: The handler function to call.
|
|
313
|
+
name: Route name for identification. Defaults to handler function name.
|
|
314
|
+
auth_tokens: List of valid authentication tokens. If provided,
|
|
315
|
+
requests without a valid token are rejected with status 60.
|
|
316
|
+
"""
|
|
317
|
+
self.path = path
|
|
318
|
+
self.handler = handler
|
|
319
|
+
self.name = (
|
|
320
|
+
name if name is not None else getattr(handler, "__name__", "<anonymous>")
|
|
321
|
+
)
|
|
322
|
+
self.auth_tokens = set(auth_tokens) if auth_tokens else None
|
|
323
|
+
|
|
324
|
+
self._param_pattern, self._param_names = self._compile_path(path)
|
|
325
|
+
self._type_hints = self._get_handler_type_hints(handler)
|
|
326
|
+
self._is_async = asyncio.iscoroutinefunction(handler)
|
|
327
|
+
|
|
328
|
+
def _compile_path(self, path: str) -> tuple[re.Pattern[str], list[str]]:
|
|
329
|
+
"""Convert a path template to a regex pattern.
|
|
330
|
+
|
|
331
|
+
Same logic as Route._compile_path().
|
|
332
|
+
"""
|
|
333
|
+
param_names: list[str] = []
|
|
334
|
+
|
|
335
|
+
def replace_param(match: re.Match[str]) -> str:
|
|
336
|
+
name = match.group(1)
|
|
337
|
+
param_type = match.group(2)
|
|
338
|
+
param_names.append(name)
|
|
339
|
+
|
|
340
|
+
if param_type == "path":
|
|
341
|
+
return f"(?P<{name}>.+)"
|
|
342
|
+
return f"(?P<{name}>[^/]+)"
|
|
343
|
+
|
|
344
|
+
escaped = re.escape(path)
|
|
345
|
+
escaped = escaped.replace(r"\{", "{").replace(r"\}", "}")
|
|
346
|
+
regex_path = PATH_PARAM_PATTERN.sub(replace_param, escaped)
|
|
347
|
+
|
|
348
|
+
return re.compile(f"^{regex_path}$"), param_names
|
|
349
|
+
|
|
350
|
+
def _get_handler_type_hints(self, handler: Callable[..., Any]) -> dict[str, type]:
|
|
351
|
+
"""Extract type hints from handler function.
|
|
352
|
+
|
|
353
|
+
Excludes 'request', 'content', 'mime_type', 'token', and 'return'.
|
|
354
|
+
"""
|
|
355
|
+
try:
|
|
356
|
+
hints = get_type_hints(handler)
|
|
357
|
+
# Remove non-path-parameter hints
|
|
358
|
+
hints.pop("request", None)
|
|
359
|
+
hints.pop("content", None)
|
|
360
|
+
hints.pop("mime_type", None)
|
|
361
|
+
hints.pop("token", None)
|
|
362
|
+
hints.pop("return", None)
|
|
363
|
+
return hints
|
|
364
|
+
except Exception:
|
|
365
|
+
return {}
|
|
366
|
+
|
|
367
|
+
def matches(self, path: str) -> bool:
|
|
368
|
+
"""Check if this route matches the given path."""
|
|
369
|
+
return self._param_pattern.match(path) is not None
|
|
370
|
+
|
|
371
|
+
def extract_params(self, path: str) -> dict[str, Any]:
|
|
372
|
+
"""Extract and type-convert path parameters."""
|
|
373
|
+
match = self._param_pattern.match(path)
|
|
374
|
+
if not match:
|
|
375
|
+
return {}
|
|
376
|
+
|
|
377
|
+
params: dict[str, Any] = {}
|
|
378
|
+
for name, value in match.groupdict().items():
|
|
379
|
+
target_type = self._type_hints.get(name, str)
|
|
380
|
+
try:
|
|
381
|
+
if target_type is int:
|
|
382
|
+
params[name] = int(value)
|
|
383
|
+
elif target_type is float:
|
|
384
|
+
params[name] = float(value)
|
|
385
|
+
elif target_type is bool:
|
|
386
|
+
params[name] = value.lower() in ("true", "1", "yes")
|
|
387
|
+
else:
|
|
388
|
+
params[name] = value
|
|
389
|
+
except (ValueError, TypeError):
|
|
390
|
+
params[name] = value
|
|
391
|
+
|
|
392
|
+
return params
|
|
393
|
+
|
|
394
|
+
async def call_handler(self, request: Any, params: dict[str, Any]) -> Any:
|
|
395
|
+
"""Call the handler with request and explicit Titan parameters.
|
|
396
|
+
|
|
397
|
+
Args:
|
|
398
|
+
request: TitanRequest object (typed as Any to avoid circular imports).
|
|
399
|
+
params: Path parameters extracted from the URL.
|
|
400
|
+
|
|
401
|
+
Handler receives: (request, content, mime_type, token, **path_params)
|
|
402
|
+
"""
|
|
403
|
+
# Add Titan-specific parameters
|
|
404
|
+
handler_params = {
|
|
405
|
+
"content": request.content,
|
|
406
|
+
"mime_type": request.mime_type,
|
|
407
|
+
"token": request.token,
|
|
408
|
+
**params,
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
if self._is_async:
|
|
412
|
+
return await self.handler(request, **handler_params)
|
|
413
|
+
loop = asyncio.get_event_loop()
|
|
414
|
+
return await loop.run_in_executor(
|
|
415
|
+
None, lambda: self.handler(request, **handler_params)
|
|
416
|
+
)
|
|
417
|
+
|
|
418
|
+
def __repr__(self) -> str:
|
|
419
|
+
return f"TitanRoute({self.path!r}, name={self.name!r})"
|
|
420
|
+
|
|
421
|
+
|
|
285
422
|
class Router:
|
|
286
423
|
"""Collection of routes with matching logic.
|
|
287
424
|
|
|
@@ -293,6 +430,7 @@ class Router:
|
|
|
293
430
|
self._routes: list[Route] = []
|
|
294
431
|
self._routes_by_name: dict[str, Route] = {}
|
|
295
432
|
self._mounted_routes: list[MountedRoute] = []
|
|
433
|
+
self._titan_routes: list[TitanRoute] = []
|
|
296
434
|
|
|
297
435
|
def add_route(self, route: Route) -> None:
|
|
298
436
|
"""Add a route to the router.
|
|
@@ -351,6 +489,33 @@ class Router:
|
|
|
351
489
|
return route, params
|
|
352
490
|
return None
|
|
353
491
|
|
|
492
|
+
def add_titan_route(self, route: TitanRoute) -> None:
|
|
493
|
+
"""Add a Titan upload route to the router.
|
|
494
|
+
|
|
495
|
+
Args:
|
|
496
|
+
route: The Titan route to add.
|
|
497
|
+
"""
|
|
498
|
+
self._titan_routes.append(route)
|
|
499
|
+
|
|
500
|
+
def match_titan(self, path: str) -> tuple[TitanRoute, dict[str, Any]] | None:
|
|
501
|
+
"""Find a matching Titan route and extract parameters.
|
|
502
|
+
|
|
503
|
+
Args:
|
|
504
|
+
path: URL path to match.
|
|
505
|
+
|
|
506
|
+
Returns:
|
|
507
|
+
Tuple of (titan_route, params) if found, None otherwise.
|
|
508
|
+
"""
|
|
509
|
+
for route in self._titan_routes:
|
|
510
|
+
if route.matches(path):
|
|
511
|
+
params = route.extract_params(path)
|
|
512
|
+
return route, params
|
|
513
|
+
return None
|
|
514
|
+
|
|
515
|
+
def has_titan_routes(self) -> bool:
|
|
516
|
+
"""Check if any Titan routes are registered."""
|
|
517
|
+
return len(self._titan_routes) > 0
|
|
518
|
+
|
|
354
519
|
def reverse(self, name: str, **params: Any) -> str:
|
|
355
520
|
"""Build URL for a named route.
|
|
356
521
|
|