Caching & Conditional Requests
Server-side response caching and HTTP conditional-request handling. Import from fastapi_views.cache, which exports Cache, cache, CacheControl, CacheHeaders, CacheMiddleware, CachedAPIView, ConditionalCachedAPIView and use_cache.
The Redis backend requires the cache extra: pip install "fastapi-views[cache]".
For a walkthrough see Caching & Conditional Requests.
Views and decorator
CacheControl
dataclass
Builder for a Cache-Control response header value.
Pass to :func:use_cache instead of a raw string to compose directives
safely. private keeps a per-user/per-tenant response out of shared
caches; s_maxage sets a separate freshness lifetime for them.
Boolean fields render as bare directives (no-store); int fields render
as name=value (max-age=30); None / False are omitted. Field
names map to directives by replacing _ with -.
Source code in fastapi_views/cache/view.py
| @dataclass(frozen=True)
class CacheControl:
"""Builder for a ``Cache-Control`` response header value.
Pass to :func:`use_cache` instead of a raw string to compose directives
safely. ``private`` keeps a per-user/per-tenant response out of shared
caches; ``s_maxage`` sets a separate freshness lifetime for them.
Boolean fields render as bare directives (``no-store``); int fields render
as ``name=value`` (``max-age=30``); ``None`` / ``False`` are omitted. Field
names map to directives by replacing ``_`` with ``-``.
"""
no_store: bool = False
no_cache: bool = False
public: bool = False
private: bool = False
max_age: int | None = None
s_maxage: int | None = None
must_revalidate: bool = False
immutable: bool = False
stale_while_revalidate: int | None = None
stale_if_error: int | None = None
def render(self) -> str:
"""Render the directives into a header value, in declaration order."""
directives: list[str] = []
for field in fields(self):
value = getattr(self, field.name)
if value is None or value is False:
continue
name = field.name.replace("_", "-")
directives.append(name if value is True else f"{name}={value}")
return ", ".join(directives)
|
render() -> str
Render the directives into a header value, in declaration order.
Source code in fastapi_views/cache/view.py
| def render(self) -> str:
"""Render the directives into a header value, in declaration order."""
directives: list[str] = []
for field in fields(self):
value = getattr(self, field.name)
if value is None or value is False:
continue
name = field.name.replace("_", "-")
directives.append(name if value is True else f"{name}={value}")
return ", ".join(directives)
|
Bases: ResponseHeaders
Cache-specific response headers documented for cached endpoints.
The ETag / Last-Modified validators are not declared here; they are
contributed dynamically by :class:~fastapi_views.views.mixins.ConditionalMixin
only when the view actually emits them.
Source code in fastapi_views/cache/view.py
| class CacheHeaders(ResponseHeaders):
"""Cache-specific response headers documented for cached endpoints.
The ``ETag`` / ``Last-Modified`` validators are not declared here; they are
contributed dynamically by :class:`~fastapi_views.views.mixins.ConditionalMixin`
only when the view actually emits them.
"""
x_cache: Literal["HIT", "MISS"] = Field(
alias="X-Cache", description="Whether the response was served from cache"
)
cache_control: str | None = Field(
default=None, alias="Cache-Control", description="Cache-control directive"
)
vary: str | None = Field(
default=None,
alias="Vary",
description="Request headers the cached response varies on",
)
|
CachedAPIView
Bases: APIView
APIView whose endpoints can cache their serialised responses.
Override :meth:build_key to control the cache key, then decorate the
relevant endpoint with :func:use_cache. The cache backend is supplied at
the application level via :class:~fastapi_views.cache.middleware.CacheMiddleware::
app.add_middleware(CacheMiddleware, backend=RedisCache(...))
class ItemView(CachedAPIView, AsyncRetrieveAPIView):
cache_key_headers = ["X-Tenant-Id"]
@use_cache(ttl=60)
async def retrieve(self, id: UUID) -> Item: ...
This view caches only; it does not handle conditional requests. Use
:class:ConditionalCachedAPIView to also revalidate with ETag /
Last-Modified and answer 304 Not Modified.
Source code in fastapi_views/cache/view.py
| class CachedAPIView(APIView):
"""``APIView`` whose endpoints can cache their serialised responses.
Override :meth:`build_key` to control the cache key, then decorate the
relevant endpoint with :func:`use_cache`. The cache backend is supplied at
the application level via :class:`~fastapi_views.cache.middleware.CacheMiddleware`::
app.add_middleware(CacheMiddleware, backend=RedisCache(...))
class ItemView(CachedAPIView, AsyncRetrieveAPIView):
cache_key_headers = ["X-Tenant-Id"]
@use_cache(ttl=60)
async def retrieve(self, id: UUID) -> Item: ...
This view caches only; it does not handle conditional requests. Use
:class:`ConditionalCachedAPIView` to also revalidate with ``ETag`` /
``Last-Modified`` and answer ``304 Not Modified``.
"""
cache_key_headers: ClassVar[Sequence[str]] = ()
vary: ClassVar[Sequence[str]] = ()
@classmethod
def get_response_headers(
cls, action: Action | None = None
) -> type[ResponseHeaders] | None:
if action in ("retrieve", "list"):
return CacheHeaders
return None
@property
def cache(self) -> Cache:
return cache
def build_key(self) -> str:
"""Cache key for the current request.
Query parameters are sorted for a stable key regardless of ordering.
Headers listed in :attr:`cache_key_headers` are appended to the key.
Override for custom schemes.
"""
request = self.request
path = request.url.path
query = urlencode(sorted(parse_qsl(request.url.query)))
parts = [f"{path}?{query}" if query else path]
parts.extend(
f"{name}={value}"
for name in self.cache_key_headers
if (value := request.headers.get(name.lower()))
)
return hashlib.md5("|".join(parts).encode(), usedforsecurity=False).hexdigest()
def get_vary_headers(self) -> list[str]:
"""Request header names the cached response varies on.
Combines :attr:`cache_key_headers` (which key the server-side cache) with
:attr:`vary`, so downstream caches key on at least the same headers and
cannot serve one client's response to another. Names are de-duplicated
case-insensitively, preserving declaration order.
"""
seen: set[str] = set()
names: list[str] = []
for name in (*self.cache_key_headers, *self.vary):
lowered = name.lower()
if lowered not in seen:
seen.add(lowered)
names.append(name)
return names
def get_cache_headers(
self,
*,
hit: bool,
ttl: int | None,
cache_control: str | CacheControl | None,
) -> dict[str, str]:
"""Build the cache-related response headers (``X-Cache`` / ``Cache-Control`` / ``Vary``)."""
cache_headers: dict[str, str] = {"X-Cache": "HIT" if hit else "MISS"}
if isinstance(cache_control, CacheControl):
# ``ttl`` provides the default freshness when not set explicitly.
if cache_control.max_age is None and ttl is not None:
cache_control = replace(cache_control, max_age=ttl)
directive = cache_control.render()
elif cache_control is not None:
directive = cache_control
elif ttl is not None:
directive = f"max-age={ttl}"
else:
directive = None
if directive:
cache_headers["cache-control"] = directive
vary = self.get_vary_headers()
if vary:
cache_headers["Vary"] = ", ".join(vary)
return cache_headers
|
build_key() -> str
Cache key for the current request.
Query parameters are sorted for a stable key regardless of ordering.
Headers listed in :attr:cache_key_headers are appended to the key.
Override for custom schemes.
Source code in fastapi_views/cache/view.py
| def build_key(self) -> str:
"""Cache key for the current request.
Query parameters are sorted for a stable key regardless of ordering.
Headers listed in :attr:`cache_key_headers` are appended to the key.
Override for custom schemes.
"""
request = self.request
path = request.url.path
query = urlencode(sorted(parse_qsl(request.url.query)))
parts = [f"{path}?{query}" if query else path]
parts.extend(
f"{name}={value}"
for name in self.cache_key_headers
if (value := request.headers.get(name.lower()))
)
return hashlib.md5("|".join(parts).encode(), usedforsecurity=False).hexdigest()
|
Request header names the cached response varies on.
Combines :attr:cache_key_headers (which key the server-side cache) with
:attr:vary, so downstream caches key on at least the same headers and
cannot serve one client's response to another. Names are de-duplicated
case-insensitively, preserving declaration order.
Source code in fastapi_views/cache/view.py
| def get_vary_headers(self) -> list[str]:
"""Request header names the cached response varies on.
Combines :attr:`cache_key_headers` (which key the server-side cache) with
:attr:`vary`, so downstream caches key on at least the same headers and
cannot serve one client's response to another. Names are de-duplicated
case-insensitively, preserving declaration order.
"""
seen: set[str] = set()
names: list[str] = []
for name in (*self.cache_key_headers, *self.vary):
lowered = name.lower()
if lowered not in seen:
seen.add(lowered)
names.append(name)
return names
|
Build the cache-related response headers (X-Cache / Cache-Control / Vary).
Source code in fastapi_views/cache/view.py
| def get_cache_headers(
self,
*,
hit: bool,
ttl: int | None,
cache_control: str | CacheControl | None,
) -> dict[str, str]:
"""Build the cache-related response headers (``X-Cache`` / ``Cache-Control`` / ``Vary``)."""
cache_headers: dict[str, str] = {"X-Cache": "HIT" if hit else "MISS"}
if isinstance(cache_control, CacheControl):
# ``ttl`` provides the default freshness when not set explicitly.
if cache_control.max_age is None and ttl is not None:
cache_control = replace(cache_control, max_age=ttl)
directive = cache_control.render()
elif cache_control is not None:
directive = cache_control
elif ttl is not None:
directive = f"max-age={ttl}"
else:
directive = None
if directive:
cache_headers["cache-control"] = directive
vary = self.get_vary_headers()
if vary:
cache_headers["Vary"] = ", ".join(vary)
return cache_headers
|
ConditionalCachedAPIView
Bases: ConditionalMixin, CachedAPIView
:class:CachedAPIView that also handles conditional requests.
Combines server-side caching with ETag / Last-Modified revalidation:
a cache hit can be downgraded to 304 Not Modified when the client is
current. Opt into validators exactly as on
:class:~fastapi_views.views.mixins.ConditionalMixin (etag = True,
last_modified = True, or the manual check_* / not_modified
helpers)::
class ItemView(ConditionalCachedAPIView, AsyncRetrieveAPIView):
etag = True
@use_cache(ttl=60)
async def retrieve(self, id: UUID) -> Item: ...
A miss whose response is downgraded to 304 still populates the cache
with the full body, so revalidating clients warm the cache for everyone
instead of re-running the view on every request.
Source code in fastapi_views/cache/view.py
| class ConditionalCachedAPIView(ConditionalMixin, CachedAPIView):
""":class:`CachedAPIView` that also handles conditional requests.
Combines server-side caching with ``ETag`` / ``Last-Modified`` revalidation:
a cache hit can be downgraded to ``304 Not Modified`` when the client is
current. Opt into validators exactly as on
:class:`~fastapi_views.views.mixins.ConditionalMixin` (``etag = True``,
``last_modified = True``, or the manual ``check_*`` / ``not_modified``
helpers)::
class ItemView(ConditionalCachedAPIView, AsyncRetrieveAPIView):
etag = True
@use_cache(ttl=60)
async def retrieve(self, id: UUID) -> Item: ...
A miss whose response is downgraded to ``304`` still populates the cache
with the full body, so revalidating clients warm the cache for everyone
instead of re-running the view on every request.
"""
def finalize_response(self, response: Response) -> Response:
"""Hand the full body to the cache before any ``304`` downgrade.
:meth:`~fastapi_views.views.mixins.ConditionalMixin.make_conditional`
replaces a successful response with an empty ``304`` when the client is
current, leaving the middleware nothing to persist. Recording the
pre-downgrade body on the request's cache context keeps the write in the
middleware (a single write per miss) while caching the representation
the next non-conditional request should be served.
"""
ctx: _CacheContext | None = self.request.scope.get(_CACHE_SCOPE_KEY)
if (
ctx is not None
and isinstance(response.body, bytes)
and response.status_code < _STATUS_CACHEABLE_MAX
):
ctx.body = response.body
return super().finalize_response(response)
|
finalize_response(response: Response) -> Response
Hand the full body to the cache before any 304 downgrade.
:meth:~fastapi_views.views.mixins.ConditionalMixin.make_conditional
replaces a successful response with an empty 304 when the client is
current, leaving the middleware nothing to persist. Recording the
pre-downgrade body on the request's cache context keeps the write in the
middleware (a single write per miss) while caching the representation
the next non-conditional request should be served.
Source code in fastapi_views/cache/view.py
| def finalize_response(self, response: Response) -> Response:
"""Hand the full body to the cache before any ``304`` downgrade.
:meth:`~fastapi_views.views.mixins.ConditionalMixin.make_conditional`
replaces a successful response with an empty ``304`` when the client is
current, leaving the middleware nothing to persist. Recording the
pre-downgrade body on the request's cache context keeps the write in the
middleware (a single write per miss) while caching the representation
the next non-conditional request should be served.
"""
ctx: _CacheContext | None = self.request.scope.get(_CACHE_SCOPE_KEY)
if (
ctx is not None
and isinstance(response.body, bytes)
and response.status_code < _STATUS_CACHEABLE_MAX
):
ctx.body = response.body
return super().finalize_response(response)
|
use_cache(ttl: int | None = None, *, cache_control: str | CacheControl | None = None) -> Callable[[Callable[..., Any]], Callable[..., Any]]
Cache a :class:CachedAPIView endpoint's serialised response.
On a hit the cached body is returned immediately. On a miss the response is
produced normally and a cache context is stored on the ASGI scope so
:class:~fastapi_views.cache.middleware.CacheMiddleware can inject the cache
headers and persist the body.
Source code in fastapi_views/cache/view.py
| def use_cache(
ttl: int | None = None,
*,
cache_control: str | CacheControl | None = None,
) -> Callable[[Callable[..., Any]], Callable[..., Any]]:
"""Cache a :class:`CachedAPIView` endpoint's serialised response.
On a hit the cached body is returned immediately. On a miss the response is
produced normally and a cache context is stored on the ASGI ``scope`` so
:class:`~fastapi_views.cache.middleware.CacheMiddleware` can inject the cache
headers and persist the body.
"""
def decorator(
func: Callable[..., Awaitable[Any]],
) -> Callable[..., Any]:
@functools.wraps(func)
async def wrapper(self: CachedAPIView, *args: Any, **kwargs: Any) -> Any:
key = self.build_key()
cached_body = await self.cache.get(key)
if cached_body is not None:
return Response(
content=cached_body,
status_code=self.get_status_code(func.__name__),
media_type="application/json",
headers=self.get_cache_headers(
hit=True, ttl=ttl, cache_control=cache_control
),
)
result = await func(self, *args, **kwargs)
if result is None:
return None
self.request.scope[_CACHE_SCOPE_KEY] = _CacheContext(
key=key,
ttl=ttl,
headers=self.get_cache_headers(
hit=False, ttl=ttl, cache_control=cache_control
),
)
return result
return wrapper
return decorator
|
Conditional requests
ConditionalMixin provides the ETag / Last-Modified validators and 304 handling reused by ConditionalCachedAPIView. It can be combined with any view independently of caching and needs no middleware or backend — it works purely from request and response headers.
When you do want caching as well, subclass ConditionalCachedAPIView rather than mixing ConditionalMixin into CachedAPIView by hand: finalize_response below does not call super(), so a hand-rolled combination loses the cache write on a 304-downgraded miss.
fastapi_views.views.mixins.ConditionalMixin
ETag / Last-Modified validators and 304 Not Modified handling.
Two ways to opt in:
- Automatic — set
etag = True for a strong ETag hashed from the
serialised body, and/or set last_modified = True and override
:meth:get_last_modified. The body is built, then downgraded to a 304
if the client is current.
-
Manual / cheap — compare a cheaply obtained validator (a version
column, updated_at) inside the view and short-circuit before building
the body, returning :meth:not_modified::
conditional_requests = True # so the 304 is documented in OpenAPI
async def retrieve(self, item_id: int) -> Item | Response:
item = await self.get_item(item_id)
if self.not_modified_since(item.updated_at):
return self.not_modified(last_modified=item.updated_at)
return item
or, for versioned models, with an ETag::
async def retrieve(self, item_id: int) -> Item | Response:
item = await self.get_item(item_id)
etag = f'"{item.version}"'
if self.etag_matches(etag):
return self.not_modified(etag=etag)
return item
Source code in fastapi_views/views/mixins.py
| class ConditionalMixin:
"""ETag / ``Last-Modified`` validators and ``304 Not Modified`` handling.
Two ways to opt in:
* **Automatic** — set ``etag = True`` for a strong ETag hashed from the
serialised body, and/or set ``last_modified = True`` and override
:meth:`get_last_modified`. The body is built, then downgraded to a ``304``
if the client is current.
* **Manual / cheap** — compare a cheaply obtained validator (a version
column, ``updated_at``) inside the view and short-circuit before building
the body, returning :meth:`not_modified`::
conditional_requests = True # so the 304 is documented in OpenAPI
async def retrieve(self, item_id: int) -> Item | Response:
item = await self.get_item(item_id)
if self.not_modified_since(item.updated_at):
return self.not_modified(last_modified=item.updated_at)
return item
or, for versioned models, with an ETag::
async def retrieve(self, item_id: int) -> Item | Response:
item = await self.get_item(item_id)
etag = f'"{item.version}"'
if self.etag_matches(etag):
return self.not_modified(etag=etag)
return item
"""
request: Request
response: Response
etag: bool = False
last_modified: bool = False
conditional_requests: bool = False
def finalize_response(self, response: Response) -> Response:
return self.make_conditional(response)
def get_etag(self, body: bytes) -> str | None:
if self.etag:
return hashlib.blake2b(body, digest_size=16).hexdigest()
return None
@property
def if_none_match(self) -> str | None:
"""The request's ``If-None-Match`` validator, if sent."""
return self.request.headers.get("if-none-match")
@property
def if_modified_since(self) -> datetime | None:
"""The request's parsed ``If-Modified-Since`` timestamp, if sent."""
value = self.request.headers.get("if-modified-since")
return _parse_http_date(value) if value is not None else None
def etag_matches(self, etag: str) -> bool:
"""Whether ``If-None-Match`` matches ``etag`` (handles ``*`` and lists).
``etag`` may be a raw value (e.g. ``str(version)``); it is quoted to a
valid entity-tag before comparison.
"""
if_none_match = self.if_none_match
return if_none_match is not None and _etag_matches(
if_none_match, _format_etag(etag)
)
def not_modified_since(self, last_modified: datetime) -> bool:
"""Whether ``last_modified`` is not newer than ``If-Modified-Since``."""
since = self.if_modified_since
return since is not None and _to_utc_seconds(last_modified) <= since
def not_modified(
self,
*,
etag: str | None = None,
last_modified: datetime | None = None,
) -> Response:
"""Build a ``304 Not Modified`` response, echoing any given validators.
Return this from a view to skip building and serialising the body once
you have determined the client's cached copy is still current.
"""
headers: dict[str, str] = {}
if etag is not None:
headers["etag"] = _format_etag(etag)
if last_modified is not None:
headers["last-modified"] = format_datetime(
_to_utc_seconds(last_modified), usegmt=True
)
return Response(status_code=HTTP_304_NOT_MODIFIED, headers=headers)
def set_etag(self, etag: str) -> None:
"""Send ``etag`` as the ``ETag`` header on this request's response.
A raw value (e.g. ``str(version)``) is quoted to a valid entity-tag.
"""
self.response.headers["etag"] = _format_etag(etag)
def set_last_modified(self, last_modified: datetime) -> None:
"""Send ``last_modified`` as the ``Last-Modified`` header on the response."""
self.response.headers["last-modified"] = format_datetime(
_to_utc_seconds(last_modified), usegmt=True
)
def check_etag(self, etag: str) -> Response | None:
"""Return a ``304`` when the client's copy matches ``etag``.
Otherwise stamp ``etag`` on the upcoming response and return ``None``,
so ``return self.check_etag(tag) or item`` skips serialising the body
when the client is current and sends the validator on the body response.
"""
if self.etag_matches(etag):
return self.not_modified(etag=etag)
self.set_etag(etag)
return None
def check_last_modified(self, last_modified: datetime) -> Response | None:
"""``Last-Modified`` counterpart of :meth:`check_etag`."""
if self.not_modified_since(last_modified):
return self.not_modified(last_modified=last_modified)
self.set_last_modified(last_modified)
return None
def get_last_modified(self) -> datetime | None:
return None
@classmethod
def supports_conditional_requests(cls) -> bool:
"""Whether the view emits validators (an ETag or ``Last-Modified``)."""
return cls.etag or cls.last_modified or cls.conditional_requests
@classmethod
def _conditional_response_headers(cls) -> dict[str, Any]:
schema = ConditionalHeaders.get_openapi_headers()
headers: dict[str, Any] = {}
if cls.etag or cls.conditional_requests:
headers["ETag"] = schema["ETag"]
if cls.last_modified or cls.conditional_requests:
headers["Last-Modified"] = schema["Last-Modified"]
return headers
@classmethod
def get_conditional_responses(
cls,
*,
action: Action | None = None, # noqa: ARG003
status_code: int | None = None,
methods: Sequence[str] | None = None,
) -> dict[int | str, dict[str, Any]]:
"""Document validator headers and a ``304 Not Modified`` response.
``ETag`` / ``Last-Modified`` are documented on the success response and,
for safe methods, a ``304`` is added — but only when the view actually
emits a validator, so views that set none are not documented with
headers or a ``304`` they will never return.
"""
headers = cls._conditional_response_headers()
if not headers:
return {}
responses: dict[int | str, dict[str, Any]] = {}
if status_code is not None:
responses[status_code] = {"headers": headers}
if methods and any(method in _SAFE_METHODS for method in methods):
responses[HTTP_304_NOT_MODIFIED] = {
"description": "Not Modified",
"headers": headers,
}
return responses
def make_conditional(self, response: Response) -> Response:
"""Attach validators and downgrade to 304 when the client is current."""
if not isinstance(response.body, bytes) or not (
_SUCCESS_MIN <= response.status_code < _SUCCESS_MAX
):
return response
etag = self.get_etag(response.body)
if etag is not None:
etag = _format_etag(etag)
last_modified = self.get_last_modified() if self.last_modified else None
headers = response.headers
if etag:
headers["etag"] = etag
if last_modified is not None:
headers["last-modified"] = format_datetime(
_to_utc_seconds(last_modified), usegmt=True
)
if self.request.method in _SAFE_METHODS and self._is_not_modified(
etag, last_modified
):
carried = {
name: headers[name] for name in _REVALIDATION_HEADERS if name in headers
}
return Response(status_code=HTTP_304_NOT_MODIFIED, headers=carried)
return response
def _is_not_modified(
self, etag: str | None, last_modified: datetime | None
) -> bool:
if self.if_none_match is not None:
# RFC 7232: If-Modified-Since is ignored when If-None-Match is present.
return etag is not None and self.etag_matches(etag)
return last_modified is not None and self.not_modified_since(last_modified)
|
if_none_match: str | None
property
The request's If-None-Match validator, if sent.
if_modified_since: datetime | None
property
The request's parsed If-Modified-Since timestamp, if sent.
etag_matches(etag: str) -> bool
Whether If-None-Match matches etag (handles * and lists).
etag may be a raw value (e.g. str(version)); it is quoted to a
valid entity-tag before comparison.
Source code in fastapi_views/views/mixins.py
| def etag_matches(self, etag: str) -> bool:
"""Whether ``If-None-Match`` matches ``etag`` (handles ``*`` and lists).
``etag`` may be a raw value (e.g. ``str(version)``); it is quoted to a
valid entity-tag before comparison.
"""
if_none_match = self.if_none_match
return if_none_match is not None and _etag_matches(
if_none_match, _format_etag(etag)
)
|
not_modified_since(last_modified: datetime) -> bool
Whether last_modified is not newer than If-Modified-Since.
Source code in fastapi_views/views/mixins.py
| def not_modified_since(self, last_modified: datetime) -> bool:
"""Whether ``last_modified`` is not newer than ``If-Modified-Since``."""
since = self.if_modified_since
return since is not None and _to_utc_seconds(last_modified) <= since
|
not_modified(*, etag: str | None = None, last_modified: datetime | None = None) -> Response
Build a 304 Not Modified response, echoing any given validators.
Return this from a view to skip building and serialising the body once
you have determined the client's cached copy is still current.
Source code in fastapi_views/views/mixins.py
| def not_modified(
self,
*,
etag: str | None = None,
last_modified: datetime | None = None,
) -> Response:
"""Build a ``304 Not Modified`` response, echoing any given validators.
Return this from a view to skip building and serialising the body once
you have determined the client's cached copy is still current.
"""
headers: dict[str, str] = {}
if etag is not None:
headers["etag"] = _format_etag(etag)
if last_modified is not None:
headers["last-modified"] = format_datetime(
_to_utc_seconds(last_modified), usegmt=True
)
return Response(status_code=HTTP_304_NOT_MODIFIED, headers=headers)
|
set_etag(etag: str) -> None
Send etag as the ETag header on this request's response.
A raw value (e.g. str(version)) is quoted to a valid entity-tag.
Source code in fastapi_views/views/mixins.py
| def set_etag(self, etag: str) -> None:
"""Send ``etag`` as the ``ETag`` header on this request's response.
A raw value (e.g. ``str(version)``) is quoted to a valid entity-tag.
"""
self.response.headers["etag"] = _format_etag(etag)
|
set_last_modified(last_modified: datetime) -> None
Send last_modified as the Last-Modified header on the response.
Source code in fastapi_views/views/mixins.py
| def set_last_modified(self, last_modified: datetime) -> None:
"""Send ``last_modified`` as the ``Last-Modified`` header on the response."""
self.response.headers["last-modified"] = format_datetime(
_to_utc_seconds(last_modified), usegmt=True
)
|
check_etag(etag: str) -> Response | None
Return a 304 when the client's copy matches etag.
Otherwise stamp etag on the upcoming response and return None,
so return self.check_etag(tag) or item skips serialising the body
when the client is current and sends the validator on the body response.
Source code in fastapi_views/views/mixins.py
| def check_etag(self, etag: str) -> Response | None:
"""Return a ``304`` when the client's copy matches ``etag``.
Otherwise stamp ``etag`` on the upcoming response and return ``None``,
so ``return self.check_etag(tag) or item`` skips serialising the body
when the client is current and sends the validator on the body response.
"""
if self.etag_matches(etag):
return self.not_modified(etag=etag)
self.set_etag(etag)
return None
|
check_last_modified(last_modified: datetime) -> Response | None
Last-Modified counterpart of :meth:check_etag.
Source code in fastapi_views/views/mixins.py
| def check_last_modified(self, last_modified: datetime) -> Response | None:
"""``Last-Modified`` counterpart of :meth:`check_etag`."""
if self.not_modified_since(last_modified):
return self.not_modified(last_modified=last_modified)
self.set_last_modified(last_modified)
return None
|
supports_conditional_requests() -> bool
classmethod
Whether the view emits validators (an ETag or Last-Modified).
Source code in fastapi_views/views/mixins.py
| @classmethod
def supports_conditional_requests(cls) -> bool:
"""Whether the view emits validators (an ETag or ``Last-Modified``)."""
return cls.etag or cls.last_modified or cls.conditional_requests
|
get_conditional_responses(*, action: Action | None = None, status_code: int | None = None, methods: Sequence[str] | None = None) -> dict[int | str, dict[str, Any]]
classmethod
Document validator headers and a 304 Not Modified response.
ETag / Last-Modified are documented on the success response and,
for safe methods, a 304 is added — but only when the view actually
emits a validator, so views that set none are not documented with
headers or a 304 they will never return.
Source code in fastapi_views/views/mixins.py
| @classmethod
def get_conditional_responses(
cls,
*,
action: Action | None = None, # noqa: ARG003
status_code: int | None = None,
methods: Sequence[str] | None = None,
) -> dict[int | str, dict[str, Any]]:
"""Document validator headers and a ``304 Not Modified`` response.
``ETag`` / ``Last-Modified`` are documented on the success response and,
for safe methods, a ``304`` is added — but only when the view actually
emits a validator, so views that set none are not documented with
headers or a ``304`` they will never return.
"""
headers = cls._conditional_response_headers()
if not headers:
return {}
responses: dict[int | str, dict[str, Any]] = {}
if status_code is not None:
responses[status_code] = {"headers": headers}
if methods and any(method in _SAFE_METHODS for method in methods):
responses[HTTP_304_NOT_MODIFIED] = {
"description": "Not Modified",
"headers": headers,
}
return responses
|
make_conditional(response: Response) -> Response
Attach validators and downgrade to 304 when the client is current.
Source code in fastapi_views/views/mixins.py
| def make_conditional(self, response: Response) -> Response:
"""Attach validators and downgrade to 304 when the client is current."""
if not isinstance(response.body, bytes) or not (
_SUCCESS_MIN <= response.status_code < _SUCCESS_MAX
):
return response
etag = self.get_etag(response.body)
if etag is not None:
etag = _format_etag(etag)
last_modified = self.get_last_modified() if self.last_modified else None
headers = response.headers
if etag:
headers["etag"] = etag
if last_modified is not None:
headers["last-modified"] = format_datetime(
_to_utc_seconds(last_modified), usegmt=True
)
if self.request.method in _SAFE_METHODS and self._is_not_modified(
etag, last_modified
):
carried = {
name: headers[name] for name in _REVALIDATION_HEADERS if name in headers
}
return Response(status_code=HTTP_304_NOT_MODIFIED, headers=carried)
return response
|
The validator headers documented in OpenAPI come from this model; ConditionalMixin contributes only the ones the view can actually emit.
Bases: ResponseHeaders
Validator headers attached to conditional (304-capable) responses.
Source code in fastapi_views/views/mixins.py
| class ConditionalHeaders(ResponseHeaders):
"""Validator headers attached to conditional (``304``-capable) responses."""
etag: str | None = Field(
default=None,
alias="ETag",
description="Validator for the returned representation",
)
last_modified: str | None = Field(
default=None,
alias="Last-Modified",
description="Time the representation was last modified",
json_schema_extra={"format": "http-date"},
)
|
Middleware and backends
fastapi_views.cache.middleware.CacheMiddleware
ASGI middleware that writes cache entries and injects cache headers.
On a cache miss the :func:~fastapi_views.cache.view.use_cache decorator
stores a :class:_CacheContext on the shared ASGI scope. This middleware
reads it back to inject the cache headers into the outgoing response and,
once the full body has been sent, persist it to the backend::
app.add_middleware(CacheMiddleware, backend=InMemoryCache())
This is the single place cache entries are written. What gets stored is the
body the context carries if the view recorded one (see
:class:_CacheContext), otherwise the successful response's outgoing body.
Source code in fastapi_views/cache/middleware.py
| class CacheMiddleware:
"""ASGI middleware that writes cache entries and injects cache headers.
On a cache miss the :func:`~fastapi_views.cache.view.use_cache` decorator
stores a :class:`_CacheContext` on the shared ASGI ``scope``. This middleware
reads it back to inject the cache headers into the outgoing response and,
once the full body has been sent, persist it to the backend::
app.add_middleware(CacheMiddleware, backend=InMemoryCache())
This is the single place cache entries are written. What gets stored is the
body the context carries if the view recorded one (see
:class:`_CacheContext`), otherwise the successful response's outgoing body.
"""
def __init__(self, app: ASGIApp, *, backend: CacheBackend | None = None) -> None:
self.app = app
if backend is not None:
cache.init_backend(backend)
async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None:
if scope["type"] != "http":
await self.app(scope, receive, send)
return
body_chunks: list[bytes] = []
cacheable = False
async def send_wrapper(message: Any) -> None:
nonlocal cacheable
ctx: _CacheContext | None = scope.get(_CACHE_SCOPE_KEY)
if ctx is None:
await send(message)
return
if message["type"] == "http.response.start":
cacheable = message["status"] < _STATUS_CACHEABLE_MAX
headers = MutableHeaders(scope=message)
for name, value in ctx.headers.items():
headers[name] = value
elif message["type"] == "http.response.body" and (
chunk := message.get("body", b"")
):
body_chunks.append(chunk)
await send(message)
if message["type"] == "http.response.body" and not message.get(
"more_body", False
):
body = ctx.resolve_body(body_chunks, cacheable=cacheable)
if body:
await cache.set(ctx.key, body, ttl=ctx.ttl)
await self.app(scope, receive, send_wrapper)
|
fastapi_views.cache.cache is the shared Cache instance the views, the middleware and the @cache decorator all use; CacheMiddleware(backend=...) (or cache.init_backend(...)) installs its backend.
fastapi_views.cache.cache.Cache
Source code in fastapi_views/cache/cache.py
| class Cache:
def __init__(self, backend: CacheBackend | None = None) -> None:
self._backend: CacheBackend | None = backend
@property
def backend(self) -> CacheBackend:
if self._backend is None:
raise ValueError("Cache backend not set")
return self._backend
def init_backend(self, backend: CacheBackend) -> None:
self._backend = backend
async def get(self, key: KeyT) -> EncodableT | None:
return await self.backend.get(key)
async def set(self, key: KeyT, value: EncodableT, ttl: int | None = None) -> None:
return await self.backend.set(key, value, ttl=ttl)
async def delete(self, key: KeyT) -> None:
await self.backend.delete(key)
async def pop(self, key: KeyT) -> EncodableT | None:
return await self.backend.pop(key)
def _format_key(
self, key: Callable[..., KeyT] | KeyT, *args: Any, **kwargs: Any
) -> KeyT:
if callable(key):
return key(*args, **kwargs)
if isinstance(key, str) and re.match(_KEY_PATTERN, key):
return key.format(*args, **kwargs)
return key
def __call__(
self,
key: KeyT | Callable[..., KeyT],
ttl: int | None = None,
) -> AsyncDecorator:
def decorator(
func: Callable[P, Awaitable[T]],
) -> Callable[P, Awaitable[T]]:
return_type = _resolve_return_type(func)
adapter = _get_type_adapter(return_type)
@wraps(func)
async def wrapper(*args: P.args, **kwargs: P.kwargs) -> T:
cache_key = self._format_key(key, *args, **kwargs)
raw = await self.get(cache_key)
if raw is None:
result = await func(*args, **kwargs)
await self.set(cache_key, adapter.dump_json(result), ttl=ttl)
return result
return adapter.validate_json(raw)
return wrapper
return decorator
|
backend: CacheBackend
property
init_backend(backend: CacheBackend) -> None
Source code in fastapi_views/cache/cache.py
| def init_backend(self, backend: CacheBackend) -> None:
self._backend = backend
|
get(key: KeyT) -> EncodableT | None
async
Source code in fastapi_views/cache/cache.py
| async def get(self, key: KeyT) -> EncodableT | None:
return await self.backend.get(key)
|
set(key: KeyT, value: EncodableT, ttl: int | None = None) -> None
async
Source code in fastapi_views/cache/cache.py
| async def set(self, key: KeyT, value: EncodableT, ttl: int | None = None) -> None:
return await self.backend.set(key, value, ttl=ttl)
|
delete(key: KeyT) -> None
async
Source code in fastapi_views/cache/cache.py
| async def delete(self, key: KeyT) -> None:
await self.backend.delete(key)
|
pop(key: KeyT) -> EncodableT | None
async
Source code in fastapi_views/cache/cache.py
| async def pop(self, key: KeyT) -> EncodableT | None:
return await self.backend.pop(key)
|
__call__(key: KeyT | Callable[..., KeyT], ttl: int | None = None) -> AsyncDecorator
Source code in fastapi_views/cache/cache.py
| def __call__(
self,
key: KeyT | Callable[..., KeyT],
ttl: int | None = None,
) -> AsyncDecorator:
def decorator(
func: Callable[P, Awaitable[T]],
) -> Callable[P, Awaitable[T]]:
return_type = _resolve_return_type(func)
adapter = _get_type_adapter(return_type)
@wraps(func)
async def wrapper(*args: P.args, **kwargs: P.kwargs) -> T:
cache_key = self._format_key(key, *args, **kwargs)
raw = await self.get(cache_key)
if raw is None:
result = await func(*args, **kwargs)
await self.set(cache_key, adapter.dump_json(result), ttl=ttl)
return result
return adapter.validate_json(raw)
return wrapper
return decorator
|
Keys and values are str | bytes (KeyT / EncodableT in fastapi_views.cache.backends).
fastapi_views.cache.backends.abc.CacheBackend
Bases: ABC
Source code in fastapi_views/cache/backends/abc.py
| class CacheBackend(ABC):
@abstractmethod
async def get(self, key: KeyT) -> EncodableT | None:
raise NotImplementedError
@abstractmethod
async def set(self, key: KeyT, value: EncodableT, ttl: int | None = None) -> None:
raise NotImplementedError
@abstractmethod
async def delete(self, key: KeyT) -> None:
raise NotImplementedError
@abstractmethod
async def pop(self, key: KeyT) -> EncodableT | None:
raise NotImplementedError
|
fastapi_views.cache.backends.memory.InMemoryCache
Bases: CacheBackend
Source code in fastapi_views/cache/backends/memory.py
| class InMemoryCache(CacheBackend):
def __init__(self, default_ttl: int | None = None) -> None:
self._default_ttl = default_ttl
self._data: dict[KeyT, ExpiringItem] = {}
async def get(self, key: KeyT) -> EncodableT | None:
item = self._data.get(key)
if item is None:
return None
if item.expires_at and time.monotonic() > item.expires_at:
self._data.pop(key, None)
return None
return item.value
async def set(self, key: KeyT, value: EncodableT, ttl: int | None = None) -> None:
ttl = ttl or self._default_ttl
expires_at = (time.monotonic() + ttl) if ttl else None
self._data[key] = ExpiringItem(value, expires_at)
async def delete(self, key: KeyT) -> None:
self._data.pop(key, None)
async def pop(self, key: KeyT) -> EncodableT | None:
item = self._data.pop(key, None)
if item is None:
return None
if item.expires_at and time.monotonic() > item.expires_at:
return None
return item.value
|
fastapi_views.cache.backends.redis.RedisCache
Bases: CacheBackend
Source code in fastapi_views/cache/backends/redis.py
| class RedisCache(CacheBackend):
def __init__(self, client: Redis) -> None:
self.redis = client
async def get(self, key: KeyT) -> EncodableT | None:
return await self.redis.get(key)
async def set(self, key: KeyT, value: EncodableT, ttl: int | None = None) -> None:
await self.redis.set(key, value, ex=ttl)
async def delete(self, key: KeyT) -> None:
await self.redis.delete(key)
async def pop(self, key: KeyT) -> EncodableT | None:
return await self.redis.getdel(key)
|