Coverage for src/lexigram/web/middleware/request_context.py: 21%
52 statements
« prev ^ index » next coverage.py v7.15.4, created at 2026-08-25 04:37 +0800
« prev ^ index » next coverage.py v7.15.4, created at 2026-08-25 04:37 +0800
1"""Request context middleware for Lexigram Framework.
3Consolidates request ID generation, context variable population,
4tracing propagation, and structlog binding into a single middleware.
6If ``RequestIDMiddleware`` has already set a request ID in the scope,
7this middleware reuses it. Otherwise it generates or extracts one from
8the configured request ID header.
9"""
11from __future__ import annotations
13from typing import Any
14import uuid
16from starlette.types import ASGIApp, Receive, Scope, Send
18from lexigram.contracts.core.identity import IdGeneratorProtocol
19from lexigram.primitives.context import (
20 Context,
21 ContextVarRegistry,
22 RequestContext,
23 create_default_context,
24)
25from lexigram.web.middleware.tracing import load_trace_to_context
28class RequestContextMiddleware:
29 """ASGI middleware that populates global request context."""
31 def __init__(
32 self,
33 app: ASGIApp,
34 context: Context | None = None,
35 registry: ContextVarRegistry | None = None,
36 header_name: str = "X-Request-ID",
37 tenant_header_name: str = "X-Tenant-ID",
38 ids: IdGeneratorProtocol | None = None,
39 ) -> None:
40 self.app = app
41 self._context = context or create_default_context()
42 self._registry = registry or self._context.registry
43 self.header_name = header_name.lower().encode()
44 self.tenant_header_name = tenant_header_name.lower().encode()
45 self._ids = ids
47 def _resolve_tenant_id(
48 self, scope: Scope, headers: dict[bytes, bytes]
49 ) -> str | None:
50 state = scope.get("state", {})
51 tenant_id = state.get("tenant_id")
52 if tenant_id is not None:
53 return str(tenant_id)
55 tenant_header = headers.get(self.tenant_header_name)
56 return tenant_header.decode() if tenant_header else None
58 async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None:
59 if scope["type"] != "http":
60 await self.app(scope, receive, send)
61 return
63 headers = dict(scope.get("headers", []))
64 state = scope.setdefault("state", {})
66 request_id = state.get("request_id")
67 if not request_id:
68 request_id_bytes = headers.get(self.header_name)
69 if request_id_bytes:
70 request_id = request_id_bytes.decode()
71 else:
72 request_id = f"req_{self._ids.generate() if self._ids else uuid.uuid4().hex[:12]}"
74 method = scope.get("method")
75 path = scope.get("path")
76 user_id = state.get("user_id")
77 tenant_id = self._resolve_tenant_id(scope, headers)
79 ctx = RequestContext(
80 self._registry,
81 request_id=request_id,
82 method=method,
83 path=path,
84 user_id=str(user_id) if user_id is not None else None,
85 tenant_id=tenant_id,
86 )
88 traceparent = headers.get(b"traceparent")
89 if traceparent:
90 load_trace_to_context(ctx, traceparent.decode())
92 async def send_wrapper(message: Any) -> None:
93 if message["type"] == "http.response.start":
94 msg_headers = list(message.get("headers", []))
95 msg_headers.append((self.header_name, request_id.encode()))
96 message["headers"] = msg_headers
97 await send(message)
99 with ctx as active_request_context:
100 state["request_id"] = request_id
101 state["request_context"] = active_request_context
102 await self.app(scope, receive, send_wrapper)