Coverage for src/lexigram/web/routing/caching.py: 20%
80 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"""HTTP caching response decorators.
3Provides declarative decorators for setting HTTP cache-control semantics
4and ETag-based conditional request handling.
6Usage::
8 from lexigram.web.routing.caching import cache_control, etag
10 class ArticleController(Controller):
12 @get("/{id}")
13 @cache_control(max_age=3600, public=True)
14 async def get_article(self, id: str) -> ArticleResponse:
15 ...
17 @get("/{id}/preview")
18 @etag
19 async def get_preview(self, id: str) -> ArticleResponse:
20 # Framework auto-generates ETag from response body and
21 # returns 304 Not Modified if If-None-Match matches.
22 ...
23"""
25from __future__ import annotations
27from collections.abc import Callable
28import hashlib
29from typing import Any, TypeVar
31from starlette.requests import Request
32from starlette.responses import Response
34from lexigram.logging import get_logger
36logger = get_logger(__name__)
38F = TypeVar("F", bound=Callable[..., Any])
40# Sentinel attribute written by decorators so ResponseSerializer can inspect
41_CACHE_CONTROL_ATTR = "__http_cache_control__"
42_ETAG_ATTR = "__http_etag__"
45# ---------------------------------------------------------------------------
46# @cache_control
47# ---------------------------------------------------------------------------
50def cache_control(
51 *,
52 max_age: int | None = None,
53 s_maxage: int | None = None,
54 public: bool = False,
55 private: bool = False,
56 no_cache: bool = False,
57 no_store: bool = False,
58 must_revalidate: bool = False,
59 immutable: bool = False,
60 stale_while_revalidate: int | None = None,
61) -> Callable[[F], F]:
62 """Declaratively set ``Cache-Control`` headers on a controller handler.
64 The generated ``Cache-Control`` value is written to the response by the
65 ``RequestPipeline`` after handler execution. When applied, it wraps
66 the handler and patches whatever ``Response`` is returned.
68 Args:
69 max_age: ``max-age`` in seconds.
70 s_maxage: ``s-maxage`` in seconds (shared/CDN caches).
71 public: Mark the response as publicly cacheable.
72 private: Mark the response as private (per-user).
73 no_cache: Force revalidation before serving from cache.
74 no_store: Disallow any caching whatsoever.
75 must_revalidate: Require revalidation when stale.
76 immutable: Hint that the response will never change.
77 stale_while_revalidate: Seconds to serve stale while revalidating.
79 Example::
81 @get("/articles")
82 @cache_control(max_age=60, public=True)
83 async def list_articles(self) -> list[ArticleDTO]:
84 ...
85 """
86 directives: list[str] = []
87 if public:
88 directives.append("public")
89 if private:
90 directives.append("private")
91 if no_store:
92 directives.append("no-store")
93 if no_cache:
94 directives.append("no-cache")
95 if must_revalidate:
96 directives.append("must-revalidate")
97 if immutable:
98 directives.append("immutable")
99 if max_age is not None:
100 directives.append(f"max-age={max_age}")
101 if s_maxage is not None:
102 directives.append(f"s-maxage={s_maxage}")
103 if stale_while_revalidate is not None:
104 directives.append(f"stale-while-revalidate={stale_while_revalidate}")
106 header_value = ", ".join(directives) if directives else "no-cache"
108 def decorator(fn: F) -> F:
109 import functools
111 @functools.wraps(fn)
112 async def wrapper(*args: Any, **kwargs: Any) -> Any:
113 result = await fn(*args, **kwargs)
114 if isinstance(result, Response):
115 result.headers["Cache-Control"] = header_value
116 return result
118 wrapper.__http_cache_control__ = header_value # type: ignore[attr-defined]
119 return wrapper # type: ignore[return-value]
121 return decorator
124# ---------------------------------------------------------------------------
125# @etag
126# ---------------------------------------------------------------------------
129def etag(fn: F) -> F:
130 """Auto-generate and validate ETag headers for a controller handler.
132 When applied, the response body is hashed (MD5) to produce a weak ETag.
133 If the client sends ``If-None-Match`` and it matches the computed ETag,
134 the response is replaced with a ``304 Not Modified`` with no body.
136 Supports both strong and weak ETags (weak is the default).
138 Args:
139 fn: The (async) handler method to decorate.
141 Example::
143 @get("/{id}")
144 @etag
145 async def get_article(self, id: str) -> ArticleResponse:
146 ...
147 """
148 import functools
150 @functools.wraps(fn)
151 async def wrapper(*args: Any, **kwargs: Any) -> Any:
152 result = await fn(*args, **kwargs)
154 # Only decorate Response objects — if the handler returned raw data
155 # it will be serialized later; skip ETag injection here
156 if not isinstance(result, Response):
157 return result
159 # Generate weak ETag from response body
160 body: bytes | bytearray = bytes(result.body) if hasattr(result, "body") else b""
161 etag_value = f'W/"{hashlib.md5(body, usedforsecurity=False).hexdigest()}"'
163 result.headers["ETag"] = etag_value
165 # Check If-None-Match — requires request in scope
166 # Starlette injects `request` as the first positional argument when
167 # the handler is a controller method. We inspect args to locate it.
168 request = _find_request(args, kwargs)
169 if request is not None:
170 client_etag = request.headers.get("If-None-Match", "")
171 if client_etag and _etag_matches(client_etag, etag_value):
172 return Response(status_code=304, headers={"ETag": etag_value})
174 return result
176 wrapper.__http_etag__ = True # type: ignore[attr-defined]
177 return wrapper # type: ignore[return-value]
180def _find_request(args: tuple[Any, ...], kwargs: dict[str, Any]) -> Request | None:
181 """Locate the Starlette Request object inside handler arguments."""
182 for arg in args:
183 if isinstance(arg, Request):
184 return arg
185 for val in kwargs.values():
186 if isinstance(val, Request):
187 return val
188 return None
191def _etag_matches(client_header: str, server_etag: str) -> bool:
192 """Return True if the client's If-None-Match header matches the server ETag.
194 Handles ``*`` (match-all) and comma-separated lists of tags.
195 """
196 if client_header.strip() == "*":
197 return True
198 client_tags = {tag.strip() for tag in client_header.split(",")}
199 # Compare both weak and strong form
200 bare = server_etag.strip('"').lstrip("W/").strip('"')
201 for tag in client_tags:
202 tag_bare = tag.strip('"').lstrip("W/").strip('"')
203 if tag_bare == bare:
204 return True
205 return False
208__all__ = ["cache_control", "etag"]