diff --git a/CHANGELOG.md b/CHANGELOG.md index a59f2fb0..43f0a9b8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,8 +5,17 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). -## [2.6.4] - +## [2.6.4] - 2026-10-11 :libra: + +- Add a `vary` parameter to the `cache_control` decorator and to + `CacheControlMiddleware`, to add field names to the `Vary` response header + (e.g. `@cache_control(max_age=60, vary=["Accept-Language"])`). Names already + listed in the response `Vary` header are not repeated (case-insensitive), and + nothing is added when the response has `Vary: *`. +- Fix `GzipMiddleware` to add `Vary: Accept-Encoding` to responses that are + eligible for compression (also when not compressed for a given request), so + that shared caches and CDNs do not serve a compressed response to clients that + don't support it, or vice versa. - Fix [#688](https://github.com/Neoteroi/BlackSheep/issues/688): `import_child_modules` derived the dotted import path for auto-imported routes/controllers from `os.path.relpath`, which resolves relative to the process's current working diff --git a/blacksheep/server/compression.py b/blacksheep/server/compression.py index ce3cb5c3..65177731 100644 --- a/blacksheep/server/compression.py +++ b/blacksheep/server/compression.py @@ -5,6 +5,7 @@ from blacksheep import Content, Request, Response from blacksheep.server.application import Application +from blacksheep.server.headers.cache import add_vary_header from blacksheep.server.normalization import ensure_response @@ -65,35 +66,38 @@ def _normalize_types(self, types: Iterable[bytes]) -> list[bytes]: normalized_types.append(_type) return normalized_types - def should_handle(self, request: Request, response: Response) -> bool: + def is_handled_response_content(self, response: Response | None) -> bool: """ - Returns True if the response should be compressed. + Returns True if the response content is of a type and size that is + eligible for compression, regardless of the request. """ + if response is None or response.content is None: + return False def _is_handled_type(content_type) -> bool: content_type = content_type.lower() return any(_type in content_type for _type in self.handled_types) - def is_handled_encoding() -> bool: - return b"gzip" in (request.get_first_header(b"accept-encoding") or b"") + body_pass: bool = ( + response.content.body is not None + and len(response.content.body) > self.min_size + ) - def is_handled_response_content() -> bool: - if response is None or response.content is None: - return False + content_type_pass: bool = response.content.type is not None and ( + _is_handled_type(response.content.type) + ) - body_pass: bool = ( - response.content.body is not None - and len(response.content.body) > self.min_size - ) + return body_pass and content_type_pass - content_type_pass: bool = ( - response.content.type is not None - and _is_handled_type(response.content.type) - ) + def should_handle(self, request: Request, response: Response) -> bool: + """ + Returns True if the response should be compressed. + """ - return body_pass and content_type_pass + def is_handled_encoding() -> bool: + return b"gzip" in (request.get_first_header(b"accept-encoding") or b"") - return is_handled_encoding() and is_handled_response_content() + return is_handled_encoding() and self.is_handled_response_content(response) async def __call__( self, request: Request, handler: Callable[[Request], Awaitable[Response]] @@ -103,6 +107,11 @@ async def __call__( if response is None or response.content is None: return response + if self.is_handled_response_content(response): + # The response depends on Accept-Encoding even when it is not compressed + # for this request, so caches must not serve it to other clients. + add_vary_header(response, [b"Accept-Encoding"]) + if not self.should_handle(request, response): return response diff --git a/blacksheep/server/headers/cache.py b/blacksheep/server/headers/cache.py index 12f1490b..87f10a81 100644 --- a/blacksheep/server/headers/cache.py +++ b/blacksheep/server/headers/cache.py @@ -5,11 +5,44 @@ import inspect from functools import wraps +from typing import Iterable from blacksheep import Request, Response from blacksheep.server.normalization import ensure_response +def add_vary_header(response: Response, values: Iterable[str | bytes]) -> None: + """ + Adds the given field names to the Vary header of the response, skipping the ones + that are already listed (case-insensitive). Nothing is added if the response + already has "Vary: *". + """ + existing: set[bytes] = set() + for header_value in response.headers.get(b"vary"): + existing.update(part.strip().lower() for part in header_value.split(b",")) + + if b"*" in existing: + return + + missing: list[bytes] = [] + for value in values: + name = value.encode("ascii") if isinstance(value, str) else value + name = name.strip() + if not name or name.lower() in existing: + continue + existing.add(name.lower()) + missing.append(name) + + if missing: + response.add_header(b"vary", b", ".join(missing)) + + +def _normalize_vary(vary: list[str] | None) -> tuple[bytes, ...]: + if not vary: + return () + return tuple(name.strip().encode("ascii") for name in vary if name and name.strip()) + + def write_cache_control_response_header( *, max_age: int | None = None, @@ -210,6 +243,7 @@ def cache_control( immutable: bool | None = None, stale_while_revalidate: int | None = None, stale_if_error: int | None = None, + vary: list[str] | None = None, ): """ Cache control decorator, applying a Cache-Control header to all successful @@ -217,6 +251,9 @@ def cache_control( Parameters ---------- + vary: list[str] | None + optional request header names to add to the Vary response header (e.g. + ["Accept-Language"]); names already listed in the response are not repeated max_age: int | None max age in seconds shared_max_age: int | None @@ -265,6 +302,8 @@ def cache_control( stale_if_error=stale_if_error, ) + vary_names = _normalize_vary(vary) + def decorator(next_handler): if inspect.iscoroutinefunction(next_handler): @@ -272,6 +311,7 @@ def decorator(next_handler): async def async_wrapped(*args, **kwargs): response = ensure_response(await next_handler(*args, **kwargs)) response.add_header(b"cache-control", header_value) + add_vary_header(response, vary_names) return response return async_wrapped @@ -281,6 +321,7 @@ async def async_wrapped(*args, **kwargs): def wrapped(*args, **kwargs): response = ensure_response(next_handler(*args, **kwargs)) response.add_header(b"cache-control", header_value) + add_vary_header(response, vary_names) return response return wrapped @@ -324,6 +365,9 @@ class CacheControlMiddleware: enables stale-while-revalidate, in seconds stale_if_error: int | None enables stale-if-error, in seconds + vary: list[str] | None + optional request header names to add to the Vary response header (e.g. + ["Accept-Language"]); names already listed in the response are not repeated """ def __init__( @@ -342,7 +386,9 @@ def __init__( immutable: bool | None = None, stale_while_revalidate: int | None = None, stale_if_error: int | None = None, + vary: list[str] | None = None, ) -> None: + self._vary_names = _normalize_vary(vary) self._header_value: bytes = write_cache_control_response_header( max_age=max_age, shared_max_age=shared_max_age, @@ -373,5 +419,6 @@ async def __call__(self, request, handler): and not response.headers[b"cache-control"] ): response.add_header(b"cache-control", self._header_value) + add_vary_header(response, self._vary_names) return response diff --git a/tests/test_caching.py b/tests/test_caching.py index 3b97c45e..a4f42a72 100644 --- a/tests/test_caching.py +++ b/tests/test_caching.py @@ -1,5 +1,6 @@ import pytest +from blacksheep import Response, TextContent from blacksheep.server.controllers import Controller from blacksheep.server.headers.cache import ( CacheControlMiddleware, @@ -121,3 +122,54 @@ def example(): return "Example" await _assert_scenario(app, expected_header) + + +async def test_cache_control_decorator_vary(app): + @app.router.get("/") + @cache_control(max_age=60, vary=["Accept-Language", "Origin"]) + def example(): + return "Example" + + @app.router.get("/existing") + @cache_control(max_age=60, vary=["origin", "Accept-Language"]) + def existing(): + return Response(200, [(b"Vary", b"Origin")], TextContent("Example")) + + @app.router.get("/star") + @cache_control(max_age=60, vary=["Origin"]) + def star(): + return Response(200, [(b"Vary", b"*")], TextContent("Example")) + + await app.start() + + await app(get_example_scope("GET", "/", []), MockReceive(), MockSend()) + assert app.response.headers[b"vary"] == (b"Accept-Language, Origin",) + + await app(get_example_scope("GET", "/existing", []), MockReceive(), MockSend()) + assert app.response.headers[b"vary"] == (b"Origin", b"Accept-Language") + + await app(get_example_scope("GET", "/star", []), MockReceive(), MockSend()) + assert app.response.headers[b"vary"] == (b"*",) + + +async def test_cache_control_middleware_vary(app): + app.middlewares.append(CacheControlMiddleware(max_age=60, vary=["Accept-Language"])) + + @app.router.get("/") + def example(): + return "Example" + + await app.start() + await app(get_example_scope("GET", "/", []), MockReceive(), MockSend()) + assert app.response.headers[b"vary"] == (b"Accept-Language",) + + +async def test_cache_control_without_vary_does_not_add_header(app): + @app.router.get("/") + @cache_control(max_age=60) + def example(): + return "Example" + + await app.start() + await app(get_example_scope("GET", "/", []), MockReceive(), MockSend()) + assert b"vary" not in app.response.headers diff --git a/tests/test_gzip.py b/tests/test_gzip.py index 0242bc5b..19b9694a 100644 --- a/tests/test_gzip.py +++ b/tests/test_gzip.py @@ -2,6 +2,7 @@ import pytest +from blacksheep import Response, TextContent from blacksheep.server.compression import GzipMiddleware from blacksheep.testing.helpers import get_example_scope from blacksheep.testing.messages import MockReceive, MockSend @@ -75,6 +76,42 @@ async def home(): assert response.headers.get_single(b"content-encoding") +async def test_gzip_adds_vary_accept_encoding(app): + @app.router.get("/") + async def home(): + return "Hello, World" + + @app.router.get("/vary") + async def vary(): + return Response(200, [(b"Vary", b"accept-encoding")], TextContent("Hello!!")) + + app.middlewares.append(GzipMiddleware(min_size=0)) + await app.start() + + await app( + get_example_scope("GET", "/", [(b"accept-encoding", b"gzip")]), + MockReceive([]), + MockSend(), + ) + assert app.response.headers[b"vary"] == (b"Accept-Encoding",) + + # eligible responses vary on Accept-Encoding even if they are not compressed + await app( + get_example_scope("GET", "/", accept_encoding=b"deflate"), + MockReceive([]), + MockSend(), + ) + assert app.response.headers[b"vary"] == (b"Accept-Encoding",) + assert not app.response.headers[b"content-encoding"] + + await app( + get_example_scope("GET", "/vary", [(b"accept-encoding", b"gzip")]), + MockReceive([]), + MockSend(), + ) + assert app.response.headers[b"vary"] == (b"accept-encoding",) + + async def test_skip_gzip_output_without_header(app): @app.router.get("/") async def home():