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

1"""Request context middleware for Lexigram Framework. 

2 

3Consolidates request ID generation, context variable population, 

4tracing propagation, and structlog binding into a single middleware. 

5 

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

10 

11from __future__ import annotations 

12 

13from typing import Any 

14import uuid 

15 

16from starlette.types import ASGIApp, Receive, Scope, Send 

17 

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 

26 

27 

28class RequestContextMiddleware: 

29 """ASGI middleware that populates global request context.""" 

30 

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 

46 

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) 

54 

55 tenant_header = headers.get(self.tenant_header_name) 

56 return tenant_header.decode() if tenant_header else None 

57 

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 

62 

63 headers = dict(scope.get("headers", [])) 

64 state = scope.setdefault("state", {}) 

65 

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

73 

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) 

78 

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 ) 

87 

88 traceparent = headers.get(b"traceparent") 

89 if traceparent: 

90 load_trace_to_context(ctx, traceparent.decode()) 

91 

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) 

98 

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)