Coverage for src/lexigram/admin/middleware/cache.py: 0%
122 statements
« prev ^ index » next coverage.py v7.15.4, created at 2026-08-24 23:18 +0800
« prev ^ index » next coverage.py v7.15.4, created at 2026-08-24 23:18 +0800
1"""Caching middleware for Lexigram Admin."""
3from __future__ import annotations
5import re
6from typing import Any
8from starlette.types import ASGIApp, Receive, Scope, Send
10from lexigram.contracts.infra.cache import CacheBackendProtocol
11from lexigram.di.decorators import inject
12from lexigram.logging import get_logger
14_log = get_logger(__name__)
17@inject
18class AdminCacheMiddleware:
19 """Pure ASGI middleware for response caching backed by ``CacheBackendProtocol``.
21 10x faster than BaseHTTPMiddleware — no task-creation overhead.
23 Caches successful GET responses keyed by path, query string, and role.
24 TTL is determined by the response ``Cache-Control: max-age`` header; the
25 ``ttl`` constructor argument is used as the default when no header is
26 present. The backend manages expiration — no manual timestamp tracking.
28 Args:
29 app: ASGI application.
30 cache_backend: Cache backend for response storage. Required for caching.
31 ttl: Default cache TTL in seconds.
32 config: Optional ``CacheConfig`` object.
33 settings_service: Optional ``SettingsService`` for runtime overrides.
34 """
36 def __init__(
37 self,
38 app: ASGIApp,
39 cache_backend: CacheBackendProtocol | None = None,
40 ttl: int = 60,
41 config: Any = None,
42 settings_service: Any = None,
43 ) -> None:
44 self.app = app
45 self.settings_service = settings_service
46 self._backend: CacheBackendProtocol | None = cache_backend
48 if config:
49 self.enabled = getattr(config, "enabled", True)
51 val = getattr(config, "default_ttl", None)
52 if val is None and hasattr(config, "get_default_backend"):
53 backend_cfg = config.get_default_backend()
54 if backend_cfg:
55 val = backend_cfg.default_ttl
57 self.ttl = val if val is not None else ttl
58 self.excluded_paths = getattr(config, "excluded_paths", [])
59 else:
60 self.enabled = True
61 self.ttl = ttl
62 self.excluded_paths = []
64 if self._backend is None:
65 self.enabled = False
67 self._exclusion_patterns = [
68 re.compile(p.replace("*", ".*")) for p in self.excluded_paths
69 ]
71 def _get_cache_key(self, scope: Scope) -> str:
72 """Generate cache key from request scope, namespaced by user."""
73 path = scope.get("path", "")
74 query = scope.get("query_string", b"").decode()
75 state = scope.get("state", {}) or {}
76 user = state.get("user", None)
77 identity = "guest"
78 if user is not None:
79 user_id = getattr(user, "user_id", None) or getattr(user, "id", None)
80 if user_id:
81 identity = f"user:{user_id}"
82 return f"admin:resp:{path}:{query}:{identity}"
84 async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None:
85 """Pure ASGI middleware implementation."""
86 enabled = self.enabled
87 ttl = self.ttl
89 if self.settings_service:
90 try:
91 val = await self.settings_service.get(
92 "admin.cache.enabled", self.enabled
93 )
94 if val is not None:
95 enabled = bool(val)
96 val = await self.settings_service.get(
97 "admin.cache.default_ttl", self.ttl
98 )
99 if val is not None:
100 ttl = int(val)
101 except (RuntimeError, ValueError, OSError) as exc:
102 _log.warning(
103 "admin.cache_middleware.settings_error",
104 error=str(exc),
105 )
107 if not enabled:
108 await self.app(scope, receive, send)
109 return
110 if self._backend is None:
111 await self.app(scope, receive, send)
112 return
114 if scope["type"] != "http":
115 await self.app(scope, receive, send)
116 return
118 path = scope.get("path", "")
119 for pattern in self._exclusion_patterns:
120 if pattern.match(path):
121 await self.app(scope, receive, send)
122 return
124 if scope.get("method", "") != "GET":
125 await self.app(scope, receive, send)
126 return
128 headers = dict(scope.get("headers", []))
129 if headers.get(b"cache-control", b"").decode() == "no-cache":
130 await self.app(scope, receive, send)
131 return
133 key = self._get_cache_key(scope)
135 # Cache lookup — backend manages TTL expiration.
136 res = await self._backend.get(key)
137 if res.is_ok():
138 cached = res.unwrap()
139 if cached is not None:
140 await send(
141 {
142 "type": "http.response.start",
143 "status": cached["status_code"],
144 "headers": cached["headers"],
145 },
146 )
147 await send({"type": "http.response.body", "body": cached["body"]})
148 return
150 # Capture response from upstream application.
151 response_started = False
152 response_status = 200
153 response_headers: list[Any] = []
154 response_body = b""
156 async def send_with_caching(message: dict[str, Any]) -> None:
157 nonlocal response_started, response_status, response_headers, response_body
159 if message["type"] == "http.response.start":
160 response_started = True
161 response_status = message.get("status", 200)
162 response_headers = list(message.get("headers", []))
164 elif message["type"] == "http.response.body":
165 if not response_started:
166 return
168 response_body += message.get("body", b"")
170 if not message.get("more_body", False):
171 await self._cache_and_send_response(
172 send,
173 response_status,
174 response_headers,
175 response_body,
176 key,
177 ttl,
178 )
179 else:
180 await send(message)
182 await self.app(scope, receive, send_with_caching) # type: ignore[arg-type]
184 async def _cache_and_send_response(
185 self,
186 send: Send,
187 status: int,
188 headers: list[Any],
189 body: bytes,
190 cache_key: str,
191 default_ttl: int,
192 ) -> None:
193 """Cache a 200 response (respecting Cache-Control) then forward it."""
194 if status == 200:
195 effective_ttl = default_ttl
196 cache_control = ""
197 for hkey, hval in headers:
198 if hkey == b"cache-control":
199 cache_control = hval.decode()
200 break
202 if "max-age=" in cache_control:
203 try:
204 for part in (p.strip() for p in cache_control.split(",")):
205 if part.startswith("max-age="):
206 effective_ttl = int(part.split("=")[1])
207 break
208 except (ValueError, IndexError):
209 pass
211 if "no-store" not in cache_control:
212 payload = {
213 "status_code": status,
214 "headers": headers,
215 "body": body,
216 }
217 store_res = await self._backend.set(cache_key, payload, effective_ttl) # type: ignore[union-attr]
218 if not store_res.is_ok():
219 _log.warning(
220 "admin.cache_middleware.store_failed",
221 key=cache_key,
222 error=str(store_res.unwrap_err()),
223 )
225 await send(
226 {"type": "http.response.start", "status": status, "headers": headers}
227 )
228 await send({"type": "http.response.body", "body": body})