Skip to content
Open
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
5 changes: 3 additions & 2 deletions src/redis_fastapi/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@
default_key_builder,
)
from redis_fastapi.cache_backend import CacheBackend, SyncCacheBackend
from redis_fastapi.config import RedisSettings, get_settings
from redis_fastapi.config import RedisSettings, get_settings, reset_settings
from redis_fastapi.deps import (
AsyncRedisDep,
CacheBackendDep,
Expand All @@ -36,9 +36,9 @@
"CacheBackendDep",
"CacheHitException",
"Coder",
"FastAPIRedis",
"JsonCoder",
"KeyBuilder",
"FastAPIRedis",
"RedisSettings",
"SyncCacheBackend",
"SyncCacheBackendDep",
Expand All @@ -55,4 +55,5 @@
"get_sync_cache_backend",
"pydantic_model_coder",
"redis_lifespan",
"reset_settings",
]
16 changes: 15 additions & 1 deletion src/redis_fastapi/cache.py
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,10 @@ async def get_items():
# ---------------------------------------------------------------------------


def _urlencode(value: str) -> str:
return value.replace(":", "%3A").replace("&", "%26").replace("=", "%3D")


def default_key_builder(
request: Request,
eviction_group: str = "",
Expand Down Expand Up @@ -88,7 +92,9 @@ def default_key_builder(
if path:
parts.append(path)
if request.query_params:
qs = ":".join(f"{k}={v}" for k, v in sorted(request.query_params.items()))
qs = "&".join(
f"{k}={_urlencode(v)}" for k, v in sorted(request.query_params.items())
)
parts.append(qs)
return ":".join(parts)

Expand Down Expand Up @@ -507,6 +513,14 @@ def cache_evict(
_prefix: str = prefix if prefix is not None else _settings.pattern_prefix("cache")
_key_builder: KeyBuilder | None = key_builder

if not eviction_group and _key_builder is None:
raise ValueError(
"cache_evict() requires at least one of 'eviction_group' or "
"'key_builder'. Omitting both deletes ALL cache keys under the "
"global prefix — if this is intentional, provide an eviction_group "
"explicitly."
)

# Flow: yield to endpoint → on success evict key or group
async def _dependency(
request: Request,
Expand Down
9 changes: 9 additions & 0 deletions src/redis_fastapi/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -227,6 +227,15 @@ def pattern_prefix(self, pattern: str) -> str:
return f"{self.prefix}:{pattern}"


def reset_settings() -> None:
"""Clear the cached settings instance.

Useful in tests to force a fresh reload from environment variables
between test cases.
"""
get_settings.cache_clear()


@lru_cache
def get_settings() -> RedisSettings:
"""Get cached RedisSettings instance.
Expand Down
2 changes: 1 addition & 1 deletion tests/unit/test_adversarial.py
Original file line number Diff line number Diff line change
Expand Up @@ -180,7 +180,7 @@ def test_extremely_long_path(self) -> None:

def test_query_params_with_colons(self) -> None:
key = default_key_builder(_make_request("/items", "key=a:b:c"), prefix="pfx")
assert "key=a:b:c" in key
assert "key=a%3Ab%3Ac" in key

def test_query_params_with_glob(self) -> None:
key = default_key_builder(
Expand Down
25 changes: 14 additions & 11 deletions tests/unit/test_cache.py
Original file line number Diff line number Diff line change
Expand Up @@ -471,10 +471,17 @@ async def _fake() -> fakeredis.aioredis.FakeRedis:
r = c.get("/items/1")
assert r.headers["X-Redis-Cache"] == "HIT"

def test_evict_no_group_no_key_builder_wipes_all(
def test_evict_no_group_no_key_builder_raises(
self, fake_async_redis: fakeredis.aioredis.FakeRedis
) -> None:
"""cache_evict() with no eviction_group and no key_builder wipes all cache keys."""
"""cache_evict() with no eviction_group and no key_builder raises ValueError."""
with pytest.raises(ValueError, match="cache_evict\\(\\) requires"):
cache_evict()

def test_evict_all_with_explicit_group(
self, fake_async_redis: fakeredis.aioredis.FakeRedis
) -> None:
"""Use an explicit eviction_group to wipe all keys under a prefix."""
from redis_fastapi.cache_backend import CacheBackend

backend = CacheBackend(fake_async_redis, eviction_group="ns")
Expand All @@ -490,28 +497,24 @@ async def seed(grp: str, key: str) -> dict:
async def has_key(grp: str, key: str) -> dict:
return {"exists": await backend.has(key, eviction_group=grp)}

@app.post(
"/wipe-all",
dependencies=[Depends(cache_evict())], # no eviction_group, no key_builder
)
async def wipe_all() -> dict:
@app.post("/wipe/{grp}")
async def wipe(grp: str) -> dict:
await backend.delete_group(grp)
return {"ok": True}

async def _fake() -> fakeredis.aioredis.FakeRedis:
return fake_async_redis

app.dependency_overrides[get_async_redis] = _fake
with TestClient(app) as c:
# Seed keys in different groups
c.post("/seed/alpha/k1")
c.post("/seed/beta/k2")
assert c.get("/has/alpha/k1").json()["exists"] is True
assert c.get("/has/beta/k2").json()["exists"] is True

# Wipe everything
c.post("/wipe-all")
c.post("/wipe/alpha")
c.post("/wipe/beta")

# All keys across all groups are gone
assert c.get("/has/alpha/k1").json()["exists"] is False
assert c.get("/has/beta/k2").json()["exists"] is False

Expand Down
71 changes: 71 additions & 0 deletions tests/unit/test_edge_cases.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
from __future__ import annotations

import pytest
from fastapi import Depends, FastAPI
from fastapi.testclient import TestClient
from starlette.requests import Request as StarletteRequest

from redis_fastapi.cache import (
cache_evict,
default_key_builder,
)
from redis_fastapi.config import get_settings
from redis_fastapi.deps import get_async_redis
from redis_fastapi.setup import FastAPIRedis


def _make_request(path: str, query: str = "") -> StarletteRequest:
scope = {
"type": "http",
"method": "GET",
"path": path,
"query_string": query.encode(),
"headers": [],
}
return StarletteRequest(scope)


class TestKeyBuilderAmbiguity:
def test_multiple_query_params_with_colons(self) -> None:
key = default_key_builder(
_make_request("/search", "q=a:b&filter=x:y:z"),
prefix="pfx",
)
segments = key.split(":")
value_segments = [s for s in segments if "=" in s]

def test_equals_in_query_value_is_ambiguous(self) -> None:
key = default_key_builder(
_make_request("/auth", "token=abc=def"),
prefix="pfx",
)


class TestEmptyEvictionGroupKey:
def test_empty_eviction_group_no_hash_tags(self) -> None:
key = default_key_builder(
_make_request("/items"), eviction_group="", prefix="pfx"
)
assert "{" not in key
assert key == "pfx:items"


class TestCacheEvictEmptyGroupRaises:
def test_evict_empty_group_raises_valueerror(self) -> None:
with pytest.raises(ValueError, match="cache_evict\\(\\) requires"):
cache_evict()

def test_evict_with_key_builder_ok(self) -> None:
dep = cache_evict(key_builder=lambda r, **kw: "custom:key")
assert dep is not None


class TestResetSettings:
def test_reset_settings_clears_cache(self) -> None:
from redis_fastapi.config import get_settings, reset_settings

settings1 = get_settings()
reset_settings()
settings2 = get_settings()
assert settings1 is not settings2
assert type(settings1) is type(settings2)
2 changes: 1 addition & 1 deletion tests/unit/test_key_builder.py
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@ def test_nested_path_slashes_to_colons(self) -> None:
def test_query_params_sorted(self) -> None:
req = _make_request("/items", query="z=2&a=1")
key = default_key_builder(req, prefix="pfx")
assert key == "pfx:items:a=1:z=2"
assert key == "pfx:items:a=1&z=2"

def test_eviction_group_included(self) -> None:
req = _make_request("/items")
Expand Down