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

1"""HTTP web protocol definitions. 

2 

3Structural protocols for the HTTP request/response lifecycle, middleware, 

4rate limiting, exception filtering, and web provider integration. 

5""" 

6 

7from __future__ import annotations 

8 

9from typing import TYPE_CHECKING, Any, Protocol, TypeVar, runtime_checkable 

10 

11from lexigram.contracts.core.middleware import ( 

12 ExceptionFilterChainProtocol as ExceptionFilterChainProtocol, 

13) 

14from lexigram.contracts.core.provider import ProviderProtocol 

15 

16if TYPE_CHECKING: 

17 from collections.abc import Awaitable, Callable 

18 from datetime import datetime 

19 

20 from lexigram.contracts.core.result import Result 

21 from lexigram.contracts.exceptions.domain import DomainError 

22 

23T = TypeVar("T") 

24 

25C = TypeVar("C") 

26 

27 

28@runtime_checkable 

29class HttpRequestLoggerProtocol(Protocol): 

30 """Protocol for HTTP request/response logging middleware. 

31 

32 Defines how to log completed HTTP requests with duration, status code, 

33 and optional metadata for monitoring and audit purposes. 

34 

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 """ 

58 

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. 

69 

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 ... 

79 

80 

81@runtime_checkable 

82class CORSPolicyProtocol(Protocol): 

83 """Protocol for CORS (Cross-Origin Resource Sharing) policy configuration. 

84 

85 Defines how to evaluate CORS requests and provide the necessary headers 

86 and configuration for browser-based clients. 

87 

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") 

93 

94 def get_allowed_headers(self) -> list[str]: 

95 return ["content-type", "authorization", "x-request-id"] 

96 

97 def get_allowed_methods(self) -> list[str]: 

98 return ["GET", "POST", "PUT", "DELETE", "PATCH", "OPTIONS"] 

99 

100 def get_max_age(self) -> int: 

101 return 3600 

102 ``` 

103 """ 

104 

105 def is_origin_allowed(self, origin: str) -> bool: 

106 """Check if an origin is permitted for CORS requests. 

107 

108 Args: 

109 origin: The value of the Origin header from a CORS preflight request. 

110 

111 Returns: 

112 True if the origin is allowed, False otherwise. 

113 """ 

114 ... 

115 

116 def get_allowed_headers(self) -> list[str]: 

117 """Return the list of allowed request headers. 

118 

119 Returns: 

120 List of header names that clients are allowed to send. 

121 Common values: ["content-type", "authorization", "x-request-id"]. 

122 """ 

123 ... 

124 

125 def get_allowed_methods(self) -> list[str]: 

126 """Return the list of allowed HTTP methods. 

127 

128 Returns: 

129 List of HTTP methods clients are allowed to use. 

130 Typical: ["GET", "POST", "PUT", "DELETE", "PATCH", "OPTIONS"]. 

131 """ 

132 ... 

133 

134 def get_max_age(self) -> int: 

135 """Return the preflight cache duration in seconds. 

136 

137 Returns: 

138 Maximum time (in seconds) browsers should cache preflight 

139 responses. Typical values: 3600 (1 hour) to 86400 (24 hours). 

140 """ 

141 ... 

142 

143 

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] 

162 

163 

164@runtime_checkable 

165class BackgroundTaskRunnerProtocol(Protocol): 

166 """Protocol for in-process background task execution. 

167 

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 """ 

174 

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. 

182 

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 ... 

189 

190 

191@runtime_checkable 

192class CSRFProtectionProtocol(Protocol): 

193 """Protocol for CSRF (Cross-Site Request Forgery) protection. 

194 

195 Validates that state-modifying requests (POST, PUT, PATCH, DELETE) 

196 include a valid CSRF token that matches the session cookie. 

197 

198 This is essential for browser-based applications using session cookies. 

199 """ 

200 

201 def generate_token(self, session_id: str) -> str: 

202 """Generate a CSRF token for a session. 

203 

204 Args: 

205 session_id: Unique session identifier. 

206 

207 Returns: 

208 CSRF token string. 

209 """ 

210 ... 

211 

212 def validate_token(self, token: str, session_id: str) -> bool: 

213 """Validate a CSRF token against a session. 

214 

215 Args: 

216 token: CSRF token from request. 

217 session_id: Session identifier. 

218 

219 Returns: 

220 True if token is valid for the session. 

221 """ 

