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/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 typing import TYPE_CHECKING, Awaitable, Callable
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
- app.add_middleware(LoggingMiddleware())
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
- app.add_middleware(TimingMiddleware())
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
- app.add_middleware(LoggingMiddleware())
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
- app.add_middleware(RateLimitMiddleware(max_requests=10, window_seconds=60))
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
- # Fall back to a placeholder (in production, use IP from transport)
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: Request | None = None) -> GeminiResponse:
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