Coverage for src/lexigram/web/middleware/stack.py: 36%

22 statements  

« prev     ^ index     » next       coverage.py v7.15.4, created at 2026-08-25 04:37 +0800

1"""Default middleware stack for Lexigram Web. 

2 

3Provides :class:`DefaultMiddlewareStack` — an explicit, named class that 

4encapsulates the minimum set of Starlette-compatible middlewares applied by 

5every Lexigram web application. 

6 

7Users can import this class to inspect, extend, or reproduce the default 

8middleware stack without relying on opaque ``WebProvider`` internals. 

9``WebProvider`` uses this class internally for DRY consistency. 

10 

11Example:: 

12 

13 from lexigram.web.middleware import DefaultMiddlewareStack 

14 

15 stack = DefaultMiddlewareStack(container=container) 

16 middlewares = stack.build() 

17 # pass to Starlette(middleware=middlewares, ...) 

18""" 

19 

20from __future__ import annotations 

21 

22from typing import TYPE_CHECKING, Any 

23 

24from lexigram.logging import get_logger 

25 

26if TYPE_CHECKING: 

27 from starlette.middleware import Middleware as StarletteMiddleware 

28 from starlette.types import ASGIApp 

29 

30 from lexigram.contracts.core.di import ContainerResolverProtocol 

31 

32logger = get_logger(__name__) 

33 

34 

35class DefaultMiddlewareStack: 

36 """Builds the default Lexigram middleware stack as an explicit, composable list. 

37 

38 Encapsulates the minimum set of middlewares that every Lexigram web 

39 application applies so users can see and extend it without magic. 

40 

41 :class:`~lexigram.web.di.provider.WebProvider` delegates to this class 

42 internally, ensuring that all code paths produce the same default stack. 

43 

44 Args: 

45 container: The resolved DI container. Required for 

46 :class:`~lexigram.web.middleware.di_scope.DIScopeMiddleware` which 

47 provides request-scoped dependency resolution. 

48 extra_middlewares: Additional user-supplied Lexigram middlewares to 

49 include in the stack. They are adapted to Starlette 

50 ``Middleware`` wrappers via 

51 :class:`~lexigram.web.middleware.registry.MiddlewareAdapterRegistry` 

52 and inserted before the built-in defaults. 

53 

54 Example:: 

55 

56 from lexigram.web.middleware import DefaultMiddlewareStack 

57 from lexigram.logging import get_logger 

58 

59 logger = get_logger(__name__) 

60 

61 # Inspect the default stack 

62 stack = DefaultMiddlewareStack(container=container) 

63 for mw in stack.build(): 

64 logger.debug("middleware", name=mw.cls.__name__) 

65 

66 # Extend with custom middleware 

67 stack = DefaultMiddlewareStack( 

68 container=container, 

69 extra_middlewares=[MyTimingMiddleware()], 

70 ) 

71 """ 

72 

73 def __init__( 

74 self, 

75 container: ContainerResolverProtocol | None = None, 

76 extra_middlewares: list[Any] | None = None, 

77 ) -> None: 

78 """Initialise the stack builder. 

79 

80 Args: 

81 container: Resolved DI container for ``DIScopeMiddleware``. 

82 extra_middlewares: Optional extra Lexigram middlewares to include. 

83 """ 

84 self._container = container 

85 self._extra_middlewares: list[Any] = list(extra_middlewares or []) 

86 

87 def build(self) -> list[StarletteMiddleware]: 

88 """Build and return the ordered list of default Starlette middlewares. 

89 

90 The always-present entry is 

91 :class:`~lexigram.web.middleware.di_scope.DIScopeMiddleware`. 

92 Any ``extra_middlewares`` supplied at construction time are prepended 

93 to that entry after adaptation. 

94 

95 Returns: 

96 An ordered list of :class:`starlette.middleware.Middleware` 

97 instances ready to pass to 

98 :class:`starlette.applications.Starlette`. 

99 """ 

100 from typing import cast 

101 

102 from lexigram.web.middleware.di_scope import DIScopeMiddleware 

103 from lexigram.web.middleware.registry import MiddlewareAdapterRegistry 

104 

105 registry = MiddlewareAdapterRegistry() 

106 

107 # Start from user-supplied extras (order preserved) 

108 middlewares: list[Any] = list(self._extra_middlewares) 

109 

110 # Always ensure DIScopeMiddleware is present for request-scoped DI 

111 has_scope_mw = any(isinstance(mw, DIScopeMiddleware) for mw in middlewares) 

112 if not has_scope_mw and self._container is not None: 

113 middlewares.insert( 

114 0, 

115 DIScopeMiddleware(cast("ASGIApp", None), self._container), # type: ignore[arg-type] 

116 ) 

117 logger.debug("default_middleware_stack.added_di_scope") 

118 

119 adapted = [registry.adapt(mw) for mw in middlewares] 

120 logger.debug("default_middleware_stack.built", count=len(adapted)) 

121 return adapted 

122 

123 

124__all__ = ["DefaultMiddlewareStack"]