222 ... 

223 

224 def get_cookie_name(self) -> str: 

225 """Get the name of the CSRF cookie. 

226 

227 Returns: 

228 Cookie name (default: "csrf_token"). 

229 """ 

230 ... 

231 

232 def get_header_name(self) -> str: 

233 """Get the name of the CSRF header. 

234 

235 Returns: 

236 Header name (default: "X-CSRF-Token"). 

237 """ 

238 ... 

239 

240 

241@runtime_checkable 

242class WebRateLimiterProtocol(Protocol): 

243 """Protocol for web rate limiting. 

244 

245 Handles rate limiting for HTTP requests based on various scopes. 

246 """ 

247 

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. 

257 

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). 

263 

264 Raises: 

265 Exception: If rate limit exceeded. 

266 """ 

267 ... 

268 

269 

270@runtime_checkable 

271class WebMiddlewareProtocol(Protocol): 

272 """Protocol for HTTP web middleware. 

273 

274 WebMiddlewareProtocol intercepts HTTP requests and responses for cross-cutting 

275 concerns such as authentication, logging, CORS, and compression. 

276 

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. 

281 

282 Example:: 

283 

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 """ 

291 

292 async def __call__( 

293 self, 

294 request: Any, 

295 call_next: Callable[[Any], Awaitable[Any]], 

296 ) -> Any: 

297 """Process the HTTP request. 

298 

299 Args: 

300 request: Incoming HTTP request. 

301 call_next: Callable to invoke the next middleware/handler. 

302 

303 Returns: 

304 HTTP response. 

305 """ 

306 ... 

307 

308 

309@runtime_checkable 

310class ExceptionFilterProtocol(Protocol): 

311 """Protocol for exception handling filters. 

312 

313 Exception filters convert exceptions to HTTP responses. 

314 

315 Example: 

316 ```python 

317 class ValidationExceptionFilter: 

318 def can_handle(self, exc): 

319 return isinstance(exc, ValidationError) 

320 

321 def handle(self, exc, request): 

322 return JSONResponse( 

323 {"errors": exc.errors()}, 

324 status_code=422, 

325 ) 

326 ``` 

327 """ 

328 

329 def can_handle(self, exc: Exception) -> bool: 

330 """Check if this filter handles the exception. 

331 

332 Args: 

333 exc: Exception to check. 

334 

335 Returns: 

336 True if this filter can handle the exception. 

337 """ 

338 ... 

339 

340 def handle(self, exc: Exception, request: Any) -> Any: 

341 """Convert exception to a response. 

342 

343 Args: 

344 exc: Exception to handle. 

345 request: Original request. 

346 

347 Returns: 

348 HTTP response. 

349 """ 

350 ... 

351 

352 

353@runtime_checkable 

354class RequestProtocol(Protocol): 

355 """Protocol for HTTP requests.""" 

356 

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 

366 

367 async def json(self) -> Any: ... 

368 

369 async def body(self) -> bytes: ... 

370 

371 

372@runtime_checkable 

373class ResponseProtocol(Protocol): 

374 """Protocol for HTTP responses.""" 

375 

376 status_code: int 

377 headers: Any 

378 body: bytes 

379 media_type: str | None 

380 background: Any | None 

381 

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: ... 

394 

395 def delete_cookie( 

396 self, 

397 key: str, 

398 path: str = "/", 

399 domain: str | None = None, 

400 ) -> None: ... 

401 

402 

403@runtime_checkable 

404class ResponseFactoryProtocol(Protocol): 

405 """Protocol for creating HTTP responses. 

406 

407 Abstracts response creation to avoid hard dependencies on specific 

408 web frameworks (e.g., Starlette/FastAPI) in business logic or middleware. 

409 """ 

410 

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 ... 

419 

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 ... 

428 

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 ... 

437 

438 

439@runtime_checkable 

440class WebProviderProtocol(ProviderProtocol, Protocol): 

441 """Protocol for web providers that provide HTTP routing and middleware. 

442 

443 Web providers are responsible for setting up HTTP servers, routing, 

444 middleware, and request/response handling. 

445 """ 

446 

447 

448@runtime_checkable 

449class HTTPApplicationProtocol(Protocol): 

450 """Minimal protocol for an ASGI-compatible HTTP application. 

