Coverage for src / lexigram / contracts / web / protocols.py: 100%
87 statements
« prev ^ index » next coverage.py v7.13.5, created at 2026-08-19 05:41 +0800
« prev ^ index » next coverage.py v7.13.5, created at 2026-08-19 05:41 +0800
1"""HTTP web protocol definitions.
3Structural protocols for the HTTP request/response lifecycle, middleware,
4rate limiting, exception filtering, and web provider integration.
5"""
7from __future__ import annotations
9from typing import TYPE_CHECKING, Any, Protocol, TypeVar, runtime_checkable
11from lexigram.contracts.core.middleware import (
12 ExceptionFilterChainProtocol as ExceptionFilterChainProtocol,
13)
14from lexigram.contracts.core.provider import ProviderProtocol
16if TYPE_CHECKING:
17 from collections.abc import Awaitable, Callable
18 from datetime import datetime
20 from lexigram.contracts.core.result import Result
21 from lexigram.contracts.exceptions.domain import DomainError
23T = TypeVar("T")
25C = TypeVar("C")
28@runtime_checkable
29class HttpRequestLoggerProtocol(Protocol):
30 """Protocol for HTTP request/response logging middleware.
32 Defines how to log completed HTTP requests with duration, status code,
33 and optional metadata for monitoring and audit purposes.
35 Example:
36 ```python
37 class RequestLogger:
38 async def log_request(
39 self,
40 method: str,
41 path: str,
42 status_code: int,
43 duration_ms: float,
44 request_id: str | None = None,
45 **metadata: Any,
46 ) -> None:
47 logger.info(
48 "request_completed",
49 method=method,
50 path=path,
51 status=status_code,
52 duration_ms=duration_ms,
53 request_id=request_id,
54 **metadata,
55 )
56 ```
57 """
59 async def log_request(
60 self,
61 method: str,
62 path: str,
63 status_code: int,
64 duration_ms: float,
65 request_id: str | None = None,
66 **metadata: Any,
67 ) -> None:
68 """Log a completed HTTP request.
70 Args:
71 method: HTTP method (GET, POST, etc.).
72 path: Request path URI.
73 status_code: HTTP response status code.
74 duration_ms: Request processing duration in milliseconds.
75 request_id: Optional request identifier for tracing.
76 **metadata: Additional context-specific data (client_id, user_id, etc.).
77 """
78 ...
81@runtime_checkable
82class CORSPolicyProtocol(Protocol):
83 """Protocol for CORS (Cross-Origin Resource Sharing) policy configuration.
85 Defines how to evaluate CORS requests and provide the necessary headers
86 and configuration for browser-based clients.
88 Example:
89 ```python
90 class CORSPolicy:
91 def is_origin_allowed(self, origin: str) -> bool:
92 return origin in ("https://app.example.com", "https://admin.example.com")
94 def get_allowed_headers(self) -> list[str]:
95 return ["content-type", "authorization", "x-request-id"]
97 def get_allowed_methods(self) -> list[str]:
98 return ["GET", "POST", "PUT", "DELETE", "PATCH", "OPTIONS"]
100 def get_max_age(self) -> int:
101 return 3600
102 ```
103 """
105 def is_origin_allowed(self, origin: str) -> bool:
106 """Check if an origin is permitted for CORS requests.
108 Args:
109 origin: The value of the Origin header from a CORS preflight request.
111 Returns:
112 True if the origin is allowed, False otherwise.
113 """
114 ...
116 def get_allowed_headers(self) -> list[str]:
117 """Return the list of allowed request headers.
119 Returns:
120 List of header names that clients are allowed to send.
121 Common values: ["content-type", "authorization", "x-request-id"].
122 """
123 ...
125 def get_allowed_methods(self) -> list[str]:
126 """Return the list of allowed HTTP methods.
128 Returns:
129 List of HTTP methods clients are allowed to use.
130 Typical: ["GET", "POST", "PUT", "DELETE", "PATCH", "OPTIONS"].
131 """
132 ...
134 def get_max_age(self) -> int:
135 """Return the preflight cache duration in seconds.
137 Returns:
138 Maximum time (in seconds) browsers should cache preflight
139 responses. Typical values: 3600 (1 hour) to 86400 (24 hours).
140 """
141 ...
144__all__ = [
145 "BackgroundTaskRunnerProtocol",
146 "CORSPolicyProtocol",
147 "CRUDServiceProtocol",
148 "CSRFProtectionProtocol",
149 "ConnectionManagerProtocol",
150 "ExceptionFilterChainProtocol",
151 "ExceptionFilterProtocol",
152 "HTTPApplicationProtocol",
153 "HttpRequestLoggerProtocol",
154 "RequestProtocol",
155 "ResponseFactoryProtocol",
156 "ResponseProtocol",
157 "WebContributorProtocol",
158 "WebMiddlewareProtocol",
159 "WebProviderProtocol",
160 "WebRateLimiterProtocol",
161]
164@runtime_checkable
165class BackgroundTaskRunnerProtocol(Protocol):
166 """Protocol for in-process background task execution.
168 Background tasks run after a response is sent to the client. This
169 protocol models post-response callable execution inside the web layer.
170 Durable job submission belongs to the task subsystem via
171 ``TaskProviderProtocol`` / ``TaskQueueProtocol`` rather than this web
172 background-runner contract.
173 """
175 def add_task(
176 self,
177 func: Callable[..., Any],
178 *args: Any,
179 **kwargs: Any,
180 ) -> None:
181 """Add a task to run in the background.
183 Args:
184 func: Async or sync callable to execute.
185 *args: Positional arguments for the callable.
186 **kwargs: Keyword arguments for the callable.
187 """
188 ...
191@runtime_checkable
192class CSRFProtectionProtocol(Protocol):
193 """Protocol for CSRF (Cross-Site Request Forgery) protection.
195 Validates that state-modifying requests (POST, PUT, PATCH, DELETE)
196 include a valid CSRF token that matches the session cookie.
198 This is essential for browser-based applications using session cookies.
199 """
201 def generate_token(self, session_id: str) -> str:
202 """Generate a CSRF token for a session.
204 Args:
205 session_id: Unique session identifier.
207 Returns:
208 CSRF token string.
209 """
210 ...
212 def validate_token(self, token: str, session_id: str) -> bool:
213 """Validate a CSRF token against a session.
215 Args:
216 token: CSRF token from request.
217 session_id: Session identifier.
219 Returns:
220 True if token is valid for the session.
221 """
222 ...
224 def get_cookie_name(self) -> str:
225 """Get the name of the CSRF cookie.
227 Returns:
228 Cookie name (default: "csrf_token").
229 """
230 ...
232 def get_header_name(self) -> str:
233 """Get the name of the CSRF header.
235 Returns:
236 Header name (default: "X-CSRF-Token").
237 """
238 ...
241@runtime_checkable
242class WebRateLimiterProtocol(Protocol):
243 """Protocol for web rate limiting.
245 Handles rate limiting for HTTP requests based on various scopes.
246 """
248 async def check_rate_limit(
249 self,
250 request: Any,
251 *,
252 max_requests: int,
253 window_seconds: int,
254 scope: str = "user",
255 ) -> None:
256 """Check if request is within rate limit.
258 Args:
259 request: The incoming request.
260 max_requests: Maximum requests per window.
261 window_seconds: Time window in seconds.
262 scope: Rate limit scope (user, ip, or endpoint).
264 Raises:
265 Exception: If rate limit exceeded.
266 """
267 ...
270@runtime_checkable
271class WebMiddlewareProtocol(Protocol):
272 """Protocol for HTTP web middleware.
274 WebMiddlewareProtocol intercepts HTTP requests and responses for cross-cutting
275 concerns such as authentication, logging, CORS, and compression.
277 This is distinct from ``MiddlewareProtocol`` in ``contracts.middleware``
278 which is transport-agnostic (events, commands, queries). WebMiddlewareProtocol is
279 exclusively for the HTTP request/response lifecycle in ``lexigram-web``
280 and compatible ASGI frameworks.
282 Example::
284 class LoggingMiddleware:
285 async def __call__(self, request, call_next):
286 start = time.monotonic()
287 response = await call_next(request)
288 logger.info("%s %s %.2fs", request.method, request.url, time.monotonic()-start)
289 return response
290 """
292 async def __call__(
293 self,
294 request: Any,
295 call_next: Callable[[Any], Awaitable[Any]],
296 ) -> Any:
297 """Process the HTTP request.
299 Args:
300 request: Incoming HTTP request.
301 call_next: Callable to invoke the next middleware/handler.
303 Returns:
304 HTTP response.
305 """
306 ...
309@runtime_checkable
310class ExceptionFilterProtocol(Protocol):
311 """Protocol for exception handling filters.
313 Exception filters convert exceptions to HTTP responses.
315 Example:
316 ```python
317 class ValidationExceptionFilter:
318 def can_handle(self, exc):
319 return isinstance(exc, ValidationError)
321 def handle(self, exc, request):
322 return JSONResponse(
323 {"errors": exc.errors()},
324 status_code=422,
325 )
326 ```
327 """
329 def can_handle(self, exc: Exception) -> bool:
330 """Check if this filter handles the exception.
332 Args:
333 exc: Exception to check.
335 Returns:
336 True if this filter can handle the exception.
337 """
338 ...
340 def handle(self, exc: Exception, request: Any) -> Any:
341 """Convert exception to a response.
343 Args:
344 exc: Exception to handle.
345 request: Original request.
347 Returns:
348 HTTP response.
349 """
350 ...
353@runtime_checkable
354class RequestProtocol(Protocol):
355 """Protocol for HTTP requests."""
357 url: Any
358 method: str
359 headers: Any
360 path_params: dict[str, Any]
361 query_params: Any
362 cookies: Any
363 state: Any
364 user: Any
365 auth: Any
367 async def json(self) -> Any: ...
369 async def body(self) -> bytes: ...
372@runtime_checkable
373class ResponseProtocol(Protocol):
374 """Protocol for HTTP responses."""
376 status_code: int
377 headers: Any
378 body: bytes
379 media_type: str | None
380 background: Any | None
382 def set_cookie(
383 self,
384 key: str,
385 value: str = "",
386 max_age: int | None = None,
387 expires: int | datetime | None = None,
388 path: str = "/",
389 domain: str | None = None,
390 secure: bool = False,
391 httponly: bool = False,
392 samesite: str = "lax",
393 ) -> None: ...
395 def delete_cookie(
396 self,
397 key: str,
398 path: str = "/",
399 domain: str | None = None,
400 ) -> None: ...
403@runtime_checkable
404class ResponseFactoryProtocol(Protocol):
405 """Protocol for creating HTTP responses.
407 Abstracts response creation to avoid hard dependencies on specific
408 web frameworks (e.g., Starlette/FastAPI) in business logic or middleware.
409 """
411 def json(
412 self,
413 content: Any,
414 status_code: int = 200,
415 headers: dict[str, str] | None = None,
416 ) -> ResponseProtocol:
417 """Create a JSON response."""
418 ...
420 def html(
421 self,
422 content: str,
423 status_code: int = 200,
424 headers: dict[str, str] | None = None,
425 ) -> ResponseProtocol:
426 """Create an HTML response."""
427 ...
429 def redirect(
430 self,
431 url: str,
432 status_code: int = 302,
433 headers: dict[str, str] | None = None,
434 ) -> ResponseProtocol:
435 """Create a redirect response."""
436 ...
439@runtime_checkable
440class WebProviderProtocol(ProviderProtocol, Protocol):
441 """Protocol for web providers that provide HTTP routing and middleware.
443 Web providers are responsible for setting up HTTP servers, routing,
444 middleware, and request/response handling.
445 """
448@runtime_checkable
449class HTTPApplicationProtocol(Protocol):
450 """Minimal protocol for an ASGI-compatible HTTP application.
452 Extension packages that need to mount sub-applications (e.g.
453 ``lexigram-graphql`` mounting a ``/graphql`` endpoint) must depend on
454 this protocol — NOT on ``lexigram-web`` — to avoid cross-extension imports.
455 The web provider implements this protocol; the container resolves it.
456 """
458 async def __call__(
459 self,
460 scope: dict[str, Any],
461 receive: Any,
462 send: Any,
463 ) -> None:
464 """ASGI callable entry-point.
466 Args:
467 scope: ASGI connection scope.
468 receive: ASGI receive channel callable.
469 send: ASGI send channel callable.
470 """
471 ...
473 def mount(self, path: str, app: HTTPApplicationProtocol) -> None:
474 """Mount a sub-application at the given path prefix.
476 Args:
477 path: URL path prefix (e.g. ``"/api/v2"``).
478 app: The application to mount.
479 """
480 ...
482 def add_route(
483 self,
484 path: str,
485 handler: Any,
486 methods: list[str] | None = None,
487 ) -> None:
488 """Register a route handler.
490 Args:
491 path: URL path pattern.
492 handler: Callable (sync or async) that handles matched requests.
493 methods: Allowed HTTP methods; ``None`` means all methods.
494 """
495 ...
497 def add_middleware(self, middleware: Any) -> None:
498 """Register a middleware layer.
500 Middleware is applied in reverse registration order (last registered
501 is the innermost layer).
503 Args:
504 middleware: A middleware class or instance.
505 """
506 ...
509@runtime_checkable
510class CRUDServiceProtocol(Protocol[T]):
511 """Protocol for services that implement basic CRUD operations."""
513 async def list_items(
514 self, limit: int = 20, offset: int = 0, **filters: Any
515 ) -> Result[list[T], DomainError]:
516 """List items with pagination and filters."""
517 ...
519 async def get(self, item_id: Any) -> Result[T | None, DomainError]:
520 """Get single item by ID."""
521 ...
523 async def create(self, data: dict[str, Any]) -> Result[T, DomainError]:
524 """Create new item."""
525 ...
527 async def update(
528 self, item_id: Any, data: dict[str, Any]
529 ) -> Result[T | None, DomainError]:
530 """Update existing item."""
531 ...
533 async def delete(self, item_id: Any) -> Result[bool, DomainError]:
534 """Delete item."""
535 ...
538@runtime_checkable
539class ConnectionManagerProtocol(Protocol[C]): # type: ignore[misc]
540 """Protocol for connection managers that track and broadcast to clients.
542 Both WebSocket and SSE handlers manage connections; this protocol
543 captures the shared surface so higher-level code can depend on the
544 abstraction rather than a concrete transport.
545 """
547 async def add(self, connection: C) -> None:
548 """Register a new connection."""
549 ...
551 async def remove(self, connection: C) -> None:
552 """Unregister a connection."""
553 ...
555 async def broadcast(self, message: Any, exclude: C | None = None) -> None:
556 """Send a message to all tracked connections."""
557 ...
559 @property
560 def count(self) -> int:
561 """Return the number of active connections."""
562 ...
565@runtime_checkable
566class WebContributorProtocol(Protocol):
567 """Protocol for packages that contribute controllers and middleware.
569 Extension packages can also mount sub-applications via the mount_to_app hook.
571 Extension packages can implement this protocol to register their HTTP
572 controllers and middleware components with the web provider via
573 entry-point discovery. This allows packages like ``lexigram-graphql``
574 and ``lexigram-admin`` to expose web routes without requiring the
575 web provider to explicitly import them.
577 Example:
578 ```python
579 class GraphQLWebContributor:
580 @property
581 def contributor_id(self) -> str:
582 return "graphql"
584 def get_controllers(self) -> list[type]:
585 return [GraphQLController]
587 def get_middleware(self) -> list[type]:
588 return []
590 async def mount_to_app(
591 self, app: HTTPApplicationProtocol, container: object
592 ) -> None:
593 pass # No-op for controller-only contributors
594 ```
595 """
597 @property
598 def contributor_id(self) -> str:
599 """Unique contributor identifier such as ``graphql`` or ``admin``.
601 Returns:
602 A string identifier for this contributor, used for tracking
603 and debugging. Must be unique across all registered contributors.
604 """
605 ...
607 def get_controllers(self) -> list[type[Any]]:
608 """Return controller classes contributed by the package.
610 Returns:
611 List of controller classes to register with the web provider.
612 Each class should implement the controller contract with
613 route definitions.
614 """
615 ...
617 def get_middleware(self) -> list[type[Any]]:
618 """Return middleware classes contributed by the package.
620 Returns:
621 List of middleware classes to register with the web provider.
622 Middleware is applied in registration order.
623 """
624 ...
626 async def mount_to_app(self, app: Any, container: object) -> None:
627 """Mount sub-applications or additional routes to the web app.
629 Called during route setup phase after metrics and debug routes
630 but before static files and controller discovery.
632 Args:
633 app: The ASGI application (typically Starlette) to mount routes on.
634 container: The DI container for resolving dependencies.
636 Note:
637 Controller-only contributors should implement this as a no-op.
638 """