Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 11 additions & 2 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
43 changes: 26 additions & 17 deletions blacksheep/server/compression.py
Original file line number Diff line number Diff line change
Expand Up @@ -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


Expand Down Expand Up @@ -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]]
Expand All @@ -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

Expand Down
47 changes: 47 additions & 0 deletions blacksheep/server/headers/cache.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -210,13 +243,17 @@ 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
responses.

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
Expand Down Expand Up @@ -265,13 +302,16 @@ def cache_control(
stale_if_error=stale_if_error,
)

vary_names = _normalize_vary(vary)

def decorator(next_handler):
if inspect.iscoroutinefunction(next_handler):

@wraps(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
Expand All @@ -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
Expand Down Expand Up @@ -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__(
Expand All @@ -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,
Expand Down Expand Up @@ -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
52 changes: 52 additions & 0 deletions tests/test_caching.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import pytest

from blacksheep import Response, TextContent
from blacksheep.server.controllers import Controller
from blacksheep.server.headers.cache import (
CacheControlMiddleware,
Expand Down Expand Up @@ -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
37 changes: 37 additions & 0 deletions tests/test_gzip.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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():
Expand Down
Loading