xitzin 0.1.2__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/exceptions.py ADDED
@@ -0,0 +1,138 @@
1
+ """Gemini exception classes for error responses.
2
+
3
+ These exceptions can be raised in handlers to return specific Gemini status codes.
4
+ """
5
+
6
+ from __future__ import annotations
7
+
8
+
9
+ class GeminiException(Exception):
10
+ """Base exception for Gemini error responses.
11
+
12
+ Subclasses define specific status codes for different error types.
13
+ Raise these in handlers to return the appropriate Gemini status.
14
+
15
+ Example:
16
+ @app.gemini("/page/{page_id}")
17
+ def get_page(request: Request, page_id: int):
18
+ page = db.get(page_id)
19
+ if not page:
20
+ raise NotFound(f"Page {page_id} not found")
21
+ return page.content
22
+ """
23
+
24
+ status_code: int = 50
25
+ default_message: str = "Permanent failure"
26
+
27
+ def __init__(self, message: str | None = None) -> None:
28
+ self.message = message or self.default_message
29
+ super().__init__(self.message)
30
+
31
+
32
+ # Input required (1x)
33
+ class InputRequired(GeminiException):
34
+ """Request requires user input (status 10)."""
35
+
36
+ status_code = 10
37
+ default_message = "Input required"
38
+
39
+
40
+ class SensitiveInputRequired(GeminiException):
41
+ """Request requires sensitive user input (status 11)."""
42
+
43
+ status_code = 11
44
+ default_message = "Sensitive input required"
45
+
46
+
47
+ # Temporary failures (4x)
48
+ class TemporaryFailure(GeminiException):
49
+ """Temporary failure - client may retry (status 40)."""
50
+
51
+ status_code = 40
52
+ default_message = "Temporary failure"
53
+
54
+
55
+ class ServerUnavailable(GeminiException):
56
+ """Server unavailable due to maintenance or overload (status 41)."""
57
+
58
+ status_code = 41
59
+ default_message = "Server unavailable"
60
+
61
+
62
+ class CGIError(GeminiException):
63
+ """CGI or similar process error (status 42)."""
64
+
65
+ status_code = 42
66
+ default_message = "CGI error"
67
+
68
+
69
+ class ProxyError(GeminiException):
70
+ """Proxy request failed (status 43)."""
71
+
72
+ status_code = 43
73
+ default_message = "Proxy error"
74
+
75
+
76
+ class SlowDown(GeminiException):
77
+ """Rate limiting - client should slow down (status 44)."""
78
+
79
+ status_code = 44
80
+ default_message = "Slow down"
81
+
82
+
83
+ # Permanent failures (5x)
84
+ class PermanentFailure(GeminiException):
85
+ """Permanent failure - client should not retry (status 50)."""
86
+
87
+ status_code = 50
88
+ default_message = "Permanent failure"
89
+
90
+
91
+ class NotFound(GeminiException):
92
+ """Resource not found (status 51)."""
93
+
94
+ status_code = 51
95
+ default_message = "Not found"
96
+
97
+
98
+ class Gone(GeminiException):
99
+ """Resource permanently removed (status 52)."""
100
+
101
+ status_code = 52
102
+ default_message = "Gone"
103
+
104
+
105
+ class ProxyRequestRefused(GeminiException):
106
+ """Proxy request refused (status 53)."""
107
+
108
+ status_code = 53
109
+ default_message = "Proxy request refused"
110
+
111
+
112
+ class BadRequest(GeminiException):
113
+ """Malformed request (status 59)."""
114
+
115
+ status_code = 59
116
+ default_message = "Bad request"
117
+
118
+
119
+ # Client certificate errors (6x)
120
+ class CertificateRequired(GeminiException):
121
+ """Client certificate required (status 60)."""
122
+
123
+ status_code = 60
124
+ default_message = "Client certificate required"
125
+
126
+
127
+ class CertificateNotAuthorized(GeminiException):
128
+ """Certificate not authorized for this resource (status 61)."""
129
+
130
+ status_code = 61
131
+ default_message = "Certificate not authorized"
132
+
133
+
134
+ class CertificateNotValid(GeminiException):
135
+ """Certificate is not valid (status 62)."""
136
+
137
+ status_code = 62
138
+ default_message = "Certificate not valid"
xitzin/middleware.py ADDED
@@ -0,0 +1,219 @@
1
+ """Middleware system for Xitzin.
2
+
3
+ Middleware functions can intercept requests before they reach handlers
4
+ and modify responses before they are sent to clients.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import time
10
+ from abc import ABC
11
+ from typing import TYPE_CHECKING, Awaitable, Callable
12
+
13
+ from nauyaca.protocol.response import GeminiResponse
14
+ from nauyaca.protocol.status import StatusCode
15
+
16
+ if TYPE_CHECKING:
17
+ from .requests import Request
18
+
19
+ # Type alias for middleware call_next function
20
+ CallNext = Callable[["Request"], Awaitable[GeminiResponse]]
21
+
22
+
23
+ class BaseMiddleware(ABC):
24
+ """Base class for class-based middleware.
25
+
26
+ Subclass this and implement before_request and/or after_response
27
+ for a cleaner interface than writing raw middleware functions.
28
+
29
+ Example:
30
+ class LoggingMiddleware(BaseMiddleware):
31
+ async def before_request(
32
+ self, request: Request
33
+ ) -> Request | GeminiResponse | None:
34
+ print(f"Request: {request.path}")
35
+ return None # Continue processing
36
+
37
+ async def after_response(
38
+ self, request: Request, response: GeminiResponse
39
+ ) -> GeminiResponse:
40
+ print(f"Response: {response.status}")
41
+ return response
42
+
43
+ app.add_middleware(LoggingMiddleware())
44
+ """
45
+
46
+ async def before_request(
47
+ self, request: "Request"
48
+ ) -> "Request | GeminiResponse | None":
49
+ """Called before the handler.
50
+
51
+ Args:
52
+ request: The incoming request.
53
+
54
+ Returns:
55
+ - None: Continue to next middleware/handler
56
+ - Request: Use this modified request
57
+ - GeminiResponse: Short-circuit and return this response immediately
58
+ """
59
+ return None
60
+
61
+ async def after_response(
62
+ self, request: "Request", response: GeminiResponse
63
+ ) -> GeminiResponse:
64
+ """Called after the handler.
65
+
66
+ Args:
67
+ request: The original request.
68
+ response: The response from the handler.
69
+
70
+ Returns:
71
+ The response to send (can be modified).
72
+ """
73
+ return response
74
+
75
+ async def __call__(self, request: "Request", call_next: CallNext) -> GeminiResponse:
76
+ """Process the request through this middleware.
77
+
78
+ This implements the middleware protocol by calling before_request,
79
+ then call_next, then after_response.
80
+ """
81
+ # Before request
82
+ result = await self.before_request(request)
83
+ if isinstance(result, GeminiResponse):
84
+ return result # Short-circuit
85
+ if result is not None:
86
+ request = result # Use modified request
87
+
88
+ # Call next handler
89
+ response = await call_next(request)
90
+
91
+ # After response
92
+ return await self.after_response(request, response)
93
+
94
+
95
+ class TimingMiddleware(BaseMiddleware):
96
+ """Middleware that tracks request processing time.
97
+
98
+ Stores the elapsed time in request.state.elapsed_time.
99
+
100
+ Example:
101
+ app.add_middleware(TimingMiddleware())
102
+
103
+ @app.gemini("/")
104
+ def home(request: Request):
105
+ elapsed = getattr(request.state, 'elapsed_time', 0)
106
+ return f"# Response generated in {elapsed:.3f}s"
107
+ """
108
+
109
+ async def before_request(
110
+ self, request: "Request"
111
+ ) -> "Request | GeminiResponse | None":
112
+ request.state.start_time = time.perf_counter()
113
+ return None
114
+
115
+ async def after_response(
116
+ self, request: "Request", response: GeminiResponse
117
+ ) -> GeminiResponse:
118
+ elapsed = time.perf_counter() - request.state.start_time
119
+ request.state.elapsed_time = elapsed
120
+ return response
121
+
122
+
123
+ class LoggingMiddleware(BaseMiddleware):
124
+ """Middleware that logs requests and responses.
125
+
126
+ Example:
127
+ app.add_middleware(LoggingMiddleware())
128
+ """
129
+
130
+ def __init__(self, logger: Callable[[str], None] | None = None) -> None:
131
+ """Create logging middleware.
132
+
133
+ Args:
134
+ logger: Custom logging function. Defaults to print.
135
+ """
136
+ self._log = logger or print
137
+
138
+ async def before_request(
139
+ self, request: "Request"
140
+ ) -> "Request | GeminiResponse | None":
141
+ cert_info = ""
142
+ if request.client_cert_fingerprint:
143
+ cert_info = f" [cert:{request.client_cert_fingerprint[:8]}]"
144
+ self._log(f"[Xitzin] Request: {request.path}{cert_info}")
145
+ return None
146
+
147
+ async def after_response(
148
+ self, request: "Request", response: GeminiResponse
149
+ ) -> GeminiResponse:
150
+ self._log(f"[Xitzin] Response: {response.status} {response.meta}")
151
+ return response
152
+
153
+
154
+ class RateLimitMiddleware(BaseMiddleware):
155
+ """Simple in-memory rate limiting middleware.
156
+
157
+ Limits requests per client based on certificate fingerprint or IP.
158
+
159
+ Example:
160
+ app.add_middleware(RateLimitMiddleware(max_requests=10, window_seconds=60))
161
+ """
162
+
163
+ def __init__(
164
+ self,
165
+ max_requests: int = 10,
166
+ window_seconds: float = 60.0,
167
+ retry_after: int = 30,
168
+ ) -> None:
169
+ """Create rate limit middleware.
170
+
171
+ Args:
172
+ max_requests: Maximum requests allowed per window.
173
+ window_seconds: Time window in seconds.
174
+ retry_after: Seconds to tell client to wait.
175
+ """
176
+ self.max_requests = max_requests
177
+ self.window_seconds = window_seconds
178
+ self.retry_after = retry_after
179
+ self._requests: dict[str, list[float]] = {}
180
+
181
+ def _get_client_id(self, request: "Request") -> str:
182
+ """Get a unique identifier for the client."""
183
+ if request.client_cert_fingerprint:
184
+ return f"cert:{request.client_cert_fingerprint}"
185
+ # Fall back to a placeholder (in production, use IP from transport)
186
+ return "unknown"
187
+
188
+ def _is_rate_limited(self, client_id: str) -> bool:
189
+ """Check if a client is rate limited."""
190
+ now = time.time()
191
+ cutoff = now - self.window_seconds
192
+
193
+ # Get request timestamps for this client
194
+ timestamps = self._requests.get(client_id, [])
195
+
196
+ # Filter to only recent requests
197
+ recent = [t for t in timestamps if t > cutoff]
198
+ self._requests[client_id] = recent
199
+
200
+ # Check if over limit
201
+ if len(recent) >= self.max_requests:
202
+ return True
203
+
204
+ # Record this request
205
+ recent.append(now)
206
+ return False
207
+
208
+ async def before_request(
209
+ self, request: "Request"
210
+ ) -> "Request | GeminiResponse | None":
211
+ client_id = self._get_client_id(request)
212
+
213
+ if self._is_rate_limited(client_id):
214
+ return GeminiResponse(
215
+ status=StatusCode.SLOW_DOWN,
216
+ meta=str(self.retry_after),
217
+ )
218
+
219
+ return None
xitzin/py.typed ADDED
File without changes
xitzin/requests.py ADDED
@@ -0,0 +1,150 @@
1
+ """Request wrapper for Xitzin handlers.
2
+
3
+ Provides a convenient interface to the underlying Nauyaca GeminiRequest.
4
+ """
5
+
6
+ from __future__ import annotations
7
+
8
+ from typing import TYPE_CHECKING, Any
9
+ from urllib.parse import unquote_plus
10
+
11
+ from nauyaca.protocol.request import GeminiRequest
12
+
13
+ if TYPE_CHECKING:
14
+ from cryptography.x509 import Certificate
15
+
16
+ from .application import Xitzin
17
+
18
+
19
+ class RequestState:
20
+ """Arbitrary state storage for a request.
21
+
22
+ Middleware and handlers can store arbitrary data here.
23
+
24
+ Example:
25
+ request.state.user = get_current_user()
26
+ request.state.start_time = time.time()
27
+ """
28
+
29
+ def __setattr__(self, name: str, value: Any) -> None:
30
+ self.__dict__[name] = value
31
+
32
+ def __getattr__(self, name: str) -> Any:
33
+ try:
34
+ return self.__dict__[name]
35
+ except KeyError:
36
+ raise AttributeError(f"'RequestState' has no attribute '{name}'") from None
37
+
38
+ def __delattr__(self, name: str) -> None:
39
+ try:
40
+ del self.__dict__[name]
41
+ except KeyError:
42
+ raise AttributeError(f"'RequestState' has no attribute '{name}'") from None
43
+
44
+
45
+ class Request:
46
+ """Wraps a Nauyaca GeminiRequest with convenient accessors.
47
+
48
+ Handlers receive this object as their first argument.
49
+
50
+ Example:
51
+ @app.gemini("/user/{username}")
52
+ def profile(request: Request, username: str):
53
+ cert_id = request.client_cert_fingerprint
54
+ viewer = cert_id[:16] if cert_id else 'anonymous'
55
+ return f"# {username}'s Profile\\n\\nViewing as: {viewer}"
56
+
57
+ Attributes:
58
+ app: The Xitzin application instance.
59
+ state: Arbitrary state storage for this request.
60
+ path: The URL path component.
61
+ query: The decoded query string (user input).
62
+ raw_query: The raw (URL-encoded) query string.
63
+ client_cert: The client's TLS certificate, if provided.
64
+ client_cert_fingerprint: SHA-256 fingerprint of client certificate.
65
+ """
66
+
67
+ def __init__(self, raw_request: GeminiRequest, app: Xitzin | None = None) -> None:
68
+ self._raw_request = raw_request
69
+ self._app = app
70
+ self._state = RequestState()
71
+
72
+ @property
73
+ def app(self) -> Xitzin:
74
+ """The Xitzin application handling this request."""
75
+ if self._app is None:
76
+ msg = "Request is not bound to an application"
77
+ raise RuntimeError(msg)
78
+ return self._app
79
+
80
+ @property
81
+ def state(self) -> RequestState:
82
+ """Arbitrary state storage for this request."""
83
+ return self._state
84
+
85
+ @property
86
+ def path(self) -> str:
87
+ """The URL path component."""
88
+ return self._raw_request.path
89
+
90
+ @property
91
+ def raw_query(self) -> str:
92
+ """The raw (URL-encoded) query string."""
93
+ return self._raw_request.query
94
+
95
+ @property
96
+ def query(self) -> str:
97
+ """The decoded query string.
98
+
99
+ Gemini uses URL query strings for user input (status 10/11 flow).
100
+ This property decodes the query string for convenient access.
101
+ """
102
+ if not self._raw_request.query:
103
+ return ""
104
+ return unquote_plus(self._raw_request.query)
105
+
106
+ @property
107
+ def url(self) -> str:
108
+ """The full normalized URL."""
109
+ return self._raw_request.normalized_url
110
+
111
+ @property
112
+ def raw_url(self) -> str:
113
+ """The original URL from the request."""
114
+ return self._raw_request.raw_url
115
+
116
+ @property
117
+ def hostname(self) -> str:
118
+ """The server hostname from the URL."""
119
+ return self._raw_request.hostname
120
+
121
+ @property
122
+ def port(self) -> int:
123
+ """The server port from the URL."""
124
+ return self._raw_request.port
125
+
126
+ @property
127
+ def client_cert(self) -> Certificate | None:
128
+ """The client's TLS certificate, if provided."""
129
+ return self._raw_request.client_cert
130
+
131
+ @property
132
+ def client_cert_fingerprint(self) -> str | None:
133
+ """SHA-256 fingerprint of the client certificate."""
134
+ return self._raw_request.client_cert_fingerprint
135
+
136
+ @property
137
+ def remote_addr(self) -> str | None:
138
+ """The client's IP address, if available.
139
+
140
+ Note: This property returns the client IP address if it was set
141
+ by the server or middleware. In CGI context, this is passed to
142
+ scripts via the REMOTE_ADDR environment variable.
143
+
144
+ Returns:
145
+ The client IP address string, or None if not available.
146
+ """
147
+ return getattr(self._raw_request, "remote_addr", None)
148
+
149
+ def __repr__(self) -> str:
150
+ return f"Request({self._raw_request.raw_url!r})"