451 

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 """ 

457 

458 async def __call__( 

459 self, 

460 scope: dict[str, Any], 

461 receive: Any, 

462 send: Any, 

463 ) -> None: 

464 """ASGI callable entry-point. 

465 

466 Args: 

467 scope: ASGI connection scope. 

468 receive: ASGI receive channel callable. 

469 send: ASGI send channel callable. 

470 """ 

471 ... 

472 

473 def mount(self, path: str, app: HTTPApplicationProtocol) -> None: 

474 """Mount a sub-application at the given path prefix. 

475 

476 Args: 

477 path: URL path prefix (e.g. ``"/api/v2"``). 

478 app: The application to mount. 

479 """ 

480 ... 

481 

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. 

489 

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 ... 

496 

497 def add_middleware(self, middleware: Any) -> None: 

498 """Register a middleware layer. 

499 

500 Middleware is applied in reverse registration order (last registered 

501 is the innermost layer). 

502 

503 Args: 

504 middleware: A middleware class or instance. 

505 """ 

506 ... 

507 

508 

509@runtime_checkable 

510class CRUDServiceProtocol(Protocol[T]): 

511 """Protocol for services that implement basic CRUD operations.""" 

512 

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 ... 

518 

519 async def get(self, item_id: Any) -> Result[T | None, DomainError]: 

520 """Get single item by ID.""" 

521 ... 

522 

523 async def create(self, data: dict[str, Any]) -> Result[T, DomainError]: 

524 """Create new item.""" 

525 ... 

526 

527 async def update( 

528 self, item_id: Any, data: dict[str, Any] 

529 ) -> Result[T | None, DomainError]: 

530 """Update existing item.""" 

531 ... 

532 

533 async def delete(self, item_id: Any) -> Result[bool, DomainError]: 

534 """Delete item.""" 

535 ... 

536 

537 

538@runtime_checkable 

539class ConnectionManagerProtocol(Protocol[C]): # type: ignore[misc] 

540 """Protocol for connection managers that track and broadcast to clients. 

541 

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 """ 

546 

547 async def add(self, connection: C) -> None: 

548 """Register a new connection.""" 

549 ... 

550 

551 async def remove(self, connection: C) -> None: 

552 """Unregister a connection.""" 

553 ... 

554 

555 async def broadcast(self, message: Any, exclude: C | None = None) -> None: 

556 """Send a message to all tracked connections.""" 

557 ... 

558 

559 @property 

560 def count(self) -> int: 

561 """Return the number of active connections.""" 

562 ... 

563 

564 

565@runtime_checkable 

566class WebContributorProtocol(Protocol): 

567 """Protocol for packages that contribute controllers and middleware. 

568 

569 Extension packages can also mount sub-applications via the mount_to_app hook. 

570 

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. 

576 

577 Example: 

578 ```python 

579 class GraphQLWebContributor: 

580 @property 

581 def contributor_id(self) -> str: 

582 return "graphql" 

583 

584 def get_controllers(self) -> list[type]: 

585 return [GraphQLController] 

586 

587 def get_middleware(self) -> list[type]: 

588 return [] 

589 

590 async def mount_to_app( 

591 self, app: HTTPApplicationProtocol, container: object 

592 ) -> None: 

593 pass # No-op for controller-only contributors 

594 ``` 

595 """ 

596 

597 @property 

598 def contributor_id(self) -> str: 

599 """Unique contributor identifier such as ``graphql`` or ``admin``. 

600 

601 Returns: 

602 A string identifier for this contributor, used for tracking 

603 and debugging. Must be unique across all registered contributors. 

604 """ 

605 ... 

606 

607 def get_controllers(self) -> list[type[Any]]: 

608 """Return controller classes contributed by the package. 

609 

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 ... 

616 

617 def get_middleware(self) -> list[type[Any]]: 

618 """Return middleware classes contributed by the package. 

619 

620 Returns: 

621 List of middleware classes to register with the web provider. 

622 Middleware is applied in registration order. 

623 """ 

624 ... 

625 

626 async def mount_to_app(self, app: Any, container: object) -> None: 

627 """Mount sub-applications or additional routes to the web app. 

628 

629 Called during route setup phase after metrics and debug routes 

630 but before static files and controller discovery. 

631 

632 Args: 

633 app: The ASGI application (typically Starlette) to mount routes on. 

634 container: The DI container for resolving dependencies. 

635 

636 Note: 

637 Controller-only contributors should implement this as a no-op. 

638 """