xitzin 0.4.0__py3-none-any.whl → 0.5.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 CHANGED
@@ -22,6 +22,7 @@ Example:
22
22
 
23
23
  from .application import Xitzin
24
24
  from .cgi import CGIConfig, CGIHandler, CGIScript
25
+ from .middleware import VirtualHostMiddleware
25
26
  from .scgi import SCGIApp, SCGIConfig, SCGIHandler
26
27
  from .exceptions import (
27
28
  BadRequest,
@@ -55,6 +56,8 @@ __all__ = [
55
56
  "Input",
56
57
  "Redirect",
57
58
  "Link",
59
+ # Middleware
60
+ "VirtualHostMiddleware",
58
61
  # CGI support
59
62
  "CGIConfig",
60
63
  "CGIHandler",
xitzin/application.py CHANGED
@@ -399,6 +399,69 @@ class Xitzin:
399
399
 
400
400
  self.mount(path, handler, name=name)
401
401
 
402
+ def vhost(
403
+ self,
404
+ hosts: dict[str, "Xitzin"],
405
+ *,
406
+ default_app: "Xitzin | None" = None,
407
+ fallback_status: int = 53,
408
+ fallback_handler: Callable[[Request], Any] | None = None,
409
+ ) -> None:
410
+ """Configure virtual hosting for this application.
411
+
412
+ This is a convenience method that creates and registers VirtualHostMiddleware.
413
+ The middleware routes requests to different apps based on hostname.
414
+
415
+ Args:
416
+ hosts: Mapping of hostname patterns to Xitzin apps.
417
+ Patterns can be exact ("example.com") or wildcards ("*.example.com").
418
+ Exact matches are checked first, then wildcards in definition order.
419
+ default_app: Default app when no pattern matches.
420
+ fallback_status: Status code for unmatched hosts (default: 53).
421
+ Common values: 53 (Proxy Refused), 51 (Not Found), 59 (Bad Request).
422
+ fallback_handler: Custom handler for unmatched hosts.
423
+ Takes precedence over default_app and fallback_status.
424
+
425
+ Example:
426
+ main_app = Xitzin(title="Main")
427
+ blog_app = Xitzin(title="Blog")
428
+ api_app = Xitzin(title="API")
429
+
430
+ @blog_app.gemini("/")
431
+ def blog_home(request: Request):
432
+ return "# Blog Home"
433
+
434
+ @api_app.gemini("/")
435
+ def api_home(request: Request):
436
+ return "# API Home"
437
+
438
+ @main_app.gemini("/")
439
+ def main_home(request: Request):
440
+ return "# Main Home"
441
+
442
+ # Configure as gateway
443
+ main_app.vhost({
444
+ "blog.example.com": blog_app,
445
+ "*.api.example.com": api_app,
446
+ }, default_app=main_app)
447
+
448
+ main_app.run()
449
+ """
450
+ from .middleware import VirtualHostMiddleware
451
+
452
+ vhost_mw = VirtualHostMiddleware(
453
+ hosts,
454
+ default_app=default_app,
455
+ fallback_status=fallback_status,
456
+ fallback_handler=fallback_handler,
457
+ )
458
+
459
+ @self.middleware
460
+ async def virtual_host_dispatcher(
461
+ request: Request, call_next: Callable[..., Any]
462
+ ) -> GeminiResponse:
463
+ return await vhost_mw(request, call_next)
464
+
402
465
  def on_startup(self, handler: Callable[[], Any]) -> Callable[[], Any]:
403
466
  """Register a startup event handler.
404
467
 
@@ -559,64 +622,56 @@ class Xitzin:
559
622
  """
560
623
  request = Request(raw_request, self)
561
624
 
562
- try:
563
- # Check mounted routes first
564
- mount_match = self._router.match_mount(request.path)
565
- if mount_match is not None:
566
- mounted_route, path_info = mount_match
567
-
568
- # Build middleware chain for mounted handler
569
- async def call_mounted_handler(req: Request) -> GeminiResponse:
625
+ # Build middleware chain around the entire routing logic
626
+ async def route_and_handle(req: Request) -> GeminiResponse:
627
+ try:
628
+ # Check mounted routes first
629
+ mount_match = self._router.match_mount(req.path)
630
+ if mount_match is not None:
631
+ mounted_route, path_info = mount_match
570
632
  result = await mounted_route.call_handler(req, path_info)
571
633
  return convert_response(result, req)
572
634
 
573
- # Apply middleware
574
- handler = call_mounted_handler
575
- for mw in reversed(self._middleware):
576
- handler = self._wrap_middleware(mw, handler)
577
-
578
- return await handler(request)
635
+ # Match regular route
636
+ match = self._router.match(req.path)
637
+ if match is None:
638
+ raise NotFound(f"No route matches: {req.path}")
579
639
 
580
- # Match regular route
581
- match = self._router.match(request.path)
582
- if match is None:
583
- raise NotFound(f"No route matches: {request.path}")
640
+ route, params = match
584
641
 
585
- route, params = match
642
+ # Handle input flow
643
+ if route.input_prompt and not req.query:
644
+ return Input(
645
+ route.input_prompt, route.sensitive_input
646
+ ).to_gemini_response()
586
647
 
587
- # Handle input flow
588
- if route.input_prompt and not request.query:
589
- return Input(
590
- route.input_prompt, route.sensitive_input
591
- ).to_gemini_response()
648
+ # Add query to params for input routes
649
+ if route.input_prompt and req.query:
650
+ params["query"] = req.query
592
651
 
593
- # Add query to params for input routes
594
- if route.input_prompt and request.query:
595
- params["query"] = request.query
596
-
597
- # Build middleware chain
598
- async def call_handler(req: Request) -> GeminiResponse:
652
+ # Call the handler
599
653
  result = await route.call_handler(req, params)
600
654
  return convert_response(result, req)
601
655
 
602
- # Apply middleware (in reverse order so first registered runs first)
603
- handler = call_handler
604
- for mw in reversed(self._middleware):
605
- handler = self._wrap_middleware(mw, handler)
656
+ except GeminiException as e:
657
+ return GeminiResponse(status=e.status_code, meta=e.message)
658
+ except Exception:
659
+ # Log the error and return a generic failure
660
+ import traceback
606
661
 
607
- return await handler(request)
662
+ traceback.print_exc()
663
+ return GeminiResponse(
664
+ status=StatusCode.TEMPORARY_FAILURE,
665
+ meta="Internal server error",
666
+ )
608
667
 
609
- except GeminiException as e:
610
- return GeminiResponse(status=e.status_code, meta=e.message)
611
- except Exception:
612
- # Log the error and return a generic failure
613
- import traceback
668
+ # Apply middleware around the entire routing logic
669
+ # This allows middleware to intercept requests before routing
670
+ handler = route_and_handle
671
+ for mw in reversed(self._middleware):
672
+ handler = self._wrap_middleware(mw, handler)
614
673
 
615
- traceback.print_exc()
616
- return GeminiResponse(
617
- status=StatusCode.TEMPORARY_FAILURE,
618
- meta="Internal server error",
619
- )
674
+ return await handler(request)
620
675
 
621
676
  async def _handle_titan_request(
622
677
  self, raw_request: NauyacaTitanRequest
@@ -627,41 +682,42 @@ class Xitzin:
627
682
  """
628
683
  request = TitanRequest(raw_request, self)
629
684
 
630
- try:
631
- # Match Titan route
632
- match = self._router.match_titan(request.path)
633
- if match is None:
634
- raise NotFound(f"No Titan route matches: {request.path}")
685
+ # Build middleware chain around the entire routing logic
686
+ async def route_and_handle(req: TitanRequest) -> GeminiResponse:
687
+ try:
688
+ # Match Titan route
689
+ match = self._router.match_titan(req.path)
690
+ if match is None:
691
+ raise NotFound(f"No Titan route matches: {req.path}")
635
692
 
636
- route, params = match
693
+ route, params = match
637
694
 
638
- # Validate auth token if required
639
- if route.auth_tokens is not None:
640
- if not request.token or request.token not in route.auth_tokens:
641
- raise CertificateRequired("Valid authentication token required")
695
+ # Validate auth token if required
696
+ if route.auth_tokens is not None:
697
+ if not req.token or req.token not in route.auth_tokens:
698
+ raise CertificateRequired("Valid authentication token required")
642
699
 
643
- # Build middleware chain
644
- async def call_handler(req: TitanRequest) -> GeminiResponse:
700
+ # Call the handler
645
701
  result = await route.call_handler(req, params)
646
702
  return convert_response(result, req)
647
703
 
648
- # Apply middleware (in reverse order so first registered runs first)
649
- handler = call_handler
650
- for mw in reversed(self._middleware):
651
- handler = self._wrap_middleware(mw, handler)
704
+ except GeminiException as e:
705
+ return GeminiResponse(status=e.status_code, meta=e.message)
706
+ except Exception:
707
+ import traceback
652
708
 
653
- return await handler(request)
709
+ traceback.print_exc()
710
+ return GeminiResponse(
711
+ status=StatusCode.TEMPORARY_FAILURE,
712
+ meta="Internal server error",
713
+ )
654
714
 
655
- except GeminiException as e:
656
- return GeminiResponse(status=e.status_code, meta=e.message)
657
- except Exception:
658
- import traceback
715
+ # Apply middleware around the entire routing logic
716
+ handler = route_and_handle
717
+ for mw in reversed(self._middleware):
718
+ handler = self._wrap_middleware(mw, handler)
659
719
 
660
- traceback.print_exc()
661
- return GeminiResponse(
662
- status=StatusCode.TEMPORARY_FAILURE,
663
- meta="Internal server error",
664
- )
720
+ return await handler(request)
665
721
 
666
722
  def _wrap_middleware(
667
723
  self,
xitzin/middleware.py CHANGED
@@ -7,6 +7,7 @@ and modify responses before they are sent to clients.
7
7
  from __future__ import annotations
8
8
 
9
9
  import asyncio
10
+ import re
10
11
  import time
11
12
  from abc import ABC
12
13
  from collections import OrderedDict
@@ -18,6 +19,7 @@ from nauyaca.protocol.response import GeminiResponse
18
19
  from nauyaca.protocol.status import StatusCode
19
20
 
20
21
  if TYPE_CHECKING:
22
+ from .application import Xitzin
21
23
  from .requests import Request
22
24
 
23
25
  # Type alias for middleware call_next function
@@ -382,3 +384,185 @@ class UserSessionMiddleware(BaseMiddleware):
382
384
  currsize=len(self._async_cache),
383
385
  )
384
386
  return self._sync_cached_loader.cache_info()
387
+
388
+
389
+ class VirtualHostMiddleware(BaseMiddleware):
390
+ """Middleware for hostname-based virtual hosting.
391
+
392
+ Routes requests to different Xitzin applications based on the hostname
393
+ in the request URL. Supports exact hostname matches and wildcard patterns.
394
+
395
+ Example:
396
+ from xitzin import Xitzin
397
+ from xitzin.middleware import VirtualHostMiddleware
398
+
399
+ blog_app = Xitzin(title="Blog")
400
+ api_app = Xitzin(title="API")
401
+ main_app = Xitzin(title="Gateway")
402
+
403
+ @blog_app.gemini("/")
404
+ def blog_home(request):
405
+ return "# Blog Home"
406
+
407
+ @api_app.gemini("/")
408
+ def api_home(request):
409
+ return "# API Home"
410
+
411
+ @main_app.gemini("/")
412
+ def main_home(request):
413
+ return "# Main Home"
414
+
415
+ # Create virtual host middleware
416
+ vhost_mw = VirtualHostMiddleware({
417
+ "blog.example.com": blog_app,
418
+ "*.api.example.com": api_app,
419
+ }, default_app=main_app)
420
+
421
+ @main_app.middleware
422
+ async def vhost(request, call_next):
423
+ return await vhost_mw(request, call_next)
424
+
425
+ main_app.run()
426
+ """
427
+
428
+ def __init__(
429
+ self,
430
+ hosts: dict[str, "Xitzin"],
431
+ *,
432
+ default_app: "Xitzin | None" = None,
433
+ fallback_status: int = 53,
434
+ fallback_handler: (
435
+ Callable[["Request"], Any] | Callable[["Request"], Awaitable[Any]] | None
436
+ ) = None,
437
+ ) -> None:
438
+ """Create virtual host middleware.
439
+
440
+ Args:
441
+ hosts: Mapping of hostname patterns to Xitzin apps.
442
+ Keys can be exact hostnames ("example.com") or wildcard patterns
443
+ ("*.example.com"). Exact matches are checked first, then wildcards
444
+ in definition order.
445
+ default_app: Default app to use when no pattern matches.
446
+ Takes precedence over fallback_status.
447
+ fallback_status: Status code to return when no match and no default_app.
448
+ Defaults to 53 (Proxy Request Refused). Common values:
449
+ - 53: Proxy Request Refused (default)
450
+ - 51: Not Found
451
+ - 59: Bad Request
452
+ fallback_handler: Custom handler function for unmatched hosts.
453
+ Receives the request and must return a response. Takes precedence
454
+ over both default_app and fallback_status. Can be sync or async.
455
+ """
456
+ self._default_app = default_app
457
+ self._fallback_status = fallback_status
458
+ self._fallback_handler = fallback_handler
459
+ self._is_fallback_async = (
460
+ iscoroutinefunction(fallback_handler) if fallback_handler else False
461
+ )
462
+
463
+ # Separate exact and wildcard patterns for efficiency
464
+ self._exact_hosts: dict[str, "Xitzin"] = {}
465
+ self._wildcard_patterns: list[tuple[re.Pattern[str], "Xitzin"]] = []
466
+
467
+ for pattern, app in hosts.items():
468
+ if pattern.startswith("*."):
469
+ compiled = self._compile_wildcard_pattern(pattern)
470
+ if compiled:
471
+ self._wildcard_patterns.append((compiled, app))
472
+ else:
473
+ self._exact_hosts[pattern.lower()] = app
474
+
475
+ def _compile_wildcard_pattern(self, pattern: str) -> re.Pattern[str] | None:
476
+ """Convert wildcard pattern to regex.
477
+
478
+ Supports patterns like:
479
+ - *.example.com -> matches "blog.example.com", "api.example.com"
480
+
481
+ Args:
482
+ pattern: Wildcard pattern string starting with "*.".
483
+
484
+ Returns:
485
+ Compiled regex pattern, or None if invalid.
486
+ """
487
+ if not pattern.startswith("*."):
488
+ return None
489
+
490
+ # Extract domain part after "*."
491
+ domain = pattern[2:]
492
+ # Escape special regex chars in domain
493
+ domain_escaped = re.escape(domain)
494
+ # Replace the wildcard with a pattern that matches any non-dot chars
495
+ regex = f"^[^.]+\\.{domain_escaped}$"
496
+
497
+ return re.compile(regex, re.IGNORECASE)
498
+
499
+ def _match_hostname(self, hostname: str) -> "Xitzin | None":
500
+ """Find the app for a given hostname.
501
+
502
+ Checks exact matches first, then wildcard patterns in order.
503
+
504
+ Args:
505
+ hostname: The hostname from the request.
506
+
507
+ Returns:
508
+ Matching Xitzin app, or None if no match.
509
+ """
510
+ hostname_lower = hostname.lower()
511
+
512
+ # Try exact match first
513
+ if hostname_lower in self._exact_hosts:
514
+ return self._exact_hosts[hostname_lower]
515
+
516
+ # Try wildcard patterns
517
+ for pattern, app in self._wildcard_patterns:
518
+ if pattern.match(hostname_lower):
519
+ return app
520
+
521
+ return None
522
+
523
+ async def before_request(
524
+ self, request: "Request"
525
+ ) -> "Request | GeminiResponse | None":
526
+ """Route request to appropriate app based on hostname."""
527
+ from .requests import TitanRequest
528
+ from .responses import convert_response
529
+
530
+ hostname = request.hostname
531
+
532
+ # Find matching app
533
+ app = self._match_hostname(hostname)
534
+
535
+ # Handle no match
536
+ if app is None:
537
+ # Priority 1: Custom fallback handler
538
+ if self._fallback_handler:
539
+ if self._is_fallback_async:
540
+ result = await self._fallback_handler(request)
541
+ else:
542
+ loop = asyncio.get_running_loop()
543
+ result = await loop.run_in_executor(
544
+ None, self._fallback_handler, request
545
+ )
546
+ return convert_response(result, request)
547
+
548
+ # Priority 2: Default app
549
+ if self._default_app:
550
+ app = self._default_app
551
+ else:
552
+ # Priority 3: Fallback status
553
+ return GeminiResponse(
554
+ status=StatusCode(self._fallback_status),
555
+ meta="Host not configured for this server",
556
+ )
557
+
558
+ # If the matched app is the same as the request's app, return None
559
+ # to continue processing through the normal route chain (avoid recursion)
560
+ if request._app is not None and app is request._app:
561
+ return None
562
+
563
+ # Dispatch to matched app
564
+ if isinstance(request, TitanRequest):
565
+ response = await app._handle_titan_request(request._raw_request)
566
+ else:
567
+ response = await app._handle_request(request._raw_request)
568
+ return response
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.3
2
2
  Name: xitzin
3
- Version: 0.4.0
3
+ Version: 0.5.0
4
4
  Summary: A Gemini Application Framework
5
5
  Keywords: gemini,protocol,framework,async,geminispace
6
6
  Author: Alan Velasco
@@ -1,9 +1,9 @@
1
- xitzin/__init__.py,sha256=GMfAmzBJ6TMZJWfF38r1k0y-vswOdLw9xfWpPfYMcqc,1851
2
- xitzin/application.py,sha256=axL5G4lRz0KTNLsNQkCGxWD0MGDpIH6Xe9OKkxJlJw8,27851
1
+ xitzin/__init__.py,sha256=EqdQIoz53LTfw5UMeFF-PZmBLa9tHUFrgU4Y2brt2yA,1943
2
+ xitzin/application.py,sha256=ZTAsNQWaZsXf4CIXh7LP3t5KZUsHCgW5HmqYSs-mydU,30004
3
3
  xitzin/auth.py,sha256=hHhrP_fYToY6J6CCU8vgAsWbYn6K16LUxkeDMzZTF2Q,5304
4
4
  xitzin/cgi.py,sha256=nggSuQ0gXIKdBKWHqH_CNZ6UlVGaB3VAOu3ZRPc43ek,18046
5
5
  xitzin/exceptions.py,sha256=82z-CjyC0FwFbo9hGTjjmurlL_Vd4rTVdgkmQoFLXT0,3883
6
- xitzin/middleware.py,sha256=dZhrbOJPCrrbMiwrbRzxElsC7UDrWc7lvnQ5c0xBHts,12542
6
+ xitzin/middleware.py,sha256=rES7C1gujvP-1evpXfqng9uZ91rzJdqfob-nvGBtTAo,19042
7
7
  xitzin/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
8
8
  xitzin/requests.py,sha256=1mbm5F8oywLPNpSdh2inn7K1IEeI3H5meOwj8jldi1Q,7783
9
9
  xitzin/responses.py,sha256=QVH4B-qVHP7hxi0hIrOJhdCiHDf4PDCpSZ1fAD8iNkk,6525
@@ -13,6 +13,6 @@ xitzin/sqlmodel.py,sha256=lZvDzYAgnG8S2K-EYnx6PkO7D68pjM06uQLSKUZ9yY4,4500
13
13
  xitzin/tasks.py,sha256=_smEXy-THge8wmQqWDtX3iUmAmoHniw_qqZBlKjCdqA,4597
14
14
  xitzin/templating.py,sha256=spjxb05wgwKA4txOMbWJuwEVMaoLovo13_ukbXAcBDY,6040
15
15
  xitzin/testing.py,sha256=wMiToopD2TAqTsreVqOrIUNCayORNQQennxR_GwMBSY,10980
16
- xitzin-0.4.0.dist-info/WHEEL,sha256=XV0cjMrO7zXhVAIyyc8aFf1VjZ33Fen4IiJk5zFlC3g,80
17
- xitzin-0.4.0.dist-info/METADATA,sha256=Zv-Rde7LWIcf_FQfclXu77BzGipWf6_n0fOXVzkvk0A,3456
18
- xitzin-0.4.0.dist-info/RECORD,,
16
+ xitzin-0.5.0.dist-info/WHEEL,sha256=fAguSjoiATBe7TNBkJwOjyL1Tt4wwiaQGtNtjRPNMQA,80
17
+ xitzin-0.5.0.dist-info/METADATA,sha256=vOmiKWl7qO1EAvFzFuQgRAK1QHJbMCulY_KoEKH_-3c,3456
18
+ xitzin-0.5.0.dist-info/RECORD,,
@@ -1,4 +1,4 @@
1
1
  Wheel-Version: 1.0
2
- Generator: uv 0.9.26
2
+ Generator: uv 0.9.28
3
3
  Root-Is-Purelib: true
4
4
  Tag: py3-none-any